From 017093077f3ae8475557b17facd6fd2da4d695b5 Mon Sep 17 00:00:00 2001 From: chyoo Date: Thu, 29 Oct 2026 10:39:20 +0900 Subject: [PATCH] feat: preserve compact agent context and bump to 1.2.25 --- package-lock.json | 4 +- package.json | 2 +- scripts/test-agent-conversation-context.cjs | 105 ++++++++++++++++++ scripts/test-agent-progress.cjs | 31 +++++- scripts/test-agent-request-payload.cjs | 4 +- scripts/test-agent-response-reveal.cjs | 6 + src/renderer/src/App.tsx | 10 +- .../workspace/agentConversationContext.ts | 74 ++++++++++++ .../src/features/workspace/useAiAgent.ts | 15 ++- 9 files changed, 237 insertions(+), 14 deletions(-) create mode 100644 scripts/test-agent-conversation-context.cjs create mode 100644 src/renderer/src/features/workspace/agentConversationContext.ts diff --git a/package-lock.json b/package-lock.json index 2814ba1..8ba4467 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "lms", - "version": "1.2.24", + "version": "1.2.25", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "lms", - "version": "1.2.24", + "version": "1.2.25", "dependencies": { "@electron-toolkit/preload": "^3.0.1", "@electron-toolkit/utils": "^4.0.0", diff --git a/package.json b/package.json index 99ed2b2..6de3495 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "lms", - "version": "1.2.24", + "version": "1.2.25", "description": "Autonomy Studio LMS Electron app", "main": "./out/main/index.js", "author": { diff --git a/scripts/test-agent-conversation-context.cjs b/scripts/test-agent-conversation-context.cjs new file mode 100644 index 0000000..f551301 --- /dev/null +++ b/scripts/test-agent-conversation-context.cjs @@ -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) +}) diff --git a/scripts/test-agent-progress.cjs b/scripts/test-agent-progress.cjs index ffdf4a2..e49abad 100644 --- a/scripts/test-agent-progress.cjs +++ b/scripts/test-agent-progress.cjs @@ -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,7 +42,7 @@ 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 } } @@ -219,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 () => { diff --git a/scripts/test-agent-request-payload.cjs b/scripts/test-agent-request-payload.cjs index 975546d..9df4925 100644 --- a/scripts/test-agent-request-payload.cjs +++ b/scripts/test-agent-request-payload.cjs @@ -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: [] } diff --git a/scripts/test-agent-response-reveal.cjs b/scripts/test-agent-response-reveal.cjs index 827e2ae..17cb1d0 100644 --- a/scripts/test-agent-response-reveal.cjs +++ b/scripts/test-agent-response-reveal.cjs @@ -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')) diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index a0cd4cc..9fffacc 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -3689,7 +3689,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) { console.error('[agent] workspace collection failed', error) @@ -3831,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) { @@ -3914,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 : '명령을 실행하지 못했습니다.') diff --git a/src/renderer/src/features/workspace/agentConversationContext.ts b/src/renderer/src/features/workspace/agentConversationContext.ts new file mode 100644 index 0000000..bda9450 --- /dev/null +++ b/src/renderer/src/features/workspace/agentConversationContext.ts @@ -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) } + ]) + } +} diff --git a/src/renderer/src/features/workspace/useAiAgent.ts b/src/renderer/src/features/workspace/useAiAgent.ts index d6b4a06..3bf4ef2 100644 --- a/src/renderer/src/features/workspace/useAiAgent.ts +++ b/src/renderer/src/features/workspace/useAiAgent.ts @@ -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 @@ -321,6 +324,7 @@ export function useAiAgent({ accessToken, studentEmail, courseId, weekNumber, ap executeTool?: (request: AgentToolRequest) => Promise environment?: 'local' | 'remote' workspaceTarget?: string + workspaceRoot?: string toolResults?: AgentToolResult[] } = {} ): Promise => { @@ -329,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) @@ -383,7 +391,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, ...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 (!currentRequest()) return @@ -401,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,