From a8dd23675a53c3d58e1445481b3c25c65f88e237 Mon Sep 17 00:00:00 2001 From: chyoo Date: Tue, 20 Oct 2026 11:36:28 +0900 Subject: [PATCH] feat: improve agent model selection and remote source analysis; bump to 1.2.23 --- package-lock.json | 4 +- package.json | 2 +- scripts/test-agent-code-navigation.cjs | 3 +- scripts/test-agent-context.cjs | 6 +- scripts/test-agent-evidence.cjs | 143 ++++++++++++++++++ scripts/test-agent-model-selection.cjs | 63 ++++++++ scripts/test-agent-progress.cjs | 67 +++++++- scripts/test-agent-request-payload.cjs | 22 ++- scripts/test-agent-tools.cjs | 15 ++ scripts/test-agent-workspace-routing.cjs | 68 +++++++++ scripts/test-chat-clear-confirmation.cjs | 1 + src/main/agentFileTools.ts | 10 +- src/main/agentRemoteFiles.ts | 11 +- src/renderer/src/App.tsx | 20 ++- .../src/components/AgentModelSelector.tsx | 32 ++++ src/renderer/src/components/ChatWidgets.tsx | 7 +- .../features/workspace/WorkspaceQaPanel.tsx | 7 + .../src/features/workspace/agentContext.ts | 73 ++++++--- .../src/features/workspace/agentEvidence.ts | 107 +++++++++++++ .../src/features/workspace/agentModels.ts | 34 +++++ .../src/features/workspace/useAiAgent.ts | 84 +++++++--- src/renderer/src/i18n/en.json | 2 + 22 files changed, 711 insertions(+), 70 deletions(-) create mode 100644 scripts/test-agent-evidence.cjs create mode 100644 scripts/test-agent-model-selection.cjs create mode 100644 scripts/test-agent-workspace-routing.cjs create mode 100644 src/renderer/src/components/AgentModelSelector.tsx create mode 100644 src/renderer/src/features/workspace/agentEvidence.ts create mode 100644 src/renderer/src/features/workspace/agentModels.ts diff --git a/package-lock.json b/package-lock.json index 5c6a0ef..988d88c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "lms", - "version": "1.2.22", + "version": "1.2.23", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "lms", - "version": "1.2.22", + "version": "1.2.23", "dependencies": { "@electron-toolkit/preload": "^3.0.1", "@electron-toolkit/utils": "^4.0.0", diff --git a/package.json b/package.json index 197ac8f..4e49153 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "lms", - "version": "1.2.22", + "version": "1.2.23", "description": "Autonomy Studio LMS Electron app", "main": "./out/main/index.js", "author": { diff --git a/scripts/test-agent-code-navigation.cjs b/scripts/test-agent-code-navigation.cjs index 2b8d5d4..04fef20 100644 --- a/scripts/test-agent-code-navigation.cjs +++ b/scripts/test-agent-code-navigation.cjs @@ -188,7 +188,8 @@ test('a new question cancels pending navigation without leaving its old state st }) 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 evidence = compile(fs.readFileSync(require.resolve('../src/renderer/src/features/workspace/agentEvidence.ts'), 'utf8')) + const { recoverInterruptedAgentMessages } = compile(fs.readFileSync(require.resolve('../src/renderer/src/features/workspace/agentContext.ts'), 'utf8'), {}, name => name === './agentEvidence' ? evidence : require(name)) 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') diff --git a/scripts/test-agent-context.cjs b/scripts/test-agent-context.cjs index 8998173..e19d537 100644 --- a/scripts/test-agent-context.cjs +++ b/scripts/test-agent-context.cjs @@ -5,9 +5,13 @@ const vm = require('node:vm') const ts = require('typescript') const output = {} +const evidenceApi = {} +vm.runInNewContext(ts.transpileModule(fs.readFileSync(require.resolve('../src/renderer/src/features/workspace/agentEvidence.ts'), 'utf8'), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 } +}).outputText, { exports: evidenceApi }) vm.runInNewContext(ts.transpileModule(fs.readFileSync(require.resolve('../src/renderer/src/features/workspace/agentContext.ts'), 'utf8'), { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 } -}).outputText, { exports: output, DOMException }) +}).outputText, { exports: output, DOMException, require: name => { assert.equal(name, './agentEvidence'); return evidenceApi } }) const { resolveAgentContext } = output const plain = value => JSON.parse(JSON.stringify(value)) const moduleInfo = (name, files = []) => ({ name, state: 'stopped', files }) diff --git a/scripts/test-agent-evidence.cjs b/scripts/test-agent-evidence.cjs new file mode 100644 index 0000000..e3c1153 --- /dev/null +++ b/scripts/test-agent-evidence.cjs @@ -0,0 +1,143 @@ +const { test } = require('node:test') +const assert = require('node:assert/strict') +const fs = require('node:fs') +const ts = require('typescript') +require.extensions['.ts'] = (module, filename) => module._compile(ts.transpileModule(fs.readFileSync(filename, 'utf8'), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 } +}).outputText, filename) +const { compactAgentEvidence, missingAgentRead, readFileEvidence } = require('../src/renderer/src/features/workspace/agentEvidence.ts') +const { resolveAgentContext } = require('../src/renderer/src/features/workspace/agentContext.ts') +const file = (startLine, endLine, extra = {}, result = {}) => ({ + id: `read-${startLine}-${endLine}`, name: 'files.read', moduleName: 'Control', target: 'ssh:jetson', status: 'ok', observedAt: '2026-10-20T00:00:00.000Z', truncated: true, + data: JSON.stringify({ path: 'config/car.yaml', exists: true, hash: 'a'.repeat(64), content: Array.from({ length: endLine - startLine + 1 }, (_, i) => `line${startLine + i}`).join('\n'), + startLine, endLine, totalLines: 100, complete: false, contentTruncated: false, ...extra }), ...result +}) +const read = (startLine, lineCount, path = 'config/car.yaml') => ({ id: 'read', name: 'files.read', moduleName: 'Control', args: { path, ...(startLine === undefined ? {} : { startLine }), ...(lineCount === undefined ? {} : { lineCount }) } }) +const answer = toolRequests => ({ provider: 'test', model: 'test', plan: { summary: 'result', explanation: 'verified code', toolRequests, changes: [], commands: [], actionPlan: [], createModule: null } }) + +test('overlapping and adjacent ranges merge without promoting partial evidence to editable full files', () => { + const results = compactAgentEvidence([file(1, 10), file(8, 15), file(16, 20)]) + assert.equal(results.length, 1) + const data = readFileEvidence(results[0]) + assert.equal(data.startLine, 1) + assert.equal(data.endLine, 20) + assert.equal(data.content.split('\n').length, 20) + assert.equal(data.complete, false) + assert.equal(results[0].truncated, true) + assert.equal(missingAgentRead(read(3, 16), results), undefined) + assert.deepEqual(missingAgentRead(read(), results), read()) +}) + +test('queries only the first missing range and never joins disjoint or conflicting content', () => { + const results = compactAgentEvidence([file(1, 5), file(10, 15)]) + assert.equal(results.length, 2) + assert.deepEqual(missingAgentRead(read(3, 15), results).args, { path: 'config/car.yaml', startLine: 6, lineCount: 4 }) + assert.equal(compactAgentEvidence([file(1, 10), file(8, 15, { content: 'different\ncontent' })]).length, 2) +}) + +test('host boundaries and file revisions cannot be combined or used to fill each other’s gaps', () => { + const changed = compactAgentEvidence([file(1, 10), file(11, 20, { hash: 'b'.repeat(64) })]) + assert.equal(changed.length, 1) + assert.equal(missingAgentRead(read(1, 20), changed).args.startLine, 1) + const hosts = compactAgentEvidence([file(1, 10, {}, { target: 'local-host' }), file(11, 20)]) + assert.equal(hosts.length, 2) + assert.equal(missingAgentRead(read(1, 20), hosts).args.startLine, 1) +}) + +test('full and missing observations are reused but partial character fragments are never coverage', () => { + const full = file(1, 10, { complete: true }, { truncated: false }) + assert.equal(missingAgentRead(read(5, 100), [full]), undefined) + assert.equal(compactAgentEvidence([full, file(5, 8)]).length, 1) + const missing = file(1, 1, { exists: false, content: '', hash: null, complete: true }, { truncated: false }) + assert.equal(missingAgentRead(read(), [missing]), undefined) + const cut = file(1, 2, { content: 'whole\ncut', endLine: 1, contentTruncated: true }) + assert.equal(missingAgentRead(read(2, 1), [cut]).args.startLine, 2) + const legacy = file(1, 2, { endLine: undefined, contentTruncated: undefined }) + assert.equal(missingAgentRead(read(2, 1), [legacy]).args.startLine, 2) +}) + +test('malformed or oversized evidence never becomes reusable complete coverage', () => { + assert.equal(readFileEvidence(file(1, 10, { hash: 'not-a-hash' })), undefined) + assert.equal(readFileEvidence(file(1, 10, { endLine: 999 })), undefined) + const large = compactAgentEvidence([file(1, 1, { content: 'a'.repeat(30_000) }), file(2, 2, { content: 'b'.repeat(30_000) })]) + assert.equal(large.length, 2) +}) + +test('overlap requests execute only missing lines and finish once the requested range is cached', async () => { + const executions = [] + let calls = 0 + await resolveAgentContext({ + initialContext: { modules: [], runtimeLogs: [] }, signal: new AbortController().signal, + executeTool: async tool => { executions.push(tool.args); return file(tool.args.startLine, tool.args.startLine + tool.args.lineCount - 1) }, + requestPlan: async (context, final) => { + calls++ + if (final) { + assert.equal(context.toolResults.length, 1) + assert.equal(JSON.parse(context.toolResults[0].data).endLine, 15) + return answer([]) + } + return answer([[read(1, 10)], [read(5, 11)], [read(4, 10)]][calls - 1]) + } + }) + assert.deepEqual(executions, [read(1, 10).args, read(11, 5).args]) + assert.equal(calls, 4) +}) + +test('source evidence survives many later searches and omissions are reported within DTO limits', async () => { + const searchResults = Array.from({ length: 15 }, (_, i) => ({ ...file(1, 1), id: `search-${i}`, name: 'files.search', data: JSON.stringify({ query: `speed${i}`, matches: [] }) })) + await resolveAgentContext({ + initialContext: { modules: [], runtimeLogs: [], toolResults: [file(1, 10), ...searchResults] }, signal: new AbortController().signal, + requestPlan: async context => { + assert.equal(context.toolResults.length, 12) + assert.equal(context.toolResults[0].name, 'files.read') + assert.match(context.contextNotes[0], /4개/) + assert.ok(context.contextNotes.length <= 12) + assert.ok(context.contextNotes.every(note => note.length <= 500)) + return answer([]) + } + }) +}) + +test('a cached file omitted by budgets is restored when the model requests it again', async () => { + let calls = 0 + const results = Array.from({ length: 13 }, (_, i) => file(1, 10, { path: `file${i}.yaml` })) + await resolveAgentContext({ + initialContext: { modules: [], runtimeLogs: [], toolResults: results }, signal: new AbortController().signal, + executeTool: async () => { throw Error('cached file must not be queried again') }, + requestPlan: async (context, final) => { + calls++ + if (!final) return answer([read(1, 5, 'file0.yaml')]) + assert.ok(context.toolResults.some(result => JSON.parse(result.data).path === 'file0.yaml')) + return answer([]) + } + }) + assert.equal(calls, 2) +}) + +test('finalization retains verified code locations while stripping all proposed mutations', async () => { + const reference = { id: 'code-1', moduleName: 'Control', path: 'config/car.yaml', startLine: 1, endLine: 3, reason: 'verified', expectedHash: 'a'.repeat(64), snippet: 'line1' } + const result = await resolveAgentContext({ + initialContext: { modules: [], runtimeLogs: [], toolResults: [file(1, 10)] }, signal: new AbortController().signal, + executeTool: async () => { throw Error('already cached') }, + requestPlan: async (_, final) => ({ ...answer([read(1, 5)]), plan: { ...answer([read(1, 5)]).plan, ...(final ? { changes: [{ path: 'unsafe' }], commands: [{ command: 'unsafe' }] } : { codeReferences: [reference] }) } }) + }) + assert.equal(result.plan.codeReferences.length, 1) + assert.match(result.plan.explanation, /verified code/) + assert.deepEqual(result.plan.changes, []) + assert.deepEqual(result.plan.commands, []) +}) + +test('recovery never resurrects code locations after a different file revision was observed', async () => { + let calls = 0 + const reference = { id: 'code-1', moduleName: 'Control', path: 'config/car.yaml', startLine: 1, endLine: 3, reason: 'verified', expectedHash: 'a'.repeat(64), snippet: 'line1' } + const result = await resolveAgentContext({ + initialContext: { modules: [], runtimeLogs: [], toolResults: [file(1, 10)] }, signal: new AbortController().signal, + executeTool: async () => file(11, 20, { hash: 'b'.repeat(64) }), + requestPlan: async (_, final) => { + calls++ + return !final && calls === 1 ? { ...answer([read(11, 10)]), plan: { ...answer([read(11, 10)]).plan, codeReferences: [reference] } } : answer([read(11, 10)]) + } + }) + assert.deepEqual(result.plan.codeReferences, []) + assert.match(result.plan.summary, /확정하지 못했습니다/) +}) diff --git a/scripts/test-agent-model-selection.cjs b/scripts/test-agent-model-selection.cjs new file mode 100644 index 0000000..1a3b65d --- /dev/null +++ b/scripts/test-agent-model-selection.cjs @@ -0,0 +1,63 @@ +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') + +function compile(path, overrides = {}) { + const exports = {} + const source = fs.readFileSync(require.resolve(path), 'utf8') + vm.runInNewContext(ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, jsx: ts.JsxEmit.ReactJSX } }).outputText, + { exports, require: name => overrides[name] ?? require(name) }) + return exports +} +const models = compile('../src/renderer/src/features/workspace/agentModels.ts') +const options = [{ provider: 'OPENAI', model: 'gpt-6.1-sol', imagesEnabled: false }, { provider: 'QWEN', model: 'qwen3.8-flash', imagesEnabled: true }] +const plain = value => JSON.parse(JSON.stringify(value)) + +test('normalizes enabled models, validates IDs and preserves old single-model servers', () => { + const status = models.normalizeAgentStatus({ enabled: true, models: [options[1], options[0], options[0], { provider: 'FORGED', model: 'bad' }, { provider: 'GEMINI', model: '' }] }) + assert.deepEqual(plain(status.models), options) + assert.equal(status.selectionSupported, true) + assert.deepEqual(plain(models.normalizeAgentStatus({ enabled: true, ...options[1] }).models), [options[1]]) + assert.equal(models.normalizeAgentStatus({ enabled: true, ...options[1] }).selectionSupported, false) + assert.equal(models.normalizeAgentStatus({ enabled: false, models: options }).models.length, 0) + assert.equal(models.normalizeAgentStatus({ enabled: true, models: [] }).enabled, false) +}) + +test('selects the saved provider only while it is available, and never invents one', () => { + assert.equal(models.selectedAgentModel(options, 'QWEN'), options[1]) + assert.equal(models.selectedAgentModel(options, 'ANTHROPIC'), options[0]) + assert.equal(models.selectedAgentModel([options[0]], 'QWEN'), options[0]) + assert.equal(models.selectedAgentModel([], 'QWEN'), null) +}) + +function elements(root, predicate) { + if (!React.isValidElement(root)) return [] + return [...(predicate(root) ? [root] : []), ...React.Children.toArray(root.props.children).flatMap(child => elements(child, predicate))] +} +const { AgentModelSelector } = compile('../src/renderer/src/components/AgentModelSelector.tsx', { + '../i18n': { t: value => value }, '../features/workspace/agentModels': models, +}) + +for (const dark of [true, false]) test(`model control lists only allowed providers and follows the theme (${dark})`, () => { + let selected + const tree = AgentModelSelector({ models: options, provider: 'QWEN', dark, disabled: false, onChange: value => { selected = value } }) + const select = elements(tree, node => node.type === 'select')[0] + assert.equal(select.props.value, 'QWEN') + assert.equal(select.props.style.colorScheme, dark ? 'dark' : 'light') + assert.deepEqual(elements(select, node => node.type === 'option').map(node => node.props.value), ['OPENAI', 'QWEN']) + select.props.onChange({ target: { value: 'OPENAI' } }) + assert.equal(selected, 'OPENAI') + assert.match(select.props.className, /truncate/) + assert.ok(elements(AgentModelSelector({ models: options, provider: 'QWEN', dark, disabled: true, onChange() {} }), node => node.type === 'select')[0].props.disabled) + const empty = elements(AgentModelSelector({ models: [], provider: null, dark, disabled: false, onChange() {} }), node => node.type === 'select')[0] + assert.ok(empty.props.disabled) + assert.equal(elements(empty, node => node.type === 'option')[0].props.children, '사용 가능한 모델 없음') +}) + +test('A-Copilot has a single managed label', () => { + assert.equal(models.agentModelLabel({ provider: 'A_COPILOT', model: 'A-Copilot', imagesEnabled: false }), 'A-Copilot') + assert.equal(models.agentModelLabel(options[0]), 'OpenAI · gpt-6.1-sol') +}) diff --git a/scripts/test-agent-progress.cjs b/scripts/test-agent-progress.cjs index 747047f..766013f 100644 --- a/scripts/test-agent-progress.cjs +++ b/scripts/test-agent-progress.cjs @@ -11,7 +11,10 @@ function compile(source, globals = {}) { }).outputText, { exports, DOMException, Error, ...globals }) return exports } -const context = compile(fs.readFileSync(require.resolve('../src/renderer/src/features/workspace/agentContext.ts'), 'utf8')) +const evidenceApi = compile(fs.readFileSync(require.resolve('../src/renderer/src/features/workspace/agentEvidence.ts'), 'utf8')) +const context = compile(fs.readFileSync(require.resolve('../src/renderer/src/features/workspace/agentContext.ts'), 'utf8'), { + require: name => { assert.equal(name, './agentEvidence'); return evidenceApi } +}) const source = ts.createSourceFile('useAiAgent.ts', fs.readFileSync(require.resolve('../src/renderer/src/features/workspace/useAiAgent.ts'), 'utf8'), ts.ScriptTarget.Latest, true) const hook = source.statements.find(node => ts.isFunctionDeclaration(node) && node.name?.text === 'useAiAgent') const functions = hook.body.statements.filter(node => ts.isVariableStatement(node) && ['requestPlan', 'stop'].includes(node.declarationList.declarations[0].name.getText(source))) @@ -27,11 +30,13 @@ function harness(requestApi) { class Clock extends Date { static now() { return now } } const state = { pending: false, activity: '', steps: [], messages: [], saved: [], notices: 0, requestStartedAt: null } const messageStateRef = { current: { session } } + const selectedModel = { provider: 'QWEN', model: 'fixture', imagesEnabled: true } const api = compile(functions.map(node => `export ${node.getText(source)}`).join('\n'), { - ...context, session, messageStateRef, AbortController, + ...context, session, messageStateRef, AbortController, FormData, Date: Clock, - courseId: 'course', weekNumber: 3, pending: false, historyLoading: false, - status: { enabled: true }, abortControllerRef: { current: null }, + courseId: 'course', weekNumber: 3, pending: false, historyLoading: false, statusLoading: false, + selectedModel, + status: { enabled: true, selectionSupported: true }, abortControllerRef: { current: null }, setPending: value => { state.pending = value }, setRequestStartedAt: value => { state.requestStartedAt = value }, setActivity: value => { state.activity = value }, @@ -39,7 +44,7 @@ function harness(requestApi) { updateMessages: update => { state.messages = update(state.messages); state.saved.push(plain(state.messages)) }, onResponseRef: { current: () => { state.notices++ } }, requestApi }) - return { ...api, state, messageStateRef, advance: ms => { now += ms } } + return { ...api, state, selectedModel, messageStateRef, advance: ms => { now += ms } } } test('publishes completed stages immediately without persisting progress or adding model calls', async () => { @@ -76,6 +81,58 @@ test('publishes completed stages immediately without persisting progress or addi assert.equal(h.state.notices, 1) }) +test('image uploads and every lookup round retain the original provider without uploading the image twice', async () => { + const bodies = [] + let uploads = 0 + const h = harness(async (path, request) => { + if (path.endsWith('/attachments/images')) { + uploads++ + assert.equal(request.body.get('provider'), 'QWEN') + assert.equal(request.body.get('courseId'), 'course') + assert.equal(request.body.get('week'), '3') + assert.equal(request.body.get('file').name, 'screen.png') + return { token: 'image-reference', name: 'screen.png', url: 'https://images.test/screen.png' } + } + bodies.push(JSON.parse(request.body)) + h.selectedModel.provider = 'OPENAI' + return bodies.length === 1 + ? { ...response(), plan: { ...response().plan, requestedModules: ['Control'] } } + : response() + }) + await h.requestPlan('이미지 확인', [{ name: 'Control', files: [] }], [], { + attachments: [{ kind: 'image', name: 'screen.png', file: new Blob(['fixture'], { type: 'image/png' }) }], + loadRequestedContext: async () => ({ modules: [{ name: 'Control', files: [{ path: 'config/control.yaml', content: 'speed: 0.3' }] }], runtimeLogs: [] }) + }) + assert.equal(uploads, 1) + assert.equal(bodies.length, 2) + assert.ok(bodies.every(body => body.provider === 'QWEN')) + assert.ok(bodies.every(body => body.imageTokens[0] === 'image-reference')) + assert.equal(h.state.messages.at(-1).model, 'fixture') +}) + +test('remote target and observed source ranges reach each provider request including finalization', async () => { + const bodies = [] + const tool = { id: 'read', name: 'files.read', moduleName: 'Control', args: { path: 'config/car.yaml', startLine: 20, lineCount: 1 } } + const h = harness(async (_, request) => { + bodies.push(JSON.parse(request.body)) + return bodies.length < 3 ? response([tool]) : response() + }) + await h.requestPlan('속도 제한 코드 위치', [{ name: 'Control', state: 'running', parameters: ['sim_mode:=false'], files: [] }], [], { + environment: 'remote', workspaceTarget: 'ssh:100.64.0.10', + executeTool: async input => result({ id: input.id, moduleName: 'Control', target: 'ssh:100.64.0.10', truncated: true, + data: JSON.stringify({ path: 'config/car.yaml', exists: true, hash: 'a'.repeat(64), complete: false, content: 'max_speed: 0.4', startLine: 20, endLine: 20, contentTruncated: false, totalLines: 100 }) }) + }) + assert.equal(bodies.length, 3) + for (const body of bodies) { + assert.equal(body.environment, 'remote') + assert.equal(body.workspaceTarget, 'ssh:100.64.0.10') + assert.deepEqual(body.modules[0].parameters, ['sim_mode:=false']) + } + assert.equal(JSON.parse(bodies[1].toolResults[0].data).content, 'max_speed: 0.4') + assert.equal(JSON.parse(bodies[2].toolResults[0].data).endLine, 20) + assert.match(bodies[2].request, /자료 조회는 종료되었습니다/) +}) + test('errors replace temporary progress with the existing error reply', async () => { let calls = 0 const h = harness(async () => { diff --git a/scripts/test-agent-request-payload.cjs b/scripts/test-agent-request-payload.cjs index 17d1047..975546d 100644 --- a/scripts/test-agent-request-payload.cjs +++ b/scripts/test-agent-request-payload.cjs @@ -4,9 +4,13 @@ const fs = require('node:fs') const vm = require('node:vm') const ts = require('typescript') const contextApi = {} +const evidenceApi = {} +vm.runInNewContext(ts.transpileModule(fs.readFileSync(require.resolve('../src/renderer/src/features/workspace/agentEvidence.ts'), 'utf8'), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 } +}).outputText, { exports: evidenceApi }) vm.runInNewContext(ts.transpileModule(fs.readFileSync(require.resolve('../src/renderer/src/features/workspace/agentContext.ts'), 'utf8'), { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 } -}).outputText, { exports: contextApi, DOMException }) +}).outputText, { exports: contextApi, DOMException, require: name => { assert.equal(name, './agentEvidence'); return evidenceApi } }) const source = ts.createSourceFile('useAiAgent.ts', fs.readFileSync(require.resolve('../src/renderer/src/features/workspace/useAiAgent.ts'), 'utf8'), ts.ScriptTarget.Latest, true) let payloadExpression @@ -33,10 +37,11 @@ test('user messages preserve only typed text while attachments remain separate m } }) -function serialize(attachments, finalize = false, request = '설정 위치 알려줘', imageTokens = []) { +function serialize(attachments, finalize = false, request = '설정 위치 알려줘', imageTokens = [], provider = 'QWEN') { return JSON.parse(vm.runInNewContext(payloadExpression, { courseId: 'course-1', weekNumber: 3, request, options: { attachments }, finalize, textAttachments: attachments ?? [], imageTokens, + selection: provider ? { provider } : {}, agentPlanRequestText: contextApi.agentPlanRequestText, context: { modules: [{ name: 'Control', files: [] }], runtimeLogs: [] } })) @@ -70,7 +75,7 @@ test('finalization instruction reaches the API without sending unsupported DTO f assert.match(body.request, /설정 위치 알려줘/) assert.match(body.request, /최종 답변/) assert.equal(Object.hasOwn(body, 'finalize'), false) - assert.deepEqual(Object.keys(body).sort(), ['courseId', 'modules', 'request', 'runtimeLogs', 'week']) + assert.deepEqual(Object.keys(body).sort(), ['courseId', 'modules', 'provider', 'request', 'runtimeLogs', 'week']) }) test('finalization keeps the request within the backend 4000-character limit', () => { @@ -78,3 +83,14 @@ test('finalization keeps the request within the backend 4000-character limit', ( assert.equal(body.request.length, 4000) assert.match(body.request, /원래 학생 질문/) }) + +test('sends only a provider selection rather than student-supplied models or keys', () => { + for (const finalize of [false, true]) { + const body = serialize(undefined, finalize, '확인', [], 'OPENAI') + assert.equal(body.provider, 'OPENAI') + assert.equal(Object.hasOwn(body, 'model'), false) + assert.equal(Object.hasOwn(body, 'apiKey'), false) + assert.equal(Object.hasOwn(body, 'endpointUrl'), false) + } + assert.equal(Object.hasOwn(serialize(undefined, false, '확인', [], null), 'provider'), false) +}) diff --git a/scripts/test-agent-tools.cjs b/scripts/test-agent-tools.cjs index ed564de..fa95f70 100644 --- a/scripts/test-agent-tools.cjs +++ b/scripts/test-agent-tools.cjs @@ -63,15 +63,30 @@ for (const backend of ['local', 'remote']) { const full = await read(root, request('files.read', { path: 'config/sensor.yaml' })) assert.equal(full.data.hash, agentFileHash(await fs.readFile(path.join(root, 'config/sensor.yaml'), 'utf8'))) assert.equal(full.data.complete, true) + const rangedFull = await read(root, request('files.read', { path: 'config/sensor.yaml', startLine: 1, lineCount: 200 })) + assert.equal(rangedFull.data.complete, true) + assert.equal(rangedFull.truncated, false) + assert.equal(rangedFull.data.endLine, 3) + assert.equal(rangedFull.data.contentTruncated, false) const partial = await read(root, request('files.read', { path: 'config/sensor.yaml', startLine: 2, lineCount: 1 })) assert.equal(partial.data.content, '조향: ackermann') assert.equal(partial.data.complete, false) assert.equal(partial.truncated, true) + assert.equal(partial.data.endLine, 2) + assert.equal(partial.data.contentTruncated, false) const missing = await read(root, request('files.read', { path: 'config/new.yaml' })) assert.equal(missing.data.exists, false) assert.equal(missing.data.hash, null) await assert.rejects(read(root, request('files.read', { path: 'missing/new.yaml' }))) }) + test(`${backend}: character truncation never advertises a cut line as fully observed`, async t => { + const root = await fixture(t) + await fs.writeFile(path.join(root, 'config/long.yaml'), `first: true\n${'x'.repeat(40_000)}\nlast: true`) + const readout = await read(root, request('files.read', { path: 'config/long.yaml', startLine: 1, lineCount: 3 })) + assert.equal(readout.data.complete, false) + assert.equal(readout.data.contentTruncated, true) + assert.equal(readout.data.endLine, 1) + }) test(`${backend}: a scoped search does not consume its budget in unrelated source trees`, async t => { const root = await fixture(t) await fs.mkdir(path.join(root, 'code')) diff --git a/scripts/test-agent-workspace-routing.cjs b/scripts/test-agent-workspace-routing.cjs new file mode 100644 index 0000000..6fe299b --- /dev/null +++ b/scripts/test-agent-workspace-routing.cjs @@ -0,0 +1,68 @@ +const { test } = require('node:test') +const assert = require('node:assert/strict') +const fs = require('node:fs') +const ts = require('typescript') +const vm = require('node:vm') +const app = ts.createSourceFile('App.tsx', fs.readFileSync(require.resolve('../src/renderer/src/App.tsx'), 'utf8'), ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX) +const declarations = new Map() +function visit(node) { + if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name)) declarations.set(node.name.text, node.initializer?.getText(app)) + ts.forEachChild(node, visit) +} +visit(app) +const plain = value => JSON.parse(JSON.stringify(value)) + +function harness(remoteWorkspace) { + const executions = [] + const calls = [] + const errors = [] + const target = { host: '100.64.0.10', user: 'fixture', workspaceRoot: '/fixture/workspace' } + const live = [{ name: 'NAV2-MKMini', folderName: 'vehicle', state: 'running', parameters: ['sim_mode:=false', 'robot:=mkmini'] }] + const readTool = backend => async (...args) => { executions.push({ backend, args: plain(args) }); return { status: 'ok', data: '{"content":"remote source"}' } } + const exports = {} + const globals = { + exports, remoteWorkspace, availableModules: [{ ...live[0], state: 'stopped', parameters: ['sim_mode:=true'] }], + clonePathPreview: '/local/workspace', runtimeModules: [], agentActionControllerRef: { current: null }, agentApplying: false, + getRemoteWorkspaceTarget: () => target, applyModuleParameterOverrides: modules => modules, + validateAgentTool: request => request, AGENT_PRACTICE_PROFILES: [], + window: { + remoteWorkspace: { modules: { list: async () => ({ modules: live }) }, agent: { readTool: readTool('remote') } }, + localWorkspace: { modules: { list: async () => ({ modules: live }) }, agent: { readTool: readTool('local') } } + }, + requestAgentPlan: async (...args) => { calls.push(args) }, + onTitlebarNotice: (...args) => { errors.push(args) }, console: { error: () => {} }, + loadAgentRequestedContext: async () => ({ modules: [], runtimeLogs: [] }) + } + const names = ['collectAgentWorkspace', 'readAgentModules', 'executeWorkspaceAgentTool', 'submitAgentRequest'] + vm.runInNewContext(ts.transpileModule(names.map(name => `const ${name} = ${declarations.get(name)}; exports.${name} = ${name}`).join('\n'), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 } + }).outputText, globals) + return { ...exports, executions, calls, errors } +} + +for (const remote of [false, true]) { + test(`${remote ? 'remote' : 'local'}: a new question refreshes state and start settings before sending context`, async () => { + const h = harness(remote) + await h.submitAgentRequest('최대 속도 제한 코드 위치') + assert.deepEqual(h.errors, []) + assert.equal(h.calls.length, 1) + const [, modules, , options] = h.calls[0] + assert.equal(modules[0].state, 'running') + assert.deepEqual(plain(modules[0].parameters), ['sim_mode:=false', 'robot:=mkmini']) + assert.deepEqual(plain(modules[0].files), []) + assert.equal(options.environment, remote ? 'remote' : 'local') + assert.equal(options.workspaceTarget, remote ? 'ssh:100.64.0.10' : 'local-host') + }) + + test(`${remote ? 'remote' : 'local'}: source reads use only the connected workspace transport and module folder`, async () => { + const h = harness(remote) + const tool = { id: 'read-1', name: 'files.read', moduleName: 'NAV2-MKMini', args: { path: 'src/profiles.py', startLine: 10, lineCount: 20 } } + await h.executeWorkspaceAgentTool(tool) + assert.equal(h.executions.length, 1) + assert.equal(h.executions[0].backend, remote ? 'remote' : 'local') + const args = h.executions[0].args + assert.deepEqual(args[remote ? 1 : 0], tool) + assert.equal(args[2], 'vehicle') + assert.equal(remote ? args[0].host : args[1], remote ? '100.64.0.10' : '/local/workspace') + }) +} diff --git a/scripts/test-chat-clear-confirmation.cjs b/scripts/test-chat-clear-confirmation.cjs index 7b72a6e..1e61c5f 100644 --- a/scripts/test-chat-clear-confirmation.cjs +++ b/scripts/test-chat-clear-confirmation.cjs @@ -42,6 +42,7 @@ function setup(overrides = {}, browser = {}) { '../../components/AgentProgressBubble': {}, '../../components/AgentWorkDuration': {}, '../../components/AgentCodeReferences': {}, + '../../components/AgentModelSelector': { AgentModelSelector: () => null }, 'react-dom': { createPortal: child => child }, react: { ...React, diff --git a/src/main/agentFileTools.ts b/src/main/agentFileTools.ts index 312300c..f594c4b 100644 --- a/src/main/agentFileTools.ts +++ b/src/main/agentFileTools.ts @@ -43,10 +43,14 @@ export async function runAgentFileTool(root: string, request: AgentToolRequest): try { const content = await readText(root, path) const lines = content.split('\n') - const partial = args.startLine !== undefined || args.lineCount !== undefined || content.length > 40_000 const startLine = args.startLine ?? 1 - const selected = partial ? lines.slice(startLine - 1, startLine - 1 + (args.lineCount ?? 200)).join('\n').slice(0, 40_000) : content - return { data: { path, exists: true, hash: agentFileHash(content), content: selected, complete: !partial, startLine, totalLines: lines.length }, truncated: partial } + const ranged = args.startLine !== undefined || args.lineCount !== undefined || content.length > 40_000 + const selection = ranged ? lines.slice(startLine - 1, startLine - 1 + (args.lineCount ?? 200)).join('\n') : content + const selected = selection.slice(0, 40_000) + const contentTruncated = selected.length < selection.length + const complete = startLine === 1 && selected === content + const endLine = Math.min(lines.length, startLine + selected.split('\n').length - 1 - Number(contentTruncated)) + return { data: { path, exists: true, hash: agentFileHash(content), content: selected, complete, startLine, endLine, contentTruncated, totalLines: lines.length }, truncated: !complete } } catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error // A missing leaf in an existing directory is evidence for a new file. diff --git a/src/main/agentRemoteFiles.ts b/src/main/agentRemoteFiles.ts index 2478272..173cf82 100644 --- a/src/main/agentRemoteFiles.ts +++ b/src/main/agentRemoteFiles.ts @@ -116,10 +116,15 @@ else: try: content = read(root, path) lines = content.split('\n') - partial = 'startLine' in options or 'lineCount' in options or len(content) > 40000 + ranged = 'startLine' in options or 'lineCount' in options or len(content) > 40000 start = options.get('startLine', 1) - selected = '\n'.join(lines[start-1:start-1+options.get('lineCount', 200)])[:40000] if partial else content - data = {'path': path, 'exists': True, 'hash': digest(content), 'content': selected, 'complete': not partial, 'startLine': start, 'totalLines': len(lines)} + selection = '\n'.join(lines[start-1:start-1+options.get('lineCount', 200)]) if ranged else content + selected = selection[:40000] + content_truncated = len(selected) < len(selection) + complete = start == 1 and selected == content + partial = not complete + end = min(len(lines), start + len(selected.split('\n')) - 1 - int(content_truncated)) + data = {'path': path, 'exists': True, 'hash': digest(content), 'content': selected, 'complete': complete, 'startLine': start, 'endLine': end, 'contentTruncated': content_truncated, 'totalLines': len(lines)} except FileNotFoundError: checked(root, path, True) data, partial = {'path': path, 'exists': False, 'hash': None, 'content': '', 'complete': True}, False diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 752dc13..5820dc7 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -630,6 +630,8 @@ function LocalPcWorkspaceMockup({ enabled: agentEnabled, provider: agentProvider, imagesEnabled: agentImagesEnabled, + models: agentModels, + selectProvider: selectAgentProvider, statusLoading: agentStatusLoading, pending: agentPending, activity: agentActivity, @@ -3526,9 +3528,9 @@ function LocalPcWorkspaceMockup({ const createCustomModule = async (input: CustomModuleInput): Promise => { await createCustomModuleRecord(input) } - const collectAgentWorkspace = async (requestedModuleNames: string[] = []): Promise => { + const collectAgentWorkspace = async (requestedModuleNames: string[] = [], moduleSnapshot = availableModules): Promise => { const requestedNames = new Set(requestedModuleNames.map((name) => name.toLowerCase())) - const workspaceModules: AgentWorkspaceModule[] = availableModules.map((module) => ({ + const workspaceModules: AgentWorkspaceModule[] = moduleSnapshot.map((module) => ({ name: module.name, folderName: module.folderName, category: module.category, @@ -3542,7 +3544,7 @@ function LocalPcWorkspaceMockup({ } } : {}) })) - const targetModules = availableModules.filter((module) => ( + const targetModules = moduleSnapshot.filter((module) => ( module.state !== 'uninstalled' && requestedNames.has(module.name.toLowerCase()) )) if (!targetModules.length) return workspaceModules @@ -3677,12 +3679,13 @@ function LocalPcWorkspaceMockup({ const submitAgentRequest = async (content: string, attachments: ChatAttachment[] = []): Promise => { if (agentActionControllerRef.current || agentApplying) return try { - const modules = await collectAgentWorkspace() + const modules = await collectAgentWorkspace([], await readAgentModules()) await requestAgentPlan(content, modules, [], { attachments, loadRequestedContext: loadAgentRequestedContext, executeTool: executeWorkspaceAgentTool, - environment: remoteWorkspace ? 'remote' : 'local' + environment: remoteWorkspace ? 'remote' : 'local', + workspaceTarget: remoteWorkspace ? `ssh:${getRemoteWorkspaceTarget().host}` : 'local-host' }) } catch (error) { console.error('[agent] workspace collection failed', error) @@ -3824,7 +3827,7 @@ function LocalPcWorkspaceMockup({ const currentModules = await readAgentModules() assertActive() await requestAgentPlan(`학생의 원래 요청: ${originalRequest.slice(0, 2600)}\n승인 작업 ${command.command}의 실행 결과가 workspace.toolResults에 있습니다. 결과를 분석하고 확인된 성공과 실패를 설명하세요. 이미 승인·실행한 작업을 반복하지 마세요.`, currentModules.map(item => ({ name: item.name, state: item.state, category: item.category, parameters: item.parameters, files: [] })), [], { - showUserMessage: false, environment, executeTool: executeWorkspaceAgentTool, loadRequestedContext: loadAgentRequestedContext, + showUserMessage: false, environment, workspaceTarget: remoteWorkspace ? `ssh:${getRemoteWorkspaceTarget().host}` : 'local-host', executeTool: executeWorkspaceAgentTool, loadRequestedContext: loadAgentRequestedContext, toolResults: [{ id: `action-${Date.now()}`, name: 'action.result', moduleName: module.name, observedAt: new Date().toISOString(), target: remoteWorkspace ? `ssh:${getRemoteWorkspaceTarget().host}` : 'local-host', status: ok ? 'ok' : 'error', data: JSON.stringify({ approvedAction: operation, result: output }), truncated: false }] }) } catch (error) { @@ -3906,7 +3909,8 @@ function LocalPcWorkspaceMockup({ showUserMessage: false, loadRequestedContext: loadAgentRequestedContext, executeTool: executeWorkspaceAgentTool, - environment: remoteWorkspace ? 'remote' : 'local' + environment: remoteWorkspace ? 'remote' : 'local', + workspaceTarget: remoteWorkspace ? `ssh:${getRemoteWorkspaceTarget().host}` : 'local-host' }) } catch (error) { const errorMessage = (error instanceof Error ? error.message : '명령을 실행하지 못했습니다.') @@ -4752,6 +4756,8 @@ function LocalPcWorkspaceMockup({ agentEnabled={agentEnabled} agentProvider={agentProvider} agentImagesEnabled={Boolean(agentImagesEnabled)} + agentModels={agentModels} + onAgentProviderChange={selectAgentProvider} agentStatusLoading={agentStatusLoading} agentPending={agentPending} agentActivity={agentActivity} diff --git a/src/renderer/src/components/AgentModelSelector.tsx b/src/renderer/src/components/AgentModelSelector.tsx new file mode 100644 index 0000000..049a68d --- /dev/null +++ b/src/renderer/src/components/AgentModelSelector.tsx @@ -0,0 +1,32 @@ +import { Bot, ChevronDown } from 'lucide-react' +import { t } from '../i18n' +import { agentModelLabel, type AgentModelOption } from '../features/workspace/agentModels' + +export function AgentModelSelector({ models, provider, disabled, dark, onChange }: { + models: AgentModelOption[] + provider: string | null + disabled: boolean + dark: boolean + onChange: (provider: string) => void +}): React.JSX.Element { + const selected = models.find(model => model.provider === provider) + return ( +
+
+ ) +} diff --git a/src/renderer/src/components/ChatWidgets.tsx b/src/renderer/src/components/ChatWidgets.tsx index aa7417d..966a5ef 100644 --- a/src/renderer/src/components/ChatWidgets.tsx +++ b/src/renderer/src/components/ChatWidgets.tsx @@ -298,6 +298,7 @@ export const QaInputBox = memo(function QaInputBox({ imagesEnabled = false, onClearConversation, clearDisabled = false, + footerAction, onStop, onSubmit }: { @@ -309,6 +310,7 @@ export const QaInputBox = memo(function QaInputBox({ imagesEnabled?: boolean onClearConversation?: () => void clearDisabled?: boolean + footerAction?: React.ReactNode onStop: () => void onSubmit: (content: string, attachments?: ChatAttachment[]) => void }): React.JSX.Element { @@ -456,8 +458,8 @@ export const QaInputBox = memo(function QaInputBox({ )} file.kind === 'image' ? [{ name: file.name, url: file.previewUrl }] : [])} removeDisabled={busy || readingAttachments} onRemove={image => setAttachments(current => current.filter(file => file.kind !== 'image' || file.previewUrl !== image.url))} /> {attachmentError &&

{t(attachmentError)}

} - {(attachmentsEnabled || onClearConversation) && ( -
+ {(attachmentsEnabled || onClearConversation || footerAction) && ( +
{attachmentsEnabled && ( <> { @@ -471,6 +473,7 @@ export const QaInputBox = memo(function QaInputBox({ {onClearConversation && ( )} + {footerAction}
)}
diff --git a/src/renderer/src/features/workspace/WorkspaceQaPanel.tsx b/src/renderer/src/features/workspace/WorkspaceQaPanel.tsx index 29480e3..87d89aa 100644 --- a/src/renderer/src/features/workspace/WorkspaceQaPanel.tsx +++ b/src/renderer/src/features/workspace/WorkspaceQaPanel.tsx @@ -10,6 +10,8 @@ import { ChatImageAttachments } from '../../components/ChatImageAttachments' import { AgentProgressBubble } from '../../components/AgentProgressBubble' import { AgentWorkDuration } from '../../components/AgentWorkDuration' import { AgentCodeReferences } from '../../components/AgentCodeReferences' +import { AgentModelSelector } from '../../components/AgentModelSelector' +import type { AgentModelOption } from './agentModels' import type { AgentProgressStep } from './agentContext' import { ChatAvatar, ChatEmptyState, ChatResponseSkeleton, ExpandablePlainMessage, MarkdownMessage, QaInputBox } from '../../components/ChatWidgets' import type { AgentCommand, AgentConversationMessage, AgentFileChange, AgentPlan } from './useAiAgent' @@ -47,6 +49,8 @@ type WorkspaceQaPanelProps = { agentEnabled: boolean agentProvider?: string | null agentImagesEnabled?: boolean + agentModels?: AgentModelOption[] + onAgentProviderChange?: (provider: string) => void agentStatusLoading: boolean agentPending: boolean agentActivity?: string @@ -124,6 +128,8 @@ export default function WorkspaceQaPanel({ agentEnabled, agentProvider, agentImagesEnabled = false, + agentModels = [], + onAgentProviderChange, agentStatusLoading, agentPending, agentActivity, @@ -348,6 +354,7 @@ export default function WorkspaceQaPanel({ imagesEnabled={qaMode === 'agent' && agentImagesEnabled} onClearConversation={requestClearConversation} clearDisabled={clearDisabled} + footerAction={qaMode === 'agent' && onAgentProviderChange ? : undefined} /> {clearConfirmationOpen && createPortal( Number(preferredModules.includes(right.name)) - Number(preferredModules.includes(left.name))) const selected = new Map() const candidates = orderedModules.map((module) => ({ module, files: [...module.files], index: 0 })) @@ -111,12 +112,28 @@ function fitAgentContext(context: AgentContext, preferredModules: string[] = [], return { ...log, content, ...(notes ? { message: notes } : {}) } }) let toolSize = 0 - const toolResults = [...(context.toolResults ?? [])].reverse().filter(result => { - if (result.data.length > 60_000 || toolSize + result.data.length > 180_000) return false + const evidence = compactAgentEvidence(context.toolResults ?? []) + const priority = (result: AgentToolResult): number => { + const file = readFileEvidence(result) + if (file && preferredReads.some(tool => tool.name === 'files.read' && tool.moduleName === result.moduleName && tool.args?.path === file.path)) return 6 + return result.name === 'action.result' ? 5 : !result.name.startsWith('files.') ? 4 + : file || result.status === 'error' ? 3 : 1 + } + const candidatesByPriority = evidence.map((result, index) => ({ result, index })) + .sort((a, b) => priority(b.result) - priority(a.result) || b.index - a.index) + const retained = new Set() + for (const { result } of candidatesByPriority) { + if (retained.size >= 12 || result.data.length > 60_000 || toolSize + result.data.length > 180_000) continue + retained.add(result) toolSize += result.data.length - return true - }).slice(0, 12).reverse() - return { modules, runtimeLogs, ...(context.toolResults ? { toolResults } : {}) } + } + const toolResults = evidence.filter(result => retained.has(result)) + const omittedTools = evidence.filter(result => !retained.has(result)) + const contextNotes = omittedTools.length + ? [`용량·개수 한도로 조회 근거 ${omittedTools.length}개를 제외했습니다. 제외된 내용을 확인했다고 주장하지 마세요.`, + ...omittedTools.slice(-10).map(result => describeAgentEvidence(result).slice(0, 500))] + : [] + return { modules, runtimeLogs, ...(context.toolResults ? { toolResults, contextNotes } : {}) } } export async function resolveAgentContext({ @@ -142,6 +159,7 @@ export async function resolveAgentContext({ const requestedLogs = new Set(initialContext.runtimeLogs.map((log) => log.moduleName)) const queriedTools = new Set() let toolCount = 0 + let confirmedAnswer: AgentPlanResponse | undefined const fingerprint = (tool: AgentToolRequest): string => JSON.stringify([tool.name, tool.moduleName ?? '', Object.entries(tool.args ?? {}).sort(([a], [b]) => a.localeCompare(b))]) const usage: NonNullable = {} const checkAborted = (): void => { @@ -162,9 +180,17 @@ export async function resolveAgentContext({ const sourceStatus = context.modules.filter((module) => requestedModules.has(module.name)).map((module) => ( `- ${module.name}: 제공된 파일 ${module.files.length}개${module.fileContext?.message ? ` (${module.fileContext.message})` : ''}\n${module.files.slice(0, 4).map((file) => ` ${file.path}`).join('\n')}` )).join('\n').slice(0, 8000) - const toolStatus = (context.toolResults ?? []).map(result => `- ${result.moduleName ? `${result.moduleName} · ` : ''}${result.name}: ${result.status}${result.truncated ? ' (일부 결과)' : ''} · ${result.target} · ${result.observedAt}`).join('\n').slice(0, 5000) + const toolStatus = (context.toolResults ?? []).map(result => `- ${describeAgentEvidence(result)}`).join('\n').slice(0, 8000) + const candidate = response.plan.codeReferences?.length ? response : confirmedAnswer + const references = candidate?.plan.codeReferences?.filter(reference => { + const latest = (cachedContext.toolResults ?? []).filter(result => result.moduleName === reference.moduleName) + .map(readFileEvidence).filter(file => file?.path === reference.path).at(-1) + return !latest || latest.exists && latest.hash === reference.expectedHash + }) + const confirmed = references?.length && candidate ? { ...candidate, plan: { ...candidate.plan, codeReferences: references } } : undefined + const confirmedReferences = Boolean(confirmed?.plan.codeReferences?.length) const explanation = stillRequesting - ? `AI가 최종 분석 요청 후에도 같은 자료를 재요청하여 결론을 확정하지 못했습니다. 파일 조회 실패나 설치 문제로 단정할 수는 없습니다.\n\n현재 AI에 제공된 자료:\n${sourceStatus || (toolStatus ? '- 모듈 묶음 조회 대신 아래 도구 결과를 제공했습니다.' : '- 소스 파일 없음')}\n\n도구 조회 근거:\n${toolStatus || '- 없음'}\n\n제공된 로그: ${context.runtimeLogs.map((log) => log.moduleName).join(', ') || '없음'}\n\n반복 조회는 중단했으며, 확인되지 않은 설정값이나 코드 변경은 제안하지 않았습니다.` + ? `${confirmedReferences ? `${confirmed!.plan.explanation}\n\n추가 분석은 완료하지 못했지만 확인된 코드 위치는 유지했습니다.` : 'AI가 최종 분석 요청 후에도 같은 자료를 재요청하여 결론을 확정하지 못했습니다. 파일 조회 실패나 설치 문제로 단정할 수는 없습니다.'}\n\n현재 AI에 제공된 자료:\n${sourceStatus || (toolStatus ? '- 모듈 묶음 조회 대신 아래 도구 결과를 제공했습니다.' : '- 소스 파일 없음')}\n\n도구 조회 근거:\n${toolStatus || '- 없음'}\n\n제공된 로그: ${context.runtimeLogs.map((log) => log.moduleName).join(', ') || '없음'}\n\n반복 조회는 중단했으며, 확인되지 않은 설정값이나 코드 변경은 제안하지 않았습니다.`.slice(0, 12_000) : response.plan.explanation return { ...response, @@ -172,7 +198,8 @@ export async function resolveAgentContext({ // Recovery is analysis-only, even if the model proposes actions despite the instruction. plan: { ...response.plan, - summary: stillRequesting ? '확보한 자료만으로는 답변을 확정하지 못했습니다.' : response.plan.summary, + summary: stillRequesting ? confirmedReferences ? '추가 분석은 미완료이며 확인된 코드 위치를 안내합니다.' : '확보한 자료만으로는 답변을 확정하지 못했습니다.' : response.plan.summary, + ...(stillRequesting ? { codeReferences: confirmed?.plan.codeReferences ?? [] } : {}), explanation, requestedModules: [], requestedLogs: [], toolRequests: [], evidence: response.plan.evidence ?? context.toolResults, actionPlan: [], commands: [], changes: [], createModule: null } @@ -186,19 +213,31 @@ export async function resolveAgentContext({ const response = await requestPlan(context) checkAborted() addUsage(response) + if (response.plan.codeReferences?.length) confirmedAnswer = response const modules = [...new Set(response.plan.requestedModules ?? [])] const logs = [...new Set(response.plan.requestedLogs ?? [])] const tools = response.plan.toolRequests ?? [] onStep?.({ id: `analysis-${round}`, kind: 'analysis', status: 'ok', round: round + 1, requests: modules.length + logs.length + tools.length }) if (!modules.length && !logs.length && !tools.length) return { ...response, usage, plan: { ...response.plan, ...(executeTool ? { evidence: response.plan.evidence ?? context.toolResults ?? [] } : {}) } } - if (round === maxRounds) return finalize() + if (round === maxRounds) { + context = fitAgentContext(cachedContext, [], [], tools) + return finalize() + } if (tools.length) { if (!executeTool || tools.length > 6) return finalize() - const fresh = tools.filter(tool => !queriedTools.has(fingerprint(tool))) - if (!fresh.length || toolCount + fresh.length > 24) return finalize() + const fresh = tools.filter(tool => { + const missing = missingAgentRead(tool, cachedContext.toolResults ?? []) + return missing && !queriedTools.has(fingerprint(missing)) + }) + if (!fresh.length || toolCount + fresh.length > 24) { + context = fitAgentContext(cachedContext, [], [], tools) + return finalize() + } const results = [...(cachedContext.toolResults ?? [])] - for (const [index, tool] of fresh.entries()) { + for (const [index, proposedTool] of fresh.entries()) { checkAborted() + const tool = missingAgentRead(proposedTool, results) + if (!tool) continue const key = fingerprint(tool) if (queriedTools.has(key)) continue queriedTools.add(key) @@ -209,8 +248,8 @@ export async function resolveAgentContext({ toolCount++ results.push(result) } - cachedContext = { ...cachedContext, toolResults: results } - context = fitAgentContext(cachedContext) + cachedContext = { ...cachedContext, toolResults: compactAgentEvidence(results) } + context = fitAgentContext(cachedContext, [], [], tools) onProgress?.('조회 결과 분석 중') // A model may also use legacy source requests; process those on the next pass. continue diff --git a/src/renderer/src/features/workspace/agentEvidence.ts b/src/renderer/src/features/workspace/agentEvidence.ts new file mode 100644 index 0000000..656e467 --- /dev/null +++ b/src/renderer/src/features/workspace/agentEvidence.ts @@ -0,0 +1,107 @@ +import type { AgentToolRequest, AgentToolResult } from '../../../../shared/agentTools' + +type FileEvidence = { + path: string; exists: boolean; content: string; hash: string | null + complete: boolean; startLine: number; endLine: number; totalLines: number + contentTruncated?: boolean +} + +export function readFileEvidence(result: AgentToolResult): FileEvidence | undefined { + if (result.name !== 'files.read' || result.status !== 'ok') return + try { + const data = JSON.parse(result.data) + if (typeof data.path !== 'string' || typeof data.content !== 'string' || typeof data.complete !== 'boolean' + || data.contentTruncated !== undefined && typeof data.contentTruncated !== 'boolean') return + if (data.exists === false && data.complete === true && data.hash === null && data.content === '') { + return { ...data, startLine: 1, endLine: 0, totalLines: 0 } + } + if (data.exists !== true || typeof data.hash !== 'string' || !/^[a-f0-9]{64}$/.test(data.hash)) return + const startLine = data.startLine ?? 1 + const last = startLine + data.content.split('\n').length - 1 + // Older clients cannot distinguish a ranged read from a cut final line. + const endLine = data.endLine ?? last - Number(result.truncated) + if (!Number.isSafeInteger(startLine) || startLine < 1 || !Number.isSafeInteger(endLine) + || endLine < startLine - 1 || endLine > last || !Number.isSafeInteger(data.totalLines) || data.totalLines < 1 || endLine > data.totalLines + || data.contentTruncated === true && endLine >= last) return + return { ...data, startLine, endLine } + } catch { return } +} + +const fileKey = (result: AgentToolResult, file: FileEvidence): string => JSON.stringify([result.target, result.moduleName, file.path]) + +// Only combine observations of the same file revision on the same host. +export function compactAgentEvidence(results: AgentToolResult[]): AgentToolResult[] { + const compacted: AgentToolResult[] = [] + for (const result of results) { + const file = readFileEvidence(result) + if (!file) { compacted.push(result); continue } + const key = fileKey(result, file) + let current = result + let currentFile = file + for (let index = compacted.length - 1; index >= 0; index--) { + const previous = compacted[index] + const prior = readFileEvidence(previous) + if (!prior || fileKey(previous, prior) !== key) continue + if (prior.hash !== file.hash || prior.exists !== file.exists) { + compacted.splice(index, 1) + continue + } + if (currentFile.complete) { compacted.splice(index, 1); continue } + if (prior.complete) { current = previous; currentFile = prior; compacted.splice(index, 1); continue } + if (prior.contentTruncated !== false || currentFile.contentTruncated !== false + || prior.totalLines !== currentFile.totalLines + || prior.endLine + 1 < currentFile.startLine || currentFile.endLine + 1 < prior.startLine) continue + const lines = new Map() + let conflict = false + for (const part of [prior, currentFile]) { + for (const [offset, line] of part.content.split('\n').entries()) { + const number = part.startLine + offset + if (number > part.endLine) break + if (lines.has(number) && lines.get(number) !== line) conflict = true + lines.set(number, line) + } + } + if (conflict) continue + const startLine = Math.min(prior.startLine, currentFile.startLine) + const endLine = Math.max(prior.endLine, currentFile.endLine) + const content = Array.from({ length: endLine - startLine + 1 }, (_, offset) => lines.get(startLine + offset)!).join('\n') + // Merged ranges remain read-only evidence: no unverified full-file hash promotion. + const merged = { ...currentFile, startLine, endLine, content, complete: false, contentTruncated: false } + const data = JSON.stringify(merged) + if (content.length > 40_000 || data.length > 60_000) continue + currentFile = merged + current = { ...result, data, truncated: true } + compacted.splice(index, 1) + } + compacted.push(current) + } + return compacted +} + +export function missingAgentRead(tool: AgentToolRequest, results: AgentToolResult[]): AgentToolRequest | undefined { + if (tool.name !== 'files.read') return tool + const observations = results.filter(result => result.moduleName === tool.moduleName) + .flatMap(result => { const file = readFileEvidence(result); return file && file.path === tool.args?.path ? [{ file, target: result.target }] : [] }) + const latest = observations.at(-1) + const files = observations.filter(item => item.target === latest?.target && item.file.hash === latest?.file.hash).map(item => item.file) + if (files.some(file => file.complete)) return + // An unbounded read may be needed for an approved edit; partial ranges do not replace it. + if (tool.args?.startLine === undefined && tool.args?.lineCount === undefined) return tool + const start = tool.args?.startLine ?? 1 + const total = files[0]?.totalLines + const end = Math.min(start + (tool.args?.lineCount ?? 200) - 1, total ?? Infinity) + let next = start + const ranges = files.map(file => [file.startLine, file.endLine]).sort((a, b) => a[0] - b[0]) + for (const [from, to] of ranges) { + if (from > next) return { ...tool, args: { ...tool.args, startLine: next, lineCount: Math.min(end, from - 1) - next + 1 } } + if (to >= next) next = to + 1 + if (next > end) return + } + if (next > end) return + return { ...tool, args: { ...tool.args, startLine: next, lineCount: end - next + 1 } } +} + +export function describeAgentEvidence(result: AgentToolResult): string { + const file = readFileEvidence(result) + return `${result.moduleName ? `${result.moduleName} · ` : ''}${result.name}: ${result.status}${file ? ` · ${file.path}${file.exists ? ` · ${file.startLine}–${file.endLine}/${file.totalLines}줄` : ' · 파일 없음'}` : ''}${result.truncated ? ' (일부 결과)' : ''} · ${result.target} · ${result.observedAt}` +} diff --git a/src/renderer/src/features/workspace/agentModels.ts b/src/renderer/src/features/workspace/agentModels.ts new file mode 100644 index 0000000..7086ab2 --- /dev/null +++ b/src/renderer/src/features/workspace/agentModels.ts @@ -0,0 +1,34 @@ +export const AGENT_PROVIDERS = ['A_COPILOT', 'OPENAI', 'ANTHROPIC', 'GEMINI', 'QWEN'] as const +export type AgentProvider = typeof AGENT_PROVIDERS[number] +export type AgentModelOption = { provider: AgentProvider; model: string; imagesEnabled: boolean } +export type AgentStatus = { + enabled: boolean + provider: string | null + model: string | null + imagesEnabled?: boolean + models: AgentModelOption[] + selectionSupported: boolean +} + +export const AGENT_PROVIDER_LABELS: Record = { + A_COPILOT: 'A-Copilot', OPENAI: 'OpenAI', ANTHROPIC: 'Claude', GEMINI: 'Gemini', QWEN: 'Qwen' +} + +export function normalizeAgentStatus(value: Partial): AgentStatus { + const selectionSupported = Array.isArray(value.models) + const candidates = value.enabled ? selectionSupported ? value.models! : [value] : [] + const models = AGENT_PROVIDERS.flatMap(provider => { + const item = candidates.find(item => item.provider === provider && typeof item.model === 'string' && item.model.trim() && item.model.length <= 120) + return item ? [{ provider, model: item.model!.trim(), imagesEnabled: provider === 'QWEN' && item.imagesEnabled === true }] : [] + }) + return { enabled: models.length > 0, provider: models[0]?.provider ?? null, model: models[0]?.model ?? null, + imagesEnabled: models[0]?.imagesEnabled ?? false, models, selectionSupported } +} + +export function selectedAgentModel(models: AgentModelOption[], preference: string | null): AgentModelOption | null { + return models.find(model => model.provider === preference) ?? models[0] ?? null +} + +export function agentModelLabel(model: AgentModelOption): string { + return model.provider === 'A_COPILOT' ? 'A-Copilot' : `${AGENT_PROVIDER_LABELS[model.provider]} · ${model.model}` +} diff --git a/src/renderer/src/features/workspace/useAiAgent.ts b/src/renderer/src/features/workspace/useAiAgent.ts index b9ce26b..d404a13 100644 --- a/src/renderer/src/features/workspace/useAiAgent.ts +++ b/src/renderer/src/features/workspace/useAiAgent.ts @@ -3,6 +3,7 @@ import { createAgentSessionSync } from './agentSessionSync' import { agentPlanRequestText, recoverInterruptedAgentMessages, resolveAgentContext, type AgentContext, type AgentContextRequest, type AgentPlanResponse, type AgentProgressStep } from './agentContext' import type { AgentImageReference, ChatAttachment } from '../../components/chatAttachments' import type { AgentOperation, AgentToolRequest, AgentToolResult } from '../../../../shared/agentTools' +import { normalizeAgentStatus, selectedAgentModel, type AgentStatus } from './agentModels' export type AgentFileChange = { expectedHash?: string | null @@ -117,13 +118,6 @@ type UseAiAgentOptions = { onResponse?: () => void } -type AgentStatus = { - enabled: boolean - provider: string | null - model: string | null - imagesEnabled?: boolean -} - type AgentSessionResponse = { sessionId: string | null messages: AgentConversationMessage[] @@ -132,7 +126,11 @@ type AgentSessionResponse = { const sessionSyncs = new Map>() export function useAiAgent({ accessToken, studentEmail, courseId, weekNumber, apiBaseUrl, getFreshAccessToken, onResponse }: UseAiAgentOptions) { - const [status, setStatus] = useState({ enabled: false, provider: null, model: null }) + const modelPreferenceKey = `autonomy-studio.lms.agent-model:${JSON.stringify([apiBaseUrl, studentEmail.toLowerCase(), courseId])}` + const [statusSnapshot, setStatusSnapshot] = useState<{ scope: string; status: AgentStatus }>({ scope: '', status: normalizeAgentStatus({}) }) + const status = statusSnapshot.scope === modelPreferenceKey ? statusSnapshot.status : normalizeAgentStatus({}) + const [modelPreference, setModelPreference] = useState<{ scope: string; provider: string | null }>({ scope: '', provider: null }) + const selectedModel = selectedAgentModel(status.models, modelPreference.scope === modelPreferenceKey ? modelPreference.provider : null) const [statusLoading, setStatusLoading] = useState(Boolean(accessToken && courseId)) const [historyLoading, setHistoryLoading] = useState(Boolean(accessToken && courseId)) const [historyError, setHistoryError] = useState(null) @@ -150,6 +148,18 @@ export function useAiAgent({ accessToken, studentEmail, courseId, weekNumber, ap onResponseRef.current = onResponse const authenticated = Boolean(accessToken && courseId && studentEmail) + const selectProvider = (provider: string): void => { + if (pending || applyingRef.current || abortControllerRef.current || !status.models.some(model => model.provider === provider)) return + setModelPreference({ scope: modelPreferenceKey, provider }) + try { window.localStorage.setItem(modelPreferenceKey, provider) } catch { /* Selection still works without persistent storage. */ } + } + + useEffect(() => { + let provider: string | null = null + try { provider = window.localStorage.getItem(modelPreferenceKey) } catch { /* Use the first available model. */ } + setModelPreference({ scope: modelPreferenceKey, provider }) + }, [modelPreferenceKey]) + const requestApi = useCallback(async (path: string, options?: RequestInit): Promise => { const authorization = new Headers(options?.headers).get('Authorization') ?? `Bearer ${await tokenReaderRef.current()}` @@ -167,7 +177,7 @@ export function useAiAgent({ accessToken, studentEmail, courseId, weekNumber, ap if (response.status === 400 && message?.split('\n').includes('property attachments should not exist')) { throw new Error('연결된 서버가 에이전트 파일 첨부를 지원하지 않습니다. 백엔드를 업데이트한 뒤 다시 시도하거나 첨부 없이 질문해 주세요.') } - if (response.status === 400 && /property (?:toolProtocol|toolResults|environment|imageTokens) should not exist/.test(message ?? '')) throw new Error('연결된 서버가 에이전트 요청 형식을 지원하지 않습니다. 백엔드를 먼저 업데이트해주세요.') + if (response.status === 400 && /property (?:provider|toolProtocol|toolResults|environment|workspaceTarget|contextNotes|imageTokens) should not exist/.test(message ?? '')) throw new Error('연결된 서버가 에이전트 요청 형식을 지원하지 않습니다. 백엔드를 먼저 업데이트해주세요.') throw new Error(message ?? 'AI 에이전트 요청을 처리하지 못했습니다.') } return response.json() as Promise @@ -205,27 +215,43 @@ export function useAiAgent({ accessToken, studentEmail, courseId, weekNumber, ap useEffect(() => { if (!accessToken || !courseId) { - setStatus({ enabled: false, provider: null, model: null }) + setStatusSnapshot({ scope: modelPreferenceKey, status: normalizeAgentStatus({}) }) setStatusLoading(false) return } let cancelled = false - setStatusLoading(true) - requestApi(`/chat/agent/status?courseId=${encodeURIComponent(courseId)}`) - .then((nextStatus) => { - if (!cancelled) setStatus(nextStatus) - }) - .catch(() => { - if (!cancelled) setStatus({ enabled: false, provider: null, model: null }) - }) - .finally(() => { - if (!cancelled) setStatusLoading(false) - }) + let revision = 0 + let initialLoad = true + const refresh = (): void => { + const requestRevision = ++revision + if (initialLoad) setStatusLoading(true) + requestApi(`/chat/agent/status?courseId=${encodeURIComponent(courseId)}`) + .then(nextStatus => { + if (!cancelled && requestRevision === revision) setStatusSnapshot({ scope: modelPreferenceKey, status: normalizeAgentStatus(nextStatus) }) + }) + .catch(() => { + if (!cancelled && requestRevision === revision) setStatusSnapshot({ scope: modelPreferenceKey, status: normalizeAgentStatus({}) }) + }) + .finally(() => { + if (!cancelled && requestRevision === revision) { + initialLoad = false + setStatusLoading(false) + } + }) + } + const refreshVisible = (): void => { if (!document.hidden) refresh() } + refresh() + window.addEventListener('focus', refresh) + document.addEventListener('visibilitychange', refreshVisible) + const timer = window.setInterval(refreshVisible, 30_000) return () => { cancelled = true + window.removeEventListener('focus', refresh) + document.removeEventListener('visibilitychange', refreshVisible) + window.clearInterval(timer) } - }, [accessToken, courseId, requestApi, weekNumber]) + }, [accessToken, courseId, modelPreferenceKey, requestApi, weekNumber]) // The workspace stays mounted while other app pages or Q&A tabs are shown. // Request cancellation follows the conversation lifetime, not its visibility. @@ -293,11 +319,14 @@ export function useAiAgent({ accessToken, studentEmail, courseId, weekNumber, ap loadRequestedContext?: (request: AgentContextRequest) => Promise executeTool?: (request: AgentToolRequest) => Promise environment?: 'local' | 'remote' + workspaceTarget?: string toolResults?: AgentToolResult[] } = {} ): Promise => { const request = rawRequest.trim() - if (!request || pending || historyLoading || abortControllerRef.current || !status.enabled) return + if (!request || pending || historyLoading || statusLoading || abortControllerRef.current || !status.enabled || !selectedModel) return + const requestModel = selectedModel + const selection = status.selectionSupported ? { provider: requestModel.provider } : {} const startedAt = Date.now() const userMessage: AgentConversationMessage = { id: `agent-user-${Date.now()}`, @@ -321,10 +350,11 @@ export function useAiAgent({ accessToken, studentEmail, courseId, weekNumber, ap const imageAttachments: AgentImageReference[] = [] for (const file of options.attachments ?? []) { if (file.kind !== 'image') continue - if (!status.imagesEnabled) throw new Error('현재 모델은 이미지 첨부를 지원하지 않습니다.') + if (!requestModel.imagesEnabled) throw new Error('현재 모델은 이미지 첨부를 지원하지 않습니다.') setActivity(`이미지 업로드 중: ${file.name}`) const body = new FormData() body.append('courseId', courseId) + if (selection.provider) body.append('provider', selection.provider) if (Number.isInteger(weekNumber) && weekNumber >= 1) body.append('week', String(weekNumber)) body.append('file', file.file, file.name) const uploaded = await requestApi('/chat/agent/attachments/images', { method: 'POST', body, signal: abortController.signal }) @@ -345,7 +375,7 @@ export function useAiAgent({ accessToken, studentEmail, courseId, weekNumber, ap requestPlan: (context, finalize) => requestApi('/chat/agent/plan', { method: 'POST', signal: abortController.signal, - body: JSON.stringify({ courseId, week: weekNumber, request: agentPlanRequestText(request, finalize), ...(options.executeTool ? { toolProtocol: 1, environment: options.environment } : {}), ...(textAttachments.length ? { attachments: textAttachments } : {}), ...(imageTokens.length ? { imageTokens } : {}), ...context }) + body: JSON.stringify({ courseId, week: weekNumber, ...selection, request: agentPlanRequestText(request, finalize), ...(options.executeTool ? { toolProtocol: 1, environment: options.environment, workspaceTarget: options.workspaceTarget } : {}), ...(textAttachments.length ? { attachments: textAttachments } : {}), ...(imageTokens.length ? { imageTokens } : {}), ...context }) }) }) if (abortController.signal.aborted) return @@ -504,6 +534,10 @@ export function useAiAgent({ accessToken, studentEmail, courseId, weekNumber, ap return { ...status, + provider: selectedModel?.provider ?? null, + model: selectedModel?.model ?? null, + imagesEnabled: selectedModel?.imagesEnabled ?? false, + selectProvider, statusLoading: statusLoading || historyLoading, pending, activity, diff --git a/src/renderer/src/i18n/en.json b/src/renderer/src/i18n/en.json index 827c683..ffed5e7 100644 --- a/src/renderer/src/i18n/en.json +++ b/src/renderer/src/i18n/en.json @@ -1,4 +1,6 @@ { + "에이전트 모델 선택": "Agent model", + "사용 가능한 모델 없음": "No models available", "전송": "Send", "파일 첨부": "Attach files", "첨부 제거": "Remove attachment",