diff --git a/package-lock.json b/package-lock.json index 988d88c..2814ba1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "lms", - "version": "1.2.23", + "version": "1.2.24", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "lms", - "version": "1.2.23", + "version": "1.2.24", "dependencies": { "@electron-toolkit/preload": "^3.0.1", "@electron-toolkit/utils": "^4.0.0", diff --git a/package.json b/package.json index 4e49153..99ed2b2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "lms", - "version": "1.2.23", + "version": "1.2.24", "description": "Autonomy Studio LMS Electron app", "main": "./out/main/index.js", "author": { diff --git a/scripts/test-agent-progress.cjs b/scripts/test-agent-progress.cjs index 766013f..ffdf4a2 100644 --- a/scripts/test-agent-progress.cjs +++ b/scripts/test-agent-progress.cjs @@ -47,6 +47,92 @@ function harness(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 diff --git a/scripts/test-agent-workspace-routing.cjs b/scripts/test-agent-workspace-routing.cjs index 6fe299b..ea089a9 100644 --- a/scripts/test-agent-workspace-routing.cjs +++ b/scripts/test-agent-workspace-routing.cjs @@ -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), []) diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 5820dc7..a0cd4cc 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -3679,9 +3679,13 @@ function LocalPcWorkspaceMockup({ const submitAgentRequest = async (content: string, attachments: ChatAttachment[] = []): Promise => { 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', diff --git a/src/renderer/src/features/workspace/useAiAgent.ts b/src/renderer/src/features/workspace/useAiAgent.ts index d404a13..d6b4a06 100644 --- a/src/renderer/src/features/workspace/useAiAgent.ts +++ b/src/renderer/src/features/workspace/useAiAgent.ts @@ -316,6 +316,7 @@ export function useAiAgent({ accessToken, studentEmail, courseId, weekNumber, ap options: { showUserMessage?: boolean attachments?: ChatAttachment[] + loadInitialContext?: (signal: AbortSignal) => Promise loadRequestedContext?: (request: AgentContextRequest) => Promise executeTool?: (request: AgentToolRequest) => Promise environment?: 'local' | 'remote' @@ -346,6 +347,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 +366,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('/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, @@ -378,7 +386,7 @@ export function useAiAgent({ accessToken, studentEmail, courseId, weekNumber, ap 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 + if (!currentRequest()) return const plan: AgentPlan = { ...response.plan, requestedModules: response.plan.requestedModules ?? [], @@ -402,7 +410,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', diff --git a/src/renderer/src/i18n/en.json b/src/renderer/src/i18n/en.json index ffed5e7..6588130 100644 --- a/src/renderer/src/i18n/en.json +++ b/src/renderer/src/i18n/en.json @@ -741,6 +741,7 @@ "소스 파일": "Source files", "{{count}}건": "{{count}} items", "요청 분석 중": "Analyzing request", + "모듈 상태 조회 중": "Checking module status", "조회 결과 분석 중": "Analyzing retrieved results", "추가 자료 조회 중": "Retrieving additional context", "최종 답변 정리 중": "Preparing the final answer",