diff --git a/TODO/done/34-fix-desktop-proxy-prompt-and-crash.md b/TODO/done/34-fix-desktop-proxy-prompt-and-crash.md new file mode 100644 index 0000000..de8b606 --- /dev/null +++ b/TODO/done/34-fix-desktop-proxy-prompt-and-crash.md @@ -0,0 +1,31 @@ +# MVP-34:修复 Desktop 代理、首条消息和会话崩溃 + +- 状态:已完成 +- 优先级:P0 + +## 目标 + +修复 deb 安装版无法自动读取 Shell 或 v2rayN 本地 HTTP 代理、新 Session 首条用户消息不显示,以及 Assistant Turn 结束时的 Renderer 崩溃。 + +## 任务 + +- [x] 自动代理在 Login Shell 未返回 HTTP 代理时,补充读取交互 Shell 和 v2rayN 配置。 +- [x] 新 Session 发送时立即显示用户消息,失败时恢复输入。 +- [x] 避免 Agent 运行中扫描正在写入的 Session 文件。 +- [x] 修复活动 Assistant Turn 转入历史时的 assistant-ui 索引越界。 +- [x] 增加三个问题的回归测试。 + +## 完成条件 + +- [x] `pnpm check` 通过。 +- [x] `pnpm build` 通过。 +- [x] Linux AppImage 和 deb 生成及包检查通过。 +- [x] 源码版和解包安装版的真实 Runtime smoke 通过。 +- [x] PR Quality 通过。 + +## 验证记录 + +- PR:#49 +- PR Quality:30341670776 +- 本地测试:25 个测试文件、162 个测试通过。 +- Linux AppImage 和 deb 生成、包检查、源码版与解包安装版 Runtime smoke 通过。 diff --git a/docs/product-spec.md b/docs/product-spec.md index 2ca3fcf..de281f7 100644 --- a/docs/product-spec.md +++ b/docs/product-spec.md @@ -224,7 +224,7 @@ OMP Desktop 不为 Electron 自身建立一套泛化代理。MVP 的网络设置 Runtime Network Profile 提供三种模式: 1. 不使用代理。 -2. 使用系统代理。 +2. 自动代理。 3. 手动代理。 手动代理配置包括: @@ -238,7 +238,7 @@ Runtime Network Profile 提供三种模式: 三种模式的解析规则: - 不使用代理:从最终环境中显式移除大小写代理变量。 -- 使用系统代理:保留 Desktop 启动环境中已有的大小写代理变量;未发现时明确报错,不静默直连。 +- 自动代理:先读取 Login Shell 和交互 Shell 的 HTTP 代理变量;未发现时读取 v2rayN `guiNConfig.json` 中的入站端口,仅在本地端口能返回 HTTP 代理响应时使用。不扫描其他端口;未发现时保持直连并在界面显示未注入代理。 - 手动代理:用用户输入的值覆盖继承的代理变量。 ### 9.1 OMP Runtime 与 RPC Bash diff --git a/src/main/runtime-environment.ts b/src/main/runtime-environment.ts index 95587bc..439be17 100644 --- a/src/main/runtime-environment.ts +++ b/src/main/runtime-environment.ts @@ -1,7 +1,8 @@ import { execFile } from 'node:child_process' -import { access } from 'node:fs/promises' +import { access, readFile } from 'node:fs/promises' import { constants } from 'node:fs' import { createConnection } from 'node:net' +import { homedir } from 'node:os' import { delimiter, join } from 'node:path' import { promisify } from 'node:util' import type { @@ -38,6 +39,51 @@ export type ResolvedRuntimeEnvironment = { sourceError?: string } +type ReadShellEnvironment = ( + shell: string, + args: string[], + env: NodeJS.ProcessEnv +) => Promise + +type DiscoveredLocalProxy = { url: string; source: string } +type DiscoverLocalProxy = () => Promise + +export function extractV2rayNPorts(value: unknown): number[] { + if (!value || typeof value !== 'object') return [] + const inbounds = (value as Record)['Inbound'] + if (!Array.isArray(inbounds)) return [] + return [ + ...new Set( + inbounds.flatMap((item) => { + if (!item || typeof item !== 'object') return [] + const port = (item as Record)['LocalPort'] + return typeof port === 'number' && + Number.isInteger(port) && + port >= 1 && + port <= 65_535 + ? [port] + : [] + }) + ) + ] +} + +export async function discoverV2rayNProxy( + dataHome = process.env['XDG_DATA_HOME'] || join(homedir(), '.local', 'share') +): Promise { + const configPath = join(dataHome, 'v2rayN', 'guiConfigs', 'guiNConfig.json') + const config = await readFile(configPath, 'utf8') + .then((text) => JSON.parse(text) as unknown) + .catch(() => undefined) + for (const port of extractV2rayNPorts(config)) { + if (await checkLocalHttpProxy(port, 700)) { + const url = `http://127.0.0.1:${port}` + return { url, source: `v2rayN (${url})` } + } + } + return undefined +} + function removeProxyVariables(env: NodeJS.ProcessEnv): void { for (const key of PROXY_KEYS) delete env[key] } @@ -86,7 +132,22 @@ export class RuntimeEnvironmentResolver { constructor( readonly runtimePath: string, private readonly electronEnv: NodeJS.ProcessEnv = process.env, - private readonly timeoutMs = 5_000 + timeoutMs = 5_000, + private readonly readShellEnvironment: ReadShellEnvironment = async ( + shell, + args, + env + ) => + ( + await execFileAsync(shell, args, { + encoding: 'utf8', + env, + maxBuffer: 4 * 1024 * 1024, + timeout: timeoutMs + }) + ).stdout, + private readonly discoverLocalProxy: DiscoverLocalProxy = () => + discoverV2rayNProxy(electronEnv['XDG_DATA_HOME']) ) {} async resolve( @@ -96,14 +157,15 @@ export class RuntimeEnvironmentResolver { let base: NodeJS.ProcessEnv let source: RuntimeNetworkStatus['source'] = 'login-shell' let sourceError: string | undefined + let discoveredProxySource: string | undefined try { - const { stdout } = await execFileAsync(shell, ['-ilc', 'env -0'], { - encoding: 'utf8', - env: this.electronEnv, - maxBuffer: 4 * 1024 * 1024, - timeout: this.timeoutMs - }) - base = parseShellEnvironment(stdout) + base = parseShellEnvironment( + await this.readShellEnvironment( + shell, + ['-ilc', 'env -0'], + this.electronEnv + ) + ) } catch (error) { base = { ...this.electronEnv } source = 'electron-fallback' @@ -113,8 +175,42 @@ export class RuntimeEnvironmentResolver { : 'Login Shell 探测失败' } + if ( + config.mode === 'auto' && + this.detectProxy(base, config, source).result !== 'http-proxy' + ) { + try { + const interactive = parseShellEnvironment( + await this.readShellEnvironment( + shell, + ['-ic', 'env -0'], + this.electronEnv + ) + ) + for (const key of PROXY_KEYS) { + if (interactive[key]) base[key] = interactive[key] + } + } catch { + // Login Shell 仍可用时,交互 Shell 探测失败不影响其他环境。 + } + } + + if ( + config.mode === 'auto' && + this.detectProxy(base, config, source).result !== 'http-proxy' + ) { + const discovered = await this.discoverLocalProxy().catch(() => undefined) + if (discovered) { + base['HTTPS_PROXY'] = discovered.url + discoveredProxySource = discovered.source + } + } + const env = { ...base } - const detected = this.detectProxy(base, config, source) + const environmentDetection = this.detectProxy(base, config, source) + const detected = discoveredProxySource + ? { ...environmentDetection, proxySource: discoveredProxySource } + : environmentDetection removeProxyVariables(env) if (config.mode === 'manual') { if (!config.manualPort) throw new Error('请输入 1–65535 的本地代理端口') @@ -303,3 +399,36 @@ export function checkLocalProxyPort( socket.once('error', () => finish(false)) }) } + +export function checkLocalHttpProxy( + port: number, + timeoutMs = 1_500 +): Promise { + if (!Number.isInteger(port) || port < 1 || port > 65_535) + return Promise.resolve(false) + return new Promise((resolve) => { + const socket = createConnection({ host: '127.0.0.1', port }) + let settled = false + let response = '' + const finish = (result: boolean): void => { + if (settled) return + settled = true + socket.destroy() + resolve(result) + } + socket.setTimeout(timeoutMs, () => finish(false)) + socket.once('connect', () => { + socket.write( + 'GET http://127.0.0.1:1/ HTTP/1.1\r\nHost: 127.0.0.1:1\r\nConnection: close\r\n\r\n' + ) + }) + socket.on('data', (chunk) => { + response += String(chunk) + if (response.includes('\r\n')) + finish(/^HTTP\/1\.[01] \d{3}/u.test(response)) + else if (response.length > 256) finish(false) + }) + socket.once('end', () => finish(false)) + socket.once('error', () => finish(false)) + }) +} diff --git a/src/main/runtime-ipc.ts b/src/main/runtime-ipc.ts index 62f1bc1..1e5a5d5 100644 --- a/src/main/runtime-ipc.ts +++ b/src/main/runtime-ipc.ts @@ -1919,9 +1919,7 @@ export function registerRuntimeIpc( await supervisor.restart(approvalMode) supervisor.setApprovalState(approvalMode, false) } - await supervisor.newSession() - await supervisor.prompt(input) - const snapshot = await supervisor.getState() + const snapshot = await supervisor.newSession() if (!snapshot.sessionId) throw new RuntimeFailure( 'PROTOCOL_ERROR', @@ -1949,8 +1947,11 @@ export function registerRuntimeIpc( workspace.id, snapshot.sessionId ) + await supervisor.prompt(input) return success({ - snapshot: approvalModeSaved ? snapshot : supervisor.snapshot, + snapshot: approvalModeSaved + ? supervisor.snapshot + : supervisor.setApprovalState(approvalMode, false, false), session: stateStore.applyPreferences(workspace.id, session) }) } catch (error) { diff --git a/src/renderer/app.tsx b/src/renderer/app.tsx index 3b3bf93..c687c2a 100644 --- a/src/renderer/app.tsx +++ b/src/renderer/app.tsx @@ -37,6 +37,7 @@ import { createConversationProjection, projectHistory, reduceOmpEvent, + removeConversationTurn, type ConversationProjection } from './omp-event-reducer' import { strings } from './strings' @@ -264,6 +265,7 @@ function Conversation({ !runtime.isAuthenticating && currentModelAvailable const composerRef = useRef(null) + const optimisticUserSequence = useRef(0) const composerComposingRef = useRef(false) const suppressComposerSelectionSyncRef = useRef(false) const [composerSelection, setComposerSelection] = useState( @@ -388,11 +390,20 @@ function Conversation({ } setError(null) setSending(true) + const visibleUserText = + visibleText.trim() || (attachments.length ? '已发送图片' : message) + const optimisticUserId = `optimistic-user-${++optimisticUserSequence.current}` const promptInput = { message, references, ...(attachments.length ? { images: attachments } : {}) } + setProjection((current) => + appendUserTurn(current, visibleUserText, Date.now(), optimisticUserId) + ) + onInput('') + onReferences([]) + onAttachments([]) const result = temporarySession && !busy ? await window.desktop.createSession( @@ -404,15 +415,19 @@ function Conversation({ ? await window.desktop.followUp(promptInput) : await window.desktop.prompt(promptInput) if (result.ok) { - setProjection((current) => appendUserTurn(current, visibleText)) - onInput('') onSentReferences(references) - onReferences([]) - onAttachments([]) setSlashMenuDismissedFor(null) setSlashSelectionIndex(0) if (temporarySession && result.data) onSessionCreated(result.data) - } else setError(result.error.message) + } else { + setProjection((current) => + removeConversationTurn(current, optimisticUserId) + ) + onInput(value) + onReferences(references) + onAttachments(attachments) + setError(result.error.message) + } setSending(false) } @@ -921,6 +936,7 @@ export function App(): React.JSX.Element { hasFreshSnapshot: false }) const projectionSessionId = useRef(undefined) + const skipHistoryRestoreKey = useRef(undefined) const projectionRef = useRef(projection) const projectionCache = useRef(new Map()) const attachmentCache = useRef( @@ -951,28 +967,31 @@ export function App(): React.JSX.Element { setComposerInput(value) }, []) - const applySnapshot = useCallback((snapshot: RuntimeSnapshot): void => { - const nextProjectionId = runtimeSessionKey(snapshot) - if (nextProjectionId !== projectionSessionId.current) { - projectionSessionId.current = nextProjectionId - setProjection(createConversationProjection()) - } - setSlashCatalog((current) => { - if (current.sessionKey === nextProjectionId) return current - const cached = nextProjectionId - ? (slashCatalogCache.current.get(nextProjectionId) ?? []) - : [] - return { - sessionKey: nextProjectionId, - commands: cached, - loading: false, - error: null, - stale: false, - hasFreshSnapshot: cached.length > 0 + const applySnapshot = useCallback( + (snapshot: RuntimeSnapshot, preserveProjection = false): void => { + const nextProjectionId = runtimeSessionKey(snapshot) + if (nextProjectionId !== projectionSessionId.current) { + projectionSessionId.current = nextProjectionId + if (!preserveProjection) setProjection(createConversationProjection()) } - }) - setRuntime(snapshot) - }, []) + setSlashCatalog((current) => { + if (current.sessionKey === nextProjectionId) return current + const cached = nextProjectionId + ? (slashCatalogCache.current.get(nextProjectionId) ?? []) + : [] + return { + sessionKey: nextProjectionId, + commands: cached, + loading: false, + error: null, + stale: false, + hasFreshSnapshot: cached.length > 0 + } + }) + setRuntime(snapshot) + }, + [] + ) const refreshWorkspaces = useCallback(async (offset = 0): Promise => { if (fixture) return @@ -1295,6 +1314,11 @@ export function App(): React.JSX.Element { !currentProjectionKey ) return + if (skipHistoryRestoreKey.current === currentProjectionKey) { + skipHistoryRestoreKey.current = undefined + projectionCache.current.set(currentProjectionKey, projectionRef.current) + return + } let cancelled = false const loadingTimer = window.setTimeout(() => { if (!projectionCache.current.has(currentProjectionKey)) @@ -1620,7 +1644,8 @@ export function App(): React.JSX.Element { onSessionCreated={({ snapshot, session }) => { setTemporarySession(false) setTemporaryApprovalMode('yolo') - applySnapshot(snapshot) + skipHistoryRestoreKey.current = runtimeSessionKey(snapshot) + applySnapshot(snapshot, true) sessionRequestId.current += 1 setSessions((current) => [ session, diff --git a/src/renderer/conversation-thread.tsx b/src/renderer/conversation-thread.tsx index a080c3e..ff13b67 100644 --- a/src/renderer/conversation-thread.tsx +++ b/src/renderer/conversation-thread.tsx @@ -1346,10 +1346,11 @@ export function ConversationRuntime({ onNew: async (message) => onSend(findText(message)), onCancel }) + const messageKey = messages.map((message) => message.id).join('\n') return ( - + {children} diff --git a/src/renderer/omp-event-reducer.ts b/src/renderer/omp-event-reducer.ts index 1d3fc71..29b60e1 100644 --- a/src/renderer/omp-event-reducer.ts +++ b/src/renderer/omp-event-reducer.ts @@ -801,11 +801,12 @@ export function reduceOmpEvent( export function appendUserTurn( projection: ConversationProjection, text: string, - now = Date.now() + now = Date.now(), + id?: string ): ConversationProjection { const state = cloneProjection(projection) state.turns.push({ - id: nextId(state, 'user'), + id: id ?? nextId(state, 'user'), role: 'user', text, createdAt: now @@ -813,6 +814,16 @@ export function appendUserTurn( return state } +export function removeConversationTurn( + projection: ConversationProjection, + turnId: string +): ConversationProjection { + const state = cloneProjection(projection) + state.turns = state.turns.filter((turn) => turn.id !== turnId) + if (state.activeTurnId === turnId) state.activeTurnId = undefined + return state +} + export function setTurnCollapsed( projection: ConversationProjection, turnId: string, diff --git a/tests/main/runtime-environment.test.ts b/tests/main/runtime-environment.test.ts index 82337dd..ea80663 100644 --- a/tests/main/runtime-environment.test.ts +++ b/tests/main/runtime-environment.test.ts @@ -1,8 +1,14 @@ // @vitest-environment node +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' import { createServer } from 'node:net' +import { tmpdir } from 'node:os' +import { join } from 'node:path' import { describe, expect, it } from 'vitest' import { + checkLocalHttpProxy, checkLocalProxyPort, + discoverV2rayNProxy, + extractV2rayNPorts, RuntimeEnvironmentResolver } from '../../src/main/runtime-environment' @@ -83,6 +89,79 @@ describe('RuntimeEnvironmentResolver', () => { expect(socks.error).toContain('本地 HTTP 入站') }) + it('自动模式在 Login Shell 没有代理时读取交互 Shell 配置', async () => { + const calls: string[][] = [] + const resolver = new RuntimeEnvironmentResolver( + process.execPath, + { SHELL: '/bin/bash', PATH: '/usr/bin:/bin' }, + 5_000, + async (_shell, args, env) => { + calls.push(args) + const values = + args[0] === '-ic' + ? { ...env, https_proxy: 'http://127.0.0.1:10808' } + : env + return `${Object.entries(values) + .map(([key, value]) => `${key}=${value}`) + .join('\0')}\0` + } + ) + + const resolved = await resolver.resolve({ mode: 'auto' }) + + expect(calls).toEqual([ + ['-ilc', 'env -0'], + ['-ic', 'env -0'] + ]) + expect(resolved.network.result).toBe('http-proxy') + expect(resolved.env).toMatchObject({ + PI_PROXY: 'http://127.0.0.1:10808', + HTTPS_PROXY: 'http://127.0.0.1:10808', + https_proxy: 'http://127.0.0.1:10808' + }) + }) + + it('自动模式在 Shell 没有代理时读取可达的 v2rayN 入站', async () => { + const resolver = new RuntimeEnvironmentResolver( + process.execPath, + { SHELL: '/bin/bash', PATH: '/usr/bin:/bin' }, + 5_000, + async (_shell, _args, env) => + `${Object.entries(env) + .map(([key, value]) => `${key}=${value}`) + .join('\0')}\0`, + async () => ({ + url: 'http://127.0.0.1:10808', + source: 'v2rayN (http://127.0.0.1:10808)' + }) + ) + + const resolved = await resolver.resolve({ mode: 'auto' }) + + expect(resolved.network).toMatchObject({ + result: 'http-proxy', + proxySource: 'v2rayN (http://127.0.0.1:10808)' + }) + expect(resolved.env).toMatchObject({ + PI_PROXY: 'http://127.0.0.1:10808', + HTTPS_PROXY: 'http://127.0.0.1:10808', + https_proxy: 'http://127.0.0.1:10808' + }) + }) + + it('只提取 v2rayN 配置中合法且去重的本地端口', () => { + expect( + extractV2rayNPorts({ + Inbound: [ + { LocalPort: 10808 }, + { LocalPort: 10808 }, + { LocalPort: 0 }, + { LocalPort: '7890' } + ] + }) + ).toEqual([10808]) + }) + it('诊断复制结果不包含代理凭据或普通环境变量', async () => { const resolver = new RuntimeEnvironmentResolver(process.execPath, { SHELL: '/missing-shell', @@ -113,4 +192,40 @@ describe('RuntimeEnvironmentResolver', () => { await expect(checkLocalProxyPort(address.port, 50)).resolves.toBe(false) await expect(checkLocalProxyPort(0)).resolves.toBe(false) }) + + it('只把返回 HTTP 响应的本地端口当作 HTTP 代理', async () => { + const httpProxy = createServer((socket) => { + socket.once('data', () => + socket.end( + 'HTTP/1.1 503 Service Unavailable\r\nConnection: close\r\n\r\n' + ) + ) + }) + await new Promise((resolve) => + httpProxy.listen(0, '127.0.0.1', resolve) + ) + const address = httpProxy.address() + if (!address || typeof address === 'string') throw new Error('监听失败') + + const dataHome = await mkdtemp(join(tmpdir(), 'omp-v2rayn-test-')) + const configDirectory = join(dataHome, 'v2rayN', 'guiConfigs') + try { + await mkdir(configDirectory, { recursive: true }) + await writeFile( + join(configDirectory, 'guiNConfig.json'), + JSON.stringify({ Inbound: [{ LocalPort: address.port }] }) + ) + + await expect(checkLocalHttpProxy(address.port, 200)).resolves.toBe(true) + await expect(discoverV2rayNProxy(dataHome)).resolves.toEqual({ + url: `http://127.0.0.1:${address.port}`, + source: `v2rayN (http://127.0.0.1:${address.port})` + }) + } finally { + await new Promise((resolve, reject) => + httpProxy.close((error) => (error ? reject(error) : resolve())) + ) + await rm(dataHome, { recursive: true, force: true }) + } + }) }) diff --git a/tests/renderer/app.test.tsx b/tests/renderer/app.test.tsx index 9259b48..fa45046 100644 --- a/tests/renderer/app.test.tsx +++ b/tests/renderer/app.test.tsx @@ -211,8 +211,10 @@ describe('App shell', () => { }) ) - fireEvent.change(composer, { target: { value: '/compact' } }) - fireEvent.keyDown(composer, { key: 'Enter' }) + const nextComposer = screen.getByRole('textbox', { name: '任务输入' }) + await waitFor(() => expect(nextComposer).toBeEnabled()) + fireEvent.change(nextComposer, { target: { value: '/compact' } }) + fireEvent.keyDown(nextComposer, { key: 'Enter' }) expect(window.desktop.followUp).toHaveBeenCalledTimes(1) expect( await screen.findByText('任务结束后可执行 Slash Command') @@ -351,8 +353,9 @@ describe('App shell', () => { const sendButton = screen.getByRole('button', { name: '发送' }) fireEvent.click(sendButton) - await waitFor(() => expect(sendButton).toBeDisabled()) - fireEvent.click(sendButton) + const pendingSendButton = screen.getByRole('button', { name: '发送' }) + await waitFor(() => expect(pendingSendButton).toBeDisabled()) + fireEvent.click(pendingSendButton) expect(window.desktop.prompt).toHaveBeenCalledTimes(1) finishPrompt?.({ ok: true, data: undefined }) }) @@ -495,6 +498,53 @@ describe('App shell', () => { 'yolo' ) ) - expect(await screen.findByText('第一条消息')).toBeInTheDocument() + expect( + await screen.findByText('第一条消息', { + selector: '[data-role="user"] *' + }) + ).toBeInTheDocument() + }) + + it('新 Session 创建请求返回前立即显示用户首条消息', async () => { + vi.mocked(window.desktop.getWorkspaces).mockResolvedValueOnce({ + ok: true, + data: { + activeWorkspaceId: 'workspace-1', + workspaces: [ + { + id: 'workspace-1', + path: '/tmp/workspace', + name: 'workspace', + available: true, + pinned: false, + addedAt: '2026-01-01T00:00:00.000Z', + lastUsedAt: '2026-01-01T00:00:00.000Z' + } + ], + hasMore: false + } + }) + vi.mocked(window.desktop.getRuntimeState).mockResolvedValueOnce({ + ok: true, + data: { + status: 'ready', + workspacePath: '/tmp/workspace', + sessionId: 'old-session', + isStreaming: false, + queuedMessageCount: 0 + } + }) + vi.mocked(window.desktop.createSession).mockReturnValueOnce( + new Promise(() => undefined) + ) + render() + + fireEvent.click(await screen.findByRole('button', { name: '新建对话' })) + const composer = screen.getByRole('textbox', { name: '任务输入' }) + fireEvent.change(composer, { target: { value: '面试会问什么' } }) + fireEvent.click(screen.getByRole('button', { name: '发送' })) + + expect(await screen.findByText('面试会问什么')).toBeInTheDocument() + expect(screen.getByRole('textbox', { name: '任务输入' })).toHaveValue('') }) }) diff --git a/tests/renderer/conversation-thread.test.tsx b/tests/renderer/conversation-thread.test.tsx index ff0b9a7..193ad51 100644 --- a/tests/renderer/conversation-thread.test.tsx +++ b/tests/renderer/conversation-thread.test.tsx @@ -46,6 +46,68 @@ function Harness({ } describe('ConversationThread', () => { + it('活动 Turn 完成并转入历史时不会按旧索引读取消息', async () => { + function CompletionHarness(): React.JSX.Element { + const [projection, setProjection] = useState(() => + projectionFrom([ + { type: 'agent_start' }, + { + type: 'message_end', + message: { + id: 'working', + role: 'assistant', + stopReason: 'toolUse', + content: [ + { + type: 'toolCall', + id: 'tool-1', + name: 'read', + arguments: { path: 'README.md' } + } + ] + } + } + ]) + ) + return ( +
+ + undefined} + onSend={async () => undefined} + projection={projection} + setProjection={setProjection} + > + + +
+ ) + } + + render() + fireEvent.click(screen.getByRole('button', { name: '完成' })) + + expect(await screen.findByText('最终回答')).toBeInTheDocument() + }) + it('工具审批使用中文单项和批量操作,默认焦点在允许', async () => { const deadline = Date.now() + 30_000 const initial = projectionFrom([{ type: 'agent_start' }])