Files
apes-sidebar/tests/process-activity.spec.ts
T
Menghuan1918 a454cfc9fe refactor(tasks): 移植主 Agent 同款合并活动折叠(数据层)
把上一轮验证过的数据层搬到工作图基座上,图/树两种模式共用:

- 新增 src/process-activity.ts:宿主 ui-chat 的 process group 算法移植
  (activity() 分类表逐条照抄、liveToolDetail 键优先级与 160 字截断、
  counts 计数排序与并列规则、preparing 语义、按 user/reply 边界切范围)。
  按本轮设计裁剪掉逐阶段列表(阶段改由徽标 + 颜色表达)。新增一条
  偏差:最新范围为空(子代理刚回复完)时回退到上一个「做过事」的范围,
  否则已结算卡片的小条只会显示「已完成分析」。
- chatT:从宿主 chat 命名空间读 message.stepProcess.* 措辞(方法调用 +
  try/catch 兜底,key 未命中即回退),4 个回归用例钉住「不许解绑 bind」。
- subagents.live 载荷换新:SidebarChildLiveView { running?, text?, summary?,
  lastEventTime? },报告树内每个子节点(含已结算)与拓扑根;删除
  expand/stages 机制;childActivity → childLive(「不在 map 里 = 未运行」
  的读法随旧协议一起作废)。
- tasks-model 消费新形状;LiveLine 与节点详情浮窗改显示合并活动措辞。
- 删除 src/subagent-activity.ts 与 tests/subagent-activity.spec.ts。

验证:typecheck / lint 通过;pnpm test 1252 passed / 9 skipped。

(cherry picked from commit 5e5284f214846b0c366048acbee459f3a7a79456)
2026-09-27 22:18:35 +08:00

252 lines
11 KiB
TypeScript

