refactor(agent): replace OMP with Claude Code kernel
This commit is contained in:
1 parent
0fb2226f8c
commit
9a1f182397
232 files changed
+2561
-25564
No files matched your search
@@ -1,256 +0,0 @@
|
||||
// Real hidden Electron Chromium + host tools + HTTP gateway and installed OMP, all against loopback fixtures.
|
||||
const fs = require('node:fs/promises')
|
||||
const path = require('node:path')
|
||||
if (!process.versions.electron) {
|
||||
;(async () => {
|
||||
const directory = await fs.mkdtemp(path.resolve('.tmp/browser-automation-'))
|
||||
const modulePath = path.join(directory, 'host.cjs')
|
||||
require('esbuild').buildSync({ stdin: { resolveDir: process.cwd(), contents: [
|
||||
"export * from './src/main/browser-automation';", "export * from './src/main/agent/tools';",
|
||||
"export * from './src/main/agent/tools/browser';", "export * from './src/main/agent/conversation-tools';",
|
||||
"export * from './src/main/agent/loop';", "export * from './src/main/agent/omp-turn';",
|
||||
"export * from './src/main/agent/history';", "export * from './src/shared/message-parts';",
|
||||
"export * from './src/main/gateway';", "export * from './src/main/gateway/responses';",
|
||||
"export * from './src/main/gateway/native-protocols';", "export * from './src/main/omp';", "export * from './src/main/omp-model';"
|
||||
].join('\n') }, bundle: true, platform: 'node', format: 'cjs', external: ['electron'], outfile: modulePath, logLevel: 'warning' })
|
||||
const env = { ...process.env, YCODE_BROWSER_TEST_DIRECTORY: directory, YCODE_OMP_HOME: path.join(directory, 'omp-home'),
|
||||
YCODE_USER_DATA_DIR: path.join(directory, 'data'), YCODE_DISABLE_SYSTEM_INTEGRATION: '1', YCODE_DISABLE_GPU: '1' }
|
||||
delete env.ELECTRON_RUN_AS_NODE
|
||||
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; process.stdout.write(bytes) })
|
||||
child.stderr.on('data', bytes => { output += bytes })
|
||||
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"}'))
|
||||
if (code || report.result !== 'PASS') console.error(output.slice(-6000))
|
||||
console.log(JSON.stringify({ directory, exitCode: code, result: report.result })); process.exitCode = code || report.result !== 'PASS' ? 1 : 0
|
||||
})
|
||||
})().catch(error => { console.error(error); process.exitCode = 1 })
|
||||
} else {
|
||||
const { app, BrowserWindow } = require('electron')
|
||||
const assert = require('node:assert/strict')
|
||||
const http = require('node:http')
|
||||
const directory = process.env.YCODE_BROWSER_TEST_DIRECTORY
|
||||
app.setPath('userData', path.join(directory, 'data')); app.disableHardwareAcceleration()
|
||||
app.on('window-all-closed', () => {})
|
||||
const servers = [], checks = [], apiRequests = []
|
||||
let host, kernel, phase = 'boot'
|
||||
const pause = ms => new Promise(resolve => setTimeout(resolve, ms))
|
||||
function check(label, condition) { assert.ok(condition, label); checks.push(label); console.log('BROWSER_CHECK ' + label) }
|
||||
async function listen(handler) {
|
||||
const server = http.createServer(handler); servers.push(server)
|
||||
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve))
|
||||
return { server, base: 'http://127.0.0.1:' + server.address().port }
|
||||
}
|
||||
function page(next = false) {
|
||||
return `<!doctype html><html><head><title>${next ? 'Next fixture' : 'Browser fixture'}</title></head>
|
||||
<body style="background:#edf9f2;font:20px Arial;margin:32px;min-height:2400px">
|
||||
<h1>${next ? 'NEXT_PAGE' : 'BROWSER_FIXTURE'}</h1><label for="query">Query</label><input id="query" value="old value">
|
||||
<button id="apply">Apply</button><button id="later">Delayed status</button>
|
||||
<output id="result">Waiting</output><a id="next" href="/next">Next page</a>
|
||||
<div id="cookies"></div><div id="node-access"></div><input type="password" value="never-expose-password">
|
||||
<script>document.querySelector('#apply').addEventListener('click',event=>{document.querySelector('#result').textContent='APPLIED:'+document.querySelector('#query').value+':trusted='+event.isTrusted});
|
||||
document.querySelector('#later').onclick=()=>setTimeout(()=>document.querySelector('#result').textContent='DELAYED_READY',150);
|
||||
document.querySelector('#cookies').textContent='cookie:'+document.cookie;
|
||||
document.querySelector('#node-access').textContent='node:'+typeof require+'/'+typeof process;</script></body></html>`
|
||||
}
|
||||
const ctx = (sessionId = 'browser-a', extra = {}) => ({ workspaceRoot: directory, sessionId, runId: 'test-' + sessionId,
|
||||
toolCallId: 'browser-test', permissionMode: 'full-access', signal: new AbortController().signal, ...extra })
|
||||
async function call(args, context = ctx()) { return host.executeTool('browser', args, context) }
|
||||
async function ok(args, context = ctx()) { const result = await call(args, context); assert.equal(result.ok, true, result.content); return result }
|
||||
async function rejected(args, pattern, context = ctx()) { const result = await call(args, context); assert.equal(result.ok, false); assert.match(result.content, pattern); return result }
|
||||
function sse(res, call, final) {
|
||||
res.writeHead(200, { 'content-type': 'text/event-stream' })
|
||||
const delta = call ? { role: 'assistant', tool_calls: [{ index: 0, id: call.id, type: 'function', function: { name: 'browser', arguments: JSON.stringify(call.args) } }] }
|
||||
: { role: 'assistant', content: final }
|
||||
res.end([{ id: 'controlled-browser', object: 'chat.completion.chunk', model: 'browser-controlled', choices: [{ index: 0, delta, finish_reason: null }] },
|
||||
{ id: 'controlled-browser', object: 'chat.completion.chunk', model: 'browser-controlled', choices: [{ index: 0, delta: {}, finish_reason: call ? 'tool_calls' : 'stop' }], usage: { prompt_tokens: 100, completion_tokens: 20, total_tokens: 120 } }]
|
||||
.map(row => 'data: ' + JSON.stringify(row) + '\n\n').join('') + 'data: [DONE]\n\n')
|
||||
}
|
||||
async function conversation(viaOmp, fixture, production = false, supportsImages = true) {
|
||||
const mode = production ? 'APP' : (viaOmp ? 'OMP' : 'HTTP') + (supportsImages ? '' : '_TEXT_ONLY')
|
||||
let stage = 0, observedImage = false, observedApplied = false
|
||||
const expected = mode + '_BROWSER_VALUE'
|
||||
const api = await listen(async (req, res) => {
|
||||
try {
|
||||
let source = ''; for await (const chunk of req) source += chunk
|
||||
const body = JSON.parse(source || '{}'); apiRequests.push({ mode, path: req.url, body })
|
||||
if (req.url !== '/v1/chat/completions') { res.writeHead(404); res.end('{}'); return }
|
||||
assert.ok(body.tools.some(tool => tool.function.name === 'browser'), 'model actually receives browser tool')
|
||||
const serialized = JSON.stringify(body.messages)
|
||||
observedApplied ||= serialized.includes('APPLIED:' + expected + ':trusted=true')
|
||||
observedImage ||= serialized.includes('data:image/jpeg;base64,')
|
||||
const actions = [{ action: 'open', url: fixture + '/page' }, { action: 'type', selector: '#query', text: expected },
|
||||
{ action: 'click', selector: '#apply' }, { action: 'screenshot' }]
|
||||
if (stage < actions.length) sse(res, { id: mode + '-call-' + stage, args: actions[stage++] })
|
||||
else { assert.ok(observedApplied, 'page changes returned to model'); assert.equal(observedImage, supportsImages, 'screenshot follows model image capabilities'); sse(res, undefined, mode + '_BROWSER_COMPLETE') }
|
||||
} catch (error) { res.writeHead(500, { 'content-type': 'application/json' }); res.end(JSON.stringify({ error: { message: String(error) } })) }
|
||||
})
|
||||
const events = [], common = { runId: mode + '-run', sessionId: mode + '-session', workspaceRoot: directory,
|
||||
userText: 'Use the browser to fill the fixture and inspect its screenshot.', signal: new AbortController().signal,
|
||||
permissionMode: 'full-access', supportsImages, emit: event => events.push(event) }
|
||||
const config = { baseUrl: api.base + '/v1', api: 'openai-completions', model: 'browser-controlled', apiKey: 'loopback-only', maxOutputTokens: 1024, maxToolRounds: 8 }
|
||||
let result
|
||||
if (production) {
|
||||
// The harness already disabled acceleration before app readiness.
|
||||
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)
|
||||
let main
|
||||
for (let index = 0; index < 300; index++) {
|
||||
main = BrowserWindow.getAllWindows().find(window => window.getTitle() === 'YCode')
|
||||
if (main && !main.webContents.isLoading()) break
|
||||
await pause(25)
|
||||
}
|
||||
assert.ok(main, 'production main window')
|
||||
const read = source => main.webContents.executeJavaScript(source)
|
||||
for (let index = 0; index < 200 && !await read('!!window.ycode'); index++) await pause(25)
|
||||
await read('window.__browserEvents=[];window.ycode.chat.onEvent(event=>window.__browserEvents.push(event));true')
|
||||
const profile = { id: 'browser-controlled', name: 'Browser controlled', providerId: 'custom', model: 'browser-controlled', protocol: 'openai',
|
||||
apiKey: 'loopback-only', baseUrl: config.baseUrl, endpoint: { api: 'openai-completions', baseUrl: config.baseUrl, checkedAt: Date.now() },
|
||||
supportsToolCall: true, supportsImages: true, supportsReasoning: false, contextWindowTokens: 32000, maxOutputTokens: 1024, tokenBudgetVersion: 2 }
|
||||
await read(`window.ycode.models.save(${JSON.stringify(profile)})`)
|
||||
const workspace = await read(`window.ycode.workspace.attach(${JSON.stringify(directory)})`)
|
||||
const session = await read(`window.ycode.session.create('Browser APP',{modelId:'browser-controlled',workspaceId:${JSON.stringify(workspace.attachedWorkspaceId)}})`)
|
||||
await read(`window.ycode.chat.send({sessionId:${JSON.stringify(session.id)},text:'Control the browser fixture.',permissionMode:'full-access'})`)
|
||||
let assistant
|
||||
for (let index = 0; index < 1200; index++) {
|
||||
const messages = await read(`window.ycode.session.messages(${JSON.stringify(session.id)})`)
|
||||
assistant = messages.filter(message => message.role === 'assistant').at(-1)
|
||||
if (assistant && ['done', 'error', 'cancelled'].includes(assistant.status) && !await read(`window.ycode.chat.activeRun(${JSON.stringify(session.id)})`)) break
|
||||
await pause(40)
|
||||
}
|
||||
assert.equal(assistant?.status, 'done', JSON.stringify({ status: assistant?.status, error: assistant?.error,
|
||||
tools: assistant?.tools?.map(tool => ({ name: tool.name, status: tool.status })) }))
|
||||
result = { content: assistant.content, tools: assistant.tools }
|
||||
events.push(...await read('window.__browserEvents'))
|
||||
check('APP: production IPC persists the screenshot as an assistant image part', assistant.parts.some(part => part.type === 'image' && part.url.startsWith('data:image/jpeg;base64,')))
|
||||
const persisted = JSON.parse(await fs.readFile(path.join(directory, 'data', 'sessions.json'), 'utf8'))
|
||||
check('APP: cold history contains successful browser actions and the real screenshot', persisted.sessions.some(item => item.id === session.id && item.messages.some(message => message.id === assistant.id && message.parts?.some(part => part.type === 'image'))))
|
||||
check('APP: the production host creates a hidden browser through its own registration', BrowserWindow.getAllWindows().some(window => window !== main && !window.isVisible()))
|
||||
await read(`window.ycode.session.remove(${JSON.stringify(session.id)})`)
|
||||
check('APP: deleting a session disposes its browser and native kernel', BrowserWindow.getAllWindows().length === 1)
|
||||
} else if (viaOmp) {
|
||||
const executable = host.detectOmpPath(); assert.ok(executable, 'installed OMP is available')
|
||||
const prepared = await host.prepareOmpModel({ config, profile: { id: 'browser-controlled', name: 'Browser controlled', providerId: 'custom', model: 'browser-controlled',
|
||||
baseUrl: config.baseUrl, hasApiKey: true, keyStorage: 'none', supportsToolCall: true, supportsImages, supportsReasoning: false,
|
||||
contextWindowTokens: 32000, maxOutputTokens: 1024, tokenBudgetVersion: 2 } })
|
||||
kernel = new host.OmpKernel(executable, directory, prepared.profile, path.join(directory, 'omp-browser-session.jsonl'), prepared.model, prepared.env)
|
||||
result = await host.runOmpTurn({ ...common, kernel })
|
||||
await kernel.dispose(); kernel = undefined
|
||||
} else result = await host.runTurn({ ...common, history: [], gateway: new host.Gateway(config), model: config.model, maxToolRounds: 8 })
|
||||
check(mode + ': controlled model completes a real browser open/type/click/screenshot round trip', result.content.includes(mode + '_BROWSER_COMPLETE'))
|
||||
check(mode + ': tool events contain four successful browser actions', result.tools.filter(tool => tool.name === 'browser' && tool.status === 'ok').length === 4)
|
||||
check(mode + ': screenshot is visible in conversation image events', events.some(event => event.type === 'part' && event.part.type === 'image' && event.part.url.startsWith('data:image/jpeg;base64,')))
|
||||
check(mode + ': page content and image capability are respected', observedApplied && observedImage === supportsImages)
|
||||
const final = apiRequests.filter(request => request.mode === mode).at(-1)
|
||||
if (!viaOmp && supportsImages) {
|
||||
const wire = { messages: final.body.messages }
|
||||
check('Responses fallback keeps browser screenshots as input_image', JSON.stringify(host.buildResponsesRequest(config, wire)).includes('input_image'))
|
||||
check('Anthropic fallback keeps browser screenshots as image blocks', JSON.stringify(host.buildAnthropicRequest(config, wire)).includes('"type":"image"'))
|
||||
check('Gemini fallback keeps browser screenshots as inlineData', JSON.stringify(host.buildGeminiRequest(config, wire)).includes('inlineData'))
|
||||
}
|
||||
if (!production) {
|
||||
const message = events.reduce((message, event) => host.applyAssistantEvent(message, event),
|
||||
{ id: mode + '-assistant', requestId: common.runId, role: 'assistant', content: '', createdAt: 0 })
|
||||
check(mode + ': compatible history replays screenshot images in subsequent turns', JSON.stringify(host.assistantWireHistory(message)).includes('data:image/jpeg;base64,'))
|
||||
check(mode + ': text-only history excludes screenshot input without losing tool results', !JSON.stringify(host.assistantWireHistory(message, { supportsImages: false })).includes('data:image/jpeg;base64,') && host.assistantWireHistory(message, { supportsImages: false }).some(row => row.role === 'tool'))
|
||||
}
|
||||
host.disposeSessionBrowsers(common.sessionId)
|
||||
}
|
||||
|
||||
app.whenReady().then(async () => {
|
||||
host = require(path.join(directory, 'host.cjs')); host.configureBrowserTool(host.executeBrowserTool)
|
||||
const fixture = await listen((req, res) => {
|
||||
if (req.url === '/hang') return
|
||||
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }); res.end(page(req.url === '/next'))
|
||||
})
|
||||
phase = 'direct-browser-actions'
|
||||
check('browser is registered in the host tool schema', host.toolSpecs().some(tool => tool.function.name === 'browser'))
|
||||
await rejected({ action: 'open', url: 'file:///C:/Windows/win.ini' }, /HTTP/)
|
||||
await rejected({ action: 'open', url: 'javascript:alert(1)' }, /HTTP/)
|
||||
await rejected({ action: 'open', url: 'http://user:password@localhost/' }, /HTTP/)
|
||||
check('file, script and credential URLs are refused', BrowserWindow.getAllWindows().length === 0)
|
||||
const blocked = await host.executeTurnTool('browser', { action: 'open', url: fixture.base + '/page' },
|
||||
{ ...ctx('mode-denied'), agentMode: 'plan', emit() {} })
|
||||
check('plan mode cannot bypass the shared tool gate', !blocked.ok && BrowserWindow.getAllWindows().length === 0)
|
||||
const hostBrowserContext = ctx('host-browser', { permissionMode: 'default-sandbox', approve: async () => false })
|
||||
const hostBrowser = await ok({ action: 'open', url: fixture.base + '/page' }, hostBrowserContext)
|
||||
check('host browser opens without sandbox approval', BrowserWindow.getAllWindows().length === 1)
|
||||
await ok({ action: 'close', tabId: hostBrowser.data.tabId }, hostBrowserContext)
|
||||
check('host browser close releases the unapproved tab', BrowserWindow.getAllWindows().length === 0)
|
||||
const first = await ok({ action: 'open', url: fixture.base + '/page' })
|
||||
const id = first.data.tabId
|
||||
check('real hidden Chromium returns the rendered page and interactive refs', first.data.text.includes('BROWSER_FIXTURE') && first.data.elements.some(element => element.name === 'Apply'))
|
||||
check('browser remains hidden and has no renderer Node access', BrowserWindow.getAllWindows().every(window => !window.isVisible()) && first.data.text.includes('node:undefined/undefined'))
|
||||
check('password values do not appear in browser snapshots', !first.content.includes('never-expose-password'))
|
||||
const managed = BrowserWindow.getAllWindows()[0], prefs = managed.webContents.getLastWebPreferences()
|
||||
check('browser renderer uses isolation and a sandbox without preload', prefs.sandbox && prefs.contextIsolation && !prefs.nodeIntegration && !prefs.preload)
|
||||
await rejected({ action: 'snapshot', tabId: id }, /不属于/, ctx('browser-b'))
|
||||
check('another conversation cannot use the browser id', (await ok({ action: 'list' }, ctx('browser-b'))).data.length === 0)
|
||||
await managed.webContents.session.cookies.set({ url: fixture.base, name: 'isolation', value: 'private-a' })
|
||||
const second = await ok({ action: 'open', url: fixture.base + '/page' }, ctx('browser-b'))
|
||||
check('browser cookies are isolated between conversations', !second.data.text.includes('private-a'))
|
||||
await rejected({ action: 'click', tabId: id, selector: 'button' }, /唯一匹配/)
|
||||
await ok({ action: 'type', tabId: id, selector: '#query', text: '' })
|
||||
check('type can clear a text field', (await ok({ action: 'snapshot', tabId: id })).data.elements.find(element => element.tag === 'input' && element.type === 'text').value === '')
|
||||
await ok({ action: 'type', tabId: id, selector: '#query', text: 'typed real text' })
|
||||
const applied = await ok({ action: 'click', tabId: id, ref: first.data.elements.find(element => element.name === 'Apply').ref })
|
||||
check('typing and clicking produce actual trusted Chromium events', applied.data.text.includes('APPLIED:typed real text:trusted=true'))
|
||||
await ok({ action: 'type', tabId: id, selector: '#query', text: '+append', replace: false })
|
||||
const appended = await ok({ action: 'snapshot', tabId: id })
|
||||
check('type supports append without erasing existing input', appended.data.elements.find(element => element.tag === 'input' && element.type === 'text').value === 'typed real text+append')
|
||||
await ok({ action: 'press_key', tabId: id, key: 'Tab' })
|
||||
await ok({ action: 'press_key', tabId: id, key: 'Enter' })
|
||||
check('keyboard events can activate the focused button', (await ok({ action: 'snapshot', tabId: id })).data.text.includes('APPLIED:typed real text+append:trusted=true'))
|
||||
await ok({ action: 'click', tabId: id, selector: '#later' })
|
||||
await ok({ action: 'wait', tabId: id, text: 'DELAYED_READY', timeoutMs: 1500 })
|
||||
check('wait observes asynchronous rendered page changes', true)
|
||||
await rejected({ action: 'wait', tabId: id, text: 'never-present', timeoutMs: 100 }, /超时/)
|
||||
const scrolled = await ok({ action: 'scroll', tabId: id, y: 700 })
|
||||
check('scroll changes the real page viewport', scrolled.data.scroll.y > 0)
|
||||
await ok({ action: 'scroll', tabId: id, y: -700 })
|
||||
const capture = await ok({ action: 'screenshot', tabId: id })
|
||||
const bytes = Buffer.from(capture.images[0].data, 'base64'); await fs.writeFile(path.join(directory, 'browser-actual.jpg'), bytes)
|
||||
check('screenshot returns a bounded real JPEG image', bytes[0] === 0xff && bytes[1] === 0xd8 && bytes.length > 2000 && bytes.length < 500 * 1024)
|
||||
check('taking a screenshot never opens a visible window', BrowserWindow.getAllWindows().every(window => !window.isVisible()))
|
||||
const oldRef = applied.data.elements.find(element => element.name === 'Apply').ref
|
||||
const next = await ok({ action: 'navigate', tabId: id, url: fixture.base + '/next' })
|
||||
check('navigate reads the new document', next.data.text.includes('NEXT_PAGE'))
|
||||
await rejected({ action: 'click', tabId: id, ref: oldRef }, /ref/)
|
||||
check('refs from previous documents cannot trigger new-page controls', true)
|
||||
let approvals = 0
|
||||
const noApproval = await ok({ action: 'open', url: fixture.base + '/page' }, ctx('approved', { permissionMode: 'default-sandbox', approve: async () => { approvals++; return true } }))
|
||||
check('legacy sandbox approval callback is never invoked', approvals === 0)
|
||||
await ok({ action: 'close', tabId: noApproval.data.tabId }, ctx('approved'))
|
||||
const cancellation = new AbortController(), started = Date.now()
|
||||
setTimeout(() => cancellation.abort(), 150)
|
||||
await rejected({ action: 'open', url: fixture.base + '/hang' }, /取消/, ctx('cancelled-open', { signal: cancellation.signal }))
|
||||
check('cancelled navigation stops promptly without leaving an orphan tab', Date.now() - started < 2000 && (await ok({ action: 'list' }, ctx('cancelled-open'))).data.length === 0)
|
||||
host.disposeSessionBrowsers('browser-a')
|
||||
check('session cleanup closes only its own browsers', (await ok({ action: 'list' })).data.length === 0 && (await ok({ action: 'list' }, ctx('browser-b'))).data.length === 1)
|
||||
await ok({ action: 'close', tabId: second.data.tabId }, ctx('browser-b'))
|
||||
check('close releases a browser through the public tool action', (await ok({ action: 'list' }, ctx('browser-b'))).data.length === 0)
|
||||
host.disposeBrowserAutomation()
|
||||
check('all browser contents are released', BrowserWindow.getAllWindows().length === 0)
|
||||
phase = 'http-conversation'; await conversation(false, fixture.base)
|
||||
phase = 'omp-conversation'; await conversation(true, fixture.base)
|
||||
phase = 'text-only-http-conversation'; await conversation(false, fixture.base, false, false)
|
||||
phase = 'text-only-omp-conversation'; await conversation(true, fixture.base, false, false)
|
||||
phase = 'production-conversation'; await conversation(true, fixture.base, true)
|
||||
await fs.writeFile(path.join(directory, 'report.json'), JSON.stringify({ result: 'PASS', checks, apiRequests, phase }, null, 2))
|
||||
console.log('BROWSER_AUTOMATION_PASS ' + directory)
|
||||
}).catch(async error => {
|
||||
console.error(error)
|
||||
await fs.writeFile(path.join(directory, 'report.json'), JSON.stringify({ result: 'FAIL', phase, checks, error: String(error.stack || error), apiRequests }, null, 2))
|
||||
process.exitCode = 1
|
||||
}).finally(async () => {
|
||||
if (kernel) await kernel.dispose().catch(() => undefined)
|
||||
host?.disposeBrowserAutomation()
|
||||
for (const server of servers) { server.closeAllConnections(); server.close() }
|
||||
await pause(100); app.exit(process.exitCode || 0)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { build } from 'esbuild'
|
||||
|
||||
await build({
|
||||
entryPoints: ['scripts/claude-kernel-integration.ts'],
|
||||
outfile: '.tmp/claude-kernel-integration.mjs',
|
||||
bundle: true,
|
||||
platform: 'node',
|
||||
format: 'esm',
|
||||
external: ['@anthropic-ai/claude-agent-sdk'],
|
||||
logLevel: 'warning',
|
||||
plugins: [{
|
||||
name: 'claude-kernel-electron-stub',
|
||||
setup(plugin) {
|
||||
plugin.onResolve({ filter: /^electron$/ }, () => ({ path: 'electron', namespace: 'claude-test' }))
|
||||
plugin.onLoad({ filter: /.*/, namespace: 'claude-test' }, () => ({
|
||||
loader: 'js',
|
||||
contents: `
|
||||
export const app = {
|
||||
getPath: (name) => name === 'userData' ? process.env.CLAUDE_TEST_USER_DATA : process.cwd(),
|
||||
getAppPath: () => process.cwd()
|
||||
};
|
||||
`
|
||||
}))
|
||||
}
|
||||
}]
|
||||
})
|
||||
@@ -1,65 +0,0 @@
|
||||
import fs from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import { spawn, execFileSync } from 'node:child_process'
|
||||
import { createHash } from 'node:crypto'
|
||||
import { verifyBundledGit } from './vendor-sandbox-git.mjs'
|
||||
|
||||
export async function buildSandbox({ outputDirectory = path.resolve('resources/sandbox/win32-x64'), probe = false } = {}) {
|
||||
if (process.platform !== 'win32' || process.arch !== 'x64') throw new Error('The sandbox native build currently requires Windows x64')
|
||||
const bundledGit = !probe ? await verifyBundledGit(path.join(outputDirectory, 'git')).catch(error => { throw new Error(`Bundled Git is missing or invalid; run npm run sandbox:vendor-git. ${error.message}`) }) : undefined
|
||||
const programFiles = process.env['ProgramFiles(x86)'] || 'C:\\Program Files (x86)'
|
||||
const vswhere = path.join(programFiles, 'Microsoft Visual Studio/Installer/vswhere.exe')
|
||||
const installation = execFileSync(vswhere, ['-latest', '-products', '*', '-requires', 'Microsoft.VisualStudio.Component.VC.Tools.x86.x64', '-property', 'installationPath'], { encoding: 'utf8', windowsHide: true }).trim()
|
||||
if (!installation) throw new Error('Visual Studio C++ Build Tools are required; see native/sandbox/README.md')
|
||||
const versions = async directory => (await fs.readdir(directory)).filter(name => /^\d+(?:\.\d+)+$/.test(name)).sort((a, b) => b.localeCompare(a, undefined, { numeric: true }))
|
||||
const msvcRoot = path.join(installation, 'VC/Tools/MSVC')
|
||||
const msvc = path.join(msvcRoot, (await versions(msvcRoot))[0])
|
||||
const sdkRoot = path.join(programFiles, 'Windows Kits/10')
|
||||
const sdk = (await versions(path.join(sdkRoot, 'Include')))[0]
|
||||
if (!sdk) throw new Error('Windows 10/11 SDK is required')
|
||||
const compilerDirectory = path.join(msvc, 'bin/Hostx64/x64')
|
||||
const environment = { ...process.env,
|
||||
PATH: `${compilerDirectory};${process.env.PATH || ''}`,
|
||||
INCLUDE: [path.join(msvc, 'include'), ...['ucrt', 'shared', 'um', 'winrt'].map(part => path.join(sdkRoot, 'Include', sdk, part))].join(';'),
|
||||
LIB: [path.join(msvc, 'lib/x64'), ...['ucrt', 'um'].map(part => path.join(sdkRoot, 'Lib', sdk, part, 'x64'))].join(';') }
|
||||
await fs.mkdir(outputDirectory, { recursive: true })
|
||||
const objects = await fs.mkdtemp(path.resolve('.tmp/sandbox-native-build-'))
|
||||
const source = path.resolve(probe ? 'native/sandbox/probe.cpp' : 'native/sandbox/runner.cpp')
|
||||
const binary = path.join(outputDirectory, probe ? 'sandbox-probe.exe' : 'ycode-sandbox.exe')
|
||||
const args = ['/nologo', '/std:c++17', '/EHsc', '/W4', '/WX', '/MT', '/O2', '/Brepro', '/utf-8', '/DUNICODE', '/D_UNICODE', '/D_WIN32_WINNT=0x0A00', `/Fo${objects}${path.sep}`, `/Fe${binary}`, source, '/link', '/INCREMENTAL:NO', 'advapi32.lib', 'userenv.lib', 'ws2_32.lib', 'bcrypt.lib']
|
||||
await new Promise((resolve, reject) => {
|
||||
const child = spawn(path.join(compilerDirectory, 'cl.exe'), args, { env: environment, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] })
|
||||
let log = ''
|
||||
child.stdout.on('data', bytes => { log += bytes })
|
||||
child.stderr.on('data', bytes => { log += bytes })
|
||||
child.once('error', reject)
|
||||
child.once('close', code => code === 0 ? resolve() : reject(new Error(`Sandbox compiler exited ${code}\n${log}`)))
|
||||
})
|
||||
const hash = bytes => createHash('sha256').update(bytes).digest('hex')
|
||||
const manifest = { protocol: 1, backend: 'windows-appcontainer', version: '0.7.0', architecture: 'x64',
|
||||
sourceSha256: hash(await fs.readFile(source)), binarySha256: hash(await fs.readFile(binary)), compiler: path.basename(msvc), sdk }
|
||||
if (!probe) {
|
||||
manifest.workspaceTransactions = 1
|
||||
manifest.workspaceState = 1
|
||||
manifest.gitMetadataTransactions = 1
|
||||
manifest.bundledGit = bundledGit
|
||||
manifest.sources = Object.fromEntries(await Promise.all(['runner.cpp', 'filesystem-read.h', 'filesystem-write.h', 'workspace-state.h', 'git-control.h', 'node-bridge.cjs', 'node-process.cjs', 'python-sitecustomize.py'].map(async name => [name, hash(await fs.readFile(path.resolve('native/sandbox', name)))])))
|
||||
const adapter = await fs.readFile(path.resolve('native/sandbox/node-bridge.cjs'))
|
||||
await fs.writeFile(path.join(outputDirectory, 'node-bridge.cjs'), adapter)
|
||||
manifest.nodeBridgeSha256 = hash(adapter)
|
||||
const processes = await fs.readFile(path.resolve('native/sandbox/node-process.cjs'))
|
||||
await fs.writeFile(path.join(outputDirectory, 'node-process.cjs'), processes)
|
||||
manifest.nodeProcessSha256 = hash(processes)
|
||||
const pythonAdapter = await fs.readFile(path.resolve('native/sandbox/python-sitecustomize.py'))
|
||||
await fs.writeFile(path.join(outputDirectory, 'python-sitecustomize.py'), pythonAdapter)
|
||||
manifest.pythonAdapterSha256 = hash(pythonAdapter)
|
||||
}
|
||||
if (!probe) await fs.writeFile(path.join(outputDirectory, 'manifest.json'), JSON.stringify(manifest, null, 2) + '\n')
|
||||
console.log(`SANDBOX_BUILD_PASS ${path.relative(process.cwd(), binary)}`)
|
||||
return binary
|
||||
}
|
||||
|
||||
if (process.argv[1] && path.resolve(process.argv[1]) === import.meta.filename) {
|
||||
await fs.mkdir(path.resolve('.tmp'), { recursive: true })
|
||||
await buildSandbox({ probe: process.argv.includes('--probe') })
|
||||
}
|
||||
@@ -1,236 +0,0 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { createServer } from 'node:http'
|
||||
import { Gateway, GatewayError, type GatewayDelta } from '../src/main/gateway'
|
||||
|
||||
const requests = new Map<string, Array<Record<string, unknown>>>()
|
||||
const abortConnections = new Set<import('node:http').ServerResponse>()
|
||||
|
||||
function attempts(model: string): Array<Record<string, unknown>> {
|
||||
return requests.get(model) ?? []
|
||||
}
|
||||
|
||||
function recordAttempt(body: Record<string, unknown>): number {
|
||||
const model = String(body.model)
|
||||
const items = requests.get(model) ?? []
|
||||
items.push(body)
|
||||
requests.set(model, items)
|
||||
return items.length
|
||||
}
|
||||
|
||||
function successSse(response: import('node:http').ServerResponse, usage: Record<string, unknown>): void {
|
||||
response.writeHead(200, { 'content-type': 'text/event-stream' })
|
||||
response.end([
|
||||
{ choices: [{ delta: { content: 'ok' }, finish_reason: null }] },
|
||||
{ choices: [{ delta: {}, finish_reason: 'stop' }] },
|
||||
{ choices: [], usage }
|
||||
].map((frame) => `data: ${JSON.stringify(frame)}\n\n`).join('') + 'data: [DONE]\n\n')
|
||||
}
|
||||
|
||||
const server = createServer(async (request, response) => {
|
||||
const chunks: Buffer[] = []
|
||||
for await (const chunk of request) chunks.push(Buffer.from(chunk))
|
||||
const body = JSON.parse(Buffer.concat(chunks).toString()) as Record<string, unknown>
|
||||
const model = String(body.model)
|
||||
const attempt = recordAttempt(body)
|
||||
const validUsage = {
|
||||
prompt_tokens: 128,
|
||||
completion_tokens: 2,
|
||||
total_tokens: 130,
|
||||
prompt_tokens_details: { cached_tokens: 32 }
|
||||
}
|
||||
|
||||
if (model === 'valid' || model === 'invalid-usage') {
|
||||
successSse(response, model === 'invalid-usage'
|
||||
? { prompt_tokens: -1, completion_tokens: 2, total_tokens: 1 }
|
||||
: validUsage)
|
||||
return
|
||||
}
|
||||
|
||||
if (model === 'legacy-400') {
|
||||
if (attempt === 1 && body.stream_options) {
|
||||
response.writeHead(400, { 'content-type': 'application/json' })
|
||||
response.end(JSON.stringify({ error: { message: 'Unsupported parameter: stream_options' } }))
|
||||
return
|
||||
}
|
||||
successSse(response, validUsage)
|
||||
return
|
||||
}
|
||||
|
||||
if (model === 'legacy-422') {
|
||||
if (attempt === 1 && body.stream_options) {
|
||||
response.writeHead(422, { 'content-type': 'application/json' })
|
||||
response.end(JSON.stringify({ error: { message: 'include_usage is not supported by this server' } }))
|
||||
return
|
||||
}
|
||||
successSse(response, validUsage)
|
||||
return
|
||||
}
|
||||
|
||||
if (model === 'ordinary-400') {
|
||||
response.writeHead(400, { 'content-type': 'application/json' })
|
||||
response.end(JSON.stringify({
|
||||
error: { message: 'request validation failed while parsing stream_options' },
|
||||
apiKey: 'UPSTREAM_SECRET_MUST_NOT_ECHO'
|
||||
}))
|
||||
return
|
||||
}
|
||||
|
||||
if (model === 'auth-401') {
|
||||
response.writeHead(401, { 'content-type': 'application/json' })
|
||||
response.end(JSON.stringify({
|
||||
error: { message: 'invalid api key' },
|
||||
authorization: 'Bearer UPSTREAM_SECRET_MUST_NOT_ECHO'
|
||||
}))
|
||||
return
|
||||
}
|
||||
|
||||
if (model === 'server-500-options') {
|
||||
response.writeHead(500, { 'content-type': 'application/json' })
|
||||
response.end(JSON.stringify({ error: { message: 'stream_options unsupported due to server failure' } }))
|
||||
return
|
||||
}
|
||||
|
||||
if (model === 'started-sse-error') {
|
||||
response.writeHead(200, { 'content-type': 'text/event-stream' })
|
||||
response.write(`data: ${JSON.stringify({ choices: [{ delta: { content: 'before-error' }, finish_reason: null }] })}\n\n`)
|
||||
response.write(`data: ${JSON.stringify({ error: { message: 'stream_options unsupported' } })}\n\n`)
|
||||
response.end()
|
||||
return
|
||||
}
|
||||
|
||||
if (model === 'abort-retry') {
|
||||
if (attempt === 1 && body.stream_options) {
|
||||
response.writeHead(400, { 'content-type': 'application/json' })
|
||||
response.end(JSON.stringify({ error: { message: 'unknown field: stream_options' } }))
|
||||
return
|
||||
}
|
||||
abortConnections.add(response)
|
||||
response.writeHead(200, {
|
||||
'content-type': 'text/event-stream',
|
||||
'cache-control': 'no-cache',
|
||||
connection: 'keep-alive'
|
||||
})
|
||||
response.flushHeaders()
|
||||
const keepalive = setInterval(() => {
|
||||
if (!response.destroyed) response.write(': keepalive\n\n')
|
||||
}, 100)
|
||||
response.on('close', () => {
|
||||
clearInterval(keepalive)
|
||||
abortConnections.delete(response)
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
response.writeHead(400, { 'content-type': 'application/json' })
|
||||
response.end(JSON.stringify({ error: { message: `unknown QA model: ${model}` } }))
|
||||
})
|
||||
|
||||
function gatewayFor(model: string): Gateway {
|
||||
const address = server.address()
|
||||
assert.ok(address && typeof address === 'object')
|
||||
return new Gateway({
|
||||
baseUrl: `http://127.0.0.1:${address.port}/v1`,
|
||||
apiKey: '',
|
||||
authMode: 'none',
|
||||
model,
|
||||
temperature: 0,
|
||||
maxToolRounds: 1
|
||||
})
|
||||
}
|
||||
|
||||
async function collect(model: string, signal?: AbortSignal): Promise<GatewayDelta[]> {
|
||||
const events: GatewayDelta[] = []
|
||||
for await (const event of gatewayFor(model).stream({
|
||||
messages: [{ role: 'user', content: 'test' }],
|
||||
signal
|
||||
})) {
|
||||
events.push(event)
|
||||
}
|
||||
return events
|
||||
}
|
||||
|
||||
async function captureError(model: string): Promise<GatewayError> {
|
||||
try {
|
||||
await collect(model)
|
||||
} catch (error) {
|
||||
assert.ok(error instanceof GatewayError, `${model} must reject with GatewayError`)
|
||||
return error
|
||||
}
|
||||
assert.fail(`${model} should reject`)
|
||||
}
|
||||
|
||||
async function waitForAttempts(model: string, count: number): Promise<void> {
|
||||
for (let index = 0; index < 250; index++) {
|
||||
if (attempts(model).length === count) return
|
||||
await new Promise((resolve) => setTimeout(resolve, 10))
|
||||
}
|
||||
assert.fail(`${model} did not reach ${count} requests`)
|
||||
}
|
||||
|
||||
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve))
|
||||
try {
|
||||
const valid = await collect('valid')
|
||||
assert.deepEqual(valid.filter((event) => event.type === 'text'), [{ type: 'text', text: 'ok' }])
|
||||
assert.deepEqual(valid.filter((event) => event.type === 'finish'), [{ type: 'finish', reason: 'stop' }])
|
||||
assert.deepEqual(valid.filter((event) => event.type === 'usage'), [{
|
||||
type: 'usage',
|
||||
usage: { inputTokens: 128, outputTokens: 2, totalTokens: 130, cachedTokens: 32 }
|
||||
}])
|
||||
assert.equal(attempts('valid').length, 1)
|
||||
assert.deepEqual(attempts('valid')[0]?.stream_options, { include_usage: true })
|
||||
|
||||
const invalidUsage = await collect('invalid-usage')
|
||||
assert.equal(invalidUsage.filter((event) => event.type === 'usage').length, 0,
|
||||
'invalid usage must not emit a usage event')
|
||||
|
||||
for (const model of ['legacy-400', 'legacy-422']) {
|
||||
const events = await collect(model)
|
||||
assert.deepEqual(events.filter((event) => event.type === 'text'), [{ type: 'text', text: 'ok' }])
|
||||
assert.deepEqual(events.filter((event) => event.type === 'usage'), [{
|
||||
type: 'usage',
|
||||
usage: { inputTokens: 128, outputTokens: 2, totalTokens: 130, cachedTokens: 32 }
|
||||
}])
|
||||
const tries = attempts(model)
|
||||
assert.equal(tries.length, 2, `${model} must retry exactly once`)
|
||||
assert.deepEqual(tries[0]?.stream_options, { include_usage: true })
|
||||
assert.equal(Object.hasOwn(tries[1] ?? {}, 'stream_options'), false,
|
||||
`${model} retry must omit stream_options`)
|
||||
}
|
||||
|
||||
const ordinary = await captureError('ordinary-400')
|
||||
assert.equal(ordinary.status, 400)
|
||||
assert.equal(attempts('ordinary-400').length, 1, 'an ordinary 400 must not retry')
|
||||
assert.equal(ordinary.message.includes('UPSTREAM_SECRET_MUST_NOT_ECHO'), false,
|
||||
'upstream body content must not be copied into the renderer-facing message')
|
||||
|
||||
const auth = await captureError('auth-401')
|
||||
assert.equal(auth.status, 401)
|
||||
assert.equal(attempts('auth-401').length, 1, 'authentication failures must not retry')
|
||||
assert.equal(auth.message.includes('UPSTREAM_SECRET_MUST_NOT_ECHO'), false,
|
||||
'authorization material must not be copied into the renderer-facing message')
|
||||
|
||||
const serverFailure = await captureError('server-500-options')
|
||||
assert.equal(serverFailure.status, 500)
|
||||
assert.equal(attempts('server-500-options').length, 1,
|
||||
'a 5xx response must not retry even when its body names stream_options')
|
||||
|
||||
const startedSse = await captureError('started-sse-error')
|
||||
assert.equal(attempts('started-sse-error').length, 1,
|
||||
'an error after SSE has started must not trigger another request')
|
||||
assert.match(startedSse.message, /上游错误/)
|
||||
|
||||
const controller = new AbortController()
|
||||
const aborted = collect('abort-retry', controller.signal)
|
||||
await waitForAttempts('abort-retry', 2)
|
||||
controller.abort()
|
||||
await assert.rejects(aborted, (error: unknown) =>
|
||||
error instanceof Error && error.name === 'AbortError')
|
||||
assert.equal(attempts('abort-retry').length, 2,
|
||||
'the shared AbortSignal must cancel the in-flight compatibility retry')
|
||||
|
||||
console.log('chat-completions-usage-regression: all checks passed')
|
||||
} finally {
|
||||
for (const response of abortConnections) response.destroy()
|
||||
server.closeAllConnections?.()
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()))
|
||||
}
|
||||
@@ -13,9 +13,7 @@ interface PersistenceTestState {
|
||||
promptCalls: number
|
||||
abortCalls: number
|
||||
cancelOutputEmitted: boolean
|
||||
useOmp: boolean
|
||||
fallbackHistory: ChatMessage[] | null
|
||||
fallbackWireHistory?: Array<{ role: string; content: string }>
|
||||
resumeAts: Array<string | undefined>
|
||||
}
|
||||
|
||||
const TEST_STATE_KEY = '__YCODE_CHAT_PERSISTENCE_TEST__'
|
||||
@@ -73,8 +71,7 @@ async function main(): Promise<void> {
|
||||
promptCalls: 0,
|
||||
abortCalls: 0,
|
||||
cancelOutputEmitted: false,
|
||||
useOmp: true,
|
||||
fallbackHistory: null
|
||||
resumeAts: []
|
||||
}
|
||||
await mkdir(state.userData, { recursive: true })
|
||||
|
||||
@@ -134,74 +131,74 @@ async function main(): Promise<void> {
|
||||
export class WebContentsView {}
|
||||
`
|
||||
}))
|
||||
pluginBuild.onResolve({ filter: /^\.\/omp$/ }, () => ({ path: 'omp', namespace: 'mock-omp' }))
|
||||
pluginBuild.onLoad({ filter: /.*/, namespace: 'mock-omp' }, () => ({
|
||||
pluginBuild.onResolve({ filter: /^\.\/agent\/claude\/claude-kernel$/ }, () => ({ path: 'claude-kernel', namespace: 'mock-claude-kernel' }))
|
||||
pluginBuild.onLoad({ filter: /.*/, namespace: 'mock-claude-kernel' }, () => ({
|
||||
loader: 'js',
|
||||
contents: `
|
||||
import { EventEmitter } from 'node:events';
|
||||
const state = globalThis.${TEST_STATE_KEY};
|
||||
const toolId = 'persisted-tool';
|
||||
const fullOutput = '${'long-output-'.repeat(60)}PERSISTED-OUTPUT-SENTINEL';
|
||||
const emit = (kernel, frame) => kernel.emit('event', frame);
|
||||
export class OmpKernel extends EventEmitter {
|
||||
constructor() { super(); }
|
||||
setThinkingLevel() { return Promise.resolve(); }
|
||||
setHostTools() { return Promise.resolve({ type: 'response', success: true, data: { toolNames: [] } }); }
|
||||
send() { return Promise.resolve({ type: 'response', success: true, data: { messages: [] } }); }
|
||||
prompt(message) {
|
||||
const kernels = new Map();
|
||||
class MockClaudeKernel {
|
||||
constructor(options) { this.options = options; this.busy = false; this.active = null; }
|
||||
async ensureStarted() {}
|
||||
async setPolicy() {}
|
||||
async runTurn(request) {
|
||||
state.promptCalls++;
|
||||
if (message.split('\\n', 1)[0] === 'persist-cancel') {
|
||||
setImmediate(() => {
|
||||
emit(this, { type: 'message_update', assistantMessageEvent: { type: 'text_delta', delta: 'cancel-before' } });
|
||||
emit(this, { type: 'tool_execution_start', toolCall: { id: toolId, name: 'shell', args: { command: 'long-running' } } });
|
||||
emit(this, { type: 'tool_execution_update', toolCall: { id: toolId, partialResult: 'cancelled tool output retained' } });
|
||||
state.cancelOutputEmitted = true;
|
||||
});
|
||||
} else {
|
||||
setImmediate(() => {
|
||||
emit(this, { type: 'message_end', message: { role: 'assistant', usage: { input: 1200, output: 10, totalTokens: 1210 } } });
|
||||
emit(this, { type: 'message_update', assistantMessageEvent: { type: 'text_delta', delta: 'before' } });
|
||||
emit(this, { type: 'tool_execution_start', toolCall: { id: toolId, name: 'read_file', args: { path: 'large.txt' } } });
|
||||
emit(this, { type: 'tool_execution_update', toolCall: { id: toolId, partialResult: fullOutput } });
|
||||
emit(this, { type: 'message_update', assistantMessageEvent: { type: 'text_delta', delta: 'after' } });
|
||||
emit(this, { type: 'tool_execution_end', toolCall: { id: toolId, name: 'read_file', result: { content: [{ type: 'text', text: fullOutput }], isError: false }, success: true } });
|
||||
emit(this, { type: 'message_end', message: { role: 'assistant', usage: { contextTokens: 12800, input: 12000, cacheRead: 800, output: 200, totalTokens: 13000 } } });
|
||||
emit(this, { type: 'agent_end', yielded: true });
|
||||
this.busy = true;
|
||||
const cancelled = request.prompt.startsWith('persist-cancel');
|
||||
if (cancelled) {
|
||||
request.emit({ runId: request.runId, type: 'text', delta: 'cancel-before' });
|
||||
request.emit({ runId: request.runId, type: 'tool_call', call: {
|
||||
id: toolId, name: 'Read', args: { path: 'large.txt' }, argsRaw: '{"path":"large.txt"}',
|
||||
status: 'running', output: 'cancelled tool output retained'
|
||||
} });
|
||||
state.cancelOutputEmitted = true;
|
||||
return new Promise((resolve, reject) => {
|
||||
const abort = () => {
|
||||
state.abortCalls++;
|
||||
this.busy = false;
|
||||
reject(Object.assign(new Error('回合已取消'), { name: 'AbortError' }));
|
||||
};
|
||||
if (request.signal.aborted) abort();
|
||||
else request.signal.addEventListener('abort', abort, { once: true });
|
||||
});
|
||||
}
|
||||
return Promise.resolve({ type: 'response', success: true });
|
||||
const tool = {
|
||||
id: toolId, name: 'Read', args: { path: 'large.txt' }, argsRaw: '{"path":"large.txt"}',
|
||||
status: 'ok', output: fullOutput, summary: fullOutput.slice(0, 600), result: { content: fullOutput }
|
||||
};
|
||||
request.emit({ runId: request.runId, type: 'text', delta: 'before' });
|
||||
request.emit({ runId: request.runId, type: 'tool_call', call: { ...tool, status: 'running', output: undefined, summary: undefined, result: undefined } });
|
||||
request.emit({ runId: request.runId, type: 'tool_result', call: tool });
|
||||
request.emit({ runId: request.runId, type: 'text', delta: 'after' });
|
||||
request.emit({ runId: request.runId, type: 'usage', usage: { inputTokens: 12800, outputTokens: 200, totalTokens: 13000, cachedTokens: 800 } });
|
||||
this.busy = false;
|
||||
return { content: 'beforeafter', tools: [tool], status: 'done', entryId: 'mock-claude-entry' };
|
||||
}
|
||||
abort() {
|
||||
state.abortCalls++;
|
||||
setImmediate(() => emit(this, { type: 'agent_end', yielded: true }));
|
||||
return Promise.resolve({ type: 'response', success: true });
|
||||
}
|
||||
dispose() {}
|
||||
async rewindFiles() {}
|
||||
async close() { this.busy = false; }
|
||||
}
|
||||
export const detectOmpPath = () => state.useOmp ? 'mock-omp' : null;
|
||||
export const getOmpInfo = () => ({ available: true, exePath: 'mock-omp', profile: 'ycode', model: 'ycode/test' });
|
||||
`
|
||||
}))
|
||||
pluginBuild.onResolve({ filter: /^\.\/agent\/loop$/ }, () => ({ path: 'agent-loop', namespace: 'mock-loop' }))
|
||||
pluginBuild.onLoad({ filter: /.*/, namespace: 'mock-loop' }, () => ({
|
||||
loader: 'js',
|
||||
contents: `
|
||||
const state = globalThis.${TEST_STATE_KEY};
|
||||
export async function runTurn(options) {
|
||||
state.fallbackHistory = options.history;
|
||||
state.fallbackWireHistory = options.wireHistory;
|
||||
const content = 'fallback response';
|
||||
options.emit({ runId: options.runId, type: 'text', delta: content });
|
||||
options.emit({ runId: options.runId, type: 'done', content, tools: [] });
|
||||
return { content, tools: [] };
|
||||
export function getClaudeKernel(options) {
|
||||
state.resumeAts.push(options.resumeAt);
|
||||
let kernel = kernels.get(options.sessionId);
|
||||
if (!kernel) { kernel = new MockClaudeKernel(options); kernels.set(options.sessionId, kernel); }
|
||||
return kernel;
|
||||
}
|
||||
export const cachedClaudeKernel = (sessionId) => kernels.get(sessionId);
|
||||
export const releaseClaudeKernel = async (sessionId, force) => {
|
||||
if (!force) return;
|
||||
const kernel = kernels.get(sessionId);
|
||||
if (!kernel) return;
|
||||
kernels.delete(sessionId);
|
||||
await kernel.close();
|
||||
};
|
||||
export const setSessionVisible = () => {};
|
||||
export const removeSessionView = () => {};
|
||||
export const disposeClaudeKernels = async () => { for (const kernel of kernels.values()) await kernel.close(); kernels.clear(); };
|
||||
export const claudeRuntimeInfo = () => ({ available: false, exePath: null, sessionDir: state.userData });
|
||||
`
|
||||
}))
|
||||
pluginBuild.onResolve({ filter: /^\.\/gateway$/ }, () => ({ path: 'gateway', namespace: 'mock-gateway' }))
|
||||
pluginBuild.onLoad({ filter: /.*/, namespace: 'mock-gateway' }, () => ({
|
||||
loader: 'js',
|
||||
contents: 'export class Gateway { constructor() {} }'
|
||||
}))
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -235,6 +232,7 @@ async function main(): Promise<void> {
|
||||
]
|
||||
assert.deepEqual(normalAssistant.parts, expectedParts, 'session:messages should retain text/tool/text order')
|
||||
assert.equal(normalAssistant.status, 'done')
|
||||
assert.equal(normalAssistant.claudeEntryId, 'mock-claude-entry')
|
||||
assert.equal(normalAssistant.tools?.length, 1)
|
||||
const normalTool = normalAssistant.tools?.[0]
|
||||
assert.ok(normalTool, 'completed tool should be present')
|
||||
@@ -243,7 +241,8 @@ async function main(): Promise<void> {
|
||||
assert.ok((normalTool.summary?.length ?? Infinity) < (normalTool.output?.length ?? 0),
|
||||
'the summary may be truncated while full output remains intact')
|
||||
|
||||
const diskNormalAssistant = assistant(await readDiskMessages(state.userData, normalSession.id))
|
||||
const diskNormalAssistant = await waitForDiskAssistant(state.userData, normalSession.id,
|
||||
(message) => message.status === 'done' && message.claudeEntryId === 'mock-claude-entry')
|
||||
assert.deepEqual(normalAssistant.contextUsage, {
|
||||
usage: { inputTokens: 12800, outputTokens: 200, totalTokens: 13000, cachedTokens: 800 }, contextWindow: 64000,
|
||||
modelId:'usage-snapshot-model',requestId:normalAssistant.requestId,source:'upstream',sampledAt:normalAssistant.contextUsage?.sampledAt
|
||||
@@ -252,45 +251,32 @@ async function main(): Promise<void> {
|
||||
assert.deepEqual(diskNormalAssistant.contextUsage, normalAssistant.contextUsage,
|
||||
'the checkpoint whitelist must persist the upstream sample and frozen denominator together')
|
||||
assert.deepEqual(diskNormalAssistant.parts, expectedParts, 'disk JSON should retain text/tool/text order')
|
||||
assert.equal(diskNormalAssistant.claudeEntryId, 'mock-claude-entry',
|
||||
'disk JSON should retain the Claude fork point')
|
||||
assert.ok((diskNormalAssistant.tools?.[0].output?.length ?? 0) > 600,
|
||||
'disk JSON should retain output beyond the summary limit')
|
||||
assert.equal(diskNormalAssistant.tools?.[0].output, normalTool.output,
|
||||
'disk JSON should retain the complete tool output')
|
||||
assert.ok(diskNormalAssistant.tools?.[0].output?.endsWith('PERSISTED-OUTPUT-SENTINEL'))
|
||||
|
||||
const fallbackSession = (await invoke('session:create', 'fallback history')) as { id: string }
|
||||
const sessions = ipc.sessions as {
|
||||
appendMessage(sessionId: string, message: ChatMessage): Promise<void>
|
||||
}
|
||||
await sessions.appendMessage(fallbackSession.id, {
|
||||
id: 'fallback-user',
|
||||
role: 'user',
|
||||
content: 'old request',
|
||||
quotes: [{ id: 'saved-table', sourceId: 'table-source', kind: 'table', markdown: '| name | value |\n| --- | --- |\n| A | 42 |', preview: 'A 42' }],
|
||||
createdAt: Date.now(),
|
||||
status: 'done'
|
||||
await invoke('chat:send', { sessionId: normalSession.id, text: 'persist-second' })
|
||||
const secondMessages = await waitForMessages(invoke, normalSession.id,
|
||||
(messages) => messages.filter((message) => message.role === 'assistant').length === 2 &&
|
||||
assistant(messages).status === 'done')
|
||||
const secondUser = secondMessages.filter((message) => message.role === 'user').at(-1)
|
||||
assert.ok(secondUser)
|
||||
await invoke('chat:send', {
|
||||
sessionId: normalSession.id,
|
||||
text: 'persist-edited',
|
||||
fromMessageId: secondUser.id,
|
||||
action: 'edit',
|
||||
rollbackArtifacts: false
|
||||
})
|
||||
await sessions.appendMessage(fallbackSession.id, {
|
||||
id: 'fallback-assistant',
|
||||
role: 'assistant',
|
||||
content: 'old answer',
|
||||
parts: [{ type: 'text', text: 'old answer' }],
|
||||
createdAt: Date.now(),
|
||||
status: 'done'
|
||||
})
|
||||
state.useOmp = false
|
||||
await invoke('chat:send', { sessionId: fallbackSession.id, text: 'fallback-history' })
|
||||
const fallbackMessages = await waitForMessages(
|
||||
invoke,
|
||||
fallbackSession.id,
|
||||
(messages) => assistant(messages).content === 'fallback response' && assistant(messages).status === 'done'
|
||||
)
|
||||
assert.equal(assistant(fallbackMessages).content, 'fallback response')
|
||||
assert.deepEqual(state.fallbackHistory?.map((message) => message.content), ['old request', 'old answer'],
|
||||
'the fallback loop should receive previously persisted user and assistant messages')
|
||||
assert.equal(state.fallbackWireHistory?.[0].content, 'old request\n\n| name | value |\n| --- | --- |\n| A | 42 |',
|
||||
'the next fallback request must include the exact previously quoted table snapshot')
|
||||
state.useOmp = true
|
||||
await waitForMessages(invoke, normalSession.id,
|
||||
(messages) => messages.filter((message) => message.role === 'assistant').length === 2 &&
|
||||
assistant(messages).status === 'done')
|
||||
assert.equal(state.resumeAts.at(-1), normalAssistant.claudeEntryId,
|
||||
'editing a prior turn should fork at the last kept Claude chain entry')
|
||||
|
||||
const cancelSession = (await invoke('session:create', 'cancelled persistence')) as { id: string }
|
||||
const cancelledSend = (await invoke('chat:send', {
|
||||
@@ -298,7 +284,7 @@ async function main(): Promise<void> {
|
||||
text: 'persist-cancel'
|
||||
})) as { runId: string }
|
||||
for (let attempt = 0; attempt < 100 && !state.cancelOutputEmitted; attempt++) await wait(10)
|
||||
assert.equal(state.cancelOutputEmitted, true, 'fake OMP should emit a running tool and partial output')
|
||||
assert.equal(state.cancelOutputEmitted, true, 'the mock Claude kernel should emit a running tool and partial output')
|
||||
const streamingCheckpoint = await waitForDiskAssistant(state.userData, cancelSession.id, (message) =>
|
||||
message.status === 'streaming' &&
|
||||
message.content === 'cancel-before' &&
|
||||
@@ -331,7 +317,7 @@ async function main(): Promise<void> {
|
||||
assert.equal(diskCancelledAssistant.tools?.[0].status, 'cancelled', 'disk JSON should persist terminal tool status')
|
||||
assert.equal(diskCancelledAssistant.tools?.[0].output, 'cancelled tool output retained',
|
||||
'disk JSON should preserve output from a running tool cancelled mid-turn')
|
||||
assert.equal(state.promptCalls, 2)
|
||||
assert.equal(state.promptCalls, 4)
|
||||
assert.equal(state.abortCalls, 1)
|
||||
|
||||
console.log('chat-persistence-regression: all checks passed')
|
||||
|
||||
@@ -104,6 +104,7 @@ function installFakeWindow(send: (payload: { sessionId: string; text: string })
|
||||
},
|
||||
chat: {
|
||||
send,
|
||||
setSessionVisible: async () => ({ accepted: true }),
|
||||
cancel: async () => ({ cancelled: true }),
|
||||
revert: async () => ({ messages: [] }),
|
||||
activeRun: async (sessionId: string) => {
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { claudeAnthropicBaseUrl } from '../src/main/agent/claude/claude-endpoint'
|
||||
|
||||
assert.equal(claudeAnthropicBaseUrl('https://api.example.com'), 'https://api.example.com')
|
||||
assert.equal(claudeAnthropicBaseUrl('https://api.example.com/'), 'https://api.example.com')
|
||||
assert.equal(claudeAnthropicBaseUrl('https://api.example.com/v1'), 'https://api.example.com')
|
||||
assert.equal(claudeAnthropicBaseUrl('https://api.example.com/v1/messages'), 'https://api.example.com')
|
||||
assert.equal(claudeAnthropicBaseUrl('https://api.example.com/anthropic/v1'), 'https://api.example.com/anthropic')
|
||||
assert.equal(claudeAnthropicBaseUrl('http://gateway.local:8080/proxy/v1/'), 'http://gateway.local:8080/proxy')
|
||||
|
||||
console.log('{"result":"PASS","cases":6}')
|
||||
@@ -0,0 +1,315 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { createServer } from 'node:http'
|
||||
import { existsSync } from 'node:fs'
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import type { ChatEvent } from '../src/shared/types'
|
||||
import type { ResolvedModel } from '../src/shared/models'
|
||||
import type { InteractionBroker } from '../src/main/interaction-broker'
|
||||
import { ClaudeKernel } from '../src/main/agent/claude/claude-kernel'
|
||||
|
||||
interface CapturedRequest {
|
||||
url: string
|
||||
body: Record<string, unknown>
|
||||
userText: string
|
||||
hasToolResult: boolean
|
||||
}
|
||||
|
||||
const requests: CapturedRequest[] = []
|
||||
const events: ChatEvent[] = []
|
||||
const persistedSessionIds: string[] = []
|
||||
const temp = await mkdtemp(join(tmpdir(), 'ycode-claude-kernel-'))
|
||||
process.env.CLAUDE_TEST_USER_DATA = join(temp, 'user-data')
|
||||
let askToolIssued = false
|
||||
let planToolIssued = false
|
||||
let pendingToolCompletion: 'ask' | 'plan' | 'write' | null = null
|
||||
|
||||
function sse(res: import('node:http').ServerResponse, event: string, data: unknown): void {
|
||||
res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`)
|
||||
}
|
||||
|
||||
function messageStart(res: import('node:http').ServerResponse, id: string): void {
|
||||
sse(res, 'message_start', {
|
||||
type: 'message_start',
|
||||
message: {
|
||||
id, type: 'message', role: 'assistant', model: 'claude-mock',
|
||||
content: [], stop_reason: null, stop_sequence: null,
|
||||
usage: { input_tokens: 8, output_tokens: 1 }
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function messageEnd(res: import('node:http').ServerResponse, stopReason: string): void {
|
||||
sse(res, 'message_delta', {
|
||||
type: 'message_delta',
|
||||
delta: { stop_reason: stopReason, stop_sequence: null },
|
||||
usage: { output_tokens: 4 }
|
||||
})
|
||||
sse(res, 'message_stop', { type: 'message_stop' })
|
||||
res.end()
|
||||
}
|
||||
|
||||
function textBlock(res: import('node:http').ServerResponse, text: string, index = 0): void {
|
||||
sse(res, 'content_block_start', {
|
||||
type: 'content_block_start', index,
|
||||
content_block: { type: 'text', text: '' }
|
||||
})
|
||||
sse(res, 'content_block_delta', {
|
||||
type: 'content_block_delta', index,
|
||||
delta: { type: 'text_delta', text }
|
||||
})
|
||||
sse(res, 'content_block_stop', { type: 'content_block_stop', index })
|
||||
}
|
||||
|
||||
function toolBlock(
|
||||
res: import('node:http').ServerResponse,
|
||||
id: string,
|
||||
name: string,
|
||||
input: Record<string, unknown>,
|
||||
index: number
|
||||
): void {
|
||||
sse(res, 'content_block_start', {
|
||||
type: 'content_block_start', index,
|
||||
content_block: { type: 'tool_use', id, name, input: {} }
|
||||
})
|
||||
sse(res, 'content_block_delta', {
|
||||
type: 'content_block_delta', index,
|
||||
delta: { type: 'input_json_delta', partial_json: JSON.stringify(input) }
|
||||
})
|
||||
sse(res, 'content_block_stop', { type: 'content_block_stop', index })
|
||||
}
|
||||
|
||||
function plainText(res: import('node:http').ServerResponse, id: string, text: string): void {
|
||||
messageStart(res, id)
|
||||
textBlock(res, text)
|
||||
messageEnd(res, 'end_turn')
|
||||
}
|
||||
|
||||
const server = createServer((req, res) => {
|
||||
let raw = ''
|
||||
req.on('data', (chunk) => { raw += chunk })
|
||||
req.on('end', () => {
|
||||
let body: Record<string, unknown> = {}
|
||||
try { body = JSON.parse(raw || '{}') as Record<string, unknown> } catch { body = { raw } }
|
||||
const messages = Array.isArray(body.messages) ? body.messages as Array<Record<string, unknown>> : []
|
||||
const latest = messages.at(-1)
|
||||
const latestContent = JSON.stringify(latest?.content ?? '')
|
||||
const userText = messages
|
||||
.filter((message) => message.role === 'user')
|
||||
.map((message) => JSON.stringify(message.content))
|
||||
.join('\n')
|
||||
requests.push({ url: req.url ?? '', body, userText, hasToolResult: latestContent.includes('"tool_result"') })
|
||||
if (process.env.DEBUG_CLAUDE_KERNEL === '1') {
|
||||
console.error('[kernel-test] request', requests.length, req.url, {
|
||||
ask: userText.includes('ASK_SMOKE'),
|
||||
plan: userText.includes('PLAN_SMOKE'),
|
||||
resume: userText.includes('RESUME_SMOKE'),
|
||||
hasToolResult: requests.at(-1)?.hasToolResult
|
||||
})
|
||||
}
|
||||
|
||||
if (!req.url?.includes('/messages')) {
|
||||
res.writeHead(200, { 'content-type': 'application/json' })
|
||||
res.end('{}')
|
||||
return
|
||||
}
|
||||
res.writeHead(200, {
|
||||
'content-type': 'text/event-stream',
|
||||
'cache-control': 'no-cache',
|
||||
connection: 'keep-alive'
|
||||
})
|
||||
const id = `msg_kernel_${requests.length}`
|
||||
if (userText.includes('Write the title in the predominant language')) {
|
||||
plainText(res, id, 'Plan Smoke')
|
||||
return
|
||||
}
|
||||
if (userText.includes('RESUME_SMOKE')) {
|
||||
plainText(res, id, 'Resumed.')
|
||||
return
|
||||
}
|
||||
if (pendingToolCompletion === 'ask') {
|
||||
pendingToolCompletion = null
|
||||
plainText(res, id, 'Question answered.')
|
||||
return
|
||||
}
|
||||
if (pendingToolCompletion === 'plan') {
|
||||
pendingToolCompletion = 'write'
|
||||
messageStart(res, id)
|
||||
toolBlock(res, 'write_tool', 'Write', {
|
||||
file_path: join(temp, 'blocked.txt'),
|
||||
content: 'This must not be written in plan mode.'
|
||||
}, 0)
|
||||
messageEnd(res, 'tool_use')
|
||||
return
|
||||
}
|
||||
if (pendingToolCompletion === 'write') {
|
||||
pendingToolCompletion = null
|
||||
plainText(res, id, 'Plan ready.')
|
||||
return
|
||||
}
|
||||
if (userText.includes('ASK_SMOKE') && !askToolIssued) {
|
||||
askToolIssued = true
|
||||
pendingToolCompletion = 'ask'
|
||||
messageStart(res, id)
|
||||
toolBlock(res, 'ask_tool', 'AskUserQuestion', {
|
||||
questions: [{
|
||||
question: 'Which library?',
|
||||
header: 'Library',
|
||||
multiSelect: false,
|
||||
options: [
|
||||
{ label: 'Alpha', description: 'First option' },
|
||||
{ label: 'Beta', description: 'Second option' }
|
||||
]
|
||||
}]
|
||||
}, 0)
|
||||
messageEnd(res, 'tool_use')
|
||||
return
|
||||
}
|
||||
if (userText.includes('PLAN_SMOKE') && !planToolIssued) {
|
||||
planToolIssued = true
|
||||
pendingToolCompletion = 'plan'
|
||||
messageStart(res, id)
|
||||
textBlock(res, 'Plan:\n1. Inspect files\n2. Implement change')
|
||||
toolBlock(res, 'plan_tool', 'ExitPlanMode', {
|
||||
plan: 'Plan:\n1. Inspect files\n2. Implement change',
|
||||
planFilePath: join(temp, 'plan.md')
|
||||
}, 1)
|
||||
messageEnd(res, 'tool_use')
|
||||
return
|
||||
}
|
||||
plainText(res, id, 'Unexpected prompt.')
|
||||
})
|
||||
})
|
||||
|
||||
await new Promise<void>((resolveListen) => server.listen(0, '127.0.0.1', resolveListen))
|
||||
const address = server.address()
|
||||
if (!address || typeof address === 'string') throw new Error('mock server did not bind')
|
||||
|
||||
const model = {
|
||||
profile: {
|
||||
id: 'mock-model', name: 'Mock Claude', model: 'claude-mock', authMode: 'api-key',
|
||||
protocol: 'anthropic', contextWindowTokens: 200000
|
||||
},
|
||||
config: {
|
||||
api: 'anthropic-messages',
|
||||
baseUrl: `http://127.0.0.1:${address.port}/v1`,
|
||||
model: 'claude-mock',
|
||||
apiKey: 'ycode-test-key',
|
||||
authMode: 'api-key'
|
||||
}
|
||||
} as unknown as ResolvedModel
|
||||
|
||||
const questions: unknown[] = []
|
||||
const interactions = {
|
||||
async question(_toolCallId: string, _title: string, items: Array<{ id: string; question: string; options: unknown[] }>) {
|
||||
questions.push(items)
|
||||
return {
|
||||
kind: 'question' as const,
|
||||
answer: {
|
||||
questions: items.map((item) => ({ id: item.id, question: item.question, answers: ['Alpha'] }))
|
||||
}
|
||||
}
|
||||
},
|
||||
async approval() {
|
||||
throw new Error('approval was not expected in this regression')
|
||||
}
|
||||
} as unknown as InteractionBroker
|
||||
|
||||
function request(prompt: string, userMessageId: string, controller: AbortController) {
|
||||
return {
|
||||
runId: randomUUID(),
|
||||
userMessageId,
|
||||
prompt,
|
||||
emit: (event: ChatEvent) => events.push(event),
|
||||
interactions,
|
||||
signal: controller.signal
|
||||
}
|
||||
}
|
||||
|
||||
let kernel: ClaudeKernel | null = null
|
||||
let resumed: ClaudeKernel | null = null
|
||||
try {
|
||||
const sessionId = randomUUID()
|
||||
kernel = new ClaudeKernel({
|
||||
sessionId,
|
||||
workspaceRoot: temp,
|
||||
model,
|
||||
permissionMode: 'default-sandbox',
|
||||
agentMode: 'craft',
|
||||
thinkingLevel: 'off',
|
||||
forceNewSession: true,
|
||||
onSessionId: (id) => { persistedSessionIds.push(id) }
|
||||
})
|
||||
const first = await kernel.runTurn(request('ASK_SMOKE', randomUUID(), new AbortController()))
|
||||
if (process.env.DEBUG_CLAUDE_KERNEL === '1') console.error('[kernel-test] first complete')
|
||||
assert.equal(first.content, 'Question answered.')
|
||||
assert.equal(first.tools.length, 1)
|
||||
assert.equal(questions.length, 1)
|
||||
assert.deepEqual(
|
||||
(questions[0] as Array<{ options: Array<{ question: string; option: string; id: string }> }>)[0].options.map((option) => option.question),
|
||||
['Alpha', 'Beta']
|
||||
)
|
||||
assert.ok(persistedSessionIds.includes(kernel.sessionId), 'the initial Claude session id should be persisted')
|
||||
|
||||
// Simulate an abnormal process exit while keeping the same ClaudeKernel alive.
|
||||
const runningKernel = kernel as unknown as { instance: { close(): void } | null }
|
||||
runningKernel.instance?.close()
|
||||
for (let index = 0; index < 200 && runningKernel.instance; index++) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 10))
|
||||
}
|
||||
assert.equal(runningKernel.instance, null, 'Claude process should be released before restart')
|
||||
|
||||
await kernel.setPolicy('default-sandbox', 'plan')
|
||||
const second = await kernel.runTurn(request('PLAN_SMOKE', randomUUID(), new AbortController()))
|
||||
if (process.env.DEBUG_CLAUDE_KERNEL === '1') console.error('[kernel-test] second complete')
|
||||
assert.ok(second.content.endsWith('Plan ready.'))
|
||||
if (process.env.DEBUG_CLAUDE_KERNEL === '1') {
|
||||
console.error('[kernel-test] events', events.map((event) => event.type === 'part' ? `${event.type}:${event.part.type}` : event.type))
|
||||
console.error('[kernel-test] second tools', second.tools)
|
||||
}
|
||||
const plan = events.find((event) => event.type === 'part' && event.part.type === 'plan')
|
||||
assert.ok(plan && plan.type === 'part' && plan.part.type === 'plan')
|
||||
assert.deepEqual(plan.part.plan.steps.map((step) => step.text), ['Plan:', 'Inspect files', 'Implement change'])
|
||||
assert.equal(second.tools.find((tool) => tool.name === 'ExitPlanMode')?.status, 'error')
|
||||
assert.equal(second.tools.find((tool) => tool.name === 'Write')?.status, 'error')
|
||||
assert.equal(existsSync(join(temp, 'blocked.txt')), false)
|
||||
|
||||
const runtimeSessionId = kernel.sessionId
|
||||
assert.ok(runtimeSessionId)
|
||||
assert.ok(requests.some((item) => item.url.includes('/v1/messages')))
|
||||
assert.equal(requests.some((item) => item.url.includes('/v1/v1/')), false)
|
||||
|
||||
await kernel.close()
|
||||
kernel = null
|
||||
resumed = new ClaudeKernel({
|
||||
sessionId,
|
||||
workspaceRoot: temp,
|
||||
model,
|
||||
permissionMode: 'default-sandbox',
|
||||
agentMode: 'craft',
|
||||
thinkingLevel: 'off',
|
||||
resumeSessionId: runtimeSessionId,
|
||||
onSessionId: () => undefined
|
||||
})
|
||||
const third = await resumed.runTurn(request('RESUME_SMOKE', randomUUID(), new AbortController()))
|
||||
if (process.env.DEBUG_CLAUDE_KERNEL === '1') console.error('[kernel-test] third complete')
|
||||
assert.equal(third.content, 'Resumed.')
|
||||
const resumedRequest = [...requests].reverse().find((item) => item.userText.includes('RESUME_SMOKE'))
|
||||
assert.ok(resumedRequest)
|
||||
assert.ok(resumedRequest.userText.includes('ASK_SMOKE'))
|
||||
assert.ok(resumedRequest.userText.includes('PLAN_SMOKE'))
|
||||
console.log(JSON.stringify({
|
||||
result: 'PASS',
|
||||
requests: requests.length,
|
||||
events: events.length,
|
||||
turns: 3,
|
||||
basePath: requests.find((item) => item.url.includes('/v1/messages'))?.url
|
||||
}))
|
||||
} finally {
|
||||
await resumed?.close().catch(() => undefined)
|
||||
await kernel?.close().catch(() => undefined)
|
||||
await new Promise<void>((resolveClose) => server.close(() => resolveClose()))
|
||||
await rm(temp, { recursive: true, force: true })
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { createServer } from 'node:http'
|
||||
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { createSdkMcpServer, query, tool } from '@anthropic-ai/claude-agent-sdk'
|
||||
import { z } from 'zod'
|
||||
|
||||
const executable = resolve(process.env.YCODE_CLAUDE_EXE ?? 'node_modules/@anthropic-ai/claude-agent-sdk-win32-x64/claude.exe')
|
||||
const temp = await mkdtemp(join(tmpdir(), 'ycode-claude-smoke-'))
|
||||
const requests = []
|
||||
|
||||
function sse(res, event, data) {
|
||||
res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`)
|
||||
}
|
||||
|
||||
function textResponse(res, id, model, text) {
|
||||
sse(res, 'message_start', {
|
||||
type: 'message_start',
|
||||
message: {
|
||||
id, type: 'message', role: 'assistant', model,
|
||||
content: [], stop_reason: null, stop_sequence: null,
|
||||
usage: { input_tokens: 12, output_tokens: 1 }
|
||||
}
|
||||
})
|
||||
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 })
|
||||
sse(res, 'message_delta', {
|
||||
type: 'message_delta',
|
||||
delta: { stop_reason: 'end_turn', stop_sequence: null },
|
||||
usage: { output_tokens: 5 }
|
||||
})
|
||||
sse(res, 'message_stop', { type: 'message_stop' })
|
||||
res.end()
|
||||
}
|
||||
|
||||
const server = createServer((req, res) => {
|
||||
let raw = ''
|
||||
req.on('data', chunk => { raw += chunk })
|
||||
req.on('end', () => {
|
||||
let body
|
||||
try { body = JSON.parse(raw || '{}') } catch { body = { raw } }
|
||||
requests.push({ url: req.url, method: req.method, body })
|
||||
if (!req.url?.includes('/messages')) {
|
||||
res.writeHead(200, { 'content-type': 'application/json' })
|
||||
res.end(JSON.stringify({ data: [] }))
|
||||
return
|
||||
}
|
||||
res.writeHead(200, {
|
||||
'content-type': 'text/event-stream',
|
||||
'cache-control': 'no-cache',
|
||||
connection: 'keep-alive'
|
||||
})
|
||||
const id = `msg_smoke_${requests.length}`
|
||||
const latestUser = [...(body.messages ?? [])].reverse().find(message => message.role === 'user')
|
||||
const latestUserContent = JSON.stringify(latestUser?.content ?? [])
|
||||
if (process.env.DEBUG_CLAUDE_SMOKE === '1') console.error('smoke request', requests.length, latestUserContent.slice(0, 500))
|
||||
const hasToolResult = latestUserContent.includes('"tool_result"')
|
||||
const wantsRead = latestUserContent.includes('Read the smoke file.')
|
||||
if (hasToolResult) {
|
||||
textResponse(res, id, body.model ?? 'claude-smoke', 'Tool read complete.')
|
||||
return
|
||||
}
|
||||
if (wantsRead && !hasToolResult) {
|
||||
sse(res, 'message_start', {
|
||||
type: 'message_start',
|
||||
message: {
|
||||
id, type: 'message', role: 'assistant', model: body.model ?? 'claude-smoke',
|
||||
content: [], stop_reason: null, stop_sequence: null,
|
||||
usage: { input_tokens: 12, output_tokens: 1 }
|
||||
}
|
||||
})
|
||||
sse(res, 'content_block_start', {
|
||||
type: 'content_block_start', index: 0,
|
||||
content_block: { type: 'tool_use', id: 'tool_smoke', name: 'Read', input: {} }
|
||||
})
|
||||
sse(res, 'content_block_delta', {
|
||||
type: 'content_block_delta', index: 0,
|
||||
delta: { type: 'input_json_delta', partial_json: JSON.stringify({ file_path: join(temp, 'smoke.txt') }) }
|
||||
})
|
||||
sse(res, 'content_block_stop', { type: 'content_block_stop', index: 0 })
|
||||
sse(res, 'message_delta', {
|
||||
type: 'message_delta',
|
||||
delta: { stop_reason: 'tool_use', stop_sequence: null },
|
||||
usage: { output_tokens: 5 }
|
||||
})
|
||||
sse(res, 'message_stop', { type: 'message_stop' })
|
||||
res.end()
|
||||
return
|
||||
}
|
||||
textResponse(res, id, body.model ?? 'claude-smoke', 'Claude mock reply.')
|
||||
})
|
||||
})
|
||||
|
||||
await new Promise(resolveListen => server.listen(0, '127.0.0.1', resolveListen))
|
||||
const address = server.address()
|
||||
if (!address || typeof address === 'string') throw new Error('mock server did not bind')
|
||||
|
||||
const sessionId = crypto.randomUUID()
|
||||
let runtimeSessionId = sessionId
|
||||
let releaseSecondTurn
|
||||
const firstTurnComplete = new Promise(resolve => { releaseSecondTurn = resolve })
|
||||
const input = (async function* () {
|
||||
yield {
|
||||
type: 'user',
|
||||
message: { role: 'user', content: [{ type: 'text', text: 'Read the smoke file.' }] },
|
||||
parent_tool_use_id: null,
|
||||
uuid: crypto.randomUUID(),
|
||||
session_id: runtimeSessionId
|
||||
}
|
||||
await firstTurnComplete
|
||||
yield {
|
||||
type: 'user',
|
||||
message: { role: 'user', content: [{ type: 'text', text: 'Reply once more.' }] },
|
||||
parent_tool_use_id: null,
|
||||
uuid: crypto.randomUUID(),
|
||||
session_id: runtimeSessionId
|
||||
}
|
||||
})()
|
||||
|
||||
await writeFile(join(temp, 'smoke.txt'), 'SMOKE_FILE_CONTENTS', 'utf8')
|
||||
|
||||
const messages = []
|
||||
const results = []
|
||||
let instance
|
||||
try {
|
||||
instance = query({
|
||||
prompt: input,
|
||||
options: {
|
||||
cwd: temp,
|
||||
model: 'claude-smoke',
|
||||
pathToClaudeCodeExecutable: executable,
|
||||
env: {
|
||||
...process.env,
|
||||
ANTHROPIC_BASE_URL: `http://127.0.0.1:${address.port}`,
|
||||
ANTHROPIC_API_KEY: 'ycode-smoke-key',
|
||||
CLAUDE_CONFIG_DIR: join(temp, 'claude-config'),
|
||||
CLAUDE_CODE_ENABLE_TODO_TOOLS: '1',
|
||||
CLAUDE_CODE_ENABLE_TASKS: '0',
|
||||
...(process.platform === 'win32' ? { CLAUDE_CODE_USE_POWERSHELL_TOOL: '1' } : {})
|
||||
},
|
||||
includePartialMessages: true,
|
||||
permissionMode: 'default',
|
||||
permissionPrompts: 'host',
|
||||
settingSources: [],
|
||||
strictMcpConfig: true,
|
||||
tools: ['Read', 'Write', 'Edit', 'Glob', 'Grep', 'Bash', 'PowerShell', 'WebFetch', 'WebSearch',
|
||||
'TodoWrite', 'AskUserQuestion', 'EnterPlanMode', 'ExitPlanMode'],
|
||||
allowedTools: [],
|
||||
disallowedTools: ['Agent', 'Task', 'Skill', 'Workflow', 'CronCreate', 'CronDelete', 'CronList'],
|
||||
plugins: [],
|
||||
agents: {},
|
||||
skills: [],
|
||||
mcpServers: {
|
||||
ycode: createSdkMcpServer({
|
||||
name: 'ycode',
|
||||
tools: [tool('ping', 'Test host tool', { value: z.string() }, async ({ value }) => ({
|
||||
content: [{ type: 'text', text: value }]
|
||||
}))]
|
||||
})
|
||||
},
|
||||
canUseTool: async (toolName) => toolName === 'Read'
|
||||
? { behavior: 'allow', updatedInput: { file_path: join(temp, 'smoke.txt') } }
|
||||
: { behavior: 'deny', message: 'Only Read is enabled in this smoke test.' }
|
||||
}
|
||||
})
|
||||
for await (const message of instance) {
|
||||
messages.push(message)
|
||||
if (message.type === 'system' && message.subtype === 'init' && message.session_id) {
|
||||
runtimeSessionId = message.session_id
|
||||
}
|
||||
if (message.type === 'result') {
|
||||
results.push(message)
|
||||
if (results.length === 1) releaseSecondTurn()
|
||||
else break
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
instance?.close()
|
||||
await new Promise(resolveClose => server.close(resolveClose))
|
||||
await rm(temp, { recursive: true, force: true })
|
||||
}
|
||||
|
||||
const result = results.at(-1)
|
||||
const streamed = messages.filter(message => message.type === 'stream_event')
|
||||
.map(message => message.event)
|
||||
.filter(event => event?.type === 'content_block_delta' && event.delta?.type === 'text_delta')
|
||||
.map(event => event.delta.text)
|
||||
.join('')
|
||||
const toolResult = messages.find(message => message.type === 'user' &&
|
||||
Array.isArray(message.message?.content) &&
|
||||
message.message.content.some(block => block.type === 'tool_result'))
|
||||
|
||||
assert.ok(requests.some(request => request.url?.includes('/messages')), 'Claude Code should call the Anthropic Messages endpoint')
|
||||
assert.equal(results.length, 2, 'one process should serve two consecutive turns')
|
||||
assert.equal(new Set(results.map(message => message.session_id)).size, 1,
|
||||
'both turns should stay in the same Claude session')
|
||||
assert.equal(streamed, 'Tool read complete.Claude mock reply.')
|
||||
assert.ok(toolResult, 'the native Read tool result should return to Claude')
|
||||
assert.equal(result?.subtype, 'success')
|
||||
assert.equal(result?.result, 'Claude mock reply.')
|
||||
const init = messages.find(message => message.type === 'system' && message.subtype === 'init')
|
||||
assert.ok(init?.tools.includes('TodoWrite'), 'TodoWrite should be enabled')
|
||||
if (process.platform === 'win32') assert.ok(init?.tools.includes('PowerShell'), 'PowerShell should be enabled on Windows')
|
||||
assert.equal(init?.tools.some(tool => ['Agent', 'Task', 'Skill', 'Workflow', 'CronCreate'].includes(tool)), false)
|
||||
|
||||
console.log(JSON.stringify({ result: 'PASS', requests: requests.length, messages: messages.length, turns: results.length, text: result.result }))
|
||||
@@ -0,0 +1,29 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { planFromExitInput, questionsFromInput } from '../src/main/agent/claude/claude-tool-input'
|
||||
|
||||
const questions = questionsFromInput({
|
||||
questions: [{
|
||||
question: 'Which library?',
|
||||
header: 'Library',
|
||||
multiSelect: false,
|
||||
options: [
|
||||
{ label: 'First', description: 'First option' },
|
||||
{ label: 'Second', description: 'Second option' }
|
||||
]
|
||||
}]
|
||||
})
|
||||
assert.equal(questions.length, 1)
|
||||
assert.equal(questions[0].id, 'question-0')
|
||||
assert.deepEqual(questions[0].options[0], {
|
||||
id: 'First',
|
||||
label: 'First',
|
||||
question: 'First',
|
||||
option: 'First',
|
||||
description: 'First option'
|
||||
})
|
||||
|
||||
const plan = planFromExitInput({}, 'Plan:\n1. Inspect files\n2. Implement change')
|
||||
assert.deepEqual(plan.steps.map((step) => step.text), ['Plan:', 'Inspect files', 'Implement change'])
|
||||
assert.equal(plan.steps[0].status, 'pending')
|
||||
|
||||
console.log('{"result":"PASS","questions":1,"planSteps":3}')
|
||||
@@ -1,53 +0,0 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { EventEmitter } from 'node:events'
|
||||
import { mkdtemp, readFile, writeFile, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import { contextObserverSource, observeOmpContextSamples, prepareContextObserver } from '../src/main/omp-context-observer'
|
||||
import type { OmpKernel } from '../src/main/omp'
|
||||
|
||||
const directory = await mkdtemp(join(tmpdir(), 'ycode-observer-'))
|
||||
const previous = { config: process.env.YCODE_CONTEXT_REQUEST_FILE, sample: process.env.YCODE_CONTEXT_SAMPLE_FILE }
|
||||
let stop: (() => void) | undefined
|
||||
try {
|
||||
const paths = await prepareContextObserver(directory, 'session')
|
||||
process.env.YCODE_CONTEXT_REQUEST_FILE = paths.configPath
|
||||
process.env.YCODE_CONTEXT_SAMPLE_FILE = paths.samplePath
|
||||
await writeFile(paths.configPath, JSON.stringify({ runId: 'run', sessionPath: '/fixture.jsonl', modelId: 'selected', modelName: 'fixture', origins: [], toolCategories: {} }))
|
||||
const emitter = new EventEmitter(), frames: Array<{ type: string; breakdown?: { sequence: number } }> = []
|
||||
stop = observeOmpContextSamples(emitter as OmpKernel, paths.samplePath)
|
||||
emitter.on('event', frame => frames.push(frame))
|
||||
const extension = await import(pathToFileURL(paths.extensionPath).href)
|
||||
let hook: (event: unknown, ctx: unknown) => void = () => { throw Error('hook missing') }
|
||||
extension.default({ on(name: string, callback: typeof hook) { assert.equal(name, 'before_provider_request'); hook = callback } })
|
||||
const event = { payload: { messages: [{ role: 'user', content: 'POISON_NEVER_PERSIST_PROMPT' }], tools: [] } }
|
||||
const context = { sessionManager: { getSessionFile: () => '/fixture.jsonl' }, model: { id: 'fixture' } }
|
||||
const write = process.stdout.write
|
||||
let stdoutCalls = 0
|
||||
try { process.stdout.write = (() => { stdoutCalls++; return true }) as typeof process.stdout.write; hook(event, context) }
|
||||
finally { process.stdout.write = write }
|
||||
assert.equal(stdoutCalls, 0, 'observer must not write into the native JSONL channel')
|
||||
assert.ok(!contextObserverSource().includes('process.stdout'), 'extension cannot interleave its data with an image frame')
|
||||
const sample = await readFile(paths.samplePath, 'utf8')
|
||||
assert.ok(!sample.includes('POISON_NEVER_PERSIST_PROMPT'), 'only counts and identity leave the request observer')
|
||||
emitter.emit('event', { type: 'message_end' })
|
||||
assert.equal(frames[0].type, 'ycode_context_breakdown', 'sample is observed before request-end usage')
|
||||
assert.equal(frames[0].breakdown?.sequence, 1)
|
||||
emitter.emit('event', { type: 'message_end' })
|
||||
assert.equal(frames.filter(frame => frame.type === 'ycode_context_breakdown').length, 1, 'duplicate notification does not clear a newer provider total')
|
||||
hook(event, context)
|
||||
emitter.emit('event', { type: 'message_end' })
|
||||
assert.equal(frames.at(-2)?.breakdown?.sequence, 2, 'new prepared input precedes the next provider usage frame')
|
||||
stop(); stop = undefined
|
||||
const count = frames.length
|
||||
hook(event, context)
|
||||
emitter.emit('event', { type: 'message_end' })
|
||||
assert.equal(frames.length, count + 1, 'retired observers stop injecting sample frames')
|
||||
console.log('CONTEXT_OBSERVER_TRANSPORT_PASS 7 checks')
|
||||
} finally {
|
||||
stop?.()
|
||||
if (previous.config === undefined) delete process.env.YCODE_CONTEXT_REQUEST_FILE; else process.env.YCODE_CONTEXT_REQUEST_FILE = previous.config
|
||||
if (previous.sample === undefined) delete process.env.YCODE_CONTEXT_SAMPLE_FILE; else process.env.YCODE_CONTEXT_SAMPLE_FILE = previous.sample
|
||||
await rm(directory, { recursive: true, force: true })
|
||||
}
|
||||
@@ -1,110 +0,0 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { mkdtemp,writeFile,rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { createContextUsageService,recoverNativeUsage } from '../src/main/context-usage-service'
|
||||
import { estimateContextInput,contextInputOrigins } from '../src/main/context-usage-estimator'
|
||||
import { applyAssistantEvent } from '../src/shared/message-parts'
|
||||
import { contextInfoFromMessages } from '../src/shared/context-usage'
|
||||
import type { ChatMessage,ContextUsageBreakdown } from '../src/shared/types'
|
||||
import { Gateway } from '../src/main/gateway'
|
||||
|
||||
const lines=(entries:unknown[])=>entries.map(entry=>JSON.stringify(entry)).join('\n')
|
||||
const user={id:'native-user',type:'message',message:{role:'user',content:[{type:'text',text:'hello'}]}}
|
||||
const first={id:'native-a1',parentId:'native-user',type:'message',message:{role:'assistant',model:'fixture',usage:{input:50,output:10},stopReason:'toolUse',timestamp:100}}
|
||||
const last={id:'native-a2',parentId:'native-a1',type:'message',message:{role:'assistant',model:'fixture',usage:{input:4009,cacheRead:1152,output:125,totalTokens:5286},stopReason:'stop',timestamp:200}}
|
||||
const history=lines([user,first,last])
|
||||
assert.equal(recoverNativeUsage(history,'native-user')?.sample.usage.inputTokens,5161,'cache input belongs to input occupancy, not output')
|
||||
assert.equal(recoverNativeUsage(history,'native-user')?.sample.usage.totalTokens,5286)
|
||||
assert.equal(recoverNativeUsage(history,'native-user')?.sample.nativeEntryId,'native-a2','use the latest request, never the sum of tool rounds')
|
||||
assert.equal(recoverNativeUsage(lines([user,first,last,{...last,id:'sibling',parentId:'native-user'}]),'native-user'),undefined,'sibling histories cannot be guessed')
|
||||
assert.equal(recoverNativeUsage(lines([user,first,{...last,message:{...last.message,stopReason:'error'}}]),'native-user'),undefined,'a failed final request does not inherit earlier tool usage')
|
||||
|
||||
const directory=await mkdtemp(join(tmpdir(),'ycode-context-'))
|
||||
try {
|
||||
const path=join(directory,'native.jsonl')
|
||||
await writeFile(path,history)
|
||||
let messages:ChatMessage[]=[{id:'user',role:'user',content:'hello',createdAt:1,requestId:'run',nativeEntryId:'native-user',nativeSessionPath:path},
|
||||
{id:'assistant',role:'assistant',content:'done',createdAt:2,requestId:'run',modelId:'model',status:'done'}]
|
||||
let persisted=0,running=false
|
||||
let live:(()=>Promise<unknown>)|undefined
|
||||
const service=createContextUsageService({messages:async()=>messages,modelId:async()=> 'model',nativePath:()=>path,running:()=>running,
|
||||
liveState:()=>live?.(),persistIfCurrent:async(_id,assistantId,requestId,sample)=>{
|
||||
const current=messages.findLast(message=>message.role==='assistant')
|
||||
if(!running&¤t?.id===assistantId&¤t.requestId===requestId){current.contextUsage=sample;persisted++}
|
||||
}})
|
||||
const restored=await service.get('session')
|
||||
assert.equal(restored.sample?.usage.inputTokens,5161)
|
||||
assert.equal(restored.sample?.source,'native-history')
|
||||
assert.equal(restored.sample?.contextWindow,undefined,'do not invent a historical denominator from current settings')
|
||||
assert.equal(persisted,1)
|
||||
await service.get('session');assert.equal(persisted,1,'reading a cached sample does not rewrite history repeatedly')
|
||||
messages[1].contextUsage=undefined
|
||||
live=async()=>({success:true,data:{sessionFile:path,model:{id:'fixture'},contextUsage:{tokens:5161,contextWindow:64000}}})
|
||||
assert.equal((await service.get('session')).sample?.contextWindow,64000,'matching live native state can supply its window')
|
||||
messages[1].contextUsage=undefined
|
||||
messages[0].nativeSessionPath=join(directory,'outside.jsonl')
|
||||
assert.equal((await service.get('session')).sample,undefined,'a message cannot redirect the reader to an arbitrary file')
|
||||
messages[0].nativeSessionPath=path
|
||||
running=true
|
||||
assert.equal((await service.get('session')).sample,undefined,'querying a running turn must not recover a previous round')
|
||||
running=false
|
||||
let release!:(value:unknown)=>void
|
||||
live=()=>new Promise(resolve=>{release=resolve})
|
||||
const stale=service.get('session')
|
||||
while(!release)await new Promise(resolve=>setTimeout(resolve,1))
|
||||
messages=[...messages,{id:'new-user',role:'user',content:'next',createdAt:3},{id:'new-assistant',role:'assistant',content:'',createdAt:4,status:'streaming',requestId:'next'}]
|
||||
release({data:{sessionFile:path,model:{id:'fixture'},contextUsage:{tokens:5161,contextWindow:64000}}})
|
||||
assert.equal((await stale).sample,undefined,'a delayed query cannot overwrite a newer turn')
|
||||
assert.equal(persisted,2)
|
||||
assert.equal(contextInfoFromMessages(messages).sample,undefined,'no traversal past the latest assistant')
|
||||
}finally{await rm(directory,{recursive:true,force:true})}
|
||||
|
||||
const skill='<skill id="skill:test">skill instructions</skill>'
|
||||
const injected=`Project rules\n\n${skill}`
|
||||
const input={systemPrompt:'Base system instructions',origins:contextInputOrigins([injected]),
|
||||
tools:[{type:'function',function:{name:'unused_tool',description:'This declaration is present without a call',parameters:{type:'object'}}},
|
||||
{name:'mcp_fixture_lookup',description:'MCP lookup',parameters:{type:'object'}}],
|
||||
messages:[{role:'user',content:`Question\n\n${injected}`},
|
||||
{role:'assistant',tool_calls:[{id:'call',function:{name:'mcp_fixture_lookup',arguments:'{}'}}]},
|
||||
{role:'tool',tool_call_id:'call',content:'returned MCP contents'}]}
|
||||
const counted=estimateContextInput(input)
|
||||
assert.ok(counted.counts.system>0&&counted.counts.skills>0&&counted.counts.tools>0&&counted.counts.conversation>0&&counted.counts.mcp>0)
|
||||
assert.equal(Object.values(counted.counts).reduce((a,b)=>a+b,0),counted.inputTokens)
|
||||
assert.equal(estimateContextInput({messages:[{role:'user',content:skill}]}).counts.skills,0,'ordinary user text is not relabeled by a tag pattern')
|
||||
assert.equal(estimateContextInput({messages:[{role:'user',content:[{type:'image_url',image_url:{url:'data:image/png;base64,secret'}}]}]}).partial,true,'images are partial, not base64 tokens')
|
||||
const breakdown:ContextUsageBreakdown={...counted,requestId:'run',sequence:1,sampledAt:1,method:'utf8-estimate',scope:'provider-request'}
|
||||
let assistant:ChatMessage={id:'a',role:'assistant',content:'',createdAt:0,requestId:'run'}
|
||||
assistant=applyAssistantEvent(assistant,{runId:'run',type:'context_breakdown',breakdown})
|
||||
assistant=applyAssistantEvent(assistant,{runId:'run',type:'usage',usage:{inputTokens:9999,outputTokens:1,totalTokens:10000}})
|
||||
assert.equal(assistant.contextUsage?.usage.inputTokens,9999)
|
||||
assert.equal(assistant.contextBreakdown?.inputTokens,counted.inputTokens,'classification is not rescaled to provider usage')
|
||||
assistant=applyAssistantEvent(assistant,{runId:'run',type:'context_breakdown',breakdown})
|
||||
assert.equal(assistant.contextUsage?.usage.inputTokens,9999,'a repeated old observer frame cannot clear newer usage')
|
||||
assistant=applyAssistantEvent(assistant,{runId:'run',type:'context_breakdown',breakdown:{...breakdown,sequence:2}})
|
||||
assert.equal(assistant.contextUsage,undefined,'a new prepared request invalidates the previous provider total')
|
||||
assert.equal(contextInfoFromMessages([{...assistant,contextBreakdown:breakdown},{id:'pending',role:'user',content:'next',createdAt:5}]).status,'previous',
|
||||
'an unmatched latest user message marks any retained statistic as previous')
|
||||
assert.equal(contextInfoFromMessages([{...assistant,requestId:'different',contextBreakdown:breakdown}]).breakdown,undefined,
|
||||
'a stored classification for a different request is not reused')
|
||||
|
||||
// The fallback observes the final body for both supported provider protocols.
|
||||
const originalFetch=globalThis.fetch
|
||||
try {
|
||||
for(const responses of [false,true]) {
|
||||
let observed:Record<string,unknown>|undefined,transmitted:Record<string,unknown>|undefined
|
||||
globalThis.fetch=(async(_url,options)=>{
|
||||
transmitted=JSON.parse(String(options?.body))
|
||||
const payload=responses
|
||||
? 'data: '+JSON.stringify({type:'response.completed',response:{id:'reply',status:'completed',output:[],usage:{input_tokens:10,output_tokens:1,total_tokens:11}}})+'\n\n'
|
||||
: 'data: '+JSON.stringify({choices:[{index:0,delta:{content:'done'},finish_reason:'stop'}],usage:{prompt_tokens:10,completion_tokens:1,total_tokens:11}})+'\n\ndata: [DONE]\n\n'
|
||||
return new Response(payload,{status:200,headers:{'content-type':'text/event-stream'}})
|
||||
}) as typeof fetch
|
||||
const gateway=new Gateway({baseUrl:responses?'http://127.0.0.1:1/v1/responses':'http://127.0.0.1:1/v1',apiKey:'',authMode:'none',model:'fixture',temperature:0,maxToolRounds:1},body=>{observed=structuredClone(body)})
|
||||
for await(const _delta of gateway.stream({messages:[{role:'system',content:'system'},{role:'user',content:'hello'}],tools:[{type:'function',function:{name:'unused',description:'definition',parameters:{type:'object'}}}]})) {/* fully consume */}
|
||||
assert.deepEqual(observed,transmitted,'observer sees the exact transmitted body without changing it')
|
||||
const body=observed as any
|
||||
assert.ok(estimateContextInput({messages:body.messages??body.input,systemPrompt:body.instructions,tools:body.tools}).counts.tools>0)
|
||||
}
|
||||
}finally{globalThis.fetch=originalFetch}
|
||||
console.log('context-usage-regression: all checks passed')
|
||||
@@ -1,931 +0,0 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { testTerminalHostPlugin } from './test-terminal-host-plugin'
|
||||
import { createServer } from 'node:http'
|
||||
import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import { build } from 'esbuild'
|
||||
import type { ChatMessage, ToolCallRecord } from '../src/shared/types'
|
||||
|
||||
interface TimelineEntry {
|
||||
kind: 'branch' | 'prompt'
|
||||
sessionId: string
|
||||
entryId?: string
|
||||
text?: string
|
||||
}
|
||||
|
||||
interface TestState {
|
||||
userData: string
|
||||
home: string
|
||||
handlers: Map<string, (...args: unknown[]) => unknown>
|
||||
dialogPaths: string[]
|
||||
nativeMessages: Map<string, Array<{ entryId: string; text: string }>>
|
||||
failBranchIds: Set<string>
|
||||
timeline: TimelineEntry[]
|
||||
pendingPrompts: Array<{ sessionId: string; complete: () => void }>
|
||||
holdSessions: Set<string>
|
||||
activePrompts: number
|
||||
maxActivePrompts: number
|
||||
nextNativeId: number
|
||||
speechStatus: number
|
||||
openPathError?: string
|
||||
failStartTurn?: boolean
|
||||
nativeFailurePath?: string
|
||||
speechRequests: Array<{ path: string; authorization: string | undefined; contentType: string | undefined; body: string }>
|
||||
events: unknown[]
|
||||
}
|
||||
|
||||
interface TestBundle {
|
||||
registerIpc(getWindow: () => unknown): void
|
||||
disposeOmpKernels(): Promise<void>
|
||||
sessions: {
|
||||
appendMessage(sessionId: string, message: ChatMessage): Promise<void>
|
||||
getMessages(sessionId: string): Promise<ChatMessage[]>
|
||||
getPendingNativeBranch(sessionId: string): Promise<string | undefined>
|
||||
setPendingNativeBranch(sessionId: string, entryId: string | undefined): Promise<void>
|
||||
}
|
||||
attachments: {
|
||||
getAttachment(id: string): Promise<{ path?: string; name: string }>
|
||||
}
|
||||
}
|
||||
|
||||
const TEST_STATE_KEY = '__YCODE_CONVERSATION_IPC_REGRESSION__'
|
||||
|
||||
function wait(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
async function waitUntil(predicate: () => boolean | Promise<boolean>, message: string): Promise<void> {
|
||||
for (let attempt = 0; attempt < 250; attempt++) {
|
||||
if (await predicate()) return
|
||||
await wait(10)
|
||||
}
|
||||
assert.fail(`Timed out: ${message}`)
|
||||
}
|
||||
|
||||
async function waitForAssistant(
|
||||
invoke: (channel: string, ...args: unknown[]) => Promise<unknown>,
|
||||
sessionId: string,
|
||||
predicate: (message: ChatMessage) => boolean = (message) => message.status === 'done'
|
||||
): Promise<ChatMessage[]> {
|
||||
for (let attempt = 0; attempt < 250; attempt++) {
|
||||
const messages = await invoke('session:messages', sessionId) as ChatMessage[]
|
||||
const last = messages.findLast((message) => message.role === 'assistant')
|
||||
// In-memory messages can reach the terminal status before the atomic write
|
||||
// and terminal broadcast finish. Wait for the real turn barrier as well.
|
||||
if (last && predicate(last) && !await invoke('chat:active-run', sessionId)) return messages
|
||||
await wait(10)
|
||||
}
|
||||
const messages = await invoke('session:messages', sessionId) as ChatMessage[]
|
||||
assert.fail(`Timed out waiting for assistant state: ${JSON.stringify(messages.at(-1))}`)
|
||||
}
|
||||
|
||||
function assistant(messages: ChatMessage[]): ChatMessage {
|
||||
const found = messages.findLast((message) => message.role === 'assistant')
|
||||
assert.ok(found, 'expected an assistant message')
|
||||
return found
|
||||
}
|
||||
|
||||
function invokeFor(state: TestState) {
|
||||
return (channel: string, ...args: unknown[]): Promise<unknown> => {
|
||||
const handler = state.handlers.get(channel)
|
||||
if (!handler) throw new Error(`Missing IPC handler: ${channel}`)
|
||||
return Promise.resolve().then(() => handler({}, ...args))
|
||||
}
|
||||
}
|
||||
|
||||
function message(id: string, role: 'user' | 'assistant', content: string, extras: Partial<ChatMessage> = {}): ChatMessage {
|
||||
return { id, role, content, createdAt: Date.now(), status: 'done', ...extras }
|
||||
}
|
||||
|
||||
function seedNativeHistory(state: TestState, sessionId: string, entries: Array<[string, string]>): void {
|
||||
state.nativeMessages.set(sessionId, entries.map(([entryId, text]) => ({ entryId, text })))
|
||||
}
|
||||
|
||||
function nativePath(userData: string, sessionId: string): string {
|
||||
return join(userData, 'omp-sessions', `session-${encodeURIComponent(sessionId)}.jsonl`)
|
||||
}
|
||||
|
||||
async function buildBundle(tempRoot: string, state: TestState, name: string): Promise<TestBundle> {
|
||||
const ipcEntry = join(process.cwd(), 'src', 'main', 'ipc.ts')
|
||||
const sessionsEntry = join(process.cwd(), 'src', 'main', 'sessions.ts')
|
||||
const attachmentsEntry = join(process.cwd(), 'src', 'main', 'attachments.ts')
|
||||
const outfile = join(tempRoot, `${name}.mjs`)
|
||||
const stateKey = JSON.stringify(TEST_STATE_KEY)
|
||||
await build({
|
||||
stdin: {
|
||||
contents: [
|
||||
`export { registerIpc, disposeOmpKernels } from ${JSON.stringify(ipcEntry)};`,
|
||||
`export * as sessions from ${JSON.stringify(sessionsEntry)};`,
|
||||
`export * as attachments from ${JSON.stringify(attachmentsEntry)};`
|
||||
].join('\n'),
|
||||
resolveDir: process.cwd(),
|
||||
sourcefile: `${name}.ts`,
|
||||
loader: 'ts'
|
||||
},
|
||||
outfile,
|
||||
bundle: true,
|
||||
external: ['node-pty'],
|
||||
platform: 'node',
|
||||
format: 'esm',
|
||||
banner: { js: `import { createRequire as createTestRequire } from 'node:module'; const require = createTestRequire(import.meta.url);` },
|
||||
logLevel: 'silent',
|
||||
plugins: [
|
||||
testTerminalHostPlugin,
|
||||
{
|
||||
name: 'conversation-ipc-electron-and-omp-mocks',
|
||||
setup(pluginBuild) {
|
||||
pluginBuild.onResolve({ filter: /^\.\/sessions$/ }, () => ({ path: 'sessions', namespace: 'mock-session-start' }))
|
||||
pluginBuild.onResolve({ filter: /sessions\.ts$/, namespace: 'mock-session-start' }, () => ({ path: sessionsEntry, namespace: 'file' }))
|
||||
pluginBuild.onLoad({ filter: /.*/, namespace: 'mock-session-start' }, () => ({
|
||||
loader: 'js', contents: `
|
||||
import * as real from ${JSON.stringify(sessionsEntry)};
|
||||
export * from ${JSON.stringify(sessionsEntry)};
|
||||
export async function startTurn(...args) {
|
||||
const state = globalThis[${stateKey}];
|
||||
if (state.failStartTurn) {
|
||||
state.failStartTurn = false;
|
||||
if (state.nativeFailurePath) await (await import('node:fs/promises')).writeFile(state.nativeFailurePath, 'changed-native-leaf');
|
||||
throw new Error('mock turn persistence failure');
|
||||
}
|
||||
return real.startTurn(...args);
|
||||
}
|
||||
`
|
||||
}))
|
||||
pluginBuild.onResolve({ filter: /^electron$/ }, () => ({ path: 'electron', namespace: 'mock-electron' }))
|
||||
pluginBuild.onLoad({ filter: /.*/, namespace: 'mock-electron' }, () => ({
|
||||
loader: 'js',
|
||||
contents: `
|
||||
const state = globalThis[${stateKey}];
|
||||
export const app = {
|
||||
getPath: (name) => name === 'userData' ? state.userData : name === 'home' ? state.home : state.userData,
|
||||
getName: () => 'YCode', getVersion: () => '0.1.0',
|
||||
isPackaged: false,
|
||||
isReady: () => true,
|
||||
getAppPath: () => state.userData,
|
||||
getLoginItemSettings: () => ({ openAtLogin: false }),
|
||||
setLoginItemSettings: () => {}
|
||||
};
|
||||
export const safeStorage = {
|
||||
isEncryptionAvailable: () => false,
|
||||
encryptString: (value) => Buffer.from(value),
|
||||
decryptString: (value) => value.toString()
|
||||
};
|
||||
export const ipcMain = { handle: (name, handler) => state.handlers.set(name, handler) };
|
||||
export const dialog = {
|
||||
showOpenDialog: async (...args) => ({ canceled: false, filePaths: state.dialogPaths.splice(0) }),
|
||||
showSaveDialog: async () => ({ canceled: true, filePath: undefined })
|
||||
};
|
||||
export const shell = { openPath: async () => state.openPathError || '', openExternal: async () => {} };
|
||||
export const powerSaveBlocker = {
|
||||
start: () => { state.powerBlockers = (state.powerBlockers ?? 0) + 1; return state.powerBlockers; },
|
||||
stop: () => true,
|
||||
isStarted: () => (state.powerBlockers ?? 0) > 0
|
||||
};
|
||||
export class BrowserWindow {}
|
||||
export class WebContentsView {}
|
||||
`
|
||||
}))
|
||||
pluginBuild.onResolve({ filter: /^\.\/omp$/ }, () => ({ path: 'omp', namespace: 'mock-omp' }))
|
||||
pluginBuild.onLoad({ filter: /.*/, namespace: 'mock-omp' }, () => ({
|
||||
loader: 'js',
|
||||
contents: `
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { basename } from 'node:path';
|
||||
const state = globalThis[${stateKey}];
|
||||
export class OmpKernel extends EventEmitter {
|
||||
constructor(_exe, _workspace, _profile, sessionPath) {
|
||||
super();
|
||||
this.sessionId = decodeURIComponent(basename(sessionPath).replace(/^session-/, '').replace(/\\.jsonl$/, ''));
|
||||
if (!state.nativeMessages.has(this.sessionId)) state.nativeMessages.set(this.sessionId, []);
|
||||
}
|
||||
setHostTools() { return Promise.resolve({ type: 'response', success: true, data: { toolNames: [] } }); }
|
||||
setThinkingLevel() { return Promise.resolve(); }
|
||||
send(command) {
|
||||
if (command.type === 'get_branch_messages') {
|
||||
return Promise.resolve({ type: 'response', success: true, data: { messages: [...(state.nativeMessages.get(this.sessionId) || [])] } });
|
||||
}
|
||||
if (command.type === 'branch') {
|
||||
const entryId = String(command.entryId || '');
|
||||
state.timeline.push({ kind: 'branch', sessionId: this.sessionId, entryId });
|
||||
if (state.failBranchIds.has(entryId)) return Promise.resolve({ type: 'response', success: false, error: 'mock branch failure' });
|
||||
const entries = state.nativeMessages.get(this.sessionId) || [];
|
||||
const index = entries.findIndex((entry) => entry.entryId === entryId);
|
||||
if (index < 0) return Promise.resolve({ type: 'response', success: false, error: 'Invalid entry ID for branching' });
|
||||
state.nativeMessages.set(this.sessionId, entries.slice(0, index));
|
||||
}
|
||||
return Promise.resolve({ type: 'response', success: true, data: {} });
|
||||
}
|
||||
branch(entryId) { return this.send({ type: 'branch', entryId }); }
|
||||
prompt(text) {
|
||||
state.timeline.push({ kind: 'prompt', sessionId: this.sessionId, text });
|
||||
const entryId = 'native-prompt-' + (++state.nextNativeId);
|
||||
const entries = state.nativeMessages.get(this.sessionId) || [];
|
||||
entries.push({ entryId, text });
|
||||
state.nativeMessages.set(this.sessionId, entries);
|
||||
state.activePrompts++;
|
||||
state.maxActivePrompts = Math.max(state.maxActivePrompts, state.activePrompts);
|
||||
const complete = () => {
|
||||
state.activePrompts--;
|
||||
this.emit('event', { type: 'message_update', assistantMessageEvent: { type: 'text_delta', delta: 'reply:' + text } });
|
||||
this.emit('event', { type: 'agent_end', yielded: true });
|
||||
};
|
||||
if (state.holdSessions.has(this.sessionId)) state.pendingPrompts.push({ sessionId: this.sessionId, complete });
|
||||
else setImmediate(complete);
|
||||
return Promise.resolve({ type: 'response', success: true });
|
||||
}
|
||||
abort() {
|
||||
setImmediate(() => this.emit('event', { type: 'prompt_result', status: 'aborted' }));
|
||||
return Promise.resolve({ type: 'response', success: true });
|
||||
}
|
||||
respondToHostTool() { return Promise.resolve(); }
|
||||
dispose() { return Promise.resolve(); }
|
||||
}
|
||||
export const detectOmpPath = () => 'mock-omp';
|
||||
export const getOmpInfo = () => ({ available: true, exePath: 'mock-omp', profile: 'ycode', model: 'ycode/test' });
|
||||
`
|
||||
}))
|
||||
pluginBuild.onResolve({ filter: /^\.\/agent\/loop$/ }, () => ({ path: 'agent-loop', namespace: 'mock-agent-loop' }))
|
||||
pluginBuild.onLoad({ filter: /.*/, namespace: 'mock-agent-loop' }, () => ({
|
||||
loader: 'js',
|
||||
contents: `export async function runTurn() { throw new Error('unexpected non-OMP fallback'); }`
|
||||
}))
|
||||
pluginBuild.onResolve({ filter: /^\.\/gateway$/ }, () => ({ path: 'gateway', namespace: 'mock-gateway' }))
|
||||
pluginBuild.onLoad({ filter: /.*/, namespace: 'mock-gateway' }, () => ({
|
||||
loader: 'js', contents: 'export class Gateway { constructor() {} }'
|
||||
}))
|
||||
}
|
||||
}
|
||||
]
|
||||
})
|
||||
return await import(pathToFileURL(outfile).href) as TestBundle
|
||||
}
|
||||
|
||||
function register(bundle: TestBundle, state: TestState): (channel: string, ...args: unknown[]) => Promise<unknown> {
|
||||
state.handlers.clear()
|
||||
const mainWindow = {
|
||||
isDestroyed: () => false,
|
||||
webContents: { isDestroyed: () => false, send: (channel: string, event: unknown) => { if (channel === 'chat:event') state.events.push(event) } }
|
||||
}
|
||||
bundle.registerIpc(() => mainWindow)
|
||||
return invokeFor(state)
|
||||
}
|
||||
|
||||
async function testConcurrency(bundle: TestBundle, state: TestState, invoke: ReturnType<typeof invokeFor>): Promise<void> {
|
||||
const first = await invoke('session:create', 'concurrent one') as { id: string }
|
||||
const second = await invoke('session:create', 'concurrent two') as { id: string }
|
||||
state.holdSessions.add(first.id)
|
||||
state.holdSessions.add(second.id)
|
||||
|
||||
const firstSend = await invoke('chat:send', { sessionId: first.id, text: 'concurrent-first' }) as {
|
||||
modelId?: string
|
||||
modelName?: string
|
||||
}
|
||||
await assert.rejects(invoke('chat:send', { sessionId: first.id, text: 'must-not-append' }), /正在运行/)
|
||||
await invoke('chat:send', { sessionId: second.id, text: 'concurrent-second' })
|
||||
|
||||
await waitUntil(() => state.pendingPrompts.filter((item) => item.sessionId === first.id || item.sessionId === second.id).length === 2,
|
||||
'both sessions should reach OMP concurrently')
|
||||
const firstMessages = await invoke('session:messages', first.id) as ChatMessage[]
|
||||
assert.equal(firstSend.modelId, assistant(firstMessages).modelId,
|
||||
'chat:send returns the frozen model ID used by the persisted assistant message')
|
||||
assert.equal(firstSend.modelName, assistant(firstMessages).modelName,
|
||||
'chat:send returns the frozen model name used by the persisted assistant message')
|
||||
assert.deepEqual(firstMessages.filter((item) => item.role === 'user').map((item) => item.content), ['concurrent-first'],
|
||||
'a duplicate same-session send must not append a second user message')
|
||||
assert.ok(state.maxActivePrompts >= 2, 'different sessions should be able to run in parallel')
|
||||
|
||||
const held = state.pendingPrompts.splice(0)
|
||||
state.holdSessions.clear()
|
||||
held.forEach((item) => item.complete())
|
||||
const doneFirst = await waitForAssistant(invoke, first.id)
|
||||
const doneSecond = await waitForAssistant(invoke, second.id)
|
||||
assert.equal(assistant(doneFirst).status, 'done')
|
||||
assert.equal(assistant(doneSecond).status, 'done')
|
||||
const metadata = (await invoke('session:list')) as Array<{ id: string; title: string }>
|
||||
assert.equal(metadata.find((item) => item.id === first.id)?.title, 'concurrent-first', 'first send should set its title')
|
||||
}
|
||||
|
||||
async function seedActionHistory(
|
||||
bundle: TestBundle,
|
||||
state: TestState,
|
||||
action: 'edit' | 'resend' | 'regenerate'
|
||||
): Promise<{ sessionId: string; targetUserId: string; targetAssistantId: string }> {
|
||||
const invoke = invokeFor(state)
|
||||
const session = await invoke('session:create', `action-${action}`) as { id: string }
|
||||
const targetUserId = `${action}-user-target`
|
||||
const targetAssistantId = `${action}-assistant-target`
|
||||
const history = [
|
||||
message(`${action}-user-before`, 'user', 'first prompt', { nativeEntryId: `${action}-native-before` }),
|
||||
message(`${action}-assistant-before`, 'assistant', 'first reply'),
|
||||
message(targetUserId, 'user', 'old prompt', { nativeEntryId: `${action}-native-target` }),
|
||||
message(targetAssistantId, 'assistant', 'old reply')
|
||||
]
|
||||
for (const item of history) await bundle.sessions.appendMessage(session.id, item)
|
||||
seedNativeHistory(state, session.id, [
|
||||
[`${action}-native-before`, 'first prompt'],
|
||||
[`${action}-native-target`, 'old prompt']
|
||||
])
|
||||
return { sessionId: session.id, targetUserId, targetAssistantId }
|
||||
}
|
||||
|
||||
async function testActionBranch(
|
||||
bundle: TestBundle,
|
||||
state: TestState,
|
||||
action: 'edit' | 'resend' | 'regenerate'
|
||||
): Promise<void> {
|
||||
const invoke = invokeFor(state)
|
||||
const seeded = await seedActionHistory(bundle, state, action)
|
||||
const timelineStart = state.timeline.length
|
||||
const fromMessageId = action === 'regenerate' ? seeded.targetAssistantId : seeded.targetUserId
|
||||
await invoke('chat:send', {
|
||||
sessionId: seeded.sessionId,
|
||||
text: `${action}-replacement`,
|
||||
action,
|
||||
fromMessageId
|
||||
})
|
||||
const messages = await waitForAssistant(invoke, seeded.sessionId)
|
||||
const activity = state.timeline.slice(timelineStart).filter((item) => item.sessionId === seeded.sessionId)
|
||||
const branchIndex = activity.findIndex((item) => item.kind === 'branch' && item.entryId === `${action}-native-target`)
|
||||
const promptIndex = activity.findIndex((item) => item.kind === 'prompt' && item.text?.split(/\r?\n/, 1)[0] === `${action}-replacement`)
|
||||
assert.ok(branchIndex >= 0 && promptIndex > branchIndex, `${action} must branch by native entryId before prompting`)
|
||||
assert.equal(activity[promptIndex].text, [`${action}-replacement`, messages[2].capabilityContext].filter(Boolean).join('\n\n'),
|
||||
'native prompt must include the exact persisted turn context without changing the user text')
|
||||
assert.match(activity[promptIndex].text ?? '', /<ycode-agent-mode mode="craft">/, 'native prompt declares the frozen craft mode')
|
||||
assert.equal(messages.length, 4, `${action} should keep the old prefix and replace the selected suffix`)
|
||||
assert.deepEqual(messages.slice(0, 2).map((item) => item.content), ['first prompt', 'first reply'])
|
||||
assert.equal(messages[2].content, `${action}-replacement`)
|
||||
assert.ok(messages[2].nativeEntryId?.startsWith('native-prompt-'), 'new user message should receive the returned native entryId')
|
||||
assert.equal(await bundle.sessions.getPendingNativeBranch(seeded.sessionId), undefined,
|
||||
'successful native entry lookup should clear the pending branch marker')
|
||||
|
||||
const archiveDirectory = join(state.userData, 'session-branches', encodeURIComponent(seeded.sessionId))
|
||||
const archiveFiles = await readdir(archiveDirectory)
|
||||
assert.equal(archiveFiles.length, 1, `${action} should archive the replaced branch`)
|
||||
const archive = JSON.parse(await readFile(join(archiveDirectory, archiveFiles[0]), 'utf8')) as {
|
||||
reason: string
|
||||
session: { messages: ChatMessage[] }
|
||||
}
|
||||
assert.equal(archive.reason, action)
|
||||
assert.equal(archive.session.messages.length, 4, 'archive should preserve the full pre-action conversation')
|
||||
}
|
||||
|
||||
async function testActionBranchFailure(bundle: TestBundle, state: TestState): Promise<void> {
|
||||
const invoke = invokeFor(state)
|
||||
const seeded = await seedActionHistory(bundle, state, 'edit')
|
||||
const before = await bundle.sessions.getMessages(seeded.sessionId)
|
||||
state.failBranchIds.add('edit-native-target')
|
||||
await assert.rejects(invoke('chat:send', {
|
||||
sessionId: seeded.sessionId,
|
||||
text: 'must-not-append-on-branch-failure',
|
||||
action: 'edit',
|
||||
fromMessageId: seeded.targetUserId
|
||||
}), /branch failure/)
|
||||
state.failBranchIds.delete('edit-native-target')
|
||||
assert.deepEqual(await bundle.sessions.getMessages(seeded.sessionId), before,
|
||||
'failed native branching must not archive or append a replacement user message')
|
||||
assert.equal(await bundle.sessions.getPendingNativeBranch(seeded.sessionId), undefined,
|
||||
'failed action branch should restore the prior marker')
|
||||
}
|
||||
|
||||
async function testEditArtifactRollback(bundle: TestBundle, state: TestState, workspace: string): Promise<void> {
|
||||
const invoke = invokeFor(state)
|
||||
// 有产物:编辑提交先回退文件,再从该消息处重新开始对话(原版 userMessage.edit.hint)。
|
||||
const rolledPath = join(workspace, 'edit-rollback.txt')
|
||||
const rolledSession = await seedWriteHistory(bundle, state, 'edit-rollback', rolledPath, 'edit-rollback-native-target', 'after')
|
||||
await invoke('chat:send', { sessionId: rolledSession, text: 'edited prompt', action: 'edit', fromMessageId: 'edit-rollback-user' })
|
||||
const rolledMessages = await waitForAssistant(invoke, rolledSession)
|
||||
assert.equal(await readFile(rolledPath, 'utf8'), 'before',
|
||||
'edit submit must roll the artifact back to its version before the message')
|
||||
assert.equal(rolledMessages.length, 2, 'edit keeps the prefix before the edited message and appends the replacement')
|
||||
assert.equal(rolledMessages[0].content, 'edited prompt')
|
||||
assert.ok(rolledMessages[0].nativeEntryId?.startsWith('native-prompt-'), 'replacement turn should receive the new native entryId')
|
||||
|
||||
// 分支失败:撤销文件回退,保持会话不变。
|
||||
const failedPath = join(workspace, 'edit-rollback-branch-failure.txt')
|
||||
const failedSession = await seedWriteHistory(bundle, state, 'edit-rollback-failed', failedPath, 'edit-rollback-failed-native', 'after')
|
||||
const failedBefore = await bundle.sessions.getMessages(failedSession)
|
||||
state.failBranchIds.add('edit-rollback-failed-native')
|
||||
await assert.rejects(invoke('chat:send', {
|
||||
sessionId: failedSession, text: 'edited failed', action: 'edit', fromMessageId: 'edit-rollback-failed-user'
|
||||
}), /branch failure/)
|
||||
state.failBranchIds.delete('edit-rollback-failed-native')
|
||||
assert.equal(await readFile(failedPath, 'utf8'), 'after', 'failed native branch must undo the artifact rollback')
|
||||
assert.deepEqual(await bundle.sessions.getMessages(failedSession), failedBefore,
|
||||
'failed edit must leave the persisted conversation untouched')
|
||||
|
||||
// 无产物改动:直接重开对话,不因缺少文件记录而报错。
|
||||
const plainSession = await invoke('session:create', 'edit-plain') as { id: string }
|
||||
await bundle.sessions.appendMessage(plainSession.id, message('edit-plain-user', 'user', 'plain prompt', { nativeEntryId: 'edit-plain-native' }))
|
||||
await bundle.sessions.appendMessage(plainSession.id, message('edit-plain-assistant', 'assistant', 'plain reply'))
|
||||
seedNativeHistory(state, plainSession.id, [['edit-plain-native', 'plain prompt']])
|
||||
await invoke('chat:send', { sessionId: plainSession.id, text: 'edited plain', action: 'edit', fromMessageId: 'edit-plain-user' })
|
||||
const plainMessages = await waitForAssistant(invoke, plainSession.id)
|
||||
assert.equal(plainMessages.length, 2)
|
||||
assert.equal(plainMessages[0].content, 'edited plain')
|
||||
|
||||
// 记录不完整(不可回滚工具):拒绝编辑,会话与文件都不变。
|
||||
const blockedPath = join(workspace, 'edit-blocked.txt')
|
||||
await writeFile(blockedPath, 'after', 'utf8')
|
||||
const blockedSession = await invoke('session:create', 'edit-blocked') as { id: string }
|
||||
await bundle.sessions.appendMessage(blockedSession.id, message('edit-blocked-user', 'user', 'blocked prompt', { nativeEntryId: 'edit-blocked-native' }))
|
||||
await bundle.sessions.appendMessage(blockedSession.id, message('edit-blocked-assistant', 'assistant', 'ran shell', {
|
||||
tools: [{ id: 'shell-tool', name: 'run_command', args: {}, argsRaw: '{}', status: 'ok' }]
|
||||
}))
|
||||
seedNativeHistory(state, blockedSession.id, [['edit-blocked-native', 'blocked prompt']])
|
||||
const blockedBefore = await bundle.sessions.getMessages(blockedSession.id)
|
||||
await assert.rejects(invoke('chat:send', {
|
||||
sessionId: blockedSession.id, text: 'edited blocked', action: 'edit', fromMessageId: 'edit-blocked-user'
|
||||
}), /无法完整回滚/)
|
||||
assert.equal(await readFile(blockedPath, 'utf8'), 'after', 'rejected edit must not touch artifacts')
|
||||
assert.deepEqual(await bundle.sessions.getMessages(blockedSession.id), blockedBefore,
|
||||
'rejected edit must leave history untouched')
|
||||
|
||||
await invoke('chat:send', {
|
||||
sessionId: blockedSession.id, text: 'edit without rolling files back', action: 'edit',
|
||||
fromMessageId: 'edit-blocked-user', rollbackArtifacts: false
|
||||
})
|
||||
await waitForAssistant(invoke, blockedSession.id)
|
||||
assert.equal(await readFile(blockedPath, 'utf8'), 'after', 'explicit skip must preserve current files even for non-reversible tools')
|
||||
|
||||
const keptPath = join(workspace, 'edit-keep-latest.txt')
|
||||
const keptSession = await seedWriteHistory(bundle, state, 'edit-keep-latest', keptPath, 'keep-native', 'after')
|
||||
await invoke('chat:send', { sessionId: keptSession, text: 'use latest files', action: 'edit',
|
||||
fromMessageId: 'edit-keep-latest-user', rollbackArtifacts: false })
|
||||
await waitForAssistant(invoke, keptSession)
|
||||
assert.equal(await readFile(keptPath, 'utf8'), 'after', 'skipping artifact rollback retains the latest version')
|
||||
|
||||
const persistPath = join(workspace, 'edit-turn-persist-failed.txt')
|
||||
const persistSession = await seedWriteHistory(bundle, state, 'edit-persist-failed', persistPath, 'edit-persist-native', 'after')
|
||||
const persistHistory = structuredClone(await bundle.sessions.getMessages(persistSession))
|
||||
await mkdir(join(state.userData, 'omp-sessions'), { recursive: true })
|
||||
state.nativeFailurePath = nativePath(state.userData, persistSession)
|
||||
await writeFile(state.nativeFailurePath, 'original-native-transcript', 'utf8')
|
||||
state.failStartTurn = true
|
||||
await assert.rejects(invoke('chat:send', { sessionId: persistSession, text: 'failed save', action: 'edit',
|
||||
fromMessageId: 'edit-persist-failed-user' }), /turn persistence failure/)
|
||||
assert.equal(await readFile(persistPath, 'utf8'), 'after', 'turn persistence failure must undo the already completed artifact rollback')
|
||||
assert.equal(await readFile(state.nativeFailurePath, 'utf8'), 'original-native-transcript', 'failed edited turn must restore the native resume transcript too')
|
||||
state.nativeFailurePath = undefined
|
||||
assert.deepEqual(await bundle.sessions.getMessages(persistSession), persistHistory, 'failed edited turn persistence must preserve prior history')
|
||||
}
|
||||
|
||||
async function testSenderRoutingAndPreflight(bundle: TestBundle, state: TestState, workspace: string): Promise<void> {
|
||||
const invoke = invokeFor(state)
|
||||
const session = await invoke('session:create', 'secondary window') as { id: string }
|
||||
const events: Array<{ runId: string; type: string }> = []
|
||||
let closed = false
|
||||
const handler = state.handlers.get('chat:send')!
|
||||
const sender = { isDestroyed: () => { if (closed) throw new Error('WebContents destroyed'); return false },
|
||||
send: (channel: string, event: { runId: string; type: string }) => { if (channel === 'chat:event') events.push(event) } }
|
||||
const sent = await handler({ sender }, { sessionId: session.id, text: 'secondary request' }) as { runId: string }
|
||||
await waitForAssistant(invoke, session.id)
|
||||
assert.ok(events.some((event) => event.type === 'text'), 'the requesting secondary renderer receives text deltas')
|
||||
assert.ok(events.some((event) => event.type === 'done'), 'the requesting secondary renderer receives its terminal event')
|
||||
assert.ok(state.events.some((event) => (event as { runId?: string }).runId === sent.runId), 'the main renderer also observes the shared turn')
|
||||
const streamSequences = events.map((event) => (event as { sequence?: number }).sequence)
|
||||
assert.ok(streamSequences.every((sequence, index) => typeof sequence === 'number' && (index === 0 || sequence > (streamSequences[index - 1] ?? -1))),
|
||||
'shared events must carry a strictly increasing run sequence')
|
||||
await assert.rejects(invoke('session:delete', session.id, { onlyIfEmpty: true }), /已有消息/,
|
||||
'empty-session cleanup cannot remove a conversation populated by another renderer')
|
||||
|
||||
state.holdSessions.add(session.id)
|
||||
await handler({ sender }, { sessionId: session.id, text: 'finish after window closes' })
|
||||
await waitUntil(() => state.pendingPrompts.some((item) => item.sessionId === session.id), 'secondary held prompt')
|
||||
const active = await invoke('chat:active-run', session.id) as { runId: string; messages: ChatMessage[]; sequence: number }
|
||||
assert.ok(active?.runId && active.messages.at(-1)?.status === 'streaming', 'a reopened renderer can obtain the current run and exact live messages')
|
||||
assert.equal(active.messages.filter((message) => message.role === 'user').length, 2, 'live snapshots include the complete history exactly once')
|
||||
await assert.rejects(invoke('session:delete', session.id, { onlyIfEmpty: true }), /正在运行/,
|
||||
'cleanup must reject a running session without cancelling its turn')
|
||||
closed = true
|
||||
const before = events.length
|
||||
const held = state.pendingPrompts.find((item) => item.sessionId === session.id)!
|
||||
state.pendingPrompts = state.pendingPrompts.filter((item) => item !== held)
|
||||
state.holdSessions.delete(session.id)
|
||||
held.complete()
|
||||
await waitForAssistant(invoke, session.id)
|
||||
assert.equal(events.length, before, 'closing the requester cannot interrupt persistence or send into a destroyed renderer')
|
||||
|
||||
const legacy = await invoke('session:create', 'legacy edit') as { id: string }
|
||||
await bundle.sessions.appendMessage(legacy.id, message('legacy-user', 'user', 'imported input'))
|
||||
await bundle.sessions.appendMessage(legacy.id, message('legacy-answer', 'assistant', 'imported answer'))
|
||||
const denied = await invoke('chat:inspect-edit', legacy.id, 'legacy-user') as { editable: boolean; reason?: string }
|
||||
assert.equal(denied.editable, false, 'legacy native branches must be checked before opening an editor')
|
||||
assert.match(denied.reason ?? '', /原生分支/)
|
||||
assert.deepEqual(await invoke('chat:inspect-edit', session.id, (await bundle.sessions.getMessages(session.id))[0].id), { editable: true })
|
||||
|
||||
const keepPath = join(workspace, 'rewind-keep.txt')
|
||||
const keepSession = await seedWriteHistory(bundle, state, 'rewind-keep', keepPath, 'rewind-keep-native', 'after')
|
||||
await invoke('chat:revert', keepSession, 'rewind-keep-user', { rollbackArtifacts: false })
|
||||
assert.equal(await readFile(keepPath, 'utf8'), 'after', 'conversation-only rewind preserves artifact contents')
|
||||
assert.deepEqual(await bundle.sessions.getMessages(keepSession), [], 'conversation-only rewind still persists the truncated history')
|
||||
await invoke('session:delete', keepSession, { onlyIfEmpty: true })
|
||||
assert.ok(!(await invoke('session:list') as Array<{ id: string }>).some((item) => item.id === keepSession), 'atomic cleanup removes a genuinely empty session')
|
||||
|
||||
state.openPathError = 'mock folder cannot be opened'
|
||||
await assert.rejects(invoke('general:openFolder', 'workspace'), /mock folder cannot be opened/)
|
||||
state.openPathError = undefined
|
||||
}
|
||||
|
||||
async function testInterruptedResend(bundle: TestBundle, state: TestState): Promise<void> {
|
||||
const invoke = invokeFor(state)
|
||||
for (const nativeAccepted of [false, true]) {
|
||||
const session = await invoke('session:create', 'interrupted-resend') as { id: string }
|
||||
await bundle.sessions.appendMessage(session.id, message('prefix-user', 'user', 'earlier', { nativeEntryId: 'prefix-native' }))
|
||||
await bundle.sessions.appendMessage(session.id, message('prefix-assistant', 'assistant', 'earlier reply'))
|
||||
await bundle.sessions.appendMessage(session.id, message('interrupted-user', 'user', 'interrupted prompt'))
|
||||
await bundle.sessions.appendMessage(session.id, message('interrupted-assistant', 'assistant', 'partial', { status: 'cancelled', interrupted: true }))
|
||||
seedNativeHistory(state, session.id, [['prefix-native', 'earlier'], ...(nativeAccepted ? [['unbound-native', 'interrupted prompt'] as [string, string]] : [])])
|
||||
const start = state.timeline.length
|
||||
await invoke('chat:send', { sessionId: session.id, text: 'interrupted prompt', action: 'resend', fromMessageId: 'interrupted-user' })
|
||||
const result = await waitForAssistant(invoke, session.id)
|
||||
assert.equal(result.length, 4)
|
||||
assert.equal(result[2].content, 'interrupted prompt')
|
||||
const branches = state.timeline.slice(start).filter((entry) => entry.kind === 'branch')
|
||||
assert.equal(branches.length, nativeAccepted ? 1 : 0)
|
||||
if (nativeAccepted) assert.equal(branches[0].entryId, 'unbound-native', 'recover the accepted native user without truncating its preceding context')
|
||||
assert.ok(result[2].nativeEntryId?.startsWith('native-prompt-'))
|
||||
}
|
||||
}
|
||||
|
||||
function writeTool(id: string, path: string, oldContent: string | null, newContent: string): ToolCallRecord {
|
||||
return {
|
||||
id,
|
||||
name: 'write_file',
|
||||
args: { path },
|
||||
argsRaw: JSON.stringify({ path }),
|
||||
status: 'ok',
|
||||
output: 'write complete',
|
||||
result: { path, oldContent, newContent }
|
||||
}
|
||||
}
|
||||
|
||||
async function seedWriteHistory(
|
||||
bundle: TestBundle,
|
||||
state: TestState,
|
||||
title: string,
|
||||
path: string,
|
||||
targetEntryId: string,
|
||||
currentFile: string
|
||||
): Promise<string> {
|
||||
await writeFile(path, currentFile, 'utf8')
|
||||
const session = await invokeFor(state)('session:create', title) as { id: string }
|
||||
await bundle.sessions.appendMessage(session.id, message(`${title}-user`, 'user', 'write prompt', { nativeEntryId: targetEntryId }))
|
||||
await bundle.sessions.appendMessage(session.id, message(`${title}-assistant`, 'assistant', 'write answer', {
|
||||
tools: [writeTool(`${title}-write`, path, 'before', 'after')]
|
||||
}))
|
||||
seedNativeHistory(state, session.id, [[targetEntryId, 'write prompt']])
|
||||
return session.id
|
||||
}
|
||||
|
||||
async function testFeedbackAndRevert(bundle: TestBundle, state: TestState, workspace: string): Promise<{
|
||||
markerSessionId: string
|
||||
markerEntryId: string
|
||||
}> {
|
||||
const invoke = invokeFor(state)
|
||||
const feedbackSession = await invoke('session:create', 'feedback') as { id: string }
|
||||
const feedbackUser = message('feedback-user', 'user', 'feedback request')
|
||||
const feedbackAssistant = message('feedback-assistant', 'assistant', 'feedback response')
|
||||
await bundle.sessions.appendMessage(feedbackSession.id, feedbackUser)
|
||||
await bundle.sessions.appendMessage(feedbackSession.id, feedbackAssistant)
|
||||
await invoke('chat:feedback', feedbackSession.id, feedbackAssistant.id, 'like')
|
||||
assert.equal((await bundle.sessions.getMessages(feedbackSession.id))[1].feedback, 'like')
|
||||
await assert.rejects(invoke('chat:feedback', feedbackSession.id, feedbackUser.id, 'dislike'), /助手消息/)
|
||||
await assert.rejects(invoke('chat:feedback', feedbackSession.id, 'missing', 'like'), /助手消息/)
|
||||
await assert.rejects(invoke('chat:feedback', feedbackSession.id, feedbackAssistant.id, 'bad'), /反馈值无效/)
|
||||
|
||||
const conflictPath = join(workspace, 'conflict.txt')
|
||||
const conflictId = await seedWriteHistory(bundle, state, 'conflict-revert', conflictPath, 'native-conflict-target', 'external edit')
|
||||
const conflictBefore = await bundle.sessions.getMessages(conflictId)
|
||||
await assert.rejects(invoke('chat:revert', conflictId, 'conflict-revert-user'), /外部修改/)
|
||||
assert.equal(await readFile(conflictPath, 'utf8'), 'external edit', 'revert conflict must preserve the externally edited file')
|
||||
assert.deepEqual(await bundle.sessions.getMessages(conflictId), conflictBefore, 'conflict should leave history intact')
|
||||
assert.equal(await bundle.sessions.getPendingNativeBranch(conflictId), undefined)
|
||||
|
||||
const rejectedPath = join(workspace, 'branch-failure.txt')
|
||||
const rejectedId = await seedWriteHistory(bundle, state, 'failed-revert', rejectedPath, 'native-failed-revert', 'after')
|
||||
state.failBranchIds.add('native-failed-revert')
|
||||
await assert.rejects(invoke('chat:revert', rejectedId, 'failed-revert-user'), /恢复失败/)
|
||||
state.failBranchIds.delete('native-failed-revert')
|
||||
assert.equal(await readFile(rejectedPath, 'utf8'), 'after', 'failed native branch must undo the file restore')
|
||||
assert.equal((await bundle.sessions.getMessages(rejectedId)).length, 2, 'failed native branch must preserve history')
|
||||
assert.equal(await bundle.sessions.getPendingNativeBranch(rejectedId), undefined)
|
||||
|
||||
const readOnlyId = await invoke('session:create', 'read-only-revert') as { id: string }
|
||||
await bundle.sessions.appendMessage(readOnlyId.id, message('read-only-user', 'user', 'read only', { nativeEntryId: 'native-read-only-target' }))
|
||||
await bundle.sessions.appendMessage(readOnlyId.id, message('read-only-assistant', 'assistant', 'read', {
|
||||
tools: [{ id: 'read-tool', name: 'read_file', args: {}, argsRaw: '{}', status: 'ok' }]
|
||||
}))
|
||||
seedNativeHistory(state, readOnlyId.id, [['native-read-only-target', 'read only']])
|
||||
// 原版 rewind 的 no-version 变体:没有产物版本时只回退对话内容,产物保持不变。
|
||||
const readOnlyResult = await invoke('chat:revert', readOnlyId.id, 'read-only-user') as { messages: unknown[] }
|
||||
assert.deepEqual(readOnlyResult.messages, [], 'conversation-only rewind should truncate to before the message')
|
||||
assert.deepEqual(await bundle.sessions.getMessages(readOnlyId.id), [], 'conversation-only rewind should persist the truncated prefix')
|
||||
|
||||
const unknownPath = join(workspace, 'unknown-write.txt')
|
||||
await writeFile(unknownPath, 'after', 'utf8')
|
||||
const unknownId = await invoke('session:create', 'unknown-write-revert') as { id: string }
|
||||
await bundle.sessions.appendMessage(unknownId.id, message('unknown-write-user', 'user', 'unknown write', {
|
||||
nativeEntryId: 'native-unknown-write'
|
||||
}))
|
||||
await bundle.sessions.appendMessage(unknownId.id, message('unknown-write-assistant', 'assistant', 'unknown', {
|
||||
tools: [{
|
||||
id: 'unknown-write-tool', name: 'delete_file', args: {}, argsRaw: '{}', status: 'ok',
|
||||
result: { path: unknownPath, oldContent: 'before', newContent: 'after' }
|
||||
}]
|
||||
}))
|
||||
await assert.rejects(invoke('chat:revert', unknownId.id, 'unknown-write-user'), /无法完整回滚/)
|
||||
assert.equal(await readFile(unknownPath, 'utf8'), 'after', 'unrecognized writes must not be treated as reversible')
|
||||
|
||||
const markerPath = join(workspace, 'persist-revert.txt')
|
||||
const markerSessionId = await seedWriteHistory(bundle, state, 'pending-revert', markerPath, 'native-revert-target', 'after')
|
||||
await invoke('chat:revert', markerSessionId, 'pending-revert-user')
|
||||
assert.equal(await readFile(markerPath, 'utf8'), 'before', 'known complete host write should be restored')
|
||||
assert.deepEqual(await bundle.sessions.getMessages(markerSessionId), [], 'successful revert should truncate the active history prefix')
|
||||
const markerEntryId = await bundle.sessions.getPendingNativeBranch(markerSessionId)
|
||||
assert.equal(markerEntryId, 'native-revert-target', 'successful revert should persist a replay marker')
|
||||
const revertArchiveDirectory = join(state.userData, 'session-branches', encodeURIComponent(markerSessionId))
|
||||
const revertArchiveFiles = await readdir(revertArchiveDirectory)
|
||||
assert.equal(revertArchiveFiles.length, 1, 'revert should archive its abandoned branch')
|
||||
const revertArchive = JSON.parse(await readFile(join(revertArchiveDirectory, revertArchiveFiles[0]), 'utf8')) as {
|
||||
reason: string
|
||||
session: { messages: ChatMessage[] }
|
||||
}
|
||||
assert.equal(revertArchive.reason, 'revert')
|
||||
assert.equal(revertArchive.session.messages.length, 2, 'revert archive should preserve the messages removed from the active history')
|
||||
const stored = JSON.parse(await readFile(join(state.userData, 'sessions.json'), 'utf8')) as {
|
||||
sessions: Array<{ id: string; pendingNativeBranchId?: string }>
|
||||
}
|
||||
assert.equal(stored.sessions.find((item) => item.id === markerSessionId)?.pendingNativeBranchId, markerEntryId,
|
||||
'pending native branch marker should be present on disk')
|
||||
return { markerSessionId, markerEntryId: markerEntryId! }
|
||||
}
|
||||
|
||||
async function testAttachments(bundle: TestBundle, state: TestState, tempRoot: string): Promise<void> {
|
||||
const invoke = invokeFor(state)
|
||||
const pickedPath = join(tempRoot, 'picked.txt')
|
||||
await writeFile(pickedPath, 'picked file content', 'utf8')
|
||||
state.dialogPaths.push(pickedPath)
|
||||
const picked = await invoke('attachments:pick') as Array<{ id: string; name: string; path?: string; sourcePath?: string; size: number }>
|
||||
assert.equal(picked.length, 1)
|
||||
assert.equal(picked[0].name, 'picked.txt')
|
||||
assert.equal(picked[0].size, Buffer.byteLength('picked file content'))
|
||||
assert.ok(picked[0].path && (await readFile(picked[0].path, 'utf8')) === 'picked file content',
|
||||
'picker should stage a real copy in user data')
|
||||
assert.equal(picked[0].sourcePath, pickedPath, 'display source path stays separate from the managed attachment copy')
|
||||
|
||||
const staged = await invoke('attachments:stage', {
|
||||
name: '../safe-note.txt', mimeType: 'text/plain', dataBase64: Buffer.from('attachment body').toString('base64')
|
||||
}) as { id: string; name: string; path?: string; size: number }
|
||||
assert.equal(staged.name, 'safe-note.txt', 'staging should discard path components from the supplied filename')
|
||||
assert.equal(staged.size, Buffer.byteLength('attachment body'))
|
||||
assert.equal((await bundle.attachments.getAttachment(staged.id)).name, 'safe-note.txt')
|
||||
assert.equal(await readFile(staged.path!, 'utf8'), 'attachment body')
|
||||
await assert.rejects(invoke('resources:read-text', staged.path), /超出工作目录/)
|
||||
const preview = await invoke('attachments:preview', staged.id) as { kind: string; content: string; path: string }
|
||||
assert.equal(preview.kind, 'text')
|
||||
assert.equal(preview.content, 'attachment body', 'registered attachment preview must work outside the current workspace')
|
||||
assert.equal(await invoke('resources:read-text', preview.path), 'attachment body', 'the same registered attachment can be read by the real Markdown/code viewer')
|
||||
await assert.rejects(invoke('resources:read-text', pickedPath), /超出工作目录/)
|
||||
await assert.rejects(invoke('attachments:preview', '../outside'), /标识无效/)
|
||||
await assert.rejects(invoke('attachments:stage', {
|
||||
name: 'metadata.json', mimeType: 'text/plain', dataBase64: Buffer.from('no').toString('base64')
|
||||
}), /文件名无效/)
|
||||
|
||||
const tamperedMetadata = join(state.userData, 'attachments', staged.id, 'metadata.json')
|
||||
await writeFile(tamperedMetadata, JSON.stringify({ ...staged, path: join(tempRoot, 'outside.txt') }), 'utf8')
|
||||
const pathChecked = await bundle.attachments.getAttachment(staged.id)
|
||||
assert.equal(pathChecked.path, staged.path, 'attachment metadata path must be recomputed inside the staged directory')
|
||||
assert.notEqual(pathChecked.path, join(tempRoot, 'outside.txt'))
|
||||
assert.equal((await invoke('attachments:preview', staged.id) as { path: string }).path, staged.path,
|
||||
'attachment preview must use the registered directory, not a metadata path override')
|
||||
await writeFile(tamperedMetadata, JSON.stringify({ ...staged, name: '../outside.txt', path: join(tempRoot, 'outside.txt') }), 'utf8')
|
||||
await assert.rejects(bundle.attachments.getAttachment(staged.id), /元数据无效/)
|
||||
const sendSession = await invoke('session:create', 'invalid-attachment') as { id: string }
|
||||
await assert.rejects(invoke('chat:send', {
|
||||
sessionId: sendSession.id, text: 'must not send', attachments: [{ ...staged, id: staged.id }]
|
||||
}), /元数据无效/)
|
||||
assert.deepEqual(await invoke('session:messages', sendSession.id), [], 'invalid attachment metadata must reject before appending')
|
||||
|
||||
const sendWithAttachment = await invoke('session:create', 'attachment-send') as { id: string }
|
||||
await invoke('chat:send', { sessionId: sendWithAttachment.id, text: 'read attachment', attachments: [picked[0]] })
|
||||
await waitForAssistant(invoke, sendWithAttachment.id)
|
||||
const prompt = state.timeline.findLast((item) => item.kind === 'prompt' && item.sessionId === sendWithAttachment.id)
|
||||
assert.ok(prompt?.text?.includes('附件 picked.txt:\npicked file content'), 'OMP prompt should receive staged file contents')
|
||||
|
||||
await assert.rejects(invoke('attachments:remove', picked[0].id), /已用于会话/)
|
||||
assert.ok(await readFile(join(state.userData, 'attachments', picked[0].id, 'metadata.json'), 'utf8'),
|
||||
'removing a committed attachment should leave the session source intact')
|
||||
await invoke('attachments:remove', staged.id)
|
||||
await assert.rejects(bundle.attachments.getAttachment(staged.id))
|
||||
}
|
||||
|
||||
async function testSpeech(bundle: TestBundle, state: TestState): Promise<void> {
|
||||
const server = createServer((request, response) => {
|
||||
const chunks: Buffer[] = []
|
||||
request.on('data', (chunk: Buffer) => chunks.push(Buffer.from(chunk)))
|
||||
request.on('end', () => {
|
||||
state.speechRequests.push({
|
||||
path: request.url || '',
|
||||
authorization: request.headers.authorization,
|
||||
contentType: request.headers['content-type'],
|
||||
body: Buffer.concat(chunks).toString('utf8')
|
||||
})
|
||||
response.writeHead(state.speechStatus, { 'content-type': 'application/json' })
|
||||
response.end(state.speechStatus === 200 ? JSON.stringify({ text: 'recognized local audio' }) : JSON.stringify({ error: 'mock failure' }))
|
||||
})
|
||||
})
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.once('error', reject)
|
||||
server.listen(0, '127.0.0.1', resolve)
|
||||
})
|
||||
try {
|
||||
const address = server.address()
|
||||
assert.ok(address && typeof address === 'object')
|
||||
const speechBaseUrl = `http://127.0.0.1:${address.port}`
|
||||
const invoke = invokeFor(state)
|
||||
await invoke('config:update', { speechBaseUrl, speechModel: 'local-test-model', apiKey: 'regression-only-key' })
|
||||
const audioBase64 = Buffer.from('local fake wav bytes').toString('base64')
|
||||
const result = await invoke('speech:transcribe', { audioBase64, mimeType: 'audio/wav' }) as { text: string }
|
||||
assert.deepEqual(result, { text: 'recognized local audio' })
|
||||
const request = state.speechRequests.at(-1)
|
||||
assert.ok(request)
|
||||
assert.equal(request.path, '/audio/transcriptions')
|
||||
assert.equal(request.authorization, 'Bearer regression-only-key')
|
||||
assert.ok(request.contentType?.includes('multipart/form-data; boundary='))
|
||||
assert.ok(request.body.includes('name="model"') && request.body.includes('local-test-model'))
|
||||
assert.ok(request.body.includes('filename="recording.wav"') && request.body.includes('local fake wav bytes'))
|
||||
|
||||
state.speechStatus = 503
|
||||
await assert.rejects(invoke('speech:transcribe', { audioBase64, mimeType: 'audio/wav' }), /转写失败(503)/)
|
||||
assert.equal(state.speechRequests.length, 2, 'both transcription requests should stay on the local server')
|
||||
assert.ok(state.speechRequests.every((item) => item.path === '/audio/transcriptions'))
|
||||
} finally {
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()))
|
||||
}
|
||||
}
|
||||
|
||||
async function testPendingReplay(
|
||||
tempRoot: string,
|
||||
bundle: TestBundle,
|
||||
state: TestState,
|
||||
markerSessionId: string,
|
||||
markerEntryId: string
|
||||
): Promise<TestBundle> {
|
||||
// A real OMP cold start loads the old file leaf; branch alone was not persisted.
|
||||
seedNativeHistory(state, markerSessionId, [[markerEntryId, 'write prompt']])
|
||||
const restarted = await buildBundle(tempRoot, state, 'conversation-ipc-restarted')
|
||||
const invoke = register(restarted, state)
|
||||
const before = await restarted.sessions.getMessages(markerSessionId)
|
||||
assert.deepEqual(before, [], 'restarted session should load the archived active prefix')
|
||||
|
||||
state.failBranchIds.add(markerEntryId)
|
||||
await assert.rejects(invoke('chat:send', { sessionId: markerSessionId, text: 'blocked until branch succeeds' }), /恢复已保存的原生会话分支/)
|
||||
state.failBranchIds.delete(markerEntryId)
|
||||
assert.deepEqual(await restarted.sessions.getMessages(markerSessionId), before,
|
||||
'failed pending branch replay must not append a user message')
|
||||
assert.equal(await restarted.sessions.getPendingNativeBranch(markerSessionId), markerEntryId,
|
||||
'failed pending branch replay must retain its marker for a retry')
|
||||
|
||||
const start = state.timeline.length
|
||||
await invoke('chat:send', { sessionId: markerSessionId, text: 'replayed branch prompt' })
|
||||
const messages = await waitForAssistant(invoke, markerSessionId)
|
||||
const activity = state.timeline.slice(start).filter((item) => item.sessionId === markerSessionId)
|
||||
const branch = activity.findIndex((item) => item.kind === 'branch' && item.entryId === markerEntryId)
|
||||
const prompt = activity.findIndex((item) => item.kind === 'prompt' && item.text?.split(/\r?\n/, 1)[0] === 'replayed branch prompt')
|
||||
assert.ok(branch >= 0 && prompt > branch, 'fresh OMP kernel must replay the saved branch marker before prompt')
|
||||
assert.equal(activity[prompt].text, ['replayed branch prompt', messages[0].capabilityContext].filter(Boolean).join('\n\n'),
|
||||
'restart prompt retains the exact current turn context')
|
||||
assert.match(activity[prompt].text ?? '', /<ycode-agent-mode mode="craft">/, 'restart prompt declares current craft mode')
|
||||
assert.equal(messages[0].content, 'replayed branch prompt', 'persisted user content stays exact across restart')
|
||||
assert.ok(messages[0].nativeEntryId?.startsWith('native-prompt-'), 'restarted send should map its returned native entryId')
|
||||
assert.equal(await restarted.sessions.getPendingNativeBranch(markerSessionId), undefined,
|
||||
'new native user entry should clear the marker after successful lookup')
|
||||
return restarted
|
||||
}
|
||||
|
||||
async function testColdRecovery(tempRoot: string, state: TestState, bundle: TestBundle): Promise<void> {
|
||||
const invoke = invokeFor(state)
|
||||
const session = await invoke('session:create', 'cold streaming recovery') as { id: string }
|
||||
await bundle.sessions.appendMessage(session.id, message('cold-user', 'user', 'incomplete request', { status: 'done' }))
|
||||
await bundle.sessions.appendMessage(session.id, message('cold-assistant', 'assistant', 'partial', {
|
||||
requestId: 'cold-run',
|
||||
status: 'streaming',
|
||||
parts: [{ type: 'text', text: 'partial' }, { type: 'tool', toolId: 'cold-tool' }],
|
||||
tools: [{ id: 'cold-tool', name: 'shell', args: {}, argsRaw: '{}', status: 'running', output: 'partial tool output' }]
|
||||
}))
|
||||
|
||||
const cold = await buildBundle(tempRoot, state, 'conversation-ipc-cold-recovery')
|
||||
const messages = await cold.sessions.getMessages(session.id)
|
||||
const recovered = assistant(messages)
|
||||
assert.equal(recovered.status, 'cancelled')
|
||||
assert.equal(recovered.interrupted, true)
|
||||
assert.equal(recovered.tools?.[0].status, 'cancelled')
|
||||
assert.equal(recovered.tools?.[0].output, 'partial tool output', 'cold recovery should retain running tool output')
|
||||
const disk = JSON.parse(await readFile(join(state.userData, 'sessions.json'), 'utf8')) as {
|
||||
sessions: Array<{ id: string; messages: ChatMessage[] }>
|
||||
}
|
||||
const diskRecovered = disk.sessions.find((item) => item.id === session.id)?.messages.at(-1)
|
||||
assert.equal(diskRecovered?.status, 'cancelled', 'cold recovery should persist its terminal status')
|
||||
assert.equal(diskRecovered?.tools?.[0].status, 'cancelled')
|
||||
assert.equal(diskRecovered?.tools?.[0].output, 'partial tool output')
|
||||
}
|
||||
|
||||
async function testInlineReferencePersistence(state: TestState, invoke: ReturnType<typeof invokeFor>): Promise<string> {
|
||||
const session = await invoke('session:create', 'inline reference persistence') as { id: string }
|
||||
const quote = { id: 'inline-q', sourceId: 'original:table:table-0', kind: 'table', markdown: '| 项目 |\n| --- |\n| alpha |', preview: '| old raw markdown |' }
|
||||
const layout = [{ type: 'text', text: 'before' }, { type: 'quote', id: quote.id }, { type: 'text', text: 'after' }]
|
||||
await invoke('chat:send', { sessionId: session.id, text: 'beforeafter', quotes: [quote], userContent: [
|
||||
...layout, { type: 'attachment', id: 'untrusted-view-only' }, { type: 'quote', id: quote.id }
|
||||
] })
|
||||
const messages = await waitForAssistant(invoke, session.id)
|
||||
const user = messages.find(item => item.role === 'user')!
|
||||
assert.deepEqual(user.userContent, layout, 'main process must preserve mixed order, deduplicate references, and reject forged view identities')
|
||||
assert.equal(user.quotes?.[0].markdown, quote.markdown, 'the real table snapshot must survive independently of the UI label')
|
||||
const stored = JSON.parse(await readFile(join(state.userData, 'sessions.json'), 'utf8')) as { sessions: Array<{ id: string; messages: ChatMessage[] }> }
|
||||
assert.deepEqual(stored.sessions.find(item => item.id === session.id)?.messages.find(item => item.role === 'user')?.userContent, layout,
|
||||
'inline presentation must reach the actual atomic session file')
|
||||
return session.id
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const tempRoot = await mkdtemp(join(tmpdir(), 'ycode-conversation-ipc-'))
|
||||
const state: TestState = {
|
||||
userData: join(tempRoot, 'userData'),
|
||||
home: join(tempRoot, 'home'),
|
||||
handlers: new Map(),
|
||||
dialogPaths: [],
|
||||
nativeMessages: new Map(),
|
||||
failBranchIds: new Set(),
|
||||
timeline: [],
|
||||
pendingPrompts: [],
|
||||
holdSessions: new Set(),
|
||||
activePrompts: 0,
|
||||
maxActivePrompts: 0,
|
||||
nextNativeId: 0,
|
||||
speechStatus: 200,
|
||||
speechRequests: [],
|
||||
events: []
|
||||
}
|
||||
await mkdir(state.userData, { recursive: true })
|
||||
await mkdir(state.home, { recursive: true })
|
||||
|
||||
const globals = globalThis as typeof globalThis & Record<string, unknown>
|
||||
const previousState = globals[TEST_STATE_KEY]
|
||||
globals[TEST_STATE_KEY] = state
|
||||
const bundles: TestBundle[] = []
|
||||
try {
|
||||
const bundle = await buildBundle(tempRoot, state, 'conversation-ipc-primary')
|
||||
bundles.push(bundle)
|
||||
const invoke = register(bundle, state)
|
||||
|
||||
const workspace = join(tempRoot, 'workspace')
|
||||
await mkdir(workspace, { recursive: true })
|
||||
state.dialogPaths.push(workspace)
|
||||
assert.deepEqual(await invoke('workspace:choose'), { root: workspace, changed: true })
|
||||
|
||||
const project = await invoke('projects:create', { name: '指令项目', instructions: '项目回复必须包含验收标记 PROJECT-CONTEXT', experts: [], skills: [], connectors: [] }) as { projectId: string }
|
||||
const projectTask = await invoke('session:create', '项目指令回归', { projectId: project.projectId }) as { id: string; projectId: string }
|
||||
assert.equal(projectTask.projectId, project.projectId)
|
||||
await invoke('chat:send', { sessionId: projectTask.id, text: '检查项目指令' })
|
||||
const projectMessages = await waitForAssistant(invoke, projectTask.id)
|
||||
const projectPrompt = state.timeline.find(item => item.kind === 'prompt' && item.sessionId === projectTask.id)?.text
|
||||
assert.ok(projectPrompt?.includes('项目指令:\n项目回复必须包含验收标记 PROJECT-CONTEXT'), 'project instructions must reach the actual OMP adapter prompt')
|
||||
assert.equal(projectMessages.find(item => item.role === 'user')?.content, '检查项目指令', 'project instructions must not overwrite the displayed user message')
|
||||
await invoke('projects:update', project.projectId, { instructions: 'UPDATED-PROJECT-CONTEXT' })
|
||||
await invoke('chat:send', { sessionId: projectTask.id, text: '检查更新指令' })
|
||||
await waitForAssistant(invoke, projectTask.id)
|
||||
const updatedProjectPrompt = state.timeline.filter(item => item.kind === 'prompt' && item.sessionId === projectTask.id).at(-1)?.text
|
||||
assert.ok(updatedProjectPrompt?.includes('UPDATED-PROJECT-CONTEXT'), 'next project turn must use the updated persisted instructions')
|
||||
await assert.rejects(invoke('session:update-context', projectTask.id, { projectId: 'another-project' }), /目标项目/)
|
||||
await testConcurrency(bundle, state, invoke)
|
||||
for (const action of ['edit', 'resend', 'regenerate'] as const) await testActionBranch(bundle, state, action)
|
||||
await testActionBranchFailure(bundle, state)
|
||||
await testEditArtifactRollback(bundle, state, workspace)
|
||||
await testSenderRoutingAndPreflight(bundle, state, workspace)
|
||||
await testInterruptedResend(bundle, state)
|
||||
await testAttachments(bundle, state, tempRoot)
|
||||
await testSpeech(bundle, state)
|
||||
const inlineSessionId = await testInlineReferencePersistence(state, invoke)
|
||||
|
||||
const { markerSessionId, markerEntryId } = await testFeedbackAndRevert(bundle, state, workspace)
|
||||
const restarted = await testPendingReplay(tempRoot, bundle, state, markerSessionId, markerEntryId)
|
||||
bundles.push(restarted)
|
||||
await testColdRecovery(tempRoot, state, restarted)
|
||||
const restoredInline = await invoke('session:messages', inlineSessionId) as ChatMessage[]
|
||||
assert.deepEqual(restoredInline.find(item => item.role === 'user')?.userContent,
|
||||
[{ type: 'text', text: 'before' }, { type: 'quote', id: 'inline-q' }, { type: 'text', text: 'after' }],
|
||||
'a fresh IPC/session repository must restore the same inline order after restart')
|
||||
console.log('conversation-ipc-regression: all checks passed')
|
||||
} finally {
|
||||
await Promise.all(bundles.map((bundle) => bundle.disposeOmpKernels().catch(() => undefined)))
|
||||
globals[TEST_STATE_KEY] = previousState
|
||||
await rm(tempRoot, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
|
||||
void main().catch((error: unknown) => {
|
||||
console.error('conversation-ipc-regression failed:', error)
|
||||
process.exitCode = 1
|
||||
})
|
||||
@@ -1,155 +0,0 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { EventEmitter } from 'node:events'
|
||||
import { mkdtemp, mkdir, readFile, writeFile, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { createInteractionBroker, respondToInteraction } from '../src/main/interaction-broker'
|
||||
import { executeConversationTool } from '../src/main/agent/conversation-tools'
|
||||
import { runOmpTurn } from '../src/main/agent/omp-turn'
|
||||
import { restoreWorkspaceFiles } from '../src/main/workspace-revert'
|
||||
import { getRevertChanges } from '../src/shared/revert'
|
||||
import { applyAssistantEvent } from '../src/shared/message-parts'
|
||||
import type { ChatEvent, ChatMessage } from '../src/shared/types'
|
||||
import type { ChatInteraction } from '../src/shared/conversation'
|
||||
import type { OmpKernel } from '../src/main/omp'
|
||||
import { assistantWireHistory } from '../src/main/agent/history'
|
||||
|
||||
async function main() {
|
||||
const directory = await mkdtemp(join(tmpdir(), 'ycode-conversation-runtime-'))
|
||||
const workspace = join(directory, 'workspace')
|
||||
await mkdir(workspace)
|
||||
try {
|
||||
const events: ChatEvent[] = []
|
||||
const emitted = new EventEmitter()
|
||||
const emit = (event: ChatEvent) => { events.push(event); emitted.emit('event', event) }
|
||||
const controller = new AbortController()
|
||||
const broker = createInteractionBroker('run', controller.signal, emit)
|
||||
const context = { runId: 'run', toolCallId: 'write', workspaceRoot: workspace, signal: controller.signal, interactions: broker, emit }
|
||||
const nextInteraction = () => new Promise<ChatInteraction>((resolve) => {
|
||||
const listener = (event: ChatEvent) => { if (event.type === 'interaction' && event.interaction.status === 'pending') { emitted.off('event', listener); resolve(event.interaction) } }
|
||||
emitted.on('event', listener)
|
||||
})
|
||||
const write = executeConversationTool('write_file', { path: 'nested/new/file.txt', content: 'first' }, context)
|
||||
const written = await write
|
||||
assert.equal(written.ok, true)
|
||||
assert.equal(await readFile(join(workspace, 'nested/new/file.txt'), 'utf8'), 'first')
|
||||
assert.equal((written.data as { oldContent: unknown }).oldContent, null)
|
||||
assert.equal(events.some(event => event.type === 'interaction'), false, 'host file writes no longer create sandbox approvals')
|
||||
|
||||
const edit = executeConversationTool('edit_file', { path: 'nested/new/file.txt', oldText: 'first', newText: 'second' }, context)
|
||||
assert.equal((await edit).ok, true)
|
||||
assert.equal(await readFile(join(workspace, 'nested/new/file.txt'), 'utf8'), 'second')
|
||||
const outside = join(directory, 'outside')
|
||||
await mkdir(outside)
|
||||
await writeFile(join(outside, 'secret.txt'), 'private')
|
||||
assert.equal((await executeConversationTool('write_file', { path: '../outside/deep/new.txt', content: 'x' }, context)).ok, false)
|
||||
const hostCommand = await executeConversationTool('execute_command', { command: 'echo HOST_COMMAND_OK' }, context)
|
||||
assert.equal(hostCommand.ok, true)
|
||||
assert.match(hostCommand.content, /HOST_COMMAND_OK/)
|
||||
const fullController = new AbortController()
|
||||
const childScript = join(directory, 'child.cjs')
|
||||
const parentScript = join(directory, 'parent.cjs')
|
||||
const pidFile = join(directory, 'child-pid.txt')
|
||||
await writeFile(childScript, `require('node:fs').writeFileSync(${JSON.stringify(pidFile)},String(process.pid));setInterval(()=>{},1000)`)
|
||||
await writeFile(parentScript, `require('node:child_process').spawn(process.execPath,[${JSON.stringify(childScript)}],{windowsHide:true,stdio:'ignore'});process.stdout.write('child started');setInterval(()=>{},1000)`)
|
||||
const fullCommand = executeConversationTool('execute_command', { command: `"${process.execPath}" "${parentScript}"` }, { ...context, signal: fullController.signal })
|
||||
let childPid = 0
|
||||
for (let attempt = 0; attempt < 100; attempt++) {
|
||||
childPid = Number(await readFile(pidFile, 'utf8').catch(() => '0'))
|
||||
if (childPid) break
|
||||
await new Promise((resolve) => setTimeout(resolve, 20))
|
||||
}
|
||||
fullController.abort()
|
||||
const terminated = await fullCommand
|
||||
assert.ok(childPid, 'command should have spawned its own child for cancellation verification')
|
||||
assert.equal(terminated.ok, false)
|
||||
assert.throws(() => process.kill(childPid, 0), 'cancellation must terminate the shell child tree')
|
||||
|
||||
const questionPending = nextInteraction()
|
||||
const questionTask = executeConversationTool('ask_user_question', { questions: [
|
||||
{ id: 'q1', question: 'Question one?', options: ['A', 'B'] },
|
||||
{ id: 'q2', question: 'Question two?', options: ['C', 'D'], multiSelect: true }
|
||||
] }, { ...context, toolCallId: 'question' })
|
||||
const question = await questionPending
|
||||
respondToInteraction('run', question.id, { kind: 'question', answer: { questions: [
|
||||
{ id: 'q1', question: 'untrusted changed label', answers: [] },
|
||||
{ id: 'q2', question: 'Question two?', answers: ['C', 'custom answer'] }
|
||||
] } }, emit)
|
||||
const answered = await questionTask
|
||||
assert.equal(JSON.parse(answered.content).questions[0].question, 'Question one?')
|
||||
assert.deepEqual((answered.metadata!.questionAnswer as { questions: unknown[] }).questions.length, 2)
|
||||
const aborting = new AbortController()
|
||||
const abortBroker = createInteractionBroker('abort', aborting.signal, emit)
|
||||
const cancelled = abortBroker.question('cancel-question', 'Cancel?', [{ id: 'q', question: 'Cancel?', options: ['yes'] }])
|
||||
const abortedAssertion = assert.rejects(cancelled, { name: 'AbortError' })
|
||||
aborting.abort()
|
||||
await abortedAssertion
|
||||
assert.ok(events.some((event) => event.type === 'interaction' && event.interaction.status === 'cancelled'))
|
||||
|
||||
let message: ChatMessage = { id: 'assistant', role: 'assistant', content: '', createdAt: 0, status: 'streaming' }
|
||||
const planEmit = (event: ChatEvent) => { message = applyAssistantEvent(message, event) }
|
||||
const firstPlan = await executeConversationTool('todo_write', { todos: [{ id: '1', content: 'step', status: 'in_progress' }] }, { ...context, emit: planEmit })
|
||||
const lastPlan = await executeConversationTool('todo_write', { todos: [{ id: '1', content: 'step', status: 'completed' }] }, { ...context, emit: planEmit })
|
||||
assert.equal(message.parts, undefined, 'TodoWrite produces a specialized tool result rather than a duplicate plan part')
|
||||
for (const result of [firstPlan, lastPlan]) planEmit({ runId: 'run', type: 'part', part: { type: 'plan', plan: result.data as import('../src/shared/conversation').PlanContent } })
|
||||
assert.equal(message.parts?.length, 1, 'plan updates replace the same plan without duplicated cards')
|
||||
assert.equal(message.parts?.[0].type === 'plan' && message.parts[0].plan.steps[0].status, 'completed')
|
||||
message = applyAssistantEvent(message, { runId: 'run', type: 'done', content: 'complete', tools: [{ id: 'final-only', name: 'read_file', args: {}, argsRaw: '{}', status: 'ok' }] })
|
||||
assert.deepEqual(message.parts?.map((part) => part.type), ['text', 'plan', 'tool'])
|
||||
const exchanges = assistantWireHistory({ id: 'history', role: 'assistant', content: 'beforeafter', createdAt: 0,
|
||||
parts: [{ type: 'text', text: 'before' }, { type: 'tool', toolId: 'read' }, { type: 'text', text: 'after' }],
|
||||
tools: [{ id: 'read', name: 'read_file', args: {}, argsRaw: '{}', status: 'ok', output: 'complete tool output' }] })
|
||||
assert.deepEqual(exchanges.map((entry) => entry.role), ['assistant', 'tool', 'assistant'])
|
||||
assert.equal(exchanges[0].content, 'before')
|
||||
assert.equal(exchanges[1].content, 'complete tool output')
|
||||
assert.equal(exchanges[2].content, 'after')
|
||||
|
||||
await writeFile(join(workspace, 'a.txt'), 'new A')
|
||||
await writeFile(join(workspace, 'b.txt'), 'new B')
|
||||
const history: ChatMessage[] = [{ id: 'user', role: 'user', content: 'change', createdAt: 0 }, { id: 'assistant', role: 'assistant', content: 'done', createdAt: 1, status: 'done', tools: [
|
||||
{ id: 'a', name: 'edit_file', args: {}, argsRaw: '{}', status: 'ok', result: { path: join(workspace, 'a.txt'), oldContent: 'old A', newContent: 'new A' } },
|
||||
{ id: 'b', name: 'write_file', args: {}, argsRaw: '{}', status: 'ok', result: { path: join(workspace, 'b.txt'), oldContent: null, newContent: 'new B' } }
|
||||
] }]
|
||||
const changes = getRevertChanges(history, 'user')
|
||||
await writeFile(join(workspace, 'b.txt'), 'manual B')
|
||||
await assert.rejects(restoreWorkspaceFiles(workspace, changes), /外部修改/)
|
||||
assert.equal(await readFile(join(workspace, 'a.txt'), 'utf8'), 'new A', 'all conflicts are checked before the first write')
|
||||
await writeFile(join(workspace, 'b.txt'), 'new B')
|
||||
const undo = await restoreWorkspaceFiles(workspace, changes)
|
||||
assert.equal(await readFile(join(workspace, 'a.txt'), 'utf8'), 'old A')
|
||||
await assert.rejects(readFile(join(workspace, 'b.txt')), { code: 'ENOENT' })
|
||||
await undo()
|
||||
assert.equal(await readFile(join(workspace, 'b.txt'), 'utf8'), 'new B')
|
||||
|
||||
class HostKernel extends EventEmitter {
|
||||
definitions: unknown[] = []
|
||||
async setHostTools(tools: unknown[]) { this.definitions = tools; return { success: true } }
|
||||
async prompt() {
|
||||
queueMicrotask(() => {
|
||||
this.emit('event', { type: 'tool_execution_start', toolCallId: 'native-read', toolName: 'read_file', args: { path: 'a.txt' } })
|
||||
this.emit('event', { type: 'host_tool_call', id: 'host-request', toolCallId: 'native-read', toolName: 'read_file', arguments: { path: 'a.txt' } })
|
||||
})
|
||||
return { success: true }
|
||||
}
|
||||
async respondToHostTool(id: string, content: string, isError: boolean) {
|
||||
assert.equal(id, 'host-request')
|
||||
assert.equal(isError, false)
|
||||
assert.match(content, /new A/)
|
||||
this.emit('event', { type: 'tool_execution_end', toolCallId: 'native-read', result: { content: [{ type: 'text', text: content }] } })
|
||||
this.emit('event', { type: 'message_update', assistantMessageEvent: { type: 'text_delta', delta: 'Native host complete' } })
|
||||
this.emit('event', { type: 'agent_end' })
|
||||
}
|
||||
async abort() { return { success: true } }
|
||||
}
|
||||
const hostKernel = new HostKernel()
|
||||
const native = await runOmpTurn({ runId: 'native', kernel: hostKernel as unknown as OmpKernel, workspaceRoot: workspace, userText: 'read', signal: controller.signal, emit })
|
||||
assert.equal(native.content, 'Native host complete')
|
||||
assert.equal(hostKernel.definitions.length, 9)
|
||||
assert.ok(hostKernel.definitions.some(tool => tool.name === 'browser'), 'native host registers the browser tool')
|
||||
assert.equal(native.tools[0].status, 'ok')
|
||||
assert.equal((native.tools[0].result as { path: string }).path, join(workspace, 'a.txt'))
|
||||
assert.equal(hostKernel.listenerCount('event'), 0)
|
||||
console.log('conversation-runtime-regression: approval, questions, cancellation, real file writes/revert, path constraints, plan updates and native host adapter passed')
|
||||
} finally { await rm(directory, { recursive: true, force: true }) }
|
||||
}
|
||||
void main().catch((error) => { console.error(error); process.exitCode = 1 })
|
||||
@@ -1,102 +0,0 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { createServer } from 'node:http'
|
||||
import { once } from 'node:events'
|
||||
import { Gateway, type GatewayDelta, type WireMessage } from '../src/main/gateway'
|
||||
import { buildAnthropicRequest, buildGeminiRequest } from '../src/main/gateway/native-protocols'
|
||||
import { discoverModelConnection } from '../src/main/model-connection'
|
||||
import type { GatewayConfig } from '../src/shared/types'
|
||||
|
||||
const checks: string[] = []
|
||||
let mode = 'tools', protocol: 'anthropic' | 'gemini' = 'anthropic', noKey = false
|
||||
const observed: Array<{ path: string; body: any }> = []
|
||||
const sse = (rows: unknown[]): string => rows.map(row => `data: ${JSON.stringify(row)}\n\n`).join('')
|
||||
function anthropic(): unknown[] {
|
||||
const rows: any[] = [
|
||||
{ type: 'message_start', message: { usage: { input_tokens: 10, output_tokens: 0, cache_read_input_tokens: 4, cache_creation_input_tokens: 2 } } },
|
||||
{ type: 'content_block_start', index: 0, content_block: { type: 'thinking', thinking: '' } },
|
||||
{ type: 'content_block_delta', index: 0, delta: { type: 'thinking_delta', thinking: '考虑步骤' } },
|
||||
{ type: 'content_block_delta', index: 0, delta: { type: 'signature_delta', signature: 'controlled-signature' } },
|
||||
{ type: 'content_block_stop', index: 0 },
|
||||
{ type: 'content_block_start', index: 1, content_block: { type: 'tool_use', id: 'native-call', name: 'inspect', input: {} } },
|
||||
{ type: 'content_block_delta', index: 1, delta: { type: 'input_json_delta', partial_json: '{"path":' } },
|
||||
{ type: 'content_block_delta', index: 1, delta: { type: 'input_json_delta', partial_json: '"src"}' } },
|
||||
{ type: 'content_block_stop', index: 1 }
|
||||
]
|
||||
if (mode !== 'truncated') rows.push({ type: 'message_delta', delta: { stop_reason: mode === 'limit' ? 'max_tokens' : 'tool_use' }, usage: { output_tokens: 8 } }, { type: 'message_stop' })
|
||||
if (mode === 'error') rows.push({ type: 'error', error: { message: 'private-key-that-must-not-leak' } })
|
||||
return rows
|
||||
}
|
||||
function gemini(): unknown[] {
|
||||
const rows: any[] = [{ candidates: [{ index: 0, content: { role: 'model', parts: [{ thought: true, text: '考虑步骤' }] } }] },
|
||||
{ candidates: [{ index: 0, content: { role: 'model', parts: [{ functionCall: { id: 'native-call', name: 'inspect', args: { path: 'src' } }, thoughtSignature: 'controlled-signature' }] },
|
||||
...(mode !== 'truncated' ? { finishReason: mode === 'limit' ? 'MAX_TOKENS' : 'STOP' } : {}) }],
|
||||
usageMetadata: { promptTokenCount: 16, candidatesTokenCount: 5, thoughtsTokenCount: 3, totalTokenCount: 24, cachedContentTokenCount: 4 } }]
|
||||
if (mode === 'error') rows.push({ error: { message: 'private-key-that-must-not-leak' } })
|
||||
return rows
|
||||
}
|
||||
const server = createServer(async (req, res) => {
|
||||
const url = new URL(req.url!, 'http://local')
|
||||
if (url.pathname.startsWith('/root-test')) {
|
||||
assert.equal(url.pathname, '/root-test/chat/completions')
|
||||
let bytes = ''; for await (const chunk of req) bytes += chunk
|
||||
const body = JSON.parse(bytes)
|
||||
assert.equal(body.max_completion_tokens, 1024); assert.equal(body.max_tokens, undefined)
|
||||
assert.equal(body.reasoning_effort, 'none'); assert.equal(body.temperature, undefined)
|
||||
res.writeHead(200, { 'content-type': 'text/event-stream' }); res.end(sse([{ choices: [{ delta: { content: 'OK' }, finish_reason: 'stop' }] }]) + 'data: [DONE]\n\n'); return
|
||||
}
|
||||
const authenticated = noKey ? !req.headers.authorization && !req.headers['x-api-key'] && !req.headers['x-goog-api-key']
|
||||
: protocol === 'anthropic' ? req.headers['x-api-key'] === 'controlled-key' : req.headers['x-goog-api-key'] === 'controlled-key'
|
||||
if (!authenticated) { res.writeHead(401); res.end('{}'); return }
|
||||
if (req.method === 'GET') {
|
||||
res.setHeader('content-type', 'application/json')
|
||||
res.end(JSON.stringify(protocol === 'anthropic'
|
||||
? url.searchParams.has('after_id') ? { data: [{ id: 'second' }, { id: 'first' }], has_more: false } : { data: [{ id: 'first' }], has_more: true, last_id: 'first' }
|
||||
: url.searchParams.has('pageToken') ? { models: [{ name: 'models/second', supportedGenerationMethods: ['generateContent'] }, { name: 'models/embedding', supportedGenerationMethods: ['embedContent'] }] }
|
||||
: { models: [{ name: 'models/first', supportedGenerationMethods: ['generateContent'] }], nextPageToken: 'next' })); return
|
||||
}
|
||||
let bytes = ''; for await (const chunk of req) bytes += chunk
|
||||
observed.push({ path: req.url!, body: JSON.parse(bytes) })
|
||||
res.writeHead(200, { 'content-type': 'text/event-stream' }); res.end(sse(protocol === 'anthropic' ? anthropic() : gemini()))
|
||||
})
|
||||
server.listen(0, '127.0.0.1'); await once(server, 'listening')
|
||||
const address = server.address(); assert(address && typeof address === 'object')
|
||||
const root = `http://127.0.0.1:${address.port}`
|
||||
const collect = async (config: GatewayConfig): Promise<GatewayDelta[]> => { const events: GatewayDelta[] = []; for await (const event of new Gateway(config).stream({ messages: [{ role: 'user', content: 'inspect src' }] })) events.push(event); return events }
|
||||
try {
|
||||
const geminiAuto: GatewayConfig = { api: 'google-generative-ai', baseUrl: root, model: 'gemini-3.1-pro-preview', apiKey: 'controlled-key', temperature: 0.5, maxToolRounds: 3,
|
||||
supportsReasoning: true, thinkingLevel: 'auto', reasoningOptions: [{ type: 'effort', values: ['low', 'medium', 'high'] }] }
|
||||
assert.deepEqual(buildGeminiRequest(geminiAuto, { messages: [] }).generationConfig.thinkingConfig, { includeThoughts: true })
|
||||
assert.deepEqual(buildGeminiRequest({ ...geminiAuto, thinkingLevel: 'low' }, { messages: [] }).generationConfig.thinkingConfig, { thinkingLevel: 'LOW', includeThoughts: true })
|
||||
checks.push('Gemini effort-only models use native thinking levels without inventing a token budget for auto')
|
||||
const rootConfig: GatewayConfig = { api: 'openai-completions', baseUrl: root + '/root-test', model: 'gpt-5.4', apiKey: 'controlled-key', temperature: 0.5, maxToolRounds: 3,
|
||||
maxOutputTokens: 1024, supportsReasoning: true, thinkingLevel: 'off', reasoningOptions: [{ type: 'effort', values: ['none', 'low', 'high'] }], temperatureSupported: false }
|
||||
assert((await collect(rootConfig)).some(event => event.type === 'text' && event.text === 'OK'))
|
||||
checks.push('OpenAI fallback honors the negotiated root, modern completion budget and disabled reasoning/temperature')
|
||||
for (protocol of ['anthropic', 'gemini'] as const) {
|
||||
noKey = false; mode = 'tools'
|
||||
const config: GatewayConfig = { api: protocol === 'anthropic' ? 'anthropic-messages' : 'google-generative-ai', baseUrl: root + (protocol === 'anthropic' ? '/v1' : '/v1beta'), model: 'controlled-model', apiKey: 'controlled-key', temperature: 0, maxToolRounds: 3 }
|
||||
const discovery = await discoverModelConnection(root, protocol, 'controlled-key', true)
|
||||
assert.deepEqual(discovery.models.map(model => model.id), ['first', 'second']); checks.push(protocol + ' lists every page, filters non-generative IDs and removes duplicates')
|
||||
const events = await collect(config), native = events.find(event => event.type === 'native_content')!, calls = events.find(event => event.type === 'tool_calls')!
|
||||
assert(native.type === 'native_content' && calls.type === 'tool_calls')
|
||||
assert.deepEqual(calls.calls[0], { id: 'native-call', type: 'function', function: { name: 'inspect', arguments: '{"path":"src"}' } })
|
||||
assert.equal(events.filter(event => event.type === 'reasoning').map(event => event.text).join(''), '考虑步骤')
|
||||
const usage = events.filter(event => event.type === 'usage').at(-1)!
|
||||
assert(usage.type === 'usage'); assert.deepEqual(usage.usage, { inputTokens: 16, outputTokens: 8, totalTokens: 24, cachedTokens: 4 })
|
||||
const messages: WireMessage[] = [{ role: 'user', content: 'inspect src' }, { role: 'assistant', content: null, tool_calls: calls.calls, providerContent: { api: native.api, content: native.content } }, { role: 'tool', tool_call_id: 'native-call', content: 'contents' }]
|
||||
const body = protocol === 'anthropic' ? buildAnthropicRequest(config, { messages }) : buildGeminiRequest(config, { messages })
|
||||
assert(JSON.stringify(body).includes('controlled-signature'))
|
||||
assert(JSON.stringify(body).includes('native-call')); assert(JSON.stringify(body).includes('contents'))
|
||||
checks.push(protocol + ' native tool IDs, arguments, reasoning, signatures and usage survive tool continuation')
|
||||
for (mode of ['truncated', 'limit', 'error']) {
|
||||
const pending: GatewayDelta[] = []
|
||||
await assert.rejects(async () => { for await (const event of new Gateway(config).stream({ messages: [{ role: 'user', content: 'go' }] })) pending.push(event) }, error => error instanceof Error && !error.message.includes('private-key-that-must-not-leak'))
|
||||
assert(!pending.some(event => event.type === 'tool_calls')); checks.push(protocol + ' ' + mode + ' never dispatches incomplete tools or leaks provider error content')
|
||||
}
|
||||
mode = 'tools'; noKey = true
|
||||
assert.equal((await discoverModelConnection(root, protocol, '', true)).models.length, 2)
|
||||
assert((await collect({ ...config, authMode: 'none', apiKey: '' })).some(event => event.type === 'tool_calls'))
|
||||
checks.push(protocol + ' keyless custom endpoints send no authentication headers')
|
||||
}
|
||||
console.log('CUSTOM_MODEL_NATIVE_PASS ' + JSON.stringify({ checks }))
|
||||
} finally { server.closeAllConnections(); await new Promise<void>(resolve => server.close(() => resolve())) }
|
||||
@@ -1,130 +0,0 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { createServer } from 'node:http'
|
||||
import { mkdtemp, readFile } from 'node:fs/promises'
|
||||
import { resolve } from 'node:path'
|
||||
import { once } from 'node:events'
|
||||
import { normalizeModelAddress, endpointCandidates, endpointUrl, modelEndpoint } from '../src/shared/model-endpoint'
|
||||
import { cheapestModelPrice, lookupModelMetadata, type ModelCatalog } from '../src/shared/model-metadata'
|
||||
import { discoverModelConnection, modelFetch } from '../src/main/model-connection'
|
||||
import { Gateway } from '../src/main/gateway'
|
||||
import { buildAnthropicRequest, buildGeminiRequest } from '../src/main/gateway/native-protocols'
|
||||
import { prepareOmpModel, supportsOmpModel } from '../src/main/omp-model'
|
||||
import { OmpKernel, detectOmpPath } from '../src/main/omp'
|
||||
import type { ModelApi, ResolvedModel } from '../src/shared/models'
|
||||
|
||||
const directory = await mkdtemp(resolve('.tmp/custom-model-regression-'))
|
||||
const checks: string[] = []
|
||||
function check(label: string, run: () => void): void { run(); checks.push(label) }
|
||||
const catalog = JSON.parse(await readFile('resources/model-metadata/models.dev.json', 'utf8')) as ModelCatalog
|
||||
const source = JSON.parse(await readFile('resources/model-metadata/manifest.json', 'utf8'))
|
||||
check('domain-only, local origin and full routes normalize', () => {
|
||||
assert.equal(normalizeModelAddress('api.example.com'), 'https://api.example.com')
|
||||
assert.equal(normalizeModelAddress('127.0.0.1:9000'), 'http://127.0.0.1:9000')
|
||||
assert.throws(() => normalizeModelAddress('https://user:password@example.com'))
|
||||
assert.throws(() => normalizeModelAddress('https://example.com?key=secret'))
|
||||
assert(endpointCandidates('https://example.com/proxy', 'anthropic').some(endpoint => endpointUrl(endpoint, 'generate') === 'https://example.com/proxy/v1/messages'))
|
||||
})
|
||||
check('metadata uses the canonical provider and cheapest context tier', () => {
|
||||
const value = lookupModelMetadata(catalog, 'gpt-5.4', 'openai', source)!
|
||||
assert.equal(value.providerId, 'openai'); assert.equal(value.contextWindowTokens, 272000)
|
||||
assert.equal(value.maxInputTokens, 144000); assert(value.supportsImages); assert(value.supportsReasoning)
|
||||
assert.equal(value.price?.input, 2.5); assert.equal(value.price?.output, 15)
|
||||
assert.equal(lookupModelMetadata(catalog, 'openai/gpt-5.4', 'auto', source)?.modelId, 'gpt-5.4')
|
||||
assert.equal(lookupModelMetadata(catalog, 'gpt-5.4-not-a-real-alias', 'auto', source), null)
|
||||
assert.equal(cheapestModelPrice({ id: 'bad', cost: { input: -1, output: 1 } }), undefined)
|
||||
})
|
||||
check('tier thresholds constrain defaults without changing provider', () => {
|
||||
const price = cheapestModelPrice({ id: 'test', limit: { context: 100000 }, cost: { input: 4, output: 8, tiers: [{ input: 1, output: 2, tier: { type: 'context', size: 32000 } }, { input: 5, output: 10, tier: { type: 'context', size: 64000 } }] } })!
|
||||
assert.equal(price.minContext, 32000); assert.equal(price.maxContext, 64000)
|
||||
})
|
||||
|
||||
let api: ModelApi = 'anthropic-messages', anonymous = false
|
||||
const requests: Array<{ api: ModelApi; path: string; body: any }> = []
|
||||
const rejectedAuth: unknown[] = []
|
||||
const anthropic = (model: string): string => [
|
||||
{ type: 'message_start', message: { id: 'msg_test', type: 'message', role: 'assistant', content: [], model, stop_reason: null, stop_sequence: null, usage: { input_tokens: 10, output_tokens: 0 } } },
|
||||
{ type: 'content_block_start', index: 0, content_block: { type: 'text', text: '' } },
|
||||
{ type: 'content_block_delta', index: 0, delta: { type: 'text_delta', text: '协议正常' } },
|
||||
{ type: 'content_block_stop', index: 0 },
|
||||
{ type: 'message_delta', delta: { stop_reason: 'end_turn', stop_sequence: null }, usage: { output_tokens: 2 } },
|
||||
{ type: 'message_stop' }
|
||||
].map(value => `event: ${value.type}\ndata: ${JSON.stringify(value)}\n\n`).join('')
|
||||
const gemini = (): string => 'data: ' + JSON.stringify({ candidates: [{ index: 0, content: { role: 'model', parts: [{ text: '协议正常' }] }, finishReason: 'STOP' }], usageMetadata: { promptTokenCount: 10, candidatesTokenCount: 2, totalTokenCount: 12 } }) + '\n\n'
|
||||
const server = createServer(async (req, res) => {
|
||||
const url = new URL(req.url!, 'http://local')
|
||||
if (url.pathname === '/redirect') { res.writeHead(302, { location: 'http://127.0.0.1:1/trap' }); res.end(); return }
|
||||
if (req.method === 'GET') {
|
||||
const valid = api === 'anthropic-messages' ? url.pathname === '/v1/models' && req.headers['x-api-key'] === 'controlled-test-key'
|
||||
: api === 'google-generative-ai' ? url.pathname === '/v1beta/models' && req.headers['x-goog-api-key'] === 'controlled-test-key' : url.pathname === '/v1/models' && req.headers.authorization === 'Bearer controlled-test-key'
|
||||
res.writeHead(valid ? 200 : 404, { 'content-type': 'application/json' })
|
||||
res.end(JSON.stringify(api === 'google-generative-ai' ? { models: [{ name: 'models/gemini-2.5-flash', displayName: 'Gemini test', supportedGenerationMethods: ['generateContent'] }, { name: 'models/embed', supportedGenerationMethods: ['embedContent'] }] }
|
||||
: { data: [{ id: 'claude-3-5-haiku-latest' }, { id: 'claude-3-5-haiku-latest' }] })); return
|
||||
}
|
||||
let raw = ''; for await (const chunk of req) raw += chunk
|
||||
const authorized = anonymous ? !req.headers['x-api-key'] && !req.headers['x-goog-api-key'] && !req.headers.authorization
|
||||
: api === 'anthropic-messages' ? req.headers['x-api-key'] === 'controlled-test-key' && !req.headers.authorization : req.headers['x-goog-api-key'] === 'controlled-test-key'
|
||||
if (!authorized) { rejectedAuth.push({ api, anonymous, path: req.url, headers: Object.keys(req.headers).filter(name => /api-key|authorization/.test(name)), googleSentinel: req.headers['x-goog-api-key'] === 'N/A' }); res.writeHead(401, { 'content-type': 'application/json' }); res.end(JSON.stringify({ error: { message: 'Incorrect native authentication' } })); return }
|
||||
const body = JSON.parse(raw || '{}'); requests.push({ api, path: req.url!, body })
|
||||
res.writeHead(200, { 'content-type': 'text/event-stream' })
|
||||
res.end(api === 'anthropic-messages' ? anthropic(body.model) : gemini())
|
||||
})
|
||||
server.listen(0, '127.0.0.1'); await once(server, 'listening')
|
||||
const address = server.address(); assert(address && typeof address === 'object')
|
||||
const root = `http://127.0.0.1:${address.port}`
|
||||
try {
|
||||
await assert.rejects(() => modelFetch(root + '/redirect', {}), /不同域名/); checks.push('credentials cannot follow a cross-origin redirect')
|
||||
for (const currentApi of ['anthropic-messages', 'google-generative-ai'] as const) {
|
||||
api = currentApi
|
||||
const discovery = await discoverModelConnection(root, 'auto', 'controlled-test-key', true)
|
||||
assert.equal(discovery.endpoint.api, api); assert.equal(discovery.models.length, 1)
|
||||
checks.push(api + ' model discovery negotiates path and auth, filters and deduplicates')
|
||||
const config = { api, baseUrl: discovery.endpoint.baseUrl, model: api === 'anthropic-messages' ? 'claude-3-5-haiku-latest' : 'gemini-2.5-flash', apiKey: 'controlled-test-key', temperature: 0, maxToolRounds: 2, maxOutputTokens: 128 }
|
||||
const events = []
|
||||
for await (const event of new Gateway(config).stream({ messages: [{ role: 'user', content: 'hello' }] })) events.push(event)
|
||||
assert.equal(events.filter(event => event.type === 'text').map(event => event.text).join(''), '协议正常')
|
||||
assert(events.some(event => event.type === 'usage' && event.usage.totalTokens === 12))
|
||||
checks.push(api + ' fallback gateway streams native text and usage')
|
||||
const body = api === 'anthropic-messages' ? buildAnthropicRequest(config, { messages: [{ role: 'user', content: [{ type: 'image_url', image_url: { url: 'data:image/png;base64,AAAA' } }] }] })
|
||||
: buildGeminiRequest(config, { messages: [{ role: 'user', content: [{ type: 'image_url', image_url: { url: 'data:image/png;base64,AAAA' } }] }] })
|
||||
assert(JSON.stringify(body).includes('AAAA')); checks.push(api + ' image input preserves base64 data')
|
||||
|
||||
const executable = detectOmpPath(); assert(executable, 'Real OMP required for native protocol acceptance')
|
||||
for (const authMode of ['api-key', 'none'] as const) {
|
||||
anonymous = authMode === 'none'
|
||||
const resolved: ResolvedModel = { profile: { id: config.model, providerId: 'custom', name: config.model, model: config.model, baseUrl: root, protocol: api === 'anthropic-messages' ? 'anthropic' : 'gemini', authMode, hasApiKey: !anonymous, keyStorage: 'none', supportsReasoning: true, supportsImages: true }, config: { ...config, authMode, apiKey: anonymous ? '' : config.apiKey } }
|
||||
if (!supportsOmpModel(resolved)) {
|
||||
assert.equal(api, 'google-generative-ai'); assert.equal(authMode, 'none')
|
||||
await assert.rejects(() => prepareOmpModel(resolved), /原生 HTTP/)
|
||||
checks.push('keyless Gemini explicitly routes to the native HTTP gateway rather than sending OMP sentinel credentials')
|
||||
continue
|
||||
}
|
||||
process.env.YCODE_OMP_HOME = directory
|
||||
const managed = await prepareOmpModel(resolved), frames: string[] = [], errors: string[] = []
|
||||
assert(!(await readFile(resolve(directory, '.omp/profiles', managed.profile, 'agent/models.yml'), 'utf8')).includes(config.apiKey), 'native profile must never store the API key')
|
||||
const kernel = new OmpKernel(executable, directory, managed.profile, undefined, managed.model, managed.env)
|
||||
kernel.on('error', error => errors.push(String(error)))
|
||||
kernel.on('stderr', value => { if (/error/i.test(String(value))) errors.push(String(value).slice(0, 300)) })
|
||||
const start = requests.length
|
||||
try {
|
||||
await kernel.start()
|
||||
const auto = await kernel.send({ type: 'set_thinking_level', level: 'auto' })
|
||||
assert.equal(auto.success, true, 'OMP must accept automatic thinking intensity')
|
||||
await kernel.setThinkingLevel('minimal')
|
||||
const end = new Promise<void>((resolve, reject) => {
|
||||
const timer = setTimeout(() => reject(Error('Native protocol turn timeout: ' + JSON.stringify({ api, paths: requests.slice(start).map(r => r.path), frames, errors }))), 18000)
|
||||
kernel.on('event', frame => { frames.push(frame.type); if (frame.type === 'agent_end') { clearTimeout(timer); resolve() } })
|
||||
})
|
||||
await kernel.prompt('Reply briefly.'); await end
|
||||
assert(requests.length > start, JSON.stringify({ api, authMode, frames, errors, rejectedAuth }))
|
||||
const request = requests[start]
|
||||
console.log(JSON.stringify({ api, nativePath: request.path, thinking: request.body.thinking ?? request.body.generationConfig?.thinkingConfig, expectedRoot: discovery.endpoint.baseUrl, frameTypes: [...new Set(frames)], errors }))
|
||||
const nativeUrl = new URL(request.path, root)
|
||||
assert.equal(nativeUrl.pathname, api === 'anthropic-messages' ? '/v1/messages' : '/v1beta/models/gemini-2.5-flash:streamGenerateContent')
|
||||
if (api === 'google-generative-ai') assert.equal(nativeUrl.searchParams.get('alt'), 'sse')
|
||||
checks.push(api + ' real OMP native adapter completes using the negotiated route and ' + authMode + ' authentication')
|
||||
} finally { await kernel.dispose() }
|
||||
}
|
||||
anonymous = false
|
||||
}
|
||||
console.log('CUSTOM_MODEL_REGRESSION_PASS ' + JSON.stringify({ directory, checks }))
|
||||
} finally { server.closeAllConnections(); await new Promise<void>(resolve => server.close(() => resolve())) }
|
||||
@@ -1,115 +0,0 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { useChatStore } from '../src/renderer/src/stores/chat'
|
||||
import { clearExpertInputPrefill, expertInputUpdate } from '../src/renderer/src/capabilities/expert-input'
|
||||
|
||||
const events: Array<{ owner: string; prompt: string; explicit: boolean }> = []
|
||||
const writes: Array<{ owner: string; expertId: string | null }> = []
|
||||
const calls: string[] = []
|
||||
const gates = new Map<string, ReturnType<typeof deferred<any>>>()
|
||||
let failWrite = false
|
||||
function deferred<T>() { let resolve!: (value: T) => void, reject!: (error: Error) => void; const promise = new Promise<T>((yes, no) => { resolve = yes; reject = no }); return { promise, resolve, reject } }
|
||||
function activation(id: string) { return { id, name: id, expertType: 'agent' as const, runtimeIdentity: 'expert-v2:' + id, defaultInitPrompt: { zh: '默认提问:' + id } } }
|
||||
const sessions = [
|
||||
{ id: 'one', title: 'one', createdAt: 1, updatedAt: 1, workspaceId: 'space-one', cwd: 'C:/fixture', modelId: 'custom-model', expertId: 'old-expert' },
|
||||
{ id: 'two', title: 'two', createdAt: 2, updatedAt: 2 }
|
||||
]
|
||||
Object.defineProperty(globalThis, 'CustomEvent', { configurable: true, value: class { type: string; detail: unknown; constructor(type: string, options: { detail: unknown }) { this.type = type; this.detail = options.detail } } })
|
||||
Object.defineProperty(globalThis, 'window', { configurable: true, value: {
|
||||
dispatchEvent(event: { type: string; detail: any }) { if (event.type === 'ycode:expert-input') events.push(event.detail); return true },
|
||||
ycode: { experts: { activate: async (id: string) => { calls.push(id); return gates.get(id)?.promise ?? activation(id) } },
|
||||
session: { messages: async () => [], updateContext: async (owner: string, patch: any) => { if (failWrite) throw Error('disk unavailable'); writes.push({ owner, expertId: patch.expertId }); return { ...sessions.find(session => session.id === owner), ...patch } } } }
|
||||
} })
|
||||
function reset(owner: string | null = null) {
|
||||
useChatStore.getState().startTask({ cwd: 'C:/draft', workspaceId: 'draft-space', modelId: 'draft-model' })
|
||||
useChatStore.setState({ sessions: structuredClone(sessions), currentSessionId: owner, messages: [], sessionMessages: {}, running: null, sending: false, runs: {}, sendingSessions: [] })
|
||||
events.length = 0; writes.length = 0; calls.length = 0; gates.clear(); failWrite = false
|
||||
}
|
||||
let checks = 0
|
||||
function eq(actual: unknown, expected: unknown, label: string) { assert.deepEqual(actual, expected, label); checks++ }
|
||||
|
||||
const empty = { text: '', attachments: [] as unknown[] }
|
||||
eq(expertInputUpdate(empty, 'A').text, 'A', 'empty draft gets default')
|
||||
eq(expertInputUpdate({ ...empty, text: 'user question' }, 'A').text, 'user question', 'user text survives')
|
||||
eq(expertInputUpdate({ ...empty, text: 'A', autoFilledPrompt: 'A' }, 'B').text, 'B', 'unchanged expert default replaced')
|
||||
eq(expertInputUpdate({ ...empty, text: 'A edited', autoFilledPrompt: 'A' }, 'B').text, 'A edited', 'edited default survives')
|
||||
eq(expertInputUpdate({ ...empty, attachments: ['image'] }, 'A').text, '', 'non-text-only attachment survives without autofill')
|
||||
eq(expertInputUpdate({ ...empty, resources: ['file'] }, 'A').text, '', 'non-text-only reference survives without autofill')
|
||||
eq(expertInputUpdate({ ...empty, skills: ['skill'] }, 'A').text, '', 'manual skill-only input survives')
|
||||
eq(expertInputUpdate({ ...empty, text: 'A', autoFilledPrompt: 'A', attachments: ['image'] }, 'B').attachments, ['image'], 'replacing default keeps attachments')
|
||||
eq(expertInputUpdate({ ...empty, text: 'edit', editingId: 'old-message' }, 'A').text, 'edit', 'editing message preserved')
|
||||
eq(expertInputUpdate({ ...empty, text: 'user', attachments: ['image'] }, 'explicit', true).text, 'explicit', 'explicit quick prompt adopted')
|
||||
eq(expertInputUpdate({ ...empty, text: 'A', autoFilledPrompt: 'A' }, '').text, 'A', 'expert without default keeps input')
|
||||
eq(expertInputUpdate({ ...empty, text: 'A', autoFilledPrompt: 'A' }, '', false, true).text, '', 'conversation expert without default clears old prefill')
|
||||
eq(expertInputUpdate({ ...empty, text: 'user edited', autoFilledPrompt: 'A' }, '', false, true).text, 'user edited', 'conversation expert without default preserves user text')
|
||||
eq(clearExpertInputPrefill({ ...empty, text: 'A', autoFilledPrompt: 'A' }).text, '', 'conversation clear removes untouched prefill')
|
||||
eq(clearExpertInputPrefill({ ...empty, text: 'A edited', autoFilledPrompt: 'A' }).text, 'A edited', 'conversation clear preserves edited text')
|
||||
|
||||
reset()
|
||||
const draft = useChatStore.getState().draftId
|
||||
await useChatStore.getState().selectExpert('cpp')
|
||||
eq(useChatStore.getState().draftId, draft, 'picker binds same unsent owner')
|
||||
eq(useChatStore.getState().draftContext, { cwd: 'C:/draft', workspaceId: 'draft-space', modelId: 'draft-model', expertId: 'cpp', agentMode: 'craft' }, 'model and workspace preserved and expert resumes craft')
|
||||
eq(events, [{ owner: `new-${draft}`, prompt: '默认提问:cpp', explicit: false }], 'single validated activation supplies default')
|
||||
eq(writes.length, 0, 'no persisted empty conversation created')
|
||||
await useChatStore.getState().selectExpert('cpp')
|
||||
eq(calls.length, 2, 'explicit repeat refreshes activation like source provider')
|
||||
await useChatStore.getState().selectExpert(null)
|
||||
eq(useChatStore.getState().draftContext.expertId, null, 'remove pending expert')
|
||||
eq(events.length, 2, 'homepage remove never rewrites input')
|
||||
|
||||
reset('one')
|
||||
await useChatStore.getState().summonExpert('pm')
|
||||
eq(writes, [{ owner: 'one', expertId: 'pm' }], 'market return binds existing conversation')
|
||||
eq(useChatStore.getState().currentSessionId, 'one', 'return retains conversation')
|
||||
eq(events.length, 1, 'market return offers default with user input protection in composer')
|
||||
eq(useChatStore.getState().sessions[0].modelId, 'custom-model', 'conversation model unchanged')
|
||||
await useChatStore.getState().selectExpert('ui')
|
||||
eq(events.at(-1)?.owner, 'one', 'picker targets same conversation')
|
||||
eq(events.at(-1)?.prompt, '默认提问:ui', 'picker default gated by current input in composer')
|
||||
|
||||
reset('one')
|
||||
await useChatStore.getState().summonExpert('algo', { newTask: true })
|
||||
eq(useChatStore.getState().currentSessionId, null, 'standalone market summon prepares homepage')
|
||||
eq(useChatStore.getState().draftContext, { workspaceId: 'space-one', cwd: 'C:/fixture', modelId: 'custom-model', expertId: 'algo', agentMode: 'craft' }, 'new expert task inherits model and space and uses craft')
|
||||
eq(writes.length, 0, 'standalone summon leaves old session unchanged')
|
||||
eq(events.at(-1)?.owner, `new-${useChatStore.getState().draftId}`, 'default follows new owner immediately')
|
||||
|
||||
reset('one'); failWrite = true
|
||||
await assert.rejects(useChatStore.getState().selectExpert('bad'), /disk unavailable/); checks++
|
||||
eq(useChatStore.getState().sessions[0].expertId, 'old-expert', 'failed persist retains prior expert')
|
||||
eq(events.length, 0, 'failed persist leaves draft untouched')
|
||||
|
||||
reset(); const bad = deferred<any>(); gates.set('bad', bad)
|
||||
const rejected = useChatStore.getState().selectExpert('bad'); bad.reject(Error('missing dependency'))
|
||||
await assert.rejects(rejected, /missing dependency/); checks++
|
||||
eq(useChatStore.getState().draftContext.expertId, undefined, 'failed activation does not bind')
|
||||
eq(events.length, 0, 'failed activation never inserts default')
|
||||
|
||||
reset(); const slow = deferred<any>(); gates.set('slow', slow)
|
||||
const stale = useChatStore.getState().selectExpert('slow')
|
||||
await useChatStore.getState().selectExpert('latest')
|
||||
slow.resolve(activation('slow'))
|
||||
await assert.rejects(stale, /选择已切换/); checks++
|
||||
eq(useChatStore.getState().draftContext.expertId, 'latest', 'late activation cannot overwrite latest')
|
||||
eq(events.map(event => event.prompt), ['默认提问:latest'], 'late activation cannot overwrite text')
|
||||
|
||||
reset(); const shared = deferred<any>(); gates.set('same', shared)
|
||||
const first = useChatStore.getState().selectExpert('same'), second = useChatStore.getState().selectExpert('same')
|
||||
const obsolete = assert.rejects(first, /选择已切换/)
|
||||
shared.resolve(activation('same')); await Promise.all([obsolete, second]); checks++
|
||||
eq(calls, ['same'], 'concurrent same expert shares activation')
|
||||
eq(events.length, 1, 'only newest concurrent selection publishes')
|
||||
|
||||
reset('one'); const late = deferred<any>(); gates.set('late', late)
|
||||
const departed = useChatStore.getState().selectExpert('late')
|
||||
await useChatStore.getState().selectSession('two'); late.resolve(activation('late'))
|
||||
await assert.rejects(departed, /任务或专家选择已切换/); checks++
|
||||
eq(writes.length, 0, 'changing conversation prevents stale bind')
|
||||
eq(events.length, 0, 'changing conversation prevents stale prompt')
|
||||
|
||||
reset('one'); useChatStore.setState({ running: { runId: 'running', assistantId: 'assistant', sessionId: 'one' } })
|
||||
await assert.rejects(useChatStore.getState().selectExpert('next'), /先停止/); checks++
|
||||
eq(calls.length, 0, 'running conversation switch rejected before activation')
|
||||
await useChatStore.getState().summonExpert('next', { newTask: true })
|
||||
eq(useChatStore.getState().draftContext.expertId, 'next', 'independent new task may be prepared during another turn')
|
||||
console.log(JSON.stringify({ result: 'PASS', checks, scope: 'production Zustand expert state and composer input policy with controlled IPC; no model requests' }))
|
||||
@@ -1,4 +1,4 @@
|
||||
// Deterministic UI data. Native/OMP execution is verified separately by the runtime regressions.
|
||||
// Deterministic UI data. Native kernel execution is verified separately by the runtime regressions.
|
||||
const now = Date.now()
|
||||
const message = (id, role, content, extra = {}) => ({ id, role, content, createdAt: now, status: 'done', ...extra })
|
||||
const tool = (id, name, args, result) => ({ id, name, args, argsRaw: JSON.stringify(args), status: 'ok', result, output: typeof result === 'string' ? result : JSON.stringify(result) })
|
||||
|
||||
@@ -1,152 +0,0 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { createServer, type ServerResponse } from 'node:http'
|
||||
import { once } from 'node:events'
|
||||
import { mkdtemp, writeFile } from 'node:fs/promises'
|
||||
import { resolve, join, relative, isAbsolute } from 'node:path'
|
||||
import { Gateway, GatewayError, type GatewayDelta, type WireMessage } from '../src/main/gateway'
|
||||
import { runTurn } from '../src/main/agent/loop'
|
||||
import { responsesInput } from '../src/main/gateway/responses'
|
||||
import type { GatewayConfig, ChatEvent } from '../src/shared/types'
|
||||
|
||||
const directory = await mkdtemp(resolve('.tmp/gateway-responses-'))
|
||||
const rel = relative(resolve('.tmp'), directory)
|
||||
assert.ok(!isAbsolute(rel) && !rel.startsWith('..') && rel.startsWith('gateway-responses-'))
|
||||
await writeFile(join(directory, 'proof.txt'), 'only controlled local data')
|
||||
const secret = 'controlled-secret-must-not-appear-in-errors'
|
||||
const requests: Array<{ path: string; body: any; authorizationPresent: boolean }> = []
|
||||
const checks: string[] = []
|
||||
const check = (name: string, actual: unknown, expected: unknown): void => { assert.deepEqual(actual, expected, name); checks.push(name) }
|
||||
const payload = (type: string, values: Record<string, unknown> = {}): Record<string, unknown> => ({ type, ...values })
|
||||
const frame = (data: unknown, event?: string): string => `${event ? 'event: ' + event + '\r\n' : ''}data: ${JSON.stringify(data)}\r\n\r\n`
|
||||
const complete = (output: unknown[] = []): Record<string, unknown> => payload('response.completed', { response: { id: 'response-controlled', status: 'completed', output } })
|
||||
const functionItem = { type: 'function_call', id: 'fc_internal', call_id: 'call_public', name: 'list_dir', arguments: '{"path":"."}', status: 'completed' }
|
||||
const messageItem = (text: string, id = 'msg_1'): Record<string, unknown> => ({ type: 'message', id, role: 'assistant', status: 'completed', content: [{ type: 'output_text', text, annotations: [] }] })
|
||||
const reasoningItem = { type: 'reasoning', id: 'rs_1', summary: [{ type: 'summary_text', text: '先看目录。' }] }
|
||||
async function send(res: ServerResponse, events: unknown[], tail = ''): Promise<void> {
|
||||
res.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-cache' })
|
||||
const data = Buffer.from(': keepalive\r\n\r\ndata: invalid-json-heartbeat\r\n\r\n' + events.map(item => frame(item)).join('') + tail)
|
||||
// Actual HTTP chunks split UTF-8 glyphs, CRLF boundaries and JSON tokens.
|
||||
for (let start = 0; start < data.length; start += 13) {
|
||||
if (res.destroyed) return
|
||||
if (!res.write(data.subarray(start, start + 13))) await once(res, 'drain')
|
||||
if (start % 143 === 0) await new Promise<void>(resolve => setImmediate(resolve))
|
||||
}
|
||||
res.end()
|
||||
}
|
||||
const server = createServer((req, res) => {
|
||||
void (async () => {
|
||||
let raw = ''; for await (const chunk of req) raw += chunk
|
||||
const body = JSON.parse(raw), url = new URL(req.url ?? '', 'http://127.0.0.1'), mode = url.searchParams.get('case')
|
||||
requests.push({ path: req.url ?? '', body, authorizationPresent: !!req.headers.authorization })
|
||||
if (url.pathname.endsWith('/chat/completions')) {
|
||||
await send(res, [{ choices: [{ delta: { content: '旧 Chat 保留' }, finish_reason: 'stop' }] }], 'data: [DONE]\n\n'); return
|
||||
}
|
||||
assert.equal(url.pathname, '/v1/responses')
|
||||
if (mode === 'http-error') { res.writeHead(429, { 'content-type': 'application/json' }); res.end(JSON.stringify({ error: { message: secret } })); return }
|
||||
if (mode === 'stream-error') { await send(res, [payload('error', { message: secret, code: 'provider_failure' })]); return }
|
||||
if (mode === 'failed') { await send(res, [payload('response.failed', { response: { status: 'failed', error: { message: secret } } })]); return }
|
||||
if (mode === 'incomplete') { await send(res, [payload('response.output_item.added', { output_index: 0, item: { ...functionItem, status: 'in_progress' } }), payload('response.incomplete', { response: { status: 'incomplete', incomplete_details: { reason: 'max_output_tokens' } } })]); return }
|
||||
if (mode === 'wrong-status') { await send(res, [payload('response.completed', { response: { status: 'failed', error: { message: secret } } })]); return }
|
||||
if (mode === 'missing-status' || mode === 'missing-output') {
|
||||
await send(res, [payload('response.output_item.done', { output_index: 0, item: functionItem }),
|
||||
payload('response.completed', { response: mode === 'missing-status' ? { output: [functionItem] } : { status: 'completed' } })]); return
|
||||
}
|
||||
if (mode === 'truncated') { await send(res, [payload('response.output_text.delta', { item_id: 'msg_1', output_index: 0, content_index: 0, delta: '部分文本' })], 'data: [DONE]\n\n'); return }
|
||||
if (mode === 'cancel') {
|
||||
res.writeHead(200, { 'content-type': 'text/event-stream' })
|
||||
res.write(frame(payload('response.output_text.delta', { item_id: 'msg_1', output_index: 0, content_index: 0, delta: '取消前文本' })))
|
||||
return
|
||||
}
|
||||
if (mode === 'tool') {
|
||||
if (body.input.some((item: any) => item.type === 'function_call_output')) {
|
||||
await send(res, [payload('response.output_text.delta', { item_id: 'msg_final', output_index: 0, content_index: 0, delta: '完成读取。' }), complete([messageItem('完成读取。', 'msg_final')])]); return
|
||||
}
|
||||
await send(res, [
|
||||
payload('response.created', { response: { status: 'in_progress' } }),
|
||||
payload('response.output_item.added', { output_index: 0, item: { type: 'reasoning', id: 'rs_1', summary: [] } }),
|
||||
payload('response.reasoning_summary_text.delta', { item_id: 'rs_1', output_index: 0, summary_index: 0, delta: '先看' }),
|
||||
payload('response.reasoning_summary_text.delta', { item_id: 'rs_1', output_index: 0, summary_index: 0, delta: '目录。' }),
|
||||
payload('response.reasoning_summary_text.done', { item_id: 'rs_1', output_index: 0, summary_index: 0, text: '先看目录。' }),
|
||||
payload('response.output_item.done', { output_index: 0, item: reasoningItem }),
|
||||
payload('response.output_text.delta', { item_id: 'msg_1', output_index: 1, content_index: 0, delta: '查看' }),
|
||||
payload('response.output_text.done', { item_id: 'msg_1', output_index: 1, content_index: 0, text: '查看目录。' }),
|
||||
payload('response.output_item.added', { output_index: 2, item: { ...functionItem, arguments: '', status: 'in_progress' } }),
|
||||
payload('response.function_call_arguments.delta', { item_id: 'fc_internal', output_index: 2, delta: '{"pa' }),
|
||||
payload('response.function_call_arguments.delta', { item_id: 'fc_internal', output_index: 2, delta: 'th":"."}' }),
|
||||
payload('response.function_call_arguments.done', { item_id: 'fc_internal', output_index: 2, arguments: '{"path":"."}' }),
|
||||
payload('response.output_item.done', { output_index: 2, item: functionItem }),
|
||||
complete([reasoningItem, messageItem('查看目录。'), functionItem])]); return
|
||||
}
|
||||
if (mode === 'snapshot') {
|
||||
await send(res, [complete([reasoningItem, messageItem('仅完成快照。')])]); return
|
||||
}
|
||||
if (mode === 'parallel') {
|
||||
await send(res, [payload('response.function_call_arguments.delta', { output_index: 1, item_id: 'fc_second', delta: '{"b":' }),
|
||||
payload('response.output_item.added', { output_index: 0, item: { type: 'function_call', id: 'fc_first', call_id: 'call_first', name: 'one', arguments: '', status: 'in_progress' } }),
|
||||
payload('response.function_call_arguments.done', { output_index: 0, item_id: 'fc_first', arguments: '{"a":1}' }),
|
||||
payload('response.output_item.done', { output_index: 0, item: { type: 'function_call', id: 'fc_first', call_id: 'call_first', name: 'one', arguments: '{"a":1}' } }),
|
||||
payload('response.output_item.done', { output_index: 1, item: { type: 'function_call', id: 'fc_second', call_id: 'call_second', name: 'two', arguments: '{"b":2}' } }), complete()]); return
|
||||
}
|
||||
if (mode === 'invalid-call') { await send(res, [complete([{ ...functionItem, arguments: '{' }])]); return }
|
||||
if (mode === 'missing-id') { await send(res, [complete([{ ...functionItem, call_id: '' }])]); return }
|
||||
await send(res, [payload('response.output_text.delta', { output_index: 0, content_index: 0, delta: '你好' }),
|
||||
payload('response.output_text.done', { item_id: 'msg_1', output_index: 0, content_index: 0, text: '你好,Responses。' }),
|
||||
payload('response.content_part.done', { item_id: 'msg_1', output_index: 0, content_index: 0, part: { type: 'output_text', text: '你好,Responses。' } }),
|
||||
complete([messageItem('你好,Responses。')])])
|
||||
})().catch(() => { res.destroy() })
|
||||
})
|
||||
server.listen(0, '127.0.0.1'); await once(server, 'listening')
|
||||
const address = server.address(); assert.ok(address && typeof address === 'object')
|
||||
const base = `http://127.0.0.1:${address.port}`
|
||||
const config = (mode: string, suffix = '/v1/responses'): GatewayConfig => ({ baseUrl: `${base}${suffix}?case=${mode}`, model: 'gpt-6-luna', apiKey: secret, temperature: 0.3, maxToolRounds: 4, maxOutputTokens: 1024 })
|
||||
const request = { messages: [{ role: 'user' as const, content: '受控测试' }] }
|
||||
async function collect(gateway: Gateway, input = request): Promise<GatewayDelta[]> { const result: GatewayDelta[] = []; for await (const delta of gateway.stream(input)) result.push(delta); return result }
|
||||
const joined = (deltas: GatewayDelta[], type: 'text' | 'reasoning'): string => deltas.filter(delta => delta.type === type).map(delta => 'text' in delta ? delta.text : '').join('')
|
||||
try {
|
||||
const events: ChatEvent[] = []
|
||||
const result = await runTurn({ runId: 'controlled-responses', history: [], userText: '列出本地受控文件', signal: new AbortController().signal,
|
||||
emit: event => events.push(event), gateway: new Gateway(config('tool')), model: 'gpt-6-luna', maxToolRounds: 4, workspaceRoot: directory })
|
||||
check('real fallback loop executes function and completes next response', [result.content, result.tools.length, result.tools[0].status], ['查看目录。完成读取。', 1, 'ok'])
|
||||
const rounds = requests.filter(req => req.path.includes('case=tool'))
|
||||
check('complete endpoint has exactly two rounds and no repeated protocol path', rounds.map(req => req.path), ['/v1/responses?case=tool', '/v1/responses?case=tool'])
|
||||
check('function tools flatten and keep optional parameter semantics', [rounds[0].body.tools[0].type, rounds[0].body.tools[0].name, rounds[0].body.tools[0].strict, rounds[0].body.tools[0].function], ['function', 'list_dir', false, undefined])
|
||||
check('Responses token/temperature/model fields', [rounds[0].body.max_output_tokens, rounds[0].body.max_tokens, rounds[0].body.temperature, rounds[0].body.model, rounds[0].body.store], [1024, undefined, 0.3, 'gpt-6-luna', false])
|
||||
const call = rounds[1].body.input.find((item: any) => item.type === 'function_call'), output = rounds[1].body.input.find((item: any) => item.type === 'function_call_output')
|
||||
check('history replays public call_id, exact JSON and matching output', [call.call_id, call.arguments, output.call_id, output.output.includes('proof.txt')], ['call_public', '{"path":"."}', 'call_public', true])
|
||||
check('reasoning summary deltas do not duplicate snapshots', events.filter(event => event.type === 'part' && event.part.type === 'reasoning').at(-1)?.type === 'part' ? (events.filter(event => event.type === 'part' && event.part.type === 'reasoning').at(-1) as any).part.text : '', '先看目录。')
|
||||
check('fallback emits done exactly once and no error', [events.filter(event => event.type === 'done').length, events.filter(event => event.type === 'error').length], [1, 0])
|
||||
const multimodal = await collect(new Gateway(config('text')), { messages: [{ role: 'user', content: [{ type: 'text', text: '看图' }, { type: 'image_url', image_url: { url: 'data:image/png;base64,Y29udHJvbGxlZA==' } }] }] })
|
||||
const multimodalRequest = requests.at(-1)!
|
||||
check('image/text convert to Responses input content', multimodalRequest.body.input[0].content, [{ type: 'input_text', text: '看图' }, { type: 'input_image', image_url: 'data:image/png;base64,Y29udHJvbGxlZA==', detail: 'auto' }])
|
||||
check('no tools means no tools/tool_choice keys', [multimodalRequest.body.tools, multimodalRequest.body.tool_choice], [undefined, undefined])
|
||||
check('text delta/done/part/item/completed snapshots deduplicate even late item id', [joined(multimodal, 'text'), multimodal.at(-1)], ['你好,Responses。', { type: 'finish', reason: 'stop' }])
|
||||
const snapshots = await collect(new Gateway(config('snapshot')))
|
||||
check('snapshot-only response can deliver text/reasoning', [joined(snapshots, 'text'), joined(snapshots, 'reasoning')], ['仅完成快照。', '先看目录。'])
|
||||
const parallel = await collect(new Gateway(config('parallel'))), calls = parallel.find(delta => delta.type === 'tool_calls')
|
||||
check('parallel calls reconcile by item/index and order by output_index', calls?.type === 'tool_calls' ? calls.calls.map(call => [call.id, call.function.name, call.function.arguments]) : [], [['call_first', 'one', '{"a":1}'], ['call_second', 'two', '{"b":2}']])
|
||||
check('tool finish is emitted once', parallel.filter(delta => delta.type === 'finish'), [{ type: 'finish', reason: 'tool_calls' }])
|
||||
for (const mode of ['http-error', 'stream-error', 'failed', 'wrong-status', 'missing-status', 'missing-output', 'incomplete', 'truncated', 'invalid-call', 'missing-id']) {
|
||||
const deltas: GatewayDelta[] = []
|
||||
let error: unknown
|
||||
try { for await (const delta of new Gateway(config(mode)).stream(request)) deltas.push(delta) } catch (reason) { error = reason }
|
||||
check(`${mode}: throws GatewayError without successful finish or executable calls`, [error instanceof GatewayError, deltas.some(delta => delta.type === 'finish' || delta.type === 'tool_calls'), String(error).includes(secret), (error as GatewayError).body], [true, false, false, undefined])
|
||||
if (mode === 'http-error') check('HTTP failure retains safe status', (error as GatewayError).status, 429)
|
||||
}
|
||||
const failedEvents: ChatEvent[] = []
|
||||
await assert.rejects(runTurn({ runId: 'incomplete-turn', history: [], userText: '受控未完成', signal: new AbortController().signal,
|
||||
emit: event => failedEvents.push(event), gateway: new Gateway(config('incomplete')), model: 'gpt-6-luna', maxToolRounds: 2, workspaceRoot: directory }), GatewayError)
|
||||
check('incomplete fallback loop emits error rather than done/tool execution', [failedEvents.some(event => event.type === 'error'), failedEvents.some(event => event.type === 'done' || event.type === 'tool_call')], [true, false])
|
||||
const abort = new AbortController(), cancelled: GatewayDelta[] = []
|
||||
await assert.rejects(async () => { for await (const delta of new Gateway(config('cancel')).stream({ ...request, signal: abort.signal })) { cancelled.push(delta); abort.abort() } }, { name: 'AbortError' })
|
||||
check('abort preserves partial text and never finishes', [joined(cancelled, 'text'), cancelled.some(delta => delta.type === 'finish')], ['取消前文本', false])
|
||||
for (const suffix of ['/v1', '/v1/chat/completions/']) {
|
||||
const chat = await collect(new Gateway(config('chat', suffix)))
|
||||
check('legacy Chat endpoint and stream preserved: ' + suffix, [requests.at(-1)!.path, joined(chat, 'text'), requests.at(-1)!.body.max_tokens, requests.at(-1)!.body.max_output_tokens, requests.at(-1)!.body.messages], ['/v1/chat/completions?case=chat', '旧 Chat 保留', 1024, undefined, request.messages])
|
||||
}
|
||||
const history: WireMessage[] = [{ role: 'system', content: 'system' }, { role: 'assistant', content: null, tool_calls: [{ id: 'public', type: 'function', function: { name: 'f', arguments: '{}' } }] }, { role: 'tool', tool_call_id: 'public', content: 'ok' }, { role: 'assistant', content: 'reply' }]
|
||||
check('system/assistant/tool history has no null assistant tool-only message', responsesInput(history), [{ role: 'system', content: 'system' }, { type: 'function_call', call_id: 'public', name: 'f', arguments: '{}' }, { type: 'function_call_output', call_id: 'public', output: 'ok' }, { role: 'assistant', content: 'reply' }])
|
||||
assert.throws(() => responsesInput([{ role: 'tool', content: 'missing' }]), /调用 ID/); checks.push('missing historical tool call id fails locally')
|
||||
const report = { result: 'PASS', scope: 'Controlled loopback HTTP + actual Gateway/fallback runTurn/local list_dir; no real user endpoint or OMP', checks, requestCount: requests.length }
|
||||
await writeFile(join(directory, 'report.json'), JSON.stringify(report, null, 2))
|
||||
console.log(JSON.stringify({ ...report, directory }))
|
||||
} finally { server.closeAllConnections(); await new Promise<void>(resolve => server.close(() => resolve())) }
|
||||
@@ -68,7 +68,7 @@ async function main(): Promise<void> {
|
||||
await writeFixture(reportPath, '# Updated report\n')
|
||||
await writeFixture(tablePath, 'name,value\na,1\n')
|
||||
await writeFixture(resultPath, 'written result')
|
||||
await writeFixture(nativeWritePath, 'native OMP write')
|
||||
await writeFixture(nativeWritePath, 'native tool write')
|
||||
|
||||
const messagesA: ChatMessage[] = [
|
||||
message('user-presentation', 500, {
|
||||
@@ -188,7 +188,7 @@ async function main(): Promise<void> {
|
||||
const nativeWriteMessages = [message('native-write-message', 3_500, {
|
||||
tools: [tool('native-write', 'write', {
|
||||
path: 'native/native-write.md',
|
||||
content: 'native OMP write'
|
||||
content: 'native tool write'
|
||||
})]
|
||||
})]
|
||||
const nativeCandidates = extractArtifactCandidates(nativeWriteMessages, {
|
||||
@@ -196,7 +196,7 @@ async function main(): Promise<void> {
|
||||
cwd: root,
|
||||
sourceSessionTitle: '原生写入任务'
|
||||
})
|
||||
assert.equal(nativeCandidates.length, 1, '小写 OMP write 应经 Write 别名规范化后提取')
|
||||
assert.equal(nativeCandidates.length, 1, '小写 native tool write 应经 Write 别名规范化后提取')
|
||||
assert.equal(nativeCandidates[0].sourceKind, 'write')
|
||||
assert.equal(nativeCandidates[0].sourceToolCallId, 'native-write')
|
||||
assert.equal(artifactPathKey(nativeCandidates[0].path), artifactPathKey(nativeWritePath))
|
||||
|
||||
@@ -77,5 +77,5 @@ const full = resolveModelTokenLimits({ tokenBudgetVersion: 2, metadata: sol, con
|
||||
assert.equal(full.maxInputTokens, 922000); assert.deepEqual(full.compaction, { reserveTokens: 157500, thresholdTokens: 892500 })
|
||||
const small = resolveModelTokenLimits({ tokenBudgetVersion: 2, contextWindowTokens: 8192, maxOutputTokens: 1234 })
|
||||
assert.deepEqual(small.compaction, { reserveTokens: 1234, thresholdTokens: 6958 })
|
||||
checks.push('compression follows the larger of output reserve and OMP headroom, including small windows')
|
||||
checks.push('compression follows the larger of output reserve and runtime headroom, including small windows')
|
||||
console.log('MODEL_METADATA_REGRESSION_PASS ' + JSON.stringify({ directory, checks }))
|
||||
@@ -1,11 +0,0 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { normalize } from 'node:path'
|
||||
import { detectOmpPath } from '../src/main/omp'
|
||||
|
||||
delete process.env['YCODE_OMP_PATH']
|
||||
const detected = detectOmpPath()
|
||||
assert.ok(detected, 'bundled OMP must be detected without YCODE_OMP_PATH')
|
||||
const expected = normalize('resources/omp/omp.exe')
|
||||
assert.ok(normalize(detected).toLowerCase().endsWith(expected.toLowerCase()),
|
||||
`detected OMP must resolve to the bundled resources/omp/omp.exe, got ${detected}`)
|
||||
console.log(`PASS detectOmpPath => ${detected}`)
|
||||
@@ -1,710 +0,0 @@
|
||||
import { spawn, spawnSync, type ChildProcessWithoutNullStreams } from 'node:child_process'
|
||||
import { createServer, type IncomingMessage } from 'node:http'
|
||||
import { createInterface } from 'node:readline'
|
||||
import { existsSync } from 'node:fs'
|
||||
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import { homedir, tmpdir } from 'node:os'
|
||||
import { join, parse, relative, resolve, sep } from 'node:path'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
|
||||
interface Frame {
|
||||
type?: string
|
||||
id?: string
|
||||
command?: string
|
||||
success?: boolean
|
||||
data?: unknown
|
||||
error?: { message?: string }
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
interface ProbeRun {
|
||||
child: ChildProcessWithoutNullStreams
|
||||
frames: Frame[]
|
||||
rawLines: string[]
|
||||
stderrLines: string[]
|
||||
}
|
||||
|
||||
interface MockRequest {
|
||||
model?: string
|
||||
stream: boolean
|
||||
roles: string[]
|
||||
toolNames: string[]
|
||||
toolProperties: Record<string, string[]>
|
||||
containsImage: boolean
|
||||
messageShape: Array<Record<string, unknown>>
|
||||
}
|
||||
|
||||
const STARTUP_WAIT_MS = 12_000
|
||||
const COMMAND_WAIT_MS = 20_000
|
||||
const SHUTDOWN_WAIT_MS = 1_500
|
||||
const PROVIDER_ID = 'loopback-probe'
|
||||
const MODEL_ID = 'probe-model'
|
||||
const MODEL = `${PROVIDER_ID}/${MODEL_ID}`
|
||||
|
||||
function delay(ms: number): Promise<void> {
|
||||
return new Promise((resolveDelay) => setTimeout(resolveDelay, ms))
|
||||
}
|
||||
|
||||
function makeIsolatedEnvironment(home: string, tempRoot: string, profile: string): NodeJS.ProcessEnv {
|
||||
const env: NodeJS.ProcessEnv = {}
|
||||
for (const name of ['PATH', 'SystemRoot', 'SystemDrive', 'ComSpec', 'WINDIR']) {
|
||||
const value = process.env[name]
|
||||
if (value) env[name] = value
|
||||
}
|
||||
|
||||
const root = parse(home).root
|
||||
env.USERPROFILE = home
|
||||
env.HOME = home
|
||||
env.HOMEDRIVE = root.replace(/[\\/]$/, '')
|
||||
env.HOMEPATH = relative(root, home).replaceAll('/', '\\')
|
||||
env.APPDATA = join(home, 'AppData', 'Roaming')
|
||||
env.LOCALAPPDATA = join(home, 'AppData', 'Local')
|
||||
env.TEMP = tempRoot
|
||||
env.TMP = tempRoot
|
||||
env.OMP_PROFILE = profile
|
||||
return env
|
||||
}
|
||||
|
||||
function summarizeFrame(frame: Frame): Record<string, unknown> {
|
||||
const data = frame.data
|
||||
const dataKeys = data && typeof data === 'object' ? Object.keys(data as object) : []
|
||||
const event = frame.assistantMessageEvent
|
||||
const eventKeys = event && typeof event === 'object' ? Object.keys(event as object) : []
|
||||
return {
|
||||
type: frame.type,
|
||||
frameKeys: Object.keys(frame),
|
||||
id: frame.id,
|
||||
command: frame.command,
|
||||
success: frame.success,
|
||||
status: frame.status,
|
||||
assistantEventType: event && typeof event === 'object' ? (event as Record<string, unknown>).type : undefined,
|
||||
toolCallId: frame.toolCallId,
|
||||
toolName: frame.toolName,
|
||||
argumentKeys: frame.arguments && typeof frame.arguments === 'object' ? Object.keys(frame.arguments as object) : [],
|
||||
eventKeys,
|
||||
dataKeys,
|
||||
error: typeof frame.error === 'string' ? frame.error : frame.error?.message
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForFrame(
|
||||
run: ProbeRun,
|
||||
predicate: (frame: Frame) => boolean,
|
||||
timeoutMs = COMMAND_WAIT_MS,
|
||||
startIndex = 0
|
||||
): Promise<Frame | null> {
|
||||
const deadline = Date.now() + timeoutMs
|
||||
while (Date.now() < deadline) {
|
||||
const frame = run.frames.slice(startIndex).find(predicate)
|
||||
if (frame) return frame
|
||||
if (run.child.exitCode !== null || run.child.signalCode !== null) break
|
||||
await delay(20)
|
||||
}
|
||||
return run.frames.slice(startIndex).find(predicate) ?? null
|
||||
}
|
||||
|
||||
async function sendCommand(run: ProbeRun, id: string, command: Record<string, unknown>): Promise<Frame | null> {
|
||||
run.child.stdin.write(`${JSON.stringify({ id, ...command })}\n`)
|
||||
return waitForFrame(run, (frame) => frame.id === id)
|
||||
}
|
||||
|
||||
async function sendLooseCommand(run: ProbeRun, id: string, command: Record<string, unknown>): Promise<Frame | null> {
|
||||
const startIndex = run.frames.length
|
||||
run.child.stdin.write(`${JSON.stringify({ id, ...command })}\n`)
|
||||
return waitForFrame(run, (frame) => frame.id === id || frame.type === 'response', 3_000, startIndex)
|
||||
}
|
||||
|
||||
async function installProbeHostTool(run: ProbeRun, suffix: string): Promise<Frame | null> {
|
||||
return sendCommand(run, `probe-host-tools-${suffix}`, {
|
||||
type: 'set_host_tools',
|
||||
tools: [{
|
||||
name: 'read',
|
||||
label: 'Read local probe file',
|
||||
description: 'Read a file from the isolated protocol probe directory.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: { path: { type: 'string' } },
|
||||
required: ['path'],
|
||||
additionalProperties: false
|
||||
}
|
||||
}]
|
||||
})
|
||||
}
|
||||
|
||||
async function stopRun(run: ProbeRun): Promise<void> {
|
||||
try {
|
||||
run.child.stdin.end()
|
||||
} catch {
|
||||
// The sidecar may already have exited.
|
||||
}
|
||||
|
||||
await new Promise<void>((resolveExit) => {
|
||||
if (run.child.exitCode !== null || run.child.signalCode !== null) {
|
||||
resolveExit()
|
||||
return
|
||||
}
|
||||
let settled = false
|
||||
const finish = (): void => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
clearTimeout(timer)
|
||||
resolveExit()
|
||||
}
|
||||
const timer = setTimeout(finish, SHUTDOWN_WAIT_MS)
|
||||
run.child.once('exit', finish)
|
||||
})
|
||||
|
||||
if (run.child.exitCode === null && run.child.signalCode === null) {
|
||||
run.child.kill()
|
||||
await new Promise<void>((resolveExit) => {
|
||||
if (run.child.exitCode !== null || run.child.signalCode !== null) resolveExit()
|
||||
else run.child.once('exit', () => resolveExit())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async function startRun(
|
||||
exePath: string,
|
||||
tempRoot: string,
|
||||
profile: string,
|
||||
home: string,
|
||||
sessionDir: string,
|
||||
sessionPath: string
|
||||
): Promise<ProbeRun> {
|
||||
const args = [
|
||||
'--mode', 'rpc',
|
||||
'--profile', profile,
|
||||
'--cwd', tempRoot,
|
||||
`--model=${MODEL}`,
|
||||
'--no-tools',
|
||||
`--session-dir=${sessionDir}`,
|
||||
`--resume=${sessionPath}`,
|
||||
'--no-ui',
|
||||
'--no-title',
|
||||
'--no-extensions',
|
||||
'--no-skills',
|
||||
'--no-rules',
|
||||
'--no-lsp'
|
||||
]
|
||||
const child = spawn(exePath, args, {
|
||||
cwd: tempRoot,
|
||||
env: makeIsolatedEnvironment(home, tempRoot, profile),
|
||||
windowsHide: true,
|
||||
stdio: ['pipe', 'pipe', 'pipe']
|
||||
})
|
||||
const run: ProbeRun = { child, frames: [], rawLines: [], stderrLines: [] }
|
||||
const stdout = createInterface({ input: child.stdout })
|
||||
const stderr = createInterface({ input: child.stderr })
|
||||
stdout.on('line', (line) => {
|
||||
run.rawLines.push(line)
|
||||
try {
|
||||
run.frames.push(JSON.parse(line) as Frame)
|
||||
} catch {
|
||||
// Keep non-JSON stdout separate from RPC frames.
|
||||
}
|
||||
})
|
||||
stderr.on('line', (line) => run.stderrLines.push(line))
|
||||
const ready = await waitForFrame(run, (frame) => frame.type === 'ready', STARTUP_WAIT_MS)
|
||||
if (!ready) {
|
||||
const stderrText = run.stderrLines.join('\n').slice(0, 1000)
|
||||
await stopRun(run)
|
||||
throw new Error(`OMP did not send ready; exit=${child.exitCode}; stderr=${stderrText}`)
|
||||
}
|
||||
return run
|
||||
}
|
||||
|
||||
async function readRequest(request: IncomingMessage): Promise<Record<string, unknown>> {
|
||||
const chunks: Buffer[] = []
|
||||
for await (const chunk of request) chunks.push(Buffer.from(chunk))
|
||||
try {
|
||||
return JSON.parse(Buffer.concat(chunks).toString('utf8')) as Record<string, unknown>
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
function toolNamesFrom(body: Record<string, unknown>): string[] {
|
||||
if (!Array.isArray(body.tools)) return []
|
||||
return body.tools.flatMap((tool) => {
|
||||
if (!tool || typeof tool !== 'object') return []
|
||||
const item = tool as Record<string, unknown>
|
||||
const fn = item.function && typeof item.function === 'object' ? item.function as Record<string, unknown> : null
|
||||
const name = fn?.name ?? item.name
|
||||
return typeof name === 'string' ? [name] : []
|
||||
})
|
||||
}
|
||||
|
||||
function requestHasImage(body: Record<string, unknown>): boolean {
|
||||
if (!Array.isArray(body.messages)) return false
|
||||
return body.messages.some((message) => {
|
||||
if (!message || typeof message !== 'object') return false
|
||||
const content = (message as Record<string, unknown>).content
|
||||
return Array.isArray(content) && content.some((part) => {
|
||||
if (!part || typeof part !== 'object') return false
|
||||
const item = part as Record<string, unknown>
|
||||
return item.type === 'image_url' || item.type === 'image'
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async function startMockProvider(probeFile: string): Promise<{
|
||||
server: ReturnType<typeof createServer>
|
||||
url: string
|
||||
requests: MockRequest[]
|
||||
toolCallSent: boolean
|
||||
}> {
|
||||
const requests: MockRequest[] = []
|
||||
let toolCallSent = false
|
||||
const server = createServer(async (request, response) => {
|
||||
const body = await readRequest(request)
|
||||
const names = toolNamesFrom(body)
|
||||
const messages = Array.isArray(body.messages) ? body.messages as Array<Record<string, unknown>> : []
|
||||
const latestUser = [...messages].reverse().find((message) => message.role === 'user')
|
||||
const latestUserContent = Array.isArray(latestUser?.content)
|
||||
? (latestUser.content as Array<Record<string, unknown>>).map((block) => block.text).filter((value) => typeof value === 'string').join('\n')
|
||||
: typeof latestUser?.content === 'string' ? latestUser.content : ''
|
||||
const toolProperties: Record<string, string[]> = {}
|
||||
for (const tool of Array.isArray(body.tools) ? body.tools : []) {
|
||||
if (!tool || typeof tool !== 'object') continue
|
||||
const item = tool as Record<string, unknown>
|
||||
const fn = item.function && typeof item.function === 'object' ? item.function as Record<string, unknown> : null
|
||||
const name = fn?.name ?? item.name
|
||||
const parameters = fn?.parameters && typeof fn.parameters === 'object' ? fn.parameters as Record<string, unknown> : null
|
||||
const properties = parameters?.properties && typeof parameters.properties === 'object'
|
||||
? parameters.properties as Record<string, unknown> : null
|
||||
if (typeof name === 'string') toolProperties[name] = Object.keys(properties ?? {})
|
||||
}
|
||||
requests.push({
|
||||
model: typeof body.model === 'string' ? body.model : undefined,
|
||||
stream: body.stream === true,
|
||||
roles: messages.map((message) => typeof message.role === 'string' ? message.role : '?'),
|
||||
toolNames: names,
|
||||
toolProperties,
|
||||
containsImage: requestHasImage(body),
|
||||
messageShape: messages.map((message) => {
|
||||
const content = message.content
|
||||
const blocks = Array.isArray(content) ? content as Array<Record<string, unknown>> : []
|
||||
const calls = Array.isArray(message.tool_calls) ? message.tool_calls as Array<Record<string, unknown>> : []
|
||||
return {
|
||||
role: message.role,
|
||||
contentType: typeof content === 'string' ? 'string' : Array.isArray(content) ? 'array' : typeof content,
|
||||
contentBlockTypes: blocks.map((block) => block.type),
|
||||
toolCallCount: calls.length,
|
||||
hasToolCallId: typeof message.tool_call_id === 'string'
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
const readToolName = names.find((name) => name === 'read' || name.endsWith('__read'))
|
||||
const sendReadTool = latestUserContent.includes('TOOL_PROBE') && readToolName && !toolCallSent
|
||||
const completion = sendReadTool
|
||||
? {
|
||||
message: {
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
tool_calls: [{
|
||||
id: 'call_local_read_1',
|
||||
type: 'function',
|
||||
function: { name: readToolName, arguments: JSON.stringify({ path: probeFile }) }
|
||||
}]
|
||||
},
|
||||
finish_reason: 'tool_calls'
|
||||
}
|
||||
: {
|
||||
message: { role: 'assistant', content: 'PROBE_OK' },
|
||||
finish_reason: 'stop'
|
||||
}
|
||||
if (sendReadTool) toolCallSent = true
|
||||
if (body.stream === true) {
|
||||
response.writeHead(200, {
|
||||
'content-type': 'text/event-stream',
|
||||
'cache-control': 'no-cache',
|
||||
connection: 'keep-alive'
|
||||
})
|
||||
const completionId = `chatcmpl-local-${requests.length}`
|
||||
const writeChunk = (delta: Record<string, unknown>, finishReason: string | null = null): void => {
|
||||
response.write(`data: ${JSON.stringify({
|
||||
id: completionId,
|
||||
object: 'chat.completion.chunk',
|
||||
created: Math.floor(Date.now() / 1000),
|
||||
model: MODEL_ID,
|
||||
choices: [{ index: 0, delta, finish_reason: finishReason }]
|
||||
})}\n\n`)
|
||||
}
|
||||
writeChunk({ role: 'assistant' })
|
||||
if (sendReadTool) {
|
||||
writeChunk({ tool_calls: [{
|
||||
index: 0,
|
||||
id: 'call_local_read_1',
|
||||
type: 'function',
|
||||
function: { name: readToolName, arguments: JSON.stringify({ path: probeFile }) }
|
||||
}] })
|
||||
writeChunk({}, 'tool_calls')
|
||||
} else {
|
||||
writeChunk({ reasoning_content: 'LOCAL_THINKING_PROBE' })
|
||||
writeChunk({ content: 'PROBE_OK' })
|
||||
writeChunk({}, 'stop')
|
||||
}
|
||||
response.write(`data: ${JSON.stringify({
|
||||
id: completionId,
|
||||
object: 'chat.completion.chunk',
|
||||
created: Math.floor(Date.now() / 1000),
|
||||
model: MODEL_ID,
|
||||
choices: [],
|
||||
usage: { prompt_tokens: 17, completion_tokens: 4, total_tokens: 21 }
|
||||
})}\n\n`)
|
||||
response.end('data: [DONE]\n\n')
|
||||
return
|
||||
}
|
||||
response.writeHead(200, { 'content-type': 'application/json' })
|
||||
response.end(JSON.stringify({
|
||||
id: `chatcmpl-local-${requests.length}`,
|
||||
object: 'chat.completion',
|
||||
created: Math.floor(Date.now() / 1000),
|
||||
model: MODEL_ID,
|
||||
choices: [{ index: 0, ...completion }],
|
||||
usage: { prompt_tokens: 17, completion_tokens: 4, total_tokens: 21 }
|
||||
}))
|
||||
})
|
||||
await new Promise<void>((resolveListen, reject) => {
|
||||
server.once('error', reject)
|
||||
server.listen(0, '127.0.0.1', () => {
|
||||
server.off('error', reject)
|
||||
resolveListen()
|
||||
})
|
||||
})
|
||||
const address = server.address()
|
||||
if (!address || typeof address === 'string') throw new Error('Loopback mock did not bind to a TCP port')
|
||||
return { server, url: `http://127.0.0.1:${address.port}/v1`, requests, get toolCallSent() { return toolCallSent } }
|
||||
}
|
||||
|
||||
function summarizeMessages(data: unknown): Record<string, unknown> {
|
||||
const root = data && typeof data === 'object' ? data as Record<string, unknown> : {}
|
||||
const possibleArrays = [root.messages, root.entries, root.branchMessages]
|
||||
const records = possibleArrays.find(Array.isArray) as unknown[] | undefined
|
||||
const items = (records ?? (Array.isArray(data) ? data : [])).map((value) => {
|
||||
if (!value || typeof value !== 'object') return { valueType: typeof value }
|
||||
const item = value as Record<string, unknown>
|
||||
const message = item.message && typeof item.message === 'object' ? item.message as Record<string, unknown> : item
|
||||
const content = Array.isArray(message.content) ? message.content as Array<Record<string, unknown>> : []
|
||||
const toolCalls = Array.isArray(message.toolCalls) ? message.toolCalls as Array<Record<string, unknown>>
|
||||
: Array.isArray(message.tool_calls) ? message.tool_calls as Array<Record<string, unknown>> : []
|
||||
return {
|
||||
itemKeys: Object.keys(item),
|
||||
messageKeys: Object.keys(message),
|
||||
type: item.type,
|
||||
role: message.role,
|
||||
entryId: item.entryId ?? item.id,
|
||||
parentId: item.parentId,
|
||||
textBlocks: content.filter((block) => block.type === 'text').length,
|
||||
toolCalls: toolCalls.map((call) => call.name ?? (call.function as Record<string, unknown> | undefined)?.name)
|
||||
}
|
||||
})
|
||||
return { keys: Object.keys(root), count: items.length, messages: items }
|
||||
}
|
||||
|
||||
async function inspectTranscript(path: string): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
const text = await readFile(path, 'utf8')
|
||||
const records = text.split(/\r?\n/).filter(Boolean).map((record) => JSON.parse(record) as Record<string, unknown>)
|
||||
return {
|
||||
exists: true,
|
||||
records: records.length,
|
||||
entries: records.map((record) => ({
|
||||
type: record.type,
|
||||
id: record.id,
|
||||
parentId: record.parentId,
|
||||
messageRole: (record.message as Record<string, unknown> | undefined)?.role,
|
||||
toolName: (record.toolName ?? (record.message as Record<string, unknown> | undefined)?.toolName) ?? undefined
|
||||
}))
|
||||
}
|
||||
} catch {
|
||||
return { exists: false }
|
||||
}
|
||||
}
|
||||
|
||||
function summarizeTree(value: unknown): unknown {
|
||||
if (Array.isArray(value)) return value.map(summarizeTree)
|
||||
if (!value || typeof value !== 'object') return value
|
||||
const node = value as Record<string, unknown>
|
||||
const entry = node.entry && typeof node.entry === 'object' ? node.entry as Record<string, unknown> : node
|
||||
const message = entry.message && typeof entry.message === 'object' ? entry.message as Record<string, unknown> : null
|
||||
return {
|
||||
nodeKeys: Object.keys(node),
|
||||
entryKeys: Object.keys(entry),
|
||||
id: entry.id,
|
||||
parentId: entry.parentId,
|
||||
type: entry.type,
|
||||
role: message?.role,
|
||||
label: node.label,
|
||||
children: Array.isArray(node.children) ? summarizeTree(node.children) : []
|
||||
}
|
||||
}
|
||||
|
||||
function findUserNode(value: unknown): { id: string; parentId: string | null } | null {
|
||||
if (Array.isArray(value)) {
|
||||
let latest: { id: string; parentId: string | null } | null = null
|
||||
for (const child of value) {
|
||||
const found = findUserNode(child)
|
||||
if (found) latest = found
|
||||
}
|
||||
return latest
|
||||
}
|
||||
if (!value || typeof value !== 'object') return null
|
||||
const node = value as Record<string, unknown>
|
||||
const entry = node.entry && typeof node.entry === 'object' ? node.entry as Record<string, unknown> : node
|
||||
const message = entry.message && typeof entry.message === 'object' ? entry.message as Record<string, unknown> : null
|
||||
let latest: { id: string; parentId: string | null } | null = null
|
||||
if (entry.type === 'message' && message?.role === 'user' && typeof entry.id === 'string') {
|
||||
latest = { id: entry.id, parentId: typeof entry.parentId === 'string' ? entry.parentId : null }
|
||||
}
|
||||
const child = Array.isArray(node.children) ? findUserNode(node.children) : null
|
||||
return child ?? latest
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const exePath = process.env.YCODE_OMP_PATH ?? 'C:\\Users\\Administrator\\AppData\\Local\\Ygent\\omp.exe'
|
||||
if (!existsSync(exePath)) throw new Error(`OMP executable not found: ${exePath}`)
|
||||
|
||||
const tempRoot = await mkdtemp(join(tmpdir(), 'ycode-omp-protocol-'))
|
||||
const home = join(tempRoot, 'isolated-home')
|
||||
const sessionDir = join(tempRoot, 'sessions')
|
||||
const profile = `protocol-probe-${randomUUID().replaceAll('-', '')}`
|
||||
const sessionPath = join(sessionDir, 'probe.jsonl')
|
||||
const probeFile = join(tempRoot, 'probe.txt')
|
||||
const realProfilePath = resolve(homedir(), '.omp', 'profiles', profile)
|
||||
const realProfileRoot = resolve(homedir(), '.omp', 'profiles')
|
||||
const realProfileRelative = relative(realProfileRoot, realProfilePath)
|
||||
if (!realProfileRelative || realProfileRelative.startsWith(`..${sep}`) || resolve(realProfileRoot, realProfileRelative) !== realProfilePath) {
|
||||
throw new Error('Refusing an invalid isolated profile path')
|
||||
}
|
||||
if (existsSync(realProfilePath)) throw new Error('The unique probe profile unexpectedly exists')
|
||||
|
||||
await mkdir(home, { recursive: true })
|
||||
await mkdir(sessionDir, { recursive: true })
|
||||
await writeFile(probeFile, 'LOCAL_OMP_TOOL_SENTINEL\n', 'utf8')
|
||||
const profileAgentDir = join(home, '.omp', 'profiles', profile, 'agent')
|
||||
await mkdir(profileAgentDir, { recursive: true })
|
||||
const mock = await startMockProvider(probeFile)
|
||||
await writeFile(join(profileAgentDir, 'models.yml'), [
|
||||
'providers:',
|
||||
` ${PROVIDER_ID}:`,
|
||||
` baseUrl: ${mock.url}`,
|
||||
' api: openai-completions',
|
||||
' auth: none',
|
||||
' models:',
|
||||
` - id: ${MODEL_ID}`,
|
||||
' name: Local protocol probe',
|
||||
' reasoning: true',
|
||||
' input: [text, image]',
|
||||
' contextWindow: 64000',
|
||||
' maxTokens: 4096',
|
||||
''
|
||||
].join('\n'), 'utf8')
|
||||
|
||||
const runs: ProbeRun[] = []
|
||||
try {
|
||||
const version = spawnSync(exePath, ['--version'], {
|
||||
encoding: 'utf8',
|
||||
windowsHide: true,
|
||||
env: makeIsolatedEnvironment(home, tempRoot, profile)
|
||||
})
|
||||
if (version.error) throw version.error
|
||||
if (version.status !== 0) throw new Error(`OMP --version failed with exit ${version.status}`)
|
||||
console.log(`OMP_VERSION: ${version.stdout.trim()}`)
|
||||
console.log(`ISOLATED_PROFILE: ${profile}`)
|
||||
console.log('PROVIDER_NETWORK: loopback only')
|
||||
|
||||
const first = await startRun(exePath, tempRoot, profile, home, sessionDir, sessionPath)
|
||||
runs.push(first)
|
||||
const hostToolsFirst = await installProbeHostTool(first, '1')
|
||||
const stateResponse = await sendCommand(first, 'probe-state-1', { type: 'get_state' })
|
||||
console.log(`FIRST_READY: true`)
|
||||
console.log(`SET_HOST_TOOLS: ${JSON.stringify(hostToolsFirst ? summarizeFrame(hostToolsFirst) : null)}`)
|
||||
console.log(`GET_STATE: ${JSON.stringify(stateResponse ? summarizeFrame(stateResponse) : null)}`)
|
||||
|
||||
const image = {
|
||||
type: 'image' as const,
|
||||
data: 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/dXEAAAAASUVORK5CYII=',
|
||||
mimeType: 'image/png'
|
||||
}
|
||||
first.child.stdin.write(`${JSON.stringify({
|
||||
id: 'probe-prompt-1',
|
||||
type: 'prompt',
|
||||
message: 'Reply exactly PROBE_OK.',
|
||||
images: [image]
|
||||
})}\n`)
|
||||
const promptAck = await waitForFrame(first, (frame) => frame.id === 'probe-prompt-1')
|
||||
const turnDone = await waitForFrame(first, (frame) =>
|
||||
frame.type === 'agent_end' || (frame.type === 'prompt_result' && frame.agentInvoked === false), 60_000)
|
||||
const toolTurnStart = first.frames.length
|
||||
first.child.stdin.write(`${JSON.stringify({
|
||||
id: 'probe-prompt-tool',
|
||||
type: 'prompt',
|
||||
message: 'TOOL_PROBE: call the read tool for probe.txt, then reply exactly PROBE_OK.'
|
||||
})}\n`)
|
||||
const toolPromptAck = await waitForFrame(first, (frame) => frame.id === 'probe-prompt-tool')
|
||||
const hostToolCall = await waitForFrame(first, (frame) => frame.type === 'host_tool_call', 20_000, toolTurnStart)
|
||||
let hostToolResponseSent = false
|
||||
if (hostToolCall && typeof hostToolCall.id === 'string') {
|
||||
const args = hostToolCall.arguments && typeof hostToolCall.arguments === 'object'
|
||||
? hostToolCall.arguments as Record<string, unknown> : {}
|
||||
const targetPath = typeof args.path === 'string' ? args.path : probeFile
|
||||
const content = await readFile(targetPath, 'utf8')
|
||||
first.child.stdin.write(`${JSON.stringify({
|
||||
type: 'host_tool_result',
|
||||
id: hostToolCall.id,
|
||||
result: { content: [{ type: 'text', text: content }] }
|
||||
})}\n`)
|
||||
hostToolResponseSent = true
|
||||
}
|
||||
const toolTurnDone = await waitForFrame(first, (frame) =>
|
||||
frame.type === 'agent_end' || (frame.type === 'prompt_result' && frame.agentInvoked === false), 60_000, toolTurnStart)
|
||||
const getMessages = await sendCommand(first, 'probe-messages-1', { type: 'get_messages' })
|
||||
const tree = await sendCommand(first, 'probe-tree-1', { type: 'get_tree' })
|
||||
const branchMessages = await sendCommand(first, 'probe-branch-messages-1', { type: 'get_branch_messages' })
|
||||
const stateAfterTurn = await sendCommand(first, 'probe-state-after-turn', { type: 'get_state' })
|
||||
|
||||
const entries = getMessages ? summarizeMessages(getMessages.data) : null
|
||||
const treeData = tree?.data && typeof tree.data === 'object' ? tree.data as Record<string, unknown> : {}
|
||||
const treeShape = summarizeTree(treeData.tree)
|
||||
const firstUserNode = findUserNode(treeData.tree)
|
||||
const fork = firstUserNode
|
||||
? await sendCommand(first, 'probe-fork-before-branch', { type: 'fork', entryId: firstUserNode.id })
|
||||
: null
|
||||
const stateAfterFork = await sendCommand(first, 'probe-state-after-fork', { type: 'get_state' })
|
||||
const forkStateData = stateAfterFork?.data && typeof stateAfterFork.data === 'object'
|
||||
? stateAfterFork.data as Record<string, unknown> : {}
|
||||
const forkSessionPath = typeof forkStateData.sessionFile === 'string' ? forkStateData.sessionFile : ''
|
||||
const relativeForkSession = forkSessionPath ? relative(tempRoot, resolve(forkSessionPath)) : ''
|
||||
const forkSessionInProbe = !!relativeForkSession && relativeForkSession !== '..' &&
|
||||
!relativeForkSession.startsWith(`..${sep}`) && !relativeForkSession.startsWith(sep)
|
||||
const messagesAfterFork = await sendCommand(first, 'probe-messages-after-fork', { type: 'get_messages' })
|
||||
const treeAfterFork = await sendCommand(first, 'probe-tree-after-fork', { type: 'get_tree' })
|
||||
const treeAfterForkData = treeAfterFork?.data && typeof treeAfterFork.data === 'object'
|
||||
? treeAfterFork.data as Record<string, unknown> : {}
|
||||
const forkData = fork?.data && typeof fork.data === 'object' ? fork.data as Record<string, unknown> : {}
|
||||
const stateBeforeForkData = stateAfterTurn?.data && typeof stateAfterTurn.data === 'object'
|
||||
? stateAfterTurn.data as Record<string, unknown> : {}
|
||||
|
||||
console.log(`PROMPT_ACK: ${JSON.stringify(promptAck ? summarizeFrame(promptAck) : null)}`)
|
||||
console.log(`FIRST_TURN_END_FRAME: ${JSON.stringify(turnDone ? summarizeFrame(turnDone) : null)}`)
|
||||
console.log(`TOOL_PROMPT_ACK: ${JSON.stringify(toolPromptAck ? summarizeFrame(toolPromptAck) : null)}`)
|
||||
console.log(`HOST_TOOL_CALL: ${JSON.stringify(hostToolCall ? summarizeFrame(hostToolCall) : null)}`)
|
||||
console.log(`HOST_TOOL_RESPONSE_SENT: ${hostToolResponseSent}`)
|
||||
console.log(`TOOL_TURN_END_FRAME: ${JSON.stringify(toolTurnDone ? summarizeFrame(toolTurnDone) : null)}`)
|
||||
console.log(`TURN_EVENTS: ${JSON.stringify(first.frames.filter((frame) => [
|
||||
'agent_start', 'message_start', 'message_update', 'message_end', 'tool_execution_start',
|
||||
'tool_execution_update', 'tool_execution_end', 'host_tool_call', 'turn_start', 'turn_end', 'agent_end', 'prompt_result'
|
||||
].includes(frame.type ?? '')).map(summarizeFrame))}`)
|
||||
console.log(`GET_MESSAGES_RESPONSE: ${JSON.stringify(getMessages ? {
|
||||
...summarizeFrame(getMessages),
|
||||
dataSummary: entries
|
||||
} : null)}`)
|
||||
console.log(`GET_TREE_RESPONSE: ${JSON.stringify(tree ? {
|
||||
...summarizeFrame(tree), leafId: treeData.leafId, tree: treeShape
|
||||
} : null)}`)
|
||||
console.log(`GET_BRANCH_MESSAGES_RESPONSE: ${JSON.stringify(branchMessages ? {
|
||||
...summarizeFrame(branchMessages), dataSummary: summarizeMessages(branchMessages.data)
|
||||
} : null)}`)
|
||||
console.log(`FORK_ENTRY_ID: ${JSON.stringify(firstUserNode?.id ?? null)}`)
|
||||
console.log(`FORK_RESPONSE: ${JSON.stringify(fork ? summarizeFrame(fork) : null)}`)
|
||||
console.log(`FORK_CANCELLED: ${forkData.cancelled}`)
|
||||
console.log(`FORK_STATE: ${JSON.stringify(stateAfterFork ? summarizeFrame(stateAfterFork) : null)}`)
|
||||
console.log(`FORK_SESSION_ID_CHANGED: ${stateBeforeForkData.sessionId !== forkStateData.sessionId}`)
|
||||
console.log(`FORK_SESSION_FILE_IS_PROBE_PATH: ${forkSessionInProbe}`)
|
||||
console.log(`FORK_SESSION_FILE_EXISTS: ${forkSessionInProbe ? existsSync(forkSessionPath) : false}`)
|
||||
console.log(`FORK_MESSAGES: ${JSON.stringify(messagesAfterFork ? summarizeMessages(messagesAfterFork.data) : null)}`)
|
||||
console.log(`FORK_TREE_LEAF_ID: ${treeAfterForkData.leafId ?? null}`)
|
||||
console.log(`TRANSCRIPT_AFTER_FORK: ${JSON.stringify(await inspectTranscript(sessionPath))}`)
|
||||
console.log(`STATE_AFTER_TURN: ${JSON.stringify(stateAfterTurn ? summarizeFrame(stateAfterTurn) : null)}`)
|
||||
console.log(`LOOPBACK_REQUESTS: ${JSON.stringify(mock.requests)}`)
|
||||
console.log(`TOOL_CALL_SENT: ${mock.toolCallSent}`)
|
||||
console.log(`TRANSCRIPT_AFTER_TURN: ${JSON.stringify(await inspectTranscript(sessionPath))}`)
|
||||
|
||||
await stopRun(first)
|
||||
const second = await startRun(exePath, tempRoot, profile, home, sessionDir, sessionPath)
|
||||
runs.push(second)
|
||||
const hostToolsSecond = await installProbeHostTool(second, '2')
|
||||
const resumedState = await sendCommand(second, 'probe-state-2', { type: 'get_state' })
|
||||
const resumedMessages = await sendCommand(second, 'probe-messages-2', { type: 'get_messages' })
|
||||
console.log(`RESUME_READY: true`)
|
||||
console.log(`RESUME_SET_HOST_TOOLS: ${JSON.stringify(hostToolsSecond ? summarizeFrame(hostToolsSecond) : null)}`)
|
||||
console.log(`RESUME_GET_STATE: ${JSON.stringify(resumedState ? summarizeFrame(resumedState) : null)}`)
|
||||
console.log(`RESUME_GET_MESSAGES: ${JSON.stringify(resumedMessages ? {
|
||||
...summarizeFrame(resumedMessages),
|
||||
dataSummary: summarizeMessages(resumedMessages.data)
|
||||
} : null)}`)
|
||||
console.log(`RESUMED_TRANSCRIPT: ${JSON.stringify(await inspectTranscript(sessionPath))}`)
|
||||
let currentRun: ProbeRun = second
|
||||
|
||||
const resumeTree = await sendCommand(second, 'probe-tree-2', { type: 'get_tree' })
|
||||
const resumeTreeData = resumeTree?.data && typeof resumeTree.data === 'object' ? resumeTree.data as Record<string, unknown> : {}
|
||||
const resumeUserNode = findUserNode(resumeTreeData.tree)
|
||||
console.log(`RESUME_TREE: ${JSON.stringify(resumeTree ? {
|
||||
...summarizeFrame(resumeTree), leafId: resumeTreeData.leafId, tree: summarizeTree(resumeTreeData.tree)
|
||||
} : null)}`)
|
||||
|
||||
if (resumeUserNode) {
|
||||
const branch = await sendCommand(second, 'probe-branch-1', { type: 'branch', entryId: resumeUserNode.id })
|
||||
const branchState = await sendCommand(second, 'probe-state-after-branch', { type: 'get_state' })
|
||||
const branchHistory = await sendCommand(second, 'probe-messages-after-branch', { type: 'get_messages' })
|
||||
const branchTree = await sendCommand(second, 'probe-tree-after-branch', { type: 'get_tree' })
|
||||
const branchTreeData = branchTree?.data && typeof branchTree.data === 'object' ? branchTree.data as Record<string, unknown> : {}
|
||||
console.log(`BRANCH_ENTRY_ID: ${JSON.stringify(resumeUserNode.id)}`)
|
||||
console.log(`BRANCH_RESPONSE: ${JSON.stringify(branch ? summarizeFrame(branch) : null)}`)
|
||||
console.log(`BRANCH_STATE: ${JSON.stringify(branchState ? summarizeFrame(branchState) : null)}`)
|
||||
console.log(`BRANCH_MESSAGES: ${JSON.stringify(branchHistory ? {
|
||||
...summarizeFrame(branchHistory),
|
||||
dataSummary: summarizeMessages(branchHistory.data)
|
||||
} : null)}`)
|
||||
console.log(`BRANCH_TREE: ${JSON.stringify(branchTree ? {
|
||||
...summarizeFrame(branchTree), leafId: branchTreeData.leafId, tree: summarizeTree(branchTreeData.tree)
|
||||
} : null)}`)
|
||||
if (branch?.success === true) {
|
||||
await stopRun(second)
|
||||
const branchResume = await startRun(exePath, tempRoot, profile, home, sessionDir, sessionPath)
|
||||
runs.push(branchResume)
|
||||
currentRun = branchResume
|
||||
const resumeHostTools = await installProbeHostTool(branchResume, 'after-branch')
|
||||
const messagesAfterBranchResume = await sendCommand(branchResume, 'probe-messages-after-branch-resume', { type: 'get_messages' })
|
||||
const treeAfterBranchResume = await sendCommand(branchResume, 'probe-tree-after-branch-resume', { type: 'get_tree' })
|
||||
const treeAfterBranchResumeData = treeAfterBranchResume?.data && typeof treeAfterBranchResume.data === 'object'
|
||||
? treeAfterBranchResume.data as Record<string, unknown> : {}
|
||||
console.log(`BRANCH_RESUME_READY: true`)
|
||||
console.log(`BRANCH_RESUME_SET_HOST_TOOLS: ${JSON.stringify(resumeHostTools ? summarizeFrame(resumeHostTools) : null)}`)
|
||||
console.log(`BRANCH_RESUME_MESSAGES: ${JSON.stringify(messagesAfterBranchResume ? summarizeMessages(messagesAfterBranchResume.data) : null)}`)
|
||||
console.log(`BRANCH_RESUME_TREE_LEAF_ID: ${treeAfterBranchResumeData.leafId ?? null}`)
|
||||
console.log(`TRANSCRIPT_AFTER_BRANCH_WITHOUT_PROMPT: ${JSON.stringify(await inspectTranscript(sessionPath))}`)
|
||||
}
|
||||
|
||||
const navigateTree = await sendLooseCommand(currentRun, 'probe-navigate-tree', {
|
||||
type: 'navigate_tree', entryId: resumeUserNode.id
|
||||
})
|
||||
const navigateToEntry = await sendLooseCommand(currentRun, 'probe-navigate-to-entry', {
|
||||
type: 'navigate_to_entry', entryId: resumeUserNode.id
|
||||
})
|
||||
const switchSession = await sendCommand(currentRun, 'probe-switch-session', {
|
||||
type: 'switch_session', sessionPath
|
||||
})
|
||||
const clear = await sendLooseCommand(currentRun, 'probe-clear', { type: 'clear' })
|
||||
console.log(`NAVIGATE_TREE_RESPONSE: ${JSON.stringify(navigateTree ? summarizeFrame(navigateTree) : null)}`)
|
||||
console.log(`NAVIGATE_TO_ENTRY_RESPONSE: ${JSON.stringify(navigateToEntry ? summarizeFrame(navigateToEntry) : null)}`)
|
||||
console.log(`SWITCH_SESSION_SAME_PATH: ${JSON.stringify(switchSession ? summarizeFrame(switchSession) : null)}`)
|
||||
console.log(`CLEAR_RESPONSE: ${JSON.stringify(clear ? summarizeFrame(clear) : null)}`)
|
||||
} else {
|
||||
console.log('BRANCH_ENTRY_ID: unavailable from get_tree parent relation')
|
||||
}
|
||||
await stopRun(currentRun)
|
||||
} finally {
|
||||
for (const run of runs) await stopRun(run)
|
||||
await new Promise<void>((resolveClose) => mock.server.close(() => resolveClose()))
|
||||
await rm(tempRoot, { recursive: true, force: true })
|
||||
if (existsSync(realProfilePath)) await rm(realProfilePath, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error: unknown) => {
|
||||
console.error('OMP protocol probe failed:', error)
|
||||
process.exitCode = 1
|
||||
})
|
||||
@@ -1,90 +0,0 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { EventEmitter } from 'node:events'
|
||||
import { runOmpTurn } from '../src/main/agent/omp-turn'
|
||||
import type { OmpKernel } from '../src/main/omp'
|
||||
import type { ChatEvent } from '../src/shared/types'
|
||||
|
||||
class Kernel extends EventEmitter {
|
||||
abortCalls = 0
|
||||
disposeCalls = 0
|
||||
rejectAbort = false
|
||||
async prompt(): Promise<{ type: string; success: boolean }> { return { type: 'response', success: true } }
|
||||
async abort(): Promise<void> { this.abortCalls++; if (this.rejectAbort) throw new Error('abort unavailable') }
|
||||
async dispose(): Promise<void> { this.disposeCalls++ }
|
||||
}
|
||||
function begin(kernel = new Kernel(), controller = new AbortController()) {
|
||||
const events: ChatEvent[] = []
|
||||
const outcome = runOmpTurn({ kernel: kernel as unknown as OmpKernel, runId: 'provider-error',
|
||||
userText: 'fixture', signal: controller.signal, emit: event => events.push(event) })
|
||||
.then(value => ({ value, error: undefined }), (error: Error) => ({ value: undefined, error }))
|
||||
return { kernel, controller, events, outcome }
|
||||
}
|
||||
const errorMessage = { role: 'assistant', stopReason: 'error', content: [], errorMessage: 'Unable to connect. Is the computer able to access the url?' }
|
||||
const emit = (kernel: Kernel, frame: Record<string, unknown>): void => { kernel.emit('event', frame) }
|
||||
const delay = (): Promise<void> => new Promise(resolve => setTimeout(resolve, 15))
|
||||
|
||||
{
|
||||
const test = begin()
|
||||
emit(test.kernel, { type: 'message_end', message: errorMessage })
|
||||
const result = await test.outcome
|
||||
assert.equal(result.error?.message, errorMessage.errorMessage)
|
||||
assert.equal(test.kernel.abortCalls, 1)
|
||||
assert.equal(test.events.filter(event => event.type === 'error').length, 1)
|
||||
emit(test.kernel, { type: 'agent_end', yielded: false, messages: [errorMessage] })
|
||||
emit(test.kernel, { type: 'agent_end', yielded: true })
|
||||
assert.equal(test.events.filter(event => ['done', 'cancelled'].includes(event.type)).length, 0)
|
||||
assert.equal(test.kernel.listenerCount('event'), 0)
|
||||
assert.equal(test.kernel.listenerCount('error'), 0)
|
||||
}
|
||||
{
|
||||
const test = begin()
|
||||
emit(test.kernel, { type: 'agent_end', yielded: false, messages: [{ role: 'user' }, errorMessage] })
|
||||
assert.equal((await test.outcome).error?.message, errorMessage.errorMessage)
|
||||
assert.equal(test.kernel.abortCalls, 1)
|
||||
}
|
||||
{
|
||||
const test = begin()
|
||||
emit(test.kernel, { type: 'message_end', message: { ...errorMessage, errorMessage: 'Failed https://account:secret@example.invalid/v1?key=url-secret API Key: field-secret Authorization: Bearer bearer-secret sk-plainsecret123' } })
|
||||
const message = (await test.outcome).error!.message
|
||||
for (const secret of ['account', 'secret@example', 'url-secret', 'field-secret', 'bearer-secret', 'plainsecret123']) assert.equal(message.includes(secret), false)
|
||||
assert.match(message, /已隐藏/)
|
||||
}
|
||||
{
|
||||
const test = begin()
|
||||
test.controller.abort()
|
||||
emit(test.kernel, { type: 'message_end', message: errorMessage })
|
||||
assert.equal((await test.outcome).value?.status, 'cancelled')
|
||||
assert.equal(test.events.some(event => event.type === 'error'), false)
|
||||
assert.equal(test.events.filter(event => event.type === 'cancelled').length, 1)
|
||||
assert.equal(test.kernel.abortCalls, 1)
|
||||
}
|
||||
{
|
||||
const test = begin()
|
||||
let ended = false
|
||||
void test.outcome.then(() => { ended = true })
|
||||
// There is no first-byte deadline: long thinking and tool-level failures remain live.
|
||||
emit(test.kernel, { type: 'message_update', assistantMessageEvent: { type: 'thinking_delta', delta: 'thinking', contentIndex: 0 } })
|
||||
emit(test.kernel, { type: 'message_end', message: { ...errorMessage, role: 'toolResult' } })
|
||||
emit(test.kernel, { type: 'tool_execution_start', toolCallId: 'tool', toolName: 'shell', args: {} })
|
||||
emit(test.kernel, { type: 'tool_execution_end', toolCallId: 'tool', toolName: 'shell', isError: true, result: { content: [{ type: 'text', text: 'command failed' }] } })
|
||||
emit(test.kernel, { type: 'agent_end', yielded: false, messages: [{ role: 'assistant', stopReason: 'toolUse' }] })
|
||||
await delay()
|
||||
assert.equal(ended, false)
|
||||
assert.equal(test.kernel.abortCalls, 0)
|
||||
assert.equal(test.events.some(event => event.type === 'error'), false)
|
||||
emit(test.kernel, { type: 'message_update', assistantMessageEvent: { type: 'text_delta', delta: 'recovered' } })
|
||||
emit(test.kernel, { type: 'agent_end', yielded: true, messages: [errorMessage, { role: 'assistant', stopReason: 'stop' }] })
|
||||
const result = (await test.outcome).value!
|
||||
assert.equal(result.status, 'done')
|
||||
assert.equal(result.content, 'recovered')
|
||||
assert.equal(result.tools[0].status, 'error')
|
||||
}
|
||||
{
|
||||
const test = begin()
|
||||
test.kernel.rejectAbort = true
|
||||
emit(test.kernel, { type: 'message_end', message: { role: 'assistant', stopReason: 'error' } })
|
||||
assert.equal((await test.outcome).error?.message, 'OMP 模型服务请求失败')
|
||||
await delay()
|
||||
assert.equal(test.kernel.disposeCalls, 1, 'failed abort disposes the sidecar instead of leaving automatic retries running')
|
||||
}
|
||||
console.log('omp-provider-error-regression: provider error, retry stop, sanitization, cancellation, long thinking, and tool error isolation passed (protocol fixtures; no real upstream request)')
|
||||
@@ -1,444 +0,0 @@
|
||||
import { EventEmitter } from 'node:events'
|
||||
import { testTerminalHostPlugin } from './test-terminal-host-plugin'
|
||||
import { mkdir, mkdtemp, readFile, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import { build } from 'esbuild'
|
||||
import type { OmpFrame, OmpKernel } from '../src/main/omp'
|
||||
import { runOmpTurn, type OmpTurnResult } from '../src/main/agent/omp-turn'
|
||||
import type { ChatEvent } from '../src/shared/types'
|
||||
|
||||
type Outcome<T> =
|
||||
| { kind: 'resolved'; value: T }
|
||||
| { kind: 'rejected'; error: unknown }
|
||||
| { kind: 'timeout' }
|
||||
|
||||
class FakeOmpKernel extends EventEmitter {
|
||||
promptCalls = 0
|
||||
abortCalls = 0
|
||||
ack: OmpFrame = { type: 'response', success: true }
|
||||
|
||||
prompt(_message: string): Promise<OmpFrame> {
|
||||
this.promptCalls++
|
||||
return Promise.resolve(this.ack)
|
||||
}
|
||||
|
||||
abort(): Promise<OmpFrame> {
|
||||
this.abortCalls++
|
||||
return Promise.resolve({ type: 'response', success: true })
|
||||
}
|
||||
|
||||
setHostTools(tools: Array<{ name: string }>): Promise<OmpFrame<{ toolNames: string[] }>> {
|
||||
return Promise.resolve({ type: 'response', command: 'set_host_tools', success: true, data: { toolNames: tools.map((tool) => tool.name) } })
|
||||
}
|
||||
|
||||
respondToHostTool(): Promise<void> {
|
||||
return Promise.resolve()
|
||||
}
|
||||
}
|
||||
|
||||
let failures = 0
|
||||
|
||||
function check(label: string, condition: boolean, detail = ''): void {
|
||||
if (condition) console.log(` ✓ ${label}`)
|
||||
else {
|
||||
failures++
|
||||
console.error(` ✗ ${label}${detail ? ` — ${detail}` : ''}`)
|
||||
}
|
||||
}
|
||||
|
||||
function listenerCounts(kernel: FakeOmpKernel): Record<'event' | 'error' | 'exit', number> {
|
||||
return {
|
||||
event: kernel.listenerCount('event'),
|
||||
error: kernel.listenerCount('error'),
|
||||
exit: kernel.listenerCount('exit')
|
||||
}
|
||||
}
|
||||
|
||||
function checkListeners(label: string, kernel: FakeOmpKernel, baseline: ReturnType<typeof listenerCounts>): void {
|
||||
const actual = listenerCounts(kernel)
|
||||
check(
|
||||
`${label}: event/error/exit listener counts return to baseline`,
|
||||
JSON.stringify(actual) === JSON.stringify(baseline),
|
||||
`baseline=${JSON.stringify(baseline)}, actual=${JSON.stringify(actual)}`
|
||||
)
|
||||
}
|
||||
|
||||
async function settleWithin<T>(promise: Promise<T>, timeoutMs = 250): Promise<Outcome<T>> {
|
||||
let timer: NodeJS.Timeout | undefined
|
||||
const timeout = new Promise<Outcome<T>>((resolve) => {
|
||||
timer = setTimeout(() => resolve({ kind: 'timeout' }), timeoutMs)
|
||||
})
|
||||
const settled = promise.then<Outcome<T>, Outcome<T>>(
|
||||
(value) => ({ kind: 'resolved', value }),
|
||||
(error: unknown) => ({ kind: 'rejected', error })
|
||||
)
|
||||
const outcome = await Promise.race([settled, timeout])
|
||||
if (timer) clearTimeout(timer)
|
||||
return outcome
|
||||
}
|
||||
|
||||
function turn(
|
||||
kernel: FakeOmpKernel,
|
||||
signal: AbortSignal,
|
||||
events: ChatEvent[]
|
||||
): Promise<OmpTurnResult> {
|
||||
return runOmpTurn({
|
||||
runId: 'run-regression',
|
||||
kernel: kernel as unknown as OmpKernel,
|
||||
userText: 'test prompt',
|
||||
signal,
|
||||
emit: (event) => events.push(event)
|
||||
})
|
||||
}
|
||||
|
||||
async function testTurnLifecycle(): Promise<void> {
|
||||
console.log('\n[1] OMP turn lifecycle')
|
||||
|
||||
{
|
||||
const kernel = new FakeOmpKernel()
|
||||
const baseline = listenerCounts(kernel)
|
||||
const events: ChatEvent[] = []
|
||||
const pending = turn(kernel, new AbortController().signal, events)
|
||||
kernel.emit('event', {
|
||||
type: 'message_update',
|
||||
assistantMessageEvent: { type: 'text_delta', delta: 'hello' }
|
||||
})
|
||||
kernel.emit('event', { type: 'agent_end', yielded: true })
|
||||
const outcome = await settleWithin(pending)
|
||||
check(
|
||||
'agent_end resolves as done with accumulated text',
|
||||
outcome.kind === 'resolved' && outcome.value.status === 'done' && outcome.value.content === 'hello'
|
||||
)
|
||||
check('normal completion emits done', events.some((event) => event.type === 'done'))
|
||||
checkListeners('normal completion', kernel, baseline)
|
||||
kernel.removeAllListeners()
|
||||
}
|
||||
|
||||
{
|
||||
const kernel = new FakeOmpKernel()
|
||||
const baseline = listenerCounts(kernel)
|
||||
const controller = new AbortController()
|
||||
const events: ChatEvent[] = []
|
||||
const pending = turn(kernel, controller.signal, events)
|
||||
controller.abort()
|
||||
kernel.emit('event', { type: 'agent_end', yielded: true })
|
||||
const outcome = await settleWithin(pending)
|
||||
check(
|
||||
'abort followed by agent_end resolves as cancelled',
|
||||
outcome.kind === 'resolved' && outcome.value.status === 'cancelled'
|
||||
)
|
||||
check('abort requests kernel cancellation once', kernel.abortCalls === 1, `actual ${kernel.abortCalls}`)
|
||||
check('agent_end after abort emits cancelled', events.some((event) => event.type === 'cancelled'))
|
||||
checkListeners('agent_end cancellation', kernel, baseline)
|
||||
kernel.removeAllListeners()
|
||||
}
|
||||
|
||||
{
|
||||
const kernel = new FakeOmpKernel()
|
||||
const baseline = listenerCounts(kernel)
|
||||
const events: ChatEvent[] = []
|
||||
const pending = turn(kernel, new AbortController().signal, events)
|
||||
kernel.emit('event', { type: 'prompt_result', status: 'aborted' })
|
||||
const outcome = await settleWithin(pending)
|
||||
check(
|
||||
'aborted prompt_result resolves as cancelled',
|
||||
outcome.kind === 'resolved' && outcome.value.status === 'cancelled'
|
||||
)
|
||||
check('aborted prompt_result emits cancelled', events.some((event) => event.type === 'cancelled'))
|
||||
checkListeners('prompt_result cancellation', kernel, baseline)
|
||||
kernel.removeAllListeners()
|
||||
}
|
||||
|
||||
{
|
||||
const kernel = new FakeOmpKernel()
|
||||
kernel.ack = { type: 'response', success: false, error: { message: 'ack rejected' } }
|
||||
const baseline = listenerCounts(kernel)
|
||||
const events: ChatEvent[] = []
|
||||
const pending = turn(kernel, new AbortController().signal, events)
|
||||
const outcome = await settleWithin(pending)
|
||||
check(
|
||||
'a rejected prompt ack rejects with Error',
|
||||
outcome.kind === 'rejected' && outcome.error instanceof Error && outcome.error.message === 'ack rejected',
|
||||
outcome.kind === 'rejected' ? `rejection=${String(outcome.error)}` : `outcome=${outcome.kind}`
|
||||
)
|
||||
check('a rejected prompt ack emits error', events.some((event) => event.type === 'error'))
|
||||
checkListeners('prompt ack error', kernel, baseline)
|
||||
kernel.removeAllListeners()
|
||||
}
|
||||
|
||||
{
|
||||
const kernel = new FakeOmpKernel()
|
||||
const baseline = listenerCounts(kernel)
|
||||
const events: ChatEvent[] = []
|
||||
const pending = turn(kernel, new AbortController().signal, events)
|
||||
kernel.emit('event', { type: 'prompt_result', status: 'error', error: { message: 'event rejected' } })
|
||||
const outcome = await settleWithin(pending)
|
||||
check(
|
||||
'an error prompt_result rejects with Error',
|
||||
outcome.kind === 'rejected' && outcome.error instanceof Error && outcome.error.message === 'event rejected'
|
||||
)
|
||||
check('an error prompt_result emits error', events.some((event) => event.type === 'error'))
|
||||
checkListeners('prompt_result error', kernel, baseline)
|
||||
kernel.removeAllListeners()
|
||||
}
|
||||
|
||||
{
|
||||
const kernel = new FakeOmpKernel()
|
||||
const baseline = listenerCounts(kernel)
|
||||
const events: ChatEvent[] = []
|
||||
const pending = turn(kernel, new AbortController().signal, events)
|
||||
kernel.emit('error', new Error('kernel error'))
|
||||
const outcome = await settleWithin(pending)
|
||||
check(
|
||||
'a kernel error rejects with Error',
|
||||
outcome.kind === 'rejected' && outcome.error instanceof Error && outcome.error.message === 'kernel error'
|
||||
)
|
||||
checkListeners('kernel error', kernel, baseline)
|
||||
kernel.removeAllListeners()
|
||||
}
|
||||
|
||||
{
|
||||
const kernel = new FakeOmpKernel()
|
||||
const baseline = listenerCounts(kernel)
|
||||
const events: ChatEvent[] = []
|
||||
const pending = turn(kernel, new AbortController().signal, events)
|
||||
kernel.emit('exit', 1)
|
||||
const outcome = await settleWithin(pending)
|
||||
check(
|
||||
'a kernel exit rejects with Error',
|
||||
outcome.kind === 'rejected' && outcome.error instanceof Error && outcome.error.message.includes('OMP 进程已退出')
|
||||
)
|
||||
checkListeners('kernel exit', kernel, baseline)
|
||||
kernel.removeAllListeners()
|
||||
}
|
||||
|
||||
{
|
||||
const kernel = new FakeOmpKernel()
|
||||
const baseline = listenerCounts(kernel)
|
||||
const controller = new AbortController()
|
||||
controller.abort()
|
||||
const events: ChatEvent[] = []
|
||||
const pending = turn(kernel, controller.signal, events)
|
||||
const outcome = await settleWithin(pending, 75)
|
||||
check(
|
||||
'a pre-aborted signal rejects with AbortError',
|
||||
outcome.kind === 'rejected' && outcome.error instanceof Error && outcome.error.name === 'AbortError',
|
||||
`outcome=${outcome.kind}`
|
||||
)
|
||||
check(
|
||||
'a pre-aborted signal sends neither prompt nor abort',
|
||||
kernel.promptCalls === 0 && kernel.abortCalls === 0,
|
||||
`prompt=${kernel.promptCalls}, abort=${kernel.abortCalls}`
|
||||
)
|
||||
if (outcome.kind === 'timeout') {
|
||||
kernel.emit('event', { type: 'agent_end', yielded: true })
|
||||
await pending.catch(() => undefined)
|
||||
}
|
||||
checkListeners('pre-aborted signal', kernel, baseline)
|
||||
kernel.removeAllListeners()
|
||||
}
|
||||
}
|
||||
|
||||
interface IpcTestState {
|
||||
userData: string
|
||||
handlers: Map<string, (...args: unknown[]) => unknown>
|
||||
kernel: EventEmitter | null
|
||||
promptCalls: number
|
||||
abortCalls: number
|
||||
rejectNextPrompt: boolean
|
||||
thinkingLevels: string[]
|
||||
}
|
||||
|
||||
async function testIpcCancellationPersistence(): Promise<void> {
|
||||
console.log('\n[2] IPC cancellation persistence')
|
||||
const tempRoot = await mkdtemp(join(tmpdir(), 'ycode-omp-regression-'))
|
||||
const state: IpcTestState = {
|
||||
userData: join(tempRoot, 'userData'),
|
||||
handlers: new Map(),
|
||||
kernel: null,
|
||||
promptCalls: 0,
|
||||
abortCalls: 0,
|
||||
rejectNextPrompt: false,
|
||||
thinkingLevels: []
|
||||
}
|
||||
await mkdir(state.userData, { recursive: true })
|
||||
const globalWithMock = globalThis as typeof globalThis & { __YCODE_OMP_TEST__?: IpcTestState }
|
||||
const previous = globalWithMock.__YCODE_OMP_TEST__
|
||||
globalWithMock.__YCODE_OMP_TEST__ = state
|
||||
let cleanupKernels: (() => Promise<void>) | undefined
|
||||
|
||||
try {
|
||||
const entry = join(process.cwd(), 'src', 'main', 'ipc.ts')
|
||||
const outfile = join(tempRoot, 'ipc-regression.mjs')
|
||||
await build({
|
||||
entryPoints: [entry],
|
||||
outfile,
|
||||
bundle: true,
|
||||
external: ['node-pty'],
|
||||
platform: 'node',
|
||||
format: 'esm',
|
||||
banner: { js: "import { createRequire as createIpcTestRequire } from 'node:module'; const require = createIpcTestRequire(import.meta.url);" },
|
||||
logLevel: 'silent',
|
||||
plugins: [
|
||||
testTerminalHostPlugin,
|
||||
{
|
||||
name: 'ipc-regression-mocks',
|
||||
setup(pluginBuild) {
|
||||
pluginBuild.onResolve({ filter: /^electron$/ }, () => ({ path: 'electron', namespace: 'mock-electron' }))
|
||||
pluginBuild.onLoad({ filter: /.*/, namespace: 'mock-electron' }, () => ({
|
||||
loader: 'js',
|
||||
contents: `
|
||||
const state = globalThis.__YCODE_OMP_TEST__;
|
||||
export const app = {
|
||||
getPath: (name) => name === 'userData' ? state.userData : state.userData,
|
||||
getName: () => 'YCode',
|
||||
getVersion: () => '0.1.0'
|
||||
};
|
||||
export const safeStorage = {
|
||||
isEncryptionAvailable: () => false,
|
||||
encryptString: (value) => Buffer.from(value),
|
||||
decryptString: (value) => value.toString()
|
||||
};
|
||||
export const powerSaveBlocker = { start: () => 1, stop: () => undefined, isStarted: () => true };
|
||||
export const ipcMain = {
|
||||
handle: (name, handler) => state.handlers.set(name, handler)
|
||||
};
|
||||
export const dialog = { showOpenDialog: async () => ({ canceled: true, filePaths: [] }) };
|
||||
export const shell = { openPath: async () => '' };
|
||||
export class BrowserWindow {}
|
||||
export class WebContentsView {}
|
||||
`
|
||||
}))
|
||||
pluginBuild.onResolve({ filter: /^\.\/omp$/ }, () => ({ path: 'omp', namespace: 'mock-omp' }))
|
||||
pluginBuild.onLoad({ filter: /.*/, namespace: 'mock-omp' }, () => ({
|
||||
loader: 'js',
|
||||
contents: `
|
||||
import { EventEmitter } from 'node:events';
|
||||
export class OmpKernel extends EventEmitter {
|
||||
constructor() { super(); globalThis.__YCODE_OMP_TEST__.kernel = this; }
|
||||
setThinkingLevel(level) { globalThis.__YCODE_OMP_TEST__.thinkingLevels.push(level); return Promise.resolve(); }
|
||||
prompt() {
|
||||
const state = globalThis.__YCODE_OMP_TEST__;
|
||||
state.promptCalls++;
|
||||
if (state.rejectNextPrompt) {
|
||||
state.rejectNextPrompt = false;
|
||||
return Promise.resolve({ type: 'response', success: false, error: { message: 'mock OMP error' } });
|
||||
}
|
||||
return Promise.resolve({ type: 'response', success: true });
|
||||
}
|
||||
setHostTools(tools) {
|
||||
return Promise.resolve({ type: 'response', command: 'set_host_tools', success: true, data: { toolNames: tools.map((tool) => tool.name) } });
|
||||
}
|
||||
respondToHostTool() { return Promise.resolve(); }
|
||||
send() { return Promise.resolve({ type: 'response', success: true, data: { messages: [] } }); }
|
||||
abort() {
|
||||
const state = globalThis.__YCODE_OMP_TEST__;
|
||||
state.abortCalls++;
|
||||
setImmediate(() => this.emit('event', { type: 'agent_end', yielded: true }));
|
||||
return Promise.resolve({ type: 'response', success: true });
|
||||
}
|
||||
dispose() { return Promise.resolve(); }
|
||||
}
|
||||
export const detectOmpPath = () => 'mock-omp';
|
||||
export const getOmpInfo = () => ({ available: true, exePath: 'mock-omp', profile: 'ycode', model: 'ycode/test' });
|
||||
`
|
||||
}))
|
||||
}
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
const ipc = await import(pathToFileURL(outfile).href)
|
||||
cleanupKernels = ipc.disposeOmpKernels
|
||||
;(ipc.registerIpc as (getWindow: () => unknown) => void)(() => ({
|
||||
webContents: { send: () => undefined }
|
||||
}))
|
||||
const invoke = (channel: string, ...args: unknown[]): Promise<unknown> => {
|
||||
const handler = state.handlers.get(channel)
|
||||
if (!handler) throw new Error(`Missing IPC handler: ${channel}`)
|
||||
return Promise.resolve(handler({}, ...args))
|
||||
}
|
||||
|
||||
const session = (await invoke('session:create', 'cancel persistence')) as { id: string }
|
||||
const sent = (await invoke('chat:send', { sessionId: session.id, text: 'cancel this turn' })) as { runId: string }
|
||||
// Native kernel acquisition is asynchronous; exercise cancellation after prompt dispatch.
|
||||
for (let i = 0; i < 50 && state.promptCalls === 0; i++) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 10))
|
||||
}
|
||||
const cancelled = (await invoke('chat:cancel', sent.runId)) as { cancelled: boolean }
|
||||
check('chat:cancel accepts the active run', cancelled.cancelled === true)
|
||||
|
||||
let messages: Array<{ role: string; status?: string }> = []
|
||||
for (let i = 0; i < 50; i++) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 10))
|
||||
messages = (await invoke('session:messages', session.id)) as Array<{ role: string; status?: string }>
|
||||
if (messages.at(-1)?.status !== 'streaming') break
|
||||
}
|
||||
check('the fake OMP kernel received one prompt and one abort', state.promptCalls === 1 && state.abortCalls === 1)
|
||||
check('a non-reasoning model explicitly disables native thinking before prompt', state.thinkingLevels[0] === 'off')
|
||||
check('cancelled assistant status is persisted through IPC', messages.at(-1)?.role === 'assistant' && messages.at(-1)?.status === 'cancelled', JSON.stringify(messages))
|
||||
|
||||
const sessionsFile = join(state.userData, 'sessions.json')
|
||||
const readStoredMessages = async (sessionId: string): Promise<Array<{ role: string; status?: string; error?: string }>> => {
|
||||
try {
|
||||
const stored = JSON.parse(await readFile(sessionsFile, 'utf8')) as {
|
||||
sessions: Array<{ id: string; messages: Array<{ role: string; status?: string; error?: string }> }>
|
||||
}
|
||||
return stored.sessions.find((item) => item.id === sessionId)?.messages ?? []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
let diskMessages: Array<{ role: string; status?: string; error?: string }> = []
|
||||
for (let i = 0; i < 50; i++) {
|
||||
diskMessages = await readStoredMessages(session.id)
|
||||
if (diskMessages.at(-1)?.status === 'cancelled') break
|
||||
await new Promise((resolve) => setTimeout(resolve, 10))
|
||||
}
|
||||
check('cancelled status is present on disk', diskMessages.at(-1)?.status === 'cancelled', JSON.stringify(diskMessages))
|
||||
|
||||
state.rejectNextPrompt = true
|
||||
const errorSession = (await invoke('session:create', 'error persistence')) as { id: string }
|
||||
await invoke('chat:send', { sessionId: errorSession.id, text: 'fail this turn' })
|
||||
let errorMessages: Array<{ role: string; status?: string; error?: string }> = []
|
||||
for (let i = 0; i < 50; i++) {
|
||||
errorMessages = (await invoke('session:messages', errorSession.id)) as Array<{ role: string; status?: string; error?: string }>
|
||||
if (errorMessages.at(-1)?.status === 'error') break
|
||||
await new Promise((resolve) => setTimeout(resolve, 10))
|
||||
}
|
||||
check(
|
||||
'OMP error status and message are preserved in session memory',
|
||||
errorMessages.at(-1)?.status === 'error' && errorMessages.at(-1)?.error === 'mock OMP error',
|
||||
JSON.stringify(errorMessages)
|
||||
)
|
||||
let errorDiskMessages: Array<{ role: string; status?: string; error?: string }> = []
|
||||
for (let i = 0; i < 50; i++) {
|
||||
errorDiskMessages = await readStoredMessages(errorSession.id)
|
||||
if (errorDiskMessages.at(-1)?.status === 'error') break
|
||||
await new Promise((resolve) => setTimeout(resolve, 10))
|
||||
}
|
||||
check(
|
||||
'OMP error status and message are preserved on disk',
|
||||
errorDiskMessages.at(-1)?.status === 'error' && errorDiskMessages.at(-1)?.error === 'mock OMP error',
|
||||
JSON.stringify(errorDiskMessages)
|
||||
)
|
||||
} finally {
|
||||
await cleanupKernels?.()
|
||||
globalWithMock.__YCODE_OMP_TEST__ = previous
|
||||
await rm(tempRoot, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
await testTurnLifecycle()
|
||||
await testIpcCancellationPersistence()
|
||||
console.log(failures === 0 ? '\nOMP regression checks passed.\n' : `\n${failures} OMP regression check(s) failed.\n`)
|
||||
process.exitCode = failures === 0 ? 0 : 1
|
||||
}
|
||||
|
||||
main().catch((error: unknown) => {
|
||||
console.error('OMP regression checks aborted:', error)
|
||||
process.exitCode = 1
|
||||
})
|
||||
@@ -1,293 +0,0 @@
|
||||
import { EventEmitter } from 'node:events'
|
||||
import { mkdtemp, rm, stat } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import { build } from 'esbuild'
|
||||
|
||||
interface FakeChildState {
|
||||
exePath: string
|
||||
args: string[]
|
||||
cwd: string
|
||||
child: EventEmitter & {
|
||||
pid: number
|
||||
exitCode: number | null
|
||||
signalCode: string | null
|
||||
killed: boolean
|
||||
stdin: { write: (payload: string) => boolean; end: () => void }
|
||||
kill: () => boolean
|
||||
}
|
||||
}
|
||||
|
||||
interface KernelTestState {
|
||||
children: FakeChildState[]
|
||||
readers: EventEmitter[]
|
||||
stdinWrites: string[]
|
||||
autoExitOnEnd: boolean
|
||||
stdinEnds: number
|
||||
readerCloses: number
|
||||
killCalls: number
|
||||
}
|
||||
|
||||
let failures = 0
|
||||
|
||||
function check(label: string, condition: boolean, detail = ''): void {
|
||||
if (condition) console.log(` ✓ ${label}`)
|
||||
else {
|
||||
failures++
|
||||
console.error(` ✗ ${label}${detail ? ` — ${detail}` : ''}`)
|
||||
}
|
||||
}
|
||||
|
||||
async function waitFor(predicate: () => boolean, timeoutMs = 500): Promise<boolean> {
|
||||
const deadline = Date.now() + timeoutMs
|
||||
while (Date.now() < deadline) {
|
||||
if (predicate()) return true
|
||||
await new Promise((resolve) => setTimeout(resolve, 5))
|
||||
}
|
||||
return predicate()
|
||||
}
|
||||
|
||||
async function buildKernelBundle(outfile: string): Promise<void> {
|
||||
await build({
|
||||
entryPoints: [join(process.cwd(), 'src', 'main', 'omp.ts')],
|
||||
outfile,
|
||||
bundle: true,
|
||||
platform: 'node',
|
||||
format: 'esm',
|
||||
logLevel: 'silent',
|
||||
plugins: [
|
||||
{
|
||||
name: 'omp-session-kernel-mocks',
|
||||
setup(pluginBuild) {
|
||||
pluginBuild.onResolve({ filter: /^node:child_process$/ }, () => ({
|
||||
path: 'mock-child-process',
|
||||
namespace: 'omp-session-mock'
|
||||
}))
|
||||
pluginBuild.onLoad({ filter: /^mock-child-process$/, namespace: 'omp-session-mock' }, () => ({
|
||||
loader: 'js',
|
||||
contents: `
|
||||
import { EventEmitter } from 'node:events';
|
||||
const state = globalThis.__YCODE_OMP_SESSION_TEST__;
|
||||
class FakeChild extends EventEmitter {
|
||||
constructor(exePath, args, cwd) {
|
||||
super();
|
||||
this.pid = 100 + state.children.length;
|
||||
this.exitCode = null;
|
||||
this.signalCode = null;
|
||||
this.killed = false;
|
||||
this.stdin = {
|
||||
write: (payload) => {
|
||||
state.stdinWrites.push(payload);
|
||||
let command;
|
||||
try { command = JSON.parse(payload); } catch { return true; }
|
||||
if (typeof command.id === 'string' && command.type !== 'host_tool_result') {
|
||||
const reader = state.readers[state.readers.length - 1];
|
||||
const data = command.type === 'set_host_tools'
|
||||
? { toolNames: (command.tools || []).map((tool) => tool.name) }
|
||||
: command.type === 'get_branch_messages'
|
||||
? { messages: [{ entryId: 'user-entry-1', text: 'saved prompt' }] }
|
||||
: command.type === 'get_tree' ? { tree: [], leafId: null }
|
||||
: command.type === 'get_messages' ? { messages: [] } : {};
|
||||
setImmediate(() => reader?.emit('line', JSON.stringify({
|
||||
id: command.id, type: 'response', command: command.type, success: true, data
|
||||
})));
|
||||
}
|
||||
return true;
|
||||
},
|
||||
end: () => {
|
||||
state.stdinEnds++;
|
||||
if (state.autoExitOnEnd) setTimeout(() => this.finish(0, null), 5);
|
||||
}
|
||||
};
|
||||
this.stdout = {};
|
||||
this.stderr = new EventEmitter();
|
||||
state.children.push({ exePath, args, cwd, child: this });
|
||||
}
|
||||
finish(code, signal) {
|
||||
if (this.exitCode !== null || this.signalCode !== null) return;
|
||||
this.exitCode = code;
|
||||
this.signalCode = signal;
|
||||
this.emit('exit', code, signal);
|
||||
this.emit('close', code, signal);
|
||||
}
|
||||
kill() {
|
||||
this.killed = true;
|
||||
state.killCalls++;
|
||||
this.finish(null, 'SIGTERM');
|
||||
return true;
|
||||
}
|
||||
}
|
||||
export function spawn(exePath, args, options) {
|
||||
return new FakeChild(exePath, args, options.cwd);
|
||||
}
|
||||
`
|
||||
}))
|
||||
pluginBuild.onResolve({ filter: /^node:readline$/ }, () => ({
|
||||
path: 'mock-readline',
|
||||
namespace: 'omp-session-mock'
|
||||
}))
|
||||
pluginBuild.onLoad({ filter: /^mock-readline$/, namespace: 'omp-session-mock' }, () => ({
|
||||
loader: 'js',
|
||||
contents: `
|
||||
import { EventEmitter } from 'node:events';
|
||||
const state = globalThis.__YCODE_OMP_SESSION_TEST__;
|
||||
export function createInterface() {
|
||||
const reader = new EventEmitter();
|
||||
reader.close = () => { state.readerCloses++; };
|
||||
state.readers.push(reader);
|
||||
return reader;
|
||||
}
|
||||
`
|
||||
}))
|
||||
}
|
||||
}
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const tempRoot = await mkdtemp(join(tmpdir(), 'ycode-omp-session-regression-'))
|
||||
const state: KernelTestState = {
|
||||
children: [],
|
||||
readers: [],
|
||||
stdinWrites: [],
|
||||
autoExitOnEnd: true,
|
||||
stdinEnds: 0,
|
||||
readerCloses: 0,
|
||||
killCalls: 0
|
||||
}
|
||||
const globalWithMock = globalThis as typeof globalThis & { __YCODE_OMP_SESSION_TEST__?: KernelTestState }
|
||||
const previous = globalWithMock.__YCODE_OMP_SESSION_TEST__
|
||||
globalWithMock.__YCODE_OMP_SESSION_TEST__ = state
|
||||
|
||||
try {
|
||||
const outfile = join(tempRoot, 'omp-kernel-regression.mjs')
|
||||
await buildKernelBundle(outfile)
|
||||
const { OmpKernel, parseFirstProfileModel } = await import(pathToFileURL(outfile).href)
|
||||
|
||||
const yaml = [
|
||||
'providers:',
|
||||
' local-probe:',
|
||||
' api: openai-completions',
|
||||
' models:',
|
||||
' - id: probe-model',
|
||||
' name: Probe model',
|
||||
' second-provider:',
|
||||
' models:',
|
||||
' - id: second-model'
|
||||
].join('\n')
|
||||
check('profile model resolution returns provider/model, not profile/model',
|
||||
parseFirstProfileModel(yaml) === 'local-probe/probe-model')
|
||||
check('model resolution returns null when models.yml has no configured provider model',
|
||||
parseFirstProfileModel('providers:\n empty-provider:\n models: []') === null)
|
||||
|
||||
const sessionPath = join(tempRoot, 'native', 'session-one.jsonl')
|
||||
const selectedModel = 'local-probe/probe-model'
|
||||
const persistent = new OmpKernel(process.execPath, tempRoot, 'ycode', sessionPath, selectedModel)
|
||||
const persistentStart = persistent.start()
|
||||
check('session parent directory is created before spawn', await waitFor(() => state.children.length === 1) &&
|
||||
(await stat(dirname(sessionPath))).isDirectory())
|
||||
check('persistent start reaches the ready frame', state.readers.length === 1)
|
||||
state.readers[0]?.emit('line', JSON.stringify({ type: 'ready' }))
|
||||
await persistentStart
|
||||
const persistentArgs = state.children[0]?.args ?? []
|
||||
check('persistent mode passes the configured provider/model selection',
|
||||
persistentArgs.includes(`--model=${selectedModel}`))
|
||||
check('built-in OMP tools are disabled by default so execution stays behind the host broker',
|
||||
persistentArgs.includes('--no-tools'))
|
||||
check('persistent mode passes the native resume path and session directory',
|
||||
persistentArgs.includes(`--resume=${sessionPath}`) && persistentArgs.includes(`--session-dir=${dirname(sessionPath)}`))
|
||||
check('persistent mode omits --no-session', !persistentArgs.includes('--no-session'))
|
||||
|
||||
const hostToolsAck = await persistent.setHostTools([{
|
||||
name: 'read',
|
||||
description: 'Read workspace text',
|
||||
parameters: { type: 'object', properties: { path: { type: 'string' } }, required: ['path'] }
|
||||
}])
|
||||
const branchMessages = await persistent.getBranchMessages()
|
||||
const branchAck = await persistent.branch('user-entry-1')
|
||||
await persistent.respondToHostTool('host-request-1', 'tool result')
|
||||
const image = { type: 'image' as const, data: 'c21hbGw=', mimeType: 'image/png' }
|
||||
const promptAck = await persistent.prompt('inspect this image', [image])
|
||||
const writtenFrames = state.stdinWrites.map((payload) => JSON.parse(payload) as Record<string, unknown>)
|
||||
const promptFrame = writtenFrames.find((frame) => frame.type === 'prompt')
|
||||
const hostResultFrame = writtenFrames.find((frame) => frame.type === 'host_tool_result')
|
||||
check('host tools, branch history, and images use their typed RPC helpers',
|
||||
hostToolsAck.success === true && branchMessages.data.messages[0]?.entryId === 'user-entry-1' &&
|
||||
branchAck.success === true && promptAck.success === true &&
|
||||
Array.isArray(promptFrame?.images) &&
|
||||
JSON.stringify((promptFrame.images as unknown[])[0]) === JSON.stringify(image) &&
|
||||
hostResultFrame?.id === 'host-request-1')
|
||||
|
||||
let sawExit = false
|
||||
persistent.once('exit', () => { sawExit = true })
|
||||
await persistent.dispose()
|
||||
check('dispose ends stdin and waits until the child exits', state.stdinEnds === 1 && sawExit)
|
||||
check('kernel event listeners are removed after child exit', persistent.eventNames().length === 0)
|
||||
|
||||
const ephemeral = new OmpKernel(process.execPath, tempRoot, 'ycode', undefined, selectedModel)
|
||||
const ephemeralStart = ephemeral.start()
|
||||
check('ephemeral start creates a second child', await waitFor(() => state.children.length === 2))
|
||||
state.readers[1]?.emit('line', JSON.stringify({ type: 'ready' }))
|
||||
await ephemeralStart
|
||||
const ephemeralArgs = state.children[1]?.args ?? []
|
||||
check('three-argument constructors keep --no-session behavior',
|
||||
ephemeralArgs.includes('--no-session') && !ephemeralArgs.some((arg) => arg.startsWith('--resume=')) &&
|
||||
ephemeralArgs.includes('--no-tools'))
|
||||
await ephemeral.dispose()
|
||||
|
||||
const missingModel = new OmpKernel(process.execPath, tempRoot, 'ycode', undefined, null)
|
||||
let missingModelError: unknown
|
||||
try {
|
||||
await missingModel.start()
|
||||
} catch (error) {
|
||||
missingModelError = error
|
||||
}
|
||||
check('a profile without provider/model fails before spawn',
|
||||
missingModelError instanceof Error && missingModelError.message.includes('provider/model') && state.children.length === 2)
|
||||
|
||||
state.autoExitOnEnd = false
|
||||
const forceKilled = new OmpKernel(process.execPath, tempRoot, 'ycode', join(tempRoot, 'native', 'session-three.jsonl'), selectedModel)
|
||||
const forceKillStart = forceKilled.start()
|
||||
check('force-kill case starts a third child', await waitFor(() => state.children.length === 3))
|
||||
state.readers[2]?.emit('line', JSON.stringify({ type: 'ready' }))
|
||||
await forceKillStart
|
||||
check('built-in OMP tools remain disabled for every kernel session',
|
||||
state.children[2]?.args.includes('--no-tools') === true)
|
||||
const killStartedAt = Date.now()
|
||||
const keepAlive = setTimeout(() => undefined, 1800)
|
||||
await forceKilled.dispose()
|
||||
clearTimeout(keepAlive)
|
||||
const killDelay = Date.now() - killStartedAt
|
||||
check('stalled child is killed after the 1500ms grace period',
|
||||
state.killCalls === 1 && state.children[2]?.child.killed === true && killDelay >= 1400 && killDelay < 2500,
|
||||
`${killDelay}ms`)
|
||||
|
||||
const recoverable = new OmpKernel(process.execPath, tempRoot, 'ycode', sessionPath, selectedModel)
|
||||
const firstStart = recoverable.start()
|
||||
check('recoverable kernel creates a fresh sidecar', await waitFor(() => state.children.length === 4))
|
||||
state.readers[3]?.emit('line', JSON.stringify({ type: 'ready' }))
|
||||
await firstStart
|
||||
state.children[3]?.child.finish(1, null)
|
||||
const recoveredStart = recoverable.start()
|
||||
check('kernel can restart after its sidecar exits', await waitFor(() => state.children.length === 5))
|
||||
state.readers[4]?.emit('line', JSON.stringify({ type: 'ready' }))
|
||||
await recoveredStart
|
||||
const recoveredArgs = state.children[4]?.args ?? []
|
||||
check('restarted sidecar reuses the same resume path and provider/model',
|
||||
recoveredArgs.includes(`--resume=${sessionPath}`) && recoveredArgs.includes(`--model=${selectedModel}`))
|
||||
await recoverable.dispose()
|
||||
|
||||
console.log(failures === 0 ? '\nOMP session regression checks passed.\n' : `\n${failures} OMP session regression check(s) failed.\n`)
|
||||
process.exitCode = failures === 0 ? 0 : 1
|
||||
} finally {
|
||||
globalWithMock.__YCODE_OMP_SESSION_TEST__ = previous
|
||||
await rm(tempRoot, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error: unknown) => {
|
||||
console.error('OMP session regression checks aborted:', error)
|
||||
process.exitCode = 1
|
||||
})
|
||||
@@ -1,96 +0,0 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { promises as fs } from 'node:fs'
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import { configureSandbox } from '../src/main/sandbox/configuration'
|
||||
import { listSandboxToolchains, importSandboxToolchain } from '../src/main/sandbox/toolchains'
|
||||
import { executeSandboxCommand, sandboxStatus } from '../src/main/sandbox/service'
|
||||
|
||||
if (process.platform !== 'win32') throw new Error('Bundled Git verification requires real Windows isolation')
|
||||
const fixture = await fs.mkdtemp(resolve('.tmp/sandbox-bundled-git-'))
|
||||
const workspace = join(fixture, 'workspace \u4e2d\u6587'), stateRoot = join(fixture, 'data'), resources = resolve(process.env.YCODE_TEST_BUNDLED_RESOURCES ?? 'resources/sandbox/win32-x64')
|
||||
await fs.mkdir(workspace); await fs.writeFile(join(workspace, 'input.txt'), 'baseline\n')
|
||||
const lock = JSON.parse(await fs.readFile(resolve('resources/sandbox/git-runtime.lock.json'), 'utf8'))
|
||||
const checks: Array<{ label: string; pass: boolean }> = []
|
||||
function check(label: string, condition: unknown): void { checks.push({ label, pass: !!condition }); assert.ok(condition, label); console.log(`PASS ${label}`) }
|
||||
const context = { workspaceRoot: workspace, runtimes: ['git'], sessionId: 'bundled-git-session', runId: 'bundled-git-run', toolCallId: 'bundled-git-call', approve: async () => true }
|
||||
const configure = (resourceDirectory = resources, useBundledGit = true): void => configureSandbox({ stateRoot, resourceDirectory, useBundledGit })
|
||||
const gitView = async () => (await listSandboxToolchains()).find(item => item.kind === 'git')!
|
||||
const { verifyBundledGit } = await import(pathToFileURL(resolve('scripts/vendor-sandbox-git.mjs')).href)
|
||||
const originalPath = process.env.PATH
|
||||
try {
|
||||
configure()
|
||||
const view = await gitView()
|
||||
check('fresh userData exposes the pinned bundled Git without registration', view.state === 'registered' && view.source === 'bundled' && view.version === lock.version)
|
||||
check('availability queries do not create a user Git registration', !await fs.stat(join(stateRoot, 'toolchains/git.json')).catch(() => null))
|
||||
const component = JSON.parse(await fs.readFile(join(resources, 'manifest.json'), 'utf8'))
|
||||
const bundle = await verifyBundledGit(join(resources, 'git'))
|
||||
check('native component pins the complete bundled runtime manifest', component.bundledGit.manifestSha256 === bundle.manifestSha256 && component.bundledGit.archiveSha256 === lock.archive.sha256)
|
||||
const notices = await fs.readFile(join(resources, 'git/files/THIRD_PARTY_NOTICES.txt'), 'utf8')
|
||||
check('license, copyright and source provenance travel with the runtime', notices.includes(lock.releaseUrl) && notices.includes('GPL-2.0') && (await fs.readFile(join(resources, 'git/files/LICENSE.txt'), 'utf8')).includes('GNU GENERAL PUBLIC LICENSE'))
|
||||
process.env.PATH = ''
|
||||
let result = await executeSandboxCommand('git --version && git status --porcelain=v1', context)
|
||||
check('real AppContainer Git works with empty host PATH and no user registry', result.ok && result.data.stdout.includes(`git version ${lock.version}`) && result.data.toolchains?.[0].id === view.id && result.data.git?.persistence === 'unchanged')
|
||||
result = await executeSandboxCommand('echo bundled>bundled.txt && git add bundled.txt && git commit -q -m bundled-offline-commit', context)
|
||||
check('bundled Git stages and commits through the existing approval boundary', result.ok && result.data.git?.persistence === 'saved' && result.data.writeback === 'committed' && (await fs.readFile(join(workspace, 'bundled.txt'), 'utf8')).trim() === 'bundled')
|
||||
result = await executeSandboxCommand('git log -1 --oneline && git status --porcelain=v1', context)
|
||||
check('a later command sees the saved bundled-Git commit', result.ok && result.data.stdout.includes('bundled-offline-commit') && result.data.git?.persistence === 'unchanged')
|
||||
const copied = join(fixture, 'app resources \u5b89\u88c5/sandbox/win32-x64')
|
||||
await fs.cp(resources, copied, { recursive: true, errorOnExist: true, force: false })
|
||||
configure(copied)
|
||||
result = await executeSandboxCommand('git --version && git log -1 --oneline', context)
|
||||
check('copied application resources work at a space/Unicode installation path', result.ok && result.data.stdout.includes(`git version ${lock.version}`) && result.data.stdout.includes('bundled-offline-commit'))
|
||||
const override = await importSandboxToolchain('git', join(copied, 'git/files/git.exe'))
|
||||
check('a deliberate imported override takes precedence over the bundled runtime', (await gitView()).source === 'imported' && (await gitView()).id === override.id && override.id !== view.id)
|
||||
const overrideIdentity = JSON.parse(await fs.readFile(join(stateRoot, 'toolchains/git.json'), 'utf8'))
|
||||
const overrideImage = join(stateRoot, 'toolchains/objects', overrideIdentity.id, 'files/git.exe')
|
||||
await fs.appendFile(overrideImage, 'tampered')
|
||||
await assert.rejects(executeSandboxCommand('git --version', context), /摘要不匹配/)
|
||||
check('a damaged explicit override is rejected without silently switching sources', (await gitView()).source === 'imported')
|
||||
const alias = join(stateRoot, 'toolchains/git.json'), aliasBytes = await fs.readFile(alias)
|
||||
await fs.writeFile(alias, '{}')
|
||||
await assert.rejects(executeSandboxCommand('git --version', context), /引用无效/)
|
||||
check('an invalid user reference does not activate a bundled fallback', (await gitView()).state === 'invalid')
|
||||
await fs.writeFile(alias, aliasBytes); await fs.unlink(alias)
|
||||
check('removing the optional override restores the application default', (await gitView()).source === 'bundled' && (await gitView()).id === view.id)
|
||||
const image = join(copied, 'git/files/git.exe'), imageBytes = await fs.readFile(image)
|
||||
await fs.appendFile(image, 'tampered')
|
||||
await assert.rejects(executeSandboxCommand('git --version', context), /摘要不匹配/)
|
||||
await assert.rejects(verifyBundledGit(join(copied, 'git')), /digest mismatch/)
|
||||
check('tampered bundled executable fails runtime and packaging checks', true)
|
||||
await fs.writeFile(image, imageBytes)
|
||||
const manifestPath = join(copied, 'git/manifest.json'), manifestBytes = await fs.readFile(manifestPath)
|
||||
await fs.appendFile(manifestPath, ' ')
|
||||
await assert.rejects(executeSandboxCommand('git --version', context), /内置 Git 清单摘要不匹配/)
|
||||
await assert.rejects(verifyBundledGit(join(copied, 'git')), /manifest digest mismatch/)
|
||||
check('a bundled manifest replacement is rejected against the component pin', (await gitView()).state === 'invalid')
|
||||
await fs.writeFile(manifestPath, manifestBytes)
|
||||
await fs.rename(manifestPath, `${manifestPath}.missing`)
|
||||
await assert.rejects(executeSandboxCommand('git --version', context), /ENOENT/)
|
||||
check('missing bundled files never fall back to the system Git installation', (await gitView()).state === 'invalid')
|
||||
result = await executeSandboxCommand('echo SHELL_STILL_ISOLATED', { ...context, runtimes: [] })
|
||||
check('a Git component failure does not turn shell execution into a host fallback', result.ok && result.data.isolated && result.data.stdout.includes('SHELL_STILL_ISOLATED') && (await sandboxStatus()).capabilities?.some(item => item.id === 'git' && item.state === 'unavailable'))
|
||||
await fs.rename(`${manifestPath}.missing`, manifestPath)
|
||||
configure(copied, false)
|
||||
check('a host configuration can explicitly disable bundled Git without granting host PATH', (await gitView()).state === 'missing')
|
||||
configure(copied)
|
||||
check('restored resources expose the original pinned identity', (await gitView()).id === view.id && (await verifyBundledGit(join(copied, 'git'))).manifestSha256 === bundle.manifestSha256)
|
||||
process.env.PATH = originalPath
|
||||
const badArchive = join(fixture, 'bad.zip'); await fs.writeFile(badArchive, 'not-an-archive')
|
||||
assert.throws(() => execFileSync(process.execPath, [resolve('scripts/vendor-sandbox-git.mjs'), '--archive', badArchive, '--offline', '--output', join(fixture, 'bad-output/git')], { windowsHide: true, stdio: 'pipe' }), /Official MinGit archive digest mismatch/)
|
||||
check('the explicit vendor command rejects archives with a wrong digest before extraction', true)
|
||||
const unrelated = join(fixture, 'unrelated/git'); await fs.mkdir(unrelated, { recursive: true }); await fs.writeFile(join(unrelated, 'user-file.txt'), 'preserve-user-data')
|
||||
assert.throws(() => execFileSync(process.execPath, [resolve('scripts/vendor-sandbox-git.mjs'), '--offline', '--output', unrelated], { windowsHide: true, stdio: 'pipe' }), /Refusing to replace an existing directory/)
|
||||
check('vendoring refuses to move an unrelated existing output directory', await fs.readFile(join(unrelated, 'user-file.txt'), 'utf8') === 'preserve-user-data')
|
||||
if (process.env.YCODE_TEST_MINGIT_ARCHIVE) {
|
||||
const output = join(fixture, 'reproducible/git')
|
||||
execFileSync(process.execPath, [resolve('scripts/vendor-sandbox-git.mjs'), '--archive', resolve(process.env.YCODE_TEST_MINGIT_ARCHIVE), '--offline', '--output', output], { windowsHide: true, stdio: 'pipe', timeout: 60_000 })
|
||||
check('the pinned archive produces an identical manifest in an offline rebuild', (await verifyBundledGit(output)).manifestSha256 === bundle.manifestSha256)
|
||||
}
|
||||
console.log(`SANDBOX_BUNDLED_GIT_PASS ${checks.length}/${checks.length}`)
|
||||
} finally {
|
||||
if (originalPath === undefined) delete process.env.PATH; else process.env.PATH = originalPath
|
||||
await fs.writeFile(join(fixture, 'report.json'), JSON.stringify({ checks, fixture, resources, version: lock.version, scope: 'Real AppContainer execution using bundled Git with no user registration and empty host PATH; copied/actual packaged resources when supplied.' }, null, 2) + '\n')
|
||||
console.log(`SANDBOX_BUNDLED_GIT_REPORT ${join(fixture, 'report.json')}`)
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { promises as fs } from 'node:fs'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { configureSandbox } from '../src/main/sandbox/configuration'
|
||||
import { executeSandboxCommand } from '../src/main/sandbox/service'
|
||||
import { commitChanges, restoreTransaction, digest } from '../src/main/sandbox/transactions'
|
||||
|
||||
if (process.platform !== 'win32') throw new Error('Directory integration requires real Windows native execution')
|
||||
await fs.mkdir(resolve('.tmp'), { recursive: true })
|
||||
const fixture = await fs.mkdtemp(resolve('.tmp/sandbox-directories-')), workspace = join(fixture, 'workspace'), stateRoot = join(fixture, 'state')
|
||||
await fs.mkdir(workspace)
|
||||
configureSandbox({ stateRoot, resourceDirectory: resolve('resources/sandbox/win32-x64') })
|
||||
const context = { workspaceRoot: workspace, sessionId: 'directory-session', runId: 'directory-run', toolCallId: 'directory-call', approve: async () => true }
|
||||
const checks: Array<{ label: string; pass: boolean }> = []
|
||||
function check(label: string, condition: unknown): void { checks.push({ label, pass: !!condition }); assert.ok(condition, label); console.log(`PASS ${label}`) }
|
||||
try {
|
||||
let approvals = 0
|
||||
let result = await executeSandboxCommand('mkdir empty\\nested', { ...context, approve: async () => { approvals++; return true } })
|
||||
check('empty directory tree commits after one batch approval', result.ok && approvals === 1 && result.data.changes.filter(change => change.operation === 'mkdir').length === 2 && (await fs.stat(join(workspace, 'empty/nested'))).isDirectory())
|
||||
const manifest = JSON.parse(await fs.readFile(join(stateRoot, 'transactions', result.data.transactionId!, 'manifest.json'), 'utf8'))
|
||||
check('directory transaction persists a versioned recovery journal', manifest.version === 3 && manifest.directories.length === 2 && /^[a-f0-9]{16}:[a-f0-9]{32}$/.test(manifest.workspaceIdentity))
|
||||
await restoreTransaction(result.data.transactionId!, workspace); await restoreTransaction(result.data.transactionId!, workspace)
|
||||
check('directory creation recovery removes children and parents and is idempotent', !await fs.stat(join(workspace, 'empty')).catch(() => null))
|
||||
result = await executeSandboxCommand('mkdir denied', { ...context, approve: async () => false })
|
||||
check('directory creation rejection leaves no host directory', !result.ok && !await fs.stat(join(workspace, 'denied')).catch(() => null))
|
||||
await fs.mkdir(join(workspace, 'original/sub'), { recursive: true }); await fs.writeFile(join(workspace, 'original/sub/data.bin'), Buffer.from([0, 1, 127, 255]))
|
||||
result = await executeSandboxCommand('rmdir /s /q original', context)
|
||||
check('recursive directory deletion records files and directories in one transaction', result.ok && result.data.changes.some(change => change.operation === 'delete') && result.data.changes.filter(change => change.operation === 'rmdir').length === 2 && !await fs.stat(join(workspace, 'original')).catch(() => null))
|
||||
await restoreTransaction(result.data.transactionId!, workspace)
|
||||
check('recursive deletion recovery recreates original directories and exact binary bytes', (await fs.readFile(join(workspace, 'original/sub/data.bin'))).equals(Buffer.from([0, 1, 127, 255])))
|
||||
result = await executeSandboxCommand('move original moved', context)
|
||||
check('directory rename is represented by complete transactional tree changes', result.ok && !await fs.stat(join(workspace, 'original')).catch(() => null) && (await fs.stat(join(workspace, 'moved/sub'))).isDirectory())
|
||||
await restoreTransaction(result.data.transactionId!, workspace)
|
||||
check('directory rename restores its original tree and removes the new tree', !await fs.stat(join(workspace, 'moved')).catch(() => null) && (await fs.stat(join(workspace, 'original/sub'))).isDirectory())
|
||||
const conflict = await executeSandboxCommand('rmdir /s /q original', { ...context, approve: async () => { await fs.writeFile(join(workspace, 'original/external.txt'), 'external'); return true } }).then(() => null, error => error as Error)
|
||||
check('approval-time additions reject the full directory deletion before mutation', !!conflict && (await fs.readFile(join(workspace, 'original/sub/data.bin'))).equals(Buffer.from([0, 1, 127, 255])) && await fs.readFile(join(workspace, 'original/external.txt'), 'utf8') === 'external')
|
||||
await fs.unlink(join(workspace, 'original/external.txt'))
|
||||
await fs.writeFile(join(workspace, 'original/.env'), 'DUMMY_CREDENTIAL')
|
||||
await assert.rejects(executeSandboxCommand('rmdir /s /q original', context), /未授权|新增内容/)
|
||||
check('excluded credentials inside a deleted directory are protected by host preflight', await fs.readFile(join(workspace, 'original/.env'), 'utf8') === 'DUMMY_CREDENTIAL' && (await fs.stat(join(workspace, 'original/sub/data.bin'))).isFile())
|
||||
await fs.unlink(join(workspace, 'original/.env'))
|
||||
result = await executeSandboxCommand('mkdir restored-conflict & echo generated>restored-conflict\\file.txt', context)
|
||||
await fs.writeFile(join(workspace, 'restored-conflict/external.txt'), 'external')
|
||||
await assert.rejects(restoreTransaction(result.data.transactionId!, workspace), /未授权|新增内容/)
|
||||
check('directory recovery refuses to remove later external additions', (await fs.readFile(join(workspace, 'restored-conflict/file.txt'), 'utf8')).trim() === 'generated' && await fs.readFile(join(workspace, 'restored-conflict/external.txt'), 'utf8') === 'external')
|
||||
await fs.unlink(join(workspace, 'restored-conflict/external.txt')); await restoreTransaction(result.data.transactionId!, workspace)
|
||||
check('recovery succeeds after the external conflict is explicitly resolved', !await fs.stat(join(workspace, 'restored-conflict')).catch(() => null))
|
||||
await fs.writeFile(join(workspace, 'sample.txt'), 'before')
|
||||
let guarded = 0
|
||||
const failed = await commitChanges(workspace, [{ path: 'sample.txt', beforeHash: digest(Buffer.from('before')), after: Buffer.from('after') }], context, undefined,
|
||||
{ beforeApply: async () => undefined, check: async () => { if (++guarded === 3) throw new Error('injected policy revocation') } }, [{ path: 'compensated-empty', before: false, after: true }]).then(() => null, error => error as Error & { recoveryRequired?: boolean })
|
||||
check('mid-commit failure compensates both file writes and created directories', !!failed && !failed.recoveryRequired && await fs.readFile(join(workspace, 'sample.txt'), 'utf8') === 'before' && !await fs.stat(join(workspace, 'compensated-empty')).catch(() => null))
|
||||
console.log(`SANDBOX_DIRECTORY_PASS ${checks.length}/${checks.length}`)
|
||||
} finally {
|
||||
await fs.writeFile(join(fixture, 'report.json'), JSON.stringify({ checks, fixture, limitations: ['Native NTFS transactions pin root/ancestors and use relative handle operations. Rename handoff is not a single atomic swap. ACL/timestamp parity, power-loss durability and Windows recycle-bin semantics remain pending.'] }, null, 2) + '\n')
|
||||
console.log(`SANDBOX_DIRECTORY_REPORT ${join(fixture, 'report.json')}`)
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { mkdtemp, readFile, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { executeTool, toolSpecs } from '../src/main/agent/tools'
|
||||
|
||||
const workspace = await mkdtemp(join(tmpdir(), 'ycode-host-tools-'))
|
||||
let approvals = 0
|
||||
const context = {
|
||||
workspaceRoot: workspace,
|
||||
permissionMode: 'default-sandbox' as const,
|
||||
approve: async () => {
|
||||
approvals += 1
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const specs = toolSpecs()
|
||||
const names = specs.map(spec => spec.function.name)
|
||||
assert.equal(names.includes('sandbox_workspace'), false)
|
||||
assert.equal(names.includes('sandbox_git'), false)
|
||||
const command = specs.find(spec => spec.function.name === 'execute_command')
|
||||
const properties = command?.function.parameters.properties as Record<string, unknown> | undefined
|
||||
assert.deepEqual(Object.keys(properties ?? {}), ['command'])
|
||||
|
||||
const written = await executeTool('write_file', { path: 'nested/file.txt', content: 'first' }, context)
|
||||
assert.equal(written.ok, true)
|
||||
assert.equal(await readFile(join(workspace, 'nested/file.txt'), 'utf8'), 'first')
|
||||
assert.equal(approvals, 0)
|
||||
|
||||
const edited = await executeTool('edit_file', { path: 'nested/file.txt', oldText: 'first', newText: 'second' }, context)
|
||||
assert.equal(edited.ok, true)
|
||||
assert.equal(await readFile(join(workspace, 'nested/file.txt'), 'utf8'), 'second')
|
||||
|
||||
const commandResult = await executeTool('execute_command', {
|
||||
command: `${JSON.stringify(process.execPath)} -e "require('node:fs').writeFileSync('host-command.txt','host')"`
|
||||
}, context)
|
||||
assert.equal(commandResult.ok, true)
|
||||
assert.equal(await readFile(join(workspace, 'host-command.txt'), 'utf8'), 'host')
|
||||
assert.equal(JSON.stringify(commandResult.data).includes('windows-appcontainer'), false)
|
||||
assert.equal(JSON.stringify(commandResult.data).includes('sandbox'), false)
|
||||
assert.equal(approvals, 0)
|
||||
|
||||
const outside = await executeTool('read_file', { path: '../outside.txt' }, context)
|
||||
assert.equal(outside.ok, false)
|
||||
assert.match(outside.content, /工作区/)
|
||||
console.log('sandbox-disabled-regression: host tools are active and legacy sandbox modes are inert')
|
||||
} finally {
|
||||
await rm(workspace, { recursive: true, force: true })
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { promises as fs } from 'node:fs'
|
||||
import { spawn } from 'node:child_process'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { release, arch } from 'node:os'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { configureSandbox } from '../src/main/sandbox/configuration'
|
||||
import { executeSandboxCommand } from '../src/main/sandbox/service'
|
||||
|
||||
if (process.platform !== 'win32') throw new Error('P0 mechanisms must be tested on Windows')
|
||||
await fs.mkdir(resolve('.tmp'), { recursive: true })
|
||||
const fixture = await fs.mkdtemp(resolve('.tmp/sandbox-feasibility-')), workspace = join(fixture, 'workspace')
|
||||
await fs.mkdir(workspace)
|
||||
const probe = resolve('resources/sandbox/win32-x64/sandbox-probe.exe'), registryId = randomUUID()
|
||||
configureSandbox({ stateRoot: join(fixture, 'state'), resourceDirectory: resolve('resources/sandbox/win32-x64') })
|
||||
await fs.copyFile(probe, join(workspace, 'sandbox-probe.exe'))
|
||||
const checks: Array<{ label: string; pass: boolean }> = [], observations: Record<string, unknown> = { os: release(), architecture: arch() }
|
||||
function check(label: string, condition: unknown): void { checks.push({ label, pass: !!condition }); assert.ok(condition, label); console.log(`PASS ${label}`) }
|
||||
function host(args: string[]): Promise<{ code: number | null; stdout: string }> {
|
||||
return new Promise((done, reject) => {
|
||||
const child = spawn(probe, args, { windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] }); let stdout = ''
|
||||
const timeout = setTimeout(() => child.kill(), 8000)
|
||||
child.stdout.on('data', chunk => { stdout += chunk }); child.stderr.on('data', chunk => { stdout += chunk })
|
||||
child.once('error', error => { clearTimeout(timeout); reject(error) }); child.once('close', code => { clearTimeout(timeout); done({ code, stdout }) })
|
||||
})
|
||||
}
|
||||
try {
|
||||
observations.host = JSON.parse((await host(['--host-facts'])).stdout)
|
||||
const created = await host(['--registry-create', registryId])
|
||||
check('registry fixture is created only under a UUID-owned test key', created.code === 0)
|
||||
const before = await host(['--registry-read', registryId])
|
||||
check('host positive control reads the exact fake registry value', before.code === 0 && before.stdout.includes('1193046'))
|
||||
const context = { workspaceRoot: workspace, runId: 'p0-mechanisms', toolCallId: 'registry' }
|
||||
const read = await executeSandboxCommand(`sandbox-probe.exe --registry-read ${registryId}`, context)
|
||||
const write = await executeSandboxCommand(`sandbox-probe.exe --registry-write ${registryId}`, context)
|
||||
observations.registry = { read: { ok: read.ok, stdout: read.data.stdout }, write: { ok: write.ok, stdout: write.data.stdout } }
|
||||
check('AppContainer cannot read the host-owned registry fixture value', !read.ok && !read.data.stdout.includes('1193046'))
|
||||
const after = await host(['--registry-read', registryId])
|
||||
check('AppContainer registry writes cannot change the host fixture', after.code === 0 && after.stdout === before.stdout)
|
||||
check('invalid registry identities are rejected without arbitrary key access', (await host(['--registry-create', '../outside'])).code === 2)
|
||||
const directory = join(fixture, 'locked'), renamed = join(fixture, 'renamed'), ready = join(fixture, 'ready'), unlock = join(fixture, 'unlock')
|
||||
await fs.mkdir(directory); await fs.writeFile(join(directory, 'sample.txt'), 'test-only')
|
||||
const held = host(['--hold-directory', directory, ready, unlock])
|
||||
for (let attempt = 0; attempt < 100 && !await fs.stat(ready).catch(() => null); attempt++) await new Promise(done => setTimeout(done, 20))
|
||||
check('native directory handle acquires its test-only object before replacement attempt', !!await fs.stat(ready).catch(() => null))
|
||||
const replacement = await fs.rename(directory, renamed).then(() => null, error => error as NodeJS.ErrnoException)
|
||||
observations.directoryReplacement = { error: replacement?.code ?? null }
|
||||
const blocked = !!replacement && ['EPERM', 'EACCES', 'EBUSY'].includes(replacement.code ?? '')
|
||||
await fs.writeFile(unlock, 'release')
|
||||
check('native directory-handle probe exits and releases ownership', (await held).code === 0)
|
||||
check('no-delete-sharing directory handle blocks replacement while held', blocked)
|
||||
await fs.rename(directory, renamed)
|
||||
check('positive control can rename the directory after handle release', await fs.readFile(join(renamed, 'sample.txt'), 'utf8') === 'test-only')
|
||||
if (process.argv.includes('--probe-pipe')) {
|
||||
const pipeId = randomUUID(), runs = join(fixture, 'state/runs')
|
||||
const client = executeSandboxCommand(`sandbox-probe.exe --pipe-client ${pipeId}`, { ...context, toolCallId: 'p0-pipe', timeoutMs: 6000 })
|
||||
let profile: string | undefined
|
||||
for (let attempt = 0; attempt < 100 && !profile; attempt++) {
|
||||
for (const entry of await fs.readdir(runs)) {
|
||||
const record = await fs.readFile(join(runs, entry, 'request.json'), 'utf8').then(raw => JSON.parse(raw), () => null)
|
||||
if (record?.identity?.toolCallId === 'p0-pipe') profile = record.profile
|
||||
}
|
||||
if (!profile) await new Promise(done => setTimeout(done, 10))
|
||||
}
|
||||
assert.ok(profile, 'pipe scenario request must provide a host-owned profile identity')
|
||||
const server = host(['--pipe-server', pipeId, profile])
|
||||
const clientResult = await client, serverResult = await server
|
||||
observations.authenticatedPipe = { client: { ok: clientResult.ok, stdout: clientResult.data.stdout }, server: serverResult }
|
||||
const available = clientResult.ok && clientResult.data.stdout.includes('BROKER_PONG') && serverResult.code === 0 && serverResult.stdout.includes('PIPE_AUTHENTICATED')
|
||||
observations.brokerPipeAvailable = available
|
||||
if (available) check('host-scoped broker pipe accepts and authenticates the actual AppContainer client', true)
|
||||
else {
|
||||
check('unavailable broker transport cannot enable host execution or writeback', !clientResult.ok && clientResult.data.isolated && clientResult.data.sandbox === 'windows-appcontainer' && clientResult.data.writeback === 'not-applied' && !clientResult.data.changes.length)
|
||||
console.log('SANDBOX_PIPE_UNAVAILABLE: experimental transport remains gated; see observations')
|
||||
if (process.argv.includes('--require-pipe')) throw new Error('Authenticated broker pipe feasibility has not passed')
|
||||
}
|
||||
}
|
||||
console.log(`SANDBOX_FEASIBILITY_PASS ${checks.length}/${checks.length}`)
|
||||
} finally {
|
||||
const cleanup = await host(['--registry-delete', registryId]).catch(error => ({ code: -1, stdout: String(error) }))
|
||||
observations.registryCleanup = cleanup
|
||||
await fs.writeFile(join(fixture, 'report.json'), JSON.stringify({ checks, observations, fixture, limitations: [
|
||||
'Registry results describe the current AppContainer and fake HKCU key only; no configurable registry policy is implemented.',
|
||||
'Holding one directory is a candidate primitive, not proof of safe ancestor traversal or a complete file broker.',
|
||||
'The pipe probe exchanges a fixed echo only; it does not implement operations, policy dispatch, queue bounds or descendant Job membership authentication.',
|
||||
'Controlled egress, descendant program policy, restricted ConPTY and hard disk quotas require further prototypes.',
|
||||
'This host permission level and OS are the only tested environment; no original WorkBuddy execution occurred.'
|
||||
] }, null, 2) + '\n')
|
||||
console.log(`SANDBOX_FEASIBILITY_REPORT ${join(fixture, 'report.json')}`)
|
||||
assert.equal(cleanup.code, 0, 'test registry key must be cleaned')
|
||||
}
|
||||
@@ -1,113 +0,0 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { promises as fs } from 'node:fs'
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { configureSandbox } from '../src/main/sandbox/configuration'
|
||||
import { effectiveSandboxPolicy } from '../src/main/sandbox/policy-store'
|
||||
import { createGitControl } from '../src/main/sandbox/git-control'
|
||||
import { captureHostGit } from '../src/main/sandbox/git-host-source'
|
||||
import { gitHostAction, hostGitStatus } from '../src/main/sandbox/git-host-service'
|
||||
import { executeSandboxCommand } from '../src/main/sandbox/service'
|
||||
import { executeTool } from '../src/main/agent/tools'
|
||||
import { workspaceAction } from '../src/main/sandbox/workspace-service'
|
||||
|
||||
const fixture = await fs.mkdtemp(resolve('.tmp/sandbox-git-complete-')), workspace = join(fixture, 'project \u4e2d\u6587 ^'), stateRoot = join(fixture, 'state')
|
||||
await fs.mkdir(workspace); await fs.mkdir(stateRoot)
|
||||
configureSandbox({ stateRoot, resourceDirectory: resolve('resources/sandbox/win32-x64') })
|
||||
const git = resolve('resources/sandbox/win32-x64/git/files/git.exe')
|
||||
const checks: Array<{ label: string; pass: boolean }> = []
|
||||
function check(label: string, condition: unknown): void { checks.push({ label, pass: !!condition }); assert.ok(condition, label); console.log(`PASS ${label}`) }
|
||||
function hostGit(...args: string[]): string {
|
||||
return execFileSync(git, ['-c', 'core.hooksPath=NUL', '-c', 'core.fsmonitor=false', '-c', 'commit.gpgSign=false', '-c', 'user.name=Fixture', '-c', 'user.email=fixture@example.invalid', ...args],
|
||||
{ cwd: workspace, encoding: 'utf8', windowsHide: true, env: { SystemRoot: process.env.SystemRoot, PATH: `${resolve('resources/sandbox/win32-x64/git/files')};${process.env.SystemRoot}\\System32`, GIT_CONFIG_NOSYSTEM: '1', GIT_CONFIG_GLOBAL: 'NUL', GIT_CONFIG_SYSTEM: 'NUL', GIT_TERMINAL_PROMPT: '0' } })
|
||||
}
|
||||
let approvals = 0
|
||||
const context = { workspaceRoot: workspace, sessionId: 'complete-session', runId: 'complete-run', toolCallId: 'complete-call', approve: async () => { approvals++; return true } }
|
||||
const run = (command: string) => executeSandboxCommand(command, { ...context, runtimes: ['git'], workspaceMode: 'persistent', preserveGitConflicts: true })
|
||||
async function gitRun(argv: string[]) {
|
||||
const result = await executeTool('sandbox_git', { action: 'run', argv }, context)
|
||||
await fs.writeFile(join(fixture, 'last-tool-result.json'), JSON.stringify(result, null, 2))
|
||||
return result
|
||||
}
|
||||
try {
|
||||
const policy = await effectiveSandboxPolicy(workspace), control = await createGitControl(policy)
|
||||
try {
|
||||
check('native parent-monitored host-only control runs the bundled Git', (await control.run(['--version'])).stdout.toString().includes('git version'))
|
||||
hostGit('init', '-q', '--template=', '--initial-branch=main')
|
||||
await fs.writeFile(join(workspace, 'tracked.txt'), 'initial\n')
|
||||
hostGit('add', '.'); hostGit('commit', '-q', '-m', 'real-host-history')
|
||||
await fs.writeFile(join(workspace, 'tracked.txt'), 'staged\n'); hostGit('add', 'tracked.txt')
|
||||
await fs.writeFile(join(workspace, 'tracked.txt'), 'unstaged\n')
|
||||
hostGit('config', 'credential.helper', '!echo SHOULD_NOT_RUN')
|
||||
const snapshot = await captureHostGit(policy, control)
|
||||
check('native metadata capture retains real HEAD, index and object history', snapshot.repository.has('HEAD') && snapshot.repository.has('index') && snapshot.files.some(file => file.path.startsWith('objects/')))
|
||||
check('host executable configuration is excluded from the mirror', !snapshot.repository.get('config')!.toString().includes('SHOULD_NOT_RUN'))
|
||||
} finally { await control.close() }
|
||||
const imported = await gitHostAction('import', context)
|
||||
check('explicit import is approval-bound and creates a session host mirror', imported.state === 'ready' && approvals === 1 && imported.pendingMetadata === false)
|
||||
let result = await run('git log -1 --oneline && git diff --cached && git diff')
|
||||
check('actual isolated Git sees host history and distinguishes staged and unstaged changes', result.ok && result.data.git?.mode === 'host-mirror' && result.data.stdout.includes('real-host-history') && result.data.stdout.includes('+staged') && result.data.stdout.includes('+unstaged'))
|
||||
check('queries do not consume a host write approval', approvals === 1 && result.data.git?.persistence === 'unchanged')
|
||||
result = await run('echo complete-local>tracked.txt && git add tracked.txt && git commit -q -m complete-local-commit && git branch feature && git tag v1')
|
||||
check('private commits, branches and tags retain host ancestry without touching the host', result.ok && !hostGit('log', '-1', '--oneline').includes('complete-local-commit') && hostGit('diff').includes('+unstaged'))
|
||||
const published = await gitHostAction('commit', context)
|
||||
check('one approved group publishes worktree and actual host Git metadata', published.writeback === 'committed' && !!published.groupId && approvals === 2 && hostGit('log', '-1', '--oneline').includes('complete-local-commit') && hostGit('branch', '--list', 'feature').includes('feature') && hostGit('tag', '--list', 'v1').includes('v1'))
|
||||
check('publication leaves the real host index and worktree coherent', hostGit('status', '--porcelain=v1').trim() === '' && (await fs.readFile(join(workspace, 'tracked.txt'), 'utf8')).includes('complete-local'))
|
||||
check('host executable configuration survives publication without being imported', hostGit('config', '--get', 'credential.helper').includes('SHOULD_NOT_RUN'))
|
||||
check('host status is bound to the imported session and clears metadata changes', (await hostGitStatus(workspace, context.sessionId)).pendingMetadata === false && (await hostGitStatus(workspace, 'another-session')).state === 'unimported')
|
||||
const literalMessage = 'literal %PATH% & ^ "quoted"'
|
||||
check('native argv executes Git directly without a command-shell expansion', (await gitRun(['commit', '--allow-empty', '-m', literalMessage])).ok && (await gitRun(['log', '-1', '--format=%s'])).content.includes(literalMessage))
|
||||
check('branch switching retains the real host branch topology', (await gitRun(['switch', 'feature'])).ok)
|
||||
await run('echo feature-change>feature.txt && git add feature.txt && git commit -q -m feature-change')
|
||||
await gitRun(['switch', 'main']); await gitRun(['merge', '--no-edit', 'feature'])
|
||||
check('a normal merge completes in the isolated draft', (await gitRun(['log', '--oneline', '-3'])).content.includes('feature-change'))
|
||||
await run('echo scratch>stash.txt && git add stash.txt')
|
||||
check('stash creation and restore persist across Jobs', (await gitRun(['stash', 'push', '-m', 'saved-stash'])).ok && (await gitRun(['stash', 'list'])).content.includes('saved-stash') && (await gitRun(['stash', 'pop'])).ok)
|
||||
await gitRun(['reset', '--hard', 'HEAD'])
|
||||
await gitRun(['switch', '-c', 'conflict-side'])
|
||||
await run('echo side-value>tracked.txt && git add tracked.txt && git commit -q -m side-conflict')
|
||||
await gitRun(['switch', 'main'])
|
||||
await run('echo main-value>tracked.txt && git add tracked.txt && git commit -q -m main-conflict')
|
||||
const merge = await gitRun(['merge', '--no-edit', 'conflict-side'])
|
||||
check('a failed merge retains genuine conflict files and MERGE_HEAD for a later command', !merge.ok && (merge.data as { gitConflictState?: boolean }).gitConflictState && (await gitRun(['status', '--porcelain'])).content.includes('UU tracked.txt'))
|
||||
await run('echo resolved-value>tracked.txt && git add tracked.txt')
|
||||
check('conflict resolution commits with the owned noninteractive editor', (await gitRun(['commit', '--no-edit'])).ok)
|
||||
await gitRun(['switch', '-c', 'cherry-source'])
|
||||
await run('echo cherry>cherry.txt && git add cherry.txt && git commit -q -m cherry-source')
|
||||
await gitRun(['switch', 'main'])
|
||||
check('cherry-pick applies an existing real history commit', (await gitRun(['cherry-pick', 'cherry-source'])).ok && (await gitRun(['show', 'HEAD:cherry.txt'])).content.includes('cherry'))
|
||||
check('revert makes a new inverse commit', (await gitRun(['revert', '--no-edit', 'HEAD'])).ok)
|
||||
await gitRun(['switch', '-c', 'rebase-side'])
|
||||
await run('echo rebase>rebase.txt && git add rebase.txt && git commit -q -m rebase-side')
|
||||
await gitRun(['switch', 'main'])
|
||||
await run('echo base>base.txt && git add base.txt && git commit -q -m rebase-base')
|
||||
await gitRun(['switch', 'rebase-side'])
|
||||
check('rebase rewrites a private branch onto the updated base', (await gitRun(['rebase', 'main'])).ok && (await gitRun(['log', '-3', '--oneline'])).content.includes('rebase-base'))
|
||||
check('detached HEAD checkout and return survive command boundaries', (await gitRun(['switch', '--detach', 'HEAD~1'])).ok && (await gitRun(['status', '--short', '--branch'])).content.includes('HEAD') && (await gitRun(['switch', 'rebase-side'])).ok)
|
||||
check('worktree reset retains coherent object history', (await gitRun(['reset', '--hard', 'HEAD~1'])).ok && (await gitRun(['fsck', '--connectivity-only'])).ok)
|
||||
const beforeReject = hostGit('rev-parse', 'HEAD').trim()
|
||||
const rejected = await gitHostAction('commit', { ...context, approve: async () => false })
|
||||
check('rejecting the combined commit preserves host HEAD and private changes', rejected.writeback === 'rejected' && hostGit('rev-parse', 'HEAD').trim() === beforeReject && (await hostGitStatus(workspace, context.sessionId)).pendingMetadata)
|
||||
const integrated = await workspaceAction('commit', context)
|
||||
check('the common workspace commit routes imported Git through the same combined host transaction', integrated.writeback === 'committed' && !!integrated.git?.groupId && hostGit('branch', '--show-current').trim() === 'rebase-side')
|
||||
const newProject = join(fixture, 'new-project'), newContext = { ...context, workspaceRoot: newProject, sessionId: 'new-session' }
|
||||
await fs.mkdir(newProject); await fs.writeFile(join(newProject, 'new.txt'), 'new-host-file\n')
|
||||
const initialized = await gitHostAction('init', newContext)
|
||||
check('init creates an unborn private repository without a synthetic baseline or host .git', initialized.mode === 'new-repository' && !await fs.lstat(join(newProject, '.git')).catch(() => null))
|
||||
const firstCommit = await executeTool('sandbox_git', { action: 'run', argv: ['add', '.'] }, newContext)
|
||||
const firstHistory = await executeTool('sandbox_git', { action: 'run', argv: ['commit', '-m', 'first-real-commit'] }, newContext)
|
||||
check('a new private repository stages existing project files and makes its first commit', firstCommit.ok && firstHistory.ok)
|
||||
const firstPublish = await gitHostAction('commit', newContext)
|
||||
check('first host publication creates and identity-binds .git before publishing the real first commit', firstPublish.writeback === 'committed' && !!firstPublish.groupId && (await fs.lstat(join(newProject, '.git/HEAD'))).isFile())
|
||||
const linked = join(fixture, 'linked-worktree')
|
||||
hostGit('worktree', 'add', '-q', '-b', 'linked-test', linked)
|
||||
const linkedContext = { ...context, workspaceRoot: linked, sessionId: 'linked-session' }
|
||||
check('linked worktree import binds both private and common Git directory identities', (await gitHostAction('import', linkedContext)).linkedWorktree === true)
|
||||
await executeSandboxCommand('echo linked>linked.txt && git add linked.txt && git commit -q -m linked-commit', { ...linkedContext, runtimes: ['git'], workspaceMode: 'persistent' })
|
||||
const linkedPublish = await gitHostAction('commit', linkedContext)
|
||||
check('linked worktree publication commits its index/HEAD and shared objects/refs with separate native roots', linkedPublish.writeback === 'committed' && hostGit('log', '-1', '--oneline', 'linked-test').includes('linked-commit') && (await fs.readFile(join(linked, 'linked.txt'), 'utf8')).includes('linked'))
|
||||
console.log(`SANDBOX_GIT_COMPLETE_PASS ${checks.length}/${checks.length}`)
|
||||
} finally {
|
||||
await fs.writeFile(join(fixture, 'report.json'), JSON.stringify({ fixture, checks }, null, 2) + '\n')
|
||||
console.log(`SANDBOX_GIT_COMPLETE_REPORT ${join(fixture, 'report.json')}`)
|
||||
}
|
||||
@@ -1,182 +0,0 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { promises as fs } from 'node:fs'
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { createHash } from 'node:crypto'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { configureSandbox } from '../src/main/sandbox/configuration'
|
||||
import { importSandboxToolchain, listSandboxToolchains } from '../src/main/sandbox/toolchains'
|
||||
import { executeSandboxCommand, sandboxStatus } from '../src/main/sandbox/service'
|
||||
import { resetSandboxGit, sandboxGitState } from '../src/main/sandbox/git-repository'
|
||||
import { importSandboxDependencies } from '../src/main/sandbox/dependency-cache'
|
||||
import { effectiveSandboxPolicy, readSandboxPolicy, updateSandboxPolicy } from '../src/main/sandbox/policy-store'
|
||||
import { restoreTransaction } from '../src/main/sandbox/transactions'
|
||||
import { sandboxOperation } from '../src/main/sandbox/maintenance'
|
||||
|
||||
if (process.platform !== 'win32') throw new Error('Git regression requires real Windows isolation')
|
||||
await fs.mkdir(resolve('.tmp'), { recursive: true })
|
||||
const fixture = await fs.mkdtemp(resolve('.tmp/sandbox-git-'))
|
||||
const workspace = join(fixture, 'workspace space \u4e2d\u6587'), stateRoot = join(fixture, 'state'), home = join(fixture, 'host-home')
|
||||
await fs.mkdir(workspace); await fs.mkdir(home)
|
||||
configureSandbox({ stateRoot, resourceDirectory: resolve('resources/sandbox/win32-x64'), useBundledGit: false })
|
||||
const source = process.env.YCODE_TEST_GIT_SOURCE ?? 'C:\\Program Files\\Git\\cmd\\git.exe'
|
||||
const environment = Object.fromEntries(Object.entries(process.env).filter(([name]) => !/^GIT_/i.test(name)))
|
||||
Object.assign(environment, { HOME: home, USERPROFILE: home, GIT_CONFIG_NOSYSTEM: '1', GIT_CONFIG_GLOBAL: 'NUL', GIT_CONFIG_SYSTEM: 'NUL', GIT_TERMINAL_PROMPT: '0' })
|
||||
const hostGit = (...args: string[]): string => execFileSync(source, args, { cwd: workspace, env: environment, windowsHide: true, encoding: 'utf8', timeout: 15_000 })
|
||||
const checks: Array<{ label: string; pass: boolean }> = []
|
||||
function check(label: string, condition: unknown): void { checks.push({ label, pass: !!condition }); assert.ok(condition, label); console.log(`PASS ${label}`) }
|
||||
let approvals = 0
|
||||
const context = { workspaceRoot: workspace, sessionId: 'git-session', runId: 'git-run', toolCallId: 'git-call', runtimes: ['git'], approve: async () => { approvals++; return true } }
|
||||
async function run(command: string, overrides: Partial<typeof context> = {}) {
|
||||
const result = await executeSandboxCommand(command, { ...context, ...overrides })
|
||||
await fs.writeFile(join(fixture, 'last-result.json'), JSON.stringify(result, null, 2) + '\n')
|
||||
return result
|
||||
}
|
||||
async function inventory(root: string): Promise<Record<string, string>> {
|
||||
const result: Record<string, string> = {}
|
||||
async function walk(path: string, prefix = ''): Promise<void> {
|
||||
for (const entry of await fs.readdir(path, { withFileTypes: true })) {
|
||||
const name = `${prefix}${entry.name}`
|
||||
if (entry.isDirectory()) await walk(join(path, entry.name), `${name}/`)
|
||||
else result[name] = createHash('sha256').update(await fs.readFile(join(path, entry.name))).digest('hex')
|
||||
}
|
||||
}
|
||||
await walk(root); return result
|
||||
}
|
||||
try {
|
||||
await fs.writeFile(join(workspace, 'tracked.txt'), 'host committed\n')
|
||||
await fs.writeFile(join(workspace, '.env'), 'DUMMY_HISTORICAL_SECRET_NOT_FOR_SANDBOX')
|
||||
hostGit('init', '-q', '--template=', '--initial-branch=host-branch')
|
||||
hostGit('-c', 'user.name=Fixture', '-c', 'user.email=fixture@example.invalid', 'add', '--all')
|
||||
hostGit('-c', 'user.name=Fixture', '-c', 'user.email=fixture@example.invalid', 'commit', '-q', '-m', 'HOST_HISTORY_MUST_NOT_BE_IMPORTED')
|
||||
await fs.unlink(join(workspace, '.env'))
|
||||
await fs.writeFile(join(workspace, 'tracked.txt'), 'current filtered snapshot\n')
|
||||
hostGit('add', 'tracked.txt')
|
||||
hostGit('config', 'credential.helper', '!echo HOST_CREDENTIAL_HELPER_MUST_NOT_RUN')
|
||||
hostGit('config', 'core.fsmonitor', '!echo HOST_FSMONITOR_MUST_NOT_RUN')
|
||||
const originalGit = await inventory(join(workspace, '.git'))
|
||||
await fs.mkdir(join(workspace, 'nested space'))
|
||||
await fs.writeFile(join(workspace, 'nested space/\u8def\u5f84.txt'), 'unicode baseline\n')
|
||||
await fs.writeFile(join(workspace, 'binary.dat'), Buffer.from([0, 1, 255, 0, 32]))
|
||||
await fs.writeFile(join(workspace, 'hook-script'), '#!/bin/sh\nexit 73\n')
|
||||
await fs.writeFile(join(workspace, '.gitattributes'), 'crlf.txt text eol=lf\nencoded.txt working-tree-encoding=UTF-16LE filter=host-filter\n')
|
||||
await fs.writeFile(join(workspace, 'crlf.txt'), 'raw CRLF baseline\r\n')
|
||||
await fs.writeFile(join(workspace, 'encoded.txt'), 'raw snapshot bytes\n')
|
||||
check('Git starts missing without inheriting the host installation', (await listSandboxToolchains()).find(item => item.kind === 'git')?.state === 'missing')
|
||||
await assert.rejects(run('git --version'), /尚未注册/)
|
||||
check('missing Git runtime fails before native launch', true)
|
||||
const runtime = await importSandboxToolchain('git', source)
|
||||
check('explicit Git import selects the x64 image and its DLL closure', runtime.state === 'registered' && runtime.features?.includes('git-local') && runtime.files! >= 7)
|
||||
const isolatedImage = join(fixture, 'missing-dll/git.exe')
|
||||
await fs.mkdir(join(fixture, 'missing-dll'))
|
||||
await fs.copyFile(join(stateRoot, 'toolchains/objects', runtime.id!, 'files/git.exe'), isolatedImage)
|
||||
await assert.rejects(importSandboxToolchain('git', isolatedImage), /ENOENT/)
|
||||
check('an incomplete import preserves the previously registered Git runtime', (await listSandboxToolchains()).find(item => item.kind === 'git')?.id === runtime.id)
|
||||
check('registered Git capability states the private repository boundary', (await sandboxStatus()).capabilities?.some(item => item.id === 'git' && item.state === 'implemented' && item.detail.includes('沙盒专用')))
|
||||
let result = await run('git --version && git status --porcelain=v1 && git diff --exit-code && git diff --cached --exit-code && git branch --show-current && git log -1 --oneline')
|
||||
check('real isolated Git runs with a synthetic filtered baseline', result.ok && result.data.stdout.includes('git version') && result.data.stdout.includes('ycode-sandbox') && result.data.stdout.includes('filtered workspace snapshot') && !result.data.stdout.includes('HOST_HISTORY'))
|
||||
check('read-only Git does not ask for approval or change the repository revision', approvals === 0 && result.data.git?.initialized === true && result.data.git.persistence === 'unchanged')
|
||||
check('result and content explicitly distinguish sandbox state from host Git state', result.data.git?.mode === 'private-snapshot' && result.content.includes('不含宿主历史/索引') && result.data.toolchains?.[0].id === runtime.id)
|
||||
const baseline = await sandboxGitState(workspace)
|
||||
result = await run('git show "HEAD:nested space/\u8def\u5f84.txt" && git fsck --no-reflogs')
|
||||
check('synthetic trees/index support nested Unicode names, spaces and binary objects', result.ok && result.data.stdout.includes('unicode baseline'))
|
||||
result = await run('git diff --exit-code -- crlf.txt encoded.txt && git show HEAD:crlf.txt && git show HEAD:encoded.txt')
|
||||
check('raw-byte baseline remains clean with CRLF and project encoding/filter attributes', result.ok && result.data.stdout.includes('raw CRLF baseline') && result.data.stdout.includes('raw snapshot bytes') && result.data.git?.persistence === 'unchanged')
|
||||
result = await run('git status --porcelain=v1 && git log --all -p && git config --get-regexp "credential|fsmonitor|hooksPath"')
|
||||
check('host history, deleted credentials and host configuration are absent', result.ok && !result.data.stdout.includes('DUMMY_HISTORICAL_SECRET') && !result.data.stdout.includes('HOST_CREDENTIAL') && !result.data.stdout.includes('HOST_FSMONITOR'))
|
||||
check('repeated status/log preserves the exact private revision without approvals', (await sandboxGitState(workspace)).id === baseline.id && approvals === 0)
|
||||
await fs.writeFile(join(workspace, 'tracked.txt'), 'edited outside after sandbox baseline\n')
|
||||
result = await run('git status --short && git diff -- tracked.txt')
|
||||
check('status and diff observe external worktree edits against the private baseline', result.ok && result.data.stdout.includes(' M tracked.txt') && result.data.stdout.includes('+edited outside after sandbox baseline') && approvals === 0)
|
||||
result = await run('git add tracked.txt && git diff --cached --stat')
|
||||
check('staging is approval-bound and persists independently of worktree writeback', result.ok && result.data.git?.persistence === 'saved' && result.data.writeback === 'unchanged' && approvals === 1)
|
||||
result = await run('git diff --cached -- tracked.txt && git commit -q -m local-sandbox-commit')
|
||||
check('a later command sees the staged index and creates a local commit', result.ok && result.data.stdout.includes('+edited outside after sandbox baseline') && result.data.git?.persistence === 'saved' && approvals === 2)
|
||||
check('completed Git persistence can be queried from the durable run outcome', (await sandboxOperation(result.data.runId)).git?.revision === result.data.git?.revision)
|
||||
result = await run('git log -2 --oneline && git status --porcelain=v1 && git fsck --no-reflogs')
|
||||
check('private commits and valid objects survive command boundaries', result.ok && result.data.stdout.includes('local-sandbox-commit') && result.data.stdout.includes('filtered workspace snapshot') && result.data.git?.persistence === 'unchanged')
|
||||
await importSandboxToolchain('node', await fs.realpath(process.execPath))
|
||||
result = await run('node --require "%YCODE_SANDBOX_PROCESS%" -e "console.log(require(\'child_process\').execFileSync(\'git\',[\'log\',\'-1\',\'--oneline\'],{encoding:\'utf8\'}))"', { runtimes: ['node', 'git'] })
|
||||
check('a controlled Node subprocess inherits the private Git runtime and repository', result.ok && result.data.stdout.includes('local-sandbox-commit') && result.data.git?.persistence === 'unchanged')
|
||||
const beforeReject = await sandboxGitState(workspace)
|
||||
result = await run('git branch rejected-branch', { approve: async () => false })
|
||||
check('rejected metadata-only approval leaves the active revision unchanged', !result.ok && result.data.git?.persistence === 'rejected' && (await sandboxGitState(workspace)).id === beforeReject.id)
|
||||
result = await run('git branch --list rejected-branch')
|
||||
check('a rejected branch does not appear in later commands', result.ok && !result.data.stdout.trim())
|
||||
const beforeFailure = await sandboxGitState(workspace)
|
||||
result = await run('git branch failed-command-branch && exit /b 7')
|
||||
check('failed commands discard private metadata changes', !result.ok && result.data.exitCode === 7 && result.data.git?.persistence === 'not-applied' && (await sandboxGitState(workspace)).id === beforeFailure.id)
|
||||
result = await executeSandboxCommand('git branch timed-out-branch && echo GIT_MUTATION_BEFORE_TIMEOUT && powershell.exe -NoProfile -NonInteractive -Command "Start-Sleep -Seconds 15"', { ...context, timeoutMs: 1200 })
|
||||
check('a real native timeout discards already-created private branch metadata', !result.ok && result.data.status === 'timeout' && result.data.stdout.includes('GIT_MUTATION_BEFORE_TIMEOUT') && result.data.git?.persistence === 'not-applied' && (await sandboxGitState(workspace)).id === beforeFailure.id)
|
||||
const controller = new AbortController(), cancellation = setTimeout(() => controller.abort(), 2500)
|
||||
try { result = await executeSandboxCommand('git branch cancelled-branch && echo GIT_MUTATION_BEFORE_CANCEL && powershell.exe -NoProfile -NonInteractive -Command "Start-Sleep -Seconds 15"', { ...context, signal: controller.signal }) }
|
||||
finally { clearTimeout(cancellation) }
|
||||
check('cancellation discards private mutations and releases the repository lock', !result.ok && result.data.status === 'cancelled' && result.data.stdout.includes('GIT_MUTATION_BEFORE_CANCEL') && result.data.git?.persistence === 'not-applied' && (await sandboxGitState(workspace)).id === beforeFailure.id)
|
||||
result = await run('git switch -c feature-local && echo sandbox-file>created.txt && git add created.txt && git commit -q -m approved-file-and-commit')
|
||||
check('approved worktree changes and private branch/commit are both saved', result.ok && result.data.writeback === 'committed' && result.data.git?.persistence === 'saved' && (await fs.readFile(join(workspace, 'created.txt'), 'utf8')).trim() === 'sandbox-file')
|
||||
check('file writeback retains an independently recoverable transaction', !!result.data.transactionId && !!result.data.authorizationId)
|
||||
await restoreTransaction(result.data.transactionId!, workspace)
|
||||
result = await run('git status --short && git branch --show-current')
|
||||
check('file recovery becomes a visible worktree deletion without rewriting private history', result.ok && result.data.stdout.includes(' D created.txt') && result.data.stdout.includes('feature-local'))
|
||||
const beforeConflict = await sandboxGitState(workspace)
|
||||
await assert.rejects(run('git branch concurrent-conflict', { approve: async () => { await fs.appendFile(join(workspace, 'tracked.txt'), 'parallel edit\n'); return true } }), /Git.*期间变化/)
|
||||
check('an edit during metadata approval rejects stale private state', (await sandboxGitState(workspace)).id === beforeConflict.id)
|
||||
result = await run('mkdir "%GIT_DIR%\\hooks" && copy /y hook-script "%GIT_DIR%\\hooks\\pre-commit" >nul && git config core.hooksPath "%GIT_DIR%\\hooks" && git config core.fsmonitor "echo BAD_FSMONITOR" && git config credential.helper "echo BAD_CREDENTIAL" && git commit --allow-empty -q -m hooks-disabled')
|
||||
check('default hooks and fsmonitor remain disabled even after private repo config changes', result.ok && result.data.git?.persistence === 'saved')
|
||||
result = await run('git config --get core.fsmonitor && git config --get core.hooksPath && git ls-remote https://example.invalid/test.git')
|
||||
check('remote transport is denied without prompting for host credentials', !result.ok && /not allowed|not a git command|transport/i.test(result.data.stderr) && !result.data.stdout.includes('HOST_CREDENTIAL'))
|
||||
const outside = join(fixture, 'outside.txt')
|
||||
await fs.writeFile(outside, 'HOST_OUTSIDE_MUST_NOT_BE_READ')
|
||||
result = await run(`git hash-object "${outside}"`)
|
||||
check('native Git cannot read an ordinary file outside its snapshot', !result.ok && /denied|not permitted/i.test(result.data.stderr) && !result.data.stdout.includes('HOST_OUTSIDE'))
|
||||
result = await run('powershell.exe -NoProfile -NonInteractive -Command "try {[IO.File]::WriteAllText((Join-Path $env:YCODE_SANDBOX_ROOT \'tools/git/git.exe\'),\'bad\'); exit 9} catch {[Console]::WriteLine(\'GIT_RUNTIME_READONLY\')}"')
|
||||
check('AppContainer ACLs deny modification of the staged Git image', result.ok && result.data.stdout.includes('GIT_RUNTIME_READONLY'))
|
||||
const otherWorkspace = join(fixture, 'other-workspace'); await fs.mkdir(otherWorkspace)
|
||||
await fs.writeFile(join(otherWorkspace, '.git'), 'gitdir: C:/host-repository-must-not-be-opened\n')
|
||||
result = await run('git log --all --oneline', { workspaceRoot: otherWorkspace })
|
||||
check('another workspace receives a separate baseline and ignores host worktree gitfiles', result.ok && result.data.git?.initialized && !result.data.stdout.includes('local-sandbox-commit') && !result.data.stdout.includes('hooks-disabled'))
|
||||
await assert.rejects(importSandboxDependencies(workspace, 'git'), /node\/python/)
|
||||
check('Git runtime is not accepted as a Node/Python dependency-cache kind', true)
|
||||
const workspaceKey = createHash('sha256').update((await fs.realpath(workspace)).toLowerCase()).digest('hex')
|
||||
const privateRoot = join(stateRoot, 'git', workspaceKey)
|
||||
const active = JSON.parse(await fs.readFile(join(privateRoot, 'active.json'), 'utf8'))
|
||||
const privateFile = join(privateRoot, 'objects', active.id, 'files/HEAD'), privateBytes = await fs.readFile(privateFile)
|
||||
await fs.appendFile(privateFile, 'tampered')
|
||||
await assert.rejects(run('git status'), /metadata digest mismatch/)
|
||||
check('tampered saved metadata fails closed before execution', true)
|
||||
await fs.writeFile(privateFile, privateBytes)
|
||||
const hardlinkTarget = join(fixture, 'git-hardlink.txt'); await fs.writeFile(hardlinkTarget, privateBytes)
|
||||
await fs.unlink(privateFile); await fs.link(hardlinkTarget, privateFile)
|
||||
await assert.rejects(run('git status'), /metadata contains a link/)
|
||||
check('saved metadata hardlinks are rejected even when content digests match', true)
|
||||
await fs.unlink(privateFile); await fs.writeFile(privateFile, privateBytes)
|
||||
const runtimeIdentity = JSON.parse(await fs.readFile(join(stateRoot, 'toolchains/git.json'), 'utf8'))
|
||||
const runtimeFile = join(stateRoot, 'toolchains/objects', runtimeIdentity.id, 'files/git.exe'), runtimeBytes = await fs.readFile(runtimeFile)
|
||||
await fs.appendFile(runtimeFile, 'tampered')
|
||||
await assert.rejects(run('git status'), /摘要不匹配/)
|
||||
check('tampered Git runtime fails before any metadata or worktree access', true)
|
||||
await fs.writeFile(runtimeFile, runtimeBytes)
|
||||
await assert.rejects(run('if not exist "%GIT_DIR%\\objects\\info" mkdir "%GIT_DIR%\\objects\\info"\r\necho C:/outside>"%GIT_DIR%\\objects\\info\\alternates"'), /unsafe sandbox Git metadata path/)
|
||||
check('external object alternates cannot be persisted', true)
|
||||
await assert.rejects(run('echo stale>"%GIT_DIR%\\index.lock"'), /unsafe sandbox Git metadata path/)
|
||||
check('incomplete Git lock files are rejected instead of poisoning the next command', true)
|
||||
const policy = await readSandboxPolicy()
|
||||
await updateSandboxPolicy({ expectedRevision: policy.revision, rules: { deniedPaths: ['tracked.txt'] }, workspaceRoot: workspace })
|
||||
await assert.rejects(run('git show HEAD:tracked.txt'), /policy changed/)
|
||||
check('newly denied paths cannot leak through an older private history', true)
|
||||
await resetSandboxGit(workspace)
|
||||
result = await run('git ls-files && git log -1 --oneline')
|
||||
check('explicit reset creates a new filtered baseline after policy changes', result.ok && result.data.git?.initialized && !result.data.stdout.includes('tracked.txt') && result.data.stdout.includes('filtered workspace snapshot'))
|
||||
check('reset archives earlier revisions without mutating host Git', (await fs.readdir(privateRoot)).some(name => name.startsWith('archived-')) && JSON.stringify(await inventory(join(workspace, '.git'))) === JSON.stringify(originalGit))
|
||||
check('Git metadata remains outside the host worktree and policy retains network deny', !(await fs.stat(join(workspace, 'repository')).catch(() => null)) && (await effectiveSandboxPolicy(workspace)).deniedPaths.includes('tracked.txt'))
|
||||
const denyPolicy = await readSandboxPolicy()
|
||||
await updateSandboxPolicy({ expectedRevision: denyPolicy.revision, rules: { writeback: 'deny' }, workspaceRoot: otherWorkspace })
|
||||
await resetSandboxGit(otherWorkspace)
|
||||
result = await run('git status --short', { workspaceRoot: otherWorkspace })
|
||||
const denyBaseline = await sandboxGitState(otherWorkspace)
|
||||
await assert.rejects(run('git branch forbidden-by-policy', { workspaceRoot: otherWorkspace }), /禁止写回/)
|
||||
check('writeback-deny policy also refuses private repository publication', result.ok && (await sandboxGitState(otherWorkspace)).id === denyBaseline.id)
|
||||
console.log(`SANDBOX_GIT_PASS ${checks.length}/${checks.length}`)
|
||||
} finally {
|
||||
await fs.writeFile(join(fixture, 'report.json'), JSON.stringify({ checks, fixture, scope: 'Real AppContainer Git for Windows and private filtered repository. No host Git history/index writeback or remote transport is supported.' }, null, 2) + '\n')
|
||||
console.log(`SANDBOX_GIT_REPORT ${join(fixture, 'report.json')}`)
|
||||
}
|
||||
@@ -1,223 +0,0 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { promises as fs } from 'node:fs'
|
||||
import { createServer, type ServerResponse, type IncomingMessage } from 'node:http'
|
||||
import { createServer as createTlsServer } from 'node:https'
|
||||
import { spawn, execFileSync } from 'node:child_process'
|
||||
import { generateKeyPairSync } from 'node:crypto'
|
||||
import { createRequire } from 'node:module'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { configureSandbox } from '../src/main/sandbox/configuration'
|
||||
import { gitHostAction } from '../src/main/sandbox/git-host-service'
|
||||
import { gitRemoteAction, gitRemoteOperation, reconcileGitRemote } from '../src/main/sandbox/git-remote'
|
||||
import { importGitCredential, listGitCredentials, removeGitCredential, gitRemoteUrl } from '../src/main/sandbox/git-credentials'
|
||||
import { executeTool } from '../src/main/agent/tools'
|
||||
import { workspaceAction } from '../src/main/sandbox/workspace-service'
|
||||
import { readSandboxPolicy, updateSandboxPolicy } from '../src/main/sandbox/policy-store'
|
||||
import { gitRemoteEndpoint } from '../src/main/sandbox/git-credentials'
|
||||
|
||||
const require = createRequire(import.meta.url)
|
||||
const { Server: SshServer } = require(resolve('scripts/fixtures/git-transport/node_modules/ssh2'))
|
||||
const selfsigned = require(resolve('scripts/fixtures/git-transport/node_modules/selfsigned'))
|
||||
const fixture = await fs.mkdtemp(resolve('.tmp/sandbox-git-remote-')), workspace = join(fixture, 'project \u4e2d\u6587 ^'), stateRoot = join(fixture, 'state'), remote = join(fixture, 'remote.git')
|
||||
await fs.mkdir(workspace); await fs.mkdir(stateRoot)
|
||||
configureSandbox({ stateRoot, resourceDirectory: resolve('resources/sandbox/win32-x64') })
|
||||
const git = resolve('resources/sandbox/win32-x64/git/files/git.exe'), tool = resolve('resources/sandbox/win32-x64/git/files')
|
||||
const environment = { SystemRoot: process.env.SystemRoot, PATH: `${tool};${process.env.SystemRoot}\\System32`, GIT_CONFIG_NOSYSTEM: '1', GIT_CONFIG_GLOBAL: 'NUL', GIT_CONFIG_SYSTEM: 'NUL', GIT_TERMINAL_PROMPT: '0' }
|
||||
const prefix = ['-c', 'core.hooksPath=NUL', '-c', 'core.fsmonitor=false', '-c', 'user.name=Fixture', '-c', 'user.email=fixture@example.invalid']
|
||||
const hostGit = (...args: string[]): string => execFileSync(git, [...prefix, ...args], { cwd: workspace, env: environment, encoding: 'utf8', windowsHide: true })
|
||||
const checks: Array<{ label: string; pass: boolean }> = []
|
||||
function check(label: string, condition: unknown): void { checks.push({ label, pass: !!condition }); assert.ok(condition, label); console.log(`PASS ${label}`) }
|
||||
let approvals = 0, httpRequests = 0, sshConnections = 0, requireAuth = false, disconnectPush = false, holdResponses = false
|
||||
let onHeldRequest: (() => void) | undefined
|
||||
const heldResponses = new Set<ServerResponse>(), remotePaths = new Map([['/repo.git', remote]])
|
||||
const context = { workspaceRoot: workspace, sessionId: 'remote-session', runId: 'remote-run', toolCallId: 'remote-call', approve: async () => { approvals++; return true } }
|
||||
const run = (argv: string[]) => executeTool('sandbox_git', { action: 'run', argv }, context)
|
||||
const children = new Set<ReturnType<typeof spawn>>()
|
||||
function serve(request: IncomingMessage, response: ServerResponse): void {
|
||||
httpRequests++
|
||||
if (holdResponses) { heldResponses.add(response); response.once('close', () => heldResponses.delete(response)); onHeldRequest?.(); return }
|
||||
if (requireAuth && request.headers.authorization !== `Basic ${Buffer.from('fixture:DUMMY_GIT_TOKEN_97832').toString('base64')}`) { response.writeHead(401, { 'WWW-Authenticate': 'Basic realm="fixture"' }); response.end(); return }
|
||||
const url = new URL(request.url!, 'http://fixture.invalid'), service = request.method === 'GET' ? url.searchParams.get('service') : url.pathname.split('/').at(-1)
|
||||
const repository = remotePaths.get('/' + url.pathname.split('/')[1])
|
||||
if (!repository || !['git-upload-pack', 'git-receive-pack'].includes(service ?? '')) { response.writeHead(404); response.end(); return }
|
||||
const advertise = request.method === 'GET', args = [...prefix, service!.slice(4), '--stateless-rpc', ...(advertise ? ['--advertise-refs'] : []), repository]
|
||||
const child = spawn(git, args, { env: environment, windowsHide: true, stdio: ['pipe', 'pipe', 'pipe'] }); children.add(child)
|
||||
child.stderr.on('data', () => undefined); child.on('error', error => response.destroy(error)); child.on('close', () => children.delete(child))
|
||||
response.writeHead(200, { 'Content-Type': `application/x-${service}-${advertise ? 'advertisement' : 'result'}`, 'Cache-Control': 'no-cache' })
|
||||
if (advertise) { const header = Buffer.from(`# service=${service}\n`); response.write(Buffer.from((header.length + 4).toString(16).padStart(4, '0'))); response.write(header); response.write('0000'); child.stdin.end() }
|
||||
else request.pipe(child.stdin)
|
||||
if (disconnectPush && service === 'git-receive-pack' && !advertise) { child.stdout.resume(); child.once('close', () => response.destroy()) }
|
||||
else child.stdout.pipe(response)
|
||||
child.stdin.on('error', () => undefined)
|
||||
}
|
||||
const http = createServer(serve), pems = selfsigned.generate([{ name: 'commonName', value: '127.0.0.1' }], { days: 2, keySize: 2048, algorithm: 'sha256', extensions: [
|
||||
{ name: 'basicConstraints', cA: true }, { name: 'keyUsage', keyCertSign: true, digitalSignature: true, keyEncipherment: true },
|
||||
{ name: 'extKeyUsage', serverAuth: true }, { name: 'subjectAltName', altNames: [{ type: 7, ip: '127.0.0.1' }] }
|
||||
] })
|
||||
const https = createTlsServer({ key: pems.private, cert: pems.cert }, serve)
|
||||
const listen = (server: any): Promise<number> => new Promise((done, reject) => { server.once('error', reject); server.listen(0, '127.0.0.1', () => done(server.address().port)) })
|
||||
function sshPublic(jwk: JsonWebKey): Buffer {
|
||||
const field = (data: Buffer): Buffer => { const header = Buffer.alloc(4); header.writeUInt32BE(data.length); return Buffer.concat([header, data]) }
|
||||
const integer = (text: string): Buffer => { const data = Buffer.from(text, 'base64url'); return field(data[0] & 128 ? Buffer.concat([Buffer.from([0]), data]) : data) }
|
||||
return Buffer.concat([field(Buffer.from('ssh-rsa')), integer(jwk.e!), integer(jwk.n!)])
|
||||
}
|
||||
const hostKey = generateKeyPairSync('rsa', { modulusLength: 2048 }), clientKey = generateKeyPairSync('rsa', { modulusLength: 2048 })
|
||||
const hostPem = hostKey.privateKey.export({ type: 'pkcs1', format: 'pem' }).toString(), clientPem = clientKey.privateKey.export({ type: 'pkcs1', format: 'pem' }).toString()
|
||||
const clientPublic = sshPublic(clientKey.publicKey.export({ format: 'jwk' })), clients = new Set<any>()
|
||||
const ssh = new SshServer({ hostKeys: [hostPem] }, (client: any) => {
|
||||
sshConnections++; clients.add(client); client.on('error', () => undefined); client.on('close', () => clients.delete(client))
|
||||
client.on('authentication', (auth: any) => { if (auth.method === 'publickey' && auth.key.data.equals(clientPublic)) auth.accept(); else auth.reject() })
|
||||
client.on('ready', () => client.on('session', (accept: any) => accept().on('exec', (accept: any, reject: any, info: any) => {
|
||||
const match = /^git-(upload-pack|receive-pack) '\/repo\.git'$/.exec(info.command)
|
||||
if (!match) { reject(); return }
|
||||
const stream = accept(), child = spawn(git, [...prefix, match[1], remote], { env: environment, windowsHide: true, stdio: ['pipe', 'pipe', 'pipe'] })
|
||||
children.add(child); stream.pipe(child.stdin); child.stdout.pipe(stream, { end: false }); child.stderr.pipe(stream.stderr, { end: false }); child.stdin.on('error', () => undefined)
|
||||
child.once('close', code => { children.delete(child); stream.exit(code ?? 1); stream.end() })
|
||||
})))
|
||||
})
|
||||
try {
|
||||
hostGit('init', '-q', '--template=', '--initial-branch=main'); await fs.writeFile(join(workspace, 'file.txt'), 'remote-base\n'); hostGit('add', '.'); hostGit('commit', '-q', '-m', 'remote-base')
|
||||
hostGit('init', '--bare', '-q', '--template=', '--initial-branch=main', remote)
|
||||
hostGit('-c', 'protocol.file.allow=always', 'push', '-q', remote, 'main')
|
||||
await gitHostAction('import', context)
|
||||
const httpPort = await listen(http), tlsPort = await listen(https), sshPort = await listen(ssh)
|
||||
const httpUrl = `http://127.0.0.1:${httpPort}/repo.git`, httpsUrl = `https://127.0.0.1:${tlsPort}/repo.git`, sshUrl = `ssh://fixture@127.0.0.1:${sshPort}/repo.git`
|
||||
const beforeDeny = httpRequests
|
||||
let result = await gitRemoteAction({ action: 'ls-remote', url: httpUrl }, { ...context, approve: async () => false })
|
||||
check('remote rejection sends no network requests', result.state === 'denied' && httpRequests === beforeDeny)
|
||||
result = await gitRemoteAction({ action: 'ls-remote', url: httpUrl }, context)
|
||||
check('approved smart HTTP discovery uses the bundled transport helper', result.state === 'completed' && result.stdout!.includes('refs/heads/main'))
|
||||
result = await gitRemoteAction({ action: 'fetch', url: httpUrl, branch: 'main' }, context)
|
||||
check('approved fetch persists remote tracking refs without host metadata writes', result.state === 'completed' && !!result.revision && !hostGit('branch', '-r').includes('origin/main'))
|
||||
check('isolated commands observe the fetched tracking refs', (await run(['log', '-1', '--oneline', 'refs/remotes/origin/main'])).content.includes('remote-base'))
|
||||
result = await gitRemoteAction({ action: 'ls-remote', url: httpsUrl }, context)
|
||||
check('HTTPS refuses an untrusted self-signed server without an explicit CA', result.state === 'failed' && /certificate|SSL|schannel/i.test(result.reason ?? ''))
|
||||
const credential = await importGitCredential(httpsUrl, { type: 'https', username: 'fixture', secret: 'DUMMY_GIT_TOKEN_97832', caCertificate: pems.cert })
|
||||
requireAuth = true
|
||||
result = await gitRemoteAction({ action: 'ls-remote', url: httpsUrl, credentialId: credential.id }, context)
|
||||
check('HTTPS authenticates using an exact-remote DPAPI credential and verifies the explicit CA', result.state === 'completed' && result.stdout!.includes('refs/heads/main'))
|
||||
const stored = await fs.readFile(join(stateRoot, 'git-credentials', `${credential.id}.json`), 'utf8')
|
||||
check('credential records and result queries contain no plaintext token', !stored.includes('DUMMY_GIT_TOKEN_97832') && !JSON.stringify(await gitRemoteOperation(context, result.operationId)).includes('DUMMY_GIT_TOKEN_97832'))
|
||||
check('credential listing exposes metadata only', (await listGitCredentials()).some(value => value.id === credential.id) && !JSON.stringify(await listGitCredentials()).includes('DUMMY_GIT_TOKEN_97832'))
|
||||
const beforeScope = httpRequests
|
||||
await assert.rejects(gitRemoteAction({ action: 'ls-remote', url: httpsUrl.replace('repo.git', 'other.git'), credentialId: credential.id }, context), /scoped/)
|
||||
check('credential scope mismatch is rejected before any connection', httpRequests === beforeScope)
|
||||
const revokedCredential = await importGitCredential(httpsUrl, { type: 'https', username: 'fixture', secret: 'DUMMY_REVOKED_TOKEN', caCertificate: pems.cert })
|
||||
const beforeRevoke = httpRequests
|
||||
result = await gitRemoteAction({ action: 'ls-remote', url: httpsUrl, credentialId: revokedCredential.id }, { ...context, approve: async () => { await removeGitCredential(revokedCredential.id); return true } })
|
||||
check('revoking a credential during approval prevents the first connection', result.state === 'failed' && httpRequests === beforeRevoke)
|
||||
requireAuth = false
|
||||
await run(['commit', '--allow-empty', '-m', 'remote-push-commit'])
|
||||
result = await gitRemoteAction({ action: 'push', url: httpUrl, refspecs: ['HEAD:refs/heads/feature'] }, context)
|
||||
check('approved atomic push publishes exact intended refs', result.state === 'completed' && result.writeback === 'remote-committed' && hostGit('--git-dir=' + remote, 'log', '-1', '--oneline', 'feature').includes('remote-push-commit'))
|
||||
check('remote result survives a fresh owned query', (await gitRemoteOperation(context, result.operationId)).writeback === 'remote-committed')
|
||||
result = await gitRemoteAction({ action: 'push', url: httpUrl, refspecs: ['HEAD:refs/heads/dry-run'], dryRun: true }, context)
|
||||
check('push dry-run performs negotiation without publishing a ref', result.state === 'completed' && result.writeback === 'not-applied' && !hostGit('--git-dir=' + remote, 'for-each-ref', 'refs/heads/dry-run').trim())
|
||||
const remoteTree = hostGit('--git-dir=' + remote, 'rev-parse', 'main^{tree}').trim()
|
||||
const divergent = hostGit('--git-dir=' + remote, 'commit-tree', remoteTree, '-m', 'divergent-remote').trim()
|
||||
hostGit('--git-dir=' + remote, 'update-ref', 'refs/heads/feature', divergent)
|
||||
result = await gitRemoteAction({ action: 'push', url: httpUrl, refspecs: ['HEAD:refs/heads/feature'], forceWithLease: { 'refs/heads/feature': divergent } }, context)
|
||||
check('force-with-lease permits a non-fast-forward update only with the approved old object', result.state === 'completed' && hostGit('--git-dir=' + remote, 'rev-parse', 'feature').trim() !== divergent)
|
||||
const protectedFeature = hostGit('--git-dir=' + remote, 'rev-parse', 'feature').trim()
|
||||
await run(['commit', '--allow-empty', '-m', 'stale-lease-attempt'])
|
||||
result = await gitRemoteAction({ action: 'push', url: httpUrl, refspecs: ['HEAD:refs/heads/feature'], forceWithLease: { 'refs/heads/feature': divergent } }, context)
|
||||
check('a stale force-with-lease is refused without changing the remote ref', result.state !== 'completed' && /stale|rejected/i.test(result.reason ?? '') && hostGit('--git-dir=' + remote, 'rev-parse', 'feature').trim() === protectedFeature)
|
||||
result = await gitRemoteAction({ action: 'push', url: httpUrl, refspecs: [':refs/heads/feature'] }, context)
|
||||
check('explicit approved deletion removes only the named remote ref', result.state === 'completed' && !hostGit('--git-dir=' + remote, 'for-each-ref', 'refs/heads/feature').trim() && !!hostGit('--git-dir=' + remote, 'for-each-ref', 'refs/heads/main').trim())
|
||||
await run(['remote', 'add', 'origin', httpUrl]); await run(['config', 'branch.main.remote', 'origin']); await run(['config', 'branch.main.merge', 'refs/heads/main'])
|
||||
await gitRemoteAction({ action: 'push', url: httpUrl, refspecs: ['HEAD:refs/heads/main'] }, context)
|
||||
const advanceRemote = (message: string): string => {
|
||||
const parent = hostGit('--git-dir=' + remote, 'rev-parse', 'main').trim(), next = hostGit('--git-dir=' + remote, 'commit-tree', remoteTree, '-p', parent, '-m', message).trim()
|
||||
hostGit('--git-dir=' + remote, 'update-ref', 'refs/heads/main', next); return next
|
||||
}
|
||||
const fastForward = advanceRemote('remote-fast-forward')
|
||||
let pulled = await executeTool('sandbox_git', { action: 'pull' }, context)
|
||||
check('pull derives the current upstream and fast-forwards only the private draft by default', pulled.ok && (await run(['rev-parse', 'HEAD'])).content.includes(fastForward) && !hostGit('log', '-1', '--oneline').includes('remote-fast-forward'))
|
||||
await run(['commit', '--allow-empty', '-m', 'local-divergence'])
|
||||
const privateHead = (await run(['rev-parse', 'HEAD'])).content
|
||||
advanceRemote('remote-divergence')
|
||||
pulled = await executeTool('sandbox_git', { action: 'pull' }, context)
|
||||
check('default pull refuses divergent history and preserves the local commit', !pulled.ok && (await run(['rev-parse', 'HEAD'])).content === privateHead)
|
||||
pulled = await executeTool('sandbox_git', { action: 'pull', pullStrategy: 'rebase' }, context)
|
||||
check('explicit pull rebase integrates divergence inside the isolated draft', pulled.ok && (await run(['log', '-3', '--oneline'])).content.includes('remote-divergence'))
|
||||
await run(['commit', '--allow-empty', '-m', 'local-merge-divergence']); advanceRemote('remote-merge-divergence')
|
||||
pulled = await executeTool('sandbox_git', { action: 'pull', pullStrategy: 'merge' }, context)
|
||||
check('explicit pull merge retains both histories with a real merge commit', pulled.ok && /[a-f0-9]{40} [a-f0-9]{40}/.test((await run(['log', '-1', '--format=%P'])).content))
|
||||
const sshCredential = await importGitCredential(sshUrl, { type: 'ssh', privateKey: clientPem, knownHosts: `[127.0.0.1]:${sshPort} ssh-rsa ${sshPublic(hostKey.publicKey.export({ format: 'jwk' })).toString('base64')}\n` })
|
||||
result = await gitRemoteAction({ action: 'ls-remote', url: sshUrl, credentialId: sshCredential.id }, context)
|
||||
check('bundled SSH authenticates with an explicit key and pinned host key', result.state === 'completed' && result.stdout!.includes('refs/heads/main') && sshConnections > 0)
|
||||
result = await gitRemoteAction({ action: 'fetch', url: sshUrl, credentialId: sshCredential.id, branch: 'main' }, context)
|
||||
check('SSH fetch persists objects through the same controlled mirror', result.state === 'completed' && !!result.revision)
|
||||
result = await gitRemoteAction({ action: 'push', url: sshUrl, credentialId: sshCredential.id, refspecs: ['HEAD:refs/heads/ssh-pushed'] }, context)
|
||||
check('SSH push updates only the approved reference', result.state === 'completed' && hostGit('--git-dir=' + remote, 'rev-parse', 'ssh-pushed').trim().length === 40)
|
||||
const badPin = await importGitCredential(sshUrl, { type: 'ssh', privateKey: clientPem, knownHosts: `[127.0.0.1]:${sshPort} ssh-rsa ${clientPublic.toString('base64')}\n` })
|
||||
result = await gitRemoteAction({ action: 'ls-remote', url: sshUrl, credentialId: badPin.id }, context)
|
||||
check('a mismatched SSH host key fails closed', result.state === 'failed' && /host key|identification/i.test(result.reason ?? ''))
|
||||
const cloneWorkspace = join(fixture, 'clone-project'), cloneContext = { ...context, workspaceRoot: cloneWorkspace, sessionId: 'clone-session' }
|
||||
await fs.mkdir(cloneWorkspace)
|
||||
const cloned = await executeTool('sandbox_git', { action: 'clone', url: httpUrl }, cloneContext)
|
||||
await fs.writeFile(join(fixture, 'clone-result.json'), JSON.stringify(cloned, null, 2))
|
||||
check('clone fetches and checks out the default branch inside an empty draft', cloned.ok && !await fs.lstat(join(cloneWorkspace, 'file.txt')).catch(() => null))
|
||||
check('cloned worktree bytes are readable through isolated Git commands', (await executeTool('sandbox_git', { action: 'run', argv: ['show', 'HEAD:file.txt'] }, cloneContext)).content.includes('remote-base'))
|
||||
check('ordinary clone fetches the other remote branches as well as the default branch', (await executeTool('sandbox_git', { action: 'run', argv: ['branch', '-r'] }, cloneContext)).content.includes('origin/ssh-pushed'))
|
||||
const clonePublished = await workspaceAction('commit', cloneContext)
|
||||
check('clone publication creates a real host repository with its original remote ancestry', clonePublished.writeback === 'committed' && !!clonePublished.git?.groupId && (await fs.readFile(join(cloneWorkspace, 'file.txt'), 'utf8')).includes('remote-base'))
|
||||
const maintenanceBundle = join(fixture, 'maintenance.mjs'), cliProject = join(fixture, 'cli-project'), cliRequest = join(fixture, 'cli-request.json')
|
||||
await require('esbuild').build({ entryPoints: ['scripts/sandbox-maintain.ts'], bundle: true, platform: 'node', format: 'esm', outfile: maintenanceBundle, logLevel: 'silent' })
|
||||
await fs.mkdir(cliProject)
|
||||
const cli = (command: string): Promise<any> => new Promise((done, reject) => {
|
||||
const child = spawn(process.execPath, [maintenanceBundle, command, '--workspace', cliProject, '--session', 'cli-session', '--state-root', stateRoot, '--request', cliRequest, '--approve'], { windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] })
|
||||
let stdout = '', stderr = ''; const timer = setTimeout(() => child.kill(), 120000)
|
||||
child.stdout.on('data', data => { stdout += data }); child.stderr.on('data', data => { stderr += data })
|
||||
child.once('error', reject); child.once('close', code => { clearTimeout(timer); if (code) reject(new Error(`Git maintenance failed (${code}): ${stderr}`)); else { try { done(JSON.parse(stdout)) } catch (error) { reject(error) } } })
|
||||
})
|
||||
await fs.writeFile(cliRequest, JSON.stringify({ action: 'clone', url: httpUrl }))
|
||||
check('maintenance clone performs the same isolated checkout as the conversation tool', (await cli('git-remote')).ok)
|
||||
await fs.writeFile(cliRequest, JSON.stringify(['show', 'HEAD:file.txt']))
|
||||
const cliRead = await cli('git-run')
|
||||
check('a fresh maintenance process sees the cloned history and retained draft', cliRead.ok && cliRead.content.includes('remote-base'))
|
||||
const emptyRemote = join(fixture, 'empty.git'), emptyProject = join(fixture, 'empty-project')
|
||||
hostGit('init', '--bare', '-q', '--template=', '--initial-branch=main', emptyRemote); remotePaths.set('/empty.git', emptyRemote); await fs.mkdir(emptyProject)
|
||||
const emptyClone = await executeTool('sandbox_git', { action: 'clone', url: `http://127.0.0.1:${httpPort}/empty.git` }, { ...context, workspaceRoot: emptyProject, sessionId: 'empty-clone' })
|
||||
check('cloning an empty remote retains an unborn private repository', emptyClone.ok && !await fs.lstat(join(emptyProject, '.git')).catch(() => null))
|
||||
const shaRemote = join(fixture, 'sha256.git'), shaProject = join(fixture, 'sha256-project')
|
||||
hostGit('init', '--bare', '-q', '--object-format=sha256', '--template=', '--initial-branch=main', shaRemote)
|
||||
const shaTree = hostGit('--git-dir=' + shaRemote, 'hash-object', '-w', '-t', 'tree', '--stdin').trim(), shaCommit = hostGit('--git-dir=' + shaRemote, 'commit-tree', shaTree, '-m', 'sha256-history').trim()
|
||||
hostGit('--git-dir=' + shaRemote, 'update-ref', 'refs/heads/main', shaCommit); remotePaths.set('/sha256.git', shaRemote); await fs.mkdir(shaProject)
|
||||
const shaContext = { ...context, workspaceRoot: shaProject, sessionId: 'sha256-clone' }
|
||||
const shaClone = await executeTool('sandbox_git', { action: 'clone', url: `http://127.0.0.1:${httpPort}/sha256.git` }, shaContext)
|
||||
check('clone detects and preserves a real SHA-256 repository', shaClone.ok && shaCommit.length === 64 && (await executeTool('sandbox_git', { action: 'run', argv: ['rev-parse', 'HEAD'] }, shaContext)).content.includes(shaCommit))
|
||||
await removeGitCredential(credential.id)
|
||||
check('credential removal revokes subsequent lookup', !(await listGitCredentials()).some(value => value.id === credential.id))
|
||||
for (const bad of ['ext::echo pwned', 'file:///C:/private.git', 'https://token@example.invalid/repo.git', 'http://example.invalid/repo.git', 'ssh://git@host/repo;bad']) assert.throws(() => gitRemoteUrl(bad))
|
||||
check('remote URL parsing rejects helper execution, inline secrets and unsafe protocols', true)
|
||||
const scp = 'fixture@example.invalid:relative/repo.git'
|
||||
check('SCP remotes retain relative transport paths while credentials use an unambiguous scope', gitRemoteUrl(scp).pathname === '/~/relative/repo.git' && gitRemoteEndpoint(scp, gitRemoteUrl(scp)) === scp)
|
||||
disconnectPush = true
|
||||
result = await gitRemoteAction({ action: 'push', url: httpUrl, refspecs: ['HEAD:refs/heads/lost-reply'] }, context)
|
||||
check('lost push acknowledgement remains unknown after real remote refs changed', result.state === 'unknown' && result.writeback === 'unknown' && hostGit('--git-dir=' + remote, 'rev-parse', 'lost-reply').trim().length === 40)
|
||||
check('durable query never infers lost push success or retries it', (await gitRemoteOperation(context, result.operationId)).state === 'unknown')
|
||||
disconnectPush = false
|
||||
const reconciled = await reconcileGitRemote(context, result.operationId)
|
||||
check('explicit reconciliation confirms current remote refs without repeating the push', reconciled.state === 'completed' && reconciled.reason!.includes('observing the current remote refs'))
|
||||
const heldOperation = async (cancel: () => Promise<void>, signal?: AbortSignal) => {
|
||||
holdResponses = true
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
const requested = new Promise<void>((done, reject) => { onHeldRequest = done; timer = setTimeout(() => reject(new Error('Git did not reach the real transport cancellation boundary')), 15000) })
|
||||
const operation = gitRemoteAction({ action: 'ls-remote', url: httpUrl }, { ...context, signal })
|
||||
try { await requested; await cancel(); return await operation }
|
||||
finally { clearTimeout(timer); holdResponses = false; onHeldRequest = undefined; for (const response of heldResponses) response.destroy() }
|
||||
}
|
||||
const controller = new AbortController()
|
||||
const cancelled = await heldOperation(async () => controller.abort(), controller.signal)
|
||||
check('cancelling an active real Git transport terminates its Job and saves the failed read outcome', cancelled.state === 'failed' && /cancel/i.test(cancelled.reason ?? '') && (await gitRemoteOperation(context, cancelled.operationId)).state === 'failed')
|
||||
const revoked = await heldOperation(async () => { const policy = await readSandboxPolicy(); await updateSandboxPolicy({ expectedRevision: policy.revision, rules: { deniedPaths: ['revoked-by-test.txt'] } }) })
|
||||
check('policy revocation terminates an in-flight trusted Git transport', revoked.state === 'failed' && /policy|策略/i.test(revoked.reason ?? ''))
|
||||
console.log(`SANDBOX_GIT_REMOTE_PASS ${checks.length}/${checks.length}`)
|
||||
} finally {
|
||||
for (const child of children) child.kill()
|
||||
for (const client of clients) client.end()
|
||||
http.closeAllConnections(); https.closeAllConnections(); http.close(); https.close(); ssh.close()
|
||||
await fs.writeFile(join(fixture, 'report.json'), JSON.stringify({ fixture, checks, approvals, httpRequests, sshConnections }, null, 2) + '\n')
|
||||
console.log(`SANDBOX_GIT_REMOTE_REPORT ${join(fixture, 'report.json')}`)
|
||||
}
|
||||
@@ -1,142 +0,0 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { promises as fs } from 'node:fs'
|
||||
import { execFileSync, spawn } from 'node:child_process'
|
||||
import { randomBytes, randomUUID } from 'node:crypto'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { join, resolve, relative, isAbsolute } from 'node:path'
|
||||
import { configureSandbox } from '../src/main/sandbox/configuration'
|
||||
import { gitHostAction, hostGitStatus } from '../src/main/sandbox/git-host-service'
|
||||
import { effectiveSandboxPolicy, readSandboxPolicy, updateSandboxPolicy } from '../src/main/sandbox/policy-store'
|
||||
import { hostGitRoot, loadHostGitBinding } from '../src/main/sandbox/git-host-source'
|
||||
import { executeSandboxCommand } from '../src/main/sandbox/service'
|
||||
import { executeTool } from '../src/main/agent/tools'
|
||||
import { modeAllowsTool } from '../src/main/agent/mode-policy'
|
||||
import { workspaceAction } from '../src/main/sandbox/workspace-service'
|
||||
import { cleanupGitControls, createGitControl } from '../src/main/sandbox/git-control'
|
||||
import { withSandboxExecution } from '../src/main/sandbox/execution'
|
||||
import { PersistentWorkspace, workspaceKey } from '../src/main/sandbox/workspace'
|
||||
|
||||
if (process.argv[2] === '--worker-commit') {
|
||||
configureSandbox({ stateRoot: process.argv[4], resourceDirectory: resolve('resources/sandbox/win32-x64') })
|
||||
await gitHostAction('commit', { workspaceRoot: process.argv[3], sessionId: process.argv[5], approve: async () => true })
|
||||
process.exit(0)
|
||||
}
|
||||
const fixture = await fs.mkdtemp(resolve('.tmp/sandbox-git-safety-')), workspace = join(fixture, 'project \u4e2d\u6587 ^'), stateRoot = join(fixture, 'state')
|
||||
await fs.mkdir(workspace); await fs.mkdir(stateRoot)
|
||||
configureSandbox({ stateRoot, resourceDirectory: resolve('resources/sandbox/win32-x64') })
|
||||
const git = resolve('resources/sandbox/win32-x64/git/files/git.exe'), tool = resolve('resources/sandbox/win32-x64/git/files')
|
||||
const env = { SystemRoot: process.env.SystemRoot, PATH: `${tool};${process.env.SystemRoot}\\System32`, GIT_CONFIG_NOSYSTEM: '1', GIT_CONFIG_GLOBAL: 'NUL', GIT_CONFIG_SYSTEM: 'NUL', GIT_TERMINAL_PROMPT: '0' }
|
||||
const hostGit = (...args: string[]): string => execFileSync(git, ['-c', 'core.hooksPath=NUL', '-c', 'core.fsmonitor=false', '-c', 'user.name=Fixture', '-c', 'user.email=fixture@example.invalid', ...args], { cwd: workspace, env, encoding: 'utf8', windowsHide: true })
|
||||
const context = { workspaceRoot: workspace, sessionId: 'safety-session', runId: 'safety-run', toolCallId: 'safety-call', approve: async () => true }
|
||||
const checks: Array<{ label: string; pass: boolean }> = []
|
||||
function check(label: string, condition: unknown): void { checks.push({ label, pass: !!condition }); assert.ok(condition, label); console.log(`PASS ${label}`) }
|
||||
const run = (command: string) => executeSandboxCommand(command, { ...context, runtimes: ['git'], workspaceMode: 'persistent' })
|
||||
async function savedJson(path: string): Promise<any> { return fs.readFile(path, 'utf8').then(JSON.parse, error => { if (error.code === 'ENOENT') return null; throw error }) }
|
||||
try {
|
||||
hostGit('init', '-q', '--template=', '--initial-branch=main')
|
||||
await fs.writeFile(join(workspace, 'file.txt'), 'base\n'); hostGit('add', '.'); hostGit('commit', '-q', '-m', 'base')
|
||||
const rejected = await gitHostAction('import', { ...context, approve: async () => false })
|
||||
check('denied history import creates no active host binding', rejected.writeback === 'rejected' && (await hostGitStatus(workspace, context.sessionId)).state === 'unimported')
|
||||
let prompt = ''
|
||||
await assert.rejects(gitHostAction('import', { ...context, approve: async title => { prompt = title; hostGit('update-ref', 'refs/heads/concurrent-import', 'HEAD'); return true } }), /changed while import/)
|
||||
check('import approval is invalidated by an external reference change', prompt.includes('历史可能包含') && (await hostGitStatus(workspace, context.sessionId)).state === 'unimported')
|
||||
await gitHostAction('import', context)
|
||||
const priorRevision = (await workspaceAction('status', context)).revision!
|
||||
let releaseQueue!: () => void, enteredQueue!: () => void
|
||||
const queueEntered = new Promise<void>(done => { enteredQueue = done }), queueRelease = new Promise<void>(done => { releaseQueue = done })
|
||||
const queuedMutation = withSandboxExecution(workspace, undefined, async () => {
|
||||
enteredQueue(); await queueRelease
|
||||
const policy = await effectiveSandboxPolicy(workspace), binding = await loadHostGitBinding(policy, context.sessionId)
|
||||
const draft = await PersistentWorkspace.open(policy, context.sessionId, binding!.layout.projectIdentity)
|
||||
try { await fs.writeFile(join(workspace, 'queued-external.txt'), 'queued-host-edit\n'); await draft.synchronize() } finally { await draft.close() }
|
||||
})
|
||||
await queueEntered
|
||||
const staleDiscard = workspaceAction('discard', context, priorRevision)
|
||||
const staleDiscardCheck = assert.rejects(staleDiscard, /版本已变化/)
|
||||
await new Promise(done => setTimeout(done, 100)); releaseQueue(); await queuedMutation; await staleDiscardCheck
|
||||
check('workspace actions recheck their expected revision after waiting in the shared Git queue', (await workspaceAction('status', context)).revision !== priorRevision)
|
||||
const nativeTool = await executeTool('sandbox_git', { action: 'run', argv: ['status', '--short'] }, { ...context, permissionMode: 'full-access' })
|
||||
check('explicit Git tool keeps native confinement in a full-access context', nativeTool.ok && (nativeTool.data as { isolated: boolean }).isolated)
|
||||
check('model tool rejects forged project/session ownership', !(await executeTool('sandbox_git', { action: 'status', workspaceRoot: fixture, sessionId: 'other' }, context)).ok)
|
||||
check('planning mode cannot mutate or import Git state', !modeAllowsTool('plan', 'sandbox_git') && !modeAllowsTool('ask', 'sandbox_git'))
|
||||
const defaultResult = await executeSandboxCommand('git log -1 --oneline', { ...context, runtimes: ['git'] })
|
||||
check('ordinary Git runtime calls select the imported session mirror after import', defaultResult.ok && defaultResult.data.git?.mode === 'host-mirror' && defaultResult.data.workspace?.mode === 'persistent')
|
||||
await run('echo pending>file.txt && git add file.txt && git commit -q -m pending')
|
||||
const original = hostGit('rev-parse', 'HEAD').trim()
|
||||
await assert.rejects(gitHostAction('commit', { ...context, approve: async () => { hostGit('update-ref', 'refs/heads/raced', 'HEAD'); return true } }), /changed while commit approval/)
|
||||
check('host metadata edits during commit approval prevent every host file write', hostGit('rev-parse', 'HEAD').trim() === original && (await fs.readFile(join(workspace, 'file.txt'), 'utf8')) === 'base\n')
|
||||
const refreshed = await executeSandboxCommand('git show-ref raced && git log -1 --oneline', { ...context, runtimes: ['git'], workspaceMode: 'persistent' })
|
||||
check('independent external reference updates merge without losing private commits', refreshed.ok && refreshed.data.stdout.includes('raced') && refreshed.data.stdout.includes('pending'))
|
||||
await assert.rejects(gitHostAction('commit', { ...context, approve: async () => { await fs.chmod(join(workspace, '.git/index'), 0o444); return true } }), /read.only|readonly|原生|Transaction/i)
|
||||
await fs.chmod(join(workspace, '.git/index'), 0o666)
|
||||
check('a metadata-phase failure compensates the already committed worktree phase', (await fs.readFile(join(workspace, 'file.txt'), 'utf8')) === 'base\n' && hostGit('rev-parse', 'HEAD').trim() === original)
|
||||
const bindingRoot = hostGitRoot(workspace, context.sessionId)
|
||||
check('compensated failure clears only its owned Git coordination locks', !await fs.lstat(join(workspace, '.git/index.lock')).catch(() => null) && !await fs.lstat(join(bindingRoot, 'commit.json')).catch(() => null))
|
||||
const partial = await gitHostAction('commit', { ...context, approve: async () => {
|
||||
await fs.rename(join(bindingRoot, 'binding.json'), join(bindingRoot, 'binding-before-fault.json')); await fs.mkdir(join(bindingRoot, 'binding.json')); return true
|
||||
} })
|
||||
check('private metadata failure after real host writes retains a committed outcome', partial.writeback === 'committed' && partial.state === 'interrupted' && hostGit('log', '-1', '--oneline').includes('pending') && (await fs.readFile(join(workspace, 'file.txt'), 'utf8')).includes('pending'))
|
||||
const committedHead = hostGit('rev-parse', 'HEAD').trim(), beforeRecoveryTransactions = (await fs.readdir(join(stateRoot, 'transactions'))).length
|
||||
await fs.rmdir(join(bindingRoot, 'binding.json')); await fs.rename(join(bindingRoot, 'binding-before-fault.json'), join(bindingRoot, 'binding.json'))
|
||||
const recovered = await gitHostAction('recover', context)
|
||||
check('group recovery finishes bookkeeping without replaying committed host mutations', recovered.writeback === 'committed' && recovered.state === 'ready' && hostGit('rev-parse', 'HEAD').trim() === committedHead && (await fs.readdir(join(stateRoot, 'transactions'))).length === beforeRecoveryTransactions)
|
||||
await run('echo root-swap>file.txt && git add file.txt && git commit -q -m root-swap')
|
||||
const backup = join(workspace, '.git-before-swap')
|
||||
await assert.rejects(gitHostAction('commit', { ...context, approve: async () => { await fs.rename(join(workspace, '.git'), backup); await fs.cp(backup, join(workspace, '.git'), { recursive: true }); return true } }), /changed/)
|
||||
check('same-content .git directory replacement invalidates the approved root identity', hostGit('rev-parse', 'HEAD').trim() === committedHead && (await fs.readFile(join(workspace, 'file.txt'), 'utf8')).includes('pending'))
|
||||
const replacement = resolve(workspace, '.git'), rel = relative(fixture, replacement)
|
||||
assert.ok(rel && !rel.startsWith('..') && !isAbsolute(rel)); await fs.rm(replacement, { recursive: true }); await fs.rename(backup, replacement)
|
||||
await gitHostAction('discard', context)
|
||||
await run('git commit --allow-empty -q -m cleanup-acknowledgement')
|
||||
const workspaceLock = join(stateRoot, 'workspaces', workspaceKey(workspace, context.sessionId), 'workspace.lock')
|
||||
const alteredLockNonce = randomUUID()
|
||||
const cleanupFailure = await gitHostAction('commit', { ...context, approve: async () => { await fs.writeFile(workspaceLock, JSON.stringify({ pid: process.pid, nonce: alteredLockNonce }) + '\n'); return true } })
|
||||
await fs.writeFile(join(fixture, 'cleanup-failure-result.json'), JSON.stringify(cleanupFailure, null, 2))
|
||||
check('a lock cleanup failure never replaces an already committed host outcome', cleanupFailure.writeback === 'committed' && cleanupFailure.state === 'interrupted' && /cleanup requires maintenance/.test(cleanupFailure.reason ?? '') && hostGit('log', '-1', '--oneline').includes('cleanup-acknowledgement'))
|
||||
assert.equal((await savedJson(workspaceLock)).nonce, alteredLockNonce); await fs.unlink(workspaceLock)
|
||||
for (let index = 0; index < 20; index++) await fs.writeFile(join(workspace, `large-${index}.bin`), randomBytes(512 * 1024))
|
||||
await run('echo crash-draft>file.txt && git add . && git commit -q -m crash-draft && git gc --quiet')
|
||||
const beforeCrashHead = hostGit('rev-parse', 'HEAD').trim(), beforeCrashBytes = await fs.readFile(join(workspace, 'file.txt'))
|
||||
const worker = spawn(process.execPath, [fileURLToPath(import.meta.url), '--worker-commit', workspace, stateRoot, context.sessionId], { windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] })
|
||||
let workerError = ''; worker.stderr.on('data', (data: Buffer) => { workerError = (workerError + data.toString()).slice(-4000) })
|
||||
const closed = new Promise<void>((done, reject) => { worker.once('error', reject); worker.once('close', () => done()) })
|
||||
let interrupted = false
|
||||
for (let attempt = 0; attempt < 2500; attempt++) {
|
||||
const group = await savedJson(join(bindingRoot, 'commit.json'))
|
||||
if (group?.status === 'applying' && group.transactions?.worktree && !group.transactions.common) { interrupted = true; worker.kill(); break }
|
||||
if (worker.exitCode !== null) break
|
||||
await new Promise(done => setTimeout(done, 5))
|
||||
}
|
||||
if (!interrupted) { worker.kill(); await closed; throw new Error(`Did not observe the real mid-group interruption boundary: ${workerError}`) }
|
||||
await closed
|
||||
let crashRecovery: Awaited<ReturnType<typeof gitHostAction>> | undefined
|
||||
for (let attempt = 0; attempt < 50; attempt++) {
|
||||
try { crashRecovery = await gitHostAction('recover', context); break }
|
||||
catch (error) { if (!/active|正在|共享|sharing|win32=32/i.test((error as Error).message) || attempt === 49) throw error; await new Promise(done => setTimeout(done, 50)) }
|
||||
}
|
||||
check('actual host-process death between native phases recovers a determinate outcome', !!crashRecovery && ['not-applied', 'committed'].includes(crashRecovery.writeback ?? ''))
|
||||
if (crashRecovery?.writeback === 'not-applied') check('interrupted multi-root publication restores the original host files and refs', hostGit('rev-parse', 'HEAD').trim() === beforeCrashHead && (await fs.readFile(join(workspace, 'file.txt'))).equals(beforeCrashBytes))
|
||||
else check('an already completed group is acknowledged without compensating its committed state', hostGit('log', '-1', '--oneline').includes('crash-draft'))
|
||||
check('recovered private Git history remains available for explicit retry or inspection', (await run('git log -1 --oneline')).data.stdout.includes('crash-draft'))
|
||||
const cleanup = await cleanupGitControls()
|
||||
check('Git maintenance removes only abandoned controls after verifying owner/native lifetimes', cleanup.some(record => record.state === 'removed'))
|
||||
const liveControl = await createGitControl(await effectiveSandboxPolicy(workspace))
|
||||
try { check('maintenance preserves a live owned Git control', (await cleanupGitControls()).some(record => record.id === liveControl.root.split(/[\\/]/).at(-1) && record.state === 'active')) } finally { await liveControl.close() }
|
||||
if (crashRecovery?.writeback === 'not-applied') await gitHostAction('commit', context)
|
||||
check('a real pack larger than the ordinary 8 MiB file ceiling publishes through the Git metadata channel', hostGit('log', '-1', '--oneline').includes('crash-draft') && (await fs.readdir(join(workspace, '.git/objects/pack'))).some(name => name.endsWith('.pack') && name.startsWith('pack-')) && hostGit('fsck', '--connectivity-only').trim() === '')
|
||||
const foreignLock = join(workspace, '.git/index.lock'); await fs.writeFile(foreignLock, 'foreign-lock')
|
||||
await assert.rejects(run('git status --short'), /busy|lock/)
|
||||
check('a foreign Git lock is never consumed or removed by refresh', (await fs.readFile(foreignLock, 'utf8')) === 'foreign-lock')
|
||||
await fs.unlink(foreignLock)
|
||||
const policy = await readSandboxPolicy(); await updateSandboxPolicy({ expectedRevision: policy.revision, rules: { deniedPaths: ['secret.txt'] } })
|
||||
check('policy revocation makes the full history import stale', (await hostGitStatus(workspace, context.sessionId)).state === 'stale')
|
||||
await assert.rejects(run('git log -1'), /策略|policy/)
|
||||
const discarded = await workspaceAction('discard', context)
|
||||
check('discard under a new policy archives the old full-history grant without silently reimporting it', discarded.writeback === 'not-applied' && (await hostGitStatus(workspace, context.sessionId)).state === 'unimported')
|
||||
const currentPolicy = await effectiveSandboxPolicy(workspace)
|
||||
check('the active policy remains in force after Git recovery and discard', currentPolicy.deniedPaths.includes('secret.txt'))
|
||||
console.log(`SANDBOX_GIT_SAFETY_PASS ${checks.length}/${checks.length}`)
|
||||
} finally {
|
||||
await fs.writeFile(join(fixture, 'report.json'), JSON.stringify({ fixture, checks }, null, 2) + '\n')
|
||||
console.log(`SANDBOX_GIT_SAFETY_REPORT ${join(fixture, 'report.json')}`)
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
import { build } from 'esbuild'
|
||||
import { promises as fs } from 'node:fs'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { join } from 'node:path'
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url'
|
||||
|
||||
const project = fileURLToPath(new URL('../', import.meta.url))
|
||||
const temporary = join(project, '.tmp')
|
||||
await fs.mkdir(temporary, { recursive: true })
|
||||
const output = join(temporary, `sandbox-maintenance-cli-${randomUUID()}.mjs`)
|
||||
try {
|
||||
// One node entry point avoids npm's double cmd-shim escaping of path arguments.
|
||||
await build({ absWorkingDir: project, entryPoints: ['scripts/sandbox-maintain.ts'], bundle: true, platform: 'node', format: 'esm', outfile: output, logLevel: 'warning' })
|
||||
await import(pathToFileURL(output).href)
|
||||
} finally { await fs.unlink(output).catch(() => undefined) }
|
||||
@@ -1,135 +0,0 @@
|
||||
import { promises as fs } from 'node:fs'
|
||||
import { isAbsolute, join, resolve } from 'node:path'
|
||||
import { configureSandbox } from '../src/main/sandbox/configuration'
|
||||
import { sandboxStatus, verifiedBinary } from '../src/main/sandbox/service'
|
||||
import { releaseRunLease, sandboxRecords, sandboxOperation, sandboxBrokerOperation } from '../src/main/sandbox/maintenance'
|
||||
import { restoreTransaction } from '../src/main/sandbox/transactions'
|
||||
import { readSandboxPolicy, effectiveSandboxPolicy, updateSandboxPolicy } from '../src/main/sandbox/policy-store'
|
||||
import { runSandboxSelfTest } from '../src/main/sandbox/self-test'
|
||||
import { importSandboxToolchain, listSandboxToolchains } from '../src/main/sandbox/toolchains'
|
||||
import { importSandboxDependencies } from '../src/main/sandbox/dependency-cache'
|
||||
import { sandboxGitState, resetSandboxGit } from '../src/main/sandbox/git-repository'
|
||||
import { workspaceAction } from '../src/main/sandbox/workspace-service'
|
||||
import type { SandboxWorkspaceAction } from '../src/shared/sandbox'
|
||||
import { gitHostAction } from '../src/main/sandbox/git-host-service'
|
||||
import { gitRemoteOperation, reconcileGitRemote } from '../src/main/sandbox/git-remote'
|
||||
import { sandboxGitTool } from '../src/main/agent/tools/sandbox-git'
|
||||
import { importGitCredential, listGitCredentials, removeGitCredential, type GitCredential } from '../src/main/sandbox/git-credentials'
|
||||
import { regularBytes } from '../src/main/sandbox/workspace-files'
|
||||
import { executeSandboxCommand } from '../src/main/sandbox/service'
|
||||
import { cleanupGitControls } from '../src/main/sandbox/git-control'
|
||||
|
||||
const args = process.argv.slice(2)
|
||||
function option(name: string): string | undefined {
|
||||
const index = args.indexOf(name)
|
||||
if (index < 0) return undefined
|
||||
const value = args[index + 1]
|
||||
if (!value || value.startsWith('--')) throw new Error(`Missing ${name} value`)
|
||||
args.splice(index, 2)
|
||||
return value
|
||||
}
|
||||
const stateRoot = option('--state-root'), resourceDirectory = option('--resources') ?? resolve('resources/sandbox/win32-x64')
|
||||
const workspace = option('--workspace')
|
||||
const expectedRevision = option('--expected-revision'), rulesFile = option('--rules')
|
||||
const source = option('--source')
|
||||
const sessionId = option('--session'), draftRevision = option('--draft-revision')
|
||||
const requestFile = option('--request'), credentialFile = option('--credential-file'), remoteUrl = option('--url')
|
||||
const credentialId = option('--credential-id')
|
||||
const approvedIndex = args.indexOf('--approve')
|
||||
const explicitlyApproved = approvedIndex >= 0
|
||||
if (explicitlyApproved) args.splice(approvedIndex, 1)
|
||||
const command = args.shift() ?? 'status'
|
||||
if (stateRoot && !isAbsolute(stateRoot)) throw new Error('--state-root must be an absolute directory')
|
||||
if (workspace && !isAbsolute(workspace)) throw new Error('--workspace must be an absolute directory')
|
||||
if (!['status', 'self-test'].includes(command) && !stateRoot) throw new Error('Stateful maintenance requires an explicit --state-root (the app userData/sandbox directory)')
|
||||
const temporary = !stateRoot ? await fs.mkdtemp(resolve('.tmp/sandbox-maintenance-')) : stateRoot
|
||||
configureSandbox({ stateRoot: temporary, resourceDirectory })
|
||||
if (command === 'status') console.log(JSON.stringify(await sandboxStatus(), null, 2))
|
||||
else if (command === 'toolchains') console.log(JSON.stringify(await listSandboxToolchains(), null, 2))
|
||||
else if (command.startsWith('git-host-')) {
|
||||
const action = command.slice('git-host-'.length)
|
||||
if (!workspace || !sessionId || !['status', 'init', 'import', 'changes', 'commit', 'discard', 'recover'].includes(action)) throw new Error('Usage: git-host-status|init|import|changes|commit|discard|recover --workspace ROOT --session ID --state-root ROOT [--approve]')
|
||||
console.log(JSON.stringify(await gitHostAction(action as 'status' | 'init' | 'import' | 'changes' | 'commit' | 'discard' | 'recover', { workspaceRoot: workspace, sessionId, approve: async () => explicitlyApproved }, draftRevision), null, 2))
|
||||
}
|
||||
else if (command === 'git-remote' || command === 'git-run' || command === 'git-remote-operation' || command === 'git-remote-reconcile') {
|
||||
if (!workspace || !sessionId) throw new Error('Git requires explicit --workspace and --session')
|
||||
const context = { workspaceRoot: workspace, sessionId, approve: async () => explicitlyApproved }
|
||||
if (command === 'git-remote-operation' || command === 'git-remote-reconcile') console.log(JSON.stringify(await (command === 'git-remote-reconcile' ? reconcileGitRemote(context, args.shift() ?? '', credentialId) : gitRemoteOperation(context, args.shift() ?? '')), null, 2))
|
||||
else {
|
||||
if (!requestFile || !isAbsolute(requestFile)) throw new Error('Git requires an absolute --request JSON file')
|
||||
const request = JSON.parse((await regularBytes(requestFile, 64 * 1024)).toString('utf8'))
|
||||
if (command === 'git-run') {
|
||||
if (!Array.isArray(request) || !request.length || request.some(value => typeof value !== 'string')) throw new Error('git-run request must be an argv array')
|
||||
console.log(JSON.stringify(await executeSandboxCommand(`git ${request.join(' ')}`, { ...context, runtimes: ['git'], workspaceMode: 'persistent', gitArguments: request, preserveGitConflicts: true }), null, 2))
|
||||
} else {
|
||||
if (!request || typeof request !== 'object' || Array.isArray(request) || !['ls-remote', 'clone', 'fetch', 'pull', 'push'].includes(request.action)) throw new Error('git-remote request requires a supported remote action')
|
||||
console.log(JSON.stringify(await sandboxGitTool.run(request, context), null, 2))
|
||||
}
|
||||
}
|
||||
}
|
||||
else if (command === 'git-credential-add') {
|
||||
if (!remoteUrl || !credentialFile || !isAbsolute(credentialFile)) throw new Error('Usage: git-credential-add --url REMOTE_URL --credential-file ABSOLUTE_JSON --state-root ROOT')
|
||||
const credential = JSON.parse((await regularBytes(credentialFile, 64 * 1024)).toString('utf8')) as GitCredential
|
||||
console.log(JSON.stringify(await importGitCredential(remoteUrl, credential), null, 2))
|
||||
}
|
||||
else if (command === 'git-credentials') console.log(JSON.stringify(await listGitCredentials(), null, 2))
|
||||
else if (command === 'git-cleanup') console.log(JSON.stringify(await cleanupGitControls(), null, 2))
|
||||
else if (command === 'git-credential-remove') { await removeGitCredential(args.shift() ?? ''); console.log(JSON.stringify({ removed: true })) }
|
||||
else if (command.startsWith('workspace-')) {
|
||||
const action = command.slice('workspace-'.length) as SandboxWorkspaceAction
|
||||
if (!['status', 'changes', 'commit', 'discard', 'recover'].includes(action) || !workspace || !sessionId) throw new Error('Usage: workspace-status|changes|commit|discard|recover --workspace ROOT --session ID --state-root ROOT [--draft-revision UUID] [--approve]')
|
||||
console.log(JSON.stringify(await workspaceAction(action, { workspaceRoot: workspace, sessionId, approve: async () => explicitlyApproved }, draftRevision), null, 2))
|
||||
}
|
||||
else if (command === 'toolchain-add') {
|
||||
const kind = args.shift()
|
||||
if (!kind || !source) throw new Error('Usage: toolchain-add node|python|git --source ABSOLUTE_EXE --state-root ROOT')
|
||||
console.log(JSON.stringify(await importSandboxToolchain(kind, source), null, 2))
|
||||
}
|
||||
else if (command === 'git-state' || command === 'git-reset') {
|
||||
if (!workspace) throw new Error('Usage: git-state|git-reset --workspace ABSOLUTE_ROOT --state-root ROOT')
|
||||
if (command === 'git-reset') await resetSandboxGit(workspace)
|
||||
console.log(JSON.stringify(await sandboxGitState(workspace), null, 2))
|
||||
}
|
||||
else if (command === 'dependency-add') {
|
||||
const kind = args.shift()
|
||||
if (!kind || !workspace || !isAbsolute(workspace)) throw new Error('Usage: dependency-add node|python --workspace ABSOLUTE_ROOT --state-root ROOT')
|
||||
console.log(JSON.stringify(await importSandboxDependencies(workspace, kind), null, 2))
|
||||
}
|
||||
else if (command === 'policy') console.log(JSON.stringify(workspace ? await effectiveSandboxPolicy(workspace) : await readSandboxPolicy(), null, 2))
|
||||
else if (command === 'operation') {
|
||||
const id = args.shift()
|
||||
if (!id) throw new Error('Usage: operation RUN_UUID --state-root ROOT')
|
||||
console.log(JSON.stringify(await sandboxOperation(id), null, 2))
|
||||
}
|
||||
else if (command === 'broker-operation') {
|
||||
const runId = args.shift(), requestId = args.shift()
|
||||
if (!runId || !requestId) throw new Error('Usage: broker-operation RUN_UUID REQUEST_UUID --state-root ROOT')
|
||||
console.log(JSON.stringify(await sandboxBrokerOperation(runId, requestId), null, 2))
|
||||
}
|
||||
else if (command === 'policy-set') {
|
||||
if (!expectedRevision || !rulesFile) throw new Error('Usage: policy-set --expected-revision REVISION --rules RULES_JSON --state-root ROOT [--workspace ROOT]')
|
||||
const stat = await fs.stat(rulesFile)
|
||||
if (!stat.isFile() || stat.size > 64 * 1024) throw new Error('Rules input must be a JSON file smaller than 64 KiB')
|
||||
const rules = JSON.parse(await fs.readFile(rulesFile, 'utf8'))
|
||||
console.log(JSON.stringify(await updateSandboxPolicy({ expectedRevision, rules, ...(workspace ? { workspaceRoot: workspace } : {}) }), null, 2))
|
||||
}
|
||||
else if (command === 'audit' || command === 'transactions') console.log(JSON.stringify(await sandboxRecords(command === 'audit' ? 'runs' : 'transactions'), null, 2))
|
||||
else if (command === 'restore') {
|
||||
const id = args.shift()
|
||||
if (!id || !workspace || !isAbsolute(workspace)) throw new Error('Usage: restore <transaction-id> --workspace <absolute-root> --state-root <absolute-state>')
|
||||
await restoreTransaction(id, workspace)
|
||||
console.log(JSON.stringify({ restored: id, workspace }))
|
||||
} else if (command === 'cleanup') {
|
||||
const binary = await verifiedBinary()
|
||||
const root = join(temporary, 'runs')
|
||||
const directories = await fs.readdir(root, { withFileTypes: true }).catch(() => [])
|
||||
const results = []
|
||||
for (const entry of directories) {
|
||||
if (entry.isDirectory() && !entry.isSymbolicLink() && /^[0-9a-f-]{36}$/.test(entry.name)) results.push({ id: entry.name, status: await releaseRunLease(binary, join(root, entry.name)) })
|
||||
}
|
||||
console.log(JSON.stringify(results, null, 2))
|
||||
} else if (command === 'self-test') {
|
||||
const result = await runSandboxSelfTest()
|
||||
if (!result.passed) throw new Error('Native sandbox self-test failed')
|
||||
console.log(JSON.stringify({ ...result, stateRoot: temporary }))
|
||||
} else throw new Error('Commands: status, self-test, policy, policy-set, toolchains, toolchain-add, dependency-add, git-state, git-reset, operation, broker-operation, audit, transactions, restore, cleanup')
|
||||
@@ -1,179 +0,0 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { promises as fs } from 'node:fs'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { configureSandbox } from '../src/main/sandbox/configuration'
|
||||
import { readSandboxPolicy, updateSandboxPolicy, effectiveSandboxPolicy, validatePolicyOverride } from '../src/main/sandbox/policy-store'
|
||||
import { authorizeSandboxWrite } from '../src/main/sandbox/authorizations'
|
||||
import { executeSandboxCommand, sandboxStatus } from '../src/main/sandbox/service'
|
||||
import { executeTool } from '../src/main/agent/tools'
|
||||
import { runSandboxSelfTest } from '../src/main/sandbox/self-test'
|
||||
import { digest, restoreTransaction } from '../src/main/sandbox/transactions'
|
||||
import { sandboxOperation } from '../src/main/sandbox/maintenance'
|
||||
import { createInteractionBroker, respondToInteraction } from '../src/main/interaction-broker'
|
||||
import type { ChatEvent } from '../src/shared/types'
|
||||
|
||||
await fs.mkdir(resolve('.tmp'), { recursive: true })
|
||||
const fixture = await fs.mkdtemp(resolve('.tmp/sandbox-policy-'))
|
||||
const workspace = join(fixture, 'workspace'), other = join(fixture, 'other'), stateRoot = join(fixture, 'state')
|
||||
await fs.mkdir(workspace); await fs.mkdir(other)
|
||||
await fs.writeFile(join(workspace, 'sample.txt'), 'before')
|
||||
await fs.mkdir(join(workspace, 'private')); await fs.writeFile(join(workspace, 'private/file.txt'), 'private-dummy')
|
||||
configureSandbox({ stateRoot, resourceDirectory: resolve('resources/sandbox/win32-x64') })
|
||||
const checks: Array<{ label: string; pass: boolean }> = []
|
||||
function check(label: string, condition: unknown): void { checks.push({ label, pass: !!condition }); assert.ok(condition, label); console.log(`PASS ${label}`) }
|
||||
let document = await readSandboxPolicy()
|
||||
const context = { workspaceRoot: workspace, sessionId: 'policy-session', runId: 'policy-run', toolCallId: 'policy-tool', approve: async () => true }
|
||||
async function update(rules: Parameters<typeof updateSandboxPolicy>[0]['rules'], root?: string): Promise<void> {
|
||||
document = await updateSandboxPolicy({ expectedRevision: document.revision, rules, ...(root ? { workspaceRoot: root } : {}) })
|
||||
}
|
||||
try {
|
||||
check('missing policy loads safe deterministic defaults without writing state', document.defaults.commandsEnabled && document.defaults.writeback === 'ask' && document.updatedAt === 0 && !await fs.stat(join(stateRoot, 'policy.json')).catch(() => null))
|
||||
for (const value of [{ network: 'allow' }, { writeback: 'automatic' }, { limits: { files: -1 } }, { limits: { timeoutMs: Infinity } }, { deniedPaths: ['../escape'] }, { deniedPaths: ['C:/host'] }, { deniedPaths: ['a\\..\\escape'] }]) assert.throws(() => validatePolicyOverride(value))
|
||||
check('unsupported permissions and malformed policies cannot be enabled', true)
|
||||
const initialRevision = document.revision
|
||||
await update({ deniedPaths: ['private'], limits: { files: 500 } })
|
||||
check('policy is atomically persisted with a new revision', document.revision !== initialRevision && (await readSandboxPolicy()).revision === document.revision)
|
||||
await assert.rejects(updateSandboxPolicy({ expectedRevision: initialRevision, rules: { commandsEnabled: false } }), /版本冲突/)
|
||||
check('stale policy compare-and-swap is rejected', (await readSandboxPolicy()).defaults.commandsEnabled)
|
||||
await update({ commandsEnabled: false, deniedPaths: ['workspace-only'], limits: { files: 10 } }, workspace)
|
||||
check('workspace policy applies only to its canonical workspace', !(await effectiveSandboxPolicy(workspace)).commandsEnabled && (await effectiveSandboxPolicy(other)).commandsEnabled)
|
||||
await update({ commandsEnabled: true, deniedPaths: [] }, workspace)
|
||||
check('workspace overrides cannot remove global path restrictions', (await effectiveSandboxPolicy(workspace)).deniedPaths.includes('private'))
|
||||
check('workspace limits cannot widen global limits', (await effectiveSandboxPolicy(workspace)).limits.files === 10)
|
||||
let tool = await executeTool('read_file', { path: 'private/file.txt' }, context)
|
||||
check('host file reads enforce the effective denied-path policy', !tool.ok && !tool.content.includes('private-dummy'))
|
||||
tool = await executeTool('write_file', { path: 'private/file.txt', content: 'unsafe' }, context)
|
||||
check('host writes cannot bypass denied paths', !tool.ok && await fs.readFile(join(workspace, 'private/file.txt'), 'utf8') === 'private-dummy')
|
||||
await update({ limits: { files: 500 } }, workspace)
|
||||
const targets = digest(Buffer.from('target-and-content'))
|
||||
let policy = await effectiveSandboxPolicy(workspace)
|
||||
let ticket = await authorizeSandboxWrite(policy, 'approve', context, targets)
|
||||
assert.ok(ticket)
|
||||
await assert.rejects(ticket.consume(digest(Buffer.from('another-target'))), /目标或内容/)
|
||||
await ticket.consume(targets)
|
||||
await assert.rejects(ticket.consume(targets), /已使用/)
|
||||
check('authorization binds the exact operation and is single use', true)
|
||||
ticket = await authorizeSandboxWrite(policy, 'approve', context, targets)
|
||||
context.toolCallId = 'another-call'
|
||||
await assert.rejects(ticket!.consume(targets), /归属/)
|
||||
context.toolCallId = 'policy-tool'
|
||||
check('authorization rejects changed call ownership', true)
|
||||
const substitutedOwner = { ...context, approve: async () => { substitutedOwner.toolCallId = 'substituted-during-approval'; return true } }
|
||||
await assert.rejects(authorizeSandboxWrite(policy, 'approve', substitutedOwner, targets), /归属/)
|
||||
check('approval captures ownership before waiting for the user', true)
|
||||
const substitutedWorkspace = { ...context, approve: async () => { substitutedWorkspace.workspaceRoot = other; return true } }
|
||||
await assert.rejects(authorizeSandboxWrite(policy, 'approve', substitutedWorkspace, targets), /归属/)
|
||||
check('approval cannot migrate to a different workspace while pending', true)
|
||||
const abort = new AbortController()
|
||||
await assert.rejects(authorizeSandboxWrite(policy, 'approve', { ...context, signal: abort.signal, approve: async () => { abort.abort(); return true } }, targets), /取消/)
|
||||
check('cancellation during approval never grants a ticket', true)
|
||||
const now = Date.now
|
||||
try {
|
||||
await assert.rejects(authorizeSandboxWrite(policy, 'approve', { ...context, approve: async () => { Date.now = () => now() + 11 * 60_000; return true } }, targets), /过期/)
|
||||
} finally { Date.now = now }
|
||||
check('an approval arriving after its deadline is rejected', true)
|
||||
ticket = await authorizeSandboxWrite(policy, 'approve', context, targets)
|
||||
await update({ maxChanges: 3 })
|
||||
await assert.rejects(ticket!.consume(targets), /策略已变化/)
|
||||
check('policy updates revoke existing authorization', true)
|
||||
policy = await effectiveSandboxPolicy(workspace)
|
||||
ticket = await authorizeSandboxWrite(policy, 'approve', context, targets)
|
||||
const unchangedRevisionBytes = await fs.readFile(join(stateRoot, 'policy.json'), 'utf8')
|
||||
const editedWithoutRevision = JSON.parse(unchangedRevisionBytes); editedWithoutRevision.defaults.commandsEnabled = false
|
||||
await fs.writeFile(join(stateRoot, 'policy.json'), JSON.stringify(editedWithoutRevision))
|
||||
await assert.rejects(ticket!.consume(targets), /策略已变化/)
|
||||
await fs.writeFile(join(stateRoot, 'policy.json'), unchangedRevisionBytes)
|
||||
await assert.rejects(ticket!.consume(targets), /过期|归属/)
|
||||
check('policy content changes without a new revision still revoke approval permanently', true)
|
||||
let approvals = 0
|
||||
tool = await executeTool('write_file', { path: 'sample.txt', content: 'after' }, { ...context, approve: async () => { approvals++; await update({ maxChanges: 4 }); return true } })
|
||||
check('a policy update while the user approves a host write prevents mutation', !tool.ok && approvals === 1 && await fs.readFile(join(workspace, 'sample.txt'), 'utf8') === 'before')
|
||||
const args = { path: 'sample.txt', content: 'approved-content' }
|
||||
tool = await executeTool('write_file', args, { ...context, approve: async () => { args.content = 'substituted'; return true } })
|
||||
check('approved host content cannot be substituted by mutating the request', tool.ok && await fs.readFile(join(workspace, 'sample.txt'), 'utf8') === 'approved-content')
|
||||
const transactionId = (tool.data as { transactionId: string }).transactionId
|
||||
const transaction = JSON.parse(await fs.readFile(join(stateRoot, 'transactions', transactionId, 'manifest.json'), 'utf8'))
|
||||
check('transaction persists policy, authorization and session identity', transaction.identity.sessionId === context.sessionId && transaction.identity.policyRevision === document.revision && !!transaction.identity.authorizationId)
|
||||
await restoreTransaction(transactionId, workspace)
|
||||
await update({ writeback: 'deny' })
|
||||
approvals = 0
|
||||
tool = await executeTool('write_file', { path: 'sample.txt', content: 'denied' }, { ...context, approve: async () => { approvals++; return true } })
|
||||
check('deny-writeback policy applies before prompting', !tool.ok && approvals === 0 && await fs.readFile(join(workspace, 'sample.txt'), 'utf8') === 'before')
|
||||
await update({ writeback: 'ask' })
|
||||
if (process.platform !== 'win32') throw new Error('This suite includes real Windows native checks; unsupported hosts cannot report a pass')
|
||||
let result = await executeSandboxCommand('type private\\file.txt', context)
|
||||
check('command snapshots enforce the same denied paths as host tools', !result.ok && !result.data.stdout.includes('private-dummy'))
|
||||
await assert.rejects(executeSandboxCommand('mkdir private & echo denied>private\\new.txt', context), /受保护路径|策略/)
|
||||
check('command-generated denied paths cannot reach the host', !await fs.stat(join(workspace, 'private/new.txt')).catch(() => null))
|
||||
await update({ commandsEnabled: false })
|
||||
await assert.rejects(executeSandboxCommand('echo unsafe>disabled.txt', context), /禁用/)
|
||||
check('command-disable policy is enforced by the native route', !await fs.stat(join(workspace, 'disabled.txt')).catch(() => null))
|
||||
await update({ commandsEnabled: true, limits: { fileBytes: 4 } })
|
||||
await assert.rejects(executeSandboxCommand('echo unsafe>size.txt', context), /上限/)
|
||||
check('configured snapshot byte limits are enforced before execution', !await fs.stat(join(workspace, 'size.txt')).catch(() => null))
|
||||
await update({ limits: { fileBytes: 256 * 1024 * 1024 } })
|
||||
result = await executeSandboxCommand('echo approved>sample.txt', context)
|
||||
check('native writeback records policy and authorization identities', result.ok && result.data.policyRevision === document.revision && !!result.data.authorizationId)
|
||||
const operation = await sandboxOperation(result.data.runId)
|
||||
check('host commit outcome can be queried independently of the chat response', operation.writeback === 'committed' && operation.transactionId === result.data.transactionId && operation.identity.sessionId === context.sessionId)
|
||||
await assert.rejects(sandboxOperation('../outside'), /标识无效/)
|
||||
check('operation queries reject path traversal identities', true)
|
||||
await restoreTransaction(result.data.transactionId!, workspace)
|
||||
const selfTest = await runSandboxSelfTest()
|
||||
check('self-test persists a binary and policy-bound native observation', selfTest.passed && (await sandboxStatus()).selfTest?.runId === selfTest.runId)
|
||||
const beforeManualEdit = await fs.readFile(join(stateRoot, 'policy.json'), 'utf8')
|
||||
const modified = JSON.parse(beforeManualEdit); modified.defaults.maxChanges = 9
|
||||
await fs.writeFile(join(stateRoot, 'policy.json'), JSON.stringify(modified))
|
||||
check('same-revision manual policy edits invalidate a self-test observation', !(await sandboxStatus()).selfTest)
|
||||
await fs.writeFile(join(stateRoot, 'policy.json'), beforeManualEdit)
|
||||
await update({ maxChanges: 2 })
|
||||
check('policy changes invalidate an old self-test observation', !(await sandboxStatus()).selfTest)
|
||||
policy = await effectiveSandboxPolicy(workspace)
|
||||
const liveCommand = executeSandboxCommand('powershell.exe -NoProfile -NonInteractive -Command "Start-Sleep -Seconds 10"', context)
|
||||
const cancelByPolicy = setTimeout(() => { void update({ commandsEnabled: false }).catch(() => undefined) }, 900)
|
||||
await assert.rejects(liveCommand, /策略已变化/)
|
||||
clearTimeout(cancelByPolicy)
|
||||
check('an external policy revision stops an active native process', true)
|
||||
await update({ commandsEnabled: true })
|
||||
const approvalController = new AbortController(), events: ChatEvent[] = []
|
||||
let pendingReady!: (id: string) => void
|
||||
const ready = new Promise<string>(done => { pendingReady = done })
|
||||
const emit = (event: ChatEvent): void => { events.push(event); if (event.type === 'interaction' && event.interaction.status === 'pending') pendingReady(event.interaction.id) }
|
||||
const broker = createInteractionBroker(context.runId, approvalController.signal, emit)
|
||||
const pendingWrite = executeTool('write_file', { path: 'revoked.txt', content: 'must-not-write' }, { ...context, signal: approvalController.signal,
|
||||
approve: async (title, cancellation) => (await broker.approval(context.toolCallId, title, [{ optionId: 'allow-once', name: 'allow', kind: 'allow_once' }], undefined, cancellation)).optionId === 'allow-once' })
|
||||
const interactionId = await ready
|
||||
await update({ maxChanges: 3 })
|
||||
const revokedWrite = await pendingWrite
|
||||
check('policy revocation cancels the actual pending conversation interaction', !revokedWrite.ok && events.some(event => event.type === 'interaction' && event.interaction.id === interactionId && event.interaction.status === 'cancelled'))
|
||||
check('a revoked interaction cannot accept a later approval response', !respondToInteraction(context.runId, interactionId, { kind: 'approval', optionId: 'allow-once' }, emit) && !await fs.stat(join(workspace, 'revoked.txt')).catch(() => null))
|
||||
let releaseApproval!: () => void, approvalStarted!: () => void
|
||||
const waitApproval = new Promise<boolean>(done => { releaseApproval = () => done(true) }), approvalIsPending = new Promise<void>(done => { approvalStarted = done })
|
||||
const runCount = (await fs.readdir(join(stateRoot, 'runs'))).length
|
||||
const first = executeSandboxCommand('echo serialized-first>serialized.txt', { ...context, approve: async () => { approvalStarted(); return waitApproval } })
|
||||
await approvalIsPending
|
||||
const second = executeSandboxCommand('type serialized.txt', context)
|
||||
const queuedAbort = new AbortController()
|
||||
const third = executeSandboxCommand('echo should-not-run>queued-cancelled.txt', { ...context, signal: queuedAbort.signal })
|
||||
const thirdRejected = assert.rejects(third, /取消/)
|
||||
queuedAbort.abort(); await thirdRejected
|
||||
await new Promise(done => setTimeout(done, 50))
|
||||
check('same-workspace commands and cancelled queued requests do not start stale snapshots', (await fs.readdir(join(stateRoot, 'runs'))).length === runCount + 1)
|
||||
releaseApproval()
|
||||
const firstResult = await first, secondResult = await second
|
||||
check('a queued command observes the previous approved host commit', firstResult.ok && secondResult.ok && secondResult.data.stdout.includes('serialized-first') && !await fs.stat(join(workspace, 'queued-cancelled.txt')).catch(() => null))
|
||||
await restoreTransaction(firstResult.data.transactionId!, workspace)
|
||||
const original = await fs.readFile(join(stateRoot, 'policy.json'), 'utf8')
|
||||
await fs.writeFile(join(stateRoot, 'policy.json'), '{malformed')
|
||||
tool = await executeTool('execute_command', { command: 'echo unsafe>corrupt.txt' }, context)
|
||||
check('corrupt policy fails closed without host execution', !tool.ok && !await fs.stat(join(workspace, 'corrupt.txt')).catch(() => null))
|
||||
await fs.writeFile(join(stateRoot, 'policy.json'), original)
|
||||
await fs.writeFile(join(stateRoot, 'policy.lock'), 'existing-foreign-maintenance-lock')
|
||||
await assert.rejects(updateSandboxPolicy({ expectedRevision: document.revision, rules: {} }), /其他维护进程/)
|
||||
check('policy updates respect an existing cross-process maintenance lock', await fs.readFile(join(stateRoot, 'policy.lock'), 'utf8') === 'existing-foreign-maintenance-lock')
|
||||
await fs.unlink(join(stateRoot, 'policy.lock'))
|
||||
console.log(`SANDBOX_POLICY_PASS ${checks.length}/${checks.length}`)
|
||||
} finally {
|
||||
await fs.writeFile(join(fixture, 'report.json'), JSON.stringify({ checks, fixture, limitations: ['Trusted-process authorization contracts and real Windows command integration; child IPC authentication and original WorkBuddy parity remain separate.'] }, null, 2) + '\n')
|
||||
console.log(`SANDBOX_POLICY_REPORT ${join(fixture, 'report.json')}`)
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { promises as fs } from 'node:fs'
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { configureSandbox } from '../src/main/sandbox/configuration'
|
||||
import { importSandboxToolchain } from '../src/main/sandbox/toolchains'
|
||||
import { executeSandboxCommand, sandboxStatus } from '../src/main/sandbox/service'
|
||||
|
||||
await fs.mkdir(resolve('.tmp'), { recursive: true })
|
||||
const fixture = await fs.mkdtemp(resolve('.tmp/sandbox-process-')), workspace = join(fixture, 'workspace')
|
||||
await fs.mkdir(workspace)
|
||||
const outside = join(fixture, 'outside.txt'); await fs.writeFile(outside, 'OUTSIDE_FIXTURE')
|
||||
configureSandbox({ stateRoot: join(fixture, 'state'), resourceDirectory: resolve('resources/sandbox/win32-x64') })
|
||||
const checks: Array<{ label: string; pass: boolean }> = []
|
||||
function check(label: string, condition: unknown): void { checks.push({ label, pass: !!condition }); assert.ok(condition, label); console.log(`PASS ${label}`) }
|
||||
const context = { workspaceRoot: workspace, runtimes: ['node'], timeoutMs: 10_000 }
|
||||
const source = String.raw`
|
||||
const assert=require('node:assert/strict'),fs=require('node:fs'),os=require('node:os'),path=require('node:path'),cp=require('node:child_process');
|
||||
const labels=[];function check(label,fn){fn();labels.push(label);console.log('PROCESS_CHECK '+label)}
|
||||
const scratch=fs.mkdtempSync(path.join(os.tmpdir(),'node-runtime-'));
|
||||
try{
|
||||
check('private-temp',()=>{fs.writeFileSync(path.join(scratch,'x.txt'),'private');assert.equal(fs.readFileSync(path.join(scratch,'x.txt'),'utf8'),'private');fs.unlinkSync(path.join(scratch,'x.txt'))});
|
||||
check('binary-stdout-stderr',()=>{const r=cp.spawnSync(process.execPath,['-e','process.stdout.write(Buffer.from([0,255,1,2]));process.stderr.write("diagnostic")']);assert.equal(r.status,0);assert.deepEqual(r.stdout,Buffer.from([0,255,1,2]));assert.equal(r.stderr.toString(),'diagnostic')});
|
||||
check('stdin',()=>{const r=cp.spawnSync(process.execPath,['-e','process.stdin.on("data",x=>process.stdout.write(x))'],{input:Buffer.from([0,255,19])});assert.equal(r.status,0);assert.deepEqual(r.stdout,Buffer.from([0,255,19]))});
|
||||
check('cwd-and-env',()=>{const r=cp.spawnSync(process.execPath,['-e','process.stdout.write(process.cwd()+"|"+process.env.YCODE_FIXTURE)'],{cwd:scratch,env:{...process.env,YCODE_FIXTURE:'private-value'},encoding:'utf8'});assert.equal(r.status,0);assert.equal(r.stdout,scratch+'|private-value')});
|
||||
check('exec-file',()=>assert.equal(cp.execFileSync(process.execPath,['-e','process.stdout.write("file-result")'],{encoding:'utf8'}),'file-result'));
|
||||
check('exec-shell',()=>assert.equal(cp.execSync('echo shell-result',{encoding:'utf8'}).trim(),'shell-result'));
|
||||
check('omitted-args-options',()=>assert.equal(cp.execFileSync(process.execPath,undefined,{input:'process.stdout.write("stdin-script")',encoding:'utf8'}),'stdin-script'));
|
||||
check('nonzero-status',()=>{assert.throws(()=>cp.execFileSync(process.execPath,['-e','process.stderr.write("expected-error");process.exit(7)'],{encoding:'utf8'}),e=>e.status===7&&e.stderr==='expected-error')});
|
||||
check('missing-executable',()=>{const r=cp.spawnSync('definitely-missing-ycode-program.exe');assert.equal(r.error.code,'ENOENT');assert.equal(r.status,null)});
|
||||
check('timeout',()=>{const r=cp.spawnSync(process.execPath,['-e','setTimeout(()=>console.log("late"),3000)'],{timeout:150,encoding:'utf8'});assert.equal(r.error.code,'ETIMEDOUT');assert.ok(!r.stdout.includes('late'))});
|
||||
check('max-buffer',()=>{const r=cp.spawnSync(process.execPath,['-e','process.stdout.write("x".repeat(2048))'],{maxBuffer:128,encoding:'utf8'});assert.equal(r.error.code,'ENOBUFS');assert.equal(r.stdout.length,128)});
|
||||
check('inherited-stdio',()=>assert.equal(cp.spawnSync(process.execPath,['-e','console.log("INHERITED_STDIO_PASS")'],{stdio:'inherit'}).status,0));
|
||||
check('esm-exports',()=>{const r=cp.spawnSync(process.execPath,['--require',process.env.YCODE_SANDBOX_PROCESS,'--input-type=module','-e','import {execSync} from "node:child_process";console.log(execSync("echo ESM_CAPTURE_PASS",{encoding:"utf8"}).trim())'],{encoding:'utf8'});assert.equal(r.status,0);assert.ok(r.stdout.includes('ESM_CAPTURE_PASS'))});
|
||||
check('adapter-readonly',()=>{assert.throws(()=>fs.appendFileSync(process.env.YCODE_SANDBOX_PROCESS,'x'),e=>['EACCES','EPERM'].includes(e.code))});
|
||||
check('host-boundary',()=>{const r=cp.spawnSync(process.execPath,['-e',__HOST_PROBE__],{encoding:'utf8'});assert.equal(r.status,0);assert.equal(r.stdout,'HOST_DENIED')});
|
||||
check('scratch-cleanup',()=>assert.deepEqual(fs.readdirSync(os.tmpdir()).filter(name=>name.startsWith('ycode-process-')),[]));
|
||||
console.log('PROCESS_LABELS '+JSON.stringify(labels));
|
||||
}finally{fs.rmdirSync(scratch)}
|
||||
`
|
||||
try {
|
||||
await importSandboxToolchain('node', await fs.realpath(process.execPath))
|
||||
const hostProbe = `try{require('fs').readFileSync(${JSON.stringify(outside)});process.exit(9)}catch(e){if(!['EACCES','EPERM'].includes(e.code))throw e;process.stdout.write('HOST_DENIED')}`
|
||||
await fs.writeFile(join(workspace, 'process.cjs'), source.replace('__HOST_PROBE__', JSON.stringify(hostProbe)))
|
||||
const result = await executeSandboxCommand('node --require "%YCODE_SANDBOX_PROCESS%" process.cjs', context)
|
||||
assert.ok(result.ok, result.content)
|
||||
const labels = JSON.parse(result.data.stdout.split(/\r?\n/).find(line => line.startsWith('PROCESS_LABELS '))!.slice(15)) as string[]
|
||||
for (const label of labels) check(`actual AppContainer Node ${label}`, true)
|
||||
check('process adapter creates no host writeback or network authority', result.data.writeback === 'unchanged' && result.data.profileRemoved && result.data.driveRemoved)
|
||||
const pythonSource = execFileSync('where.exe', ['python'], { encoding: 'utf8', windowsHide: true }).trim().split(/\r?\n/)[0]
|
||||
await importSandboxToolchain('python', await fs.realpath(pythonSource))
|
||||
const outsideDirectory = join(fixture, 'outside-directory')
|
||||
await fs.writeFile(join(workspace, 'temp.py'), [
|
||||
'import tempfile, pathlib, subprocess, sys, os, sitecustomize',
|
||||
'with tempfile.TemporaryDirectory() as directory:',
|
||||
' path = pathlib.Path(directory) / "data.txt"',
|
||||
' path.write_text("private-python", encoding="utf-8")',
|
||||
' assert path.read_text() == "private-python"',
|
||||
' result = subprocess.run([sys.executable, "-c", "print(43)"], capture_output=True, text=True, timeout=3)',
|
||||
' assert result.returncode == 0 and result.stdout.strip() == "43"',
|
||||
' private = pathlib.Path(directory) / "mode700"',
|
||||
' os.mkdir(private, 0o700)',
|
||||
' (private / "child.txt").write_text("accessible")',
|
||||
' assert (private / "child.txt").read_text() == "accessible"',
|
||||
`try: os.mkdir(${JSON.stringify(outsideDirectory)}, 0o700)`,
|
||||
'except PermissionError: pass',
|
||||
'else: raise AssertionError("outside directory unexpectedly created")',
|
||||
'try: pathlib.Path(sitecustomize.__file__).write_text("tamper")',
|
||||
'except PermissionError: pass',
|
||||
'else: raise AssertionError("adapter unexpectedly writable")',
|
||||
'print("PYTHON_TEMP_CAPTURE_PASS")', ''
|
||||
].join('\n'))
|
||||
const python = await executeSandboxCommand('python temp.py', { ...context, runtimes: ['python'] })
|
||||
assert.ok(python.ok, python.content)
|
||||
check('actual Python private tempfile and anonymous-pipe subprocess capture work', python.ok && python.data.stdout.includes('PYTHON_TEMP_CAPTURE_PASS') && python.data.writeback === 'unchanged')
|
||||
check('Python mode700 compatibility retains native host denial and read-only adapter', !await fs.stat(outsideDirectory).catch(() => null))
|
||||
const components = join(fixture, 'components')
|
||||
await fs.mkdir(components)
|
||||
for (const name of ['ycode-sandbox.exe', 'manifest.json', 'node-bridge.cjs', 'node-process.cjs', 'python-sitecustomize.py']) await fs.copyFile(resolve('resources/sandbox/win32-x64', name), join(components, name))
|
||||
configureSandbox({ stateRoot: join(fixture, 'state'), resourceDirectory: components })
|
||||
assert.equal((await sandboxStatus()).state, 'installed')
|
||||
for (const name of ['node-bridge.cjs', 'node-process.cjs', 'python-sitecustomize.py']) {
|
||||
const original = await fs.readFile(join(components, name))
|
||||
await fs.appendFile(join(components, name), 'tamper')
|
||||
assert.equal((await sandboxStatus()).state, 'unavailable')
|
||||
await assert.rejects(executeSandboxCommand('echo MUST_NOT_RUN', context), /摘要/)
|
||||
await fs.writeFile(join(components, name), original)
|
||||
}
|
||||
check('a tampered shipped adapter invalidates installed status and fails commands closed', true)
|
||||
console.log(`SANDBOX_PROCESS_PASS ${checks.length}/${checks.length}`)
|
||||
} finally {
|
||||
await fs.writeFile(join(fixture, 'report.json'), JSON.stringify({ fixture, checks, scope: 'Actual AppContainer Node explicit synchronous file-stdio adapter and Python temporary-directory/capture regression. Default Node pipes and asynchronous spawn/fork compatibility remain unverified/unsupported.' }, null, 2) + '\n')
|
||||
console.log(`SANDBOX_PROCESS_REPORT ${join(fixture, 'report.json')}`)
|
||||
}
|
||||
@@ -1,150 +0,0 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { promises as fs } from 'node:fs'
|
||||
import { randomUUID, createHash } from 'node:crypto'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { configureSandbox } from '../src/main/sandbox/configuration'
|
||||
import { NativeWorkspaceReader } from '../src/main/sandbox/filesystem-reader'
|
||||
import { executeSandboxCommand, verifiedBinary } from '../src/main/sandbox/service'
|
||||
import { effectiveSandboxPolicy, readSandboxPolicy, updateSandboxPolicy } from '../src/main/sandbox/policy-store'
|
||||
import { importSandboxToolchain } from '../src/main/sandbox/toolchains'
|
||||
import { sandboxBrokerOperation } from '../src/main/sandbox/maintenance'
|
||||
import { executeTool } from '../src/main/agent/tools'
|
||||
|
||||
if (process.platform !== 'win32') throw new Error('Read bridge regression requires actual Windows/NTFS/AppContainer execution')
|
||||
await fs.mkdir(resolve('.tmp'), { recursive: true })
|
||||
const fixture = await fs.mkdtemp(resolve('.tmp/sandbox-read-bridge-')), workspace = join(fixture, 'workspace'), stateRoot = join(fixture, 'state')
|
||||
await fs.mkdir(workspace); await fs.mkdir(join(fixture, 'outside'))
|
||||
const outside = join(fixture, 'outside', 'host.txt')
|
||||
await fs.writeFile(outside, 'OUTSIDE_MUST_NEVER_BE_RETURNED')
|
||||
configureSandbox({ stateRoot, resourceDirectory: resolve('resources/sandbox/win32-x64') })
|
||||
const bytes = Buffer.alloc(83_171); for (let index = 0; index < bytes.length; index++) bytes[index] = index % 251
|
||||
const expectedHash = createHash('sha256').update(bytes).digest('hex')
|
||||
await fs.writeFile(join(workspace, 'data.bin'), bytes)
|
||||
await fs.writeFile(join(workspace, '.env'), 'SECRET_MUST_NOT_BE_LISTED_OR_RETURNED')
|
||||
await fs.mkdir(join(workspace, '\u76ee\u5f55 with spaces')); await fs.writeFile(join(workspace, '\u76ee\u5f55 with spaces', 'text.txt'), 'hello-unicode-path')
|
||||
const checks: Array<{ label: string; pass: boolean }> = []
|
||||
function check(label: string, condition: unknown): void { checks.push({ label, pass: !!condition }); assert.ok(condition, label); console.log(`PASS ${label}`) }
|
||||
const pause = (ms: number): Promise<void> => new Promise(done => setTimeout(done, ms))
|
||||
const context = { workspaceRoot: workspace, runId: 'host-bound-run', toolCallId: 'host-bound-tool', sessionId: 'host-bound-session', runtimes: ['node'], readBridge: true, timeoutMs: 10_000 }
|
||||
let caseId = 0
|
||||
async function run(source: string) {
|
||||
const name = `case-${++caseId}.cjs`
|
||||
await fs.writeFile(join(workspace, name), source)
|
||||
return executeSandboxCommand(`node ${name}`, context)
|
||||
}
|
||||
const load = "const assert=require('node:assert/strict');const mod=require(process.env.YCODE_SANDBOX_BRIDGE);"
|
||||
try {
|
||||
const policy = await effectiveSandboxPolicy(workspace), binary = await verifiedBinary(), reader = await NativeWorkspaceReader.create(binary, policy)
|
||||
let result = await reader.read('read', 'data.bin')
|
||||
check('native pinned file reads preserve exact binary bytes and root identity', Buffer.from(result.data!, 'base64').equals(bytes) && result.rootIdentity === reader.rootIdentity)
|
||||
result = await reader.read('read', '\u76ee\u5f55 with spaces/text.txt')
|
||||
check('native canonical paths support Unicode and spaces', Buffer.from(result.data!, 'base64').toString() === 'hello-unicode-path')
|
||||
result = await reader.read('list', '.')
|
||||
check('directory enumeration uses its held native handle and filters protected names', result.entries!.some(entry => entry.name === 'data.bin') && !result.entries!.some(entry => entry.name === '.env'))
|
||||
for (const path of ['.env', '../outside/host.txt', outside, 'data.bin:stream', '\\\\localhost\\C$\\host.txt', '\\\\?\\C:\\host.txt', 'CON', 'data.bin.', 'data.bin ', '.git/config']) await assert.rejects(reader.read('read', path))
|
||||
check('credentials, traversal, absolute/device/UNC/ADS and ambiguous names are denied', true)
|
||||
await fs.symlink(join(fixture, 'outside'), join(workspace, 'junction'), 'junction')
|
||||
await assert.rejects(reader.read('read', 'junction/host.txt'))
|
||||
await fs.unlink(join(workspace, 'junction'))
|
||||
await fs.link(outside, join(workspace, 'hard.txt')); await assert.rejects(reader.read('read', 'hard.txt')); await fs.unlink(join(workspace, 'hard.txt'))
|
||||
check('native reads reject junction escape and multiple hard links', true)
|
||||
const writer = await fs.open(join(workspace, 'data.bin'), 'r+')
|
||||
try { await assert.rejects(reader.read('read', 'data.bin'), (error: any) => error.code === 'EBUSY') }
|
||||
finally { await writer.close() }
|
||||
check('an already-open writable handle prevents a read lock rather than weakening sharing', true)
|
||||
const replacement = join(fixture, 'replacement'), original = join(fixture, 'original')
|
||||
await fs.mkdir(replacement); await fs.writeFile(join(replacement, 'data.bin'), 'different-workspace')
|
||||
await fs.rename(workspace, original); await fs.rename(replacement, workspace)
|
||||
try { await assert.rejects(reader.read('read', 'data.bin'), (error: any) => error.code === 'ESTALE') }
|
||||
finally { await fs.rename(workspace, replacement); await fs.rename(original, workspace) }
|
||||
check('replacing the workspace root invalidates the native identity even at the same path', true)
|
||||
await fs.mkdir(join(workspace, 'swap')); await fs.writeFile(join(workspace, 'swap/good.txt'), 'inside')
|
||||
await fs.writeFile(join(fixture, 'outside/good.txt'), 'OUTSIDE_MUST_NEVER_BE_RETURNED')
|
||||
let attacking = true, swaps = 0
|
||||
async function retry(action: () => Promise<void>): Promise<void> {
|
||||
for (let attempt = 0; ; attempt++) {
|
||||
try { await action(); return }
|
||||
catch (error) { if (attempt >= 500 || !['EPERM', 'EACCES', 'EBUSY'].includes((error as NodeJS.ErrnoException).code!)) throw error; await pause(2) }
|
||||
}
|
||||
}
|
||||
const attacker = (async () => {
|
||||
while (attacking) {
|
||||
let moved = false
|
||||
try {
|
||||
await fs.rename(join(workspace, 'swap'), join(workspace, 'swap-held'))
|
||||
moved = true
|
||||
await fs.symlink(join(fixture, 'outside'), join(workspace, 'swap'), 'junction'); swaps++; await pause(1)
|
||||
} catch (error) { if (!['EPERM', 'EACCES', 'EBUSY'].includes((error as NodeJS.ErrnoException).code!)) throw error }
|
||||
finally {
|
||||
// Windows may leave an empty directory when setting the junction races our read lock.
|
||||
if (moved) await retry(async () => {
|
||||
const stat = await fs.lstat(join(workspace, 'swap')).catch(error => { if (error.code === 'ENOENT') return null; throw error })
|
||||
if (stat?.isSymbolicLink()) await fs.unlink(join(workspace, 'swap'))
|
||||
else if (stat) await fs.rmdir(join(workspace, 'swap'))
|
||||
})
|
||||
if (moved) await retry(() => fs.rename(join(workspace, 'swap-held'), join(workspace, 'swap')))
|
||||
}
|
||||
await pause(1)
|
||||
}
|
||||
})()
|
||||
try {
|
||||
for (let index = 0; index < 40; index++) {
|
||||
try { const read = await reader.read('read', 'swap/good.txt'); assert.equal(Buffer.from(read.data!, 'base64').toString(), 'inside') }
|
||||
catch (error) { if (error instanceof assert.AssertionError) throw error; assert.ok(['ENOENT', 'EACCES', 'EBUSY'].includes((error as any).code)) }
|
||||
}
|
||||
} finally { attacking = false; await attacker }
|
||||
check('concurrent ancestor replacement with a real junction never returns outside bytes', swaps > 0)
|
||||
await importSandboxToolchain('node', await fs.realpath(process.execPath))
|
||||
await fs.copyFile(binary, join(workspace, 'host-reader.exe'))
|
||||
const sync = await run(load + `const c=mod.connectSync(); process.stdout.write('NORMAL_NO_NEWLINE');const b=c.readFileSync('data.bin');assert.equal(require('crypto').createHash('sha256').update(b).digest('hex'),${JSON.stringify(expectedHash)});assert.equal(c.statSync('data.bin').kind,'file');assert.ok(c.readdirSync('.').some(e=>e.name==='data.bin'));assert.throws(()=>c.readFileSync('.env'),{code:'EACCES'});assert.throws(()=>c.call('workspace.writeFile',{path:'data.bin'}),{code:'EACCES'});assert.throws(()=>c.call('workspace.stat',{path:'data.bin',workspaceRoot:'fake'}),{code:'EACCES'});c.close();console.log('SYNC_BRIDGE_PASS')`)
|
||||
check('real AppContainer Node sync client reads multiple binary chunks with backpressure', sync.ok && sync.data.stdout.includes('SYNC_BRIDGE_PASS') && sync.data.stdout.includes('NORMAL_NO_NEWLINE') && sync.data.writeback === 'unchanged')
|
||||
check('host writes remain unavailable over the read bridge', (await fs.readFile(join(workspace, 'data.bin'))).equals(bytes))
|
||||
const nested = await run(load + `const result=require('child_process').spawnSync('.\\\\host-reader.exe',['--workspace-read','identity',${JSON.stringify(workspace)},'.','-','1024'],{stdio:'inherit'});assert.equal(result.status,125);try{require('fs').readFileSync(${JSON.stringify(outside)});throw Error('host path unexpectedly readable')}catch(e){assert.ok(['EACCES','EPERM'].includes(e.code))}console.log('NATIVE_HOST_ONLY_PASS')`)
|
||||
check('a descendant cannot invoke the host-only reader or use ordinary fs to bypass the bridge', nested.ok && nested.data.stdout.includes('NATIVE_HOST_ONLY_PASS') && nested.data.stderr.includes('host-only'))
|
||||
const queryId = randomUUID()
|
||||
const asynchronous = await run(load + `(async()=>{const c=mod.connect();try{const results=await Promise.all(Array.from({length:12},()=>c.stat('data.bin')));assert.ok(results.every(r=>r.kind==='file'));const b=await c.readFile('data.bin');assert.equal(require('crypto').createHash('sha256').update(b).digest('hex'),${JSON.stringify(expectedHash)});await c.call('workspace.stat',{path:'data.bin'},${JSON.stringify(queryId)});assert.equal((await c.query(${JSON.stringify(queryId)})).state,'succeeded');await assert.rejects(c.query(${JSON.stringify(randomUUID())}),{code:'ENOENT'});console.log('ASYNC_BRIDGE_PASS')}finally{c.close()}})().catch(e=>{console.error(e);process.exitCode=1})`)
|
||||
check('async Node client queues requests and supports result queries within the bound Job', asynchronous.ok && asynchronous.data.stdout.includes('ASYNC_BRIDGE_PASS'))
|
||||
const operation = await sandboxBrokerOperation(asynchronous.data.runId, queryId)
|
||||
check('durable result remains queryable after disconnect with exact host-bound ownership', operation.state === 'succeeded' && operation.owner.sessionId === context.sessionId && operation.owner.runId === context.runId && operation.owner.sandboxRunId === asynchronous.data.runId)
|
||||
const journal = await fs.readFile(join(stateRoot, 'runs', asynchronous.data.runId, 'broker', `${queryId}.json`), 'utf8')
|
||||
check('operation journals retain metadata without contents or live read tokens', !journal.includes('"data"') && !journal.includes('snapshotId'))
|
||||
await assert.rejects(sandboxBrokerOperation(sync.data.runId, queryId))
|
||||
check('operation query cannot substitute an operation from another Job', true)
|
||||
const frozenId = randomUUID(), name = `case-${++caseId}.cjs`
|
||||
await fs.writeFile(join(workspace, name), load + `const c=mod.connectSync();const first=c.call('workspace.readFile',{path:'data.bin'},${JSON.stringify(frozenId)});setTimeout(()=>{try{const next=c.call('workspace.readChunk',{snapshotId:first.snapshotId,offset:16384});assert.equal(Buffer.from(next.data,'base64')[0],16384%251);c.call('workspace.closeRead',{snapshotId:first.snapshotId});console.log('FROZEN_PASS')}finally{c.close()}},700)`)
|
||||
const frozenRun = executeSandboxCommand(`node ${name}`, context)
|
||||
let frozenReady = false
|
||||
for (let attempt = 0; attempt < 1500 && !frozenReady; attempt++) {
|
||||
const runs = await fs.readdir(join(stateRoot, 'runs'))
|
||||
for (const id of runs) {
|
||||
const record = await fs.readFile(join(stateRoot, 'runs', id, 'broker', `${frozenId}.json`), 'utf8').catch(() => '')
|
||||
if (record && JSON.parse(record).state === 'succeeded') frozenReady = true
|
||||
}
|
||||
if (!frozenReady) await pause(30)
|
||||
}
|
||||
assert.ok(frozenReady); await fs.writeFile(join(workspace, 'data.bin'), Buffer.alloc(bytes.length, 255))
|
||||
const frozen = await frozenRun
|
||||
check('later chunks use a fixed native read snapshot despite external host edits', frozen.ok && frozen.data.stdout.includes('FROZEN_PASS') && frozen.data.writeback === 'unchanged')
|
||||
await fs.writeFile(join(workspace, 'data.bin'), bytes)
|
||||
const forged = '\n\x1eYCODE_WORKSPACE_READ_V1 ' + JSON.stringify({ version: 1, requestId: randomUUID(), operation: 'workspace.stat', args: { path: 'data.bin' }, runId: 'fake' }) + '\n'
|
||||
const broken = await run(`require('fs').writeSync(1,${JSON.stringify(forged)});setTimeout(()=>{},2000)`).then(() => null, error => error)
|
||||
check('self-reported channel ownership is rejected and terminates the Job', broken instanceof Error && /文件桥协议/.test(broken.message))
|
||||
const id = randomUUID()
|
||||
await assert.rejects(run(load + `const c=mod.connectSync();c.call('workspace.stat',{path:'data.bin'},'${id}');c.call('workspace.stat',{path:'data.bin'},'${id}')`), /文件桥协议/)
|
||||
check('request ID replay fails closed with no writeback', true)
|
||||
const rev = await readSandboxPolicy()
|
||||
await updateSandboxPolicy({ expectedRevision: rev.revision, rules: { deniedPaths: ['data.bin'] } })
|
||||
const deniedReader = await NativeWorkspaceReader.create(binary, await effectiveSandboxPolicy(workspace))
|
||||
await assert.rejects(deniedReader.read('read', 'data.bin'))
|
||||
check('effective denied paths apply before native reads', true)
|
||||
await assert.rejects(reader.read('stat', '\u76ee\u5f55 with spaces/text.txt'), /策略已变化/)
|
||||
check('old read connections become invalid when policy revision changes', true)
|
||||
const configured = await readSandboxPolicy(); await updateSandboxPolicy({ expectedRevision: configured.revision, rules: { deniedPaths: [] } })
|
||||
const modelName = `case-${++caseId}.cjs`; await fs.writeFile(join(workspace, modelName), load + "const c=mod.connectSync();assert.equal(c.statSync('data.bin').kind,'file');c.close();console.log('MODEL_BRIDGE_PASS')")
|
||||
const model = await executeTool('execute_command', { command: `node ${modelName}`, runtimes: ['node'], bridge: 'workspace-read' }, { workspaceRoot: workspace, sessionId: context.sessionId })
|
||||
check('model-facing command explicitly selects the real read bridge', model.ok && model.content.includes('MODEL_BRIDGE_PASS'))
|
||||
console.log(`SANDBOX_READ_BRIDGE_PASS ${checks.length}/${checks.length}`)
|
||||
} finally {
|
||||
await fs.writeFile(join(fixture, 'report.json'), JSON.stringify({ fixture, checks, limitations: ['NTFS/local fixed volumes only; one stdio client per Job; read-only API, not per-descendant PID authentication, full fs shims, host write broker or WorkBuddy equivalence.'] }, null, 2) + '\n')
|
||||
console.log(`SANDBOX_READ_BRIDGE_REPORT ${join(fixture, 'report.json')}`)
|
||||
}
|
||||
@@ -1,98 +0,0 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { promises as fs } from 'node:fs'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { configureSandbox } from '../src/main/sandbox/configuration'
|
||||
import { NativeWorkspaceReader } from '../src/main/sandbox/filesystem-reader'
|
||||
import { WorkspaceReadBroker, READ_BRIDGE_PREFIX } from '../src/main/sandbox/operation-broker'
|
||||
import { effectiveSandboxPolicy, readSandboxPolicy, updateSandboxPolicy } from '../src/main/sandbox/policy-store'
|
||||
import { verifiedBinary } from '../src/main/sandbox/service'
|
||||
import { sandboxBrokerOperation } from '../src/main/sandbox/maintenance'
|
||||
|
||||
await fs.mkdir(resolve('.tmp'), { recursive: true })
|
||||
const fixture = await fs.mkdtemp(resolve('.tmp/sandbox-read-protocol-')), workspace = join(fixture, 'workspace'), stateRoot = join(fixture, 'state')
|
||||
await fs.mkdir(workspace); await fs.writeFile(join(workspace, 'data.txt'), 'pinned-workspace-data\n'.repeat(4000))
|
||||
configureSandbox({ stateRoot, resourceDirectory: resolve('resources/sandbox/win32-x64') })
|
||||
const policy = await effectiveSandboxPolicy(workspace), reader = await NativeWorkspaceReader.create(await verifiedBinary(), policy)
|
||||
const checks: Array<{ label: string; pass: boolean }> = []
|
||||
function check(label: string, condition: unknown): void { checks.push({ label, pass: !!condition }); assert.ok(condition, label); console.log(`PASS ${label}`) }
|
||||
async function harness(failReply = false) {
|
||||
const runId = randomUUID(), directory = join(stateRoot, 'runs', runId), replies: any[] = [], output: Buffer[] = []
|
||||
const identity = { sessionId: 'protocol-session', runId: 'protocol-run', toolCallId: 'protocol-call', policyRevision: policy.revision, policyFingerprint: policy.fingerprint }
|
||||
await fs.mkdir(join(directory, 'broker'), { recursive: true })
|
||||
await fs.writeFile(join(directory, 'request.json'), JSON.stringify({ protocol: 1, id: runId, workspace, identity, createdAt: Date.now() }))
|
||||
let stopped = false
|
||||
const broker = new WorkspaceReadBroker(reader, join(directory, 'broker'), { ...identity, sandboxRunId: runId }, bytes => output.push(bytes), async line => {
|
||||
if (failReply) throw new Error('simulated broken reply pipe')
|
||||
replies.push(JSON.parse(line))
|
||||
}, () => { stopped = true })
|
||||
function send(operation: string, args: Record<string, unknown>, requestId = randomUUID()): string {
|
||||
broker.accept(Buffer.from('\n' + READ_BRIDGE_PREFIX + JSON.stringify({ version: 1, requestId, operation, args }) + '\n'))
|
||||
return requestId
|
||||
}
|
||||
async function reply(id: string): Promise<any> {
|
||||
for (let attempt = 0; attempt < 300; attempt++) {
|
||||
const response = replies.find(item => item.requestId === id)
|
||||
if (response) return response
|
||||
if (broker.failure) throw broker.failure
|
||||
await new Promise(done => setTimeout(done, 10))
|
||||
}
|
||||
throw new Error('Timed out waiting for broker response')
|
||||
}
|
||||
return { runId, directory, broker, send, reply, replies, output, stopped: () => stopped }
|
||||
}
|
||||
try {
|
||||
let h = await harness()
|
||||
h.broker.accept(Buffer.from('ordinary-output'.repeat(30_000)))
|
||||
const request = { version: 1, requestId: randomUUID(), operation: 'workspace.stat', args: { path: 'data.txt' } }, frame = Buffer.from('\n' + READ_BRIDGE_PREFIX + JSON.stringify(request) + '\n')
|
||||
h.broker.accept(frame.subarray(0, 12)); h.broker.accept(frame.subarray(12))
|
||||
assert.ok((await h.reply(request.requestId)).ok)
|
||||
check('stream parser preserves long ordinary output and split control prefixes', Buffer.concat(h.output).toString() === 'ordinary-output'.repeat(30_000) + '\n')
|
||||
let snapshot = (await h.reply(h.send('workspace.readFile', { path: 'data.txt' }))).value
|
||||
const chunk = await h.reply(h.send('workspace.readChunk', { snapshotId: snapshot.snapshotId, offset: 16384 }))
|
||||
check('chunk replies stay bounded while preserving one immutable native read', chunk.ok && chunk.value.sha256 === snapshot.sha256 && Buffer.from(chunk.value.data, 'base64').length === 16384)
|
||||
const fake = await h.reply(h.send('workspace.readChunk', { snapshotId: randomUUID(), offset: 0 }))
|
||||
check('unknown or cross-connection read token is denied', !fake.ok && fake.error.code === 'ENOENT')
|
||||
await h.reply(h.send('workspace.closeRead', { snapshotId: snapshot.snapshotId }))
|
||||
for (let index = 0; index < 8; index++) await h.reply(h.send('workspace.readFile', { path: 'data.txt' }))
|
||||
const tooMany = await h.reply(h.send('workspace.readFile', { path: 'data.txt' }))
|
||||
check('open immutable read snapshots have an enforced count limit', !tooMany.ok && tooMany.error.code === 'EBUSY')
|
||||
const unknown = await h.reply(h.send('network.fetch', { url: 'http://127.0.0.1' }))
|
||||
check('network and host write operations have no dispatch implementation', !unknown.ok && unknown.error.code === 'EACCES')
|
||||
await h.broker.finish(true)
|
||||
h = await harness()
|
||||
h.broker.accept(Buffer.from('\n' + READ_BRIDGE_PREFIX + JSON.stringify({ ...request, runId: 'forged' }) + '\n'))
|
||||
await h.broker.finish(true)
|
||||
check('caller-supplied ownership cannot rebind the channel', h.stopped() && !!h.broker.failure)
|
||||
h = await harness(); h.broker.accept(Buffer.from('\n' + READ_BRIDGE_PREFIX + ' '.repeat(8193))); await h.broker.finish(true)
|
||||
check('oversized request frames fail closed before JSON dispatch', h.stopped() && !!h.broker.failure)
|
||||
h = await harness(); for (let index = 0; index < 9; index++) h.send('workspace.stat', { path: 'data.txt' }); await h.broker.finish(true)
|
||||
check('request floods cannot grow the native work queue beyond eight', h.stopped() && !!h.broker.failure && h.replies.length === 0)
|
||||
h = await harness(); const replayId = h.send('workspace.stat', { path: 'data.txt' }); await h.reply(replayId); h.send('workspace.stat', { path: 'data.txt' }, replayId); await h.broker.finish(true)
|
||||
check('duplicate request IDs terminate the channel instead of repeating operations', h.stopped() && !!h.broker.failure)
|
||||
h = await harness(); h.broker.accept(Buffer.from('\n' + READ_BRIDGE_PREFIX + '{')); await h.broker.finish(false)
|
||||
check('a truncated final control frame is a protocol failure', h.stopped() && !!h.broker.failure)
|
||||
h = await harness(); h.broker.accept(Buffer.from('\n' + READ_BRIDGE_PREFIX.slice(0, 12))); await h.broker.finish(false)
|
||||
check('a truncated protocol prefix cannot be silently discarded on normal exit', h.stopped() && !!h.broker.failure)
|
||||
h = await harness(); const cancelledId = h.send('workspace.readFile', { path: 'data.txt' }); await h.broker.finish(true)
|
||||
const cancelled = await sandboxBrokerOperation(h.runId, cancelledId)
|
||||
check('disconnect cancels pending work and persists cancellation without a reply', cancelled.state === 'cancelled' && h.replies.length === 0)
|
||||
h = await harness(true); const lostId = h.send('workspace.stat', { path: 'data.txt' })
|
||||
for (let attempt = 0; attempt < 300 && !h.stopped(); attempt++) await new Promise(done => setTimeout(done, 10))
|
||||
await h.broker.finish(true)
|
||||
const durable = await sandboxBrokerOperation(h.runId, lostId)
|
||||
check('lost reply after durable completion retains a queryable successful result', h.stopped() && durable.state === 'succeeded' && durable.result?.kind === 'file')
|
||||
const raw = JSON.parse(await fs.readFile(join(h.directory, 'broker', `${lostId}.json`), 'utf8'))
|
||||
raw.state = 'running'; delete raw.completedAt; delete raw.result
|
||||
await fs.writeFile(join(h.directory, 'broker', `${lostId}.json`), JSON.stringify(raw))
|
||||
check('an interrupted running record is queried as unknown rather than success', (await sandboxBrokerOperation(h.runId, lostId)).state === 'unknown')
|
||||
h = await harness(); snapshot = (await h.reply(h.send('workspace.readFile', { path: 'data.txt' }))).value
|
||||
const document = await readSandboxPolicy(); await updateSandboxPolicy({ expectedRevision: document.revision, rules: { commandsEnabled: false } })
|
||||
const revoked = await h.reply(h.send('workspace.readChunk', { snapshotId: snapshot.snapshotId, offset: 16384 }))
|
||||
check('policy revocation also blocks bytes cached before revocation', !revoked.ok && revoked.error.code === 'EACCES')
|
||||
await h.broker.finish(true)
|
||||
console.log(`SANDBOX_READ_PROTOCOL_PASS ${checks.length}/${checks.length}`)
|
||||
} finally {
|
||||
await fs.writeFile(join(fixture, 'report.json'), JSON.stringify({ fixture, checks, scope: 'Protocol fault harness with actual native workspace reader; process isolation is covered separately by read-bridge integration.' }, null, 2) + '\n')
|
||||
console.log(`SANDBOX_READ_PROTOCOL_REPORT ${join(fixture, 'report.json')}`)
|
||||
}
|
||||
@@ -1,191 +0,0 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { promises as fs } from 'node:fs'
|
||||
import { createServer } from 'node:net'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { configureSandbox } from '../src/main/sandbox/configuration'
|
||||
import { executeSandboxCommand, sandboxStatus } from '../src/main/sandbox/service'
|
||||
import { commitChanges, digest, restoreTransaction } from '../src/main/sandbox/transactions'
|
||||
import { executeTool } from '../src/main/agent/tools'
|
||||
import { releaseRunLease } from '../src/main/sandbox/maintenance'
|
||||
import { spawn } from 'node:child_process'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
|
||||
if (process.platform !== 'win32') throw new Error('Native sandbox regression must run on Windows; unsupported hosts must not report a pass')
|
||||
await fs.mkdir(resolve('.tmp'), { recursive: true })
|
||||
const fixture = await fs.mkdtemp(resolve('.tmp/sandbox-regression-'))
|
||||
const workspace = join(fixture, 'workspace with spaces'), outside = join(fixture, 'outside.txt')
|
||||
await fs.mkdir(workspace)
|
||||
await fs.writeFile(outside, 'outside-native-fixture')
|
||||
await fs.writeFile(join(workspace, 'sample.txt'), 'before')
|
||||
await fs.writeFile(join(workspace, '.env'), 'DUMMY_SECRET=not-a-real-secret')
|
||||
await fs.copyFile(resolve('resources/sandbox/win32-x64/sandbox-probe.exe'), join(workspace, 'sandbox-probe.exe'))
|
||||
const stateRoot = join(fixture, 'state'), resourceDirectory = resolve('resources/sandbox/win32-x64')
|
||||
configureSandbox({ stateRoot, resourceDirectory })
|
||||
const checks: Array<{ label: string; pass: boolean }> = []
|
||||
function check(label: string, condition: unknown): void {
|
||||
checks.push({ label, pass: !!condition })
|
||||
assert.ok(condition, label)
|
||||
console.log(`PASS ${label}`)
|
||||
}
|
||||
const context = { workspaceRoot: workspace, runId: 'test-run', toolCallId: 'test-tool', approve: async () => true }
|
||||
try {
|
||||
check('native binary and manifest identity are installed', (await sandboxStatus()).state === 'installed')
|
||||
process.env.YCODE_FAKE_SECRET = 'dummy-sensitive-fixture'
|
||||
let result = await executeSandboxCommand('sandbox-probe.exe --info', context)
|
||||
const initialAuditRunId = result.data.runId
|
||||
check('real child is an AppContainer in a Job and has no inherited dummy credential', result.ok && result.data.stdout.includes('"appContainer":true,"job":true,"secretInherited":false'))
|
||||
check('read-only command needs no writeback and native profile is removed', result.data.writeback === 'unchanged' && result.data.profileRemoved)
|
||||
check('temporary drive mapping is removed on normal completion', result.data.driveRemoved)
|
||||
const cleanup = JSON.parse(await fs.readFile(join(stateRoot, 'runs', result.data.runId, 'cleanup.json'), 'utf8'))
|
||||
check('completed scratch copy is removed while audit remains', cleanup.stageRemoved && !(await fs.stat(join(stateRoot, 'runs', result.data.runId, 'stage')).catch(() => null)))
|
||||
result = await executeSandboxCommand('dir /b', context)
|
||||
check('cmd directory enumeration works in mapped scratch drive', result.ok && result.data.stdout.includes('sample.txt'))
|
||||
result = await executeSandboxCommand('powershell.exe -NoProfile -NonInteractive -Command "Get-Content sample.txt; Get-ChildItem -Name"', context)
|
||||
check('PowerShell can read and enumerate the scratch workspace', result.ok && result.data.stdout.includes('before') && result.data.stdout.includes('sample.txt'))
|
||||
delete process.env.YCODE_FAKE_SECRET
|
||||
result = await executeSandboxCommand(`sandbox-probe.exe --read "${outside}"`, context)
|
||||
check('native process cannot read an ordinary host file outside the snapshot', !result.ok && result.data.stdout.includes('READ_DENIED'))
|
||||
result = await executeSandboxCommand(`sandbox-probe.exe --write "${outside}"`, context)
|
||||
check('native process cannot overwrite outside file', !result.ok && result.data.stdout.includes('WRITE_DENIED'))
|
||||
check('outside file remains byte-identical', await fs.readFile(outside, 'utf8') === 'outside-native-fixture')
|
||||
result = await executeSandboxCommand(`sandbox-probe.exe --spawn-write "${outside}"`, context)
|
||||
check('descendant process inherits isolation', !result.ok && result.data.stdout.includes('WRITE_DENIED'))
|
||||
result = await executeSandboxCommand('sandbox-probe.exe --breakaway escaped.txt', context)
|
||||
check('Job denies explicit process breakaway', !result.ok && result.data.stdout.includes('SPAWN_DENIED'))
|
||||
result = await executeSandboxCommand('type .env', context)
|
||||
check('dummy credential file is excluded from command snapshot', !result.ok && !result.data.stdout.includes('DUMMY_SECRET'))
|
||||
let approvals = 0
|
||||
result = await executeSandboxCommand('echo approved>sample.txt', { ...context, approve: async () => { approvals++; return true } })
|
||||
check('workspace changes are written back after exactly one approval', result.ok && approvals === 1 && result.data.writeback === 'committed')
|
||||
check('before/after identity and independent transaction are returned', result.data.changes[0]?.operation === 'update' && !!result.data.transactionId)
|
||||
check('approved native write reaches actual workspace', (await fs.readFile(join(workspace, 'sample.txt'), 'utf8')).trim() === 'approved')
|
||||
await restoreTransaction(result.data.transactionId!, workspace)
|
||||
await restoreTransaction(result.data.transactionId!, workspace)
|
||||
check('independent backup restores exact bytes and recovery is idempotent', await fs.readFile(join(workspace, 'sample.txt'), 'utf8') === 'before')
|
||||
result = await executeSandboxCommand('echo rejected>sample.txt', { ...context, approve: async () => false })
|
||||
check('rejected writeback reports failure without host mutation', !result.ok && result.data.writeback === 'rejected' && await fs.readFile(join(workspace, 'sample.txt'), 'utf8') === 'before')
|
||||
const conflict = await executeSandboxCommand('echo pending>sample.txt', { ...context, approve: async () => { await fs.writeFile(join(workspace, 'sample.txt'), 'external-change'); return true } }).then(() => null, error => error as Error)
|
||||
check('approval-time external edit causes a conflict', conflict instanceof Error)
|
||||
check('conflict does not overwrite external changes', await fs.readFile(join(workspace, 'sample.txt'), 'utf8') === 'external-change')
|
||||
await fs.writeFile(join(workspace, 'sample.txt'), 'before')
|
||||
result = await executeSandboxCommand('del sample.txt', context)
|
||||
check('delete is an approved backed-up transaction', result.ok && result.data.changes[0]?.operation === 'delete' && !(await fs.stat(join(workspace, 'sample.txt')).catch(() => null)))
|
||||
await restoreTransaction(result.data.transactionId!, workspace)
|
||||
check('deleted file restores from independent backup', await fs.readFile(join(workspace, 'sample.txt'), 'utf8') === 'before')
|
||||
result = await executeSandboxCommand('echo new>created.txt', context)
|
||||
check('new file is recorded as create', result.ok && result.data.changes.some(change => change.operation === 'create'))
|
||||
await restoreTransaction(result.data.transactionId!, workspace)
|
||||
check('restoring creation removes only the unchanged created file', !(await fs.stat(join(workspace, 'created.txt')).catch(() => null)))
|
||||
result = await executeSandboxCommand('echo uncommitted>sample.txt & exit /b 7', context)
|
||||
check('unsuccessful command never writes back', !result.ok && result.data.exitCode === 7 && await fs.readFile(join(workspace, 'sample.txt'), 'utf8') === 'before')
|
||||
const cancelled = new AbortController()
|
||||
const pending = executeSandboxCommand('sandbox-probe.exe --delay 4000 late.txt', { ...context, signal: cancelled.signal })
|
||||
setTimeout(() => cancelled.abort(), 700)
|
||||
result = await pending
|
||||
check('cancellation terminates native command and removes profile', !result.ok && result.data.status === 'cancelled' && result.data.profileRemoved)
|
||||
result = await executeSandboxCommand('sandbox-probe.exe --delay 4000 timed-out.txt', { ...context, timeoutMs: 300 })
|
||||
check('native timeout is enforced', !result.ok && result.data.status === 'timeout' && result.data.profileRemoved)
|
||||
result = await executeSandboxCommand('sandbox-probe.exe --spawn-delayed descendant-late.txt', context)
|
||||
await new Promise(done => setTimeout(done, 1600))
|
||||
const descendantPath = join(stateRoot, 'runs', result.data.runId, 'stage/workspace/descendant-late.txt')
|
||||
check('background descendants are terminated when the root command exits', !(await fs.stat(descendantPath).catch(() => null)))
|
||||
const orphanId = randomUUID(), orphanDirectory = join(stateRoot, 'runs', orphanId), orphanStage = join(orphanDirectory, 'stage')
|
||||
for (const part of ['workspace', 'tools', 'tmp', 'home/AppData/Roaming', 'home/AppData/Local']) await fs.mkdir(join(orphanStage, part), { recursive: true })
|
||||
await fs.copyFile(join(workspace, 'sandbox-probe.exe'), join(orphanStage, 'workspace/sandbox-probe.exe'))
|
||||
await fs.writeFile(join(orphanStage, 'command.cmd'), '@echo off\r\nsandbox-probe.exe --delay 5000 crash-late.txt\r\n')
|
||||
const crashed = spawn(join(resourceDirectory, 'ycode-sandbox.exe'), ['--run', `YCode.Run.${orphanId.replaceAll('-', '')}`, orphanStage, join(orphanDirectory, 'result.json'), '10000'], { windowsHide: true, stdio: ['pipe', 'ignore', 'ignore'] })
|
||||
for (let attempt = 0; attempt < 100 && !await fs.stat(join(orphanDirectory, 'lease.json')).catch(() => null); attempt++) await new Promise(done => setTimeout(done, 20))
|
||||
check('native leases are persisted outside the child writable area', !!await fs.stat(join(orphanDirectory, 'lease.json')).catch(() => null))
|
||||
const closed = new Promise(done => crashed.once('close', done))
|
||||
crashed.kill(); await closed
|
||||
check('maintenance releases crash-orphaned AppContainer and exact drive mapping', await releaseRunLease(join(resourceDirectory, 'ycode-sandbox.exe'), orphanDirectory) === 'released')
|
||||
const parentId = randomUUID(), parentDirectory = join(stateRoot, 'runs', parentId), parentStage = join(parentDirectory, 'stage')
|
||||
for (const part of ['workspace', 'tools', 'tmp', 'home/AppData/Roaming', 'home/AppData/Local']) await fs.mkdir(join(parentStage, part), { recursive: true })
|
||||
await fs.copyFile(join(workspace, 'sandbox-probe.exe'), join(parentStage, 'workspace/sandbox-probe.exe'))
|
||||
await fs.writeFile(join(parentStage, 'command.cmd'), '@echo off\r\nsandbox-probe.exe --delay 5000 parent-late.txt\r\n')
|
||||
const parentScript = join(fixture, 'parent-exit.cjs')
|
||||
await fs.writeFile(parentScript, `const fs=require('node:fs');const cp=require('node:child_process');const a=process.argv.slice(2);cp.spawn(a[0],a.slice(1),{windowsHide:true,detached:true,stdio:'ignore'});const t=setInterval(()=>{if(fs.existsSync(${JSON.stringify(join(parentDirectory, 'lease.json'))})){clearInterval(t);setTimeout(()=>process.exit(0),250)}},20);setTimeout(()=>process.exit(2),5000).unref();`)
|
||||
const shortParent = spawn(process.execPath, [parentScript, join(resourceDirectory, 'ycode-sandbox.exe'), '--run', `YCode.Run.${parentId.replaceAll('-', '')}`, parentStage, join(parentDirectory, 'result.json'), '10000'], { windowsHide: true, stdio: 'ignore' })
|
||||
await new Promise(done => shortParent.once('close', done))
|
||||
for (let attempt = 0; attempt < 250 && !await fs.stat(join(parentDirectory, 'result.json')).catch(() => null); attempt++) await new Promise(done => setTimeout(done, 20))
|
||||
const parentReport = JSON.parse(await fs.readFile(join(parentDirectory, 'result.json'), 'utf8'))
|
||||
check('launch-parent exit terminates the Job and cleans native resources', parentReport.status === 'parent-exited' && parentReport.profileRemoved && parentReport.driveRemoved)
|
||||
let connections = 0
|
||||
const server = createServer(socket => { connections++; socket.end('local-test') })
|
||||
await new Promise<void>(done => server.listen(0, '127.0.0.1', done))
|
||||
try {
|
||||
const address = server.address(); assert.ok(address && typeof address !== 'string')
|
||||
result = await executeSandboxCommand(`sandbox-probe.exe --tcp ${address.port}`, { ...context, timeoutMs: 4000 })
|
||||
check('real local TCP connection is denied by capability-free AppContainer', !result.ok && result.data.stdout.includes('TCP_DENIED'))
|
||||
check('local TCP listener observes no sandbox connection', connections === 0)
|
||||
} finally { await new Promise<void>(done => server.close(() => done())) }
|
||||
const defaultContext = { ...context, permissionMode: 'default-sandbox' as const }
|
||||
let tool = await executeTool('read_file', { path: '.env' }, defaultContext)
|
||||
check('host file tool shares the sensitive-file policy', !tool.ok)
|
||||
tool = await executeTool('write_file', { path: '.env', content: 'blocked' }, defaultContext)
|
||||
check('host writes cannot replace a protected credential file', !tool.ok && (await fs.readFile(join(workspace, '.env'), 'utf8')).includes('DUMMY_SECRET'))
|
||||
tool = await executeTool('read_file', { path: outside }, defaultContext)
|
||||
check('host file tool rejects outside reads', !tool.ok)
|
||||
await fs.symlink(join(fixture), join(workspace, 'outside-link'), 'junction')
|
||||
tool = await executeTool('read_file', { path: 'outside-link/outside.txt' }, defaultContext)
|
||||
check('host file tool rejects junction escape', !tool.ok)
|
||||
tool = await executeTool('write_file', { path: 'outside-link/outside.txt', content: 'explicit-full-access' }, { ...defaultContext, permissionMode: 'full-access' })
|
||||
check('explicit full access preserves existing link-target write behavior', tool.ok && (tool.data as { path: string }).path === outside && await fs.readFile(outside, 'utf8') === 'explicit-full-access')
|
||||
await fs.writeFile(outside, 'outside-native-fixture')
|
||||
await fs.link(outside, join(workspace, 'outside-hardlink.txt'))
|
||||
tool = await executeTool('read_file', { path: 'outside-hardlink.txt' }, defaultContext)
|
||||
check('host file tool rejects multi-link alias', !tool.ok)
|
||||
await fs.unlink(join(workspace, 'outside-hardlink.txt'))
|
||||
await fs.unlink(join(workspace, 'outside-link'))
|
||||
tool = await executeTool('write_file', { path: 'host.txt', content: 'protected-write' }, defaultContext)
|
||||
const data = tool.data as { transactionId?: string }
|
||||
check('host write records a persistent independent transaction', tool.ok && !!data.transactionId)
|
||||
await restoreTransaction(data.transactionId!, workspace)
|
||||
check('host write backup supports recovery', !(await fs.stat(join(workspace, 'host.txt')).catch(() => null)))
|
||||
const signal = new AbortController()
|
||||
tool = await executeTool('write_file', { path: 'cancelled-host.txt', content: 'blocked' }, { ...defaultContext, signal: signal.signal, approve: async () => { signal.abort(); return true } })
|
||||
check('cancellation during host approval does not write', !tool.ok && !(await fs.stat(join(workspace, 'cancelled-host.txt')).catch(() => null)))
|
||||
tool = await executeTool('execute_command', { command: 'echo host-route' }, defaultContext)
|
||||
check('actual default host command uses native sandbox route', tool.ok && (tool.data as { sandbox?: string }).sandbox === 'windows-appcontainer')
|
||||
tool = await executeTool('execute_command', { command: 'mkdir empty-only' }, defaultContext)
|
||||
check('empty-directory writeback uses an approved directory transaction', tool.ok && (await fs.stat(join(workspace, 'empty-only'))).isDirectory())
|
||||
await restoreTransaction((tool.data as { transactionId: string }).transactionId, workspace)
|
||||
tool = await executeTool('execute_command', { command: 'echo bypass>.env' }, defaultContext)
|
||||
check('command-generated protected file never reaches the host', !tool.ok && (await fs.readFile(join(workspace, '.env'), 'utf8')).includes('DUMMY_SECRET'))
|
||||
result = await executeSandboxCommand('echo unicode>unicode-\u4e2d\u6587.txt', context)
|
||||
check('UTF-8 script and Unicode file writeback work', result.ok && (await fs.readFile(join(workspace, 'unicode-\u4e2d\u6587.txt'), 'utf8')).trim() === 'unicode')
|
||||
await restoreTransaction(result.data.transactionId!, workspace)
|
||||
result = await executeSandboxCommand('echo committed>audit-fault.txt', { ...context, toolCallId: 'audit-fault', approve: async () => {
|
||||
for (const entry of await fs.readdir(join(stateRoot, 'runs'))) {
|
||||
const record = await fs.readFile(join(stateRoot, 'runs', entry, 'request.json'), 'utf8').then(raw => JSON.parse(raw), () => null)
|
||||
if (record?.identity?.toolCallId === 'audit-fault') await fs.mkdir(join(stateRoot, 'runs', entry, 'audit.json'))
|
||||
}
|
||||
return true
|
||||
} })
|
||||
check('audit storage failure preserves the actual committed outcome', result.ok && result.data.writeback === 'committed' && !result.data.auditSaved && (await fs.readFile(join(workspace, 'audit-fault.txt'), 'utf8')).trim() === 'committed')
|
||||
await restoreTransaction(result.data.transactionId!, workspace)
|
||||
const transaction = await commitChanges(workspace, [{ path: 'sample.txt', beforeHash: digest(Buffer.from('before')), after: Buffer.from('after') }], context)
|
||||
await fs.writeFile(join(workspace, 'sample.txt'), 'new-external-change')
|
||||
await assert.rejects(restoreTransaction(transaction, workspace), /外部修改/)
|
||||
check('recovery refuses to overwrite subsequent external edit', await fs.readFile(join(workspace, 'sample.txt'), 'utf8') === 'new-external-change')
|
||||
configureSandbox({ stateRoot, resourceDirectory: join(fixture, 'missing-backend') })
|
||||
tool = await executeTool('execute_command', { command: 'echo unsafe>fallback.txt' }, defaultContext)
|
||||
check('missing native backend fails closed without shell fallback', !tool.ok && !(await fs.stat(join(workspace, 'fallback.txt')).catch(() => null)))
|
||||
const tampered = join(fixture, 'tampered-backend')
|
||||
await fs.mkdir(tampered)
|
||||
await fs.copyFile(join(resourceDirectory, 'ycode-sandbox.exe'), join(tampered, 'ycode-sandbox.exe'))
|
||||
const badManifest = JSON.parse(await fs.readFile(join(resourceDirectory, 'manifest.json'), 'utf8')); badManifest.binarySha256 = '0'.repeat(64)
|
||||
await fs.writeFile(join(tampered, 'manifest.json'), JSON.stringify(badManifest))
|
||||
configureSandbox({ stateRoot, resourceDirectory: tampered })
|
||||
check('manifest mismatch marks the backend unavailable', (await sandboxStatus()).state === 'unavailable')
|
||||
tool = await executeTool('execute_command', { command: 'echo unsafe>fallback.txt' }, defaultContext)
|
||||
check('manifest mismatch cannot enable a host fallback', !tool.ok && !(await fs.stat(join(workspace, 'fallback.txt')).catch(() => null)))
|
||||
configureSandbox({ stateRoot, resourceDirectory })
|
||||
const audit = JSON.parse(await fs.readFile(join(stateRoot, 'runs', initialAuditRunId, 'audit.json'), 'utf8'))
|
||||
check('audit persists identity and excludes raw stdout/stderr', audit.identity.runId === 'test-run' && !Object.hasOwn(audit, 'stdout') && !Object.hasOwn(audit, 'stderr'))
|
||||
console.log(`SANDBOX_REGRESSION_PASS ${checks.length}/${checks.length}`)
|
||||
} finally {
|
||||
delete process.env.YCODE_FAKE_SECRET
|
||||
await fs.writeFile(join(fixture, 'report.json'), JSON.stringify({ checks, fixture, limitations: ['Windows AppContainer boundary and YCode host tools; not WorkBuddy equivalence, arbitrary native hooks, registry parity or all possible network protocols.'] }, null, 2) + '\n')
|
||||
console.log(`SANDBOX_REPORT ${join(fixture, 'report.json')}`)
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { promises as fs } from 'node:fs'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { createServer, createConnection } from 'node:net'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { configureSandbox } from '../src/main/sandbox/configuration'
|
||||
import { executeSandboxCommand } from '../src/main/sandbox/service'
|
||||
import { executeTool } from '../src/main/agent/tools'
|
||||
|
||||
if (process.platform !== 'win32') throw new Error('Bound stdio probe requires actual Windows processes')
|
||||
await fs.mkdir(resolve('.tmp'), { recursive: true })
|
||||
const fixture = await fs.mkdtemp(resolve('.tmp/sandbox-stdio-')), workspace = join(fixture, 'workspace')
|
||||
await fs.mkdir(workspace); await fs.copyFile(resolve('resources/sandbox/win32-x64/sandbox-probe.exe'), join(workspace, 'sandbox-probe.exe'))
|
||||
configureSandbox({ stateRoot: join(fixture, 'state'), resourceDirectory: resolve('resources/sandbox/win32-x64') })
|
||||
const context = { workspaceRoot: workspace, runId: 'bound-host-run', toolCallId: 'bound-host-call', stdioProbe: true }
|
||||
const checks: Array<{ label: string; pass: boolean }> = []
|
||||
function check(label: string, condition: unknown): void { checks.push({ label, pass: !!condition }); assert.ok(condition, label); console.log(`PASS ${label}`) }
|
||||
try {
|
||||
let result = await executeSandboxCommand(`sandbox-probe.exe --broker-echo ${randomUUID()}`, context)
|
||||
check('real capability-free AppContainer exchanges a bounded reply over its inherited stdio', result.ok && result.data.stdout.includes('BROKER_ECHO_RECEIVED') && result.data.brokerTransport === 'bound-stdio-v1')
|
||||
check('control frames are not reinterpreted as ordinary model output', !result.data.stdout.includes('YCODE_BROKER_V1') && result.data.writeback === 'unchanged')
|
||||
result = await executeSandboxCommand(`sandbox-probe.exe --broker-spoof ${randomUUID()}`, context)
|
||||
check('child-supplied run identity is denied instead of rebinding the request', result.ok && result.data.stdout.includes('BROKER_DENIAL_RECEIVED'))
|
||||
result = await executeSandboxCommand(`sandbox-probe.exe --broker-deny ${randomUUID()}`, context)
|
||||
check('experimental transport cannot perform host filesystem or network operations', result.ok && result.data.stdout.includes('BROKER_DENIAL_RECEIVED') && result.data.writeback === 'unchanged')
|
||||
await assert.rejects(executeSandboxCommand(`sandbox-probe.exe --broker-replay ${randomUUID()}`, context), /代理协议/)
|
||||
check('replayed request IDs cancel the experiment without writeback', true)
|
||||
await assert.rejects(executeSandboxCommand(`sandbox-probe.exe --broker-oversize ${randomUUID()}`, context), /代理协议/)
|
||||
check('oversized frames fail closed', true)
|
||||
await assert.rejects(executeSandboxCommand(`sandbox-probe.exe --broker-flood ${randomUUID()}`, context), /代理协议/)
|
||||
check('excess concurrent requests are bounded and rejected', true)
|
||||
let accepted = 0
|
||||
const server = createServer(socket => { accepted++; socket.destroy() })
|
||||
await new Promise<void>(done => server.listen(0, '127.0.0.1', done))
|
||||
try {
|
||||
const address = server.address(); assert.ok(address && typeof address === 'object')
|
||||
await new Promise<void>((done, reject) => { const client = createConnection(address.port, '127.0.0.1'); client.once('connect', () => { client.destroy(); done() }); client.once('error', reject) })
|
||||
const before = accepted
|
||||
result = await executeSandboxCommand(`sandbox-probe.exe --tcp ${address.port}`, context)
|
||||
check('enabling the inherited transport leaves actual network egress denied', !result.ok && result.data.stdout.includes('TCP_DENIED') && accepted === before && before > 0)
|
||||
} finally { await new Promise<void>(done => server.close(() => done())) }
|
||||
const abort = new AbortController()
|
||||
const pending = executeSandboxCommand('sandbox-probe.exe --delay 5000 late.txt', { ...context, signal: abort.signal })
|
||||
setTimeout(() => abort.abort(), 500)
|
||||
result = await pending
|
||||
check('cancellation terminates the stdio-enabled Job and cleans its resources', !result.ok && result.data.status === 'cancelled' && result.data.profileRemoved && result.data.driveRemoved && !await fs.stat(join(workspace, 'late.txt')).catch(() => null))
|
||||
const tool = await executeTool('execute_command', { command: `sandbox-probe.exe --broker-echo ${randomUUID()}`, stdioProbe: true }, { workspaceRoot: workspace })
|
||||
check('model arguments cannot enable the experimental broker transport', !tool.ok && !(tool.data as { brokerTransport?: string })?.brokerTransport)
|
||||
console.log(`SANDBOX_STDIO_PASS ${checks.length}/${checks.length}`)
|
||||
} finally {
|
||||
await fs.writeFile(join(fixture, 'report.json'), JSON.stringify({ checks, fixture, limitations: ['Bound inherited channel and echo-only dispatch; not a per-descendant PID-authenticated broker, filesystem broker, network proxy or WorkBuddy equivalence. Model-facing tools cannot enable this experiment.'] }, null, 2) + '\n')
|
||||
console.log(`SANDBOX_STDIO_REPORT ${join(fixture, 'report.json')}`)
|
||||
}
|
||||
@@ -1,103 +0,0 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { promises as fs } from 'node:fs'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { configureSandbox } from '../src/main/sandbox/configuration'
|
||||
import { importSandboxToolchain, listSandboxToolchains } from '../src/main/sandbox/toolchains'
|
||||
import { executeSandboxCommand, sandboxStatus } from '../src/main/sandbox/service'
|
||||
import { restoreTransaction } from '../src/main/sandbox/transactions'
|
||||
import { importSandboxDependencies } from '../src/main/sandbox/dependency-cache'
|
||||
|
||||
if (process.platform !== 'win32') throw new Error('Toolchain regression requires real Windows isolation')
|
||||
await fs.mkdir(resolve('.tmp'), { recursive: true })
|
||||
const fixture = await fs.mkdtemp(resolve('.tmp/sandbox-toolchain-'))
|
||||
const workspace = join(fixture, 'workspace'), stateRoot = join(fixture, 'state'), outside = join(fixture, 'outside.txt')
|
||||
await fs.mkdir(workspace); await fs.writeFile(outside, 'host-only-fixture')
|
||||
configureSandbox({ stateRoot, resourceDirectory: resolve('resources/sandbox/win32-x64') })
|
||||
const checks: Array<{ label: string; pass: boolean }> = []
|
||||
function check(label: string, condition: unknown): void { checks.push({ label, pass: !!condition }); assert.ok(condition, label); console.log(`PASS ${label}`) }
|
||||
const context = { workspaceRoot: workspace, sessionId: 'toolchain-session', runId: 'toolchain-run', toolCallId: 'toolchain-call', approve: async () => true }
|
||||
try {
|
||||
check('Node/Python start missing instead of inheriting host PATH', (await listSandboxToolchains()).filter(item => item.kind !== 'git').every(item => item.state === 'missing'))
|
||||
await assert.rejects(executeSandboxCommand('node --version', { ...context, runtimes: ['node'] }), /尚未注册/)
|
||||
check('requesting a missing runtime fails closed', true)
|
||||
await assert.rejects(importSandboxToolchain('node', 'relative-node.exe'), /绝对路径/)
|
||||
const node = await importSandboxToolchain('node', await fs.realpath(process.execPath))
|
||||
check('explicit Node import persists content-addressed file identities and npm', node.state === 'registered' && !!node.id && !!node.files && node.features!.includes('npm'))
|
||||
process.env.YCODE_DUMMY_MODEL_TOKEN = 'not-a-real-credential'
|
||||
let result = await executeSandboxCommand('node -e "console.log(process.version); console.log(process.env.YCODE_DUMMY_MODEL_TOKEN || \'NO_TOKEN\'); console.log(process.env.NPM_CONFIG_OFFLINE)"', { ...context, runtimes: ['node'] })
|
||||
check('real Node executes inside the native sandbox with a clean environment', result.ok && result.data.stdout.includes(process.version) && result.data.stdout.includes('NO_TOKEN') && !result.data.stdout.includes('not-a-real-credential'))
|
||||
check('native execution returns the exact staged runtime identity', result.data.toolchains?.[0]?.id === node.id)
|
||||
await fs.writeFile(join(workspace, 'host-read.cjs'), `try { require('fs').readFileSync(${JSON.stringify(outside)});process.exit(9) } catch(error) { if(!['EACCES','EPERM'].includes(error.code))throw error; console.log('HOST_READ_DENIED') }`)
|
||||
result = await executeSandboxCommand('node host-read.cjs', { ...context, runtimes: ['node'] })
|
||||
check('real Node cannot read the ordinary host fixture', result.ok && result.data.stdout.includes('HOST_READ_DENIED'))
|
||||
await fs.writeFile(join(workspace, 'runtime-ro.cjs'), "const fs=require('fs'),path=require('path');const target=path.join(path.dirname(process.execPath),'node_modules/npm/package.json');fs.readFileSync(target);try {fs.appendFileSync(target,'x');process.exit(9)}catch(error){if(!['EACCES','EPERM'].includes(error.code))throw error;console.log('RUNTIME_READONLY')}")
|
||||
result = await executeSandboxCommand('node runtime-ro.cjs', { ...context, runtimes: ['node'] })
|
||||
check('native ACLs prevent modification of the staged interpreter', result.ok && result.data.stdout.includes('RUNTIME_READONLY'))
|
||||
await fs.writeFile(join(workspace, 'package.json'), JSON.stringify({ name: 'ycode-sandbox-fixture', version: '1.0.0', private: true, scripts: { build: 'node build.cjs', test: 'node test.cjs' } }))
|
||||
await fs.writeFile(join(workspace, 'build.cjs'), "require('fs').writeFileSync('artifact.txt','built-in-sandbox')")
|
||||
await fs.writeFile(join(workspace, 'test.cjs'), "require('assert').equal(require('fs').readFileSync('artifact.txt','utf8'),'built-in-sandbox');console.log('NODE_PROJECT_PASS')")
|
||||
result = await executeSandboxCommand('npm run build && npm test', { ...context, runtimes: ['node'] })
|
||||
check('npm builds and tests a real offline project in the snapshot', result.ok && result.data.stdout.includes('NODE_PROJECT_PASS') && result.data.writeback === 'committed')
|
||||
check('npm build artifact reaches the host only through approved transaction', await fs.readFile(join(workspace, 'artifact.txt'), 'utf8') === 'built-in-sandbox' && !!result.data.transactionId)
|
||||
await restoreTransaction(result.data.transactionId!, workspace)
|
||||
const pythonSource = execFileSync('where.exe', ['python'], { encoding: 'utf8', windowsHide: true }).trim().split(/\r?\n/)[0]
|
||||
const python = await importSandboxToolchain('python', await fs.realpath(pythonSource))
|
||||
check('Python standard library and pip are imported without executing source on the host', python.state === 'registered' && python.features!.includes('pip'))
|
||||
result = await executeSandboxCommand('python -c "import sys,json,ssl,sqlite3; print(\'PYTHON_STDLIB_PASS\'); print(sys.prefix)"', { ...context, runtimes: ['python'] })
|
||||
check('real Python loads its private standard library and extension modules', result.ok && result.data.stdout.includes('PYTHON_STDLIB_PASS') && !result.data.stdout.includes(pythonSource))
|
||||
result = await executeSandboxCommand('python -m pip --version', { ...context, runtimes: ['python'] })
|
||||
check('pip runs from the controlled Python runtime', result.ok && /pip \d/.test(result.data.stdout))
|
||||
await fs.writeFile(join(workspace, 'test_fixture.py'), "from pathlib import Path\nimport unittest\nclass Fixture(unittest.TestCase):\n def test_write(self):\n Path('python-artifact.txt').write_text('python-built',encoding='utf-8')\n self.assertEqual(Path('python-artifact.txt').read_text(), 'python-built')\n")
|
||||
result = await executeSandboxCommand('python -m unittest test_fixture', { ...context, runtimes: ['python'] })
|
||||
check('Python tests execute and write approved artifacts', result.ok && result.data.writeback === 'committed' && await fs.readFile(join(workspace, 'python-artifact.txt'), 'utf8') === 'python-built')
|
||||
await restoreTransaction(result.data.transactionId!, workspace)
|
||||
check('runtime status exposes separate registered Node and Python capabilities', (await sandboxStatus()).toolchains?.filter(item => item.kind !== 'git').every(item => item.state === 'registered'))
|
||||
await fs.mkdir(join(workspace, 'node_modules/fixture-package'), { recursive: true })
|
||||
await fs.writeFile(join(workspace, 'node_modules/fixture-package/package.json'), JSON.stringify({ name: 'fixture-package', version: '1.0.0', exports: { require: './index.cjs', import: './esm.mjs' } }))
|
||||
await fs.writeFile(join(workspace, 'node_modules/fixture-package/index.cjs'), "module.exports={value:'DEPENDENCY_CJS_PASS'}")
|
||||
await fs.writeFile(join(workspace, 'node_modules/fixture-package/esm.mjs'), "export const value='DEPENDENCY_ESM_PASS'")
|
||||
await fs.writeFile(join(workspace, 'node_modules/fixture-package/.env'), 'DUMMY_PACKAGE_CREDENTIAL')
|
||||
const nodeDependencies = await importSandboxDependencies(workspace, 'node')
|
||||
check('dependency import binds workspace lock inputs and runtime identity while filtering credentials', nodeDependencies.excluded === 1 && nodeDependencies.identity.runtimeId === node.id && nodeDependencies.files === 3)
|
||||
result = await executeSandboxCommand('node -e "console.log(require(\'fixture-package\').value)" && node --input-type=module -e "import {value} from \'fixture-package\';console.log(value)"', { ...context, runtimes: ['node'], dependencies: ['node'] })
|
||||
check('controlled node_modules supports real CommonJS and ESM resolution', result.ok && result.data.stdout.includes('DEPENDENCY_CJS_PASS') && result.data.stdout.includes('DEPENDENCY_ESM_PASS') && result.data.writeback === 'unchanged')
|
||||
check('execution records the exact workspace dependency layer identity', result.data.dependencies?.[0]?.id === nodeDependencies.identity.id)
|
||||
await fs.writeFile(join(workspace, 'dep-ro.cjs'), "const fs=require('fs'),p=require.resolve('fixture-package');fs.readFileSync(p);try{fs.appendFileSync(p,'x');process.exit(9)}catch(error){if(!['EACCES','EPERM'].includes(error.code))throw error;console.log('DEPENDENCY_READONLY')}")
|
||||
result = await executeSandboxCommand('node dep-ro.cjs', { ...context, runtimes: ['node'], dependencies: ['node'] })
|
||||
check('native ACLs enforce dependency-layer immutability', result.ok && result.data.stdout.includes('DEPENDENCY_READONLY'))
|
||||
await fs.writeFile(join(workspace, 'package-lock.json'), '{}')
|
||||
await assert.rejects(executeSandboxCommand('node --version', { ...context, runtimes: ['node'], dependencies: ['node'] }), /锁文件已变化/)
|
||||
check('changed project lock inputs invalidate a cached dependency layer', true)
|
||||
await fs.unlink(join(workspace, 'package-lock.json'))
|
||||
const anotherWorkspace = join(fixture, 'another-workspace'); await fs.mkdir(anotherWorkspace)
|
||||
await assert.rejects(executeSandboxCommand('node --version', { ...context, workspaceRoot: anotherWorkspace, runtimes: ['node'], dependencies: ['node'] }), /ENOENT/)
|
||||
check('one workspace cannot accidentally select another workspace dependency cache', true)
|
||||
await fs.mkdir(join(workspace, '.venv/Lib/site-packages/fixture_module'), { recursive: true })
|
||||
await fs.writeFile(join(workspace, '.venv/Lib/site-packages/fixture_module/__init__.py'), "value='PYTHON_DEPENDENCY_PASS'\n")
|
||||
const pythonDependencies = await importSandboxDependencies(workspace, 'python')
|
||||
result = await executeSandboxCommand('python -c "import fixture_module;print(fixture_module.value)"', { ...context, runtimes: ['python'], dependencies: ['python'] })
|
||||
check('Python imports a workspace-scoped controlled site-packages layer', result.ok && result.data.stdout.includes('PYTHON_DEPENDENCY_PASS') && result.data.dependencies?.[0]?.id === pythonDependencies.identity.id)
|
||||
const cacheKey = (await fs.readdir(join(stateRoot, 'dependencies')))[0]
|
||||
const cacheFile = join(stateRoot, 'dependencies', cacheKey, 'objects', nodeDependencies.identity.id, 'files/fixture-package/index.cjs')
|
||||
const cacheBytes = await fs.readFile(cacheFile)
|
||||
await fs.appendFile(cacheFile, 'tampered')
|
||||
await assert.rejects(executeSandboxCommand('node --version', { ...context, runtimes: ['node'], dependencies: ['node'] }), /依赖文件摘要不匹配/)
|
||||
check('a tampered dependency layer fails closed before execution', true)
|
||||
await fs.writeFile(cacheFile, cacheBytes)
|
||||
const alias = JSON.parse(await fs.readFile(join(stateRoot, 'toolchains/node.json'), 'utf8'))
|
||||
const executable = join(stateRoot, 'toolchains/objects', alias.id, 'files/node.exe')
|
||||
const bytes = await fs.readFile(executable)
|
||||
await fs.appendFile(executable, 'tampered')
|
||||
await assert.rejects(executeSandboxCommand('node --version', { ...context, runtimes: ['node'] }), /摘要不匹配/)
|
||||
check('a tampered registered interpreter is rejected before native execution', true)
|
||||
await fs.writeFile(executable, bytes)
|
||||
await assert.rejects(executeSandboxCommand('echo bad', { ...context, runtimes: ['node', 'node'] }), /选择无效/)
|
||||
await assert.rejects(executeSandboxCommand('echo bad', { ...context, runtimes: ['../host'] }), /只支持/)
|
||||
check('runtime IDs cannot be duplicated or used as arbitrary source paths', true)
|
||||
console.log(`SANDBOX_TOOLCHAIN_PASS ${checks.length}/${checks.length}`)
|
||||
} finally {
|
||||
delete process.env.YCODE_DUMMY_MODEL_TOKEN
|
||||
await fs.writeFile(join(fixture, 'report.json'), JSON.stringify({ checks, fixture, limitations: ['This suite covers Node/npm, Python/pip and immutable dependency layers; private Git repository coverage is in test:sandbox:git. Host Git metadata integration, network installs, persistent mutable workspace, pnpm link layouts and full native-addon compatibility remain pending.'] }, null, 2) + '\n')
|
||||
console.log(`SANDBOX_TOOLCHAIN_REPORT ${join(fixture, 'report.json')}`)
|
||||
}
|
||||
@@ -1,236 +0,0 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { promises as fs } from 'node:fs'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { configureSandbox } from '../src/main/sandbox/configuration'
|
||||
import { executeSandboxCommand } from '../src/main/sandbox/service'
|
||||
import { workspaceAction } from '../src/main/sandbox/workspace-service'
|
||||
import { workspaceKey, workspaceRecord, type WorkspaceRevision } from '../src/main/sandbox/workspace'
|
||||
import { sandboxOperation } from '../src/main/sandbox/maintenance'
|
||||
import { executeTool } from '../src/main/agent/tools'
|
||||
import { executeConversationTool } from '../src/main/agent/conversation-tools'
|
||||
import { readSandboxPolicy, updateSandboxPolicy } from '../src/main/sandbox/policy-store'
|
||||
import { importSandboxToolchain } from '../src/main/sandbox/toolchains'
|
||||
import { importSandboxDependencies } from '../src/main/sandbox/dependency-cache'
|
||||
import { spawn, execFileSync } from 'node:child_process'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { restoreTransaction } from '../src/main/sandbox/transactions'
|
||||
|
||||
if (process.argv[2] === '--worker') {
|
||||
const [, , , childWorkspace, childState, childSession] = process.argv
|
||||
configureSandbox({ stateRoot: childState, resourceDirectory: resolve('resources/sandbox/win32-x64') })
|
||||
await executeSandboxCommand('git branch lost-on-crash && echo interrupted>crash.txt && powershell.exe -NoProfile -NonInteractive -Command "Start-Sleep -Seconds 25"',
|
||||
{ workspaceRoot: childWorkspace, sessionId: childSession, workspaceMode: 'persistent', runtimes: ['git'] })
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
if (process.platform !== 'win32') throw new Error('Persistent workspace requires real Windows isolation')
|
||||
await fs.mkdir(resolve('.tmp'), { recursive: true })
|
||||
const fixture = await fs.mkdtemp(resolve('.tmp/sandbox-workspace-')), workspace = join(fixture, 'project \u4e2d\u6587 ^'), stateRoot = join(fixture, 'state')
|
||||
await fs.mkdir(workspace); await fs.writeFile(join(workspace, 'file.txt'), 'host-before')
|
||||
for (const generated of ['out', 'dist', 'release']) {
|
||||
await fs.mkdir(join(workspace, generated), { recursive: true })
|
||||
await fs.writeFile(join(workspace, generated, 'generated.bin'), 'generated-output')
|
||||
}
|
||||
configureSandbox({ stateRoot, resourceDirectory: resolve('resources/sandbox/win32-x64') })
|
||||
let approvals = 0
|
||||
const context = { workspaceRoot: workspace, sessionId: 'persistent-session', runId: 'persistent-run', toolCallId: 'persistent-call', approve: async () => { approvals++; return true } }
|
||||
const run = (command: string, extra: Partial<Parameters<typeof executeSandboxCommand>[1]> = {}) => executeSandboxCommand(command, { ...context, workspaceMode: 'persistent', ...extra })
|
||||
const checks: Array<{ label: string; pass: boolean }> = []
|
||||
function check(label: string, condition: unknown): void { checks.push({ label, pass: !!condition }); assert.ok(condition, label); console.log(`PASS ${label}`) }
|
||||
const root = join(stateRoot, 'workspaces', workspaceKey(workspace, context.sessionId))
|
||||
async function revision(): Promise<WorkspaceRevision> {
|
||||
const active = await workspaceRecord<{ id: string }>(join(root, 'active.json'))
|
||||
return workspaceRecord(join(root, 'revisions', `${active.id}.json`))
|
||||
}
|
||||
try {
|
||||
let result = await run('echo draft-value>file.txt')
|
||||
check('successful command saves a draft without host writes or approval', result.ok && result.data.writeback === 'deferred' && approvals === 0 && await fs.readFile(join(workspace, 'file.txt'), 'utf8') === 'host-before')
|
||||
const first = await revision()
|
||||
check('generated build output stays outside the retained draft', !first.tree.some(entry => /^(out|dist|release)(\/|$)/.test(entry.path)) && first.excluded >= 3)
|
||||
result = await run('type file.txt')
|
||||
const second = await revision()
|
||||
check('next real native command reads the retained draft', result.ok && result.data.stdout.includes('draft-value') && result.data.workspace?.changes.length === 1)
|
||||
check('normal command boundaries preserve the exact physical worktree identity', first.physicalIdentity === second.physicalIdentity && first.id !== second.id)
|
||||
const outcome = await sandboxOperation(result.data.runId)
|
||||
check('durable run outcome distinguishes a deferred draft from host commit', outcome.writeback === 'deferred' && outcome.workspace?.sessionId === context.sessionId && outcome.workspace.revision === second.id)
|
||||
let status = await workspaceAction('changes', context)
|
||||
check('change inspection reports the exact pending host change', status.state === 'ready' && status.changes.length === 1 && status.changes[0].path === 'file.txt')
|
||||
let committed = await workspaceAction('commit', context, status.revision)
|
||||
check('explicit commit consumes one approval and uses a native host transaction', committed.writeback === 'committed' && !!committed.transactionId && approvals === 1 && (await fs.readFile(join(workspace, 'file.txt'), 'utf8')).trim() === 'draft-value')
|
||||
check('commit advances the host baseline and clears pending changes', committed.changes.length === 0 && committed.lastTransactionId === committed.transactionId)
|
||||
result = await run('type file.txt')
|
||||
check('read-only command after commit leaves the draft clean', result.ok && result.data.writeback === 'unchanged' && approvals === 1)
|
||||
await run('echo pending>pending.txt')
|
||||
committed = await workspaceAction('commit', { ...context, approve: async () => false })
|
||||
check('rejected commit keeps draft bytes and leaves host untouched', committed.writeback === 'rejected' && committed.changes.some(change => change.path === 'pending.txt') && !await fs.stat(join(workspace, 'pending.txt')).catch(() => null))
|
||||
status = await workspaceAction('discard', context)
|
||||
result = await run('if exist pending.txt (exit /b 9) else (type file.txt)')
|
||||
check('discard archives the draft and rebuilds from the current host', status.state === 'ready' && status.changes.length === 0 && result.ok && result.data.stdout.includes('draft-value') && (await fs.readdir(join(root, 'archives'))).some(name => name.startsWith('discarded-')))
|
||||
await run('echo private-again>file.txt')
|
||||
await fs.writeFile(join(workspace, 'external.txt'), 'external-clean')
|
||||
result = await run('type file.txt && type external.txt')
|
||||
check('three-way synchronization imports unrelated host edits without losing draft changes', result.ok && result.data.stdout.includes('private-again') && result.data.stdout.includes('external-clean') && result.data.workspace?.changes.length === 1)
|
||||
await fs.writeFile(join(workspace, 'file.txt'), 'host-conflict')
|
||||
await assert.rejects(run('echo must-not-run>blocked.txt'), /编辑冲突/)
|
||||
status = await workspaceAction('changes', context)
|
||||
check('same-file host and draft edits block execution and commit with explicit conflicts', status.state === 'conflict' && status.conflicts?.includes('file.txt') && !await fs.stat(join(workspace, 'blocked.txt')).catch(() => null) && await fs.readFile(join(workspace, 'file.txt'), 'utf8') === 'host-conflict')
|
||||
await workspaceAction('discard', context)
|
||||
await run('echo saved-before-failure>checkpoint.txt')
|
||||
result = await run('echo unsuccessful>checkpoint.txt && echo unsuccessful>failed.txt && exit /b 7')
|
||||
check('failed commands do not publish their private filesystem changes', !result.ok && result.data.exitCode === 7 && !await fs.stat(join(workspace, 'failed.txt')).catch(() => null))
|
||||
result = await run('type checkpoint.txt && if exist failed.txt exit /b 9')
|
||||
check('failed command rollback restores the last durable draft checkpoint', result.ok && result.data.stdout.includes('saved-before-failure'))
|
||||
const controller = new AbortController(), cancel = setTimeout(() => controller.abort(), 1100)
|
||||
try { result = await run('echo cancelled>checkpoint.txt && echo CANCELLATION_MUTATED && powershell.exe -NoProfile -NonInteractive -Command "Start-Sleep -Seconds 12"', { signal: controller.signal }) }
|
||||
finally { clearTimeout(cancel) }
|
||||
check('cancelled command returns native cancellation and retains a draft view', !result.ok && result.data.status === 'cancelled' && result.data.workspace?.state === 'ready')
|
||||
result = await run('type checkpoint.txt')
|
||||
check('cancellation restores the last saved private bytes', result.ok && result.data.stdout.includes('saved-before-failure'))
|
||||
result = await run('echo timed-out>checkpoint.txt && powershell.exe -NoProfile -NonInteractive -Command "Start-Sleep -Seconds 10"', { timeoutMs: 1000 })
|
||||
check('native timeout discards private changes without writing the host', !result.ok && result.data.status === 'timeout' && !await fs.stat(join(workspace, 'checkpoint.txt')).catch(() => null))
|
||||
await assert.rejects(run('echo forbidden>.env'), /受保护|链接/)
|
||||
result = await run('if exist .env (exit /b 9) else (type checkpoint.txt)')
|
||||
check('protected private outputs reject the whole draft candidate and roll back', result.ok && result.data.stdout.includes('saved-before-failure'))
|
||||
const other = await run('if exist checkpoint.txt (exit /b 9) else (echo OTHER_SESSION_CLEAN)', { sessionId: 'other-session' })
|
||||
check('another session starts from the host and cannot inherit pending private files', other.ok && other.data.stdout.includes('OTHER_SESSION_CLEAN'))
|
||||
const privateRead = await executeSandboxCommand(`type "${join(root, 'worktree/checkpoint.txt')}"`, { ...context, sessionId: 'other-session' })
|
||||
check('a different AppContainer cannot read the sealed retained tree by absolute path', !privateRead.ok && !privateRead.data.stdout.includes('saved-before-failure'))
|
||||
const current = await revision(), privateBlob = current.tree.find(entry => entry.path === 'checkpoint.txt')!
|
||||
result = await run(`type "${join(root, 'blobs', privateBlob.hash!)}"`)
|
||||
check('even a new owner-session Job cannot directly read host-only checkpoint blobs', !result.ok && !result.data.stdout.includes('saved-before-failure'))
|
||||
await fs.rename(join(root, 'worktree'), join(root, 'replaced-physical'))
|
||||
await fs.cp(join(root, 'replaced-physical'), join(root, 'worktree'), { recursive: true, force: false, errorOnExist: true })
|
||||
await assert.rejects(run('echo must-not-start>root-replaced.txt'), /物理目录身份/)
|
||||
await workspaceAction('recover', context)
|
||||
result = await run('type checkpoint.txt')
|
||||
check('physical tree replacement is rejected and explicit recovery restores a verified checkpoint', result.ok && result.data.stdout.includes('saved-before-failure') && !await fs.stat(join(workspace, 'root-replaced.txt')).catch(() => null))
|
||||
let tool = await executeTool('execute_command', { command: 'echo tool-route>tool.txt', workspace: 'persistent' }, context)
|
||||
check('model-facing command selects the real persistent backend', tool.ok && (tool.data as { writeback?: string }).writeback === 'deferred' && !await fs.stat(join(workspace, 'tool.txt')).catch(() => null))
|
||||
tool = await executeTool('execute_command', { command: 'echo default-tool-route>default-tool.txt' }, context)
|
||||
check('model-facing commands default to the retained draft instead of copying on every command', tool.ok
|
||||
&& (tool.data as { writeback?: string; workspace?: { mode?: string } }).writeback === 'deferred'
|
||||
&& (tool.data as { workspace?: { mode?: string } }).workspace?.mode === 'persistent'
|
||||
&& !await fs.stat(join(workspace, 'default-tool.txt')).catch(() => null))
|
||||
tool = await executeTool('sandbox_workspace', { action: 'changes' }, context)
|
||||
check('model-facing workspace tool inspects pending draft changes', tool.ok && (tool.data as { changes: Array<{ path: string }> }).changes.some(change => change.path === 'tool.txt')
|
||||
&& (tool.data as { changes: Array<{ path: string }> }).changes.some(change => change.path === 'default-tool.txt'))
|
||||
tool = await executeTool('sandbox_workspace', { action: 'status', sessionId: 'other-session', workspaceRoot: stateRoot }, context)
|
||||
check('workspace tool rejects caller-supplied ownership and path overrides', !tool.ok && /归属参数/.test(tool.content))
|
||||
tool = await executeTool('execute_command', { command: 'echo explicit-native>explicit-mode.txt', workspace: 'persistent' }, { ...context, permissionMode: 'full-access' })
|
||||
check('explicit persistent mode keeps native confinement even with full-access host context', tool.ok && (tool.data as { sandbox?: string }).sandbox === 'windows-appcontainer' && !await fs.stat(join(workspace, 'explicit-mode.txt')).catch(() => null))
|
||||
const deniedMode = await executeConversationTool('sandbox_workspace', { action: 'commit' }, { ...context, agentMode: 'plan', signal: new AbortController().signal, emit: () => undefined })
|
||||
check('planning mode cannot invoke workspace commit or recovery operations', !deniedMode.ok && /模式不允许/.test(deniedMode.content))
|
||||
await assert.rejects(workspaceAction('discard', context, first.id), /版本已变化/)
|
||||
check('stale revision cannot discard a newer private draft', (await workspaceAction('status', context)).changes.some(change => change.path === 'tool.txt'))
|
||||
const cli = JSON.parse(execFileSync(process.execPath, ['scripts/sandbox-maintain.mjs', 'workspace-changes', '--workspace', workspace, '--session', context.sessionId, '--state-root', stateRoot],
|
||||
{ windowsHide: true, encoding: 'utf8', timeout: 30_000 }))
|
||||
check('fresh maintenance process reloads the exact saved owner and revision at Unicode caret paths', cli.sessionId === context.sessionId && cli.revision === (await revision()).id && cli.changes.some((change: { path: string }) => change.path === 'tool.txt'))
|
||||
await run('git status --short', { runtimes: ['git'] })
|
||||
result = await run('echo git-draft>git.txt && git add git.txt && git commit -q -m persistent-private-commit', { runtimes: ['git'] })
|
||||
check('draft Git commits persist without a host write approval', result.ok && result.data.git?.scope === 'draft' && result.data.git.persistence === 'saved' && !await fs.stat(join(workspace, 'git.txt')).catch(() => null))
|
||||
result = await run('git log -1 --oneline && type git.txt', { runtimes: ['git'] })
|
||||
check('next command sees both private Git history and private worktree bytes', result.ok && result.data.stdout.includes('persistent-private-commit') && result.data.stdout.includes('git-draft'))
|
||||
result = await run('git log --all --oneline', { runtimes: ['git'], sessionId: 'other-session' })
|
||||
check('private Git history is isolated by session as well as project', result.ok && !result.data.stdout.includes('persistent-private-commit'))
|
||||
const beforeCrash = await revision()
|
||||
const worker = spawn(process.execPath, [fileURLToPath(import.meta.url), '--worker', workspace, stateRoot, context.sessionId], { windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] })
|
||||
let workerError = ''; worker.stderr.on('data', (bytes: Buffer) => { workerError = (workerError + bytes.toString()).slice(-4000) })
|
||||
const workerClosed = new Promise<void>((done, reject) => { worker.once('error', reject); worker.once('close', () => done()) })
|
||||
let marker: { runId: string } | undefined
|
||||
for (let attempt = 0; attempt < 240; attempt++) {
|
||||
marker = await workspaceRecord<{ runId: string }>(join(root, 'checkout.json')).catch(() => undefined)
|
||||
if (marker && await fs.stat(join(stateRoot, 'runs', marker.runId, 'stage/workspace/crash.txt')).catch(() => null)) break
|
||||
if (worker.exitCode !== null) throw new Error(`Worker exited before mutation: ${workerError}`)
|
||||
await new Promise(done => setTimeout(done, 40))
|
||||
}
|
||||
assert.ok(marker, workerError); worker.kill(); await workerClosed
|
||||
let recovered = false
|
||||
for (let attempt = 0; attempt < 60; attempt++) {
|
||||
try { await workspaceAction('recover', context); recovered = true; break }
|
||||
catch (error) { if (!/活跃|活动|未清理/.test((error as Error).message)) throw error; await new Promise(done => setTimeout(done, 60)) }
|
||||
}
|
||||
check('explicit recovery reclaims a dead main-process lock and checks the native lease', recovered && !await fs.stat(join(root, 'checkout.json')).catch(() => null))
|
||||
result = await run('git branch --list lost-on-crash && if exist crash.txt (exit /b 9) else (type git.txt)', { runtimes: ['git'] })
|
||||
check('actual host-process death restores prior draft and private Git revision', result.ok && result.data.stdout.includes('git-draft') && !result.data.stdout.includes('lost-on-crash') && !await fs.stat(join(workspace, 'crash.txt')).catch(() => null) && beforeCrash.tree.some(entry => entry.path === 'git.txt'))
|
||||
await workspaceAction('discard', context)
|
||||
result = await run('git log --all --oneline && if exist git.txt exit /b 9', { runtimes: ['git'] })
|
||||
check('discard archives private history and removes uncommitted private files', result.ok && !result.data.stdout.includes('persistent-private-commit'))
|
||||
await run('echo committed-despite-metadata-fault>recovery-host.txt')
|
||||
const activePath = join(root, 'active.json'), savedActive = join(root, 'held-active.json')
|
||||
committed = await workspaceAction('commit', { ...context, approve: async () => { await fs.rename(activePath, savedActive); await fs.mkdir(activePath); return true } })
|
||||
check('post-host-commit metadata failure preserves the actual committed outcome', committed.writeback === 'committed' && committed.state === 'interrupted' && (await fs.readFile(join(workspace, 'recovery-host.txt'), 'utf8')).includes('committed-despite-metadata-fault'))
|
||||
await fs.rmdir(activePath); await fs.rename(savedActive, activePath)
|
||||
status = await workspaceAction('recover', context)
|
||||
check('recovery resolves the native committed transaction before advancing the draft baseline', status.state === 'ready' && status.changes.length === 0 && status.lastTransactionId === committed.transactionId)
|
||||
await fs.writeFile(join(workspace, 'private.txt'), 'DUMMY_POLICY_FILTERED_CONTENT')
|
||||
await run('type private.txt', { runtimes: ['git'] })
|
||||
const policy = await readSandboxPolicy()
|
||||
await updateSandboxPolicy({ expectedRevision: policy.revision, rules: { deniedPaths: ['private.txt'] } })
|
||||
status = await workspaceAction('status', context)
|
||||
await assert.rejects(run('type private.txt'), /策略.*已变化/)
|
||||
check('policy change makes the old draft stale without returning denied filenames', status.state === 'stale' && status.changes.length === 0)
|
||||
await workspaceAction('discard', context)
|
||||
result = await run('if exist private.txt (exit /b 9) else (git log --all --oneline)', { runtimes: ['git'] })
|
||||
check('discard under new policy rebuilds filtered files and resets old private history', result.ok && !result.data.stdout.includes('DUMMY_POLICY_FILTERED_CONTENT'))
|
||||
const policy2 = await readSandboxPolicy(); await updateSandboxPolicy({ expectedRevision: policy2.revision, rules: { deniedPaths: [], writeback: 'deny' } })
|
||||
await workspaceAction('discard', context)
|
||||
result = await run('echo policy-private>denied-host.txt')
|
||||
await assert.rejects(workspaceAction('commit', context), /禁止写回/)
|
||||
check('writeback-deny permits private drafting while refusing host commit', result.ok && result.data.writeback === 'deferred' && !await fs.stat(join(workspace, 'denied-host.txt')).catch(() => null))
|
||||
const policy3 = await readSandboxPolicy(); await updateSandboxPolicy({ expectedRevision: policy3.revision, rules: { writeback: 'ask' } })
|
||||
await workspaceAction('discard', context)
|
||||
await fs.writeFile(join(workspace, 'package.json'), '{"name":"workspace-test","version":"1.0.0"}')
|
||||
await fs.writeFile(join(workspace, 'package-lock.json'), '{"name":"workspace-test","lockfileVersion":3}')
|
||||
await fs.mkdir(join(workspace, 'node_modules/offline-fixture'), { recursive: true })
|
||||
await fs.writeFile(join(workspace, 'node_modules/offline-fixture/index.js'), 'module.exports="DEPENDENCY_OK"')
|
||||
await importSandboxToolchain('node', process.execPath)
|
||||
await importSandboxDependencies(workspace, 'node')
|
||||
result = await run('node -e "require(\'fs\').writeFileSync(\'node-result.txt\',require(\'offline-fixture\'))"', { runtimes: ['node'], dependencies: ['node'] })
|
||||
check('read-only dependency injection works in the retained physical tree', result.ok && result.data.toolchains?.[0].kind === 'node' && !await fs.stat(join(root, 'worktree/node_modules')).catch(() => null))
|
||||
result = await run('type node-result.txt')
|
||||
check('dependency layers detach before checkpoint without losing project outputs', result.ok && result.data.stdout.includes('DEPENDENCY_OK'))
|
||||
const privateCheckpoint = await revision()
|
||||
await fs.writeFile(join(root, 'worktree/node-result.txt'), 'incomplete-private-sync')
|
||||
await fs.writeFile(join(root, 'mutation.json'), JSON.stringify({ version: 1, revision: privateCheckpoint.id }))
|
||||
await assert.rejects(run('echo unsafe>must-not-run.txt'), /中断|未确定/)
|
||||
await workspaceAction('recover', context)
|
||||
result = await run('type node-result.txt')
|
||||
check('private sync intent blocks reuse until explicit checkpoint recovery', result.ok && result.data.stdout.includes('DEPENDENCY_OK') && !await fs.stat(join(workspace, 'must-not-run.txt')).catch(() => null))
|
||||
const completed = await revision()
|
||||
await fs.writeFile(join(root, 'checkout.json'), JSON.stringify({ version: 1, runId: result.data.runId, revision: completed.previousRevision, physicalIdentity: completed.physicalIdentity }))
|
||||
await workspaceAction('recover', context)
|
||||
check('recovery accepts a completed checkpoint whose checkout acknowledgement was interrupted', (await revision()).id === completed.id && !await fs.stat(join(root, 'checkout.json')).catch(() => null))
|
||||
const failedAgain = await run('echo incomplete>node-result.txt && exit /b 7'), restored = await revision()
|
||||
await fs.writeFile(join(root, 'checkout.json'), JSON.stringify({ version: 1, runId: failedAgain.data.runId, revision: restored.previousRevision, physicalIdentity: privateCheckpoint.physicalIdentity }))
|
||||
await workspaceAction('recover', context)
|
||||
check('recovery is repeatable after rollback checkpoint publication before marker removal', (await revision()).id === restored.id)
|
||||
await fs.mkdir(join(workspace, 'source-tree/sub'), { recursive: true }); await fs.writeFile(join(workspace, 'source-tree/sub/data.bin'), Buffer.from([0, 1, 128, 255]))
|
||||
result = await run('move source-tree renamed-tree && mkdir empty\\nested')
|
||||
committed = await workspaceAction('commit', context)
|
||||
check('persistent tree rename and empty directories commit through the native batch', result.ok && committed.writeback === 'committed' && !await fs.stat(join(workspace, 'source-tree')).catch(() => null) && (await fs.readFile(join(workspace, 'renamed-tree/sub/data.bin'))).equals(Buffer.from([0, 1, 128, 255])) && (await fs.stat(join(workspace, 'empty/nested'))).isDirectory())
|
||||
await restoreTransaction(committed.transactionId!, workspace)
|
||||
result = await run('if exist renamed-tree (exit /b 9) else (dir source-tree\\sub)')
|
||||
check('native host restore is imported as an external change into a clean retained tree', result.ok && result.data.workspace?.changes.length === 0)
|
||||
await assert.rejects(run('rmdir /s /q source-tree && echo changed-type>source-tree'), /文件\/目录互换/)
|
||||
result = await run('dir source-tree\\sub')
|
||||
check('unsupported private file-directory swaps are rejected before publishing a poisoned revision', result.ok && result.data.stdout.includes('data.bin'))
|
||||
const activeCommand = run('echo READY_FOR_STATUS && powershell.exe -NoProfile -NonInteractive -Command "Start-Sleep -Seconds 2"')
|
||||
for (let attempt = 0; attempt < 100 && !await fs.stat(join(root, 'checkout.json')).catch(() => null); attempt++) await new Promise(done => setTimeout(done, 20))
|
||||
const observedAt = Date.now(), busy = await workspaceAction('status', context)
|
||||
check('status remains responsive while its owner is running a native command', busy.state === 'busy' && Date.now() - observedAt < 1500)
|
||||
await activeCommand
|
||||
await fs.copyFile(resolve('resources/sandbox/win32-x64/ycode-sandbox.exe'), join(workspace, 'owned-runner.exe'))
|
||||
result = await run('owned-runner.exe --seal-workspace "%CD%"')
|
||||
check('sandbox code cannot invoke the host-only ACL sealing entry point', !result.ok && /host-only/.test(result.data.stderr))
|
||||
await fs.unlink(join(workspace, 'owned-runner.exe'))
|
||||
const livePolicy = await readSandboxPolicy()
|
||||
const revoke = setTimeout(() => { void updateSandboxPolicy({ expectedRevision: livePolicy.revision, rules: { deniedPaths: ['file.txt'] } }) }, 1100)
|
||||
try { await assert.rejects(run('echo revoked>node-result.txt && powershell.exe -NoProfile -NonInteractive -Command "Start-Sleep -Seconds 10"'), /策略已变化/) }
|
||||
finally { clearTimeout(revoke) }
|
||||
status = await workspaceAction('status', context)
|
||||
check('policy revocation interrupts a live draft command and leaves old checkpoint stale', status.state === 'stale' && status.changes.length === 0 && !await fs.stat(join(root, 'checkout.json')).catch(() => null))
|
||||
console.log(`SANDBOX_WORKSPACE_PASS ${checks.length}/${checks.length}`)
|
||||
} finally {
|
||||
await fs.writeFile(join(fixture, 'report.json'), JSON.stringify({ fixture, checks }, null, 2) + '\n')
|
||||
console.log(`SANDBOX_WORKSPACE_REPORT ${join(fixture, 'report.json')}`)
|
||||
}
|
||||
@@ -1,163 +0,0 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { promises as fs } from 'node:fs'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { configureSandbox } from '../src/main/sandbox/configuration'
|
||||
import { NativeWorkspaceWriter } from '../src/main/sandbox/filesystem-writer'
|
||||
import { digest } from '../src/main/sandbox/transactions'
|
||||
import { commitChanges, restoreTransaction } from '../src/main/sandbox/transactions'
|
||||
import { executeSandboxCommand } from '../src/main/sandbox/service'
|
||||
import { executeTool } from '../src/main/agent/tools'
|
||||
import { execFileSync, type ChildProcess } from 'node:child_process'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
|
||||
if (process.platform !== 'win32') throw new Error('Native write integration requires Windows')
|
||||
await fs.mkdir(resolve('.tmp'), { recursive: true })
|
||||
const fixture = await fs.mkdtemp(resolve('.tmp/sandbox-write-'))
|
||||
const workspace = join(fixture, 'workspace'), stateRoot = join(fixture, 'state')
|
||||
await fs.mkdir(workspace)
|
||||
configureSandbox({ stateRoot, resourceDirectory: resolve('resources/sandbox/win32-x64') })
|
||||
const checks: Array<{ label: string; pass: boolean }> = []
|
||||
function check(label: string, condition: unknown): void { checks.push({ label, pass: !!condition }); assert.ok(condition, label); console.log(`PASS ${label}`) }
|
||||
try {
|
||||
await fs.writeFile(join(workspace, 'sample.txt'), 'before')
|
||||
const writer = await NativeWorkspaceWriter.create(workspace)
|
||||
try {
|
||||
const before = await writer.inspect('sample.txt', true)
|
||||
check('native transaction reads exact bytes under a pinned root', before.bytes?.toString() === 'before')
|
||||
await writer.replace('sample.txt', digest(Buffer.from('before')), Buffer.from('after'))
|
||||
check('replacement succeeds while original target remains pinned', await fs.readFile(join(workspace, 'sample.txt'), 'utf8') === 'after')
|
||||
await assert.rejects(writer.replace('sample.txt', digest(Buffer.from('before')), Buffer.from('wrong')), /changed/)
|
||||
check('stale expected content cannot overwrite the target', await fs.readFile(join(workspace, 'sample.txt'), 'utf8') === 'after')
|
||||
} finally { await writer.close() }
|
||||
await fs.mkdir(join(workspace, 'nested'))
|
||||
await fs.writeFile(join(workspace, 'nested/data.bin'), Buffer.from([0, 255, 1, 127]))
|
||||
const outside = join(fixture, 'outside'); await fs.mkdir(outside)
|
||||
await fs.writeFile(join(outside, 'data.bin'), 'OUTSIDE_UNCHANGED')
|
||||
const pinned = await NativeWorkspaceWriter.create(workspace)
|
||||
try {
|
||||
await pinned.inspect('nested/data.bin', true)
|
||||
const moved = await fs.rename(join(workspace, 'nested'), join(workspace, 'moved')).then(() => true, () => false)
|
||||
check('pinned ancestor cannot be renamed into a junction attack', !moved)
|
||||
const rootMoved = await fs.rename(workspace, join(fixture, 'replaced-root')).then(() => true, () => false)
|
||||
check('workspace root remains pinned for the whole transaction session', !rootMoved)
|
||||
await pinned.replace('nested/data.bin', digest(Buffer.from([0, 255, 1, 127])), Buffer.alloc(0))
|
||||
check('nested binary-to-empty replacement preserves byte semantics', (await fs.stat(join(workspace, 'nested/data.bin'))).size === 0)
|
||||
await pinned.replace('created.bin', null, Buffer.from([0, 128, 255]))
|
||||
await assert.rejects(pinned.replace('created.bin', null, Buffer.from('overwrite')), /changed/)
|
||||
check('creation refuses to overwrite a target that already exists', (await fs.readFile(join(workspace, 'created.bin'))).equals(Buffer.from([0, 128, 255])))
|
||||
for (const path of ['../outside/data.bin', '.env', 'stream.txt:ads', '.ycode-sandbox-owned.tmp']) await assert.rejects(pinned.replace(path, null, Buffer.from('blocked')))
|
||||
check('outside, sensitive, ADS and reserved transaction paths are denied', await fs.readFile(join(outside, 'data.bin'), 'utf8') === 'OUTSIDE_UNCHANGED')
|
||||
await fs.symlink(outside, join(workspace, 'junction'), 'junction')
|
||||
await assert.rejects(pinned.replace('junction/data.bin', digest(Buffer.from('OUTSIDE_UNCHANGED')), Buffer.from('blocked')))
|
||||
check('native relative opens reject a junction target', await fs.readFile(join(outside, 'data.bin'), 'utf8') === 'OUTSIDE_UNCHANGED')
|
||||
await fs.unlink(join(workspace, 'junction'))
|
||||
await fs.link(join(outside, 'data.bin'), join(workspace, 'hardlink.bin'))
|
||||
await assert.rejects(pinned.replace('hardlink.bin', digest(Buffer.from('OUTSIDE_UNCHANGED')), Buffer.from('blocked')), /hard link/)
|
||||
check('native replacement rejects hard-linked files', await fs.readFile(join(outside, 'data.bin'), 'utf8') === 'OUTSIDE_UNCHANGED')
|
||||
await fs.unlink(join(workspace, 'hardlink.bin'))
|
||||
const busy = await fs.open(join(workspace, 'sample.txt'), 'r+')
|
||||
try { await assert.rejects(pinned.replace('sample.txt', digest(Buffer.from('after')), Buffer.from('blocked')), /win32=32/) }
|
||||
finally { await busy.close() }
|
||||
check('existing writable handles fail closed without relaxed sharing', await fs.readFile(join(workspace, 'sample.txt'), 'utf8') === 'after')
|
||||
await fs.chmod(join(workspace, 'sample.txt'), 0o444)
|
||||
try { await assert.rejects(pinned.replace('sample.txt', digest(Buffer.from('after')), Buffer.from('blocked')), /read-only/) }
|
||||
finally { await fs.chmod(join(workspace, 'sample.txt'), 0o666) }
|
||||
check('read-only targets are rejected before handoff', await fs.readFile(join(workspace, 'sample.txt'), 'utf8') === 'after')
|
||||
await pinned.replace('created.bin', digest(Buffer.from([0, 128, 255])), null)
|
||||
check('native deletion removes only the verified file object', !await fs.stat(join(workspace, 'created.bin')).catch(() => null))
|
||||
} finally { await pinned.close() }
|
||||
await fs.rename(join(workspace, 'nested'), join(workspace, 'released'))
|
||||
check('session close releases ancestor pins', (await fs.stat(join(workspace, 'released'))).isDirectory())
|
||||
const context = { workspaceRoot: workspace, sessionId: 'write-session', runId: 'write-run', toolCallId: 'write-call', approve: async () => true }
|
||||
await fs.copyFile(resolve('resources/sandbox/win32-x64/ycode-sandbox.exe'), join(workspace, 'runner-copy.exe'))
|
||||
const hostOnly = await executeSandboxCommand('runner-copy.exe --workspace-write "%CD%" -', context)
|
||||
check('sandbox descendants cannot invoke the host transaction entry point', !hostOnly.ok && /host-only/.test(hostOnly.data.stderr))
|
||||
await fs.unlink(join(workspace, 'runner-copy.exe'))
|
||||
const tool = await executeTool('write_file', { path: 'implicit/a/b/file.txt', content: 'implicit-content' }, context)
|
||||
const transactionId = (tool.data as { transactionId?: string })?.transactionId
|
||||
check('default file tool commits through the native transaction backend', tool.ok && !!transactionId && await fs.readFile(join(workspace, 'implicit/a/b/file.txt'), 'utf8') === 'implicit-content')
|
||||
const journal = JSON.parse(await fs.readFile(join(stateRoot, 'transactions', transactionId!, 'manifest.json'), 'utf8'))
|
||||
check('journal binds root identity and records implicit parents and mutation intent', journal.version === 3 && journal.workspaceIdentity && journal.directories.length === 3 && journal.mutations.length === 1)
|
||||
await restoreTransaction(transactionId!, workspace)
|
||||
check('recovery removes implicitly created parents as well as file bytes', !await fs.stat(join(workspace, 'implicit')).catch(() => null))
|
||||
let attempts = 0
|
||||
const guardedId = await commitChanges(workspace, [{ path: 'released/data.bin', beforeHash: digest(Buffer.alloc(0)), after: Buffer.from('guarded') }], context, undefined, {
|
||||
beforeApply: async () => {
|
||||
for (let i = 0; i < 20; i++) if (!await fs.rename(join(workspace, 'released'), join(workspace, 'attack-moved')).then(() => true, () => false)) attempts++
|
||||
}, check: async () => undefined
|
||||
})
|
||||
check('real commit rejects repeated ancestor replacement attempts after preflight', attempts === 20 && await fs.readFile(join(workspace, 'released/data.bin'), 'utf8') === 'guarded' && await fs.readFile(join(outside, 'data.bin'), 'utf8') === 'OUTSIDE_UNCHANGED')
|
||||
await restoreTransaction(guardedId, workspace)
|
||||
await fs.mkdir(join(workspace, 'delete-tree')); await fs.writeFile(join(workspace, 'delete-tree/original.txt'), 'original')
|
||||
await assert.rejects(commitChanges(workspace, [{ path: 'delete-tree/original.txt', beforeHash: digest(Buffer.from('original')), after: null }], context, undefined,
|
||||
{ beforeApply: async () => { await fs.writeFile(join(workspace, 'delete-tree/external.txt'), 'external') }, check: async () => undefined }, [{ path: 'delete-tree', before: true, after: false }]))
|
||||
check('late directory additions stop deletion and compensate earlier file removals', await fs.readFile(join(workspace, 'delete-tree/original.txt'), 'utf8') === 'original' && await fs.readFile(join(workspace, 'delete-tree/external.txt'), 'utf8') === 'external')
|
||||
await assert.rejects(commitChanges(workspace, [{ path: 'sample.txt', beforeHash: digest(Buffer.from('after')), after: Buffer.from('approved') }], context, undefined,
|
||||
{ beforeApply: async () => { await fs.writeFile(join(workspace, 'sample.txt'), 'external-edit') }, check: async () => undefined }))
|
||||
check('post-preflight external file edits survive a rejected commit', await fs.readFile(join(workspace, 'sample.txt'), 'utf8') === 'external-edit')
|
||||
for (const route of ['command', 'file-tool']) {
|
||||
const targetRoot = join(fixture, `approval-${route}`), movedRoot = `${targetRoot}-old`
|
||||
await fs.mkdir(targetRoot); await fs.writeFile(join(targetRoot, 'sample.txt'), 'before-approval')
|
||||
const approve = async (): Promise<boolean> => { await fs.rename(targetRoot, movedRoot); await fs.mkdir(targetRoot); await fs.writeFile(join(targetRoot, 'sample.txt'), 'before-approval'); return true }
|
||||
if (route === 'command') await assert.rejects(executeSandboxCommand('echo approved>sample.txt', { ...context, workspaceRoot: targetRoot, approve }), /identity changed|被替换/)
|
||||
else assert.equal((await executeTool('write_file', { path: 'sample.txt', content: 'approved' }, { ...context, workspaceRoot: targetRoot, approve })).ok, false)
|
||||
check(`${route} approval cannot authorize a replacement root with identical contents`, await fs.readFile(join(targetRoot, 'sample.txt'), 'utf8') === 'before-approval' && await fs.readFile(join(movedRoot, 'sample.txt'), 'utf8') === 'before-approval')
|
||||
}
|
||||
const rootRecovery = join(fixture, 'root-recovery'); await fs.mkdir(rootRecovery); await fs.writeFile(join(rootRecovery, 'sample.txt'), 'before')
|
||||
const rootTransaction = await commitChanges(rootRecovery, [{ path: 'sample.txt', beforeHash: digest(Buffer.from('before')), after: Buffer.from('after') }])
|
||||
await fs.rename(rootRecovery, `${rootRecovery}-old`); await fs.mkdir(rootRecovery); await fs.writeFile(join(rootRecovery, 'sample.txt'), 'after')
|
||||
await assert.rejects(restoreTransaction(rootTransaction, rootRecovery), /identity changed/)
|
||||
check('recovery refuses a same-path replacement root with matching file hashes', await fs.readFile(join(rootRecovery, 'sample.txt'), 'utf8') === 'after')
|
||||
await fs.writeFile(join(workspace, 'first.txt'), 'first-before'); await fs.writeFile(join(workspace, 'second.txt'), 'second-before')
|
||||
let phase = 0
|
||||
const killed = await commitChanges(workspace, [
|
||||
{ path: 'first.txt', beforeHash: digest(Buffer.from('first-before')), after: Buffer.from('first-after') },
|
||||
{ path: 'second.txt', beforeHash: digest(Buffer.from('second-before')), after: Buffer.from('second-after') }
|
||||
], context, undefined, { beforeApply: async () => undefined, check: async () => {
|
||||
if (++phase !== 2) return
|
||||
const query = `Get-CimInstance Win32_Process -Filter 'ParentProcessId=${process.pid}' | Where-Object { $_.Name -eq 'ycode-sandbox.exe' } | Select-Object -ExpandProperty ProcessId`
|
||||
const pids = execFileSync('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', query], { encoding: 'utf8', windowsHide: true }).trim().split(/\s+/).map(Number).filter(Number.isSafeInteger)
|
||||
assert.equal(pids.length, 1); process.kill(pids[0]); await new Promise(done => setTimeout(done, 40))
|
||||
} }).then(() => null, error => error as Error & { transactionId?: string; recoveryRequired?: boolean })
|
||||
check('native backend death reports recovery-required after a partial batch', killed?.recoveryRequired && !!killed.transactionId && await fs.readFile(join(workspace, 'first.txt'), 'utf8') === 'first-after')
|
||||
await restoreTransaction(killed!.transactionId!, workspace)
|
||||
check('a new native session recovers a batch after actual backend death', await fs.readFile(join(workspace, 'first.txt'), 'utf8') === 'first-before' && await fs.readFile(join(workspace, 'second.txt'), 'utf8') === 'second-before')
|
||||
const handoff = await commitChanges(workspace, [{ path: 'first.txt', beforeHash: digest(Buffer.from('first-before')), after: Buffer.from('handoff-after') }])
|
||||
const manifestPath = join(stateRoot, 'transactions', handoff, 'manifest.json'), manifest = JSON.parse(await fs.readFile(manifestPath, 'utf8'))
|
||||
const intent = manifest.mutations[0], oldScratch = join(workspace, `.ycode-sandbox-${intent.nonce}-old.tmp`), newScratch = join(workspace, `.ycode-sandbox-${intent.nonce}-new.tmp`)
|
||||
await fs.unlink(join(workspace, 'first.txt')); await fs.writeFile(oldScratch, 'first-before'); await fs.writeFile(newScratch, 'handoff-after')
|
||||
manifest.status = 'applying'; manifest.mutations = []; await fs.writeFile(manifestPath, JSON.stringify(manifest))
|
||||
await restoreTransaction(handoff, workspace)
|
||||
check('interrupted-handoff disk fixture restores absent basename and cleans verified scratch', await fs.readFile(join(workspace, 'first.txt'), 'utf8') === 'first-before' && !await fs.stat(oldScratch).catch(() => null) && !await fs.stat(newScratch).catch(() => null))
|
||||
const blocked = await NativeWorkspaceWriter.create(workspace)
|
||||
try {
|
||||
const nonce = randomUUID().replaceAll('-', ''), scratch = join(workspace, `.ycode-sandbox-${nonce}-old.tmp`)
|
||||
await fs.writeFile(scratch, 'foreign-content')
|
||||
await assert.rejects(blocked.cleanup('first.txt', nonce, digest(Buffer.from('first-before')), null), /changed externally/)
|
||||
check('scratch cleanup refuses unrelated or externally changed contents', await fs.readFile(scratch, 'utf8') === 'foreign-content')
|
||||
} finally { await blocked.close() }
|
||||
const dying = await NativeWorkspaceWriter.create(workspace)
|
||||
const child = Reflect.get(dying, 'child') as ChildProcess
|
||||
child.kill(); await new Promise(done => setTimeout(done, 30))
|
||||
await assert.rejects(dying.replace('first.txt', digest(Buffer.from('first-before')), Buffer.from('never')))
|
||||
await dying.close()
|
||||
check('dead native channel cannot fall back to an ordinary host write', await fs.readFile(join(workspace, 'first.txt'), 'utf8') === 'first-before')
|
||||
const cliRoot = join(fixture, 'CLI work \u4e2d\u6587 ^'); await fs.mkdir(cliRoot); await fs.writeFile(join(cliRoot, 'sample.txt'), 'cli-before')
|
||||
const cliId = await commitChanges(cliRoot, [{ path: 'sample.txt', beforeHash: digest(Buffer.from('cli-before')), after: Buffer.from('cli-after') }])
|
||||
execFileSync(process.execPath, ['scripts/sandbox-maintain.mjs', 'restore', cliId, '--workspace', cliRoot, '--state-root', stateRoot], { windowsHide: true, timeout: 30_000, stdio: 'pipe' })
|
||||
check('actual maintenance CLI restores v3 at a space Unicode caret path', await fs.readFile(join(cliRoot, 'sample.txt'), 'utf8') === 'cli-before')
|
||||
for (const version of [1, 2]) {
|
||||
const id = await commitChanges(cliRoot, [{ path: 'sample.txt', beforeHash: digest(Buffer.from('cli-before')), after: Buffer.from('legacy-after') }])
|
||||
const path = join(stateRoot, 'transactions', id, 'manifest.json'), legacy = JSON.parse(await fs.readFile(path, 'utf8'))
|
||||
legacy.version = version; delete legacy.workspaceIdentity; delete legacy.mutationJournal; delete legacy.mutations
|
||||
if (version === 1) delete legacy.directories
|
||||
await fs.writeFile(path, JSON.stringify(legacy))
|
||||
await restoreTransaction(id, cliRoot)
|
||||
const upgraded = JSON.parse(await fs.readFile(path, 'utf8'))
|
||||
check(`legacy v${version} recovery uses native backend and upgrades root identity`, await fs.readFile(join(cliRoot, 'sample.txt'), 'utf8') === 'cli-before' && upgraded.version === 3 && !!upgraded.workspaceIdentity)
|
||||
}
|
||||
console.log(`SANDBOX_WRITE_PASS ${checks.length}/${checks.length}`)
|
||||
} finally {
|
||||
await fs.writeFile(join(fixture, 'report.json'), JSON.stringify({ fixture, checks }, null, 2) + '\n')
|
||||
console.log(`SANDBOX_WRITE_REPORT ${join(fixture, 'report.json')}`)
|
||||
}
|
||||
@@ -128,20 +128,6 @@ async function main(): Promise<void> {
|
||||
export class WebContentsView {}
|
||||
`
|
||||
}))
|
||||
pluginBuild.onResolve({ filter: /^(?:\.\/|\.\.\/)omp$/ }, () => ({ path: 'omp', namespace: 'mock-omp' }))
|
||||
pluginBuild.onLoad({ filter: /.*/, namespace: 'mock-omp' }, () => ({
|
||||
loader: 'js',
|
||||
contents: `
|
||||
export class OmpKernel {
|
||||
constructor() {}
|
||||
prompt() { return Promise.resolve({ type: 'response', success: true }); }
|
||||
abort() { return Promise.resolve({ type: 'response', success: true }); }
|
||||
dispose() {}
|
||||
}
|
||||
export const detectOmpPath = () => null;
|
||||
export const getOmpInfo = () => ({ available: false, exePath: null, profile: 'ycode', model: null });
|
||||
`
|
||||
}))
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
@@ -1,246 +0,0 @@
|
||||
/**
|
||||
* 最小闭环冒烟测试(不依赖 Electron、不依赖真实 API Key)。
|
||||
*
|
||||
* 用一个本地假上游(OpenAI 兼容 SSE)驱动真实的 gateway + agent loop,
|
||||
* 验证:SSE 流式解析 → 流式工具调用组装 → 工具执行 → 上下文回填 → 终态。
|
||||
*
|
||||
* 运行:npm run smoke
|
||||
*/
|
||||
|
||||
import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http'
|
||||
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { Gateway } from '../src/main/gateway'
|
||||
import { runTurn } from '../src/main/agent/loop'
|
||||
import { executeTool } from '../src/main/agent/tools'
|
||||
import type { ChatEvent, ChatMessage } from '../src/shared/types'
|
||||
|
||||
let failures = 0
|
||||
|
||||
function check(label: string, condition: boolean, detail = ''): void {
|
||||
if (condition) {
|
||||
console.log(` ✓ ${label}`)
|
||||
} else {
|
||||
failures++
|
||||
console.error(` ✗ ${label}${detail ? ` — ${detail}` : ''}`)
|
||||
}
|
||||
}
|
||||
|
||||
function sseChunk(payload: unknown): string {
|
||||
return `data: ${JSON.stringify(payload)}\n\n`
|
||||
}
|
||||
|
||||
/** 假上游:第 1 次请求返回工具调用,第 2 次返回最终文本 */
|
||||
function startFakeUpstream(): Promise<{ server: Server; port: number; requests: unknown[] }> {
|
||||
const requests: unknown[] = []
|
||||
|
||||
const server = createServer((req: IncomingMessage, res: ServerResponse) => {
|
||||
let raw = ''
|
||||
req.on('data', (c) => (raw += c))
|
||||
req.on('end', () => {
|
||||
const body = JSON.parse(raw || '{}')
|
||||
requests.push(body)
|
||||
const round = requests.length
|
||||
|
||||
res.writeHead(200, {
|
||||
'content-type': 'text/event-stream',
|
||||
'cache-control': 'no-cache',
|
||||
connection: 'keep-alive'
|
||||
})
|
||||
|
||||
if (round === 1) {
|
||||
// 分片下发工具调用,模拟真实上游的增量行为
|
||||
res.write(
|
||||
sseChunk({
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
delta: {
|
||||
tool_calls: [
|
||||
{
|
||||
index: 0,
|
||||
id: 'call_abc',
|
||||
type: 'function',
|
||||
function: { name: 'list_dir', arguments: '{"pa' }
|
||||
}
|
||||
]
|
||||
},
|
||||
finish_reason: null
|
||||
}
|
||||
]
|
||||
})
|
||||
)
|
||||
res.write(
|
||||
sseChunk({
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
delta: {
|
||||
tool_calls: [{ index: 0, function: { arguments: 'th":"."}' } }]
|
||||
},
|
||||
finish_reason: null
|
||||
}
|
||||
]
|
||||
})
|
||||
)
|
||||
res.write(sseChunk({ choices: [{ index: 0, delta: {}, finish_reason: 'tool_calls' }] }))
|
||||
} else {
|
||||
for (const piece of ['工作目录里有 ', 'a.txt', ' 和 ', 'b.txt', '。']) {
|
||||
res.write(sseChunk({ choices: [{ index: 0, delta: { content: piece } }] }))
|
||||
}
|
||||
res.write(sseChunk({ choices: [{ index: 0, delta: {}, finish_reason: 'stop' }] }))
|
||||
}
|
||||
|
||||
res.write('data: [DONE]\n\n')
|
||||
res.end()
|
||||
})
|
||||
})
|
||||
|
||||
return new Promise((resolve) => {
|
||||
server.listen(0, '127.0.0.1', () => {
|
||||
const address = server.address()
|
||||
const port = typeof address === 'object' && address ? address.port : 0
|
||||
resolve({ server, port, requests })
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
// ---------- 准备临时工作目录 ----------
|
||||
const workspaceRoot = await mkdtemp(join(tmpdir(), 'ycode-smoke-'))
|
||||
await writeFile(join(workspaceRoot, 'a.txt'), 'hello a', 'utf8')
|
||||
await writeFile(join(workspaceRoot, 'b.txt'), 'hello b', 'utf8')
|
||||
|
||||
const { server, port, requests } = await startFakeUpstream()
|
||||
|
||||
try {
|
||||
const gateway = new Gateway({
|
||||
baseUrl: `http://127.0.0.1:${port}/v1`,
|
||||
model: 'fake-model',
|
||||
apiKey: 'test-key',
|
||||
temperature: 0.7,
|
||||
maxToolRounds: 4
|
||||
})
|
||||
|
||||
const events: ChatEvent[] = []
|
||||
const history: ChatMessage[] = []
|
||||
|
||||
console.log('\n[1] Agent 闭环:工具调用 → 执行 → 回填 → 终态')
|
||||
const result = await runTurn({
|
||||
runId: 'run-smoke',
|
||||
history,
|
||||
userText: '列出工作目录里有哪些文件',
|
||||
signal: new AbortController().signal,
|
||||
emit: (event) => events.push(event),
|
||||
gateway,
|
||||
model: 'fake-model',
|
||||
maxToolRounds: 4,
|
||||
workspaceRoot
|
||||
})
|
||||
|
||||
check('上游被调用 2 次(工具轮 + 收尾轮)', requests.length === 2, `实际 ${requests.length}`)
|
||||
|
||||
const textDeltas = events.filter((e) => e.type === 'text')
|
||||
check('收到流式文本增量 ≥ 5 段', textDeltas.length >= 5, `实际 ${textDeltas.length}`)
|
||||
|
||||
check(
|
||||
'最终文本拼接正确',
|
||||
result.content === '工作目录里有 a.txt 和 b.txt。',
|
||||
`实际「${result.content}」`
|
||||
)
|
||||
|
||||
check('产生 1 条工具调用记录', result.tools.length === 1, `实际 ${result.tools.length}`)
|
||||
const tool = result.tools[0]
|
||||
check('工具名为 list_dir', tool?.name === 'list_dir', `实际 ${tool?.name}`)
|
||||
check(
|
||||
'流式分片的 arguments 被正确拼成 JSON',
|
||||
JSON.stringify(tool?.args) === JSON.stringify({ path: '.' }),
|
||||
`实际 ${JSON.stringify(tool?.args)}`
|
||||
)
|
||||
check('工具执行成功', tool?.status === 'ok', `实际 ${tool?.status}`)
|
||||
check('工具结果含 a.txt', tool?.summary?.includes('a.txt') === true)
|
||||
|
||||
const doneEvent = events.find((e) => e.type === 'done')
|
||||
check('发出 done 终态事件', doneEvent !== undefined)
|
||||
|
||||
// 第 2 次请求应包含回填的 assistant(tool_calls) 与 tool 结果
|
||||
const second = requests[1] as { messages: Array<Record<string, unknown>> }
|
||||
check(
|
||||
'第 2 次请求回填了 assistant.tool_calls',
|
||||
Array.isArray(second?.messages) &&
|
||||
second.messages.some((m) => m.role === 'assistant' && Array.isArray(m.tool_calls))
|
||||
)
|
||||
check(
|
||||
'第 2 次请求回填了 tool 角色结果',
|
||||
Array.isArray(second?.messages) &&
|
||||
second.messages.some(
|
||||
(m) => m.role === 'tool' && String(m.content ?? '').includes('a.txt')
|
||||
)
|
||||
)
|
||||
|
||||
console.log('\n[2] 工具安全边界:工作区逃逸必须被拒绝')
|
||||
const escape = await executeTool('read_file', { path: '../../../../etc/passwd' }, { workspaceRoot })
|
||||
check('越界读取被拒绝', escape.ok === false, `实际 ok=${escape.ok}`)
|
||||
check('拒绝理由可读', escape.content.includes('工作区内'))
|
||||
|
||||
const unknown = await executeTool('not_a_tool', {}, { workspaceRoot })
|
||||
check('未知工具被拒绝', unknown.ok === false)
|
||||
|
||||
const normal = await executeTool('read_file', { path: 'a.txt' }, { workspaceRoot })
|
||||
check('工作区内读取正常', normal.ok === true && normal.content.includes('hello a'))
|
||||
|
||||
console.log('\n[3] 取消与错误传播')
|
||||
const abortController = new AbortController()
|
||||
abortController.abort()
|
||||
const cancelled = await runTurn({
|
||||
runId: 'run-cancel',
|
||||
history: [],
|
||||
userText: 'x',
|
||||
signal: abortController.signal,
|
||||
emit: () => {},
|
||||
gateway,
|
||||
model: 'fake-model',
|
||||
maxToolRounds: 4,
|
||||
workspaceRoot
|
||||
}).then(
|
||||
() => 'resolved',
|
||||
(err: Error) => err.name
|
||||
)
|
||||
check('已取消的信号会中断回合', cancelled === 'AbortError', `实际 ${cancelled}`)
|
||||
|
||||
const badGateway = new Gateway({
|
||||
baseUrl: `http://127.0.0.1:${port}/v1`,
|
||||
model: 'fake-model',
|
||||
apiKey: '',
|
||||
temperature: 0.7,
|
||||
maxToolRounds: 2
|
||||
})
|
||||
const missingKey = await runTurn({
|
||||
runId: 'run-nokey',
|
||||
history: [],
|
||||
userText: 'x',
|
||||
signal: new AbortController().signal,
|
||||
emit: () => {},
|
||||
gateway: badGateway,
|
||||
model: 'fake-model',
|
||||
maxToolRounds: 2,
|
||||
workspaceRoot
|
||||
}).then(
|
||||
() => 'resolved',
|
||||
(err: Error) => err.message
|
||||
)
|
||||
check('缺少 Key 时给出明确错误', String(missingKey).includes('API Key'), `实际 ${missingKey}`)
|
||||
} finally {
|
||||
server.close()
|
||||
await rm(workspaceRoot, { recursive: true, force: true })
|
||||
}
|
||||
|
||||
console.log(failures === 0 ? '\n全部通过 ✓\n' : `\n${failures} 项失败 ✗\n`)
|
||||
process.exit(failures === 0 ? 0 : 1)
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error('冒烟测试异常终止:', err)
|
||||
process.exit(1)
|
||||
})
|
||||
@@ -1,314 +0,0 @@
|
||||
// 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 ? 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 = 'legacy-sandbox-mode-inert'
|
||||
const approval = await start('agent-approval', 'default-sandbox')
|
||||
const approvedMessage = await finish(approval.session)
|
||||
check('APP: legacy default-sandbox value is inert; child write lands on the host without an approval prompt',
|
||||
approvedMessage.status === 'done' && (approvedMessage.interactions ?? []).every(item => item.status !== 'pending')
|
||||
&& await fs.readFile(path.join(workspaceRoot, 'approval.txt'), 'utf8') === 'WRITTEN_BY_CHILD_A')
|
||||
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)
|
||||
})
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import type { ChatMessage } from '../src/shared/types'
|
||||
import type { SubagentSnapshot } from '../src/shared/subagents'
|
||||
import { collectTurnMembers, projectMemberMessage, selectComposerTeam, sortMembers } from '../src/renderer/src/workbuddy/subagents/member-view'
|
||||
import { useWorkbenchStore } from '../src/renderer/src/stores/workbench'
|
||||
|
||||
let checks = 0
|
||||
const check = (name: string, condition: unknown): void => { assert.ok(condition, name); checks++; console.log('PASS ' + name) }
|
||||
const base: SubagentSnapshot = { id: 'a', name: '分析', task: '检查消息', status: 'running', access: 'read-only', files: [], modelName: 'local', createdAt: 1, events: [], tools: [] }
|
||||
const parent: ChatMessage = { id: 'parent-a', role: 'assistant', content: 'parent', createdAt: 1, parts: [
|
||||
{ type: 'subagent', agent: { ...base, status: 'completed' } },
|
||||
{ type: 'subagent', agent: { ...base, id: 'b', name: '验收', status: 'queued' } },
|
||||
{ type: 'subagent', agent: base }
|
||||
] }
|
||||
const members = collectTurnMembers(parent)
|
||||
check('same child snapshot upserts without duplicating membership', members.length === 2 && members[0].agent === base)
|
||||
check('membership remains scoped to its own assistant turn', collectTurnMembers({ ...parent, id: 'next-turn', parts: [] }).length === 0)
|
||||
const next: ChatMessage = { ...parent, id: 'next-turn', parts: [], status: 'streaming' }
|
||||
check('input activity belongs to the active assistant rather than an older team', selectComposerTeam([parent, next], next.id) === undefined)
|
||||
check('an idle latest reply without children does not resurrect an earlier team', selectComposerTeam([parent, { ...next, status: 'done' }]) === undefined)
|
||||
check('hydration with a missing active assistant never falls back to stale members', selectComposerTeam([parent], 'not-loaded') === undefined)
|
||||
check('latest completed team remains reachable from the input area', selectComposerTeam([{ ...parent, status: 'done' }])?.assistantId === parent.id && selectComposerTeam([parent])?.members.length === 2)
|
||||
const completed = [{ ...members[0], agent: { ...base, status: 'completed' as const } }, members[1]]
|
||||
const sorted = sortMembers(completed)
|
||||
check('running or queued members sort first without changing task ordinals', sorted[0].agent.id === 'b' && sorted[0].ordinal === 2 && completed[0].agent.id === 'a')
|
||||
const tool = { id: '1:tool', name: 'read_file', args: { path: 'src/a.ts' }, argsRaw: '{"path":"src/a.ts"}', status: 'ok' as const, output: 'REAL_FILE_OUTPUT' }
|
||||
const child = { ...base, events: [
|
||||
{ id: 'thought', kind: 'reasoning' as const, text: '先核对入口' },
|
||||
{ id: tool.id, kind: 'tool' as const, name: tool.name, status: 'ok' as const, text: 'DISPLAY_SUMMARY' },
|
||||
{ id: 'reply', kind: 'text' as const, text: 'partial reply' }
|
||||
], tools: [tool], result: 'complete reply', status: 'completed' as const, finishedAt: 2000 }
|
||||
const before = JSON.stringify(child)
|
||||
const projected = projectMemberMessage(child)
|
||||
check('detail preserves thinking, tool and reply order', projected.parts?.map(part => part.type).join(',') === 'reasoning,tool,text')
|
||||
check('detail uses actual child tool arguments and output', projected.tools?.[0] === tool && projected.tools[0].output === 'REAL_FILE_OUTPUT')
|
||||
check('complete child result replaces its truncated streaming tail exactly once', projected.content === 'complete reply' && projected.parts?.filter(part => part.type === 'text').length === 1)
|
||||
check('projection never mutates the persisted child snapshot', JSON.stringify(child) === before)
|
||||
check('member reasoning is finished only for a terminal child', projected.parts?.[0].type === 'reasoning' && projected.parts[0].finished === true && projectMemberMessage({ ...child, status: 'running' }).status === 'streaming')
|
||||
check('legacy snapshots retain real tool records when events are absent', projectMemberMessage({ ...base, tools: [tool] }).tools?.[0] === tool)
|
||||
check('cancelled children remain cancelled in the detail view', projectMemberMessage({ ...base, status: 'cancelled' }).status === 'cancelled')
|
||||
|
||||
const api = useWorkbenchStore.getState()
|
||||
api.openView('session-a', 'fileTree')
|
||||
const tab = useWorkbenchStore.getState().sessions['session-a'].activeTabId!
|
||||
api.openMember('session-a', 'parent-a', 'a')
|
||||
check('opening a member preserves existing workbench tabs and opens its surface', useWorkbenchStore.getState().sessions['session-a'].member?.agentId === 'a' && useWorkbenchStore.getState().sessions['session-a'].open && useWorkbenchStore.getState().sessions['session-a'].tabs.length === 1)
|
||||
api.openMember('session-b', 'parent-b', 'b')
|
||||
check('member selection is isolated between conversations', useWorkbenchStore.getState().sessions['session-a'].member?.agentId === 'a' && useWorkbenchStore.getState().sessions['session-b'].member?.agentId === 'b')
|
||||
api.select('session-a', tab)
|
||||
check('selecting a workbench tab exits the member surface without removing child history', !useWorkbenchStore.getState().sessions['session-a'].member && useWorkbenchStore.getState().sessions['session-a'].activeTabId === tab && useWorkbenchStore.getState().sessions['session-b'].member?.agentId === 'b')
|
||||
api.openMember('session-a', 'parent-a', 'a')
|
||||
await api.openResource('session-a', 'session-a', 'artifact:demo', undefined, { kind: 'artifact', path: 'artifact:demo', name: '产物', content: 'content' })
|
||||
check('opening an artifact from child tools replaces the member surface', !useWorkbenchStore.getState().sessions['session-a'].member && useWorkbenchStore.getState().sessions['session-a'].tabs.length === 2)
|
||||
api.closeMember('session-b')
|
||||
check('closing a member preserves its conversation workbench', !useWorkbenchStore.getState().sessions['session-b'].member)
|
||||
console.log('SUBAGENT_DISPLAY_PASS ' + JSON.stringify({ checks }))
|
||||
@@ -1,257 +0,0 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { mkdtemp, readFile, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { SubagentScheduler, type SubagentWorker } from '../src/main/subagents/scheduler'
|
||||
import { executeTurnTool, type ConversationToolContext } from '../src/main/agent/conversation-tools'
|
||||
import { toolSpecs } from '../src/main/agent/tools'
|
||||
import type { CapabilityToolSnapshot } from '../src/main/capabilities/runtime'
|
||||
import type { ChatEvent, ChatMessage, ToolCallRecord } from '../src/shared/types'
|
||||
import { applyAssistantEvent } from '../src/shared/message-parts'
|
||||
import { getRevertChanges } from '../src/shared/revert'
|
||||
import { assistantWireHistory } from '../src/main/agent/history'
|
||||
|
||||
const root = await mkdtemp(join(tmpdir(), 'ycode-agents-'))
|
||||
const checks: string[] = []
|
||||
function check(label: string, condition: unknown): void { assert.ok(condition, label); checks.push(label); console.log('PASS ' + label) }
|
||||
const pause = (ms = 10): Promise<void> => new Promise(done => setTimeout(done, ms))
|
||||
const base: CapabilityToolSnapshot = { specs: [], execute: async () => undefined }
|
||||
function setup(enabled = true, hold = true, timeoutMs = 2000, resultText?: string) {
|
||||
const id = Math.random().toString(36), controller = new AbortController(), events: ChatEvent[] = []
|
||||
const policies = new Map<string, CapabilityToolSnapshot>(), prompts: string[] = [], release = new Map<string, () => void>()
|
||||
let current = 0, peak = 0, disposed = 0
|
||||
const scheduler = new SubagentScheduler({ runId: id, workspaceRoot: root, modelName: 'selected-model', enabled,
|
||||
signal: controller.signal, emit: event => events.push(event), capabilities: base, timeoutMs,
|
||||
worker: async (task, policy): Promise<SubagentWorker> => {
|
||||
policies.set(task.id, policy)
|
||||
return { async run(prompt, signal, emit) {
|
||||
current++; peak = Math.max(peak, current); prompts.push(prompt)
|
||||
emit({ runId: 'child', type: 'usage', usage: { inputTokens: 20, outputTokens: 5, totalTokens: 25 } })
|
||||
try {
|
||||
if (hold) await new Promise<void>((done, reject) => {
|
||||
const onAbort = (): void => { signal.removeEventListener('abort', onAbort); reject(Object.assign(new Error('cancelled'), { name: 'AbortError' })) }
|
||||
signal.addEventListener('abort', onAbort, { once: true })
|
||||
release.set(task.id, () => { signal.removeEventListener('abort', onAbort); done() })
|
||||
if (signal.aborted) onAbort()
|
||||
})
|
||||
emit({ runId: 'child', type: 'text', delta: 'RESULT:' + task.name })
|
||||
return { content: resultText ?? 'RESULT:' + task.name, tools: [] }
|
||||
} finally { current-- }
|
||||
}, async dispose() { disposed++ } }
|
||||
}
|
||||
})
|
||||
const ctx: ConversationToolContext = { runId: id, toolCallId: 'test-call', sessionId: 'parent-' + id, workspaceRoot: root,
|
||||
permissionMode: 'full-access', agentMode: 'craft', signal: controller.signal, emit: event => events.push(event) }
|
||||
const call = (name: string, args: Record<string, unknown> = {}) => executeTurnTool(name, args, ctx, scheduler.tools)
|
||||
const spawn = async (name: string, extra = {}) => {
|
||||
const result = await call('spawn_agent', { name, task: '独立调查并给出验收结果', ...extra })
|
||||
assert.equal(result.ok, true, result.content); return JSON.parse(result.content) as { id: string; status: string }
|
||||
}
|
||||
return { scheduler, controller, ctx, call, spawn, events, policies, prompts, release,
|
||||
get peak() { return peak }, get current() { return current }, get disposed() { return disposed } }
|
||||
}
|
||||
|
||||
{
|
||||
const state = setup(false)
|
||||
check('disabled switch hides spawn and rejects a direct stale-name invocation', !state.scheduler.tools.specs.some(tool => tool.function.name === 'spawn_agent') && !(await state.call('spawn_agent', { name: 'x', task: 'x' })).ok)
|
||||
state.scheduler.setEnabled(true)
|
||||
check('enabling immediately exposes real scheduling tools', state.scheduler.tools.specs.some(tool => tool.function.name === 'spawn_agent'))
|
||||
check('plan mode cannot bypass the scheduling mode gate', !(await executeTurnTool('spawn_agent', { name: 'x', task: 'x' }, { ...state.ctx, agentMode: 'plan' }, state.scheduler.tools)).ok)
|
||||
const spawned = await Promise.all([1, 2, 3, 4, 5, 6].map(i => state.spawn('任务' + i)))
|
||||
await pause()
|
||||
check('six tasks use five workers with the sixth queued', state.peak === 5 && JSON.parse((await state.call('list_agents')).content).filter((view: { status: string }) => view.status === 'queued').length === 1)
|
||||
const stranger = setup()
|
||||
check('cross-turn agent identifiers cannot be read or cancelled', !(await stranger.call('cancel_agent', { id: spawned[0].id })).ok)
|
||||
await stranger.spawn('跨会话排队')
|
||||
check('the concurrency budget also applies across root conversations', stranger.current === 0)
|
||||
await stranger.scheduler.close()
|
||||
state.scheduler.setEnabled(false)
|
||||
await state.scheduler.beforeFinish()
|
||||
check('disabling cancels running and queued children without aborting the parent', !state.controller.signal.aborted && JSON.parse((await state.call('list_agents')).content).every((view: { status: string }) => view.status === 'cancelled'))
|
||||
check('disabled switch also denies follow-up messages', !(await state.call('send_agent_message', { id: spawned[0].id, message: '继续' })).ok)
|
||||
await state.scheduler.close()
|
||||
check('cleanup disposes the five created child runtimes', state.disposed === 5)
|
||||
}
|
||||
{
|
||||
const state = setup(true, false, 2000, '\u0000'.repeat(60000))
|
||||
await state.spawn('长结果')
|
||||
await pause()
|
||||
const result = await state.call('wait_agent', { timeoutMs: 1000 })
|
||||
check('large escaped results remain bounded complete JSON', result.content.length <= 30000 && JSON.parse(result.content)[0].truncated)
|
||||
await state.scheduler.close()
|
||||
}
|
||||
{
|
||||
const state = setup()
|
||||
const writer = await state.spawn('写文件', { access: 'write', files: ['owned.txt'] })
|
||||
check('write declarations reject directories, escapes and glob patterns', !(await state.call('spawn_agent', { name: 'bad', task: 'x', access: 'write', files: ['.'] })).ok
|
||||
&& !(await state.call('spawn_agent', { name: 'bad', task: 'x', access: 'write', files: ['../escape.txt'] })).ok
|
||||
&& !(await state.call('spawn_agent', { name: 'bad', task: 'x', access: 'write', files: ['*.ts'] })).ok)
|
||||
await pause()
|
||||
const policy = state.policies.get(writer.id)!
|
||||
const workerCtx = { ...state.ctx, runId: 'worker', sessionId: 'child', toolCallId: 'child-write' }
|
||||
check('child schemas hide commands, browsers and nested scheduling', policy.filterSpecs!(toolSpecs()).every(tool => ['list_dir', 'read_file', 'write_file', 'edit_file'].includes(tool.function.name)))
|
||||
check('unlisted shell and nested spawn calls are rejected by the host', !(await executeTurnTool('execute_command', { command: 'echo forbidden' }, workerCtx, policy)).ok
|
||||
&& !(await executeTurnTool('spawn_agent', { name: 'nested', task: 'nested' }, workerCtx, policy)).ok)
|
||||
check('writer cannot modify another file', !(await executeTurnTool('write_file', { path: 'outside.txt', content: 'no' }, workerCtx, policy)).ok)
|
||||
check('same-file sibling assignment is denied', !(await state.call('spawn_agent', { name: '冲突', task: 'x', access: 'write', files: ['owned.txt'] })).ok)
|
||||
const blockedWrite = await state.call('write_file', { path: 'owned.txt', content: 'parent' })
|
||||
check('parent write to a child-leased file is still denied', !blockedWrite.ok)
|
||||
check('the denial names the file and the next step instead of blaming another task', blockedWrite.content.includes('子智能体') && blockedWrite.content.includes('owned.txt'))
|
||||
// 同一回合里并发发出的命令必须都能跑完:以前后到的一条会被判成"文件正由其他任务修改"。
|
||||
const [slow, sibling] = await Promise.all([
|
||||
state.call('execute_command', { command: 'ping -n 2 127.0.0.1 >nul && echo first' }),
|
||||
state.call('execute_command', { command: 'echo second' })
|
||||
])
|
||||
check('same-round shell calls both succeed while a child holds a file lease', slow.ok && sibling.ok
|
||||
&& slow.content.includes('first') && sibling.content.includes('second'))
|
||||
const childWrite = await executeTurnTool('write_file', { path: 'owned.txt', content: 'child' }, workerCtx, policy)
|
||||
check('declared full-access child file write really reaches disk', childWrite.ok && await readFile(join(root, 'owned.txt'), 'utf8') === 'child')
|
||||
state.release.get(writer.id)!()
|
||||
await state.call('wait_agent', { ids: [writer.id], timeoutMs: 1000 })
|
||||
const parentWrite = await state.call('write_file', { path: 'owned.txt', content: 'parent-after' })
|
||||
check('file lease is released after child completion', parentWrite.ok && await readFile(join(root, 'owned.txt'), 'utf8') === 'parent-after')
|
||||
const childTool: ToolCallRecord = { id: 'child-write', name: 'write_file', status: 'ok', args: {}, argsRaw: '{}', result: childWrite.data, metadata: childWrite.metadata }
|
||||
const parentTool: ToolCallRecord = { ...childTool, id: 'parent-write', result: parentWrite.data, metadata: parentWrite.metadata }
|
||||
const view = state.events.findLast(event => event.type === 'part' && event.part.type === 'subagent')
|
||||
assert.ok(view?.type === 'part' && view.part.type === 'subagent')
|
||||
const messages: ChatMessage[] = [{ id: 'user', role: 'user', content: 'change', createdAt: 0 },
|
||||
{ id: 'assistant', role: 'assistant', content: '', createdAt: 1, tools: [parentTool],
|
||||
parts: [{ type: 'subagent', agent: { ...view.part.agent, tools: [childTool] } }], status: 'done' }]
|
||||
const changes = getRevertChanges(messages, 'user')
|
||||
check('rewind includes child writes before later parent writes', changes.length === 1 && changes[0].before === null && changes[0].after === 'parent-after')
|
||||
await state.scheduler.close()
|
||||
}
|
||||
{
|
||||
// 同一回合里并发发出的未知足迹工具(命令、连接器等)必须排队串行,而不是互相判成文件冲突。
|
||||
const id = 'root-queue-' + Math.random().toString(36).slice(2)
|
||||
const controller = new AbortController()
|
||||
const started: string[] = [], finished: string[] = []
|
||||
const gates = new Map<string, () => void>()
|
||||
let active = 0, peak = 0
|
||||
const tool = (name: string) => ({ type: 'function', function: { name, description: '排队用例的假工具', parameters: { type: 'object', properties: {} } } })
|
||||
const capabilities: CapabilityToolSnapshot = {
|
||||
specs: [tool('custom_command'), tool('write_file')],
|
||||
async execute(_name, args) {
|
||||
const key = String((args as { key?: unknown; path?: unknown }).key ?? (args as { path?: unknown }).path ?? '')
|
||||
started.push(key); active++; peak = Math.max(peak, active)
|
||||
await new Promise<void>(done => gates.set(key, done))
|
||||
active--; finished.push(key)
|
||||
return { ok: true, content: 'done:' + key }
|
||||
}
|
||||
}
|
||||
const scheduler = new SubagentScheduler({ runId: id, workspaceRoot: root, modelName: 'queue-model', enabled: true,
|
||||
signal: controller.signal, emit: () => {}, capabilities,
|
||||
worker: async () => { throw new Error('排队用例不创建子智能体运行时') } })
|
||||
const ctx: ConversationToolContext = { runId: id, toolCallId: 'queue', sessionId: 'parent-' + id, workspaceRoot: root,
|
||||
permissionMode: 'full-access', agentMode: 'craft', signal: controller.signal, emit: () => {} }
|
||||
const run = (key: string) => executeTurnTool('custom_command', { key }, ctx, scheduler.tools)
|
||||
const write = (path: string, context = ctx) => executeTurnTool('write_file', { path }, context, scheduler.tools)
|
||||
const startedKey = async (count: number): Promise<string> => {
|
||||
for (let i = 0; i < 200 && started.length < count; i++) await pause(5)
|
||||
return started[count - 1]
|
||||
}
|
||||
const waitForKey = async (key: string): Promise<void> => {
|
||||
for (let i = 0; i < 200 && !started.includes(key); i++) await pause(5)
|
||||
}
|
||||
const calls = [run('a'), run('b'), run('c')]
|
||||
const first = await startedKey(1)
|
||||
check('sibling root tools with unknown footprints are serialized, not rejected', started.length === 1 && peak === 1)
|
||||
gates.get(first)!()
|
||||
const second = await startedKey(2)
|
||||
check('the next sibling starts by itself once the holder finishes', started.length === 2 && peak === 1)
|
||||
gates.get(second)!()
|
||||
gates.get(await startedKey(3))!()
|
||||
const results = await Promise.all(calls)
|
||||
check('every concurrently issued root call succeeds exactly once in first-come order', results.every(result => result.ok)
|
||||
&& new Set(results.map(result => result.content)).size === 3 && finished.length === 3 && peak === 1)
|
||||
// 父写文件期间到达的 spawn_agent 不能声明同一文件:父→子方向也必须互斥。
|
||||
const pendingWrite = write('parent.txt')
|
||||
await waitForKey('parent.txt')
|
||||
const late = await executeTurnTool('spawn_agent', { name: 'late', task: 'same file', access: 'write', files: ['parent.txt'] }, ctx, scheduler.tools)
|
||||
check('a child cannot declare the file the parent is writing right now', !late.ok && late.content.includes('主智能体'))
|
||||
gates.get('parent.txt')!()
|
||||
check('the pending parent write completes normally', (await pendingWrite).ok)
|
||||
// 队首被取消后,后面与持有者不冲突的等待者必须立刻接续。
|
||||
const blocker = write('block.txt')
|
||||
await waitForKey('block.txt')
|
||||
const headController = new AbortController()
|
||||
const head = write('block.txt', { ...ctx, signal: headController.signal })
|
||||
await pause(50)
|
||||
const tail = write('queue-only.txt')
|
||||
await pause(30)
|
||||
check('a conflicting waiter holds the queue and an unrelated one waits behind it', !started.includes('queue-only.txt'))
|
||||
headController.abort()
|
||||
const headRejection = await head.then(() => undefined, (error: Error) => error)
|
||||
await waitForKey('queue-only.txt')
|
||||
check('cancelling the head lets the next non-conflicting waiter start at once',
|
||||
headRejection?.name === 'AbortError' && started.includes('queue-only.txt'))
|
||||
gates.get('queue-only.txt')!()
|
||||
await tail
|
||||
gates.get('block.txt')!()
|
||||
await blocker
|
||||
const holder = run('hold')
|
||||
await waitForKey('hold')
|
||||
const queued = run('waiting')
|
||||
await pause(30)
|
||||
check('a sibling arriving while the footprint is held waits instead of failing', !started.includes('waiting'))
|
||||
controller.abort()
|
||||
const rejection = await queued.then(() => undefined, (error: Error) => error)
|
||||
check('a queued root call is cancelled instead of hanging when the turn aborts', rejection?.name === 'AbortError')
|
||||
gates.get('hold')!()
|
||||
await holder
|
||||
await scheduler.close()
|
||||
}
|
||||
{
|
||||
const state = setup()
|
||||
const reader = await state.spawn('只读')
|
||||
await pause()
|
||||
const policy = state.policies.get(reader.id)!
|
||||
check('read-only child cannot write even with inherited full-access permission', !(await executeTurnTool('write_file', { path: 'read-only.txt', content: 'no' }, { ...state.ctx, runId: 'reader' }, policy)).ok)
|
||||
const after = state.scheduler.beforeFinish()
|
||||
state.release.get(reader.id)!()
|
||||
check('parent finalization waits and supplies unread child results for synthesis', (await after)?.includes('RESULT:只读'))
|
||||
check('collected results do not create an infinite finalization loop', await state.scheduler.beforeFinish() === undefined)
|
||||
const part = state.events.findLast(event => event.type === 'part' && event.part.type === 'subagent')
|
||||
assert.ok(part?.type === 'part')
|
||||
check('HTTP cold history preserves child results as explicitly labeled task data', JSON.stringify(assistantWireHistory({
|
||||
id: 'history', role: 'assistant', content: '', createdAt: 0, parts: [part.part]
|
||||
})).includes('RESULT:只读'))
|
||||
const follow = await state.call('send_agent_message', { id: reader.id, message: '补充核对' })
|
||||
await pause()
|
||||
state.release.get(reader.id)!()
|
||||
await state.call('wait_agent', { ids: [reader.id], timeoutMs: 1000 })
|
||||
check('completed child keeps its runtime for follow-up instructions', follow.ok && state.prompts.length === 2 && state.prompts[1] === '补充核对')
|
||||
await state.scheduler.close()
|
||||
}
|
||||
{
|
||||
const state = setup()
|
||||
await state.spawn('取消测试')
|
||||
await pause()
|
||||
state.controller.abort()
|
||||
await state.scheduler.close()
|
||||
check('parent cancellation ends all children and releases their runtimes', state.current === 0 && state.disposed === 1)
|
||||
}
|
||||
{
|
||||
const state = setup(true, true, 50)
|
||||
await state.spawn('超时')
|
||||
await pause(100)
|
||||
const result = JSON.parse((await state.call('list_agents')).content)
|
||||
check('child execution timeout reaches a terminal state', result[0].status === 'cancelled')
|
||||
await state.scheduler.close()
|
||||
}
|
||||
{
|
||||
const state = setup()
|
||||
for (let i = 0; i < 16; i++) await state.spawn('有界任务' + i)
|
||||
check('task creation is bounded per root turn', !(await state.call('spawn_agent', { name: 'overflow', task: 'overflow' })).ok)
|
||||
const first = state.events.find(event => event.type === 'part' && event.part.type === 'subagent')
|
||||
assert.ok(first?.type === 'part' && first.part.type === 'subagent')
|
||||
let message: ChatMessage = { id: 'recover', role: 'assistant', content: '', createdAt: 0 }
|
||||
message = applyAssistantEvent(message, first)
|
||||
message = applyAssistantEvent(message, { ...first, part: { type: 'subagent', agent: { ...first.part.agent, status: 'running' } } })
|
||||
check('streaming agent snapshots upsert in place', message.parts?.length === 1)
|
||||
message = applyAssistantEvent(message, { runId: state.ctx.runId, type: 'cancelled' })
|
||||
check('cold interrupted history never leaves a child shown as running', message.parts?.[0].type === 'subagent' && message.parts[0].agent.status === 'cancelled')
|
||||
await state.scheduler.close()
|
||||
}
|
||||
await writeFile(join(root, 'report.json'), JSON.stringify({ result: 'PASS', checks }, null, 2))
|
||||
console.log('SUBAGENT_REGRESSION_PASS ' + JSON.stringify({ checks: checks.length, directory: root }))
|
||||
@@ -1,60 +0,0 @@
|
||||
// Real filesystem + SDK MCP + production fallback Gateway/turn loop. All servers are local fixtures.
|
||||
import fs from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import assert from 'node:assert/strict'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import { build } from 'esbuild'
|
||||
import { createServer } from 'node:http'
|
||||
const directory = await fs.mkdtemp(path.resolve('.tmp/capability-gateway-'))
|
||||
const userData = path.join(directory, 'data'), cwd = path.join(directory, 'workspace'), source = path.join(directory, 'skill')
|
||||
await Promise.all([userData, cwd, source].map(folder => fs.mkdir(folder)))
|
||||
await fs.writeFile(path.join(source, 'SKILL.md'), '---\nname: gateway-proof\ndescription: Controlled gateway capability\n---\nGATEWAY_SKILL_PROOF\n')
|
||||
await fs.writeFile(path.join(source, 'guide.txt'), 'GATEWAY_RESOURCE_PROOF')
|
||||
await build({ stdin: { contents: `export * as skills from './src/main/capabilities/skills';export * as mcp from './src/main/capabilities/mcp';export {createCapabilitySnapshot} from './src/main/capabilities/runtime';export {runTurn} from './src/main/agent/loop';export {Gateway} from './src/main/gateway'`, resolveDir: process.cwd() }, outfile: path.join(directory, 'runtime.mjs'), bundle: true, platform: 'node', format: 'esm', packages: 'external', logLevel: 'warning', plugins: [{ name: 'isolated-path', setup(context) { context.onResolve({ filter: /^electron$/ }, () => ({ path: 'electron', namespace: 'qa' })); context.onLoad({ filter: /.*/, namespace: 'qa' }, () => ({ contents: `export const app={getPath:()=>${JSON.stringify(userData)}};export const safeStorage={isEncryptionAvailable:()=>false}` })) } }] })
|
||||
const { skills, mcp, createCapabilitySnapshot, runTurn, Gateway } = await import(pathToFileURL(path.join(directory, 'runtime.mjs')).href)
|
||||
const checks = [], events = [], requests = []
|
||||
const check = (label, actual, expected = true) => { assert.deepEqual(actual, expected, label); checks.push(label) }
|
||||
let server
|
||||
try {
|
||||
const skill = await skills.importSkill(source)
|
||||
const saved = await mcp.saveMcpServer({ name: 'gateway-connector', transport: 'stdio', command: process.execPath, args: [path.resolve('scripts/test-mcp-server.mjs'), 'stdio', path.join(directory, 'mcp-events.jsonl')] })
|
||||
await mcp.trustMcpServer(saved.id, saved.configHash); await mcp.setMcpEnabled(saved.id, true)
|
||||
const snapshot = await createCapabilitySnapshot([skill.id], cwd)
|
||||
check('resource URI uses source skill scheme', snapshot.references[0].uri, 'skill://gateway-proof')
|
||||
check('manual skill body in actual turn context', snapshot.context.includes('GATEWAY_SKILL_PROOF'))
|
||||
const mcpName = snapshot.specs.find(spec => spec.function.name.startsWith('mcp_echo_')).function.name
|
||||
server = createServer(async (request, response) => {
|
||||
let raw = ''; for await (const bytes of request) raw += bytes
|
||||
const body = JSON.parse(raw), results = body.messages.filter(message => message.role === 'tool')
|
||||
requests.push({ toolNames: body.tools.map(tool => tool.function.name), results: results.map(item => item.content), skillContext: JSON.stringify(body.messages).includes('GATEWAY_SKILL_PROOF') })
|
||||
const calls = [{ name: 'ycode_load_skill', args: { id: skill.id } }, { name: 'ycode_read_skill_resource', args: { id: skill.id, path: 'guide.txt' } }, { name: mcpName, args: { text: 'GATEWAY_ECHO_PROOF' } }]
|
||||
const call = calls[results.length], delta = call ? { tool_calls: [{ index: 0, id: 'call-' + results.length, type: 'function', function: { name: call.name, arguments: JSON.stringify(call.args) } }] } : { content: 'GATEWAY_CAPABILITIES_OK' }
|
||||
response.writeHead(200, { 'content-type': 'text/event-stream' })
|
||||
response.end(`data: ${JSON.stringify({ id: 'qa', choices: [{ index: 0, delta, finish_reason: null }] })}\n\ndata: ${JSON.stringify({ id: 'qa', choices: [{ index: 0, delta: {}, finish_reason: call ? 'tool_calls' : 'stop' }] })}\n\ndata: [DONE]\n\n`)
|
||||
})
|
||||
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve))
|
||||
const gateway = new Gateway({ baseUrl: `http://127.0.0.1:${server.address().port}/v1`, apiKey: '', authMode: 'none', model: 'qa', temperature: 0, maxToolRounds: 6 })
|
||||
const signal = new AbortController().signal
|
||||
const result = await runTurn({ runId: 'gateway-capabilities', history: [], userText: 'Run capability proof\n' + snapshot.context, signal, emit: event => events.push(event), gateway, model: 'qa', maxToolRounds: 6, workspaceRoot: cwd, permissionMode: 'full-access', capabilityTools: snapshot })
|
||||
check('production fallback returns final reply', result.content, 'GATEWAY_CAPABILITIES_OK')
|
||||
check('three actual tool rounds succeeded', result.tools.map(tool => tool.status), ['ok', 'ok', 'ok'])
|
||||
check('auto load returned actual installed bytes', result.tools[0].output.includes('GATEWAY_SKILL_PROOF'))
|
||||
check('resource tool returned actual file bytes', result.tools[1].output, 'GATEWAY_RESOURCE_PROOF')
|
||||
check('MCP returned SDK server result', result.tools[2].output, 'stdio:GATEWAY_ECHO_PROOF')
|
||||
check('MCP display identity survives the fallback turn', [result.tools[2].metadata?.serverName, result.tools[2].metadata?.toolName], ['gateway-connector', 'echo'])
|
||||
check('all dynamic tools reach actual HTTP upstream', requests.every(request => ['ycode_load_skill', 'ycode_read_skill_resource', mcpName].every(name => request.toolNames.includes(name))))
|
||||
check('MCP result is fed back to upstream', requests.at(-1).results.includes('stdio:GATEWAY_ECHO_PROOF'))
|
||||
check('tool completion events precede terminal', events.filter(event => ['tool_result', 'done'].includes(event.type)).map(event => event.type), ['tool_result', 'tool_result', 'tool_result', 'done'])
|
||||
const ctx = { runId: 'qa', toolCallId: 'qa-tool', workspaceRoot: cwd, permissionMode: 'full-access', signal, emit() {} }
|
||||
check('unknown skill ID cannot load', (await snapshot.execute('ycode_load_skill', { id: 'other-workspace' }, ctx)).ok, false)
|
||||
await assert.rejects(snapshot.execute('ycode_read_skill_resource', { id: skill.id, path: '../outside' }, ctx)); checks.push('resource traversal rejected through turn dispatcher')
|
||||
await mcp.setMcpToolEnabled(saved.id, 'echo', false)
|
||||
check('disabled MCP tool rejects an older snapshot', (await snapshot.execute(mcpName, { text: 'NO_EXECUTE' }, ctx)).ok, false)
|
||||
check('next turn omits disabled MCP tool', !(await createCapabilitySnapshot([], cwd)).specs.some(tool => tool.function.name === mcpName))
|
||||
await skills.setSkillEnabled(skill.id, false)
|
||||
await assert.rejects(createCapabilitySnapshot([skill.id], cwd)); checks.push('selected disabled skill rejects before a turn')
|
||||
check('built-in expert manager remains available after user skill disabled', (await createCapabilitySnapshot([], cwd)).specs.some(tool => tool.function.name === 'ycode_load_skill'))
|
||||
console.log(JSON.stringify({ result: 'PASS', checks: checks.length, directory }))
|
||||
await fs.writeFile(path.join(directory, 'report.json'), JSON.stringify({ result: 'PASS', checks, requests }, null, 2))
|
||||
} catch (error) { await fs.writeFile(path.join(directory, 'report.json'), JSON.stringify({ result: 'FAIL', checks, error: String(error) }, null, 2)); throw error }
|
||||
finally { await mcp.disposeMcpServers(); if (server) { server.closeAllConnections(); await new Promise(resolve => server.close(resolve)) } }
|
||||
@@ -1,76 +0,0 @@
|
||||
/** Production experts module + real isolated FS. Controlled Electron path/MCP state and injected I/O failures. */
|
||||
import fs from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import assert from 'node:assert/strict'
|
||||
import { build } from 'esbuild'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import { createHash } from 'node:crypto'
|
||||
const output = await fs.mkdtemp(path.resolve('.tmp/expert-review-snapshot-'))
|
||||
const relative = path.relative(path.resolve('.tmp'), output)
|
||||
assert.ok(relative.startsWith('expert-review-snapshot-') && !relative.includes('..') && !path.isAbsolute(relative))
|
||||
const data = path.join(output, 'data'); await fs.mkdir(data)
|
||||
globalThis.expertReviewMcp = { servers: [], hook: null, calls: [], generation: 1 }
|
||||
await build({ stdin: { contents: `export * from ${JSON.stringify(path.resolve('src/main/capabilities/experts.ts'))};export {createCapabilitySnapshot} from ${JSON.stringify(path.resolve('src/main/capabilities/runtime.ts'))};`, resolveDir: process.cwd(), sourcefile: 'expert-snapshot-review-entry.ts', loader: 'ts' }, outfile: path.join(output, 'experts.mjs'), bundle: true, platform: 'node', format: 'esm', packages: 'external', plugins: [{ name: 'isolated-review-host', setup(builder) {
|
||||
builder.onResolve({ filter: /^electron$/ }, () => ({ path: 'electron', namespace: 'review' }))
|
||||
builder.onLoad({ filter: /.*/, namespace: 'review' }, () => ({ contents: `export const app={getPath:()=>${JSON.stringify(data)}}` }))
|
||||
builder.onResolve({ filter: /^\.\/mcp$/ }, () => ({ path: 'mcp', namespace: 'review-mcp' }))
|
||||
builder.onLoad({ filter: /.*/, namespace: 'review-mcp' }, () => ({ contents: `export async function listMcpServers(){const state=globalThis.expertReviewMcp;state.calls.push('gate');if(state.hook)await state.hook();return state.servers}export async function snapshotMcpTools(){const state=globalThis.expertReviewMcp;state.calls.push('snapshot');const generation=state.generation;return {specs:[{type:'function',function:{name:'controlled_mcp',description:'controlled snapshot',parameters:{type:'object'}}}],describe:()=>({serverName:'worker',toolName:'echo'}),execute:async()=>generation===state.generation?{ok:true,content:'captured'}:{ok:false,content:'CONTROLLED_GENERATION_CHANGED'}}}` }))
|
||||
} }], logLevel: 'warning' })
|
||||
const api = await import(pathToFileURL(path.join(output, 'experts.mjs')).href)
|
||||
const checks = [], packages = path.join(data, 'plugins', 'marketplaces', 'my-experts', 'plugins'), market = path.join(data, 'plugins', 'marketplaces', 'my-experts', '.codebuddy-plugin', 'marketplace.json')
|
||||
const check = (name, actual, expected) => { assert.deepEqual(actual, expected, name); checks.push(name) }
|
||||
function fixture(id, label = 'OLD_LABEL', role = 'OLD_ROLE') {
|
||||
const p = { name: id, version: '1.0.0', description: 'controlled fixture', expertType: 'agent', agentName: id, plugin: id, agents: [`./agents/${id}.md`], displayName: { zh: label, en: label }, profession: { zh: label, en: label }, displayDescription: { zh: '测试专家用于明确受控的隔离验证本地角色技能包数据一致性并保持行为诚实不伪造任何外部环境状态', en: 'controlled' }, defaultInitPrompt: { zh: 'hello', en: 'hello' }, quickPrompts: [{ zh: 'hello', en: 'hello' }, { zh: 'two', en: 'two' }, { zh: 'three', en: 'three' }], tags: [{ zh: 'one', en: 'one' }, { zh: 'two', en: 'two' }, { zh: 'three', en: 'three' }] }
|
||||
return { '.codebuddy-plugin/plugin.json': JSON.stringify(p), [`agents/${id}.md`]: `---\nname: ${id}\ndescription: fixture\n---\n${role}\n` }
|
||||
}
|
||||
async function install(id, files, session = 'owner-' + id) { await api.expertPackageOperation(session, { id, action: 'write', files }); return api.expertPackageOperation(session, { id, action: 'register' }) }
|
||||
const catalogHas = async id => JSON.parse(await fs.readFile(market, 'utf8')).plugins.some(item => item.name === id)
|
||||
const exists = file => fs.stat(file).then(() => true, () => false)
|
||||
const originalRm = fs.rm, originalRename = fs.rename
|
||||
try {
|
||||
let files = fixture('snapshot-expert'), metadata = JSON.parse(files['.codebuddy-plugin/plugin.json'])
|
||||
metadata.mcpServers = {}; files['.codebuddy-plugin/plugin.json'] = JSON.stringify(metadata)
|
||||
await install('snapshot-expert', files)
|
||||
globalThis.expertReviewMcp.hook = async () => { globalThis.expertReviewMcp.hook = null; await install('snapshot-expert', fixture('snapshot-expert', 'NEW_LABEL', 'NEW_ROLE'), 'concurrent-editor') }
|
||||
const snapshot = await api.expertTurnContext('snapshot-expert')
|
||||
check('concurrent register does not mix assistant identity with role', [snapshot.display.name, snapshot.context.includes('OLD_ROLE'), snapshot.context.includes('NEW_ROLE')], ['OLD_LABEL', true, false])
|
||||
check('concurrent register actually publishes new package', (await api.readExpert('snapshot-expert')).prompt, 'NEW_ROLE')
|
||||
files = fixture('dependency-expert'); metadata = JSON.parse(files['.codebuddy-plugin/plugin.json']); metadata.mcpServers = { worker: { command: 'node', args: ['expected.js'] } }; files['.codebuddy-plugin/plugin.json'] = JSON.stringify(metadata)
|
||||
await install('dependency-expert', files)
|
||||
globalThis.expertReviewMcp.servers = [{ id: 'worker', name: 'worker', enabled: true, trusted: true, status: 'connected', transport: 'stdio', command: 'node', args: ['other.js'] }]
|
||||
await assert.rejects(api.activateExpert('dependency-expert'), /参数/); checks.push('same-name trusted connector with different args is rejected')
|
||||
globalThis.expertReviewMcp.servers[0].args = ['expected.js']; check('matching actual connector allows activation', (await api.activateExpert('dependency-expert')).id, 'dependency-expert')
|
||||
await install('remove-expert', fixture('remove-expert'))
|
||||
fs.rm = async (target, options) => { if (path.basename(String(target)).startsWith('.remove-')) { await fs.unlink(path.join(String(target), 'agents', 'remove-expert.md')); throw Object.assign(Error('Injected partial tomb cleanup failure'), { code: 'EIO' }) }; return originalRm.call(fs, target, options) }
|
||||
await api.removeExpert('remove-expert'); fs.rm = originalRm
|
||||
check('partial delete cleanup never resurrects catalog registration', await catalogHas('remove-expert'), false)
|
||||
check('partial delete cleanup leaves original managed identity absent', await exists(path.join(packages, 'remove-expert')), false)
|
||||
await install('rollback-expert', fixture('rollback-expert'))
|
||||
fs.rename = async (from, to) => { if (path.resolve(String(to)) === market) throw Object.assign(Error('Injected atomic catalog commit failure'), { code: 'EIO' }); return originalRename.call(fs, from, to) }
|
||||
await assert.rejects(api.removeExpert('rollback-expert'), /Injected/); fs.rename = originalRename
|
||||
check('delete catalog failure restores full original package', [(await api.readExpert('rollback-expert')).prompt, await catalogHas('rollback-expert')], ['OLD_ROLE', true])
|
||||
await api.expertPackageOperation('rollback-editor', { id: 'rollback-expert', action: 'write', files: fixture('rollback-expert', 'EDITED_LABEL', 'EDITED_ROLE') })
|
||||
fs.rename = async (from, to) => { if (path.resolve(String(to)) === market) throw Object.assign(Error('Injected atomic catalog commit failure'), { code: 'EIO' }); return originalRename.call(fs, from, to) }
|
||||
await assert.rejects(api.expertPackageOperation('rollback-editor', { id: 'rollback-expert', action: 'register' }), /Injected/); fs.rename = originalRename
|
||||
check('register catalog failure restores full original package', [(await api.readExpert('rollback-expert')).prompt, await catalogHas('rollback-expert')], ['OLD_ROLE', true])
|
||||
await install('cleanup-expert', fixture('cleanup-expert'))
|
||||
fs.rm = async (target, options) => { if (path.basename(String(target)).startsWith('.backup-') || String(target).includes('expert-drafts') && path.basename(String(target)) === 'cleanup-expert.json') throw Object.assign(Error('Injected postcommit cleanup failure'), { code: 'EIO' }); return originalRm.call(fs, target, options) }
|
||||
const registered = await install('cleanup-expert', fixture('cleanup-expert', 'EDITED_LABEL', 'EDITED_ROLE'), 'cleanup-editor'); fs.rm = originalRm
|
||||
check('postcommit cleanup failure reports genuine committed success', [registered.success, (await api.readExpert('cleanup-expert')).prompt, await catalogHas('cleanup-expert')], [true, 'EDITED_ROLE', true])
|
||||
await api.expertPackageOperation('corrupt-market-editor', { id: 'leak-expert', action: 'write', files: fixture('leak-expert') })
|
||||
await fs.writeFile(market, 'INVALID JSON')
|
||||
const before = (await fs.readdir(packages)).filter(name => name.startsWith('.stage-')).length
|
||||
await assert.rejects(api.expertPackageOperation('corrupt-market-editor', { id: 'leak-expert', action: 'register' }))
|
||||
check('invalid catalog does not leak install stage', (await fs.readdir(packages)).filter(name => name.startsWith('.stage-')).length, before)
|
||||
assert.throws(() => api.expertManagerResource('__proto__'), /不存在/); assert.throws(() => api.expertManagerResource('constructor'), /不存在/); checks.push('builtin manager refuses inherited resource properties')
|
||||
globalThis.expertReviewMcp.calls = []
|
||||
globalThis.expertReviewMcp.hook = async () => { globalThis.expertReviewMcp.hook = null; globalThis.expertReviewMcp.generation++ }
|
||||
const capability = await api.createCapabilitySnapshot([], output, { sessionId: 'controlled-turn', expertId: 'dependency-expert' })
|
||||
check('actual runtime captures MCP before expert dependency validation', globalThis.expertReviewMcp.calls, ['snapshot', 'gate'])
|
||||
const ctx = { runId: 'controlled-turn', toolCallId: 'controlled-call', workspaceRoot: output, permissionMode: 'full-access', signal: new AbortController().signal, emit() {} }
|
||||
check('actual runtime retains captured snapshot when connection generation changes', await capability.execute('controlled_mcp', {}, ctx), { ok: false, content: 'CONTROLLED_GENERATION_CHANGED', metadata: { capability: 'mcp', serverName: 'worker', toolName: 'echo' } })
|
||||
check('active expert refuses inherited resource properties', (await capability.execute('ycode_expert_resource', { path: '__proto__' }, ctx)).ok, false)
|
||||
check('active expert still reads actual package resource', (await capability.execute('ycode_expert_resource', { path: 'agents/dependency-expert.md' }, ctx)).content.includes('OLD_ROLE'), true)
|
||||
await fs.writeFile(path.join(output, 'report.json'), JSON.stringify({ result: 'PASS', checks, sources: await Promise.all(['src/main/capabilities/experts.ts', 'src/main/capabilities/runtime.ts'].map(async file => ({ file, sha256: createHash('sha256').update(await fs.readFile(file)).digest('hex') }))), limits: 'Actual isolated FS + production experts/runtime module; Electron path and MCP state/generation contract are controlled. Actual network/SDK generation guard and WorkBuddy pixels are not asserted.' }, null, 2))
|
||||
console.log(JSON.stringify({ result: 'PASS', checks: checks.length, output }))
|
||||
} finally { fs.rm = originalRm; fs.rename = originalRename }
|
||||
@@ -1,4 +1,4 @@
|
||||
/** Isolated real Electron backend QA; no user registry, OMP or original application. */
|
||||
/** Isolated real Electron backend QA; no user registry or original application. */
|
||||
import fs from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import assert from 'node:assert/strict'
|
||||
@@ -14,7 +14,7 @@ if(!process.versions.electron){
|
||||
const sourceSha256=createHash('sha256').update(await fs.readFile('src/main/capabilities/experts.ts')).digest('hex'),moduleSha256=createHash('sha256').update(await fs.readFile(path.join(directory,'experts-under-test.mjs'))).digest('hex')
|
||||
const bootstrap=path.join(directory,'bootstrap.cjs')
|
||||
await fs.writeFile(bootstrap,`const {app}=require('electron');app.setPath('userData',${JSON.stringify(path.join(directory,'data'))});app.setPath('sessionData',${JSON.stringify(path.join(directory,'sessions'))});app.disableHardwareAcceleration();app.whenReady().then(()=>import(${JSON.stringify(import.meta.url)}).catch(e=>{console.error(String(e));app.exit(1)}))`)
|
||||
const env={...process.env,YCODE_EXPERTS_QA_DIRECTORY:directory,YCODE_OMP_HOME:path.join(directory,'omp')};delete env.ELECTRON_RUN_AS_NODE
|
||||
const env={...process.env,YCODE_EXPERTS_QA_DIRECTORY:directory};delete env.ELECTRON_RUN_AS_NODE
|
||||
const reports=[]
|
||||
for(const phase of phases){
|
||||
const code=await new Promise(resolve=>{const child=spawn(createRequire(import.meta.url)('electron'),['--no-sandbox','--disable-gpu-sandbox',bootstrap,phase],{env,windowsHide:true,stdio:['ignore','pipe','pipe']});let output='';child.stdout.on('data',c=>output+=c);child.stderr.on('data',c=>output+=c);const timer=setTimeout(()=>child.kill(),45000);child.on('exit',async code=>{clearTimeout(timer);await fs.writeFile(path.join(directory,phase+'.log'),output);resolve(code??1)})})
|
||||
|
||||
@@ -1,134 +0,0 @@
|
||||
/** Production expert/capability modules + real imported REA packages in an isolated YCode profile.
|
||||
* Electron's getPath is the only host replacement. The actual MCP SDK/registry uses an empty profile.
|
||||
* No original application access, expert script execution, model invocation or network operation.
|
||||
*/
|
||||
import fs from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import crypto from 'node:crypto'
|
||||
import assert from 'node:assert/strict'
|
||||
import {spawnSync} from 'node:child_process'
|
||||
import {pathToFileURL} from 'node:url'
|
||||
import {build} from 'esbuild'
|
||||
import {parseDocument} from 'yaml'
|
||||
|
||||
const output=await fs.mkdtemp(path.resolve('.tmp/imported-expert-qa-'))
|
||||
const userData=path.join(output,'YCode'),workspace=path.join(output,'workspace')
|
||||
await fs.mkdir(userData);await fs.mkdir(workspace)
|
||||
assert.equal(path.basename(userData),'YCode')
|
||||
assert.ok(path.relative(path.resolve('.tmp'),output).startsWith('imported-expert-qa-'))
|
||||
await fs.writeFile(path.join(userData,'mcp-servers.json'),JSON.stringify({version:1,servers:[]}))
|
||||
const hash=bytes=>crypto.createHash('sha256').update(bytes).digest('hex')
|
||||
const checks=[],imports=[],packages=[],resourceChecks=[],sourceShas=[]
|
||||
let networkCalls=0
|
||||
const originalFetch=globalThis.fetch
|
||||
globalThis.fetch=async()=>{networkCalls++;throw Error('QA prohibits network calls')}
|
||||
function check(label,actual,expected=true){
|
||||
const pass=JSON.stringify(actual)===JSON.stringify(expected)
|
||||
checks.push({label,pass,actual,expected});assert.ok(pass,label)
|
||||
}
|
||||
const sources=[
|
||||
{directory:'.tmp/rea-cpp-expert-recovered-20261016',id:'cpp-super-expert',pin:'b90023e9622bd74c55d583051bee3625eb491b552baeea17ae10ac792a352498'},
|
||||
{directory:'.tmp/rea-project-manager-recovered-20261016',id:'senior-project-manager',pin:'304805c85b87e8266294fb3aaf283d45fc71d7977af064fcb1eed1199e9389e6'},
|
||||
{directory:'.tmp/rea-ui-designer-recovered-20261016',id:'ui-designer',pin:'c9c83df190f0f0f871706d52b7e372f20b7ad63fde739f59cf9181ace1d08afa'},
|
||||
{directory:'.tmp/rea-algorithm-recovered-20261016',id:'senior-algorithm-expert',pin:'44ed0738d809fe492a36c697b0edd53b72ed75549f5b773291d05f9cf4a6c66f'}
|
||||
]
|
||||
const sourceFiles=['scripts/import-rea-expert.mjs','src/main/capabilities/experts.ts','src/main/capabilities/runtime.ts','src/main/capabilities/skills.ts','src/main/capabilities/mcp.ts','src/shared/capabilities.ts']
|
||||
const textMember=name=>/\.(?:md|json|txt|yaml|yml|py|js|cjs|mjs|ts|csv|css|html|svg|cs|csproj|slnx|xml|xsd|sh|ps1|LICENSE)$/i.test(name)||['LICENSE','.gitignore'].includes(path.basename(name))
|
||||
function promptBody(raw){const match=/^\uFEFF?---\r?\n[\s\S]*?\r?\n---(?:\r?\n|$)/.exec(raw);assert.ok(match);return raw.slice(match[0].length).trim()}
|
||||
function metadata(raw){const match=/^\uFEFF?---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/.exec(raw);assert.ok(match);const parsed=parseDocument(match[1],{uniqueKeys:true,schema:'core'});assert.equal(parsed.errors.length,0);return parsed.toJS({maxAliasCount:30})}
|
||||
try{
|
||||
for(const file of sourceFiles)sourceShas.push({file,sha256:hash(await fs.readFile(file))})
|
||||
for(const source of sources){
|
||||
const manifest=JSON.parse(await fs.readFile(path.join(source.directory,'evidence-manifest.json'),'utf8'))
|
||||
check(source.id+' fixed REA root identity',manifest.rootSha256,source.pin)
|
||||
const plugin=JSON.parse(await fs.readFile(path.join(source.directory,'.codebuddy-plugin/plugin.json'),'utf8'))
|
||||
check(source.id+' fixed package identity',plugin.name,source.id)
|
||||
const run=spawnSync(process.execPath,['scripts/import-rea-expert.mjs','--source',source.directory,'--user-data',userData],{encoding:'utf8',maxBuffer:4*1024*1024,timeout:60000,windowsHide:true})
|
||||
await fs.writeFile(path.join(output,source.id+'-import.log'),run.stdout+'\n'+run.stderr)
|
||||
check(source.id+' actual import helper exit',run.status,0)
|
||||
const summary=run.stdout.trim().split(/\r?\n/).map(line=>{try{return JSON.parse(line)}catch{return null}}).findLast(item=>item?.result)
|
||||
check(source.id+' actual import helper explicitly PASS',summary?.result,'PASS')
|
||||
const helperReport=JSON.parse(await fs.readFile(path.join(summary.directory,'report.json'),'utf8'))
|
||||
check(source.id+' helper report activated',helperReport.activated,true)
|
||||
imports.push({expertId:source.id,summary,helperReport:path.join(summary.directory,'report.json')})
|
||||
packages.push({...source,manifest,plugin,installedPath:path.join(userData,'plugins','marketplaces','my-experts','plugins',source.id)})
|
||||
}
|
||||
const bundle=path.join(output,'capabilities-under-test.mjs')
|
||||
const built=await build({stdin:{contents:`export * from './src/main/capabilities/experts';export {createCapabilitySnapshot} from './src/main/capabilities/runtime';export {listMcpServers} from './src/main/capabilities/mcp';`,resolveDir:process.cwd(),loader:'ts'},outfile:bundle,bundle:true,platform:'node',format:'esm',packages:'external',metafile:true,logLevel:'warning',plugins:[{name:'isolated-electron-path-only',setup(builder){
|
||||
builder.onResolve({filter:/^electron$/},()=>({path:'electron',namespace:'imported-expert-qa'}))
|
||||
builder.onLoad({filter:/.*/,namespace:'imported-expert-qa'},()=>({contents:`export const app={getPath(name){if(name!=='userData')throw Error('Unexpected host path '+name);return ${JSON.stringify(userData)}}};export const safeStorage={isEncryptionAvailable:()=>false};`}))
|
||||
}}]})
|
||||
await fs.writeFile(path.join(output,'bundle-metafile.json'),JSON.stringify(built.metafile,null,2))
|
||||
const api=await import(pathToFileURL(bundle).href)
|
||||
check('actual MCP SDK uses empty isolated registry',await api.listMcpServers(),[])
|
||||
const listed=await api.listExperts()
|
||||
check('production registry discovers all four genuine imports',listed.map(item=>item.id).sort(),sources.map(item=>item.id).sort())
|
||||
for(const pkg of packages){
|
||||
const {id,plugin,manifest,installedPath}=pkg,document=await api.readExpert(id),binding=await api.activateExpert(id)
|
||||
check(id+' actual expert is valid',document.expert.error??null,null)
|
||||
check(id+' raw localized display name',document.expert.displayName,plugin.displayName)
|
||||
check(id+' raw localized profession',document.expert.profession,plugin.profession)
|
||||
check(id+' activated localized display name',binding.name,plugin.displayName.zh)
|
||||
check(id+' activated localized profession',binding.profession,plugin.profession.zh)
|
||||
check(id+' activation returns same validated default prompt',binding.defaultInitPrompt,plugin.defaultInitPrompt)
|
||||
check(id+' activation identity mapping',JSON.parse(binding.runtimeIdentity.slice('expert-v2:'.length)),{v:2,kind:'expert',expertId:id,pluginRegisteredName:id,agentName:plugin.agentName,source:'custom'})
|
||||
const avatar=manifest.files.find(file=>file.path===plugin.avatar.replace(/^\.\//,''))
|
||||
check(id+' avatar data URL preserves complete PNG SHA',hash(Buffer.from(binding.avatarUrl.split(',')[1],'base64')),avatar.sha256)
|
||||
const agentPath='agents/'+plugin.agentName+'.md',agentRaw=await fs.readFile(path.join(pkg.directory,agentPath),'utf8')
|
||||
check(id+' role body preserved excluding YAML envelope',document.prompt,promptBody(agentRaw))
|
||||
const turn=await api.expertTurnContext(id)
|
||||
check(id+' actual turn includes real role prompt',turn.context.includes(document.prompt))
|
||||
check(id+' actual turn states isolated installed package root',turn.context.includes(JSON.stringify(installedPath)))
|
||||
const agentSkills=metadata(agentRaw).skills??[],expectedPreloads=[]
|
||||
for(const relative of plugin.skills){
|
||||
const key=relative.replace(/^\.\//,'')+'/SKILL.md',rawSkill=await fs.readFile(path.join(pkg.directory,key),'utf8'),skillName=metadata(rawSkill).name,preloaded=agentSkills.includes(skillName)
|
||||
check(id+' YAML-selected skill preload only: '+key,turn.context.includes(rawSkill),preloaded)
|
||||
check(id+' plugin skill remains in demand-load directory: '+key,turn.context.includes(JSON.stringify({name:skillName,path:key,description:metadata(rawSkill).description,preloaded})))
|
||||
if(preloaded)expectedPreloads.push(key)
|
||||
}
|
||||
check(id+' exact startup skill selection',Array.from(turn.context.matchAll(/<expert-skill path="([^"]+)">/g),match=>match[1]),expectedPreloads)
|
||||
const controller=new AbortController(),ctx={runId:'imported-expert-qa',toolCallId:'resource-check',workspaceRoot:workspace,permissionMode:'full-access',signal:controller.signal,emit(){}}
|
||||
const snapshot=await api.createCapabilitySnapshot([],workspace,{sessionId:'qa-'+id,expertId:id})
|
||||
const {defaultInitPrompt:_defaultInitPrompt,...displayBinding}=binding
|
||||
check(id+' runtime snapshot carries true expert identity',snapshot.assistantDisplay,displayBinding)
|
||||
check(id+' runtime exposes actual resource tool',snapshot.specs.some(spec=>spec.function.name==='ycode_expert_resource'))
|
||||
const resourceFiles=manifest.files.filter(file=>textMember(file.path))
|
||||
check(id+' exact complete resource index',Object.keys(document.files).sort(),resourceFiles.map(file=>file.path).sort())
|
||||
let installedBytes=0
|
||||
for(const file of manifest.files){
|
||||
const original=await fs.readFile(path.join(pkg.directory,file.path)),installed=await fs.readFile(path.join(installedPath,file.path))
|
||||
assert.equal(hash(original),file.sha256);assert.equal(hash(installed),file.sha256);assert.equal(installed.length,file.bytes);installedBytes+=installed.length
|
||||
}
|
||||
check(id+' every installed file preserves full source SHA/bytes',installedBytes,manifest.files.reduce((n,file)=>n+file.bytes,0))
|
||||
for(const file of resourceFiles){
|
||||
const result=await snapshot.execute('ycode_expert_resource',{path:file.path},ctx)
|
||||
const actualSha=typeof result?.content==='string'?hash(Buffer.from(result.content,'utf8')):null
|
||||
resourceChecks.push({expertId:id,path:file.path,sourceSha256:file.sha256,returnedSha256:actualSha,ok:result?.ok,pass:result?.ok===true&&actualSha===file.sha256})
|
||||
assert.equal(result?.ok,true,id+' resource success: '+file.path);assert.equal(actualSha,file.sha256,id+' resource full original SHA: '+file.path)
|
||||
check(id+' runtime advertises resource: '+file.path,snapshot.context.includes('- '+file.path))
|
||||
}
|
||||
check(id+' all real resource bytes preserved through production tool',resourceChecks.filter(item=>item.expertId===id).every(item=>item.pass))
|
||||
for(const bad of ['__proto__','constructor','toString','../README.md','..\\README.md','C:/outside-fixture.txt','/outside-fixture.txt','skills/../../README.md','%2e%2e/README.md','']){
|
||||
check(id+' rejects inherited/traversal/absolute resource '+JSON.stringify(bad),(await snapshot.execute('ycode_expert_resource',{path:bad},ctx))?.ok,false)
|
||||
}
|
||||
check(id+' legitimate ./ resource prefix works',hash((await snapshot.execute('ycode_expert_resource',{path:'./'+agentPath},ctx)).content),manifest.files.find(file=>file.path===agentPath).sha256)
|
||||
check(id+' binary avatar is not exposed as UTF8',(await snapshot.execute('ycode_expert_resource',{path:plugin.avatar},ctx))?.ok,false)
|
||||
for(const marker of ['.downloaded_at','.created-by-session'])check(id+' does not forge source host marker '+marker,await fs.stat(path.join(installedPath,marker)).then(()=>true,()=>false),false)
|
||||
}
|
||||
const ui=packages.find(pkg=>pkg.id==='ui-designer'),fonts=ui.manifest.files.filter(file=>file.path.endsWith('.ttf'))
|
||||
check('UI package exact full byte count',ui.manifest.files.reduce((n,file)=>n+file.bytes,0),6249920)
|
||||
check('UI package retains all 54 fonts',fonts.length,54)
|
||||
check('UI font SHA retention independently checked',await Promise.all(fonts.map(async file=>hash(await fs.readFile(path.join(ui.installedPath,file.path)))===file.sha256)),fonts.map(()=>true))
|
||||
check('PM C#/XML/XSD/csproj are genuinely readable',resourceChecks.filter(item=>item.expertId==='senior-project-manager'&&/\.(?:cs|csproj|xml|xsd)$/.test(item.path)).length,48)
|
||||
check('PM BOM csproj raw SHA preserved',resourceChecks.filter(item=>item.expertId==='senior-project-manager'&&item.path.endsWith('.csproj')).map(item=>item.pass),[true,true])
|
||||
check('UI HTML source exposed',resourceChecks.some(item=>item.expertId==='ui-designer'&&item.path.endsWith('/viewer.html')&&item.pass))
|
||||
check('UI licenses exposed',resourceChecks.some(item=>item.expertId==='ui-designer'&&/LICENSE$/i.test(item.path)&&item.pass))
|
||||
check('algorithm verifier Python source exposed',resourceChecks.some(item=>item.expertId==='senior-algorithm-expert'&&item.path.endsWith('/verify_shortest_paths.py')&&item.pass))
|
||||
check('no fetch/API call performed',networkCalls,0)
|
||||
for(const source of sourceShas)check('tested source unchanged '+source.file,hash(await fs.readFile(source.file)),source.sha256)
|
||||
await fs.writeFile(path.join(output,'report.json'),JSON.stringify({result:'PASS',checks,imports,packages:packages.map(({manifest,plugin,...rest})=>({...rest,files:manifest.files.length,bytes:manifest.files.reduce((n,file)=>n+file.bytes,0),sourceRootSha256:manifest.rootSha256,declaredSkills:plugin.skills})),resourceChecks,sourceShas,bundleSha256:hash(await fs.readFile(bundle)),networkCalls,limits:['Real imported files and production expert/capability/actual empty MCP SDK profile; Electron getPath only is a QA adapter.','No model API, original expert script, font/browser rendering, .NET build or business workflow was executed.','Original package evidence is inherited from SHA-verified REA recovered manifests; this test never accesses WorkBuddy.']},null,2))
|
||||
console.log(JSON.stringify({result:'PASS',checks:checks.length,packages:packages.length,textResources:resourceChecks.length,output}))
|
||||
}catch(error){
|
||||
await fs.writeFile(path.join(output,'report.json'),JSON.stringify({result:'FAIL',error:error.stack,checks,imports,resourceChecks,sourceShas,networkCalls},null,2))
|
||||
console.error(JSON.stringify({result:'FAIL',error:error.message,output}));process.exitCode=1
|
||||
}finally{globalThis.fetch=originalFetch}
|
||||
@@ -8,8 +8,7 @@ const directory = await fs.mkdtemp(path.resolve('.tmp/mcp-native-ui-'))
|
||||
await Promise.all(['data', 'sessions', 'home'].map(name => fs.mkdir(path.join(directory, name))))
|
||||
const bootstrap = path.join(directory, 'bootstrap.cjs')
|
||||
await fs.writeFile(bootstrap, '(' + nativeQa.toString() + ')()')
|
||||
const env = { ...process.env, YCODE_MCP_UI_DIRECTORY: directory, YCODE_QA_NODE: process.execPath, YCODE_USER_DATA_DIR: path.join(directory, 'data'),
|
||||
YCODE_OMP_HOME: path.join(directory, 'home'), YCODE_QA_OFFSCREEN: '1', YCODE_DISABLE_GPU: '1' }
|
||||
const env = { ...process.env, YCODE_MCP_UI_DIRECTORY: directory, YCODE_QA_NODE: process.execPath, YCODE_USER_DATA_DIR: path.join(directory, 'data'), YCODE_QA_OFFSCREEN: '1', YCODE_DISABLE_GPU: '1' }
|
||||
delete env.ELECTRON_RUN_AS_NODE; delete env.ELECTRON_RENDERER_URL; delete env.YCODE_MODEL_API_KEY
|
||||
const code = await new Promise(resolve => {
|
||||
const child = spawn(createRequire(import.meta.url)('electron'), ['--no-sandbox', '--disable-gpu-sandbox', bootstrap], { env, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] })
|
||||
|
||||
@@ -1,147 +0,0 @@
|
||||
// Isolated production IPC/persistence, real file tools, Gateway, SDK MCP and actual OMP.
|
||||
import fs from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import assert from 'node:assert/strict'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import { createServer } from 'node:http'
|
||||
import { build } from 'esbuild'
|
||||
|
||||
const directory = await fs.mkdtemp(path.resolve('.tmp/mode-policy-'))
|
||||
const userData = path.join(directory, 'data'), cwd = path.join(directory, 'workspace'), ompHome = path.join(directory, 'omp-home')
|
||||
await Promise.all([userData, cwd, ompHome].map(p => fs.mkdir(p)))
|
||||
await fs.writeFile(path.join(cwd, 'seed.txt'), 'MODE_READ_PROOF')
|
||||
await Promise.all(['.git', 'node_modules'].map(p => fs.mkdir(path.join(cwd, p))))
|
||||
const checks = [], requests = [], engineResults = [], handlers = new Map()
|
||||
const state = globalThis.__YCODE_MODE_QA__ = { userData, cwd, handlers, expertWrites: 0, activations: 0 }
|
||||
const previousOmpHome = process.env.YCODE_OMP_HOME
|
||||
process.env.YCODE_OMP_HOME = ompHome
|
||||
const check = (name, actual, expected = true) => { assert.deepEqual(actual, expected, name); checks.push(name) }
|
||||
const electronSource = `const s=globalThis.__YCODE_MODE_QA__;export const app={getPath:n=>n==='home'?s.cwd:s.userData,getName:()=>'YCode',getVersion:()=>'0.1.0'};export const safeStorage={isEncryptionAvailable:()=>false,encryptString:v=>Buffer.from(v),decryptString:v=>v.toString()};export const ipcMain={handle:(n,f)=>s.handlers.set(n,f)};export const dialog={showOpenDialog:async()=>({canceled:true,filePaths:[]})};export const shell={openPath:async()=>''};export class BrowserWindow{};export class WebContentsView{}`
|
||||
const plugins = [{ name: 'isolated-electron', setup(b) {
|
||||
b.onResolve({ filter: /^electron$/ }, () => ({ path: 'electron', namespace: 'mode-qa' }))
|
||||
b.onLoad({ filter: /.*/, namespace: 'mode-qa' }, () => ({ contents: electronSource, loader: 'js' }))
|
||||
b.onResolve({ filter: /^\.\/terminal-host$/ }, () => ({ path: 'terminal', namespace: 'mode-no-terminal' }))
|
||||
b.onLoad({ filter: /.*/, namespace: 'mode-no-terminal' }, () => ({ contents: `const no=()=>{throw Error('Unexpected native terminal')};export const createTerminal=no,getTerminal=no,writeTerminal=no,resizeTerminal=no,clearTerminal=no,closeTerminal=no,rebindTerminal=no,disposeTerminalHosts=no`, loader: 'js' }))
|
||||
// Expert activation is controlled here; production mode validation and atomic session writes run unchanged.
|
||||
b.onResolve({ filter: /(?:^\.\/capabilities\/experts$|^\.\/experts$)/ }, () => ({ path: 'experts', namespace: 'mode-experts' }))
|
||||
b.onLoad({ filter: /.*/, namespace: 'mode-experts' }, () => ({ contents: `const s=globalThis.__YCODE_MODE_QA__;export async function activateExpert(id){s.activations++;return{id}};export function expertManagerDocument(){return{content:'Controlled manager'}};export const expertManagerAdapter=()=>'',expertManagerResource=()=>'';export async function expertPackageOperation(){s.expertWrites++;return{success:true}};export const expertTurnContext=async()=>undefined;export const listExperts=async()=>[],readExpert=async()=>({}),removeExpert=async()=>{},expertPackageOperationTool=()=>{},disposeExperts=async()=>{},subscribeExperts=()=>()=>{},installExpertFromPath=async()=>{},openExpertPath=async()=>{}`, loader: 'js' }))
|
||||
} }]
|
||||
await build({ stdin: { contents: `export {registerIpc,disposeOmpKernels} from './src/main/ipc';export * as sessions from './src/main/sessions';export * as config from './src/main/config';export * as workspaces from './src/main/workspaces';export * as mcp from './src/main/capabilities/mcp';export {createCapabilitySnapshot} from './src/main/capabilities/runtime';export {runTurn} from './src/main/agent/loop';export {runOmpTurn} from './src/main/agent/omp-turn';export {executeConversationTool,executeTurnTool} from './src/main/agent/conversation-tools';export * as policy from './src/main/agent/mode-policy';export {Gateway} from './src/main/gateway';export {OmpKernel,detectOmpPath} from './src/main/omp';export {prepareOmpModel} from './src/main/omp-model'`, resolveDir: process.cwd() }, outfile: path.join(directory, 'runtime.mjs'), bundle: true, platform: 'node', format: 'esm', packages: 'external', plugins, logLevel: 'silent' })
|
||||
const api = await import(pathToFileURL(path.join(directory, 'runtime.mjs')).href)
|
||||
let upstream, kernel
|
||||
const invoke = (name, ...args) => handlers.get(name)({}, ...args)
|
||||
try {
|
||||
await api.config.setWorkspaceRoot(cwd)
|
||||
api.registerIpc(() => ({ webContents: { send() {} } }))
|
||||
const session = await invoke('session:create', 'mode fixture', { agentMode: 'plan' })
|
||||
check('IPC create persists plan', session.agentMode, 'plan')
|
||||
check('plan creation clears expert', session.expertId, null)
|
||||
await assert.rejects(invoke('session:create', 'bad', { agentMode: 'unknown' }), /无效的对话模式/); checks.push('invalid create mode rejected')
|
||||
await assert.rejects(invoke('session:update-context', session.id, { agentMode: null }), /无效的对话模式/); checks.push('invalid IPC update rejected')
|
||||
check('invalid mode leaves old mode', (await api.sessions.getSessionContext(session.id)).agentMode, 'plan')
|
||||
await invoke('session:update-context', session.id, { expertId: 'controlled-expert' })
|
||||
check('expert selection restores craft', (await api.sessions.getSessionContext(session.id)).agentMode, 'craft')
|
||||
await invoke('session:update-context', session.id, { agentMode: 'ask' })
|
||||
const askContext = await api.sessions.getSessionContext(session.id)
|
||||
check('existing conversation enters ask and clears expert atomically', [askContext.agentMode, askContext.expertId], ['ask', null])
|
||||
await Promise.all([invoke('session:update-context', session.id, { agentMode: 'plan' }), invoke('session:update-context', session.id, { agentMode: 'ask' })])
|
||||
check('serialized mode mutations keep latest request', (await api.sessions.getSessionContext(session.id)).agentMode, 'ask')
|
||||
const disk = JSON.parse(await fs.readFile(path.join(userData, 'sessions.json'), 'utf8'))
|
||||
check('mode and expert clear reach real sessions JSON', [disk.sessions[0].agentMode, disk.sessions[0].expertId], ['ask', null])
|
||||
const refreshed = await import(pathToFileURL(path.join(directory, 'runtime.mjs')).href + '?reload=1')
|
||||
check('fresh module hydrates persisted mode', (await refreshed.sessions.getSessionContext(session.id)).agentMode, 'ask')
|
||||
const listing = await invoke('resources:list', session.id, 'intentionally-invalid-workspace')
|
||||
check('resources list prioritizes session root', listing.some(x => x.name === 'seed.txt'))
|
||||
check('resources list excludes hidden dependency roots', !listing.some(x => ['.git', 'node_modules'].includes(x.name)))
|
||||
await assert.rejects(invoke('resources:list', undefined, 'invalid-id'), /空间不存在/); checks.push('resource registry id cannot become arbitrary root')
|
||||
await assert.rejects(invoke('resources:list', 'invalid-session', undefined), /会话不存在/); checks.push('invalid session cannot fall back to a global resource root')
|
||||
const ctx = { runId: 'mode-qa', toolCallId: 'mode-tool', workspaceRoot: cwd, permissionMode: 'full-access', signal: new AbortController().signal, emit() {} }
|
||||
for (const agentMode of ['plan', 'ask']) {
|
||||
check(agentMode + ' reads real file', (await api.executeConversationTool('read_file', { path: 'seed.txt' }, { ...ctx, agentMode })).content.includes('MODE_READ_PROOF'))
|
||||
for (const name of ['write_file', 'edit_file', 'execute_command', 'mcp_unknown', 'ycode_expert_package', 'unknown_tool']) {
|
||||
const result = await api.executeConversationTool(name, { path: 'seed.txt', content: 'BAD', oldText: 'MODE_READ_PROOF', newText: 'BAD', command: 'echo BAD', action: 'write' }, { ...ctx, agentMode })
|
||||
check(agentMode + ' execution denies ' + name, result.ok, false)
|
||||
}
|
||||
check(agentMode + ' todo policy', (await api.executeConversationTool('create_plan', { steps: ['Inspect'] }, { ...ctx, agentMode })).ok, agentMode === 'plan')
|
||||
}
|
||||
check('readonly tool denials leave real source file unchanged', await fs.readFile(path.join(cwd, 'seed.txt'), 'utf8'), 'MODE_READ_PROOF')
|
||||
const saved = await api.mcp.saveMcpServer({ name: 'mode-fixture', transport: 'stdio', command: process.execPath, args: [path.resolve('scripts/test-mcp-server.mjs'), 'stdio', path.join(directory, 'mcp-events.jsonl')] })
|
||||
await api.mcp.trustMcpServer(saved.id, saved.configHash); await api.mcp.setMcpEnabled(saved.id, true)
|
||||
const craftSnapshot = await api.createCapabilitySnapshot(['builtin:expert-manager'], cwd, { sessionId: session.id, agentMode: 'craft' })
|
||||
const mcpName = craftSnapshot.specs.find(x => x.function.name.startsWith('mcp_echo_')).function.name
|
||||
for (const agentMode of ['plan', 'ask']) {
|
||||
const snap = await api.createCapabilitySnapshot([], cwd, { sessionId: session.id, agentMode })
|
||||
check(agentMode + ' hides SDK MCP and expert package tool', !snap.specs.some(x => x.function.name === mcpName || x.function.name === 'ycode_expert_package'))
|
||||
check(agentMode + ' direct capability invocation blocked despite forged craft ctx', (await snap.execute(mcpName, { text: 'BAD' }, { ...ctx, agentMode: 'craft' })).ok, false)
|
||||
check(agentMode + ' blocks craft snapshot before dynamic dispatch', (await api.executeTurnTool(mcpName, { text: 'BAD' }, { ...ctx, agentMode }, craftSnapshot)).ok, false)
|
||||
check(agentMode + ' blocks expert package register before dispatch', (await api.executeTurnTool('ycode_expert_package', { id: 'bad', action: 'register' }, { ...ctx, agentMode }, craftSnapshot)).ok, false)
|
||||
}
|
||||
check('expert package mutation never reached service', state.expertWrites, 0)
|
||||
const noCalls = (await fs.readFile(path.join(directory, 'mcp-events.jsonl'), 'utf8')).trim().split('\n').map(JSON.parse)
|
||||
check('restricted requests never reached actual SDK callTool', noCalls.filter(x => x.type === 'call').length, 0)
|
||||
let phase = '', round = 0
|
||||
const attackCalls = () => [
|
||||
{ name: 'read_file', args: { path: 'seed.txt' } },
|
||||
{ name: 'write_file', args: { path: 'forbidden.txt', content: 'BAD' } },
|
||||
{ name: 'execute_command', args: { command: 'echo BAD > shell-forbidden.txt' } },
|
||||
{ name: mcpName, args: { text: 'BAD' } },
|
||||
{ name: 'todo_write', args: { todos: [{ content: 'Inspect inputs' }] } }
|
||||
]
|
||||
upstream = createServer(async (request, response) => {
|
||||
let raw = ''; for await (const chunk of request) raw += chunk
|
||||
const body = JSON.parse(raw), names = (body.tools ?? []).map(x => x.function.name)
|
||||
requests.push({ phase, path: request.url, tools: names, modeContext: JSON.stringify(body.messages).includes(`mode=\\"${phase.split('-').at(-1)}\\"`) })
|
||||
const calls = round++ === 0 ? attackCalls() : undefined
|
||||
const delta = calls ? { tool_calls: calls.map((c, index) => ({ index, id: 'mode-call-' + index, type: 'function', function: { name: c.name, arguments: JSON.stringify(c.args) } })) } : { content: 'MODE_DONE' }
|
||||
response.writeHead(200, { 'content-type': 'text/event-stream' })
|
||||
const chunk = d => ({ id: 'mode-fixture', object: 'chat.completion.chunk', created: 1700000000, model: body.model, choices: [{ index: 0, delta: d, finish_reason: null }] })
|
||||
const end = chunk({}); end.choices[0].finish_reason = calls ? 'tool_calls' : 'stop'
|
||||
response.end('data: ' + JSON.stringify(chunk(delta)) + '\n\ndata: ' + JSON.stringify(end) + '\n\ndata: [DONE]\n\n')
|
||||
})
|
||||
await new Promise(resolve => upstream.listen(0, '127.0.0.1', resolve))
|
||||
const config = { baseUrl: `http://127.0.0.1:${upstream.address().port}/v1`, model: 'mode-fixture', apiKey: '', authMode: 'none', temperature: 0, maxToolRounds: 5 }
|
||||
for (const mode of ['plan', 'ask']) {
|
||||
phase = 'gateway-' + mode; round = 0
|
||||
const result = await api.runTurn({ runId: phase, agentMode: mode, history: [], userText: 'Controlled mode QA', signal: ctx.signal, emit() {}, gateway: new api.Gateway(config), model: config.model, maxToolRounds: 5, workspaceRoot: cwd, permissionMode: 'full-access', capabilityTools: craftSnapshot })
|
||||
engineResults.push({ phase, tools: result.tools.map(({ name, status, output }) => ({ name, status, output })) })
|
||||
check(phase + ' real Gateway returns terminal', result.content, 'MODE_DONE')
|
||||
check(phase + ' actual tool results enforce mode', result.tools.map(x => x.status), ['ok', 'error', 'error', 'error', mode === 'plan' ? 'ok' : 'error'])
|
||||
}
|
||||
const exe = api.detectOmpPath()
|
||||
assert.ok(exe, 'Actual OMP must be available; no simulated engine PASS')
|
||||
const prepared = await api.prepareOmpModel({ id: 'mode-fixture', config, profile: { id: 'mode-fixture', name: 'Mode fixture', model: config.model, providerId: 'openai', baseUrl: config.baseUrl, authMode: 'none', supportsToolCall: true, supportsImages: false, supportsReasoning: false, maxInputTokens: 32000, maxOutputTokens: 2048, hasApiKey: false, keyStorage: 'none' } })
|
||||
for (const mode of ['plan', 'ask']) {
|
||||
phase = 'omp-' + mode; round = 0
|
||||
kernel = new api.OmpKernel(exe, cwd, prepared.profile, path.join(directory, mode + '.jsonl'), prepared.model, prepared.env)
|
||||
await kernel.start()
|
||||
const result = await api.runOmpTurn({ runId: phase, agentMode: mode, kernel, userText: 'Controlled mode QA', signal: ctx.signal, emit() {}, workspaceRoot: cwd, permissionMode: 'full-access', capabilityTools: craftSnapshot })
|
||||
engineResults.push({ phase, tools: result.tools.map(({ name, status, output }) => ({ name, status, output })) })
|
||||
check(phase + ' actual OMP terminal', result.status, 'done')
|
||||
check(phase + ' actual OMP final reply', result.content, 'MODE_DONE')
|
||||
check(phase + ' readonly read succeeds', result.tools.some(x => x.name === 'read_file' && x.status === 'ok'))
|
||||
const hidden = result.tools.filter(x => ['write_file', 'execute_command', mcpName].includes(x.name))
|
||||
check(phase + ' all three forbidden attempts are observed', hidden.length, 3)
|
||||
check(phase + ' hidden harmful calls fail', hidden.every(x => x.status === 'error'))
|
||||
check(phase + ' planning tool follows current mode', result.tools.find(x => x.name === 'todo_write')?.status, mode === 'plan' ? 'ok' : 'error')
|
||||
await kernel.dispose(); kernel = undefined
|
||||
}
|
||||
for (const mode of ['plan', 'ask']) {
|
||||
const matching = requests.filter(r => r.phase.endsWith(mode))
|
||||
check(mode + ' tool visibility filtered in both actual engines', matching.every(r => r.tools.includes('read_file') && !r.tools.includes('write_file') && !r.tools.includes('execute_command') && !r.tools.includes(mcpName) && (mode === 'plan' || !r.tools.includes('todo_write'))))
|
||||
check(mode + ' honest mode instruction reaches both actual engines', matching.length >= 4 && matching.every(r => r.modeContext))
|
||||
}
|
||||
check('no hidden write produced a file', await fs.stat(path.join(cwd, 'forbidden.txt')).then(() => true, () => false), false)
|
||||
check('no hidden shell command ran', await fs.stat(path.join(cwd, 'shell-forbidden.txt')).then(() => true, () => false), false)
|
||||
check('neither actual engine reached MCP callTool in restricted mode', (await fs.readFile(path.join(directory, 'mcp-events.jsonl'), 'utf8')).trim().split('\n').map(JSON.parse).filter(x => x.type === 'call').length, 0)
|
||||
check('craft still permits real write', (await api.executeTurnTool('write_file', { path: 'craft.txt', content: 'CRAFT_OK' }, { ...ctx, agentMode: 'craft' }, craftSnapshot)).ok)
|
||||
check('craft SDK MCP retains actual execution', (await api.executeTurnTool(mcpName, { text: 'CRAFT_OK' }, { ...ctx, agentMode: 'craft' }, craftSnapshot)).content, 'stdio:CRAFT_OK')
|
||||
const report = { result: 'PASS', checks, requests, engineResults, directory, limitations: ['Electron transport is controlled IPC registration; real session persistence and tool implementations.', 'Actual OMP executable and actual Gateway hit only a loopback SSE fixture; no remote model or native UI tested.', 'MCP uses an actual SDK stdio fixture. No WorkBuddy/OMP native mode equivalence claimed.'] }
|
||||
await fs.writeFile(path.join(directory, 'report.json'), JSON.stringify(report, null, 2))
|
||||
console.log(JSON.stringify({ result: 'PASS', checks: checks.length, directory }))
|
||||
} catch (error) {
|
||||
await fs.writeFile(path.join(directory, 'report.json'), JSON.stringify({ result: 'FAIL', checks, requests, engineResults, error: String(error), directory }, null, 2)); throw error
|
||||
} finally {
|
||||
await kernel?.dispose(); await api.mcp.disposeMcpServers(); await api.disposeOmpKernels()
|
||||
if (upstream) { upstream.closeAllConnections(); await new Promise(resolve => upstream.close(resolve)) }
|
||||
if (previousOmpHome === undefined) delete process.env.YCODE_OMP_HOME; else process.env.YCODE_OMP_HOME = previousOmpHome
|
||||
}
|
||||
@@ -1,320 +0,0 @@
|
||||
import { EventEmitter } from 'node:events'
|
||||
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import type { Gateway } from '../src/main/gateway'
|
||||
import { runTurn } from '../src/main/agent/loop'
|
||||
import { runOmpTurn, type OmpTurnResult } from '../src/main/agent/omp-turn'
|
||||
import { toolSpecs } from '../src/main/agent/tools'
|
||||
import type { OmpFrame, OmpKernel } from '../src/main/omp'
|
||||
import type { ChatEvent } from '../src/shared/types'
|
||||
|
||||
type Outcome<T> =
|
||||
| { kind: 'resolved'; value: T }
|
||||
| { kind: 'rejected'; error: unknown }
|
||||
| { kind: 'timeout' }
|
||||
|
||||
class FakeOmpKernel extends EventEmitter {
|
||||
prompt(_message: string): Promise<OmpFrame> {
|
||||
return Promise.resolve({ type: 'response', success: true })
|
||||
}
|
||||
|
||||
abort(): Promise<OmpFrame> {
|
||||
return Promise.resolve({ type: 'response', success: true })
|
||||
}
|
||||
}
|
||||
|
||||
let failures = 0
|
||||
|
||||
function check(label: string, condition: boolean, detail = ''): void {
|
||||
if (condition) console.log(` ✓ ${label}`)
|
||||
else {
|
||||
failures++
|
||||
console.error(` ✗ ${label}${detail ? ` — ${detail}` : ''}`)
|
||||
}
|
||||
}
|
||||
|
||||
async function settleWithin<T>(promise: Promise<T>, timeoutMs = 500): Promise<Outcome<T>> {
|
||||
let timer: NodeJS.Timeout | undefined
|
||||
const timeout = new Promise<Outcome<T>>((resolve) => {
|
||||
timer = setTimeout(() => resolve({ kind: 'timeout' }), timeoutMs)
|
||||
})
|
||||
const settled = promise.then<Outcome<T>, Outcome<T>>(
|
||||
(value) => ({ kind: 'resolved', value }),
|
||||
(error: unknown) => ({ kind: 'rejected', error })
|
||||
)
|
||||
const outcome = await Promise.race([settled, timeout])
|
||||
if (timer) clearTimeout(timer)
|
||||
return outcome
|
||||
}
|
||||
|
||||
function startTurn(kernel: FakeOmpKernel, signal: AbortSignal, events: ChatEvent[]): Promise<OmpTurnResult> {
|
||||
return runOmpTurn({
|
||||
runId: 'tool-output-regression',
|
||||
kernel: kernel as unknown as OmpKernel,
|
||||
userText: 'test',
|
||||
signal,
|
||||
emit: (event) => events.push(event)
|
||||
})
|
||||
}
|
||||
|
||||
function lastToolResult(events: ChatEvent[], id: string): number {
|
||||
return events.findLastIndex((event) => event.type === 'tool_result' && event.call.id === id)
|
||||
}
|
||||
|
||||
async function testStructuredOutputAndIdentity(): Promise<void> {
|
||||
console.log('\n[1] OMP structured output and tool identity')
|
||||
const specs = toolSpecs()
|
||||
for (const name of ['list_dir', 'read_file']) {
|
||||
const parameters = specs.find((spec) => spec.function.name === name)?.function.parameters as Record<string, unknown> | undefined
|
||||
const properties = parameters?.properties as Record<string, unknown> | undefined
|
||||
const required = parameters?.required as string[] | undefined
|
||||
check(`${name} accepts an optional timeline description`, properties?.description !== undefined && !required?.includes('description'))
|
||||
}
|
||||
const kernel = new FakeOmpKernel()
|
||||
const baseline = { event: kernel.listenerCount('event'), error: kernel.listenerCount('error'), exit: kernel.listenerCount('exit') }
|
||||
const events: ChatEvent[] = []
|
||||
const pending = startTurn(kernel, new AbortController().signal, events)
|
||||
const fullText = `${'x'.repeat(720)}AFTER-SUMMARY-SENTINEL`
|
||||
|
||||
kernel.emit('event', {
|
||||
type: 'tool_execution_start',
|
||||
data: {
|
||||
toolCallId: 'native-call',
|
||||
toolName: 'read_file',
|
||||
args: { path: 'large.txt' },
|
||||
intent: 'Reading project handover status'
|
||||
}
|
||||
})
|
||||
kernel.emit('event', {
|
||||
type: 'tool_execution_update',
|
||||
toolCallId: 'native-call',
|
||||
partialResult: { content: [{ type: 'text', text: fullText.slice(0, 650) }] }
|
||||
})
|
||||
kernel.emit('event', {
|
||||
type: 'tool_execution_end',
|
||||
toolCallId: 'native-call',
|
||||
result: { content: [{ type: 'text', text: fullText }], isError: false },
|
||||
success: true
|
||||
})
|
||||
kernel.emit('event', { type: 'agent_end', yielded: true })
|
||||
|
||||
const outcome = await settleWithin(pending)
|
||||
const record = outcome.kind === 'resolved' ? outcome.value.tools[0] : undefined
|
||||
check('native text result is retained in full output', record?.output === fullText)
|
||||
check('the summary remains truncated while the sentinel stays in output',
|
||||
record?.summary?.length !== undefined && record.summary.length < fullText.length && record.output?.endsWith('AFTER-SUMMARY-SENTINEL') === true)
|
||||
check('id-only update/end preserve the original tool identity and arguments',
|
||||
record?.id === 'native-call' && record.name === 'read_file' && JSON.stringify(record.args) === JSON.stringify({ path: 'large.txt' }))
|
||||
check('tool intent survives OMP start, progress updates and finalization',
|
||||
record?.intent === 'Reading project handover status' && events.find((event) => event.type === 'tool_call' && event.call.id === 'native-call')?.type === 'tool_call' &&
|
||||
events.find((event) => event.type === 'tool_call' && event.call.id === 'native-call')?.call.intent === 'Reading project handover status')
|
||||
check('successful result status and actual duration are recorded', record?.status === 'ok' && typeof record.durationMs === 'number')
|
||||
check('tool result update precedes the terminal done event', lastToolResult(events, 'native-call') >= 0 &&
|
||||
events.findIndex((event) => event.type === 'done') > lastToolResult(events, 'native-call'))
|
||||
check('OMP listeners return to their initial counts',
|
||||
kernel.listenerCount('event') === baseline.event && kernel.listenerCount('error') === baseline.error && kernel.listenerCount('exit') === baseline.exit)
|
||||
kernel.removeAllListeners()
|
||||
}
|
||||
|
||||
async function testUpdateSnapshotsAndEmptyFinalOutput(): Promise<void> {
|
||||
console.log('\n[2] OMP update snapshots and empty final output')
|
||||
const kernel = new FakeOmpKernel()
|
||||
const events: ChatEvent[] = []
|
||||
const pending = startTurn(kernel, new AbortController().signal, events)
|
||||
kernel.emit('event', { type: 'tool_execution_start', toolCallId: 'progress-call', toolName: 'shell', args: {} })
|
||||
kernel.emit('event', {
|
||||
type: 'tool_execution_update',
|
||||
toolCallId: 'progress-call',
|
||||
partialResult: { content: [{ type: 'text', text: 'phase 1' }] }
|
||||
})
|
||||
kernel.emit('event', {
|
||||
type: 'tool_execution_update',
|
||||
toolCallId: 'progress-call',
|
||||
partialResult: { content: [{ type: 'text', text: 'phase 2' }] }
|
||||
})
|
||||
const progress = events.findLast((event) => event.type === 'tool_result' && event.call.id === 'progress-call')
|
||||
check('partialResult updates replace the prior progress snapshot',
|
||||
progress?.type === 'tool_result' && progress.call.output === 'phase 2')
|
||||
|
||||
kernel.emit('event', {
|
||||
type: 'tool_execution_end',
|
||||
toolCallId: 'progress-call',
|
||||
result: { content: [{ type: 'text', text: '' }], isError: false }
|
||||
})
|
||||
kernel.emit('event', { type: 'agent_end', yielded: true })
|
||||
const outcome = await settleWithin(pending)
|
||||
const record = outcome.kind === 'resolved' ? outcome.value.tools[0] : undefined
|
||||
check('an empty final text result overwrites prior progress text', record?.output === '' && record.summary === '')
|
||||
kernel.removeAllListeners()
|
||||
}
|
||||
|
||||
async function testErrorAndCancellationFinalization(): Promise<void> {
|
||||
console.log('\n[3] OMP error and cancellation finalization')
|
||||
{
|
||||
const kernel = new FakeOmpKernel()
|
||||
const events: ChatEvent[] = []
|
||||
const pending = startTurn(kernel, new AbortController().signal, events)
|
||||
kernel.emit('event', { type: 'tool_execution_start', toolCallId: 'failed-call', toolName: 'shell', args: { command: 'x' } })
|
||||
kernel.emit('event', { type: 'tool_execution_update', toolCallId: 'failed-call', partialResult: { content: [{ type: 'text', text: 'partial-output' }] } })
|
||||
kernel.emit('event', {
|
||||
type: 'tool_execution_end',
|
||||
toolCallId: 'failed-call',
|
||||
result: { content: [{ type: 'text', text: 'final-error-output' }], isError: true },
|
||||
success: true
|
||||
})
|
||||
kernel.emit('event', { type: 'agent_end', yielded: true })
|
||||
const outcome = await settleWithin(pending)
|
||||
const record = outcome.kind === 'resolved' ? outcome.value.tools[0] : undefined
|
||||
check('result.isError wins over success=true', record?.status === 'error')
|
||||
check('final text output is retained for an errored tool', record?.output === 'final-error-output')
|
||||
kernel.removeAllListeners()
|
||||
}
|
||||
|
||||
{
|
||||
const kernel = new FakeOmpKernel()
|
||||
const events: ChatEvent[] = []
|
||||
const pending = startTurn(kernel, new AbortController().signal, events)
|
||||
kernel.emit('event', { type: 'tool_execution_start', toolCallId: 'kernel-failure', toolName: 'shell', args: {} })
|
||||
kernel.emit('event', { type: 'tool_execution_update', toolCallId: 'kernel-failure', partialResult: 'kept-before-failure' })
|
||||
kernel.emit('error', new Error('kernel failed'))
|
||||
const outcome = await settleWithin(pending)
|
||||
const record = events.findLast((event) => event.type === 'tool_result' && event.call.id === 'kernel-failure')
|
||||
const terminalIndex = events.findIndex((event) => event.type === 'error')
|
||||
check('kernel failure rejects the turn', outcome.kind === 'rejected')
|
||||
check('in-flight tool is finalized as error with partial output', record?.type === 'tool_result' && record.call.status === 'error' && record.call.output === 'kept-before-failure')
|
||||
check('final tool update is emitted before the terminal error', lastToolResult(events, 'kernel-failure') >= 0 && terminalIndex > lastToolResult(events, 'kernel-failure'))
|
||||
check('kernel failure removes all turn listeners', kernel.listenerCount('event') === 0 && kernel.listenerCount('error') === 0 && kernel.listenerCount('exit') === 0)
|
||||
kernel.removeAllListeners()
|
||||
}
|
||||
|
||||
{
|
||||
const kernel = new FakeOmpKernel()
|
||||
const controller = new AbortController()
|
||||
const events: ChatEvent[] = []
|
||||
const pending = startTurn(kernel, controller.signal, events)
|
||||
kernel.emit('event', { type: 'tool_execution_start', toolCallId: 'cancelled-call', toolName: 'shell', args: {} })
|
||||
kernel.emit('event', { type: 'tool_execution_update', toolCallId: 'cancelled-call', partialResult: 'kept-before-cancel' })
|
||||
controller.abort()
|
||||
kernel.emit('event', { type: 'agent_end', yielded: true })
|
||||
const outcome = await settleWithin(pending)
|
||||
const record = outcome.kind === 'resolved' ? outcome.value.tools[0] : undefined
|
||||
check('cancelled turn resolves as cancelled', outcome.kind === 'resolved' && outcome.value.status === 'cancelled')
|
||||
check('in-flight tool is finalized as cancelled with partial output', record?.status === 'cancelled' && record.output === 'kept-before-cancel')
|
||||
check('cancelled tool update precedes cancelled terminal event', lastToolResult(events, 'cancelled-call') >= 0 &&
|
||||
events.findIndex((event) => event.type === 'cancelled') > lastToolResult(events, 'cancelled-call'))
|
||||
kernel.removeAllListeners()
|
||||
}
|
||||
}
|
||||
|
||||
async function testNonTextOutput(): Promise<void> {
|
||||
console.log('\n[4] OMP output normalization')
|
||||
const kernel = new FakeOmpKernel()
|
||||
const events: ChatEvent[] = []
|
||||
const pending = startTurn(kernel, new AbortController().signal, events)
|
||||
kernel.emit('event', { type: 'tool_execution_start', toolCallId: 'image-call', toolName: 'inspect', args: {} })
|
||||
kernel.emit('event', {
|
||||
type: 'tool_execution_end',
|
||||
toolCallId: 'image-call',
|
||||
result: { content: [{ type: 'image', data: 'do-not-serialize-this' }], isError: false }
|
||||
})
|
||||
kernel.emit('event', { type: 'tool_execution_start', toolCallId: 'json-call', toolName: 'inspect', args: {} })
|
||||
kernel.emit('event', {
|
||||
type: 'tool_execution_end',
|
||||
toolCallId: 'json-call',
|
||||
result: { code: 7, message: 'bounded JSON fallback' }
|
||||
})
|
||||
kernel.emit('event', { type: 'tool_execution_start', toolCallId: 'multi-text-call', toolName: 'inspect', args: {} })
|
||||
kernel.emit('event', {
|
||||
type: 'tool_execution_end',
|
||||
toolCallId: 'multi-text-call',
|
||||
result: { content: [{ type: 'text', text: 'first block' }, { type: 'text', text: 'second block' }] }
|
||||
})
|
||||
kernel.emit('event', { type: 'agent_end', yielded: true })
|
||||
const outcome = await settleWithin(pending)
|
||||
const imageRecord = outcome.kind === 'resolved' ? outcome.value.tools.find((tool) => tool.id === 'image-call') : undefined
|
||||
const jsonRecord = outcome.kind === 'resolved' ? outcome.value.tools.find((tool) => tool.id === 'json-call') : undefined
|
||||
const multiTextRecord = outcome.kind === 'resolved' ? outcome.value.tools.find((tool) => tool.id === 'multi-text-call') : undefined
|
||||
check('image-only content is not serialized into tool output', imageRecord?.output === undefined)
|
||||
check('other result objects use JSON text fallback', jsonRecord?.output === '{"code":7,"message":"bounded JSON fallback"}')
|
||||
check('multiple text blocks retain order with newline separators', multiTextRecord?.output === 'first block\nsecond block')
|
||||
kernel.removeAllListeners()
|
||||
}
|
||||
|
||||
async function testFallbackLoopOutputAndCancellation(): Promise<void> {
|
||||
console.log('\n[5] fallback loop output and cancellation')
|
||||
const workspaceRoot = await mkdtemp(join(tmpdir(), 'ycode-tool-output-'))
|
||||
try {
|
||||
const fullText = `${'line\n'.repeat(150)}AFTER-SUMMARY-SENTINEL`
|
||||
await writeFile(join(workspaceRoot, 'large.txt'), fullText, 'utf8')
|
||||
let request = 0
|
||||
const gateway = {
|
||||
async *stream() {
|
||||
request++
|
||||
if (request === 1) {
|
||||
yield { type: 'tool_calls', calls: [{ id: 'fallback-call', function: { name: 'read_file', arguments: '{"path":"large.txt"}' } }] }
|
||||
} else {
|
||||
yield { type: 'text', text: 'finished' }
|
||||
}
|
||||
}
|
||||
} as unknown as Gateway
|
||||
const events: ChatEvent[] = []
|
||||
const result = await runTurn({
|
||||
runId: 'fallback-output',
|
||||
history: [],
|
||||
userText: 'read',
|
||||
signal: new AbortController().signal,
|
||||
emit: (event) => events.push(event),
|
||||
gateway,
|
||||
model: 'test',
|
||||
maxToolRounds: 2,
|
||||
workspaceRoot
|
||||
})
|
||||
check('fallback tool record retains complete result content', result.tools[0]?.output?.endsWith('AFTER-SUMMARY-SENTINEL') === true)
|
||||
|
||||
let abortRequest = 0
|
||||
const abortGateway = {
|
||||
async *stream() {
|
||||
abortRequest++
|
||||
if (abortRequest === 1) {
|
||||
yield { type: 'tool_calls', calls: [{ id: 'fallback-cancel', function: { name: 'read_file', arguments: '{"path":"large.txt"}' } }] }
|
||||
}
|
||||
}
|
||||
} as unknown as Gateway
|
||||
const abortingSignal = { get aborted() { return true } } as AbortSignal
|
||||
const cancelEvents: ChatEvent[] = []
|
||||
const cancelled = await runTurn({
|
||||
runId: 'fallback-cancel',
|
||||
history: [],
|
||||
userText: 'read',
|
||||
signal: abortingSignal,
|
||||
emit: (event) => cancelEvents.push(event),
|
||||
gateway: abortGateway,
|
||||
model: 'test',
|
||||
maxToolRounds: 1,
|
||||
workspaceRoot
|
||||
}).then(() => 'resolved', (error: Error) => error.name)
|
||||
const finalized = cancelEvents.find((event) => event.type === 'tool_result' && event.call.id === 'fallback-cancel')
|
||||
check('pre-aborted fallback cancels without executing the requested tool', cancelled === 'AbortError' &&
|
||||
finalized?.type === 'tool_result' && finalized.call.status === 'cancelled' && finalized.call.output === undefined)
|
||||
check('fallback cancelled tool update precedes terminal event',
|
||||
cancelEvents.findIndex((event) => event.type === 'cancelled') > cancelEvents.findIndex((event) => event.type === 'tool_result' && event.call.id === 'fallback-cancel'))
|
||||
} finally {
|
||||
await rm(workspaceRoot, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
await testStructuredOutputAndIdentity()
|
||||
await testUpdateSnapshotsAndEmptyFinalOutput()
|
||||
await testErrorAndCancellationFinalization()
|
||||
await testNonTextOutput()
|
||||
await testFallbackLoopOutputAndCancellation()
|
||||
console.log(failures === 0 ? '\nTool output regression checks passed.\n' : `\n${failures} tool output regression check(s) failed.\n`)
|
||||
process.exitCode = failures === 0 ? 0 : 1
|
||||
}
|
||||
|
||||
main().catch((error: unknown) => {
|
||||
console.error('Tool output regression checks aborted:', error)
|
||||
process.exitCode = 1
|
||||
})
|
||||
@@ -1,165 +0,0 @@
|
||||
/**
|
||||
* Vendors the pinned OMP kernel binary and its license files into resources/omp.
|
||||
*
|
||||
* The binary is not committed to Git. `npm run omp:vendor` restores it from the
|
||||
* pinned upstream release (or from a local copy via `--from <file|directory>`)
|
||||
* and verifies every asset against resources/omp/runtime.lock.json.
|
||||
*/
|
||||
|
||||
import { createHash } from 'node:crypto'
|
||||
import { createReadStream, createWriteStream, promises as fs } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { Readable } from 'node:stream'
|
||||
import { pipeline } from 'node:stream/promises'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const project = fileURLToPath(new URL('../', import.meta.url))
|
||||
const directory = path.join(project, 'resources', 'omp')
|
||||
const lockPath = path.join(directory, 'runtime.lock.json')
|
||||
const hashPattern = /^[a-f0-9]{64}$/
|
||||
const namePattern = /^[A-Za-z0-9._-]+$/
|
||||
const releasePrefix = 'https://github.com/can1357/oh-my-pi/releases/download/'
|
||||
const rawPrefix = 'https://raw.githubusercontent.com/can1357/oh-my-pi/'
|
||||
const maxAssetBytes = 512 * 1024 * 1024
|
||||
|
||||
function asset(value, label) {
|
||||
if (!value || typeof value !== 'object' || typeof value.name !== 'string' || !namePattern.test(value.name)
|
||||
|| !Number.isSafeInteger(value.bytes) || value.bytes <= 0 || value.bytes > maxAssetBytes
|
||||
|| typeof value.sha256 !== 'string' || !hashPattern.test(value.sha256)) {
|
||||
throw new Error(`Invalid ${label} entry in runtime.lock.json`)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
async function readLock() {
|
||||
const lock = JSON.parse((await fs.readFile(lockPath, 'utf8')).replace(/^\uFEFF/, ''))
|
||||
if (lock.format !== 1 || lock.kernel !== 'omp' || lock.upstream !== 'oh-my-pi' || lock.platform !== 'win32-x64'
|
||||
|| typeof lock.version !== 'string' || !/^\d+\.\d+\.\d+$/.test(lock.version)
|
||||
|| lock.releaseUrl !== `https://github.com/can1357/oh-my-pi/releases/tag/v${lock.version}`
|
||||
|| !Number.isFinite(Date.parse(lock.publishedAt))) {
|
||||
throw new Error('Invalid OMP runtime lock')
|
||||
}
|
||||
const binary = asset(lock.binary, 'binary')
|
||||
const license = asset(lock.license, 'license')
|
||||
const thirdPartyNotices = asset(lock.thirdPartyNotices, 'thirdPartyNotices')
|
||||
if (binary.name !== 'omp.exe' || license.name !== 'LICENSE' || thirdPartyNotices.name !== 'THIRD-PARTY-NOTICES.txt') {
|
||||
throw new Error('OMP runtime lock uses unexpected asset names')
|
||||
}
|
||||
return { lock, binary, license, thirdPartyNotices }
|
||||
}
|
||||
|
||||
async function sha256File(file) {
|
||||
const hash = createHash('sha256')
|
||||
for await (const chunk of createReadStream(file)) hash.update(chunk)
|
||||
return hash.digest('hex')
|
||||
}
|
||||
|
||||
async function verifyFile(file, spec) {
|
||||
const stat = await fs.stat(file).catch(() => null)
|
||||
if (!stat?.isFile()) throw new Error(`Missing OMP asset: ${path.relative(project, file)}`)
|
||||
if (stat.size !== spec.bytes) throw new Error(`OMP asset size mismatch: ${spec.name} (${stat.size} != ${spec.bytes})`)
|
||||
const digest = await sha256File(file)
|
||||
if (digest !== spec.sha256) throw new Error(`OMP asset digest mismatch: ${spec.name}`)
|
||||
return digest
|
||||
}
|
||||
|
||||
async function copyVerified(source, target, spec) {
|
||||
if (path.resolve(source) === path.resolve(target)) return verifyFile(target, spec)
|
||||
const stat = await fs.stat(source)
|
||||
const file = stat.isDirectory() ? path.join(source, spec.name) : source
|
||||
const sourceStat = await fs.stat(file)
|
||||
if (!sourceStat.isFile() || sourceStat.size !== spec.bytes) throw new Error(`Local OMP asset has an unexpected size: ${file}`)
|
||||
const digest = await sha256File(file)
|
||||
if (digest !== spec.sha256) throw new Error(`Local OMP asset does not match the pinned release: ${file}`)
|
||||
const temporary = `${target}.part`
|
||||
await fs.copyFile(file, temporary)
|
||||
try {
|
||||
await verifyFile(temporary, spec)
|
||||
await fs.rename(temporary, target)
|
||||
} catch (error) {
|
||||
await fs.rm(temporary, { force: true })
|
||||
throw error
|
||||
}
|
||||
return digest
|
||||
}
|
||||
|
||||
async function download(url, target, spec) {
|
||||
const response = await fetch(url, {
|
||||
redirect: 'follow',
|
||||
headers: { 'user-agent': 'YCode OMP vendor' },
|
||||
signal: AbortSignal.timeout(10 * 60 * 1000)
|
||||
})
|
||||
if (!response.ok || !response.body) throw new Error(`OMP download failed (${response.status}): ${url}`)
|
||||
const declared = response.headers.get('content-length')
|
||||
if (declared && Number(declared) !== spec.bytes) throw new Error(`OMP download size header mismatch: ${spec.name}`)
|
||||
const temporary = `${target}.part`
|
||||
try {
|
||||
await pipeline(Readable.fromWeb(response.body), createWriteStream(temporary))
|
||||
const digest = await verifyFile(temporary, spec)
|
||||
await fs.rename(temporary, target)
|
||||
return digest
|
||||
} catch (error) {
|
||||
await fs.rm(temporary, { force: true })
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
function releaseUrl(version, name) {
|
||||
const url = `${releasePrefix}v${version}/${name}`
|
||||
if (!url.startsWith(releasePrefix) || !namePattern.test(name)) throw new Error(`Unsafe OMP release URL: ${url}`)
|
||||
return url
|
||||
}
|
||||
|
||||
function rawUrl(version, name) {
|
||||
const url = `${rawPrefix}v${version}/${name}`
|
||||
if (!url.startsWith(rawPrefix) || !namePattern.test(name)) throw new Error(`Unsafe OMP raw URL: ${url}`)
|
||||
return url
|
||||
}
|
||||
|
||||
async function ensureAsset({ lock, spec, source, target, verifyOnly, offline }) {
|
||||
if (await fs.stat(target).catch(() => null)) {
|
||||
const digest = await verifyFile(target, spec)
|
||||
console.log(`[omp] ${spec.name} verified (${spec.bytes} bytes, ${digest.slice(0, 12)}…)`)
|
||||
return
|
||||
}
|
||||
if (verifyOnly) throw new Error(`Missing OMP asset: ${path.relative(project, target)}`)
|
||||
if (source) {
|
||||
if (spec.name !== 'omp.exe') throw new Error(`--from only applies to omp.exe, not ${spec.name}`)
|
||||
const digest = await copyVerified(source, target, spec)
|
||||
console.log(`[omp] ${spec.name} imported from ${source} (${spec.bytes} bytes, ${digest.slice(0, 12)}…)`)
|
||||
return
|
||||
}
|
||||
if (offline) throw new Error(`OMP asset is missing in offline mode: ${path.relative(project, target)}`)
|
||||
try {
|
||||
const digest = await download(releaseUrl(lock.version, spec.name), target, spec)
|
||||
console.log(`[omp] ${spec.name} downloaded (${spec.bytes} bytes, ${digest.slice(0, 12)}…)`)
|
||||
} catch (releaseError) {
|
||||
if (spec.name === 'omp.exe') throw releaseError
|
||||
const digest = await download(rawUrl(lock.version, spec.name), target, spec)
|
||||
console.log(`[omp] ${spec.name} downloaded from raw source (${spec.bytes} bytes, ${digest.slice(0, 12)}…)`)
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const args = process.argv.slice(2)
|
||||
const verifyOnly = args.includes('--verify')
|
||||
const offline = args.includes('--offline')
|
||||
const fromIndex = args.indexOf('--from')
|
||||
const source = fromIndex >= 0 ? args[fromIndex + 1] : null
|
||||
if (fromIndex >= 0 && (!source || source.startsWith('--'))) throw new Error('--from requires a file or directory')
|
||||
|
||||
const { lock, binary, license, thirdPartyNotices } = await readLock()
|
||||
await fs.mkdir(directory, { recursive: true })
|
||||
console.log(`[omp] pinned ${lock.upstream} v${lock.version} (${lock.platform})`)
|
||||
|
||||
await ensureAsset({ lock, spec: binary, source, target: path.join(directory, binary.name), verifyOnly, offline })
|
||||
await ensureAsset({ lock, spec: license, target: path.join(directory, license.name), verifyOnly, offline })
|
||||
await ensureAsset({ lock, spec: thirdPartyNotices, target: path.join(directory, thirdPartyNotices.name), verifyOnly, offline })
|
||||
|
||||
console.log(`[omp] ready: ${path.relative(project, directory)}`)
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(`[omp] ${error.message}`)
|
||||
process.exitCode = 1
|
||||
})
|
||||
@@ -1,156 +0,0 @@
|
||||
import { promises as fs } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { createHash, randomUUID } from 'node:crypto'
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url'
|
||||
import { build } from 'esbuild'
|
||||
import { unzipSync } from 'fflate'
|
||||
|
||||
const project = fileURLToPath(new URL('../', import.meta.url))
|
||||
const lockPath = path.join(project, 'resources/sandbox/git-runtime.lock.json')
|
||||
const defaults = path.join(project, 'resources/sandbox/win32-x64/git')
|
||||
const sha256 = bytes => createHash('sha256').update(bytes).digest('hex')
|
||||
const hashPattern = /^[a-f0-9]{64}$/
|
||||
const uuidPattern = /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/
|
||||
const within = (root, target) => { const rel = path.relative(root, target); return rel !== '..' && !rel.startsWith(`..${path.sep}`) && !path.isAbsolute(rel) }
|
||||
const safePath = name => typeof name === 'string' && name.length > 0 && name.length <= 2048 && !path.isAbsolute(name) && !/[\\:\x00-\x1f]/.test(name)
|
||||
&& !name.split('/').some(part => !part || part === '.' || part === '..' || /[. ]$/.test(part) || /^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/i.test(part))
|
||||
async function regular(file, maximum = 128 * 1024 * 1024) {
|
||||
const stat = await fs.lstat(file)
|
||||
if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1 || stat.size > maximum) throw new Error(`Unsafe bundled Git file: ${file}`)
|
||||
const bytes = await fs.readFile(file)
|
||||
if (bytes.length !== stat.size) throw new Error('Bundled Git file changed during reading')
|
||||
return bytes
|
||||
}
|
||||
async function directory(file) {
|
||||
const stat = await fs.lstat(file)
|
||||
if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error(`Bundled Git directory is a link or special file: ${file}`)
|
||||
}
|
||||
export async function readGitRuntimeLock() {
|
||||
const lock = JSON.parse((await regular(lockPath, 64 * 1024)).toString('utf8'))
|
||||
if (lock.format !== 1 || lock.provider !== 'git-for-windows' || lock.platform !== 'win32-x64' || !/^\d+\.\d+\.\d+\.windows\.\d+$/.test(lock.version)
|
||||
|| !hashPattern.test(lock.runtimeManifestSha256) || !hashPattern.test(lock.archive?.sha256) || !Number.isSafeInteger(lock.archive.bytes) || lock.archive.bytes < 1 || lock.archive.bytes > 128 * 1024 * 1024
|
||||
|| new URL(lock.archive.url).origin !== 'https://github.com' || !lock.archive.url.startsWith('https://github.com/git-for-windows/git/releases/download/')
|
||||
|| lock.archive.name !== path.basename(lock.archive.url) || !Number.isSafeInteger(Date.parse(lock.publishedAt))) throw new Error('Invalid pinned Git runtime lock')
|
||||
return lock
|
||||
}
|
||||
export async function verifyBundledGit(outputDirectory = defaults) {
|
||||
const lock = await readGitRuntimeLock()
|
||||
await directory(outputDirectory); await directory(path.join(outputDirectory, 'files'))
|
||||
const bytes = await regular(path.join(outputDirectory, 'manifest.json'), 4 * 1024 * 1024), manifest = JSON.parse(bytes.toString('utf8'))
|
||||
if (sha256(bytes) !== lock.runtimeManifestSha256) throw new Error('Bundled Git manifest digest mismatch against release lock')
|
||||
if (manifest.version !== 1 || manifest.kind !== 'git' || !uuidPattern.test(manifest.id) || manifest.distribution?.version !== lock.version
|
||||
|| manifest.distribution.archiveSha256 !== lock.archive.sha256 || manifest.distribution.releaseUrl !== lock.releaseUrl
|
||||
|| !Array.isArray(manifest.files) || manifest.files.length < 7 || manifest.files.length > 12_000) throw new Error('Bundled Git does not match the pinned release')
|
||||
const filesRoot = path.join(outputDirectory, 'files'), checked = new Set([filesRoot]), seen = new Set()
|
||||
let total = 0
|
||||
for (const file of manifest.files) {
|
||||
if (!safePath(file.path) || seen.has(file.path.toLowerCase()) || !hashPattern.test(file.sha256) || !Number.isSafeInteger(file.bytes) || file.bytes < 0 || file.bytes > 128 * 1024 * 1024) throw new Error('Invalid bundled Git file manifest')
|
||||
seen.add(file.path.toLowerCase())
|
||||
let parent = path.dirname(path.join(filesRoot, file.path))
|
||||
while (!checked.has(parent)) { if (!within(filesRoot, parent)) throw new Error('Bundled Git path escaped'); await directory(parent); checked.add(parent); parent = path.dirname(parent) }
|
||||
const content = await regular(path.join(filesRoot, file.path))
|
||||
if (content.length !== file.bytes || sha256(content) !== file.sha256) throw new Error(`Bundled Git digest mismatch: ${file.path}`)
|
||||
total += content.length
|
||||
}
|
||||
if (total !== manifest.bytes || total > 512 * 1024 * 1024 || !['git.exe', 'license.txt', 'third_party_notices.txt', 'sources.json', 'package-versions.txt'].every(name => seen.has(name))) throw new Error('Bundled Git license inventory or size is invalid')
|
||||
return { version: lock.version, archiveSha256: lock.archive.sha256, manifestSha256: sha256(bytes), files: manifest.files.length, bytes: total }
|
||||
}
|
||||
|
||||
export async function vendorSandboxGit({ archivePath, outputDirectory = defaults, offline = false, rebuild = false } = {}) {
|
||||
const lock = await readGitRuntimeLock(), target = path.resolve(outputDirectory)
|
||||
if (path.basename(target) !== 'git' || !within(project, target) || target === project) throw new Error('Git output must be a project-local generated git directory')
|
||||
if (await fs.lstat(target).catch(() => null)) {
|
||||
await directory(target)
|
||||
const previous = await regular(path.join(target, 'manifest.json'), 4 * 1024 * 1024).then(bytes => JSON.parse(bytes.toString('utf8'))).catch(() => null)
|
||||
if (previous?.version !== 1 || previous.kind !== 'git' || !hashPattern.test(previous.distribution?.archiveSha256)) throw new Error('Refusing to replace an existing directory that is not a generated Git bundle')
|
||||
}
|
||||
if (!rebuild && await fs.stat(path.join(target, 'manifest.json')).catch(() => null)) {
|
||||
try { const current = await verifyBundledGit(target); console.log(`SANDBOX_GIT_BUNDLE_READY ${current.version} ${current.bytes}`); return current } catch { /* Rebuild explicitly requested vendored resources from the pinned archive. */ }
|
||||
}
|
||||
const cache = path.join(project, '.tmp/vendor-git')
|
||||
await fs.mkdir(cache, { recursive: true })
|
||||
const archive = archivePath ? path.resolve(archivePath) : path.join(cache, lock.archive.name)
|
||||
if (!await fs.lstat(archive).catch(() => null)) {
|
||||
if (archivePath || offline) throw new Error('Pinned MinGit archive is missing; provide --archive or allow this explicit vendor command to download it')
|
||||
const response = await fetch(lock.archive.url, { signal: AbortSignal.timeout(180_000) })
|
||||
if (!response.ok || Number(response.headers.get('content-length') || 0) > lock.archive.bytes) throw new Error(`MinGit download failed: ${response.status}`)
|
||||
const bytes = Buffer.from(await response.arrayBuffer())
|
||||
if (bytes.length !== lock.archive.bytes || sha256(bytes) !== lock.archive.sha256) throw new Error('Official MinGit archive digest mismatch')
|
||||
await fs.writeFile(archive, bytes, { flag: 'wx', mode: 0o600 })
|
||||
}
|
||||
const archiveBytes = await regular(archive)
|
||||
if (archiveBytes.length !== lock.archive.bytes || sha256(archiveBytes) !== lock.archive.sha256) throw new Error('Official MinGit archive digest mismatch')
|
||||
const unpacked = unzipSync(archiveBytes, { filter: file => /^(?:ucrt64|mingw64)\/bin\/(?:git(?:-remote-https?|-upload-pack|-receive-pack)?\.exe|[^/]+\.dll)$/i.test(file.name)
|
||||
|| /^usr\/bin\/(?:(?:sh|ssh)\.exe|[^/]+\.dll)$/i.test(file.name)
|
||||
|| /^usr\/share\/licenses\//.test(file.name)
|
||||
|| /^(?:ucrt64|mingw64)\/share\/licenses\//.test(file.name) || ['LICENSE.txt', 'etc/package-versions.txt'].includes(file.name) })
|
||||
const work = await fs.mkdtemp(path.join(project, '.tmp/vendor-git-build-'))
|
||||
const extraction = path.join(work, 'archive'), stateRoot = path.join(work, 'state'), bundle = path.join(work, 'bundle')
|
||||
for (const [name, bytes] of Object.entries(unpacked)) {
|
||||
if (name.endsWith('/')) continue
|
||||
if (!safePath(name) || bytes.length > 128 * 1024 * 1024) throw new Error('Unsafe MinGit archive entry')
|
||||
const destination = path.join(extraction, name)
|
||||
await fs.mkdir(path.dirname(destination), { recursive: true })
|
||||
await fs.writeFile(destination, bytes, { flag: 'wx', mode: 0o600 })
|
||||
}
|
||||
const prefix = unpacked['ucrt64/bin/git.exe'] ? 'ucrt64' : 'mingw64'
|
||||
const loaderPath = path.join(work, 'loader.mjs')
|
||||
await build({ absWorkingDir: project, stdin: { contents: "export { importSandboxToolchain } from './src/main/sandbox/toolchains'; export { configureSandbox } from './src/main/sandbox/configuration';", resolveDir: project, sourcefile: 'git-vendor-loader.ts', loader: 'ts' }, bundle: true, platform: 'node', format: 'esm', outfile: loaderPath, logLevel: 'warning' })
|
||||
const loader = await import(pathToFileURL(loaderPath).href)
|
||||
loader.configureSandbox({ stateRoot, resourceDirectory: path.join(work, 'resources'), useBundledGit: false })
|
||||
const imported = await loader.importSandboxToolchain('git', path.join(extraction, prefix, 'bin/git.exe'))
|
||||
const importedObject = path.join(stateRoot, 'toolchains/objects', imported.id)
|
||||
await fs.cp(importedObject, bundle, { recursive: true, errorOnExist: true, force: false })
|
||||
const manifest = JSON.parse(await fs.readFile(path.join(bundle, 'manifest.json'), 'utf8'))
|
||||
const put = async (name, bytes) => {
|
||||
if (!safePath(name)) throw new Error('Invalid third-party notice path')
|
||||
const destination = path.join(bundle, 'files', name)
|
||||
await fs.mkdir(path.dirname(destination), { recursive: true })
|
||||
await fs.writeFile(destination, bytes, { flag: 'wx', mode: 0o600 })
|
||||
manifest.files.push({ path: name, bytes: bytes.length, sha256: sha256(bytes) }); manifest.bytes += bytes.length
|
||||
}
|
||||
for (const [name, bytes] of Object.entries(unpacked)) {
|
||||
if (name.endsWith('/')) continue
|
||||
if (name === 'LICENSE.txt') await put('LICENSE.txt', bytes)
|
||||
else if (name === 'etc/package-versions.txt') await put('package-versions.txt', bytes)
|
||||
else if (/^(?:ucrt64|mingw64)\/share\/licenses\//.test(name)) await put(name.replace(/^(?:ucrt64|mingw64)\/share\//, ''), bytes)
|
||||
else if (/^usr\/share\/licenses\//.test(name)) await put(name.replace(/^usr\/share\//, 'usr/'), bytes)
|
||||
}
|
||||
await put('SOURCES.json', Buffer.from(JSON.stringify({ provider: lock.provider, version: lock.version, releaseUrl: lock.releaseUrl, archive: lock.archive, sources: lock.sources, components: manifest.files.filter(file => /\.(?:exe|dll)$/i.test(file.path)).map(file => file.path) }, null, 2) + '\n'))
|
||||
await put('THIRD_PARTY_NOTICES.txt', Buffer.from(`YCode bundled Git runtime\n\nUnmodified Git for Windows ${lock.version} and its required DLLs, extracted from the official MinGit distribution.\nRelease: ${lock.releaseUrl}\nArchive SHA-256: ${lock.archive.sha256}\n\nGit is licensed under GPL-2.0; see LICENSE.txt. Dependency license/copyright notices from the upstream archive are preserved under licenses/. Some notices refer to upstream components not included in this runtime subset. See SOURCES.json and package-versions.txt for provenance and source/build references.\n\nGit executes as a separate process. No host Git configuration or credentials are bundled.\n`))
|
||||
const idBytes = Buffer.from(lock.archive.sha256.slice(0, 32), 'hex'); idBytes[6] = (idBytes[6] & 15) | 64; idBytes[8] = (idBytes[8] & 63) | 128
|
||||
const hex = idBytes.toString('hex')
|
||||
manifest.id = `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`
|
||||
manifest.createdAt = Date.parse(lock.publishedAt)
|
||||
manifest.distribution = { version: lock.version, archiveSha256: lock.archive.sha256, releaseUrl: lock.releaseUrl }
|
||||
manifest.features.push('bundled')
|
||||
manifest.files.sort((a, b) => Buffer.compare(Buffer.from(a.path), Buffer.from(b.path)))
|
||||
const manifestBytes = Buffer.from(JSON.stringify(manifest) + '\n')
|
||||
await fs.writeFile(path.join(bundle, 'manifest.json'), manifestBytes)
|
||||
if (sha256(manifestBytes) !== lock.runtimeManifestSha256) throw new Error(`Generated Git manifest needs review: ${path.join(bundle, 'manifest.json')} SHA-256 ${sha256(manifestBytes)}. Update the reviewed source lock before publishing resources.`)
|
||||
const verified = await verifyBundledGit(bundle)
|
||||
const parent = path.dirname(target)
|
||||
await fs.mkdir(parent, { recursive: true })
|
||||
if (!within(await fs.realpath(project), await fs.realpath(parent))) throw new Error('Git bundle output parent escaped the project')
|
||||
const current = await fs.lstat(target).catch(() => null), backup = path.join(work, 'previous-bundle')
|
||||
if (current) {
|
||||
await directory(target)
|
||||
if (path.dirname(await fs.realpath(target)) !== await fs.realpath(parent)) throw new Error('Git bundle replacement target escaped its parent')
|
||||
await fs.rename(target, backup)
|
||||
}
|
||||
try { await fs.rename(bundle, target) } catch (error) { if (current) await fs.rename(backup, target); throw error }
|
||||
console.log(`SANDBOX_GIT_BUNDLE_PASS ${verified.version} ${verified.files} files ${verified.bytes} bytes`)
|
||||
return verified
|
||||
}
|
||||
|
||||
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
||||
const args = process.argv.slice(2), options = {}
|
||||
while (args.length) {
|
||||
const name = args.shift()
|
||||
if (name === '--offline') options.offline = true
|
||||
else if (name === '--rebuild') options.rebuild = true
|
||||
else if (name === '--archive' || name === '--output') { const value = args.shift(); if (!value) throw new Error(`Missing ${name}`); options[name === '--archive' ? 'archivePath' : 'outputDirectory'] = value }
|
||||
else throw new Error(`Unknown option: ${name}`)
|
||||
}
|
||||
await vendorSandboxGit(options)
|
||||
}
|
||||
@@ -17,7 +17,7 @@ if (!process.versions.electron) {
|
||||
const data = path.join(directory, 'data'), home = path.join(directory, 'home')
|
||||
await fs.mkdir(data, { recursive: true })
|
||||
await fs.mkdir(home, { recursive: true })
|
||||
const env = { ...process.env, YCODE_QA_DIRECTORY: directory, YCODE_USER_DATA_DIR: data, YCODE_OMP_HOME: home,
|
||||
const env = { ...process.env, YCODE_QA_DIRECTORY: directory, YCODE_USER_DATA_DIR: data,
|
||||
YCODE_DISABLE_GPU: '1', YCODE_DISABLE_SYSTEM_INTEGRATION: '1' }
|
||||
delete env.ELECTRON_RUN_AS_NODE; delete env.ELECTRON_RENDERER_URL
|
||||
const child = require('node:child_process').spawn(require('electron'),
|
||||
|
||||
@@ -25,8 +25,7 @@ if (!process.versions.electron) {
|
||||
{ id: 'account-menu-assistant', role: 'assistant', content: '收到。', createdAt: now + 1, status: 'done', requestId: 'account-menu-run', tools: [], parts: [] }
|
||||
] }]
|
||||
await fs.writeFile(path.join(data, 'sessions.json'), JSON.stringify({ sessions }))
|
||||
const env = { ...process.env, YCODE_QA_DIRECTORY: directory, YCODE_USER_DATA_DIR: data,
|
||||
YCODE_OMP_HOME: home, YCODE_DISABLE_GPU: '1' }
|
||||
const env = { ...process.env, YCODE_QA_DIRECTORY: directory, YCODE_USER_DATA_DIR: data, YCODE_DISABLE_GPU: '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'] })
|
||||
|
||||
@@ -11,7 +11,7 @@ if (!process.versions.electron) {
|
||||
const data = path.join(directory, 'data'), home = path.join(directory, 'home')
|
||||
await fs.mkdir(data, { recursive: true })
|
||||
await fs.mkdir(home, { recursive: true })
|
||||
const env = { ...process.env, YCODE_QA_DIRECTORY: directory, YCODE_USER_DATA_DIR: data, YCODE_OMP_HOME: home,
|
||||
const env = { ...process.env, YCODE_QA_DIRECTORY: directory, YCODE_USER_DATA_DIR: data,
|
||||
YCODE_DISABLE_GPU: '1', YCODE_DISABLE_SYSTEM_INTEGRATION: '1' }
|
||||
delete env.ELECTRON_RUN_AS_NODE; delete env.ELECTRON_RENDERER_URL
|
||||
const child = require('node:child_process').spawn(require('electron'),
|
||||
|
||||
@@ -16,7 +16,6 @@ if (!process.versions.electron) {
|
||||
...process.env,
|
||||
YCODE_QA_DIRECTORY: directory,
|
||||
YCODE_USER_DATA_DIR: data,
|
||||
YCODE_OMP_HOME: home,
|
||||
YCODE_DISABLE_GPU: '1'
|
||||
}
|
||||
delete env.ELECTRON_RUN_AS_NODE
|
||||
|
||||
@@ -29,7 +29,7 @@ if (!process.versions.electron) {
|
||||
const home = path.join(directory, 'home')
|
||||
await fs.mkdir(data, { recursive: true })
|
||||
await fs.mkdir(home, { recursive: true })
|
||||
const env = { ...process.env, YCODE_QA_DIRECTORY: directory, YCODE_USER_DATA_DIR: data, YCODE_OMP_HOME: home,
|
||||
const env = { ...process.env, YCODE_QA_DIRECTORY: directory, YCODE_USER_DATA_DIR: data,
|
||||
YCODE_DISABLE_GPU: '1', YCODE_DISABLE_SYSTEM_INTEGRATION: '1' }
|
||||
delete env.ELECTRON_RUN_AS_NODE; delete env.ELECTRON_RENDERER_URL
|
||||
const child = require('node:child_process').spawn(require('electron'),
|
||||
|
||||
@@ -63,7 +63,6 @@ if (!process.versions.electron) {
|
||||
...process.env,
|
||||
YCODE_QA_DIRECTORY: directory,
|
||||
YCODE_USER_DATA_DIR: data,
|
||||
YCODE_OMP_HOME: home,
|
||||
YCODE_DISABLE_GPU: '1',
|
||||
YCODE_QA_OFFSCREEN: '1'
|
||||
}
|
||||
|
||||
@@ -1,113 +0,0 @@
|
||||
/**
|
||||
* Verifies the staged (or packaged) OMP kernel assets: lock schema, sizes,
|
||||
* SHA-256 digests, and that the binary actually launches with the pinned version.
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/verify-bundled-omp.cjs # resources/omp
|
||||
* node scripts/verify-bundled-omp.cjs --packaged # release/win-unpacked/resources/omp
|
||||
* node scripts/verify-bundled-omp.cjs <directory>
|
||||
*/
|
||||
|
||||
const { createHash } = require('node:crypto')
|
||||
const { createReadStream, existsSync, promises: fs } = require('node:fs')
|
||||
const path = require('node:path')
|
||||
const { spawn } = require('node:child_process')
|
||||
|
||||
const project = path.resolve(__dirname, '..')
|
||||
const hashPattern = /^[a-f0-9]{64}$/
|
||||
const lockName = 'runtime.lock.json'
|
||||
const maxAssetBytes = 512 * 1024 * 1024
|
||||
|
||||
function fail(message) {
|
||||
throw new Error(message)
|
||||
}
|
||||
|
||||
function asset(value, label) {
|
||||
if (!value || typeof value !== 'object' || typeof value.name !== 'string' || !/^[A-Za-z0-9._-]+$/.test(value.name)
|
||||
|| !Number.isSafeInteger(value.bytes) || value.bytes <= 0 || value.bytes > maxAssetBytes
|
||||
|| typeof value.sha256 !== 'string' || !hashPattern.test(value.sha256)) {
|
||||
fail(`Invalid ${label} entry in ${lockName}`)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
async function sha256File(file) {
|
||||
const hash = createHash('sha256')
|
||||
for await (const chunk of createReadStream(file)) hash.update(chunk)
|
||||
return hash.digest('hex')
|
||||
}
|
||||
|
||||
async function verifyAsset(directory, spec) {
|
||||
const file = path.join(directory, spec.name)
|
||||
const stat = await fs.stat(file).catch(() => null)
|
||||
if (!stat?.isFile()) fail(`Missing asset: ${file}`)
|
||||
if (stat.size !== spec.bytes) fail(`Size mismatch for ${spec.name}: ${stat.size} != ${spec.bytes}`)
|
||||
const digest = await sha256File(file)
|
||||
if (digest !== spec.sha256) fail(`SHA-256 mismatch for ${spec.name}`)
|
||||
console.log(`PASS ${spec.name} ${spec.bytes} bytes ${digest.slice(0, 12)}…`)
|
||||
}
|
||||
|
||||
function readVersion(exe, timeoutMs = 30_000) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(exe, ['--version'], { windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] })
|
||||
let stdout = ''
|
||||
let stderr = ''
|
||||
const timer = setTimeout(() => {
|
||||
child.kill()
|
||||
reject(new Error(`Timed out launching ${exe}`))
|
||||
}, timeoutMs)
|
||||
child.stdout.on('data', (chunk) => { stdout += chunk })
|
||||
child.stderr.on('data', (chunk) => { stderr += chunk })
|
||||
child.on('error', (error) => {
|
||||
clearTimeout(timer)
|
||||
reject(error)
|
||||
})
|
||||
child.on('exit', (code) => {
|
||||
clearTimeout(timer)
|
||||
if (code !== 0) reject(new Error(`OMP exited with ${code}: ${stderr.trim()}`))
|
||||
else resolve(stdout.trim())
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const args = process.argv.slice(2)
|
||||
const packaged = args.includes('--packaged')
|
||||
const positional = args.find((arg) => !arg.startsWith('--'))
|
||||
const directory = positional
|
||||
? path.resolve(positional)
|
||||
: packaged
|
||||
? path.join(project, 'release', 'win-unpacked', 'resources', 'omp')
|
||||
: path.join(project, 'resources', 'omp')
|
||||
|
||||
if (!existsSync(directory)) fail(`OMP directory does not exist: ${directory}`)
|
||||
|
||||
const lock = JSON.parse((await fs.readFile(path.join(directory, lockName), 'utf8')).replace(/^\uFEFF/, ''))
|
||||
if (lock.format !== 1 || lock.kernel !== 'omp' || lock.upstream !== 'oh-my-pi' || lock.platform !== 'win32-x64'
|
||||
|| typeof lock.version !== 'string' || !/^\d+\.\d+\.\d+$/.test(lock.version)
|
||||
|| lock.releaseUrl !== `https://github.com/can1357/oh-my-pi/releases/tag/v${lock.version}`
|
||||
|| !Number.isFinite(Date.parse(lock.publishedAt))) {
|
||||
fail(`Invalid ${lockName}`)
|
||||
}
|
||||
const binary = asset(lock.binary, 'binary')
|
||||
const license = asset(lock.license, 'license')
|
||||
const notices = asset(lock.thirdPartyNotices, 'thirdPartyNotices')
|
||||
if (binary.name !== 'omp.exe' || license.name !== 'LICENSE' || notices.name !== 'THIRD-PARTY-NOTICES.txt') {
|
||||
fail(`Unexpected asset names in ${lockName}`)
|
||||
}
|
||||
|
||||
console.log(`Checking OMP ${lock.version} in ${directory}`)
|
||||
await verifyAsset(directory, binary)
|
||||
await verifyAsset(directory, license)
|
||||
await verifyAsset(directory, notices)
|
||||
|
||||
const output = await readVersion(path.join(directory, binary.name))
|
||||
if (output !== `omp/${lock.version}`) fail(`Unexpected OMP version output: ${output}`)
|
||||
console.log(`PASS omp.exe --version => ${output}`)
|
||||
console.log(`OMP ${lock.version} bundle is valid.`)
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(`FAIL ${error.message}`)
|
||||
process.exitCode = 1
|
||||
})
|
||||
@@ -4,7 +4,7 @@ if (!process.versions.electron) {
|
||||
;(async () => {
|
||||
const directory = await fs.mkdtemp(path.resolve('.tmp/capability-center-'))
|
||||
await Promise.all(['data', 'home'].map(name => fs.mkdir(path.join(directory, name))))
|
||||
const env = { ...process.env, YCODE_QA_DIRECTORY: directory, YCODE_USER_DATA_DIR: path.join(directory, 'data'), YCODE_OMP_HOME: path.join(directory, 'home'), YCODE_QA_OFFSCREEN: '0' }
|
||||
const env = { ...process.env, YCODE_QA_DIRECTORY: directory, YCODE_USER_DATA_DIR: path.join(directory, 'data'), YCODE_QA_OFFSCREEN: '0' }
|
||||
delete env.ELECTRON_RUN_AS_NODE; delete env.ELECTRON_RENDERER_URL; delete env.YCODE_DISABLE_GPU
|
||||
const child = require('node:child_process').spawn(require('electron'), ['--no-sandbox', '--disable-gpu-sandbox', __filename], { env, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] })
|
||||
let log = ''; child.stdout.on('data', b => { log += b; if (b.toString().includes('CAPABILITY_QA')) process.stdout.write(b) }); child.stderr.on('data', b => { log += b })
|
||||
|
||||
@@ -1,184 +0,0 @@
|
||||
// Isolated production renderer/preload/IPC -> actual OMP -> controlled upstream + actual SDK MCP.
|
||||
const fs = require('node:fs/promises'), path = require('node:path'), assert = require('node:assert/strict')
|
||||
const { pathToFileURL } = require('node:url')
|
||||
const pause = ms => new Promise(resolve => setTimeout(resolve, ms))
|
||||
if (!process.versions.electron) {
|
||||
const { spawn } = require('node:child_process')
|
||||
;(async () => {
|
||||
const directory = await fs.mkdtemp(path.resolve('.tmp/capability-flows-'))
|
||||
await Promise.all(['data', 'home', 'source-skill', 'workspace'].map(name => fs.mkdir(path.join(directory, name))))
|
||||
await fs.writeFile(path.join(directory, 'source-skill', 'SKILL.md'), '---\nname: capability-acceptance\ndescription: Controlled capability integration skill\nversion: 1.0\n---\n\nCAPABILITY_SKILL_BODY_MARKER\nUse the configured echo connector when requested.\n')
|
||||
await fs.writeFile(path.join(directory, 'source-skill', 'reference.txt'), 'CAPABILITY_RESOURCE_MARKER')
|
||||
const omp = process.env.YCODE_OMP_PATH || path.join(process.env.LOCALAPPDATA, 'Ygent', 'omp.exe')
|
||||
assert.ok((await fs.stat(omp)).isFile())
|
||||
const env = { ...process.env, YCODE_QA_DIRECTORY: directory, YCODE_QA_NODE: process.execPath, YCODE_USER_DATA_DIR: path.join(directory, 'data'), YCODE_OMP_HOME: path.join(directory, 'home'), YCODE_OMP_PATH: omp, YCODE_QA_OFFSCREEN: '1', YCODE_DISABLE_GPU: '1' }
|
||||
delete env.ELECTRON_RUN_AS_NODE; delete env.ELECTRON_RENDERER_URL; delete env.YCODE_MODEL_API_KEY
|
||||
for (const stage of ['run', 'restart']) {
|
||||
const code = await new Promise(resolve => {
|
||||
const child = spawn(require('electron'), ['--no-sandbox', '--disable-gpu-sandbox', __filename], { env: { ...env, YCODE_QA_STAGE: stage }, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] })
|
||||
let output = ''; child.stdout.on('data', bytes => { output += bytes; if (bytes.toString().includes('CAPABILITY_')) process.stdout.write(bytes) }); child.stderr.on('data', bytes => { output += bytes })
|
||||
const timer = setTimeout(() => child.kill(), 160000)
|
||||
child.once('exit', async code => { clearTimeout(timer); await fs.writeFile(path.join(directory, stage + '-process.log'), output); resolve(code ?? 1) })
|
||||
})
|
||||
const failedReport = await fs.stat(path.join(directory, stage + '-failure.json')).then(() => true, () => false)
|
||||
const report = await fs.readFile(path.join(directory, 'report.json'), 'utf8').then(JSON.parse, () => null)
|
||||
if (code || failedReport || report?.result !== 'PASS') { console.log(JSON.stringify({ directory, stage, result: 'FAIL' })); process.exitCode = code || 1; return }
|
||||
}
|
||||
console.log(JSON.stringify({ directory, result: 'PASS' }))
|
||||
})().catch(error => { console.error(error); process.exitCode = 1 })
|
||||
} else {
|
||||
const { app, BrowserWindow, dialog } = require('electron'), { createServer } = require('node:http')
|
||||
const directory = process.env.YCODE_QA_DIRECTORY
|
||||
assert.ok(path.relative(path.resolve('.tmp'), directory).startsWith('capability-flows-'))
|
||||
app.disableHardwareAcceleration(); app.setPath('userData', path.join(directory, 'data')); app.setPath('home', path.join(directory, 'home'))
|
||||
const checks = [], requests = [], stage = process.env.YCODE_QA_STAGE
|
||||
let main, upstream, phase = 'boot'
|
||||
const check = (label, actual, expected = true) => { assert.deepEqual(actual, expected, label); checks.push({ label, pass: true }) }
|
||||
async function read(expression) { try { return await main.webContents.executeJavaScript(expression) } catch (error) { throw Error(String(error) + ' :: ' + expression.slice(0, 340)) } }
|
||||
async function wait(expression, label, timeout = 30000) { const start = Date.now(); while (Date.now() - start < timeout) { const value = await read(expression); if (value) return value; await pause(40) } throw Error('Timeout: ' + label) }
|
||||
async function click(selector) { await read(`(()=>{const e=document.querySelector(${JSON.stringify(selector)});if(!e||e.disabled)throw Error('Missing enabled control '+${JSON.stringify(selector)});e.click()})()`); await pause(60) }
|
||||
async function clickText(selector, text, exact = true) { await read(`(()=>{const text=${JSON.stringify(text)};const e=[...document.querySelectorAll(${JSON.stringify(selector)})].find(e=>${exact ? 'e.textContent.trim()===text' : 'e.textContent.includes(text)'});if(!e||e.disabled)throw Error('Missing enabled text '+text);e.click()})()`); await pause(60) }
|
||||
async function fill(selector, value) { await read(`(()=>{const e=document.querySelector(${JSON.stringify(selector)});if(!e||e.disabled)throw Error('Missing input');const p=e.tagName==='TEXTAREA'?HTMLTextAreaElement.prototype:HTMLInputElement.prototype;Object.getOwnPropertyDescriptor(p,'value').set.call(e,${JSON.stringify(value)});e.dispatchEvent(new Event('input',{bubbles:true}))})()`); await pause(50) }
|
||||
async function hoverAddMenuItem(id){const selector='.cr-add-menu__popover [data-add-menu-id="'+id+'"]';await wait('!!document.querySelector('+JSON.stringify(selector)+')','source add menu row');const point=await read('(()=>{const b=document.querySelector('+JSON.stringify(selector)+').getBoundingClientRect();return{x:Math.round(b.x+b.width/2),y:Math.round(b.y+b.height/2)}})()');const trigger=await read('(()=>{const b=document.querySelector(".wb-composer__add-button").getBoundingClientRect();return{x:Math.round(b.x+b.width/2),y:Math.round(b.y+b.height/2)}})()');main.webContents.sendInputEvent({type:'mouseMove',...trigger});await pause(25);main.webContents.sendInputEvent({type:'mouseMove',...point});await pause(220)}
|
||||
async function shot(name) { await read('document.fonts.ready.then(()=>true)'); await pause(120); const image = await main.webContents.capturePage(undefined, { stayHidden: true, stayAwake: true }); await fs.writeFile(path.join(directory, name + '.png'), image.toPNG()) }
|
||||
async function step(name) { phase = name; await fs.writeFile(path.join(directory, stage + '-phase.txt'), name) }
|
||||
async function boot() {
|
||||
dialog.showOpenDialog = async () => ({ canceled: false, filePaths: [path.join(directory, 'source-skill')] }) // Only OS picker selection is controlled; production installer and IPC run unchanged.
|
||||
await import(pathToFileURL(path.resolve('out/main/index.mjs')).href); await app.whenReady()
|
||||
for (let i = 0; i < 300; i++) { main = BrowserWindow.getAllWindows().find(win => win.getTitle() === 'YCode'); if (main && !main.webContents.isLoading()) break; await pause(30) }
|
||||
assert.ok(main); main.setContentSize(1336, 901)
|
||||
await wait('!!window.ycode && !!document.querySelector(".wb-composer__input")', 'production UI')
|
||||
check('isolated production userData', path.resolve((await read('window.ycode.app.info()')).configDir), path.join(directory, 'data'))
|
||||
check('actual OMP available', (await read('window.ycode.app.info()')).omp.available)
|
||||
}
|
||||
async function openCenter(tab) {
|
||||
await clickText('.sidebar__tab', '专家·技能·连接器')
|
||||
await wait('!!document.querySelector(".yc-capability-center")', 'capability center')
|
||||
if (tab) await clickText('.um-tab', tab)
|
||||
await pause(100)
|
||||
}
|
||||
function completion(response, model, content, toolCall) {
|
||||
response.writeHead(200, { 'content-type': 'text/event-stream' })
|
||||
const chunk = delta => ({ id: 'capability-completion', object: 'chat.completion.chunk', created: 1700000000, model, choices: [{ index: 0, delta, finish_reason: null }] })
|
||||
response.write('data: ' + JSON.stringify(chunk({ role: 'assistant', ...(toolCall ? { tool_calls: [{ index: 0, id: 'cap-call-' + requests.length, type: 'function', function: { name: toolCall.name, arguments: JSON.stringify(toolCall.args) } }] } : { content }) })) + '\n\n')
|
||||
const final = chunk({}); final.choices[0].finish_reason = toolCall ? 'tool_calls' : 'stop'; response.write('data: ' + JSON.stringify(final) + '\n\ndata: [DONE]\n\n'); response.end()
|
||||
}
|
||||
async function cleanup() { if (upstream) { upstream.closeAllConnections(); await new Promise(resolve => upstream.close(resolve)) } }
|
||||
;(async () => {
|
||||
await boot()
|
||||
if (stage === 'restart') {
|
||||
await step('restart')
|
||||
const previous = JSON.parse(await fs.readFile(path.join(directory, 'report.json'), 'utf8'))
|
||||
const skills = await read('window.ycode.skills.list()'), servers = await wait('window.ycode.mcp.list().then(s=>s.some(x=>x.status==="connected")&&s)', 'startup MCP reconnect')
|
||||
check('skill persists after new Electron process', skills.some(item => item.id === previous.skill.id && item.enabled))
|
||||
check('MCP reconnects and discovers tools after restart', servers.some(item => item.id === previous.server.id && item.status === 'connected' && item.tools.length === 4))
|
||||
const history = await read(`window.ycode.session.messages(${JSON.stringify(previous.sessionId)})`)
|
||||
check('skill resource link persists', history[0].skills[0].uri, 'skill://capability-acceptance')
|
||||
check('real connector result persists', history[1].tools.some(tool => tool.output.includes('stdio:CAPABILITY_ECHO_INPUT')))
|
||||
previous.restartChecks = checks; previous.result = 'PASS'; await fs.writeFile(path.join(directory, 'report.json'), JSON.stringify(previous, null, 2))
|
||||
console.log('CAPABILITY_RESTART_PASS ' + directory); app.quit(); return
|
||||
}
|
||||
upstream = createServer(async (request, response) => {
|
||||
try {
|
||||
let raw = ''; for await (const bytes of request) raw += bytes
|
||||
const body = JSON.parse(raw), text = JSON.stringify(body.messages), names = (body.tools ?? []).map(tool => tool.function.name)
|
||||
requests.push({ stream: body.stream, selectedSkillBody: text.includes('CAPABILITY_SKILL_BODY_MARKER'), toolNames: names, hasEchoResult: text.includes('stdio:CAPABILITY_ECHO_INPUT'), hasResourceResult: text.includes('CAPABILITY_RESOURCE_MARKER') })
|
||||
if (!body.stream) { response.writeHead(200, { 'content-type': 'application/json' }); response.end(JSON.stringify({ id: 'test', object: 'chat.completion', choices: [{ index: 0, message: { role: 'assistant', content: 'OK' }, finish_reason: 'stop' }] })); return }
|
||||
const lastUser = body.messages.findLast(message => message.role === 'user')?.content ?? ''
|
||||
if (JSON.stringify(lastUser).includes('CAPABILITY_AUTO_LOAD')) {
|
||||
const hasLoadResult = body.messages.some(message => message.role === 'tool' && String(message.content).includes('CAPABILITY_SKILL_BODY_MARKER'))
|
||||
if (!hasLoadResult) { const match = text.match(/user:[a-f0-9]{24}/); completion(response, body.model, '', { name: 'ycode_load_skill', args: { id: match?.[0] } }); return }
|
||||
completion(response, body.model, 'CAPABILITY_AUTO_OK'); return
|
||||
}
|
||||
const hasEchoResult = body.messages.some(message => message.role === 'tool' && String(message.content).includes('stdio:CAPABILITY_ECHO_INPUT'))
|
||||
if (!hasEchoResult) { const name = names.find(name => name.startsWith('mcp_echo_')); assert.ok(name); completion(response, body.model, '', { name, args: { text: 'CAPABILITY_ECHO_INPUT' } }); return }
|
||||
completion(response, body.model, 'CAPABILITY_OK')
|
||||
} catch (error) { response.writeHead(500); response.end('Controlled upstream failure'); requests.push({ error: String(error) }) }
|
||||
})
|
||||
await new Promise(resolve => upstream.listen(0, '127.0.0.1', resolve))
|
||||
const profile = { id: 'capability-model', name: 'Capability Fixture', providerId: 'custom', model: 'capability-fixture', baseUrl: `http://127.0.0.1:${upstream.address().port}/v1`, apiKey: '', authMode: 'none', supportsToolCall: true, supportsImages: false, maxInputTokens: 32000, maxOutputTokens: 1000 }
|
||||
await read(`window.ycode.models.save(${JSON.stringify(profile)})`); await read('window.ycode.models.select("capability-model")')
|
||||
const space = await read(`window.ycode.workspace.attach(${JSON.stringify(path.join(directory, 'workspace'))}).then(v=>v.workspaces.find(x=>x.id===v.attachedWorkspaceId))`)
|
||||
check('space registered', !!space)
|
||||
main.webContents.reload(); await pause(500); await wait('!!document.querySelector(".wb-composer__input")', 'reloaded controlled models')
|
||||
await fill('.wb-composer__input', 'DRAFT_SURVIVES_CAPABILITY_CENTER')
|
||||
await step('center-draft-and-source-layout'); await openCenter()
|
||||
await shot('experts-market')
|
||||
const bounds = await read(`(()=>{const r=document.querySelector('.yc-capability-center').getBoundingClientRect(),h=document.querySelector('.yc-capability-center header').getBoundingClientRect();return{page:[r.x,r.y,r.width,r.height],header:[h.x,h.y,h.width,h.height]}})()`)
|
||||
await fs.writeFile(path.join(directory, 'layout.json'), JSON.stringify(bounds, null, 2))
|
||||
check('source topbar height', bounds.header[3], 64)
|
||||
await clickText('button', '我的专家'); await shot('my-experts-empty')
|
||||
await clickText('.sidebar__tab', '新建任务')
|
||||
check('draft survives source center roundtrip', await read('document.querySelector(".wb-composer__input").value'), 'DRAFT_SURVIVES_CAPABILITY_CENTER')
|
||||
await openCenter('技能')
|
||||
await shot('skills-market')
|
||||
await step('real-skill-install')
|
||||
// The UI exposes source add -> upload -> choose folder. Use stable accessible labels.
|
||||
await click('[aria-label="添加技能"]'); await clickText('button,[role="menuitem"]', '上传技能')
|
||||
await click('[aria-label="选择文件夹"]')
|
||||
await wait('window.ycode.skills.list().then(s=>s.length===1)', 'real folder installer')
|
||||
const skill = (await read('window.ycode.skills.list()'))[0]
|
||||
check('source folder retained', await fs.readFile(path.join(directory, 'source-skill', 'reference.txt'), 'utf8'), 'CAPABILITY_RESOURCE_MARKER')
|
||||
if (await read('!!document.querySelector(".sm-import-modal")')) await click('[aria-label="关闭导入技能"]')
|
||||
await wait(`!document.querySelector('[aria-label="导入技能"]')`, 'import dialog closes after actual install')
|
||||
if (await read(`!!document.querySelector('[aria-label="已安装技能"]')`)) await click('[aria-label="已安装技能"]')
|
||||
await shot('skills-installed')
|
||||
await step('real-mcp-configuration')
|
||||
if (!(await read('!!document.querySelector(".um-tab")'))) await click('[aria-label="返回专家中心"]')
|
||||
await clickText('.um-tab', '连接器'); await clickText('button', '自定义连接器')
|
||||
await shot('mcp-empty')
|
||||
await clickText('button', '添加 MCP', false)
|
||||
const mcpConfig = { mcpServers: { 'capability-echo': { command: process.env.YCODE_QA_NODE, args: [path.resolve('scripts/test-mcp-server.mjs'), 'stdio', path.join(directory, 'mcp-events.jsonl')] } } }
|
||||
await wait('!!document.querySelector(".mcp-config-textarea,.mcp-config-editor-monaco-wrapper .monaco-editor textarea")', 'real JSON editor')
|
||||
if (await read('!!document.querySelector(".mcp-config-textarea")')) await fill('.mcp-config-textarea', JSON.stringify(mcpConfig, null, 2))
|
||||
else {
|
||||
await pause(500)
|
||||
main.webContents.focus()
|
||||
const point = await read(`(()=>{const r=document.querySelector('.mcp-config-editor-monaco-wrapper .view-lines').getBoundingClientRect();return{x:Math.round(r.left+80),y:Math.round(r.top+10)}})()`)
|
||||
main.webContents.sendInputEvent({ type: 'mouseDown', ...point, button: 'left', clickCount: 1 }); main.webContents.sendInputEvent({ type: 'mouseUp', ...point, button: 'left', clickCount: 1 }); await pause(60)
|
||||
main.webContents.sendInputEvent({ type: 'keyDown', keyCode: 'A', modifiers: ['control'] }); main.webContents.sendInputEvent({ type: 'keyUp', keyCode: 'A', modifiers: ['control'] })
|
||||
await read(`(()=>{const transfer=new DataTransfer();transfer.setData('text/plain',${JSON.stringify(JSON.stringify(mcpConfig, null, 2))});document.activeElement.dispatchEvent(new ClipboardEvent('paste',{clipboardData:transfer,bubbles:true,cancelable:true}));return true})()`)
|
||||
await pause(100)
|
||||
}
|
||||
await clickText('button', '保存')
|
||||
await wait('window.ycode.mcp.list().then(s=>s.length===1)', 'real MCP registration')
|
||||
// Trust/enable through the real confirmation UI, following source configuration hash semantics.
|
||||
const server = (await read('window.ycode.mcp.list()'))[0]
|
||||
if (!server.trusted) { await clickText('button', '批准配置', false) }
|
||||
await wait('window.ycode.mcp.list().then(s=>s[0]?.trusted)', 'config trust')
|
||||
if (!(await read('window.ycode.mcp.list().then(s=>s[0].enabled)'))) await click('[role="switch"][aria-label*="capability-echo"]')
|
||||
await wait('window.ycode.mcp.list().then(s=>s[0]?.status==="connected"&&s[0].tools.length===4)', 'actual SDK handshake and two tool pages')
|
||||
await shot('mcp-connected')
|
||||
await click('.mcp-modal-close')
|
||||
await clickText('.sidebar__tab', '新建任务')
|
||||
await fill('.wb-composer__input', 'CAPABILITY_MANUAL_MCP')
|
||||
await click('.wb-composer__add-button'); await hoverAddMenuItem('skills')
|
||||
await wait('!!document.querySelector("[data-skill-option]")', 'skill menu from actual registry')
|
||||
await shot('skill-picker'); await click('[data-skill-option]')
|
||||
check('structured skill chip added', await read(`!!document.querySelector('[data-skill-uri="skill://capability-acceptance"]')`))
|
||||
await step('actual-omp-skill-mcp-turn')
|
||||
await click('.cr-send-button:not(.cr-send-button--stop)')
|
||||
await wait('[...document.querySelectorAll("button")].some(e=>e.textContent.includes("允许一次"))', 'MCP runtime approval')
|
||||
await shot('mcp-tool-approval'); await clickText('button', '允许一次', false)
|
||||
const session = await wait('window.ycode.session.list().then(s=>s[0])', 'persisted session')
|
||||
const assistant = await wait(`window.ycode.session.messages(${JSON.stringify(session.id)}).then(m=>m[1]?.status!=="streaming"&&m[1])`, 'terminal actual OMP reply')
|
||||
check('actual OMP capability reply', assistant.content, 'CAPABILITY_OK'); check('actual OMP terminal', assistant.status, 'done')
|
||||
check('MCP actual tool result', assistant.tools.some(tool => tool.output.includes('stdio:CAPABILITY_ECHO_INPUT')))
|
||||
check('actual MCP identity retained in persisted tool metadata', assistant.tools.some(tool => tool.metadata?.serverName === 'capability-echo' && tool.metadata?.toolName === 'echo'))
|
||||
await wait(`!!document.querySelector('[data-tool-name="mcp_call_tool"]')`, 'source MCP dedicated renderer')
|
||||
check('selected skill body reached real upstream', requests.some(request => request.selectedSkillBody))
|
||||
await shot('capability-chat-result')
|
||||
await step('automatic-skill-loading')
|
||||
await fill('.wb-composer__input', 'CAPABILITY_AUTO_LOAD'); await click('.cr-send-button:not(.cr-send-button--stop)')
|
||||
const automatic = await wait(`window.ycode.session.messages(${JSON.stringify(session.id)}).then(m=>m.length===4&&m[3].status!=="streaming"&&m[3])`, 'automatic skill load')
|
||||
check('automatic skill actually loaded by tool', automatic.tools.some(tool => tool.name === 'ycode_load_skill' && tool.output.includes('CAPABILITY_SKILL_BODY_MARKER')))
|
||||
check('automatic skill turn done', automatic.content, 'CAPABILITY_AUTO_OK')
|
||||
await step('complete')
|
||||
await fs.writeFile(path.join(directory, 'report.json'), JSON.stringify({ result: 'PASS', checks, requests, skill, server, sessionId: session.id, bounds, limitations: ['OS file-picker selection is controlled; actual production UI/IPC/install/runtime run unchanged.', 'Original cloud catalogs, expert packages and whole-state pixel equivalence are not asserted.'] }, null, 2))
|
||||
console.log('CAPABILITY_FLOWS_PASS ' + directory); await cleanup(); app.quit()
|
||||
})().catch(async error => {
|
||||
await fs.writeFile(path.join(directory, stage + '-failure.json'), JSON.stringify({ result: 'FAIL', phase, error: String(error), stack: error.stack, checks, requests }, null, 2)).catch(() => {})
|
||||
if (main && !main.isDestroyed()) await shot(stage + '-failure').catch(() => {})
|
||||
console.log('CAPABILITY_FLOWS_FAIL ' + phase + ' ' + String(error)); await cleanup().catch(() => {}); process.exitCode = 1; app.quit()
|
||||
})
|
||||
}
|
||||
@@ -15,7 +15,7 @@ if (!process.versions.electron) {
|
||||
const b = { id: uuid(), title: '另一个会话 Beta', cwd, createdAt: now, updatedAt: now,
|
||||
messages: [{ id: uuid(), role: 'assistant', createdAt: now, status: 'done', content: '另一会话独立内容 Different conversation text.' }] }
|
||||
await fs.writeFile(path.join(data, 'sessions.json'), JSON.stringify({ sessions: [a, b] }))
|
||||
const env = { ...process.env, YCODE_USER_DATA_DIR: data, YCODE_OMP_HOME: home, YCODE_QA_DIRECTORY: directory,
|
||||
const env = { ...process.env, YCODE_USER_DATA_DIR: data, YCODE_QA_DIRECTORY: directory,
|
||||
YCODE_QA_HEADER_FIXTURES: JSON.stringify([a.id, b.id]), 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],
|
||||
|
||||
@@ -1,465 +0,0 @@
|
||||
// TODO 21 验收:会话输入区的 Git 分支标签 + 上下文用量环。
|
||||
// 真实隔离 YCode(真实 preload/IPC/渲染进程)+ 真实 OMP + 受控本地 OpenAI 兼容上游 + 真实 Git 仓库。
|
||||
//
|
||||
// 原版依据(固定 REA 归档):
|
||||
// - 运行时几何/结构:ev_2569313a7a0aa901c5a3b8dc1d7fd190109b9006c915830109b62195f312376a
|
||||
// (WorkBuddy 5.8.3 会话输入区,视口 1336×902):
|
||||
// 左组 `.cr-add-menu`(431,32) → `._branchLabel_jdvlk_244`(471,82×32) → `.cr-permission-setting`(561)
|
||||
// 右组 `.cr-context-usage`(903,32×32) → `.cr-model-selector`(939) → 语音(1117) → 发送(1157)
|
||||
// 分支标签:radius 999 / padding 5px 8px / gap 8 / max-width 188 / 字体 14/400/22;
|
||||
// 图标按钮 16×16,默认分支字形,悬停换成 copy(opacity 互换);hover 背景 `--wb-bg-secondary`。
|
||||
// 用量环:32×32 / radius 16;内部 16×16 SVG,track circle r=7 stroke-width 2、
|
||||
// progress circle 带 `stroke-dashoffset .3s` 过渡;hover 背景 `--cr-context-usage-hover-bg,#0000000d`。
|
||||
// 浮层:280 宽 / padding 16 / radius 16 / 标题「上下文用量」/ 关闭按钮 / 百分比 20/700/28 /
|
||||
// 「已使用」12/20 / 进度条 8 高 / 五类图例。
|
||||
// - 回收样式表:`contexts-BkjwYSZT.css`(`._branchLabel_*`)、`src-CyXHA0IC.css`(`.cr-context-usage*`)。
|
||||
// - 文案:已回收 i18n 表 `chatInput.contextUsage.*`(title 上下文用量 / used 已使用 /
|
||||
// tooltip "{percentage}% · {used} / {total} 上下文已使用" / needMessage / requesting / unavailable)。
|
||||
// - REA 保留的 `ContextUsageDisplay` 源码进一步确认:百分比固定一位小数,token 达 1K 后
|
||||
// 固定一位小数并显示为 K(例如 `20.0% · 12.8K / 64.0K`)。
|
||||
//
|
||||
// YCode 侧数据真实性:分支名由主进程真实执行 git;用量只用上游真实 usage(经 OMP 归一化)+
|
||||
// 该回合冻结的真实上下文窗口。历史样本没有分母时保持未知,不套当前模型配置。
|
||||
const fs = require('node:fs/promises')
|
||||
const path = require('node:path')
|
||||
const assert = require('node:assert/strict')
|
||||
const { execFileSync } = require('node:child_process')
|
||||
|
||||
const QA_BRANCH = 'qa-branch'
|
||||
/** 受控上游返回的真实用量(provider prompt_tokens 含缓存,本例无缓存)。 */
|
||||
const QA_PROMPT_TOKENS = 12800
|
||||
const QA_COMPLETION_TOKENS = 40
|
||||
const QA_CONTEXT_WINDOW = 64000
|
||||
const QA_EXPECTED_PERCENT = 20
|
||||
const QA_EXPECTED_PERCENT_TEXT = `${QA_EXPECTED_PERCENT.toFixed(1)}%`
|
||||
const QA_EXPECTED_USED_TEXT = '12.8K'
|
||||
const QA_EXPECTED_TOTAL_TEXT = '64.0K'
|
||||
const RING_CIRCUMFERENCE = 2 * Math.PI * 7
|
||||
|
||||
if (!process.versions.electron) {
|
||||
;(async () => {
|
||||
const directory = await fs.mkdtemp(path.resolve('.tmp/composer-context-'))
|
||||
const data = path.join(directory, 'data')
|
||||
const home = path.join(directory, 'home')
|
||||
const repo = path.join(directory, 'fixture-repo')
|
||||
await fs.mkdir(data, { recursive: true })
|
||||
await fs.mkdir(home, { recursive: true })
|
||||
await fs.mkdir(repo, { recursive: true })
|
||||
// 真实 Git 仓库:分支名固定,供分支标签读取。
|
||||
const git = (...args) => execFileSync('git', ['-C', repo, ...args], { stdio: 'pipe' })
|
||||
git('init', '-b', QA_BRANCH)
|
||||
git('config', 'user.email', 'qa@example.com')
|
||||
git('config', 'user.name', 'QA')
|
||||
await fs.writeFile(path.join(repo, 'README.md'), '# qa\n')
|
||||
git('add', '.')
|
||||
git('-c', 'commit.gpgsign=false', 'commit', '-m', 'init')
|
||||
const branch = git('rev-parse', '--abbrev-ref', 'HEAD').toString().trim()
|
||||
assert.equal(branch, QA_BRANCH)
|
||||
const now = Date.now()
|
||||
const sessions = [{ id: 'context-info-session', title: 'Context Info', cwd: repo, modelId: 'local-usage', createdAt: now, updatedAt: now + 1, messages: [
|
||||
{ id: 'context-user', role: 'user', content: '上下文信息验收', createdAt: now, status: 'done', requestId: 'context-run' },
|
||||
{ id: 'context-assistant', role: 'assistant', content: '收到。', createdAt: now + 1, status: 'done', requestId: 'context-run', tools: [], parts: [],
|
||||
contextUsage: { usage: { inputTokens: 8000, outputTokens: 40, totalTokens: 8040 } } }
|
||||
] }]
|
||||
const nativePath=path.join(data,'omp-sessions','session-native-context-session.jsonl')
|
||||
await fs.mkdir(path.dirname(nativePath),{recursive:true})
|
||||
await fs.writeFile(nativePath,[
|
||||
{id:'native-user',type:'message',message:{role:'user',content:[{type:'text',text:'old'}]}},
|
||||
{id:'native-reply',parentId:'native-user',type:'message',message:{role:'assistant',model:'old-model',usage:{input:4009,cacheRead:1152,output:125,totalTokens:5286},stopReason:'stop',timestamp:now-1000}}
|
||||
].map(value=>JSON.stringify(value)).join('\n'))
|
||||
sessions.push({id:'native-context-session',title:'Native Usage Recovery',cwd:repo,modelId:'local-usage',createdAt:now-1000,updatedAt:now-1000,messages:[
|
||||
{id:'legacy-user',role:'user',content:'old',createdAt:now-1000,status:'done',requestId:'legacy-native',nativeEntryId:'native-user',nativeSessionPath:nativePath},
|
||||
{id:'legacy-reply',role:'assistant',content:'old reply',createdAt:now-900,status:'done',requestId:'legacy-native',modelId:'local-usage'}
|
||||
]})
|
||||
await fs.writeFile(path.join(data, 'sessions.json'), JSON.stringify({ sessions }))
|
||||
const env = { ...process.env, YCODE_QA_DIRECTORY: directory, YCODE_USER_DATA_DIR: data, YCODE_OMP_HOME: home,
|
||||
YCODE_QA_REAL_OMP: process.env.YCODE_OMP_PATH || path.join(process.env.LOCALAPPDATA || '', 'Ygent', 'omp.exe'),
|
||||
YCODE_DISABLE_GPU: '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('COMPOSER_CONTEXT_')) process.stdout.write(bytes) })
|
||||
child.stderr.on('data', bytes => { output += bytes })
|
||||
const timer = setTimeout(() => child.kill(), 300000)
|
||||
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(-8000))
|
||||
process.exitCode = code ? 1 : 0
|
||||
})
|
||||
})().catch(error => { console.error(error); process.exitCode = 1 })
|
||||
} else {
|
||||
const { app, BrowserWindow, clipboard } = require('electron')
|
||||
const { createServer } = require('node:http')
|
||||
const { pathToFileURL } = require('node:url')
|
||||
const directory = process.env.YCODE_QA_DIRECTORY
|
||||
// GPU 关闭由主进程 bundle 依据 YCODE_DISABLE_GPU 处理;这里不能重复调用(Electron 会抛错)。
|
||||
process.env.YCODE_OMP_PATH = process.env.YCODE_QA_REAL_OMP
|
||||
app.setPath('home', path.join(directory, 'home'))
|
||||
const checks = [], snapshots = {}, rendererErrors = []
|
||||
let main, server, phase = 'boot'
|
||||
const pause = ms => new Promise(resolve => setTimeout(resolve, ms))
|
||||
async function read(source, win = main) {
|
||||
let timer
|
||||
try {
|
||||
return await Promise.race([win.webContents.executeJavaScript(source), new Promise((_, reject) => {
|
||||
timer = setTimeout(() => reject(Error('Renderer timeout: ' + source.slice(0, 120))), 20000)
|
||||
})])
|
||||
} finally { clearTimeout(timer) }
|
||||
}
|
||||
async function wait(source, label, win = main) {
|
||||
for (let index = 0; index < 400; index++) { const value = await read(source, win); if (value) return value; 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 })
|
||||
}
|
||||
async function hover(selector, win = main) {
|
||||
const point = await read(`(()=>{const node=document.querySelector(${JSON.stringify(selector)});if(!node)return null;const box=node.getBoundingClientRect();return [Math.round(box.x+box.width/2),Math.round(box.y+box.height/2)]})()`, win)
|
||||
assert.ok(point, 'hover target: ' + selector)
|
||||
if (!win.webContents.debugger.isAttached()) win.webContents.debugger.attach('1.3')
|
||||
await win.webContents.debugger.sendCommand('Input.dispatchMouseEvent', { type: 'mouseMoved', x: point[0], y: point[1], button: 'none' })
|
||||
await pause(260)
|
||||
}
|
||||
|
||||
const branchSource = `(()=>{const left=document.querySelector('.cr-input-toolbar__left');const pill=left&&left.querySelector('._branchLabel_jdvlk_244');
|
||||
if(!left||!pill)return null;const box=node=>{const r=node.getBoundingClientRect();return [r.x,r.y,r.width,r.height]};
|
||||
const style=getComputedStyle(pill);const button=pill.querySelector('._branchLabelIconButton_jdvlk_279');
|
||||
const def=pill.querySelector('._branchLabelIconDefault_jdvlk_267');const copy=pill.querySelector('._branchLabelIconCopy_jdvlk_270');
|
||||
const text=pill.querySelector('._branchLabelText_jdvlk_316');const tstyle=getComputedStyle(text);
|
||||
const addMenu=left.querySelector('.cr-add-menu');const permission=left.querySelector('.cr-permission-setting');
|
||||
const svgDefault=def.querySelector('svg');const svgCopy=copy.querySelector('svg');
|
||||
return {pill:{box:box(pill),radius:style.borderRadius,padding:style.padding,gap:style.gap,maxWidth:style.maxWidth,height:style.height,
|
||||
font:style.fontSize+'/'+style.fontWeight+'/'+style.lineHeight,color:style.color,display:style.display,
|
||||
background:style.backgroundColor,transition:style.transitionProperty+' '+style.transitionDuration},
|
||||
text:{value:text.textContent.trim(),overflow:tstyle.overflow,textOverflow:tstyle.textOverflow,whiteSpace:tstyle.whiteSpace,font:tstyle.fontSize+'/'+tstyle.fontWeight+'/'+tstyle.lineHeight},
|
||||
button:button?{box:box(button),tag:button.tagName}:null,
|
||||
icons:{defaultOpacity:getComputedStyle(def).opacity,copyOpacity:getComputedStyle(copy).opacity,
|
||||
defaultSvg:svgDefault?{box:box(svgDefault),viewBox:svgDefault.getAttribute('viewBox'),className:svgDefault.getAttribute('class'),paths:svgDefault.querySelectorAll('path').length}:null,
|
||||
copySvg:svgCopy?{box:box(svgCopy),viewBox:svgCopy.getAttribute('viewBox'),className:svgCopy.getAttribute('class'),strokeWidth:svgCopy.getAttribute('stroke-width'),rects:svgCopy.querySelectorAll('rect').length,paths:svgCopy.querySelectorAll('path').length}:null},
|
||||
order:{addMenu:addMenu?box(addMenu):null,permission:permission?box(permission):null}}})()`
|
||||
|
||||
const ringSource = `(()=>{const right=document.querySelector('.cr-input-toolbar__right');const ring=right&&right.querySelector('.cr-context-usage');
|
||||
if(!right||!ring)return null;const box=node=>{const r=node.getBoundingClientRect();return [r.x,r.y,r.width,r.height]};
|
||||
const style=getComputedStyle(ring);const svg=ring.querySelector('svg.cr-context-usage__ring');
|
||||
const track=ring.querySelector('circle:not(.cr-context-usage__ring-progress)');const progress=ring.querySelector('.cr-context-usage__ring-progress');
|
||||
const wrapper=right.querySelector('.cr-context-usage-tooltip-trigger');const model=right.querySelector('.cr-model-selector');
|
||||
return {ring:{box:box(ring),radius:style.borderRadius,width:style.width,height:style.height,transition:style.transitionProperty+' '+style.transitionDuration,
|
||||
display:style.display,role:ring.getAttribute('role'),background:style.backgroundColor,ariaLabel:ring.getAttribute('aria-label')},
|
||||
wrapper:wrapper?box(wrapper):null,
|
||||
svg:svg?{box:box(svg),viewBox:svg.getAttribute('viewBox'),className:svg.getAttribute('class')}:null,
|
||||
track:track?{r:track.getAttribute('r'),strokeWidth:track.getAttribute('stroke-width'),fill:track.getAttribute('fill'),stroke:getComputedStyle(track).stroke}:null,
|
||||
progress:progress?{r:progress.getAttribute('r'),strokeWidth:progress.getAttribute('stroke-width'),strokeLinecap:progress.getAttribute('stroke-linecap'),
|
||||
transform:progress.getAttribute('transform'),dashArray:progress.getAttribute('stroke-dasharray'),dashOffset:Number(progress.getAttribute('stroke-dashoffset')),
|
||||
transition:getComputedStyle(progress).transitionProperty+' '+getComputedStyle(progress).transitionDuration,stroke:getComputedStyle(progress).stroke}:null,
|
||||
firstChild:right.firstElementChild?right.firstElementChild.className:null,
|
||||
modelLeft:model?box(model)[0]:null}})()`
|
||||
|
||||
const popoverSource = `(()=>{const popover=document.querySelector('.cr-context-usage-popover');if(!popover)return null;
|
||||
const box=node=>{const r=node.getBoundingClientRect();return [r.x,r.y,r.width,r.height]};const style=getComputedStyle(popover);
|
||||
const probe=(property,token)=>{const el=document.createElement('div');el.style[property]='var('+token+')';popover.appendChild(el);const value=getComputedStyle(el)[property];el.remove();return value};
|
||||
const title=popover.querySelector('.cr-context-usage-popover__title');const close=popover.querySelector('.cr-context-usage-popover__close');
|
||||
const percent=popover.querySelector('.cr-context-usage-popover__percent');const usedValue=popover.querySelector('.cr-context-usage-popover__used-value');
|
||||
const usedLabel=popover.querySelector('.cr-context-usage-popover__used-label');const bar=popover.querySelector('.cr-context-usage-popover__bar');
|
||||
const rows=[...popover.querySelectorAll('.cr-context-usage-popover__row')].map(row=>({name:row.querySelector('.cr-context-usage-popover__row-name')?.textContent,
|
||||
value:row.querySelector('.cr-context-usage-popover__row-value')?.textContent,swatch:box(row.querySelector('.cr-context-usage-popover__swatch')).slice(2)}));
|
||||
return {box:box(popover),width:style.width,padding:style.padding,borderRadius:style.borderRadius,font:style.fontSize+'/'+style.lineHeight,
|
||||
background:style.backgroundColor,zIndex:style.zIndex,
|
||||
title:title?{text:title.textContent,font:getComputedStyle(title).fontSize+'/'+getComputedStyle(title).fontWeight+'/'+getComputedStyle(title).lineHeight}:null,
|
||||
close:close?box(close):null,
|
||||
percent:percent?{text:percent.textContent,font:getComputedStyle(percent).fontSize+'/'+getComputedStyle(percent).fontWeight+'/'+getComputedStyle(percent).lineHeight}:null,
|
||||
usedLabel:usedLabel?{text:usedLabel.textContent,color:getComputedStyle(usedLabel).color}:null,
|
||||
usedValue:usedValue?usedValue.textContent:null,
|
||||
bar:bar?{box:box(bar),height:getComputedStyle(bar).height,radius:getComputedStyle(bar).borderRadius,fillWidth:popover.querySelector('.cr-context-usage-popover__bar-fill')?.style.width}:null,
|
||||
rows,bodyText:popover.textContent,tokenTertiary:probe('color','--cr-text-tertiary')}})()`
|
||||
|
||||
const requestingTipSource = `(()=>{const tip=document.querySelector('.cr-context-usage-requesting-tip');if(!tip)return null;
|
||||
const style=getComputedStyle(tip);const text=tip.querySelector('.cr-context-usage-requesting-tip__text');
|
||||
return {role:tip.getAttribute('role'),text:text?.textContent,background:style.backgroundColor,border:style.border,
|
||||
borderRadius:style.borderRadius,padding:style.padding,font:style.fontSize+'/'+style.lineHeight,
|
||||
popoverPresent:!!document.querySelector('.cr-context-usage-popover')}})()`
|
||||
|
||||
;(async () => {
|
||||
// 受控上游:最后一次 chunk 带真实 usage(provider 语义,含总 prompt tokens)。
|
||||
let omitUsage=false
|
||||
server = createServer(async (req, res) => {
|
||||
let raw = ''
|
||||
for await (const part of req) raw += part
|
||||
const body = JSON.parse(raw || '{}')
|
||||
if (!body.stream) { res.setHeader('content-type', 'application/json'); res.end(JSON.stringify({ choices: [{ message: { role: 'assistant', content: 'USAGE_REPLY' } }] })); return }
|
||||
res.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-cache' })
|
||||
const chunk = payload => `data: ${JSON.stringify({ id: 'local-usage', object: 'chat.completion.chunk', created: 1, model: body.model, ...payload })}\n\n`
|
||||
res.write(chunk({ choices: [{ index: 0, delta: { role: 'assistant', content: 'USAGE_REPLY' }, finish_reason: null }] }))
|
||||
res.write(chunk({ choices: [{ index: 0, delta: {}, finish_reason: 'stop' }],
|
||||
...(omitUsage?{}:{usage: { prompt_tokens: QA_PROMPT_TOKENS, completion_tokens: QA_COMPLETION_TOKENS, total_tokens: QA_PROMPT_TOKENS + QA_COMPLETION_TOKENS }}) }))
|
||||
res.write('data: [DONE]\n\n')
|
||||
res.end()
|
||||
})
|
||||
// 注意:导入主进程 bundle 之前不能 await 任何东西,否则 Electron 会先进入 ready,
|
||||
// bundle 顶层的 app.disableHardwareAcceleration() 就会抛错。这里全部用同步准备。
|
||||
const sync = require('node:fs')
|
||||
server.listen(0, '127.0.0.1')
|
||||
sync.mkdirSync(process.env.YCODE_USER_DATA_DIR, { recursive: true })
|
||||
app.setPath('userData', process.env.YCODE_USER_DATA_DIR)
|
||||
app.setPath('sessionData', process.env.YCODE_USER_DATA_DIR)
|
||||
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`
|
||||
for (let index = 0; index < 200; index++) {
|
||||
main = BrowserWindow.getAllWindows().find(win => win.getTitle() === 'YCode')
|
||||
if (main && !main.webContents.isLoading()) break
|
||||
await pause(40)
|
||||
}
|
||||
assert.ok(main, 'main window')
|
||||
main.setSize(1336, 902)
|
||||
main.showInactive()
|
||||
main.webContents.on('console-message', (_event, level, message) => { if (level >= 2) rendererErrors.push(message) })
|
||||
await wait('!!window.ycode && !!document.querySelector(".chat-pane")', 'main renderer')
|
||||
await read(`window.ycode.models.save(${JSON.stringify({ id: 'local-usage', name: 'Local Usage', providerId: 'custom',
|
||||
model: 'model-usage', baseUrl, apiKey: 'LOCAL_TEST_KEY', supportsToolCall: true,
|
||||
maxInputTokens: QA_CONTEXT_WINDOW, maxOutputTokens: 4096 })})`)
|
||||
await read('window.ycode.models.select("local-usage")')
|
||||
|
||||
await step('conversation-with-git-workspace')
|
||||
await wait(`!!document.querySelector('.cr-input-toolbar__left')`, 'composer toolbar')
|
||||
await wait(`!!document.querySelector('._branchLabel_jdvlk_244')`, 'branch label')
|
||||
const branchInfo = await read(branchSource)
|
||||
snapshots.branch = branchInfo
|
||||
check('branch label shows the real repository branch', branchInfo.text.value, QA_BRANCH)
|
||||
// 原版证据里是 6 字符的 master(82 宽);宽度由分支名长度决定,这里断言固定契约 + 上限。
|
||||
check('branch pill keeps the recovered geometry', [Math.round(branchInfo.pill.box[3]), branchInfo.pill.padding, branchInfo.pill.radius, branchInfo.pill.gap, branchInfo.pill.maxWidth],
|
||||
[32, '5px 8px', '999px', '8px', '188px'])
|
||||
check('branch pill width stays within the recovered cap', branchInfo.pill.box[2] <= 188 && branchInfo.pill.box[2] > 32, true)
|
||||
check('branch text keeps 14/400/22 with ellipsis', [branchInfo.text.font, branchInfo.text.overflow, branchInfo.text.textOverflow, branchInfo.text.whiteSpace],
|
||||
['14px/400/22px', 'hidden', 'ellipsis', 'nowrap'])
|
||||
check('branch icon button is a 16x16 button', [branchInfo.button.tag, branchInfo.button.box[2], branchInfo.button.box[3]], ['BUTTON', 16, 16])
|
||||
check('default glyph is the recovered 16x16 branch icon', [branchInfo.icons.defaultSvg.viewBox, branchInfo.icons.defaultSvg.className, branchInfo.icons.defaultSvg.paths, branchInfo.icons.defaultSvg.box[2]],
|
||||
['0 0 16 16', 'wb-icon', 1, 16])
|
||||
check('hover glyph is the recovered lucide copy icon', [branchInfo.icons.copySvg.viewBox, branchInfo.icons.copySvg.className, branchInfo.icons.copySvg.strokeWidth, branchInfo.icons.copySvg.rects, branchInfo.icons.copySvg.paths],
|
||||
['0 0 24 24', 'lucide lucide-copy wb-icon', '1.5', 1, 1])
|
||||
check('default glyph visible and copy glyph hidden before hover', [branchInfo.icons.defaultOpacity, branchInfo.icons.copyOpacity], ['1', '0'])
|
||||
check('branch label sits 8px after the add menu', Math.round(branchInfo.pill.box[0] - (branchInfo.order.addMenu[0] + branchInfo.order.addMenu[2])), 8)
|
||||
check('permission setting sits 8px after the branch label', Math.round(branchInfo.order.permission[0] - (branchInfo.pill.box[0] + branchInfo.pill.box[2])), 8)
|
||||
|
||||
await step('branch-hover-and-copy')
|
||||
await hover('._branchLabel_jdvlk_244')
|
||||
snapshots.branchHover = await read(`(()=>{const pill=document.querySelector('._branchLabel_jdvlk_244');
|
||||
const probe=(property,token)=>{const el=document.createElement('div');el.style[property]='var('+token+')';pill.appendChild(el);const value=getComputedStyle(el)[property];el.remove();return value};
|
||||
return {background:getComputedStyle(pill).backgroundColor,hover:pill.matches(':hover'),
|
||||
defaultOpacity:getComputedStyle(pill.querySelector('._branchLabelIconDefault_jdvlk_267')).opacity,
|
||||
copyOpacity:getComputedStyle(pill.querySelector('._branchLabelIconCopy_jdvlk_270')).opacity,
|
||||
secondary:getComputedStyle(pill).getPropertyValue('--wb-bg-secondary').trim(),
|
||||
secondaryResolved:probe('backgroundColor','--wb-bg-secondary')}})()`)
|
||||
check('hovering swaps the glyph to copy', [snapshots.branchHover.defaultOpacity, snapshots.branchHover.copyOpacity], ['0', '1'])
|
||||
check('hovering paints the pill with the resolved --wb-bg-secondary', snapshots.branchHover.background, snapshots.branchHover.secondaryResolved)
|
||||
check('branch pill hover state is real', snapshots.branchHover.hover, true)
|
||||
clipboard.writeText('')
|
||||
await read(`(()=>{document.querySelector('._branchLabelIconButton_jdvlk_279').click();return true})()`)
|
||||
await pause(160)
|
||||
check('clicking the glyph copies the real branch name', clipboard.readText(), QA_BRANCH)
|
||||
|
||||
await step('context-usage-ring')
|
||||
const ring = await read(ringSource)
|
||||
snapshots.ring = ring
|
||||
check('usage ring keeps the recovered 32x32 / radius 16 box', [Math.round(ring.ring.box[2]), Math.round(ring.ring.box[3]), ring.ring.radius, ring.ring.role], [32, 32, '16px', 'button'])
|
||||
check('usage ring is the first element of the right group', ring.firstChild.includes('cr-context-usage-tooltip-trigger'), true)
|
||||
check('usage ring sits 4px before the model selector', Math.round(ring.modelLeft - (ring.ring.box[0] + ring.ring.box[2])), 4)
|
||||
check('ring svg is the recovered 16x16 ring', [ring.svg.viewBox, ring.svg.className, ring.svg.box[2], ring.svg.box[3]], ['0 0 16 16', 'cr-context-usage__ring', 16, 16])
|
||||
check('ring track matches the recovered circle', [ring.track.r, ring.track.strokeWidth, ring.track.fill], ['7', '2', 'none'])
|
||||
check('ring progress circle matches the recovered geometry', [ring.progress.r, ring.progress.strokeWidth, ring.progress.strokeLinecap, ring.progress.transform], ['7', '2', 'round', 'rotate(-90 8 8)'])
|
||||
check('ring progress keeps the recovered dashoffset transition', ring.progress.transition, 'stroke-dashoffset 0.3s')
|
||||
check('legacy usage without a frozen denominator keeps the ring empty',
|
||||
Math.round(ring.progress.dashOffset), Math.round(RING_CIRCUMFERENCE))
|
||||
|
||||
await step('usage-requesting-tip-without-denominator')
|
||||
await read(`(()=>{document.querySelector('.cr-context-usage').click();return true})()`)
|
||||
await wait('!!document.querySelector(".cr-context-usage-popover")', 'known usage with unknown denominator')
|
||||
const legacyPopover = await read(popoverSource)
|
||||
snapshots.legacyPopover = legacyPopover
|
||||
check('known token count is visible even without its historical window',legacyPopover.usedValue,'8.0K/ 未知')
|
||||
check('legacy unknown denominator is not replaced by the current model configuration',legacyPopover.percent.text,'—')
|
||||
check('legacy classifications remain unknown',legacyPopover.rows.map(row=>row.value),['—','—','—','—','—'])
|
||||
await read(`(()=>{document.querySelector('.cr-context-usage').click();return true})()`)
|
||||
await wait('!document.querySelector(".cr-context-usage-popover")', 'legacy popover closed')
|
||||
check('clicking the ring dismisses the usage panel', await read('!document.querySelector(".cr-context-usage-popover")'), true)
|
||||
|
||||
await step('real-turn-usage')
|
||||
await read(`(()=>{const input=[...document.querySelectorAll('.wb-composer__input')].find(node=>node.offsetParent!==null);
|
||||
if(!input)throw new Error('Missing composer input');
|
||||
Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype,'value').set.call(input,'用量验收');
|
||||
input.dispatchEvent(new Event('input',{bubbles:true}));return true})()`)
|
||||
await read(`(()=>{document.querySelector('.cr-input-toolbar__send').click();return true})()`)
|
||||
await wait(`document.body.textContent.includes('USAGE_REPLY')`, 'controlled upstream reply')
|
||||
await wait(`(()=>{const progress=document.querySelector('.cr-context-usage__ring-progress');return progress&&Number(progress.getAttribute('stroke-dashoffset'))<${RING_CIRCUMFERENCE - 1}})()`, 'ring reflects real usage')
|
||||
await pause(400)
|
||||
const usedRing = await read(ringSource)
|
||||
snapshots.usedRing = usedRing
|
||||
const expectedOffset = RING_CIRCUMFERENCE * (1 - QA_EXPECTED_PERCENT / 100)
|
||||
check('ring dashoffset follows the real provider usage', Math.abs(usedRing.progress.dashOffset - expectedOffset) < 0.6, true)
|
||||
check('ring stroke uses the recovered progress colour', usedRing.progress.stroke, 'rgb(156, 163, 175)')
|
||||
check('ring track uses the recovered track colour', usedRing.track.stroke, 'rgba(128, 128, 128, 0.2)')
|
||||
check('ring accessible label uses the same recovered compact usage sentence', usedRing.ring.ariaLabel,
|
||||
`${QA_EXPECTED_PERCENT_TEXT} · ${QA_EXPECTED_USED_TEXT} / ${QA_EXPECTED_TOTAL_TEXT} 上下文已使用`)
|
||||
|
||||
await step('usage-tooltip')
|
||||
await hover('.cr-context-usage')
|
||||
const tooltip = await read(`(()=>{const node=document.querySelector('.cr-context-usage-tooltip');if(!node)return null;
|
||||
const probe=(property,token)=>{const el=document.createElement('div');el.style[property]='var('+token+')';node.appendChild(el);const value=getComputedStyle(el)[property];el.remove();return value};
|
||||
const style=getComputedStyle(node);return {text:node.querySelector('.wb-tooltip__content')?.textContent,role:node.getAttribute('role'),
|
||||
background:style.backgroundColor,color:style.color,borderRadius:style.borderRadius,padding:style.padding,font:style.fontSize+'/'+style.lineHeight,
|
||||
arrow:!!node.querySelector('.wb-tooltip__arrow'),tokenBg:probe('backgroundColor','--wb-tooltip-bg'),tokenFg:probe('color','--wb-tooltip-text-color')}})()`)
|
||||
snapshots.tooltip = tooltip
|
||||
check('hover tooltip uses the recovered one-decimal and compact-token formatting', tooltip?.text,
|
||||
`${QA_EXPECTED_PERCENT_TEXT} · ${QA_EXPECTED_USED_TEXT} / ${QA_EXPECTED_TOTAL_TEXT} 上下文已使用`)
|
||||
check('tooltip keeps the recovered base surface', [tooltip.background, tooltip.color, tooltip.borderRadius, tooltip.padding, tooltip.font, tooltip.arrow],
|
||||
[tooltip.tokenBg || 'rgba(0, 0, 0, 0.75)', tooltip.tokenFg || 'rgb(255, 255, 255)', '8px', '4px 8px', '12px/20px', true])
|
||||
|
||||
await step('usage-popover-with-real-numbers')
|
||||
await read(`(()=>{document.querySelector('.cr-context-usage').click();return true})()`)
|
||||
await wait('!!document.querySelector(".cr-context-usage-popover")', 'popover with usage')
|
||||
await pause(200)
|
||||
const usedPopover = await read(popoverSource)
|
||||
snapshots.usedPopover = usedPopover
|
||||
check('popover keeps the recovered one-decimal percentage', usedPopover.percent.text, QA_EXPECTED_PERCENT_TEXT)
|
||||
check('popover percent keeps 20/700/28', usedPopover.percent.font, '20px/700/28px')
|
||||
check('popover keeps the recovered compact used/total pair', usedPopover.usedValue,
|
||||
`${QA_EXPECTED_USED_TEXT}/ ${QA_EXPECTED_TOTAL_TEXT}`)
|
||||
check('popover used label keeps the recovered copy', [usedPopover.usedLabel.text, usedPopover.usedLabel.color], ['已使用', usedPopover.tokenTertiary])
|
||||
check('popover bar keeps the recovered 8px track', [Math.round(usedPopover.bar.box[3]), usedPopover.bar.height, usedPopover.bar.radius], [8, '8px', '3px'])
|
||||
const prepared = await read(`window.ycode.session.messages('context-info-session').then(messages=>{const m=messages.filter(item=>item.role==='assistant').at(-1);return {breakdown:m?.contextBreakdown,requestId:m?.requestId}})`)
|
||||
snapshots.preparedInput=prepared
|
||||
check('real OMP provider hook records this request, not an unrelated historical sample',
|
||||
[prepared.breakdown?.scope,prepared.breakdown?.requestId,prepared.breakdown?.method],['provider-request',prepared.requestId,'utf8-estimate'])
|
||||
check('classification contains actual system, tool definitions, dialogue and skill catalogue',
|
||||
['system','tools','conversation','skills'].every(key=>prepared.breakdown?.counts[key]>0),true)
|
||||
check('category sum is independent from controlled upstream count',prepared.breakdown?.inputTokens!==QA_PROMPT_TOKENS,true)
|
||||
check('classification bar uses its own independent local estimate',Math.abs(parseFloat(usedPopover.bar.fillWidth)-Math.min(100,prepared.breakdown.inputTokens/QA_CONTEXT_WINDOW*100))<0.01,true)
|
||||
check('popover legend keeps the five recovered categories', usedPopover.rows.map(row => row.name),
|
||||
['系统提示词', '工具及子智能体', '对话消息', '连接器及MCP', '技能'])
|
||||
check('legend swatches keep the recovered 8px dots', usedPopover.rows.map(row => row.swatch), [[8, 8], [8, 8], [8, 8], [8, 8], [8, 8]])
|
||||
check('legend values are explicitly labeled local estimates',usedPopover.rows.every(row=>row.value.startsWith('≈')),true)
|
||||
await fs.writeFile(path.join(directory,'context-breakdown-light.png'),(await main.webContents.capturePage()).toPNG())
|
||||
|
||||
await step('frozen-window-survives-model-config-change')
|
||||
await read(`window.ycode.models.save(${JSON.stringify({ id: 'local-usage', previousId: 'local-usage', name: 'Local Usage', providerId: 'custom',
|
||||
model: 'model-usage', baseUrl, apiKey: 'LOCAL_TEST_KEY', supportsToolCall: true,
|
||||
maxInputTokens: QA_CONTEXT_WINDOW * 2, maxOutputTokens: 4096 })})`)
|
||||
await pause(250)
|
||||
const frozenRing = await read(ringSource)
|
||||
snapshots.frozenWindow = frozenRing
|
||||
check('historical usage keeps its own frozen denominator after model config changes',
|
||||
frozenRing.ring.ariaLabel,
|
||||
`${QA_EXPECTED_PERCENT_TEXT} · ${QA_EXPECTED_USED_TEXT} / ${QA_EXPECTED_TOTAL_TEXT} 上下文已使用`)
|
||||
check('historical usage does not silently inherit the newly configured model window',
|
||||
Math.abs(frozenRing.progress.dashOffset - expectedOffset) < 0.6, true)
|
||||
|
||||
await step('dark-theme-via-native-settings')
|
||||
// 真实路径:设置窗口切成深色,聊天区 `data-cr-theme` 随之变化,输入区 token 跟着切。
|
||||
const lightTokens = await read(`(()=>{const popover=document.querySelector('.cr-context-usage-popover');const pill=document.querySelector('._branchLabel_jdvlk_244');
|
||||
return {popoverBackground:getComputedStyle(popover).backgroundColor,bar:getComputedStyle(popover.querySelector('.cr-context-usage-popover__bar')).backgroundColor,
|
||||
pillColor:getComputedStyle(pill).color,scope:document.querySelector('.chat-pane')?.dataset.crTheme ?? null}})()`)
|
||||
await read(`window.ycode.app.openSettings('appearance')`)
|
||||
let settings
|
||||
for (let index = 0; index < 200; index++) {
|
||||
settings = BrowserWindow.getAllWindows().find(win => win !== main)
|
||||
if (settings && !settings.webContents.isLoading()) break
|
||||
await pause(25)
|
||||
}
|
||||
assert.ok(settings, 'settings window')
|
||||
await pause(600)
|
||||
snapshots.settingsWindow = await read(`({url:location.href,search:location.search,bodyClass:document.body.className,rootHtml:(document.querySelector('#root')?.innerHTML||'').slice(0,240)})`, settings)
|
||||
await wait('!!document.querySelector(".settings-window-root")', 'settings root', settings)
|
||||
await read(`(()=>{const tab=document.querySelector('[data-settings-tab="appearance"]');if(tab)tab.click();return true})()`, settings)
|
||||
await wait(`!!document.querySelector('.appearance-card')`, 'appearance panel', settings)
|
||||
await read(`(()=>{const node=document.querySelector('.appearance-card[aria-label="深色"]');if(!node)throw new Error('missing dark option');node.click();return true})()`, settings)
|
||||
await wait(`document.documentElement.dataset.theme==='dark'`, 'main window dark theme')
|
||||
await wait(`document.querySelector('.chat-pane')?.dataset.crTheme==='dark'`, 'composer dark scope')
|
||||
await pause(250)
|
||||
const darkPopover = await read(`(()=>{const popover=document.querySelector('.cr-context-usage-popover');const pill=document.querySelector('._branchLabel_jdvlk_244');
|
||||
const probe=(property,token)=>{const el=document.createElement('div');el.style[property]='var('+token+')';popover.appendChild(el);const value=getComputedStyle(el)[property];el.remove();return value};
|
||||
return {popoverBackground:getComputedStyle(popover).backgroundColor,pillColor:getComputedStyle(pill).color,
|
||||
bar:getComputedStyle(popover.querySelector('.cr-context-usage-popover__bar')).backgroundColor,
|
||||
tokenElevated:probe('backgroundColor','--cr-bg-elevated'),tokenActive:probe('backgroundColor','--cr-bg-active'),
|
||||
tokenTextPrimary:probe('color','--wb-color-text-primary'),scope:document.querySelector('.chat-pane')?.dataset.crTheme ?? null}})()`)
|
||||
snapshots.dark = { lightTokens, darkPopover }
|
||||
await fs.writeFile(path.join(directory,'context-breakdown-dark.png'),(await main.webContents.capturePage()).toPNG())
|
||||
check('native settings switches the composer to the dark scope', [lightTokens.scope, darkPopover.scope], ['light', 'dark'])
|
||||
check('dark popover uses the recovered dark elevated surface', darkPopover.popoverBackground, 'rgb(36, 38, 41)')
|
||||
check('dark popover bar uses the recovered dark active fill', darkPopover.bar, 'rgba(255, 255, 255, 0.1)')
|
||||
check('dark pill switches to the dark primary text colour', [darkPopover.pillColor, lightTokens.pillColor, darkPopover.pillColor === darkPopover.tokenTextPrimary], ['rgb(255, 255, 255)', 'rgb(0, 0, 0)', true])
|
||||
await read(`(()=>{const node=document.querySelector('.appearance-card[aria-label="浅色"]');if(!node)throw new Error('missing light option');node.click();return true})()`, settings)
|
||||
await wait(`document.documentElement.dataset.theme==='light'`, 'main window light theme')
|
||||
await wait(`document.querySelector('.chat-pane')?.dataset.crTheme==='light'`, 'composer light scope')
|
||||
await read(`document.querySelector('.settings-window-close').click()`, settings)
|
||||
for (let index = 0; index < 200 && !settings.isDestroyed(); index++) await pause(20)
|
||||
check('light theme restored through the same settings window', await read(`[document.documentElement.dataset.theme,document.querySelector('.chat-pane').dataset.crTheme]`), ['light', 'light'])
|
||||
|
||||
await step('persisted-usage-after-renderer-reload')
|
||||
await wait(`window.ycode.chat.activeRun('context-info-session').then(run=>!run)`, 'real turn persistence barrier')
|
||||
const persistedUsage = await read(`window.ycode.session.messages('context-info-session').then(messages=>messages.filter(message=>message.role==='assistant').at(-1)?.contextUsage)`)
|
||||
snapshots.persistedUsage = persistedUsage
|
||||
check('actual turn stores its upstream input sample and original model window',
|
||||
[persistedUsage?.usage?.inputTokens, persistedUsage?.contextWindow], [QA_PROMPT_TOKENS, QA_CONTEXT_WINDOW])
|
||||
const reloaded = new Promise(resolve => main.webContents.once('did-finish-load', resolve))
|
||||
main.webContents.reload()
|
||||
await reloaded
|
||||
await wait(`document.querySelector('.cr-context-usage')?.getAttribute('aria-label')===${JSON.stringify(`${QA_EXPECTED_PERCENT_TEXT} · ${QA_EXPECTED_USED_TEXT} / ${QA_EXPECTED_TOTAL_TEXT} 上下文已使用`)}`,
|
||||
'restored usage with frozen denominator')
|
||||
snapshots.reloadedUsage = await read(ringSource)
|
||||
check('renderer reload recovers usage without adopting the newly doubled model window',
|
||||
snapshots.reloadedUsage.ring.ariaLabel,
|
||||
`${QA_EXPECTED_PERCENT_TEXT} · ${QA_EXPECTED_USED_TEXT} / ${QA_EXPECTED_TOTAL_TEXT} 上下文已使用`)
|
||||
await fs.writeFile(path.join(directory, 'context-usage-restored.png'), (await main.webContents.capturePage()).toPNG())
|
||||
await step('new-request-without-provider-usage')
|
||||
omitUsage=true
|
||||
await read(`(()=>{const input=[...document.querySelectorAll('.wb-composer__input')].find(node=>node.offsetParent!==null);
|
||||
Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype,'value').set.call(input,'NO_USAGE_NEW_REQUEST');input.dispatchEvent(new Event('input',{bubbles:true}));return true})()`)
|
||||
await read(`document.querySelector('.cr-input-toolbar__send').click()`)
|
||||
await wait(`window.ycode.session.messages('context-info-session').then(messages=>messages.filter(m=>m.role==='user').at(-1)?.content==='NO_USAGE_NEW_REQUEST')`,'new round created')
|
||||
await wait(`window.ycode.chat.activeRun('context-info-session').then(run=>!run)`,'no-usage round complete')
|
||||
const noUsage=await read(`window.ycode.session.messages('context-info-session').then(messages=>messages.filter(m=>m.role==='assistant').at(-1))`)
|
||||
snapshots.noUsage={contextUsage:noUsage.contextUsage,contextBreakdown:noUsage.contextBreakdown,requestId:noUsage.requestId}
|
||||
check('a new no-usage request cannot inherit the previous provider total',noUsage.contextUsage??null,null)
|
||||
check('the new request still retains its independently prepared classification',noUsage.contextBreakdown?.requestId,noUsage.requestId)
|
||||
await wait(`document.querySelector('.cr-context-usage')?.getAttribute('aria-label')?.startsWith('≈')`,'local estimate clearly identified')
|
||||
const noUsageReload=new Promise(resolve=>main.webContents.once('did-finish-load',resolve));main.webContents.reload();await noUsageReload
|
||||
await wait(`document.querySelector('.cr-context-usage')?.getAttribute('aria-label')?.startsWith('≈')`,'estimate restored without reviving old provider sample')
|
||||
check('reopening a no-usage round does not pick an older assistant sample',
|
||||
await read(`window.ycode.session.contextUsage('context-info-session').then(info=>[!!info.sample,info.breakdown?.requestId])`),[false,noUsage.requestId])
|
||||
|
||||
await step('native-history-usage-recovery')
|
||||
const nativeUpdatedBefore=await read(`window.ycode.session.list().then(sessions=>sessions.find(session=>session.id==='native-context-session').updatedAt)`)
|
||||
await wait(`!!document.querySelector('[data-session-id="native-context-session"]')`,'legacy sidebar item')
|
||||
await read(`document.querySelector('[data-session-id="native-context-session"]').click()`)
|
||||
await wait(`document.querySelector('.cr-context-usage')?.getAttribute('aria-label')?.includes('5.2K / 未知')`,'native sample recovered without a fabricated denominator')
|
||||
await read(`document.querySelector('.cr-context-usage').click()`)
|
||||
await wait(`document.querySelector('.yc-context-source')?.textContent.includes('原生记录恢复')`,'source of recovered sample visible')
|
||||
const recovered=await read(`window.ycode.session.messages('native-context-session').then(messages=>messages.at(-1).contextUsage)`)
|
||||
snapshots.nativeRecovered=recovered
|
||||
check('real IPC saves the recovered native sample with its provenance',
|
||||
[recovered.usage.inputTokens,recovered.contextWindow??null,recovered.source,recovered.nativeEntryId],[5161,null,'native-history','native-reply'])
|
||||
check('restoring usage metadata does not reorder the sidebar task',await read(`window.ycode.session.list().then(sessions=>sessions.find(session=>session.id==='native-context-session').updatedAt)`),nativeUpdatedBefore)
|
||||
await fs.writeFile(path.join(directory,'context-native-recovered.png'),(await main.webContents.capturePage()).toPNG())
|
||||
check('querying old records does not start a native agent',await fs.readdir(path.join(process.env.YCODE_USER_DATA_DIR,'context-usage')).then(files=>files.some(name=>name.includes('native-context-session'))),false)
|
||||
check('no renderer error logs', rendererErrors, [])
|
||||
|
||||
server.close()
|
||||
const report = { result: checks.every(item => item.pass) ? 'PASS' : 'FAIL',
|
||||
scope: 'Real isolated YCode App/preload/IPC/renderer + real OMP kernel + controlled local OpenAI-compatible upstream + real git repository; measured branch pill, usage ring, tooltip and popover in the live window after a real turn.',
|
||||
sourceEvidence: ['ev_2569313a7a0aa901c5a3b8dc1d7fd190109b9006c915830109b62195f312376a', 'contexts-BkjwYSZT.css', 'src-CyXHA0IC.css', 'chatInput.contextUsage.*'],
|
||||
snapshots, checks, rendererErrors }
|
||||
await fs.writeFile(path.join(directory, 'report.json'), JSON.stringify(report, null, 2))
|
||||
console.log('COMPOSER_CONTEXT_' + report.result + ' ' + directory)
|
||||
for (const failure of checks.filter(item => !item.pass)) console.log('COMPOSER_CONTEXT_FAILURE ' + JSON.stringify(failure))
|
||||
app.exit(report.result === 'PASS' ? 0 : 1)
|
||||
})().catch(async error => {
|
||||
if (server) server.close()
|
||||
await fs.writeFile(path.join(directory, 'failure.json'), JSON.stringify({ phase, error: String(error), stack: error.stack, checks, snapshots, rendererErrors }, null, 2))
|
||||
console.error(error); app.exit(1)
|
||||
})
|
||||
}
|
||||
@@ -79,8 +79,7 @@ if (!process.versions.electron) {
|
||||
]
|
||||
await fs.writeFile(path.join(data, 'sessions.json'), JSON.stringify({ sessions }))
|
||||
// 原生滚动条只在真实合成时绘制;本脚本不设 YCODE_QA_OFFSCREEN,以便像素级比对。
|
||||
const env = { ...process.env, YCODE_QA_DIRECTORY: directory, YCODE_USER_DATA_DIR: data,
|
||||
YCODE_OMP_HOME: home, YCODE_DISABLE_GPU: '1' }
|
||||
const env = { ...process.env, YCODE_QA_DIRECTORY: directory, YCODE_USER_DATA_DIR: data, YCODE_DISABLE_GPU: '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'] })
|
||||
|
||||
@@ -1,357 +0,0 @@
|
||||
const fs = require('node:fs/promises'), path = require('node:path'), assert = require('node:assert/strict')
|
||||
if (!process.versions.electron) {
|
||||
;(async () => {
|
||||
const directory = await fs.mkdtemp(path.resolve('.tmp/custom-model-settings-'))
|
||||
for (const name of ['data', 'home']) await fs.mkdir(path.join(directory, name))
|
||||
await fs.cp(path.resolve('out'), path.join(directory, 'build/out'), { recursive: true })
|
||||
await fs.cp(path.resolve('resources/model-metadata'), path.join(directory, 'build/resources/model-metadata'), { recursive: true })
|
||||
const env = { ...process.env, YCODE_QA_DIRECTORY: directory, YCODE_USER_DATA_DIR: path.join(directory, 'data'), YCODE_OMP_HOME: path.join(directory, 'home') }
|
||||
delete env.ELECTRON_RUN_AS_NODE; delete env.ELECTRON_RENDERER_URL
|
||||
const child = require('node:child_process').spawn(require('electron'), ['--disable-gpu-sandbox', __filename], { env, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] })
|
||||
let output = ''; child.stdout.on('data', data => { output += data; if (data.toString().includes('CUSTOM_MODEL_')) process.stdout.write(data) }); child.stderr.on('data', data => output += data)
|
||||
const timer = setTimeout(() => child.kill(), 180000)
|
||||
child.on('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(-5000)); process.exitCode = code ? 1 : 0 })
|
||||
})().catch(error => { console.error(error); process.exitCode = 1 })
|
||||
} else {
|
||||
const { app, BrowserWindow } = require('electron'), { pathToFileURL } = require('node:url'), { createServer } = require('node:http')
|
||||
const directory = process.env.YCODE_QA_DIRECTORY
|
||||
app.setPath('home', path.join(directory, 'home')); app.setPath('userData', path.join(directory, 'data')); app.setPath('sessionData', path.join(directory, 'data'))
|
||||
let main, settings, phase = 'boot'
|
||||
const checks = [], servers = [], requests = [], rendererErrors = []
|
||||
const pause = ms => new Promise(resolve => setTimeout(resolve, ms))
|
||||
const read = (source, win = settings) => win.webContents.executeJavaScript(source)
|
||||
const check = (label, actual, expected = true) => checks.push({ label, actual, expected, pass: JSON.stringify(actual) === JSON.stringify(expected) })
|
||||
async function wait(source, label, win = settings) { for (let i = 0; i < 500; i++) { const value = await read(source, win); if (value) return value; await pause(20) }; throw Error('Timeout: ' + label) }
|
||||
async function step(label) { phase = label; await fs.writeFile(path.join(directory, 'phase.txt'), label) }
|
||||
const click = selector => read(`(()=>{const node=document.querySelector(${JSON.stringify(selector)});if(!node)throw Error('Missing control: '+${JSON.stringify(selector)});node.click();return true})()`)
|
||||
async function fill(selector, value) { await read(`(()=>{const node=document.querySelector(${JSON.stringify(selector)});if(!node)throw Error('Missing input: '+${JSON.stringify(selector)});Object.getOwnPropertyDescriptor(HTMLInputElement.prototype,'value').set.call(node,${JSON.stringify(value)});node.dispatchEvent(new Event('input',{bubbles:true}));return true})()`) }
|
||||
async function option(value) {
|
||||
const source = `(()=>{const nodes=[...document.querySelectorAll('[role="option"]')];const node=nodes.find(n=>n.textContent.trim()===${JSON.stringify(value)}&&n.getClientRects().length);if(!node||getComputedStyle(node.closest('.wb-select__panel')).pointerEvents==='none'||getComputedStyle(node.closest('.wb-popover')).visibility==='hidden'||Number(getComputedStyle(node.closest('.wb-popover')).opacity)<.99)return false;node.click();return true})()`
|
||||
await wait(source, 'select option ' + value)
|
||||
await wait(`![...document.querySelectorAll('[role="option"]')].some(node=>node.textContent.trim()===${JSON.stringify(value)}&&node.getClientRects().length)`, 'option committed ' + value)
|
||||
}
|
||||
async function shot(name) {
|
||||
await read(`new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(()=>resolve(true))))`)
|
||||
await read(`(async()=>{const menu=document.querySelector('.custom-model-options');if(menu)await Promise.allSettled(menu.getAnimations({subtree:true}).filter(animation=>animation.effect.getComputedTiming().iterations!==Infinity).map(animation=>animation.finished));await new Promise(resolve=>requestAnimationFrame(()=>resolve(true)))})()`)
|
||||
await fs.writeFile(path.join(directory, name + '.png'), (await settings.webContents.capturePage()).toPNG())
|
||||
await fs.writeFile(path.join(directory, name + '.layout.json'), JSON.stringify(await read(`(()=>{const body=document.querySelector('.models-settings-panel__editor-body'),menu=document.querySelector('.custom-model-options');return {theme:document.documentElement.dataset.theme,body:body?{top:body.scrollTop,height:body.clientHeight,scrollHeight:body.scrollHeight}:null,menu:menu?{x:menu.getBoundingClientRect().x,y:menu.getBoundingClientRect().y,width:menu.getBoundingClientRect().width,opacity:getComputedStyle(menu).opacity}:null}})()`), null, 2))
|
||||
}
|
||||
function anthropicStream(model) { return [
|
||||
{ type: 'message_start', message: { id: 'msg_ui', type: 'message', role: 'assistant', model, content: [], stop_reason: null, stop_sequence: null, usage: { input_tokens: 20, output_tokens: 0 } } },
|
||||
{ type: 'content_block_start', index: 0, content_block: { type: 'text', text: '' } }, { type: 'content_block_delta', index: 0, delta: { type: 'text_delta', text: 'MODEL_PROTOCOL_OK' } },
|
||||
{ type: 'content_block_stop', index: 0 }, { type: 'message_delta', delta: { stop_reason: 'end_turn', stop_sequence: null }, usage: { output_tokens: 4 } }, { type: 'message_stop' }
|
||||
].map(row => `event: ${row.type}\ndata: ${JSON.stringify(row)}\n\n`).join('') }
|
||||
async function upstream(protocol, model, options = {}) {
|
||||
const server = createServer(async (req, res) => {
|
||||
const url = new URL(req.url, 'http://local')
|
||||
const validAuth = options.noKey ? !req.headers['x-api-key'] && !req.headers['x-goog-api-key'] && !req.headers.authorization
|
||||
: protocol === 'anthropic' ? req.headers['x-api-key'] === 'controlled-model-key' && !req.headers.authorization : protocol === 'gemini' ? req.headers['x-goog-api-key'] === 'controlled-model-key' : req.headers.authorization === 'Bearer controlled-model-key'
|
||||
const listPath = protocol === 'gemini' ? '/v1beta/models' : '/v1/models'
|
||||
const generationPath = protocol === 'anthropic' ? '/v1/messages' : protocol === 'gemini' ? `/v1beta/models/${model}:streamGenerateContent` : protocol === 'responses' ? '/v1/responses' : '/v1/chat/completions'
|
||||
if (req.method === 'GET') {
|
||||
if (url.pathname !== listPath || !validAuth || options.noList) { res.writeHead(404); res.end('{}'); return }
|
||||
options.listStarted = true
|
||||
if (options.listDelay) await pause(options.listDelay)
|
||||
res.setHeader('content-type', 'application/json'); res.end(JSON.stringify(protocol === 'gemini' ? { models: options.emptyList ? [] : [{ name: 'models/' + model, displayName: model, supportedGenerationMethods: ['generateContent'] }] } : { data: options.emptyList ? [] : [{ id: model }, ...(options.additionalModels ?? []).map(id => ({ id }))] })); options.listEnded = true; return
|
||||
}
|
||||
let bytes = ''; for await (const chunk of req) bytes += chunk
|
||||
const body = JSON.parse(bytes || '{}'), nonstreamGemini = protocol === 'gemini' && url.pathname === `/v1beta/models/${model}:generateContent`
|
||||
requests.push({ protocol, path: req.url, stream: !!body.stream || url.searchParams.get('alt') === 'sse', body, authorized: validAuth,
|
||||
authShape: { names: Object.keys(req.headers), bearerMatches: req.headers.authorization === 'Bearer controlled-model-key', xKeyMatches: req.headers['x-api-key'] === 'controlled-model-key', xKeyLength: String(req.headers['x-api-key'] ?? '').length } })
|
||||
if (!validAuth) { res.writeHead(401); res.end('{}'); return }
|
||||
if (url.pathname !== generationPath && !nonstreamGemini) { res.writeHead(404); res.end('{}'); return }
|
||||
const streaming = body.stream || url.searchParams.get('alt') === 'sse'
|
||||
if (!streaming) {
|
||||
options.probeStarted = true
|
||||
if (options.probeDelay) await pause(options.probeDelay)
|
||||
res.setHeader('content-type', 'application/json')
|
||||
res.end(JSON.stringify(protocol === 'anthropic' ? { type: 'message', role: 'assistant', content: [{ type: 'text', text: 'OK' }] }
|
||||
: protocol === 'gemini' ? { candidates: [{ content: { role: 'model', parts: [{ text: 'OK' }] } }] }
|
||||
: protocol === 'responses' ? { id: 'resp_test', object: 'response', status: 'completed', output: [{ type: 'message', role: 'assistant', content: [{ type: 'output_text', text: 'OK' }] }] }
|
||||
: { choices: [{ message: { role: 'assistant', content: 'OK' } }] })); options.probeEnded = true; return
|
||||
}
|
||||
res.writeHead(200, { 'content-type': 'text/event-stream' })
|
||||
if (protocol === 'anthropic') res.end(anthropicStream(model))
|
||||
else if (protocol === 'gemini') res.end('data: ' + JSON.stringify({ candidates: [{ index: 0, content: { role: 'model', parts: [{ text: 'MODEL_PROTOCOL_OK' }] }, finishReason: 'STOP' }], usageMetadata: { promptTokenCount: 20, candidatesTokenCount: 4, totalTokenCount: 24 } }) + '\n\n')
|
||||
else if (protocol === 'responses') {
|
||||
const message = { id: 'msg_resp', type: 'message', role: 'assistant', status: 'completed', content: [{ type: 'output_text', text: 'MODEL_PROTOCOL_OK' }] }
|
||||
res.end([{ type: 'response.output_item.added', output_index: 0, item: { ...message, content: [] } }, { type: 'response.output_text.delta', output_index: 0, content_index: 0, item_id: message.id, delta: 'MODEL_PROTOCOL_OK' }, { type: 'response.completed', response: { id: 'resp_actual', status: 'completed', output: [message], usage: { input_tokens: 20, output_tokens: 4, total_tokens: 24 } } }].map(row => `event: ${row.type}\ndata: ${JSON.stringify(row)}\n\n`).join(''))
|
||||
} else res.end([{ choices: [{ index: 0, delta: { role: 'assistant', content: 'MODEL_PROTOCOL_OK' }, finish_reason: null }] }, { choices: [{ index: 0, delta: {}, finish_reason: 'stop' }], usage: { prompt_tokens: 20, completion_tokens: 4, total_tokens: 24 } }].map(row => 'data: ' + JSON.stringify(row) + '\n\n').join('') + 'data: [DONE]\n\n')
|
||||
})
|
||||
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); servers.push(server)
|
||||
return `127.0.0.1:${server.address().port}`
|
||||
}
|
||||
;(async () => {
|
||||
await import(pathToFileURL(path.join(directory, 'build/out/main/index.mjs')).href); await app.whenReady()
|
||||
for (let i = 0; i < 300; i++) { main = BrowserWindow.getAllWindows().find(win => win.getTitle() === 'YCode'); if (main && !main.webContents.isLoading()) break; await pause(20) }
|
||||
await wait(`!!window.ycode&&!!document.querySelector('.chat-pane')`, 'main app', main)
|
||||
await wait(`!!document.querySelector('.cr-model-selector__trigger')`, 'composer model selector', main)
|
||||
await read(`document.querySelector('.cr-model-selector__trigger').click()`, main)
|
||||
await wait(`document.querySelector('.cr-model-selector__menu > .cr-menu-item')?.textContent.trim()==='配置自定义模型'`, 'configure custom models action', main)
|
||||
await read(`document.querySelector('.cr-model-selector__menu > .cr-menu-item').click()`, main)
|
||||
settings = BrowserWindow.getAllWindows().find(win => win !== main)
|
||||
settings.webContents.on('console-message', event => { if (event.level === 'error') rendererErrors.push(event.message) })
|
||||
await wait(`!!document.querySelector('.models-settings-panel')`, 'model list page')
|
||||
check('configure custom models opens the list without an add dialog', await read(`!document.querySelector('.models-settings-panel__editor-overlay')`))
|
||||
check('model page has no metadata update button', await read(`![...document.querySelectorAll('.models-settings-panel button')].some(node=>node.textContent.includes('更新元数据'))`))
|
||||
await click('.models-settings-panel__add-button')
|
||||
await wait(`!!document.querySelector('.models-settings-panel__editor-overlay')`, 'model editor')
|
||||
check('keyless switch and explanatory text are removed', await read(`!document.querySelector('.models-settings-panel__editor-body').textContent.includes('无需')`))
|
||||
await step('custom-provider-only')
|
||||
await click('.models-settings-panel__provider-select'); await option('自定义')
|
||||
check('only custom provider appears in the editor', await read(`document.querySelector('.models-settings-panel__provider-select').textContent.trim()`), '自定义')
|
||||
const source = await read(`window.ycode.models.metadataStatus()`)
|
||||
const manifest = JSON.parse(await fs.readFile(path.join(directory, 'build/resources/model-metadata/manifest.json'), 'utf8'))
|
||||
check('bundled catalog matches the fetched snapshot', [source.sha256, source.providers, source.models], [manifest.sha256, manifest.providers, manifest.models])
|
||||
await shot('custom-editor-empty-light')
|
||||
const cases = [{ protocol: 'openai', model: 'gpt-5.4' }, { protocol: 'anthropic', model: 'claude-sonnet-4-6' }, { protocol: 'gemini', model: 'gemini-2.5-flash' }, { protocol: 'responses', model: 'gpt-5.4-pro' }, { protocol: 'responses', model: 'gpt-6.1-sol', extended: true }]
|
||||
const profiles = []
|
||||
for (let index = 0; index < cases.length; index++) {
|
||||
const item = cases[index]; await step('form-' + item.protocol)
|
||||
if (index) { await read(`window.ycode.app.openSettings('model','new')`, main); await wait(`!!document.querySelector('.models-settings-panel__editor-overlay')`, 'next model editor') }
|
||||
const domain = await upstream(item.protocol, item.model, index === 0 ? { additionalModels: [item.model + '-alt'] } : {})
|
||||
await fill('input[placeholder*="api.example.com"]', domain); await fill('input[type="password"]', 'controlled-model-key')
|
||||
await read(`[...document.querySelectorAll('.custom-model-id-row button')].find(node=>node.textContent.includes('获取模型')).click()`)
|
||||
await option(item.model)
|
||||
await wait(`document.querySelector('.custom-model-id-combobox input').value===${JSON.stringify(item.model)}&&!document.querySelector('.models-settings-panel__editor-save').disabled`, 'metadata applied')
|
||||
const metadata = await read(`window.ycode.models.metadata(${JSON.stringify(item.model)},'auto')`)
|
||||
check(item.protocol + ' metadata found', !!metadata)
|
||||
check(item.protocol + ' metadata descriptions and total-context field are removed', await read(`!document.querySelector('.custom-model-metadata')&&!document.querySelector('[aria-label="总上下文窗口"]')&&!document.querySelector('.models-settings-panel__editor-body').textContent.includes('参考价格')`))
|
||||
if (index === 0) {
|
||||
await fill('.custom-model-id-combobox input', '5.4-alt')
|
||||
await wait(`document.querySelectorAll('.custom-model-options [role="option"]').length===1`, 'inline model search')
|
||||
check('one combobox searches the list directly without a second search field', await read(`document.querySelector('.custom-model-id-combobox input').getAttribute('role')==='combobox'&&!document.querySelector('.custom-model-options input')`))
|
||||
await read(`document.querySelector('.custom-model-id-combobox input').dispatchEvent(new KeyboardEvent('keydown',{key:'ArrowDown',bubbles:true}))`)
|
||||
await wait(`!!document.querySelector('.custom-model-id-combobox input').getAttribute('aria-activedescendant')`, 'active keyboard option')
|
||||
await read(`document.querySelector('.custom-model-id-combobox input').dispatchEvent(new KeyboardEvent('keydown',{key:'Enter',bubbles:true}))`)
|
||||
await wait(`document.querySelector('.custom-model-id-combobox input').value==='gpt-5.4-alt'`, 'keyboard selection')
|
||||
await fill('.custom-model-id-combobox input', 'vendor/manual-model')
|
||||
await read(`document.querySelector('.custom-model-id-combobox input').dispatchEvent(new KeyboardEvent('keydown',{key:'Enter',bubbles:true}))`)
|
||||
check('an unmatched manual model stays editable and is not replaced', await read(`document.querySelector('.custom-model-id-combobox input').value`), 'vendor/manual-model')
|
||||
await fill('.custom-model-id-combobox input', item.model)
|
||||
await read(`document.querySelector('.custom-model-id-combobox input').dispatchEvent(new KeyboardEvent('keydown',{key:'Escape',bubbles:true}))`)
|
||||
check('Escape closes only the model list', await read(`!document.querySelector('.custom-model-options')&&!!document.querySelector('.models-settings-panel__editor-overlay')`))
|
||||
await wait(`!document.querySelector('.models-settings-panel__editor-save').disabled`, 'original model metadata restored')
|
||||
}
|
||||
await shot('custom-editor-' + item.protocol + '-light')
|
||||
await read(`document.querySelector('.models-settings-panel__editor-body').scrollTop=10000`)
|
||||
if (item.extended) {
|
||||
await read(`(()=>{const fields=document.querySelectorAll('.models-settings-panel__number-grid .models-settings-panel__field');fields[0].querySelectorAll('button').forEach(button=>{if(button.textContent==='500K')button.click()});fields[1].querySelectorAll('button').forEach(button=>{if(button.textContent==='128K')button.click()})})()`)
|
||||
check('500K and 128K shortcuts use exact catalog-compatible token counts', await read(`[...document.querySelectorAll('.models-settings-panel__number-grid input')].map(node=>node.value)`), ['500000', '128000'])
|
||||
check('both large presets show their selected state', await read(`[...document.querySelectorAll('.models-settings-panel__quick-value.wb-button--secondary')].map(node=>node.textContent)`), ['500K', '128K'])
|
||||
check('fields configure total context and output', await read(`[...document.querySelectorAll('.models-settings-panel__number-grid input')].map(node=>node.getAttribute('aria-label'))`), ['上下文','输出'])
|
||||
check('live budgets show 372K input and compression at 500K total', await read(`document.querySelector('[data-context-budget]').textContent.includes('372,000 tokens · 自动压缩阈值:372,000')`))
|
||||
}
|
||||
await shot('custom-editor-' + item.protocol + '-metadata-light')
|
||||
if (item.extended) await shot('custom-editor-500k-128k-light')
|
||||
await click('.models-settings-panel__editor-save')
|
||||
await wait(`!document.querySelector('.models-settings-panel__editor-overlay')`, 'save with protocol verification')
|
||||
const registry = await read(`window.ycode.models.list()`), profile = registry.models.find(model => model.id === item.model)
|
||||
assert(profile, 'saved profile ' + item.model); profiles.push(profile)
|
||||
check(item.protocol + ' stores only custom provider', profile.providerId, 'custom')
|
||||
check(item.protocol + ' automatically negotiated generation API', profile.endpoint.api, item.protocol === 'anthropic' ? 'anthropic-messages' : item.protocol === 'gemini' ? 'google-generative-ai' : item.protocol === 'responses' ? 'openai-responses' : 'openai-completions')
|
||||
check(item.protocol + ' saves actual capabilities', [profile.supportsImages, profile.supportsReasoning, profile.supportsToolCall], [metadata.supportsImages, metadata.supportsReasoning, metadata.supportsToolCall])
|
||||
check(item.protocol + ' saves metadata thinking defaults', [profile.onlyReasoning, profile.canDisableThinking, profile.defaultEffort, profile.supportedEfforts], [metadata.onlyReasoning, metadata.canDisableThinking, metadata.defaultEffort, metadata.supportedEfforts])
|
||||
check(item.protocol + ' saves total context and derived input budget', [profile.maxInputTokens, profile.maxOutputTokens, profile.contextWindowTokens], item.extended ? [372000, 128000, 500000] : [metadata.maxInputTokens, metadata.maxOutputTokens, metadata.contextWindowTokens])
|
||||
check(item.protocol + ' saves the matching tier reference', profile.price, item.extended ? metadata.priceTiers.find(tier=>tier.minContext===272000) : metadata.price)
|
||||
check(item.protocol + ' retains no plaintext key in renderer view', !JSON.stringify(profile).includes('controlled-model-key'))
|
||||
await step('real-chat-' + item.protocol)
|
||||
const session = await read(`window.ycode.session.create('Protocol ${item.protocol}',{modelId:${JSON.stringify(item.model)}})`, main)
|
||||
const before = requests.length
|
||||
await read(`window.ycode.chat.send({sessionId:${JSON.stringify(session.id)},text:'Reply briefly with MODEL_PROTOCOL_OK.'})`, main)
|
||||
await wait(`window.ycode.session.messages(${JSON.stringify(session.id)}).then(rows=>rows.some(row=>row.role==='assistant'&&['done','error'].includes(row.status)))`, 'real native turn completes', main)
|
||||
const messages = await read(`window.ycode.session.messages(${JSON.stringify(session.id)})`, main), assistant = messages.filter(row => row.role === 'assistant').at(-1)
|
||||
check(item.protocol + ' production chat completes through actual OMP', [assistant.status, assistant.content.includes('MODEL_PROTOCOL_OK')], ['done', true])
|
||||
check(item.protocol + ' actual chat reaches configured native server', requests.slice(before).some(request => request.protocol === item.protocol && request.stream && request.authorized))
|
||||
const actualRequest = requests.slice(before).find(request => request.protocol === item.protocol && request.stream)?.body
|
||||
if (item.extended) {
|
||||
check('large output budget reaches the actual Responses provider request', actualRequest.max_output_tokens, 128000)
|
||||
check('context accounting uses the configured 500K total', assistant.contextBreakdown?.contextWindow, 500000)
|
||||
const managedRoot = path.join(directory, 'home', '.omp', 'profiles')
|
||||
const yamls = await Promise.all((await fs.readdir(managedRoot)).map(async name => ({name,text:await fs.readFile(path.join(managedRoot, name, 'agent', 'models.yml'), 'utf8').catch(()=>'')})))
|
||||
const managed = yamls.find(item=>item.text.includes('gpt-6.1-sol'))
|
||||
check('real OMP profile uses 500K total and 128K output', !!managed && /contextWindow: 500000/.test(managed.text) && /maxTokens: 128000/.test(managed.text))
|
||||
const budgetConfig = await fs.readFile(path.join(managedRoot, managed.name, 'agent', 'config.yml'), 'utf8')
|
||||
check('OMP runtime config reserves output and sets the dynamic compression threshold', /reserveTokens: 128000/.test(budgetConfig) && /thresholdTokens: 372000/.test(budgetConfig))
|
||||
const executable = process.env.YCODE_OMP_PATH || path.join(process.env.LOCALAPPDATA, 'Ygent', 'omp.exe')
|
||||
const parsed = JSON.parse(require('node:child_process').execFileSync(executable, ['config','get','compaction.thresholdTokens','--json'], {encoding:'utf8',windowsHide:true,timeout:15000,env:{...process.env,OMP_PROFILE:managed.name,USERPROFILE:path.join(directory,'home'),HOME:path.join(directory,'home')}}))
|
||||
check('installed OMP loads the generated compression threshold', parsed.value ?? parsed, 372000)
|
||||
}
|
||||
check(item.protocol + ' actual request uses the automatically selected thinking strength', item.protocol === 'gemini'
|
||||
? actualRequest.generationConfig?.thinkingConfig?.thinkingBudget === 1024
|
||||
: (item.protocol === 'anthropic' ? actualRequest.output_config?.effort : item.protocol === 'responses' ? actualRequest.reasoning?.effort : actualRequest.reasoning_effort) === metadata.defaultEffort)
|
||||
check(item.protocol + ' native request retains context breakdown observations', !!assistant.contextBreakdown && assistant.contextBreakdown.scope === 'provider-request' && assistant.contextBreakdown.counts.system > 0)
|
||||
}
|
||||
async function edit(profile) {
|
||||
await read(`(()=>{const row=[...document.querySelectorAll('.models-settings-panel__row')].find(row=>row.querySelector('.models-settings-panel__row-name').textContent===${JSON.stringify(profile.name)});row.querySelector('button').click()})()`)
|
||||
await wait(`!!document.querySelector('.models-settings-panel__editor-overlay')&&!document.querySelector('.models-settings-panel__editor-save').disabled`, 'edit saved model')
|
||||
}
|
||||
const largeProfile = profiles.find(profile=>profile.id==='gpt-6.1-sol')
|
||||
await edit(largeProfile)
|
||||
check('reopening keeps the 500K and 128K budgets', await read(`[...document.querySelectorAll('.models-settings-panel__number-grid input')].map(node=>node.value)`), ['500000','128000'])
|
||||
await fill('.models-settings-panel__number-grid .models-settings-panel__field:nth-child(2) input', '64000')
|
||||
check('changing output dynamically recalculates input and keeps 15 percent compression headroom', await read(`document.querySelector('[data-context-budget]').textContent.includes('436,000 tokens · 自动压缩阈值:425,000')`))
|
||||
await fill('.models-settings-panel__number-grid .models-settings-panel__field:nth-child(2) input', '131072')
|
||||
await click('.models-settings-panel__editor-save')
|
||||
await wait(`document.querySelector('.model-ui-error')?.textContent.includes('128,000')`, 'catalog output ceiling validation')
|
||||
check('invalid output budget keeps the editor open', await read(`!!document.querySelector('.models-settings-panel__editor-overlay')`))
|
||||
check('invalid output budget preserves the saved model', (await read(`window.ycode.models.list()`)).models.find(model=>model.id===largeProfile.id).maxOutputTokens, 128000)
|
||||
await click('.models-settings-panel__editor-cancel')
|
||||
await step('existing-secret-list-does-not-downgrade-responses')
|
||||
const responsesProfile = profiles.find(profile => profile.id === 'gpt-5.4-pro')
|
||||
await edit(responsesProfile)
|
||||
check('editing never reveals saved key', await read(`document.querySelector('input[type="password"]').value`), '')
|
||||
await read(`[...document.querySelectorAll('.custom-model-id-row button')].find(node=>node.textContent.includes('获取模型')).click()`)
|
||||
await option(responsesProfile.model)
|
||||
await click('.models-settings-panel__editor-save')
|
||||
await wait(`!document.querySelector('.models-settings-panel__editor-overlay')`, 'unchanged model saved')
|
||||
check('fetching with saved key preserves verified Responses routing', (await read(`window.ycode.models.list()`)).models.find(model => model.id === responsesProfile.id).endpoint.api, 'openai-responses')
|
||||
|
||||
await step('manual-overrides-survive-reopen')
|
||||
const profile = profiles.find(profile => profile.id === 'gpt-5.4')
|
||||
await edit(profile)
|
||||
await fill('.models-settings-panel__number-grid input', '32768')
|
||||
await fill('.models-settings-panel__number-grid .models-settings-panel__field:nth-child(2) input', '4096')
|
||||
await read(`[...document.querySelectorAll('.models-settings-panel__checkbox-item')].find(node=>node.textContent.trim()==='图片输入').querySelector('input').click()`)
|
||||
await click('.models-settings-panel__editor-save')
|
||||
await wait(`!document.querySelector('.models-settings-panel__editor-overlay')`, 'manual overrides saved')
|
||||
const overridden = (await read(`window.ycode.models.list()`)).models.find(model => model.id === profile.id)
|
||||
check('manual total/output and image override are persisted with derived input', [overridden.maxInputTokens, overridden.maxOutputTokens, overridden.contextWindowTokens, overridden.supportsImages], [28672, 4096, 32768, false])
|
||||
await edit(overridden)
|
||||
check('reopening retains manual input/output limits', await read(`[...document.querySelectorAll('.models-settings-panel__number-grid input')].map(node=>node.value)`), ['32768', '4096'])
|
||||
await fill('.custom-model-id-combobox input', 'manually-entered-model')
|
||||
await wait(`!document.querySelector('.models-settings-panel__editor-save').disabled`, 'unknown metadata settled')
|
||||
check('model input remains freely editable', await read(`document.querySelector('.custom-model-id-combobox input').value`), 'manually-entered-model')
|
||||
check('unknown model keeps manual capability controls without metadata copy', await read(`!!document.querySelector('.models-settings-panel__advanced')&&!document.querySelector('.custom-model-metadata')`))
|
||||
await click('.models-settings-panel__editor-cancel')
|
||||
|
||||
await step('manual-model-without-models-api-or-key')
|
||||
const manualDomain = await upstream('openai', 'manual-unlisted', { noList: true, noKey: true })
|
||||
await read(`window.ycode.app.openSettings('model','new')`, main)
|
||||
await wait(`!!document.querySelector('.models-settings-panel__editor-overlay')`, 'edit saved model')
|
||||
await fill('input[placeholder*="api.example.com"]', manualDomain)
|
||||
await read(`[...document.querySelectorAll('.custom-model-id-row button')].find(node=>node.textContent.includes('获取模型')).click()`)
|
||||
await wait(`!!document.querySelector('.model-ui-error')`, 'missing models API feedback')
|
||||
await fill('.custom-model-id-combobox input', 'manual-unlisted')
|
||||
await wait(`!document.querySelector('.models-settings-panel__editor-save').disabled`, 'manual model ready')
|
||||
await click('.models-settings-panel__editor-save')
|
||||
await wait(`!document.querySelector('.models-settings-panel__editor-overlay')`, 'manual keyless save')
|
||||
const manual = (await read(`window.ycode.models.list()`)).models.find(model => model.id === 'manual-unlisted')
|
||||
check('manual model without models API saves a verified keyless route', [manual.authMode, manual.hasApiKey, manual.endpoint.api, manual.endpoint.verifiedBy], ['none', false, 'openai-completions', 'generation'])
|
||||
|
||||
await step('keyless-gemini-production-fallback')
|
||||
const keylessId = 'gemini-keyless-controlled'
|
||||
await read(`window.ycode.app.openSettings('model','new')`, main)
|
||||
await wait(`!!document.querySelector('.models-settings-panel__editor-overlay')`, 'keyless Gemini form')
|
||||
await fill('input[placeholder*="api.example.com"]', await upstream('gemini', keylessId, { noKey: true, noList: true }))
|
||||
await fill('.custom-model-id-combobox input', keylessId)
|
||||
await wait(`!document.querySelector('.models-settings-panel__editor-save').disabled`, 'keyless Gemini ready')
|
||||
await click('.models-settings-panel__editor-save')
|
||||
await wait(`!document.querySelector('.models-settings-panel__editor-overlay')`, 'keyless Gemini saved')
|
||||
const keylessSession = await read(`window.ycode.session.create('Keyless Gemini',{modelId:${JSON.stringify(keylessId)}})`, main)
|
||||
await read(`window.ycode.chat.send({sessionId:${JSON.stringify(keylessSession.id)},text:'Reply briefly.'})`, main)
|
||||
await wait(`window.ycode.session.messages(${JSON.stringify(keylessSession.id)}).then(rows=>rows.some(row=>row.role==='assistant'&&['done','error'].includes(row.status)))`, 'keyless Gemini turn', main)
|
||||
const keylessReply = (await read(`window.ycode.session.messages(${JSON.stringify(keylessSession.id)})`, main)).filter(row => row.role === 'assistant').at(-1)
|
||||
check('keyless Gemini completes through production native HTTP gateway', [keylessReply.status, keylessReply.content.includes('MODEL_PROTOCOL_OK')], ['done', true])
|
||||
|
||||
await step('clear-saved-key-without-an-auth-switch')
|
||||
const clearable = {}, clearId = 'clearable-controlled'
|
||||
await read(`window.ycode.app.openSettings('model','new')`, main)
|
||||
await wait(`!!document.querySelector('.models-settings-panel__editor-overlay')`, 'clearable model form')
|
||||
await fill('input[placeholder*="api.example.com"]', await upstream('openai', clearId, clearable))
|
||||
await fill('input[type="password"]', 'controlled-model-key')
|
||||
await fill('.custom-model-id-combobox input', clearId)
|
||||
await wait(`!document.querySelector('.models-settings-panel__editor-save').disabled`, 'clearable model ready')
|
||||
await click('.models-settings-panel__editor-save')
|
||||
await wait(`!document.querySelector('.models-settings-panel__editor-overlay')`, 'clearable model saved')
|
||||
const clearProfile = (await read(`window.ycode.models.list()`)).models.find(model => model.id === clearId)
|
||||
await edit(clearProfile)
|
||||
clearable.noKey = true
|
||||
await read(`document.querySelector('input[type="password"]').dispatchEvent(new KeyboardEvent('keydown',{key:'Delete',bubbles:true}))`)
|
||||
await fill('.custom-model-id-combobox input', clearId + '-renamed')
|
||||
await wait(`!document.querySelector('.models-settings-panel__editor-save').disabled`, 'clear and rename ready')
|
||||
await click('.models-settings-panel__editor-save')
|
||||
await wait(`!document.querySelector('.models-settings-panel__editor-overlay')`, 'cleared key persisted')
|
||||
const cleared = (await read(`window.ycode.models.list()`)).models.find(model => model.id === clearId + '-renamed')
|
||||
check('clearing the saved key remains keyless after changing the model ID', [cleared.authMode, cleared.hasApiKey], ['none', false])
|
||||
|
||||
await step('stale-model-fetch-and-connection-test')
|
||||
await read(`window.ycode.app.openSettings('model','new')`, main)
|
||||
await wait(`!!document.querySelector('.models-settings-panel__editor-overlay')`, 'race test form')
|
||||
const slowList = { listDelay: 300 }, slowDomain = await upstream('openai', 'old-list', slowList)
|
||||
await fill('input[placeholder*="api.example.com"]', slowDomain); await fill('input[type="password"]', 'controlled-model-key')
|
||||
await read(`[...document.querySelectorAll('.custom-model-id-row button')].find(node=>node.textContent.includes('获取模型')).click()`)
|
||||
while (!slowList.listStarted) await pause(10)
|
||||
const slowProbe = { probeDelay: 300 }, probeDomain = await upstream('openai', 'old-probe', slowProbe)
|
||||
await fill('input[placeholder*="api.example.com"]', probeDomain)
|
||||
while (!slowList.listEnded) await pause(10)
|
||||
check('a late old-domain model list cannot refill the new-domain picker', await read(`document.querySelector('.custom-model-id-toggle').disabled`))
|
||||
await fill('.custom-model-id-combobox input', 'old-probe')
|
||||
await wait(`!document.querySelector('.models-settings-panel__editor-save').disabled`, 'initial probe model ready')
|
||||
await click('.models-settings-panel__editor-test-connection')
|
||||
while (!slowProbe.probeStarted) await pause(10)
|
||||
await fill('.custom-model-id-combobox input', 'new-probe')
|
||||
while (!slowProbe.probeEnded) await pause(10)
|
||||
await wait(`!document.querySelector('.models-settings-panel__editor-save').disabled`, 'late probe settled')
|
||||
const beforeProbeSave = requests.length
|
||||
await click('.models-settings-panel__editor-save')
|
||||
await wait(`!document.querySelector('.models-settings-panel__editor-overlay')`, 'new model verified and saved')
|
||||
check('a late test result cannot mark a changed model verified', requests.slice(beforeProbeSave).some(request => request.body.model === 'new-probe' && !request.stream))
|
||||
|
||||
await step('light-dark-form-and-menu')
|
||||
await read(`window.ycode.app.openSettings('appearance')`, main)
|
||||
await wait(`!!document.querySelector('.appearance-card--dark')`, 'appearance page')
|
||||
await click('.appearance-card--dark')
|
||||
await wait(`document.documentElement.dataset.theme==='dark'`, 'dark theme persisted')
|
||||
await read(`window.ycode.app.openSettings('model','new')`, main)
|
||||
await wait(`!!document.querySelector('.models-settings-panel__editor-overlay')`, 'dark model form')
|
||||
check('supplier label no longer claims OpenAI only', await read(`!document.querySelector('.models-settings-panel__editor-body').textContent.includes('仅支持 OpenAI')`))
|
||||
await shot('custom-editor-empty-dark')
|
||||
await fill('input[placeholder*="api.example.com"]', await upstream('openai', 'gpt-5.4', { emptyList: true }))
|
||||
await fill('input[type="password"]', 'controlled-model-key')
|
||||
await read(`[...document.querySelectorAll('.custom-model-id-row button')].find(node=>node.textContent.includes('获取模型')).click()`)
|
||||
await wait(`!!document.querySelector('.model-ui-toast')?.textContent.includes('空列表')`, 'empty model list feedback')
|
||||
check('empty lists allow manual input', await read(`!document.querySelector('.custom-model-id-combobox input').disabled`))
|
||||
await fill('input[placeholder*="api.example.com"]', await upstream('gemini', 'gemini-2.5-flash'))
|
||||
await read(`[...document.querySelectorAll('.custom-model-id-row button')].find(node=>node.textContent.includes('获取模型')).click()`)
|
||||
await wait(`!!document.querySelector('[role="option"]')`, 'dark model dropdown')
|
||||
await shot('custom-editor-model-list-dark')
|
||||
check('the dropdown aligns with the full input width', await read(`(()=>{const input=document.querySelector('.custom-model-id-combobox').getBoundingClientRect(),list=document.querySelector('.custom-model-options').getBoundingClientRect();return Math.abs(input.x-list.x)<1&&Math.abs(input.width-list.width)<1})()`))
|
||||
check('the settled model dropdown is visible and opaque', await read(`(()=>{const style=getComputedStyle(document.querySelector('.custom-model-options'));return style.visibility==='visible'&&Number(style.opacity)>.99})()`))
|
||||
await option('gemini-2.5-flash')
|
||||
await fill('.custom-model-id-combobox input', 'gemini-2.5-flash')
|
||||
await wait(`!document.querySelector('.models-settings-panel__editor-save').disabled`, 'dark metadata ready')
|
||||
await read(`document.querySelector('.models-settings-panel__editor-body').scrollTop=10000`)
|
||||
await shot('custom-editor-metadata-dark')
|
||||
await click('.models-settings-panel__editor-cancel')
|
||||
await step('metadata-update-in-about')
|
||||
await read(`window.ycode.app.openSettings('helpFeedback')`, main)
|
||||
await wait(`!!document.querySelector('#settings-about-model-metadata button')`, 'metadata update in About')
|
||||
check('About contains an enabled metadata update action', await read(`!document.querySelector('#settings-about-model-metadata button').disabled`))
|
||||
await shot('about-model-metadata-dark')
|
||||
const originalFetch = globalThis.fetch
|
||||
let refreshRequests = 0
|
||||
globalThis.fetch = async (...args) => {
|
||||
if (String(args[0]) !== 'https://models.dev/api.json') return originalFetch(...args)
|
||||
refreshRequests++
|
||||
return new Response(await fs.readFile(path.join(directory, 'build/resources/model-metadata/models.dev.json')), { headers: { 'content-type': 'application/json' } })
|
||||
}
|
||||
try {
|
||||
await click('#settings-about-model-metadata button')
|
||||
await wait(`document.querySelector('.help-feedback-panel__metadata-feedback')?.textContent==='模型元数据已更新'`, 'metadata refreshed through About')
|
||||
check('About update calls the real metadata service once', refreshRequests, 1)
|
||||
} finally { globalThis.fetch = originalFetch }
|
||||
check('no renderer errors', rendererErrors, [])
|
||||
const report = { result: checks.every(item => item.pass) ? 'PASS' : 'FAIL', scope: 'Real isolated built UI/preload/IPC, bundled metadata, encrypted storage and actual native OMP against loopback HTTP; no external model calls.', source, profiles, requests, checks, rendererErrors }
|
||||
await fs.writeFile(path.join(directory, 'report.json'), JSON.stringify(report, null, 2))
|
||||
console.log('CUSTOM_MODEL_UI_' + report.result + ' ' + directory)
|
||||
for (const failure of checks.filter(item => !item.pass)) console.log('CUSTOM_MODEL_UI_FAILURE ' + JSON.stringify(failure))
|
||||
for (const server of servers) server.closeAllConnections()
|
||||
app.exit(report.result === 'PASS' ? 0 : 1)
|
||||
})().catch(async error => {
|
||||
await fs.writeFile(path.join(directory, 'failure.json'), JSON.stringify({ phase, error: String(error), stack: error.stack, checks, requests, rendererErrors }, null, 2)); console.error(error)
|
||||
if (settings && !settings.isDestroyed()) await shot('failure').catch(() => undefined)
|
||||
for (const server of servers) server.closeAllConnections()
|
||||
app.exit(1)
|
||||
})
|
||||
}
|
||||
@@ -15,7 +15,7 @@ if (!process.versions.electron) {
|
||||
assert.equal(hash(await fs.readFile(assetPath)), assetSha256, 'REA robot asset changed')
|
||||
const directory = await fs.mkdtemp(path.resolve('.tmp/desktop-appearance-'))
|
||||
await Promise.all(['data', 'home'].map(name => fs.mkdir(path.join(directory, name))))
|
||||
const env = { ...process.env, YCODE_QA_DIRECTORY: directory, YCODE_USER_DATA_DIR: path.join(directory, 'data'), YCODE_OMP_HOME: path.join(directory, 'home'), YCODE_QA_OFFSCREEN: '0' }
|
||||
const env = { ...process.env, YCODE_QA_DIRECTORY: directory, YCODE_USER_DATA_DIR: path.join(directory, 'data'), YCODE_QA_OFFSCREEN: '0' }
|
||||
delete env.ELECTRON_RUN_AS_NODE; delete env.ELECTRON_RENDERER_URL; delete env.YCODE_DISABLE_GPU; delete env.YCODE_DISABLE_GPU_SANDBOX
|
||||
const child = require('node:child_process').spawn(require('electron'), ['--no-sandbox', '--disable-gpu-sandbox', '--use-angle=swiftshader', '--disable-gpu-compositing', __filename],
|
||||
{ env, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] })
|
||||
|
||||
@@ -1,78 +0,0 @@
|
||||
// Production renderer/preload/IPC -> actual OMP -> controlled localhost tools -> expert packages.
|
||||
const fs=require('node:fs/promises'),path=require('node:path'),assert=require('node:assert/strict'),crypto=require('node:crypto')
|
||||
const {pathToFileURL}=require('node:url'),pause=ms=>new Promise(resolve=>setTimeout(resolve,ms))
|
||||
const ID='flow-local-expert',CREATE='帮我创建一个 XXX 专家,擅长 XXXXX。我的经验是:[请补充你的行业背景、相关经验]',EDIT=`帮我修改专家:[${ID}],增加/优化[请补充你希望新增/优化的技能或知识领域等]方面的能力`
|
||||
const ROLE='EXPERT_FLOW_ROLE_V1: Analyse supplied facts and identify missing evidence.',UPDATED='EXPERT_FLOW_ROLE_V2: Analyse supplied facts, identify missing evidence, and verify conclusions.'
|
||||
function sourcePackage(){const p={name:ID,version:'1.0.0',description:'Controlled source-schema expert for isolated product integration.',expertType:'agent',agentName:ID,plugin:ID,agents:[`./agents/${ID}.md`],displayName:{en:'Flow Expert',zh:'验收专家'},profession:{en:'Acceptance Analyst',zh:'验收分析师'},displayDescription:{en:'Controlled analyst who follows the registered workflow.',zh:'这位分析师依据明确输入整理事实检查证据解释限制核对输出内容并在完成任务后准确说明结果与待确认事项'},defaultInitPrompt:{en:'Analyse the supplied facts.',zh:'请分析提供的事实。'},tags:[1,2,3].map(n=>({en:'Capability '+n,zh:'能力'+n})),quickPrompts:[{en:'Analyse the supplied facts.',zh:'请分析提供的事实。'},...[2,3].map(n=>({en:'Question '+n,zh:'问题'+n}))]};return{'.codebuddy-plugin/plugin.json':JSON.stringify(p,null,2),[`agents/${ID}.md`]:`---\nname: ${ID}\ndescription: Controlled local acceptance analyst\ndisplayName:\n en: Flow Expert\n zh: 验收专家\nprofession:\n en: Acceptance Analyst\n zh: 验收分析师\nmaxTurns: 50\n---\n\n${ROLE}\n`,'README.md':'# Controlled local expert\nNo remote service or generated avatar is claimed.\n'}}
|
||||
if(!process.versions.electron){
|
||||
const {spawn}=require('node:child_process')
|
||||
;(async()=>{
|
||||
const directory=await fs.mkdtemp(path.resolve('.tmp/expert-flows-'));for(const p of['data','home','sessions','workspace'])await fs.mkdir(path.join(directory,p))
|
||||
const omp=process.env.YCODE_OMP_PATH||path.join(process.env.LOCALAPPDATA,'Ygent','omp.exe');assert.ok((await fs.stat(omp)).isFile())
|
||||
const env={...process.env,YCODE_EXPERT_FLOW_DIRECTORY:directory,YCODE_USER_DATA_DIR:path.join(directory,'data'),YCODE_OMP_HOME:path.join(directory,'home'),YCODE_OMP_PATH:omp,YCODE_QA_OFFSCREEN:'1',YCODE_DISABLE_GPU:'1'}
|
||||
delete env.ELECTRON_RUN_AS_NODE;delete env.ELECTRON_RENDERER_URL;for(const key of Object.keys(env))if(key.startsWith('YCODE_MODEL_'))delete env[key]
|
||||
assert.equal(env.ELECTRON_RUN_AS_NODE,undefined,'Must not recursively run the Node branch inside Electron')
|
||||
for(const stage of['run','restart']){
|
||||
const code=await new Promise(resolve=>{const child=spawn(require('electron'),['--no-sandbox','--disable-gpu-sandbox',__filename],{env:{...env,YCODE_EXPERT_FLOW_STAGE:stage},windowsHide:true,stdio:['ignore','pipe','pipe']});let output='';child.stdout.on('data',c=>{output+=c;if(c.toString().includes('EXPERT_FLOW_'))process.stdout.write(c)});child.stderr.on('data',c=>output+=c);const timer=setTimeout(()=>child.kill(),180000);child.once('exit',async code=>{clearTimeout(timer);await fs.writeFile(path.join(directory,stage+'-process.log'),output);resolve(code??1)})})
|
||||
const failure=await fs.stat(path.join(directory,stage+'-failure.json')).then(()=>true,()=>false),report=await fs.readFile(path.join(directory,stage+'-report.json'),'utf8').then(JSON.parse,()=>null)
|
||||
if(code||failure||report?.result!=='PASS'||!report.checks?.length){console.log(JSON.stringify({directory,stage,result:'FAIL'}));process.exitCode=1;return}
|
||||
}
|
||||
const reports=await Promise.all(['run','restart'].map(async stage=>({stage,...JSON.parse(await fs.readFile(path.join(directory,stage+'-report.json'),'utf8'))})))
|
||||
await fs.writeFile(path.join(directory,'report.json'),JSON.stringify({result:'PASS',reports,limitations:['Controlled localhost upstream returns real OMP tool calls; no remote account or model cost.','Production UI DOM events are used, no store mutation or direct expert package IPC writes.','Whole-state pixel parity, avatars and team execution are outside this integration check.']},null,2))
|
||||
console.log(JSON.stringify({directory,result:'PASS',checks:reports.map(r=>({stage:r.stage,count:r.checks.length}))}))
|
||||
})().catch(e=>{console.error(e);process.exitCode=1})
|
||||
}else{
|
||||
const{app,BrowserWindow}=require('electron'),{createServer}=require('node:http'),directory=process.env.YCODE_EXPERT_FLOW_DIRECTORY,stage=process.env.YCODE_EXPERT_FLOW_STAGE
|
||||
assert.ok(directory&&path.relative(path.resolve('.tmp'),directory).startsWith('expert-flows-'));assert.ok(['run','restart'].includes(stage))
|
||||
app.disableHardwareAcceleration();app.setPath('userData',path.join(directory,'data'));app.setPath('home',path.join(directory,'home'));app.setPath('sessionData',path.join(directory,'sessions'))
|
||||
let main,upstream,phase='boot';const checks=[],requests=[]
|
||||
const check=(label,actual,expected=true)=>{assert.deepEqual(actual,expected,label);checks.push({label,pass:true})}
|
||||
async function read(expression){const r=await main.webContents.executeJavaScript('(async()=>{try{return{ok:true,value:await ('+expression+')}}catch(e){return{ok:false,error:String(e),stack:e.stack}}})()');if(!r.ok)throw Error(r.error+'\n'+r.stack);return r.value}
|
||||
async function wait(expression,label,timeout=30000){const start=Date.now();while(Date.now()-start<timeout){const value=await read(expression);if(value)return value;await pause(40)}throw Error('Timeout: '+label)}
|
||||
async function click(selector){if(selector.startsWith('.cr-send-button')){const registry=await read('window.ycode.models.list()');assert.ok(registry.models.every(m=>new URL(m.baseUrl).hostname==='127.0.0.1'),'QA prohibits sending with any non-localhost model profile')}await read(`(()=>{const e=document.querySelector(${JSON.stringify(selector)});if(!e||e.disabled)throw Error('Missing control '+${JSON.stringify(selector)});e.click()})()`);await pause(70)}
|
||||
async function textClick(selector,text,exact=true){await read(`(()=>{const t=${JSON.stringify(text)},e=[...document.querySelectorAll(${JSON.stringify(selector)})].find(e=>${exact?'e.textContent.trim()===t':'e.textContent.includes(t)'});if(!e||e.disabled)throw Error('Missing text '+t);e.click()})()`);await pause(70)}
|
||||
async function fill(selector,value){await read(`(()=>{const e=document.querySelector(${JSON.stringify(selector)});if(!e||e.disabled)throw Error('Missing editable input');Object.getOwnPropertyDescriptor(e.tagName==='TEXTAREA'?HTMLTextAreaElement.prototype:HTMLInputElement.prototype,'value').set.call(e,${JSON.stringify(value)});e.dispatchEvent(new Event('input',{bubbles:true}))})()`);await pause(50)}
|
||||
async function hoverAddMenuItem(id){const selector='.cr-add-menu__popover [data-add-menu-id="'+id+'"]';await wait('!!document.querySelector('+JSON.stringify(selector)+')','source add menu row');const point=await read('(()=>{const b=document.querySelector('+JSON.stringify(selector)+').getBoundingClientRect();return{x:Math.round(b.x+b.width/2),y:Math.round(b.y+b.height/2)}})()');const trigger=await read('(()=>{const b=document.querySelector(".wb-composer__add-button").getBoundingClientRect();return{x:Math.round(b.x+b.width/2),y:Math.round(b.y+b.height/2)}})()');main.webContents.sendInputEvent({type:'mouseMove',...trigger});await pause(25);main.webContents.sendInputEvent({type:'mouseMove',...point});await pause(220)}
|
||||
async function shot(name){await read('document.fonts.ready.then(()=>true)');await pause(120);await fs.writeFile(path.join(directory,stage+'-'+name+'.png'),(await main.webContents.capturePage(undefined,{stayHidden:true,stayAwake:true})).toPNG())}
|
||||
async function step(name){phase=name;await fs.writeFile(path.join(directory,stage+'-phase.txt'),name);console.log('EXPERT_FLOW_STEP '+stage+' '+name)}
|
||||
async function boot(){await import(pathToFileURL(path.resolve('out/main/index.mjs')).href);await app.whenReady();for(let i=0;i<300;i++){main=BrowserWindow.getAllWindows().find(w=>w.getTitle()==='YCode');if(main&&!main.webContents.isLoading())break;await pause(30)}assert.ok(main);main.setContentSize(1336,901);await wait('!!window.ycode&&!!document.querySelector(".wb-composer__input")','production UI');const info=await read('window.ycode.app.info()');check('isolated production configDir',path.resolve(info.configDir),path.join(directory,'data'));check('actual OMP available',info.omp.available);if(stage==='run'){const registry=await read('window.ycode.models.list()');for(const model of registry.models)await read(`window.ycode.models.remove(${JSON.stringify(model.id)})`);const empty=await read('window.ycode.models.list()');check('real production models.remove clears isolated legacy migration',empty.models.length,0);check('no selected model remains after actual removal',empty.selectedModelId,null)}}
|
||||
function complete(res,model,content,tool){res.writeHead(200,{'content-type':'text/event-stream'});const chunk=delta=>({id:'expert-flow-response-'+requests.length,object:'chat.completion.chunk',created:1700000000,model,choices:[{index:0,delta,finish_reason:null}]});res.write('data: '+JSON.stringify(chunk({role:'assistant',...(tool?{tool_calls:[{index:0,id:'expert-flow-'+requests.length,type:'function',function:{name:tool.name,arguments:JSON.stringify(tool.args)}}]}:{content})}))+'\n\n');const final=chunk({});final.choices[0].finish_reason=tool?'tool_calls':'stop';res.write('data: '+JSON.stringify(final)+'\n\ndata: [DONE]\n\n');res.end()}
|
||||
async function startUpstream(){upstream=createServer(async(req,res)=>{try{assert.equal(req.url,'/v1/chat/completions');let raw='';for await(const c of req)raw+=c;const body=JSON.parse(raw),names=(body.tools??[]).map(t=>t.function.name),messages=body.messages??[],lastUser=messages.findLastIndex(m=>m.role==='user'),user=JSON.stringify(messages[lastUser]?.content??''),all=JSON.stringify(messages),results=messages.slice(lastUser+1).filter(m=>m.role==='tool'),mode=user.includes('帮我修改专家')?'edit':user.includes('帮我创建一个 XXX 专家')?'create':user.includes('EXPERT_FLOW')?'role':'unknown'
|
||||
const observed={route:req.url,stream:body.stream,mode,toolResults:results.length,tools:names,hasOriginalManager:all.includes('WorkBuddy 专家包管理器')&&all.includes('scripts/register_expert.py'),hasHostAdapter:all.includes('ycode-expert-manager-host'),hasRoleV1:all.includes(ROLE),hasRoleV2:all.includes(UPDATED),hasActualToolResult:results.length>0};requests.push(observed);assert.ok(requests.length<=40,'Unexpected OMP request loop')
|
||||
if(!body.stream){res.writeHead(200,{'content-type':'application/json'});res.end(JSON.stringify({id:'test',object:'chat.completion',choices:[{index:0,message:{role:'assistant',content:'OK'},finish_reason:'stop'}]}));return}
|
||||
if(mode==='role'){complete(res,body.model,stage==='restart'?'EXPERT_FLOW_RESTART_OK':'EXPERT_FLOW_ROLE_OK');return}
|
||||
assert.ok(['create','edit'].includes(mode),'Unknown controlled task');assert.ok(observed.hasOriginalManager&&observed.hasHostAdapter,'Manager source/adapter missing');assert.ok(names.includes('ycode_expert_package'),'Real package tool absent')
|
||||
const steps=mode==='create'?[{name:'ycode_read_skill_resource',args:{id:'builtin:expert-manager',path:'references/plugin-json-spec.md'}},{name:'ycode_read_skill_resource',args:{id:'builtin:expert-manager',path:'references/agent-md-spec.md'}},{name:'ycode_expert_package',args:{id:ID,action:'read'}},{name:'ycode_expert_package',args:{id:ID,action:'write',files:sourcePackage()}},{name:'ycode_expert_package',args:{id:ID,action:'validate'}},{name:'ycode_expert_package',args:{id:ID,action:'register'}}]:[{name:'ycode_expert_package',args:{id:ID,action:'read'}},{name:'ycode_expert_package',args:{id:ID,action:'write',files:{[`agents/${ID}.md`]:sourcePackage()[`agents/${ID}.md`].replace(ROLE,UPDATED)}}},{name:'ycode_expert_package',args:{id:ID,action:'validate'}},{name:'ycode_expert_package',args:{id:ID,action:'register'}}]
|
||||
const next=steps[results.length];observed.nextAction=next?.args.action??next?.args.path??'complete';complete(res,body.model,mode==='create'?'EXPERT_FLOW_CREATED':'EXPERT_FLOW_EDITED',next)
|
||||
}catch(error){requests.push({error:String(error)});if(!res.headersSent)res.writeHead(500);res.end('Controlled upstream failure')}});await new Promise(resolve=>upstream.listen(0,'127.0.0.1',resolve))}
|
||||
async function configure(retrySessionId){const profile={id:'expert-flow-model',name:'Expert Flow Fixture',providerId:'custom',model:'expert-flow-fixture',baseUrl:`http://127.0.0.1:${upstream.address().port}/v1`,apiKey:'',authMode:'none',supportsToolCall:true,supportsImages:false,maxInputTokens:64000,maxOutputTokens:4000,maxToolRounds:20};await read(`window.ycode.models.save(${JSON.stringify(profile)},${stage==='restart'})`);await read('window.ycode.models.select("expert-flow-model")');if(stage==='run')await read(`window.ycode.workspace.attach(${JSON.stringify(path.join(directory,'workspace'))})`);if(retrySessionId){await read(`window.ycode.session.updateContext(${JSON.stringify(retrySessionId)},{modelId:'expert-flow-model'})`);check('actual failed session model context configured',(await read('window.ycode.session.list()')).find(s=>s.id===retrySessionId).modelId,'expert-flow-model')}else{main.webContents.reload();await pause(400);await wait('!!document.querySelector(".wb-composer__input")','profile UI reload')}}
|
||||
async function centerMine(){await textClick('.sidebar__tab','专家·技能·连接器');await wait('!!document.querySelector(".yc-capability-center")','center');if(!(await read('!!document.querySelector("[data-expert-page=mine]")'))){if(await read('[...document.querySelectorAll(".um-tab")].some(e=>e.textContent.trim()==="专家")'))await textClick('.um-tab','专家');await textClick('button','我的专家')}await wait('!!document.querySelector("[data-expert-page=mine]")','source mine page')}
|
||||
async function terminal(id,expected){const start=Date.now();let approval=0;while(Date.now()-start<90000){if(await read('[...document.querySelectorAll("button")].some(e=>!e.disabled&&e.textContent.includes("允许一次"))')){await shot('approval-'+(++approval));await textClick('button','允许一次',false)}const messages=await read(`window.ycode.session.messages(${JSON.stringify(id)})`),assistant=messages.findLast(m=>m.role==='assistant');if(assistant&&assistant.status!=='streaming'){check('terminal actual OMP '+expected,assistant.status,'done');check('actual model result '+expected,assistant.content,expected);return{assistant,messages}}await pause(80)}throw Error('Timeout actual OMP '+expected)}
|
||||
async function sendNew(expected){const ids=(await read('window.ycode.session.list()')).map(s=>s.id);await click('.cr-send-button:not(.cr-send-button--stop)');const created=await wait(`window.ycode.session.list().then(s=>s.find(x=>!${JSON.stringify(ids)}.includes(x.id)))`,'new UI-created session');return{session:created,...await terminal(created.id,expected)}}
|
||||
async function summon(expectNew=true){await wait(`!!document.querySelector('.ec-cr-card')&&[...document.querySelectorAll('.ec-cr-card')].some(e=>e.textContent.includes('验收分析师'))`,'real source CreatedExpertsPanel card');const point=await read(`(()=>{const r=document.querySelector('.ec-cr-card').getBoundingClientRect();return{x:Math.round(r.right-40),y:Math.round(r.top+30)}})()`);main.webContents.sendInputEvent({type:'mouseMove',...point});await pause(200);await wait(`(()=>{const e=[...document.querySelectorAll('.ec-cr-card button')].find(e=>e.textContent.trim()==='召唤');if(!e||e.disabled)return false;for(let n=e;n&&n!==document.body;n=n.parentElement){const s=getComputedStyle(n);if(s.display==='none'||s.visibility==='hidden'||Number(s.opacity)===0)return false}return e.getBoundingClientRect().width>0})()`,'source summon action visible after native hover');check('source summon action revealed by native hover',true);await shot('my-experts-card');await textClick('.ec-cr-card button','召唤');await wait(`!!document.querySelector('[data-active-expert="${ID}"]')`,'expert bound to new draft');if(expectNew)check('summon selects unsent draft',await read('!document.querySelector(".sidebar__item[data-selected=true]")'));await shot('summoned-draft')}
|
||||
async function cleanup(){if(upstream){upstream.closeAllConnections();await new Promise(resolve=>upstream.close(resolve))}}
|
||||
;(async()=>{
|
||||
await boot();await startUpstream();if(stage==='restart')await configure();const buildSha256=crypto.createHash('sha256').update(await fs.readFile('out/main/index.mjs')).digest('hex')
|
||||
if(stage==='restart'){
|
||||
const prior=JSON.parse(await fs.readFile(path.join(directory,'run-report.json'),'utf8'));await step('restart-history');const experts=await read('window.ycode.experts.list()');check('package discovered after real app restart',experts.some(e=>e.id===ID&&!e.error));const history=await read(`window.ycode.session.messages(${JSON.stringify(prior.roleSessionId)})`);check('prior assistant identity persists',history.findLast(m=>m.role==='assistant').assistantDisplay?.id,ID);check('prior role reply persists',history.findLast(m=>m.role==='assistant').content,'EXPERT_FLOW_ROLE_OK')
|
||||
await click(`[data-session-id="${prior.roleSessionId}"]`);await wait('[...document.querySelectorAll(".cr-agent__name")].some(e=>e.textContent==="验收分析师")','restored source chat identity');await shot('history-identity')
|
||||
await centerMine();await summon();await fill('.wb-composer__input','EXPERT_FLOW_RESTART_VERIFY');const role=await sendNew('EXPERT_FLOW_RESTART_OK');check('updated role reaches actual upstream after restart',requests.some(r=>r.mode==='role'&&r.hasRoleV2));check('new reply persists correct identity',role.assistant.assistantDisplay?.id,ID);await shot('new-reply-identity');await fs.writeFile(path.join(directory,stage+'-report.json'),JSON.stringify({result:'PASS',checks,requests,buildSha256,sessionId:role.session.id},null,2))
|
||||
}else{
|
||||
await step('source-create-entry');await centerMine();await shot('mine-empty');await textClick('button','创建专家');await wait(`document.querySelector('.wb-composer__input')?.value===${JSON.stringify(CREATE)}`,'original create prompt after owner commit');check('original create prompt',await read('document.querySelector(".wb-composer__input").value'),CREATE);check('original manager structured skill chip',await read('!!document.querySelector(\'[data-skill-uri="skill://expert-manager"]\')'));await shot('create-source-draft')
|
||||
await step('actual-no-model-preflight');check('controlled model remains unconfigured before first send',(await read('window.ycode.models.list()')).models.some(m=>m.id==='expert-flow-model'),false);const beforeFailure=(await read('window.ycode.session.list()')).map(s=>s.id);await click('.cr-send-button:not(.cr-send-button--stop)');const failedSession=await wait(`window.ycode.session.list().then(s=>s.find(x=>!${JSON.stringify(beforeFailure)}.includes(x.id)))`,'real session:create before failed chat:send');await wait(`document.querySelector('[role=alert]')?.textContent.includes('请先添加并选择自定义模型')`,'real missing-model preflight rejection');check('failed session becomes actual visible owner',await read(`document.querySelector('.sidebar__item[data-selected=true]')?.dataset.sessionId`),failedSession.id);check('preflight failure retains exact create text',await read('document.querySelector(".wb-composer__input").value'),CREATE);check('preflight failure retains manager chip for new session owner',await read('!!document.querySelector(\'[data-skill-uri="skill://expert-manager"]\')'));check('preflight stores zero conversation messages',(await read(`window.ycode.session.messages(${JSON.stringify(failedSession.id)})`)).length,0);check('preflight makes no upstream model request',requests.length,0);await shot('no-model-preflight-retained')
|
||||
await configure(failedSession.id);check('model setup keeps same owner draft text',await read('document.querySelector(".wb-composer__input").value'),CREATE);check('model setup keeps same owner skill chip',await read('!!document.querySelector(\'[data-skill-uri="skill://expert-manager"]\')'))
|
||||
await step('actual-omp-create');await click('.cr-send-button:not(.cr-send-button--stop)');const creation={session:failedSession,...await terminal(failedSession.id,'EXPERT_FLOW_CREATED')};check('successful retry reuses actual failed session',(await read('window.ycode.session.list()')).length,beforeFailure.length+1);const creationTools=creation.assistant.tools.filter(t=>t.name==='ycode_expert_package');check('actual OMP package actions order',creationTools.map(t=>t.args.action),['read','write','validate','register']);check('create skill URI persisted',creation.messages[0].skills[0].uri,'skill://expert-manager');check('original full manager reached actual upstream',requests.some(r=>r.mode==='create'&&r.hasOriginalManager&&r.hasHostAdapter));check('actual register tool returned success',creationTools.at(-1).output.includes('"success":true'));await shot('created-chat')
|
||||
const root=path.join(directory,'data','plugins','marketplaces','my-experts'),market=JSON.parse(await fs.readFile(path.join(root,'.codebuddy-plugin','marketplace.json'),'utf8'));check('actual marketplace package registered',market.plugins.some(p=>p.name===ID&&p.source===`./plugins/${ID}`));check('actual package session marker',await fs.readFile(path.join(root,'plugins',ID,'.created-by-session'),'utf8'),creation.session.id)
|
||||
await step('source-card-summon');await centerMine();await summon();await fill('.wb-composer__input','EXPERT_FLOW_ROLE_VERIFY');const role=await sendNew('EXPERT_FLOW_ROLE_OK');check('real session expert binding',role.session.expertId,ID);check('expert role enters real OMP upstream',requests.some(r=>r.mode==='role'&&r.hasRoleV1));check('actual assistant identity',role.assistant.assistantDisplay?.id,ID);await wait('[...document.querySelectorAll(".cr-agent__name")].some(e=>e.textContent==="验收分析师")','source assistant header');await shot('role-chat')
|
||||
await step('source-plus-menu-interactions');await fs.writeFile(path.join(directory,'workspace','reference-notes.md'),'# Controlled reference fixture');await click('.wb-composer__add-button');await wait('!!document.querySelector("[data-add-menu-id=reference-files]")','real workspace references loaded');check('source plus trigger expands actual parent',await read('document.querySelector(".wb-composer__add-button").getAttribute("aria-expanded")'),'true');check('source plus parent width',await read('Math.round(document.querySelector(".cr-add-menu__popover").getBoundingClientRect().width)'),200);check('source plus menu row height',await read('Math.round(document.querySelector(".cr-add-menu__popover [data-add-menu-id=experts]").getBoundingClientRect().height)'),32);await hoverAddMenuItem('add-files');await wait('!!document.querySelector(".cr-files-submenu")','native files submenu');check('files hover keeps parent open',await read('!!document.querySelector(".cr-add-menu__popover")'));await hoverAddMenuItem('mode');await wait('document.querySelectorAll(".cr-mode-submenu .wb-switch__input").length===2','source mode switches');await click('.cr-mode-submenu .wb-switch__input');await wait(`window.ycode.session.list().then(s=>s.find(x=>x.id===${JSON.stringify(role.session.id)})?.agentMode==='plan')`,'native mode persists plan');check('Plan clears expert binding',(await read(`window.ycode.session.list()`)).find(s=>s.id===role.session.id).expertId,null);check('mode toggle keeps parent open',await read('!!document.querySelector(".cr-add-menu__popover")'));await click('.cr-mode-submenu .wb-switch__input');await wait(`window.ycode.session.list().then(s=>s.find(x=>x.id===${JSON.stringify(role.session.id)})?.agentMode==='craft')`,'source same Plan toggle restores craft');await hoverAddMenuItem('skills');await wait('!!document.querySelector(".yc-skill-picker[data-embedded] input")','embedded skill submenu');check('skill submenu mounted inside source overlay',await read('!!document.querySelector(".cr-sub-menu__overlay .yc-skill-picker")'));await fill('.yc-skill-picker input','NO_MATCH');main.webContents.sendInputEvent({type:'keyDown',keyCode:'ESC'});main.webContents.sendInputEvent({type:'keyUp',keyCode:'ESC'});await wait('document.querySelector(".yc-skill-picker input")?.value===""','Escape clears search without dismissing parent');check('nonempty search Escape retains parent',await read('!!document.querySelector(".cr-add-menu__popover")'));await hoverAddMenuItem('connectors');await wait('!!document.querySelector(".cr-connectors-submenu")','source connector submenu');check('connector menu includes real manage action',await read('!!document.querySelector(".cr-connectors-submenu__footer")'));await shot('source-plus-connectors');await hoverAddMenuItem('reference-files');await wait('!!document.querySelector(".cr-reference-files-submenu__item")','actual reference choices');await textClick('.cr-reference-files-submenu__item','reference-notes.md');await wait('!!document.querySelector(".wb-composer__resources")','structured workspace reference');check('reference selection closes whole parent',await read('document.querySelector(".wb-composer__add-button").getAttribute("aria-expanded")'),'false');check('actual reference filename present',await read('document.querySelector(".wb-composer__resources").textContent.includes("reference-notes.md")'));await click('.wb-composer__resources button');await click('.wb-composer__add-button');await hoverAddMenuItem('experts');await click('.yc-expert-picker [data-expert-id=flow-local-expert]');await wait('!!document.querySelector("[data-active-expert]")&&!document.querySelector(".yc-expert-picker")','native source picker restores expert');check('expert restore resumes actual craft',(await read('window.ycode.session.list()')).find(s=>s.id===role.session.id).agentMode,'craft');await fill('.wb-composer__input','');
|
||||
await step('expert-usage-existing-conversation');const countBeforeReturn=(await read('window.ycode.session.list()')).length;await fill('.wb-composer__input','USER_DRAFT_KEEP_AFTER_EXPERT_SWITCH');await click('.wb-composer__add-button');await hoverAddMenuItem('experts');await wait('!!document.querySelector(".yc-expert-picker [data-expert-manage]")','expert more action');await click('.yc-expert-picker [data-expert-manage]');await wait('!!document.querySelector("[data-expert-page=mine]")','return-aware expert center');await summon(false);check('expert center return retains original conversation',await read('document.querySelector(".sidebar__item[data-selected=true]")?.dataset.sessionId'),role.session.id);check('expert center return preserves typed draft',await read('document.querySelector(".wb-composer__input").value'),'USER_DRAFT_KEEP_AFTER_EXPERT_SWITCH');check('expert summon does not create empty sessions',(await read('window.ycode.session.list()')).length,countBeforeReturn);
|
||||
async function removeExpertChip(){const point=await read('(()=>{const e=document.querySelector("[data-active-expert]"),b=e.getBoundingClientRect();return{x:Math.round(b.x+8),y:Math.round(b.y+b.height/2)}})()');main.webContents.sendInputEvent({type:'mouseMove',...point});await pause(160);await click('[data-active-expert] button');await wait(`window.ycode.session.list().then(s=>s.find(x=>x.id===${JSON.stringify(role.session.id)})?.expertId===null)`,'expert removed through source chip')}
|
||||
async function pickExistingExpert(){await click('.wb-composer__add-button');await hoverAddMenuItem('experts');await wait('!!document.querySelector(".yc-expert-picker [data-expert-id=flow-local-expert]")','expert row');await click('.yc-expert-picker [data-expert-id=flow-local-expert]');await wait('!!document.querySelector("[data-active-expert]")&&!document.querySelector(".yc-expert-picker")','expert picker activation')}
|
||||
await removeExpertChip();check('removing expert preserves user-authored text',await read('document.querySelector(".wb-composer__input").value'),'USER_DRAFT_KEEP_AFTER_EXPERT_SWITCH');await pickExistingExpert();check('picker activation preserves user-authored text',await read('document.querySelector(".wb-composer__input").value'),'USER_DRAFT_KEEP_AFTER_EXPERT_SWITCH');check('switching expert never changes prior assistant snapshot',(await read(`window.ycode.session.messages(${JSON.stringify(role.session.id)})`)).findLast(m=>m.role==='assistant').assistantDisplay.id,ID);await fill('.wb-composer__input','');await removeExpertChip();await pickExistingExpert();check('empty conversation draft receives default input',await read('document.querySelector(".wb-composer__input").value'),'请分析提供的事实。');await removeExpertChip();check('conversation clear removes untouched expert prefill',await read('document.querySelector(".wb-composer__input").value'),'');await shot('expert-usage-cleared');check('expert interaction sends no extra user message',(await read(`window.ycode.session.messages(${JSON.stringify(role.session.id)})`)).filter(m=>m.role==='user').length,1);
|
||||
await step('source-edit-entry');await centerMine();await click('.ec-cr-more-btn');await wait('[...document.querySelectorAll(\'[role="menuitem"]\')].some(e=>e.textContent.includes("修改专家"))','source card edit menu');await shot('source-edit-menu');await textClick('[role="menuitem"]','修改专家');await wait(`document.querySelector('.wb-composer__input')?.value===${JSON.stringify(EDIT)}`,'original edit prompt after owner commit');check('original edit prompt',await read('document.querySelector(".wb-composer__input").value'),EDIT);check('edit manager structured skill chip',await read('!!document.querySelector(\'[data-skill-uri="skill://expert-manager"]\')'));check('editing starts manager draft without active expert',await read('!document.querySelector("[data-active-expert]")'));await shot('edit-source-draft')
|
||||
await step('actual-omp-edit');const edit=await sendNew('EXPERT_FLOW_EDITED');check('actual edit package action order',edit.assistant.tools.filter(t=>t.name==='ycode_expert_package').map(t=>t.args.action),['read','write','validate','register']);check('updated Agent actually written',(await fs.readFile(path.join(root,'plugins',ID,'agents',ID+'.md'),'utf8')).includes(UPDATED));check('edit marker tied to actual edit session',await fs.readFile(path.join(root,'plugins',ID,'.created-by-session'),'utf8'),edit.session.id);check('edit skill URI persists',edit.messages[0].skills[0].uri,'skill://expert-manager');await shot('edited-chat');await centerMine();await wait('!!document.querySelector(".ec-cr-card")','edited source card');await shot('edited-source-card')
|
||||
await fs.writeFile(path.join(directory,stage+'-report.json'),JSON.stringify({result:'PASS',checks,requests,buildSha256,createSessionId:creation.session.id,roleSessionId:role.session.id,editSessionId:edit.session.id},null,2))
|
||||
}
|
||||
console.log('EXPERT_FLOW_PASS '+stage+' '+directory);await cleanup();app.quit()
|
||||
})().catch(async error=>{await fs.writeFile(path.join(directory,stage+'-failure.json'),JSON.stringify({result:'FAIL',phase,error:String(error),stack:error.stack,checks,requests},null,2)).catch(()=>{});if(main&&!main.isDestroyed())await shot('failure').catch(()=>{});console.log('EXPERT_FLOW_FAIL '+phase+' '+String(error));await cleanup().catch(()=>{});process.exitCode=1;app.quit()})
|
||||
}
|
||||
@@ -26,7 +26,7 @@ if (!process.versions.electron) {
|
||||
await fs.mkdir(data, { recursive: true })
|
||||
await fs.mkdir(home, { recursive: true })
|
||||
await fs.mkdir(nextWorkspace, { recursive: true })
|
||||
const env = { ...process.env, YCODE_QA_DIRECTORY: directory, YCODE_USER_DATA_DIR: data, YCODE_OMP_HOME: home,
|
||||
const env = { ...process.env, YCODE_QA_DIRECTORY: directory, YCODE_USER_DATA_DIR: data,
|
||||
YCODE_DISABLE_GPU: '1', YCODE_DISABLE_SYSTEM_INTEGRATION: '1' }
|
||||
delete env.ELECTRON_RUN_AS_NODE; delete env.ELECTRON_RENDERER_URL
|
||||
const child = require('node:child_process').spawn(require('electron'),
|
||||
|
||||
@@ -1,141 +0,0 @@
|
||||
// Real Electron preload/IPC, real OMP process, controlled local OpenAI-compatible upstream.
|
||||
// This verifies YCode functionality; original WorkBuddy parity is assessed separately via REA.
|
||||
const fs = require('node:fs/promises')
|
||||
const path = require('node:path')
|
||||
const assert = require('node:assert/strict')
|
||||
if (!process.versions.electron) {
|
||||
const { spawn } = require('node:child_process')
|
||||
;(async () => {
|
||||
const directory = await fs.mkdtemp(path.resolve('.tmp/local-workflows-'))
|
||||
const home = path.join(directory, 'home')
|
||||
await fs.mkdir(home, { recursive: true })
|
||||
const env = { ...process.env, YCODE_USER_DATA_DIR: path.join(directory, 'data'), YCODE_QA_DIRECTORY: directory,
|
||||
YCODE_QA_REAL_OMP: process.env.YCODE_OMP_PATH || path.join(process.env.LOCALAPPDATA || '', 'Ygent', 'omp.exe'),
|
||||
YCODE_OMP_HOME: home, YCODE_DISABLE_GPU: '1', YCODE_QA_OFFSCREEN: '1' }
|
||||
delete env.ELECTRON_RUN_AS_NODE
|
||||
const child = spawn(require('electron'), ['--no-sandbox', '--disable-gpu-sandbox', __filename], { env, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] })
|
||||
let output = ''
|
||||
child.stdout.on('data', chunk => { output += chunk; if (chunk.toString().includes('LOCAL_WORKFLOWS_PASS')) process.stdout.write(chunk) })
|
||||
child.stderr.on('data', chunk => { output += chunk })
|
||||
const timeout = setTimeout(() => child.kill(), 120000)
|
||||
child.once('exit', async code => {
|
||||
clearTimeout(timeout)
|
||||
await fs.writeFile(path.join(directory, 'process.log'), output)
|
||||
console.log(JSON.stringify({ directory, exitCode: code }))
|
||||
if (code) console.error(output.slice(-6000))
|
||||
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
|
||||
app.disableHardwareAcceleration()
|
||||
process.env.YCODE_OMP_PATH = process.env.YCODE_QA_REAL_OMP
|
||||
app.setPath('home', path.join(directory, 'home'))
|
||||
const requests = []
|
||||
let main, settings, server
|
||||
const pause = ms => new Promise(resolve => setTimeout(resolve, ms))
|
||||
const read = (expression, win = main) => Promise.race([win.webContents.executeJavaScript(expression), new Promise((_, reject) => setTimeout(() => reject(new Error('Renderer call timeout: ' + expression.slice(0,180))), 15000))])
|
||||
const phase = label => fs.writeFile(path.join(directory, 'phase.txt'), label)
|
||||
async function wait(expression, label, win = main) {
|
||||
for (let i = 0; i < 500; i++) { const result = await read(expression, win); if (result) return result; await pause(40) }
|
||||
throw new Error('Timed out: ' + label)
|
||||
}
|
||||
async function fill(selector, value, win = main) {
|
||||
await read(`(()=>{const input=document.querySelector(${JSON.stringify(selector)});if(!input)throw new Error('Missing input');const proto=input.tagName==='TEXTAREA'?HTMLTextAreaElement.prototype:HTMLInputElement.prototype;Object.getOwnPropertyDescriptor(proto,'value').set.call(input,${JSON.stringify(value)});input.dispatchEvent(new Event('input',{bubbles:true}))})()`, win)
|
||||
}
|
||||
async function shot(name, win = main) {
|
||||
await phase('shot-fonts-' + name)
|
||||
await read('document.fonts.ready.then(()=>true)', win)
|
||||
await phase('shot-capture-' + name)
|
||||
await pause(100)
|
||||
const image = await Promise.race([win.webContents.capturePage(undefined, { stayHidden: true, stayAwake: true }), pause(10000).then(() => { throw new Error('capturePage timed out: ' + name) })])
|
||||
await fs.writeFile(path.join(directory, name + '.png'), image.toPNG())
|
||||
}
|
||||
;(async () => {
|
||||
// Refuse to let a functional test write OMP profiles in the user's normal home.
|
||||
assert.equal(path.resolve(process.env.YCODE_OMP_HOME), path.resolve(directory, 'home'))
|
||||
server = createServer(async (req, res) => {
|
||||
let raw = ''; for await (const part of req) raw += part
|
||||
const body = JSON.parse(raw)
|
||||
requests.push({ model: body.model, stream: body.stream, authenticated: req.headers.authorization === 'Bearer LOCAL_TEST_KEY',
|
||||
messages: body.messages.map(message => ({ role: message.role, content: message.content })) })
|
||||
if (req.headers.authorization !== 'Bearer LOCAL_TEST_KEY') { res.writeHead(401); res.end('{}'); return }
|
||||
const content = `LOCAL_REPLY_${body.model}`
|
||||
if (!body.stream) { res.setHeader('content-type', 'application/json'); res.end(JSON.stringify({ choices: [{ message: { role: 'assistant', content } }] })); return }
|
||||
res.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-cache' })
|
||||
const chunk = delta => `data: ${JSON.stringify({ id: 'local-test', object: 'chat.completion.chunk', created: 1, model: body.model, choices: [{ index: 0, delta, finish_reason: null }] })}\n\n`
|
||||
res.write(chunk({ role: 'assistant', content }))
|
||||
res.write(`data: ${JSON.stringify({ id: 'local-test', object: 'chat.completion.chunk', created: 1, model: body.model, choices: [{ index: 0, delta: {}, finish_reason: 'stop' }], usage: { prompt_tokens: 20, completion_tokens: 5, total_tokens: 25 } })}\n\ndata: [DONE]\n\n`)
|
||||
res.end()
|
||||
})
|
||||
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve))
|
||||
const baseUrl = `http://127.0.0.1:${server.address().port}/v1`
|
||||
await fs.mkdir(process.env.YCODE_USER_DATA_DIR, { recursive: true })
|
||||
app.setPath('userData', process.env.YCODE_USER_DATA_DIR)
|
||||
app.setPath('sessionData', process.env.YCODE_USER_DATA_DIR)
|
||||
await import(pathToFileURL(path.resolve('out/main/index.mjs')).href)
|
||||
await app.whenReady()
|
||||
assert.equal(path.resolve(app.getPath('userData')), path.resolve(process.env.YCODE_USER_DATA_DIR))
|
||||
for (let i = 0; i < 100; i++) { main = BrowserWindow.getAllWindows().find(win => win.getTitle() === 'YCode'); if (main && !main.webContents.isLoading()) break; await pause(50) }
|
||||
assert.ok(main)
|
||||
await wait('!!window.ycode && !!document.querySelector(".chat-pane")', 'main renderer')
|
||||
await phase('main-ready')
|
||||
assert.equal(path.resolve((await read('window.ycode.app.info()')).configDir), path.resolve(process.env.YCODE_USER_DATA_DIR))
|
||||
await read(`window.ycode.models.save(${JSON.stringify({ id: 'local-one', name: 'Local One', providerId: 'custom', model: 'model-one', baseUrl, apiKey: 'LOCAL_TEST_KEY', supportsToolCall: true, maxInputTokens: 64000, maxOutputTokens: 4096 })})`)
|
||||
await read('window.ycode.models.select("local-one")')
|
||||
await read('window.ycode.workspace.generate("空间甲")')
|
||||
const spaces = await read('window.ycode.workspace.generate("空间乙")')
|
||||
assert.equal(spaces.workspaces.length, 2)
|
||||
for (const space of spaces.workspaces) assert.ok((await fs.stat(space.root)).isDirectory())
|
||||
const sessionA = await read(`window.ycode.session.create('A',${JSON.stringify({ workspaceId: spaces.workspaces[0].id, modelId: 'local-one' })})`)
|
||||
await fs.writeFile(path.join(spaces.workspaces[0].root, 'sentinel.txt'), 'SPACE_A')
|
||||
await fs.writeFile(path.join(spaces.workspaces[1].root, 'sentinel.txt'), 'SPACE_B')
|
||||
const sessionB = await read(`window.ycode.session.create('B',${JSON.stringify({ workspaceId: spaces.workspaces[1].id, modelId: 'local-one' })})`)
|
||||
assert.equal(await read(`window.ycode.resources.readText('sentinel.txt',${JSON.stringify(sessionA.id)})`), 'SPACE_A')
|
||||
assert.equal(await read(`window.ycode.resources.readText('sentinel.txt',${JSON.stringify(sessionB.id)})`), 'SPACE_B')
|
||||
await read(`window.ycode.chat.send({sessionId:${JSON.stringify(sessionA.id)},text:'Please reply with one short sentence.'})`)
|
||||
const doneA = await wait(`window.ycode.session.messages(${JSON.stringify(sessionA.id)}).then(messages=>messages.at(-1)?.status!=='streaming'&&messages.at(-1))`, 'real OMP turn A')
|
||||
assert.equal(doneA.status, 'done', JSON.stringify(doneA))
|
||||
assert.equal(doneA.content, 'LOCAL_REPLY_model-one')
|
||||
await phase('first-real-omp-turn-passed')
|
||||
await read(`window.ycode.models.save(${JSON.stringify({ id: 'local-one', previousId: 'local-one', name: 'Local Edited', providerId: 'custom', model: 'model-two', baseUrl, supportsToolCall: true, maxInputTokens: 64000, maxOutputTokens: 4096 })})`)
|
||||
await read(`window.ycode.chat.send({sessionId:${JSON.stringify(sessionA.id)},text:'Reply again after changing the model.'})`)
|
||||
const doneB = await wait(`window.ycode.session.messages(${JSON.stringify(sessionA.id)}).then(messages=>messages.length===4&&messages.at(-1)?.status!=='streaming'&&messages.at(-1))`, 'real OMP changed model')
|
||||
assert.equal(doneB.status, 'done', JSON.stringify(doneB))
|
||||
assert.equal(doneB.content, 'LOCAL_REPLY_model-two')
|
||||
await phase('changed-model-real-omp-turn-passed')
|
||||
assert.ok(requests.some(req => req.model === 'model-one' && req.authenticated))
|
||||
assert.ok(requests.some(req => req.model === 'model-two' && req.authenticated))
|
||||
await phase('opening-settings')
|
||||
await read('void window.ycode.app.openSettings("model")')
|
||||
for (let i = 0; i < 100; i++) { settings = BrowserWindow.getAllWindows().find(win => win !== main); if (settings && !settings.webContents.isLoading()) break; await pause(40) }
|
||||
await wait('!!document.querySelector(".models-settings-panel__add-button")', 'settings model list', settings)
|
||||
await phase('settings-loaded')
|
||||
await phase('settings-get-size')
|
||||
assert.deepEqual(await read('[innerWidth,innerHeight]', settings), [900, 680])
|
||||
await phase('settings-size-passed')
|
||||
await shot('models-list', settings)
|
||||
await read('document.querySelector(".models-settings-panel__add-button").click()', settings)
|
||||
await wait('!!document.querySelector(".models-settings-panel__editor")', 'model editor', settings)
|
||||
await shot('model-add', settings)
|
||||
await read('document.querySelector(".models-settings-panel__editor-cancel").click()', settings)
|
||||
settings.close()
|
||||
const file = JSON.parse(await fs.readFile(path.join(directory, 'data', 'models.json'), 'utf8'))
|
||||
assert.equal(file.models.find(model => model.id === 'local-one').model, 'model-two')
|
||||
const yamlFiles = await fs.readdir(path.join(directory, 'home', '.omp', 'profiles'))
|
||||
for (const profile of yamlFiles) assert.ok(!(await fs.readFile(path.join(directory, 'home', '.omp', 'profiles', profile, 'agent', 'models.yml'), 'utf8')).includes('LOCAL_TEST_KEY'))
|
||||
const report = { scope: 'Real YCode Electron + preload + IPC + real OMP; controlled local upstream, not WorkBuddy parity or live supplier validation',
|
||||
sessions: [sessionA, sessionB], requests, result: 'PASS' }
|
||||
await fs.writeFile(path.join(directory, 'report.json'), JSON.stringify(report, null, 2))
|
||||
console.log('LOCAL_WORKFLOWS_PASS ' + directory)
|
||||
server.close(); app.quit()
|
||||
})().catch(async error => {
|
||||
console.error(error)
|
||||
if (main && !main.isDestroyed()) await shot('failure').catch(() => undefined)
|
||||
await fs.writeFile(path.join(directory, 'failure.json'), JSON.stringify({ error: String(error), requests }, null, 2))
|
||||
server?.close(); app.exit(1)
|
||||
})
|
||||
}
|
||||
@@ -16,7 +16,6 @@ if (!process.versions.electron) {
|
||||
...process.env,
|
||||
YCODE_QA_DIRECTORY: directory,
|
||||
YCODE_USER_DATA_DIR: data,
|
||||
YCODE_OMP_HOME: home,
|
||||
YCODE_DISABLE_GPU: '1',
|
||||
YCODE_DISABLE_SYSTEM_INTEGRATION: '1'
|
||||
}
|
||||
|
||||
@@ -1,148 +0,0 @@
|
||||
// Actual isolated production Electron/IPC -> real OMP -> loopback OpenAI-compatible HTTP.
|
||||
// Reads the generated profile again through a second real OMP loader, not a YAML-only assertion.
|
||||
const fs = require('node:fs/promises')
|
||||
const path = require('node:path')
|
||||
const assert = require('node:assert/strict')
|
||||
const { spawn } = require('node:child_process')
|
||||
if (!process.versions.electron) {
|
||||
;(async () => {
|
||||
const directory = await fs.mkdtemp(path.resolve('.tmp/model-capabilities-'))
|
||||
await Promise.all(['data', 'home'].map(name => fs.mkdir(path.join(directory, name))))
|
||||
const omp = process.env.YCODE_OMP_PATH || path.join(process.env.LOCALAPPDATA || '', 'Ygent', 'omp.exe')
|
||||
assert.ok((await fs.stat(omp)).isFile(), 'Real OMP executable required')
|
||||
const env = { ...process.env, YCODE_USER_DATA_DIR: path.join(directory, 'data'), YCODE_OMP_HOME: path.join(directory, 'home'),
|
||||
YCODE_QA_DIRECTORY: directory, YCODE_OMP_PATH: omp, YCODE_DISABLE_GPU: '1', YCODE_QA_OFFSCREEN: '1' }
|
||||
delete env.ELECTRON_RUN_AS_NODE; delete env.ELECTRON_RENDERER_URL
|
||||
const child = 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('CAPABILITIES_')) process.stdout.write(bytes) })
|
||||
child.stderr.on('data', bytes => { output += bytes })
|
||||
const timer = setTimeout(() => child.kill(), 120000)
|
||||
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(-7000)); process.exitCode = code ?? 1
|
||||
})
|
||||
})().catch(error => { console.error(error); process.exitCode = 1 })
|
||||
} else {
|
||||
const { app, BrowserWindow } = require('electron')
|
||||
app.disableHardwareAcceleration()
|
||||
const { createServer } = require('node:http'), { createInterface } = require('node:readline'), { pathToFileURL } = require('node:url')
|
||||
const directory = process.env.YCODE_QA_DIRECTORY, home = path.join(directory, 'home'), data = path.join(directory, 'data')
|
||||
const relative = path.relative(path.resolve('.tmp'), directory)
|
||||
assert.ok(relative.startsWith('model-capabilities-') && !relative.includes('..') && !path.isAbsolute(relative))
|
||||
assert.equal(path.resolve(process.env.YCODE_OMP_HOME), home)
|
||||
app.setPath('userData', data); app.setPath('sessionData', data); app.setPath('home', home)
|
||||
const checks = [], requests = [], loaderFrames = [], loaderStderr = []
|
||||
let main, server, loader, phase = 'boot'
|
||||
const pause = ms => new Promise(resolve => setTimeout(resolve, ms))
|
||||
function redact(value) {
|
||||
if (Array.isArray(value)) return value.map(redact)
|
||||
if (value && typeof value === 'object') return Object.fromEntries(Object.entries(value).filter(([key]) => !/api.?key|authorization|secret|password|credential|cookie/i.test(key)).map(([key, value]) => [key, redact(value)]))
|
||||
return value
|
||||
}
|
||||
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 }) }
|
||||
async function read(source) { let timer; try { return await Promise.race([main.webContents.executeJavaScript(source), new Promise((_, reject) => { timer = setTimeout(() => reject(Error('Renderer timeout')), 15000) })]) } finally { clearTimeout(timer) } }
|
||||
async function wait(readValue, label, timeout = 20000) { const start = Date.now(); while (Date.now() - start < timeout) { const value = await readValue(); if (value) return value; await pause(30) } throw Error('Timeout: ' + label) }
|
||||
async function step(label) { phase = label; await fs.writeFile(path.join(directory, 'phase.txt'), label) }
|
||||
async function saveReport(extra = {}) { await fs.writeFile(path.join(directory, 'report.json'), JSON.stringify({ result: checks.every(item => item.pass) ? 'PASS' : 'FAIL', phase,
|
||||
scope: 'Production isolated Electron/App/preload/IPC with actual OMP executable and loopback HTTP; actual generated profile reloaded by a second OMP RPC process. No fake IPC or Gateway-only success.',
|
||||
notImplementedOrClaimed: ['reasoning effort selection', 'custom protocol execution'], checks, requests, loaderFrames: redact(loaderFrames), loaderStderr, ...extra }, null, 2)) }
|
||||
async function cleanup() {
|
||||
if (loader && loader.exitCode === null) { loader.stdin.write(JSON.stringify({ type: 'shutdown', id: 'qa-shutdown' }) + '\n'); await pause(100); if (loader.exitCode === null) loader.kill() }
|
||||
server?.close()
|
||||
}
|
||||
;(async () => {
|
||||
// Start importing before the first await: production software-rendering setup precedes app readiness.
|
||||
const production = import(pathToFileURL(path.resolve('out/main/index.mjs')).href)
|
||||
server = createServer(async (request, response) => {
|
||||
try {
|
||||
let raw = ''; for await (const bytes of request) raw += bytes
|
||||
const body = JSON.parse(raw)
|
||||
const parts = (body.messages || []).flatMap(message => Array.isArray(message.content) ? message.content : [])
|
||||
requests.push({ path: request.url, authorizationPresent: request.headers.authorization !== undefined, model: body.model,
|
||||
stream: body.stream, max_tokens: body.max_tokens, max_completion_tokens: body.max_completion_tokens,
|
||||
toolsPresent: Object.hasOwn(body, 'tools'), toolsCount: Array.isArray(body.tools) ? body.tools.length : 0,
|
||||
toolChoice: body.tool_choice, imageParts: parts.filter(part => part.type === 'image_url' || part.type === 'image').length,
|
||||
lastUserText: (body.messages || []).filter(message => message.role === 'user').map(message => typeof message.content === 'string' ? message.content : message.content?.filter(part => part.type === 'text').map(part => part.text).join('\n')).at(-1) })
|
||||
const content = 'CAPABILITIES_REAL_OMP_REPLY'
|
||||
if (!body.stream) { response.setHeader('content-type', 'application/json'); response.end(JSON.stringify({ choices: [{ message: { role: 'assistant', content } }] })); return }
|
||||
response.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-cache' })
|
||||
const frame = delta => `data: ${JSON.stringify({ id: 'capability-loopback', object: 'chat.completion.chunk', created: 1, model: body.model, choices: [{ index: 0, delta, finish_reason: null }] })}\n\n`
|
||||
response.write(frame({ role: 'assistant', content }))
|
||||
response.end(`data: ${JSON.stringify({ id: 'capability-loopback', object: 'chat.completion.chunk', created: 1, model: body.model, choices: [{ index: 0, delta: {}, finish_reason: 'stop' }], usage: { prompt_tokens: 20, completion_tokens: 10, total_tokens: 30 } })}\n\ndata: [DONE]\n\n`)
|
||||
} catch { response.writeHead(400); response.end('{}') }
|
||||
})
|
||||
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); await production; await app.whenReady()
|
||||
for (let i = 0; i < 200; i++) { main = BrowserWindow.getAllWindows().find(win => win.getTitle() === 'YCode'); if (main && !main.webContents.isLoading()) break; await pause(30) }
|
||||
assert.ok(main); await wait(() => read('!!window.ycode&&!!document.querySelector(".chat-pane")'), 'real App')
|
||||
const info = await read('window.ycode.app.info()')
|
||||
assert.equal(path.resolve(info.configDir), data); assert.equal(info.omp.available, true)
|
||||
await step('persist-noauth-textonly-notools-model')
|
||||
const input = { id: 'capabilities-none', name: 'Capabilities None', providerId: 'custom', model: 'capabilities-none',
|
||||
baseUrl: `http://127.0.0.1:${server.address().port}/v1`, authMode: 'none', clearApiKey: true,
|
||||
supportsToolCall: false, supportsImages: false, supportsReasoning: false, maxInputTokens: 8192, maxOutputTokens: 1234 }
|
||||
const registry = await read(`window.ycode.models.save(${JSON.stringify(input)})`)
|
||||
const saved = registry.models.find(model => model.id === input.id)
|
||||
check('saved no-auth/text/no-tools/token capabilities match', [saved.authMode, saved.supportsToolCall, saved.supportsImages, saved.contextWindowTokens, saved.maxInputTokens, saved.maxOutputTokens], ['none', false, false, 8192, 6958, 1234])
|
||||
check('no-auth model has no stored secret', saved.hasApiKey, false)
|
||||
await read(`window.ycode.models.select(${JSON.stringify(input.id)})`)
|
||||
const spaces = await read('window.ycode.workspace.generate("能力验收隔离空间")'), workspace = spaces.workspaces[0]
|
||||
assert.ok(path.resolve(workspace.root).startsWith(directory + path.sep))
|
||||
const session = await read(`window.ycode.session.create('Capabilities',${JSON.stringify({ modelId: input.id, workspaceId: workspace.id })})`)
|
||||
await step('real-omp-text-turn')
|
||||
await read(`window.ycode.chat.send(${JSON.stringify({ sessionId: session.id, text: 'TEXT_CAPABILITY_REQUEST: please respond with one sentence.' })})`)
|
||||
let done = await wait(() => read(`window.ycode.session.messages(${JSON.stringify(session.id)}).then(m=>m.length===2&&m.at(-1).status!=='streaming'&&m.at(-1))`), 'real OMP text done', 40000)
|
||||
check('no-auth OMP text turn reaches done', done.status, 'done'); check('no-auth OMP reply is from loopback', done.content, 'CAPABILITIES_REAL_OMP_REPLY')
|
||||
// A text-only advertised model must not leak an image part to the supplier.
|
||||
await step('real-omp-textonly-image-attachment-turn')
|
||||
const attachment = await read(`window.ycode.attachments.stage(${JSON.stringify({ name: 'capability-pixel.png', mimeType: 'image/png', dataBase64: 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/dXEAAAAASUVORK5CYII=' })})`)
|
||||
await read(`window.ycode.chat.send(${JSON.stringify({ sessionId: session.id, text: 'IMAGE_CAPABILITY_REQUEST: please respond with one sentence.', attachments: [attachment] })})`)
|
||||
done = await wait(() => read(`window.ycode.session.messages(${JSON.stringify(session.id)}).then(m=>m.length===4&&m.at(-1).status!=='streaming'&&m.at(-1))`), 'real OMP image capability done', 40000)
|
||||
check('text-only model attachment turn reaches done', done.status, 'done')
|
||||
check('text-only model attachment reply is actual loopback', done.content, 'CAPABILITIES_REAL_OMP_REPLY')
|
||||
check('two actual OMP requests reached loopback', requests.length, 2)
|
||||
check('actual supplier request endpoint and streaming mode', requests.map(item => [item.path, item.stream]), [['/v1/chat/completions', true], ['/v1/chat/completions', true]])
|
||||
check('actual supplier payload contains both requested user turns', requests.map(item => item.lastUserText?.includes('TEXT_CAPABILITY_REQUEST') || item.lastUserText?.includes('IMAGE_CAPABILITY_REQUEST')), [true, true])
|
||||
check('all actual supplier requests omit Authorization', requests.map(item => item.authorizationPresent), [false, false])
|
||||
check('actual supplier model is configured id', requests.map(item => item.model), [input.model, input.model])
|
||||
check('supportsToolCall:false supplier tools empty or omitted', requests.map(item => item.toolsCount), [0, 0])
|
||||
check('supportsImages:false strips supplier image parts', requests.map(item => item.imageParts), [0, 0])
|
||||
check('configured maxOutputTokens appears on both supplier requests', requests.map(item => item.max_tokens ?? item.max_completion_tokens), [1234, 1234])
|
||||
const nativeTranscript = path.join(data, 'omp-sessions', `session-${encodeURIComponent(session.id)}.jsonl`)
|
||||
check('production turns generated an actual OMP transcript', (await fs.stat(nativeTranscript)).size > 0, true)
|
||||
await step('actual-profile-loader-state')
|
||||
const profiles = await fs.readdir(path.join(home, '.omp', 'profiles'))
|
||||
const generated = []
|
||||
for (const name of profiles) {
|
||||
const yaml = await fs.readFile(path.join(home, '.omp', 'profiles', name, 'agent', 'models.yml'), 'utf8')
|
||||
if (yaml.includes('capabilities-none')) generated.push({ name, yaml })
|
||||
}
|
||||
assert.equal(generated.length, 1)
|
||||
const profile = generated[0]
|
||||
check('actual generated profile requests auth:none', /^ auth: none$/m.test(profile.yaml), true)
|
||||
check('actual generated profile omits credential config', /apiKey:/i.test(profile.yaml), false)
|
||||
const env = { ...process.env, USERPROFILE: home, HOME: home, YCODE_MODEL_API_KEY: '' }
|
||||
delete env.ELECTRON_RUN_AS_NODE
|
||||
loader = spawn(process.env.YCODE_OMP_PATH, ['--mode', 'rpc', '--profile', profile.name, '--model=ycode/capabilities-none', '--cwd', workspace.root,
|
||||
'--no-tools', '--no-session', '--no-ui', '--no-title', '--no-extensions', '--no-skills', '--no-rules', '--no-lsp'], { cwd: workspace.root, env, windowsHide: true, stdio: ['pipe', 'pipe', 'pipe'] })
|
||||
createInterface({ input: loader.stdout }).on('line', line => { try { loaderFrames.push(redact(JSON.parse(line))) } catch { /* Non-JSON diagnostics are not model state. */ } })
|
||||
createInterface({ input: loader.stderr }).on('line', line => loaderStderr.push(line))
|
||||
await wait(() => loaderFrames.find(frame => frame.type === 'ready'), 'second real OMP loader ready', 30000)
|
||||
loader.stdin.write(JSON.stringify({ id: 'qa-capability-state', type: 'get_state' }) + '\n')
|
||||
const state = await wait(() => loaderFrames.find(frame => frame.id === 'qa-capability-state'), 'loader get_state')
|
||||
const model = state.data?.model
|
||||
check('real OMP loader accepts profile and reports selected custom model', model?.id, input.model)
|
||||
check('real OMP loader contextWindow uses saved total context', model?.contextWindow, 8192)
|
||||
check('real OMP loader maxTokens uses saved maxOutputTokens', model?.maxTokens, 1234)
|
||||
check('real OMP loader input capability is text only', model?.input, ['text'])
|
||||
check('real OMP loader reasoning is false', model?.reasoning, false)
|
||||
await saveReport({ savedProfile: saved, generatedProfile: { name: profile.name, yaml: profile.yaml }, session: { id: session.id, cwd: session.cwd }, nativeTranscript, loaderModel: redact(model) })
|
||||
const result = checks.every(item => item.pass) ? 'PASS' : 'FAIL'
|
||||
console.log('CAPABILITIES_INTEGRATION_' + result + ' ' + directory)
|
||||
for (const item of checks.filter(check => !check.pass)) console.log('CAPABILITIES_FAILURE ' + JSON.stringify(item))
|
||||
await cleanup(); app.quit(); if (result !== 'PASS') process.exitCode = 1
|
||||
})().catch(async error => {
|
||||
await saveReport({ result: 'FAIL', error: String(error), stack: error.stack }).catch(() => undefined)
|
||||
console.error(error); await cleanup(); app.exit(1)
|
||||
})
|
||||
}
|
||||
@@ -23,8 +23,7 @@ if (!process.versions.electron) {
|
||||
{ id: 'artifact-write', name: 'write_file', args: { path: 'report.md', content: '# UI parity artifact\n' }, argsRaw: '{}', status: 'ok' }
|
||||
] }]
|
||||
}] }))
|
||||
const env = { ...process.env, YCODE_QA_DIRECTORY: directory, YCODE_USER_DATA_DIR: data, YCODE_OMP_HOME: path.join(directory, 'home'),
|
||||
YCODE_DISABLE_GPU: '1', YCODE_QA_OFFSCREEN: '1', YCODE_DISABLE_SYSTEM_INTEGRATION: '1' }
|
||||
const env = { ...process.env, YCODE_QA_DIRECTORY: directory, YCODE_USER_DATA_DIR: data, YCODE_DISABLE_GPU: '1', YCODE_QA_OFFSCREEN: '1', YCODE_DISABLE_SYSTEM_INTEGRATION: '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],
|
||||
|
||||
@@ -1,407 +0,0 @@
|
||||
// TODO 08 验收:新任务发送后的立即切页与占位。
|
||||
// 真实隔离 YCode(真实 preload/IPC/渲染进程)+ 真实 OMP + 受控本地 OpenAI 兼容上游。
|
||||
//
|
||||
// 原版依据(REA ev_717fa5e846e64d67ac19c8925d121bff632c0d3aefb3603b10858559cabb2dcb,
|
||||
// WorkBuddy 5.8.2,见 docs/对话启动提示文案-2026-10-16.md):启动阶段文案取自
|
||||
// chat.prepare.<stage>,渲染在流式状态栏主文案位置。YCode 侧使用 acquiring_runtime
|
||||
// =「正在启动对话运行环境…」作为主进程回合就绪前的占位文案。
|
||||
// 原版「发送后」逐帧等待态没有 REA 运行时证据:REA visual probe 的 text 动作只接受
|
||||
// INPUT、key 动作排除可编辑内容上的 Enter/Space(见 .tmp/todo08-wb-new-task/notes.md),
|
||||
// 因此本脚本只验收可证行为:发送即切页 + 用户消息先出现 + 准备阶段占位先于真实回合 +
|
||||
// 真实消息替换不重复 + 发送失败恢复草稿且不残留乐观消息。
|
||||
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/new-task-switch-'))
|
||||
const home = path.join(directory, 'home')
|
||||
await fs.mkdir(home, { recursive: true })
|
||||
const env = { ...process.env, YCODE_USER_DATA_DIR: path.join(directory, 'data'), YCODE_QA_DIRECTORY: directory,
|
||||
YCODE_QA_REAL_OMP: process.env.YCODE_OMP_PATH || path.join(process.env.LOCALAPPDATA || '', 'Ygent', 'omp.exe'),
|
||||
YCODE_OMP_HOME: home, 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('NEW_TASK_SWITCH_')) process.stdout.write(bytes) })
|
||||
child.stderr.on('data', bytes => { output += bytes })
|
||||
const timer = setTimeout(() => child.kill(), 240000)
|
||||
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(-8000))
|
||||
process.exitCode = code ? 1 : 0
|
||||
})
|
||||
})().catch(error => { console.error(error); process.exitCode = 1 })
|
||||
} else {
|
||||
const { app, BrowserWindow, ipcMain } = require('electron')
|
||||
const { createServer } = require('node:http')
|
||||
const { pathToFileURL } = require('node:url')
|
||||
const directory = process.env.YCODE_QA_DIRECTORY
|
||||
app.disableHardwareAcceleration()
|
||||
process.env.YCODE_OMP_PATH = process.env.YCODE_QA_REAL_OMP
|
||||
app.setPath('home', path.join(directory, 'home'))
|
||||
const checks = []
|
||||
const snapshots = {}
|
||||
const rendererErrors = []
|
||||
const preStartFailureMarker = 'QA_PRE_START_FAILURE_20261017'
|
||||
let controlledFailureCalls = 0
|
||||
let controlledFailureSessionId
|
||||
let main, server, phase = 'boot'
|
||||
const pause = ms => new Promise(resolve => setTimeout(resolve, ms))
|
||||
const read = (source, win = main) => Promise.race([win.webContents.executeJavaScript(source),
|
||||
new Promise((_, reject) => setTimeout(() => reject(Error('Renderer timeout: ' + source.slice(0, 120))), 20000))])
|
||||
async function wait(source, label) {
|
||||
for (let index = 0; index < 400; index++) { const value = await read(source); if (value) return value; 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 })
|
||||
}
|
||||
async function fill(value) {
|
||||
await read(`(()=>{const input=[...document.querySelectorAll('.wb-composer__input')].find(node=>node.offsetParent!==null);
|
||||
if(!input)throw new Error('Missing composer input');
|
||||
Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype,'value').set.call(input,${JSON.stringify(value)});
|
||||
input.dispatchEvent(new Event('input',{bubbles:true}));return true})()`)
|
||||
}
|
||||
async function dropDraftFixtures(attachmentName, attachmentText, referencePath) {
|
||||
await read(`(()=>{const composer=document.querySelector('.wb-composer');
|
||||
if(!composer)throw new Error('Missing composer');
|
||||
const transfer=new DataTransfer();
|
||||
transfer.items.add(new File([${JSON.stringify(attachmentText)}],${JSON.stringify(attachmentName)},{type:'text/plain'}));
|
||||
transfer.setData('text/uri-list',${JSON.stringify(referencePath)});
|
||||
composer.dispatchEvent(new DragEvent('drop',{bubbles:true,cancelable:true,dataTransfer:transfer}));
|
||||
return true})()`)
|
||||
}
|
||||
const within = limit => value => typeof value === 'number' && Number.isFinite(value) && value >= 0 && value < limit
|
||||
|
||||
;(async () => {
|
||||
// 受控上游:首个响应刻意延迟,保证「真实回合」到达前有一段可观测的占位窗口。
|
||||
server = createServer(async (req, res) => {
|
||||
let raw = ''
|
||||
for await (const part of req) raw += part
|
||||
const body = JSON.parse(raw || '{}')
|
||||
// 「停止验收」场景需要一个更长的回合,方便在运行中切走/切回再停止。
|
||||
await pause(JSON.stringify(body).includes('停止验收') ? 15000 : 600)
|
||||
const content = `LOCAL_REPLY_${body.model}`
|
||||
if (!body.stream) { res.setHeader('content-type', 'application/json'); res.end(JSON.stringify({ choices: [{ message: { role: 'assistant', content } }] })); return }
|
||||
res.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-cache' })
|
||||
const chunk = delta => `data: ${JSON.stringify({ id: 'local-test', object: 'chat.completion.chunk', created: 1, model: body.model, choices: [{ index: 0, delta, finish_reason: null }] })}\n\n`
|
||||
res.write(chunk({ role: 'assistant', content }))
|
||||
res.write(`data: ${JSON.stringify({ id: 'local-test', object: 'chat.completion.chunk', created: 1, model: body.model, choices: [{ index: 0, delta: {}, finish_reason: 'stop' }], usage: { prompt_tokens: 20, completion_tokens: 5, total_tokens: 25 } })}\n\ndata: [DONE]\n\n`)
|
||||
res.end()
|
||||
})
|
||||
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve))
|
||||
const baseUrl = `http://127.0.0.1:${server.address().port}/v1`
|
||||
|
||||
await fs.mkdir(process.env.YCODE_USER_DATA_DIR, { recursive: true })
|
||||
app.setPath('userData', process.env.YCODE_USER_DATA_DIR)
|
||||
app.setPath('sessionData', process.env.YCODE_USER_DATA_DIR)
|
||||
// Register every production handler normally, but let this scenario reject one
|
||||
// chat:send after the real renderer has already created its session. The
|
||||
// wrapper runs before production startup, so no turn or persisted message exists.
|
||||
const originalIpcHandle = ipcMain.handle
|
||||
ipcMain.handle = function handleWithControlledChatFailure(channel, listener) {
|
||||
if (channel !== 'chat:send') return originalIpcHandle.call(ipcMain, channel, listener)
|
||||
return originalIpcHandle.call(ipcMain, channel, async (event, payload) => {
|
||||
if (controlledFailureCalls === 0 && typeof payload?.text === 'string'
|
||||
&& payload.text.includes(preStartFailureMarker)) {
|
||||
controlledFailureCalls += 1
|
||||
controlledFailureSessionId = payload.sessionId
|
||||
throw new Error('QA controlled chat:send failure before turn startup')
|
||||
}
|
||||
return listener(event, payload)
|
||||
})
|
||||
}
|
||||
try {
|
||||
await import(pathToFileURL(path.resolve('out/main/index.mjs')).href)
|
||||
// Production registers IPC handlers inside app.whenReady(), after module evaluation.
|
||||
await app.whenReady()
|
||||
} finally {
|
||||
ipcMain.handle = originalIpcHandle
|
||||
}
|
||||
await app.whenReady()
|
||||
for (let index = 0; index < 120; index++) {
|
||||
main = BrowserWindow.getAllWindows().find(win => win.getTitle() === 'YCode')
|
||||
if (main && !main.webContents.isLoading()) break
|
||||
await pause(50)
|
||||
}
|
||||
assert.ok(main)
|
||||
main.webContents.on('console-message', (_event, level, message) => { if (level >= 2) rendererErrors.push(message) })
|
||||
await wait('!!window.ycode && !!document.querySelector(".chat-pane")', 'main renderer')
|
||||
await read(`window.ycode.models.save(${JSON.stringify({ id: 'local-one', name: 'Local One', providerId: 'custom',
|
||||
model: 'model-one', baseUrl, apiKey: 'LOCAL_TEST_KEY', supportsToolCall: true, maxInputTokens: 64000, maxOutputTokens: 4096 })})`)
|
||||
await read('window.ycode.models.select("local-one")')
|
||||
|
||||
await step('home-surface')
|
||||
await wait(`!!document.querySelector('.wb-home-composer') && !!document.querySelector('.wb-composer__input')`, 'home composer')
|
||||
const sessionCountBefore = (await read('window.ycode.session.list()')).length
|
||||
check('no session exists before the first send', sessionCountBefore, 0)
|
||||
|
||||
await step('send-new-task')
|
||||
const message = 'QA 新任务发送验收'
|
||||
await read(`(()=>{const marks={};const text=${JSON.stringify(message)};
|
||||
const state=()=>({conversation:!!document.querySelector('.cr-message-list'),
|
||||
user:[...document.querySelectorAll('.cr-self-bubble')].some(node=>node.textContent.includes(text)),
|
||||
prepare:document.body.textContent.includes('正在启动对话运行环境…'),
|
||||
duration:!!document.querySelector('.cr-streaming-duration'),
|
||||
reply:document.body.textContent.includes('LOCAL_REPLY_model-one')});
|
||||
const tick=()=>{const now=performance.now();const value=state();
|
||||
if(!marks.conversation&&value.conversation)marks.conversation=now;
|
||||
if(!marks.userMessage&&value.user)marks.userMessage=now;
|
||||
if(!marks.prepareText&&value.prepare)marks.prepareText=now;
|
||||
if(marks.prepareText&&!marks.prepareCleared&&!value.prepare)marks.prepareCleared=now;
|
||||
if(!marks.duration&&value.duration)marks.duration=now;
|
||||
if(!marks.reply&&value.reply)marks.reply=now;};
|
||||
window.__qa={t0:0,marks};window.__qaTick=tick;
|
||||
const observer=new MutationObserver(tick);observer.observe(document.documentElement,{subtree:true,childList:true,characterData:true});
|
||||
tick();return true})()`)
|
||||
await fill(message)
|
||||
await wait(`(()=>{const button=document.querySelector('button[aria-label="发送"]');return !!button&&!button.disabled})()`, 'send button enabled')
|
||||
await read(`(()=>{window.__qa.t0=performance.now();document.querySelector('button[aria-label="发送"]').click();return true})()`)
|
||||
await wait(`!!(window.__qa.marks.conversation&&window.__qa.marks.userMessage&&window.__qa.marks.prepareText)`, 'optimistic marks')
|
||||
await pause(150)
|
||||
snapshots.optimistic = await read(`(()=>{const input=[...document.querySelectorAll('.wb-composer__input')].find(node=>node.offsetParent!==null);
|
||||
return {t0:window.__qa.t0,marks:{...window.__qa.marks},inputValue:input?input.value:null,
|
||||
userBubbles:[...document.querySelectorAll('.cr-self-bubble')].map(node=>node.textContent.trim()),
|
||||
home:!!document.querySelector('.wb-home-composer'),conversation:!!document.querySelector('.cr-message-list'),
|
||||
prepare:document.body.textContent.includes('正在启动对话运行环境…'),duration:!!document.querySelector('.cr-streaming-duration')}})()`)
|
||||
const marks = snapshots.optimistic.marks
|
||||
const t0 = snapshots.optimistic.t0
|
||||
check('send switches to the conversation view immediately', marks.conversation - t0, within(500))
|
||||
check('user message is visible immediately after send', marks.userMessage - t0, within(500))
|
||||
check('startup placeholder appears immediately after send', marks.prepareText - t0, within(500))
|
||||
check('streaming duration is running during the wait', marks.duration - t0, within(500))
|
||||
check('composer draft is cleared at send time', snapshots.optimistic.inputValue, '')
|
||||
check('home surface is left at send time', snapshots.optimistic.home, false)
|
||||
check('optimistic user message is on screen', snapshots.optimistic.userBubbles.some(text => text.includes(message)), true)
|
||||
check('startup placeholder text is the recovered acquiring_runtime string', snapshots.optimistic.prepare, true)
|
||||
|
||||
await step('real-turn')
|
||||
const sessions = await read('window.ycode.session.list()')
|
||||
const sessionId = sessions[0] && sessions[0].id
|
||||
check('exactly one session was created for the send', sessions.length, 1)
|
||||
const done = await wait(`window.ycode.session.messages(${JSON.stringify(sessionId)}).then(list=>{const last=list.at(-1);return last&&last.status==='done'&&last})`, 'turn finished')
|
||||
check('local upstream answered the turn', done.content, 'LOCAL_REPLY_model-one')
|
||||
await wait(`document.body.textContent.includes('LOCAL_REPLY_model-one')`, 'reply rendered')
|
||||
await pause(250)
|
||||
snapshots.finished = await read(`(()=>({userBubbles:[...document.querySelectorAll('.cr-self-bubble')].map(node=>node.textContent.trim()),
|
||||
prepare:document.body.textContent.includes('正在启动对话运行环境…'),duration:!!document.querySelector('.cr-streaming-duration'),
|
||||
reply:document.body.textContent.includes('LOCAL_REPLY_model-one'),marks:{...window.__qa.marks}}))()`)
|
||||
const finalMarks = snapshots.finished.marks
|
||||
check('startup placeholder is cleared before the reply arrives', finalMarks.prepareCleared !== undefined && finalMarks.prepareCleared <= (finalMarks.reply ?? Number.POSITIVE_INFINITY), true)
|
||||
check('startup placeholder is gone after the turn', snapshots.finished.prepare, false)
|
||||
check('streaming placeholder is replaced by the finished footer', snapshots.finished.duration, false)
|
||||
check('exactly one user message after the turn (no optimistic duplicate)', snapshots.finished.userBubbles.length, 1)
|
||||
check('reply is rendered in the conversation', snapshots.finished.reply, true)
|
||||
check('real turn took longer than the optimistic render (placeholder was needed)', finalMarks.reply - finalMarks.userMessage,
|
||||
value => typeof value === 'number' && value > 300)
|
||||
snapshots.timings = { conversationMs: finalMarks.conversation - t0, userMessageMs: finalMarks.userMessage - t0,
|
||||
prepareTextMs: finalMarks.prepareText - t0, durationMs: finalMarks.duration - t0,
|
||||
prepareClearedMs: finalMarks.prepareCleared === undefined ? null : finalMarks.prepareCleared - t0,
|
||||
replyMs: finalMarks.reply === undefined ? null : finalMarks.reply - t0 }
|
||||
|
||||
await step('create-success-send-failure')
|
||||
const originalSessionSnapshot = {
|
||||
sessions: await read('window.ycode.session.list()'),
|
||||
messages: await read(`window.ycode.session.messages(${JSON.stringify(sessionId)})`)
|
||||
}
|
||||
const startFailureWorkspace = await read('window.ycode.workspace.get()')
|
||||
await fs.mkdir(startFailureWorkspace.root, { recursive: true })
|
||||
const startFailureReferencePath = path.join(startFailureWorkspace.root, 'qa-start-failure-reference.txt')
|
||||
await fs.writeFile(startFailureReferencePath, 'WORKSPACE_REFERENCE_MUST_SURVIVE_CREATE_SUCCESS_SEND_FAILURE\n')
|
||||
|
||||
await read(`document.querySelector('[title="新建任务"]').click()`)
|
||||
await wait(`!!document.querySelector('.wb-home-composer')`, 'home surface for create-success send failure')
|
||||
const startFailureText = `${preStartFailureMarker} 正文草稿必须保留`
|
||||
const startFailureAttachmentName = 'qa-start-failure-attachment.txt'
|
||||
await dropDraftFixtures(startFailureAttachmentName, 'ATTACHMENT_MUST_SURVIVE', startFailureReferencePath)
|
||||
await wait(`(()=>{const names=[...document.querySelectorAll('.wb-composer__attachment-name')].map(node=>node.textContent);
|
||||
const refs=[...document.querySelectorAll('.wb-composer__resources .wb-composer__resource')].map(node=>node.textContent);
|
||||
return names.includes(${JSON.stringify(startFailureAttachmentName)})&&refs.some(text=>text.includes('qa-start-failure-reference.txt'))})()`,
|
||||
'attachment and workspace reference staged')
|
||||
await fill(startFailureText)
|
||||
await wait(`[...document.querySelectorAll('.wb-composer__input')].some(node=>node.offsetParent!==null&&node.value===${JSON.stringify(startFailureText)})`,
|
||||
'durable draft text entered')
|
||||
await read(`document.querySelector('button[aria-label="发送"]').click()`)
|
||||
await wait(`!!(document.querySelector('.wb-composer__error[role="alert"]')||document.querySelector('.msg__status--error'))`,
|
||||
'controlled pre-start failure surfaced')
|
||||
await wait(`(()=>{const input=[...document.querySelectorAll('.wb-composer__input')].find(node=>node.offsetParent!==null);
|
||||
const names=[...document.querySelectorAll('.wb-composer__attachment-name')].map(node=>node.textContent);
|
||||
const refs=[...document.querySelectorAll('.wb-composer__resources .wb-composer__resource')].map(node=>node.textContent);
|
||||
return input?.value===${JSON.stringify(startFailureText)}
|
||||
&&names.includes(${JSON.stringify(startFailureAttachmentName)})
|
||||
&&refs.some(text=>text.includes('qa-start-failure-reference.txt'))})()`,
|
||||
'durable draft restored after controlled failure')
|
||||
await pause(250)
|
||||
const sessionsAfterControlledFailure = await read('window.ycode.session.list()')
|
||||
snapshots.createSuccessSendFailure = await read(`(()=>{const input=[...document.querySelectorAll('.wb-composer__input')].find(node=>node.offsetParent!==null);
|
||||
const picker=document.querySelector('.cr-workspace-picker');
|
||||
const composerError=document.querySelector('.wb-composer__error');const statusError=document.querySelector('.msg__status--error');
|
||||
return {inputValue:input?.value??null,home:!!document.querySelector('.wb-home-composer'),
|
||||
attachments:[...document.querySelectorAll('.wb-composer__attachment-name')].map(node=>node.textContent),
|
||||
resources:[...document.querySelectorAll('.wb-composer__resources .wb-composer__resource')].map(node=>node.textContent),
|
||||
workspaceEnabled:!!picker&&!picker.disabled&&picker.getAttribute('aria-disabled')!=='true',
|
||||
error:composerError?.textContent??statusError?.textContent??null}})()`)
|
||||
check('chat:send failure consumed exactly one controlled request', controlledFailureCalls, 1)
|
||||
check('controlled failure happened after session creation', typeof controlledFailureSessionId === 'string' && controlledFailureSessionId.length > 0, true)
|
||||
check('empty failed session was atomically deleted', sessionsAfterControlledFailure.some(item => item.id === controlledFailureSessionId), false)
|
||||
check('existing completed session list remains unchanged', sessionsAfterControlledFailure, originalSessionSnapshot.sessions)
|
||||
check('existing completed session messages remain unchanged',
|
||||
await read(`window.ycode.session.messages(${JSON.stringify(sessionId)})`), originalSessionSnapshot.messages)
|
||||
check('controlled failure returns to home', snapshots.createSuccessSendFailure.home, true)
|
||||
check('controlled failure preserves body draft', snapshots.createSuccessSendFailure.inputValue, startFailureText)
|
||||
check('controlled failure preserves attachment draft',
|
||||
snapshots.createSuccessSendFailure.attachments.includes(startFailureAttachmentName), true)
|
||||
check('controlled failure preserves workspace reference draft',
|
||||
snapshots.createSuccessSendFailure.resources.some(text => text.includes('qa-start-failure-reference.txt')), true)
|
||||
check('controlled failure returns with an enabled workspace picker', snapshots.createSuccessSendFailure.workspaceEnabled, true)
|
||||
check('controlled failure error is visible',
|
||||
snapshots.createSuccessSendFailure.error, value => typeof value === 'string' && value.includes('QA controlled chat:send failure'))
|
||||
|
||||
await read(`(()=>{document.querySelector('.cr-workspace-picker')?.click();return true})()`)
|
||||
await wait(`!!document.querySelector('.source-workspace-picker-portal [role="menu"]')`, 'recovered workspace menu opens')
|
||||
const recoveredWorkspaceMenu = await read(`(()=>{const menu=document.querySelector('.source-workspace-picker-portal [role="menu"]');
|
||||
return {options:menu?menu.querySelectorAll('[data-workspace-option]').length:0,
|
||||
labels:menu?[...menu.querySelectorAll('[data-workspace-option]')].map(node=>node.textContent.trim()):[]}})()`)
|
||||
check('recovered new-task workspace menu is actionable', recoveredWorkspaceMenu.options >= 2, true)
|
||||
await read(`(()=>{document.querySelector('.cr-workspace-picker')?.click();return true})()`)
|
||||
await wait(`!document.querySelector('.source-workspace-picker-portal [role="menu"]')`, 'workspace menu closes')
|
||||
|
||||
await read(`document.querySelector('button[aria-label="发送"]').click()`)
|
||||
await wait(`window.ycode.session.list().then(list=>list.length===2)`, 'retry creates a real session')
|
||||
const sessionsAfterRetry = await read('window.ycode.session.list()')
|
||||
const retrySessionId = sessionsAfterRetry.find(item => item.id !== sessionId)?.id
|
||||
check('retry creates one replacement session', typeof retrySessionId === 'string' && !!retrySessionId, true)
|
||||
const retryDone = await wait(`window.ycode.session.messages(${JSON.stringify(retrySessionId)}).then(list=>{const last=list.at(-1);return last&&last.status==='done'&&last})`,
|
||||
'retry turn finishes')
|
||||
const retryMessages = await read(`window.ycode.session.messages(${JSON.stringify(retrySessionId)})`)
|
||||
const retryUser = retryMessages.find(item => item.role === 'user' && item.content?.includes(preStartFailureMarker))
|
||||
snapshots.createSuccessSendFailureRetry = { sessionId: retrySessionId, assistant: retryDone, user: retryUser }
|
||||
check('retry keeps the body draft', retryUser?.content, startFailureText)
|
||||
check('retry commits the preserved attachment',
|
||||
retryUser?.attachments?.some(item => item.name === startFailureAttachmentName), true)
|
||||
check('retry commits the preserved workspace reference',
|
||||
retryUser?.resources?.some(item => item.endsWith('qa-start-failure-reference.txt')), true)
|
||||
check('retry reply is rendered', retryDone.content, 'LOCAL_REPLY_model-one')
|
||||
check('existing completed session list still unchanged after retry',
|
||||
await read('window.ycode.session.list()').then(list => list.find(item => item.id === sessionId)),
|
||||
originalSessionSnapshot.sessions.find(item => item.id === sessionId))
|
||||
|
||||
await step('send-failure')
|
||||
// 清空模型注册表:发送会在 chat:send 的模型解析阶段被拒绝(早于任何回合/网络请求),
|
||||
// 这是「发送失败」路径;同时避免误连外部网关。
|
||||
const removed = await read(`window.ycode.models.list()`)
|
||||
for (const model of removed.models) await read(`window.ycode.models.remove(${JSON.stringify(model.id)})`)
|
||||
const registryAfterRemoval = await read(`window.ycode.models.list()`)
|
||||
check('model registry is empty for the failure case', registryAfterRemoval.models.length, 0)
|
||||
await read(`document.querySelector('[title="新建任务"]').click()`)
|
||||
await wait(`!!document.querySelector('.wb-home-composer')`, 'home surface after new task')
|
||||
const failureMessage = 'QA 发送失败草稿恢复'
|
||||
await fill(failureMessage)
|
||||
await wait(`(()=>{const button=document.querySelector('button[aria-label="发送"]');return !!button&&!button.disabled})()`, 'send enabled again')
|
||||
await read(`document.querySelector('button[aria-label="发送"]').click()`)
|
||||
// 发送失败的错误可能落在发送框(发送框自身状态)或会话区状态行(store error);
|
||||
// 首条消息失败时布局会从对话页切回新建页,发送框重挂载,因此两处都接受。
|
||||
await wait(`!!(document.querySelector('.wb-composer__error[role="alert"]')||document.querySelector('.msg__status--error'))`, 'send error surfaced')
|
||||
await pause(250)
|
||||
snapshots.failure = await read(`(()=>{const input=[...document.querySelectorAll('.wb-composer__input')].find(node=>node.offsetParent!==null);
|
||||
const composerError=document.querySelector('.wb-composer__error');const statusError=document.querySelector('.msg__status--error');
|
||||
return {inputValue:input?input.value:null,
|
||||
userBubbles:document.querySelectorAll('.cr-self-bubble').length,
|
||||
composerError:composerError?composerError.textContent:null,statusError:statusError?statusError.textContent:null,
|
||||
prepare:document.body.textContent.includes('正在启动对话运行环境…'),home:!!document.querySelector('.wb-home-composer')}})()`)
|
||||
check('failed send restores the draft text', snapshots.failure.inputValue, failureMessage)
|
||||
check('failed send leaves no optimistic user message', snapshots.failure.userBubbles, 0)
|
||||
check('failed send surfaces an error', snapshots.failure.composerError ?? snapshots.failure.statusError,
|
||||
value => typeof value === 'string' && /模型/.test(value))
|
||||
check('failed send leaves no startup placeholder', snapshots.failure.prepare, false)
|
||||
check('failed send returns to the home surface', snapshots.failure.home, true)
|
||||
|
||||
await step('stop-and-switch')
|
||||
await read(`window.ycode.models.save(${JSON.stringify({ id: 'local-one', name: 'Local One', providerId: 'custom',
|
||||
model: 'model-one', baseUrl, apiKey: 'LOCAL_TEST_KEY', supportsToolCall: true, maxInputTokens: 64000, maxOutputTokens: 4096 })})`)
|
||||
await read('window.ycode.models.select("local-one")')
|
||||
const idsBeforeStopScenario = (await read('window.ycode.session.list()')).map(session => session.id)
|
||||
await read(`document.querySelector('[title="新建任务"]').click()`)
|
||||
await wait(`!!document.querySelector('.wb-home-composer')`, 'home surface for the stop scenario')
|
||||
const stopMessage = 'QA 停止验收'
|
||||
await fill(stopMessage)
|
||||
await wait(`(()=>{const button=document.querySelector('button[aria-label="发送"]');return !!button&&!button.disabled})()`, 'send enabled for the stop scenario')
|
||||
await read(`document.querySelector('button[aria-label="发送"]').click()`)
|
||||
await wait(`document.body.textContent.includes('正在启动对话运行环境…')`, 'startup placeholder for the stop scenario')
|
||||
const stopSessionId = (await read('window.ycode.session.list()')).map(session => session.id)
|
||||
.find(id => !idsBeforeStopScenario.includes(id))
|
||||
check('stop scenario created exactly one new session', typeof stopSessionId === 'string' && !!stopSessionId, true)
|
||||
await wait(`!!document.querySelector('button[aria-label="停止"]')`, 'stop button available while running')
|
||||
// 运行中切到新任务再切回:消息与运行状态不丢。
|
||||
await read(`document.querySelector('[title="新建任务"]').click()`)
|
||||
await wait(`!!document.querySelector('.wb-home-composer') && !document.querySelector('.cr-self-bubble')`, 'switched to a new task mid-turn')
|
||||
await read(`(()=>{const row=[...document.querySelectorAll('[data-session-id]')].find(node=>node.getAttribute('data-session-id')===${JSON.stringify(stopSessionId)});if(row)row.click();return !!row})()`)
|
||||
await wait(`[...document.querySelectorAll('.cr-self-bubble')].some(node=>node.textContent.includes(${JSON.stringify(stopMessage)}))`, 'session restored after switching back')
|
||||
snapshots.switchedBack = await read(`(()=>({user:[...document.querySelectorAll('.cr-self-bubble')].some(node=>node.textContent.includes(${JSON.stringify(stopMessage)})),
|
||||
streaming:!!document.querySelector('.wb-cr-streaming-footer'),stop:!!document.querySelector('button[aria-label="停止"]')}))()`)
|
||||
check('switching tasks mid-turn keeps the message and the running turn', snapshots.switchedBack.user && snapshots.switchedBack.streaming && snapshots.switchedBack.stop, true)
|
||||
|
||||
await step('workbench-escape-precedence')
|
||||
await read(`window.dispatchEvent(new CustomEvent('ycode:open-artifacts',{detail:{sessionId:${JSON.stringify(stopSessionId)}}}))`)
|
||||
await wait(`!!document.querySelector('.yc-workbench')`, 'right workbench opens during generation')
|
||||
await read(`(()=>{const button=document.querySelector('.yc-workbench button[aria-label="全屏"]');
|
||||
if(!button)throw new Error('Missing fullscreen button');button.click();return true})()`)
|
||||
await wait(`document.querySelector('.yc-workbench')?.classList.contains('yc-workbench--fullscreen')===true`,
|
||||
'right workbench enters fullscreen')
|
||||
const escapeFocusInsideWorkbench = await read(`(()=>{const button=document.querySelector('.yc-workbench button[aria-label="退出全屏"]');
|
||||
button?.focus();return !!button&&document.activeElement===button&&!!document.activeElement.closest('.yc-workbench')})()`)
|
||||
check('fullscreen Escape starts with focus inside the workbench', escapeFocusInsideWorkbench, true)
|
||||
main.webContents.sendInputEvent({ type: 'keyDown', keyCode: 'Escape' })
|
||||
main.webContents.sendInputEvent({ type: 'keyUp', keyCode: 'Escape' })
|
||||
await wait(`document.querySelector('.yc-workbench')?.classList.contains('yc-workbench--fullscreen')===false`,
|
||||
'fullscreen Escape exits fullscreen')
|
||||
await pause(250)
|
||||
const statusAfterFullscreenEscape = await read(`window.ycode.session.messages(${JSON.stringify(stopSessionId)}).then(list=>list.at(-1)?.status)`)
|
||||
snapshots.fullscreenEscape = {
|
||||
fullscreen: await read(`document.querySelector('.yc-workbench')?.classList.contains('yc-workbench--fullscreen')===true`),
|
||||
assistantStatus: statusAfterFullscreenEscape,
|
||||
stopVisible: await read(`!!document.querySelector('button[aria-label="停止"]')`)
|
||||
}
|
||||
check('fullscreen Escape does not cancel the running turn',
|
||||
snapshots.fullscreenEscape.fullscreen === false && snapshots.fullscreenEscape.assistantStatus === 'streaming'
|
||||
&& snapshots.fullscreenEscape.stopVisible === true, true)
|
||||
|
||||
const normalEscapeFocusInsideWorkbench = await read(`(()=>{const button=document.querySelector('.yc-workbench button[aria-label="收起面板"]');
|
||||
button?.focus();return !!button&&document.activeElement===button&&!!document.activeElement.closest('.yc-workbench')})()`)
|
||||
check('non-fullscreen Escape starts with focus inside the workbench', normalEscapeFocusInsideWorkbench, true)
|
||||
main.webContents.sendInputEvent({ type: 'keyDown', keyCode: 'Escape' })
|
||||
main.webContents.sendInputEvent({ type: 'keyUp', keyCode: 'Escape' })
|
||||
await wait(`window.ycode.session.messages(${JSON.stringify(stopSessionId)}).then(list=>list.at(-1)?.status==='cancelled')`, 'turn cancelled')
|
||||
await pause(250)
|
||||
snapshots.stopped = await read(`(()=>({userBubbles:[...document.querySelectorAll('.cr-self-bubble')].map(node=>node.textContent.trim()),
|
||||
cancelled:!!document.querySelector('.cr-cancelled-indicator'),streaming:!!document.querySelector('.wb-cr-streaming-footer'),
|
||||
prepare:document.body.textContent.includes('正在启动对话运行环境…')}))()`)
|
||||
check('cancelled turn keeps exactly one user message', snapshots.stopped.userBubbles.length, 1)
|
||||
check('cancelled turn shows the cancelled indicator', snapshots.stopped.cancelled, true)
|
||||
check('cancelled turn leaves no streaming placeholder', snapshots.stopped.streaming, false)
|
||||
check('cancelled turn leaves no startup placeholder', snapshots.stopped.prepare, false)
|
||||
check('no renderer error logs', rendererErrors, [])
|
||||
|
||||
const report = { result: checks.every(item => item.pass) ? 'PASS' : 'FAIL',
|
||||
scope: 'Real isolated YCode App/preload/IPC/renderer with real OMP and a controlled local upstream; new-task send timing, startup placeholder, real-turn replacement and failure recovery measured in the live window.',
|
||||
sourceEvidence: ['ev_717fa5e846e64d67ac19c8925d121bff632c0d3aefb3603b10858559cabb2dcb', 'docs/对话启动提示文案-2026-10-16.md', '.tmp/todo08-wb-new-task/notes.md'],
|
||||
snapshots, checks, rendererErrors }
|
||||
await fs.writeFile(path.join(directory, 'report.json'), JSON.stringify(report, null, 2))
|
||||
console.log('NEW_TASK_SWITCH_' + report.result + ' ' + directory)
|
||||
for (const failure of checks.filter(item => !item.pass)) console.log('NEW_TASK_SWITCH_FAILURE ' + JSON.stringify(failure))
|
||||
server.close()
|
||||
app.exit(report.result === 'PASS' ? 0 : 1)
|
||||
})().catch(async error => {
|
||||
await fs.writeFile(path.join(directory, 'failure.json'), JSON.stringify({ phase, error: String(error), stack: error.stack, checks, snapshots, rendererErrors }, null, 2))
|
||||
console.error(error)
|
||||
server?.close()
|
||||
app.exit(1)
|
||||
})
|
||||
}
|
||||
@@ -16,8 +16,7 @@ if (!process.versions.electron) {
|
||||
id: `fixture-artifact-${number}`, name: 'panel-sentinel.txt', path: 'panel-sentinel.txt' } }))] }] : [])] }))
|
||||
await fs.writeFile(path.join(directory, 'data', 'sessions.json'), JSON.stringify({ sessions: fixtures }))
|
||||
await fs.writeFile(path.join(cwd, 'panel-sentinel.txt'), 'PANEL_NATIVE_RESOURCE_SENTINEL')
|
||||
const env = { ...process.env, YCODE_QA_DIRECTORY: directory, YCODE_USER_DATA_DIR: path.join(directory, 'data'),
|
||||
YCODE_OMP_HOME: path.join(directory, 'home'), YCODE_DISABLE_GPU: '1', YCODE_QA_OFFSCREEN: '1', YCODE_QA_PANEL_FIXTURES: JSON.stringify(fixtures) }
|
||||
const env = { ...process.env, YCODE_QA_DIRECTORY: directory, YCODE_USER_DATA_DIR: path.join(directory, 'data'), YCODE_DISABLE_GPU: '1', YCODE_QA_OFFSCREEN: '1', YCODE_QA_PANEL_FIXTURES: JSON.stringify(fixtures) }
|
||||
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'] })
|
||||
|
||||
@@ -22,7 +22,7 @@ if (!process.versions.electron) {
|
||||
const data = path.join(directory, 'data'), home = path.join(directory, 'home')
|
||||
await fs.mkdir(data, { recursive: true })
|
||||
await fs.mkdir(home, { recursive: true })
|
||||
const env = { ...process.env, YCODE_QA_DIRECTORY: directory, YCODE_USER_DATA_DIR: data, YCODE_OMP_HOME: home,
|
||||
const env = { ...process.env, YCODE_QA_DIRECTORY: directory, YCODE_USER_DATA_DIR: data,
|
||||
YCODE_DISABLE_GPU: '1', YCODE_DISABLE_SYSTEM_INTEGRATION: '1' }
|
||||
delete env.ELECTRON_RUN_AS_NODE; delete env.ELECTRON_RENDERER_URL
|
||||
const child = require('node:child_process').spawn(require('electron'),
|
||||
|
||||
@@ -1,317 +0,0 @@
|
||||
// Production YCode UI -> real preload/IPC -> real OMP -> controlled loopback upstream.
|
||||
// No renderer-store writes, fake IPC, or direct API mutations of models/spaces/sessions/chat.
|
||||
const fs = require('node:fs/promises')
|
||||
const path = require('node:path')
|
||||
const assert = require('node:assert/strict')
|
||||
const tempRoot = path.resolve('.tmp')
|
||||
function assertContained(root, target) {
|
||||
const relative = path.relative(path.resolve(root), path.resolve(target))
|
||||
assert.ok(relative && !relative.startsWith('..') && !path.isAbsolute(relative), `Unsafe QA path: ${target}`)
|
||||
}
|
||||
function assertQaDirectory(directory) {
|
||||
assertContained(tempRoot, directory)
|
||||
assert.match(path.basename(directory), /^product-flows-[A-Za-z0-9]+$/)
|
||||
}
|
||||
if (!process.versions.electron) {
|
||||
const { spawn } = require('node:child_process')
|
||||
;(async () => {
|
||||
// Verify the intended location before the first filesystem mutation.
|
||||
assertContained(tempRoot, path.resolve('.tmp/product-flows-pending'))
|
||||
const directory = await fs.mkdtemp(path.join(tempRoot, 'product-flows-'))
|
||||
assertQaDirectory(directory)
|
||||
const data = path.join(directory, 'data'), ompHome = path.join(directory, 'home')
|
||||
assertContained(directory, data); assertContained(directory, ompHome)
|
||||
await fs.mkdir(data); await fs.mkdir(ompHome)
|
||||
const realOmp = process.env.YCODE_OMP_PATH || path.join(process.env.LOCALAPPDATA || '', 'Ygent', 'omp.exe')
|
||||
assert.ok((await fs.stat(realOmp)).isFile(), 'A real OMP executable is required')
|
||||
const baseEnv = { ...process.env, YCODE_USER_DATA_DIR: data, YCODE_OMP_HOME: ompHome,
|
||||
YCODE_QA_DIRECTORY: directory, YCODE_QA_REAL_OMP: realOmp, YCODE_DISABLE_GPU: '1', YCODE_QA_OFFSCREEN: '1' }
|
||||
delete baseEnv.ELECTRON_RUN_AS_NODE
|
||||
async function child(stage) {
|
||||
return new Promise(resolve => {
|
||||
const processChild = spawn(require('electron'), ['--no-sandbox', '--disable-gpu-sandbox', __filename], {
|
||||
env: { ...baseEnv, YCODE_QA_PRODUCT_STAGE: stage }, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] })
|
||||
let output = ''
|
||||
processChild.stdout.on('data', bytes => { output += bytes; if (bytes.toString().includes('PRODUCT_UI_')) process.stdout.write(bytes) })
|
||||
processChild.stderr.on('data', bytes => { output += bytes })
|
||||
const timer = setTimeout(() => processChild.kill(), 150000)
|
||||
processChild.once('exit', async code => {
|
||||
clearTimeout(timer)
|
||||
assertQaDirectory(directory)
|
||||
await fs.writeFile(path.join(directory, `${stage}-process.log`), output)
|
||||
if (code) console.error(output.slice(-7000))
|
||||
resolve(code ?? 1)
|
||||
})
|
||||
processChild.once('error', error => { console.error(error); resolve(1) })
|
||||
})
|
||||
}
|
||||
let code = await child('run')
|
||||
if (!code) code = await child('restart')
|
||||
console.log(JSON.stringify({ directory, result: code ? 'FAIL' : 'PASS', exitCode: code }))
|
||||
process.exitCode = code
|
||||
})().catch(error => { console.error(error); process.exitCode = 1 })
|
||||
} else {
|
||||
const { app, BrowserWindow } = require('electron')
|
||||
// This must precede every await; enabling software rendering after ready can hang capturePage.
|
||||
app.disableHardwareAcceleration()
|
||||
const { createServer } = require('node:http')
|
||||
const { pathToFileURL } = require('node:url')
|
||||
const directory = process.env.YCODE_QA_DIRECTORY
|
||||
assertQaDirectory(directory)
|
||||
const expectedData = path.join(directory, 'data'), expectedHome = path.join(directory, 'home')
|
||||
assert.equal(path.resolve(process.env.YCODE_USER_DATA_DIR), path.resolve(expectedData))
|
||||
assert.equal(path.resolve(process.env.YCODE_OMP_HOME), path.resolve(expectedHome))
|
||||
app.setPath('userData', expectedData); app.setPath('sessionData', expectedData); app.setPath('home', expectedHome)
|
||||
assert.equal(path.resolve(app.getPath('userData')), path.resolve(expectedData))
|
||||
process.env.YCODE_OMP_PATH = process.env.YCODE_QA_REAL_OMP
|
||||
const stage = process.env.YCODE_QA_PRODUCT_STAGE
|
||||
const requests = [], checks = []
|
||||
let main, settings, server, currentPhase = 'boot'
|
||||
const pause = ms => new Promise(resolve => setTimeout(resolve, ms))
|
||||
async function safeWrite(file, content) {
|
||||
assertQaDirectory(directory); assertContained(directory, file)
|
||||
assert.equal(path.resolve(app.getPath('userData')), path.resolve(expectedData))
|
||||
await fs.writeFile(file, content)
|
||||
}
|
||||
async function phase(label) { currentPhase = label; await safeWrite(path.join(directory, `${stage}-phase.txt`), label) }
|
||||
async function read(expression, win = main) {
|
||||
let timer
|
||||
try {
|
||||
return await Promise.race([win.webContents.executeJavaScript(expression), new Promise((_, reject) => { timer = setTimeout(() => reject(new Error('Renderer timeout: ' + expression.slice(0, 180))), 15000) })])
|
||||
} catch (error) { throw new Error(`${error.message} — renderer expression: ${expression.slice(0, 240)}`) }
|
||||
finally { clearTimeout(timer) }
|
||||
}
|
||||
async function wait(expression, label, win = main) {
|
||||
for (let i = 0; i < 650; i++) { const value = await read(expression, win); if (value) return value; await pause(35) }
|
||||
throw new Error('Timed out: ' + label)
|
||||
}
|
||||
async function click(selector, win = main) {
|
||||
await read(`(()=>{const element=document.querySelector(${JSON.stringify(selector)});if(!element)throw Error('Missing clickable: '+${JSON.stringify(selector)});if(element.disabled)throw Error('Disabled clickable');element.scrollIntoView({block:'nearest'});element.click()})()`, win)
|
||||
await pause(40)
|
||||
}
|
||||
async function clickText(selector, text, win = main, exact = true) {
|
||||
await read(`(()=>{const text=${JSON.stringify(text)};const element=[...document.querySelectorAll(${JSON.stringify(selector)})].find(node=>${exact ? 'node.textContent.trim()===text' : 'node.textContent.includes(text)'});if(!element)throw Error('Missing clickable text: '+text);if(element.disabled)throw Error('Disabled clickable: '+text);element.scrollIntoView({block:'nearest'});element.click()})()`, win)
|
||||
await pause(40)
|
||||
}
|
||||
async function fill(selector, value, win = main) {
|
||||
await read(`(()=>{const input=document.querySelector(${JSON.stringify(selector)});if(!input||input.disabled)throw Error('Missing enabled input: '+${JSON.stringify(selector)});input.scrollIntoView({block:'nearest'});input.focus();const proto=input.tagName==='TEXTAREA'?HTMLTextAreaElement.prototype:HTMLInputElement.prototype;Object.getOwnPropertyDescriptor(proto,'value').set.call(input,${JSON.stringify(value)});input.dispatchEvent(new Event('input',{bubbles:true}))})()`, win)
|
||||
await pause(30)
|
||||
}
|
||||
async function shot(name, win = main) {
|
||||
await read('document.fonts.ready.then(()=>true)', win)
|
||||
await pause(100)
|
||||
const image = await Promise.race([win.webContents.capturePage(undefined, { stayHidden: true, stayAwake: true }), pause(10000).then(() => { throw Error('Screenshot timeout: ' + name) })])
|
||||
await safeWrite(path.join(directory, `${name}.png`), image.toPNG())
|
||||
}
|
||||
async function openSettings() {
|
||||
await click('.cr-model-selector__trigger')
|
||||
await wait('!!document.querySelector(".cr-model-selector__menu")', 'main model picker')
|
||||
await clickText('.cr-model-selector__menu > .cr-menu-item', '配置自定义模型', main, false)
|
||||
for (let i = 0; i < 200; i++) {
|
||||
settings = BrowserWindow.getAllWindows().find(win => win !== main)
|
||||
if (settings && !settings.webContents.isLoading()) break
|
||||
await pause(30)
|
||||
}
|
||||
assert.ok(settings, 'UI opened a separate native settings window')
|
||||
await wait('!!document.querySelector(".models-settings-panel__add-button")', 'native model settings', settings)
|
||||
await wait('!!document.querySelector(".models-settings-panel__editor")', 'picker directly opens add model form', settings)
|
||||
assert.deepEqual(await read('[innerWidth,innerHeight]', settings), [900, 680])
|
||||
}
|
||||
async function createSpace(name) {
|
||||
await click('[aria-label="新建工作空间"]')
|
||||
await wait('!!document.querySelector(".create-workspace-modal")', 'new workspace dialog')
|
||||
assert.equal(await read('document.querySelector(".create-workspace-modal__confirm").disabled'), true)
|
||||
await fill('input[aria-label="工作空间名称"]', name)
|
||||
await shot(`space-${name}-create`)
|
||||
await click('.create-workspace-modal__confirm')
|
||||
await wait('!document.querySelector(".create-workspace-modal")', 'workspace created')
|
||||
const spaces = await read('window.ycode.workspace.list()')
|
||||
const space = spaces.workspaces.find(item => item.name === name)
|
||||
assert.ok(space); assertContained(directory, space.root)
|
||||
assert.ok((await fs.stat(space.root)).isDirectory())
|
||||
return space
|
||||
}
|
||||
async function startTask(space) {
|
||||
const before = (await read('window.ycode.session.list()')).length
|
||||
await click(`[data-workspace-id="${space.id}"] .workspace-new-task-button`)
|
||||
await wait('!!document.querySelector(".wb-composer--home")', 'space new-task home')
|
||||
const after = (await read('window.ycode.session.list()')).length
|
||||
assert.equal(after, before, 'new task does not persist before first send')
|
||||
assert.match(await read('document.querySelector(".cr-workspace-picker .cr-input-footer-item__label").textContent'), new RegExp(space.name))
|
||||
return before
|
||||
}
|
||||
async function send(space, prompt, expectedCount) {
|
||||
await fill('.wb-composer__input', prompt)
|
||||
await wait('!document.querySelector(".cr-send-button:not(.cr-send-button--stop)")?.disabled', 'enabled send button')
|
||||
await click('.cr-send-button:not(.cr-send-button--stop)')
|
||||
const sessions = await wait(`window.ycode.session.list().then(s=>s.length===${expectedCount}&&s)`, 'first send creates exactly one session')
|
||||
const session = sessions.find(item => item.workspaceId === space.id)
|
||||
assert.ok(session); assert.equal(path.resolve(session.cwd), path.resolve(space.root)); assert.equal(session.modelId, 'ui-model-two')
|
||||
const terminal = await wait(`window.ycode.session.messages(${JSON.stringify(session.id)}).then(m=>m.length>=2&&m.at(-1)?.status!=='streaming'&&m.at(-1))`, 'real OMP terminal turn')
|
||||
assert.equal(terminal.status, 'done', JSON.stringify(terminal))
|
||||
assert.equal(terminal.content, 'UI_REPLY_ui-model-two')
|
||||
await wait('document.querySelector(".chat-thread")?.textContent.includes("UI_REPLY_ui-model-two")', 'real streamed reply rendered')
|
||||
return session
|
||||
}
|
||||
async function selectSession(id) {
|
||||
await click(`[data-session-id="${id}"]`)
|
||||
await wait(`document.querySelector('[data-session-id="${id}"]')?.dataset.selected==='true'`, 'session selected')
|
||||
await pause(100)
|
||||
}
|
||||
async function openSentinel(expected) {
|
||||
if (await read(`!!document.querySelector('.source-chat-header button[title="打开右侧栏"]')`)) await click('.source-chat-header button[title="打开右侧栏"]')
|
||||
await wait('!!document.querySelector(".yc-workbench")', 'real workbench open')
|
||||
if (!(await read('!!document.querySelector(".source-code-preview,.yc-workbench-text")'))) {
|
||||
await click('[data-entry-key="fileTree"]')
|
||||
await wait('[...document.querySelectorAll(".yc-workbench-tree-node")].some(e=>e.textContent.includes("sentinel.txt"))', 'real workspace file tree')
|
||||
await clickText('.yc-workbench-tree-node', 'sentinel.txt', main, false)
|
||||
}
|
||||
await wait(`document.querySelector('.source-code-preview[data-source-code-state="ready"],.yc-workbench-text')?.textContent.includes(${JSON.stringify(expected)})`, 'session-owned sentinel preview ' + expected)
|
||||
const text = await read('document.querySelector(".source-code-preview,.yc-workbench-text").textContent')
|
||||
assert.ok(!text.includes(expected === 'SPACE_A' ? 'SPACE_B' : 'SPACE_A'))
|
||||
}
|
||||
async function boot() {
|
||||
await import(pathToFileURL(path.resolve('out/main/index.mjs')).href)
|
||||
await app.whenReady()
|
||||
for (let i = 0; i < 200; i++) { main = BrowserWindow.getAllWindows().find(win => win.getTitle() === 'YCode'); if (main && !main.webContents.isLoading()) break; await pause(30) }
|
||||
assert.ok(main)
|
||||
await wait('!!window.ycode && !!document.querySelector(".wb-composer__input")', 'production main UI')
|
||||
const info = await read('window.ycode.app.info()')
|
||||
assert.equal(path.resolve(info.configDir), path.resolve(expectedData))
|
||||
assert.equal(info.omp.available, true, 'real OMP must be available; fallback-only success is insufficient')
|
||||
return info
|
||||
}
|
||||
;(async () => {
|
||||
if (stage === 'restart') {
|
||||
await boot(); await phase('restart-persistence')
|
||||
const previous = JSON.parse(await fs.readFile(path.join(directory, 'report.json'), 'utf8'))
|
||||
const [models, spaces, sessions] = await Promise.all([read('window.ycode.models.list()'), read('window.ycode.workspace.list()'), read('window.ycode.session.list()')])
|
||||
assert.equal(models.selectedModelId, 'ui-model-two')
|
||||
const model = models.models.find(item => item.id === 'ui-model-two')
|
||||
assert.ok(model?.hasApiKey); assert.equal(model.model, 'ui-model-two')
|
||||
assert.equal(spaces.workspaces.length, previous.spaces.length)
|
||||
for (const expected of previous.spaces) assert.ok(spaces.workspaces.some(space => space.id === expected.id && space.root === expected.root))
|
||||
assert.equal(sessions.length, previous.sessions.length)
|
||||
for (const expected of previous.sessions) {
|
||||
const actual = sessions.find(item => item.id === expected.id)
|
||||
assert.equal(actual.workspaceId, expected.workspaceId); assert.equal(actual.cwd, expected.cwd)
|
||||
assert.equal((await read(`window.ycode.session.messages(${JSON.stringify(expected.id)})`)).at(-1).content, 'UI_REPLY_ui-model-two')
|
||||
}
|
||||
await wait('document.querySelector(".cr-model-selector__trigger")?.textContent.includes("ui-model-two")', 'persisted effective model UI')
|
||||
await shot('restart-main')
|
||||
previous.restart = { result: 'PASS', scope: 'new Electron process; models, secret availability, selected model, spaces, sessions, ownership and replies persisted' }
|
||||
await safeWrite(path.join(directory, 'report.json'), JSON.stringify(previous, null, 2))
|
||||
console.log('PRODUCT_UI_RESTART_PASS ' + directory)
|
||||
app.quit(); return
|
||||
}
|
||||
server = createServer(async (request, response) => {
|
||||
try {
|
||||
let raw = ''; for await (const bytes of request) raw += bytes
|
||||
const body = JSON.parse(raw)
|
||||
const authenticated = request.headers.authorization === 'Bearer UI_LOOPBACK_KEY'
|
||||
requests.push({ path: request.url, model: body.model, stream: body.stream === true, authenticated })
|
||||
if (!authenticated) { response.writeHead(401); response.end('{}'); return }
|
||||
const content = `UI_REPLY_${body.model}`
|
||||
if (!body.stream) { response.setHeader('content-type', 'application/json'); response.end(JSON.stringify({ choices: [{ message: { role: 'assistant', content } }] })); return }
|
||||
response.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-cache' })
|
||||
response.write(`data: ${JSON.stringify({ id: 'ui-loopback', object: 'chat.completion.chunk', created: 1, model: body.model, choices: [{ index: 0, delta: { role: 'assistant', content }, finish_reason: null }] })}\n\n`)
|
||||
response.end(`data: ${JSON.stringify({ id: 'ui-loopback', object: 'chat.completion.chunk', created: 1, model: body.model, choices: [{ index: 0, delta: {}, finish_reason: 'stop' }], usage: { prompt_tokens: 20, completion_tokens: 5, total_tokens: 25 } })}\n\ndata: [DONE]\n\n`)
|
||||
} catch { response.writeHead(400); response.end('{}') }
|
||||
})
|
||||
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve))
|
||||
const baseUrl = `http://127.0.0.1:${server.address().port}/v1`
|
||||
await boot(); await phase('model-ui-add')
|
||||
const initialModels = await read('window.ycode.models.list()')
|
||||
await openSettings()
|
||||
const firstSettingsId = settings.id
|
||||
await click('.models-settings-panel__editor-close', settings)
|
||||
await click('[data-settings-tab="appearance"]', settings)
|
||||
await openSettings()
|
||||
assert.equal(settings.id, firstSettingsId, 'existing native settings window is reused')
|
||||
await click('.models-settings-panel__editor-close', settings)
|
||||
await click('[data-settings-tab="appearance"]', settings)
|
||||
await click('[data-settings-tab="models"]', settings)
|
||||
assert.equal(await read('!!document.querySelector(".models-settings-panel__editor")', settings), false, 'consumed add intent does not reopen on normal navigation')
|
||||
checks.push('Main model picker directly opens add form in fresh/reused native settings; consumed intent stays closed on normal navigation')
|
||||
await click('.models-settings-panel__add-button', settings)
|
||||
await click('.models-settings-panel__provider-select', settings)
|
||||
await clickText('[role="option"]', '自定义', settings)
|
||||
await fill('input[aria-label="接口地址"]', baseUrl, settings)
|
||||
await fill('input[aria-label="API Key"]', 'UI_LOOPBACK_KEY', settings)
|
||||
await fill('input[aria-label="模型名称"]', 'ui-model-one', settings)
|
||||
const numberInputs = await read('[...document.querySelectorAll(".models-settings-panel__number-grid input")].map(e=>e.getAttribute("aria-label"))', settings)
|
||||
assert.equal(numberInputs.length, 2)
|
||||
await fill(`input[aria-label="${numberInputs[0]}"]`, '64000', settings)
|
||||
await fill(`input[aria-label="${numberInputs[1]}"]`, '4096', settings)
|
||||
await shot('model-add-complete', settings)
|
||||
await click('.models-settings-panel__editor-test-connection', settings)
|
||||
await wait('document.querySelector(".model-ui-toast")?.textContent.includes("连接成功")', 'model test success', settings)
|
||||
assert.equal((await read('window.ycode.models.list()')).models.length, initialModels.models.length, 'test connection must not save the model')
|
||||
checks.push('UI provider/endpoint/key/model/token entry and successful test without save')
|
||||
await click('.models-settings-panel__editor-save', settings)
|
||||
await wait('!document.querySelector(".models-settings-panel__editor")', 'model saved', settings)
|
||||
assert.equal((await read('window.ycode.models.list()')).models.find(item => item.id === 'ui-model-one').hasApiKey, true)
|
||||
await read(`(()=>{const row=[...document.querySelectorAll('.models-settings-panel__row')].find(e=>e.querySelector('.models-settings-panel__row-name')?.textContent==='ui-model-one');if(!row)throw Error('Missing saved row');row.querySelector('button[aria-label="编辑"]').click()})()`, settings)
|
||||
await wait('!!document.querySelector(".models-settings-panel__editor")', 'edit saved model', settings)
|
||||
assert.equal(await read(`document.querySelector('input[aria-label="API Key"]').value`, settings), '', 'edit preserves secret using a blank keep-value field')
|
||||
await fill('input[aria-label="模型名称"]', 'ui-model-two', settings)
|
||||
await click('.models-settings-panel__editor-save', settings)
|
||||
await wait('!document.querySelector(".models-settings-panel__editor")', 'edited model saved', settings)
|
||||
await shot('model-edited-list', settings)
|
||||
await click('.settings-window-close', settings)
|
||||
await wait('document.querySelector(".cr-model-selector__trigger")&&!document.querySelector(".cr-model-selector__trigger").disabled', 'main model picker available')
|
||||
await click('.cr-model-selector__trigger')
|
||||
await wait('[...document.querySelectorAll("[role=option]")].some(e=>e.textContent.includes("ui-model-two"))', 'updated custom model visible in main')
|
||||
await clickText('[role="option"]', 'ui-model-two', main, false)
|
||||
await wait('document.querySelector(".cr-model-selector__trigger")?.textContent.includes("ui-model-two")', 'edited model selected in composer')
|
||||
assert.equal((await read('window.ycode.models.list()')).selectedModelId, 'ui-model-two')
|
||||
checks.push('UI save/edit with retained key and main model selection')
|
||||
await phase('spaces-ui-first-send')
|
||||
const spaceA = await createSpace('UI空间甲')
|
||||
const countA = await startTask(spaceA)
|
||||
assertContained(directory, path.join(spaceA.root, 'sentinel.txt'))
|
||||
await safeWrite(path.join(spaceA.root, 'sentinel.txt'), 'SPACE_A')
|
||||
const sessionA = await send(spaceA, 'UI_CHECK_A: please reply with one short sentence.', countA + 1)
|
||||
await shot('dialogue-space-a')
|
||||
checks.push('UI space creation and first-send-only session persistence with real OMP reply')
|
||||
const spaceB = await createSpace('UI空间乙')
|
||||
const countB = await startTask(spaceB)
|
||||
assert.equal(await read('document.querySelector(".wb-composer__input").value'), '')
|
||||
await safeWrite(path.join(spaceB.root, 'sentinel.txt'), 'SPACE_B')
|
||||
const sessionB = await send(spaceB, 'UI_CHECK_B: please reply with one short sentence.', countB + 1)
|
||||
assert.equal(await read(`window.ycode.resources.readText('sentinel.txt',${JSON.stringify(sessionA.id)})`), 'SPACE_A')
|
||||
assert.equal(await read(`window.ycode.resources.readText('sentinel.txt',${JSON.stringify(sessionB.id)})`), 'SPACE_B')
|
||||
await phase('session-drafts-and-file-ui')
|
||||
await selectSession(sessionA.id); await fill('.wb-composer__input', 'UNSENT_SPACE_A')
|
||||
await openSentinel('SPACE_A'); await shot('file-space-a')
|
||||
await selectSession(sessionB.id)
|
||||
assert.equal(await read('document.querySelector(".wb-composer__input").value'), '')
|
||||
await fill('.wb-composer__input', 'UNSENT_SPACE_B')
|
||||
await openSentinel('SPACE_B'); await shot('file-space-b')
|
||||
await selectSession(sessionA.id)
|
||||
assert.equal(await read('document.querySelector(".wb-composer__input").value'), 'UNSENT_SPACE_A')
|
||||
await openSentinel('SPACE_A')
|
||||
await selectSession(sessionB.id)
|
||||
assert.equal(await read('document.querySelector(".wb-composer__input").value'), 'UNSENT_SPACE_B')
|
||||
checks.push('two spaces, same-name file isolation, restored per-session UI previews and independent unsent drafts')
|
||||
assert.ok(requests.some(request => request.model === 'ui-model-one' && !request.stream && request.authenticated))
|
||||
assert.equal(requests.filter(request => request.model === 'ui-model-two' && request.stream && request.authenticated).length, 2)
|
||||
const profiles = await fs.readdir(path.join(expectedHome, '.omp', 'profiles'))
|
||||
assert.ok(profiles.length > 0)
|
||||
for (const profile of profiles) assert.ok(!(await fs.readFile(path.join(expectedHome, '.omp', 'profiles', profile, 'agent', 'models.yml'), 'utf8')).includes('UI_LOOPBACK_KEY'))
|
||||
const spaces = (await read('window.ycode.workspace.list()')).workspaces
|
||||
const sessions = await read('window.ycode.session.list()')
|
||||
const report = { result: 'PASS', scope: 'Real production YCode UI, native settings window, preload/IPC and real OMP with a loopback OpenAI-compatible service. No WorkBuddy pixel-parity or external supplier claim.',
|
||||
noStoreWrites: true, noFakeIpc: true, noDirectMutationApis: true, isolatedUserData: expectedData, isolatedOmpHome: expectedHome,
|
||||
checks, models: { tested: 'ui-model-one', savedEditedSelected: 'ui-model-two' }, spaces, sessions, requests }
|
||||
await safeWrite(path.join(directory, 'report.json'), JSON.stringify(report, null, 2))
|
||||
console.log('PRODUCT_UI_FLOWS_PASS ' + directory)
|
||||
server.close(); app.quit()
|
||||
})().catch(async error => {
|
||||
console.error(error)
|
||||
if (main && !main.isDestroyed()) await shot('failure-main').catch(() => undefined)
|
||||
if (settings && !settings.isDestroyed()) await shot('failure-settings', settings).catch(() => undefined)
|
||||
await safeWrite(path.join(directory, `${stage}-failure.json`), JSON.stringify({ result: 'FAIL', phase: currentPhase, error: String(error), checks, requests }, null, 2)).catch(() => undefined)
|
||||
server?.close(); app.exit(1)
|
||||
})
|
||||
}
|
||||
@@ -4,8 +4,7 @@ const assert = require('node:assert/strict')
|
||||
if (!process.versions.electron) {
|
||||
;(async () => {
|
||||
const directory = await fs.mkdtemp(path.resolve('.tmp/project-page-'))
|
||||
const env = { ...process.env, YCODE_QA_DIRECTORY: directory, YCODE_USER_DATA_DIR: path.join(directory, 'data'), YCODE_OMP_HOME: path.join(directory, 'home'), YCODE_DISABLE_GPU: '1', YCODE_DISABLE_SYSTEM_INTEGRATION: '1' }
|
||||
await fs.mkdir(env.YCODE_OMP_HOME, { recursive: true })
|
||||
const env = { ...process.env, YCODE_QA_DIRECTORY: directory, YCODE_USER_DATA_DIR: path.join(directory, 'data'), YCODE_DISABLE_GPU: '1', YCODE_DISABLE_SYSTEM_INTEGRATION: '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 log = ''
|
||||
|
||||
@@ -14,7 +14,7 @@ if (!process.versions.electron) {
|
||||
await fs.writeFile(path.join(cwd,'项目说明.md'),'# QA reference\n')
|
||||
await fs.mkdir(path.join(cwd,'参考文件夹'))
|
||||
await fs.writeFile(path.join(directory,'data','sessions.json'),JSON.stringify({sessions}))
|
||||
const env={...process.env,YCODE_REFERENCE_QA:directory,YCODE_USER_DATA_DIR:path.join(directory,'data'),YCODE_OMP_HOME:path.join(directory,'home'),YCODE_DISABLE_GPU:'1',YCODE_QA_OFFSCREEN:'1'}
|
||||
const env={...process.env,YCODE_REFERENCE_QA:directory,YCODE_USER_DATA_DIR:path.join(directory,'data'), 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',b=>{output+=b;process.stdout.write(b)});child.stderr.on('data',b=>{output+=b})
|
||||
|
||||
@@ -1,206 +0,0 @@
|
||||
// Own isolated production preload/IPC -> actual OMP -> controlled Responses SSE.
|
||||
// No original WorkBuddy observation and no real user model/registry access.
|
||||
const fs = require('node:fs/promises')
|
||||
const path = require('node:path')
|
||||
const assert = require('node:assert/strict')
|
||||
const { spawn } = require('node:child_process')
|
||||
if (!process.versions.electron) {
|
||||
;(async () => {
|
||||
const directory = await fs.mkdtemp(path.resolve('.tmp/responses-integration-'))
|
||||
await Promise.all(['data', 'sessions', 'home'].map(name => fs.mkdir(path.join(directory, name))))
|
||||
const omp = process.env.YCODE_OMP_PATH || path.join(process.env.LOCALAPPDATA || '', 'Ygent', 'omp.exe')
|
||||
assert.ok((await fs.stat(omp)).isFile(), 'Actual OMP executable required')
|
||||
const env = { ...process.env, YCODE_USER_DATA_DIR: path.join(directory, 'data'), YCODE_OMP_HOME: path.join(directory, 'home'),
|
||||
YCODE_QA_DIRECTORY: directory, YCODE_OMP_PATH: omp, YCODE_DISABLE_GPU: '1', YCODE_QA_OFFSCREEN: '1' }
|
||||
delete env.ELECTRON_RUN_AS_NODE; delete env.ELECTRON_RENDERER_URL; delete env.YCODE_MODEL_API_KEY
|
||||
const child = spawn(require('electron'), ['--no-sandbox', '--disable-gpu-sandbox', __filename], { env, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] })
|
||||
child.stdout.on('data', bytes => { if (bytes.toString().includes('RESPONSES_')) process.stdout.write(bytes) })
|
||||
child.stderr.on('data', () => {}) // Consume arbitrary diagnostics without persisting credential-bearing text.
|
||||
const timer = setTimeout(() => child.kill(), 120000)
|
||||
child.once('exit', async code => {
|
||||
clearTimeout(timer)
|
||||
// Secret is generated only inside the child, and its process logs are sanitized there.
|
||||
// Do not persist arbitrary OMP stderr here; diagnostics are recorded in the bounded child report.
|
||||
console.log(JSON.stringify({ directory, exitCode: code }))
|
||||
if (code) console.error('Responses integration failed; inspect the isolated report.json and phase.txt.')
|
||||
process.exitCode = code ?? 1
|
||||
})
|
||||
})().catch(error => { console.error(error); process.exitCode = 1 })
|
||||
} else {
|
||||
const { app, BrowserWindow } = require('electron')
|
||||
const { createServer } = require('node:http')
|
||||
const { randomBytes, createHash } = require('node:crypto')
|
||||
const { pathToFileURL } = require('node:url')
|
||||
const directory = process.env.YCODE_QA_DIRECTORY, data = path.join(directory, 'data'), sessionData = path.join(directory, 'sessions'), home = path.join(directory, 'home')
|
||||
const relative = path.relative(path.resolve('.tmp'), directory)
|
||||
assert.ok(relative.startsWith('responses-integration-') && !relative.includes('..') && !path.isAbsolute(relative))
|
||||
assert.equal(path.resolve(process.env.YCODE_OMP_HOME), home)
|
||||
assert.equal(path.resolve(process.env.YCODE_USER_DATA_DIR), data)
|
||||
app.disableHardwareAcceleration()
|
||||
app.setPath('userData', data); app.setPath('sessionData', sessionData); app.setPath('home', home)
|
||||
const secret = 'controlled-responses-' + randomBytes(24).toString('hex')
|
||||
const prompts = ['RESPONSES_FIRST_REQUEST', 'RESPONSES_SECOND_REQUEST', 'RESPONSES_CANCEL_REQUEST']
|
||||
const replies = ['RESPONSES_REAL_OMP_REPLY_ONE', 'RESPONSES_REAL_OMP_REPLY_TWO']
|
||||
const checks = [], requests = [], sentEvents = [], serverErrors = [], sockets = new Set()
|
||||
let main, server, phase = 'boot', session, info, connectionTest
|
||||
const pause = ms => new Promise(resolve => setTimeout(resolve, ms))
|
||||
const sanitize = value => value === undefined ? null : JSON.parse(JSON.stringify(value, (key, item) => {
|
||||
if (/^(apiKey|authorization|secret|password|credential|cookie)$/i.test(key)) return '[redacted]'
|
||||
return typeof item === 'string' ? item.split(secret).join('[redacted-controlled-secret]') : item
|
||||
}))
|
||||
const check = (label, actual, expected) => checks.push({ label, pass: JSON.stringify(actual) === JSON.stringify(expected), actual: sanitize(actual), expected: sanitize(expected) })
|
||||
async function read(source) {
|
||||
let timer
|
||||
try { return await Promise.race([main.webContents.executeJavaScript(source), new Promise((_, reject) => { timer = setTimeout(() => reject(Error('Renderer timeout')), 15000) })]) }
|
||||
finally { clearTimeout(timer) }
|
||||
}
|
||||
async function wait(readValue, label, timeout = 30000) {
|
||||
const start = Date.now()
|
||||
while (Date.now() - start < timeout) { const value = await readValue(); if (value) return value; await pause(30) }
|
||||
throw Error('Timeout: ' + label)
|
||||
}
|
||||
async function step(label) { phase = label; await fs.writeFile(path.join(directory, 'phase.txt'), label) }
|
||||
function flattenText(value) {
|
||||
if (typeof value === 'string') return value
|
||||
if (Array.isArray(value)) return value.map(flattenText).join('\n')
|
||||
if (value && typeof value === 'object') return ['text', 'content', 'input'].filter(key => Object.hasOwn(value, key)).map(key => flattenText(value[key])).join('\n')
|
||||
return ''
|
||||
}
|
||||
function responseObject(id, model, text, complete) {
|
||||
return { id, object: 'response', created_at: 1700000000, status: complete ? 'completed' : 'in_progress', error: null, incomplete_details: null,
|
||||
model, output: complete ? [{ id: id + '_message', type: 'message', status: 'completed', role: 'assistant', content: [{ type: 'output_text', text, annotations: [] }] }] : [],
|
||||
usage: complete ? { input_tokens: 20, output_tokens: 8, total_tokens: 28, input_tokens_details: { cached_tokens: 0 }, output_tokens_details: { reasoning_tokens: 0 } } : null,
|
||||
parallel_tool_calls: false, tools: [], tool_choice: 'none', store: false }
|
||||
}
|
||||
function streamResponse(response, id, model, text, hold = false) {
|
||||
response.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-cache', connection: 'keep-alive' })
|
||||
let sequence = 0
|
||||
const emit = (type, fields) => {
|
||||
sentEvents.push({ responseId: id, type })
|
||||
response.write(`event: ${type}\ndata: ${JSON.stringify({ type, sequence_number: sequence++, ...fields })}\n\n`)
|
||||
}
|
||||
emit('response.created', { response: responseObject(id, model, '', false) })
|
||||
emit('response.in_progress', { response: responseObject(id, model, '', false) })
|
||||
const item = { id: id + '_message', type: 'message', status: 'in_progress', role: 'assistant', content: [] }
|
||||
emit('response.output_item.added', { output_index: 0, item })
|
||||
emit('response.content_part.added', { item_id: item.id, output_index: 0, content_index: 0, part: { type: 'output_text', text: '', annotations: [] } })
|
||||
emit('response.output_text.delta', { item_id: item.id, output_index: 0, content_index: 0, delta: text, logprobs: [] })
|
||||
if (hold) return // Deliberately leave an actual SSE request open until chat.cancel.
|
||||
emit('response.output_text.done', { item_id: item.id, output_index: 0, content_index: 0, text, logprobs: [] })
|
||||
emit('response.content_part.done', { item_id: item.id, output_index: 0, content_index: 0, part: { type: 'output_text', text, annotations: [] } })
|
||||
const completed = responseObject(id, model, text, true)
|
||||
emit('response.output_item.done', { output_index: 0, item: completed.output[0] })
|
||||
emit('response.completed', { response: completed })
|
||||
response.end()
|
||||
}
|
||||
async function saveReport(extra = {}) {
|
||||
const events = main && !main.isDestroyed() ? await read('window.responsesQaEvents||[]').catch(() => []) : []
|
||||
const messages = session && main && !main.isDestroyed() ? await read(`window.ycode.session.messages(${JSON.stringify(session.id)})`).catch(() => []) : []
|
||||
const mainBytes = await fs.readFile('out/main/index.mjs')
|
||||
const report = sanitize({ result: checks.length && checks.every(item => item.pass) ? 'PASS' : 'FAIL', phase,
|
||||
scope: 'Actual isolated production Electron App/preload/IPC -> actual OMP executable -> loopback standard Responses HTTP/SSE. Controlled fixture is not real user upstream acceptance.',
|
||||
checks, requests, sentEvents, serverErrors, events, messages, connectionTest,
|
||||
isolation: { directory, configDir: info?.configDir, userData: app.getPath('userData'), sessionData: app.getPath('sessionData'), ompHome: home },
|
||||
session: session && { id: session.id, cwd: session.cwd }, buildSha256: createHash('sha256').update(mainBytes).digest('hex'), ...extra })
|
||||
await fs.writeFile(path.join(directory, 'report.json'), JSON.stringify(report, null, 2))
|
||||
}
|
||||
async function cleanup() {
|
||||
for (const socket of sockets) socket.destroy()
|
||||
server?.close()
|
||||
}
|
||||
;(async () => {
|
||||
// Import before the first await, preserving production readiness/GPU ordering.
|
||||
const production = import(pathToFileURL(path.resolve('out/main/index.mjs')).href)
|
||||
server = createServer(async (request, response) => {
|
||||
try {
|
||||
let raw = ''; for await (const bytes of request) { raw += bytes; if (raw.length > 2 * 1024 * 1024) throw Error('Fixture payload budget exceeded') }
|
||||
const body = JSON.parse(raw), text = flattenText(body.input)
|
||||
const marker = prompts.findLast(prompt => text.includes(prompt))
|
||||
const kind = marker ? marker === prompts[2] ? 'cancel' : 'turn' : 'connection-test'
|
||||
requests.push({ kind, method: request.method, path: request.url, model: body.model, stream: body.stream === true,
|
||||
authorizationPresent: !!request.headers.authorization, authorizationMatchesControlledKey: request.headers.authorization === 'Bearer ' + secret,
|
||||
toolsCount: Array.isArray(body.tools) ? body.tools.length : 0, toolChoice: body.tool_choice ?? null,
|
||||
inputRoles: Array.isArray(body.input) ? body.input.map(item => item.role ?? item.type) : [],
|
||||
history: { firstPrompt: text.includes(prompts[0]), firstReply: text.includes(replies[0]), secondPrompt: text.includes(prompts[1]), cancelPrompt: text.includes(prompts[2]) },
|
||||
maxOutputTokens: body.max_output_tokens ?? null, previousResponseIdPresent: !!body.previous_response_id })
|
||||
if (request.url !== '/v1/responses') { response.writeHead(404, { 'content-type': 'application/json' }); response.end(JSON.stringify({ error: { message: 'Unexpected controlled request path', type: 'invalid_request_error' } })); return }
|
||||
if (request.headers.authorization !== 'Bearer ' + secret) { response.writeHead(401); response.end('{}'); return }
|
||||
const reply = marker === prompts[1] ? replies[1] : marker === prompts[2] ? 'RESPONSES_CANCEL_PARTIAL' : marker === prompts[0] ? replies[0] : 'RESPONSES_CONNECTION_OK'
|
||||
const id = 'resp_controlled_' + requests.length
|
||||
if (!body.stream) { response.writeHead(200, { 'content-type': 'application/json' }); response.end(JSON.stringify(responseObject(id, body.model, reply, true))); return }
|
||||
streamResponse(response, id, body.model, reply, kind === 'cancel')
|
||||
} catch (error) { serverErrors.push(String(error)); response.writeHead(400); response.end('{}') }
|
||||
})
|
||||
server.on('connection', socket => { sockets.add(socket); socket.on('close', () => sockets.delete(socket)) })
|
||||
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); await production; await app.whenReady()
|
||||
await wait(async () => { main = BrowserWindow.getAllWindows().find(win => win.getTitle() === 'YCode'); return main && !main.webContents.isLoading() }, 'production main window')
|
||||
await wait(() => read('!!window.ycode&&!!document.querySelector(".chat-pane")'), 'real App/preload')
|
||||
info = await read('window.ycode.app.info()')
|
||||
assert.equal(path.resolve(info.configDir), data, 'Must never write the live user registry')
|
||||
assert.equal(info.omp.available, true, 'Actual OMP executable must be available')
|
||||
check('isolated configDir', path.resolve(info.configDir), data)
|
||||
check('isolated sessionData', app.getPath('sessionData'), sessionData)
|
||||
const input = { id: 'responses-controlled', name: 'Responses Controlled', providerId: 'custom', model: 'responses-fixture',
|
||||
baseUrl: `http://127.0.0.1:${server.address().port}/v1/responses`, apiKey: secret, authMode: 'api-key',
|
||||
supportsToolCall: false, supportsImages: false, supportsReasoning: false, maxInputTokens: 8192, maxOutputTokens: 1234 }
|
||||
await step('save-and-test-explicit-responses-model')
|
||||
const registry = await read(`window.ycode.models.save(${JSON.stringify(input)})`), saved = registry.models.find(model => model.id === input.id)
|
||||
check('preload model view omits existing plaintext key', Object.hasOwn(saved, 'apiKey'), false)
|
||||
check('saved model reports key configured', saved.hasApiKey, true)
|
||||
await read(`window.ycode.models.select(${JSON.stringify(input.id)})`)
|
||||
connectionTest = await read(`window.ycode.models.test(${JSON.stringify({ ...input, apiKey: undefined })})`)
|
||||
check('production models.test accepts explicit Responses endpoint', connectionTest.success, true)
|
||||
const spaces = await read('window.ycode.workspace.generate("Responses隔离验收空间")'), workspace = spaces.workspaces[0]
|
||||
assert.ok(path.resolve(workspace.root).startsWith(directory + path.sep))
|
||||
session = await read(`window.ycode.session.create('Responses Integration',${JSON.stringify({ modelId: input.id, workspaceId: workspace.id })})`)
|
||||
await read('window.responsesQaEvents=[];window.ycode.chat.onEvent(e=>window.responsesQaEvents.push(e));true')
|
||||
for (let turn = 0; turn < 2; turn++) {
|
||||
await step('actual-responses-omp-turn-' + (turn + 1))
|
||||
const run = await read(`window.ycode.chat.send(${JSON.stringify({ sessionId: session.id, text: prompts[turn] })})`)
|
||||
const message = await wait(() => read(`window.ycode.session.messages(${JSON.stringify(session.id)}).then(m=>m.find(x=>x.id===${JSON.stringify(run.assistantMessageId)}&&x.status!=='streaming'))`), 'actual Responses turn terminal', 40000)
|
||||
check(`turn ${turn + 1} terminal done`, message.status, 'done')
|
||||
check(`turn ${turn + 1} actual SSE reply`, message.content, replies[turn])
|
||||
if (message.status !== 'done') throw Error('Actual OMP Responses terminal failure: ' + (message.error || message.status))
|
||||
}
|
||||
const turns = requests.filter(item => item.kind === 'turn')
|
||||
check('two actual OMP streaming turn requests', turns.length, 2)
|
||||
check('correct endpoint no suffix duplication', requests.map(item => item.path), requests.map(() => '/v1/responses'))
|
||||
check('actual model IDs', requests.map(item => item.model), requests.map(() => input.model))
|
||||
check('secret authentication matches controlled key', requests.map(item => item.authorizationMatchesControlledKey), requests.map(() => true))
|
||||
check('actual OMP turns use streaming Responses', turns.map(item => item.stream), [true, true])
|
||||
check('tool-disabled actual OMP requests contain zero tools', turns.map(item => item.toolsCount), [0, 0])
|
||||
check('second turn receives persisted prior user/assistant history', turns[1]?.history, { firstPrompt: true, firstReply: true, secondPrompt: true, cancelPrompt: false })
|
||||
check('actual OMP max output tokens', turns.map(item => item.maxOutputTokens), [1234, 1234])
|
||||
await step('actual-responses-cancel-open-sse')
|
||||
const run = await read(`window.ycode.chat.send(${JSON.stringify({ sessionId: session.id, text: prompts[2] })})`)
|
||||
await wait(() => read(`window.ycode.session.messages(${JSON.stringify(session.id)}).then(m=>m.find(x=>x.id===${JSON.stringify(run.assistantMessageId)}&&x.content.includes('RESPONSES_CANCEL_PARTIAL')))`), 'actual open SSE partial text')
|
||||
const cancellation = await read(`window.ycode.chat.cancel(${JSON.stringify(run.runId)})`)
|
||||
check('production cancel accepted', cancellation.cancelled, true)
|
||||
const cancelled = await wait(() => read(`window.ycode.session.messages(${JSON.stringify(session.id)}).then(m=>m.find(x=>x.id===${JSON.stringify(run.assistantMessageId)}&&x.status!=='streaming'))`), 'actual Responses cancellation terminal', 10000)
|
||||
check('open SSE reaches cancelled rather than done/error', cancelled.status, 'cancelled')
|
||||
check('cancel retains actually received partial text', cancelled.content, 'RESPONSES_CANCEL_PARTIAL')
|
||||
await pause(200)
|
||||
const terminalEvents = await read(`window.responsesQaEvents.filter(e=>e.runId===${JSON.stringify(run.runId)}&&['done','error','cancelled'].includes(e.type)).map(e=>e.type)`)
|
||||
check('exactly one cancellation terminal event', terminalEvents, ['cancelled'])
|
||||
check('cancel request endpoint and tools', requests.filter(item => item.kind === 'cancel').map(item => [item.path, item.toolsCount]), [['/v1/responses', 0]])
|
||||
check('controlled fixture server errors', serverErrors, [])
|
||||
const transcript = path.join(data, 'omp-sessions', `session-${encodeURIComponent(session.id)}.jsonl`)
|
||||
check('real OMP transcript persisted', (await fs.stat(transcript)).size > 0, true)
|
||||
const profiles = await fs.readdir(path.join(home, '.omp', 'profiles')), generated = []
|
||||
for (const name of profiles) { const yaml = await fs.readFile(path.join(home, '.omp', 'profiles', name, 'agent', 'models.yml'), 'utf8'); if (yaml.includes('responses-fixture')) generated.push({ name, yaml }) }
|
||||
check('one generated Responses profile', generated.length, 1)
|
||||
check('actual profile selects openai-responses', /^ api: openai-responses$/m.test(generated[0]?.yaml || ''), true)
|
||||
check('actual profile SDK base strips explicit endpoint suffix', generated[0]?.yaml.includes(JSON.stringify(`http://127.0.0.1:${server.address().port}/v1`)), true)
|
||||
check('profile contains no controlled plaintext secret', generated[0]?.yaml.includes(secret), false)
|
||||
await step('complete')
|
||||
await saveReport({ transcript, generatedProfile: generated[0] })
|
||||
const pass = checks.every(item => item.pass)
|
||||
console.log('RESPONSES_INTEGRATION_' + (pass ? 'PASS ' : 'FAIL ') + directory)
|
||||
for (const item of checks.filter(item => !item.pass)) console.log('RESPONSES_FAILURE ' + JSON.stringify(item))
|
||||
await cleanup(); process.exitCode = pass ? 0 : 1; app.quit()
|
||||
})().catch(async error => {
|
||||
await saveReport({ result: 'FAIL', error: String(error), stack: error.stack }).catch(() => undefined)
|
||||
console.error('RESPONSES_DIAGNOSTIC ' + sanitize(String(error)))
|
||||
await cleanup(); process.exitCode = 1; app.quit()
|
||||
})
|
||||
}
|
||||
@@ -1,246 +0,0 @@
|
||||
const assert = require('node:assert/strict')
|
||||
const fs = require('node:fs/promises')
|
||||
const path = require('node:path')
|
||||
|
||||
if (!process.versions.electron) {
|
||||
;(async () => {
|
||||
const directory = await fs.mkdtemp(path.resolve('.tmp/sandbox-app-'))
|
||||
await Promise.all(['data', 'home', 'workspace'].map(name => fs.mkdir(path.join(directory, name))))
|
||||
const maintenance = path.join(directory, 'maintenance.mjs')
|
||||
await require('esbuild').build({ entryPoints: ['scripts/sandbox-maintain.ts'], bundle: true, platform: 'node', format: 'esm', outfile: maintenance, logLevel: 'silent' })
|
||||
require('node:child_process').execFileSync(process.execPath, [maintenance, 'toolchain-add', 'node', '--source', process.execPath, '--state-root', path.join(directory, 'data/sandbox')], { windowsHide: true, timeout: 60_000, stdio: 'pipe' })
|
||||
const environment = { ...process.env, YCODE_SANDBOX_QA_DIRECTORY: directory, YCODE_USER_DATA_DIR: path.join(directory, 'data'), YCODE_OMP_HOME: path.join(directory, 'home'), YCODE_DISABLE_GPU: '1', YCODE_QA_OFFSCREEN: '1' }
|
||||
delete environment.ELECTRON_RUN_AS_NODE
|
||||
delete environment.ELECTRON_RENDERER_URL
|
||||
const child = require('node:child_process').spawn(require('electron'), ['--no-sandbox', '--disable-gpu-sandbox', __filename], { env: environment, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] })
|
||||
let log = ''
|
||||
child.stdout.on('data', bytes => { log += bytes; process.stdout.write(bytes) })
|
||||
child.stderr.on('data', bytes => { log += bytes })
|
||||
const timer = setTimeout(() => child.kill(), 300_000)
|
||||
child.once('close', async code => {
|
||||
clearTimeout(timer)
|
||||
await fs.writeFile(path.join(directory, 'process.log'), log)
|
||||
if (code) console.error(log.slice(-5000))
|
||||
console.log(`SANDBOX_APP_DIRECTORY ${directory}`)
|
||||
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_SANDBOX_QA_DIRECTORY
|
||||
assert.ok(directory && path.dirname(directory) === path.resolve('.tmp') && path.basename(directory).startsWith('sandbox-app-'))
|
||||
app.setPath('home', path.join(directory, 'home'))
|
||||
BrowserWindow.prototype.show = function () {}
|
||||
BrowserWindow.prototype.showInactive = function () {}
|
||||
let win, server
|
||||
const checks = [], requests = []
|
||||
const fixtureGit = (...args) => require('node:child_process').execFileSync(path.resolve('resources/sandbox/win32-x64/git/files/git.exe'),
|
||||
['-c', 'core.hooksPath=NUL', '-c', 'core.fsmonitor=false', '-c', 'user.name=Fixture', '-c', 'user.email=fixture@example.invalid', ...args],
|
||||
{ cwd: path.join(directory, 'workspace'), windowsHide: true, env: { SystemRoot: process.env.SystemRoot, PATH: path.resolve('resources/sandbox/win32-x64/git/files') + ';' + process.env.SystemRoot + '\\System32', GIT_CONFIG_NOSYSTEM: '1', GIT_CONFIG_GLOBAL: 'NUL', GIT_CONFIG_SYSTEM: 'NUL' } })
|
||||
const pause = ms => new Promise(done => setTimeout(done, ms))
|
||||
const read = source => win.webContents.executeJavaScript(source)
|
||||
const invoke = (namespace, method, ...args) => read(`window.ycode[${JSON.stringify(namespace)}][${JSON.stringify(method)}](...${JSON.stringify(args)})`)
|
||||
const check = (label, condition = true) => { checks.push({ label, pass: !!condition }); assert.ok(condition, label); console.log(`PASS ${label}`) }
|
||||
async function wait(operation, label) {
|
||||
for (let i = 0; i < 1000; i++) { const value = await operation(); if (value) return value; await pause(40) }
|
||||
throw new Error(`Timeout ${label}`)
|
||||
}
|
||||
const frame = (body, delta, finish = null) => `data: ${JSON.stringify({ id: 'sandbox-qa', object: 'chat.completion.chunk', model: body.model, choices: [{ index: 0, delta, finish_reason: finish }] })}\n\n`
|
||||
;(async () => {
|
||||
server = createServer(async (request, response) => {
|
||||
if (request.url.startsWith('/git/repo.git/info/refs')) {
|
||||
const header = Buffer.from('# service=git-upload-pack\n')
|
||||
const advertised = fixtureGit('upload-pack', '--stateless-rpc', '--advertise-refs', path.join(directory, 'workspace/.git'))
|
||||
response.writeHead(200, { 'content-type': 'application/x-git-upload-pack-advertisement' })
|
||||
response.end(Buffer.concat([Buffer.from((header.length + 4).toString(16).padStart(4, '0')), header, Buffer.from('0000'), advertised])); return
|
||||
}
|
||||
let raw = ''; for await (const part of request) raw += part
|
||||
const body = JSON.parse(raw), messages = body.messages || []
|
||||
const prompt = JSON.stringify(messages.filter(message => message.role === 'user').at(-1)?.content || '')
|
||||
requests.push({ model: body.model, prompt })
|
||||
response.writeHead(200, { 'content-type': 'text/event-stream' })
|
||||
const lastUser = messages.findLastIndex(message => message.role === 'user')
|
||||
if (!messages.slice(lastUser + 1).some(message => message.role === 'tool')) {
|
||||
const hostGitArgs = prompt.includes('HOST_GIT_IMPORT') ? { action: 'import' } : prompt.includes('HOST_GIT_CHANGE') ? { action: 'run', argv: ['commit', '--allow-empty', '-m', 'production-host-git-commit'] }
|
||||
: prompt.includes('HOST_GIT_COMMIT') ? { action: 'commit' } : prompt.includes('HOST_GIT_READ') ? { action: 'run', argv: ['log', '-1', '--oneline'] }
|
||||
: prompt.includes('HOST_GIT_REMOTE') ? { action: 'ls-remote', url: `http://127.0.0.1:${server.address().port}/git/repo.git` } : undefined
|
||||
const workspaceAction = prompt.includes('DRAFT_COMMIT') ? 'commit' : prompt.includes('DRAFT_DISCARD') ? 'discard' : undefined
|
||||
const command = prompt.includes('DRAFT_READ') ? 'type draft-only.txt && git log -1 --oneline' : prompt.includes('DRAFT_EXTRA') ? 'echo draft-extra>draft-extra.txt' : prompt.includes('DRAFT_CREATE') ? 'echo draft-only>draft-only.txt && git add draft-only.txt && git commit -q -m production-draft-commit'
|
||||
: prompt.includes('GIT_READ') ? 'git log -1 --oneline && git status --short' : prompt.includes('GIT') ? 'git --version && echo git-approved>git-approved.txt && git add git-approved.txt && git commit -q -m production-git-commit' : prompt.includes('READ_BRIDGE') ? 'node read-bridge.cjs' : prompt.includes('CANCEL') ? 'powershell.exe -NoProfile -NonInteractive -Command "Start-Sleep -Seconds 12; Set-Content late.txt late"'
|
||||
: prompt.includes('DENY') ? 'echo denied>denied.txt' : prompt.includes('REVOKE') ? 'echo revoked>revoked.txt' : prompt.includes('NODE') ? 'node --require "%YCODE_SANDBOX_PROCESS%" -e "const fs=require(\'fs\'),cp=require(\'child_process\');fs.writeFileSync(\'node-approved.txt\',cp.execSync(\'echo node-approved\',{encoding:\'utf8\'}).trim())"' : 'echo approved>approved.txt'
|
||||
response.write(frame(body, { role: 'assistant', tool_calls: [{ index: 0, id: `sandbox-tool-${requests.length}`, type: 'function', function: { name: hostGitArgs ? 'sandbox_git' : workspaceAction ? 'sandbox_workspace' : 'execute_command', arguments: JSON.stringify(hostGitArgs ?? (workspaceAction ? { action: workspaceAction } : { command,
|
||||
...(prompt.includes('DRAFT') ? { workspace: 'persistent', runtimes: ['git'] } : prompt.includes('GIT') ? { runtimes: ['git'] } : prompt.includes('READ_BRIDGE') ? { runtimes: ['node'], bridge: 'workspace-read' } : prompt.includes('NODE') ? { runtimes: ['node'] } : {}) })) } }] }))
|
||||
response.write(frame(body, {}, 'tool_calls'))
|
||||
} else { response.write(frame(body, { role: 'assistant', content: 'SANDBOX_TOOL_RESULT_RECORDED' })); response.write(frame(body, {}, 'stop')) }
|
||||
response.end('data: [DONE]\n\n')
|
||||
})
|
||||
await new Promise(done => server.listen(0, '127.0.0.1', done))
|
||||
await import(pathToFileURL(path.resolve('out/main/index.mjs')).href)
|
||||
await app.whenReady()
|
||||
await wait(async () => {
|
||||
win = BrowserWindow.getAllWindows().find(window => window.getTitle() === 'YCode')
|
||||
return win && !win.webContents.isLoading() && await read('!!window.ycode?.app?.sandboxStatus && !!document.querySelector(".chat-pane")')
|
||||
}, 'production preload and renderer')
|
||||
const status = await invoke('app', 'sandboxStatus')
|
||||
check('production main/preload IPC exposes installed native backend', status.state === 'installed' && status.network === 'deny')
|
||||
const bundledGit = status.toolchains.find(item => item.kind === 'git')
|
||||
check('fresh production userData has bundled Git without a user registration', bundledGit.state === 'registered' && bundledGit.source === 'bundled' && !!bundledGit.version && !await fs.stat(path.join(directory, 'data/sandbox/toolchains/git.json')).catch(() => null))
|
||||
check('installed status does not invent a native self-test observation', !status.selfTest && status.capabilities.some(item => item.id === 'filesystem-broker' && item.state === 'unavailable'))
|
||||
await read('window.sandboxQaPolicyEvents=0; window.ycode.app.onSandboxChanged(()=>window.sandboxQaPolicyEvents++); true')
|
||||
const initialPolicy = await invoke('app', 'sandboxPolicy')
|
||||
let policy = await invoke('app', 'setSandboxPolicy', { expectedRevision: initialPolicy.revision, rules: { maxChanges: 128 } })
|
||||
check('production preload updates a versioned policy and broadcasts changes', policy.revision !== initialPolicy.revision && (await invoke('app', 'sandboxStatus')).policy.revision === policy.revision && await read('window.sandboxQaPolicyEvents>0'))
|
||||
check('production IPC rejects stale policy writes', await invoke('app', 'setSandboxPolicy', { expectedRevision: initialPolicy.revision, rules: { commandsEnabled: false } }).then(() => false, () => true))
|
||||
check('production IPC rejects unsupported network permissions', await invoke('app', 'setSandboxPolicy', { expectedRevision: policy.revision, rules: { network: 'allow' } }).then(() => false, () => true))
|
||||
check('production IPC rejects renderer-supplied workspace policy paths', await invoke('app', 'setSandboxPolicy', { expectedRevision: policy.revision, rules: {}, workspaceRoot: directory }).then(() => false, () => true))
|
||||
const selfTest = await invoke('app', 'sandboxSelfTest')
|
||||
check('production self-test stores a real binary and policy-bound observation', selfTest.passed && (await invoke('app', 'sandboxStatus')).selfTest.runId === selfTest.runId)
|
||||
await read('window.sandboxQaEvents=[]; window.ycode.chat.onEvent(event=>window.sandboxQaEvents.push(event)); true')
|
||||
await invoke('workspace', 'attach', path.join(directory, 'workspace'))
|
||||
await invoke('models', 'save', { id: 'sandbox-local', name: 'Sandbox local QA', providerId: 'custom', model: 'sandbox-upstream', baseUrl: `http://127.0.0.1:${server.address().port}/v1`, authMode: 'none', supportsToolCall: true, maxToolRounds: 4 })
|
||||
await invoke('models', 'select', 'sandbox-local')
|
||||
async function start(text) {
|
||||
const session = await invoke('session', 'create', text)
|
||||
const sent = await invoke('chat', 'send', { sessionId: session.id, text, permissionMode: 'default-sandbox' })
|
||||
return { session, sent }
|
||||
}
|
||||
async function approval(runId) {
|
||||
return wait(() => read(`window.sandboxQaEvents.find(event=>event.runId===${JSON.stringify(runId)} && event.type==='interaction' && event.interaction.status==='pending')?.interaction`), 'real pending approval')
|
||||
}
|
||||
async function finished(sessionId) {
|
||||
return wait(async () => {
|
||||
const messages = await invoke('session', 'messages', sessionId)
|
||||
const assistant = messages.filter(message => message.role === 'assistant').at(-1)
|
||||
return assistant && assistant.status !== 'streaming' && !await invoke('chat', 'activeRun', sessionId) ? assistant : null
|
||||
}, 'persisted assistant terminal')
|
||||
}
|
||||
let current = await start('SANDBOX_ALLOW')
|
||||
let pending = await approval(current.sent.runId)
|
||||
check('real OMP host tool reaches ordinary conversation approval', pending.toolCallId && pending.kind === 'approval')
|
||||
check('host file is unchanged while writeback awaits approval', !(await fs.stat(path.join(directory, 'workspace/approved.txt')).catch(() => null)))
|
||||
const accepted = await invoke('chat', 'respond', current.sent.runId, pending.id, { kind: 'approval', optionId: 'allow-once' })
|
||||
check('actual chat response IPC accepts the matching run and interaction', accepted.accepted)
|
||||
let assistant = await finished(current.session.id)
|
||||
const tool = assistant.tools.find(tool => tool.name === 'execute_command')
|
||||
check('native sandbox metadata survives OMP result and session persistence', tool.result.sandbox === 'windows-appcontainer' && tool.result.writeback === 'committed' && !!tool.result.transactionId)
|
||||
check('production sandbox result retains policy and authorization identity', tool.result.policyRevision === policy.revision && !!tool.result.authorizationId)
|
||||
const storedOperation = await invoke('app', 'sandboxOperation', tool.result.runId)
|
||||
check('production IPC queries durable host outcome with session ownership', storedOperation.writeback === 'committed' && storedOperation.transactionId === tool.result.transactionId && storedOperation.identity.sessionId === current.session.id)
|
||||
check('approved production tool writes actual workspace file', (await fs.readFile(path.join(directory, 'workspace/approved.txt'), 'utf8')).trim() === 'approved')
|
||||
current = await start('SANDBOX_DENY'); pending = await approval(current.sent.runId)
|
||||
await invoke('chat', 'respond', current.sent.runId, pending.id, { kind: 'approval', optionId: 'reject-once' })
|
||||
assistant = await finished(current.session.id)
|
||||
check('rejected production writeback persists rejection and no host file', assistant.tools.find(tool => tool.name === 'execute_command').result.writeback === 'rejected' && !(await fs.stat(path.join(directory, 'workspace/denied.txt')).catch(() => null)))
|
||||
current = await start('SANDBOX_NODE'); pending = await approval(current.sent.runId)
|
||||
check('real OMP forwards the requested controlled Node runtime into native approval', pending.title.includes('node-approved.txt') && !await fs.stat(path.join(directory, 'workspace/node-approved.txt')).catch(() => null))
|
||||
await invoke('chat', 'respond', current.sent.runId, pending.id, { kind: 'approval', optionId: 'allow-once' })
|
||||
assistant = await finished(current.session.id)
|
||||
const nodeTool = assistant.tools.find(tool => tool.name === 'execute_command')
|
||||
check('production Node execution commits only after approval and persists runtime identity', nodeTool.result.writeback === 'committed' && nodeTool.result.toolchains[0].kind === 'node' && await fs.readFile(path.join(directory, 'workspace/node-approved.txt'), 'utf8') === 'node-approved')
|
||||
const readRequestId = require('node:crypto').randomUUID()
|
||||
await fs.writeFile(path.join(directory, 'workspace/read-bridge.cjs'), `const assert=require('assert');const c=require(process.env.YCODE_SANDBOX_BRIDGE).connectSync();try{assert.equal(c.readFileSync('node-approved.txt','utf8'),'node-approved');c.call('workspace.stat',{path:'node-approved.txt'},'${readRequestId}');assert.equal(c.querySync('${readRequestId}').state,'succeeded');console.log('PRODUCTION_READ_BRIDGE_PASS')}finally{c.close()}`)
|
||||
current = await start('SANDBOX_READ_BRIDGE'); assistant = await finished(current.session.id)
|
||||
const readTool = assistant.tools.find(tool => tool.name === 'execute_command')
|
||||
check('real production OMP command reads through the native workspace bridge', readTool.result.workspaceReadBridge === true && readTool.result.stdout.includes('PRODUCTION_READ_BRIDGE_PASS') && readTool.result.writeback === 'unchanged')
|
||||
const readOperation = await invoke('app', 'sandboxBrokerOperation', readTool.result.runId, readRequestId)
|
||||
check('production preload queries a completed bridge result with session ownership', readOperation.state === 'succeeded' && readOperation.owner.sessionId === current.session.id && readOperation.owner.sandboxRunId === readTool.result.runId)
|
||||
check('production bridge query rejects cross-run request substitution', await invoke('app', 'sandboxBrokerOperation', nodeTool.result.runId, readRequestId).then(() => false, () => true))
|
||||
current = await start('SANDBOX_GIT'); pending = await approval(current.sent.runId)
|
||||
check('real OMP Git execution asks approval for both private repository and file changes', pending.title.includes('Git') && !await fs.stat(path.join(directory, 'workspace/git-approved.txt')).catch(() => null))
|
||||
await invoke('chat', 'respond', current.sent.runId, pending.id, { kind: 'approval', optionId: 'allow-once' })
|
||||
assistant = await finished(current.session.id)
|
||||
const gitTool = assistant.tools.find(tool => tool.name === 'execute_command')
|
||||
check('production OMP persists bundled Git identity and approved private commit', gitTool.result.toolchains[0].kind === 'git' && gitTool.result.toolchains[0].id === bundledGit.id && gitTool.result.stdout.includes(`git version ${bundledGit.version}`) && gitTool.result.git.mode === 'private-snapshot' && gitTool.result.git.persistence === 'saved' && gitTool.result.writeback === 'committed')
|
||||
check('production run-query IPC preserves Git persistence separately from file transaction', (await invoke('app', 'sandboxOperation', gitTool.result.runId)).git.revision === gitTool.result.git.revision)
|
||||
current = await start('SANDBOX_GIT_READ'); assistant = await finished(current.session.id)
|
||||
const gitRead = assistant.tools.find(tool => tool.name === 'execute_command')
|
||||
check('later production OMP command reads the saved commit without another approval', gitRead.result.stdout.includes('production-git-commit') && gitRead.result.git.persistence === 'unchanged' && gitRead.result.writeback === 'unchanged')
|
||||
current = await start('SANDBOX_DRAFT_CREATE'); assistant = await finished(current.session.id)
|
||||
const draft = assistant.tools.find(tool => tool.name === 'execute_command')
|
||||
check('real OMP saves persistent filesystem and Git drafts without host approval', draft.result.writeback === 'deferred' && draft.result.workspace.mode === 'persistent' && draft.result.git.scope === 'draft' && !await fs.stat(path.join(directory, 'workspace/draft-only.txt')).catch(() => null))
|
||||
check('deferred draft does not create a hidden conversation approval', !await read(`window.sandboxQaEvents.some(event=>event.runId===${JSON.stringify(current.sent.runId)} && event.type==='interaction' && event.interaction.kind==='approval')`))
|
||||
const draftView = await invoke('app', 'sandboxWorkspace', current.session.id)
|
||||
check('production preload resolves persistent state from its saved session owner', draftView.state === 'ready' && draftView.revision === draft.result.workspace.revision && draftView.changes.some(change => change.path === 'draft-only.txt'))
|
||||
check('production run query preserves deferred outcome and draft ownership', (await invoke('app', 'sandboxOperation', draft.result.runId)).workspace.sessionId === current.session.id)
|
||||
check('workspace IPC rejects renderer paths and missing saved sessions', await invoke('app', 'sandboxWorkspace', { sessionId: current.session.id, workspaceRoot: directory }).then(() => false, () => true)
|
||||
&& await invoke('app', 'sandboxWorkspace', require('node:crypto').randomUUID()).then(() => false, () => true))
|
||||
const unrelated = await invoke('session', 'create', 'unrelated-draft-owner')
|
||||
check('another saved session cannot query the first session draft by changing paths', (await invoke('app', 'sandboxWorkspace', unrelated.id)).state === 'missing')
|
||||
current.sent = await invoke('chat', 'send', { sessionId: current.session.id, text: 'SANDBOX_DRAFT_READ', permissionMode: 'default-sandbox' })
|
||||
assistant = await finished(current.session.id)
|
||||
const draftRead = assistant.tools.find(tool => tool.name === 'execute_command')
|
||||
check('later OMP turn reads retained physical files and draft Git history', draftRead.result.stdout.includes('draft-only') && draftRead.result.stdout.includes('production-draft-commit') && draftRead.result.writeback === 'deferred')
|
||||
current.sent = await invoke('chat', 'send', { sessionId: current.session.id, text: 'SANDBOX_DRAFT_COMMIT', permissionMode: 'default-sandbox' })
|
||||
pending = await approval(current.sent.runId)
|
||||
check('workspace commit reaches the real approval UI while status remains responsive', pending.title.includes('draft-only.txt') && (await invoke('app', 'sandboxWorkspace', current.session.id)).state === 'busy' && !await fs.stat(path.join(directory, 'workspace/draft-only.txt')).catch(() => null))
|
||||
await invoke('chat', 'respond', current.sent.runId, pending.id, { kind: 'approval', optionId: 'allow-once' })
|
||||
assistant = await finished(current.session.id)
|
||||
const draftCommit = assistant.tools.find(tool => tool.name === 'sandbox_workspace')
|
||||
check('new workspace tool commits approved bytes through production OMP routing', draftCommit.result.writeback === 'committed' && !!draftCommit.result.transactionId && (await fs.readFile(path.join(directory, 'workspace/draft-only.txt'), 'utf8')).trim() === 'draft-only')
|
||||
check('committed workspace status clears pending changes and retains native transaction ID', (await invoke('app', 'sandboxWorkspace', current.session.id)).lastTransactionId === draftCommit.result.transactionId && draftCommit.result.changes.length === 0)
|
||||
current.sent = await invoke('chat', 'send', { sessionId: current.session.id, text: 'SANDBOX_DRAFT_EXTRA', permissionMode: 'default-sandbox' })
|
||||
await finished(current.session.id)
|
||||
current.sent = await invoke('chat', 'send', { sessionId: current.session.id, text: 'SANDBOX_DRAFT_DISCARD', permissionMode: 'default-sandbox' })
|
||||
assistant = await finished(current.session.id)
|
||||
check('production discard archives private changes without mutating host files', assistant.tools.find(tool => tool.name === 'sandbox_workspace').result.changes.length === 0 && !await fs.stat(path.join(directory, 'workspace/draft-extra.txt')).catch(() => null) && await fs.stat(path.join(directory, 'workspace/draft-only.txt')))
|
||||
fixtureGit('init', '-q', '--template=', '--initial-branch=main'); fixtureGit('add', '.'); fixtureGit('commit', '-q', '-m', 'production-real-host-history')
|
||||
fixtureGit('config', 'credential.helper', '!echo HOST_HELPER_MUST_NOT_RUN')
|
||||
current = await start('SANDBOX_HOST_GIT_IMPORT'); pending = await approval(current.sent.runId)
|
||||
check('real OMP routes host-history import through an explicit scope approval', pending.title.includes('真实 Git 历史') && (await invoke('app', 'sandboxGit', current.session.id)).state === 'unimported')
|
||||
await invoke('chat', 'respond', current.sent.runId, pending.id, { kind: 'approval', optionId: 'allow-once' })
|
||||
assistant = await finished(current.session.id)
|
||||
check('production Git tool and preload share the saved session mirror identity', assistant.tools.find(tool => tool.name === 'sandbox_git').result.revision === (await invoke('app', 'sandboxGit', current.session.id)).revision)
|
||||
check('Git status IPC rejects arbitrary renderer roots and unknown saved sessions', await invoke('app', 'sandboxGit', { sessionId: current.session.id, workspaceRoot: directory }).then(() => false, () => true)
|
||||
&& await invoke('app', 'sandboxGit', require('node:crypto').randomUUID()).then(() => false, () => true))
|
||||
current.sent = await invoke('chat', 'send', { sessionId: current.session.id, text: 'SANDBOX_HOST_GIT_READ', permissionMode: 'default-sandbox' })
|
||||
assistant = await finished(current.session.id)
|
||||
check('production native argv Git command sees the real host history', assistant.tools.find(tool => tool.name === 'sandbox_git').result.stdout.includes('production-real-host-history'))
|
||||
current.sent = await invoke('chat', 'send', { sessionId: current.session.id, text: 'SANDBOX_HOST_GIT_CHANGE', permissionMode: 'default-sandbox' })
|
||||
assistant = await finished(current.session.id)
|
||||
check('production local Git commit stays private until publication', assistant.tools.find(tool => tool.name === 'sandbox_git').result.git.mode === 'host-mirror' && !fixtureGit('log', '-1', '--oneline').toString().includes('production-host-git-commit'))
|
||||
current.sent = await invoke('chat', 'send', { sessionId: current.session.id, text: 'SANDBOX_HOST_GIT_COMMIT', permissionMode: 'default-sandbox' }); pending = await approval(current.sent.runId)
|
||||
check('production combined Git publication is approval-bound and reports busy without waiting', pending.title.includes('真实 Git 元数据') && (await invoke('app', 'sandboxGit', current.session.id)).state === 'busy')
|
||||
await invoke('chat', 'respond', current.sent.runId, pending.id, { kind: 'approval', optionId: 'allow-once' }); assistant = await finished(current.session.id)
|
||||
check('real OMP publishes the exact local commit into the actual host repository', assistant.tools.find(tool => tool.name === 'sandbox_git').result.writeback === 'committed' && fixtureGit('log', '-1', '--oneline').toString().includes('production-host-git-commit') && !(await invoke('app', 'sandboxGit', current.session.id)).pendingMetadata)
|
||||
current.sent = await invoke('chat', 'send', { sessionId: current.session.id, text: 'SANDBOX_HOST_GIT_REMOTE', permissionMode: 'default-sandbox' }); pending = await approval(current.sent.runId)
|
||||
check('production remote transport names its concrete endpoint in the approval', pending.title.includes('/git/repo.git'))
|
||||
await invoke('chat', 'respond', current.sent.runId, pending.id, { kind: 'approval', optionId: 'allow-once' }); assistant = await finished(current.session.id)
|
||||
const remoteGit = assistant.tools.find(tool => tool.name === 'sandbox_git').result
|
||||
check('real OMP approved smart HTTP discovery persists an owned durable result', remoteGit.state === 'completed' && remoteGit.stdout.includes('refs/heads/main') && (await invoke('app', 'sandboxGitRemoteOperation', current.session.id, remoteGit.operationId)).state === 'completed')
|
||||
check('another session cannot query an existing remote operation ID', await invoke('app', 'sandboxGitRemoteOperation', unrelated.id, remoteGit.operationId).then(() => false, () => true))
|
||||
current = await start('SANDBOX_REVOKE'); pending = await approval(current.sent.runId)
|
||||
policy = await invoke('app', 'setSandboxPolicy', { expectedRevision: policy.revision, rules: { maxChanges: 127 } })
|
||||
assistant = await finished(current.session.id)
|
||||
const cancelledApproval = await read(`window.sandboxQaEvents.some(event=>event.runId===${JSON.stringify(current.sent.runId)} && event.type==='interaction' && event.interaction.id===${JSON.stringify(pending.id)} && event.interaction.status==='cancelled')`)
|
||||
check('production policy revocation cancels the real pending approval and refuses host writeback', cancelledApproval && assistant.tools.find(tool => tool.name === 'execute_command').status === 'error' && !await fs.stat(path.join(directory, 'workspace/revoked.txt')).catch(() => null))
|
||||
check('revoked production approval rejects a late button response', (await invoke('chat', 'respond', current.sent.runId, pending.id, { kind: 'approval', optionId: 'allow-once' })).accepted === false)
|
||||
check('production policy change invalidates the previous self-test identity', !(await invoke('app', 'sandboxStatus')).selfTest)
|
||||
current = await start('SANDBOX_CANCEL')
|
||||
const runsRoot = path.join(directory, 'data/sandbox/runs')
|
||||
const nativeRun = await wait(async () => {
|
||||
for (const entry of await fs.readdir(runsRoot)) {
|
||||
const request = await fs.readFile(path.join(runsRoot, entry, 'request.json'), 'utf8').then(raw => JSON.parse(raw), error => { if (error.code === 'ENOENT') return null; throw error })
|
||||
if (request?.identity.runId === current.sent.runId && await fs.stat(path.join(runsRoot, entry, 'lease.json')).catch(() => null)) return entry
|
||||
}
|
||||
return null
|
||||
}, 'cancel scenario native startup')
|
||||
await invoke('chat', 'cancel', current.sent.runId)
|
||||
assistant = await finished(current.session.id)
|
||||
await wait(() => fs.stat(path.join(runsRoot, nativeRun, 'cleanup.json')).catch(() => null), 'native cancellation cleanup')
|
||||
const report = JSON.parse(await fs.readFile(path.join(runsRoot, nativeRun, 'result.json'), 'utf8'))
|
||||
check('production cancellation reaches native Job, terminal persistence and cleanup', assistant.status === 'cancelled' && report.status === 'cancelled' && report.profileRemoved && report.driveRemoved)
|
||||
check('cancelled command cannot write the later file', !(await fs.stat(path.join(directory, 'workspace/late.txt')).catch(() => null)))
|
||||
check('all model traffic used the explicitly local test server', requests.length >= 5 && requests.every(request => request.model === 'sandbox-upstream'))
|
||||
await fs.writeFile(path.join(directory, 'report.json'), JSON.stringify({ checks, requests, scope: 'Actual production renderer/preload/main, real OMP RPC and native sandbox; local-only deterministic model responses. No original WorkBuddy runtime was used.' }, null, 2) + '\n')
|
||||
console.log(`SANDBOX_APP_PASS ${checks.length}/${checks.length}`)
|
||||
await new Promise(done => server.close(done))
|
||||
app.quit()
|
||||
})().catch(async error => {
|
||||
console.error(error)
|
||||
await fs.writeFile(path.join(directory, 'report.json'), JSON.stringify({ checks, error: error.stack }, null, 2)).catch(() => undefined)
|
||||
if (server) server.close()
|
||||
app.exit(1)
|
||||
})
|
||||
}
|
||||
@@ -26,7 +26,7 @@ if (!process.versions.electron) {
|
||||
const now = Date.now()
|
||||
await fs.writeFile(path.join(data, 'sessions.json'), JSON.stringify({ sessions: [{ id: 'settings-nav-session', title: 'Settings Nav',
|
||||
cwd: home, createdAt: now, updatedAt: now + 1, messages: [{ id: 'settings-nav-user', role: 'user', content: '设置导航验收', createdAt: now, status: 'done' }] }] }))
|
||||
const env = { ...process.env, YCODE_QA_DIRECTORY: directory, YCODE_USER_DATA_DIR: data, YCODE_OMP_HOME: home,
|
||||
const env = { ...process.env, YCODE_QA_DIRECTORY: directory, YCODE_USER_DATA_DIR: data,
|
||||
YCODE_DISABLE_GPU: '1' }
|
||||
delete env.ELECTRON_RUN_AS_NODE; delete env.ELECTRON_RENDERER_URL
|
||||
const child = require('node:child_process').spawn(require('electron'),
|
||||
|
||||
@@ -14,7 +14,7 @@ if (!process.versions.electron) {
|
||||
const buildRoot = path.join(directory, 'build', 'out')
|
||||
await fs.cp(path.resolve('out'), buildRoot, { recursive: true })
|
||||
await fs.cp(path.resolve('resources/model-metadata'), path.join(directory, 'build/resources/model-metadata'), { recursive: true })
|
||||
const env = { ...process.env, YCODE_QA_DIRECTORY: directory, YCODE_USER_DATA_DIR: data, YCODE_OMP_HOME: home, YCODE_QA_BUILD_ROOT: buildRoot }
|
||||
const env = { ...process.env, YCODE_QA_DIRECTORY: directory, YCODE_USER_DATA_DIR: data, YCODE_QA_BUILD_ROOT: buildRoot }
|
||||
delete env.ELECTRON_RUN_AS_NODE; delete env.ELECTRON_RENDERER_URL; delete env.YCODE_DISABLE_GPU
|
||||
if (process.env.YCODE_QA_RENDERER_URL) env.ELECTRON_RENDERER_URL = process.env.YCODE_QA_RENDERER_URL
|
||||
const child = require('node:child_process').spawn(require('electron'), ['--disable-gpu-sandbox', __filename],
|
||||
@@ -99,7 +99,6 @@ if (!process.versions.electron) {
|
||||
const firstId = settings.id, firstPid = settings.webContents.getOSProcessId()
|
||||
check('default settings opens 通用', await read(`document.querySelector('[data-settings-tab="settings"]').getAttribute('aria-current')`), 'page')
|
||||
check('native settings uses its dedicated entry', new URL(settings.webContents.getURL()).pathname.endsWith('/settings.html'))
|
||||
check('sandbox policy still authorizes the dedicated settings page', await read(`window.ycode.app.sandboxPolicy().then(()=>true,()=>false)`))
|
||||
await close()
|
||||
check('close hides and retains the native window', !settings.isDestroyed() && !settings.isVisible())
|
||||
check('close disposes all page-local controls', await read(`document.querySelectorAll('input,textarea,[role="dialog"]').length`), 0)
|
||||
|
||||
@@ -56,9 +56,7 @@ if (!process.versions.electron) {
|
||||
const env = {
|
||||
...process.env,
|
||||
YCODE_QA_DIRECTORY: directory,
|
||||
YCODE_USER_DATA_DIR: data,
|
||||
YCODE_OMP_HOME: path.join(directory, 'home'),
|
||||
YCODE_DISABLE_GPU: '1',
|
||||
YCODE_USER_DATA_DIR: data, YCODE_DISABLE_GPU: '1',
|
||||
YCODE_QA_OFFSCREEN: '1',
|
||||
YCODE_DISABLE_SYSTEM_INTEGRATION: '1'
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ if (!process.versions.electron) {
|
||||
const data = path.join(directory, 'data'), home = path.join(directory, 'home')
|
||||
await fs.mkdir(data, { recursive: true })
|
||||
await fs.mkdir(home, { recursive: true })
|
||||
const env = { ...process.env, YCODE_QA_DIRECTORY: directory, YCODE_USER_DATA_DIR: data, YCODE_OMP_HOME: home,
|
||||
const env = { ...process.env, YCODE_QA_DIRECTORY: directory, YCODE_USER_DATA_DIR: data,
|
||||
YCODE_DISABLE_GPU: '1', YCODE_DISABLE_SYSTEM_INTEGRATION: '1' }
|
||||
delete env.ELECTRON_RUN_AS_NODE; delete env.ELECTRON_RENDERER_URL
|
||||
const child = require('node:child_process').spawn(require('electron'),
|
||||
|
||||
@@ -28,8 +28,7 @@ if (!process.versions.electron) {
|
||||
{ id: 'footer-assistant', role: 'assistant', content: '收到。', createdAt: now + 1, status: 'done', requestId: 'footer-run', tools: [], parts: [] }
|
||||
] }]
|
||||
await fs.writeFile(path.join(data, 'sessions.json'), JSON.stringify({ sessions }))
|
||||
const env = { ...process.env, YCODE_QA_DIRECTORY: directory, YCODE_USER_DATA_DIR: data,
|
||||
YCODE_OMP_HOME: home, YCODE_DISABLE_GPU: '1' }
|
||||
const env = { ...process.env, YCODE_QA_DIRECTORY: directory, YCODE_USER_DATA_DIR: data, YCODE_DISABLE_GPU: '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'] })
|
||||
|
||||
@@ -9,7 +9,7 @@ if (!process.versions.electron) {
|
||||
await fs.writeFile(path.join(directory, 'data', 'sessions.json'), JSON.stringify({ sessions }))
|
||||
await fs.writeFile(path.join(sessions[0].cwd, 'fixture.ts'), Array.from({ length: 500 }, (_, index) => `const SPACE_A_${index + 1}: number = ${index + 1}; // 中文 AlphaNeedle`).join('\n'))
|
||||
await fs.writeFile(path.join(sessions[1].cwd, 'fixture.ts'), 'const SPACE_B: boolean = true;\n// Independent native workspace')
|
||||
const env = { ...process.env, YCODE_QA_DIRECTORY: directory, YCODE_USER_DATA_DIR: path.join(directory, 'data'), YCODE_OMP_HOME: path.join(directory, 'home'), YCODE_QA_CODE_SESSIONS: JSON.stringify(sessions), YCODE_DISABLE_GPU: '1', YCODE_QA_OFFSCREEN: '1' }
|
||||
const env = { ...process.env, YCODE_QA_DIRECTORY: directory, YCODE_USER_DATA_DIR: path.join(directory, 'data'), YCODE_QA_CODE_SESSIONS: JSON.stringify(sessions), 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('CODE_PREVIEW_')) process.stdout.write(bytes) }); child.stderr.on('data', bytes => { output += bytes })
|
||||
|
||||
@@ -1,165 +0,0 @@
|
||||
// Isolated real renderer with declared IPC snapshots; no model service or user data is used.
|
||||
const fs = require('node:fs/promises')
|
||||
const path = require('node:path')
|
||||
|
||||
if (!process.versions.electron) {
|
||||
;(async () => {
|
||||
const directory = await fs.mkdtemp(path.resolve('.tmp/subagent-display-'))
|
||||
const data = path.join(directory, 'data')
|
||||
const home = path.join(directory, 'home')
|
||||
const workspace = path.join(directory, 'workspace')
|
||||
await Promise.all([data, home, workspace].map(folder => fs.mkdir(folder)))
|
||||
const now = Date.now()
|
||||
const sessionId = 'subagent-mock-preview'
|
||||
const user = { id: 'mock-user', role: 'user', content: '并行检查一下当前项目的对话界面和子智能体调度。', createdAt: now - 46000, status: 'done' }
|
||||
const session = { id: sessionId, title: '子智能体展示 · Mock', cwd: workspace, modelId: 'mock-model', createdAt: now - 46000, updatedAt: now, messages: [user] }
|
||||
const other = { id: 'subagent-other-preview', title: '其他会话', cwd: workspace, modelId: 'mock-model', createdAt: now - 60000, updatedAt: now - 50000,
|
||||
messages: [{ id: 'other-assistant', role: 'assistant', content: '这是另一个会话的历史记录。', status: 'done', createdAt: now - 50000 }] }
|
||||
await fs.writeFile(path.join(data, 'sessions.json'), JSON.stringify({ sessions: [session, other] }))
|
||||
await fs.writeFile(path.join(data, 'config.json'), JSON.stringify({ baseUrl: 'http://127.0.0.1:9/v1', model: 'gpt-6.1-sol', workspaceRoot: workspace, agentSettings: { teamManagement: true } }))
|
||||
await fs.writeFile(path.join(data, 'models.json'), JSON.stringify({ version: 1, selectedModelId: 'mock-model', models: [{ id: 'mock-model', name: 'GPT-6.1 Sol', model: 'gpt-6.1-sol', providerId: 'custom', protocol: 'openai', baseUrl: 'http://127.0.0.1:9/v1', supportsToolCall: true, supportsReasoning: true, contextWindowTokens: 500000, maxOutputTokens: 128000, tokenBudgetVersion: 2 }] }))
|
||||
await fs.writeFile(path.join(directory, 'fixture.json'), JSON.stringify({ now, session, user }))
|
||||
const env = { ...process.env, YCODE_SUBAGENT_PREVIEW: directory, YCODE_USER_DATA_DIR: data, YCODE_OMP_HOME: 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', bytes => { output += bytes; process.stdout.write(bytes) })
|
||||
child.stderr.on('data', bytes => { output += bytes })
|
||||
const timer = setTimeout(() => child.kill(), 60000)
|
||||
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(-5000))
|
||||
process.exitCode = code ?? 1
|
||||
})
|
||||
})().catch(error => { console.error(error); process.exitCode = 1 })
|
||||
} else {
|
||||
const { app, BrowserWindow, ipcMain } = require('electron')
|
||||
const { pathToFileURL } = require('node:url')
|
||||
const assert = require('node:assert/strict')
|
||||
const directory = process.env.YCODE_SUBAGENT_PREVIEW
|
||||
app.setPath('userData', path.join(directory, 'data'))
|
||||
app.setPath('sessionData', path.join(directory, 'data'))
|
||||
app.disableHardwareAcceleration()
|
||||
BrowserWindow.prototype.show = function () {}
|
||||
const pause = ms => new Promise(done => setTimeout(done, ms))
|
||||
const errors = []
|
||||
const checks = []
|
||||
const check = (label, condition) => { assert.ok(condition, label); checks.push(label); console.log('DISPLAY_CHECK ' + label) }
|
||||
const until = async (operation, label) => {
|
||||
for (let index = 0; index < 300; index++) {
|
||||
const value = await operation()
|
||||
if (value) return value
|
||||
await pause(50)
|
||||
}
|
||||
throw Error('Timeout: ' + label)
|
||||
}
|
||||
app.whenReady().then(async () => {
|
||||
const { now, session, user } = JSON.parse(await fs.readFile(path.join(directory, 'fixture.json'), 'utf8'))
|
||||
const common = { access: 'read-only', files: [], modelName: 'GPT-6.1 Sol', createdAt: now - 46000, tools: [] }
|
||||
const agents = [
|
||||
{ ...common, id: 'mock-agent-ui', name: '对话界面核对', task: '检查对话区域的字体、间距和工具事件展示,整理差异及对应文件。', status: 'running', startedAt: now - 33000,
|
||||
events: [
|
||||
{ id: 'mock-reasoning', kind: 'reasoning', text: '先检查消息渲染组件与样式,再核对事件折叠和模型信息的布局。' },
|
||||
{ id: 'mock-read', kind: 'tool', name: 'read_file', status: 'ok', text: 'src/renderer/src/components/MessageBubble.tsx\n已读取消息渲染、事件折叠和子智能体卡片入口。' },
|
||||
{ id: 'mock-read-style', kind: 'tool', name: 'read_file', status: 'running', text: '正在读取 src/renderer/src/components/subagent-card.css …' }
|
||||
], usage: { inputTokens: 8600, outputTokens: 740, totalTokens: 9340 } },
|
||||
{ ...common, id: 'mock-agent-scheduler', name: '调度逻辑检查', task: '核对 5 个并发名额、排队和取消的行为。', status: 'completed', startedAt: now - 44000, finishedAt: now - 12000,
|
||||
events: [{ id: 'mock-check', kind: 'text', text: '已核对全局并发限制和任务清理逻辑。' }], result: '已核对:最多同时运行 5 个子智能体,超出上限进入队列;停止成员后释放名额。', usage: { inputTokens: 6200, outputTokens: 920, totalTokens: 7120 } },
|
||||
{ ...common, id: 'mock-agent-summary', name: '验收清单整理', task: '等待空闲名额后整理本轮检查结果。', status: 'queued', events: [] }
|
||||
]
|
||||
agents[0].tools = [
|
||||
{ id: 'mock-read', name: 'read_file', status: 'ok', args: { path: 'src/renderer/src/components/MessageBubble.tsx' }, argsRaw: '{"path":"src/renderer/src/components/MessageBubble.tsx"}', output: '消息渲染与事件折叠入口。' },
|
||||
{ id: 'mock-read-style', name: 'read_file', status: 'running', args: { path: 'src/renderer/src/workbuddy/subagents/source.css' }, argsRaw: '{"path":"src/renderer/src/workbuddy/subagents/source.css"}' }
|
||||
]
|
||||
const assistant = { id: 'mock-assistant', role: 'assistant', content: '我已拆分检查任务,正在并行核对。可以展开输入框上方的成员入口查看各自进度。', createdAt: now - 45000, turnStartedAt: now - 46000, status: 'streaming', requestId: 'mock-run', tools: [], parts: [{ type: 'text', text: '我已拆分检查任务,正在并行核对。可以展开输入框上方的成员入口查看各自进度。' }, ...agents.map(agent => ({ type: 'subagent', agent }))] }
|
||||
const snapshot = { runId: 'mock-run', assistantMessageId: assistant.id, sessionId: session.id, sequence: 1, messages: [user, assistant] }
|
||||
delete process.env.YCODE_DISABLE_GPU
|
||||
delete process.env.YCODE_DISABLE_GPU_SANDBOX
|
||||
await import(pathToFileURL(path.resolve('out/main/index.mjs')).href)
|
||||
const main = await until(() => BrowserWindow.getAllWindows().find(window => window.getTitle() === 'YCode' && !window.webContents.isLoading()), 'main')
|
||||
main.webContents.on('console-message', event => { if (event.level === 'error' || event.level >= 3) errors.push(event.message) })
|
||||
ipcMain.removeHandler('chat:active-run')
|
||||
let hydrationDelay = 0
|
||||
ipcMain.handle('chat:active-run', async (_event, id) => { if (id === session.id && hydrationDelay) await pause(hydrationDelay); return id === session.id ? snapshot : null })
|
||||
await main.loadFile(path.resolve('out/renderer/index.html'), { query: { taskId: session.id } })
|
||||
const read = code => main.webContents.executeJavaScript(code)
|
||||
main.setContentSize(1550, 970)
|
||||
await until(() => read('document.querySelector(".conversation-team-collaboration__trigger")?.textContent.includes("3 位团队成员")'), 'mock input-area members')
|
||||
check('one collapsed member entry lives above the input with 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__member")&&!document.querySelector(".yc-subagent")'))
|
||||
await read('document.fonts.ready.then(() => true)')
|
||||
await pause(400)
|
||||
const placement = await read('(() => { const area=document.querySelector("[data-testid=composer-task-activity]"), input=document.querySelector(".composer .wb-composer"); const a=area.getBoundingClientRect(), b=input.getBoundingClientRect(); return {above:a.bottom<=b.top+1,left:Math.abs(a.left-b.left),width:Math.abs(a.width-b.width),outsideTimeline:!area.closest(".chat-scroll"),owner:area.dataset.owner} })()')
|
||||
check('member entry shares input width and stays outside the transcript scroller', placement.above && placement.left < 1 && placement.width < 1 && placement.outsideTimeline && placement.owner === session.id)
|
||||
await fs.writeFile(path.join(directory, 'subagents-collapsed.png'), (await main.webContents.capturePage(undefined, { stayHidden: true, stayAwake: true })).toPNG())
|
||||
await read('document.querySelector(".conversation-team-collaboration__trigger").click();true')
|
||||
await until(() => read('document.querySelectorAll(".conversation-team-collaboration__member").length===3'), 'three members')
|
||||
check('member list reflects running, queued and completed state without changing task identity', await read('Array.from(document.querySelectorAll(".conversation-team-collaboration__member")).map(item=>item.dataset.status).join(",")==="running,queued,completed"&&document.querySelector(".conversation-team-collaboration__member[data-status=queued]").textContent.includes("任务三")'))
|
||||
await read('document.querySelector(".conversation-team-collaboration__member[data-status=running]").click();true')
|
||||
await until(() => read('!!document.querySelector(".conversation-member-detail")'), 'member detail')
|
||||
check('selection opens in the right detail slot with the matching member identity', await read('document.querySelector("[data-view-id=detail-panel] .conversation-member-detail")?.dataset.subagentId==="mock-agent-ui"&&document.querySelector(".conversation-team-collaboration__member[aria-current=true]")?.dataset.subagentId==="mock-agent-ui"'))
|
||||
const themeMetrics = await read('({header:getComputedStyle(document.querySelector(".conversation-member-detail__header")).minHeight,space10:getComputedStyle(document.querySelector(".conversation-member-detail")).getPropertyValue("--wb-spacing-10"),space2:getComputedStyle(document.querySelector(".conversation-member-detail")).getPropertyValue("--wb-spacing-2"),background:getComputedStyle(document.querySelector(".conversation-member-detail")).getPropertyValue("--wb-bg-primary")})')
|
||||
console.log('THEME_METRICS ' + JSON.stringify(themeMetrics))
|
||||
check('detail inherits source theme spacing and surface tokens', Math.abs(parseFloat(themeMetrics.header) - parseFloat(themeMetrics.space10) - parseFloat(themeMetrics.space2)) < .1 && themeMetrics.background.trim().length > 0)
|
||||
await read('document.querySelector(".conversation-member-detail__assignment-toggle").click();true')
|
||||
check('assignment can expand independently from the child message timeline', await read('document.querySelector(".conversation-member-detail__assignment-detail").textContent.includes("检查对话区域")'))
|
||||
await read('document.querySelector(".conversation-member-detail .cr-live-event-feed .cr-collapse__header")?.click();true')
|
||||
await pause(700)
|
||||
const bounds = await read('Array.from(document.querySelectorAll(".conversation-team-collaboration__member")).map(item => ({ status: item.dataset.status, text: item.innerText, y: item.getBoundingClientRect().y, bottom: item.getBoundingClientRect().bottom }))')
|
||||
assert.equal(await read('document.querySelector(".conversation-team-collaboration__trigger").getAttribute("aria-expanded")'), 'true')
|
||||
await fs.writeFile(path.join(directory, 'subagents-expanded.png'), (await main.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')
|
||||
await until(() => read('document.querySelector(".conversation-member-detail")?.dataset.crTheme==="dark"'), 'dark member detail')
|
||||
await pause(450)
|
||||
check('child thinking block uses the dark conversation styles', await read('getComputedStyle(document.querySelector(".conversation-member-detail .cr-reasoning")).backgroundColor!=="rgb(255, 255, 255)"'))
|
||||
await fs.writeFile(path.join(directory, 'subagents-dark.png'), (await main.webContents.capturePage(undefined, { stayHidden: true, stayAwake: true })).toPNG())
|
||||
const updated = { ...agents[0], events: [...agents[0].events, { id: 'live-update', kind: 'text', text: '已完成消息组件核对,正在检查状态同步。' }] }
|
||||
main.webContents.send('chat:event', { runId: snapshot.runId, sessionId: session.id, sequence: 2, type: 'part', part: { type: 'subagent', agent: updated } })
|
||||
await until(() => read('document.querySelector(".conversation-member-detail").textContent.includes("已完成消息组件核对")'), 'live child text update')
|
||||
assert.equal(await read('document.querySelectorAll(".conversation-team-collaboration__member").length'), 3)
|
||||
check('new child text streams into the selected detail without duplicate members', true)
|
||||
check('member detail follows the dark theme', await read('getComputedStyle(document.querySelector(".conversation-member-detail")).backgroundColor===getComputedStyle(document.querySelector(".chat-pane")).backgroundColor'))
|
||||
main.setContentSize(760, 850)
|
||||
await pause(700)
|
||||
const overflow = await read('document.documentElement.scrollWidth > window.innerWidth')
|
||||
assert.equal(overflow, false, 'narrow member view has no page overflow')
|
||||
check('narrow view uses the existing right drawer with no page overflow', await read('!!document.querySelector(".app-shell__detail-slot--drawer .conversation-member-detail")') && !overflow)
|
||||
await fs.writeFile(path.join(directory, 'subagents-narrow.png'), (await main.webContents.capturePage(undefined, { stayHidden: true, stayAwake: true })).toPNG())
|
||||
main.setContentSize(1550, 970)
|
||||
await pause(350)
|
||||
await read('document.querySelector("[data-session-id=subagent-other-preview]").click();true')
|
||||
await until(() => read('document.body.textContent.includes("这是另一个会话的历史记录")&&!document.querySelector(".conversation-member-detail")'), 'other conversation')
|
||||
check('switching conversation never leaks the selected child or its input entry', await read('!document.querySelector("[data-testid=composer-task-activity]")'))
|
||||
hydrationDelay = 500
|
||||
await read('document.querySelector("[data-session-id=subagent-mock-preview]").click();true')
|
||||
await until(() => read('document.querySelector(".conversation-member-detail")?.dataset.subagentId==="mock-agent-ui"'), 'restore selected member')
|
||||
await pause(700)
|
||||
check('selected member survives conversation rehydration', await read('document.querySelector(".conversation-member-detail")?.dataset.subagentId==="mock-agent-ui"'))
|
||||
// Terminal replay and a later branch snapshot must both update an already-open detail.
|
||||
main.webContents.send('chat:event', { runId: snapshot.runId, sessionId: session.id, sequence: 3, type: 'cancelled' })
|
||||
await until(() => read('document.querySelector(".conversation-member-detail")?.dataset.status==="cancelled"'), 'cancel replay')
|
||||
check('parent cancellation settles the open child and removes the stop action', await read('!document.querySelector(".yc-member-stop")'))
|
||||
const nextAssistant = { id: 'next-assistant', role: 'assistant', content: '新一轮开始,尚未调度成员。', parts: [], tools: [], status: 'streaming', requestId: 'next-run', createdAt: now }
|
||||
main.webContents.send('chat:run-started', { ...snapshot, runId: 'next-run', assistantMessageId: nextAssistant.id, sequence: 1,
|
||||
messages: [user, { ...assistant, status: 'done' }, { ...user, id: 'next-user', createdAt: now }, nextAssistant] })
|
||||
await until(() => read('document.body.textContent.includes("新一轮开始")&&!document.querySelector("[data-testid=composer-task-activity]")'), 'new turn clears previous team')
|
||||
check('a new turn never displays the previous reply team in the input area', true)
|
||||
main.webContents.send('chat:event', { runId: 'next-run', sessionId: session.id, sequence: 2, type: 'part', part: { type: 'subagent', agent: { ...agents[0], id: 'next-child', name: '新一轮成员' } } })
|
||||
await until(() => read('document.querySelector(".conversation-team-collaboration__trigger")?.textContent.includes("1 位团队成员")'), 'new team appears')
|
||||
check('new team starts collapsed and binds to its own assistant', await read('document.querySelector(".conversation-team-collaboration").dataset.assistantId==="next-assistant"&&document.querySelector(".conversation-team-collaboration__trigger").getAttribute("aria-expanded")==="false"'))
|
||||
const branch = { ...snapshot, runId: 'mock-branch-run', sequence: 4, messages: [user, { ...assistant, parts: [{ type: 'text', text: '已回退到不含子任务的分支。' }], content: '已回退到不含子任务的分支。', status: 'done' }] }
|
||||
main.webContents.send('chat:run-started', branch)
|
||||
await until(() => read('document.body.textContent.includes("已回退到不含子任务的分支")&&!document.querySelector(".conversation-member-detail")'), 'branch cleanup')
|
||||
check('a branch without the child closes stale detail and removes the input entry', await read('!document.querySelector("[data-testid=composer-task-activity]")'))
|
||||
check('real renderer reports no errors', errors.length === 0)
|
||||
await fs.writeFile(path.join(directory, 'report.json'), JSON.stringify({ result: 'PASS', checks, mock: true, rendererErrors: errors, placement, cards: bounds, screenshot: 'subagents-expanded.png' }, null, 2))
|
||||
console.log('SUBAGENT_PREVIEW ' + path.join(directory, 'subagents-expanded.png'))
|
||||
app.exit(0)
|
||||
}).catch(async error => {
|
||||
await fs.writeFile(path.join(directory, 'error.txt'), error.stack || String(error))
|
||||
console.error(error)
|
||||
app.exit(1)
|
||||
})
|
||||
}
|
||||
@@ -19,8 +19,7 @@ if (!process.versions.electron) {
|
||||
messages: [{ id: crypto.randomUUID(), role: 'user', content: title, createdAt: now + index, status: 'done' }]
|
||||
}))
|
||||
await fs.writeFile(path.join(data, 'sessions.json'), JSON.stringify({ sessions }))
|
||||
const env = { ...process.env, YCODE_QA_DIRECTORY: directory, YCODE_USER_DATA_DIR: data,
|
||||
YCODE_OMP_HOME: home, YCODE_DISABLE_GPU: '1', YCODE_QA_OFFSCREEN: '1' }
|
||||
const env = { ...process.env, YCODE_QA_DIRECTORY: directory, YCODE_USER_DATA_DIR: data, 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'] })
|
||||
|
||||
@@ -1,328 +0,0 @@
|
||||
// TODO 01 running-task acceptance.
|
||||
// Real isolated YCode App/preload/IPC/renderer + real OMP + a controlled local
|
||||
// OpenAI-compatible stream that stays open until the task is terminated.
|
||||
//
|
||||
// WorkBuddy expectations come from the fixed REA archive:
|
||||
// - contexts-task-menu-source.js: working/planning/running use the running
|
||||
// delete confirmation; archive is disabled whenever the status is not
|
||||
// completed/failed/terminated.
|
||||
// - ev_f5e9d71a676c453daab40928acfb33aeabae68ba57ffda342380cead7f1a1fe2:
|
||||
// task menu/modal shell geometry and tokens.
|
||||
// - item58.js: zh-CN delete.inline.* / archive.dialog.* copy.
|
||||
const fs = require('node:fs/promises')
|
||||
const path = require('node:path')
|
||||
const assert = require('node:assert/strict')
|
||||
|
||||
const RUNNING_SESSION_ID = 'task-running-session'
|
||||
const IDLE_SESSION_ID = 'task-idle-session'
|
||||
|
||||
if (!process.versions.electron) {
|
||||
;(async () => {
|
||||
const directory = await fs.mkdtemp(path.resolve('.tmp/task-running-'))
|
||||
const data = path.join(directory, 'data')
|
||||
const home = path.join(directory, 'home')
|
||||
const cwd = path.join(directory, 'workspace')
|
||||
await fs.mkdir(data, { recursive: true })
|
||||
await fs.mkdir(home, { recursive: true })
|
||||
await fs.mkdir(cwd, { recursive: true })
|
||||
const now = Date.now()
|
||||
const sessions = [
|
||||
{
|
||||
id: RUNNING_SESSION_ID,
|
||||
title: 'Running Menu',
|
||||
cwd,
|
||||
modelId: 'local-running',
|
||||
createdAt: now,
|
||||
updatedAt: now + 1,
|
||||
messages: []
|
||||
},
|
||||
{
|
||||
id: IDLE_SESSION_ID,
|
||||
title: 'Idle Menu',
|
||||
cwd,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
messages: []
|
||||
}
|
||||
]
|
||||
await fs.writeFile(path.join(data, 'sessions.json'), JSON.stringify({ sessions }))
|
||||
const env = {
|
||||
...process.env,
|
||||
YCODE_QA_DIRECTORY: directory,
|
||||
YCODE_USER_DATA_DIR: data,
|
||||
YCODE_OMP_HOME: home,
|
||||
YCODE_QA_REAL_OMP: process.env.YCODE_OMP_PATH || path.join(process.env.LOCALAPPDATA || '', 'Ygent', 'omp.exe'),
|
||||
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_RUNNING_')) process.stdout.write(bytes)
|
||||
})
|
||||
child.stderr.on('data', bytes => { output += bytes })
|
||||
const timer = setTimeout(() => child.kill(), 240000)
|
||||
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(-10000))
|
||||
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 directory = process.env.YCODE_QA_DIRECTORY
|
||||
assert.ok(path.relative(path.resolve('.tmp'), directory).startsWith('task-running-'))
|
||||
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 = []
|
||||
let main
|
||||
let server
|
||||
let phase = 'boot'
|
||||
let streamStarted = false
|
||||
let streamClosed = false
|
||||
let requestCount = 0
|
||||
|
||||
const pause = ms => new Promise(resolve => setTimeout(resolve, ms))
|
||||
async function read(source) {
|
||||
let timer
|
||||
try {
|
||||
return await Promise.race([
|
||||
main.webContents.executeJavaScript(source),
|
||||
new Promise((_, reject) => {
|
||||
timer = setTimeout(() => reject(Error('Renderer timeout: ' + source.slice(0, 140))), 20000)
|
||||
})
|
||||
])
|
||||
} finally {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
}
|
||||
async function wait(source, label, attempts = 750) {
|
||||
for (let index = 0; index < attempts; index++) {
|
||||
const value = await read(source)
|
||||
if (value) return value
|
||||
await pause(40)
|
||||
}
|
||||
throw Error('Timeout: ' + label)
|
||||
}
|
||||
async function waitValue(readValue, label, attempts = 750) {
|
||||
for (let index = 0; index < attempts; index++) {
|
||||
if (await readValue()) return
|
||||
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
|
||||
})
|
||||
}
|
||||
async function shot(name) {
|
||||
const capture = await main.webContents.debugger.sendCommand('Page.captureScreenshot', {
|
||||
format: 'png',
|
||||
captureBeyondViewport: false
|
||||
})
|
||||
await fs.writeFile(path.join(directory, name + '.png'), Buffer.from(capture.data, 'base64'))
|
||||
}
|
||||
const openRunningMenu = `(()=>{const row=document.querySelector('[data-session-id="${RUNNING_SESSION_ID}"]');if(!row)return false;row.classList.add('sidebar__item--menu-open');const button=row.querySelector('.agent-card-more-button');if(!button)return false;button.click();return true})()`
|
||||
const clickMenuItem = id => `(()=>{const button=document.querySelector('[data-task-menu-id="${id}"]');if(!button)return false;button.click();return true})()`
|
||||
const fillComposer = text => `(()=>{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}));return true})()`
|
||||
|
||||
;(async () => {
|
||||
server = createServer(async (req, res) => {
|
||||
requestCount += 1
|
||||
let raw = ''
|
||||
for await (const part of req) raw += part
|
||||
const body = JSON.parse(raw || '{}')
|
||||
if (req.method === 'OPTIONS') {
|
||||
res.writeHead(204, {
|
||||
'access-control-allow-origin': '*',
|
||||
'access-control-allow-headers': '*',
|
||||
'access-control-allow-methods': '*'
|
||||
})
|
||||
res.end()
|
||||
return
|
||||
}
|
||||
if (!body.stream) {
|
||||
res.setHeader('content-type', 'application/json')
|
||||
res.end(JSON.stringify({ choices: [{ message: { role: 'assistant', content: 'RUNNING_REPLY' } }] }))
|
||||
return
|
||||
}
|
||||
streamStarted = true
|
||||
streamClosed = false
|
||||
res.writeHead(200, {
|
||||
'content-type': 'text/event-stream',
|
||||
'cache-control': 'no-cache',
|
||||
connection: 'keep-alive'
|
||||
})
|
||||
const chunk = payload => `data: ${JSON.stringify({
|
||||
id: 'local-running',
|
||||
object: 'chat.completion.chunk',
|
||||
created: 1,
|
||||
model: body.model,
|
||||
...payload
|
||||
})}\n\n`
|
||||
res.write(chunk({ choices: [{ index: 0, delta: { role: 'assistant', content: 'RUNNING_REPLY' }, finish_reason: null }] }))
|
||||
const keepalive = setInterval(() => {
|
||||
if (!res.destroyed) res.write(': keepalive\n\n')
|
||||
}, 500)
|
||||
res.on('close', () => {
|
||||
clearInterval(keepalive)
|
||||
streamClosed = true
|
||||
})
|
||||
})
|
||||
const sync = require('node:fs')
|
||||
server.listen(0, '127.0.0.1')
|
||||
sync.mkdirSync(process.env.YCODE_USER_DATA_DIR, { recursive: true })
|
||||
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`
|
||||
|
||||
for (let index = 0; index < 250; index++) {
|
||||
main = BrowserWindow.getAllWindows().find(win => win.getTitle() === 'YCode')
|
||||
if (main && !main.webContents.isLoading()) break
|
||||
await pause(40)
|
||||
}
|
||||
assert.ok(main, 'main window')
|
||||
main.setSize(1336, 902)
|
||||
main.showInactive()
|
||||
main.webContents.debugger.attach('1.3')
|
||||
main.webContents.on('console-message', (_event, level, message) => {
|
||||
if (level >= 2) rendererErrors.push(message)
|
||||
})
|
||||
await wait('!!window.ycode && !!document.querySelector(".chat-pane")', 'main renderer')
|
||||
await read(`window.ycode.models.save(${JSON.stringify({
|
||||
id: 'local-running',
|
||||
name: 'Local Running',
|
||||
providerId: 'custom',
|
||||
model: 'model-running',
|
||||
baseUrl,
|
||||
apiKey: 'LOCAL_TEST_KEY',
|
||||
supportsToolCall: true,
|
||||
maxInputTokens: 64000,
|
||||
maxOutputTokens: 4096
|
||||
})})`)
|
||||
await read('window.ycode.models.select("local-running")')
|
||||
|
||||
await step('start-running-task')
|
||||
await wait(`!!document.querySelector('[data-session-id="${RUNNING_SESSION_ID}"]')`, 'running session row')
|
||||
await read(`document.querySelector('[data-session-id="${RUNNING_SESSION_ID}"]').click()`)
|
||||
await wait('!!document.querySelector(".wb-composer__input")', 'composer input')
|
||||
check('composer is the recovered textarea', await read(`document.querySelector('.wb-composer__input') instanceof HTMLTextAreaElement`), true)
|
||||
assert.equal(await read(fillComposer('KEEP_RUNNING')), true)
|
||||
await wait('!document.querySelector(".cr-send-button:not(.cr-send-button--stop)")?.disabled', 'enabled send button')
|
||||
await read(`document.querySelector('.cr-send-button:not(.cr-send-button--stop)').click()`)
|
||||
await wait('!!document.querySelector(".cr-send-button--stop")', 'running stop button')
|
||||
await waitValue(() => streamStarted, 'controlled upstream stream started')
|
||||
await wait(`document.querySelector('[data-session-id="${RUNNING_SESSION_ID}"]')?.textContent.includes('运行中')`, 'sidebar running state')
|
||||
await shot('running-row')
|
||||
|
||||
await step('running-menu')
|
||||
await read(openRunningMenu)
|
||||
await wait('!!document.querySelector("[data-task-menu-id=archive]")', 'running task menu')
|
||||
const archive = await read(`(()=>{const button=document.querySelector('[data-task-menu-id="archive"]');const style=getComputedStyle(button);return {disabled:button.getAttribute('aria-disabled'),title:button.getAttribute('title'),className:button.className,color:style.color,cursor:style.cursor}})()`)
|
||||
snapshots.archive = archive
|
||||
check('running archive item is disabled', archive.disabled, 'true')
|
||||
check('running archive uses the recovered reason', archive.title, '任务进行中,无法归档')
|
||||
check('running archive is visually disabled', [archive.className.includes('cb-context-menu-item--disabled'), archive.cursor], [true, 'not-allowed'])
|
||||
await read(clickMenuItem('archive'))
|
||||
await pause(120)
|
||||
check('clicking disabled archive keeps the menu open', await read('!!document.querySelector(".conversation-context-menu")'), true)
|
||||
check('clicking disabled archive opens no confirmation', await read('!!document.querySelector(".task-confirm-dialog")'), false)
|
||||
await shot('running-menu')
|
||||
|
||||
const archivedWhileRunning = await read(`window.ycode.session.setArchived(${JSON.stringify(RUNNING_SESSION_ID)},true).then(()=>({ok:true}),error=>({ok:false,message:String(error?.message||error)}))`)
|
||||
snapshots.archiveRejection = archivedWhileRunning
|
||||
check('main process rejects archive while the run is active', archivedWhileRunning.ok, false)
|
||||
check('main-process archive rejection uses the same reason', archivedWhileRunning.message.includes('任务进行中,无法归档'), true)
|
||||
check('rejected archive leaves the running task visible', await read(`window.ycode.session.list().then(list=>list.some(item=>item.id===${JSON.stringify(RUNNING_SESSION_ID)}))`), true)
|
||||
|
||||
await step('running-delete-confirm')
|
||||
await read(clickMenuItem('delete'))
|
||||
await wait('!!document.querySelector(".task-confirm-dialog[data-task-confirm-kind=delete]")', 'running delete confirmation')
|
||||
const runningDialog = await read(`(()=>{const dialog=document.querySelector('.task-confirm-dialog');const style=getComputedStyle(dialog);const box=node=>{const r=node.getBoundingClientRect();return [r.x,r.y,r.width,r.height]};return {box:box(dialog),width:style.width,padding:style.padding,borderRadius:style.borderRadius,title:dialog.querySelector('.wb-modal__title')?.textContent,desc:dialog.querySelector('.task-confirm-dialog__description')?.textContent,cancel:dialog.querySelector('.task-confirm-dialog__cancel')?.textContent,confirm:dialog.querySelector('.task-confirm-dialog__confirm')?.textContent,running:dialog.getAttribute('data-task-confirm-running'),ariaBusy:dialog.getAttribute('aria-busy')}})()`)
|
||||
snapshots.runningDialog = runningDialog
|
||||
check('running delete uses the recovered title', runningDialog.title, '删除任务?')
|
||||
check('running delete explains immediate termination', runningDialog.desc, '该任务正在执行中,删除将立即终止运行,且无法恢复。')
|
||||
check('running delete actions use the recovered labels', [runningDialog.cancel, runningDialog.confirm], ['取消', '停止并删除'])
|
||||
check('running delete dialog carries the running state', runningDialog.running, 'true')
|
||||
check('running delete dialog uses the recovered 480px shell', [runningDialog.width, Math.round(runningDialog.box[2]), runningDialog.padding, runningDialog.borderRadius], ['480px', 480, '24px', '24px'])
|
||||
await shot('running-delete-confirm')
|
||||
|
||||
await read(`document.querySelector('.task-confirm-dialog__cancel').click()`)
|
||||
await wait('!document.querySelector(".task-confirm-dialog")', 'running delete cancellation closes')
|
||||
check('cancelling running delete preserves the live run', await read('!!document.querySelector(".cr-send-button--stop")'), true)
|
||||
check('cancelling running delete preserves the session', await read(`window.ycode.session.list().then(list=>list.some(item=>item.id===${JSON.stringify(RUNNING_SESSION_ID)}))`), true)
|
||||
|
||||
await step('running-delete-confirm-and-stop')
|
||||
await read(openRunningMenu)
|
||||
await wait('!!document.querySelector("[data-task-menu-id=delete]")', 'second running menu')
|
||||
await read(clickMenuItem('delete'))
|
||||
await wait('!!document.querySelector(".task-confirm-dialog__confirm")', 'second running confirmation')
|
||||
await read(`document.querySelector('.task-confirm-dialog__confirm').click()`)
|
||||
await wait(`window.ycode.session.listArchived().then(list=>!list.some(item=>item.id===${JSON.stringify(RUNNING_SESSION_ID)}))`, 'running delete removes session', 1500)
|
||||
await wait('!document.querySelector(".cr-send-button--stop")', 'running delete clears stop button', 500)
|
||||
await waitValue(() => streamClosed, 'aborted upstream stream closes', 500)
|
||||
check('running delete removes the session from every listing', await read(`window.ycode.session.listArchived().then(list=>list.some(item=>item.id===${JSON.stringify(RUNNING_SESSION_ID)}))`), false)
|
||||
check('running delete leaves the idle session intact', await read(`window.ycode.session.list().then(list=>list.some(item=>item.id===${JSON.stringify(IDLE_SESSION_ID)}))`), true)
|
||||
check('running delete terminates the controlled upstream request', streamClosed, true)
|
||||
check('controlled upstream was actually requested', requestCount > 0, 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 + controlled local streaming upstream. Task deletion was confirmed and terminated the live stream. Original expectations come from fixed REA archive ev_f5e9d71a…, contexts-task-menu-source.js and item58.js.',
|
||||
sourceEvidence: [
|
||||
'ev_f5e9d71a676c453daab40928acfb33aeabae68ba57ffda342380cead7f1a1fe2'
|
||||
],
|
||||
snapshots,
|
||||
checks,
|
||||
rendererErrors
|
||||
}
|
||||
await fs.writeFile(path.join(directory, 'report.json'), JSON.stringify(report, null, 2))
|
||||
console.log('TASK_RUNNING_' + report.result + ' ' + directory)
|
||||
for (const failure of checks.filter(item => !item.pass)) {
|
||||
console.log('TASK_RUNNING_FAILURE ' + JSON.stringify(failure))
|
||||
}
|
||||
server.close()
|
||||
app.exit(report.result === 'PASS' ? 0 : 1)
|
||||
})().catch(async error => {
|
||||
await fs.writeFile(path.join(directory, 'failure.json'), JSON.stringify({
|
||||
phase,
|
||||
error: String(error),
|
||||
stack: error.stack,
|
||||
checks,
|
||||
snapshots,
|
||||
rendererErrors
|
||||
}, null, 2))
|
||||
console.error(error)
|
||||
if (main) await shot('failure').catch(() => undefined)
|
||||
server?.close()
|
||||
app.exit(1)
|
||||
})
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,339 +0,0 @@
|
||||
// Production Electron integration with isolated data and a localhost-only model.
|
||||
const assert = require('node:assert/strict')
|
||||
const fs = require('node:fs/promises')
|
||||
const path = require('node:path')
|
||||
|
||||
if (!process.versions.electron) {
|
||||
;(async () => {
|
||||
await fs.mkdir(path.resolve('.tmp'), { recursive: true })
|
||||
const directory = await fs.mkdtemp(path.resolve('.tmp/three-modules-'))
|
||||
const env = { ...process.env, YCODE_QA_DIRECTORY: directory,
|
||||
YCODE_USER_DATA_DIR: path.join(directory, 'data'), YCODE_OMP_HOME: path.join(directory, 'home'),
|
||||
YCODE_DISABLE_GPU: '1', YCODE_QA_OFFSCREEN: '1', YCODE_DISABLE_SYSTEM_INTEGRATION: '1' }
|
||||
await Promise.all(['data', 'home', 'workspace'].map(name => fs.mkdir(path.join(directory, name))))
|
||||
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 log = ''
|
||||
child.stdout.on('data', bytes => { log += bytes; process.stdout.write(bytes) })
|
||||
child.stderr.on('data', bytes => { log += bytes })
|
||||
const timer = setTimeout(() => child.kill(), 120_000)
|
||||
child.once('exit', async code => {
|
||||
clearTimeout(timer)
|
||||
await fs.writeFile(path.join(directory, 'process.log'), log)
|
||||
console.log(JSON.stringify({ directory, exitCode: code }))
|
||||
if (code) console.error(log.slice(-12000))
|
||||
process.exitCode = code ?? 1
|
||||
})
|
||||
})().catch(error => { console.error(error); process.exitCode = 1 })
|
||||
} else {
|
||||
const { app, BrowserWindow } = require('electron')
|
||||
const { pathToFileURL } = require('node:url')
|
||||
const { createServer } = require('node:http')
|
||||
const directory = process.env.YCODE_QA_DIRECTORY
|
||||
const relative = path.relative(path.resolve('.tmp'), directory || '')
|
||||
assert.ok(relative.startsWith('three-modules-') && !relative.includes(path.sep) && !path.isAbsolute(relative))
|
||||
app.setPath('home', path.join(directory, 'home'))
|
||||
const checks = []
|
||||
const requests = []
|
||||
const errors = []
|
||||
let win, server, releaseManual
|
||||
const manualGate = new Promise(resolve => { releaseManual = resolve })
|
||||
const read = source => win.webContents.executeJavaScript(source)
|
||||
const pause = ms => new Promise(resolve => setTimeout(resolve, ms))
|
||||
const wait = async (operation, label) => {
|
||||
for (let attempt = 0; attempt < 650; attempt++) {
|
||||
const result = await operation()
|
||||
if (result) return result
|
||||
await pause(40)
|
||||
}
|
||||
throw Error(`Timeout: ${label}`)
|
||||
}
|
||||
const waitUi = source => wait(() => read(source), source)
|
||||
const check = label => { checks.push(label); console.log(`PASS ${label}`) }
|
||||
const invoke = (namespace, method, ...args) => read(`window.ycode[${JSON.stringify(namespace)}][${JSON.stringify(method)}](...${JSON.stringify(args)})`)
|
||||
const fill = (selector, value, tag = 'HTMLInputElement', event = 'input') => read(`(() => {
|
||||
const input = document.querySelector(${JSON.stringify(selector)});
|
||||
Object.getOwnPropertyDescriptor(${tag}.prototype, 'value').set.call(input, ${JSON.stringify(value)});
|
||||
input.dispatchEvent(new Event(${JSON.stringify(event)}, {bubbles:true}));
|
||||
})()`)
|
||||
const sse = (body, delta, finishReason = null) => `data: ${JSON.stringify({ id: 'three-modules-response', object: 'chat.completion.chunk', created: 1,
|
||||
model: body.model, choices: [{ index: 0, delta, finish_reason: finishReason }] })}\n\n`
|
||||
const navigate = async label => {
|
||||
await read(`Array.from(document.querySelectorAll('.conversation-list-tab-button')).find(el => el.textContent.trim() === ${JSON.stringify(label)}).click()`)
|
||||
}
|
||||
|
||||
;(async () => {
|
||||
server = createServer(async (req, res) => {
|
||||
let raw = ''
|
||||
for await (const chunk of req) raw += chunk
|
||||
const body = JSON.parse(raw || '{}')
|
||||
const prompt = [...(body.messages || [])].reverse().find(message => message.role === 'user')
|
||||
const text = JSON.stringify(prompt?.content ?? '')
|
||||
requests.push({ model: body.model, text, tools: body.tools?.map(tool => tool.function?.name) })
|
||||
if (text.includes('FORCED_FAILURE')) {
|
||||
res.writeHead(400, { 'content-type': 'application/json' })
|
||||
res.end(JSON.stringify({ error: { message: 'CONTROLLED_UPSTREAM_FAILURE' } }))
|
||||
return
|
||||
}
|
||||
res.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-cache' })
|
||||
if (text.includes('PROJECT_FULL_ACCESS_COMMAND') && !body.messages.some(message => message.role === 'tool')) {
|
||||
const tool = body.tools?.find(item => item.function?.name === 'execute_command')
|
||||
assert.ok(tool, 'actual model tool schema must expose command execution')
|
||||
const args = { command: `node -e "require('fs').writeFileSync('project-full-access.txt','FULL_ACCESS_OK')"` }
|
||||
res.write(sse(body, { role: 'assistant', tool_calls: [{ index: 0, id: 'three-modules-command', type: 'function',
|
||||
function: { name: tool.function.name, arguments: JSON.stringify(args) } }] }))
|
||||
res.write(sse(body, {}, 'tool_calls'))
|
||||
} else if (text.includes('SCHEDULE_WRITE_ARTIFACT') && !body.messages.some(message => message.role === 'tool')) {
|
||||
const tool = body.tools?.find(item => /^(write|write_file|write_to_file)$/i.test(item.function?.name))
|
||||
assert.ok(tool, 'actual model tool schema must expose file writing')
|
||||
const args = { path: 'scheduled-report.md', file_path: 'scheduled-report.md', content: '# Scheduled artifact\n\nGenerated through the real tool loop.\n' }
|
||||
res.write(sse(body, { role: 'assistant', tool_calls: [{ index: 0, id: 'three-modules-write', type: 'function',
|
||||
function: { name: tool.function.name, arguments: JSON.stringify(args) } }] }))
|
||||
res.write(sse(body, {}, 'tool_calls'))
|
||||
} else {
|
||||
res.write(sse(body, { role: 'assistant', content: text.includes('MANUAL_HELD') ? 'MANUAL_PARTIAL' : 'SCHEDULE_SUCCEEDED' }))
|
||||
if (text.includes('MANUAL_HELD')) await manualGate
|
||||
if (res.destroyed) return
|
||||
res.write(sse(body, { content: '\nFINAL_PERSISTED_RESULT' }))
|
||||
res.write(sse(body, {}, 'stop'))
|
||||
}
|
||||
res.end('data: [DONE]\n\n')
|
||||
})
|
||||
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve))
|
||||
await import(pathToFileURL(path.resolve('out/main/index.mjs')).href)
|
||||
await app.whenReady()
|
||||
await wait(async () => {
|
||||
win = BrowserWindow.getAllWindows().find(item => item.getTitle() === 'YCode')
|
||||
return win && !win.webContents.isLoading() && await read('!!window.ycode?.automation && !!document.querySelector(".chat-pane")')
|
||||
}, 'production renderer')
|
||||
win.setSize(1336, 902)
|
||||
win.webContents.on('console-message', (...args) => {
|
||||
const details = args[0]
|
||||
const level = typeof details === 'object' ? details.level : args[1]
|
||||
const message = typeof details === 'object' ? details.message : args[2]
|
||||
if (level === 'error' || Number(level) >= 3) errors.push(message)
|
||||
})
|
||||
const registry = await invoke('workspace', 'attach', path.join(directory, 'workspace'))
|
||||
const workspaceId = registry.registry?.selectedWorkspaceId ?? registry.selectedWorkspaceId
|
||||
assert.ok(workspaceId, JSON.stringify(registry))
|
||||
await invoke('models', 'save', { id: 'three-modules-model', name: 'Three modules QA', providerId: 'custom',
|
||||
model: 'three-modules-upstream', baseUrl: `http://127.0.0.1:${server.address().port}/v1`,
|
||||
authMode: 'none', supportsToolCall: true, maxToolRounds: 5,
|
||||
supportsReasoning: true, defaultEffort: 'medium', supportedEfforts: ['low', 'medium', 'high', 'xhigh', 'max'],
|
||||
canDisableThinking: true })
|
||||
await invoke('models', 'select', 'three-modules-model')
|
||||
const project = await invoke('projects', 'create', { name: '三模块验收', instructions: 'QA_PROJECT_INSTRUCTION',
|
||||
workspaceId, experts: [], skills: [], connectors: [] })
|
||||
const definition = await invoke('automation', 'create', { name: '自动产物验收', prompt: 'SCHEDULE_WRITE_ARTIFACT',
|
||||
schedule: { type: 'once', scheduledAt: new Date(Date.now() + 2500).toISOString(), timeZone: 'Asia/Shanghai' },
|
||||
execution: { projectId: project.projectId, workspaceId, modelId: 'three-modules-model', permissionMode: 'full-access' } })
|
||||
const completed = await wait(async () => (await invoke('automation', 'listRuns', { automationId: definition.id }))
|
||||
.find(run => ['succeeded', 'failed', 'cancelled'].includes(run.status)), 'scheduled execution terminal')
|
||||
assert.equal(completed.status, 'succeeded', JSON.stringify(completed))
|
||||
assert.ok(completed.sessionId && completed.runId)
|
||||
const messages = await invoke('session', 'messages', completed.sessionId)
|
||||
assert.equal(messages.at(-1).status, 'done')
|
||||
assert.ok(messages.at(-1).content.includes('FINAL_PERSISTED_RESULT'))
|
||||
const session = (await invoke('session', 'list')).find(item => item.id === completed.sessionId)
|
||||
assert.equal(session.projectId, project.projectId)
|
||||
assert.equal(session.workspaceId, workspaceId)
|
||||
assert.equal(session.modelId, 'three-modules-model')
|
||||
await waitUi(`!!document.querySelector('[data-session-id="${completed.sessionId}"]')`)
|
||||
assert.equal(await fs.readFile(path.join(directory, 'workspace', 'scheduled-report.md'), 'utf8'), '# Scheduled artifact\n\nGenerated through the real tool loop.\n')
|
||||
check('scheduled task executes a real model/tool turn with frozen project, workspace and model; terminal follows persistence')
|
||||
|
||||
const manualDefinition = await invoke('automation', 'create', { name: '运行中验收', prompt: 'MANUAL_HELD',
|
||||
schedule: { type: 'once', scheduledAt: new Date(Date.now() + 3600_000).toISOString(), timeZone: 'Asia/Shanghai' },
|
||||
execution: { workspaceId, modelId: 'three-modules-model' } })
|
||||
const beforeManual = Date.now()
|
||||
const acknowledged = await invoke('automation', 'runNow', manualDefinition.id)
|
||||
assert.equal(acknowledged.status, 'running')
|
||||
assert.ok(Date.now() - beforeManual < 5000, 'runNow must acknowledge before the held response completes')
|
||||
const running = await wait(async () => (await invoke('automation', 'listRuns', { automationId: manualDefinition.id })).find(run => run.sessionId), 'real session linked while running')
|
||||
assert.equal(running.status, 'running')
|
||||
assert.equal(await invoke('automation', 'runNow', manualDefinition.id).then(() => false, () => true), true)
|
||||
check('manual execution acknowledges immediately, publishes real session while streaming and rejects overlapping execution')
|
||||
releaseManual()
|
||||
await wait(async () => (await invoke('automation', 'listRuns', { automationId: manualDefinition.id })).some(run => run.status === 'succeeded'), 'manual terminal')
|
||||
await invoke('automation', 'setEnabled', manualDefinition.id, false)
|
||||
assert.equal(await invoke('automation', 'runNow', manualDefinition.id).then(() => false, () => true), true)
|
||||
|
||||
const failing = await invoke('automation', 'create', { name: '失败验收', prompt: 'FORCED_FAILURE',
|
||||
schedule: { type: 'once', scheduledAt: new Date(Date.now() + 3600_000).toISOString(), timeZone: 'UTC' },
|
||||
execution: { workspaceId, modelId: 'three-modules-model' } })
|
||||
await invoke('automation', 'runNow', failing.id)
|
||||
const failure = await wait(async () => (await invoke('automation', 'listRuns', { automationId: failing.id })).find(run => run.status === 'failed'), 'actual upstream failure')
|
||||
assert.ok(failure.error)
|
||||
assert.equal((await invoke('session', 'messages', failure.sessionId)).at(-1).status, 'error')
|
||||
check('upstream error is persisted as a failed automation and assistant error; paused task cannot run')
|
||||
|
||||
const snapshot = await invoke('library', 'getSnapshot')
|
||||
const artifact = snapshot.artifacts.find(entry => entry.sessionId === completed.sessionId && entry.name === 'scheduled-report.md')
|
||||
assert.ok(artifact, JSON.stringify(snapshot))
|
||||
assert.equal(artifact.availability, 'available')
|
||||
assert.equal(artifact.projectId, project.projectId)
|
||||
assert.equal(snapshot.artifacts.length, 1, 'read-only source and unrelated workspace files must not be indexed')
|
||||
await navigate('资料库')
|
||||
await waitUi('!!document.querySelector(".library-page")')
|
||||
await read(`Array.from(document.querySelectorAll('.library-nav-item')).find(el => el.textContent.includes('本地产物')).click()`)
|
||||
await waitUi('!!document.querySelector(".library-file-row")')
|
||||
await read('document.querySelector(".library-file-row button[aria-label^=更多操作]").click()')
|
||||
await waitUi('!!document.querySelector(".library-file-menu .library-favorite")')
|
||||
await read('document.querySelector(".library-favorite").click()')
|
||||
await wait(async () => (await invoke('library', 'getSnapshot')).artifacts.find(entry => entry.id === artifact.id)?.favorite,
|
||||
'favorite saved through visible more menu')
|
||||
await fs.writeFile(path.join(directory, 'library-light.png'), (await win.webContents.capturePage()).toPNG())
|
||||
await read('document.querySelector(".library-file-button").click()')
|
||||
await waitUi('!!document.querySelector(".chat-pane") && document.body.innerText.includes("Scheduled artifact")')
|
||||
const recent = await invoke('library', 'getSnapshot')
|
||||
assert.ok(recent.artifacts[0].recentAt)
|
||||
assert.equal(recent.artifacts[0].favorite, true)
|
||||
check('library lists only the actual generated artifact and opens it through its source session; favorite/recent persist')
|
||||
|
||||
const loaded = new Promise(resolve => win.webContents.once('did-finish-load', resolve))
|
||||
win.webContents.reload()
|
||||
await loaded
|
||||
await waitUi('!!window.ycode?.automation && !!document.querySelector(".conversation-list-tab-button")')
|
||||
assert.equal((await invoke('library', 'getSnapshot')).artifacts[0].favorite, true)
|
||||
assert.ok((await invoke('automation', 'list')).some(item => item.id === definition.id))
|
||||
await navigate('定时任务')
|
||||
await waitUi('!!document.querySelector(".automation-page")')
|
||||
await waitUi('document.body.innerText.includes("自动产物验收")')
|
||||
await fs.writeFile(path.join(directory, 'automations-light.png'), (await win.webContents.capturePage()).toPNG())
|
||||
await read('localStorage.setItem("ycode:theme", "dark"); window.dispatchEvent(new StorageEvent("storage", {key:"ycode:theme",newValue:"dark"}))')
|
||||
await waitUi('document.documentElement.dataset.theme === "dark"')
|
||||
await pause(350)
|
||||
await fs.writeFile(path.join(directory, 'automations-dark.png'), (await win.webContents.capturePage()).toPNG())
|
||||
check('definitions, run records and library preferences survive renderer reload; real pages render in light/dark')
|
||||
|
||||
await read('document.querySelector(".automation-create-main").click()')
|
||||
await waitUi('!!document.querySelector(".automation-editor-modal")')
|
||||
await read('document.querySelector("button[aria-label=重复频率]").click()')
|
||||
await waitUi('document.querySelectorAll(".atm-freq-menu-popover [role=option]").length === 7')
|
||||
await read('Array.from(document.querySelectorAll(".atm-freq-menu-popover [role=option]")).find(el => el.textContent.trim() === "双周").click()')
|
||||
await waitUi('!document.querySelector(".atm-freq-menu-popover")')
|
||||
await fill('.automation-name-input', '表单验收')
|
||||
await fill('.automation-prompt-input', 'FORM_DAILY_TASK', 'HTMLTextAreaElement')
|
||||
await fs.writeFile(path.join(directory, 'automation-editor-dark.png'), (await win.webContents.capturePage()).toPNG())
|
||||
await read('document.querySelector(".automation-editor-footer .automation-button--primary").click()')
|
||||
await waitUi(`!document.querySelector('.automation-editor-modal') && !!document.querySelector('button[aria-label="编辑 表单验收"]')`)
|
||||
const formCreated = (await invoke('automation', 'list')).find(item => item.name === '表单验收')
|
||||
assert.equal(formCreated.schedule.frequency, 'biweekly')
|
||||
assert.ok(formCreated.schedule.rrule.includes('INTERVAL=2'))
|
||||
assert.equal(formCreated.execution.modelId, 'three-modules-model')
|
||||
await read(`document.querySelector('button[aria-label="编辑 表单验收"]').click()`)
|
||||
await waitUi('!!document.querySelector(".automation-prompt-input")')
|
||||
await fill('.automation-prompt-input', 'FORM_EDITED_TASK', 'HTMLTextAreaElement')
|
||||
await read('document.querySelector(".automation-editor-footer .automation-button--primary").click()')
|
||||
await waitUi('!document.querySelector(".automation-editor-modal")')
|
||||
const formEdited = (await invoke('automation', 'list')).find(item => item.id === formCreated.id)
|
||||
assert.equal(formEdited.prompt, 'FORM_EDITED_TASK')
|
||||
assert.equal(formEdited.schedule.anchorAt, formCreated.schedule.anchorAt)
|
||||
await read(`document.querySelector('button[aria-label="暂停 表单验收"]').click()`)
|
||||
await waitUi(`!!document.querySelector('button[aria-label="启用 表单验收"]')`)
|
||||
assert.equal((await invoke('automation', 'list')).find(item => item.id === formCreated.id).status, 'PAUSED')
|
||||
await read(`document.querySelector('button[aria-label="更多操作 表单验收"]').click()`)
|
||||
await waitUi('!!document.querySelector(".automation-task-menu")')
|
||||
await read('Array.from(document.querySelectorAll(".automation-task-menu button")).find(el=>el.textContent.trim()==="删除").click()')
|
||||
await waitUi('!!document.querySelector(".automation-confirm-dialog")')
|
||||
await read('document.querySelector(".automation-confirm-actions .automation-button--danger").click()')
|
||||
await waitUi(`!document.querySelector('.automation-confirm-dialog') && !document.querySelector('button[aria-label="编辑 表单验收"]')`)
|
||||
assert.ok(!(await invoke('automation', 'list')).some(item => item.id === formCreated.id))
|
||||
check('visible editor creates/edits a biweekly rule without moving its anchor, then pauses/deletes through real IPC')
|
||||
|
||||
await navigate('资料库')
|
||||
await waitUi('!!document.querySelector(".library-page")')
|
||||
await read(`Array.from(document.querySelectorAll('.library-nav-item')).find(el => el.textContent.includes('本地产物')).click()`)
|
||||
await waitUi('!!document.querySelector(".library-file-row")')
|
||||
await pause(350)
|
||||
await fs.writeFile(path.join(directory, 'library-dark.png'), (await win.webContents.capturePage()).toPNG())
|
||||
await read('document.querySelector(".conversation-list-tab-button-more").click()')
|
||||
await waitUi('!!document.querySelector(".yc-project-more button")')
|
||||
await read('document.querySelector(".yc-project-more button").click()')
|
||||
await waitUi('!!document.querySelector(".project-grid__card[role=button]")')
|
||||
await read('new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))')
|
||||
await read(`document.querySelector('.project-grid__card[role=button]')
|
||||
.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }))`)
|
||||
await waitUi('!!document.querySelector("textarea[aria-label=项目任务输入]")')
|
||||
await read('document.querySelector(".project-detail-view__project-composer .cr-model-selector__trigger").click()')
|
||||
await waitUi('!!document.querySelector(".cr-model-selector__menu")')
|
||||
await waitUi('!!document.querySelector(".cr-model-selector__menu .cr-model-selector__item[aria-selected=true]")')
|
||||
await pause(220)
|
||||
const modelPoint = await read(`(() => {
|
||||
const item = document.querySelector('.cr-model-selector__menu .cr-model-selector__item[aria-selected="true"]')
|
||||
if (!item) return null
|
||||
const rect = item.getBoundingClientRect()
|
||||
return { x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 }
|
||||
})()`)
|
||||
assert.ok(modelPoint)
|
||||
win.webContents.sendInputEvent({ type: 'mouseMove', x: Math.round(modelPoint.x), y: Math.round(modelPoint.y) })
|
||||
await waitUi('!!document.querySelector(".cr-model-selector__sub-menu-action")')
|
||||
await pause(220)
|
||||
const effortPoint = await read(`(() => {
|
||||
const item = document.querySelector('.cr-model-selector__sub-menu-action')
|
||||
const rect = item.getBoundingClientRect()
|
||||
return { x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 }
|
||||
})()`)
|
||||
win.webContents.sendInputEvent({ type: 'mouseMove', x: Math.round(effortPoint.x), y: Math.round(effortPoint.y) })
|
||||
await waitUi('!!document.querySelector(".cr-model-selector__sub-panel")')
|
||||
await read(`Array.from(document.querySelectorAll('.cr-model-selector__sub-panel-item'))
|
||||
.find(el => el.textContent.trim() === '极致').click()`)
|
||||
await waitUi(`Array.from(document.querySelectorAll('.cr-model-selector__sub-panel-item'))
|
||||
.some(el => el.textContent.trim() === '极致' && el.classList.contains('cr-model-selector__sub-panel-item--active'))`)
|
||||
win.webContents.sendInputEvent({ type: 'keyDown', keyCode: 'Escape' })
|
||||
win.webContents.sendInputEvent({ type: 'keyUp', keyCode: 'Escape' })
|
||||
await waitUi('!document.querySelector(".cr-model-selector__menu")')
|
||||
const projectSessionIdsBefore = new Set((await invoke('session', 'list')).map(item => item.id))
|
||||
await fill('textarea[aria-label=项目任务输入]', 'PROJECT_FULL_ACCESS_COMMAND', 'HTMLTextAreaElement')
|
||||
await read('document.querySelector(".project-detail-view__project-composer .cr-send-button").click()')
|
||||
await waitUi('!document.querySelector(".project-detail-view") && document.body.innerText.includes("PROJECT_FULL_ACCESS_COMMAND") && document.body.innerText.includes("FINAL_PERSISTED_RESULT")')
|
||||
const projectTask = (await invoke('session', 'list')).find(item => item.projectId === project.projectId && !projectSessionIdsBefore.has(item.id))
|
||||
assert.ok(projectTask)
|
||||
assert.equal((await invoke('session', 'messages', projectTask.id)).find(message => message.role === 'user').content, 'PROJECT_FULL_ACCESS_COMMAND')
|
||||
assert.equal(projectTask.projectId, project.projectId)
|
||||
assert.equal(projectTask.thinkingLevel, 'max')
|
||||
assert.equal(await fs.readFile(path.join(directory, 'workspace', 'project-full-access.txt'), 'utf8'), 'FULL_ACCESS_OK')
|
||||
check('project composer forwards max thinking and full-access, switches immediately, and executes a real privileged tool turn')
|
||||
|
||||
// A separate, unused directory avoids Windows locking an OMP process cwd.
|
||||
const failingWorkspace = path.resolve(directory, 'first-send-failure-workspace')
|
||||
assert.ok(failingWorkspace.startsWith(path.resolve(directory) + path.sep))
|
||||
await fs.mkdir(failingWorkspace)
|
||||
const failingRegistry = await invoke('workspace', 'attach', failingWorkspace)
|
||||
await invoke('projects', 'create', { name: '失败恢复项目', instructions: '',
|
||||
workspaceId: failingRegistry.selectedWorkspaceId, experts: [], skills: [], connectors: [] })
|
||||
await read('document.querySelector(".conversation-list-tab-button-more").click()')
|
||||
await waitUi('!!document.querySelector(".yc-project-more button")')
|
||||
await read('document.querySelector(".yc-project-more button").click()')
|
||||
await waitUi(`Array.from(document.querySelectorAll('.project-grid__card[role=button]')).some(el => el.textContent.includes('失败恢复项目'))`)
|
||||
await read(`Array.from(document.querySelectorAll('.project-grid__card[role=button]')).find(el => el.textContent.includes('失败恢复项目')).click()`)
|
||||
await waitUi('!!document.querySelector("textarea[aria-label=项目任务输入]")')
|
||||
const beforeFailedSend = (await invoke('session', 'list')).length
|
||||
await fs.rmdir(failingWorkspace)
|
||||
try {
|
||||
await fill('textarea[aria-label=项目任务输入]', 'RESTORE_PROJECT_DRAFT', 'HTMLTextAreaElement')
|
||||
await read('document.querySelector(".project-detail-view__project-composer .cr-send-button").click()')
|
||||
await waitUi('!document.querySelector(".project-detail-view") && document.querySelector(".wb-composer__input")?.value === "RESTORE_PROJECT_DRAFT"')
|
||||
assert.equal((await invoke('session', 'list')).length, beforeFailedSend)
|
||||
} finally { await fs.mkdir(failingWorkspace) }
|
||||
check('project first-send failure restores the composer draft and does not leave an empty session')
|
||||
assert.deepEqual(errors, [])
|
||||
assert.ok(requests.every(request => request.model === 'three-modules-upstream'))
|
||||
await fs.writeFile(path.join(directory, 'report.json'), JSON.stringify({ result: 'PASS', checks, completed, failure, artifact, requests, rendererErrors: errors }, null, 2))
|
||||
console.log(`THREE_MODULES_PASS ${directory}`)
|
||||
server.closeAllConnections()
|
||||
server.close()
|
||||
app.exit(0)
|
||||
})().catch(async error => {
|
||||
console.error(error)
|
||||
releaseManual?.()
|
||||
if (win && !win.isDestroyed()) await fs.writeFile(path.join(directory, 'failure.png'), (await win.webContents.capturePage()).toPNG()).catch(() => {})
|
||||
await fs.writeFile(path.join(directory, 'failure.json'), JSON.stringify({ error: String(error.stack || error), checks, requests, rendererErrors: errors }, null, 2))
|
||||
server?.closeAllConnections()
|
||||
server?.close()
|
||||
app.exit(1)
|
||||
})
|
||||
}
|
||||
@@ -56,7 +56,7 @@ const STUB = `<script>
|
||||
window: { minimize(){}, toggleMaximize(){}, close(){}, getState: async()=>({maximized:false,fullscreen:false}), onState:()=>()=>{}, onFocus:()=>()=>{} },
|
||||
edit: { action: async()=>{} },
|
||||
app: {
|
||||
info: async()=>({name:'YCode',version:'0.1.0',electron:'37',chromium:'',node:'',configDir:'',omp:{available:false,exePath:null,profile:'',model:null}}),
|
||||
info: async()=>({name:'YCode',version:'0.1.0',electron:'37',chromium:'',node:'',configDir:'',claude:{available:false,exePath:null,sessionDir:''}}),
|
||||
openConfigDir: async()=>{},
|
||||
// 原版由主进程另开设置窗口(settings.html?window=settings);离线替身在同一标签内切换。
|
||||
openSettings: async(section,action)=>{ try { const url=new URL('settings.html?window=settings',location.href); if(section) url.searchParams.set('section',String(section)); if(action) url.searchParams.set('action',String(action)); location.assign(url.href) } catch {} },
|
||||
@@ -93,7 +93,7 @@ const STUB = `<script>
|
||||
},
|
||||
chat: {
|
||||
send: async(payload)=>({runId:'offline-run',assistantMessageId:'offline-assistant',modelId:'',modelName:'离线假桥',userMessage:{id:'offline-user-message',role:'user',content:payload&&payload.text,createdAt:Date.now(),status:'done'}}),
|
||||
cancel: async()=>{}, cancelAgent: async()=>{}, onEvent:()=>()=>{}, onStarted:()=>()=>{},
|
||||
cancel: async()=>{}, setSessionVisible: async()=>({accepted:true}), onEvent:()=>()=>{}, onStarted:()=>()=>{},
|
||||
activeRun: async()=>null, inspectEdit: async()=>({editable:false,reason:'离线假桥不支持就地编辑'}),
|
||||
revert: async()=>({messages:[]}), respond: async()=>({accepted:false}), feedback: async()=>{}
|
||||
},
|
||||
|
||||
@@ -1,379 +0,0 @@
|
||||
// 真实隔离 YCode App + 真实 preload/IPC/渲染进程,验证用户消息就地编辑(TODO 03)。
|
||||
// 原版期望值来自:ev_245ea1740cde21869500348e47cf9663a1ea72943ccfe79c163a6838a153bfd3(打开/取消编辑)、
|
||||
// ev_5dd78e06e75b3a29b656d328c2efc2a820ea197e8204a39840207188a6496a0f(保持编辑器打开)、
|
||||
// ev_ee345b160e5f520ed866c1059b7c72e77b34e49bf1ce407cd266ea886aa86405(编辑态布局/计算样式快照)、
|
||||
// src-CyXHA0IC.css(SHA-256 a5e26b537bb50323aefbf25abddad52e288dd3868598be8d255d70886fe77cfe)。
|
||||
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/edit-'))
|
||||
const data = path.join(directory, 'data')
|
||||
const home = path.join(directory, 'home')
|
||||
const cwd = path.join(directory, 'fixture-workspace')
|
||||
await fs.mkdir(data); await fs.mkdir(home); await fs.mkdir(cwd)
|
||||
const crypto = require('node:crypto')
|
||||
const now = Date.now()
|
||||
// 悬停时间三档夹具:当天(既有消息)、同年异日、跨年。
|
||||
const currentYear = new Date(now).getFullYear()
|
||||
const todayKey = new Date(now).toDateString()
|
||||
const sameYearDay = [2, 3, 4, 5].map((day) => new Date(currentYear, 0, day, 9, 5).getTime())
|
||||
.find((value) => new Date(value).toDateString() !== todayKey)
|
||||
const otherYear = new Date(currentYear - 2, 11, 31, 9, 5).getTime()
|
||||
const crossYearUserId = crypto.randomUUID()
|
||||
const sameYearUserId = crypto.randomUUID()
|
||||
const firstUserId = crypto.randomUUID()
|
||||
const secondUserId = crypto.randomUUID()
|
||||
// 产物回退夹具:目标消息之后的助手回合写入了一个文件,编辑提交后该文件应回退到旧内容。
|
||||
const editedFilePath = path.join(cwd, 'edited-artifact.txt')
|
||||
const editedFileBefore = '旧内容:尚未编辑'
|
||||
const editedFileAfter = '新内容:编辑后写入'
|
||||
await fs.writeFile(editedFilePath, editedFileAfter, 'utf8')
|
||||
const sessions = [{ id: crypto.randomUUID(), title: 'Edit Live', cwd, createdAt: now, updatedAt: now + 1, messages: [
|
||||
{ id: crossYearUserId, role: 'user', content: '跨年历史消息', createdAt: otherYear, status: 'done', requestId: 'req-cross-year' },
|
||||
{ id: crypto.randomUUID(), role: 'assistant', content: '跨年回复。', createdAt: otherYear + 1000, status: 'done', requestId: 'req-cross-year' },
|
||||
{ id: sameYearUserId, role: 'user', content: '同年历史消息', createdAt: sameYearDay, status: 'done', requestId: 'req-same-year' },
|
||||
{ id: crypto.randomUUID(), role: 'assistant', content: '同年回复。', createdAt: sameYearDay + 1000, status: 'done', requestId: 'req-same-year' },
|
||||
{ id: firstUserId, role: 'user', content: '第一条历史消息', createdAt: now - 4000, status: 'done', requestId: 'req-first' },
|
||||
{ id: crypto.randomUUID(), role: 'assistant', content: '收到第一条。', createdAt: now - 3000, status: 'done', requestId: 'req-first' },
|
||||
{ id: secondUserId, role: 'user', content: 'workbuddy是否有目标模式', createdAt: now - 2000, status: 'done', requestId: 'req-second' },
|
||||
{ id: crypto.randomUUID(), role: 'assistant', content: '本地文档里没有直接命中。', createdAt: now - 1000, status: 'done', requestId: 'req-second', tools: [
|
||||
{ id: 'tool-write-1', name: 'write_file', status: 'ok', args: { path: editedFilePath }, result: { path: editedFilePath, oldContent: editedFileBefore, newContent: editedFileAfter } }
|
||||
] }
|
||||
] }, { id: crypto.randomUUID(), title: 'Edit Other', cwd, createdAt: now - 9000, updatedAt: now - 8000, messages: [
|
||||
{ id: crypto.randomUUID(), role: 'user', content: '另一个会话', createdAt: now - 9000, status: 'done' }
|
||||
] }]
|
||||
for (const message of sessions[0].messages) if (message.role === 'user') message.nativeEntryId = 'qa-native-' + message.id
|
||||
await fs.writeFile(path.join(data, 'sessions.json'), JSON.stringify({ sessions }))
|
||||
await fs.writeFile(path.join(directory, 'expected.json'), JSON.stringify({ crossYearUserId, sameYearUserId, firstUserId, secondUserId, editedFilePath, editedFileBefore, editedFileAfter,
|
||||
sameYearDay, otherYear, userTimes: [otherYear, sameYearDay, now - 4000, now - 2000] }))
|
||||
const env = { ...process.env, YCODE_QA_DIRECTORY: directory, YCODE_USER_DATA_DIR: data,
|
||||
YCODE_OMP_HOME: home, 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('USER_MESSAGE_EDIT_')) process.stdout.write(bytes) })
|
||||
child.stderr.on('data', bytes => { output += 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)
|
||||
process.exitCode = code ? 1 : 0
|
||||
})
|
||||
})()
|
||||
} else {
|
||||
const { app, BrowserWindow, ipcMain } = require('electron')
|
||||
app.disableHardwareAcceleration()
|
||||
const directory = process.env.YCODE_QA_DIRECTORY
|
||||
assert.ok(path.relative(path.resolve('.tmp'), directory).startsWith('edit-'))
|
||||
const expected = JSON.parse(require('node:fs').readFileSync(path.join(directory, 'expected.json'), 'utf8'))
|
||||
app.setPath('userData', path.join(directory, 'data')); app.setPath('sessionData', path.join(directory, 'data'))
|
||||
app.setPath('home', path.join(directory, 'home'))
|
||||
let main, phase = 'boot'
|
||||
const checks = [], snapshots = {}, rendererErrors = []
|
||||
const sentPayloads = []
|
||||
const revertCalls = []
|
||||
let sendDelayMs = 0
|
||||
const pause = ms => new Promise(resolve => setTimeout(resolve, ms))
|
||||
async function read(source) {
|
||||
let timer
|
||||
try {
|
||||
return await Promise.race([main.webContents.executeJavaScript(source), new Promise((_, reject) => {
|
||||
timer = setTimeout(() => reject(Error('Renderer timeout: ' + source.slice(0, 100))), 15000)
|
||||
})])
|
||||
} catch (error) { throw Error(error.message + ' - ' + source.slice(0, 240)) } finally { clearTimeout(timer) }
|
||||
}
|
||||
async function wait(source, label) {
|
||||
for (let index = 0; index < 250; index++) { const value = await read(source); if (value) return value; await pause(30) }
|
||||
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 })
|
||||
}
|
||||
async function shot(name) {
|
||||
const capture = await main.webContents.debugger.sendCommand('Page.captureScreenshot', { format: 'png', captureBeyondViewport: false })
|
||||
await fs.writeFile(path.join(directory, name + '.png'), Buffer.from(capture.data, 'base64'))
|
||||
}
|
||||
|
||||
const lastFrame = `[...document.querySelectorAll('.cr-frame--right')].pop()`
|
||||
const timesSource = `[...document.querySelectorAll('.cr-frame--right')].map(frame => frame.querySelector('.cr-toolbar__time')?.textContent ?? null)`
|
||||
// 与实现相同的三档规则(当天 HH:mm;同年 M月D日 HH:mm;跨年 YYYY年M月D日 HH:mm),独立实现用于校验渲染结果。
|
||||
function expectedTimeText(time) {
|
||||
const date = new Date(time)
|
||||
const now = new Date()
|
||||
const hoursMinutes = `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`
|
||||
if (date.toDateString() === now.toDateString()) return hoursMinutes
|
||||
const year = date.getFullYear() === now.getFullYear() ? '' : `${date.getFullYear()}年`
|
||||
return `${year}${date.getMonth() + 1}月${date.getDate()}日 ${hoursMinutes}`
|
||||
}
|
||||
const toolbarSource = `(()=>{const frame=${lastFrame};const toolbar=frame&&frame.querySelector('.cr-toolbar');if(!toolbar)return null;const style=getComputedStyle(toolbar);const time=toolbar.querySelector('.cr-toolbar__time');const buttons=[...toolbar.querySelectorAll('.cr-icon-button')].map(button=>{const bs=getComputedStyle(button);const box=button.getBoundingClientRect();const icon=button.querySelector('svg');return {action:button.getAttribute('data-action'),label:button.getAttribute('aria-label'),width:box.width,height:box.height,radius:bs.borderRadius,color:bs.color,icon:icon?[icon.getBoundingClientRect().width,icon.getBoundingClientRect().height,icon.getAttribute('viewBox')]:null}});const bubble=frame.querySelector('.cr-self-bubble').getBoundingClientRect();const frameBox=frame.getBoundingClientRect();const toolbarBox=toolbar.getBoundingClientRect();return {messageId:toolbar.getAttribute('data-message-id'),role:toolbar.getAttribute('role'),position:style.position,top:style.top,right:style.right,gap:style.gap,paddingTop:style.paddingTop,opacity:style.opacity,pointerEvents:style.pointerEvents,transition:style.transitionProperty+' '+style.transitionDuration+' '+style.transitionTimingFunction,time:time?{text:time.textContent,fontSize:getComputedStyle(time).fontSize,lineHeight:getComputedStyle(time).lineHeight,color:getComputedStyle(time).color}:null,buttons,buttonBox:[bubble.x,bubble.y,bubble.width,bubble.height],frameBox:[frameBox.x,frameBox.y,frameBox.width,frameBox.height],toolbarBox:[toolbarBox.x,toolbarBox.y,toolbarBox.width,toolbarBox.height]}})()`
|
||||
const editorSource = `(()=>{const frame=${lastFrame};const bubble=frame&&frame.querySelector('.cr-self-bubble');const editor=frame&&frame.querySelector('.cr-user-message-editor');if(!bubble||!editor)return null;const s=getComputedStyle(editor);const footer=editor.querySelector('.cr-user-message-editor__footer');const hint=editor.querySelector('.cr-user-message-editor__hint');const hintIcon=editor.querySelector('.cr-user-message-editor__hint-icon');const actions=editor.querySelector('.cr-user-message-editor__actions');const cancel=editor.querySelector('.cr-user-message-editor__cancel');const send=editor.querySelector('.cr-user-message-editor__send');const host=editor.querySelector('.cr-input-editor-host');const editable=editor.querySelector('[data-slate-editor]');const container=editor.querySelector('.cr-input-container');const box=node=>{const rect=node.getBoundingClientRect();return [rect.x,rect.y,rect.width,rect.height]};const style=node=>getComputedStyle(node);return {bubbleClass:bubble.className,bubbleBackground:style(bubble).backgroundColor,bubbleRadius:style(bubble).borderRadius,bubblePadding:style(bubble).padding,display:s.display,width:s.width,footerPaddingTop:footer?style(footer).paddingTop:null,footerGap:footer?style(footer).gap:null,footerJustify:footer?style(footer).justifyContent:null,hintText:hint?hint.textContent:null,hintFont:hint?style(hint).fontSize+'/'+style(hint).lineHeight:null,hintColor:hint?style(hint).color:null,hintGap:hint?style(hint).gap:null,hintIcon:hintIcon?box(hintIcon):null,actionsGap:actions?style(actions).gap:null,cancel:cancel?{box:box(cancel),disabled:cancel.disabled,radius:style(cancel).borderRadius,padding:style(cancel).padding,font:style(cancel).fontSize+'/'+style(cancel).lineHeight,border:style(cancel).borderTopWidth+' '+style(cancel).borderTopColor,color:style(cancel).color,background:style(cancel).backgroundColor}:null,send:send?{box:box(send),disabled:send.disabled,busy:send.getAttribute('aria-busy'),radius:style(send).borderRadius,padding:style(send).padding,font:style(send).fontSize+'/'+style(send).lineHeight,color:style(send).color,background:style(send).backgroundColor,gap:style(send).gap,spinner:!!send.querySelector('svg')}:null,host:host?{box:box(host),minHeight:style(host).minHeight,overflow:style(host).overflowY,padding:style(host).padding}:null,editable:editable?{box:box(editable),fontSize:style(editable).fontSize,lineHeight:style(editable).lineHeight,minHeight:style(editable).minHeight,text:editable.innerText}:null,container:container?{box:box(container),background:style(container).backgroundColor,border:style(container).borderTopWidth,boxShadow:style(container).boxShadow,radius:style(container).borderRadius,paddingTop:style(container).paddingTop,minHeight:style(container).minHeight}:null,error:editor.querySelector('.cr-user-message-editor__error')?editor.querySelector('.cr-user-message-editor__error').textContent:null,toolbarMounted:!!frame.querySelector('.cr-toolbar')}})()`
|
||||
const setEditable = text => `(()=>{const editable=document.querySelector('.cr-user-message-editor [data-slate-editor]');if(!editable)return false;editable.focus();editable.innerText=${JSON.stringify(text)};editable.dispatchEvent(new Event('input',{bubbles:true}));return true})()`
|
||||
|
||||
;(async () => {
|
||||
await import('node:url').then(({ pathToFileURL }) => import(pathToFileURL(path.resolve('out/main/index.mjs')).href))
|
||||
await app.whenReady()
|
||||
// 记录编辑提交负载并返回合成分支:隔离环境没有可用模型,但提交链路必须真实经过 IPC。
|
||||
// 回退走真实渲染链路与真实 preload/IPC;主进程侧换成记录器,
|
||||
// 真实文件与历史语义由 npm run test:conversation-ipc 覆盖。
|
||||
ipcMain.removeHandler('chat:revert')
|
||||
ipcMain.handle('chat:revert', async (_event, sessionId, messageId, options) => {
|
||||
revertCalls.push({ sessionId, messageId, options })
|
||||
return { messages: [] }
|
||||
})
|
||||
ipcMain.removeHandler('chat:send')
|
||||
ipcMain.handle('chat:send', async (_event, payload) => {
|
||||
if (sendDelayMs) await pause(sendDelayMs)
|
||||
sentPayloads.push(payload)
|
||||
const runId = 'edit-run-' + sentPayloads.length
|
||||
const userMessage = { id: payload.fromMessageId, nativeEntryId: 'qa-replacement', role: 'user', content: payload.text, createdAt: Date.now(), status: 'done', requestId: runId }
|
||||
const assistant = { id: 'edit-assistant-' + sentPayloads.length, role: 'assistant', content: '已重新开始。', createdAt: Date.now(), status: 'done', requestId: runId, tools: [], parts: [] }
|
||||
// ChatEvent 的 done 形状:{ runId, type:'done', content, tools }。
|
||||
setTimeout(() => { try { main?.webContents.send('chat:event', { sessionId: payload.sessionId, runId, type: 'done', content: assistant.content, tools: [] }) } catch { /* window closed */ } }, 60)
|
||||
return { runId, assistantMessageId: assistant.id, userMessage, messages: [userMessage, assistant] }
|
||||
})
|
||||
main = BrowserWindow.getAllWindows()[0]
|
||||
main.setSize(1336, 902)
|
||||
main.showInactive()
|
||||
main.webContents.debugger.attach('1.3')
|
||||
main.webContents.on('console-message', (_event, level, message) => { if (level >= 2) rendererErrors.push(message) })
|
||||
await wait('!!document.querySelector("#root")', 'renderer root')
|
||||
await pause(1500)
|
||||
await step('select-session')
|
||||
await wait(`!!document.querySelector('[data-session-id]')`, 'sidebar rows')
|
||||
await read(`(()=>{const rows=[...document.querySelectorAll('[data-session-id]')];const row=rows.find(item=>item.textContent.includes('Edit Live'));if(!row)return false;row.click();return true})()`)
|
||||
await wait(`!!${lastFrame}.querySelector('.cr-toolbar')`, 'user toolbar')
|
||||
await pause(300)
|
||||
|
||||
await step('toolbar-idle')
|
||||
main.webContents.sendInputEvent({ type: 'mouseMove', x: 4, y: 4 })
|
||||
await pause(200)
|
||||
const idle = await wait(toolbarSource, 'toolbar snapshot')
|
||||
snapshots.idle = idle
|
||||
const userBubbles = await read(`[...document.querySelectorAll('.cr-frame--right')].length`)
|
||||
check('fixture renders four user messages', userBubbles, 4)
|
||||
check('toolbar keeps the original DOM contract', idle.role === 'toolbar' && typeof idle.messageId === 'string' && idle.messageId.length > 0, true)
|
||||
check('toolbar is absolutely positioned with right 0', idle.position === 'absolute' && idle.right === '0px', true)
|
||||
// 原版实测(ev_245ea174…):工具条底边与消息 frame 底边重合、右边缘对齐(frame 182.59 / toolbar 182.59、925.98 / 925.98)。
|
||||
check('toolbar bottom and right edge align with the message frame like the original', Math.abs((idle.toolbarBox[1] + idle.toolbarBox[3]) - (idle.frameBox[1] + idle.frameBox[3])) <= 1 && Math.abs((idle.toolbarBox[0] + idle.toolbarBox[2]) - (idle.frameBox[0] + idle.frameBox[2])) <= 1, true)
|
||||
check('toolbar gap is 8 and padding-top is 4', idle.gap === '8px' && idle.paddingTop === '4px', true)
|
||||
check('toolbar fades with the original transition', idle.transition, 'opacity 0.2s ease-in-out')
|
||||
check('toolbar is hidden while not hovered', idle.opacity === '0' && idle.pointerEvents === 'none', true)
|
||||
check('toolbar time follows the 14/20 tertiary rule', idle.time.fontSize === '14px' && idle.time.lineHeight === '20px' && idle.time.color === 'rgba(0, 0, 0, 0.5)', true)
|
||||
check('toolbar time uses HH:mm for today', /^\d{2}:\d{2}$/.test(idle.time.text), true)
|
||||
// 原版桌面端回退入口标签来自 rewind.entry.tooltip(ConversationTimelineImpl.revertLabel),
|
||||
// 该消息之后存在产物写入,因此弹窗走 artifacts 变体。
|
||||
check('toolbar keeps the original button set (copy + revert + edit, no resend)', idle.buttons.length, 3)
|
||||
check('button order is copy, revert, edit like the original', idle.buttons.map(item => item.action).join(','), 'copy,revert,edit')
|
||||
check('buttons are 24x24 with 14x14 icons and radius 6', idle.buttons.every(item => item.width === 24 && item.height === 24 && item.radius === '6px' && item.icon[0] === 14 && item.icon[1] === 14), true)
|
||||
check('copy/edit keep the 16x16 viewBox while revert keeps the recovered 14x14 one', idle.buttons.map(item => item.action + ':' + item.icon[2]).join(','), 'copy:0 0 16 16,revert:0 0 14 14,edit:0 0 16 16')
|
||||
check('buttons expose the recovered rewind tooltip', idle.buttons.map(item => item.label).join(','), '复制,将对话及产物回退到此消息前,编辑')
|
||||
|
||||
await step('toolbar-hover')
|
||||
const bubble = idle.buttons[0] && (await read(`(()=>{const box=${lastFrame}.querySelector('.cr-self-bubble').getBoundingClientRect();return {x:Math.round(box.x+box.width/2),y:Math.round(box.y+box.height/2)}})()`))
|
||||
main.webContents.sendInputEvent({ type: 'mouseMove', x: bubble.x, y: bubble.y })
|
||||
await pause(400)
|
||||
check('hovering the message reveals the toolbar', await read(`getComputedStyle(${lastFrame}.querySelector('.cr-toolbar')).opacity`), '1')
|
||||
check('hovered toolbar accepts pointer events', await read(`getComputedStyle(${lastFrame}.querySelector('.cr-toolbar')).pointerEvents`), 'auto')
|
||||
await shot('toolbar-hover')
|
||||
|
||||
await step('hover-time-tiers')
|
||||
const times = await read(timesSource)
|
||||
snapshots.times = times
|
||||
check('every user message owns a hover time', times.length, expected.userTimes.length)
|
||||
check('hover time follows the confirmed three-tier format', times, expected.userTimes.map(expectedTimeText))
|
||||
check('same-year non-today tier renders M月D日 HH:mm', times[1], expectedTimeText(expected.sameYearDay))
|
||||
check('cross-year tier renders YYYY年M月D日 HH:mm', times[0], expectedTimeText(expected.otherYear))
|
||||
check('today tier renders HH:mm', times[3], expectedTimeText(expected.userTimes[3]))
|
||||
await read(`(()=>{const rows=[...document.querySelectorAll('[data-session-id]')];const row=rows.find(item=>item.textContent.includes('Edit Other'));if(!row)return false;row.click();return true})()`)
|
||||
await pause(500)
|
||||
check('switching sessions shows the other session time only', await read(`document.querySelectorAll('.cr-frame--right .cr-toolbar__time').length`), 1)
|
||||
await read(`document.querySelector('.cr-frame--right [data-action="edit"]').click()`)
|
||||
await wait(`!!document.querySelector('.chat-pane [role="alert"]')`, 'legacy edit preflight error')
|
||||
check('legacy messages fail preflight before opening an editor', await read(`!!document.querySelector('.cr-user-message-editor')`), false)
|
||||
check('legacy preflight explains the missing branch', await read(`document.querySelector('.chat-pane [role="alert"]').textContent.includes('原生分支')`), true)
|
||||
await read(`(()=>{const rows=[...document.querySelectorAll('[data-session-id]')];const row=rows.find(item=>item.textContent.includes('Edit Live'));if(!row)return false;row.click();return true})()`)
|
||||
await pause(600)
|
||||
check('history reload keeps the three-tier hover times', await read(timesSource), expected.userTimes.map(expectedTimeText))
|
||||
|
||||
await step('open-editor')
|
||||
await read(`(()=>{const button=${lastFrame}.querySelector('.cr-toolbar .cr-icon-button[data-action="edit"]');if(!button)return false;button.click();return true})()`)
|
||||
const editor = await wait(editorSource, 'editor snapshot')
|
||||
snapshots.editor = editor
|
||||
check('editing unmounts the hover toolbar like the original', editor.toolbarMounted, false)
|
||||
check('bubble gets the editing class, surface and 8x12 padding', editor.bubbleClass.includes('cr-self-bubble--editing') && editor.bubbleBackground === 'rgb(242, 242, 242)' && editor.bubblePadding === '8px 12px', true)
|
||||
check('editor container is transparent with no border or shadow', editor.container.background === 'rgba(0, 0, 0, 0)' && editor.container.border === '0px' && editor.container.boxShadow === 'none' && editor.container.radius === '0px', true)
|
||||
check('editor container keeps min-height 50 with no top padding', editor.container.minHeight === '50px' && editor.container.paddingTop === '0px', true)
|
||||
check('editor host keeps the 64px min-height without padding', editor.host.minHeight === '64px' && editor.host.padding === '0px', true)
|
||||
check('editable uses 15px/1.75 like the original', editor.editable.fontSize === '15px' && editor.editable.lineHeight === '26.25px', true)
|
||||
check('editable keeps the original min-height 50px', editor.editable.minHeight === '50px', true)
|
||||
check('editable is prefilled with the message text', editor.editable.text, 'workbuddy是否有目标模式')
|
||||
check('footer uses 12px top padding, 12px gap and space-between', editor.footerPaddingTop === '12px' && editor.footerGap === '12px' && editor.footerJustify === 'space-between', true)
|
||||
check('hint keeps the recovered copy', editor.hintText, '编辑后将从此处重新开始对话,产物也将回退至此信息前版本')
|
||||
check('hint uses 13/18 with 4px gap and 14x14 icon', editor.hintFont === '13px/18px' && editor.hintGap === '4px' && editor.hintIcon[2] === 14 && editor.hintIcon[3] === 14, true)
|
||||
check('actions gap is 8', editor.actionsGap, '8px')
|
||||
check('cancel is 62x32 with 8px radius and 5px 16px padding', Math.round(editor.cancel.box[2]) === 62 && Math.round(editor.cancel.box[3]) === 32 && editor.cancel.radius === '8px' && editor.cancel.padding === '5px 16px', true)
|
||||
check('cancel uses the default border and secondary text', editor.cancel.border === '1px rgb(235, 235, 235)' && editor.cancel.color === 'rgba(0, 0, 0, 0.7)', true)
|
||||
check('send is 60x30 on the contrast surface', Math.round(editor.send.box[2]) === 60 && Math.round(editor.send.box[3]) === 30 && editor.send.background === 'rgb(26, 26, 26)' && editor.send.color === 'rgb(255, 255, 255)', true)
|
||||
check('send button is not disabled when content is unchanged', editor.send.disabled, false)
|
||||
check('no error is shown while content is valid', editor.error, null)
|
||||
await shot('editor-open')
|
||||
|
||||
await step('cancel-restores')
|
||||
await read(setEditable('改动但不提交'))
|
||||
await pause(120)
|
||||
check('edited text is reflected in the field', await read(`document.querySelector('.cr-user-message-editor [data-slate-editor]').innerText`), '改动但不提交')
|
||||
await read(`document.querySelector('.cr-user-message-editor__cancel').click()`)
|
||||
await pause(250)
|
||||
check('cancel closes the editor', await read(`!!document.querySelector('.cr-user-message-editor')`), false)
|
||||
check('cancel restores the original bubble text', await read(`[...document.querySelectorAll('.cr-frame--right')].pop().querySelector('.cr-self-bubble').textContent`), 'workbuddy是否有目标模式')
|
||||
|
||||
await step('outside-mousedown-keeps-changes')
|
||||
await read(`(()=>{const bubble=[...document.querySelectorAll('.cr-frame--right')].pop();bubble.scrollIntoView({block:'center'});bubble.dispatchEvent(new MouseEvent('mousemove',{bubbles:true}));bubble.dispatchEvent(new MouseEvent('mouseover',{bubbles:true}));return true})()`)
|
||||
main.webContents.sendInputEvent({ type: 'mouseMove', x: bubble.x, y: bubble.y })
|
||||
await pause(350)
|
||||
await read(`${lastFrame}.querySelector('.cr-toolbar .cr-icon-button[data-action="edit"]').click()`)
|
||||
await wait(`!!document.querySelector('.cr-user-message-editor')`, 'editor reopen')
|
||||
await read(setEditable('有改动时点击外部'))
|
||||
await pause(120)
|
||||
await read(`document.body.dispatchEvent(new MouseEvent('mousedown',{bubbles:true}))`)
|
||||
await pause(200)
|
||||
check('mousedown outside keeps the editor when there are changes', await read(`!!document.querySelector('.cr-user-message-editor')`), true)
|
||||
|
||||
await step('empty-validation')
|
||||
await read(setEditable(' '))
|
||||
await pause(150)
|
||||
const emptyEditor = await read(editorSource)
|
||||
check('blank content raises the recovered empty error', emptyEditor.error, '消息内容不能为空')
|
||||
check('send is disabled while the content is empty', emptyEditor.send.disabled, true)
|
||||
await read(`document.querySelector('.cr-user-message-editor__send').click()`)
|
||||
await pause(200)
|
||||
check('blocked submit never reaches chat:send', sentPayloads.filter(item => item.action === 'edit').length, 0)
|
||||
await read(setEditable('workbuddy是否有目标模式'))
|
||||
await pause(120)
|
||||
check('error clears once content is valid again', await read(`!!document.querySelector('.cr-user-message-editor__error')`), false)
|
||||
|
||||
await step('submit-edit')
|
||||
sendDelayMs = 700
|
||||
await read(setEditable('workbuddy 有目标模式吗'))
|
||||
await pause(120)
|
||||
await read(`document.querySelector('.cr-user-message-editor__send').click()`)
|
||||
await wait(`document.querySelector('.dialog[role="dialog"]')?.getAttribute('aria-label') === '是否回退产物历史版本?'`, 'edit artifact choice')
|
||||
check('artifact edits ask before sending', sentPayloads.length, 0)
|
||||
await read(`document.querySelector('.dialog[role="dialog"] .dialog__actions button').click()`)
|
||||
await wait(`!document.querySelector('.dialog[role="dialog"]')`, 'cancel edit artifact choice')
|
||||
check('cancelling the artifact choice retains the edited draft', await read(`document.querySelector('.cr-user-message-editor [data-slate-editor]').innerText`), 'workbuddy 有目标模式吗')
|
||||
check('cancelling the artifact choice does not send', sentPayloads.length, 0)
|
||||
await read(`document.querySelector('.cr-user-message-editor__send').click()`)
|
||||
await wait(`!!document.querySelector('.dialog[role="dialog"]')`, 'edit artifact choice reopened')
|
||||
await read(`(()=>{const button=[...document.querySelectorAll('.dialog__actions button')].find(item=>item.textContent==='不回退');button.click()})()`)
|
||||
await pause(250)
|
||||
const submitting = await read(editorSource)
|
||||
snapshots.submitting = submitting
|
||||
check('submitting disables cancel and send with aria-busy', submitting.cancel.disabled === true && submitting.send.disabled === true && submitting.send.busy === 'true', true)
|
||||
check('submitting shows the spinner inside the send button', submitting.send.spinner, true)
|
||||
// 原版 showDefaultRevert/showDefaultEdit = !isSessionActive:会话活动期间回退与编辑入口都不渲染。
|
||||
const busyEntries = await read(`[...document.querySelectorAll('.cr-frame--right')].map(frame => ({messageId: frame.querySelector('.cr-toolbar')?.getAttribute('data-message-id') ?? null, actions: [...(frame.querySelector('.cr-toolbar')?.querySelectorAll('.cr-icon-button') ?? [])].map(button => button.getAttribute('data-action'))})).filter(item => item.messageId)`)
|
||||
check('active session hides rewind and edit entries like showDefaultRevert/showDefaultEdit', busyEntries.every(item => item.actions.join(',') === 'copy'), true)
|
||||
for (let index = 0; index < 60 && sentPayloads.length === 0; index++) await pause(50)
|
||||
const editPayload = sentPayloads.find(item => item.action === 'edit')
|
||||
check('edit submit reaches chat:send through IPC', !!editPayload, true)
|
||||
check('edit payload carries action=edit with the original message id', editPayload.action === 'edit' && editPayload.fromMessageId === expected.secondUserId, true)
|
||||
check('edit payload sends the modified text', editPayload.text, 'workbuddy 有目标模式吗')
|
||||
check('edit payload records the explicit choice to preserve current artifacts', editPayload.rollbackArtifacts, false)
|
||||
await wait(`!document.querySelector('.cr-user-message-editor')`, 'editor closed after submit')
|
||||
check('submit closes the editor', await read(`!!document.querySelector('.cr-user-message-editor')`), false)
|
||||
// 产物回退由真实主进程语义覆盖:npm run test:conversation-ipc 的 testEditArtifactRollback
|
||||
// (本脚本把 chat:send 换成 stub,因此不在此处重复断言文件内容)。
|
||||
await shot('editor-submitted')
|
||||
|
||||
await step('enter-submits')
|
||||
sendDelayMs = 0
|
||||
await read(`(()=>{const bubble=[...document.querySelectorAll('.cr-frame--right')].pop();bubble.dispatchEvent(new MouseEvent('mousemove',{bubbles:true}));return true})()`)
|
||||
main.webContents.sendInputEvent({ type: 'mouseMove', x: bubble.x, y: bubble.y })
|
||||
await pause(300)
|
||||
await read(`(()=>{const button=${lastFrame}.querySelector('.cr-toolbar .cr-icon-button[data-action="edit"]');if(!button)return false;button.click();return true})()`)
|
||||
await wait(`!!document.querySelector('.cr-user-message-editor')`, 'editor for enter test')
|
||||
await read(setEditable('回车提交'))
|
||||
await pause(120)
|
||||
await read(`document.querySelector('.cr-user-message-editor [data-slate-editor]').dispatchEvent(new KeyboardEvent('keydown',{key:'Enter',bubbles:true}))`)
|
||||
for (let index = 0; index < 60 && sentPayloads.filter(item => item.action === 'edit').length < 2; index++) await pause(50)
|
||||
check('Enter submits the edited message like the original onEnterSend', sentPayloads.filter(item => item.action === 'edit').length, 2)
|
||||
check('second edit payload keeps the typed text', sentPayloads.filter(item => item.action === 'edit')[1].text, '回车提交')
|
||||
await wait(`!document.querySelector('.cr-user-message-editor')`, 'editor closed after enter submit')
|
||||
|
||||
// 编辑提交会以桩返回替换内存消息;重新选中会话以从真实 sessions 存储恢复夹具,
|
||||
// 再验证回退入口(resolveRevertEntryVisible)、弹窗三态与确认链路。
|
||||
await step('rewind-reload-fixture')
|
||||
await read(`window.location.reload()`)
|
||||
await wait(`!!document.querySelector('#root')`, 'renderer reloaded')
|
||||
await pause(1500)
|
||||
await wait(`!!document.querySelector('[data-session-id]')`, 'sidebar rows after reload')
|
||||
await read(`(()=>{const rows=[...document.querySelectorAll('[data-session-id]')];const row=rows.find(item=>item.textContent.includes('Edit Live'));if(!row)return false;row.click();return true})()`)
|
||||
await wait(`document.querySelectorAll('.cr-frame--right').length === 4`, 'fixture restored')
|
||||
|
||||
await step('rewind-entry')
|
||||
const rewindEntries = await read(`[...document.querySelectorAll('.cr-frame--right')].map(frame => ({messageId: frame.querySelector('.cr-toolbar')?.getAttribute('data-message-id') ?? null, actions: [...(frame.querySelector('.cr-toolbar')?.querySelectorAll('.cr-icon-button') ?? [])].map(button => button.getAttribute('data-action'))}))`)
|
||||
snapshots.rewindEntries = rewindEntries
|
||||
const entryOf = id => rewindEntries.find(item => item.messageId === id)?.actions ?? null
|
||||
check('first user message hides the rewind entry like resolveRevertEntryVisible', entryOf(expected.crossYearUserId), ['copy', 'edit'])
|
||||
check('later user messages render copy + rewind + edit', [expected.sameYearUserId, expected.firstUserId, expected.secondUserId].map(entryOf).map(actions => actions?.join(',')).join('|'),
|
||||
'copy,revert,edit|copy,revert,edit|copy,revert,edit')
|
||||
|
||||
const openRewind = async () => {
|
||||
await read(`(()=>{const frame=[...document.querySelectorAll('.cr-frame--right')].find(item => item.querySelector('.cr-toolbar')?.getAttribute('data-message-id') === ${JSON.stringify(expected.secondUserId)});const button=frame?.querySelector('.cr-toolbar .cr-icon-button[data-action="revert"]');if(!button)return false;button.click();return true})()`)
|
||||
await wait(`!!document.querySelector('.dialog[role="dialog"]')`, 'rewind dialog')
|
||||
}
|
||||
await step('rewind-dialog-cancel')
|
||||
await openRewind()
|
||||
const dialog = await read(`(()=>{const dialog=document.querySelector('.dialog[role="dialog"]');if(!dialog)return null;const rows=[...dialog.querySelectorAll('.rewind-artifacts__row')].map(row=>({name:row.querySelector('.rewind-artifacts__name')?.textContent??null,title:row.querySelector('.rewind-artifacts__name')?.getAttribute('title')??null,status:row.querySelector('.rewind-artifacts__status')?.textContent??null}));const actions=[...dialog.querySelectorAll('.dialog__actions button')];return {title:dialog.querySelector('.dialog__title')?.textContent??null,description:dialog.querySelector('p')?.textContent??null,aria:dialog.getAttribute('aria-label'),labels:actions.map(button=>button.textContent),rows}})()`)
|
||||
snapshots.rewindDialog = dialog
|
||||
check('rewind dialog uses the recovered artifacts variant title', dialog.title, '将对话及以下产物版本回退到此消息前?')
|
||||
check('rewind dialog carries the recovered description', dialog.description, '回退暂不支持部分类型文件')
|
||||
check('rewind dialog offers cancel, keep-artifacts and rewind', dialog.labels.join(','), '取消,不回退,回退')
|
||||
check('rewind dialog lists the pending artifact with its status', dialog.rows.length === 1 && dialog.rows[0].name === 'edited-artifact.txt' && dialog.rows[0].status === '回退至历史版本' && dialog.rows[0].title === expected.editedFilePath, true)
|
||||
await shot('rewind-dialog')
|
||||
await read(`document.querySelector('.dialog[role="dialog"] .dialog__actions button').click()`)
|
||||
await wait(`!document.querySelector('.dialog[role="dialog"]')`, 'rewind dialog closed')
|
||||
check('cancel keeps the conversation and never reaches chat:revert', revertCalls.length, 0)
|
||||
|
||||
await step('rewind-confirm')
|
||||
await openRewind()
|
||||
await read(`(()=>{const actions=[...document.querySelectorAll('.dialog[role="dialog"] .dialog__actions button')];actions[actions.length-1].click();return true})()`)
|
||||
for (let index = 0; index < 60 && revertCalls.length === 0; index++) await pause(50)
|
||||
check('confirm reaches chat:revert with the session and message id', revertCalls.length === 1 && revertCalls[0].messageId === expected.secondUserId && typeof revertCalls[0].sessionId === 'string', true)
|
||||
await wait(`!document.querySelector('.dialog[role="dialog"]')`, 'rewind dialog closed after confirm')
|
||||
check('rewind replaces the conversation with the rewound prefix', await read(`document.querySelectorAll('.cr-frame--right').length`), 0)
|
||||
await read(`window.location.reload()`)
|
||||
await wait(`!!document.querySelector('[data-session-id]')`, 'reload for conversation-only rewind')
|
||||
await read(`(()=>{const row=[...document.querySelectorAll('[data-session-id]')].find(item=>item.textContent.includes('Edit Live'));row.click()})()`)
|
||||
await wait(`document.querySelectorAll('.cr-frame--right').length === 4`, 'conversation-only fixture')
|
||||
await openRewind()
|
||||
await read(`(()=>{const button=[...document.querySelectorAll('.dialog__actions button')].find(item=>item.textContent==='不回退');button.click()})()`)
|
||||
await wait(`!document.querySelector('.dialog[role="dialog"]')`, 'conversation-only rewind closed')
|
||||
check('conversation-only rewind sends the artifact option through preload', revertCalls[1]?.options?.rollbackArtifacts, false)
|
||||
check('no renderer error logs', rendererErrors, [])
|
||||
|
||||
const report = { result: checks.every(item => item.pass) ? 'PASS' : 'FAIL',
|
||||
scope: 'Real isolated YCode App/preload/IPC/renderer. chat:send is stubbed in the test main process to record payloads because the sandbox has no model.',
|
||||
sourceEvidence: ['ev_245ea1740cde21869500348e47cf9663a1ea72943ccfe79c163a6838a153bfd3', 'ev_5dd78e06e75b3a29b656d328c2efc2a820ea197e8204a39840207188a6496a0f',
|
||||
'ev_ee345b160e5f520ed866c1059b7c72e77b34e49bf1ce407cd266ea886aa86405', 'src-CyXHA0IC.css a5e26b537bb50323aefbf25abddad52e288dd3868598be8d255d70886fe77cfe',
|
||||
'ev_e91787b1d4dea651582c6cf12827775b9068b0ee8b80c68ba08d4c1d87d94e90(rewind.* zh-CN 文案)',
|
||||
'ev_717fa5e846e64d67ac19c8925d121bff632c0d3aefb3603b10858559cabb2dcb(ConversationTimelineImpl rewind 接线,脚本 SHA-256 5f35b748…)'],
|
||||
snapshots, checks, rendererErrors }
|
||||
await fs.writeFile(path.join(directory, 'report.json'), JSON.stringify(report, null, 2))
|
||||
console.log('USER_MESSAGE_EDIT_' + report.result + ' ' + directory)
|
||||
for (const failure of checks.filter(item => !item.pass)) console.log('USER_MESSAGE_EDIT_FAILURE ' + JSON.stringify(failure))
|
||||
app.exit(report.result === 'PASS' ? 0 : 1)
|
||||
})().catch(async error => {
|
||||
await fs.writeFile(path.join(directory, 'failure.json'), JSON.stringify({ phase, error: String(error), stack: error.stack, checks, snapshots, rendererErrors }, null, 2))
|
||||
console.error(error); if (main) await shot('failure').catch(() => undefined); app.exit(1)
|
||||
})
|
||||
}
|
||||
Loaded 100 of 232 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user