8b501266ff
Add live agent progress, elapsed timing, verified code navigation and rich Markdown rendering. Preserve NanumSquare styling with missing glyph fallback, fix platform availability filtering and unify workspace scrollbar and visualization controls.
173 lines
11 KiB
JavaScript
173 lines
11 KiB
JavaScript
const { test } = require('node:test')
|
|
const assert = require('node:assert/strict')
|
|
const fs = require('node:fs')
|
|
const vm = require('node:vm')
|
|
const ts = require('typescript')
|
|
const React = require('react')
|
|
const { createHash, webcrypto } = require('node:crypto')
|
|
|
|
function compile(source, globals = {}, requireModule = require) {
|
|
const exports = {}
|
|
vm.runInNewContext(ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, jsx: ts.JsxEmit.ReactJSX } }).outputText, {
|
|
exports, require: requireModule, Error, crypto: webcrypto, TextEncoder, window: { setTimeout: callback => setTimeout(callback, 1) }, ...globals
|
|
})
|
|
return exports
|
|
}
|
|
const tools = compile(fs.readFileSync(require.resolve('../src/shared/agentTools.ts'), 'utf8'))
|
|
const navigation = compile(fs.readFileSync(require.resolve('../src/renderer/src/features/workspace/agentCodeNavigation.ts'), 'utf8'), {}, name => name.endsWith('shared/agentTools') ? tools : require(name))
|
|
const content = 'def configure():\n speed = 0.4\n return speed\n'
|
|
const hash = value => createHash('sha256').update(value).digest('hex')
|
|
const reference = { id: 'location', moduleName: 'Control', path: 'src/node.py', startLine: 2, endLine: 3, reason: 'speed config', snippet: ' speed = 0.4\n return speed', expectedHash: hash(content) }
|
|
|
|
test('unchanged source reveals the verified line range, while unsaved insertions relocate it', async () => {
|
|
assert.deepEqual(JSON.parse(JSON.stringify(await navigation.resolveCodeReferenceRange(content, reference))), { startLine: 2, endLine: 3 })
|
|
assert.deepEqual(JSON.parse(JSON.stringify(await navigation.resolveCodeReferenceRange('# unsaved\n' + content, reference))), { startLine: 3, endLine: 4 })
|
|
assert.deepEqual(JSON.parse(JSON.stringify(await navigation.resolveCodeReferenceRange(content.replace(/\n/g, '\r\n'), reference))), { startLine: 2, endLine: 3 })
|
|
})
|
|
|
|
test('rejects missing, ambiguous or altered code rather than highlighting a guessed line', async () => {
|
|
for (const changed of [content.replace('0.4', '0.8'), `${content}\n${content}`]) await assert.rejects(navigation.resolveCodeReferenceRange(changed, reference), /위치를 확정/)
|
|
for (const patch of [{ expectedHash: undefined }, { snippet: '' }, { snippet: '[REDACTED]' }, { snippet: 'one line' }, { path: '../etc/passwd' }, { path: '.env' }, { startLine: 0 }, { endLine: 500 }]) {
|
|
await assert.rejects(navigation.resolveCodeReferenceRange(content, { ...reference, ...patch }))
|
|
}
|
|
})
|
|
|
|
function editorHarness(text = content) {
|
|
const calls = []
|
|
const collection = { clear: () => calls.push('clear') }
|
|
const model = { uri: { path: '/modules/Control/src/node.py' }, getValue: () => text, isDisposed: () => false, getLineMaxColumn: line => text.split('\n')[line - 1].length + 1 }
|
|
const editor = { getModel: () => model, getDomNode: () => ({ clientWidth: 800, clientHeight: 600 }), layout: () => calls.push('layout'), createDecorationsCollection: value => { calls.push(['decorate', value]); return collection }, revealRangeInCenter: value => calls.push(['reveal', value]), setPosition: value => calls.push(['position', value]), focus: () => calls.push('focus') }
|
|
const options = { file: 'modules/Control/src/node.py', reference, isCurrent: () => true, getEditor: () => editor, getActiveFile: () => 'modules/Control/src/node.py', isReady: () => true, attempts: 4 }
|
|
return { model, editor, options, calls, collection }
|
|
}
|
|
|
|
test('focuses and highlights only after the correct Monaco model has loaded', async () => {
|
|
const h = editorHarness()
|
|
let reads = 0
|
|
h.options.getEditor = () => ++reads < 3 ? null : h.editor
|
|
assert.equal(await navigation.revealAgentCodeReference(h.options), h.collection)
|
|
assert.deepEqual(h.calls.map(call => Array.isArray(call) ? call[0] : call), ['layout', 'decorate', 'position', 'reveal', 'focus'])
|
|
const selection = h.calls.find(call => call[0] === 'reveal')[1]
|
|
assert.equal(selection.startLineNumber, 2)
|
|
assert.equal(selection.endLineNumber, 3)
|
|
assert.equal(h.calls[1][1][0].options.isWholeLine, true)
|
|
})
|
|
|
|
test('does not decorate another file, a hidden/diff editor, stale scope or unready model', async () => {
|
|
for (const setup of [
|
|
h => { h.model.uri.path = '/modules/Other/src/node.py' },
|
|
h => { h.options.getActiveFile = () => 'other.py' },
|
|
h => { h.options.isReady = () => false },
|
|
h => { h.options.isCurrent = () => false },
|
|
h => { h.editor.getDomNode = () => ({ clientWidth: 0, clientHeight: 0 }) },
|
|
h => { h.model.isDisposed = () => true }
|
|
]) {
|
|
const h = editorHarness()
|
|
setup(h)
|
|
await assert.rejects(navigation.revealAgentCodeReference(h.options))
|
|
assert.equal(h.calls.length, 0)
|
|
}
|
|
})
|
|
|
|
const hookSource = ts.createSourceFile('useAiAgent.ts', fs.readFileSync(require.resolve('../src/renderer/src/features/workspace/useAiAgent.ts'), 'utf8'), ts.ScriptTarget.Latest, true)
|
|
const hook = hookSource.statements.find(node => ts.isFunctionDeclaration(node) && node.name?.text === 'useAiAgent')
|
|
const functions = hook.body.statements.filter(node => ts.isVariableStatement(node) && ['openCodeReference', 'cancelCodeReference'].includes(node.declarationList.declarations[0].name.getText(hookSource)))
|
|
function consentHarness() {
|
|
const session = {}
|
|
const messageStateRef = { current: { session, messages: [{ id: 'answer', role: 'assistant', plan: { codeReferences: [reference] } }] } }
|
|
const globals = { session, messageStateRef, navigatingRef: { current: false }, applyingRef: { current: false }, abortControllerRef: { current: null }, updateMessages: update => { messageStateRef.current.messages = update(messageStateRef.current.messages) } }
|
|
return { ...compile(functions.map(node => `export ${node.getText(hookSource)}`).join('\n'), globals), globals, message: () => messageStateRef.current.messages[0] }
|
|
}
|
|
|
|
test('opening requires explicit approval, cancellation never opens or applies anything', async () => {
|
|
const h = consentHarness()
|
|
let opened = 0
|
|
const open = async () => { opened++ }
|
|
assert.equal(opened, 0)
|
|
h.cancelCodeReference('answer', 'location')
|
|
await h.openCodeReference('answer', 'location', open)
|
|
assert.equal(opened, 0)
|
|
assert.equal(h.message().codeReferenceStates.location.status, 'cancelled')
|
|
const approved = consentHarness()
|
|
await approved.openCodeReference('answer', 'location', async ref => { assert.equal(ref.path, reference.path); opened++ })
|
|
assert.equal(opened, 1)
|
|
assert.equal(approved.message().codeReferenceStates.location.status, 'opened')
|
|
})
|
|
|
|
test('duplicate clicks, invalid IDs and busy tasks do not open more files', async () => {
|
|
const h = consentHarness()
|
|
let finish, opened = 0
|
|
const first = h.openCodeReference('answer', 'location', () => { opened++; return new Promise(resolve => { finish = resolve }) })
|
|
assert.equal(h.message().codeReferenceStates.location.status, 'opening')
|
|
h.cancelCodeReference('answer', 'location')
|
|
assert.equal(h.message().codeReferenceStates.location.status, 'opening')
|
|
await h.openCodeReference('answer', 'location', async () => { opened++ })
|
|
finish()
|
|
await first
|
|
assert.equal(opened, 1)
|
|
await h.openCodeReference('answer', 'invented', async () => { opened++ })
|
|
h.globals.applyingRef.current = true
|
|
await h.openCodeReference('answer', 'location', async () => { opened++ })
|
|
assert.equal(opened, 1)
|
|
})
|
|
|
|
test('errors remain visible and late navigation cannot update another conversation', async () => {
|
|
const h = consentHarness()
|
|
await h.openCodeReference('answer', 'location', async () => { throw Error('missing file') })
|
|
assert.equal(h.message().codeReferenceStates.location.status, 'error')
|
|
assert.equal(h.message().codeReferenceStates.location.error, 'missing file')
|
|
let finish
|
|
const pending = h.openCodeReference('answer', 'location', () => new Promise(resolve => { finish = resolve }))
|
|
const other = { id: 'other' }
|
|
h.globals.messageStateRef.current = { session: {}, messages: [other] }
|
|
finish()
|
|
await pending
|
|
assert.equal(h.message(), other)
|
|
})
|
|
|
|
test('a new question cancels pending navigation without leaving its old state stuck opening', async () => {
|
|
const h = consentHarness()
|
|
let finish, current
|
|
const pending = h.openCodeReference('answer', 'location', (_reference, isCurrent) => {
|
|
current = isCurrent
|
|
return new Promise((_resolve, reject) => { finish = () => reject(Error('cancelled')) })
|
|
})
|
|
h.globals.messageStateRef.current.messages.push({ id: 'new-question', role: 'user' })
|
|
assert.equal(current(), false)
|
|
finish()
|
|
await pending
|
|
assert.equal(h.globals.messageStateRef.current.messages[0].codeReferenceStates.location.status, 'error')
|
|
assert.equal(h.globals.messageStateRef.current.messages.at(-1).id, 'new-question')
|
|
})
|
|
|
|
test('restored interrupted navigation becomes retryable without reopening any file', () => {
|
|
const { recoverInterruptedAgentMessages } = compile(fs.readFileSync(require.resolve('../src/renderer/src/features/workspace/agentContext.ts'), 'utf8'))
|
|
const message = { id: 'answer', role: 'assistant', codeReferenceStates: { pending: { status: 'opening' }, approved: { status: 'opened' }, declined: { status: 'cancelled' } } }
|
|
const restored = recoverInterruptedAgentMessages([message])[0]
|
|
assert.equal(restored.codeReferenceStates.pending.status, 'error')
|
|
assert.equal(restored.codeReferenceStates.approved.status, 'opened')
|
|
assert.equal(restored.codeReferenceStates.declined.status, 'cancelled')
|
|
assert.equal(message.codeReferenceStates.pending.status, 'opening')
|
|
})
|
|
|
|
function elements(tree, predicate) {
|
|
if (!React.isValidElement(tree)) return []
|
|
return [...(predicate(tree) ? [tree] : []), ...React.Children.toArray(tree.props.children).flatMap(child => elements(child, predicate))]
|
|
}
|
|
function text(tree) {
|
|
return typeof tree === 'string' ? tree : React.isValidElement(tree) ? React.Children.toArray(tree.props.children).map(text).join('') : ''
|
|
}
|
|
const render = compile(fs.readFileSync(require.resolve('../src/renderer/src/components/AgentCodeReferences.tsx'), 'utf8'), {}, name => name.endsWith('/i18n') ? { t: key => key, useLocaleRender() {} } : require(name)).AgentCodeReferences
|
|
for (const dark of [true, false]) test(`consent controls use cancel-left/open-right and preserve states (${dark})`, () => {
|
|
const clicked = []
|
|
const props = { dark, references: [reference], onOpen: id => clicked.push(`open:${id}`), onCancel: id => clicked.push(`cancel:${id}`) }
|
|
const buttons = elements(render(props), element => element.type === 'button')
|
|
assert.equal(clicked.length, 0)
|
|
assert.deepEqual(buttons.map(text), ['취소', '코드에서 보기'])
|
|
buttons[1].props.onClick()
|
|
assert.deepEqual(clicked, ['open:location'])
|
|
for (const button of elements(render({ ...props, busy: true }), element => element.type === 'button')) assert.equal(button.props.disabled, true)
|
|
assert.equal(elements(render({ ...props, states: { location: { status: 'cancelled' } } }), element => element.type === 'button').length, 0)
|
|
assert.equal(elements(render({ ...props, states: { location: { status: 'error', error: 'missing' } } }), element => element.props.role === 'alert').length, 1)
|
|
})
|