156 lines
11 KiB
TypeScript
156 lines
11 KiB
TypeScript
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 })
|