/**
* The ported main-agent process fold: category mapping, live-detail
* extraction, and the range cut (`foldProcess`). These pin the parity claims
* the live route and the Tasks page cards rest on, so a later host change
* shows up as a red test rather than a silently different label.
*/
import { describe, expect, it } from 'vitest'
import {
PROCESS_DETAIL_MAX_CHARS,
PROCESS_TEXT_MAX_CHARS,
activityOf,
contentText,
foldProcess,
liveToolDetail,
type ProcessActivity,
} from '../src/process-activity.ts'
import type { SidebarSessionEvent } from '../src/context-types.ts'
/** One raw session event. */
const entry = (type: string, data: Record<string, unknown>, seq = 0, time = 0): SidebarSessionEvent => ({
type, seq, time, data,
})
/** A `tool/call` event. */
const call = (callId: string, name: string, args = '{}', seq = 0, time = 0): SidebarSessionEvent =>
entry('tool/call', { callId, name, arguments: args }, seq, time)
/** A `tool/result` event paired by callId (0.1.7's tool-role message shape). */
const result = (callId: string, seq = 0, time = 0): SidebarSessionEvent =>
entry('tool/result', { message: { source: { kind: 'tool', callId }, content: [{ type: 'text', text: 'ok' }] } }, seq, time)
/** An assistant message carrying text and/or announced tool-call blocks. */
const assistant = (content: unknown[], seq = 0, time = 0): SidebarSessionEvent =>
entry('assistant/message', { turn: 1, step: 1, message: { content } }, seq, time)
/** A user message (a range boundary). */
const user = (text = 'continue', seq = 0, time = 0): SidebarSessionEvent =>
entry('user/message', { content: [{ type: 'text', text }] }, seq, time)
describe('process activity: the host category map, verbatim', () => {
const cases: [string, ProcessActivity][] = [
['read', 'read'],
['read_image', 'readImage'],
['grep', 'search'],
['glob', 'search'],
['bash_inspect', 'search'],
['write', 'write'],
['edit', 'edit'],
['apply_patch', 'edit'],
['bash', 'commands'],
['pwsh', 'commands'],
['exec_command', 'commands'],
['write_stdin', 'commands'],
['terminal_send', 'commands'],
['run_code', 'code'],
['web_search', 'webSearch'],
['web_fetch', 'webFetch'],
['subagent', 'subagents'],
['subagent_control', 'subagents'],
['todo_write', 'plan'],
['create_goal', 'plan'],
['update_goal', 'plan'],
['get_goal', 'plan'],
['ask_user_question', 'questions'],
['request_user_input', 'questions'],
['something_else', 'tools'],
['', 'tools'],
]
for (const [name, kind] of cases) {
it(`${name || '(empty)'} → ${kind}`, () => {
expect(activityOf(name)).toBe(kind)
})
}
})
describe('process activity: live tool detail', () => {
it('takes the first present key of the host priority list', () => {
expect(liveToolDetail('bash', JSON.stringify({ command: 'npm test', path: 'x' }))).toBe('npm test')
// `title` beats `command` because it is earlier in the host's list.
expect(liveToolDetail('bash', JSON.stringify({ command: 'a', title: 'Build' }))).toBe('Build')
})
it('joins string arrays and reads the first question', () => {
expect(liveToolDetail('grep', JSON.stringify({ queries: ['one', 'two'] }))).toBe('one, two')
expect(liveToolDetail('ask_user_question', JSON.stringify({
questions: [{ question: 'first?' }, { question: 'second?' }],
}))).toBe('first?')
})
it('collapses whitespace and truncates on a grapheme boundary', () => {
const long = 'a'.repeat(PROCESS_DETAIL_MAX_CHARS + 40)
const detail = liveToolDetail('bash', JSON.stringify({ command: long }))
expect(detail.endsWith('…')).toBe(true)
expect(Array.from(detail).length).toBe(PROCESS_DETAIL_MAX_CHARS)
expect(liveToolDetail('bash', JSON.stringify({ command: 'a\n\n b\tc' }))).toBe('a b c')
})
it('falls back to the tool name for unusable arguments', () => {
expect(liveToolDetail('bash', 'not json')).toBe('bash')
expect(liveToolDetail('bash', '"scalar"')).toBe('bash')
expect(liveToolDetail('bash', JSON.stringify({ unknownKey: 'x' }))).toBe('bash')
expect(liveToolDetail('bash', '')).toBe('bash')
})
})
describe('process activity: content text', () => {
it('joins text blocks and ignores everything else', () => {
expect(contentText([{ type: 'text', text: 'hello' }, { type: 'tool-call', id: 'c' }])).toBe('hello')
expect(contentText([{ type: 'text', text: 'a' }, { type: 'text', text: 'b' }])).toBe('a\nb')
expect(contentText(undefined)).toBeUndefined()
expect(contentText('nope')).toBeUndefined()
})
})
describe('process activity: the range fold', () => {
it('returns an empty summary for an empty log', () => {
expect(foldProcess([], { live: true })).toEqual({ current: { counts: [], runningDetail: '' } })
})
it('counts distinct calls per category and ranks by count', () => {
const fold = foldProcess([
user('go', 1, 10),
call('c1', 'read', '{"path":"a"}', 2, 11),
call('c2', 'read', '{"path":"b"}', 3, 12),
call('c3', 'bash', '{"command":"ls"}', 4, 13),
], { live: true })
expect(fold.current.counts).toEqual([{ kind: 'read', count: 2 }, { kind: 'commands', count: 1 }])
})
it('breaks count ties by first appearance', () => {
const fold = foldProcess([
call('c1', 'bash', '{}', 1, 1),
call('c2', 'read', '{}', 2, 2),
], { live: true })
expect(fold.current.counts).toEqual([{ kind: 'commands', count: 1 }, { kind: 'read', count: 1 }])
})
it('reports the newest unsettled call as running, with its detail', () => {
const fold = foldProcess([
call('c1', 'bash', '{"command":"npm test"}', 1, 1),
result('c1', 2, 2),
call('c2', 'grep', '{"pattern":"host"}', 3, 3),
], { live: true })
expect(fold.current.running).toBe('search')
expect(fold.current.runningDetail).toBe('host')
expect(fold.current.counts).toEqual([{ kind: 'commands', count: 1 }, { kind: 'search', count: 1 }])
})
it('reports no running tool once the session settled (the host closed group)', () => {
const fold = foldProcess([call('c1', 'bash', '{"command":"ls"}', 1, 1)], { live: false })
expect(fold.current.running).toBeUndefined()
expect(fold.current.runningDetail).toBe('')
expect(fold.current.counts).toEqual([{ kind: 'commands', count: 1 }])
})
it('counts and flags a call the model announced but never dispatched', () => {
const fold = foldProcess([
user('go', 1, 5),
assistant([{ type: 'tool-call', id: 'c1', name: 'bash', arguments: '{"command":"ls"}' }], 2, 6),
], { live: true })
expect(fold.current.counts).toEqual([{ kind: 'commands', count: 1 }])
expect(fold.current.running).toBe('commands')
expect(fold.current.preparing).toBe(true)
// The host shows no detail while preparing (the name only for `tools`).
expect(fold.current.runningDetail).toBe('')
expect(foldProcess([
assistant([{ type: 'tool-call', id: 'c1', name: 'mystery_tool' }], 2, 6),
], { live: true }).current.runningDetail).toBe('mystery_tool')
})
it('does not double count an announced call that was dispatched and settled', () => {
const fold = foldProcess([
user('go', 1, 5),
assistant([{ type: 'tool-call', id: 'c1', name: 'read', arguments: '{"path":"a"}' }], 2, 6),
call('c1', 'read', '{"path":"a"}', 3, 7),
result('c1', 4, 8),
], { live: true })
expect(fold.current.counts).toEqual([{ kind: 'read', count: 1 }])
expect(fold.current.running).toBeUndefined()
})
it('stops at the newest range boundary and keeps the newest text', () => {
// The stale read lives in the PREVIOUS range: a reply ended it.
const fold = foldProcess([
user('go', 1, 10),
call('c1', 'read', '{"path":"old"}', 2, 11),
assistant([{ type: 'text', text: 'first answer' }], 3, 12),
call('c2', 'bash', '{"command":"npm test"}', 4, 13),
assistant([{ type: 'text', text: 'second answer' }], 5, 14),
], { live: false })
expect(fold.current.counts).toEqual([{ kind: 'commands', count: 1 }])
expect(fold.text).toBe('second answer')
expect(fold.lastEventTime).toBe(14)
})
it('falls back to the previous range when the newest one is empty', () => {
// The child replied and then did nothing: reporting the empty newest range
// would show "nothing happened" for a node that just did real work.
const fold = foldProcess([
user('go', 1, 10),
call('c1', 'read', '{"path":"a"}', 2, 11),
call('c2', 'bash', '{"command":"npm test"}', 3, 12),
assistant([{ type: 'text', text: 'all green' }], 4, 13),
], { live: false })
// Forwards order is read → bash, so the tie ranks read first.
expect(fold.current.counts).toEqual([{ kind: 'read', count: 1 }, { kind: 'commands', count: 1 }])
expect(fold.text).toBe('all green')
// The fallback range is CLOSED: it can never report a running tool.
expect(fold.current.running).toBeUndefined()
})
it('never reports the fallback range as running even while the session lives', () => {
const fold = foldProcess([
call('c1', 'bash', '{"command":"npm test"}', 1, 1),
assistant([{ type: 'text', text: 'done with that' }], 2, 2),
], { live: true })
expect(fold.current.counts).toEqual([{ kind: 'commands', count: 1 }])
expect(fold.current.running).toBeUndefined()
})
it('gives up after one empty range instead of scanning the whole log', () => {
const events = [
call('ancient', 'read', '{"path":"old"}', 1, 1),
assistant([{ type: 'text', text: 'first' }], 2, 2),
assistant([{ type: 'text', text: 'second' }], 3, 3),
assistant([{ type: 'text', text: 'third' }], 4, 4),
]
// Two replies in a row leave TWO empty ranges before the ancient call; the
// fold stops after the first empty one and reports an empty summary.
const fold = foldProcess(events, { live: false })
expect(fold.current.counts).toEqual([])
expect(fold.text).toBe('third')
})
it('stops the backward scan at the event cap', () => {
const events: SidebarSessionEvent[] = []
for (let index = 0; index < 900; index += 1) {
events.push(call(`c${index}`, 'read', `{"path":"${index}"}`, index, index))
}
const fold = foldProcess(events, { live: true })
expect(fold.current.counts[0]?.count).toBeLessThan(900)
expect(fold.current.counts[0]?.kind).toBe('read')
})
it('caps the text line without splitting the ellipsis', () => {
const long = 'x'.repeat(PROCESS_TEXT_MAX_CHARS + 30)
const fold = foldProcess([assistant([{ type: 'text', text: long }], 1, 1)], { live: false })
expect(Array.from(fold.text ?? '').length).toBe(PROCESS_TEXT_MAX_CHARS)
expect(fold.text?.endsWith('…')).toBe(true)
})
})