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)
This commit is contained in:
+51
-1
@@ -6,7 +6,7 @@
|
||||
* interpolation.
|
||||
*/
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { LOCALE_NS, attachBetterLocale, attachLocale, en, isZh, relativeTime, t, zh } from '../src/client/locales.ts'
|
||||
import { LOCALE_NS, attachBetterLocale, attachLocale, chatT, en, isZh, relativeTime, t, zh } from '../src/client/locales.ts'
|
||||
import { localeDicts } from '../src/client/chunks/locale.tsx'
|
||||
|
||||
/** Minimal structural fake of the DSH LocaleService face the sidebar uses. */
|
||||
@@ -121,6 +121,56 @@ describe('locales (DSH i18n following)', () => {
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* A locale service whose `bind` reads instance state, exactly like DSH's own
|
||||
* (`LocaleRuntime.bind` touches `this.bound`). A detached call — the natural
|
||||
* refactor `const bind = service.bind; bind('chat')` — throws here, which is
|
||||
* what a real host did before the mount lane caught it.
|
||||
*/
|
||||
class FakeLocaleWithBind {
|
||||
active = 'zh'
|
||||
private readonly dicts: Record<string, Record<string, string>> = {
|
||||
chat: { 'message.stepProcess.done.read': '已读取文件' },
|
||||
}
|
||||
getSnapshot(): { active: string } {
|
||||
return { active: this.active }
|
||||
}
|
||||
bind(ns: string): (key: string, params?: Record<string, string | number>) => string {
|
||||
// The `this` read that makes a detached call blow up.
|
||||
const dict: Record<string, string> = this.dicts[ns] ?? {}
|
||||
return (key) => dict[key] ?? key
|
||||
}
|
||||
}
|
||||
|
||||
describe('locales (host chat namespace bridge)', () => {
|
||||
it('reads the host namespace through a METHOD call on the attached service', () => {
|
||||
attachLocale(new FakeLocaleWithBind())
|
||||
expect(chatT('message.stepProcess.done.read')).toBe('已读取文件')
|
||||
})
|
||||
|
||||
it('answers undefined for a namespace or key the host does not serve', () => {
|
||||
attachLocale(new FakeLocaleWithBind())
|
||||
// An unregistered host namespace resolves to the key itself: the caller
|
||||
// must see "unavailable", never the raw `message.…` key.
|
||||
expect(chatT('message.stepProcess.read')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('answers undefined when the attached face has no bind at all', () => {
|
||||
attachLocale({ getSnapshot: () => ({ active: 'en' }) })
|
||||
expect(chatT('message.stepProcess.done.read')).toBeUndefined()
|
||||
attachLocale(undefined)
|
||||
expect(chatT('message.stepProcess.done.read')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('never lets a throwing host seam break a render', () => {
|
||||
attachLocale({
|
||||
getSnapshot: () => ({ active: 'en' }),
|
||||
bind: () => { throw new TypeError("Cannot read properties of undefined (reading 'bound')") },
|
||||
})
|
||||
expect(chatT('message.stepProcess.done.read')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
/** Minimal fake of the better-locale override store (the subset t()/isZh() read). */
|
||||
class FakeBetterLocale {
|
||||
active: string | undefined = undefined
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
/**
|
||||
* 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)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* The Tasks page's borrowed main-conversation wording: the live activity line
|
||||
* and the settled range title, composed exactly the way the host composes
|
||||
* them, plus the fallback that keeps a host rename from printing raw keys.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { doneActivityTitle, liveActivityLabel, type ChatTranslate } from '../src/client/process-labels.ts'
|
||||
import type { ProcessActivitySummary } from '../src/process-activity.ts'
|
||||
|
||||
/** The host's `chat` dictionary for the keys this module reads (zh wording). */
|
||||
const HOST: Record<string, string> = {
|
||||
'message.stepProcess.thinking': '正在分析请求',
|
||||
'message.stepProcess.commands': '正在运行命令',
|
||||
'message.stepProcess.read': '正在读取文件',
|
||||
'message.stepProcess.tools': '正在调用工具',
|
||||
'message.stepProcess.prepare.commands': '准备运行命令',
|
||||
'message.stepProcess.prepare.tools': '准备调用工具',
|
||||
'message.stepProcess.done.thinking': '已完成分析',
|
||||
'message.stepProcess.done.read': '已读取文件',
|
||||
'message.stepProcess.done.commands': '执行了命令',
|
||||
'message.stepProcess.done.search': '已搜索代码',
|
||||
'message.stepProcess.done.tools': '已调用工具',
|
||||
'message.stepProcess.joinTwo': '{first}并{second}',
|
||||
'message.stepProcess.comma': ',',
|
||||
'message.stepProcess.sharedPrefix': '已',
|
||||
'message.stepProcess.more': '{title}等',
|
||||
'message.turnProcess.separator': ' · ',
|
||||
}
|
||||
|
||||
/** The host translator, with `{name}` interpolation like DSH's own. */
|
||||
const t: ChatTranslate = (key, params) => {
|
||||
const template = HOST[key]
|
||||
if (template === undefined) return undefined
|
||||
if (params === undefined) return template
|
||||
let text = template
|
||||
for (const [name, value] of Object.entries(params)) text = text.replaceAll(`{${name}}`, String(value))
|
||||
return text
|
||||
}
|
||||
|
||||
/** A summary built from counts. */
|
||||
function summary(over: Partial<ProcessActivitySummary> = {}): ProcessActivitySummary {
|
||||
return { counts: [], runningDetail: '', ...over }
|
||||
}
|
||||
|
||||
describe('live activity label', () => {
|
||||
it('renders the category wording plus the running detail', () => {
|
||||
expect(liveActivityLabel(summary({
|
||||
counts: [{ kind: 'commands', count: 2 }],
|
||||
running: 'commands',
|
||||
runningDetail: 'npm run build',
|
||||
}), t)).toBe('正在运行命令 · npm run build')
|
||||
})
|
||||
|
||||
it('falls back to the host thinking wording while nothing is dispatched', () => {
|
||||
expect(liveActivityLabel(summary({ counts: [{ kind: 'read', count: 1 }] }), t)).toBe('正在分析请求')
|
||||
expect(liveActivityLabel(undefined, t)).toBe('正在分析请求')
|
||||
})
|
||||
|
||||
it('uses the prepare wording without a detail', () => {
|
||||
expect(liveActivityLabel(summary({
|
||||
running: 'commands',
|
||||
runningDetail: 'ignored while preparing',
|
||||
preparing: true,
|
||||
}), t)).toBe('准备运行命令')
|
||||
})
|
||||
|
||||
it('answers undefined when the host namespace is unavailable', () => {
|
||||
const missing: ChatTranslate = () => undefined
|
||||
expect(liveActivityLabel(summary({ running: 'commands' }), missing)).toBeUndefined()
|
||||
// …and falls back to the detail it does have when the host has one.
|
||||
expect(liveActivityLabel(summary({ running: 'commands', runningDetail: 'npm test' }), missing))
|
||||
.toBe('npm test')
|
||||
})
|
||||
})
|
||||
|
||||
describe('settled range title', () => {
|
||||
it('prints one category without counts', () => {
|
||||
expect(doneActivityTitle(summary({ counts: [{ kind: 'read', count: 3 }] }), t)).toBe('已读取文件')
|
||||
})
|
||||
|
||||
it('joins two categories and drops the shared prefix from the second', () => {
|
||||
expect(doneActivityTitle(summary({
|
||||
counts: [{ kind: 'read', count: 2 }, { kind: 'search', count: 1 }],
|
||||
}), t)).toBe('已读取文件并搜索代码')
|
||||
})
|
||||
|
||||
it('joins three categories with the host comma', () => {
|
||||
expect(doneActivityTitle(summary({
|
||||
counts: [
|
||||
{ kind: 'read', count: 3 },
|
||||
{ kind: 'search', count: 2 },
|
||||
{ kind: 'commands', count: 1 },
|
||||
],
|
||||
}), t)).toBe('已读取文件,已搜索代码,执行了命令')
|
||||
})
|
||||
|
||||
it('appends the host suffix beyond the top three', () => {
|
||||
const title = doneActivityTitle(summary({
|
||||
counts: [
|
||||
{ kind: 'read', count: 4 },
|
||||
{ kind: 'search', count: 3 },
|
||||
{ kind: 'commands', count: 2 },
|
||||
{ kind: 'tools', count: 1 },
|
||||
],
|
||||
}), t)
|
||||
// The host lowercases the continuation labels but only the TWO-label
|
||||
// branch strips the shared "已" prefix, so these keep it.
|
||||
expect(title).toBe('已读取文件,已搜索代码,执行了命令等')
|
||||
})
|
||||
|
||||
it('answers the host thinking wording when nothing was called', () => {
|
||||
expect(doneActivityTitle(summary(), t)).toBe('已完成分析')
|
||||
expect(doneActivityTitle(undefined, t)).toBe('已完成分析')
|
||||
})
|
||||
|
||||
it('answers undefined when a category has no host wording', () => {
|
||||
const partial: ChatTranslate = key => (key === 'message.stepProcess.done.read' ? '已读取文件' : undefined)
|
||||
expect(doneActivityTitle(summary({ counts: [{ kind: 'read', count: 1 }, { kind: 'commands', count: 1 }] }), partial))
|
||||
.toBeUndefined()
|
||||
expect(doneActivityTitle(summary({ counts: [{ kind: 'read', count: 1 }] }), partial)).toBe('已读取文件')
|
||||
})
|
||||
})
|
||||
@@ -1,105 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { contentText, lastActivity } from '../src/subagent-activity.ts'
|
||||
import type { SidebarSessionEvent } from '../src/context-types.ts'
|
||||
|
||||
describe('subagent activity summary parser', () => {
|
||||
/** One raw session event. */
|
||||
const entry = (type: string, data: Record<string, unknown>): SidebarSessionEvent => ({
|
||||
type, seq: 0, time: 0, data,
|
||||
})
|
||||
|
||||
it('extracts text blocks and skips non-text content', () => {
|
||||
// Text blocks join as paragraphs (newline-separated).
|
||||
expect(contentText([{ type: 'text', text: 'hello' }, { type: 'text', text: ' world' }])).toBe('hello\n world')
|
||||
expect(contentText([{ type: 'tool_use', name: 'bash' }])).toBeUndefined()
|
||||
expect(contentText(undefined)).toBeUndefined()
|
||||
expect(contentText('nope')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('lastActivity returns the LAST text output and the LAST tool call', () => {
|
||||
const live = lastActivity([
|
||||
entry('turn/start', { turn: 1 }),
|
||||
entry('user/message', { content: [{ type: 'text', text: '请检查代码' }] }),
|
||||
entry('tool/call', { callId: 'c1', name: 'bash', arguments: '{"command":"ls -la"}' }),
|
||||
entry('assistant/message', {
|
||||
turn: 1, step: 1,
|
||||
message: { content: [{ type: 'text', text: '检查完毕' }] },
|
||||
}),
|
||||
entry('tool/call', { callId: 'c2', name: 'read', arguments: '{"path":"a.ts"}' }),
|
||||
entry('assistant/message', {
|
||||
turn: 1, step: 2,
|
||||
message: { content: [{ type: 'text', text: '再看一眼' }] },
|
||||
}),
|
||||
])
|
||||
expect(live).toEqual({
|
||||
text: '再看一眼',
|
||||
tool: { name: 'read', args: '{"path":"a.ts"}' },
|
||||
})
|
||||
})
|
||||
|
||||
it('lastActivity keeps only the fields the log actually has', () => {
|
||||
expect(lastActivity([
|
||||
entry('tool/call', { callId: 'c1', name: 'bash', arguments: '{"command":"ls"}' }),
|
||||
])).toEqual({ tool: { name: 'bash', args: '{"command":"ls"}' } })
|
||||
expect(lastActivity([
|
||||
entry('assistant/message', { turn: 1, step: 1, message: { content: [{ type: 'text', text: 'ok' }] } }),
|
||||
])).toEqual({ text: 'ok' })
|
||||
})
|
||||
|
||||
it('lastActivity ignores lifecycle events, stream rows, and text-less messages', () => {
|
||||
const live = lastActivity([
|
||||
entry('turn/end', { turn: 1, reason: 'success' }),
|
||||
entry('step/start', { turn: 1, step: 1 }),
|
||||
entry('assistant/live-chunk', { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'x' } }),
|
||||
entry('assistant/message', { turn: 1, step: 1, message: { content: [{ type: 'tool_use', name: 'bash' }] } }),
|
||||
])
|
||||
expect(live).toEqual({})
|
||||
expect(lastActivity([])).toEqual({})
|
||||
})
|
||||
|
||||
it('lastActivity defaults a missing tool name and tolerates non-string arguments', () => {
|
||||
const live = lastActivity([
|
||||
entry('tool/call', { callId: 'c1' }),
|
||||
entry('tool/call', { callId: 'c2', name: 'web', arguments: { url: 'x' } }),
|
||||
])
|
||||
expect(live.tool).toEqual({ name: 'web', args: '' })
|
||||
})
|
||||
|
||||
it('lastActivity window excludes activity older than the last maxMessages messages', () => {
|
||||
// One stale tool call before the window: with maxMessages=1 only the
|
||||
// latest message (and the events around it) are considered.
|
||||
expect(lastActivity([
|
||||
entry('tool/call', { callId: 'stale', name: 'bash', arguments: '{"command":"old"}' }),
|
||||
entry('assistant/message', { turn: 1, step: 1, message: { content: [{ type: 'text', text: 'new text' }] } }),
|
||||
], 1)).toEqual({ text: 'new text' })
|
||||
|
||||
// The same log with a wider window surfaces the tool call again.
|
||||
expect(lastActivity([
|
||||
entry('tool/call', { callId: 'stale', name: 'bash', arguments: '{"command":"old"}' }),
|
||||
entry('assistant/message', { turn: 1, step: 1, message: { content: [{ type: 'text', text: 'new text' }] } }),
|
||||
], 2)).toEqual({
|
||||
text: 'new text',
|
||||
tool: { name: 'bash', args: '{"command":"old"}' },
|
||||
})
|
||||
|
||||
// Non-message events (tool calls) do not consume quota, but once the
|
||||
// window holds its messages, older events are dropped even without an
|
||||
// older message to stop at: the second message pushes the tool call out.
|
||||
expect(lastActivity([
|
||||
entry('tool/call', { callId: 'stale', name: 'bash', arguments: '{"command":"old"}' }),
|
||||
entry('assistant/message', { turn: 1, step: 1, message: { content: [{ type: 'text', text: 'first' }] } }),
|
||||
entry('user/message', { content: [{ type: 'text', text: '继续' }] }),
|
||||
], 1)).toEqual({})
|
||||
|
||||
// A tool call between the two in-window messages stays visible.
|
||||
expect(lastActivity([
|
||||
entry('tool/call', { callId: 'stale', name: 'bash', arguments: '{"command":"old"}' }),
|
||||
entry('assistant/message', { turn: 1, step: 1, message: { content: [{ type: 'text', text: 'first' }] } }),
|
||||
entry('tool/call', { callId: 'fresh', name: 'read', arguments: '{"path":"a.ts"}' }),
|
||||
entry('user/message', { content: [{ type: 'text', text: '继续' }] }),
|
||||
], 2)).toEqual({
|
||||
text: 'first',
|
||||
tool: { name: 'read', args: '{"path":"a.ts"}' },
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -125,7 +125,7 @@ describe('SubagentView live polling', () => {
|
||||
if (method === 'subagents.live') {
|
||||
const body = JSON.parse(String(init?.body)) as { rootSessionId?: string }
|
||||
liveCalls.push(body.rootSessionId ?? '')
|
||||
return jsonResponse({ ok: true, value: { live: { a: {}, b: {} } } })
|
||||
return jsonResponse({ ok: true, value: { live: { a: { running: true }, b: { running: true } } } })
|
||||
}
|
||||
if (method === 'jobs.list') return jsonResponse({ ok: true, value: { jobs: [] } })
|
||||
throw new Error(`unexpected fetch ${String(url)}`)
|
||||
@@ -177,7 +177,7 @@ describe('SubagentView live polling', () => {
|
||||
|
||||
// Settle the first request; only then does the next 3s tick fire.
|
||||
await act(async () => {
|
||||
resolveFirst?.(jsonResponse({ ok: true, value: { live: { a: {}, b: {} } } }))
|
||||
resolveFirst?.(jsonResponse({ ok: true, value: { live: { a: { running: true }, b: { running: true } } } }))
|
||||
await Promise.resolve()
|
||||
})
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(3_000) })
|
||||
@@ -217,7 +217,7 @@ describe('SubagentView live polling', () => {
|
||||
|
||||
// Settling the stale response must neither render nor schedule a poll.
|
||||
await act(async () => {
|
||||
resolveFirst?.(jsonResponse({ ok: true, value: { live: { a: { text: 'stale' } } } }))
|
||||
resolveFirst?.(jsonResponse({ ok: true, value: { live: { a: { running: true, text: 'stale' } } } }))
|
||||
await Promise.resolve()
|
||||
})
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(10_000) })
|
||||
@@ -235,7 +235,7 @@ describe('SubagentView live polling', () => {
|
||||
if (method === 'subagents.live') {
|
||||
const body = JSON.parse(String(init?.body)) as { rootSessionId?: string }
|
||||
liveCalls.push(body.rootSessionId ?? '')
|
||||
const live = body.rootSessionId === 'root' ? { a: { text: 'hello' } } : {}
|
||||
const live = body.rootSessionId === 'root' ? { a: { running: true, text: 'hello' } } : {}
|
||||
return jsonResponse({ ok: true, value: { live } })
|
||||
}
|
||||
if (method === 'jobs.list') return jsonResponse({ ok: true, value: { jobs: [] } })
|
||||
|
||||
@@ -49,8 +49,20 @@ function ctxWith(subagents: unknown, sessions: unknown): Context {
|
||||
} as unknown as Context
|
||||
}
|
||||
|
||||
/** One tool call event. */
|
||||
const call = (callId: string, name: string, args = '{}', seq = 0, time = 0): SidebarSessionEvent =>
|
||||
({ type: 'tool/call', seq, time, data: { callId, name, arguments: args } })
|
||||
|
||||
/** A settled tool result for one call id. */
|
||||
const result = (callId: string, seq = 0, time = 0): SidebarSessionEvent =>
|
||||
({ type: 'tool/result', seq, time, data: { message: { source: { kind: 'tool', callId } } } })
|
||||
|
||||
/** An assistant message carrying text. */
|
||||
const reply = (text: string, seq = 0, time = 0): SidebarSessionEvent =>
|
||||
({ type: 'assistant/message', seq, time, data: { message: { content: [{ type: 'text', text }] } } })
|
||||
|
||||
describe('subagents.live route', () => {
|
||||
it('returns non-empty activity for running children only', async () => {
|
||||
it('reports every child with its catalog flag and folded range', async () => {
|
||||
const subagents: SidebarSubagentsService = {
|
||||
listDescendants: vi.fn(async () => [
|
||||
child('running-a', { label: 'A' }),
|
||||
@@ -62,64 +74,70 @@ describe('subagents.live route', () => {
|
||||
}
|
||||
const sessions = {
|
||||
get: (id: string) => {
|
||||
if (id === 'running-a') {
|
||||
return session([
|
||||
{ type: 'assistant/message', seq: 0, time: 0, data: { message: { content: [{ type: 'text', text: 'hello' }] } } },
|
||||
])
|
||||
}
|
||||
if (id === 'running-b') {
|
||||
return session([
|
||||
{ type: 'tool/call', seq: 0, time: 0, data: { name: 'bash', arguments: '{"command":"ls"}' } },
|
||||
])
|
||||
}
|
||||
if (id === 'running-a') return session([reply('hello', 1, 1)])
|
||||
if (id === 'running-b') return session([call('c1', 'bash', '{"command":"ls"}', 1, 1)])
|
||||
return session([])
|
||||
},
|
||||
}
|
||||
const api = buildSubagentLiveApi(ctxWith(subagents, sessions))
|
||||
await expect(api.live({ rootSessionId: 'root' })).resolves.toEqual({
|
||||
live: {
|
||||
'running-a': { text: 'hello' },
|
||||
'running-b': { tool: { name: 'bash', args: '{"command":"ls"}' } },
|
||||
},
|
||||
const live = (await api.live({ rootSessionId: 'root' })).live
|
||||
// The topology root is reported too (its activity flag is the caller's).
|
||||
expect(live.root).toEqual({})
|
||||
expect(live['running-a']?.running).toBe(true)
|
||||
expect(live['running-a']?.text).toBe('hello')
|
||||
expect(live['running-b']?.summary).toEqual({
|
||||
counts: [{ kind: 'commands', count: 1 }],
|
||||
running: 'commands',
|
||||
runningDetail: 'ls',
|
||||
})
|
||||
// A settled child is still a row: its summary drives its card.
|
||||
expect(live.inactive?.running).toBe(false)
|
||||
// Side Chat threads and diagnostics are not topology.
|
||||
expect(live['side-chat']).toBeUndefined()
|
||||
expect(live['corrupt-row']).toBeUndefined()
|
||||
expect(subagents.listDescendants).toHaveBeenCalledWith('root')
|
||||
})
|
||||
|
||||
it('omits children with no text/tool yet', async () => {
|
||||
it('folds a settled child from its last range that did something', async () => {
|
||||
const subagents: SidebarSubagentsService = {
|
||||
listDescendants: vi.fn(async () => [child('empty', { label: 'Empty' })]),
|
||||
listDescendants: vi.fn(async () => [child('done', { activity: 'inactive', label: 'D' })]),
|
||||
}
|
||||
const sessions = { get: () => session([]) }
|
||||
const api = buildSubagentLiveApi(ctxWith(subagents, sessions))
|
||||
await expect(api.live({ rootSessionId: 'root' })).resolves.toEqual({ live: {} })
|
||||
const events = [
|
||||
call('c1', 'bash', '{"command":"npm test"}', 1, 1),
|
||||
result('c1', 2, 2),
|
||||
reply('all green', 3, 3),
|
||||
]
|
||||
const api = buildSubagentLiveApi(ctxWith(subagents, { get: () => session(events) }))
|
||||
const live = (await api.live({ rootSessionId: 'root' })).live
|
||||
expect(live.done?.summary).toEqual({
|
||||
counts: [{ kind: 'commands', count: 1 }],
|
||||
runningDetail: '',
|
||||
})
|
||||
expect(live.done?.text).toBe('all green')
|
||||
expect(live.done?.lastEventTime).toBe(3)
|
||||
})
|
||||
|
||||
it('folds only activity inside the recent 12-message window', async () => {
|
||||
// One stale tool call sits before 13 user messages; the recent window
|
||||
// (the tail's last 12 messages) must not surface it.
|
||||
const staleTool: SidebarSessionEvent = {
|
||||
type: 'tool/call', seq: 0, time: 0,
|
||||
data: { callId: 'stale', name: 'bash', arguments: '{"command":"old"}' },
|
||||
}
|
||||
const oldMessages: SidebarSessionEvent[] = Array.from({ length: 13 }, (_, i) => ({
|
||||
type: 'user/message', seq: i + 1, time: 0,
|
||||
data: { content: [{ type: 'text', text: `m${i}` }] },
|
||||
}))
|
||||
const recentText: SidebarSessionEvent = {
|
||||
type: 'assistant/message', seq: 99, time: 0,
|
||||
data: { message: { content: [{ type: 'text', text: 'recent' }] } },
|
||||
}
|
||||
it('stops the fold at the newest range boundary', async () => {
|
||||
// One stale tool call sits in an earlier range: the newest range is the
|
||||
// tail after the reply, so the stale call must not be counted.
|
||||
const subagents: SidebarSubagentsService = {
|
||||
listDescendants: vi.fn(async () => [child('windowed', { label: 'W' })]),
|
||||
}
|
||||
const sessions = { get: () => session([staleTool, ...oldMessages, recentText]) }
|
||||
const api = buildSubagentLiveApi(ctxWith(subagents, sessions))
|
||||
await expect(api.live({ rootSessionId: 'root' })).resolves.toEqual({
|
||||
live: { windowed: { text: 'recent' } },
|
||||
const events = [
|
||||
call('stale', 'bash', '{"command":"old"}', 1, 1),
|
||||
reply('done with the old work', 2, 2),
|
||||
call('fresh', 'read', '{"path":"a"}', 3, 3),
|
||||
]
|
||||
const api = buildSubagentLiveApi(ctxWith(subagents, { get: () => session(events) }))
|
||||
const live = (await api.live({ rootSessionId: 'root' })).live
|
||||
expect(live.windowed?.summary).toEqual({
|
||||
counts: [{ kind: 'read', count: 1 }],
|
||||
running: 'read',
|
||||
runningDetail: 'a',
|
||||
})
|
||||
})
|
||||
|
||||
it('skips a child whose session log is unavailable without failing the batch', async () => {
|
||||
it('keeps a child whose session log is unreadable (its flag is the catalog\'s)', async () => {
|
||||
const subagents: SidebarSubagentsService = {
|
||||
listDescendants: vi.fn(async () => [
|
||||
child('good', { label: 'Good' }),
|
||||
@@ -128,18 +146,14 @@ describe('subagents.live route', () => {
|
||||
}
|
||||
const sessions = {
|
||||
get: (id: string) => {
|
||||
if (id === 'good') {
|
||||
return session([
|
||||
{ type: 'assistant/message', seq: 0, time: 0, data: { message: { content: [{ type: 'text', text: 'ok' }] } } },
|
||||
])
|
||||
}
|
||||
if (id === 'good') return session([reply('ok', 0, 0)])
|
||||
throw new Error('missing')
|
||||
},
|
||||
}
|
||||
const api = buildSubagentLiveApi(ctxWith(subagents, sessions))
|
||||
await expect(api.live({ rootSessionId: 'root' })).resolves.toEqual({
|
||||
live: { good: { text: 'ok' } },
|
||||
})
|
||||
const live = (await api.live({ rootSessionId: 'root' })).live
|
||||
expect(live.good?.text).toBe('ok')
|
||||
expect(live.bad).toEqual({ running: true })
|
||||
})
|
||||
|
||||
it('degrades to a 503 when the subagent runtime is absent', async () => {
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
* the width-aware solver.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { SidebarChildLiveView } from '../src/context-types.ts'
|
||||
import {
|
||||
GRAPH_GAP_Y,
|
||||
GRAPH_LIVE_H,
|
||||
@@ -50,6 +51,12 @@ function layoutRow(layout: ReturnType<typeof layoutTasksGraph>, id: string): num
|
||||
return (box.y - GRAPH_PAD) / GRAPH_ROW_STRIDE
|
||||
}
|
||||
|
||||
/** One running node's live view: a merged activity row, no text tail. */
|
||||
const ACTIVITY: SidebarChildLiveView = {
|
||||
running: true,
|
||||
summary: { counts: [{ kind: 'read', count: 1 }], running: 'read', runningDetail: 'x' },
|
||||
}
|
||||
|
||||
describe('layoutTasksGraph', () => {
|
||||
it('places the root on row 0 and children one row below', () => {
|
||||
const nodes: TasksNode[] = [agent('root'), agent('a', 'root'), agent('b', 'root')]
|
||||
@@ -98,11 +105,11 @@ describe('layoutTasksGraph', () => {
|
||||
expect(plain.boxes.get('mate')?.h).toBe(GRAPH_NODE_H)
|
||||
})
|
||||
|
||||
it('reserves the live tail row only when the tail carries a tool AND text', () => {
|
||||
it('reserves the live tail row only when the tail carries activity AND text', () => {
|
||||
const nodes: TasksNode[] = [
|
||||
agent('root'),
|
||||
agent('tool-only', 'root', false, { live: { tool: { name: 'read', args: 'x' } } }),
|
||||
agent('both', 'root', false, { live: { tool: { name: 'read', args: 'x' }, text: 'y' } }),
|
||||
agent('tool-only', 'root', false, { live: ACTIVITY }),
|
||||
agent('both', 'root', false, { live: { ...ACTIVITY, text: 'y' } }),
|
||||
]
|
||||
const layout = layoutTasksGraph(nodes)
|
||||
// One live row either way (the tool row, or the meta-styled thinking
|
||||
@@ -118,9 +125,7 @@ describe('layoutTasksGraph', () => {
|
||||
expect(GRAPH_ROW_STRIDE).toBeGreaterThanOrEqual(tallest)
|
||||
const nodes: TasksNode[] = [
|
||||
agent('root'),
|
||||
agent('busiest', 'root', false, {
|
||||
live: { tool: { name: 'read', args: 'x' }, text: 'y' }, tasks: [task('t1')],
|
||||
}),
|
||||
agent('busiest', 'root', false, { live: { ...ACTIVITY, text: 'y' }, tasks: [task('t1')] }),
|
||||
]
|
||||
const layout = layoutTasksGraph(nodes)
|
||||
expect(layout.boxes.get('busiest')?.h).toBe(tallest)
|
||||
|
||||
@@ -17,7 +17,7 @@ import type {
|
||||
SidebarTeamMemberView,
|
||||
} from '../src/context-types.ts'
|
||||
import type { SubagentCatalogView } from '../src/client/subagent-catalog.ts'
|
||||
import type { LastActivity } from '../src/subagent-activity.ts'
|
||||
import type { SidebarChildLiveView } from '../src/context-types.ts'
|
||||
import type { WorkflowRunView } from '../src/workflow-runs.ts'
|
||||
|
||||
/** A summary row. */
|
||||
@@ -36,8 +36,9 @@ function catalog(entries: SidebarSubagentCatalogEntry[]): SubagentCatalogView {
|
||||
}
|
||||
|
||||
/** The live channel's fold: only the RUNNING children appear (DSH 0.1.7). */
|
||||
function running(...ids: string[]): Record<string, LastActivity> {
|
||||
return Object.fromEntries(ids.map(id => [id, { text: `${id} is working` }]))
|
||||
/** The live map of a set of RUNNING sessions (the new live-view shape). */
|
||||
function running(...ids: string[]): Record<string, SidebarChildLiveView> {
|
||||
return Object.fromEntries(ids.map(id => [id, { running: true, text: `${id} is working` }]))
|
||||
}
|
||||
|
||||
/** A workflow run view. */
|
||||
|
||||
Reference in New Issue
Block a user