Compare commits

6 Commits

34 changed files with 1700 additions and 240 deletions
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "lms", "name": "lms",
"version": "1.2.21", "version": "1.2.25",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "lms", "name": "lms",
"version": "1.2.21", "version": "1.2.25",
"dependencies": { "dependencies": {
"@electron-toolkit/preload": "^3.0.1", "@electron-toolkit/preload": "^3.0.1",
"@electron-toolkit/utils": "^4.0.0", "@electron-toolkit/utils": "^4.0.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "lms", "name": "lms",
"version": "1.2.21", "version": "1.2.25",
"description": "Autonomy Studio LMS Electron app", "description": "Autonomy Studio LMS Electron app",
"main": "./out/main/index.js", "main": "./out/main/index.js",
"author": { "author": {
+51 -6
View File
@@ -6,6 +6,7 @@ import json
import logging import logging
import math import math
import os import os
import re
import shutil import shutil
import sqlite3 import sqlite3
import struct import struct
@@ -207,6 +208,8 @@ class SlamNode(Node):
self._fallback_point_cloud: Optional[PointCloud2] = None self._fallback_point_cloud: Optional[PointCloud2] = None
self._color_point_cloud: Optional[PointCloud2] = None self._color_point_cloud: Optional[PointCloud2] = None
self._camera_image: Optional[CompressedImage] = None self._camera_image: Optional[CompressedImage] = None
self._camera_frames = {}
self._camera_subscriptions = {}
self._icp_odom: Optional[Odometry] = None self._icp_odom: Optional[Odometry] = None
self._map: Optional[OccupancyGrid] = None self._map: Optional[OccupancyGrid] = None
self._plan: Optional[NavPath] = None self._plan: Optional[NavPath] = None
@@ -261,6 +264,8 @@ class SlamNode(Node):
self.create_subscription(CompressedImage, self.camera_image_topic, self._on_camera_image, qos_profile_sensor_data) self.create_subscription(CompressedImage, self.camera_image_topic, self._on_camera_image, qos_profile_sensor_data)
if not os.environ.get("CAMERA_IMAGE_TOPIC"): if not os.environ.get("CAMERA_IMAGE_TOPIC"):
self.create_subscription(CompressedImage, "/image_raw/compressed", self._on_camera_image, qos_profile_sensor_data) self.create_subscription(CompressedImage, "/image_raw/compressed", self._on_camera_image, qos_profile_sensor_data)
self._camera_discovery_timer = self.create_timer(
1.0, self._discover_cameras, clock=Clock(clock_type=ClockType.STEADY_TIME))
self.create_subscription(Odometry, self.icp_odom_topic, self._on_icp_odom, best_effort_qos) self.create_subscription(Odometry, self.icp_odom_topic, self._on_icp_odom, best_effort_qos)
self.create_subscription(Odometry, self.icp_odom_topic, self._on_icp_odom, reliable_qos) self.create_subscription(Odometry, self.icp_odom_topic, self._on_icp_odom, reliable_qos)
self.create_subscription(OccupancyGrid, self.map_topic, self._on_map, map_qos) self.create_subscription(OccupancyGrid, self.map_topic, self._on_map, map_qos)
@@ -625,11 +630,45 @@ class SlamNode(Node):
self._color_point_cloud_count += 1 self._color_point_cloud_count += 1
self._last_color_point_cloud_wall_time = time.time() self._last_color_point_cloud_wall_time = time.time()
def _on_camera_image(self, msg: CompressedImage): def _discover_cameras(self):
try:
topics = sorted(name for name, types in self.get_topic_names_and_types()
if re.fullmatch(r'/camera_test/cameras/video[0-9]+/image_jpeg', name)
and 'sensor_msgs/msg/CompressedImage' in types)[:8]
except Exception:
return
for topic in topics:
camera_id = topic.split('/')[-2]
if camera_id not in self._camera_subscriptions:
self._camera_subscriptions[camera_id] = self.create_subscription(
CompressedImage, topic,
lambda msg, key=camera_id, name=topic: self._on_camera_image(msg, key, name),
qos_profile_sensor_data)
active = {topic.split('/')[-2] for topic in topics}
for camera_id in list(self._camera_subscriptions):
if camera_id not in active:
self.destroy_subscription(self._camera_subscriptions.pop(camera_id))
with self._lock: with self._lock:
self._camera_frames.pop(camera_id, None)
def _on_camera_image(self, msg: CompressedImage, camera_id='legacy', topic=None):
with self._lock:
if camera_id != 'legacy' and camera_id not in self._camera_subscriptions:
return
self._camera_image = msg self._camera_image = msg
self._camera_image_count += 1 self._camera_image_count += 1
self._last_camera_image_wall_time = time.time() self._last_camera_image_wall_time = time.time()
self._camera_frames[camera_id] = (msg, self._last_camera_image_wall_time,
topic or self.camera_image_topic)
def camera_sources(self):
now = time.time()
with self._lock:
frames = list(self._camera_frames.items())
return [{"id": key, "name": f'/dev/{key}' if key != 'legacy' else 'Camera',
"topic": topic, "available": now - received <= _CAMERA_FRAME_MAX_AGE_SECONDS
and bool(image.data) and 'jpeg' in image.format.lower()}
for key, (image, received, topic) in sorted(frames)]
def _active_point_cloud(self) -> Optional[PointCloud2]: def _active_point_cloud(self) -> Optional[PointCloud2]:
with self._lock: with self._lock:
@@ -687,7 +726,6 @@ class SlamNode(Node):
point_cloud_last = self._last_point_cloud_wall_time point_cloud_last = self._last_point_cloud_wall_time
fallback_point_cloud_last = self._last_fallback_point_cloud_wall_time fallback_point_cloud_last = self._last_fallback_point_cloud_wall_time
color_point_cloud_last = self._last_color_point_cloud_wall_time color_point_cloud_last = self._last_color_point_cloud_wall_time
camera_image_last = self._last_camera_image_wall_time
map_count = self._map_count map_count = self._map_count
plan_count = self._plan_count plan_count = self._plan_count
@@ -727,7 +765,7 @@ class SlamNode(Node):
}, },
"camera_image": { "camera_image": {
"name": self.camera_image_topic, "name": self.camera_image_topic,
"available": received_recently(camera_image_last), "available": any(camera['available'] for camera in self.camera_sources()),
}, },
"plan": {"name": self.plan_topic, "available": plan_available}, "plan": {"name": self.plan_topic, "available": plan_available},
"cmd_vel": {"name": self.cmd_vel_topic, "available": cmd_vel_available}, "cmd_vel": {"name": self.cmd_vel_topic, "available": cmd_vel_available},
@@ -1238,6 +1276,7 @@ class SlamNode(Node):
"sensor_point_cloud": sensor_point_cloud_payload, "sensor_point_cloud": sensor_point_cloud_payload,
"color_point_cloud": color_point_cloud_payload, "color_point_cloud": color_point_cloud_payload,
"camera_image": camera_image_payload, "camera_image": camera_image_payload,
"cameras": self.camera_sources(),
"map": map_payload, "map": map_payload,
"tf": { "tf": {
"map_to_odom": resolve_2d_transform(tf_snapshot, "map", "odom"), "map_to_odom": resolve_2d_transform(tf_snapshot, "map", "odom"),
@@ -1302,8 +1341,14 @@ class SlamNode(Node):
payload["received_at"] = received_at payload["received_at"] = received_at
return payload return payload
def camera_frame_jpeg(self) -> Optional[bytes]: def camera_frame_jpeg(self, camera_id=None) -> Optional[bytes]:
with self._lock: with self._lock:
if camera_id is not None:
entry = self._camera_frames.get(camera_id)
if entry is None:
return None
image, received_at, _topic = entry
else:
image = self._camera_image image = self._camera_image
received_at = self._last_camera_image_wall_time received_at = self._last_camera_image_wall_time
if image is None or received_at is None or time.time() - received_at > _CAMERA_FRAME_MAX_AGE_SECONDS: if image is None or received_at is None or time.time() - received_at > _CAMERA_FRAME_MAX_AGE_SECONDS:
@@ -1531,10 +1576,10 @@ def color_point_cloud():
@app.get("/camera-frame") @app.get("/camera-frame")
def camera_frame(): def camera_frame(camera_id: Optional[str] = None):
if not slam_node: if not slam_node:
raise HTTPException(status_code=503, detail="ROS node is not ready") raise HTTPException(status_code=503, detail="ROS node is not ready")
frame = slam_node.camera_frame_jpeg() frame = slam_node.camera_frame_jpeg(camera_id)
if frame is None: if frame is None:
raise HTTPException(status_code=404, detail="카메라 프레임을 아직 수신하지 못했습니다.") raise HTTPException(status_code=404, detail="카메라 프레임을 아직 수신하지 못했습니다.")
return Response(content=frame, media_type="image/jpeg", headers={"Cache-Control": "no-store"}) return Response(content=frame, media_type="image/jpeg", headers={"Cache-Control": "no-store"})
+67
View File
@@ -0,0 +1,67 @@
import ast
from pathlib import Path
import re
import threading
from types import SimpleNamespace
from typing import Optional
import unittest
class CameraTests(unittest.TestCase):
def setUp(self):
self.now = 10.0
source = ast.parse(Path(__file__).with_name('bridge.py').read_text())
node = next(item for item in source.body if isinstance(item, ast.ClassDef) and item.name == 'SlamNode')
names = ('_discover_cameras', '_on_camera_image', 'camera_sources', 'camera_frame_jpeg')
methods = [item for item in node.body if isinstance(item, ast.FunctionDef) and item.name in names]
scope = {'re': re, 'Optional': Optional, 'CompressedImage': object,
'time': SimpleNamespace(time=lambda: self.now), '_CAMERA_FRAME_MAX_AGE_SECONDS': 3,
'qos_profile_sensor_data': object()}
exec(compile(ast.Module(body=methods, type_ignores=[]), 'camera-bridge', 'exec'), scope)
self.node = type('CameraNode', (), {name: scope[name] for name in names})()
self.node._lock = threading.Lock()
self.node._camera_frames = {}
self.node._camera_subscriptions = {}
self.node._camera_image = None
self.node._last_camera_image_wall_time = None
self.node._camera_image_count = 0
self.node.camera_image_topic = '/camera_test/image_jpeg'
self.topics = []
self.callbacks = {}
self.node.get_topic_names_and_types = lambda: self.topics
def subscribe(kind, topic, callback, qos):
self.callbacks[topic] = callback
return topic
self.node.create_subscription = subscribe
self.node.destroy_subscription = lambda sub: self.callbacks.pop(sub)
def test_independent_frames_and_disconnect(self):
for key in ['video0', 'video2']:
self.topics.append((f'/camera_test/cameras/{key}/image_jpeg', ['sensor_msgs/msg/CompressedImage']))
self.topics += [('/other/image_jpeg', ['sensor_msgs/msg/CompressedImage']),
('/camera_test/cameras/video9/image_jpeg', ['sensor_msgs/msg/Image'])]
self.node._discover_cameras()
self.assertEqual(len(self.callbacks), 2)
for topic, callback in self.callbacks.items():
callback(SimpleNamespace(data=topic.encode(), format='jpeg'))
self.assertEqual([item['id'] for item in self.node.camera_sources()], ['video0', 'video2'])
self.assertIn(b'video2', self.node.camera_frame_jpeg('video2'))
self.assertIsNone(self.node.camera_frame_jpeg('../video0'))
self.now += 4
self.assertTrue(all(not item['available'] for item in self.node.camera_sources()))
self.assertIsNone(self.node.camera_frame_jpeg('video0'))
self.topics = self.topics[:1]
self.node._discover_cameras()
self.assertNotIn('video2', self.node._camera_frames)
def test_single_camera_compatibility_and_non_jpeg(self):
self.node._on_camera_image(SimpleNamespace(data=b'jpeg', format='jpeg'))
self.assertEqual(self.node.camera_frame_jpeg(), b'jpeg')
self.assertEqual(self.node.camera_frame_jpeg('legacy'), b'jpeg')
self.node._on_camera_image(SimpleNamespace(data=b'png', format='png'))
self.assertIsNone(self.node.camera_frame_jpeg('legacy'))
self.assertFalse(self.node.camera_sources()[0]['available'])
if __name__ == '__main__':
unittest.main()
+47 -1
View File
@@ -98,6 +98,51 @@ test('legacy cancelled references can be opened with the same read-only navigati
assert.equal(h.message().codeReferenceStates.location.status, 'opened') assert.equal(h.message().codeReferenceStates.location.status, 'opened')
}) })
test('code navigation does not block or abort a pending model response', async () => {
const h = consentHarness()
const controller = new AbortController()
h.globals.abortControllerRef.current = controller
let opened = 0
await h.openCodeReference('answer', 'location', async (_reference, isCurrent) => {
assert.equal(isCurrent(), true)
opened++
})
assert.equal(opened, 1)
assert.equal(controller.signal.aborted, false)
assert.equal(h.globals.abortControllerRef.current, controller)
assert.equal(h.message().codeReferenceStates.location.status, 'opened')
})
test('an arriving model response does not cancel an in-flight code navigation', async () => {
const h = consentHarness()
h.globals.abortControllerRef.current = new AbortController()
let finish, current
const pending = h.openCodeReference('answer', 'location', (_reference, isCurrent) => {
current = isCurrent
return new Promise(resolve => { finish = resolve })
})
h.globals.messageStateRef.current.messages.push({ id: 'new-answer', role: 'assistant' })
h.globals.abortControllerRef.current = null
assert.equal(current(), true)
finish()
await pending
assert.equal(h.globals.messageStateRef.current.messages[0].codeReferenceStates.location.status, 'opened')
})
test('starting file application invalidates a navigation that is still reading', async () => {
const h = consentHarness()
let finish, current
const pending = h.openCodeReference('answer', 'location', (_reference, isCurrent) => {
current = isCurrent
return new Promise((_resolve, reject) => { finish = () => reject(Error('applying')) })
})
h.globals.applyingRef.current = true
assert.equal(current(), false)
finish()
await pending
assert.equal(h.message().codeReferenceStates.location.status, 'error')
})
test('duplicate clicks, invalid IDs and busy tasks do not open more files', async () => { test('duplicate clicks, invalid IDs and busy tasks do not open more files', async () => {
const h = consentHarness() const h = consentHarness()
let finish, opened = 0 let finish, opened = 0
@@ -143,7 +188,8 @@ test('a new question cancels pending navigation without leaving its old state st
}) })
test('restored interrupted navigation becomes retryable without reopening any file', () => { test('restored interrupted navigation becomes retryable without reopening any file', () => {
const { recoverInterruptedAgentMessages } = compile(fs.readFileSync(require.resolve('../src/renderer/src/features/workspace/agentContext.ts'), 'utf8')) const evidence = compile(fs.readFileSync(require.resolve('../src/renderer/src/features/workspace/agentEvidence.ts'), 'utf8'))
const { recoverInterruptedAgentMessages } = compile(fs.readFileSync(require.resolve('../src/renderer/src/features/workspace/agentContext.ts'), 'utf8'), {}, name => name === './agentEvidence' ? evidence : require(name))
const message = { id: 'answer', role: 'assistant', codeReferenceStates: { pending: { status: 'opening' }, approved: { status: 'opened' }, declined: { status: 'cancelled' } } } const message = { id: 'answer', role: 'assistant', codeReferenceStates: { pending: { status: 'opening' }, approved: { status: 'opened' }, declined: { status: 'cancelled' } } }
const restored = recoverInterruptedAgentMessages([message])[0] const restored = recoverInterruptedAgentMessages([message])[0]
assert.equal(restored.codeReferenceStates.pending.status, 'error') assert.equal(restored.codeReferenceStates.pending.status, 'error')
+5 -1
View File
@@ -5,9 +5,13 @@ const vm = require('node:vm')
const ts = require('typescript') const ts = require('typescript')
const output = {} const output = {}
const evidenceApi = {}
vm.runInNewContext(ts.transpileModule(fs.readFileSync(require.resolve('../src/renderer/src/features/workspace/agentEvidence.ts'), 'utf8'), {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 }
}).outputText, { exports: evidenceApi })
vm.runInNewContext(ts.transpileModule(fs.readFileSync(require.resolve('../src/renderer/src/features/workspace/agentContext.ts'), 'utf8'), { vm.runInNewContext(ts.transpileModule(fs.readFileSync(require.resolve('../src/renderer/src/features/workspace/agentContext.ts'), 'utf8'), {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 } compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 }
}).outputText, { exports: output, DOMException }) }).outputText, { exports: output, DOMException, require: name => { assert.equal(name, './agentEvidence'); return evidenceApi } })
const { resolveAgentContext } = output const { resolveAgentContext } = output
const plain = value => JSON.parse(JSON.stringify(value)) const plain = value => JSON.parse(JSON.stringify(value))
const moduleInfo = (name, files = []) => ({ name, state: 'stopped', files }) const moduleInfo = (name, files = []) => ({ name, state: 'stopped', files })
+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)
})
+143
View File
@@ -0,0 +1,143 @@
const { test } = require('node:test')
const assert = require('node:assert/strict')
const fs = require('node:fs')
const ts = require('typescript')
require.extensions['.ts'] = (module, filename) => module._compile(ts.transpileModule(fs.readFileSync(filename, 'utf8'), {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 }
}).outputText, filename)
const { compactAgentEvidence, missingAgentRead, readFileEvidence } = require('../src/renderer/src/features/workspace/agentEvidence.ts')
const { resolveAgentContext } = require('../src/renderer/src/features/workspace/agentContext.ts')
const file = (startLine, endLine, extra = {}, result = {}) => ({
id: `read-${startLine}-${endLine}`, name: 'files.read', moduleName: 'Control', target: 'ssh:jetson', status: 'ok', observedAt: '2026-10-20T00:00:00.000Z', truncated: true,
data: JSON.stringify({ path: 'config/car.yaml', exists: true, hash: 'a'.repeat(64), content: Array.from({ length: endLine - startLine + 1 }, (_, i) => `line${startLine + i}`).join('\n'),
startLine, endLine, totalLines: 100, complete: false, contentTruncated: false, ...extra }), ...result
})
const read = (startLine, lineCount, path = 'config/car.yaml') => ({ id: 'read', name: 'files.read', moduleName: 'Control', args: { path, ...(startLine === undefined ? {} : { startLine }), ...(lineCount === undefined ? {} : { lineCount }) } })
const answer = toolRequests => ({ provider: 'test', model: 'test', plan: { summary: 'result', explanation: 'verified code', toolRequests, changes: [], commands: [], actionPlan: [], createModule: null } })
test('overlapping and adjacent ranges merge without promoting partial evidence to editable full files', () => {
const results = compactAgentEvidence([file(1, 10), file(8, 15), file(16, 20)])
assert.equal(results.length, 1)
const data = readFileEvidence(results[0])
assert.equal(data.startLine, 1)
assert.equal(data.endLine, 20)
assert.equal(data.content.split('\n').length, 20)
assert.equal(data.complete, false)
assert.equal(results[0].truncated, true)
assert.equal(missingAgentRead(read(3, 16), results), undefined)
assert.deepEqual(missingAgentRead(read(), results), read())
})
test('queries only the first missing range and never joins disjoint or conflicting content', () => {
const results = compactAgentEvidence([file(1, 5), file(10, 15)])
assert.equal(results.length, 2)
assert.deepEqual(missingAgentRead(read(3, 15), results).args, { path: 'config/car.yaml', startLine: 6, lineCount: 4 })
assert.equal(compactAgentEvidence([file(1, 10), file(8, 15, { content: 'different\ncontent' })]).length, 2)
})
test('host boundaries and file revisions cannot be combined or used to fill each other’s gaps', () => {
const changed = compactAgentEvidence([file(1, 10), file(11, 20, { hash: 'b'.repeat(64) })])
assert.equal(changed.length, 1)
assert.equal(missingAgentRead(read(1, 20), changed).args.startLine, 1)
const hosts = compactAgentEvidence([file(1, 10, {}, { target: 'local-host' }), file(11, 20)])
assert.equal(hosts.length, 2)
assert.equal(missingAgentRead(read(1, 20), hosts).args.startLine, 1)
})
test('full and missing observations are reused but partial character fragments are never coverage', () => {
const full = file(1, 10, { complete: true }, { truncated: false })
assert.equal(missingAgentRead(read(5, 100), [full]), undefined)
assert.equal(compactAgentEvidence([full, file(5, 8)]).length, 1)
const missing = file(1, 1, { exists: false, content: '', hash: null, complete: true }, { truncated: false })
assert.equal(missingAgentRead(read(), [missing]), undefined)
const cut = file(1, 2, { content: 'whole\ncut', endLine: 1, contentTruncated: true })
assert.equal(missingAgentRead(read(2, 1), [cut]).args.startLine, 2)
const legacy = file(1, 2, { endLine: undefined, contentTruncated: undefined })
assert.equal(missingAgentRead(read(2, 1), [legacy]).args.startLine, 2)
})
test('malformed or oversized evidence never becomes reusable complete coverage', () => {
assert.equal(readFileEvidence(file(1, 10, { hash: 'not-a-hash' })), undefined)
assert.equal(readFileEvidence(file(1, 10, { endLine: 999 })), undefined)
const large = compactAgentEvidence([file(1, 1, { content: 'a'.repeat(30_000) }), file(2, 2, { content: 'b'.repeat(30_000) })])
assert.equal(large.length, 2)
})
test('overlap requests execute only missing lines and finish once the requested range is cached', async () => {
const executions = []
let calls = 0
await resolveAgentContext({
initialContext: { modules: [], runtimeLogs: [] }, signal: new AbortController().signal,
executeTool: async tool => { executions.push(tool.args); return file(tool.args.startLine, tool.args.startLine + tool.args.lineCount - 1) },
requestPlan: async (context, final) => {
calls++
if (final) {
assert.equal(context.toolResults.length, 1)
assert.equal(JSON.parse(context.toolResults[0].data).endLine, 15)
return answer([])
}
return answer([[read(1, 10)], [read(5, 11)], [read(4, 10)]][calls - 1])
}
})
assert.deepEqual(executions, [read(1, 10).args, read(11, 5).args])
assert.equal(calls, 4)
})
test('source evidence survives many later searches and omissions are reported within DTO limits', async () => {
const searchResults = Array.from({ length: 15 }, (_, i) => ({ ...file(1, 1), id: `search-${i}`, name: 'files.search', data: JSON.stringify({ query: `speed${i}`, matches: [] }) }))
await resolveAgentContext({
initialContext: { modules: [], runtimeLogs: [], toolResults: [file(1, 10), ...searchResults] }, signal: new AbortController().signal,
requestPlan: async context => {
assert.equal(context.toolResults.length, 12)
assert.equal(context.toolResults[0].name, 'files.read')
assert.match(context.contextNotes[0], /4개/)
assert.ok(context.contextNotes.length <= 12)
assert.ok(context.contextNotes.every(note => note.length <= 500))
return answer([])
}
})
})
test('a cached file omitted by budgets is restored when the model requests it again', async () => {
let calls = 0
const results = Array.from({ length: 13 }, (_, i) => file(1, 10, { path: `file${i}.yaml` }))
await resolveAgentContext({
initialContext: { modules: [], runtimeLogs: [], toolResults: results }, signal: new AbortController().signal,
executeTool: async () => { throw Error('cached file must not be queried again') },
requestPlan: async (context, final) => {
calls++
if (!final) return answer([read(1, 5, 'file0.yaml')])
assert.ok(context.toolResults.some(result => JSON.parse(result.data).path === 'file0.yaml'))
return answer([])
}
})
assert.equal(calls, 2)
})
test('finalization retains verified code locations while stripping all proposed mutations', async () => {
const reference = { id: 'code-1', moduleName: 'Control', path: 'config/car.yaml', startLine: 1, endLine: 3, reason: 'verified', expectedHash: 'a'.repeat(64), snippet: 'line1' }
const result = await resolveAgentContext({
initialContext: { modules: [], runtimeLogs: [], toolResults: [file(1, 10)] }, signal: new AbortController().signal,
executeTool: async () => { throw Error('already cached') },
requestPlan: async (_, final) => ({ ...answer([read(1, 5)]), plan: { ...answer([read(1, 5)]).plan, ...(final ? { changes: [{ path: 'unsafe' }], commands: [{ command: 'unsafe' }] } : { codeReferences: [reference] }) } })
})
assert.equal(result.plan.codeReferences.length, 1)
assert.match(result.plan.explanation, /verified code/)
assert.deepEqual(result.plan.changes, [])
assert.deepEqual(result.plan.commands, [])
})
test('recovery never resurrects code locations after a different file revision was observed', async () => {
let calls = 0
const reference = { id: 'code-1', moduleName: 'Control', path: 'config/car.yaml', startLine: 1, endLine: 3, reason: 'verified', expectedHash: 'a'.repeat(64), snippet: 'line1' }
const result = await resolveAgentContext({
initialContext: { modules: [], runtimeLogs: [], toolResults: [file(1, 10)] }, signal: new AbortController().signal,
executeTool: async () => file(11, 20, { hash: 'b'.repeat(64) }),
requestPlan: async (_, final) => {
calls++
return !final && calls === 1 ? { ...answer([read(11, 10)]), plan: { ...answer([read(11, 10)]).plan, codeReferences: [reference] } } : answer([read(11, 10)])
}
})
assert.deepEqual(result.plan.codeReferences, [])
assert.match(result.plan.summary, /확정하지 못했습니다/)
})
+99
View File
@@ -0,0 +1,99 @@
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 React = require('react')
function compile(path, overrides = {}) {
const exports = {}
const source = fs.readFileSync(require.resolve(path), 'utf8')
vm.runInNewContext(ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, jsx: ts.JsxEmit.ReactJSX } }).outputText,
{ exports, document: { body: {} }, require: name => overrides[name] ?? require(name) })
return exports
}
const models = compile('../src/renderer/src/features/workspace/agentModels.ts')
const options = [{ provider: 'OPENAI', model: 'gpt-6.1-sol', imagesEnabled: false }, { provider: 'QWEN', model: 'qwen3.8-flash', imagesEnabled: true }]
const plain = value => JSON.parse(JSON.stringify(value))
test('normalizes enabled models, validates IDs and preserves old single-model servers', () => {
const status = models.normalizeAgentStatus({ enabled: true, models: [options[1], options[0], options[0], { provider: 'FORGED', model: 'bad' }, { provider: 'GEMINI', model: '' }] })
assert.deepEqual(plain(status.models), options)
assert.equal(status.selectionSupported, true)
assert.deepEqual(plain(models.normalizeAgentStatus({ enabled: true, ...options[1] }).models), [options[1]])
assert.equal(models.normalizeAgentStatus({ enabled: true, ...options[1] }).selectionSupported, false)
assert.equal(models.normalizeAgentStatus({ enabled: false, models: options }).models.length, 0)
assert.equal(models.normalizeAgentStatus({ enabled: true, models: [] }).enabled, false)
})
test('selects the saved provider only while it is available, and never invents one', () => {
assert.equal(models.selectedAgentModel(options, 'QWEN'), options[1])
assert.equal(models.selectedAgentModel(options, 'ANTHROPIC'), options[0])
assert.equal(models.selectedAgentModel([options[0]], 'QWEN'), options[0])
assert.equal(models.selectedAgentModel([], 'QWEN'), null)
})
function elements(root, predicate) {
if (!React.isValidElement(root)) return []
return [...(predicate(root) ? [root] : []), ...React.Children.toArray(root.props.children).flatMap(child => elements(child, predicate))]
}
function selectorHarness(props) {
const state = [], refs = []
let stateIndex = 0, refIndex = 0
const { AgentModelSelector } = compile('../src/renderer/src/components/AgentModelSelector.tsx', {
'../i18n': { t: value => value }, '../features/workspace/agentModels': models,
'react-dom': { createPortal: child => child },
react: { ...React, useId: () => 'model-menu', useEffect() {}, useLayoutEffect() {},
useState(initial) { const index = stateIndex++; if (!(index in state)) state[index] = initial; return [state[index], value => { state[index] = typeof value === 'function' ? value(state[index]) : value }] },
useRef(initial) { return refs[refIndex++] ?? (refs[refIndex - 1] = { current: initial }) }
}
})
return { render(patch = {}) { stateIndex = 0; refIndex = 0; return AgentModelSelector({ ...props, ...patch }) } }
}
for (const dark of [true, false]) test(`model control lists only allowed providers and follows the theme (${dark})`, () => {
let selected
const h = selectorHarness({ models: options, provider: 'QWEN', dark, disabled: false, onChange: value => { selected = value } })
const tree = h.render()
const trigger = elements(tree, node => node.type === 'button')[0]
assert.equal(trigger.props['aria-haspopup'], 'menu')
assert.equal(trigger.props['aria-expanded'], false)
assert.equal(trigger.props.title, 'Qwen · qwen3.8-flash')
assert.equal(elements(tree, node => node.props.role === 'menu').length, 0)
assert.equal(elements(tree, node => node.type === 'select').length, 0)
trigger.props.onClick()
const menu = elements(h.render(), node => node.props.role === 'menu')[0]
assert.match(menu.props.className, dark ? /bg-\[#252526\]/ : /bg-white/)
assert.match(menu.props.className, /fixed/)
const items = elements(menu, node => node.props.role === 'menuitemradio')
assert.deepEqual(items.map(node => node.props.title), ['OpenAI · gpt-6.1-sol', 'Qwen · qwen3.8-flash'])
assert.deepEqual(items.map(node => node.props['aria-checked']), [false, true])
assert.ok(items.every(node => elements(node, item => item.type === 'span' && item.props.className.includes('truncate')).length === 1))
items[0].props.onClick()
assert.equal(selected, 'OPENAI')
assert.equal(elements(h.render(), node => node.props.role === 'menu').length, 0)
assert.ok(elements(h.render({ disabled: true }), node => node.type === 'button')[0].props.disabled)
const empty = elements(h.render({ models: [], provider: null }), node => node.type === 'button')[0]
assert.ok(empty.props.disabled)
assert.equal(empty.props.title, '사용 가능한 모델 없음')
})
test('model menu opens from arrow keys and closes when a request blocks the selector', () => {
const h = selectorHarness({ models: options, provider: 'QWEN', dark: true, disabled: false, onChange() {} })
const trigger = elements(h.render(), node => node.type === 'button')[0]
trigger.props.onKeyDown({ key: 'ArrowUp', preventDefault() {} })
assert.equal(elements(h.render(), node => node.props.role === 'menu').length, 1)
assert.equal(elements(h.render({ disabled: true }), node => node.props.role === 'menu').length, 0)
})
test('input footer places the model action before the attachment and clear controls', () => {
const source = fs.readFileSync(require.resolve('../src/renderer/src/components/ChatWidgets.tsx'), 'utf8')
const row = source.slice(source.indexOf('{(attachmentsEnabled || onClearConversation || footerAction)'))
assert.ok(row.indexOf('{footerAction}') < row.indexOf('title={t(\'파일 첨부\')}'))
assert.ok(row.indexOf('{footerAction}') < row.indexOf('title={t(\'대화내역 삭제\')}'))
})
test('A-Copilot has a single managed label', () => {
assert.equal(models.agentModelLabel({ provider: 'A_COPILOT', model: 'A-Copilot', imagesEnabled: false }), 'A-Copilot')
assert.equal(models.agentModelLabel(options[0]), 'OpenAI · gpt-6.1-sol')
})
+175 -7
View File
@@ -11,7 +11,11 @@ function compile(source, globals = {}) {
}).outputText, { exports, DOMException, Error, ...globals }) }).outputText, { exports, DOMException, Error, ...globals })
return exports return exports
} }
const context = compile(fs.readFileSync(require.resolve('../src/renderer/src/features/workspace/agentContext.ts'), 'utf8')) const evidenceApi = compile(fs.readFileSync(require.resolve('../src/renderer/src/features/workspace/agentEvidence.ts'), 'utf8'))
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 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 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))) const functions = hook.body.statements.filter(node => ts.isVariableStatement(node) && ['requestPlan', 'stop'].includes(node.declarationList.declarations[0].name.getText(source)))
@@ -26,22 +30,110 @@ function harness(requestApi) {
let now = 1000 let now = 1000
class Clock extends Date { static now() { return now } } class Clock extends Date { static now() { return now } }
const state = { pending: false, activity: '', steps: [], messages: [], saved: [], notices: 0, requestStartedAt: null } 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'), { const api = compile(functions.map(node => `export ${node.getText(source)}`).join('\n'), {
...context, session, messageStateRef, AbortController, ...context, ...conversation, session, messageStateRef, AbortController, FormData,
Date: Clock, Date: Clock,
courseId: 'course', weekNumber: 3, pending: false, historyLoading: false, courseId: 'course', weekNumber: 3, pending: false, historyLoading: false, statusLoading: false,
status: { enabled: true }, abortControllerRef: { current: null }, selectedModel,
status: { enabled: true, selectionSupported: true }, abortControllerRef: { current: null },
setPending: value => { state.pending = value }, setPending: value => { state.pending = value },
setRequestStartedAt: value => { state.requestStartedAt = value }, setRequestStartedAt: value => { state.requestStartedAt = value },
setActivity: value => { state.activity = value }, setActivity: value => { state.activity = value },
setProgressSteps: value => { state.steps = typeof value === 'function' ? value(state.steps) : 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 onResponseRef: { current: () => { state.notices++ } }, requestApi
}) })
return { ...api, state, messageStateRef, advance: ms => { now += ms } } 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 () => { test('publishes completed stages immediately without persisting progress or adding model calls', async () => {
const first = deferred(), tool = deferred(), final = deferred() const first = deferred(), tool = deferred(), final = deferred()
let calls = 0 let calls = 0
@@ -76,6 +168,82 @@ test('publishes completed stages immediately without persisting progress or addi
assert.equal(h.state.notices, 1) assert.equal(h.state.notices, 1)
}) })
test('image uploads and every lookup round retain the original provider without uploading the image twice', async () => {
const bodies = []
let uploads = 0
const h = harness(async (path, request) => {
if (path.endsWith('/attachments/images')) {
uploads++
assert.equal(request.body.get('provider'), 'QWEN')
assert.equal(request.body.get('courseId'), 'course')
assert.equal(request.body.get('week'), '3')
assert.equal(request.body.get('file').name, 'screen.png')
return { token: 'image-reference', name: 'screen.png', url: 'https://images.test/screen.png' }
}
bodies.push(JSON.parse(request.body))
h.selectedModel.provider = 'OPENAI'
return bodies.length === 1
? { ...response(), plan: { ...response().plan, requestedModules: ['Control'] } }
: response()
})
await h.requestPlan('이미지 확인', [{ name: 'Control', files: [] }], [], {
attachments: [{ kind: 'image', name: 'screen.png', file: new Blob(['fixture'], { type: 'image/png' }) }],
loadRequestedContext: async () => ({ modules: [{ name: 'Control', files: [{ path: 'config/control.yaml', content: 'speed: 0.3' }] }], runtimeLogs: [] })
})
assert.equal(uploads, 1)
assert.equal(bodies.length, 2)
assert.ok(bodies.every(body => body.provider === 'QWEN'))
assert.ok(bodies.every(body => body.imageTokens[0] === 'image-reference'))
assert.equal(h.state.messages.at(-1).model, 'fixture')
})
test('remote target and observed source ranges reach each provider request including finalization', async () => {
const bodies = []
const tool = { id: 'read', name: 'files.read', moduleName: 'Control', args: { path: 'config/car.yaml', startLine: 20, lineCount: 1 } }
const h = harness(async (_, request) => {
bodies.push(JSON.parse(request.body))
return bodies.length < 3 ? response([tool]) : response()
})
await h.requestPlan('속도 제한 코드 위치', [{ name: 'Control', state: 'running', parameters: ['sim_mode:=false'], files: [] }], [], {
environment: 'remote', workspaceTarget: 'ssh:100.64.0.10',
executeTool: async input => result({ id: input.id, moduleName: 'Control', target: 'ssh:100.64.0.10', truncated: true,
data: JSON.stringify({ path: 'config/car.yaml', exists: true, hash: 'a'.repeat(64), complete: false, content: 'max_speed: 0.4', startLine: 20, endLine: 20, contentTruncated: false, totalLines: 100 }) })
})
assert.equal(bodies.length, 3)
for (const body of bodies) {
assert.equal(body.environment, 'remote')
assert.equal(body.workspaceTarget, 'ssh:100.64.0.10')
assert.deepEqual(body.modules[0].parameters, ['sim_mode:=false'])
}
assert.equal(JSON.parse(bodies[1].toolResults[0].data).content, 'max_speed: 0.4')
assert.equal(JSON.parse(bodies[2].toolResults[0].data).endLine, 20)
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 () => { test('errors replace temporary progress with the existing error reply', async () => {
let calls = 0 let calls = 0
const h = harness(async () => { const h = harness(async () => {
+21 -5
View File
@@ -4,9 +4,13 @@ const fs = require('node:fs')
const vm = require('node:vm') const vm = require('node:vm')
const ts = require('typescript') const ts = require('typescript')
const contextApi = {} const contextApi = {}
const evidenceApi = {}
vm.runInNewContext(ts.transpileModule(fs.readFileSync(require.resolve('../src/renderer/src/features/workspace/agentEvidence.ts'), 'utf8'), {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 }
}).outputText, { exports: evidenceApi })
vm.runInNewContext(ts.transpileModule(fs.readFileSync(require.resolve('../src/renderer/src/features/workspace/agentContext.ts'), 'utf8'), { vm.runInNewContext(ts.transpileModule(fs.readFileSync(require.resolve('../src/renderer/src/features/workspace/agentContext.ts'), 'utf8'), {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 } compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 }
}).outputText, { exports: contextApi, DOMException }) }).outputText, { exports: contextApi, DOMException, require: name => { assert.equal(name, './agentEvidence'); return evidenceApi } })
const source = ts.createSourceFile('useAiAgent.ts', fs.readFileSync(require.resolve('../src/renderer/src/features/workspace/useAiAgent.ts'), 'utf8'), ts.ScriptTarget.Latest, true) const source = ts.createSourceFile('useAiAgent.ts', fs.readFileSync(require.resolve('../src/renderer/src/features/workspace/useAiAgent.ts'), 'utf8'), ts.ScriptTarget.Latest, true)
let payloadExpression let payloadExpression
@@ -28,15 +32,16 @@ test('user messages preserve only typed text while attachments remain separate m
find(source) find(source)
assert.ok(initializer) assert.ok(initializer)
for (const request of ['이 이미지에서 슬램에 필요한 모듈만 골라봐', '첫 줄\n첨부 파일: 직접 입력한 내용']) { 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) assert.equal(message.content, request)
} }
}) })
function serialize(attachments, finalize = false, request = '설정 위치 알려줘', imageTokens = []) { function serialize(attachments, finalize = false, request = '설정 위치 알려줘', imageTokens = [], provider = 'QWEN') {
return JSON.parse(vm.runInNewContext(payloadExpression, { return JSON.parse(vm.runInNewContext(payloadExpression, {
courseId: 'course-1', weekNumber: 3, request, options: { attachments }, finalize, courseId: 'course-1', weekNumber: 3, request, options: { attachments }, finalize,
textAttachments: attachments ?? [], imageTokens, textAttachments: attachments ?? [], imageTokens, conversationContext: undefined,
selection: provider ? { provider } : {},
agentPlanRequestText: contextApi.agentPlanRequestText, agentPlanRequestText: contextApi.agentPlanRequestText,
context: { modules: [{ name: 'Control', files: [] }], runtimeLogs: [] } context: { modules: [{ name: 'Control', files: [] }], runtimeLogs: [] }
})) }))
@@ -70,7 +75,7 @@ test('finalization instruction reaches the API without sending unsupported DTO f
assert.match(body.request, /설정 위치 알려줘/) assert.match(body.request, /설정 위치 알려줘/)
assert.match(body.request, /최종 답변/) assert.match(body.request, /최종 답변/)
assert.equal(Object.hasOwn(body, 'finalize'), false) assert.equal(Object.hasOwn(body, 'finalize'), false)
assert.deepEqual(Object.keys(body).sort(), ['courseId', 'modules', 'request', 'runtimeLogs', 'week']) assert.deepEqual(Object.keys(body).sort(), ['courseId', 'modules', 'provider', 'request', 'runtimeLogs', 'week'])
}) })
test('finalization keeps the request within the backend 4000-character limit', () => { test('finalization keeps the request within the backend 4000-character limit', () => {
@@ -78,3 +83,14 @@ test('finalization keeps the request within the backend 4000-character limit', (
assert.equal(body.request.length, 4000) assert.equal(body.request.length, 4000)
assert.match(body.request, /원래 학생 질문/) assert.match(body.request, /원래 학생 질문/)
}) })
test('sends only a provider selection rather than student-supplied models or keys', () => {
for (const finalize of [false, true]) {
const body = serialize(undefined, finalize, '확인', [], 'OPENAI')
assert.equal(body.provider, 'OPENAI')
assert.equal(Object.hasOwn(body, 'model'), false)
assert.equal(Object.hasOwn(body, 'apiKey'), false)
assert.equal(Object.hasOwn(body, 'endpointUrl'), false)
}
assert.equal(Object.hasOwn(serialize(undefined, false, '확인', [], null), 'provider'), false)
})
+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 })) 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', () => { 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) const html = renderResponse({ content: '분석 결과', provider: 'A_COPILOT', model: 'A-Copilot' }, 0)
assert.ok(html.includes('A-Copilot')) assert.ok(html.includes('A-Copilot'))
+15
View File
@@ -63,15 +63,30 @@ for (const backend of ['local', 'remote']) {
const full = await read(root, request('files.read', { path: 'config/sensor.yaml' })) const full = await read(root, request('files.read', { path: 'config/sensor.yaml' }))
assert.equal(full.data.hash, agentFileHash(await fs.readFile(path.join(root, 'config/sensor.yaml'), 'utf8'))) assert.equal(full.data.hash, agentFileHash(await fs.readFile(path.join(root, 'config/sensor.yaml'), 'utf8')))
assert.equal(full.data.complete, true) assert.equal(full.data.complete, true)
const rangedFull = await read(root, request('files.read', { path: 'config/sensor.yaml', startLine: 1, lineCount: 200 }))
assert.equal(rangedFull.data.complete, true)
assert.equal(rangedFull.truncated, false)
assert.equal(rangedFull.data.endLine, 3)
assert.equal(rangedFull.data.contentTruncated, false)
const partial = await read(root, request('files.read', { path: 'config/sensor.yaml', startLine: 2, lineCount: 1 })) const partial = await read(root, request('files.read', { path: 'config/sensor.yaml', startLine: 2, lineCount: 1 }))
assert.equal(partial.data.content, '조향: ackermann') assert.equal(partial.data.content, '조향: ackermann')
assert.equal(partial.data.complete, false) assert.equal(partial.data.complete, false)
assert.equal(partial.truncated, true) assert.equal(partial.truncated, true)
assert.equal(partial.data.endLine, 2)
assert.equal(partial.data.contentTruncated, false)
const missing = await read(root, request('files.read', { path: 'config/new.yaml' })) const missing = await read(root, request('files.read', { path: 'config/new.yaml' }))
assert.equal(missing.data.exists, false) assert.equal(missing.data.exists, false)
assert.equal(missing.data.hash, null) assert.equal(missing.data.hash, null)
await assert.rejects(read(root, request('files.read', { path: 'missing/new.yaml' }))) await assert.rejects(read(root, request('files.read', { path: 'missing/new.yaml' })))
}) })
test(`${backend}: character truncation never advertises a cut line as fully observed`, async t => {
const root = await fixture(t)
await fs.writeFile(path.join(root, 'config/long.yaml'), `first: true\n${'x'.repeat(40_000)}\nlast: true`)
const readout = await read(root, request('files.read', { path: 'config/long.yaml', startLine: 1, lineCount: 3 }))
assert.equal(readout.data.complete, false)
assert.equal(readout.data.contentTruncated, true)
assert.equal(readout.data.endLine, 1)
})
test(`${backend}: a scoped search does not consume its budget in unrelated source trees`, async t => { test(`${backend}: a scoped search does not consume its budget in unrelated source trees`, async t => {
const root = await fixture(t) const root = await fixture(t)
await fs.mkdir(path.join(root, 'code')) await fs.mkdir(path.join(root, 'code'))
+77
View File
@@ -0,0 +1,77 @@
const { test } = require('node:test')
const assert = require('node:assert/strict')
const fs = require('node:fs')
const ts = require('typescript')
const vm = require('node:vm')
const app = ts.createSourceFile('App.tsx', fs.readFileSync(require.resolve('../src/renderer/src/App.tsx'), 'utf8'), ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX)
const declarations = new Map()
function visit(node) {
if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name)) declarations.set(node.name.text, node.initializer?.getText(app))
ts.forEachChild(node, visit)
}
visit(app)
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'] }]
const readTool = backend => async (...args) => { executions.push({ backend, args: plain(args) }); return { status: 'ok', data: '{"content":"remote source"}' } }
const exports = {}
const globals = {
exports, remoteWorkspace, availableModules: [{ ...live[0], state: 'stopped', parameters: ['sim_mode:=true'] }],
clonePathPreview: '/local/workspace', runtimeModules: [], agentActionControllerRef: { current: null }, agentApplying: false,
getRemoteWorkspaceTarget: () => target, applyModuleParameterOverrides: modules => modules,
validateAgentTool: request => request, AGENT_PRACTICE_PROFILES: [],
window: {
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))
},
onTitlebarNotice: (...args) => { errors.push(args) }, console: { error: () => {} },
loadAgentRequestedContext: async () => ({ modules: [], runtimeLogs: [] })
}
const names = ['collectAgentWorkspace', 'readAgentModules', 'executeWorkspaceAgentTool', 'submitAgentRequest']
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, contexts, events, errors }
}
for (const remote of [false, true]) {
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 [, 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), [])
assert.equal(options.environment, remote ? 'remote' : 'local')
assert.equal(options.workspaceTarget, remote ? 'ssh:100.64.0.10' : 'local-host')
})
test(`${remote ? 'remote' : 'local'}: source reads use only the connected workspace transport and module folder`, async () => {
const h = harness(remote)
const tool = { id: 'read-1', name: 'files.read', moduleName: 'NAV2-MKMini', args: { path: 'src/profiles.py', startLine: 10, lineCount: 20 } }
await h.executeWorkspaceAgentTool(tool)
assert.equal(h.executions.length, 1)
assert.equal(h.executions[0].backend, remote ? 'remote' : 'local')
const args = h.executions[0].args
assert.deepEqual(args[remote ? 1 : 0], tool)
assert.equal(args[2], 'vehicle')
assert.equal(remote ? args[0].host : args[1], remote ? '100.64.0.10' : '/local/workspace')
})
}
+23
View File
@@ -22,6 +22,7 @@ const panelSource = ts.createSourceFile('WorkspaceQaPanel.tsx', fs.readFileSync(
const declarations = panelSource.statements.filter(node => ts.isFunctionDeclaration(node) && ['AiMessages', 'AgentMessages', 'TeacherMessages'].includes(node.name?.text)) const declarations = panelSource.statements.filter(node => ts.isFunctionDeclaration(node) && ['AiMessages', 'AgentMessages', 'TeacherMessages'].includes(node.name?.text))
const ProgressiveChatResponse = props => React.createElement('div', { 'data-reveal-start': props.message.revealStartedAt, 'data-collapsed': props.defaultCollapsed }, props.message.content) const ProgressiveChatResponse = props => React.createElement('div', { 'data-reveal-start': props.message.revealStartedAt, 'data-collapsed': props.defaultCollapsed }, props.message.content)
const ChatImageAttachments = props => React.createElement('div', { 'data-chat-image-attachments': props.variant }, props.images.map(image => React.createElement('img', { key: image.url, src: image.url, alt: image.name }))) const ChatImageAttachments = props => React.createElement('div', { 'data-chat-image-attachments': props.variant }, props.images.map(image => React.createElement('img', { key: image.url, src: image.url, alt: image.name })))
const AgentCodeReferences = () => null
const { AgentWorkDuration } = compile(fs.readFileSync(require.resolve('../src/renderer/src/components/AgentWorkDuration.tsx'), 'utf8'), { const { AgentWorkDuration } = compile(fs.readFileSync(require.resolve('../src/renderer/src/components/AgentWorkDuration.tsx'), 'utf8'), {
'../i18n': { t: (key, values = {}) => key.replace(/\{\{(\w+)\}\}/g, (_, name) => values[name] ?? ''), useLocaleRender() {} } '../i18n': { t: (key, values = {}) => key.replace(/\{\{(\w+)\}\}/g, (_, name) => values[name] ?? ''), useLocaleRender() {} }
}) })
@@ -36,6 +37,7 @@ const messages = compile(declarations.map(node => `export ${node.getText(panelSo
ChatEmptyState: widgets.ChatEmptyState, ChatEmptyState: widgets.ChatEmptyState,
ProgressiveChatResponse, ProgressiveChatResponse,
ChatImageAttachments, ChatImageAttachments,
AgentCodeReferences,
AgentProgressBubble, AgentProgressBubble,
AgentWorkDuration, AgentWorkDuration,
MessageCopyButton: () => null, MessageCopyButton: () => null,
@@ -43,6 +45,27 @@ const messages = compile(declarations.map(node => `export ${node.getText(panelSo
Bot: () => React.createElement('svg'), WandSparkles: () => React.createElement('svg') Bot: () => React.createElement('svg'), WandSparkles: () => React.createElement('svg')
}) })
test('existing code locations stay usable during model and command work but not file application', () => {
for (const applying of [false, true]) {
const onOpen = () => {}
const tree = messages.AgentMessages({
dark: true, enabled: true, statusLoading: false, pending: true, busy: true,
messages: [{ id: 'answer', role: 'assistant', content: '코드 위치', applyState: applying ? 'applying' : undefined, plan: { codeReferences: [{ id: 'code-1' }], changes: [], commands: [], actionPlan: [], createModule: null } }],
onOpenCodeReference: onOpen
})
let references
function visit(element) {
if (!React.isValidElement(element)) return
if (element.type === AgentCodeReferences) references = element
React.Children.toArray(element.props.children).forEach(visit)
}
visit(tree)
assert.ok(references)
assert.equal(references.props.busy, applying)
assert.equal(typeof references.props.onOpen, 'function')
}
})
test('agent image thumbnails are siblings below the user bubble rather than bubble contents', () => { test('agent image thumbnails are siblings below the user bubble rather than bubble contents', () => {
const tree = messages.AgentMessages({ const tree = messages.AgentMessages({
dark: true, enabled: true, statusLoading: false, pending: false, dark: true, enabled: true, statusLoading: false, pending: false,
+1
View File
@@ -42,6 +42,7 @@ function setup(overrides = {}, browser = {}) {
'../../components/AgentProgressBubble': {}, '../../components/AgentProgressBubble': {},
'../../components/AgentWorkDuration': {}, '../../components/AgentWorkDuration': {},
'../../components/AgentCodeReferences': {}, '../../components/AgentCodeReferences': {},
'../../components/AgentModelSelector': { AgentModelSelector: () => null },
'react-dom': { createPortal: child => child }, 'react-dom': { createPortal: child => child },
react: { react: {
...React, ...React,
+7 -3
View File
@@ -43,10 +43,14 @@ export async function runAgentFileTool(root: string, request: AgentToolRequest):
try { try {
const content = await readText(root, path) const content = await readText(root, path)
const lines = content.split('\n') const lines = content.split('\n')
const partial = args.startLine !== undefined || args.lineCount !== undefined || content.length > 40_000
const startLine = args.startLine ?? 1 const startLine = args.startLine ?? 1
const selected = partial ? lines.slice(startLine - 1, startLine - 1 + (args.lineCount ?? 200)).join('\n').slice(0, 40_000) : content const ranged = args.startLine !== undefined || args.lineCount !== undefined || content.length > 40_000
return { data: { path, exists: true, hash: agentFileHash(content), content: selected, complete: !partial, startLine, totalLines: lines.length }, truncated: partial } const selection = ranged ? lines.slice(startLine - 1, startLine - 1 + (args.lineCount ?? 200)).join('\n') : content
const selected = selection.slice(0, 40_000)
const contentTruncated = selected.length < selection.length
const complete = startLine === 1 && selected === content
const endLine = Math.min(lines.length, startLine + selected.split('\n').length - 1 - Number(contentTruncated))
return { data: { path, exists: true, hash: agentFileHash(content), content: selected, complete, startLine, endLine, contentTruncated, totalLines: lines.length }, truncated: !complete }
} catch (error) { } catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
// A missing leaf in an existing directory is evidence for a new file. // A missing leaf in an existing directory is evidence for a new file.
+8 -3
View File
@@ -116,10 +116,15 @@ else:
try: try:
content = read(root, path) content = read(root, path)
lines = content.split('\n') lines = content.split('\n')
partial = 'startLine' in options or 'lineCount' in options or len(content) > 40000 ranged = 'startLine' in options or 'lineCount' in options or len(content) > 40000
start = options.get('startLine', 1) start = options.get('startLine', 1)
selected = '\n'.join(lines[start-1:start-1+options.get('lineCount', 200)])[:40000] if partial else content selection = '\n'.join(lines[start-1:start-1+options.get('lineCount', 200)]) if ranged else content
data = {'path': path, 'exists': True, 'hash': digest(content), 'content': selected, 'complete': not partial, 'startLine': start, 'totalLines': len(lines)} selected = selection[:40000]
content_truncated = len(selected) < len(selection)
complete = start == 1 and selected == content
partial = not complete
end = min(len(lines), start + len(selected.split('\n')) - 1 - int(content_truncated))
data = {'path': path, 'exists': True, 'hash': digest(content), 'content': selected, 'complete': complete, 'startLine': start, 'endLine': end, 'contentTruncated': content_truncated, 'totalLines': len(lines)}
except FileNotFoundError: except FileNotFoundError:
checked(root, path, True) checked(root, path, True)
data, partial = {'path': path, 'exists': False, 'hash': None, 'content': '', 'complete': True}, False data, partial = {'path': path, 'exists': False, 'hash': None, 'content': '', 'complete': True}, False
+29 -2
View File
@@ -2563,6 +2563,23 @@ async function startRemoteVisualizationBridge(
].join('\n'), 900_000) ].join('\n'), 900_000)
} }
const command = bridgeContainerCommand(stack) const command = bridgeContainerCommand(stack)
let bridgeContextRevision: string | null = null
if (stack !== 'autoware') {
const context = resolveResourceContextPath('resources/slam-ros-bridge')
if (!context) throw new Error('Bundled SLAM bridge source is missing')
const files = { 'bridge.py': await readFile(join(context, 'bridge.py'), 'utf8'), 'requirements.txt': await readFile(join(context, 'requirements.txt'), 'utf8') }
bridgeContextRevision = createHash('sha256').update(JSON.stringify(files)).digest('hex')
await runUnqueuedSshOutput(target, `python3 -c ${quoteShellSingle([
'import json, os, pathlib, sys',
'data = json.load(sys.stdin)',
'root = pathlib.Path.home() / ".autonomy-studio/runtime/bridges" / data["revision"]',
'root.mkdir(parents=True, exist_ok=True)',
'for name in ("bridge.py", "requirements.txt"):',
' temporary = root / ("." + name + "." + str(os.getpid()) + ".tmp")',
' temporary.write_text(data["files"][name], encoding="utf-8")',
' temporary.replace(root / name)',
].join('\n'))}`, 30_000, JSON.stringify({ revision: bridgeContextRevision, files }))
}
reportVisualizationProgress('시각화 시작 중...') reportVisualizationProgress('시각화 시작 중...')
@@ -2580,6 +2597,7 @@ async function startRemoteVisualizationBridge(
'docker run -d --name "$CONTAINER" --net=host', 'docker run -d --name "$CONTAINER" --net=host',
'-v /dev/shm:/dev/shm', '-v /dev/shm:/dev/shm',
'-v "$ROOT:/workspace"', '-v "$ROOT:/workspace"',
...(bridgeContextRevision ? [`-v "$HOME/.autonomy-studio/runtime/bridges/${bridgeContextRevision}:/app:ro"`] : []),
...(fastDdsConfigPath ? ['-v "$FASTDDS_CONFIG_PATH:/fastdds_profiles.xml:ro"', '-e FASTDDS_DEFAULT_PROFILES_FILE=/fastdds_profiles.xml', '-e FASTRTPS_DEFAULT_PROFILES_FILE=/fastdds_profiles.xml'] : []), ...(fastDdsConfigPath ? ['-v "$FASTDDS_CONFIG_PATH:/fastdds_profiles.xml:ro"', '-e FASTDDS_DEFAULT_PROFILES_FILE=/fastdds_profiles.xml', '-e FASTRTPS_DEFAULT_PROFILES_FILE=/fastdds_profiles.xml'] : []),
'-e "ROS_DOMAIN_ID=$ROS_DOMAIN_ID"', '-e "ROS_DOMAIN_ID=$ROS_DOMAIN_ID"',
'-e ROS_LOCALHOST_ONLY=0', '-e ROS_LOCALHOST_ONLY=0',
@@ -4777,10 +4795,19 @@ function buildNav2DockerRunScript(): string {
'test -f "$MODULE_ROOT/$MODULE_FOLDER/install/setup.bash" || { echo "Module is not built: $MODULE_FOLDER" >&2; exit 1; }', 'test -f "$MODULE_ROOT/$MODULE_FOLDER/install/setup.bash" || { echo "Module is not built: $MODULE_FOLDER" >&2; exit 1; }',
...yahboomDevicePreflight(), ...yahboomDevicePreflight(),
'CAMERA_DEVICE=""', 'CAMERA_DEVICE=""',
'CAMERA_DEVICES=()',
'if [[ "$MODULE_FOLDER" == *lms-nav2-camera-test* ]]; then', 'if [[ "$MODULE_FOLDER" == *lms-nav2-camera-test* ]]; then',
' CAMERA_DEVICE=/dev/video0', ' CAMERA_DEVICE=/dev/video0',
' for arg in "$@"; do case "$arg" in video_device:=*) CAMERA_DEVICE=${arg#video_device:=} ;; esac; done', ' for arg in "$@"; do case "$arg" in video_device:=*) CAMERA_DEVICE=${arg#video_device:=} ;; esac; done',
' [[ "$CAMERA_DEVICE" =~ ^/dev/video[0-9]+$ ]] && [ -c "$CAMERA_DEVICE" ] || { echo "Camera device not found: $CAMERA_DEVICE" >&2; exit 1; }', ' if [ "$CAMERA_DEVICE" = /dev ]; then',
' CAMERA_DISCOVERY="$MODULE_ROOT/$MODULE_FOLDER/src/camera_test_pkg/camera_test_pkg/devices.py"',
' test -f "$CAMERA_DISCOVERY" || { echo "Update and rebuild the camera module for automatic discovery" >&2; exit 1; }',
' camera_devices=$(python3 "$CAMERA_DISCOVERY" /dev) || exit 1',
' mapfile -t CAMERA_DEVICES <<< "$camera_devices"',
' else CAMERA_DEVICES=("$CAMERA_DEVICE"); fi',
' for camera_device in "${CAMERA_DEVICES[@]}"; do',
' [[ "$camera_device" =~ ^/dev/video[0-9]+$ ]] && [ -c "$camera_device" ] || { echo "Camera device not found: $camera_device" >&2; exit 1; }',
' done',
'fi', 'fi',
'docker rm -f "$CONTAINER_NAME" >/dev/null 2>&1 || true', 'docker rm -f "$CONTAINER_NAME" >/dev/null 2>&1 || true',
'', '',
@@ -4800,7 +4827,7 @@ function buildNav2DockerRunScript(): string {
')', ')',
'if [ -n "$YAHBOOM_DEVICE" ]; then args+=(--device "$YAHBOOM_DEVICE:$YAHBOOM_DEVICE"); fi', 'if [ -n "$YAHBOOM_DEVICE" ]; then args+=(--device "$YAHBOOM_DEVICE:$YAHBOOM_DEVICE"); fi',
'if [ -n "$CAMERA_DEVICE" ]; then', 'if [ -n "$CAMERA_DEVICE" ]; then',
' args+=(--device "$CAMERA_DEVICE:$CAMERA_DEVICE")', ' for camera_device in "${CAMERA_DEVICES[@]}"; do args+=(--device "$camera_device:$camera_device"); done',
' for capture_device in /dev/capture-vi-channel*; do', ' for capture_device in /dev/capture-vi-channel*; do',
' if [ -c "$capture_device" ]; then args+=(--device "$capture_device:$capture_device"); fi', ' if [ -c "$capture_device" ]; then args+=(--device "$capture_device:$capture_device"); fi',
' done', ' done',
@@ -0,0 +1,113 @@
export function createCameraPreview({ root, baseUrl, getState, onAvailable }) {
const cameras = new Map();
let enabled = false;
let timer = null;
function sources() {
const { state, receivedAt } = getState();
if (!enabled || Date.now() - receivedAt >= 3000) return [];
const list = Array.isArray(state?.cameras) ? state.cameras
: state?.topic_status?.camera_image?.available ? [{ id: 'legacy', name: 'Camera', available: true }] : [];
const seen = new Set();
return list.filter(item => {
if (!item?.available || !/^[a-zA-Z0-9_-]{1,64}$/.test(item.id) || seen.has(item.id)) return false;
seen.add(item.id);
return true;
}).slice(0, 8);
}
function clearFrame(camera) {
if (camera.url) URL.revokeObjectURL(camera.url);
camera.url = null;
camera.image.removeAttribute('src');
}
function remove(id, camera) {
cameras.delete(id);
camera.controller?.abort();
clearFrame(camera);
camera.element.remove();
}
async function frame(id, camera) {
if (camera.controller || Date.now() < camera.retryAt || document.hidden) return;
const controller = new AbortController();
camera.controller = controller;
const timeout = window.setTimeout(() => controller.abort(), 2000);
try {
const query = id === 'legacy' ? '' : `?camera_id=${encodeURIComponent(id)}`;
const response = await fetch(`${baseUrl}/camera-frame${query}`, { cache: 'no-store', signal: controller.signal });
if (!response.ok) throw new Error('Camera frame unavailable');
const blob = await response.blob();
if (controller.signal.aborted || document.hidden || cameras.get(id) !== camera || !sources().some(item => item.id === id)) return;
const previous = camera.url;
camera.url = URL.createObjectURL(blob);
camera.image.src = camera.url;
camera.retryAt = 0;
if (previous) URL.revokeObjectURL(previous);
} catch (_) {
if (cameras.get(id) === camera) {
camera.retryAt = Date.now() + 5000;
clearFrame(camera);
}
} finally {
window.clearTimeout(timeout);
if (camera.controller === controller) camera.controller = null;
}
}
function refresh() {
const live = sources();
const ids = new Set(live.map(item => item.id));
for (const [id, camera] of cameras) if (!ids.has(id)) remove(id, camera);
for (const item of live) {
let camera = cameras.get(item.id);
if (!camera) {
const element = document.createElement('div');
element.className = 'camera-preview-item';
element.dataset.cameraId = item.id;
const label = document.createElement('div');
label.className = 'camera-preview-label';
const image = document.createElement('img');
image.className = 'camera-preview-image';
element.append(label, image);
root.append(element);
camera = { element, label, image, url: null, controller: null, retryAt: 0 };
image.addEventListener('error', () => {
if (cameras.get(item.id) === camera) {
clearFrame(camera);
camera.retryAt = Date.now() + 5000;
}
});
cameras.set(item.id, camera);
}
camera.label.textContent = item.name || item.id;
camera.image.alt = item.name || item.id;
void frame(item.id, camera);
}
onAvailable(enabled && cameras.size > 0);
}
function visibilityChanged() {
if (document.hidden) for (const camera of cameras.values()) camera.controller?.abort();
else refresh();
}
document.addEventListener('visibilitychange', visibilityChanged);
function setEnabled(value) {
enabled = value;
if (timer) window.clearInterval(timer);
timer = null;
refresh();
if (enabled) timer = window.setInterval(refresh, 200);
}
return {
setEnabled,
refresh,
dispose() {
setEnabled(false);
document.removeEventListener('visibilitychange', visibilityChanged);
},
};
}
+28 -76
View File
@@ -61,14 +61,29 @@
#cameraPreviewWrap.is-visible { display:block; } #cameraPreviewWrap.is-visible { display:block; }
#cameraToggle { display:grid; place-items:center; width:34px; flex:0 0 34px; padding:0; } #cameraToggle { display:grid; place-items:center; width:34px; flex:0 0 34px; padding:0; }
#cameraPreviewWrap.is-collapsed { background:transparent; border-color:transparent; box-shadow:none; } #cameraPreviewWrap.is-collapsed { background:transparent; border-color:transparent; box-shadow:none; }
#cameraPreview { #cameraPreviewList {
max-height:min(60vh, 600px);
overflow-y:auto;
scrollbar-width:thin;
scrollbar-color:rgba(148,163,184,.65) transparent;
}
.camera-preview-item + .camera-preview-item { border-top:1px solid rgba(255,255,255,.18); }
.camera-preview-label {
padding:5px 8px;
overflow:hidden;
text-overflow:ellipsis;
white-space:nowrap;
color:rgba(255,255,255,.85);
font:11px sans-serif;
}
.camera-preview-image {
display:block; display:block;
width:100%; width:100%;
aspect-ratio:16 / 9; aspect-ratio:16 / 9;
object-fit:contain; object-fit:contain;
background:#111520; background:#111520;
} }
#cameraPreviewWrap.is-collapsed #cameraPreview { display:none; } #cameraPreviewWrap.is-collapsed #cameraPreviewList { display:none; }
#cameraPreviewToggle { #cameraPreviewToggle {
position:absolute; position:absolute;
left:0; left:0;
@@ -426,7 +441,7 @@
<button id="miniMapToggle" title="미니맵 접기/펼치기" data-i18n-title="미니맵 접기/펼치기" aria-label="미니맵 접기" data-i18n-aria-label="미니맵 접기">▾</button> <button id="miniMapToggle" title="미니맵 접기/펼치기" data-i18n-title="미니맵 접기/펼치기" aria-label="미니맵 접기" data-i18n-aria-label="미니맵 접기">▾</button>
<div id="cameraPreviewWrap"> <div id="cameraPreviewWrap">
<button id="cameraPreviewToggle" type="button" title="카메라 화면 접기/펼치기" data-i18n-title="카메라 화면 접기/펼치기" aria-label="카메라 화면 접기" data-i18n-aria-label="카메라 화면 접기" aria-expanded="true"><span id="cameraPreviewChevron" aria-hidden="true">▾</span></button> <button id="cameraPreviewToggle" type="button" title="카메라 화면 접기/펼치기" data-i18n-title="카메라 화면 접기/펼치기" aria-label="카메라 화면 접기" data-i18n-aria-label="카메라 화면 접기" aria-expanded="true"><span id="cameraPreviewChevron" aria-hidden="true">▾</span></button>
<img id="cameraPreview" alt="실시간 카메라 화면" data-i18n-alt="실시간 카메라 화면" /> <div id="cameraPreviewList"></div>
</div> </div>
<div id="keyGuide"> <div id="keyGuide">
<div data-i18n="i 전진 · , 후진 · j/l 회전 · k 정지">i 전진 · , 후진 · j/l 회전 · k 정지</div> <div data-i18n="i 전진 · , 후진 · j/l 회전 · k 정지">i 전진 · , 후진 · j/l 회전 · k 정지</div>
@@ -463,6 +478,7 @@
<script type="module" src="./locale.js"></script> <script type="module" src="./locale.js"></script>
<script type="module"> <script type="module">
import { t, setText } from './locale.js'; import { t, setText } from './locale.js';
import { createCameraPreview } from './camera-preview.js';
import * as THREE from 'three'; import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js'; import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
@@ -502,7 +518,7 @@ const miniMap = document.getElementById('miniMap');
const miniCtx = miniMap.getContext('2d'); const miniCtx = miniMap.getContext('2d');
const miniMapToggle = document.getElementById('miniMapToggle'); const miniMapToggle = document.getElementById('miniMapToggle');
const cameraPreviewWrap = document.getElementById('cameraPreviewWrap'); const cameraPreviewWrap = document.getElementById('cameraPreviewWrap');
const cameraPreview = document.getElementById('cameraPreview'); const cameraPreviewList = document.getElementById('cameraPreviewList');
const cameraPreviewToggle = document.getElementById('cameraPreviewToggle'); const cameraPreviewToggle = document.getElementById('cameraPreviewToggle');
const cameraToggleButton = document.getElementById('cameraToggle'); const cameraToggleButton = document.getElementById('cameraToggle');
const topRightControls = document.getElementById('topRightControls'); const topRightControls = document.getElementById('topRightControls');
@@ -937,12 +953,7 @@ let pointCloudRefreshTimer = null;
let savedMapPointCloudLoaded = false; let savedMapPointCloudLoaded = false;
let sensorPointCloudRequestInFlight = false; let sensorPointCloudRequestInFlight = false;
let sensorPointCloudRefreshTimer = null; let sensorPointCloudRefreshTimer = null;
let cameraFrameRequestInFlight = false;
let cameraFrameRetryAt = 0;
let cameraFrameRefreshTimer = null;
let cameraFrameObjectUrl = null;
let cameraPreviewEnabled = ENABLE_CAMERA_PREVIEW; let cameraPreviewEnabled = ENABLE_CAMERA_PREVIEW;
let cameraFrameAbortController = null;
let pathVisible = false; let pathVisible = false;
// SLAM always renders its live scan, including the forward beam. Navigation // SLAM always renders its live scan, including the forward beam. Navigation
// keeps the sensor opt-in so a saved-map view starts cleanly. // keeps the sensor opt-in so a saved-map view starts cleanly.
@@ -2087,84 +2098,27 @@ async function refreshSensorPointCloud() {
} }
} }
function setCameraPreviewAvailable(available) { const cameraPreviewController = createCameraPreview({
available = available && cameraPreviewEnabled; root: cameraPreviewList,
cameraPreviewWrap?.classList.toggle('is-visible', available); baseUrl: BACKEND_URL,
if (available) return; getState: () => ({ state: latestSlamState, receivedAt: latestSlamStateReceivedAt }),
if (cameraFrameObjectUrl) { onAvailable: available => cameraPreviewWrap.classList.toggle('is-visible', available),
URL.revokeObjectURL(cameraFrameObjectUrl); });
cameraFrameObjectUrl = null;
}
if (cameraPreview) cameraPreview.removeAttribute('src');
}
function hasLiveCameraState() {
return latestSlamState?.topic_status?.camera_image?.available === true
&& Date.now() - latestSlamStateReceivedAt < 3000;
}
async function refreshCameraFrame() {
if (!cameraPreviewEnabled || cameraFrameRequestInFlight || document.hidden) return;
// Reuse websocket topic status instead of probing an absent camera with HTTP.
if (!hasLiveCameraState()) {
setCameraPreviewAvailable(false);
return;
}
if (Date.now() < cameraFrameRetryAt) return;
cameraFrameRequestInFlight = true;
const controller = new AbortController();
cameraFrameAbortController = controller;
const timeout = window.setTimeout(() => controller.abort(), 2000);
try {
const response = await fetch(`${BACKEND_URL}/camera-frame`, { cache: 'no-store', signal: controller.signal });
if (!response.ok) {
cameraFrameRetryAt = Date.now() + 5000;
setCameraPreviewAvailable(false);
return;
}
const frame = await response.blob();
if (controller.signal.aborted || !cameraPreviewEnabled || !hasLiveCameraState() || document.hidden) {
setCameraPreviewAvailable(false);
return;
}
cameraFrameRetryAt = 0;
const nextUrl = URL.createObjectURL(frame);
const previousUrl = cameraFrameObjectUrl;
cameraFrameObjectUrl = nextUrl;
if (cameraPreview) cameraPreview.src = nextUrl;
if (previousUrl) URL.revokeObjectURL(previousUrl);
} catch (_) {
if (!controller.signal.aborted) cameraFrameRetryAt = Date.now() + 5000;
setCameraPreviewAvailable(false);
} finally {
window.clearTimeout(timeout);
cameraFrameAbortController = null;
cameraFrameRequestInFlight = false;
}
}
function setCameraPreviewEnabled(enabled) { function setCameraPreviewEnabled(enabled) {
cameraPreviewEnabled = ENABLE_CAMERA_PREVIEW && enabled; cameraPreviewEnabled = ENABLE_CAMERA_PREVIEW && enabled;
cameraToggleButton.classList.toggle('active', cameraPreviewEnabled); cameraToggleButton.classList.toggle('active', cameraPreviewEnabled);
cameraToggleButton.setAttribute('aria-pressed', String(cameraPreviewEnabled)); cameraToggleButton.setAttribute('aria-pressed', String(cameraPreviewEnabled));
if (cameraFrameRefreshTimer) window.clearInterval(cameraFrameRefreshTimer); cameraPreviewController.setEnabled(cameraPreviewEnabled);
cameraFrameRefreshTimer = null;
if (!cameraPreviewEnabled) { if (!cameraPreviewEnabled) {
cameraFrameAbortController?.abort();
setCameraPreviewAvailable(false);
return; return;
} }
cameraFrameRetryAt = 0;
cameraPreviewWrap.classList.remove('is-collapsed'); cameraPreviewWrap.classList.remove('is-collapsed');
cameraPreviewToggle.setAttribute('aria-expanded', 'true'); cameraPreviewToggle.setAttribute('aria-expanded', 'true');
cameraPreviewToggle.setAttribute('aria-label', t('카메라 화면 접기')); cameraPreviewToggle.setAttribute('aria-label', t('카메라 화면 접기'));
void refreshCameraFrame();
cameraFrameRefreshTimer = window.setInterval(() => void refreshCameraFrame(), 200);
} }
cameraToggleButton.addEventListener('click', () => setCameraPreviewEnabled(!cameraPreviewEnabled)); cameraToggleButton.addEventListener('click', () => setCameraPreviewEnabled(!cameraPreviewEnabled));
cameraPreview?.addEventListener('load', () => setCameraPreviewAvailable(hasLiveCameraState()));
cameraPreview?.addEventListener('error', () => setCameraPreviewAvailable(false));
function drawMiniMap(state) { function drawMiniMap(state) {
pendingMiniMapState = state; pendingMiniMapState = state;
@@ -3299,9 +3253,7 @@ window.addEventListener('beforeunload', () => {
if (pointCloudRefreshTimer) window.clearInterval(pointCloudRefreshTimer); if (pointCloudRefreshTimer) window.clearInterval(pointCloudRefreshTimer);
if (sensorPointCloudRefreshTimer) window.clearInterval(sensorPointCloudRefreshTimer); if (sensorPointCloudRefreshTimer) window.clearInterval(sensorPointCloudRefreshTimer);
if (miniMapRenderTimer) window.clearTimeout(miniMapRenderTimer); if (miniMapRenderTimer) window.clearTimeout(miniMapRenderTimer);
if (cameraFrameRefreshTimer) window.clearInterval(cameraFrameRefreshTimer); cameraPreviewController.dispose();
cameraFrameAbortController?.abort();
if (cameraFrameObjectUrl) URL.revokeObjectURL(cameraFrameObjectUrl);
}); });
function connectControlSocket() { function connectControlSocket() {
+23 -9
View File
@@ -630,6 +630,8 @@ function LocalPcWorkspaceMockup({
enabled: agentEnabled, enabled: agentEnabled,
provider: agentProvider, provider: agentProvider,
imagesEnabled: agentImagesEnabled, imagesEnabled: agentImagesEnabled,
models: agentModels,
selectProvider: selectAgentProvider,
statusLoading: agentStatusLoading, statusLoading: agentStatusLoading,
pending: agentPending, pending: agentPending,
activity: agentActivity, activity: agentActivity,
@@ -3526,9 +3528,9 @@ function LocalPcWorkspaceMockup({
const createCustomModule = async (input: CustomModuleInput): Promise<void> => { const createCustomModule = async (input: CustomModuleInput): Promise<void> => {
await createCustomModuleRecord(input) await createCustomModuleRecord(input)
} }
const collectAgentWorkspace = async (requestedModuleNames: string[] = []): Promise<AgentWorkspaceModule[]> => { const collectAgentWorkspace = async (requestedModuleNames: string[] = [], moduleSnapshot = availableModules): Promise<AgentWorkspaceModule[]> => {
const requestedNames = new Set(requestedModuleNames.map((name) => name.toLowerCase())) const requestedNames = new Set(requestedModuleNames.map((name) => name.toLowerCase()))
const workspaceModules: AgentWorkspaceModule[] = availableModules.map((module) => ({ const workspaceModules: AgentWorkspaceModule[] = moduleSnapshot.map((module) => ({
name: module.name, name: module.name,
folderName: module.folderName, folderName: module.folderName,
category: module.category, category: module.category,
@@ -3542,7 +3544,7 @@ function LocalPcWorkspaceMockup({
} }
} : {}) } : {})
})) }))
const targetModules = availableModules.filter((module) => ( const targetModules = moduleSnapshot.filter((module) => (
module.state !== 'uninstalled' && requestedNames.has(module.name.toLowerCase()) module.state !== 'uninstalled' && requestedNames.has(module.name.toLowerCase())
)) ))
if (!targetModules.length) return workspaceModules if (!targetModules.length) return workspaceModules
@@ -3677,12 +3679,18 @@ function LocalPcWorkspaceMockup({
const submitAgentRequest = async (content: string, attachments: ChatAttachment[] = []): Promise<void> => { const submitAgentRequest = async (content: string, attachments: ChatAttachment[] = []): Promise<void> => {
if (agentActionControllerRef.current || agentApplying) return if (agentActionControllerRef.current || agentApplying) return
try { try {
const modules = await collectAgentWorkspace() await requestAgentPlan(content, [], [], {
await requestAgentPlan(content, modules, [], {
attachments, attachments,
loadInitialContext: async (signal) => {
const modules = await readAgentModules()
if (signal.aborted) throw new DOMException('Aborted', 'AbortError')
return { modules: await collectAgentWorkspace([], modules), runtimeLogs: [] }
},
loadRequestedContext: loadAgentRequestedContext, loadRequestedContext: loadAgentRequestedContext,
executeTool: executeWorkspaceAgentTool, executeTool: executeWorkspaceAgentTool,
environment: remoteWorkspace ? 'remote' : 'local' environment: remoteWorkspace ? 'remote' : 'local',
workspaceTarget: remoteWorkspace ? `ssh:${getRemoteWorkspaceTarget().host}` : 'local-host',
workspaceRoot: remoteWorkspace ? JSON.stringify([getRemoteWorkspaceTarget().user, getRemoteWorkspaceTarget().port, getRemoteWorkspaceTarget().workspaceRoot]) : clonePathPreview
}) })
} catch (error) { } catch (error) {
console.error('[agent] workspace collection failed', error) console.error('[agent] workspace collection failed', error)
@@ -3690,7 +3698,7 @@ function LocalPcWorkspaceMockup({
} }
} }
const openWorkspaceAgentCodeReference = async (reference: AgentCodeReference, conversationCurrent: () => boolean): Promise<void> => { const openWorkspaceAgentCodeReference = async (reference: AgentCodeReference, conversationCurrent: () => boolean): Promise<void> => {
if (agentActionControllerRef.current || agentApplying) throw new Error('진행 중인 에이전트 작업이 끝난 뒤 열어주세요.') if (agentApplying) throw new Error('진행 중인 에이전트 작업이 끝난 뒤 열어주세요.')
validateCodeReference(reference) validateCodeReference(reference)
const scope = agentNavigationScope const scope = agentNavigationScope
const isCurrent = (): boolean => conversationCurrent() && agentNavigationRef.current.scope === scope && agentNavigationRef.current.visible const isCurrent = (): boolean => conversationCurrent() && agentNavigationRef.current.scope === scope && agentNavigationRef.current.visible
@@ -3824,7 +3832,9 @@ function LocalPcWorkspaceMockup({
const currentModules = await readAgentModules() const currentModules = await readAgentModules()
assertActive() 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: [] })), [], { 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, 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 }] 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) { } catch (error) {
@@ -3906,7 +3916,9 @@ function LocalPcWorkspaceMockup({
showUserMessage: false, showUserMessage: false,
loadRequestedContext: loadAgentRequestedContext, loadRequestedContext: loadAgentRequestedContext,
executeTool: executeWorkspaceAgentTool, executeTool: executeWorkspaceAgentTool,
environment: remoteWorkspace ? 'remote' : 'local' environment: remoteWorkspace ? 'remote' : 'local',
workspaceTarget: remoteWorkspace ? `ssh:${getRemoteWorkspaceTarget().host}` : 'local-host',
workspaceRoot: remoteWorkspace ? JSON.stringify([getRemoteWorkspaceTarget().user, getRemoteWorkspaceTarget().port, getRemoteWorkspaceTarget().workspaceRoot]) : clonePathPreview
}) })
} catch (error) { } catch (error) {
const errorMessage = (error instanceof Error ? error.message : '명령을 실행하지 못했습니다.') const errorMessage = (error instanceof Error ? error.message : '명령을 실행하지 못했습니다.')
@@ -4752,6 +4764,8 @@ function LocalPcWorkspaceMockup({
agentEnabled={agentEnabled} agentEnabled={agentEnabled}
agentProvider={agentProvider} agentProvider={agentProvider}
agentImagesEnabled={Boolean(agentImagesEnabled)} agentImagesEnabled={Boolean(agentImagesEnabled)}
agentModels={agentModels}
onAgentProviderChange={selectAgentProvider}
agentStatusLoading={agentStatusLoading} agentStatusLoading={agentStatusLoading}
agentPending={agentPending} agentPending={agentPending}
agentActivity={agentActivity} agentActivity={agentActivity}
@@ -0,0 +1,119 @@
import { useEffect, useId, useLayoutEffect, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { Bot, Check } from 'lucide-react'
import { t } from '../i18n'
import { agentModelLabel, type AgentModelOption } from '../features/workspace/agentModels'
export function AgentModelSelector({ models, provider, disabled, dark, onChange }: {
models: AgentModelOption[]
provider: string | null
disabled: boolean
dark: boolean
onChange: (provider: string) => void
}): React.JSX.Element {
const selected = models.find(model => model.provider === provider)
const blocked = disabled || models.length === 0
const [open, setOpen] = useState(false)
const [position, setPosition] = useState({ left: 8, bottom: 8, width: 280, maxHeight: 320 })
const buttonRef = useRef<HTMLButtonElement>(null)
const menuRef = useRef<HTMLDivElement>(null)
const menuId = useId()
const visible = open && !blocked
const close = (restoreFocus = false): void => {
setOpen(false)
if (restoreFocus) buttonRef.current?.focus({ preventScroll: true })
}
useEffect(() => {
if (blocked) setOpen(false)
}, [blocked])
useLayoutEffect(() => {
if (!visible || !buttonRef.current) return
const bounds = buttonRef.current.getBoundingClientRect()
const width = Math.min(320, window.innerWidth - 16)
setPosition({
left: Math.max(8, Math.min(bounds.left, window.innerWidth - width - 8)),
bottom: window.innerHeight - bounds.top + 6,
width,
maxHeight: Math.max(0, Math.min(320, bounds.top - 14))
})
const items = menuRef.current?.querySelectorAll<HTMLButtonElement>('[role="menuitemradio"]')
const index = Math.max(0, models.findIndex(model => model.provider === provider))
items?.[index]?.focus({ preventScroll: true })
}, [visible, models, provider])
useEffect(() => {
if (!visible) return
const outside = (event: Event): void => {
const target = event.target as Node
if (!menuRef.current?.contains(target) && !buttonRef.current?.contains(target)) setOpen(false)
}
const dismiss = (): void => setOpen(false)
window.addEventListener('pointerdown', outside, true)
window.addEventListener('contextmenu', outside)
window.addEventListener('scroll', outside, true)
window.addEventListener('resize', dismiss)
window.addEventListener('blur', dismiss)
return () => {
window.removeEventListener('pointerdown', outside, true)
window.removeEventListener('contextmenu', outside)
window.removeEventListener('scroll', outside, true)
window.removeEventListener('resize', dismiss)
window.removeEventListener('blur', dismiss)
}
}, [visible])
return (
<>
<button
ref={buttonRef}
type="button"
aria-label={t('에이전트 모델 선택')}
aria-haspopup="menu"
aria-expanded={visible}
aria-controls={visible ? menuId : undefined}
title={selected ? agentModelLabel(selected) : t('사용 가능한 모델 없음')}
disabled={blocked}
onClick={() => setOpen(current => !current)}
onKeyDown={event => {
if (event.key === 'ArrowUp' || event.key === 'ArrowDown') { event.preventDefault(); setOpen(true) }
else if (event.key === 'Escape') close()
}}
className={['grid size-7 shrink-0 cursor-pointer place-items-center rounded outline-none transition focus-visible:ring-1 focus-visible:ring-[#8b5cf6] disabled:cursor-default disabled:opacity-35',
dark ? 'text-white/60 hover:bg-white/10 hover:text-white/90' : 'text-[#64748b] hover:bg-[#eef1f7] hover:text-[#111520]',
visible ? dark ? 'bg-white/10 text-white/90' : 'bg-[#eef1f7] text-[#111520]' : ''].join(' ')}
>
<Bot size={15} aria-hidden="true" />
</button>
{visible && createPortal(
<div ref={menuRef} id={menuId} role="menu" aria-label={t('에이전트 모델 선택')}
className={['fixed z-[160] overflow-y-auto overscroll-contain rounded-md border py-1 text-[12px] font-medium shadow-[0_14px_32px_rgba(0,0,0,0.32)]',
dark ? 'border-white/10 bg-[#252526] text-white/80' : 'border-[#d8dee9] bg-white text-[#374151]'].join(' ')}
style={position}
onContextMenu={event => { event.preventDefault(); event.stopPropagation() }}
onKeyDown={event => {
if (event.key === 'Escape') { event.preventDefault(); event.stopPropagation(); close(true); return }
if (event.key === 'Tab') { close(true); return }
if (!['ArrowUp', 'ArrowDown', 'Home', 'End'].includes(event.key)) return
event.preventDefault()
const items = Array.from(menuRef.current?.querySelectorAll<HTMLButtonElement>('[role="menuitemradio"]') ?? [])
const current = items.indexOf(document.activeElement as HTMLButtonElement)
const next = event.key === 'Home' ? 0 : event.key === 'End' ? items.length - 1
: (current + (event.key === 'ArrowDown' ? 1 : -1) + items.length) % items.length
items[next]?.focus()
}}
>
{models.map(model => <button key={model.provider} type="button" role="menuitemradio" aria-checked={model.provider === provider}
title={agentModelLabel(model)} tabIndex={-1}
onClick={() => { if (!blocked) { onChange(model.provider); close(true) } }}
className={['flex h-8 w-full cursor-pointer items-center gap-2 px-3 text-left outline-none transition',
dark ? 'hover:bg-[#37373d] hover:text-white focus:bg-[#37373d] focus:text-white' : 'hover:bg-[#eef1f7] hover:text-[#111520] focus:bg-[#eef1f7] focus:text-[#111520]'].join(' ')}>
<Check size={13} className={['shrink-0', model.provider === provider ? '' : 'opacity-0'].join(' ')} aria-hidden="true" />
<span className="min-w-0 truncate">{agentModelLabel(model)}</span>
</button>)}
</div>, document.body
)}
</>
)
}
+5 -2
View File
@@ -298,6 +298,7 @@ export const QaInputBox = memo(function QaInputBox({
imagesEnabled = false, imagesEnabled = false,
onClearConversation, onClearConversation,
clearDisabled = false, clearDisabled = false,
footerAction,
onStop, onStop,
onSubmit onSubmit
}: { }: {
@@ -309,6 +310,7 @@ export const QaInputBox = memo(function QaInputBox({
imagesEnabled?: boolean imagesEnabled?: boolean
onClearConversation?: () => void onClearConversation?: () => void
clearDisabled?: boolean clearDisabled?: boolean
footerAction?: React.ReactNode
onStop: () => void onStop: () => void
onSubmit: (content: string, attachments?: ChatAttachment[]) => void onSubmit: (content: string, attachments?: ChatAttachment[]) => void
}): React.JSX.Element { }): React.JSX.Element {
@@ -456,8 +458,9 @@ export const QaInputBox = memo(function QaInputBox({
)} )}
<ChatImageAttachments dark={dark} images={attachments.flatMap(file => file.kind === 'image' ? [{ name: file.name, url: file.previewUrl }] : [])} removeDisabled={busy || readingAttachments} onRemove={image => setAttachments(current => current.filter(file => file.kind !== 'image' || file.previewUrl !== image.url))} /> <ChatImageAttachments dark={dark} images={attachments.flatMap(file => file.kind === 'image' ? [{ name: file.name, url: file.previewUrl }] : [])} removeDisabled={busy || readingAttachments} onRemove={image => setAttachments(current => current.filter(file => file.kind !== 'image' || file.previewUrl !== image.url))} />
{attachmentError && <p role="alert" className="break-words pt-2 text-[11px] text-[#ef4444]">{t(attachmentError)}</p>} {attachmentError && <p role="alert" className="break-words pt-2 text-[11px] text-[#ef4444]">{t(attachmentError)}</p>}
{(attachmentsEnabled || onClearConversation) && ( {(attachmentsEnabled || onClearConversation || footerAction) && (
<div className={['flex h-9 items-center gap-1 pt-1', dark ? 'text-white/50' : 'text-[#64748b]'].join(' ')}> <div className={['flex h-9 min-w-0 items-center gap-1 pt-1', dark ? 'text-white/50' : 'text-[#64748b]'].join(' ')}>
{footerAction}
{attachmentsEnabled && ( {attachmentsEnabled && (
<> <>
<input ref={fileInputRef} type="file" multiple accept={[CHAT_ATTACHMENT_ACCEPT, ...(imagesEnabled ? [CHAT_IMAGE_ACCEPT] : [])].join(',')} className="hidden" aria-label={t('파일 첨부')} onChange={(event) => { <input ref={fileInputRef} type="file" multiple accept={[CHAT_ATTACHMENT_ACCEPT, ...(imagesEnabled ? [CHAT_IMAGE_ACCEPT] : [])].join(',')} className="hidden" aria-label={t('파일 첨부')} onChange={(event) => {
@@ -10,6 +10,8 @@ import { ChatImageAttachments } from '../../components/ChatImageAttachments'
import { AgentProgressBubble } from '../../components/AgentProgressBubble' import { AgentProgressBubble } from '../../components/AgentProgressBubble'
import { AgentWorkDuration } from '../../components/AgentWorkDuration' import { AgentWorkDuration } from '../../components/AgentWorkDuration'
import { AgentCodeReferences } from '../../components/AgentCodeReferences' import { AgentCodeReferences } from '../../components/AgentCodeReferences'
import { AgentModelSelector } from '../../components/AgentModelSelector'
import type { AgentModelOption } from './agentModels'
import type { AgentProgressStep } from './agentContext' import type { AgentProgressStep } from './agentContext'
import { ChatAvatar, ChatEmptyState, ChatResponseSkeleton, ExpandablePlainMessage, MarkdownMessage, QaInputBox } from '../../components/ChatWidgets' import { ChatAvatar, ChatEmptyState, ChatResponseSkeleton, ExpandablePlainMessage, MarkdownMessage, QaInputBox } from '../../components/ChatWidgets'
import type { AgentCommand, AgentConversationMessage, AgentFileChange, AgentPlan } from './useAiAgent' import type { AgentCommand, AgentConversationMessage, AgentFileChange, AgentPlan } from './useAiAgent'
@@ -47,6 +49,8 @@ type WorkspaceQaPanelProps = {
agentEnabled: boolean agentEnabled: boolean
agentProvider?: string | null agentProvider?: string | null
agentImagesEnabled?: boolean agentImagesEnabled?: boolean
agentModels?: AgentModelOption[]
onAgentProviderChange?: (provider: string) => void
agentStatusLoading: boolean agentStatusLoading: boolean
agentPending: boolean agentPending: boolean
agentActivity?: string agentActivity?: string
@@ -124,6 +128,8 @@ export default function WorkspaceQaPanel({
agentEnabled, agentEnabled,
agentProvider, agentProvider,
agentImagesEnabled = false, agentImagesEnabled = false,
agentModels = [],
onAgentProviderChange,
agentStatusLoading, agentStatusLoading,
agentPending, agentPending,
agentActivity, agentActivity,
@@ -348,6 +354,7 @@ export default function WorkspaceQaPanel({
imagesEnabled={qaMode === 'agent' && agentImagesEnabled} imagesEnabled={qaMode === 'agent' && agentImagesEnabled}
onClearConversation={requestClearConversation} onClearConversation={requestClearConversation}
clearDisabled={clearDisabled} clearDisabled={clearDisabled}
footerAction={qaMode === 'agent' && onAgentProviderChange ? <AgentModelSelector models={agentModels} provider={agentProvider ?? null} dark={dark} disabled={inputBusy || agentStatusLoading || inputDisabled} onChange={onAgentProviderChange} /> : undefined}
/> />
{clearConfirmationOpen && createPortal( {clearConfirmationOpen && createPortal(
<ConfirmStatusModal <ConfirmStatusModal
@@ -429,6 +436,8 @@ function AgentMessages({
) )
} }
const codeNavigationBusy = messages.some(message => message.applyState === 'applying')
return ( return (
<div className="grid gap-3"> <div className="grid gap-3">
{messages.map((message, index) => message.role === 'user' ? ( {messages.map((message, index) => message.role === 'user' ? (
@@ -447,7 +456,7 @@ function AgentMessages({
<ProgressiveChatResponse message={message} dark={dark} collapseOnSignal={index < messages.length - 1} collapseSignal={messages.length} footerAction={<MessageCopyButton content={agentMessageCopyText(message)} dark={dark} />}> <ProgressiveChatResponse message={message} dark={dark} collapseOnSignal={index < messages.length - 1} collapseSignal={messages.length} footerAction={<MessageCopyButton content={agentMessageCopyText(message)} dark={dark} />}>
{message.plan && ( {message.plan && (
<> <>
{!!message.plan.codeReferences?.length && <AgentCodeReferences references={message.plan.codeReferences} states={message.codeReferenceStates} dark={dark} busy={busy} onOpen={onOpenCodeReference ? id => onOpenCodeReference(message.id, id) : undefined} />} {!!message.plan.codeReferences?.length && <AgentCodeReferences references={message.plan.codeReferences} states={message.codeReferenceStates} dark={dark} busy={codeNavigationBusy} onOpen={onOpenCodeReference ? id => onOpenCodeReference(message.id, id) : undefined} />}
{!!message.plan.evidence?.length && ( {!!message.plan.evidence?.length && (
<details className={['border-t pt-2 text-[11px]', dark ? 'border-white/10 text-white/55' : 'border-[#ddd6fe] text-[#64748b]'].join(' ')}> <details className={['border-t pt-2 text-[11px]', dark ? 'border-white/10 text-white/55' : 'border-[#ddd6fe] text-[#64748b]'].join(' ')}>
<summary className="cursor-pointer select-none">{t("조회 근거")} · {message.plan.evidence.length}</summary> <summary className="cursor-pointer select-none">{t("조회 근거")} · {message.plan.evidence.length}</summary>
@@ -1,7 +1,8 @@
import type { AgentConversationMessage, AgentPlan, AgentRuntimeLog, AgentWorkspaceModule } from './useAiAgent' import type { AgentConversationMessage, AgentPlan, AgentRuntimeLog, AgentWorkspaceModule } from './useAiAgent'
import type { AgentToolRequest, AgentToolResult } from '../../../../shared/agentTools' import type { AgentToolRequest, AgentToolResult } from '../../../../shared/agentTools'
import { compactAgentEvidence, describeAgentEvidence, missingAgentRead, readFileEvidence } from './agentEvidence'
export type AgentContext = { modules: AgentWorkspaceModule[]; runtimeLogs: AgentRuntimeLog[]; toolResults?: AgentToolResult[] } export type AgentContext = { modules: AgentWorkspaceModule[]; runtimeLogs: AgentRuntimeLog[]; toolResults?: AgentToolResult[]; contextNotes?: string[] }
export type AgentContextRequest = { modules: string[]; logs: string[] } export type AgentContextRequest = { modules: string[]; logs: string[] }
export type AgentProgressStep = { export type AgentProgressStep = {
id: string id: string
@@ -49,11 +50,11 @@ export function recoverInterruptedAgentMessages(messages: AgentConversationMessa
export function agentPlanRequestText(request: string, finalize = false): string { export function agentPlanRequestText(request: string, finalize = false): string {
if (!finalize) return request if (!finalize) return request
const instruction = '자료 조회는 종료되었습니다. 제공된 workspace의 파일·조회 상태·로그·toolResults만 근거로 아래 원래 질문에 최종 답변하세요. toolRequests, requestedModules, requestedLogs, requestedDocs, actionPlan, commands, changes는 모두 빈 배열, createModule은 null로 반환하세요. 같은 자료를 다시 요청하거나 확인하겠다는 예고로 끝내지 마세요. 확인한 파일 경로와 설정 항목을 구체적으로 설명하되, 자료가 없거나 제외됐다면 확정할 수 없는 부분과 추가로 필요한 정확한 자료를 명시하세요. 확인하지 않은 내용이나 경로를 추측하지 마세요.\n\n원래 학생 질문:\n' const instruction = '자료 조회는 종료되었습니다. 제공된 workspace의 파일·조회 상태·로그·toolResults만 근거로 아래 원래 질문에 최종 답변하세요. toolRequests, requestedModules, requestedLogs, requestedDocs, actionPlan, commands, changes는 모두 빈 배열, createModule은 null로 반환하세요. 같은 자료를 다시 요청하거나 확인하겠다는 예고로 끝내지 마세요. 코드 위치와 역할 설명은 전체 파일이 아닌 확인된 줄 범위만으로 답할 수 있습니다. 확인된 내용은 codeReferences와 함께 답하고 미확인 항목만 별도로 구분하세요. 확인한 파일 경로와 설정 항목을 구체적으로 설명하되, 자료가 없거나 제외됐다면 확정할 수 없는 부분과 추가로 필요한 정확한 자료를 명시하세요. 확인하지 않은 내용이나 경로를 추측하지 마세요.\n\n원래 학생 질문:\n'
return instruction + request.slice(0, 4000 - instruction.length) return instruction + request.slice(0, 4000 - instruction.length)
} }
function fitAgentContext(context: AgentContext, preferredModules: string[] = [], preferredLogs: string[] = []): AgentContext { function fitAgentContext(context: AgentContext, preferredModules: string[] = [], preferredLogs: string[] = [], preferredReads: AgentToolRequest[] = []): AgentContext {
const orderedModules = [...context.modules].sort((left, right) => Number(preferredModules.includes(right.name)) - Number(preferredModules.includes(left.name))) const orderedModules = [...context.modules].sort((left, right) => Number(preferredModules.includes(right.name)) - Number(preferredModules.includes(left.name)))
const selected = new Map<string, AgentWorkspaceModule['files']>() const selected = new Map<string, AgentWorkspaceModule['files']>()
const candidates = orderedModules.map((module) => ({ module, files: [...module.files], index: 0 })) const candidates = orderedModules.map((module) => ({ module, files: [...module.files], index: 0 }))
@@ -111,12 +112,28 @@ function fitAgentContext(context: AgentContext, preferredModules: string[] = [],
return { ...log, content, ...(notes ? { message: notes } : {}) } return { ...log, content, ...(notes ? { message: notes } : {}) }
}) })
let toolSize = 0 let toolSize = 0
const toolResults = [...(context.toolResults ?? [])].reverse().filter(result => { const evidence = compactAgentEvidence(context.toolResults ?? [])
if (result.data.length > 60_000 || toolSize + result.data.length > 180_000) return false const priority = (result: AgentToolResult): number => {
const file = readFileEvidence(result)
if (file && preferredReads.some(tool => tool.name === 'files.read' && tool.moduleName === result.moduleName && tool.args?.path === file.path)) return 6
return result.name === 'action.result' ? 5 : !result.name.startsWith('files.') ? 4
: file || result.status === 'error' ? 3 : 1
}
const candidatesByPriority = evidence.map((result, index) => ({ result, index }))
.sort((a, b) => priority(b.result) - priority(a.result) || b.index - a.index)
const retained = new Set<AgentToolResult>()
for (const { result } of candidatesByPriority) {
if (retained.size >= 12 || result.data.length > 60_000 || toolSize + result.data.length > 180_000) continue
retained.add(result)
toolSize += result.data.length toolSize += result.data.length
return true }
}).slice(0, 12).reverse() const toolResults = evidence.filter(result => retained.has(result))
return { modules, runtimeLogs, ...(context.toolResults ? { toolResults } : {}) } const omittedTools = evidence.filter(result => !retained.has(result))
const contextNotes = omittedTools.length
? [`용량·개수 한도로 조회 근거 ${omittedTools.length}개를 제외했습니다. 제외된 내용을 확인했다고 주장하지 마세요.`,
...omittedTools.slice(-10).map(result => describeAgentEvidence(result).slice(0, 500))]
: []
return { modules, runtimeLogs, ...(context.toolResults ? { toolResults, contextNotes } : {}) }
} }
export async function resolveAgentContext({ export async function resolveAgentContext({
@@ -142,6 +159,7 @@ export async function resolveAgentContext({
const requestedLogs = new Set(initialContext.runtimeLogs.map((log) => log.moduleName)) const requestedLogs = new Set(initialContext.runtimeLogs.map((log) => log.moduleName))
const queriedTools = new Set<string>() const queriedTools = new Set<string>()
let toolCount = 0 let toolCount = 0
let confirmedAnswer: AgentPlanResponse | undefined
const fingerprint = (tool: AgentToolRequest): string => JSON.stringify([tool.name, tool.moduleName ?? '', Object.entries(tool.args ?? {}).sort(([a], [b]) => a.localeCompare(b))]) const fingerprint = (tool: AgentToolRequest): string => JSON.stringify([tool.name, tool.moduleName ?? '', Object.entries(tool.args ?? {}).sort(([a], [b]) => a.localeCompare(b))])
const usage: NonNullable<AgentConversationMessage['usage']> = {} const usage: NonNullable<AgentConversationMessage['usage']> = {}
const checkAborted = (): void => { const checkAborted = (): void => {
@@ -162,9 +180,17 @@ export async function resolveAgentContext({
const sourceStatus = context.modules.filter((module) => requestedModules.has(module.name)).map((module) => ( const sourceStatus = context.modules.filter((module) => requestedModules.has(module.name)).map((module) => (
`- ${module.name}: 제공된 파일 ${module.files.length}개${module.fileContext?.message ? ` (${module.fileContext.message})` : ''}\n${module.files.slice(0, 4).map((file) => ` ${file.path}`).join('\n')}` `- ${module.name}: 제공된 파일 ${module.files.length}개${module.fileContext?.message ? ` (${module.fileContext.message})` : ''}\n${module.files.slice(0, 4).map((file) => ` ${file.path}`).join('\n')}`
)).join('\n').slice(0, 8000) )).join('\n').slice(0, 8000)
const toolStatus = (context.toolResults ?? []).map(result => `- ${result.moduleName ? `${result.moduleName} · ` : ''}${result.name}: ${result.status}${result.truncated ? ' (일부 결과)' : ''} · ${result.target} · ${result.observedAt}`).join('\n').slice(0, 5000) const toolStatus = (context.toolResults ?? []).map(result => `- ${describeAgentEvidence(result)}`).join('\n').slice(0, 8000)
const candidate = response.plan.codeReferences?.length ? response : confirmedAnswer
const references = candidate?.plan.codeReferences?.filter(reference => {
const latest = (cachedContext.toolResults ?? []).filter(result => result.moduleName === reference.moduleName)
.map(readFileEvidence).filter(file => file?.path === reference.path).at(-1)
return !latest || latest.exists && latest.hash === reference.expectedHash
})
const confirmed = references?.length && candidate ? { ...candidate, plan: { ...candidate.plan, codeReferences: references } } : undefined
const confirmedReferences = Boolean(confirmed?.plan.codeReferences?.length)
const explanation = stillRequesting const explanation = stillRequesting
? `AI가 최종 분석 요청 후에도 같은 자료를 재요청하여 결론을 확정하지 못했습니다. 파일 조회 실패나 설치 문제로 단정할 수는 없습니다.\n\n현재 AI에 제공된 자료:\n${sourceStatus || (toolStatus ? '- 모듈 묶음 조회 대신 아래 도구 결과를 제공했습니다.' : '- 소스 파일 없음')}\n\n도구 조회 근거:\n${toolStatus || '- 없음'}\n\n제공된 로그: ${context.runtimeLogs.map((log) => log.moduleName).join(', ') || '없음'}\n\n반복 조회는 중단했으며, 확인되지 않은 설정값이나 코드 변경은 제안하지 않았습니다.` ? `${confirmedReferences ? `${confirmed!.plan.explanation}\n\n추가 분석은 완료하지 못했지만 확인된 코드 위치는 유지했습니다.` : 'AI가 최종 분석 요청 후에도 같은 자료를 재요청하여 결론을 확정하지 못했습니다. 파일 조회 실패나 설치 문제로 단정할 수는 없습니다.'}\n\n현재 AI에 제공된 자료:\n${sourceStatus || (toolStatus ? '- 모듈 묶음 조회 대신 아래 도구 결과를 제공했습니다.' : '- 소스 파일 없음')}\n\n도구 조회 근거:\n${toolStatus || '- 없음'}\n\n제공된 로그: ${context.runtimeLogs.map((log) => log.moduleName).join(', ') || '없음'}\n\n반복 조회는 중단했으며, 확인되지 않은 설정값이나 코드 변경은 제안하지 않았습니다.`.slice(0, 12_000)
: response.plan.explanation : response.plan.explanation
return { return {
...response, ...response,
@@ -172,7 +198,8 @@ export async function resolveAgentContext({
// Recovery is analysis-only, even if the model proposes actions despite the instruction. // Recovery is analysis-only, even if the model proposes actions despite the instruction.
plan: { plan: {
...response.plan, ...response.plan,
summary: stillRequesting ? '확보한 자료만으로는 답변을 확정하지 못했습니다.' : response.plan.summary, summary: stillRequesting ? confirmedReferences ? '추가 분석은 미완료이며 확인된 코드 위치를 안내합니다.' : '확보한 자료만으로는 답변을 확정하지 못했습니다.' : response.plan.summary,
...(stillRequesting ? { codeReferences: confirmed?.plan.codeReferences ?? [] } : {}),
explanation, explanation,
requestedModules: [], requestedLogs: [], toolRequests: [], evidence: response.plan.evidence ?? context.toolResults, actionPlan: [], commands: [], changes: [], createModule: null requestedModules: [], requestedLogs: [], toolRequests: [], evidence: response.plan.evidence ?? context.toolResults, actionPlan: [], commands: [], changes: [], createModule: null
} }
@@ -186,19 +213,31 @@ export async function resolveAgentContext({
const response = await requestPlan(context) const response = await requestPlan(context)
checkAborted() checkAborted()
addUsage(response) addUsage(response)
if (response.plan.codeReferences?.length) confirmedAnswer = response
const modules = [...new Set(response.plan.requestedModules ?? [])] const modules = [...new Set(response.plan.requestedModules ?? [])]
const logs = [...new Set(response.plan.requestedLogs ?? [])] const logs = [...new Set(response.plan.requestedLogs ?? [])]
const tools = response.plan.toolRequests ?? [] const tools = response.plan.toolRequests ?? []
onStep?.({ id: `analysis-${round}`, kind: 'analysis', status: 'ok', round: round + 1, requests: modules.length + logs.length + tools.length }) onStep?.({ id: `analysis-${round}`, kind: 'analysis', status: 'ok', round: round + 1, requests: modules.length + logs.length + tools.length })
if (!modules.length && !logs.length && !tools.length) return { ...response, usage, plan: { ...response.plan, ...(executeTool ? { evidence: response.plan.evidence ?? context.toolResults ?? [] } : {}) } } if (!modules.length && !logs.length && !tools.length) return { ...response, usage, plan: { ...response.plan, ...(executeTool ? { evidence: response.plan.evidence ?? context.toolResults ?? [] } : {}) } }
if (round === maxRounds) return finalize() if (round === maxRounds) {
context = fitAgentContext(cachedContext, [], [], tools)
return finalize()
}
if (tools.length) { if (tools.length) {
if (!executeTool || tools.length > 6) return finalize() if (!executeTool || tools.length > 6) return finalize()
const fresh = tools.filter(tool => !queriedTools.has(fingerprint(tool))) const fresh = tools.filter(tool => {
if (!fresh.length || toolCount + fresh.length > 24) return finalize() const missing = missingAgentRead(tool, cachedContext.toolResults ?? [])
return missing && !queriedTools.has(fingerprint(missing))
})
if (!fresh.length || toolCount + fresh.length > 24) {
context = fitAgentContext(cachedContext, [], [], tools)
return finalize()
}
const results = [...(cachedContext.toolResults ?? [])] const results = [...(cachedContext.toolResults ?? [])]
for (const [index, tool] of fresh.entries()) { for (const [index, proposedTool] of fresh.entries()) {
checkAborted() checkAborted()
const tool = missingAgentRead(proposedTool, results)
if (!tool) continue
const key = fingerprint(tool) const key = fingerprint(tool)
if (queriedTools.has(key)) continue if (queriedTools.has(key)) continue
queriedTools.add(key) queriedTools.add(key)
@@ -209,8 +248,8 @@ export async function resolveAgentContext({
toolCount++ toolCount++
results.push(result) results.push(result)
} }
cachedContext = { ...cachedContext, toolResults: results } cachedContext = { ...cachedContext, toolResults: compactAgentEvidence(results) }
context = fitAgentContext(cachedContext) context = fitAgentContext(cachedContext, [], [], tools)
onProgress?.('조회 결과 분석 중') onProgress?.('조회 결과 분석 중')
// A model may also use legacy source requests; process those on the next pass. // A model may also use legacy source requests; process those on the next pass.
continue continue
@@ -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) }
])
}
}
@@ -0,0 +1,107 @@
import type { AgentToolRequest, AgentToolResult } from '../../../../shared/agentTools'
type FileEvidence = {
path: string; exists: boolean; content: string; hash: string | null
complete: boolean; startLine: number; endLine: number; totalLines: number
contentTruncated?: boolean
}
export function readFileEvidence(result: AgentToolResult): FileEvidence | undefined {
if (result.name !== 'files.read' || result.status !== 'ok') return
try {
const data = JSON.parse(result.data)
if (typeof data.path !== 'string' || typeof data.content !== 'string' || typeof data.complete !== 'boolean'
|| data.contentTruncated !== undefined && typeof data.contentTruncated !== 'boolean') return
if (data.exists === false && data.complete === true && data.hash === null && data.content === '') {
return { ...data, startLine: 1, endLine: 0, totalLines: 0 }
}
if (data.exists !== true || typeof data.hash !== 'string' || !/^[a-f0-9]{64}$/.test(data.hash)) return
const startLine = data.startLine ?? 1
const last = startLine + data.content.split('\n').length - 1
// Older clients cannot distinguish a ranged read from a cut final line.
const endLine = data.endLine ?? last - Number(result.truncated)
if (!Number.isSafeInteger(startLine) || startLine < 1 || !Number.isSafeInteger(endLine)
|| endLine < startLine - 1 || endLine > last || !Number.isSafeInteger(data.totalLines) || data.totalLines < 1 || endLine > data.totalLines
|| data.contentTruncated === true && endLine >= last) return
return { ...data, startLine, endLine }
} catch { return }
}
const fileKey = (result: AgentToolResult, file: FileEvidence): string => JSON.stringify([result.target, result.moduleName, file.path])
// Only combine observations of the same file revision on the same host.
export function compactAgentEvidence(results: AgentToolResult[]): AgentToolResult[] {
const compacted: AgentToolResult[] = []
for (const result of results) {
const file = readFileEvidence(result)
if (!file) { compacted.push(result); continue }
const key = fileKey(result, file)
let current = result
let currentFile = file
for (let index = compacted.length - 1; index >= 0; index--) {
const previous = compacted[index]
const prior = readFileEvidence(previous)
if (!prior || fileKey(previous, prior) !== key) continue
if (prior.hash !== file.hash || prior.exists !== file.exists) {
compacted.splice(index, 1)
continue
}
if (currentFile.complete) { compacted.splice(index, 1); continue }
if (prior.complete) { current = previous; currentFile = prior; compacted.splice(index, 1); continue }
if (prior.contentTruncated !== false || currentFile.contentTruncated !== false
|| prior.totalLines !== currentFile.totalLines
|| prior.endLine + 1 < currentFile.startLine || currentFile.endLine + 1 < prior.startLine) continue
const lines = new Map<number, string>()
let conflict = false
for (const part of [prior, currentFile]) {
for (const [offset, line] of part.content.split('\n').entries()) {
const number = part.startLine + offset
if (number > part.endLine) break
if (lines.has(number) && lines.get(number) !== line) conflict = true
lines.set(number, line)
}
}
if (conflict) continue
const startLine = Math.min(prior.startLine, currentFile.startLine)
const endLine = Math.max(prior.endLine, currentFile.endLine)
const content = Array.from({ length: endLine - startLine + 1 }, (_, offset) => lines.get(startLine + offset)!).join('\n')
// Merged ranges remain read-only evidence: no unverified full-file hash promotion.
const merged = { ...currentFile, startLine, endLine, content, complete: false, contentTruncated: false }
const data = JSON.stringify(merged)
if (content.length > 40_000 || data.length > 60_000) continue
currentFile = merged
current = { ...result, data, truncated: true }
compacted.splice(index, 1)
}
compacted.push(current)
}
return compacted
}
export function missingAgentRead(tool: AgentToolRequest, results: AgentToolResult[]): AgentToolRequest | undefined {
if (tool.name !== 'files.read') return tool
const observations = results.filter(result => result.moduleName === tool.moduleName)
.flatMap(result => { const file = readFileEvidence(result); return file && file.path === tool.args?.path ? [{ file, target: result.target }] : [] })
const latest = observations.at(-1)
const files = observations.filter(item => item.target === latest?.target && item.file.hash === latest?.file.hash).map(item => item.file)
if (files.some(file => file.complete)) return
// An unbounded read may be needed for an approved edit; partial ranges do not replace it.
if (tool.args?.startLine === undefined && tool.args?.lineCount === undefined) return tool
const start = tool.args?.startLine ?? 1
const total = files[0]?.totalLines
const end = Math.min(start + (tool.args?.lineCount ?? 200) - 1, total ?? Infinity)
let next = start
const ranges = files.map(file => [file.startLine, file.endLine]).sort((a, b) => a[0] - b[0])
for (const [from, to] of ranges) {
if (from > next) return { ...tool, args: { ...tool.args, startLine: next, lineCount: Math.min(end, from - 1) - next + 1 } }
if (to >= next) next = to + 1
if (next > end) return
}
if (next > end) return
return { ...tool, args: { ...tool.args, startLine: next, lineCount: end - next + 1 } }
}
export function describeAgentEvidence(result: AgentToolResult): string {
const file = readFileEvidence(result)
return `${result.moduleName ? `${result.moduleName} · ` : ''}${result.name}: ${result.status}${file ? ` · ${file.path}${file.exists ? ` · ${file.startLine}–${file.endLine}/${file.totalLines}줄` : ' · 파일 없음'}` : ''}${result.truncated ? ' (일부 결과)' : ''} · ${result.target} · ${result.observedAt}`
}
@@ -0,0 +1,34 @@
export const AGENT_PROVIDERS = ['A_COPILOT', 'OPENAI', 'ANTHROPIC', 'GEMINI', 'QWEN'] as const
export type AgentProvider = typeof AGENT_PROVIDERS[number]
export type AgentModelOption = { provider: AgentProvider; model: string; imagesEnabled: boolean }
export type AgentStatus = {
enabled: boolean
provider: string | null
model: string | null
imagesEnabled?: boolean
models: AgentModelOption[]
selectionSupported: boolean
}
export const AGENT_PROVIDER_LABELS: Record<AgentProvider, string> = {
A_COPILOT: 'A-Copilot', OPENAI: 'OpenAI', ANTHROPIC: 'Claude', GEMINI: 'Gemini', QWEN: 'Qwen'
}
export function normalizeAgentStatus(value: Partial<AgentStatus>): AgentStatus {
const selectionSupported = Array.isArray(value.models)
const candidates = value.enabled ? selectionSupported ? value.models! : [value] : []
const models = AGENT_PROVIDERS.flatMap(provider => {
const item = candidates.find(item => item.provider === provider && typeof item.model === 'string' && item.model.trim() && item.model.length <= 120)
return item ? [{ provider, model: item.model!.trim(), imagesEnabled: provider === 'QWEN' && item.imagesEnabled === true }] : []
})
return { enabled: models.length > 0, provider: models[0]?.provider ?? null, model: models[0]?.model ?? null,
imagesEnabled: models[0]?.imagesEnabled ?? false, models, selectionSupported }
}
export function selectedAgentModel(models: AgentModelOption[], preference: string | null): AgentModelOption | null {
return models.find(model => model.provider === preference) ?? models[0] ?? null
}
export function agentModelLabel(model: AgentModelOption): string {
return model.provider === 'A_COPILOT' ? 'A-Copilot' : `${AGENT_PROVIDER_LABELS[model.provider]} · ${model.model}`
}
@@ -3,6 +3,8 @@ import { createAgentSessionSync } from './agentSessionSync'
import { agentPlanRequestText, recoverInterruptedAgentMessages, resolveAgentContext, type AgentContext, type AgentContextRequest, type AgentPlanResponse, type AgentProgressStep } from './agentContext' import { agentPlanRequestText, recoverInterruptedAgentMessages, resolveAgentContext, type AgentContext, type AgentContextRequest, type AgentPlanResponse, type AgentProgressStep } from './agentContext'
import type { AgentImageReference, ChatAttachment } from '../../components/chatAttachments' import type { AgentImageReference, ChatAttachment } from '../../components/chatAttachments'
import type { AgentOperation, AgentToolRequest, AgentToolResult } from '../../../../shared/agentTools' 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 = { export type AgentFileChange = {
expectedHash?: string | null expectedHash?: string | null
@@ -53,6 +55,7 @@ export type AgentCommandExecution = {
} }
export type AgentPlan = { export type AgentPlan = {
conversationSummary?: string
codeReferences?: AgentCodeReference[] codeReferences?: AgentCodeReference[]
toolRequests?: AgentToolRequest[] toolRequests?: AgentToolRequest[]
evidence?: AgentToolResult[] evidence?: AgentToolResult[]
@@ -87,6 +90,7 @@ export type AgentRuntimeLog = {
} }
export type AgentConversationMessage = { export type AgentConversationMessage = {
conversationScope?: AgentConversationScope
id: string id: string
role: 'user' | 'assistant' role: 'user' | 'assistant'
content: string content: string
@@ -117,13 +121,6 @@ type UseAiAgentOptions = {
onResponse?: () => void onResponse?: () => void
} }
type AgentStatus = {
enabled: boolean
provider: string | null
model: string | null
imagesEnabled?: boolean
}
type AgentSessionResponse = { type AgentSessionResponse = {
sessionId: string | null sessionId: string | null
messages: AgentConversationMessage[] messages: AgentConversationMessage[]
@@ -132,7 +129,11 @@ type AgentSessionResponse = {
const sessionSyncs = new Map<string, ReturnType<typeof createAgentSessionSync>>() const sessionSyncs = new Map<string, ReturnType<typeof createAgentSessionSync>>()
export function useAiAgent({ accessToken, studentEmail, courseId, weekNumber, apiBaseUrl, getFreshAccessToken, onResponse }: UseAiAgentOptions) { export function useAiAgent({ accessToken, studentEmail, courseId, weekNumber, apiBaseUrl, getFreshAccessToken, onResponse }: UseAiAgentOptions) {
const [status, setStatus] = useState<AgentStatus>({ enabled: false, provider: null, model: null }) const modelPreferenceKey = `autonomy-studio.lms.agent-model:${JSON.stringify([apiBaseUrl, studentEmail.toLowerCase(), courseId])}`
const [statusSnapshot, setStatusSnapshot] = useState<{ scope: string; status: AgentStatus }>({ scope: '', status: normalizeAgentStatus({}) })
const status = statusSnapshot.scope === modelPreferenceKey ? statusSnapshot.status : normalizeAgentStatus({})
const [modelPreference, setModelPreference] = useState<{ scope: string; provider: string | null }>({ scope: '', provider: null })
const selectedModel = selectedAgentModel(status.models, modelPreference.scope === modelPreferenceKey ? modelPreference.provider : null)
const [statusLoading, setStatusLoading] = useState(Boolean(accessToken && courseId)) const [statusLoading, setStatusLoading] = useState(Boolean(accessToken && courseId))
const [historyLoading, setHistoryLoading] = useState(Boolean(accessToken && courseId)) const [historyLoading, setHistoryLoading] = useState(Boolean(accessToken && courseId))
const [historyError, setHistoryError] = useState<string | null>(null) const [historyError, setHistoryError] = useState<string | null>(null)
@@ -150,6 +151,18 @@ export function useAiAgent({ accessToken, studentEmail, courseId, weekNumber, ap
onResponseRef.current = onResponse onResponseRef.current = onResponse
const authenticated = Boolean(accessToken && courseId && studentEmail) const authenticated = Boolean(accessToken && courseId && studentEmail)
const selectProvider = (provider: string): void => {
if (pending || applyingRef.current || abortControllerRef.current || !status.models.some(model => model.provider === provider)) return
setModelPreference({ scope: modelPreferenceKey, provider })
try { window.localStorage.setItem(modelPreferenceKey, provider) } catch { /* Selection still works without persistent storage. */ }
}
useEffect(() => {
let provider: string | null = null
try { provider = window.localStorage.getItem(modelPreferenceKey) } catch { /* Use the first available model. */ }
setModelPreference({ scope: modelPreferenceKey, provider })
}, [modelPreferenceKey])
const requestApi = useCallback(async <T,>(path: string, options?: RequestInit): Promise<T> => { const requestApi = useCallback(async <T,>(path: string, options?: RequestInit): Promise<T> => {
const authorization = new Headers(options?.headers).get('Authorization') const authorization = new Headers(options?.headers).get('Authorization')
?? `Bearer ${await tokenReaderRef.current()}` ?? `Bearer ${await tokenReaderRef.current()}`
@@ -167,7 +180,7 @@ export function useAiAgent({ accessToken, studentEmail, courseId, weekNumber, ap
if (response.status === 400 && message?.split('\n').includes('property attachments should not exist')) { if (response.status === 400 && message?.split('\n').includes('property attachments should not exist')) {
throw new Error('연결된 서버가 에이전트 파일 첨부를 지원하지 않습니다. 백엔드를 업데이트한 뒤 다시 시도하거나 첨부 없이 질문해 주세요.') throw new Error('연결된 서버가 에이전트 파일 첨부를 지원하지 않습니다. 백엔드를 업데이트한 뒤 다시 시도하거나 첨부 없이 질문해 주세요.')
} }
if (response.status === 400 && /property (?:toolProtocol|toolResults|environment|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 에이전트 요청을 처리하지 못했습니다.') throw new Error(message ?? 'AI 에이전트 요청을 처리하지 못했습니다.')
} }
return response.json() as Promise<T> return response.json() as Promise<T>
@@ -205,27 +218,43 @@ export function useAiAgent({ accessToken, studentEmail, courseId, weekNumber, ap
useEffect(() => { useEffect(() => {
if (!accessToken || !courseId) { if (!accessToken || !courseId) {
setStatus({ enabled: false, provider: null, model: null }) setStatusSnapshot({ scope: modelPreferenceKey, status: normalizeAgentStatus({}) })
setStatusLoading(false) setStatusLoading(false)
return return
} }
let cancelled = false let cancelled = false
setStatusLoading(true) let revision = 0
let initialLoad = true
const refresh = (): void => {
const requestRevision = ++revision
if (initialLoad) setStatusLoading(true)
requestApi<AgentStatus>(`/chat/agent/status?courseId=${encodeURIComponent(courseId)}`) requestApi<AgentStatus>(`/chat/agent/status?courseId=${encodeURIComponent(courseId)}`)
.then((nextStatus) => { .then(nextStatus => {
if (!cancelled) setStatus(nextStatus) if (!cancelled && requestRevision === revision) setStatusSnapshot({ scope: modelPreferenceKey, status: normalizeAgentStatus(nextStatus) })
}) })
.catch(() => { .catch(() => {
if (!cancelled) setStatus({ enabled: false, provider: null, model: null }) if (!cancelled && requestRevision === revision) setStatusSnapshot({ scope: modelPreferenceKey, status: normalizeAgentStatus({}) })
}) })
.finally(() => { .finally(() => {
if (!cancelled) setStatusLoading(false) if (!cancelled && requestRevision === revision) {
initialLoad = false
setStatusLoading(false)
}
}) })
}
const refreshVisible = (): void => { if (!document.hidden) refresh() }
refresh()
window.addEventListener('focus', refresh)
document.addEventListener('visibilitychange', refreshVisible)
const timer = window.setInterval(refreshVisible, 30_000)
return () => { return () => {
cancelled = true cancelled = true
window.removeEventListener('focus', refresh)
document.removeEventListener('visibilitychange', refreshVisible)
window.clearInterval(timer)
} }
}, [accessToken, courseId, requestApi, weekNumber]) }, [accessToken, courseId, modelPreferenceKey, requestApi, weekNumber])
// The workspace stays mounted while other app pages or Q&A tabs are shown. // The workspace stays mounted while other app pages or Q&A tabs are shown.
// Request cancellation follows the conversation lifetime, not its visibility. // Request cancellation follows the conversation lifetime, not its visibility.
@@ -290,19 +319,28 @@ export function useAiAgent({ accessToken, studentEmail, courseId, weekNumber, ap
options: { options: {
showUserMessage?: boolean showUserMessage?: boolean
attachments?: ChatAttachment[] attachments?: ChatAttachment[]
loadInitialContext?: (signal: AbortSignal) => Promise<AgentContext>
loadRequestedContext?: (request: AgentContextRequest) => Promise<AgentContext> loadRequestedContext?: (request: AgentContextRequest) => Promise<AgentContext>
executeTool?: (request: AgentToolRequest) => Promise<AgentToolResult> executeTool?: (request: AgentToolRequest) => Promise<AgentToolResult>
environment?: 'local' | 'remote' environment?: 'local' | 'remote'
workspaceTarget?: string
workspaceRoot?: string
toolResults?: AgentToolResult[] toolResults?: AgentToolResult[]
} = {} } = {}
): Promise<void> => { ): Promise<void> => {
const request = rawRequest.trim() const request = rawRequest.trim()
if (!request || pending || historyLoading || abortControllerRef.current || !status.enabled) return if (!request || pending || historyLoading || statusLoading || abortControllerRef.current || !status.enabled || !selectedModel) return
const requestModel = selectedModel
const selection = status.selectionSupported ? { provider: requestModel.provider } : {}
const startedAt = Date.now() 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 = { const userMessage: AgentConversationMessage = {
id: `agent-user-${Date.now()}`, id: `agent-user-${Date.now()}`,
role: 'user', role: 'user',
content: request content: request,
...(conversationScope ? { conversationScope } : {})
} }
if (options.showUserMessage !== false) updateMessages((current) => [...current, userMessage]) if (options.showUserMessage !== false) updateMessages((current) => [...current, userMessage])
setPending(true) setPending(true)
@@ -317,26 +355,34 @@ export function useAiAgent({ accessToken, studentEmail, courseId, weekNumber, ap
} }
try { 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 textAttachments = (options.attachments ?? []).flatMap(file => file.kind === 'image' ? [] : [{ name: file.name, content: file.content }])
const imageAttachments: AgentImageReference[] = [] const imageAttachments: AgentImageReference[] = []
for (const file of options.attachments ?? []) { for (const file of options.attachments ?? []) {
if (file.kind !== 'image') continue if (file.kind !== 'image') continue
if (!status.imagesEnabled) throw new Error('현재 모델은 이미지 첨부를 지원하지 않습니다.') if (!requestModel.imagesEnabled) throw new Error('현재 모델은 이미지 첨부를 지원하지 않습니다.')
setActivity(`이미지 업로드 중: ${file.name}`) setActivity(`이미지 업로드 중: ${file.name}`)
const body = new FormData() const body = new FormData()
body.append('courseId', courseId) body.append('courseId', courseId)
if (selection.provider) body.append('provider', selection.provider)
if (Number.isInteger(weekNumber) && weekNumber >= 1) body.append('week', String(weekNumber)) if (Number.isInteger(weekNumber) && weekNumber >= 1) body.append('week', String(weekNumber))
body.append('file', file.file, file.name) body.append('file', file.file, file.name)
const uploaded = await requestApi<AgentImageReference>('/chat/agent/attachments/images', { method: 'POST', body, signal: abortController.signal }) const uploaded = await requestApi<AgentImageReference>('/chat/agent/attachments/images', { method: 'POST', body, signal: abortController.signal })
if (!currentRequest()) return
imageAttachments.push(uploaded) imageAttachments.push(uploaded)
if (options.showUserMessage !== false) updateMessages(current => current.map(item => item.id === userMessage.id ? { ...item, imageAttachments: [...imageAttachments] } : item)) 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 }) appendStep({ id: `image-${imageAttachments.length}`, kind: 'image', status: 'ok', name: file.name })
} }
const imageTokens = imageAttachments.map(image => image.token) const imageTokens = imageAttachments.map(image => image.token)
setActivity('요청 분석 중') setActivity('요청 분석 중')
const response = await resolveAgentContext({ const response = await resolveAgentContext({
initialContext: { modules, runtimeLogs, ...(options.toolResults?.length ? { toolResults: options.toolResults } : {}) }, initialContext,
signal: abortController.signal, signal: abortController.signal,
loadRequestedContext: options.loadRequestedContext, loadRequestedContext: options.loadRequestedContext,
executeTool: options.executeTool, executeTool: options.executeTool,
@@ -345,10 +391,10 @@ export function useAiAgent({ accessToken, studentEmail, courseId, weekNumber, ap
requestPlan: (context, finalize) => requestApi<AgentPlanResponse>('/chat/agent/plan', { requestPlan: (context, finalize) => requestApi<AgentPlanResponse>('/chat/agent/plan', {
method: 'POST', method: 'POST',
signal: abortController.signal, signal: abortController.signal,
body: JSON.stringify({ courseId, week: weekNumber, request: agentPlanRequestText(request, finalize), ...(options.executeTool ? { toolProtocol: 1, environment: options.environment } : {}), ...(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 = { const plan: AgentPlan = {
...response.plan, ...response.plan,
requestedModules: response.plan.requestedModules ?? [], requestedModules: response.plan.requestedModules ?? [],
@@ -363,6 +409,7 @@ export function useAiAgent({ accessToken, studentEmail, courseId, weekNumber, ap
revealStartedAt: Date.now(), revealStartedAt: Date.now(),
durationMs: Math.max(0, Date.now() - startedAt), durationMs: Math.max(0, Date.now() - startedAt),
content: plan.summary, content: plan.summary,
...(conversationScope ? { conversationScope } : {}),
provider: response.provider, provider: response.provider,
model: response.model, model: response.model,
usage: response.usage, usage: response.usage,
@@ -372,7 +419,7 @@ export function useAiAgent({ accessToken, studentEmail, courseId, weekNumber, ap
}]) }])
onResponseRef.current?.() onResponseRef.current?.()
} catch (error) { } catch (error) {
if (abortController.signal.aborted) return if (!currentRequest()) return
updateMessages((current) => [...current, { updateMessages((current) => [...current, {
id: `agent-error-${Date.now()}`, id: `agent-error-${Date.now()}`,
role: 'assistant', role: 'assistant',
@@ -460,11 +507,11 @@ export function useAiAgent({ accessToken, studentEmail, courseId, weekNumber, ap
} }
const openCodeReference = async (messageId: string, referenceId: string, open: (reference: AgentCodeReference, isCurrent: () => boolean) => Promise<void>): Promise<void> => { const openCodeReference = async (messageId: string, referenceId: string, open: (reference: AgentCodeReference, isCurrent: () => boolean) => Promise<void>): Promise<void> => {
if (navigatingRef.current || applyingRef.current || abortControllerRef.current || messageStateRef.current.session !== session) return if (navigatingRef.current || applyingRef.current || messageStateRef.current.session !== session) return
const message = messageStateRef.current.messages.find(message => message.id === messageId) const message = messageStateRef.current.messages.find(message => message.id === messageId)
const reference = message?.plan?.codeReferences?.find(reference => reference.id === referenceId) const reference = message?.plan?.codeReferences?.find(reference => reference.id === referenceId)
if (!reference) return if (!reference) return
const latestMessageId = messageStateRef.current.messages.at(-1)?.id const latestUserMessageId = messageStateRef.current.messages.findLast(message => message.role === 'user')?.id
const isCurrent = (): boolean => messageStateRef.current.session === session && messageStateRef.current.messages.some(message => message.id === messageId) const isCurrent = (): boolean => messageStateRef.current.session === session && messageStateRef.current.messages.some(message => message.id === messageId)
const update = (state: AgentCodeReferenceState): void => { const update = (state: AgentCodeReferenceState): void => {
if (isCurrent()) updateMessages(current => current.map(message => message.id === messageId ? { ...message, codeReferenceStates: { ...message.codeReferenceStates, [referenceId]: state } } : message)) if (isCurrent()) updateMessages(current => current.map(message => message.id === messageId ? { ...message, codeReferenceStates: { ...message.codeReferenceStates, [referenceId]: state } } : message))
@@ -472,7 +519,7 @@ export function useAiAgent({ accessToken, studentEmail, courseId, weekNumber, ap
navigatingRef.current = true navigatingRef.current = true
update({ status: 'opening' }) update({ status: 'opening' })
try { try {
await open(reference, () => isCurrent() && messageStateRef.current.messages.at(-1)?.id === latestMessageId && !abortControllerRef.current) await open(reference, () => isCurrent() && messageStateRef.current.messages.findLast(message => message.role === 'user')?.id === latestUserMessageId && !applyingRef.current)
update({ status: 'opened' }) update({ status: 'opened' })
} catch (error) { } catch (error) {
update({ status: 'error', error: error instanceof Error ? error.message : '코드 위치를 열지 못했습니다. 다시 분석을 요청해주세요.' }) update({ status: 'error', error: error instanceof Error ? error.message : '코드 위치를 열지 못했습니다. 다시 분석을 요청해주세요.' })
@@ -504,6 +551,10 @@ export function useAiAgent({ accessToken, studentEmail, courseId, weekNumber, ap
return { return {
...status, ...status,
provider: selectedModel?.provider ?? null,
model: selectedModel?.model ?? null,
imagesEnabled: selectedModel?.imagesEnabled ?? false,
selectProvider,
statusLoading: statusLoading || historyLoading, statusLoading: statusLoading || historyLoading,
pending, pending,
activity, activity,
+3
View File
@@ -1,4 +1,6 @@
{ {
"에이전트 모델 선택": "Agent model",
"사용 가능한 모델 없음": "No models available",
"전송": "Send", "전송": "Send",
"파일 첨부": "Attach files", "파일 첨부": "Attach files",
"첨부 제거": "Remove attachment", "첨부 제거": "Remove attachment",
@@ -739,6 +741,7 @@
"소스 파일": "Source files", "소스 파일": "Source files",
"{{count}}건": "{{count}} items", "{{count}}건": "{{count}} items",
"요청 분석 중": "Analyzing request", "요청 분석 중": "Analyzing request",
"모듈 상태 조회 중": "Checking module status",
"조회 결과 분석 중": "Analyzing retrieved results", "조회 결과 분석 중": "Analyzing retrieved results",
"추가 자료 조회 중": "Retrieving additional context", "추가 자료 조회 중": "Retrieving additional context",
"최종 답변 정리 중": "Preparing the final answer", "최종 답변 정리 중": "Preparing the final answer",
+55
View File
@@ -0,0 +1,55 @@
const {test} = require('node:test')
const assert = require('node:assert/strict')
const fs = require('node:fs/promises')
const path = require('node:path')
const os = require('node:os')
const vm = require('node:vm')
const ts = require('typescript')
const {createHash} = require('node:crypto')
const {spawnSync} = require('node:child_process')
test('remote visualization stages the bundled bridge over stdin and mounts that revision read-only', async () => {
const source = ts.createSourceFile('index.ts', await fs.readFile(path.join(__dirname, '../src/main/index.ts'), 'utf8'), ts.ScriptTarget.Latest, true)
const fn = source.statements.find(node => ts.isFunctionDeclaration(node) && node.name?.text === 'startRemoteVisualizationBridge')
assert.ok(fn)
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'lms-camera-bridge-'))
const resource = path.resolve(__dirname, '../resources/slam-ros-bridge')
const quote = value => "'" + value.replaceAll("'", "'\\''") + "'"
let launch, transfers = 0
const context = {
normalizeVisualizationStack: () => 'slam3d', normalizeRemoteTarget: target => target,
prepareRemoteRosNetworkConfig: async () => null, visualizationBridgeContainerName: () => 'slam-ros-bridge',
resolveVisualizationBridgeImage: () => 'fixture:arm64', bridgeArtifact: () => null,
slamRosBridgeImage: 'slam:local',
bridgeContainerCommand: () => 'python3 /app/bridge.py', resolveResourceContextPath: () => resource,
readFile: fs.readFile, join: path.join, createHash, quoteShellSingle: quote,
expandRemotePathVar: () => [], reportVisualizationProgress() {},
runUnqueuedSshOutput: async (_target, command, timeout, input) => {
transfers++
assert.equal(timeout, 30000)
assert.ok(input.length > 1000)
const {spawn} = require('node:child_process')
await new Promise((resolve, reject) => {
const child = spawn('bash', ['-c', command], {env: {...process.env, HOME: dir}, stdio:['pipe','ignore','pipe']})
let error = ''
child.stderr.on('data', data => {error += data})
child.on('error', reject)
child.on('close', code => code === 0 ? resolve() : reject(Error(error)))
child.stdin.end(input)
})
return ''
},
runSsh: async (_target, command) => { launch = command }
}
vm.runInNewContext(ts.transpileModule(fn.getText(source), {compilerOptions:{target:ts.ScriptTarget.ES2022}}).outputText, context)
try {
await context.startRemoteVisualizationBridge({workspaceRoot:'/workspace'}, 'slam3d', '77')
assert.equal(transfers, 1)
assert.match(launch, /-v "\$HOME\/\.autonomy-studio\/runtime\/bridges\/[a-f0-9]{64}:\/app:ro"/)
const roots = await fs.readdir(path.join(dir, '.autonomy-studio/runtime/bridges'))
for (const name of ['bridge.py','requirements.txt']) assert.equal(await fs.readFile(path.join(dir,'.autonomy-studio/runtime/bridges',roots[0],name),'utf8'), await fs.readFile(path.join(resource,name),'utf8'))
const syntax = spawnSync('bash', ['-n'], {input:launch,encoding:'utf8'})
assert.equal(syntax.status, 0, syntax.stderr)
assert.ok(launch.includes('docker run'))
} finally {await fs.rm(dir,{recursive:true,force:true})}
})
+4 -1
View File
@@ -35,7 +35,10 @@ for (const script of [run, build]) {
} }
assert.match(run, /lms-nav2-camera-test\*\) PACKAGE=camera_test_pkg; LAUNCH=camera.launch.py/); assert.match(run, /lms-nav2-camera-test\*\) PACKAGE=camera_test_pkg; LAUNCH=camera.launch.py/);
assert.match(build, /lms-nav2-camera-test\*\) TARGET=camera_test_pkg/); assert.match(build, /lms-nav2-camera-test\*\) TARGET=camera_test_pkg/);
assert.match(run, /--device "\$CAMERA_DEVICE:\$CAMERA_DEVICE"/); assert.match(run, /--device "\$camera_device:\$camera_device"/);
assert.match(run, /CAMERA_DEVICE" = \/dev/);
assert.match(run, /mapfile -t CAMERA_DEVICES/);
assert.doesNotMatch(run, /-v \/dev:\/dev/);
assert.match(run, /for capture_device in \/dev\/capture-vi-channel\*/); assert.match(run, /for capture_device in \/dev\/capture-vi-channel\*/);
assert.match(run, /--device "\$capture_device:\$capture_device"/); assert.match(run, /--device "\$capture_device:\$capture_device"/);
assert.match(run, /FASTDDS_DEFAULT_PROFILES_FILE/); assert.match(run, /FASTDDS_DEFAULT_PROFILES_FILE/);
+101 -68
View File
@@ -1,70 +1,103 @@
const assert = require('node:assert/strict'); const { test } = require('node:test')
const fs = require('node:fs'); const assert = require('node:assert/strict')
const path = require('node:path'); const fs = require('node:fs')
const vm = require('node:vm'); const vm = require('node:vm')
const ts = require('typescript')
const html = fs.readFileSync(path.join(__dirname, '../src/renderer/public/slam-viewer/index.html'), 'utf8'); function harness() {
const source = html.slice(html.indexOf('function hasLiveCameraState()'), html.indexOf("cameraPreview?.addEventListener('load'")); const exports = {}, requests = [], revoked = [], pending = new Map()
let now = 10000; let now = 10000, state = null, receivedAt = now, visible = false, enabledFetch = true
let requests = 0; let sequence = 0
let ok = true; function element() {
let visible = false; return { children: [], dataset: {}, src: '', append(...children) { this.children.push(...children); children.forEach(child => { child.parent = this }) }, remove() { this.parent.children = this.parent.children.filter(child => child !== this) }, removeAttribute() { this.src = '' }, addEventListener() {} }
let afterBlob = () => {}; }
const context = vm.createContext({ const root = element(), document = { hidden: false, createElement: element, addEventListener() {}, removeEventListener() {} }
ENABLE_CAMERA_PREVIEW: true, const globals = { exports, document, AbortController, Date: { now: () => now },
cameraFrameRequestInFlight: false, window: { setTimeout: () => 1, clearTimeout() {}, setInterval: () => 1, clearInterval() {} },
cameraFrameRetryAt: 0, URL: { createObjectURL: () => `blob:${++sequence}`, revokeObjectURL: url => revoked.push(url) },
cameraFrameObjectUrl: null, fetch: async (url, options) => {
cameraPreview: {}, requests.push({ url, signal: options.signal })
latestSlamState: null, if (pending.has(url)) await pending.get(url)
latestSlamStateReceivedAt: now, return { ok: enabledFetch, blob: async () => ({}) }
document: { hidden: false }, }
Date: { now: () => now }, }
BACKEND_URL: 'http://localhost:8765', vm.runInNewContext(ts.transpileModule(fs.readFileSync(require.resolve('../src/renderer/public/slam-viewer/camera-preview.js'), 'utf8'), { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 } }).outputText, globals)
AbortSignal, const controller = exports.createCameraPreview({ root, baseUrl: 'http://camera.test', getState: () => ({ state, receivedAt }), onAvailable: value => { visible = value } })
URL: { createObjectURL: () => 'blob:frame', revokeObjectURL() {} }, return { root, requests, revoked, pending, document, controller,
setCameraPreviewAvailable(value) { visible = value; }, state(value) { state = value; receivedAt = now }, advance(ms) { now += ms }, fail(value) { enabledFetch = !value }, visible: () => visible,
async fetch() { settle: () => new Promise(resolve => setImmediate(resolve)) }
requests++; }
return { ok, async blob() { afterBlob(); return {}; } };
},
});
vm.runInContext(source, context);
const refresh = () => context.refreshCameraFrame();
const available = (value) => {
context.latestSlamState = { topic_status: { camera_image: { available: value } } };
context.latestSlamStateReceivedAt = now;
};
(async () => { test('legacy camera polling stays compatible and skips absent, hidden and stale cameras', async () => {
await refresh(); const h = harness()
available(false); h.controller.setEnabled(true)
await refresh(); assert.equal(h.requests.length, 0)
assert.equal(requests, 0, 'Absent camera must not fetch frames'); h.state({ topic_status: { camera_image: { available: true } } })
available(true); h.controller.refresh(); await h.settle()
await refresh(); assert.equal(h.requests[0].url, 'http://camera.test/camera-frame')
assert.equal(requests, 1); assert.equal(h.root.children[0].children[1].src, 'blob:1')
assert.equal(context.cameraPreview.src, 'blob:frame'); h.document.hidden = true
now += 3001; h.controller.refresh(); await h.settle()
await refresh(); assert.equal(h.requests.length, 1)
assert.equal(requests, 1, 'Stale websocket state must stop polling'); h.advance(3001)
assert.equal(visible, false); h.controller.refresh()
available(true); assert.equal(h.visible(), false)
ok = false; assert.equal(h.root.children.length, 0)
await refresh(); assert.deepEqual(h.revoked, ['blob:1'])
await refresh(); })
assert.equal(requests, 2, 'HTTP errors must back off');
now += 5000; test('multiple camera frames are isolated and one unavailable camera cannot hide another', async () => {
available(true); const h = harness()
ok = true; h.state({ cameras: [{ id: 'video0', name: '/dev/video0', available: true }, { id: 'video2', name: '/dev/video2', available: true }] })
afterBlob = () => available(false); h.controller.setEnabled(true); await h.settle()
context.cameraPreview.src = ''; assert.equal(h.root.children.length, 2)
await refresh(); assert.equal(h.requests.length, 2)
assert.equal(context.cameraPreview.src, '', 'Late responses must not revive a disconnected camera'); assert.deepEqual(h.requests.map(request => request.url), ['http://camera.test/camera-frame?camera_id=video0', 'http://camera.test/camera-frame?camera_id=video2'])
assert.equal(context.cameraFrameRequestInFlight, false); const first = h.root.children[0].children[1].src
afterBlob = () => {}; h.state({ cameras: [{ id: 'video2', name: '/dev/video2', available: true }] })
available(true); h.controller.refresh(); await h.settle()
await refresh(); assert.equal(h.root.children.length, 1)
assert.equal(context.cameraPreview.src, 'blob:frame', 'Camera reconnect must resume frames'); assert.equal(h.root.children[0].dataset.cameraId, 'video2')
console.log('camera preview polling tests passed'); assert.ok(h.revoked.includes(first))
})().catch((error) => { console.error(error); process.exitCode = 1; }); assert.equal(h.visible(), true)
h.controller.dispose()
assert.equal(h.root.children.length, 0)
assert.equal(h.visible(), false)
})
test('in-flight requests are bounded and late frames cannot resurrect a removed or disabled camera', async () => {
const h = harness()
let release
h.pending.set('http://camera.test/camera-frame?camera_id=video0', new Promise(resolve => { release = resolve }))
h.state({ cameras: [{ id: 'video0', available: true }] })
h.controller.setEnabled(true)
h.controller.refresh()
assert.equal(h.requests.length, 1)
h.controller.setEnabled(false)
assert.equal(h.requests[0].signal.aborted, true)
release(); await h.settle()
assert.equal(h.root.children.length, 0)
assert.equal(h.visible(), false)
})
test('HTTP failures back off per camera and reconnecting restores frames', async () => {
const h = harness()
h.state({ cameras: [{ id: 'video0', available: true }] })
h.fail(true)
h.controller.setEnabled(true); await h.settle()
h.controller.refresh(); await h.settle()
assert.equal(h.requests.length, 1)
h.advance(5000); h.state({ cameras: [{ id: 'video0', available: true }] }); h.fail(false)
h.controller.refresh(); await h.settle()
assert.equal(h.requests.length, 2)
assert.ok(h.root.children[0].children[1].src.startsWith('blob:'))
})
test('camera identifiers are validated, deduplicated and limited without executing names as markup', async () => {
const h = harness()
h.state({ cameras: [{ id: '../etc', available: true }, ...Array.from({ length: 12 }, (_, i) => ({ id: `video${i}`, available: true, name: '<script>unsafe</script>' })), { id: 'video0', available: true }] })
h.controller.setEnabled(true); await h.settle()
assert.equal(h.requests.length, 8)
assert.equal(h.root.children.length, 8)
assert.equal(h.root.children[0].children[0].textContent, '<script>unsafe</script>')
})