diff --git a/.project-docs/30-worklog/tasks/20261012-agent-reasoning-39afbc21.md b/.project-docs/30-worklog/tasks/20261012-agent-reasoning-39afbc21.md new file mode 100644 index 00000000..5daf2066 --- /dev/null +++ b/.project-docs/30-worklog/tasks/20261012-agent-reasoning-39afbc21.md @@ -0,0 +1,69 @@ +# Task: Trace personal agent reasoning output + +## Identity + +- Task ID: 20261012-agent-reasoning-39afbc21 +- Mode: Feature +- Branch: codex/20261012-agent-reasoning-39afbc21-agent-reasoning +- Worktree: C:\Users\7brot\.codex\worktrees\makelore-agent-reasoning-39afbc21 +- Base commit: 7a7d1f7fd89fff3b77f2fd8f1986da65fb6223ea +- Owner: codex +- Status: Ready for Integration + +## Scope + +- Continue the diagnosed gap with user-authorized personal cloud chat rendering: separate thinking and tools from Markdown answers, retain per-message boundaries, and project existing saved process data for history/reopen. +- Reuse Yuxi native conversation semantics; no new persistence, model behavior, credentials or deployment. User now authorizes committing and merging the paired change and one fresh read-only Reviewer. + +## Intent And Constraints + +- Concurrent and Planning Gates Passed through official check/start/status; task ID, feature mode, isolated absolute worktree, branch and base match Identity. +- Project Context Loaded: entry and own task record, positioning, integrated current state, decisions/personal-cloud ADR, system architecture, domain and evidence context; unchanged prior session context reused. Yuxi has 64 peer records and Makelore 148. Peer task Scope/Intent/Promotion records are read-only; historical template scopes remain unknown without a concrete conflicting dependency. +- Yuxi owns cloud execution; Makelore Main owns credentials and transport; personal Agents and Code teacher consultation are separate consumers. +- Existing avatar integration and optional-purpose work remain separate. No claims about installed-client versions, deployed services, or the actual provider payload of a particular run. +- Same-task resume check/start/status passed; owner, branch, base and all peer registry fields unchanged. Reused already loaded context and read-only peer scopes. Planning Gate Passed for this implementation continuation. +- Plan: (1) reuse native history projection and prove ownership/paging with HTTP/PostgreSQL; (2) implement message-scoped SSE accumulation, expandable process and shared Markdown rendering; (3) verify stream replay, terminal/history replacement and thread isolation with focused unit and Electron scenarios, then type/lint/build and documentation gates. + +- Merge continuation: official same-task start/status passed; unchanged bases and all 64/148 peer registry entries permit reuse of prior read-only context and scope assessment. Planning Gate Passed. User approved fresh read-only reasoning_merge_review; full backend non-slow unit gate and independent review precede commit. User also approved primary ownership handoff: official release/adopt completed under dedicated integration tasks, preserving all five foreign records. + +## Outcome + +- Confirmed Yuxi already emits reasoning in compact personal SSE, but Makelore CloudChat and the personal history DTO omitted its display. +- Added message-scoped live reasoning, Markdown answers, collapsible tool inputs/results/errors and Run/thread isolation. Redis numeric cursors suppress replay duplicates; terminal history replaces matching live messages, while stale/failed refresh retains the full live answer. +- Formal, preview, shared and saved channel conversations use CloudMessageBody. Thinking starts expanded, collapses as answer text arrives and remains manually expandable. No invented or empty thinking sections; artifact downloads remain supported. +- Yuxi projects existing saved reasoning and tools through native parsers and visibility rules. Only state-confirmed terminal tool messages enter ordinary history; ownership and filtering-before-pagination remain enforced. No new schema, model requests or provider behavior. +- Main limits personal/channel history to display fields and omits raw internal metadata. Updated client README, Yuxi user reference and the implemented decision (created as a proposal before implementation). +- Review correction: compact SSE retains tool-started.input; the renderer adopts effective execution arguments by tool_call_id and prevents later model argument fragments from overriding them. Empty effective input is retained, and tool-error remains provisional until native Run reconciliation. + +## Verification + +- Client TypeScript passed. Affected ESLint: 0 errors, 2 pre-existing CloudChat warnings (Fast Refresh export / cleanup ref). Fixed a pre-existing line-break lint error in the touched it.each test. +- Client Vitest: cloud-chat-rendering, cloud-agents-main, cloud-agents-workflows and cloud-channel-conversations passed 80 tests; cloud-agents-page, cloud-agents-management and cloud-agents-guidance passed 36 tests (116 total / 7 files). +- Seven rendering tests cover replay (including large cursors), separate messages, wrong Run/thread, tool chunks/results/errors, Markdown/artifact links, history failure/staleness, reopening and absent thinking. Main tests cover personal/channel field projection. +- pnpm run build:vite passed with final production code. Electron cloud-chat-rendering.spec.ts passed 1 test against the built real window with test IPC/SSE fixtures. Screenshots were visually checked for thought/tool details, Markdown tables and reopened history. +- Yuxi Ruff check/format passed for the six changed Python files. WSL pytest: conversation message audits, queue history and provider reasoning passed 47 tests; repository memory history/workdir passed 10 (57 total). +- Two real uvicorn HTTP/PostgreSQL tests in test_makelore_agents.py passed (28 deselected): saved/legacy reasoning, literal user think tags, tool success/error, running-vs-terminal visibility, hidden metadata, other-user 404, 104 unique paged messages, and shared/application authorization. +- The first integration run found a detached fixture object read after DB commit; retaining its scalar conversation_id fixed the fixture and the rerun passed. +- verify_engineering_contracts.py passed (119 decisions / 5 workflows / 4 agents files / 172 docs / 34 routers / 248 web sources). Its unittest module passed 62 tests in WSL; Windows symlink privilege and path-separator failures were environmental, with no production workaround. +- VitePress build passed. Ignored dependency junctions reuse existing pinned client/docs dependencies; no dependency lockfiles changed. +- Task artifact directory: C:/Users/7brot/.codex/visualizations/2026/10/12/01a12e4f-9365-74c2-9e57-3f900a028618/reasoning-local (client-build.log, docs-build.log and rendered docs). +- Electron images: Makelore worktree test-results/cloud-chat-rendering-perso-31b4f--restores-them-after-reload/personal-chat-process.png and personal-chat-reopened.png. +- Temporary task PostgreSQL: WSL /tmp/makelore-reasoning-39afbc21/data, loopback 58413; stopped during final cleanup. No primary service database changed. +- Submission gate: WSL Python 3.12 non-slow unit suite passed 2270 tests / 1 skipped. Command: PYTHONPATH=/test-shim:package:. python -m pytest test/unit -m 'not slow' -q. The external namespace shim points the installed stdlib test package at this checkout for pytest and subprocess imports; production code and test assertions are unchanged. Full log: reasoning-local/backend-unit-all.log. Initial collection without the shim failed on seven stdlib-shadowed imports. +- Fresh independent reasoning_merge_review reviewed full requirements, tracked/untracked diffs, native protocol, persistence, tests and rules, and returned PASS with no open material findings. Its one effective-input finding was fixed and verified; both new regression assertions failed for the expected reason before production correction. +- After that correction: 80 affected client tests passed (plus 36 unchanged page tests), tsc/scoped lint, final production build, updated real Electron-window fixture, docs build and engineering checks passed. Backend non-slow suite again passed 2270 / 1 skipped. Earlier real HTTP/PostgreSQL history tests remain valid because their code path is unchanged. +- git diff --check passed; official task-aware drift and completion commands are the final documentation gates. + +## Follow-ups + +- User authorized committing the independently reviewed result and local main-branch integration. Both source task records must be imported unchanged. No push, installer packaging, deployment or real provider call is authorized. +- A reviewer test attempt created an ignored Yuxi .venv before being stopped; it is not staged and may be removed with the source worktree only after explicit cleanup consent. +- Release the paired history API and client for saved process display. Reasoning is limited to what the selected model actually returns and stores; older records without reasoning remain without it. +- Preserve the separate optional-purpose worktree and earlier avatar work. No unrelated task files changed. + +## Promotion Candidates + +- Target: canonical personal-cloud architecture/current state in both repositories. +- Proposal: record shared message presentation, separate live/saved process fields, native state-confirmed tool visibility and paired release dependency. +- Evidence: 116 client tests, 1 Electron scenario, 57 backend unit tests, 2 real HTTP/PostgreSQL tests and successful builds. Future impact: align formal/preview/shared/channel messages and avoid reconstructing durable history from short-lived SSE. +- Semantic conflicts: none found against integrated context; peers remain read-only. Human confirmation: no additional product decision needed; promotion belongs to an authorized integration task. diff --git a/README.md b/README.md index ae86d761..206298df 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,7 @@ Makelore 是一个面向软件、视觉创作、智能机器人与个人云智 - 共享开发浏览器:AI 编程右侧提供项目级浏览器,用户与 Agent 查看并调试同一实时页面、Console 和 Network,支持本地与公网开发地址。 - 后端边界:Renderer 通过 Main 所有的 Host API 访问认证、模型、同步、更新、语音、图像与运行时能力。 - 智能体头像:创建和编辑个人云智能体时可上传、更换或移除头像,支持 PNG、JPEG、WebP、GIF,最大 5 MB。复用 Yuxi 的 `Agent.icon` 和公开图片存储;上传后先预览,保存草稿后生效,正式使用的头像随发布版本固定。列表、编辑页、收到的分享、会话入口和版本对比展示对应头像,未设置或加载失败时显示默认图标。Main 负责文件选择、上传和相对地址解析,客户端不维护第二份头像数据;需配套更新 Yuxi 的 Makelore 头像接口。 +- 智能体对话:试用、正式对话和历史记录使用相同的 Markdown 正文与可折叠过程区,展示模型实际返回的思考、工具输入、结果和失败。实时事件按消息与工具身份累积,重连去重;开始输出正文后过程区自动收起,可手动展开。配套 Yuxi 历史接口投影已有持久思考和工具记录,重新打开及微信历史均可查看;没有思考内容时不显示空面板。 - 客户端更新:Electron Main 按平台与架构选择更新源并保留原始诊断;设置页只显示一条脱敏后的中文状态。正式源缺少对应安装包时保持错误并允许重试,不会误报为已是最新版。 - 个人资料:姓名、年龄、性别与个人头像按账号同步到云端;首次登录进入模块选择页时会要求先完善姓名,首页欢迎语和主平台左下角账号区优先展示个人资料姓名;头像支持 PNG、JPEG、WebP,保存时自动居中裁剪并以圆形缩略图展示,未设置或加载失败时回退为姓名首字母。 - 会话观察同步:本地编程会话在一轮问答完成并进入空闲后,后台通过个人资料 PUT 上传该会话截至当前的完整问答快照;只保留用户/助手自然语言文本,过滤代码、路径、日志、工具调用、附件与产物。同步只从本地上行,云端不回写、不恢复或删除本地会话;失败数据留在本地等待重试。 diff --git a/electron/services/cloud-agent-operations.ts b/electron/services/cloud-agent-operations.ts index e29b7645..de3c01b2 100644 --- a/electron/services/cloud-agent-operations.ts +++ b/electron/services/cloud-agent-operations.ts @@ -77,11 +77,26 @@ function projectRun(value: unknown): unknown { } function projectConversation(value: unknown): unknown { const source = object(value); - return { ...projectSession(source), messages: list(source.messages, item => copy(item, ['id', 'role', 'content', 'request_id', 'run_id', 'created_at'])), + return { ...projectSession(source), messages: list(source.messages, projectMessage), run: source.run == null ? null : projectRun(source.run), queued_requests: list(source.queued_requests, item => copy(item, ['request_id', 'thread_id', 'run_id', 'status', 'version'])), next_offset: source.next_offset ?? null }; } +function projectMessage(value: unknown): unknown { + const source = object(value); + return { ...copy(source, ['id', 'role', 'content', 'request_id', 'run_id', 'created_at', 'message_id', 'reasoning_content']), + ...(Array.isArray(source.tool_calls) ? { tool_calls: source.tool_calls.map(item => { + const tool = object(item); + return { ...copy(tool, ['id', 'name', 'args', 'status', 'error_message']), + ...(tool.tool_call_result == null ? {} : { tool_call_result: copy(tool.tool_call_result, ['content']) }) }; + }) } : {}), + }; +} +function projectHistory(value: unknown): unknown { + const source = object(value); + return { ...copy(source, ['thread_id', 'queued_requests', 'schedule_proposals', 'next_offset']), + messages: list(source.messages, projectMessage), run: source.run == null ? null : projectRun(source.run) }; +} function projectFiles(value: unknown): unknown { const source = object(value); return { session_id: source.session_id, thread_id: source.thread_id ?? null, @@ -253,7 +268,7 @@ const operations: Record = { saveBudget: { method: 'PUT', cloud: 'ws', path: '/agents/:slug/budget', body: ['request_limit_points', 'daily_limit_points'], output: budget }, threads: { method: 'GET', path: '/threads', query: { slug: 'slug', offset: 'offset', archived: 'archived' }, output: ['threads', 'next_offset'] }, createThread: { method: 'POST', path: '/agents/:slug/threads', body: ['thread_id', 'preview', 'expected_revision'], output: ['thread_id', 'client_thread_id'] }, - history: { method: 'GET', path: '/threads/:thread_id', query: { offset: 'offset' }, output: ['thread_id', 'messages', 'run', 'queued_requests', 'schedule_proposals', 'next_offset'] }, + history: { method: 'GET', path: '/threads/:thread_id', query: { offset: 'offset' }, output: ['thread_id', 'messages', 'run', 'queued_requests', 'schedule_proposals', 'next_offset'], project: projectHistory }, viewed: { method: 'POST', path: '/threads/:thread_id/viewed', body: ['run_id'], output: ['viewed'] }, submit: { method: 'POST', path: '/agents/:slug/requests', body: prompt, output: request }, preview: { method: 'POST', path: '/agents/:slug/preview', body: prompt, output: request }, diff --git a/shared/cloud-agents.ts b/shared/cloud-agents.ts index 52d50e7d..71fbb990 100644 --- a/shared/cloud-agents.ts +++ b/shared/cloud-agents.ts @@ -98,6 +98,14 @@ export interface CloudInterrupt { export interface CloudMessage { id: number; role: 'user' | 'assistant' | 'system'; content: string; request_id: string; run_id: string | null; created_at: string; + message_id?: string | null; + reasoning_content?: string; + tool_calls?: CloudToolCall[]; +} +export interface CloudToolCall { + id: string; name: string; args: unknown; status: string; + tool_call_result?: { content: unknown } | null; + error_message?: string | null; } export interface CloudHistory { thread_id: string; messages: CloudMessage[]; run: CloudRun | null; next_offset: number | null; diff --git a/src/pages/CloudAgents/CloudChannelConversations.tsx b/src/pages/CloudAgents/CloudChannelConversations.tsx index e5a4bfa5..46fa0566 100644 --- a/src/pages/CloudAgents/CloudChannelConversations.tsx +++ b/src/pages/CloudAgents/CloudChannelConversations.tsx @@ -1,8 +1,7 @@ import { useCallback, useEffect, useState } from 'react'; import { Download, Folder, RefreshCw } from 'lucide-react'; import { Button } from '@/components/ui/button'; -import ReactMarkdown from 'react-markdown'; -import remarkGfm from 'remark-gfm'; +import { CloudMessageBody } from './CloudMessageBody'; import { cloudAgentsApi } from '@/lib/cloud-agents-api'; import type { CloudAgentOperations, CloudChannelSession, CloudChannelConversation, CloudFile, CloudPendingOperation } from '../../../shared/cloud-agents'; import { CloudApproval, cloudStatus } from './CloudChat'; @@ -185,7 +184,7 @@ function ChannelSession({ sessionId, visible, refreshRevision, onBusy, onNewSess {error &&

