Compare commits

...
Author SHA1 Message Date
weichen c6464bc4b0 Merge main into feature/chat-process-split-r1 2026-09-30 16:15:07 +00:00
weichen 65f7753a79 Merge pull request 'Record Gitea access facts in AGENTS.md' (#17) from feature/gitea-access-facts into main 2026-09-30 16:08:28 +00:00
weichen 8712fadb49 Record Gitea access facts in AGENTS.md
Why: agents rediscovered Gitea hosts and credential locations from
scratch each session, and the retired git-panel host kept failing with
TLS broken-pipe errors before the right host was recalled.

Evidence: curl git-home...:30319/api/v1/version with token from
~/.secrets/gitea -> {"version":"1.27.3"}; ssh -p 31411 git@git-ssh...
(BatchMode) -> "Hi there, weichen!" with key ~/.ssh/home_gitea (ED25519,
SHA256:GYXaOXIMeloQ1fKSfdJ/ndgPQzs/Ix7Hk62ekfkfQ20). Same section already
merged in Agent-System-Dev (PR #73).

Impact: AGENTS.md only; records hosts and credential locations, no
secret values (baseline hard boundary 2).

Refs: Agent-System-Dev PR #73
2026-09-30 16:06:16 +00:00
weichen 64837575a9 Port artifact events to the items stream and finish the main merge
Why: the merge commit left tsc broken in two spots - an artifact.created
handler still pushing to the removed LiveState.parts, and main's newer ui
icons not yet checked out in this worktree; also Part was imported unused.

Evidence: artifact.created now appends an artifact LiveItem (process=true,
matching the backend's ae8e5d0) projected to artifact_ref by
liveToMessage; new test asserts it lands after its tool call. tsc -b exit
0 after initializing the ui submodule checkout; vitest 33/33.

Impact: live artifact chips render inside the process stream and fold on
reconcile; no functional change elsewhere.

Refs: feature/chat-process-split-r1
2026-09-30 15:47:22 +00:00
weichen 765789f34d Merge main into feature/chat-process-split-r1
Why: main advanced with PR #15/#16 (part-order preservation, artifact
downloads, cross-session UI); task-sync requires the branch to carry it.

Evidence: resolution keeps the arrival-ordered items stream from this
branch (tool-driven process marking, pure/testable logic in live.ts) and
adopts main's rendering and ids: groupParts in-place process blocks,
real tool callIds on live tool parts, messageparts utilities, and the
restored phase-driven showProcessInline predicate alongside the CF0
long-reply excerpt that main's rewrite had dropped. vitest 32/32
(includes main's messageparts suite and this branch's 12 live cases);
tsc -b clean.

Impact: single coherent live pipeline - items accumulate in event order,
liveToMessage projects Parts with tool-driven process marks, rendering
folds consecutive process blocks in place; responding still collapses
the process area (Cursor-style) as originally reviewed.

Refs: feature/chat-process-split-r1
2026-09-30 15:43:49 +00:00
weichen 754ce83ffd Interleave live parts per turn and mark commentary process on tool.call
Why: liveToMessage emitted all text buckets then all tools, while the
persisted message interleaves per turn (thinking, text, calls), so parts
reordered on reconcile with 2+ tool turns. Commentary stayed in the reply
area until the next turn's text bucket appeared, because the process flag
was positional (i < length - 1) instead of tool-driven.

Evidence: LiveState now holds an arrival-ordered items stream
(thinking/text deltas merge into their last segment; tools land in place),
so event order - which matches the backend's per-turn append order - maps
1:1 onto persisted parts. A text segment is process iff a tool.call lands
before the next thinking/text segment, aligning with the backend's
Process: len(calls) > 0; the collapse predicate moved to testable
showProcessInline. Long replies (>800 chars) now show a 100-char excerpt
and label the count as Markdown source length. vitest 27/27 pass (was 5
live cases, now 12 incl. multi-turn order, immediate process marking,
collapse predicate); tsc -b clean.

Impact: streaming UI no longer reshuffles parts across turns or on final
reconcile; tool-turn commentary folds into the process area as soon as the
tool call arrives. Residual edge (documented in live.ts): a silent
tool-only turn with no text and no thinking attributes its call to the
previous text segment.

Refs: code review of feature/chat-process-split-r1 (2026-09-30)
2026-09-30 15:30:41 +00:00
weichen 22a87232d7 Untrack the node_modules symlink and ignore both directory and link forms
Why: 642299d committed node_modules as a symlink pointing at the absolute
path /home/weichen/projects/Agent-System-Dev/modules/frontend/node_modules.
The .gitignore rule "node_modules/" only matches directories, not symlinks,
so the link slipped through and other clones would receive this machine's
path while normal installs get bypassed.

Evidence: git ls-tree HEAD node_modules => mode 120000 blob before this
commit; git check-ignore -v node_modules exits 0 with rule
".gitignore:2:node_modules" after unstaging the link.

Impact: fresh clones no longer carry a machine-specific symlink; local
installs work normally. No code changes.

Refs: code review of feature/chat-process-split-r1 (2026-09-30)
2026-09-30 15:28:29 +00:00
weichen fb219e04a3 Merge pull request 'Preserve part order, wire artifact downloads, and upgrade file management' (#15) from feature/chat-order-artifact-files into main 2026-09-30 15:23:49 +00:00
weichen 642299ddd4 Collapse process output the moment the final reply starts streaming (Cursor-style)
Why: 流式期间思考/工具/中间轮解说与最终回复混排在中栏,用户难以扫读答复;参考 Cursor:回复开始输出时过程即时折叠,可手动展开回看。
Evidence: npx tsc --noEmit 通过;npx vitest run 20/20(新增 test/live.test.ts:分桶、除末桶外 process 标记、终态同构)。
Impact: LiveState.text 改为按 delta index 分桶的 texts;responding 阶段过程折叠、acting 重展开;终态新增 >800 字长回复折叠(CF0);无契约变更。
Refs: 任务卡 feature/chat-process-split-r1;配套 backend 提交 0e76f59(工具轮文本 process=true)。
2026-09-30 14:54:37 +00:00
7 changed files with 317 additions and 61 deletions
+2 -1
View File
@@ -1,3 +1,4 @@
node_modules/
# 无斜杠形式同时忽略目录与符号链接(目录式规则不匹配 symlink,曾致链接被提交)。
node_modules
dist/
*.local
+17
View File
@@ -17,3 +17,20 @@
- 模块边界、公开契约与隔离档位见 `policy/modules/`(如有)与 PROJECT.md。
- 跨仓 gitlink 变更须经组合验证后在 Dev 仓推进;受保护分支经 PR 合入。
- 验证命令见 PROJECT.md「验证」节;完成声明须附实际命令输出证据。
## Gitea 与 Git 访问事实(2026-09-30 实测)
历史文档与旧链接里的 `git-panel.u1680832.nyat.app:30319` 已失效(TLS 握手失败),
见到一律按过期处理,不要再试。当前权威通道:
- **Web / API(HTTPS)**:`https://git-home.u1680832.nyat.app:30319`(Gitea 1.27.3,
API 前缀 `/api/v1`;偶发 broken pipe / 超时,重试即可)。
- **Git over SSH**:`ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/<Repo>.git`
(各仓 origin 与 gitlink 均为此格式)。
- **API token**:`~/.secrets/gitea`,两行键值 `URL_PREFIX=…` 与
`ACCESS_TOKEN=<40 位 hex>`;取值 `grep ACCESS_TOKEN ~/.secrets/gitea | cut -d= -f2-`,
用作 `Authorization: token <值>` 请求头。**token 值不入仓、不回显、不写进产出物**
(baseline 硬边界 2:只记录来源、字段与可用状态)。
- **SSH key**:`~/.ssh/home_gitea`(ED25519,公钥指纹
`SHA256:GYXaOXIMeloQ1fKSfdJ/ndgPQzs/Ix7Hk62ekfkfQ20`,Gitea 侧密钥名 GMKtec);
`~/.ssh/config` 已有 `git-ssh.u1680832.nyat.app` 主机条目,克隆/推送直接可用。
+35 -17
View File
@@ -4,6 +4,7 @@ import { useEffect, useMemo, useRef, useState } from 'react'
import { marked } from 'marked'
import { api } from '../api'
import { store, useStore, activeRun, type LiveState, type ContextObs } from '../store'
import { liveToMessage, showProcessInline } from '../live'
import type { Message, Part, HITLCard } from '../contracts'
import { UIRenderer, InlineConfirmCard, hasOpenConfirm } from '@ui'
import { docBridge } from './Workbench'
@@ -93,6 +94,26 @@ function ProcessCollapse({ parts }: { parts: Part[] }) {
)
}
// 长回复折叠阈值(CF0):终态后超长正文收成摘要行,点击展开全文;live 期间全文可见。
const LONG_REPLY_CHARS = 800
function LongReplyCollapse({ text }: { text: string }) {
// 摘句取压缩空白后的前 100 字;字数按 Markdown 源码长度计。
const excerpt = text.replace(/\s+/g, ' ').trim().slice(0, 100)
return (
<details className="process-collapse long-reply">
<summary>
<span>📄 长回复</span>
<span>{text.length} 字(按 Markdown 源码计)</span>
{excerpt && <span style={{ color: 'var(--text-faint)' }}>{excerpt}…</span>}
</summary>
<div className="body">
<div className="part-text" dangerouslySetInnerHTML={{ __html: marked.parse(text) }} />
</div>
</details>
)
}
function AssistantMessage({ msg, live }: { msg: Message; live?: LiveState | null }) {
const autoCollapse = useStore(s => s.ui.autoCollapseProcess)
const ctxObs = useStore(s => (msg.runId ? s.contextObs[msg.runId] : undefined))
@@ -101,8 +122,10 @@ function AssistantMessage({ msg, live }: { msg: Message; live?: LiveState | null
// 工具调用显示在它真正发生的位置,不被抽到消息头部重排。
const blocks = useMemo(() => groupParts(parts), [parts])
// 流式中:过程部件实时展开;run 终态后:按偏好折叠为摘要行(可回看)。
const showProcessInline = live || !autoCollapse
// 流式期间:工具/思考阶段过程实时展开;一旦进入 responding(最终回复开始流式)
// 立即折叠过程,只显式显示回复(Cursor 式);工具恢复(acting)时重新展开。
// run 终态后:按偏好折叠为摘要行(可回看)。
const showProcess = showProcessInline(live ?? null, autoCollapse)
const lastContentIdx = (() => {
for (let i = blocks.length - 1; i >= 0; i--) if (blocks[i].kind === 'content') return i
return -1
@@ -115,28 +138,23 @@ function AssistantMessage({ msg, live }: { msg: Message; live?: LiveState | null
{live && <span className="live-bar"><span className="live-dot" />{live.phase}</span>}
</div>
{blocks.map((b, i) => b.kind === 'process'
? (showProcessInline
? (showProcess
? <div className="part-group" key={i}>{b.parts.map((p, j) => <div className="part" key={j}><PartView part={p} live={!!live} /></div>)}</div>
: <ProcessCollapse key={i} parts={b.parts} />)
: <div className="part" key={i}>
<PartView part={b.part} live={!!(live && i === lastContentIdx && b.part.type === 'text')} />
</div>)}
: (() => {
// CF0:非 live 的超长文本部件折叠为摘要行;live 与短文本不受影响。
if (!live && b.part.type === 'text' && (b.part.text?.length ?? 0) > LONG_REPLY_CHARS)
return <LongReplyCollapse key={i} text={b.part.text ?? ''} />
return <div className="part" key={i}>
<PartView part={b.part} live={!!(live && i === lastContentIdx && b.part.type === 'text')} />
</div>
})())}
{live && <LiveAgentNotes live={live} />}
{!live && ctxObs && <ContextObsView obs={ctxObs} />}
</div>
)
}
// live 投影:run 进行中没有已落库的助手消息,把 LiveState 合成为同构的临时消息。
// LiveState.parts 已按事件到达序维护(store.applyEvent),直接透传即保序。
function liveToMessage(live: LiveState): Message {
if (live.parts.length === 0) {
// 兜底(无事件到达的短暂窗口):只给 phase 占位,避免空白。
return { id: `live:${live.runId}`, conversationId: '', role: 'assistant', runId: live.runId, parts: [], createdAt: '' }
}
return { id: `live:${live.runId}`, conversationId: '', role: 'assistant', runId: live.runId, parts: live.parts, createdAt: '' }
}
// 上下文组装观测(4.10/4.17):模型实际看到什么、裁了什么——可解释性入口。
function ContextObsView({ obs }: { obs: ContextObs }) {
return (
@@ -248,7 +266,7 @@ export function Conversation() {
useEffect(() => {
bodyRef.current?.scrollTo({ top: bodyRef.current.scrollHeight })
}, [messages.length, live?.text, live?.thinking, live?.toolCalls.length, live?.phase, activeHitl.length])
}, [messages.length, live?.items, live?.phase, activeHitl.length])
const send = async () => {
if (!text.trim() && attachmentIds.length === 0) return
+100
View File
@@ -0,0 +1,100 @@
// live 流式投影纯逻辑(中栏过程/回复分离):事件按到达序并入 items——thinking/text
// 增量各并入其末段,工具按当前位置落位。SSE 有序且后端每轮按 思考→正文→工具 追加落库
// 部件,事件到达序即落库序,故合成消息与落库部件同构,终态对账不重排。
// 文本段是否为过程(process)由「本段之后、下一个思考/文本段之前是否出现工具」判定,
// 对齐落库的 Process: len(calls) > 0——工具轮解说的 tool.call 一到即并入过程区,
// 无需等下一轮文本段开启。
// 残余边界:某轮若既无正文也无思考、仅静默发起工具,其调用会被并入上一文本段(需协议
// 携带轮次归属才能消除;现行事件流无法区分)。
import type { Message, Part } from './contracts'
import type { LiveState } from './store'
// 到达序部件流的元素。
export type LiveItem =
| { kind: 'thinking'; text: string }
| { kind: 'text'; index: number; text: string }
| { kind: 'tool'; callId?: string; name: string; status?: string; summary?: string }
| { kind: 'artifact'; artifact: { artifactId: string; name: string; kind?: string } }
// applyThinkingDelta 并入思考增量:末段是思考则追加,否则开启新思考段(新轮开始)。
export function applyThinkingDelta(items: LiveItem[], delta: string): LiveItem[] {
const last = items[items.length - 1]
if (last && last.kind === 'thinking') {
const out = items.slice()
out[out.length - 1] = { kind: 'thinking', text: last.text + delta }
return out
}
return [...items, { kind: 'thinking', text: delta }]
}
// applyTextDelta 并入正文增量:末段是同 index 的文本段则追加,否则开启新文本段。
export function applyTextDelta(items: LiveItem[], index: number, delta: string): LiveItem[] {
const last = items[items.length - 1]
if (last && last.kind === 'text' && last.index === index) {
const out = items.slice()
out[out.length - 1] = { kind: 'text', index, text: last.text + delta }
return out
}
return [...items, { kind: 'text', index, text: delta }]
}
export function applyToolCall(items: LiveItem[], name: string, callId?: string): LiveItem[] {
return [...items, { kind: 'tool', callId, name }]
}
// applyArtifactCreated 追加工件引用(artifact.created 事件;与落库一致标记为过程)。
export function applyArtifactCreated(items: LiveItem[], artifact: { artifactId: string; name: string; kind?: string }): LiveItem[] {
return [...items, { kind: 'artifact', artifact }]
}
// applyToolResult 回填对应工具(优先按 callId 配对,回落到最近一个未决工具——
// 与落库的尾部配对语义一致)。
export function applyToolResult(items: LiveItem[], callId: string | undefined, status: string, summary?: string): LiveItem[] {
for (let i = items.length - 1; i >= 0; i--) {
const it = items[i]
if (it.kind !== 'tool' || it.status !== undefined) continue
if (callId && it.callId && it.callId !== callId) continue
const out = items.slice()
out[i] = { kind: 'tool', callId: it.callId, name: it.name, status, summary }
return out
}
return items
}
// liveToMessage:run 进行中没有已落库的助手消息,把 LiveState 合成同构临时消息,
// 复用 AssistantMessage 渲染(思考/工具/文本实时上屏);终态对账后由真消息替代。
export function liveToMessage(live: LiveState): Message {
// 文本段是否为过程:本段之后、下一个思考/文本段之前出现工具(= 该轮带工具调用)。
const textProcess = new Map<number, boolean>()
for (let i = 0; i < live.items.length; i++) {
if (live.items[i].kind !== 'text') continue
let hasCalls = false
for (let j = i + 1; j < live.items.length && live.items[j].kind === 'tool'; j++) hasCalls = true
textProcess.set(i, hasCalls)
}
const parts: Part[] = []
for (let i = 0; i < live.items.length; i++) {
const it = live.items[i]
if (it.kind === 'thinking') {
if (it.text) parts.push({ type: 'thinking', text: it.text, process: true })
} else if (it.kind === 'tool') {
parts.push({ type: 'tool_call', process: true, toolCall: { id: it.callId ?? '', name: it.name } })
if (it.status !== undefined) {
parts.push({ type: 'tool_result', process: true, toolCall: { id: it.callId ?? '', name: it.name },
toolResult: { callId: it.callId ?? '', status: it.status === 'error' ? 'error' : 'ok', summary: it.summary } })
}
} else if (it.kind === 'artifact') {
parts.push({ type: 'artifact_ref', process: true, artifact: it.artifact })
} else {
parts.push({ type: 'text', text: it.text, process: textProcess.get(i) || undefined })
}
}
return { id: `live:${live.runId}`, conversationId: '', role: 'assistant', runId: live.runId, parts, createdAt: '' }
}
// showProcessInline:流式期间过程区是否展开——thinking/acting 实时展开;一旦进入
// responding(最终回复开始流式)立即折叠,只显示回复(Cursor 式),工具恢复再展开。
// 非 live 消息随「自动折叠过程」偏好。
export function showProcessInline(live: LiveState | null, autoCollapse: boolean): boolean {
return live ? live.phase !== 'responding' : !autoCollapse
}
+13 -42
View File
@@ -2,9 +2,10 @@
// useSyncExternalStore 驱动;live 流式部件为会话内临时态,run 终态后由消息树对账收敛。
import { useSyncExternalStore } from 'react'
import { api, connectEvents } from './api'
import { applyTextDelta, applyThinkingDelta, applyToolCall, applyToolResult, applyArtifactCreated, type LiveItem } from './live'
import type {
Project, Conversation, Message, Run, Artifact, HITLCard, PlanSnapshot, TodoList,
AgentProfile, StreamEvent, PluginPanel, ActivityCard, Part,
AgentProfile, StreamEvent, PluginPanel, ActivityCard,
} from './contracts'
export interface ContextObs {
@@ -16,13 +17,11 @@ export interface ContextObs {
export interface LiveState {
runId: string
phase: string
thinking: string
text: string
toolCalls: { name: string; status?: string; summary?: string }[]
// 到达序部件流(见 live.ts LiveItem):事件到达序即落库序,与落库部件的轮次穿插序
// 同构;文本段是否为过程由「本段内是否出现工具调用」判定,tool.call 一到即改标。
items: LiveItem[]
agentNotes: Record<string, string>
startedAt: number
/** 到达序部件(渲染顺序 = 事件顺序;与后端落库的交错顺序同构)。 */
parts: Part[]
}
export interface UIState {
@@ -264,48 +263,22 @@ export const store = {
switch (e.type) {
case 'message.delta': {
const p = e.payload as { index: number; text: string }
set({ live: touchLive(e.runId ?? '', l => {
l.text += p.text
// 到达序:文本增量并入尾部的流式 text part,否则新开一段。
const last = l.parts[l.parts.length - 1]
if (last && last.type === 'text' && !last.process) last.text = (last.text ?? '') + p.text
else l.parts.push({ type: 'text', text: p.text })
}) })
set({ live: touchLive(e.runId ?? '', l => { l.items = applyTextDelta(l.items, p.index, p.text) }) })
break
}
case 'thinking.delta': {
const p = e.payload as { index: number; text: string }
set({ live: touchLive(e.runId ?? '', l => {
l.thinking += p.text
const last = l.parts[l.parts.length - 1]
if (last && last.type === 'thinking' && last.process) last.text = (last.text ?? '') + p.text
else l.parts.push({ type: 'thinking', text: p.text, process: true })
}) })
set({ live: touchLive(e.runId ?? '', l => { l.items = applyThinkingDelta(l.items, p.text) }) })
break
}
case 'tool.call': {
const p = e.payload as { callId: string; name: string }
set({ live: touchLive(e.runId ?? '', l => {
l.toolCalls.push({ name: p.name })
l.parts.push({ type: 'tool_call', process: true, toolCall: { id: p.callId, name: p.name } })
}) })
set({ live: touchLive(e.runId ?? '', l => { l.items = applyToolCall(l.items, p.name, p.callId) }) })
break
}
case 'tool.result': {
const p = e.payload as { callId: string; status: string; summary?: string }
set({ live: touchLive(e.runId ?? '', l => {
let name = ''
for (let i = l.toolCalls.length - 1; i >= 0; i--) {
if (l.toolCalls[i].status === undefined) {
l.toolCalls[i] = { ...l.toolCalls[i], status: p.status, summary: p.summary }
name = l.toolCalls[i].name
break
}
}
// 结果紧跟对应 tool_call 之后展示(顺序执行下 push 即到位)。
l.parts.push({ type: 'tool_result', process: true, toolCall: { id: p.callId, name },
toolResult: { callId: p.callId, status: p.status === 'error' ? 'error' : 'ok', summary: p.summary } })
}) })
set({ live: touchLive(e.runId ?? '', l => { l.items = applyToolResult(l.items, p.callId, p.status, p.summary) }) })
break
}
case 'run.phase': {
@@ -369,7 +342,7 @@ export const store = {
const p = e.payload as { artifact?: { id: string; name: string; kind?: string } }
if (p?.artifact) {
set({ live: touchLive(e.runId ?? '', l => {
l.parts.push({ type: 'artifact_ref', artifact: { artifactId: p.artifact!.id, name: p.artifact!.name, kind: p.artifact!.kind } })
l.items = applyArtifactCreated(l.items, { artifactId: p.artifact!.id, name: p.artifact!.name, kind: p.artifact!.kind })
}) })
}
if (state.currentProjectId) store.refreshArtifacts(state.currentProjectId)
@@ -406,7 +379,7 @@ export const store = {
// 竞态保护:POST 返回前 SSE 事件可能已先行到达并累积(同 run),
// 此时保留已累积的 live,只补挂用户消息;否则初始化空 live。
if (!state.live || state.live.runId !== out.run.id) {
set({ live: { runId: out.run.id, phase: 'thinking', thinking: '', text: '', toolCalls: [], agentNotes: {}, startedAt: Date.now(), parts: [] } })
set({ live: { runId: out.run.id, phase: 'thinking', items: [], agentNotes: {}, startedAt: Date.now() } })
}
set({ messages: [...state.messages, out.message] })
store.connectIfNeeded()
@@ -541,10 +514,8 @@ export const store = {
}
function touchLive(_runId: string, mutate: (l: LiveState) => void): LiveState | null {
const base = state.live ?? { runId: _runId, phase: 'thinking', thinking: '', text: '', toolCalls: [], agentNotes: {}, startedAt: Date.now(), parts: [] as Part[] }
const next: LiveState = { ...base, toolCalls: [...base.toolCalls], agentNotes: { ...base.agentNotes }, parts: [...base.parts] }
// parts 内部对象的后续可变更新(delta 并入尾部)需要新引用——浅拷贝后再克隆被改的尾部。
if (next.parts.length > 0) next.parts[next.parts.length - 1] = { ...next.parts[next.parts.length - 1] }
const base = state.live ?? { runId: _runId, phase: 'thinking', items: [], agentNotes: {}, startedAt: Date.now() }
const next: LiveState = { ...base, agentNotes: { ...base.agentNotes } }
mutate(next)
return next
}
+149
View File
@@ -0,0 +1,149 @@
// live 流式投影纯逻辑测试(中栏过程/回复分离):到达序并段、process 标记时机、
// 轮次穿插同构、过程区折叠谓词。
import { describe, it, expect } from 'vitest'
import {
applyTextDelta, applyThinkingDelta, applyToolCall, applyToolResult, applyArtifactCreated,
liveToMessage, showProcessInline, type LiveItem,
} from '../src/live'
import type { LiveState } from '../src/store'
function live(over: Partial<LiveState> = {}): LiveState {
return { runId: 'r1', phase: 'thinking', items: [], agentNotes: {}, startedAt: 0, ...over }
}
// 两轮流式事件回放:思考→解说文本→工具→思考→最终回复(与后端事件序一致)。
function twoTurnItems(): LiveItem[] {
let items: LiveItem[] = []
items = applyThinkingDelta(items, '先想想')
items = applyThinkingDelta(items, '一下')
items = applyTextDelta(items, 0, '我先更新待办。')
items = applyToolCall(items, 'update_todos', 'c1')
items = applyToolResult(items, 'c1', 'ok', '已更新')
items = applyThinkingDelta(items, '再想想')
items = applyTextDelta(items, 1, '最终')
items = applyTextDelta(items, 1, '回复')
return items
}
describe('applyThinkingDelta / applyTextDelta', () => {
it('merges thinking deltas into the last thinking segment', () => {
let items: LiveItem[] = []
items = applyThinkingDelta(items, '先')
items = applyThinkingDelta(items, '想想')
expect(items).toEqual([{ kind: 'thinking', text: '先想想' }])
})
it('opens a new thinking segment after text/tool (turn boundary)', () => {
let items: LiveItem[] = applyThinkingDelta([], '第一轮思考')
items = applyTextDelta(items, 0, '解说')
items = applyThinkingDelta(items, '第二轮思考')
expect(items).toEqual([
{ kind: 'thinking', text: '第一轮思考' },
{ kind: 'text', index: 0, text: '解说' },
{ kind: 'thinking', text: '第二轮思考' },
])
})
it('appends text to the last segment when index matches', () => {
let items: LiveItem[] = []
items = applyTextDelta(items, 0, '先查')
items = applyTextDelta(items, 0, '一下')
expect(items).toEqual([{ kind: 'text', index: 0, text: '先查一下' }])
})
it('opens a new text segment when index advances (new turn)', () => {
let items: LiveItem[] = applyTextDelta([], 0, '解说')
items = applyTextDelta(items, 1, '最终')
items = applyTextDelta(items, 1, '回复')
expect(items).toEqual([
{ kind: 'text', index: 0, text: '解说' },
{ kind: 'text', index: 1, text: '最终回复' },
])
})
it('fills the most recent pending tool call (callId match, tail fallback)', () => {
let items: LiveItem[] = []
items = applyToolCall(items, 'a', 'ca')
items = applyToolCall(items, 'b', 'cb')
items = applyToolResult(items, 'cb', 'ok', '结果')
expect(items).toEqual([
{ kind: 'tool', callId: 'ca', name: 'a' },
{ kind: 'tool', callId: 'cb', name: 'b', status: 'ok', summary: '结果' },
])
// 无 callId 的事件回落到尾部配对。
items = applyToolResult(items, undefined, 'ok')
expect(items[0]).toEqual({ kind: 'tool', callId: 'ca', name: 'a', status: 'ok' })
})
})
describe('liveToMessage', () => {
it('interleaves parts per turn in arrival order, matching persisted parts', () => {
const msg = liveToMessage(live({ items: twoTurnItems() }))
expect(msg.parts).toEqual([
{ type: 'thinking', text: '先想想一下', process: true },
{ type: 'text', text: '我先更新待办。', process: true },
{ type: 'tool_call', process: true, toolCall: { id: 'c1', name: 'update_todos' } },
{ type: 'tool_result', process: true, toolCall: { id: 'c1', name: 'update_todos' },
toolResult: { callId: 'c1', status: 'ok', summary: '已更新' } },
{ type: 'thinking', text: '再想想', process: true },
{ type: 'text', text: '最终回复' },
])
})
it('marks the in-flight segment as process the moment its tool call arrives', () => {
let items: LiveItem[] = []
items = applyTextDelta(items, 0, '我先查一下')
const before = liveToMessage(live({ items }))
expect(before.parts.map(p => p.process)).toEqual([undefined]) // 尚无工具:仍在回复位
items = applyToolCall(items, 'web_search')
const after = liveToMessage(live({ items }))
expect(after.parts.map(p => p.process)).toEqual([true, true]) // tool.call 一到即改标
})
it('single final segment without tools carries no process flag', () => {
let items: LiveItem[] = []
items = applyTextDelta(items, 0, '直接回答')
const msg = liveToMessage(live({ items }))
expect(msg.parts.map(p => p.process)).toEqual([undefined])
})
it('tools between segments keep every non-final segment in the process area', () => {
let items: LiveItem[] = []
items = applyTextDelta(items, 0, '第一轮')
items = applyToolCall(items, 'a')
items = applyToolResult(items, undefined, 'ok')
items = applyTextDelta(items, 1, '第二轮')
items = applyToolCall(items, 'b')
items = applyTextDelta(items, 2, '最终回复')
const msg = liveToMessage(live({ items }))
expect(msg.parts.filter(p => p.type === 'text').map(p => p.process)).toEqual([true, true, undefined])
// 顺序必须与落库穿插序一致:text → tool → text → tool → text。
expect(msg.parts.map(p => p.type)).toEqual(['text', 'tool_call', 'tool_result', 'text', 'tool_call', 'text'])
})
it('thinking and tool parts are always process', () => {
let items: LiveItem[] = []
items = applyThinkingDelta(items, '想')
items = applyTextDelta(items, 0, '答')
items = applyToolCall(items, 'web_search')
items = applyToolResult(items, undefined, 'ok', '命中')
const msg = liveToMessage(live({ items }))
for (const p of msg.parts.filter(p => p.type !== 'text')) expect(p.process).toBe(true)
})
it('artifact refs land after their tool call and are process', () => {
let items: LiveItem[] = []
items = applyTextDelta(items, 0, '生成报表。')
items = applyToolCall(items, 'make_report', 'c1')
items = applyToolResult(items, 'c1', 'ok')
items = applyArtifactCreated(items, { artifactId: 'a1', name: 'report.csv', kind: 'csv' })
items = applyTextDelta(items, 1, '完成。')
const msg = liveToMessage(live({ items }))
expect(msg.parts.map(p => p.type)).toEqual(['text', 'tool_call', 'tool_result', 'artifact_ref', 'text'])
expect(msg.parts.find(p => p.type === 'artifact_ref')).toMatchObject({ process: true })
expect(msg.parts.filter(p => p.type === 'text').map(p => p.process)).toEqual([true, undefined])
})
})
describe('showProcessInline', () => {
it('expands during thinking/acting and collapses the moment responding starts', () => {
expect(showProcessInline(live({ phase: 'thinking' }), true)).toBe(true)
expect(showProcessInline(live({ phase: 'acting' }), true)).toBe(true)
expect(showProcessInline(live({ phase: 'responding' }), true)).toBe(false)
})
it('falls back to the auto-collapse preference when no live run', () => {
expect(showProcessInline(null, true)).toBe(false)
expect(showProcessInline(null, false)).toBe(true)
})
})
+1 -1
View File
@@ -1 +1 @@
{"root":["./src/App.tsx","./src/api.ts","./src/auth.ts","./src/contracts.ts","./src/main.tsx","./src/messageparts.ts","./src/passwords.ts","./src/store.ts","./src/tasklist.ts","./src/components/AdminUsersModal.tsx","./src/components/AgentTracker.tsx","./src/components/Conversation.tsx","./src/components/DocViewer.tsx","./src/components/LoginPage.tsx","./src/components/SettingsModal.tsx","./src/components/TaskSidebar.tsx","./src/components/Workbench.tsx","./src/components/Workspaces.tsx","./test/auth.test.ts","./test/contracts.test.ts","./test/messageparts.test.ts","./test/tasklist.test.ts"],"version":"5.9.3"}
{"root":["./src/App.tsx","./src/api.ts","./src/auth.ts","./src/contracts.ts","./src/live.ts","./src/main.tsx","./src/messageparts.ts","./src/passwords.ts","./src/store.ts","./src/tasklist.ts","./src/components/AdminUsersModal.tsx","./src/components/AgentTracker.tsx","./src/components/Conversation.tsx","./src/components/DocViewer.tsx","./src/components/LoginPage.tsx","./src/components/ProjectSettingsModal.tsx","./src/components/SettingsModal.tsx","./src/components/TaskSidebar.tsx","./src/components/Workbench.tsx","./src/components/Workspaces.tsx","./test/auth.test.ts","./test/contracts.test.ts","./test/live.test.ts","./test/messageparts.test.ts","./test/tasklist.test.ts"],"version":"5.9.3"}