Compare commits

2 Commits

11 changed files with 357 additions and 26 deletions
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "lms",
"version": "1.2.23",
"version": "1.2.25",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "lms",
"version": "1.2.23",
"version": "1.2.25",
"dependencies": {
"@electron-toolkit/preload": "^3.0.1",
"@electron-toolkit/utils": "^4.0.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "lms",
"version": "1.2.23",
"version": "1.2.25",
"description": "Autonomy Studio LMS Electron app",
"main": "./out/main/index.js",
"author": {
+105
View File
@@ -0,0 +1,105 @@
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 api = {}
vm.runInNewContext(ts.transpileModule(fs.readFileSync(require.resolve('../src/renderer/src/features/workspace/agentConversationContext.ts'), 'utf8'), {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 }
}).outputText, { exports: api })
const { agentConversationWorkspace: scope, buildAgentConversationContext: build } = api
const now = Date.now()
const local = scope('local', 'local-host', '/workspace/a')
const plain = value => JSON.parse(JSON.stringify(value))
const turn = (n, workspace = local, createdAt = now) => [
{ id: `u${n}`, role: 'user', content: `question-${n}`, conversationScope: { workspace, createdAt } },
{ id: `a${n}`, role: 'assistant', content: `summary-${n}`, conversationScope: { workspace, createdAt }, plan: {
summary: `summary-${n}`, explanation: `answer-${n}`, conversationSummary: `누적 기억-${n}`, changes: [], commands: [], requestedModules: [], requestedLogs: [], createModule: null
} }
]
test('uses the latest cumulative summary with only two recent exchanges and no duplicate current question', () => {
const messages = Array.from({ length: 20 }, (_, i) => turn(i)).flat()
messages.push({ ...turn(20)[0], content: 'pending current question' })
const context = build(messages, local, now)
assert.deepEqual(plain(context.recentMessages).map(item => item.content), ['question-18', 'summary-18\nanswer-18', 'question-19', 'summary-19\nanswer-19'])
assert.equal(context.summary, '누적 기억-19')
assert.doesNotMatch(JSON.stringify(context), /question-17|pending current question/)
})
test('hard caps payload text even with huge original answers and requests', () => {
const messages = Array.from({ length: 10 }, (_, i) => {
const pair = turn(i)
pair[0].content = 'q'.repeat(4000)
pair[1].plan.summary = 's'.repeat(500)
pair[1].plan.explanation = 'e'.repeat(12000)
pair[1].plan.conversationSummary = 'm'.repeat(2000)
return pair
}).flat()
const context = build(messages, local, now)
assert.ok(context.summary.length <= 1000)
assert.equal(context.recentMessages.length, 4)
assert.ok(context.recentMessages.every(message => message.content.length <= 700))
assert.ok(context.summary.length + context.recentMessages.reduce((sum, item) => sum + item.content.length, 0) <= 3800)
})
test('does not mix hosts, roots, SSH identities, unscoped legacy messages or expired history', () => {
for (const other of [scope('remote', 'ssh:a', '/workspace/a'), scope('local', 'local-host', '/workspace/b'), scope('remote', 'ssh:b', '/workspace/a')]) {
assert.equal(build(turn(1, other), local, now), undefined)
}
assert.equal(build(turn(1, local, now - 86400000), local, now), undefined)
assert.equal(build(turn(1, local, now + 1), local, now), undefined)
assert.equal(build(turn(1).map(({ conversationScope, ...message }) => message), local, now), undefined)
assert.equal(scope('local', 'local-host'), undefined)
assert.equal(build(turn(1), undefined, now), undefined)
assert.equal(build([], local, now), undefined)
})
test('a missing or empty generated summary preserves earlier valid memory without an extra model call', () => {
const first = turn(1), second = turn(2)
delete second[1].plan.conversationSummary
assert.equal(build([...first, ...second], local, now).summary, '누적 기억-1')
second[1].plan.conversationSummary = ''
assert.equal(build([...first, ...second], local, now).summary, '누적 기억-1')
delete first[1].plan.conversationSummary
assert.equal(build([...first, ...second], local, now).summary, '')
second[1].plan.conversationSummary = '새 누적 요약'
assert.equal(build([...first, ...second], local, now).summary, '새 누적 요약')
})
test('keeps file identities and action outcomes, but excludes payloads and tool evidence', () => {
const pair = turn(1)
pair[1].plan.explanation = '설명\n```python\nPRIVATE_SOURCE\n```\n끝'
pair[1].plan.changes = [{ moduleName: 'Control', path: 'config.yaml', content: 'PRIVATE_CHANGE' }]
pair[1].plan.commands = [{ id: 'build', moduleName: 'Control', purpose: 'build', command: 'PRIVATE_COMMAND' }]
pair[1].plan.evidence = [{ data: 'PRIVATE_LOG' }]
pair[1].plan.codeReferences = [{ moduleName: 'Control', path: 'node.py', startLine: 20, endLine: 23, snippet: 'PRIVATE_SNIPPET' }]
pair[1].commandExecutions = { build: { status: 'completed', output: 'PRIVATE_OUTPUT' } }
pair[1].applyState = 'cancelled'
const context = JSON.stringify(build(pair, local, now))
assert.match(context, /Control\/node.py:20-23/)
assert.match(context, /file change: cancelled/)
assert.match(context, /build: completed/)
assert.doesNotMatch(context, /PRIVATE_/)
})
test('ignores errors, interrupted turns and initial lookup responses', () => {
const pair = turn(1)
const error = { ...pair[1], plan: undefined, content: 'network error' }
assert.equal(build([pair[0], error], local, now), undefined)
pair[1].plan.toolRequests = [{ name: 'files.read' }]
assert.equal(build(pair, local, now), undefined)
assert.equal(build([turn(1)[0], ...turn(2, scope('remote', 'ssh:x', '/workspace'))], local, now), undefined)
})
test('rebuilds from persisted metadata and reflects updated execution outcomes without new memory storage', () => {
const pair = turn(1)
pair[1].plan.commands = [{ id: 'build', moduleName: 'Control', purpose: 'build' }]
assert.match(JSON.stringify(build(plain(pair), local, now)), /pending/)
pair[1].commandExecutions = { build: { status: 'failed' } }
assert.match(JSON.stringify(build(plain(pair), local, now)), /failed/)
const followUp = { ...pair[1], content: 'result', plan: { ...pair[1].plan, summary: '실행 결과 분석', explanation: '실패했습니다.' } }
assert.match(JSON.stringify(build([...pair, followUp], local, now)), /실행 결과 분석/)
assert.equal(build([], local, now), undefined)
})
+114 -3
View File
@@ -15,6 +15,7 @@ const evidenceApi = compile(fs.readFileSync(require.resolve('../src/renderer/src
const context = compile(fs.readFileSync(require.resolve('../src/renderer/src/features/workspace/agentContext.ts'), 'utf8'), {
require: name => { assert.equal(name, './agentEvidence'); return evidenceApi }
})
const conversation = compile(fs.readFileSync(require.resolve('../src/renderer/src/features/workspace/agentConversationContext.ts'), 'utf8'))
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)))
@@ -29,10 +30,10 @@ function harness(requestApi) {
let now = 1000
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 messageStateRef = { current: { session, messages: [] } }
const selectedModel = { provider: 'QWEN', model: 'fixture', imagesEnabled: true }
const api = compile(functions.map(node => `export ${node.getText(source)}`).join('\n'), {
...context, session, messageStateRef, AbortController, FormData,
...context, ...conversation, session, messageStateRef, AbortController, FormData,
Date: Clock,
courseId: 'course', weekNumber: 3, pending: false, historyLoading: false, statusLoading: false,
selectedModel,
@@ -41,12 +42,98 @@ function harness(requestApi) {
setRequestStartedAt: value => { state.requestStartedAt = value },
setActivity: value => { state.activity = value },
setProgressSteps: value => { state.steps = typeof value === 'function' ? value(state.steps) : value },
updateMessages: update => { state.messages = update(state.messages); state.saved.push(plain(state.messages)) },
updateMessages: update => { state.messages = update(state.messages); messageStateRef.current.messages = state.messages; state.saved.push(plain(state.messages)) },
onResponseRef: { current: () => { state.notices++ } }, requestApi
})
return { ...api, state, selectedModel, messageStateRef, advance: ms => { now += ms } }
}
test('shows and persists the question, pending state and timer before a slow module snapshot finishes', async () => {
const snapshot = deferred(), completion = deferred()
const bodies = []
let loads = 0
const h = harness((_, request) => { bodies.push(JSON.parse(request.body)); return completion.promise })
const running = h.requestPlan('원격 차량 속도 제한 위치', [], [], {
environment: 'remote', workspaceTarget: 'ssh:100.64.0.10', executeTool: async () => result(),
loadInitialContext: signal => { loads++; assert.equal(signal.aborted, false); return snapshot.promise }
})
assert.equal(h.state.messages[0].content, '원격 차량 속도 제한 위치')
assert.equal(h.state.saved[0][0].role, 'user')
assert.equal(h.state.pending, true)
assert.equal(h.state.requestStartedAt, 1000)
assert.equal(h.state.activity, '모듈 상태 조회 중')
assert.equal(bodies.length, 0)
await h.requestPlan('중복 전송', [], [], { loadInitialContext: () => { loads++; return snapshot.promise } })
assert.equal(loads, 1)
assert.equal(h.state.messages.length, 1)
h.advance(5000)
h.selectedModel.provider = 'OPENAI'
snapshot.resolve({ modules: [{ name: 'Control', state: 'running', parameters: ['sim_mode:=false'], files: [] }], runtimeLogs: [] })
await tick()
assert.equal(bodies.length, 1)
assert.equal(bodies[0].provider, 'QWEN')
assert.equal(bodies[0].modules[0].state, 'running')
assert.deepEqual(bodies[0].modules[0].parameters, ['sim_mode:=false'])
h.advance(2000)
completion.resolve(response())
await running
assert.equal(h.state.messages[1].durationMs, 7000)
assert.equal(h.state.pending, false)
})
for (const reject of [false, true]) test(`cancels during initial module lookup and ignores its late ${reject ? 'failure' : 'result'} without affecting a new request`, async () => {
const old = deferred(), next = deferred()
let calls = 0, signal
const h = harness(async () => { calls++; return response() })
const first = h.requestPlan('첫 질문', [], [], { loadInitialContext: current => { signal = current; return old.promise } })
h.stop()
assert.equal(signal.aborted, true)
assert.equal(h.state.pending, false)
const second = h.requestPlan('다음 질문', [], [], { loadInitialContext: () => next.promise })
if (reject) old.reject(Error('늦게 도착한 SSH 오류'))
else old.resolve({ modules: [], runtimeLogs: [] })
await first
assert.equal(calls, 0)
assert.equal(h.state.pending, true)
assert.equal(h.state.activity, '모듈 상태 조회 중')
assert.equal(h.state.messages.length, 2)
assert.equal(h.state.notices, 0)
next.resolve({ modules: [], runtimeLogs: [] })
await second
assert.equal(calls, 1)
assert.equal(h.state.messages.length, 3)
})
test('initial snapshot errors preserve the question and show a timed error without calling the model', async () => {
const snapshot = deferred()
let calls = 0
const h = harness(async () => { calls++; return response() })
const running = h.requestPlan('질문', [], [], { loadInitialContext: () => snapshot.promise })
h.advance(3000)
snapshot.reject(Error('원격 모듈 상태 조회 실패'))
await running
assert.equal(calls, 0)
assert.equal(h.state.messages[0].content, '질문')
assert.equal(h.state.messages[1].content, '원격 모듈 상태 조회 실패')
assert.equal(h.state.messages[1].durationMs, 3000)
assert.equal(h.state.pending, false)
assert.equal(h.state.requestStartedAt, null)
assert.equal(h.state.activity, '')
})
test('scope changes during initial snapshot loading suppress later API calls and errors', async () => {
const snapshot = deferred()
let calls = 0
const h = harness(async () => { calls++; return response() })
const running = h.requestPlan('이전 강좌 질문', [], [], { loadInitialContext: () => snapshot.promise })
h.messageStateRef.current.session = {}
snapshot.resolve({ modules: [], runtimeLogs: [] })
await running
assert.equal(calls, 0)
assert.equal(h.state.messages.length, 1)
assert.equal(h.state.notices, 0)
})
test('publishes completed stages immediately without persisting progress or adding model calls', async () => {
const first = deferred(), tool = deferred(), final = deferred()
let calls = 0
@@ -133,6 +220,30 @@ test('remote target and observed source ranges reach each provider request inclu
assert.match(bodies[2].request, /자료 조회는 종료되었습니다/)
})
test('sends only prior same-workspace context and keeps it stable throughout tool lookups', async () => {
const bodies = []
const h = harness(async (_, request) => {
bodies.push(JSON.parse(request.body))
const reply = bodies.length === 2 ? response([{ id: 'read', name: 'files.read', moduleName: 'Control', args: { path: 'config.yaml' } }]) : response()
return { ...reply, plan: { ...reply.plan, conversationSummary: 'Control의 config.yaml 설명을 진행 중입니다.' } }
})
const options = { environment: 'local', workspaceTarget: 'local-host', workspaceRoot: '/workspace/a', executeTool: async () => result() }
await h.requestPlan('Control 설정 위치', [], [], options)
assert.equal(bodies[0].conversationContext, undefined)
await h.requestPlan('그 파일 설명해줘', [], [], options)
assert.equal(bodies.length, 3)
assert.equal(bodies[1].conversationContext.recentMessages[0].content, 'Control 설정 위치')
assert.equal(bodies[1].conversationContext.summary, 'Control의 config.yaml 설명을 진행 중입니다.')
assert.equal(h.state.saved[1][1].plan.conversationSummary, 'Control의 config.yaml 설명을 진행 중입니다.')
assert.equal(JSON.stringify(bodies[1].conversationContext).includes('그 파일 설명해줘'), false)
assert.deepEqual(bodies[1].conversationContext, bodies[2].conversationContext)
assert.equal(bodies[1].workspaceRoot, undefined)
await h.requestPlan('다른 원격 질문', [], [], { ...options, environment: 'remote', workspaceTarget: 'ssh:device' })
assert.equal(bodies.at(-1).conversationContext, undefined)
await h.requestPlan('다른 로컬 폴더', [], [], { ...options, workspaceRoot: '/workspace/b' })
assert.equal(bodies.at(-1).conversationContext, undefined)
})
test('errors replace temporary progress with the existing error reply', async () => {
let calls = 0
const h = harness(async () => {
+2 -2
View File
@@ -32,7 +32,7 @@ test('user messages preserve only typed text while attachments remain separate m
find(source)
assert.ok(initializer)
for (const request of ['이 이미지에서 슬램에 필요한 모듈만 골라봐', '첫 줄\n첨부 파일: 직접 입력한 내용']) {
const message = vm.runInNewContext(`(${initializer})`, { request, options: { attachments: [{ name: '스크린샷.png' }] } })
const message = vm.runInNewContext(`(${initializer})`, { request, conversationScope: undefined, options: { attachments: [{ name: '스크린샷.png' }] } })
assert.equal(message.content, request)
}
})
@@ -40,7 +40,7 @@ test('user messages preserve only typed text while attachments remain separate m
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,
textAttachments: attachments ?? [], imageTokens, conversationContext: undefined,
selection: provider ? { provider } : {},
agentPlanRequestText: contextApi.agentPlanRequestText,
context: { modules: [{ name: 'Control', files: [] }], runtimeLogs: [] }
+6
View File
@@ -92,6 +92,12 @@ function renderResponse(message, now, reducedMotion = false, extra = {}) {
return renderToStaticMarkup(ProgressiveChatResponse({ message, dark: true, children: React.createElement('button', null, '승인'), ...extra }))
}
test('internal cumulative memory is not rendered in the response bubble', () => {
const html = renderResponse({ content: '화면 답변', plan: { summary: '화면 요약', explanation: '화면 설명', conversationSummary: 'HIDDEN_CONVERSATION_MEMORY' } }, 0)
assert.match(html, /화면 설명/)
assert.doesNotMatch(html, /HIDDEN_CONVERSATION_MEMORY/)
})
test('managed agent responses display A-Copilot without an internal provider ID or duplicate model name', () => {
const html = renderResponse({ content: '분석 결과', provider: 'A_COPILOT', model: 'A-Copilot' }, 0)
assert.ok(html.includes('A-Copilot'))
+15 -6
View File
@@ -15,6 +15,8 @@ const plain = value => JSON.parse(JSON.stringify(value))
function harness(remoteWorkspace) {
const executions = []
const calls = []
const contexts = []
const events = []
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'] }]
@@ -26,10 +28,14 @@ function harness(remoteWorkspace) {
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') } }
remoteWorkspace: { modules: { list: async () => { events.push('snapshot-read'); return { modules: live } } }, agent: { readTool: readTool('remote') } },
localWorkspace: { modules: { list: async () => { events.push('snapshot-read'); return { modules: live } } }, agent: { readTool: readTool('local') } }
},
requestAgentPlan: async (...args) => {
calls.push(args)
events.push('plan-start')
contexts.push(await args[3].loadInitialContext(new AbortController().signal))
},
requestAgentPlan: async (...args) => { calls.push(args) },
onTitlebarNotice: (...args) => { errors.push(args) }, console: { error: () => {} },
loadAgentRequestedContext: async () => ({ modules: [], runtimeLogs: [] })
}
@@ -37,16 +43,19 @@ function harness(remoteWorkspace) {
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 }
return { ...exports, executions, calls, contexts, events, errors }
}
for (const remote of [false, true]) {
test(`${remote ? 'remote' : 'local'}: a new question refreshes state and start settings before sending context`, async () => {
test(`${remote ? 'remote' : 'local'}: enters the chat lifecycle immediately and refreshes state before the provider request`, async () => {
const h = harness(remote)
await h.submitAgentRequest('최대 속도 제한 코드 위치')
assert.deepEqual(h.errors, [])
assert.equal(h.calls.length, 1)
const [, modules, , options] = h.calls[0]
const [, initialModules, , options] = h.calls[0]
assert.deepEqual(plain(initialModules), [])
assert.deepEqual(h.events, ['plan-start', 'snapshot-read'])
const { modules } = h.contexts[0]
assert.equal(modules[0].state, 'running')
assert.deepEqual(plain(modules[0].parameters), ['sim_mode:=false', 'robot:=mkmini'])
assert.deepEqual(plain(modules[0].files), [])
+13 -5
View File
@@ -3679,13 +3679,18 @@ function LocalPcWorkspaceMockup({
const submitAgentRequest = async (content: string, attachments: ChatAttachment[] = []): Promise<void> => {
if (agentActionControllerRef.current || agentApplying) return
try {
const modules = await collectAgentWorkspace([], await readAgentModules())
await requestAgentPlan(content, modules, [], {
await requestAgentPlan(content, [], [], {
attachments,
loadInitialContext: async (signal) => {
const modules = await readAgentModules()
if (signal.aborted) throw new DOMException('Aborted', 'AbortError')
return { modules: await collectAgentWorkspace([], modules), runtimeLogs: [] }
},
loadRequestedContext: loadAgentRequestedContext,
executeTool: executeWorkspaceAgentTool,
environment: remoteWorkspace ? 'remote' : 'local',
workspaceTarget: remoteWorkspace ? `ssh:${getRemoteWorkspaceTarget().host}` : 'local-host'
workspaceTarget: remoteWorkspace ? `ssh:${getRemoteWorkspaceTarget().host}` : 'local-host',
workspaceRoot: remoteWorkspace ? JSON.stringify([getRemoteWorkspaceTarget().user, getRemoteWorkspaceTarget().port, getRemoteWorkspaceTarget().workspaceRoot]) : clonePathPreview
})
} catch (error) {
console.error('[agent] workspace collection failed', error)
@@ -3827,7 +3832,9 @@ 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, workspaceTarget: remoteWorkspace ? `ssh:${getRemoteWorkspaceTarget().host}` : 'local-host', executeTool: executeWorkspaceAgentTool, loadRequestedContext: loadAgentRequestedContext,
showUserMessage: false, environment, workspaceTarget: remoteWorkspace ? `ssh:${getRemoteWorkspaceTarget().host}` : 'local-host',
workspaceRoot: remoteWorkspace ? JSON.stringify([getRemoteWorkspaceTarget().user, getRemoteWorkspaceTarget().port, getRemoteWorkspaceTarget().workspaceRoot]) : clonePathPreview,
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) {
@@ -3910,7 +3917,8 @@ function LocalPcWorkspaceMockup({
loadRequestedContext: loadAgentRequestedContext,
executeTool: executeWorkspaceAgentTool,
environment: remoteWorkspace ? 'remote' : 'local',
workspaceTarget: remoteWorkspace ? `ssh:${getRemoteWorkspaceTarget().host}` : 'local-host'
workspaceTarget: remoteWorkspace ? `ssh:${getRemoteWorkspaceTarget().host}` : 'local-host',
workspaceRoot: remoteWorkspace ? JSON.stringify([getRemoteWorkspaceTarget().user, getRemoteWorkspaceTarget().port, getRemoteWorkspaceTarget().workspaceRoot]) : clonePathPreview
})
} catch (error) {
const errorMessage = (error instanceof Error ? error.message : '명령을 실행하지 못했습니다.')
@@ -0,0 +1,74 @@
import type { AgentConversationMessage } from './useAiAgent'
export type AgentConversationScope = { workspace: string; createdAt: number }
export type AgentConversationContext = {
environment: 'local' | 'remote'
workspaceTarget: string
summary: string
recentMessages: Array<{ role: 'user' | 'assistant'; content: string }>
}
const MAX_AGE_MS = 24 * 60 * 60 * 1000
const RECENT_TURNS = 2
const MESSAGE_LIMIT = 700
const SUMMARY_LIMIT = 1000
const clip = (text: string, limit: number): string => text.length > limit ? `${text.slice(0, limit - 3)}...` : text
export function agentConversationWorkspace(environment?: 'local' | 'remote', target?: string, root?: string): string | undefined {
if (!environment || !target || !root) return undefined
const workspace = JSON.stringify([environment, target, root])
return workspace.length <= 1000 ? workspace : undefined
}
function responseContext(message: AgentConversationMessage, limit: number): string {
const plan = message.plan!
// Keep identities and outcomes, never replay source contents, logs, or executable payloads.
const details = [
...(plan.codeReferences ?? []).slice(0, 3).map(item => `${item.moduleName}/${item.path}:${item.startLine}-${item.endLine}`),
...plan.changes.slice(0, 2).map(item => `${item.moduleName}/${item.path} (file change: ${message.applyState ?? 'ready'})`),
...plan.commands.slice(0, 2).map(item => `${item.moduleName} (${clip(item.purpose, 60)}: ${message.commandExecutions?.[item.id]?.status ?? 'pending'})`)
].join('; ')
const targets = details ? `\nHistorical references/outcomes: ${clip(details, Math.min(240, Math.floor(limit / 2)))}` : ''
const answer = (limit > 300 ? `${plan.summary}\n${plan.explanation}` : plan.summary).replace(/```[^\n]*\n[\s\S]*?(?:```|$)/g, '[code block omitted]')
return `${clip(answer.trim(), limit - targets.length)}${targets}`
}
export function buildAgentConversationContext(
messages: AgentConversationMessage[],
workspace: string | undefined,
now = Date.now()
): AgentConversationContext | undefined {
if (!workspace) return undefined
const [environment, workspaceTarget] = JSON.parse(workspace) as ['local' | 'remote', string, string]
const turns: Array<{ user: AgentConversationMessage; assistant: AgentConversationMessage }> = []
let summary = ''
let user: AgentConversationMessage | undefined
const inScope = (message: AgentConversationMessage): boolean => message.conversationScope?.workspace === workspace
&& Number.isFinite(message.conversationScope.createdAt)
&& message.conversationScope.createdAt <= now
&& now - message.conversationScope.createdAt < MAX_AGE_MS
for (const message of messages) {
if (message.role === 'user') {
user = inScope(message) ? message : undefined
continue
}
if (!user || !inScope(message) || !message.plan || message.plan.toolRequests?.length
|| message.plan.requestedModules.length || message.plan.requestedLogs.length) continue
const previous = turns.at(-1)
if (previous?.user === user) previous.assistant = message
else turns.push({ user, assistant: message })
if (turns.length > RECENT_TURNS) turns.shift()
if (message.plan.conversationSummary?.trim()) summary = clip(message.plan.conversationSummary.trim(), SUMMARY_LIMIT)
}
if (!turns.length) return undefined
const recent = turns.slice(-RECENT_TURNS)
return {
environment, workspaceTarget, summary,
recentMessages: recent.flatMap(turn => [
{ role: 'user' as const, content: clip(turn.user.content, MESSAGE_LIMIT) },
{ role: 'assistant' as const, content: responseContext(turn.assistant, MESSAGE_LIMIT) }
])
}
}
@@ -4,6 +4,7 @@ import { agentPlanRequestText, recoverInterruptedAgentMessages, resolveAgentCont
import type { AgentImageReference, ChatAttachment } from '../../components/chatAttachments'
import type { AgentOperation, AgentToolRequest, AgentToolResult } from '../../../../shared/agentTools'
import { normalizeAgentStatus, selectedAgentModel, type AgentStatus } from './agentModels'
import { agentConversationWorkspace, buildAgentConversationContext, type AgentConversationScope } from './agentConversationContext'
export type AgentFileChange = {
expectedHash?: string | null
@@ -54,6 +55,7 @@ export type AgentCommandExecution = {
}
export type AgentPlan = {
conversationSummary?: string
codeReferences?: AgentCodeReference[]
toolRequests?: AgentToolRequest[]
evidence?: AgentToolResult[]
@@ -88,6 +90,7 @@ export type AgentRuntimeLog = {
}
export type AgentConversationMessage = {
conversationScope?: AgentConversationScope
id: string
role: 'user' | 'assistant'
content: string
@@ -177,7 +180,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 (?:provider|toolProtocol|toolResults|environment|workspaceTarget|contextNotes|imageTokens) should not exist/.test(message ?? '')) throw new Error('연결된 서버가 에이전트 요청 형식을 지원하지 않습니다. 백엔드를 먼저 업데이트해주세요.')
if (response.status === 400 && /property (?:provider|toolProtocol|toolResults|environment|workspaceTarget|contextNotes|imageTokens|conversationContext) should not exist/.test(message ?? '')) throw new Error('연결된 서버가 에이전트 요청 형식을 지원하지 않습니다. 백엔드를 먼저 업데이트해주세요.')
throw new Error(message ?? 'AI 에이전트 요청을 처리하지 못했습니다.')
}
return response.json() as Promise<T>
@@ -316,10 +319,12 @@ export function useAiAgent({ accessToken, studentEmail, courseId, weekNumber, ap
options: {
showUserMessage?: boolean
attachments?: ChatAttachment[]
loadInitialContext?: (signal: AbortSignal) => Promise<AgentContext>
loadRequestedContext?: (request: AgentContextRequest) => Promise<AgentContext>
executeTool?: (request: AgentToolRequest) => Promise<AgentToolResult>
environment?: 'local' | 'remote'
workspaceTarget?: string
workspaceRoot?: string
toolResults?: AgentToolResult[]
} = {}
): Promise<void> => {
@@ -328,10 +333,14 @@ export function useAiAgent({ accessToken, studentEmail, courseId, weekNumber, ap
const requestModel = selectedModel
const selection = status.selectionSupported ? { provider: requestModel.provider } : {}
const startedAt = Date.now()
const workspace = agentConversationWorkspace(options.environment, options.workspaceTarget, options.workspaceRoot)
const conversationScope = workspace ? { workspace, createdAt: startedAt } : undefined
const conversationContext = buildAgentConversationContext(messageStateRef.current.messages, workspace, startedAt)
const userMessage: AgentConversationMessage = {
id: `agent-user-${Date.now()}`,
role: 'user',
content: request
content: request,
...(conversationScope ? { conversationScope } : {})
}
if (options.showUserMessage !== false) updateMessages((current) => [...current, userMessage])
setPending(true)
@@ -346,6 +355,13 @@ export function useAiAgent({ accessToken, studentEmail, courseId, weekNumber, ap
}
try {
let initialContext: AgentContext = { modules, runtimeLogs, ...(options.toolResults?.length ? { toolResults: options.toolResults } : {}) }
if (options.loadInitialContext) {
setActivity('모듈 상태 조회 중')
const loaded = await options.loadInitialContext(abortController.signal)
if (!currentRequest()) return
initialContext = { ...initialContext, ...loaded }
}
const textAttachments = (options.attachments ?? []).flatMap(file => file.kind === 'image' ? [] : [{ name: file.name, content: file.content }])
const imageAttachments: AgentImageReference[] = []
for (const file of options.attachments ?? []) {
@@ -358,15 +374,15 @@ export function useAiAgent({ accessToken, studentEmail, courseId, weekNumber, ap
if (Number.isInteger(weekNumber) && weekNumber >= 1) body.append('week', String(weekNumber))
body.append('file', file.file, file.name)
const uploaded = await requestApi<AgentImageReference>('/chat/agent/attachments/images', { method: 'POST', body, signal: abortController.signal })
if (!currentRequest()) return
imageAttachments.push(uploaded)
if (options.showUserMessage !== false) updateMessages(current => current.map(item => item.id === userMessage.id ? { ...item, imageAttachments: [...imageAttachments] } : item))
if (abortController.signal.aborted) return
appendStep({ id: `image-${imageAttachments.length}`, kind: 'image', status: 'ok', name: file.name })
}
const imageTokens = imageAttachments.map(image => image.token)
setActivity('요청 분석 중')
const response = await resolveAgentContext({
initialContext: { modules, runtimeLogs, ...(options.toolResults?.length ? { toolResults: options.toolResults } : {}) },
initialContext,
signal: abortController.signal,
loadRequestedContext: options.loadRequestedContext,
executeTool: options.executeTool,
@@ -375,10 +391,10 @@ export function useAiAgent({ accessToken, studentEmail, courseId, weekNumber, ap
requestPlan: (context, finalize) => requestApi<AgentPlanResponse>('/chat/agent/plan', {
method: 'POST',
signal: abortController.signal,
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 })
body: JSON.stringify({ courseId, week: weekNumber, ...selection, request: agentPlanRequestText(request, finalize), environment: options.environment, workspaceTarget: options.workspaceTarget, ...(options.executeTool ? { toolProtocol: 1 } : {}), ...(textAttachments.length ? { attachments: textAttachments } : {}), ...(imageTokens.length ? { imageTokens } : {}), ...context, ...(conversationContext ? { conversationContext } : {}) })
})
})
if (abortController.signal.aborted) return
if (!currentRequest()) return
const plan: AgentPlan = {
...response.plan,
requestedModules: response.plan.requestedModules ?? [],
@@ -393,6 +409,7 @@ export function useAiAgent({ accessToken, studentEmail, courseId, weekNumber, ap
revealStartedAt: Date.now(),
durationMs: Math.max(0, Date.now() - startedAt),
content: plan.summary,
...(conversationScope ? { conversationScope } : {}),
provider: response.provider,
model: response.model,
usage: response.usage,
@@ -402,7 +419,7 @@ export function useAiAgent({ accessToken, studentEmail, courseId, weekNumber, ap
}])
onResponseRef.current?.()
} catch (error) {
if (abortController.signal.aborted) return
if (!currentRequest()) return
updateMessages((current) => [...current, {
id: `agent-error-${Date.now()}`,
role: 'assistant',
+1
View File
@@ -741,6 +741,7 @@
"소스 파일": "Source files",
"{{count}}건": "{{count}} items",
"요청 분석 중": "Analyzing request",
"모듈 상태 조회 중": "Checking module status",
"조회 결과 분석 중": "Analyzing retrieved results",
"추가 자료 조회 중": "Retrieving additional context",
"최종 답변 정리 중": "Preparing the final answer",