import assert from 'node:assert/strict' import { EventEmitter } from 'node:events' import { mkdtemp, mkdir, readFile, writeFile, rm, symlink } 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' import { configureSandbox } from '../src/main/sandbox/configuration' async function main() { const directory = await mkdtemp(join(tmpdir(), 'ycode-conversation-runtime-')) const workspace = join(directory, 'workspace') await mkdir(workspace) configureSandbox({ stateRoot: join(directory, 'sandbox-state'), resourceDirectory: join(directory, 'missing-sandbox-backend') }) 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((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 pendingApproval = nextInteraction() const write = executeConversationTool('write_file', { path: 'nested/new/file.txt', content: 'first' }, context) const approval = await pendingApproval assert.equal(approval.kind, 'approval') await assert.rejects(readFile(join(workspace, 'nested/new/file.txt')), { code: 'ENOENT' }) assert.equal(respondToInteraction('another-run', approval.id, { kind: 'approval', optionId: 'allow-once' }, emit), false) assert.throws(() => respondToInteraction('run', approval.id, { kind: 'approval', optionId: 'invented' }, emit), /无效/) assert.equal(respondToInteraction('run', approval.id, { kind: 'approval', optionId: 'allow-once' }, emit), true) assert.equal(respondToInteraction('run', approval.id, { kind: 'approval', optionId: 'allow-once' }, emit), false) 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) const pendingEdit = nextInteraction() const edit = executeConversationTool('edit_file', { path: 'nested/new/file.txt', oldText: 'first', newText: 'second' }, context) const editApproval = await pendingEdit await writeFile(join(workspace, 'nested/new/file.txt'), 'external change') respondToInteraction('run', editApproval.id, { kind: 'approval', optionId: 'allow-once' }, emit) assert.equal((await edit).ok, false) assert.equal(await readFile(join(workspace, 'nested/new/file.txt'), 'utf8'), 'external change', 'stale approval cannot overwrite a newer file') const outside = join(directory, 'outside') await mkdir(outside) await writeFile(join(outside, 'secret.txt'), 'private') await symlink(outside, join(workspace, 'linked'), process.platform === 'win32' ? 'junction' : 'dir') assert.equal((await executeConversationTool('read_file', { path: 'linked/secret.txt' }, context)).ok, false) assert.equal((await executeConversationTool('write_file', { path: '../outside/deep/new.txt', content: 'x' }, context)).ok, false) const unavailableCommand = await executeConversationTool('execute_command', { command: 'echo SHOULD_NOT_RUN' }, context) assert.equal(unavailableCommand.ok, false, 'a missing native backend must fail closed') assert.match(unavailableCommand.content, /ENOENT|原生组件/) 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, permissionMode: 'full-access', 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 })