From b638db08c7c9d06033c65da5ac45752495409ce5 Mon Sep 17 00:00:00 2001 From: Delicious233 <101502465+DeliciousBuding@users.noreply.github.com> Date: Wed, 19 Aug 2026 20:08:51 +0800 Subject: [PATCH] =?UTF-8?q?test(shared):=20hubClientPayloadRequestsSocial?= =?UTF-8?q?=20+=20PayloadPaths=20+=20chatview=20adapter=20=E8=A1=A5=20137?= =?UTF-8?q?=20=E4=B8=AA=E5=8D=95=E5=85=83=E6=B5=8B=E8=AF=95=EF=BC=88Lane?= =?UTF-8?q?=20D=20#1764=20=E7=AC=AC=E4=B8=83=E6=89=B9=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - hubClientPayloadRequestsSocial.ts:+23(27 个 social builder 全量覆盖,optional 字段 hasOwnProperty 断言) - hubClientPayloadPaths.ts:+27(96 个 path builder,qs 语义/percent-encoding/空串/unicode) - chatview/adapter.ts:+87(blocksToTranscriptItems 全分支 + resolveUnreadAnchorItemIndex/resolveCompactDividerIndices 首次覆盖) 不改任何产品代码。Lane D #1764 Co-authored-by: Cursor --- app/shared/src/chatview/adapter.test.ts | 996 ++++++++++++++++++ .../src/hub/hubClientPayloadPaths.test.ts | 450 ++++++++ .../hubClientPayloadRequestsSocial.test.ts | 407 +++++++ 3 files changed, 1853 insertions(+) create mode 100644 app/shared/src/chatview/adapter.test.ts create mode 100644 app/shared/src/hub/hubClientPayloadPaths.test.ts create mode 100644 app/shared/src/hub/hubClientPayloadRequestsSocial.test.ts diff --git a/app/shared/src/chatview/adapter.test.ts b/app/shared/src/chatview/adapter.test.ts new file mode 100644 index 000000000..17824e7a2 --- /dev/null +++ b/app/shared/src/chatview/adapter.test.ts @@ -0,0 +1,996 @@ +// real_tested=true +import { describe, it, expect } from 'vitest' + +import { + blocksToTranscriptItems, + resolveCompactDividerIndices, + resolveUnreadAnchorItemIndex, + SEP, +} from './adapter' +import type { AgentTranscriptBlock, TranscriptUserItem } from './index' +import type { TranscriptBlock } from '../transcript/types' +import { + makeAuthor, + makeUser, + makeTime, + DEFAULT_AGENT_NAME, + DEFAULT_USER_NAME, +} from './adapter-test-helpers' + +const makeSystemAuthor = (id: string, name = 'System') => ({ id, name, role: 'system' as const }) + +describe('SEP re-export', () => { + it('re-exports the display separator constant', () => { + expect(SEP).toBe(' · ') + }) +}) + +describe('blocksToTranscriptItems — user messages', () => { + it('returns an empty array for empty input', () => { + expect(blocksToTranscriptItems([])).toEqual([]) + }) + + it('converts a user text block into a TranscriptUserItem', () => { + const blocks: TranscriptBlock[] = [ + { id: 'u1', kind: 'text', createdAt: makeTime(0), author: makeUser('alice'), text: 'hello' }, + ] + const items = blocksToTranscriptItems(blocks) + expect(items).toHaveLength(1) + const item = items[0] as TranscriptUserItem + expect(item.type).toBe('user') + expect(item.id).toBe('u1') + expect(item.name).toBe(DEFAULT_USER_NAME) + expect(item.text).toBe('hello') + expect(item.time).toBeTruthy() + expect(item.time).toMatch(/\d{1,2}:\d{2}/) + }) + + it('produces an empty time string when createdAt is missing', () => { + const blocks: TranscriptBlock[] = [ + { id: 'u1', kind: 'text', author: makeUser('alice'), text: 'hello' }, + ] + const item = blocksToTranscriptItems(blocks)[0] as TranscriptUserItem + expect(item.time).toBe('') + }) + + it('propagates display overrides onto the user item', () => { + const blocks: TranscriptBlock[] = [ + { + id: 'u1', kind: 'text', author: makeUser('alice'), text: 'hello', + displayTitle: 'T', displayDetail: 'D', badgeLabel: 'B', badgeVariant: 'thinking', + }, + ] + const item = blocksToTranscriptItems(blocks)[0] as TranscriptUserItem + expect(item.displayTitle).toBe('T') + expect(item.displayDetail).toBe('D') + expect(item.badgeLabel).toBe('B') + expect(item.badgeVariant).toBe('thinking') + }) + + it('omits display override keys when the block has none', () => { + const blocks: TranscriptBlock[] = [ + { id: 'u1', kind: 'text', author: makeUser('alice'), text: 'hello' }, + ] + const item = blocksToTranscriptItems(blocks)[0] as TranscriptUserItem + expect(item.displayTitle).toBeUndefined() + expect(item.displayDetail).toBeUndefined() + expect(item.badgeLabel).toBeUndefined() + expect(item.badgeVariant).toBeUndefined() + }) + + it('produces one item per user text block', () => { + const blocks: TranscriptBlock[] = [ + { id: 'u1', kind: 'text', createdAt: makeTime(0), author: makeUser('alice'), text: 'a' }, + { id: 'u2', kind: 'text', createdAt: makeTime(1), author: makeUser('alice'), text: 'b' }, + ] + const items = blocksToTranscriptItems(blocks) + expect(items).toHaveLength(2) + expect((items[0] as TranscriptUserItem).text).toBe('a') + expect((items[1] as TranscriptUserItem).text).toBe('b') + }) + + it('flushes a pending agent item before pushing a user item', () => { + const blocks: TranscriptBlock[] = [ + { id: 'th1', kind: 'thinking', createdAt: makeTime(1), author: makeAuthor('b1'), content: 'x', isThinking: true }, + { id: 'u1', kind: 'text', createdAt: makeTime(2), author: makeUser('alice'), text: 'stop' }, + { id: 'tc1', kind: 'tool_call', createdAt: makeTime(3), author: makeAuthor('b1'), toolName: 'Read', status: 'running' }, + ] + const items = blocksToTranscriptItems(blocks) + expect(items).toHaveLength(3) + expect(items[0]).toMatchObject({ agent: DEFAULT_AGENT_NAME }) + expect(items[1]).toMatchObject({ type: 'user' }) + expect(items[2]).toMatchObject({ agent: DEFAULT_AGENT_NAME }) + expect((items[2] as AgentTranscriptBlock).id).toBe('b1-3') + }) + + it('keeps the undefined text value when a user text block lacks text', () => { + const blocks = [ + { id: 'u1', kind: 'text' as const, author: makeUser('alice'), text: undefined as unknown as string }, + ] as TranscriptBlock[] + const item = blocksToTranscriptItems(blocks)[0] as TranscriptUserItem + expect(item.type).toBe('user') + expect(item.text).toBeUndefined() + }) +}) + +describe('blocksToTranscriptItems — agent text bubbles and grouping', () => { + it('converts a single agent text block into an agent item with one bubble', () => { + const blocks: TranscriptBlock[] = [ + { id: 'a1', kind: 'text', createdAt: makeTime(0), author: makeAuthor('b1'), text: 'hello' }, + ] + const items = blocksToTranscriptItems(blocks) + expect(items).toHaveLength(1) + const agent = items[0] as AgentTranscriptBlock + expect(agent.agent).toBe(DEFAULT_AGENT_NAME) + expect(agent.role).toBe('agent') + expect(agent.groupId).toBe('b1') + expect(agent.id).toBe('b1-1') // `${author.id}-${seq}` React key scheme + expect(agent.bubbles).toEqual(['hello']) + expect(agent.rows).toEqual([]) + expect(agent.time).toBeTruthy() + }) + + it('derives the agent id seq from the absolute block position', () => { + const blocks: TranscriptBlock[] = [ + { id: 'u1', kind: 'text', createdAt: makeTime(0), author: makeUser('alice'), text: 'q' }, + { id: 'a1', kind: 'text', createdAt: makeTime(1), author: makeAuthor('b1'), text: 'reply' }, + ] + const items = blocksToTranscriptItems(blocks) + expect((items[1] as AgentTranscriptBlock).id).toBe('b1-2') + }) + + it('merges consecutive same-author text blocks into one item', () => { + const blocks: TranscriptBlock[] = [ + { id: 'a1', kind: 'text', createdAt: makeTime(1), author: makeAuthor('b1'), text: 'first' }, + { id: 'a2', kind: 'text', createdAt: makeTime(2), author: makeAuthor('b1'), text: 'second' }, + ] + const items = blocksToTranscriptItems(blocks) + expect(items).toHaveLength(1) + const agent = items[0] as AgentTranscriptBlock + expect(agent.bubbles).toEqual(['first', 'second']) + expect(agent.parts).toHaveLength(2) + expect(agent.parts![0]).toMatchObject({ type: 'bubble', text: 'first' }) + expect(agent.parts![1]).toMatchObject({ type: 'bubble', text: 'second' }) + }) + + it('creates the agent item for an empty text block but pushes no bubble', () => { + const blocks: TranscriptBlock[] = [ + { id: 'a1', kind: 'text', createdAt: makeTime(1), author: makeAuthor('b1'), text: '' }, + ] + const items = blocksToTranscriptItems(blocks) + expect(items).toHaveLength(1) + const agent = items[0] as AgentTranscriptBlock + expect(agent.bubbles).toEqual([]) + expect(agent.parts).toEqual([]) + }) + + it('applies display overrides only from the first block of a merged group', () => { + const blocks: TranscriptBlock[] = [ + { id: 'a1', kind: 'text', createdAt: makeTime(1), author: makeAuthor('b1'), text: 'first', displayTitle: 'first-title' }, + { id: 'a2', kind: 'text', createdAt: makeTime(2), author: makeAuthor('b1'), text: 'second', displayTitle: 'second-title' }, + ] + const agent = blocksToTranscriptItems(blocks)[0] as AgentTranscriptBlock + expect(agent.displayTitle).toBe('first-title') + }) + + it('maps reply-to metadata from the first text block onto the agent item', () => { + const blocks: TranscriptBlock[] = [ + { + id: 'a1', kind: 'text', createdAt: makeTime(1), author: makeAuthor('b1'), + text: 'hi', replyToMessageId: 'm0', replyAuthor: 'alice', replyPreview: 'original message', + }, + ] + const agent = blocksToTranscriptItems(blocks)[0] as AgentTranscriptBlock + expect(agent.replyBlockId).toBe('m0') + expect(agent.replyAuthor).toBe('alice') + expect(agent.replyPreview).toBe('original message') + }) + + it('keeps the reply metadata of the first reply-bearing block', () => { + const blocks: TranscriptBlock[] = [ + { id: 'a1', kind: 'text', createdAt: makeTime(1), author: makeAuthor('b1'), text: 'hi', replyToMessageId: 'm0', replyAuthor: 'alice' }, + { id: 'a2', kind: 'text', createdAt: makeTime(2), author: makeAuthor('b1'), text: 'again', replyToMessageId: 'm9', replyAuthor: 'bob' }, + ] + const agent = blocksToTranscriptItems(blocks)[0] as AgentTranscriptBlock + expect(agent.replyBlockId).toBe('m0') + expect(agent.replyAuthor).toBe('alice') + }) + + it('omits replyAuthor and replyPreview when they are undefined', () => { + const blocks: TranscriptBlock[] = [ + { id: 'a1', kind: 'text', createdAt: makeTime(1), author: makeAuthor('b1'), text: 'hi', replyToMessageId: 'm0' }, + ] + const agent = blocksToTranscriptItems(blocks)[0] as AgentTranscriptBlock + expect(agent.replyBlockId).toBe('m0') + expect(agent.replyAuthor).toBeUndefined() + expect(agent.replyPreview).toBeUndefined() + }) + + it('takes reply metadata from a later block when earlier blocks lack it', () => { + const blocks: TranscriptBlock[] = [ + { id: 'a1', kind: 'text', createdAt: makeTime(1), author: makeAuthor('b1'), text: 'plain' }, + { id: 'a2', kind: 'text', createdAt: makeTime(2), author: makeAuthor('b1'), text: 'reply', replyToMessageId: 'm9', replyAuthor: 'bob' }, + ] + const agent = blocksToTranscriptItems(blocks)[0] as AgentTranscriptBlock + expect(agent.replyBlockId).toBe('m9') + expect(agent.replyAuthor).toBe('bob') + }) + + it('splits text blocks from different authors into separate items', () => { + const blocks: TranscriptBlock[] = [ + { id: 'a1', kind: 'text', createdAt: makeTime(1), author: makeAuthor('b1'), text: 'a' }, + { id: 'a2', kind: 'text', createdAt: makeTime(2), author: makeAuthor('b2', 'ReviewerAgent'), text: 'b' }, + ] + const items = blocksToTranscriptItems(blocks) + expect(items).toHaveLength(2) + expect((items[0] as AgentTranscriptBlock).id).toBe('b1-1') + expect((items[1] as AgentTranscriptBlock).id).toBe('b2-2') + }) + + it('treats a system-role text block as an agent bubble', () => { + const blocks: TranscriptBlock[] = [ + { id: 's1', kind: 'text', createdAt: makeTime(0), author: makeSystemAuthor('sys1'), text: 'system note' }, + ] + const items = blocksToTranscriptItems(blocks) + expect(items).toHaveLength(1) + const agent = items[0] as AgentTranscriptBlock + expect(agent.role).toBe('system') + expect(agent.agent).toBe('System') + expect(agent.bubbles).toEqual(['system note']) + }) + + it('falls back to role system and name Agent for an author-less text block', () => { + const blocks = [ + { id: 't1', kind: 'text' as const, createdAt: makeTime(1), author: null as unknown as TranscriptBlock['author'], text: 'hello' }, + ] as TranscriptBlock[] + const items = blocksToTranscriptItems(blocks) + expect(items).toHaveLength(1) + const agent = items[0] as AgentTranscriptBlock + expect(agent.agent).toBe('Agent') + expect(agent.role).toBe('system') + expect(agent.groupId).toBe('unknown') + expect(agent.id).toBe('unknown-1') + expect(agent.bubbles).toEqual(['hello']) + }) + + it('merges consecutive author-less text blocks into one item', () => { + const blocks = [ + { id: 't1', kind: 'text' as const, createdAt: makeTime(1), author: null as unknown as TranscriptBlock['author'], text: 'a' }, + { id: 't2', kind: 'text' as const, createdAt: makeTime(2), author: null as unknown as TranscriptBlock['author'], text: 'b' }, + ] as TranscriptBlock[] + const items = blocksToTranscriptItems(blocks) + expect(items).toHaveLength(1) + expect((items[0] as AgentTranscriptBlock).bubbles).toEqual(['a', 'b']) + }) + + it('appends a text bubble to an existing structured group of the same author', () => { + const blocks: TranscriptBlock[] = [ + { id: 'th1', kind: 'thinking', createdAt: makeTime(1), author: makeAuthor('b1'), content: 'x', isThinking: true }, + { id: 't1', kind: 'text', createdAt: makeTime(2), author: makeAuthor('b1'), text: 'hi' }, + ] + const items = blocksToTranscriptItems(blocks) + expect(items).toHaveLength(1) + const agent = items[0] as AgentTranscriptBlock + expect(agent.rows).toHaveLength(1) + expect(agent.bubbles).toEqual(['hi']) + expect(agent.parts!.map(p => p.type)).toEqual(['row', 'bubble']) + }) +}) + +describe('blocksToTranscriptItems — agent_timeline flattening', () => { + it('maps every timeline status to the think status vocabulary', () => { + const blocks: TranscriptBlock[] = [ + { + id: 'tl1', kind: 'agent_timeline', createdAt: makeTime(1), author: makeAuthor('b1'), + items: [ + { label: 'A', status: 'completed' }, + { label: 'B', status: 'done' }, + { label: 'C', status: 'failed' }, + { label: 'D', status: 'todo' }, + { label: 'E', status: 'running' }, + { label: 'F', status: 'pending' }, + ], + }, + ] + const agent = blocksToTranscriptItems(blocks)[0] as AgentTranscriptBlock + expect(agent.rows!.map(r => r.status)).toEqual(['ok', 'ok', 'fail', 'waiting', 'running', 'running']) + }) + + it('builds think rows with id, label, collapsible flag and content', () => { + const blocks: TranscriptBlock[] = [ + { + id: 'tl1', kind: 'agent_timeline', createdAt: makeTime(1), author: makeAuthor('b1'), + items: [{ label: 'Compile', detail: 'Build succeeded', status: 'completed' }], + }, + ] + const agent = blocksToTranscriptItems(blocks)[0] as AgentTranscriptBlock + expect(agent.rows![0]).toMatchObject({ + id: 'tl1-Compile', + type: 'think', + label: '', + status: 'ok', + collapsible: true, + content: 'Compile: Build succeeded', + }) + }) + + it('falls back to an empty detail suffix in the think content', () => { + const blocks: TranscriptBlock[] = [ + { + id: 'tl1', kind: 'agent_timeline', createdAt: makeTime(1), author: makeAuthor('b1'), + items: [{ label: 'Plan', status: 'todo' }], + }, + ] + const row = (blocksToTranscriptItems(blocks)[0] as AgentTranscriptBlock).rows![0]! + expect(row.content).toBe('Plan: ') + expect(row.status).toBe('waiting') + }) + + it('falls back to running for an unknown timeline status', () => { + const blocks = [ + { + id: 'tl1', kind: 'agent_timeline' as const, createdAt: makeTime(1), author: makeAuthor('b1'), + items: [{ label: 'X', status: 'paused' as unknown as 'completed' }], + }, + ] as TranscriptBlock[] + const row = (blocksToTranscriptItems(blocks)[0] as AgentTranscriptBlock).rows![0]! + expect(row.status).toBe('running') + }) + + it('produces no items for a timeline with an empty items array', () => { + const blocks: TranscriptBlock[] = [ + { id: 'tl1', kind: 'agent_timeline', createdAt: makeTime(1), author: makeAuthor('b1'), items: [] }, + ] + expect(blocksToTranscriptItems(blocks)).toEqual([]) + }) + + it('produces no items when the timeline items array is missing', () => { + const blocks = [ + { id: 'tl1', kind: 'agent_timeline' as const, createdAt: makeTime(1), author: makeAuthor('b1') }, + ] as TranscriptBlock[] + expect(blocksToTranscriptItems(blocks)).toEqual([]) + }) + + it('appends timeline rows to an existing same-author group', () => { + const blocks: TranscriptBlock[] = [ + { id: 't1', kind: 'text', createdAt: makeTime(1), author: makeAuthor('b1'), text: 'hi' }, + { + id: 'tl1', kind: 'agent_timeline', createdAt: makeTime(2), author: makeAuthor('b1'), + items: [{ label: 'X', status: 'completed' }], + }, + ] + const items = blocksToTranscriptItems(blocks) + expect(items).toHaveLength(1) + const agent = items[0] as AgentTranscriptBlock + expect(agent.bubbles).toEqual(['hi']) + expect(agent.rows).toHaveLength(1) + expect(agent.parts!.map(p => p.type)).toEqual(['bubble', 'row']) + }) + + it('creates a standalone timeline group using the plain author id', () => { + const blocks: TranscriptBlock[] = [ + { + id: 'tl1', kind: 'agent_timeline', createdAt: makeTime(1), author: makeAuthor('b1'), + items: [{ label: 'X', status: 'completed' }], + }, + ] + const agent = blocksToTranscriptItems(blocks)[0] as AgentTranscriptBlock + expect(agent.id).toBe('b1') + expect(agent.role).toBe('agent') + expect(agent.groupId).toBe('b1') + expect(agent.evidenceRefs).toBeUndefined() + expect(agent.time).toBeTruthy() + }) + + it('propagates evidenceRefs from the timeline block', () => { + const blocks: TranscriptBlock[] = [ + { + id: 'tl1', kind: 'agent_timeline', createdAt: makeTime(1), author: makeAuthor('b1'), + items: [{ label: 'X', status: 'completed' }], + evidenceRefs: [{ id: 'er1', kind: 'run', label: 'Run', status: 'running' }], + }, + ] + const agent = blocksToTranscriptItems(blocks)[0] as AgentTranscriptBlock + expect(agent.evidenceRefs).toHaveLength(1) + expect(agent.evidenceRefs![0]!.id).toBe('er1') + }) + + it('processes a human-authored timeline as an agent item with role human', () => { + const blocks: TranscriptBlock[] = [ + { + id: 'tl1', kind: 'agent_timeline', createdAt: makeTime(1), author: makeUser('alice'), + items: [{ label: 'S', status: 'completed' }], + }, + ] + const items = blocksToTranscriptItems(blocks) + expect(items).toHaveLength(1) + const agent = items[0] as AgentTranscriptBlock + expect(agent.role).toBe('human') + expect(agent.agent).toBe(DEFAULT_USER_NAME) + expect(agent.rows).toHaveLength(1) + }) +}) + +describe('blocksToTranscriptItems — run_step_group recursion', () => { + it('maps children through mapBlock and wraps them in a sub row', () => { + const blocks: TranscriptBlock[] = [ + { + id: 'rsg1', kind: 'run_step_group', createdAt: makeTime(1), author: makeAuthor('b1'), + icon: '>', title: 'Commands', status: 'completed', open: true, + children: [ + { id: 'tc1', kind: 'tool_call', author: makeUser('alice'), toolName: 'Read', status: 'running' } as TranscriptBlock, + ], + }, + ] + const agent = blocksToTranscriptItems(blocks)[0] as AgentTranscriptBlock + expect(agent.rows).toHaveLength(1) + const row = agent.rows![0]! + expect(row.type).toBe('sub') + expect(row.id).toBe('rsg1') + expect(row.label).toBe('Commands') + expect(row.status).toBe('ok') + expect(row.collapsible).toBe(true) + expect(row.open).toBe(true) + expect(row.children).toHaveLength(1) + expect(row.children![0]!.type).toBe('tool') + }) + + it('maps group statuses completed/failed/running/pending to ok/fail/running/running', () => { + const blocks: TranscriptBlock[] = [ + { id: 'g1', kind: 'run_step_group', author: makeAuthor('b1'), icon: '>', title: 'T1', status: 'completed', children: [] }, + { id: 'g2', kind: 'run_step_group', author: makeAuthor('b1'), icon: '>', title: 'T2', status: 'failed', children: [] }, + { id: 'g3', kind: 'run_step_group', author: makeAuthor('b1'), icon: '>', title: 'T3', status: 'running', children: [] }, + { id: 'g4', kind: 'run_step_group', author: makeAuthor('b1'), icon: '>', title: 'T4', status: 'pending', children: [] }, + ] + const agent = blocksToTranscriptItems(blocks)[0] as AgentTranscriptBlock + expect(agent.rows!.map(r => r.status)).toEqual(['ok', 'fail', 'running', 'running']) + }) + + it('defaults open to false and preserves explicit open', () => { + const blocks: TranscriptBlock[] = [ + { id: 'g1', kind: 'run_step_group', author: makeAuthor('b1'), icon: '>', title: 'T1', status: 'completed', children: [] }, + { id: 'g2', kind: 'run_step_group', author: makeAuthor('b1'), icon: '>', title: 'T2', status: 'completed', open: true, children: [] }, + ] + const agent = blocksToTranscriptItems(blocks)[0] as AgentTranscriptBlock + expect(agent.rows![0]!.open).toBe(false) + expect(agent.rows![1]!.open).toBe(true) + }) + + it('maps group meta to row extra only when defined', () => { + const blocks: TranscriptBlock[] = [ + { id: 'g1', kind: 'run_step_group', author: makeAuthor('b1'), icon: '>', title: 'T1', status: 'completed', meta: 'startup', children: [] }, + { id: 'g2', kind: 'run_step_group', author: makeAuthor('b1'), icon: '>', title: 'T2', status: 'completed', children: [] }, + ] + const agent = blocksToTranscriptItems(blocks)[0] as AgentTranscriptBlock + expect(agent.rows![0]!.extra).toBe('startup') + expect(agent.rows![1]!.extra).toBeUndefined() + }) + + it('skips children that mapBlock drops', () => { + const blocks: TranscriptBlock[] = [ + { + id: 'g1', kind: 'run_step_group', author: makeAuthor('b1'), icon: '>', title: 'T1', status: 'completed', + children: [ + { id: 'r1', kind: 'result', author: makeAuthor('b1'), success: true } as TranscriptBlock, + ], + }, + ] + const row = (blocksToTranscriptItems(blocks)[0] as AgentTranscriptBlock).rows![0]! + expect(row.type).toBe('sub') + expect(row.children).toEqual([]) + }) + + it('wraps an empty children array in a sub row with no children', () => { + const blocks: TranscriptBlock[] = [ + { id: 'g1', kind: 'run_step_group', author: makeAuthor('b1'), icon: '>', title: 'T1', status: 'completed', children: [] }, + ] + const agent = blocksToTranscriptItems(blocks)[0] as AgentTranscriptBlock + expect(agent.rows![0]).toMatchObject({ type: 'sub', children: [] }) + }) + + it('skips the whole group when children are missing', () => { + const blocks = [ + { id: 'g1', kind: 'run_step_group' as const, createdAt: makeTime(1), author: makeAuthor('b1'), icon: '>', title: 'T1', status: 'completed' as const }, + ] as TranscriptBlock[] + expect(blocksToTranscriptItems(blocks)).toEqual([]) + }) + + it('propagates evidenceRefs onto a new group item', () => { + const blocks: TranscriptBlock[] = [ + { + id: 'g1', kind: 'run_step_group', createdAt: makeTime(1), author: makeAuthor('b1'), icon: '>', title: 'T1', status: 'completed', + children: [], + evidenceRefs: [{ id: 'er1', kind: 'artifact', label: 'A', status: 'completed' }], + }, + ] + const agent = blocksToTranscriptItems(blocks)[0] as AgentTranscriptBlock + expect(agent.evidenceRefs).toHaveLength(1) + }) +}) + +describe('blocksToTranscriptItems — structured blocks and standalone routing', () => { + it('maps a thinking block into an inline think row', () => { + const blocks: TranscriptBlock[] = [ + { id: 'th1', kind: 'thinking', createdAt: makeTime(1), author: makeAuthor('b1'), content: 'x', isThinking: true }, + ] + const agent = blocksToTranscriptItems(blocks)[0] as AgentTranscriptBlock + expect(agent.rows).toHaveLength(1) + expect(agent.rows![0]!.type).toBe('think') + expect(agent.standaloneRows).toEqual([]) + }) + + it('routes all standalone card types to standaloneRows', () => { + const blocks: TranscriptBlock[] = [ + { id: 'rd1', kind: 'route_decision', author: makeAuthor('b1'), action: 'dispatch', summary: '→ builder' }, + { id: 'd1', kind: 'deploy', author: makeAuthor('b1'), runId: 'run-1', status: 'ready', url: 'https://preview.example.com' }, + { id: 'cu1', kind: 'context_usage', author: makeAuthor('b1'), inputTokens: 1000, outputTokens: 500, usagePercent: 40, modelLabel: 'gpt-4' }, + { id: 'ap1', kind: 'approval', author: makeAuthor('b1'), title: 'Allow', status: 'pending' }, + { id: 'rs1', kind: 'run_session', author: makeAuthor('b1'), title: 'Run #1', status: 'completed' }, + { id: 'at1', kind: 'attachment', author: makeAuthor('b1'), attachmentRef: { id: 'att-1', name: 'shot.png', size: 2048, mime_type: 'image/png' }, contentType: 'image' }, + { id: 'pv1', kind: 'preview', author: makeAuthor('b1'), previewId: 'prev-1', status: 'completed', url: 'https://example.com/a.html' }, + ] + const items = blocksToTranscriptItems(blocks) + expect(items).toHaveLength(1) + const agent = items[0] as AgentTranscriptBlock + expect(agent.rows).toEqual([]) + expect(agent.standaloneRows!.map(r => r.type)).toEqual([ + 'route', 'deploy', 'ctx', 'approval', 'session', 'attachment', 'preview', + ]) + expect(agent.parts).toHaveLength(7) + }) + + it('keeps inline card types in rows', () => { + const blocks: TranscriptBlock[] = [ + { id: 'th1', kind: 'thinking', author: makeAuthor('b1'), content: 'x', isThinking: true }, + { id: 'tc1', kind: 'tool_call', author: makeAuthor('b1'), toolName: 'Read', status: 'running' }, + { id: 'fc1', kind: 'file_change', author: makeAuthor('b1'), path: 'a.ts', action: 'modified' }, + { id: 'sa1', kind: 'subagent', author: makeAuthor('b1'), title: 'T', worker: 'w', status: 'pending' }, + { id: 'f1', kind: 'failure', author: makeAuthor('b1'), title: 'E', reason: 'boom' }, + ] + const agent = blocksToTranscriptItems(blocks)[0] as AgentTranscriptBlock + expect(agent.rows!.map(r => r.type)).toEqual(['think', 'tool', 'file', 'sub', 'think']) + expect(agent.standaloneRows).toEqual([]) + }) + + it('drops result, finished and replay_gap blocks', () => { + const blocks: TranscriptBlock[] = [ + { id: 'r1', kind: 'result', createdAt: makeTime(1), author: makeAuthor('b1'), success: true }, + { id: 'f1', kind: 'finished', createdAt: makeTime(2), author: makeAuthor('b1'), title: 'done' }, + { id: 'rg1', kind: 'replay_gap', createdAt: makeTime(3), author: makeAuthor('b1'), replayedCount: 3 }, + ] + expect(blocksToTranscriptItems(blocks)).toEqual([]) + }) + + it('drops compact_boundary blocks', () => { + const blocks: TranscriptBlock[] = [ + { id: 'cb1', kind: 'compact_boundary', createdAt: makeTime(1), author: makeAuthor('b1') }, + ] + expect(blocksToTranscriptItems(blocks)).toEqual([]) + }) + + it('falls back to Agent/unknown for a structured block without an author', () => { + const blocks = [ + { id: 'th1', kind: 'thinking' as const, createdAt: makeTime(1), author: null as unknown as TranscriptBlock['author'], content: 'x', isThinking: true }, + ] as TranscriptBlock[] + const items = blocksToTranscriptItems(blocks) + expect(items).toHaveLength(1) + const agent = items[0] as AgentTranscriptBlock + expect(agent.agent).toBe('Agent') + expect(agent.role).toBe('system') + expect(agent.groupId).toBe('unknown') + expect(agent.id).toBe('unknown-1') + expect(agent.rows![0]!.type).toBe('think') + }) + + it('propagates non-empty evidenceRefs onto a new agent item', () => { + const blocks: TranscriptBlock[] = [ + { + id: 'th1', kind: 'thinking', createdAt: makeTime(1), author: makeAuthor('b1'), content: 'x', isThinking: true, + evidenceRefs: [{ id: 'er1', kind: 'tool', label: 'Logs', status: 'completed' }], + }, + ] + const agent = blocksToTranscriptItems(blocks)[0] as AgentTranscriptBlock + expect(agent.evidenceRefs).toHaveLength(1) + expect(agent.evidenceRefs![0]!.id).toBe('er1') + }) + + it('omits evidenceRefs when the block list is empty', () => { + const blocks: TranscriptBlock[] = [ + { id: 'th1', kind: 'thinking', createdAt: makeTime(1), author: makeAuthor('b1'), content: 'x', isThinking: true, evidenceRefs: [] }, + ] + const agent = blocksToTranscriptItems(blocks)[0] as AgentTranscriptBlock + expect(agent.evidenceRefs).toBeUndefined() + }) + + it('merges mixed same-author kinds into one item preserving part order', () => { + const blocks: TranscriptBlock[] = [ + { id: 'th1', kind: 'thinking', createdAt: makeTime(1), author: makeAuthor('b1'), content: 'think', isThinking: true }, + { id: 'tc1', kind: 'tool_call', createdAt: makeTime(2), author: makeAuthor('b1'), toolName: 'Read', status: 'running', target: 'a.ts' }, + { id: 't1', kind: 'text', createdAt: makeTime(3), author: makeAuthor('b1'), text: 'hello' }, + { id: 'rd1', kind: 'route_decision', createdAt: makeTime(4), author: makeAuthor('b1'), action: 'dispatch', summary: '→ builder' }, + { id: 'tr1', kind: 'tool_result', createdAt: makeTime(5), author: makeAuthor('b1'), toolName: 'Read', status: 'completed', summary: 'done' }, + ] + const items = blocksToTranscriptItems(blocks) + expect(items).toHaveLength(1) + const agent = items[0] as AgentTranscriptBlock + expect(agent.rows).toHaveLength(2) + expect(agent.rows![0]!.type).toBe('think') + expect(agent.rows![1]).toMatchObject({ id: 'tc1', type: 'tool', status: 'ok', content: 'done', isResult: true }) + expect(agent.bubbles).toEqual(['hello']) + expect(agent.standaloneRows).toHaveLength(1) + expect(agent.standaloneRows![0]!.type).toBe('route') + expect(agent.parts!.map(p => p.type)).toEqual(['row', 'row', 'bubble', 'row']) + }) + + it('derives the structured agent id from the absolute block position', () => { + const blocks: TranscriptBlock[] = [ + { id: 'u1', kind: 'text', createdAt: makeTime(0), author: makeUser('alice'), text: 'q' }, + { id: 'th1', kind: 'thinking', createdAt: makeTime(1), author: makeAuthor('b1'), content: 'x', isThinking: true }, + ] + const items = blocksToTranscriptItems(blocks) + expect((items[1] as AgentTranscriptBlock).id).toBe('b1-2') + }) +}) + +describe('blocksToTranscriptItems — tool call/result merging', () => { + it('merges a tool_result into its tool_call by toolName', () => { + const blocks: TranscriptBlock[] = [ + { id: 'tc1', kind: 'tool_call', createdAt: makeTime(1), author: makeAuthor('b1'), toolName: 'Read', status: 'running' }, + { id: 'tr1', kind: 'tool_result', createdAt: makeTime(2), author: makeAuthor('b1'), toolName: 'Read', status: 'completed', summary: '42 lines' }, + ] + const agent = blocksToTranscriptItems(blocks)[0] as AgentTranscriptBlock + expect(agent.rows).toHaveLength(1) + expect(agent.rows![0]).toMatchObject({ + id: 'tc1', type: 'tool', status: 'ok', content: '42 lines', isResult: true, + }) + }) + + it('replaces the toolCallId with the result row via spread semantics', () => { + const blocks: TranscriptBlock[] = [ + { id: 'tc1', kind: 'tool_call', createdAt: makeTime(1), author: makeAuthor('b1'), toolName: 'Read', callId: 'toolu-1', status: 'running' }, + { id: 'tr1', kind: 'tool_result', createdAt: makeTime(2), author: makeAuthor('b1'), toolName: 'Read', status: 'completed', summary: 'out' }, + ] + const agent = blocksToTranscriptItems(blocks)[0] as AgentTranscriptBlock + expect(agent.rows).toHaveLength(1) + expect(agent.rows![0]).toMatchObject({ id: 'tc1', content: 'out', isResult: true }) + expect(agent.rows![0]!.toolCallId).toBeUndefined() + }) + + it('pairs multiple same-name calls with results in FIFO order', () => { + const blocks: TranscriptBlock[] = [ + { id: 'c1', kind: 'tool_call', createdAt: makeTime(1), author: makeAuthor('b1'), toolName: 'Read', status: 'running', target: 'file1' }, + { id: 'c2', kind: 'tool_call', createdAt: makeTime(2), author: makeAuthor('b1'), toolName: 'Read', status: 'running', target: 'file2' }, + { id: 'r1', kind: 'tool_result', createdAt: makeTime(3), author: makeAuthor('b1'), toolName: 'Read', status: 'completed', summary: 'content1' }, + { id: 'r2', kind: 'tool_result', createdAt: makeTime(4), author: makeAuthor('b1'), toolName: 'Read', status: 'completed', summary: 'content2' }, + ] + const agent = blocksToTranscriptItems(blocks)[0] as AgentTranscriptBlock + expect(agent.rows).toHaveLength(2) + expect(agent.rows![0]).toMatchObject({ id: 'c1', content: 'content1', isResult: true }) + expect(agent.rows![1]).toMatchObject({ id: 'c2', content: 'content2', isResult: true }) + }) + + it('matches results by callId even when they arrive out of order', () => { + const blocks: TranscriptBlock[] = [ + { id: 'call-a', kind: 'tool_call', createdAt: makeTime(1), author: makeAuthor('b1'), toolName: 'Read', callId: 'toolu-a', status: 'running' }, + { id: 'call-b', kind: 'tool_call', createdAt: makeTime(2), author: makeAuthor('b1'), toolName: 'Read', callId: 'toolu-b', status: 'running' }, + { id: 'res-b', kind: 'tool_result', createdAt: makeTime(3), author: makeAuthor('b1'), toolName: 'Read', callId: 'toolu-b', status: 'completed', summary: 'b result' }, + { id: 'res-a', kind: 'tool_result', createdAt: makeTime(4), author: makeAuthor('b1'), toolName: 'Read', callId: 'toolu-a', status: 'completed', summary: 'a result' }, + ] + const agent = blocksToTranscriptItems(blocks)[0] as AgentTranscriptBlock + expect(agent.rows).toHaveLength(2) + expect(agent.rows![0]).toMatchObject({ id: 'call-a', content: 'a result', toolCallId: 'toolu-a' }) + expect(agent.rows![1]).toMatchObject({ id: 'call-b', content: 'b result', toolCallId: 'toolu-b' }) + }) + + it('does not merge same-name blocks with different callIds', () => { + const blocks: TranscriptBlock[] = [ + { id: 'call-a', kind: 'tool_call', createdAt: makeTime(1), author: makeAuthor('b1'), toolName: 'Read', callId: 'toolu-a', status: 'running' }, + { id: 'res-b', kind: 'tool_result', createdAt: makeTime(2), author: makeAuthor('b1'), toolName: 'Read', callId: 'toolu-b', status: 'completed', summary: 'x' }, + ] + const agent = blocksToTranscriptItems(blocks)[0] as AgentTranscriptBlock + expect(agent.rows).toHaveLength(2) + expect(agent.rows![0]!.isResult).toBeUndefined() + expect(agent.rows![1]!.isResult).toBe(true) + }) + + it('lets a matching callId win over a mismatched toolName', () => { + const blocks: TranscriptBlock[] = [ + { id: 'call-w', kind: 'tool_call', createdAt: makeTime(1), author: makeAuthor('b1'), toolName: 'Read', callId: 'toolu-c', status: 'running' }, + { id: 'res-w', kind: 'tool_result', createdAt: makeTime(2), author: makeAuthor('b1'), toolName: 'Write', callId: 'toolu-c', status: 'completed', summary: 'written' }, + ] + const agent = blocksToTranscriptItems(blocks)[0] as AgentTranscriptBlock + expect(agent.rows).toHaveLength(1) + expect(agent.rows![0]).toMatchObject({ + id: 'call-w', label: 'Write', toolName: 'write', toolCallId: 'toolu-c', status: 'ok', isResult: true, + }) + }) + + it('pushes an unmatched tool_result as its own row', () => { + const blocks: TranscriptBlock[] = [ + { id: 'call-x', kind: 'tool_call', createdAt: makeTime(1), author: makeAuthor('b1'), toolName: 'Read', status: 'running' }, + { id: 'res-y', kind: 'tool_result', createdAt: makeTime(2), author: makeAuthor('b1'), toolName: 'Write', status: 'completed', summary: 'out' }, + ] + const agent = blocksToTranscriptItems(blocks)[0] as AgentTranscriptBlock + expect(agent.rows).toHaveLength(2) + expect(agent.rows![1]).toMatchObject({ id: 'res-y', isResult: true, content: 'out' }) + }) + + it('pushes a duplicate result for an already-merged call as a separate row', () => { + const blocks: TranscriptBlock[] = [ + { id: 'call-d', kind: 'tool_call', createdAt: makeTime(1), author: makeAuthor('b1'), toolName: 'Read', callId: 'toolu-1', status: 'running' }, + { id: 'res-1', kind: 'tool_result', createdAt: makeTime(2), author: makeAuthor('b1'), toolName: 'Read', callId: 'toolu-1', status: 'completed', summary: 'first' }, + { id: 'res-2', kind: 'tool_result', createdAt: makeTime(3), author: makeAuthor('b1'), toolName: 'Read', callId: 'toolu-1', status: 'completed', summary: 'second' }, + ] + const agent = blocksToTranscriptItems(blocks)[0] as AgentTranscriptBlock + expect(agent.rows).toHaveLength(2) + expect(agent.rows![0]).toMatchObject({ id: 'call-d', content: 'first', isResult: true }) + expect(agent.rows![1]).toMatchObject({ id: 'res-2', content: 'second', isResult: true }) + }) + + it('updates the parts stream in place when a tool result replaces its call', () => { + const blocks: TranscriptBlock[] = [ + { id: 'tc1', kind: 'tool_call', createdAt: makeTime(1), author: makeAuthor('b1'), toolName: 'Read', status: 'running' }, + { id: 'tr1', kind: 'tool_result', createdAt: makeTime(2), author: makeAuthor('b1'), toolName: 'Read', status: 'completed', summary: '42 lines' }, + ] + const agent = blocksToTranscriptItems(blocks)[0] as AgentTranscriptBlock + expect(agent.parts).toHaveLength(1) + expect(agent.parts![0]).toMatchObject({ type: 'row', row: { id: 'tc1', content: '42 lines', isResult: true } }) + }) + + it('reflects a failed tool_result status in the merged row', () => { + const blocks: TranscriptBlock[] = [ + { id: 'tc1', kind: 'tool_call', createdAt: makeTime(1), author: makeAuthor('b1'), toolName: 'Bash', status: 'running' }, + { id: 'tr1', kind: 'tool_result', createdAt: makeTime(2), author: makeAuthor('b1'), toolName: 'Bash', status: 'failed', summary: 'exit 1' }, + ] + const agent = blocksToTranscriptItems(blocks)[0] as AgentTranscriptBlock + expect(agent.rows).toHaveLength(1) + expect(agent.rows![0]).toMatchObject({ id: 'tc1', status: 'fail', isResult: true, content: 'exit 1' }) + }) +}) + +describe('resolveUnreadAnchorItemIndex', () => { + const userText = (id: string, authorId = 'alice', offset = 0): TranscriptBlock => ({ + id, kind: 'text', createdAt: makeTime(offset), author: makeUser(authorId), text: 'msg ' + id, + }) + const agentText = (id: string, authorId = 'b1', offset = 0): TranscriptBlock => ({ + id, kind: 'text', createdAt: makeTime(offset), author: makeAuthor(authorId), text: 'reply ' + id, + }) + + it('returns -1 without a descriptor', () => { + const blocks = [userText('m1')] as TranscriptBlock[] + const items = blocksToTranscriptItems(blocks) + expect(resolveUnreadAnchorItemIndex(blocks, items, undefined)).toBe(-1) + }) + + it('returns -1 when the count is zero', () => { + const blocks = [userText('m1')] as TranscriptBlock[] + const items = blocksToTranscriptItems(blocks) + expect(resolveUnreadAnchorItemIndex(blocks, items, { anchorBlockId: 'm1', count: 0 })).toBe(-1) + }) + + it('returns -1 when the count is negative', () => { + const blocks = [userText('m1')] as TranscriptBlock[] + const items = blocksToTranscriptItems(blocks) + expect(resolveUnreadAnchorItemIndex(blocks, items, { anchorBlockId: 'm1', count: -3 })).toBe(-1) + }) + + it('returns -1 when the items array is empty', () => { + const blocks = [userText('m1')] as TranscriptBlock[] + expect(resolveUnreadAnchorItemIndex(blocks, [], { anchorBlockId: 'm1', count: 1 })).toBe(-1) + }) + + it('falls back to the unread tail when no anchorBlockId is given', () => { + const blocks = [ + userText('m1', 'alice', 0), + userText('m2', 'alice', 1), + userText('m3', 'alice', 2), + userText('m4', 'alice', 3), + ] as TranscriptBlock[] + const items = blocksToTranscriptItems(blocks) + expect(resolveUnreadAnchorItemIndex(blocks, items, { count: 2 })).toBe(2) + }) + + it('falls back to the tail and clamps at zero when the anchor block is missing', () => { + const blocks = [ + userText('m1', 'alice', 0), + userText('m2', 'alice', 1), + userText('m3', 'alice', 2), + ] as TranscriptBlock[] + const items = blocksToTranscriptItems(blocks) + expect(resolveUnreadAnchorItemIndex(blocks, items, { anchorBlockId: 'ghost', count: 2 })).toBe(1) + expect(resolveUnreadAnchorItemIndex(blocks, items, { anchorBlockId: 'ghost', count: 99 })).toBe(0) + }) + + it('returns index 0 when the anchor is the first block', () => { + const blocks = [ + userText('m1', 'alice', 0), + userText('m2', 'alice', 1), + userText('m3', 'alice', 2), + ] as TranscriptBlock[] + const items = blocksToTranscriptItems(blocks) + expect(resolveUnreadAnchorItemIndex(blocks, items, { anchorBlockId: 'm1', count: 3 })).toBe(0) + }) + + it('treats a merged agent group as a single containing item', () => { + const blocks = [ + userText('u1', 'alice', 0), + agentText('a1', 'b1', 1), + agentText('a2', 'b1', 2), + agentText('a3', 'b1', 3), + ] as TranscriptBlock[] + const items = blocksToTranscriptItems(blocks) + expect(items).toHaveLength(2) + expect(resolveUnreadAnchorItemIndex(blocks, items, { anchorBlockId: 'a2', count: 2 })).toBe(1) + expect(resolveUnreadAnchorItemIndex(blocks, items, { anchorBlockId: 'a1', count: 3 })).toBe(1) + }) + + it('locates a user item after an interleaved agent group', () => { + const blocks = [ + userText('u1', 'alice', 0), + agentText('a1', 'b1', 1), + userText('u2', 'alice', 2), + ] as TranscriptBlock[] + const items = blocksToTranscriptItems(blocks) + expect(items).toHaveLength(3) + expect(resolveUnreadAnchorItemIndex(blocks, items, { anchorBlockId: 'u2', count: 1 })).toBe(2) + }) + + it('ignores human non-text blocks when counting item starts', () => { + const blocks: TranscriptBlock[] = [ + userText('u1', 'alice', 0), + { + id: 'att1', kind: 'attachment', createdAt: makeTime(1), author: makeUser('alice'), + attachmentRef: { id: 'att-1', name: 'f.txt', size: 10, mime_type: 'text/plain' }, contentType: 'file', + }, + agentText('a1', 'b1', 2), + ] + const items = blocksToTranscriptItems(blocks) + expect(items).toHaveLength(2) + expect(resolveUnreadAnchorItemIndex(blocks, items, { anchorBlockId: 'a1', count: 1 })).toBe(1) + }) + + it('groups author-less blocks under the unknown author id', () => { + const blocks = [ + { id: 't1', kind: 'text' as const, createdAt: makeTime(0), author: null as unknown as TranscriptBlock['author'], text: 'a' }, + userText('u1', 'alice', 1), + ] as TranscriptBlock[] + const items = blocksToTranscriptItems(blocks) + expect(items).toHaveLength(2) + expect(resolveUnreadAnchorItemIndex(blocks, items, { anchorBlockId: 'u1', count: 1 })).toBe(1) + }) + + it('counts agent blocks that the adapter later drops, so the index can reach items.length', () => { + const blocks: TranscriptBlock[] = [ + { id: 'r1', kind: 'result', createdAt: makeTime(0), author: makeAuthor('b1'), success: true }, + userText('u1', 'alice', 1), + ] + const items = blocksToTranscriptItems(blocks) + expect(items).toHaveLength(1) + expect(resolveUnreadAnchorItemIndex(blocks, items, { anchorBlockId: 'u1', count: 1 })).toBe(1) + }) +}) + +describe('resolveCompactDividerIndices', () => { + const boundary = (id: string, trigger?: string, preTokens?: number): TranscriptBlock => + ({ id, kind: 'compact_boundary', createdAt: makeTime(1), author: makeAuthor('b1'), trigger, preTokens }) + const userText = (id: string, authorId = 'alice', offset = 0): TranscriptBlock => ({ + id, kind: 'text', createdAt: makeTime(offset), author: makeUser(authorId), text: 'msg ' + id, + }) + const agentText = (id: string, authorId = 'b1', offset = 0): TranscriptBlock => ({ + id, kind: 'text', createdAt: makeTime(offset), author: makeAuthor(authorId), text: 'reply ' + id, + }) + + it('returns an empty array for empty blocks', () => { + expect(resolveCompactDividerIndices([])).toEqual([]) + }) + + it('returns an empty array when there are no boundaries', () => { + const blocks = [userText('u1'), agentText('a1', 'b1', 1)] as TranscriptBlock[] + expect(resolveCompactDividerIndices(blocks)).toEqual([]) + }) + + it('places a lone boundary at index 0', () => { + expect(resolveCompactDividerIndices([boundary('cb1')])).toEqual([{ index: 0 }]) + }) + + it('propagates trigger and preTokens metadata', () => { + const blocks = [boundary('cb1', 'auto', 1234)] as TranscriptBlock[] + expect(resolveCompactDividerIndices(blocks)).toEqual([{ index: 0, trigger: 'auto', preTokens: 1234 }]) + }) + + it('keeps a zero preTokens and omits an empty trigger', () => { + const blocks = [boundary('cb1', '', 0)] as TranscriptBlock[] + expect(resolveCompactDividerIndices(blocks)).toEqual([{ index: 0, preTokens: 0 }]) + }) + + it('counts each user text block as one item', () => { + const blocks = [ + userText('u1', 'alice', 0), + userText('u2', 'alice', 1), + boundary('cb1'), + ] as TranscriptBlock[] + expect(resolveCompactDividerIndices(blocks)).toEqual([{ index: 2 }]) + }) + + it('counts a merged agent group as one item', () => { + const blocks = [ + agentText('a1', 'b1', 0), + agentText('a2', 'b1', 1), + boundary('cb1'), + ] as TranscriptBlock[] + expect(resolveCompactDividerIndices(blocks)).toEqual([{ index: 1 }]) + }) + + it('does not split a same-author group across a boundary', () => { + const blocks: TranscriptBlock[] = [ + { id: 'th1', kind: 'thinking', createdAt: makeTime(0), author: makeAuthor('b1'), content: 'x', isThinking: true }, + boundary('cb1'), + { id: 'tc1', kind: 'tool_call', createdAt: makeTime(2), author: makeAuthor('b1'), toolName: 'Read', status: 'running' }, + ] + expect(resolveCompactDividerIndices(blocks)).toEqual([{ index: 1 }]) + }) + + it('increments the item index when the author changes', () => { + const blocks: TranscriptBlock[] = [ + { id: 'th1', kind: 'thinking', createdAt: makeTime(0), author: makeAuthor('b1'), content: 'x', isThinking: true }, + { id: 'th2', kind: 'thinking', createdAt: makeTime(1), author: makeAuthor('b2'), content: 'y', isThinking: true }, + boundary('cb1'), + ] + expect(resolveCompactDividerIndices(blocks)).toEqual([{ index: 2 }]) + }) + + it('does not count human non-text blocks as items', () => { + const blocks: TranscriptBlock[] = [ + userText('u1', 'alice', 0), + { + id: 'att1', kind: 'attachment', createdAt: makeTime(1), author: makeUser('alice'), + attachmentRef: { id: 'att-1', name: 'f.txt', size: 10, mime_type: 'text/plain' }, contentType: 'file', + }, + boundary('cb1'), + ] + expect(resolveCompactDividerIndices(blocks)).toEqual([{ index: 1 }]) + }) + + it('keeps consecutive boundaries at the same index', () => { + const blocks = [boundary('cb1'), boundary('cb2', 'manual')] as TranscriptBlock[] + expect(resolveCompactDividerIndices(blocks)).toEqual([ + { index: 0 }, + { index: 0, trigger: 'manual' }, + ]) + }) + + it('returns descriptors sorted by ascending index across the transcript', () => { + const blocks: TranscriptBlock[] = [ + userText('u1', 'alice', 0), + boundary('cb1', 'auto'), + agentText('a1', 'b1', 1), + agentText('a2', 'b1', 2), + boundary('cb2', 'manual', 999), + ] + expect(resolveCompactDividerIndices(blocks)).toEqual([ + { index: 1, trigger: 'auto' }, + { index: 2, trigger: 'manual', preTokens: 999 }, + ]) + }) + + it('counts system-role blocks as agent-like items', () => { + const blocks: TranscriptBlock[] = [ + { id: 's1', kind: 'text', createdAt: makeTime(0), author: makeSystemAuthor('sys1'), text: 'note' }, + boundary('cb1'), + ] + expect(resolveCompactDividerIndices(blocks)).toEqual([{ index: 1 }]) + }) + + it('does not count a human-authored timeline, diverging from the adapter grouping', () => { + // The adapter itself creates an agent item for this block; the compact + // divider simulation only counts human *text* and agent/system blocks. + const blocks: TranscriptBlock[] = [ + { + id: 'tl1', kind: 'agent_timeline', createdAt: makeTime(0), author: makeUser('alice'), + items: [{ label: 'S', status: 'completed' }], + }, + boundary('cb1'), + ] + expect(resolveCompactDividerIndices(blocks)).toEqual([{ index: 0 }]) + }) +}) diff --git a/app/shared/src/hub/hubClientPayloadPaths.test.ts b/app/shared/src/hub/hubClientPayloadPaths.test.ts new file mode 100644 index 000000000..17796fd90 --- /dev/null +++ b/app/shared/src/hub/hubClientPayloadPaths.test.ts @@ -0,0 +1,450 @@ +// real_tested=true +import { describe, expect, it } from 'vitest'; + +import { + buildAcceptFriendRequestPath, + buildAckRelayCommandPath, + buildAckTaskPath, + buildAgentProfilePath, + buildAgentProfilesPath, + buildAgentTasksPath, + buildAgentTeamMembersPath, + buildAgentTeamPath, + buildAgentTeamRunsPath, + buildAgentTeamsPath, + buildAttachmentsPath, + buildBlockContactPath, + buildCancelAgentTaskPaths, + buildContactRemarkPath, + buildCreateGroupSessionPath, + buildCreatePrivateSessionPath, + buildCustomAgentPath, + buildCustomAgentsPath, + buildDecideTaskApprovalPath, + buildDecideTeamApprovalPath, + buildDissolveSessionPath, + buildDocumentPath, + buildDocumentsPath, + buildDoneTaskPath, + buildEditMessagePath, + buildExecutionTargetPath, + buildExecutionTargetsPath, + buildFailTaskPath, + buildForwardMessagePath, + buildFriendRequestsPath, + buildGetMessagesPath, + buildGetTeamRunPath, + buildGetTeamRunStatePath, + buildLeaveSessionPath, + buildListAgentProfilesPath, + buildListAuditEventsPath, + buildListContactsPath, + buildListDocumentsPath, + buildListExecutionTargetsPath, + buildListMessageReactionsPath, + buildListNotificationsPath, + buildListPublicMCPServersPath, + buildListPublicSkillsPath, + buildListSessionsPath, + buildListTaskApprovalsPath, + buildListTaskArtifactsPath, + buildListTaskRunEventsAfterPath, + buildListTaskRunEventsPath, + buildListTeamEventsPath, + buildListTeamTasksPath, + buildListWorkspaceProjectThreadMessagesPath, + buildListWorkspaceProjectsPath, + buildLogoutPath, + buildMarkNotificationReadPaths, + buildMarkReadPath, + buildMePath, + buildMessageReactionsPath, + buildOidcAuthorizePath, + buildOidcCallbackPath, + buildPinMessagePath, + buildPingExecutionTargetPath, + buildPostTeamRouteDecisionPath, + buildProbeAttachmentPath, + buildReadAllNotificationsPaths, + buildRecallMessagePath, + buildRefreshPath, + buildRegenerateAgentTaskPath, + buildRegisterDevicePaths, + buildRejectFriendRequestPath, + buildRelayCommandPath, + buildRelayCommandsPath, + buildRemoveAgentTeamMemberPath, + buildRemoveContactPath, + buildRemoveSessionMemberPath, + buildResolveTeamConflictPath, + buildSearchMessagesPath, + buildSearchSessionMessagesPath, + buildSearchSessionsPath, + buildSearchUserPath, + buildSendWorkspaceProjectThreadMessagePath, + buildSessionAgentsPath, + buildSessionInfoPath, + buildSessionMembersPath, + buildSessionPath, + buildSessionPinsPath, + buildSessionSettingsPath, + buildSettingsPath, + buildStreamTaskPath, + buildSyncMessagesPath, + buildTaskRunEventSummaryPath, + buildTransferSessionOwnerPath, + buildUnblockContactPath, + buildUpdateProfilePath, + buildWorkspaceProjectPath, + buildWorkspaceProjectThreadsPath, + buildWorkspaceProjectsPath, +} from './hubClientPayloadPaths'; + +describe('hubClientPayloadPaths (#822 / #833 / #901 / #913)', () => { + it('builds search paths with percent-encoded ids (#822)', () => { + expect(buildSearchUserPath('user/1')).toBe('/client/contacts/search?id=user%2F1'); + expect(buildSearchUserPath('')).toBe('/client/contacts/search?id='); + expect(buildSearchUserPath('用户@a&b')).toBe( + '/client/contacts/search?id=%E7%94%A8%E6%88%B7%40a%26b', + ); + expect(buildSearchSessionsPath('hello world')).toBe('/client/sessions/search?q=hello%20world'); + expect(buildListMessageReactionsPath('msg/1', 'sess/2')).toBe( + '/client/messages/msg%2F1/reactions?session_id=sess%2F2', + ); + }); + + it('builds task event listing paths with numeric query params', () => { + expect(buildListTaskRunEventsAfterPath('task/1', 7)).toBe( + '/web/agent-tasks/task%2F1/events?after_seq=7&limit=500', + ); + expect(buildListTaskRunEventsAfterPath('task/1', 0)).toBe( + '/web/agent-tasks/task%2F1/events?after_seq=0&limit=500', + ); + expect(buildListTaskRunEventsAfterPath('task/1', -3)).toBe( + '/web/agent-tasks/task%2F1/events?after_seq=-3&limit=500', + ); + }); + + it('builds colon-then-slash fallback path pairs', () => { + expect(buildCancelAgentTaskPaths('task/1')).toEqual([ + '/web/agent-tasks/task%2F1:cancel', + '/web/agent-tasks/task%2F1/cancel', + ]); + expect(buildCancelAgentTaskPaths('t:1')).toEqual([ + '/web/agent-tasks/t%3A1:cancel', + '/web/agent-tasks/t%3A1/cancel', + ]); + expect(buildMarkNotificationReadPaths('n/1')).toEqual([ + '/client/notifications/n%2F1:read', + '/client/notifications/n%2F1/read', + ]); + expect(buildReadAllNotificationsPaths()).toEqual([ + '/client/notifications:read-all', + '/client/notifications/read-all', + ]); + }); + + it('builds team approval decision paths', () => { + expect(buildDecideTeamApprovalPath('t/1', 'r/2', 'a/3')).toBe( + '/web/agent-teams/t%2F1/runs/r%2F2/approvals/a%2F3/decide', + ); + expect(buildResolveTeamConflictPath('t/1', 'r/2', 'c/3')).toBe( + '/web/agent-teams/t%2F1/runs/r%2F2/conflicts/c%2F3/resolve', + ); + expect(buildPostTeamRouteDecisionPath('t/1', 'r/2')).toBe( + '/web/agent-teams/t%2F1/runs/r%2F2/route-decisions', + ); + }); + + it('builds edge device registration fallback paths', () => { + expect(buildRegisterDevicePaths()).toEqual([ + '/edge/devices:register', + '/edge/devices/register', + ]); + }); + + it('builds contact request, block and remark paths (#833 / #901)', () => { + expect(buildAcceptFriendRequestPath('req/1')).toBe( + '/client/contacts/friend-requests/req%2F1/accept', + ); + expect(buildRejectFriendRequestPath('req/2')).toBe( + '/client/contacts/friend-requests/req%2F2/reject', + ); + expect(buildBlockContactPath('user/a')).toBe('/client/contacts/user%2Fa/block'); + expect(buildBlockContactPath('')).toBe('/client/contacts//block'); + expect(buildUnblockContactPath('user/b')).toBe('/client/contacts/user%2Fb/unblock'); + expect(buildContactRemarkPath('user/c')).toBe('/client/contacts/user%2Fc/remark'); + expect(buildRemoveContactPath('friend/x')).toBe('/client/contacts/friend%2Fx'); + }); + + it('builds session message paths with optional query strings (#833)', () => { + expect(buildRemoveSessionMemberPath('sess/1', 'user/2')).toBe( + '/client/sessions/sess%2F1/members/user%2F2', + ); + expect(buildGetMessagesPath('sess/1', { before_seq: 9, limit: 50 })).toBe( + '/client/sessions/sess%2F1/messages?before_seq=9&limit=50', + ); + expect(buildGetMessagesPath('sess/1')).toBe('/client/sessions/sess%2F1/messages'); + expect(buildGetMessagesPath('sess/1', {})).toBe('/client/sessions/sess%2F1/messages'); + expect(buildGetMessagesPath('sess/1', { before_seq: undefined })).toBe( + '/client/sessions/sess%2F1/messages', + ); + expect(buildGetMessagesPath('sess/1', { limit: 0 })).toBe( + '/client/sessions/sess%2F1/messages?limit=0', + ); + expect(buildSyncMessagesPath('sess/2', { after_seq: 3 })).toBe( + '/client/sessions/sess%2F2/messages/sync?after_seq=3', + ); + expect(buildSyncMessagesPath('sess/2', { after_seq: 0, limit: 10 })).toBe( + '/client/sessions/sess%2F2/messages/sync?after_seq=0&limit=10', + ); + }); + + it('builds session message search paths with filters (#833)', () => { + expect(buildSearchSessionMessagesPath('sess/3', { q: 'a b', content_type: 'text' })).toBe( + '/client/sessions/sess%2F3/messages/search?q=a+b&content_type=text', + ); + expect( + buildSearchSessionMessagesPath('sess/3', { + q: 'a b', + content_type: 'text', + from: 'u/1', + to: 'u/2', + }), + ).toBe( + '/client/sessions/sess%2F3/messages/search?q=a+b&content_type=text&from=u%2F1&to=u%2F2', + ); + expect(buildSearchSessionMessagesPath('sess/4', { q: '' })).toBe( + '/client/sessions/sess%2F4/messages/search?q=', + ); + }); + + it('builds workspace project thread message paths (#833)', () => { + expect(buildListWorkspaceProjectThreadMessagesPath('p/1', 'th/2', { limit: 20 })).toBe( + '/web/projects/p%2F1/threads/th%2F2/messages?limit=20', + ); + expect(buildListWorkspaceProjectThreadMessagesPath('p/1', 'th/2')).toBe( + '/web/projects/p%2F1/threads/th%2F2/messages', + ); + expect(buildListWorkspaceProjectThreadMessagesPath('p/1', 'th/2', { limit: undefined })).toBe( + '/web/projects/p%2F1/threads/th%2F2/messages', + ); + expect(buildSendWorkspaceProjectThreadMessagePath('p/1', 'th/2')).toBe( + '/web/projects/p%2F1/threads/th%2F2/messages', + ); + }); + + it('builds team run paths (#833)', () => { + expect(buildGetTeamRunPath('t/1', 'r/2')).toBe('/web/agent-teams/t%2F1/runs/r%2F2'); + expect(buildGetTeamRunStatePath('t/1', 'r/2')).toBe( + '/web/agent-teams/t%2F1/runs/r%2F2/state', + ); + expect(buildListTeamEventsPath('t/1', 'r/2')).toBe( + '/web/agent-teams/t%2F1/runs/r%2F2/events', + ); + expect(buildListTeamTasksPath('t/1', 'r/2')).toBe( + '/web/agent-teams/t%2F1/runs/r%2F2/tasks', + ); + expect(buildRemoveAgentTeamMemberPath('t/1', 'm/9')).toBe( + '/web/agent-teams/t%2F1/members/m%2F9', + ); + expect(buildDecideTaskApprovalPath('task/1', 'appr/2')).toBe( + '/web/agent-tasks/task%2F1/approvals/appr%2F2/decide', + ); + }); + + it('builds session lifecycle paths (#901)', () => { + expect(buildSessionMembersPath('sess/1')).toBe('/client/sessions/sess%2F1/members'); + expect(buildLeaveSessionPath('sess/1')).toBe('/client/sessions/sess%2F1/leave'); + expect(buildTransferSessionOwnerPath('sess/1')).toBe( + '/client/sessions/sess%2F1/transfer-owner', + ); + expect(buildDissolveSessionPath('sess/1')).toBe('/client/sessions/sess%2F1/dissolve'); + expect(buildSessionInfoPath('sess/1')).toBe('/client/sessions/sess%2F1/info'); + expect(buildSessionSettingsPath('sess/1')).toBe('/client/sessions/sess%2F1/settings'); + expect(buildSessionPath('sess/1')).toBe('/client/sessions/sess%2F1'); + expect(buildMarkReadPath('sess/1')).toBe('/client/sessions/sess%2F1/read'); + expect(buildSessionPinsPath('sess/1')).toBe('/client/sessions/sess%2F1/pins'); + expect(buildSessionAgentsPath('sess/1')).toBe('/client/sessions/sess%2F1/agents'); + }); + + it('builds message action paths (#901)', () => { + expect(buildRecallMessagePath('msg/1')).toBe('/client/messages/msg%2F1/recall'); + expect(buildPinMessagePath('msg/1')).toBe('/client/messages/msg%2F1/pin'); + expect(buildForwardMessagePath('msg/1')).toBe('/client/messages/msg%2F1/forward'); + expect(buildEditMessagePath('msg/1')).toBe('/client/messages/msg%2F1'); + expect(buildMessageReactionsPath('msg/1')).toBe('/client/messages/msg%2F1/reactions'); + }); + + it('builds message search and notification list query strings (#901)', () => { + expect(buildSearchMessagesPath({ q: 'needle' })).toBe('/client/messages/search?q=needle'); + expect( + buildSearchMessagesPath({ + q: 'a b', + session_id: 's/1', + content_type: 'image', + from: '2024-01-01', + to: '2024-02-01', + }), + ).toBe( + '/client/messages/search?q=a+b&session_id=s%2F1&content_type=image&from=2024-01-01&to=2024-02-01', + ); + expect(buildSearchMessagesPath({ q: '' })).toBe('/client/messages/search?q='); + expect(buildListNotificationsPath()).toBe('/client/notifications'); + expect(buildListNotificationsPath({})).toBe('/client/notifications'); + expect(buildListNotificationsPath({ unread_only: true })).toBe( + '/client/notifications?unread_only=true', + ); + expect(buildListNotificationsPath({ unread_only: false, limit: 0, offset: 5 })).toBe( + '/client/notifications?unread_only=false&limit=0&offset=5', + ); + expect(buildListNotificationsPath({ limit: undefined, offset: undefined })).toBe( + '/client/notifications', + ); + }); + + it('builds edge task lifecycle paths (#901)', () => { + expect(buildAckTaskPath('task/1')).toBe('/edge/agent-tasks/task%2F1/ack'); + expect(buildStreamTaskPath('task/1')).toBe('/edge/agent-tasks/task%2F1/stream'); + expect(buildDoneTaskPath('task/1')).toBe('/edge/agent-tasks/task%2F1/done'); + expect(buildFailTaskPath('task/1')).toBe('/edge/agent-tasks/task%2F1/fail'); + expect(buildRegenerateAgentTaskPath('task/1')).toBe('/web/agent-tasks/task%2F1/regenerate'); + }); + + it('builds execution-target list and detail paths (#901)', () => { + expect(buildListExecutionTargetsPath()).toBe('/web/execution-targets'); + expect( + buildListExecutionTargetsPath({ pageSize: 25, pageCursor: 'cur/1', target_type: 'edge' }), + ).toBe('/web/execution-targets?pageSize=25&pageCursor=cur%2F1&target_type=edge'); + expect(buildListExecutionTargetsPath({ target_type: '' })).toBe( + '/web/execution-targets?target_type=', + ); + expect(buildExecutionTargetPath('et/1')).toBe('/web/execution-targets/et%2F1'); + expect(buildPingExecutionTargetPath('et/1')).toBe('/web/execution-targets/et%2F1/ping'); + }); + + it('builds audit event and relay command paths (#901)', () => { + expect(buildListAuditEventsPath()).toBe('/web/audit-events'); + expect(buildListAuditEventsPath({ pageSize: 50, pageCursor: 'p2' })).toBe( + '/web/audit-events?pageSize=50&pageCursor=p2', + ); + expect(buildRelayCommandPath('cmd/1')).toBe('/web/relay/commands/cmd%2F1'); + expect(buildAckRelayCommandPath('cmd/1')).toBe('/web/relay/commands/cmd%2F1/ack'); + }); + + it('builds custom agent and public catalog paths with forced is_public (#901)', () => { + expect(buildCustomAgentPath('ca/1')).toBe('/web/custom-agents/ca%2F1'); + expect(buildListPublicSkillsPath()).toBe('/web/skills?is_public=true'); + expect(buildListPublicSkillsPath({ skill_type: 'cli', q: 'git' })).toBe( + '/web/skills?is_public=true&skill_type=cli&q=git', + ); + expect( + buildListPublicSkillsPath({ skill_type: undefined, pageCursor: 'c1', pageSize: 10 }), + ).toBe('/web/skills?is_public=true&pageCursor=c1&pageSize=10'); + // A caller-supplied is_public is spread after the forced default and wins. + expect(buildListPublicSkillsPath({ is_public: 'false', q: 'x' })).toBe( + '/web/skills?is_public=false&q=x', + ); + expect(buildListPublicMCPServersPath()).toBe('/web/mcp-servers?is_public=true'); + expect(buildListPublicMCPServersPath({ transport: 'streamable', pageSize: 20 })).toBe( + '/web/mcp-servers?is_public=true&transport=streamable&pageSize=20', + ); + }); + + it('builds workspace project paths (#901)', () => { + expect(buildListWorkspaceProjectsPath()).toBe('/web/projects'); + expect(buildListWorkspaceProjectsPath({ pageSize: 10, pageCursor: 'pc', q: 'name' })).toBe( + '/web/projects?pageSize=10&pageCursor=pc&q=name', + ); + expect(buildWorkspaceProjectPath('proj/1')).toBe('/web/projects/proj%2F1'); + expect(buildWorkspaceProjectThreadsPath('proj/1')).toBe('/web/projects/proj%2F1/threads'); + }); + + it('builds agent team and task event paths (#901)', () => { + expect(buildTaskRunEventSummaryPath('task/1')).toBe( + '/web/agent-tasks/task%2F1/events/summary', + ); + expect(buildListTaskRunEventsPath('task/1')).toBe('/web/agent-tasks/task%2F1/events'); + expect(buildAgentTeamPath('team/1')).toBe('/web/agent-teams/team%2F1'); + expect(buildAgentTeamMembersPath('team/1')).toBe('/web/agent-teams/team%2F1/members'); + expect(buildAgentTeamRunsPath('team/1')).toBe('/web/agent-teams/team%2F1/runs'); + }); + + it('builds agent profile list and detail paths (#901)', () => { + expect(buildListAgentProfilesPath()).toBe('/web/agent-profiles'); + expect( + buildListAgentProfilesPath({ runtime_id: 'rt/1', q: 'ag', pageCursor: 'c', pageSize: 5 }), + ).toBe('/web/agent-profiles?runtime_id=rt%2F1&q=ag&pageCursor=c&pageSize=5'); + expect(buildAgentProfilePath('prof/1')).toBe('/web/agent-profiles/prof%2F1'); + }); + + it('builds document list and detail paths (#901)', () => { + expect(buildListDocumentsPath()).toBe('/web/documents'); + expect( + buildListDocumentsPath({ + status: 'ready', + source: 'upload', + tag: 't', + pageCursor: 'c', + pageSize: 10, + }), + ).toBe('/web/documents?status=ready&source=upload&tag=t&pageCursor=c&pageSize=10'); + expect(buildDocumentPath('doc/1')).toBe('/web/documents/doc%2F1'); + }); + + it('builds task approval and artifact list paths (#901)', () => { + expect(buildListTaskApprovalsPath('task/1')).toBe('/web/agent-tasks/task%2F1/approvals'); + expect(buildListTaskArtifactsPath('task/1')).toBe('/web/agent-tasks/task%2F1/artifacts'); + }); + + it('builds auth static paths (#913)', () => { + expect(buildRefreshPath()).toBe('/client/auth/refresh'); + expect(buildLogoutPath()).toBe('/client/auth/logout'); + expect(buildMePath()).toBe('/client/auth/me'); + expect(buildUpdateProfilePath()).toBe('/client/auth/profile'); + expect(buildOidcAuthorizePath()).toBe('/client/auth/oidc/authorize'); + expect(buildOidcCallbackPath()).toBe('/client/auth/oidc/callback'); + }); + + it('builds static client collection paths (#913)', () => { + expect(buildListContactsPath()).toBe('/client/contacts'); + expect(buildFriendRequestsPath()).toBe('/client/contacts/friend-requests'); + expect(buildListSessionsPath()).toBe('/client/sessions'); + expect(buildCreatePrivateSessionPath()).toBe('/client/sessions/private'); + expect(buildCreateGroupSessionPath()).toBe('/client/sessions/group'); + expect(buildSettingsPath()).toBe('/client/settings'); + expect(buildAttachmentsPath()).toBe('/client/attachments'); + expect(buildProbeAttachmentPath()).toBe('/client/attachments/probe'); + }); + + it('builds static web collection paths (#913)', () => { + expect(buildAgentTasksPath()).toBe('/web/agent-tasks'); + expect(buildExecutionTargetsPath()).toBe('/web/execution-targets'); + expect(buildRelayCommandsPath()).toBe('/web/relay/commands'); + expect(buildCustomAgentsPath()).toBe('/web/custom-agents'); + expect(buildAgentTeamsPath()).toBe('/web/agent-teams'); + expect(buildAgentProfilesPath()).toBe('/web/agent-profiles'); + expect(buildDocumentsPath()).toBe('/web/documents'); + expect(buildWorkspaceProjectsPath()).toBe('/web/projects'); + }); + + it('percent-encodes unicode and reserved characters in path ids', () => { + expect(buildSessionPath('会话/1?x=1&y=2')).toBe( + '/client/sessions/%E4%BC%9A%E8%AF%9D%2F1%3Fx%3D1%26y%3D2', + ); + expect(buildSessionPath('a b')).toBe('/client/sessions/a%20b'); + expect(buildSessionPath('')).toBe('/client/sessions/'); + expect(buildEditMessagePath('m#1!*')).toBe('/client/messages/m%231!*'); + expect(buildWorkspaceProjectPath('~keep._-')).toBe('/web/projects/~keep._-'); + expect(buildAgentTeamPath('team?a=b&c=d')).toBe('/web/agent-teams/team%3Fa%3Db%26c%3Dd'); + }); + + it('percent-encodes unicode query values and skips null params', () => { + expect(buildSearchMessagesPath({ q: '你好 世界' })).toBe( + '/client/messages/search?q=%E4%BD%A0%E5%A5%BD+%E4%B8%96%E7%95%8C', + ); + expect(buildGetMessagesPath('s1', { before_seq: null as unknown as number })).toBe( + '/client/sessions/s1/messages', + ); + }); +}); diff --git a/app/shared/src/hub/hubClientPayloadRequestsSocial.test.ts b/app/shared/src/hub/hubClientPayloadRequestsSocial.test.ts new file mode 100644 index 000000000..4c357eb1c --- /dev/null +++ b/app/shared/src/hub/hubClientPayloadRequestsSocial.test.ts @@ -0,0 +1,407 @@ +// real_tested=true +import { describe, expect, it } from 'vitest'; +import { + buildAcceptFriendRequest, + buildAddAgentToSessionRequest, + buildAddMessageReactionRequest, + buildAddSessionMembersRequest, + buildBlockContactRequest, + buildCreateGroupSessionRequest, + buildCreatePrivateSessionRequest, + buildDeleteSessionRequest, + buildDissolveSessionRequest, + buildEditMessageRequest, + buildForwardMessageRequest, + buildLeaveSessionRequest, + buildMarkReadRequest, + buildPinMessageRequest, + buildRecallMessageRequest, + buildRejectFriendRequest, + buildRemoveContactRequest, + buildRemoveMessageReactionRequest, + buildRemoveSessionMemberRequest, + buildSendFriendRequest, + buildSendMessageRequest, + buildTransferSessionOwnershipRequest, + buildUnblockContactRequest, + buildUnpinMessageRequest, + buildUpdateContactRemarkRequest, + buildUpdateSessionInfoRequest, + buildUpdateSessionSettingsRequest, +} from './hubClientPayloadRequestsSocial'; + +describe('hubClientPayloadRequestsSocial — friend request builders', () => { + it('builds send-friend-request POST with optional message omitted when undefined', () => { + expect(buildSendFriendRequest('u-2', 'hi')).toEqual({ + path: '/client/contacts/friend-requests', + init: { + method: 'POST', + body: JSON.stringify({ friend_id: 'u-2', message: 'hi' }), + }, + }); + + const withoutMessage = buildSendFriendRequest('u-3'); + expect(withoutMessage).toEqual({ + path: '/client/contacts/friend-requests', + init: { method: 'POST', body: JSON.stringify({ friend_id: 'u-3' }) }, + }); + const parsedBody = JSON.parse(withoutMessage.init.body) as Record; + expect(Object.prototype.hasOwnProperty.call(parsedBody, 'message')).toBe(false); + + // Explicit empty string is a provided value, not absence. + const emptyMessage = buildSendFriendRequest('u-4', ''); + expect(JSON.parse(emptyMessage.init.body) as Record).toEqual({ + friend_id: 'u-4', + message: '', + }); + + // Friend id is only serialized in the body — never path-encoded. + expect(buildSendFriendRequest('user/with spaces', '打招呼 👍')).toEqual({ + path: '/client/contacts/friend-requests', + init: { + method: 'POST', + body: JSON.stringify({ friend_id: 'user/with spaces', message: '打招呼 👍' }), + }, + }); + }); + + it('builds accept-friend-request POST with no body key and encoded id', () => { + expect(buildAcceptFriendRequest('req/1')).toEqual({ + path: '/client/contacts/friend-requests/req%2F1/accept', + init: { method: 'POST' }, + }); + expect(Object.prototype.hasOwnProperty.call(buildAcceptFriendRequest('req/1').init, 'body')).toBe( + false, + ); + expect(buildAcceptFriendRequest('req 好友')).toEqual({ + path: `/client/contacts/friend-requests/${encodeURIComponent('req 好友')}/accept`, + init: { method: 'POST' }, + }); + }); + + it('builds reject-friend-request POST with no body key and encoded id', () => { + expect(buildRejectFriendRequest('req/2')).toEqual({ + path: '/client/contacts/friend-requests/req%2F2/reject', + init: { method: 'POST' }, + }); + expect(Object.prototype.hasOwnProperty.call(buildRejectFriendRequest('req/2').init, 'body')).toBe( + false, + ); + }); + + it('builds update-contact-remark PUT with remark body and encoded id', () => { + expect(buildUpdateContactRemarkRequest('user/c', 'buddy')).toEqual({ + path: '/client/contacts/user%2Fc/remark', + init: { method: 'PUT', body: JSON.stringify({ remark: 'buddy' }) }, + }); + + // Empty remark is a valid value and must be serialized. + expect(buildUpdateContactRemarkRequest('user/c', '')).toEqual({ + path: '/client/contacts/user%2Fc/remark', + init: { method: 'PUT', body: JSON.stringify({ remark: '' }) }, + }); + + expect(buildUpdateContactRemarkRequest('好友 1', '备注')).toEqual({ + path: `/client/contacts/${encodeURIComponent('好友 1')}/remark`, + init: { method: 'PUT', body: JSON.stringify({ remark: '备注' }) }, + }); + }); +}); + +describe('hubClientPayloadRequestsSocial — contact lifecycle builders', () => { + it('builds remove-contact DELETE with no body key and encoded id', () => { + expect(buildRemoveContactRequest('friend/1')).toEqual({ + path: '/client/contacts/friend%2F1', + init: { method: 'DELETE' }, + }); + expect(Object.prototype.hasOwnProperty.call(buildRemoveContactRequest('friend/1').init, 'body')).toBe( + false, + ); + + // Empty id yields a trailing slash, not an encoded placeholder. + expect(buildRemoveContactRequest('')).toEqual({ + path: '/client/contacts/', + init: { method: 'DELETE' }, + }); + }); + + it('builds block-contact POST with no body key and encoded id', () => { + expect(buildBlockContactRequest('user/1')).toEqual({ + path: '/client/contacts/user%2F1/block', + init: { method: 'POST' }, + }); + expect(Object.prototype.hasOwnProperty.call(buildBlockContactRequest('user/1').init, 'body')).toBe( + false, + ); + expect(buildBlockContactRequest('user with space')).toEqual({ + path: '/client/contacts/user%20with%20space/block', + init: { method: 'POST' }, + }); + }); + + it('builds unblock-contact POST with no body key and encoded id', () => { + expect(buildUnblockContactRequest('user/2')).toEqual({ + path: '/client/contacts/user%2F2/unblock', + init: { method: 'POST' }, + }); + expect(Object.prototype.hasOwnProperty.call(buildUnblockContactRequest('user/2').init, 'body')).toBe( + false, + ); + }); +}); + +describe('hubClientPayloadRequestsSocial — session membership builders', () => { + it('builds add-session-members POST for multiple, single, and empty member lists', () => { + expect(buildAddSessionMembersRequest('sess/1', ['a', 'b'])).toEqual({ + path: '/client/sessions/sess%2F1/members', + init: { method: 'POST', body: JSON.stringify({ member_ids: ['a', 'b'] }) }, + }); + expect(buildAddSessionMembersRequest('sess/1', ['only'])).toEqual({ + path: '/client/sessions/sess%2F1/members', + init: { method: 'POST', body: JSON.stringify({ member_ids: ['only'] }) }, + }); + // Empty list is serialized as an empty array, not omitted. + expect(buildAddSessionMembersRequest('sess/1', [])).toEqual({ + path: '/client/sessions/sess%2F1/members', + init: { method: 'POST', body: JSON.stringify({ member_ids: [] }) }, + }); + }); + + it('builds transfer-session-ownership POST with encoded ids', () => { + expect(buildTransferSessionOwnershipRequest('sess/1', 'owner-9')).toEqual({ + path: '/client/sessions/sess%2F1/transfer-owner', + init: { method: 'POST', body: JSON.stringify({ new_owner_id: 'owner-9' }) }, + }); + expect(buildTransferSessionOwnershipRequest('会话', '新主人')).toEqual({ + path: `/client/sessions/${encodeURIComponent('会话')}/transfer-owner`, + init: { method: 'POST', body: JSON.stringify({ new_owner_id: '新主人' }) }, + }); + }); + + it('builds remove-session-member DELETE with no body key and both ids encoded', () => { + expect(buildRemoveSessionMemberRequest('sess/1', 'user/2')).toEqual({ + path: '/client/sessions/sess%2F1/members/user%2F2', + init: { method: 'DELETE' }, + }); + expect( + Object.prototype.hasOwnProperty.call( + buildRemoveSessionMemberRequest('sess/1', 'user/2').init, + 'body', + ), + ).toBe(false); + }); +}); + +describe('hubClientPayloadRequestsSocial — session lifecycle builders', () => { + it('builds create-private-session POST serializing arbitrary bodies', () => { + expect(buildCreatePrivateSessionRequest({ peer_user_id: 'p1' })).toEqual({ + path: '/client/sessions/private', + init: { method: 'POST', body: JSON.stringify({ peer_user_id: 'p1' }) }, + }); + + // Nested payloads round-trip through JSON.stringify untouched. + const nested = { peer_user_id: 'p2', meta: { invite: true, tags: ['a', 'b'] } }; + expect(buildCreatePrivateSessionRequest(nested)).toEqual({ + path: '/client/sessions/private', + init: { method: 'POST', body: JSON.stringify(nested) }, + }); + + // Empty object serializes as '{}'. + expect(buildCreatePrivateSessionRequest({})).toEqual({ + path: '/client/sessions/private', + init: { method: 'POST', body: '{}' }, + }); + + // null serializes as the string 'null'. + expect(buildCreatePrivateSessionRequest(null as unknown)).toEqual({ + path: '/client/sessions/private', + init: { method: 'POST', body: 'null' }, + }); + }); + + it('builds create-group-session POST and handles undefined body edge', () => { + expect(buildCreateGroupSessionRequest({ name: 'g' })).toEqual({ + path: '/client/sessions/group', + init: { method: 'POST', body: JSON.stringify({ name: 'g' }) }, + }); + + // JSON.stringify(undefined) yields undefined, so body is undefined at runtime. + const undefinedBody = buildCreateGroupSessionRequest(undefined as unknown); + expect(undefinedBody.init.method).toBe('POST'); + expect(undefinedBody.init.body).toBeUndefined(); + }); + + it('builds update-session-info PUT with encoded session id', () => { + expect(buildUpdateSessionInfoRequest('sess/1', { title: 't' })).toEqual({ + path: '/client/sessions/sess%2F1/info', + init: { method: 'PUT', body: JSON.stringify({ title: 't' }) }, + }); + expect(buildUpdateSessionInfoRequest('sess/1', {})).toEqual({ + path: '/client/sessions/sess%2F1/info', + init: { method: 'PUT', body: '{}' }, + }); + }); + + it('builds update-session-settings PUT with encoded session id', () => { + expect(buildUpdateSessionSettingsRequest('sess/1', { mute: true })).toEqual({ + path: '/client/sessions/sess%2F1/settings', + init: { method: 'PUT', body: JSON.stringify({ mute: true }) }, + }); + expect(buildUpdateSessionSettingsRequest('会话/1', { mute: false })).toEqual({ + path: `/client/sessions/${encodeURIComponent('会话/1')}/settings`, + init: { method: 'PUT', body: JSON.stringify({ mute: false }) }, + }); + }); + + it('builds leave/dissolve/delete-session requests with method-only init', () => { + expect(buildLeaveSessionRequest('sess/1')).toEqual({ + path: '/client/sessions/sess%2F1/leave', + init: { method: 'POST' }, + }); + expect(Object.prototype.hasOwnProperty.call(buildLeaveSessionRequest('sess/1').init, 'body')).toBe( + false, + ); + + expect(buildDissolveSessionRequest('sess/1')).toEqual({ + path: '/client/sessions/sess%2F1/dissolve', + init: { method: 'POST' }, + }); + expect( + Object.prototype.hasOwnProperty.call(buildDissolveSessionRequest('sess/1').init, 'body'), + ).toBe(false); + + expect(buildDeleteSessionRequest('sess/1')).toEqual({ + path: '/client/sessions/sess%2F1', + init: { method: 'DELETE' }, + }); + expect( + Object.prototype.hasOwnProperty.call(buildDeleteSessionRequest('sess/1').init, 'body'), + ).toBe(false); + }); +}); + +describe('hubClientPayloadRequestsSocial — message builders', () => { + it('builds mark-read POST with boundary sequence values and encoded session id', () => { + expect(buildMarkReadRequest('sess/1', 42)).toEqual({ + path: '/client/sessions/sess%2F1/read', + init: { method: 'POST', body: JSON.stringify({ last_read_seq: 42 }) }, + }); + expect(buildMarkReadRequest('sess/1', 0)).toEqual({ + path: '/client/sessions/sess%2F1/read', + init: { method: 'POST', body: JSON.stringify({ last_read_seq: 0 }) }, + }); + expect(buildMarkReadRequest('sess/1', -7)).toEqual({ + path: '/client/sessions/sess%2F1/read', + init: { method: 'POST', body: JSON.stringify({ last_read_seq: -7 }) }, + }); + expect(buildMarkReadRequest('sess/1', Number.MAX_SAFE_INTEGER)).toEqual({ + path: '/client/sessions/sess%2F1/read', + init: { + method: 'POST', + body: JSON.stringify({ last_read_seq: Number.MAX_SAFE_INTEGER }), + }, + }); + }); + + it('builds pin-message POST and unpin-message DELETE with shared path and session body', () => { + expect(buildPinMessageRequest('msg/1', 'sess/9')).toEqual({ + path: '/client/messages/msg%2F1/pin', + init: { method: 'POST', body: JSON.stringify({ session_id: 'sess/9' }) }, + }); + expect(buildUnpinMessageRequest('msg/1', 'sess/9')).toEqual({ + path: '/client/messages/msg%2F1/pin', + init: { method: 'DELETE', body: JSON.stringify({ session_id: 'sess/9' }) }, + }); + expect(buildPinMessageRequest('消息 1', '会话 9')).toEqual({ + path: `/client/messages/${encodeURIComponent('消息 1')}/pin`, + init: { method: 'POST', body: JSON.stringify({ session_id: '会话 9' }) }, + }); + }); + + it('builds forward-message POST for multiple, single, and empty target lists', () => { + expect(buildForwardMessageRequest('msg/1', ['s1', 's2'])).toEqual({ + path: '/client/messages/msg%2F1/forward', + init: { + method: 'POST', + body: JSON.stringify({ target_session_ids: ['s1', 's2'] }), + }, + }); + expect(buildForwardMessageRequest('msg/1', ['only'])).toEqual({ + path: '/client/messages/msg%2F1/forward', + init: { method: 'POST', body: JSON.stringify({ target_session_ids: ['only'] }) }, + }); + expect(buildForwardMessageRequest('msg/1', [])).toEqual({ + path: '/client/messages/msg%2F1/forward', + init: { method: 'POST', body: JSON.stringify({ target_session_ids: [] }) }, + }); + }); + + it('builds add-message-reaction POST and remove-message-reaction DELETE', () => { + expect(buildAddMessageReactionRequest('msg/1', 'sess-1', { emoji: '👍' })).toEqual({ + path: '/client/messages/msg%2F1/reactions', + init: { + method: 'POST', + body: JSON.stringify({ session_id: 'sess-1', emoji: '👍' }), + }, + }); + expect(buildRemoveMessageReactionRequest('msg/1', 'sess-1', { emoji: '❤️' })).toEqual({ + path: '/client/messages/msg%2F1/reactions', + init: { + method: 'DELETE', + body: JSON.stringify({ session_id: 'sess-1', emoji: '❤️' }), + }, + }); + + // Reaction emoji is serialized verbatim (no path encoding involved). + expect(buildAddMessageReactionRequest('msg/1', 'sess-1', { emoji: '🎉' })).toEqual({ + path: '/client/messages/msg%2F1/reactions', + init: { + method: 'POST', + body: JSON.stringify({ session_id: 'sess-1', emoji: '🎉' }), + }, + }); + }); + + it('builds edit-message PUT with encoded message id', () => { + expect(buildEditMessageRequest('msg/1', { content: 'edited' })).toEqual({ + path: '/client/messages/msg%2F1', + init: { method: 'PUT', body: JSON.stringify({ content: 'edited' }) }, + }); + expect(buildEditMessageRequest('消息/1', {})).toEqual({ + path: `/client/messages/${encodeURIComponent('消息/1')}`, + init: { method: 'PUT', body: '{}' }, + }); + }); + + it('builds recall-message POST with no body key and encoded message id', () => { + expect(buildRecallMessageRequest('msg/1')).toEqual({ + path: '/client/messages/msg%2F1/recall', + init: { method: 'POST' }, + }); + expect(Object.prototype.hasOwnProperty.call(buildRecallMessageRequest('msg/1').init, 'body')).toBe( + false, + ); + }); + + it('builds send-message POST with encoded session id and stringified body', () => { + expect(buildSendMessageRequest('sess/1', { content: 'hi' })).toEqual({ + path: '/client/sessions/sess%2F1/messages', + init: { method: 'POST', body: JSON.stringify({ content: 'hi' }) }, + }); + expect(buildSendMessageRequest('sess/1', null as unknown)).toEqual({ + path: '/client/sessions/sess%2F1/messages', + init: { method: 'POST', body: 'null' }, + }); + }); + + it('builds add-agent-to-session POST with encoded session id', () => { + expect(buildAddAgentToSessionRequest('sess/1', { agent_type: 'codex' })).toEqual({ + path: '/client/sessions/sess%2F1/agents', + init: { method: 'POST', body: JSON.stringify({ agent_type: 'codex' }) }, + }); + expect(buildAddAgentToSessionRequest('sess/1', {})).toEqual({ + path: '/client/sessions/sess%2F1/agents', + init: { method: 'POST', body: '{}' }, + }); + }); +});