feat: preserve compact agent context and bump to 1.2.25
This commit is contained in:
@@ -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)
|
||||
})
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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: [] }
|
||||
|
||||
@@ -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'))
|
||||
|
||||
Reference in New Issue
Block a user