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) })