{error}

}
{data?.messages.filter(message => message.role !== 'system').map(message =>
-

{message.role === 'user' ? '你 · 微信' : '智能体'}

href && /^(https?:|mailto:)/i.test(href) ? {children} : {children} }}>{message.content}
)} +

{message.role === 'user' ? '你 · 微信' : '智能体'}

)} {data && !data.messages.length &&

还没有消息。

}
{!recoveryReady && } diff --git a/src/pages/CloudAgents/CloudChat.tsx b/src/pages/CloudAgents/CloudChat.tsx index d7732f99..e2eb590b 100644 --- a/src/pages/CloudAgents/CloudChat.tsx +++ b/src/pages/CloudAgents/CloudChat.tsx @@ -6,8 +6,8 @@ import { cloudAgentsApi } from '@/lib/cloud-agents-api'; import type { CloudHistory, CloudInterrupt, CloudPrompt, CloudRequest, CloudRun, CloudThread, CloudScheduleProposal } from '../../../shared/cloud-agents'; import { CloudFiles } from './CloudFiles'; import './desktop-workspace.css'; -import ReactMarkdown, { defaultUrlTransform } from 'react-markdown'; -import remarkGfm from 'remark-gfm'; +import { CloudMessageBody } from './CloudMessageBody'; +import { reduceCloudStream, type CloudStream } from './cloud-chat-stream'; import { CloudPendingContext, useCloudPendingState, usePendingCloudInput } from './CloudPending'; import { agentExamples } from './agent-guidance'; @@ -39,7 +39,7 @@ export function CloudChat({ slug, previewRevision: latestRevision, initialThread const [intent, setIntent] = useState(null); const [busy, setBusy] = useState(false); const [error, setError] = useState(''); - const [liveText, setLiveText] = useState(''); + const [liveStream, setLiveStream] = useState({ runId: '', cursor: '', messages: [] }); const [progress, setProgress] = useState(''); const [interrupt, setInterrupt] = useState(null); const [loading, setLoading] = useState(true); @@ -151,6 +151,9 @@ export function CloudChat({ slug, previewRevision: latestRevision, initialThread const runId = request && active(request.status) ? request.run_id ?? undefined : run?.agent_run_id; const running = active(run?.status) || active(request?.status); + const liveHistoryReady = history?.run?.agent_run_id === liveStream.runId + ? !active(history.run.status) + : Boolean(history?.messages.some(item => item.run_id === liveStream.runId)); useEffect(() => { if (!runId || !running) return; let live = true; @@ -163,16 +166,17 @@ export function CloudChat({ slug, previewRevision: latestRevision, initialThread const payload = object(envelope.payload); const chunk = object(payload.chunk); const targetThread = thread?.thread_id ?? request?.thread_id; + if (envelope.run_id && envelope.run_id !== runId) return; if (envelope.thread_id && targetThread && envelope.thread_id !== targetThread) return; - for (const item of Array.isArray(payload.items) ? payload.items : [chunk]) { - const semantic = object(object(item).stream_event); - if (semantic.type === 'message_delta' && typeof semantic.content === 'string') setLiveText(v => v + semantic.content); - if (semantic.type === 'tool_call' && typeof semantic.name === 'string') setProgress('正在使用 ' + semantic.name); - } + setLiveStream(current => reduceCloudStream( + current.runId === runId ? current : { runId, cursor: '', messages: [] }, + envelope, targetThread ?? '', event.lastEventId, + )); if (event.type === 'interrupt') setInterrupt(chunk as CloudInterrupt); if (event.type === 'end') { source?.close(); - setLiveText(''); setProgress(''); + setLiveStream(current => ({ ...current, finished: true })); + setProgress(''); const id = thread?.thread_id ?? request?.thread_id; if (id) void readHistory(id, g).catch(e => { if (live) setError(message(e)); }); } @@ -191,14 +195,14 @@ export function CloudChat({ slug, previewRevision: latestRevision, initialThread if (!live || g !== generation.current) return; setRun(next); if (!active(next.status)) { - source?.close(); setLiveText(''); setProgress(''); setInterrupt(next.interrupt ?? null); + source?.close(); setProgress(''); setInterrupt(next.interrupt ?? null); void readHistory(next.thread_id, g).catch(e => { if (live) setError(message(e)); }); } }).catch(e => { if (live) setError(message(e)); }); }, 5000); return () => { live = false; source?.close(); window.clearInterval(timer); }; }, [runId, running, thread?.thread_id, request?.thread_id, readHistory]); - useEffect(() => { if (following.current && transcript.current) transcript.current.scrollTop = transcript.current.scrollHeight; }, [history?.messages.length, liveText, interrupt]); + useEffect(() => { if (following.current && transcript.current) transcript.current.scrollTop = transcript.current.scrollHeight; }, [history?.messages.length, liveStream, interrupt]); const stalePreview = previewRevision !== undefined && latestRevision !== undefined && previewRevision !== latestRevision; const rememberThread = (next: CloudThread) => { @@ -218,7 +222,7 @@ export function CloudChat({ slug, previewRevision: latestRevision, initialThread try { const accepted = await cloudAgentsApi.call(previewRevision === undefined ? 'submit' : 'preview', { slug, ...input }); if (!alive.current || g !== generation.current) return; - setRequest(accepted); setIntent(null); setQuery(''); setLiveText(''); setAttachmentIds([]); + setRequest(accepted); setIntent(null); setQuery(''); setLiveStream({ runId: '', cursor: '', messages: [] }); setAttachmentIds([]); rememberThread({ thread_id: accepted.thread_id, client_thread_id: clientId, agent_slug: slug, title: input.query.slice(0, 80), mode, draft_revision: previewRevision, updated_at: new Date().toISOString(), run_id: accepted.run_id, status: accepted.status, unread: false }); await readHistory(accepted.thread_id, g); @@ -231,7 +235,7 @@ export function CloudChat({ slug, previewRevision: latestRevision, initialThread if (mode === 'preview') setPreviewRevision(next?.draft_revision ?? revision); const g = ++generation.current; setThread(next); setClientId(next?.client_thread_id ?? crypto.randomUUID()); setHistory(null); - setRequest(null); setQueued([]); setRun(null); setLiveText(''); setInterrupt(null); setError(''); if (!keepQuery) setQuery(''); + setRequest(null); setQueued([]); setRun(null); setLiveStream({ runId: '', cursor: '', messages: [] }); setInterrupt(null); setError(''); if (!keepQuery) setQuery(''); setAttachmentIds([]); if (next) { setLoading(true); try { await readHistory(next.thread_id, g); } catch(e) { setError(message(e)); } finally { setLoading(false); } } }; @@ -280,7 +284,7 @@ export function CloudChat({ slug, previewRevision: latestRevision, initialThread
{ const el = e.currentTarget; following.current = el.scrollHeight - el.scrollTop - el.clientHeight < 64; }}>
{loading ?

正在读取对话…

- : !history?.messages.length && !liveText &&
+ : !history?.messages.length && !liveStream.messages.length &&

{mode === 'preview' ? '问它一个小问题' : '今天想让它帮你做什么?'}

{mode === 'preview' ? '看看它的回答是否符合你的要求。不满意就改一改,再试一次。' : '从下面选一句,或写下你自己的想法。'}

{examples.map(example => - : {children}, - }}>{item.content}
+ void cloudAgentsApi.download(history.thread_id, path).catch(e => onError(message(e)))} /> ); } diff --git a/src/pages/CloudAgents/CloudMessageBody.tsx b/src/pages/CloudAgents/CloudMessageBody.tsx new file mode 100644 index 00000000..d3299f89 --- /dev/null +++ b/src/pages/CloudAgents/CloudMessageBody.tsx @@ -0,0 +1,34 @@ +import ReactMarkdown, { defaultUrlTransform } from 'react-markdown'; +import remarkGfm from 'remark-gfm'; +import type { CloudToolCall } from '../../../shared/cloud-agents'; + +const display = (value: unknown) => typeof value === 'string' ? value : value == null ? '' : JSON.stringify(value, null, 2); + +export function CloudMessageBody({ content, reasoning_content, tool_calls = [], active = false, onDownload }: { + content: string; reasoning_content?: string; tool_calls?: CloudToolCall[]; active?: boolean; + onDownload?: (path: string) => void; +}) { + return
+ {(reasoning_content || tool_calls.length > 0) &&
+ {reasoning_content ? '思考过程' : '工具调用'}{tool_calls.length > 0 && ' · ' + tool_calls.length + ' 个工具'}{active && !content ? ' · 进行中' : ''} +
+ {reasoning_content &&
{reasoning_content}
} + {tool_calls.map(tool =>
+ {tool.name} · {tool.status === 'success' ? '已完成' : tool.status === 'error' ? '失败' : active ? tool.status === 'running' ? '执行中' : '准备调用' : '未完成'} +
+

输入

{display(tool.args)}
+ {tool.tool_call_result != null && <>

结果

{display(tool.tool_call_result.content)}
} + {tool.error_message &&

{tool.error_message}

} +
+
)} +
+
} + {content &&
+ url.startsWith('sandbox:/') ? url.slice(8) : defaultUrlTransform(url)} remarkPlugins={[remarkGfm]} components={{ + a: ({ href, children }) => href && !/^(https?:|mailto:|#)/i.test(href) + ? onDownload ? : {children} + : {children}, + }}>{content} +
} +
; +} diff --git a/src/pages/CloudAgents/cloud-chat-stream.ts b/src/pages/CloudAgents/cloud-chat-stream.ts new file mode 100644 index 00000000..2c858ede --- /dev/null +++ b/src/pages/CloudAgents/cloud-chat-stream.ts @@ -0,0 +1,74 @@ +import type { CloudToolCall } from '../../../shared/cloud-agents'; + +export interface CloudLiveMessage { + message_id: string; + content: string; + reasoning_content: string; + tool_calls: (CloudToolCall & { index?: number })[]; +} +export interface CloudStream { + runId: string; + cursor: string; + messages: CloudLiveMessage[]; + finished?: boolean; +} +export const object = (value: unknown): Record => + value !== null && typeof value === 'object' && !Array.isArray(value) ? value as Record : {}; + +/** Redis SSE cursors order replayed batches without treating subthread output as an answer. */ +export function reduceCloudStream(state: CloudStream, data: unknown, threadId: string, cursor: string): CloudStream { + if (cursor && /^\d+-\d+$/.test(cursor) && state.cursor) { + const [time, sequence] = cursor.split('-').map(BigInt); + const [previousTime, previousSequence] = state.cursor.split('-').map(BigInt); + if (time < previousTime || (time === previousTime && sequence <= previousSequence)) return state; + } + const envelope = object(data); + if (envelope.run_id && envelope.run_id !== state.runId) return state; + const next = { ...state, cursor: /^\d+-\d+$/.test(cursor) ? cursor : state.cursor }; + if (envelope.thread_id && envelope.thread_id !== threadId) return next; + const payload = object(envelope.payload); + const chunks = Array.isArray(payload.items) ? payload.items : [payload.chunk]; + let messages = state.messages; + for (const value of chunks) { + const chunk = object(value); + const event = object(chunk.stream_event); + if ((chunk.thread_id && chunk.thread_id !== threadId) || (event.thread_id && event.thread_id !== threadId)) continue; + if (['message_delta', 'tool_call', 'tool_call_delta'].includes(String(event.type)) && typeof event.message_id === 'string') { + const id = event.message_id; + const current = messages.find(message => message.message_id === id) + ?? { message_id: id, content: '', reasoning_content: '', tool_calls: [] }; + const updated = { ...current }; + if (event.type === 'message_delta') { + updated.content += typeof event.content === 'string' ? event.content : ''; + updated.reasoning_content += typeof event.reasoning_content === 'string' ? event.reasoning_content + : typeof event.additional_reasoning_content === 'string' ? event.additional_reasoning_content : ''; + } else { + const index = typeof event.index === 'number' ? event.index : 0; + const existing = current.tool_calls.find(tool => (event.tool_call_id && tool.id === event.tool_call_id) || tool.index === index); + const tool = { ...existing, id: typeof event.tool_call_id === 'string' ? event.tool_call_id : existing?.id ?? id + ':' + index, + name: typeof event.name === 'string' ? event.name : existing?.name ?? '工具', + args: existing && existing.status !== 'pending' ? existing.args + : event.type === 'tool_call' ? event.args ?? {} : String(existing?.args ?? '') + String(event.args_delta ?? ''), + status: existing?.status ?? 'pending', index }; + updated.tool_calls = existing ? current.tool_calls.map(item => item === existing ? tool : item) : [...current.tool_calls, tool]; + } + messages = messages.some(message => message.message_id === id) + ? messages.map(message => message.message_id === id ? updated : message) : [...messages, updated]; + } + const toolEvent = object(chunk.event); + const result = object(toolEvent.data); + if (toolEvent.method === 'tools' && typeof result.tool_call_id === 'string') { + messages = messages.map(message => ({ ...message, tool_calls: message.tool_calls.map(tool => { + if (tool.id !== result.tool_call_id) return tool; + if (result.event === 'tool-started') return { ...tool, args: result.input ?? tool.args, status: 'running' }; + if (result.event !== 'tool-finished') return tool; + const output = object(result.output); + const error = result.error || (output.status === 'error' ? output.content : null); + return { ...tool, status: error ? 'error' : 'success', + error_message: error ? (typeof error === 'string' ? error : JSON.stringify(error)) : null, + tool_call_result: { content: output.content ?? result.output ?? '' } }; + }) })); + } + } + return { ...next, messages }; +} diff --git a/tests/e2e/cloud-chat-rendering.spec.ts b/tests/e2e/cloud-chat-rendering.spec.ts new file mode 100644 index 00000000..adfdf2dd --- /dev/null +++ b/tests/e2e/cloud-chat-rendering.spec.ts @@ -0,0 +1,97 @@ +import { closeElectronApp, expect, getStableWindow, test } from './fixtures/electron'; + +test('personal chat displays thinking, tools and Markdown, then restores them after reload', async ({ launchElectronApp }, testInfo) => { + const app = await launchElectronApp({ skipSetup: true }); + try { + const page = await getStableWindow(app); + await expect(page.getByTestId('ai-module-selection-page')).toBeVisible(); + const applicationUrl = page.url(); + await page.goto('about:blank'); + // Cloud provider is deterministic here; the real history HTTP/PG boundary is tested in Yuxi. + await app.evaluate(({ ipcMain, BrowserWindow }) => { + BrowserWindow.getAllWindows()[0].setSize(1180, 820); + let complete = false; + Object.assign(globalThis, { finishRenderRun: () => { complete = true; } }); + const slug = 'ml-' + 'a'.repeat(32); + const configuration = { model: 'test', tools: [], knowledges: [], mcps: [], skills: [], preload_skills: [], subagents: [], tool_approval_mode: 'always_trust', max_execution_steps: 300, max_output_tokens: 4096, max_run_seconds: 600 }; + const draft = { slug, name: '渲染测试助手', purpose: '整理资料', system_prompt: '', configuration, + draft_revision: 1, published_version: 1, enabled: true, updated_at: '' }; + const run = { agent_run_id: 'render-run', request_id: 'render-request', thread_id: 'render-thread', agent_slug: slug, status: 'running', output: '', version: '1' }; + const thread = { thread_id: 'render-thread', client_thread_id: 'client', agent_slug: slug, title: '整理资料', mode: 'published', updated_at: '', run_id: run.agent_run_id, unread: false, status: 'running' }; + const result = (json: unknown) => ({ ok: true, data: { status: 200, ok: true, json } }); + ipcMain.removeHandler('hostapi:fetch'); + ipcMain.handle('hostapi:fetch', async (_event, request: { path?: string; body?: string }) => { + const path = request.path ?? ''; + const body = request.body ? JSON.parse(request.body) : {}; + if (path === '/api/auth/session/sync') return result({ success: true, session: { accessToken: 'fixture', tokenType: 'Bearer', expiresAt: Date.now() + 3600000, lastActiveAt: Date.now(), canRefresh: false } }); + if (path === '/api/auth/me') return result({ success: true, user: { username: 'creator', userId: 'creator', tenantId: null, deptId: null, authorities: [] }, moduleAccess: { programming: true, design: true, robot: true, cloud_agents: true } }); + if (path === '/api/works/user/agent-profile') return result({ success: true, profile: { display_name: '创作者', completed: true, version: 1 } }); + if (path.endsWith('/recovery')) return result({ recent: null, pending: [] }); + if (path.endsWith('/recovery/recent')) return result({}); + if (path.endsWith('/agents') || path.endsWith('/bootstrap')) return result({ agents: [draft], next_cursor: null }); + if (path.endsWith('/actions')) { + if (body.operation === 'threads') return result({ threads: [thread], next_offset: null }); + if (body.operation === 'history') return result({ thread_id: thread.thread_id, run: { ...run, status: complete ? 'completed' : 'running' }, next_offset: null, messages: complete ? [{ + id: 1, message_id: 'answer', role: 'assistant', run_id: run.agent_run_id, request_id: run.request_id, created_at: '', + content: '## 整理结果\n\n| 项目 | 状态 |\n| --- | --- |\n| 资料 | 已核对 |\n\n**可以使用**', + reasoning_content: '先核对来源,再整理结果。', + tool_calls: [{ id: 'lookup', name: '资料检索', args: { query: '实际检索资料' }, status: 'success', tool_call_result: { content: '找到两条资料' } }], + }] : [] }); + if (body.operation === 'run') return result({ ...run, status: complete ? 'completed' : 'running' }); + if (body.operation === 'viewed') return result({}); + if (body.operation === 'files') return result({ files: [] }); + if (body.operation === 'attachments') return result({ attachments: [] }); + if (body.operation === 'catalog') return result({ models: [{ id: 'test', name: '测试模型' }], resources: {}, pricing: null }); + if (body.operation === 'access') return result({ published_version: 1, enabled: true, versions: [], grants: [], applications: [], share_url: '', api_url: '' }); + if (body.operation === 'channelBindings') return result({ items: [], available_accounts: [] }); + return result({}); + } + if (path.startsWith('/api/cloud-agents/agents/')) return result(draft); + return result({ success: true }); + }); + }); + await page.addInitScript(() => { + const sources: EventTarget[] = []; + Object.assign(window, { renderSources: sources }); + class FixtureSource extends EventTarget { + onopen = null; onerror = null; close() {} + constructor(url: string) { super(); if (url.includes('/cloud-agents/runs/')) sources.push(this); } + } + Object.defineProperty(window, 'EventSource', { value: FixtureSource }); + }); + await page.goto(applicationUrl); + await page.getByTestId('ai-module-option-cloud_agents').click(); + await page.getByRole('button', { name: /渲染测试助手.*打开对话/ }).click(); + const emit = async (items: unknown[], cursor: string) => page.evaluate(({ items, cursor }) => { + const sources = (window as unknown as { renderSources: EventTarget[] }).renderSources; + sources.at(-1)!.dispatchEvent(new MessageEvent('messages', { lastEventId: cursor, data: JSON.stringify({ run_id: 'render-run', thread_id: 'render-thread', payload: { items } }) })); + }, { items, cursor }); + await expect.poll(() => page.evaluate(() => (window as unknown as { renderSources: EventTarget[] }).renderSources.length)).toBeGreaterThan(0); + await emit([{ stream_event: { type: 'message_delta', message_id: 'answer', reasoning_content: '先核对来源,再整理结果。' } }], '100-1'); + const process = page.getByTestId('cloud-message-process'); + await expect(page.getByLabel('智能体思考过程')).toBeVisible(); + await emit([ + { stream_event: { type: 'tool_call', message_id: 'answer', tool_call_id: 'lookup', name: '资料检索', args: { query: '测试资料' } } }, + { event: { method: 'tools', data: { event: 'tool-started', tool_call_id: 'lookup', input: { query: '实际检索资料' } } } }, + { event: { method: 'tools', data: { event: 'tool-finished', tool_call_id: 'lookup', output: { content: '找到两条资料' } } } }, + { stream_event: { type: 'message_delta', message_id: 'answer', content: '## 整理结果\n\n| 项目 | 状态 |\n| --- | --- |\n| 资料 | 已核对 |\n\n**可以使用**' } }, + ], '100-2'); + await expect(page.getByRole('heading', { name: '整理结果' })).toBeVisible(); + await expect(page.getByRole('table')).toBeVisible(); + await process.locator(':scope > summary').click(); + await process.getByText('资料检索 · 已完成', { exact: true }).click(); + await expect(process.getByText('找到两条资料', { exact: true })).toBeVisible(); + await expect(process.getByText('"实际检索资料"', { exact: false })).toBeVisible(); + await page.screenshot({ path: testInfo.outputPath('personal-chat-process.png') }); + await app.evaluate(() => (globalThis as unknown as { finishRenderRun: () => void }).finishRenderRun()); + await page.evaluate(() => (window as unknown as { renderSources: EventTarget[] }).renderSources.at(-1)!.dispatchEvent(new MessageEvent('end', { data: JSON.stringify({ thread_id: 'render-thread', payload: { status: 'completed' } }) }))); + await expect(page.getByRole('heading', { name: '整理结果' })).toHaveCount(1); + await page.reload(); + await page.getByRole('button', { name: /渲染测试助手.*打开对话/ }).click(); + await expect(page.getByRole('heading', { name: '整理结果' })).toBeVisible(); + await page.getByTestId('cloud-message-process').locator(':scope > summary').click(); + await expect(page.getByLabel('智能体思考过程')).toHaveText('先核对来源,再整理结果。'); + await expect(page.getByText('资料检索 · 已完成', { exact: true })).toBeVisible(); + await page.screenshot({ path: testInfo.outputPath('personal-chat-reopened.png') }); + } finally { await closeElectronApp(app); } +}); diff --git a/tests/unit/cloud-agents-main.test.ts b/tests/unit/cloud-agents-main.test.ts index 62590d6f..123f40da 100644 --- a/tests/unit/cloud-agents-main.test.ts +++ b/tests/unit/cloud-agents-main.test.ts @@ -45,6 +45,22 @@ beforeEach(() => { vi.clearAllMocks(); journalRecords.clear(); clearWorksSquareS afterEach(() => { instances.splice(0).forEach((module) => module.dispose()); clearWorksSquareSession(); vi.useRealTimers(); }); describe('Main cloud Agents boundary', () => { + it('projects saved reasoning and tool results for both personal and channel history without raw metadata', () => { + const message = { id: 1, role: 'assistant', content: 'answer', reasoning_content: 'thought', message_id: 'model-1', + extra_metadata: { internal: true }, tool_calls: [{ id: 'call-1', name: 'search', args: { query: 'topic' }, + status: 'success', internal: 'hidden', tool_call_result: { content: 'result', secret: 'hidden' } }] }; + const expected = { id: 1, role: 'assistant', content: 'answer', reasoning_content: 'thought', message_id: 'model-1', + tool_calls: [{ id: 'call-1', name: 'search', args: { query: 'topic' }, status: 'success', tool_call_result: { content: 'result' } }] }; + for (const input of [ + { operation: 'history', input: { thread_id: 'thread-1' } }, + { operation: 'channelConversation', input: { session_id: 'session-1' } }, + ]) { + expect(operationPlan(input).project({ messages: [message], run: null, next_offset: null })).toMatchObject({ messages: [expected] }); + expect(JSON.stringify(operationPlan(input).project({ messages: [message] }))).not.toContain('hidden'); + expect(JSON.stringify(operationPlan(input).project({ messages: [message] }))).not.toContain('extra_metadata'); + } + }); + it('uploads avatar bytes through Main and resolves Yuxi public URLs without exposing credentials', async () => { const directory = await mkdtemp(join(tmpdir(), 'makelore-avatar-')); try { @@ -106,8 +122,7 @@ describe('Main cloud Agents boundary', () => { expect(await module.execute({ operation: 'version', input: { slug: draft.slug, version: '1' } })).toMatchObject(avatar); }); - it.each(['file:///private/avatar.png', 'javascript:alert(1)', 'https://secret@images.example.test/avatar.png']) - ('falls back for an unusable legacy avatar URL %s', async icon => { + it.each(['file:///private/avatar.png', 'javascript:alert(1)', 'https://secret@images.example.test/avatar.png'])('falls back for an unusable legacy avatar URL %s', async icon => { const transport = vi.fn().mockResolvedValueOnce(session()).mockResolvedValueOnce(json({ ...draft, icon })); expect(await moduleFor(transport).get(draft.slug)).toMatchObject({ icon, icon_url: null }); }); diff --git a/tests/unit/cloud-agents-workflows.test.tsx b/tests/unit/cloud-agents-workflows.test.tsx index aa093aeb..d1e5936e 100644 --- a/tests/unit/cloud-agents-workflows.test.tsx +++ b/tests/unit/cloud-agents-workflows.test.tsx @@ -158,8 +158,8 @@ it('retries the same request after a lost response and renders batched native SS expect(submissions[0][1]).toEqual(submissions[1][1]); await act(async () => stream.dispatchEvent(new MessageEvent('messages', { data: JSON.stringify({ thread_id: 'thread-1', payload: { items: [ - { stream_event: { type: 'message_delta', content: '第一段' } }, - { stream_event: { type: 'message_delta', content: '文字' } }, + { stream_event: { type: 'message_delta', message_id: 'answer', content: '第一段' } }, + { stream_event: { type: 'message_delta', message_id: 'answer', content: '文字' } }, ] } }), }))); expect(screen.getByText('第一段文字')).toBeVisible(); diff --git a/tests/unit/cloud-chat-rendering.test.tsx b/tests/unit/cloud-chat-rendering.test.tsx new file mode 100644 index 00000000..fea328c4 --- /dev/null +++ b/tests/unit/cloud-chat-rendering.test.tsx @@ -0,0 +1,165 @@ +import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'; +import { afterEach, beforeEach, expect, it, vi } from 'vitest'; +import { CloudChat } from '@/pages/CloudAgents/CloudChat'; +import { reduceCloudStream, type CloudStream } from '@/pages/CloudAgents/cloud-chat-stream'; +import type { CloudHistory } from '../../shared/cloud-agents'; + +const api = vi.hoisted(() => ({ call: vi.fn(), events: vi.fn(), recovery: vi.fn(), remember: vi.fn(), download: vi.fn() })); +vi.mock('@/lib/cloud-agents-api', () => ({ cloudAgentsApi: api })); +class Stream extends EventTarget { close = vi.fn(); onopen = null; onerror = null; } +let stream: Stream; +let history: CloudHistory; +const run = { agent_run_id: 'run', request_id: 'request', thread_id: 'thread', agent_slug: 'agent', status: 'running', output: '', version: '1' }; +const thread = { thread_id: 'thread', client_thread_id: 'client', agent_slug: 'agent', mode: 'published', status: 'running', title: '对话', updated_at: '', unread: false, run_id: 'run' }; +beforeEach(() => { + vi.resetAllMocks(); + stream = new Stream(); + history = { thread_id: 'thread', messages: [], run, next_offset: null }; + api.recovery.mockResolvedValue({ recent: null, pending: [] }); + api.remember.mockResolvedValue({}); + api.download.mockResolvedValue({ saved: true }); + api.events.mockResolvedValue(stream); + api.call.mockImplementation(async (operation: string) => { + if (operation === 'threads') return { threads: [thread], next_offset: null }; + if (operation === 'history') return history; + if (operation === 'run') return history.run; + if (operation === 'files') return { files: [] }; + if (operation === 'attachments') return { attachments: [] }; + if (operation === 'viewed') return {}; + throw new Error(operation); + }); +}); +afterEach(cleanup); +async function emit(items: unknown[], cursor: string, threadId = 'thread') { + await act(async () => stream.dispatchEvent(new MessageEvent('messages', { + lastEventId: cursor, data: JSON.stringify({ run_id: 'run', thread_id: threadId, payload: { items } }), + }))); +} +const delta = (message_id: string, fields: Record) => ({ stream_event: { type: 'message_delta', message_id, ...fields } }); + +it('renders separate thinking and Markdown messages, ignores replay/subthreads, and restores durable history on reopen', async () => { + const view = render(); + await waitFor(() => expect(api.events).toHaveBeenCalledWith('run')); + await emit([delta('first', { reasoning_content: '先查资料' })], '100-1'); + expect(screen.getByLabelText('智能体思考过程')).toHaveTextContent('先查资料'); + expect(screen.getByTestId('cloud-message-process')).toHaveAttribute('open'); + await emit([delta('first', { reasoning_content: '先查资料' })], '100-1'); + await emit([delta('child', { content: '不应出现在主回答', reasoning_content: '子线程思考' })], '100-2', 'child'); + await emit([delta('first', { content: '**核对完成**' }), delta('second', { additional_reasoning_content: '再整理结论' })], '100-3'); + expect(screen.getByText('核对完成').tagName).toBe('STRONG'); + expect(screen.queryByText('不应出现在主回答')).not.toBeInTheDocument(); + expect(screen.getAllByLabelText('智能体思考过程')[0]).toHaveTextContent(/^先查资料$/); + expect(screen.getAllByTestId('cloud-message-process')[0]).not.toHaveAttribute('open'); + await emit([delta('second', { content: '| 项目 | 结果 |\n| --- | --- |\n| 校验 | 通过 |\n\n[报告](sandbox:/report.md)' })], '100-4'); + expect(screen.getByRole('table')).toHaveTextContent('校验通过'); + fireEvent.click(screen.getByRole('button', { name: '报告' })); + expect(api.download).toHaveBeenCalledWith('thread', '/report.md'); + history = { ...history, run: { ...run, status: 'completed' }, messages: [ + { id: 1, role: 'assistant', message_id: 'first', run_id: 'run', request_id: 'request', created_at: '', content: '**核对完成**', reasoning_content: '先查资料' }, + { id: 2, role: 'assistant', message_id: 'second', run_id: 'run', request_id: 'request', created_at: '', content: '最终结论', reasoning_content: '再整理结论' }, + ] }; + await act(async () => stream.dispatchEvent(new MessageEvent('end', { data: JSON.stringify({ thread_id: 'thread', payload: { status: 'completed' } }) }))); + await screen.findByText('最终结论'); + expect(screen.getAllByText('核对完成')).toHaveLength(1); + expect(screen.getAllByLabelText('智能体思考过程')).toHaveLength(2); + expect(screen.queryByRole('table')).not.toBeInTheDocument(); + view.unmount(); + render(); + await screen.findByText('最终结论'); + expect(screen.getAllByLabelText('智能体思考过程')[1]).toHaveTextContent('再整理结论'); + expect(screen.getAllByTestId('cloud-message-process').every(element => !element.hasAttribute('open'))).toBe(true); +}); + +it('associates tool arguments and results by call identity, keeps failures visible and clears a switched thread', async () => { + render(); + await waitFor(() => expect(api.events).toHaveBeenCalled()); + await emit([ + { stream_event: { type: 'tool_call_delta', message_id: 'm', tool_call_id: 'search', name: '查资料', args_delta: '{"query":', index: 0 } }, + { stream_event: { type: 'tool_call_delta', message_id: 'm', args_delta: '"天气"}', index: 0 } }, + { stream_event: { type: 'tool_call', message_id: 'm', tool_call_id: 'write', name: '写文件', args: { path: '/a' }, index: 1 } }, + { event: { method: 'tools', data: { event: 'tool-finished', tool_call_id: 'write', output: { content: '磁盘不足', status: 'error' } } } }, + { event: { method: 'tools', data: { event: 'tool-finished', tool_call_id: 'search', output: { content: '晴天', status: 'success' } } } }, + ], '101-0'); + const process = screen.getByTestId('cloud-message-process'); + expect(process).toHaveTextContent('查资料 · 已完成'); + expect(process).toHaveTextContent('写文件 · 失败'); + expect(within(process).getByText('{"query":"天气"}')).toBeInTheDocument(); + expect(within(process).getByText('晴天')).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: '新对话' })); + await waitFor(() => expect(screen.queryByTestId('cloud-message-process')).not.toBeInTheDocument()); + await emit([delta('m', { reasoning_content: '旧连接的迟到内容' })], '102-0'); + expect(screen.queryByText('旧连接的迟到内容')).not.toBeInTheDocument(); +}); + +it('retains partial thinking and answer if final history cannot be read', async () => { + render(); + await waitFor(() => expect(api.events).toHaveBeenCalled()); + await emit([delta('m', { reasoning_content: '已完成分析', content: '已生成部分结果' })], '1-0'); + const implementation = api.call.getMockImplementation()!; + api.call.mockImplementation(async (operation, ...args) => { + if (operation === 'history') throw new Error('历史暂不可用'); + return implementation(operation, ...args); + }); + await act(async () => stream.dispatchEvent(new MessageEvent('end', { data: JSON.stringify({ payload: { status: 'failed' } }) }))); + await screen.findByText('历史暂不可用'); + expect(screen.getByText('已生成部分结果')).toBeVisible(); + expect(screen.getByLabelText('智能体思考过程')).toHaveTextContent('已完成分析'); +}); + +it('leaves ordinary answers without an empty thinking panel', async () => { + history = { ...history, run: { ...run, status: 'completed' }, messages: [ + { id: 1, role: 'assistant', content: '普通回答', request_id: 'request', run_id: 'run', created_at: '' }, + ] }; + render(); + await screen.findByText('普通回答'); + expect(screen.queryByTestId('cloud-message-process')).not.toBeInTheDocument(); +}); + +it('keeps the current full stream while a stale partial history is waiting for terminal refresh', async () => { + history = { ...history, messages: [{ id: 1, message_id: 'm', role: 'assistant', content: '旧片段', + request_id: 'request', run_id: 'run', created_at: '' }] }; + render(); + await waitFor(() => expect(api.events).toHaveBeenCalled()); + await emit([delta('m', { content: '完整的新答案', reasoning_content: '已完成的思考' })], '200-1'); + expect(screen.queryByText('旧片段')).not.toBeInTheDocument(); + const implementation = api.call.getMockImplementation()!; + let finish!: (value: CloudHistory) => void; + api.call.mockImplementation((operation, ...args) => operation === 'history' + ? new Promise(resolve => { finish = resolve; }) : implementation(operation, ...args)); + await act(async () => stream.dispatchEvent(new MessageEvent('end', { data: JSON.stringify({ payload: { status: 'completed' } }) }))); + expect(screen.getByText('完整的新答案')).toBeVisible(); + expect(screen.queryByText('旧片段')).not.toBeInTheDocument(); + await act(async () => finish({ ...history, run: { ...run, status: 'completed' }, messages: [{ + ...history.messages[0], content: '完整的新答案', reasoning_content: '已完成的思考', + }] })); + expect(screen.getAllByText('完整的新答案')).toHaveLength(1); +}); + +it('uses effective execution input once each tool starts, including empty arguments', () => { + let state: CloudStream = { runId: 'run', cursor: '', messages: [] }; + state = reduceCloudStream(state, { payload: { items: [ + { stream_event: { type: 'tool_call', message_id: 'm', tool_call_id: 'search', name: '查资料', args: { query: '模型参数' }, index: 0 } }, + { stream_event: { type: 'tool_call', message_id: 'm', tool_call_id: 'other', name: '其他工具', args: { unused: true }, index: 1 } }, + ] } }, 'thread', '1-0'); + state = reduceCloudStream(state, { payload: { items: [ + { event: { method: 'tools', data: { event: 'tool-started', tool_call_id: 'search', input: { query: '实际执行参数' } } } }, + { event: { method: 'tools', data: { event: 'tool-started', tool_call_id: 'other', input: {} } } }, + ] } }, 'thread', '2-0'); + expect(state.messages[0].tool_calls).toMatchObject([ + { id: 'search', args: { query: '实际执行参数' }, status: 'running' }, + { id: 'other', args: {}, status: 'running' }, + ]); + expect(state.messages[0].tool_calls[1].args).toEqual({}); + state = reduceCloudStream(state, { payload: { chunk: { + stream_event: { type: 'tool_call', message_id: 'm', tool_call_id: 'search', args: { query: '模型参数' }, index: 0 }, + } } }, 'thread', '3-0'); + expect(state.messages[0].tool_calls[0].args).toEqual({ query: '实际执行参数' }); +}); + +it('keeps Redis cursor precision while ignoring other runs', () => { + let state: CloudStream = { runId: 'run', cursor: '', messages: [] }; + const event = { run_id: 'run', thread_id: 'thread', payload: { chunk: delta('m', { content: 'A' }) } }; + state = reduceCloudStream(state, event, 'thread', '9007199254740993-0'); + expect(reduceCloudStream(state, event, 'thread', '9007199254740992-1')).toBe(state); + expect(reduceCloudStream(state, { ...event, run_id: 'other' }, 'thread', '9007199254740994-0')).toBe(state); +});