Merge branch 'master' into feat/produced-files-folder
This commit is contained in:
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/README.md
|
||||
README.md: bbc32fb3944dcb3b7aa48ef1f8e24e5c93ff7a67
|
||||
README.zh.md: 5bfbd1ce6b41a44d3ef421ea59ecc29e1c329b3c
|
||||
README.md: 42258a961b522271cf61367856c162a7312c79f2
|
||||
README.zh.md: 5ecebbfd1e3c8aaa294e3bfd1bf943600ab3366e
|
||||
|
||||
@@ -23,6 +23,7 @@ The browser side of the dsh web GUI: shell boot, browser-host communication, sha
|
||||
| [`ui-workspace/`](ui-workspace/README.md) | Provides workspace selection and creation surfaces. |
|
||||
| [`ui-conversation/`](ui-conversation/README.md) | Presents the active conversation and its input surface. |
|
||||
| [`ui-tool/`](ui-tool/README.md) | Composes Tool call trees and keyed per-Tool views. |
|
||||
| [`ui-workflow-run/`](ui-workflow-run/README.md) | Replays durable workflow runs as nested Chat disclosures with live-only child navigation. |
|
||||
| [`ui-goal/`](ui-goal/README.md) | Presents and manages the current goal. |
|
||||
| [`ui-trajectory/`](ui-trajectory/README.md) | Presents alternate views of agent activity. |
|
||||
| [`ui-command/`](ui-command/README.md) | Provides session-aware command discovery and dispatch. |
|
||||
|
||||
@@ -23,6 +23,7 @@ dsh web GUI 的浏览器侧:shell 启动、浏览器与宿主通信、共享 U
|
||||
| [`ui-workspace/`](ui-workspace/README.md) | 提供 Workspace 选择与创建界面。 |
|
||||
| [`ui-conversation/`](ui-conversation/README.md) | 展示当前会话及其输入界面。 |
|
||||
| [`ui-tool/`](ui-tool/README.md) | 编排工具调用树和按工具键控的视图。 |
|
||||
| [`ui-workflow-run/`](ui-workflow-run/README.md) | 把持久工作流运行回放为 Chat 嵌套折叠项,并只为实时子 Session 提供导航。 |
|
||||
| [`ui-goal/`](ui-goal/README.md) | 展示和管理当前目标。 |
|
||||
| [`ui-trajectory/`](ui-trajectory/README.md) | 提供 agent(智能体)活动的其他视图。 |
|
||||
| [`ui-command/`](ui-command/README.md) | 提供会话感知的命令发现与分发。 |
|
||||
|
||||
@@ -2837,8 +2837,8 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
|
||||
return Promise.resolve({ accepted: true })
|
||||
},
|
||||
// Satisfies the ApiProxy contract type only: the browser export button
|
||||
// fetches GET /api/session.export directly (window.fetch), so this stub is
|
||||
// never reached through the fixture's dispatch.
|
||||
// hands GET /api/session.export to the native download manager, so this
|
||||
// stub is never reached through the fixture's dispatch.
|
||||
downloads: {
|
||||
sessionLog: () => Promise.resolve(new Response('fixture mode does not serve session export', { status: 404 })),
|
||||
},
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/ui-trajectory/README.md
|
||||
README.md: e82b2cc9d4a65c3095aeee7002fb6c43a43b695d
|
||||
README.zh.md: a1ba62393c2aae3f6baa7c481dd80f04dbbb477d
|
||||
README.md: f4b3bd223c2872f0341d49bdaa102440d73b4f29
|
||||
README.zh.md: 9bcb3b6ad98d672cc524c168f2024be9ba56b577
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Trajectory renders a turn-aware event ledger with selectable User, Assistant, Tool, and nested Subtool records. Thick rules mark Turn boundaries, compact inline markers identify Steps, and the main ledger keeps only index, event, and content; selection opens a local inspector for token usage, duration, Input, Output, and Timing. Scrollable Summary regions keep their scrollbar thumbs transparent until the region is hovered or contains keyboard focus, without changing the reserved scroll geometry. A standalone compaction request appears chronologically in its own `Between turns` section, while a numbered compaction remains inside its owning turn. Long ledgers open at the current tail, load one older page when the user reaches the loaded range's top, and mount only the visible row window plus a small overscan; request-only separators share the next measurable virtual item, while semantic row keys and ARIA indexes survive prepends. Selection, timeline navigation, folding, search, and Request totals cover the currently loaded window. The ledger covers records with an explicit loading row until the initial tail is positioned and while an older page is pending. A fixed Overview above the ledger projects real record start/duration timing from left to right; when earlier records remain unloaded and the viewport includes the loaded domain's start, a neutral ellipsis control identifies the omitted prefix and loads one earlier page without assigning unknown history fabricated duration. Assistant spans divide recorded TTFT from decoding, and a 500 ms hover reveals exact clock and duration details. Dragging an interval focuses the ledger on every record active at any point in that inclusive range, while clearing the selection restores the full loaded ledger. Wheel gestures zoom the time domain. A right-button click clears the selected interval, while a right-button drag pans an already zoomed viewport without changing it. The initial view and streaming updates stay at the tail; scrolling upward suspends following so new records do not interrupt inspection of earlier rows. Content-only stream frames preserve virtual row keys and heights, reuse measurements, and do not issue repeated tail-scroll writes. The toolbar's Export button downloads the session log — the root plus every subagent descendant — as a ZIP streamed by the host (`GET /api/session.export`): every file is the session's stored artifact text verbatim (`session.jsonl` at the root, `subagents/<id>/session.jsonl` for descendants; no manifest, byte-identical to the backend's durable artifact), and every image any included log references sits under `media/<attachmentId>.<ext>`. Fixture mode (no host) answers 404 for the export. Completed replies retain assembled blocks, timing, and usage in Trajectory target State, while the shared Session window keeps the raw Events. Trajectory asks the conversation shell to float the composer over the full-height ledger, while its responsive vertical scrollers reserve the composer's live height so final rows remain reachable. Trajectory-owned Definitions assemble business records, including cancellation-frozen Assistant and Tool records, from the shared Session window, so Trajectory neither reads nor changes the Chat conversation snapshot. The package provides no service and declares no Context merge; it registers target-specific Event Definitions, a Trajectory view builder, and one tab in the conversation's `'conversation.view'` slot ring. Contract: api-contracts v3 §8.
|
||||
Trajectory renders a turn-aware event ledger with selectable User, Assistant, Tool, and nested Subtool records. Thick rules mark Turn boundaries, compact inline markers identify Steps, and the main ledger keeps only index, event, and content; selection opens a local inspector for token usage, duration, Input, Output, and Timing. Scrollable Summary regions keep their scrollbar thumbs transparent until the region is hovered or contains keyboard focus, without changing the reserved scroll geometry. A standalone compaction request appears chronologically in its own `Between turns` section, while a numbered compaction remains inside its owning turn. Long ledgers open at the current tail, load one older page when the user reaches the loaded range's top, and mount only the visible row window plus a small overscan; request-only separators share the next measurable virtual item, while semantic row keys and ARIA indexes survive prepends. Selection, timeline navigation, folding, search, and Request totals cover the currently loaded window. The ledger covers records with an explicit loading row until the initial tail is positioned and while an older page is pending. A fixed Overview above the ledger projects real record start/duration timing from left to right; when earlier records remain unloaded and the viewport includes the loaded domain's start, a neutral ellipsis control identifies the omitted prefix and loads one earlier page without assigning unknown history fabricated duration. Assistant spans divide recorded TTFT from decoding, and a 500 ms hover reveals exact clock and duration details. Dragging an interval focuses the ledger on every record active at any point in that inclusive range, while clearing the selection restores the full loaded ledger. Wheel gestures zoom the time domain. A right-button click clears the selected interval, while a right-button drag pans an already zoomed viewport without changing it. The initial view and streaming updates stay at the tail; scrolling upward suspends following so new records do not interrupt inspection of earlier rows. Content-only stream frames preserve virtual row keys and heights, reuse measurements, and do not issue repeated tail-scroll writes. The toolbar's Export button hands the session log — the root plus every subagent descendant — directly to the browser download manager as a ZIP streamed by the host (`GET /api/session.export`), so JavaScript never buffers the response: every file is the session's stored artifact text verbatim (`session.jsonl` at the root, `subagents/<id>/session.jsonl` for descendants; no manifest, byte-identical to the backend's durable artifact), and every image any included log references sits under `media/<attachmentId>.<ext>`. Fixture mode (no host) answers 404 for the export. Completed replies retain assembled blocks, timing, and usage in Trajectory target State, while the shared Session window keeps the raw Events. Trajectory asks the conversation shell to float the composer over the full-height ledger, while its responsive vertical scrollers reserve the composer's live height so final rows remain reachable. Trajectory-owned Definitions assemble business records, including cancellation-frozen Assistant and Tool records, from the shared Session window, so Trajectory neither reads nor changes the Chat conversation snapshot. The package provides no service and declares no Context merge; it registers target-specific Event Definitions, a Trajectory view builder, and one tab in the conversation's `'conversation.view'` slot ring. Contract: api-contracts v3 §8.
|
||||
|
||||
## Model Experience
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
Trajectory 渲染按轮次组织的事件记录表,其中可选择用户、助手、工具和嵌套子工具记录。较粗的分割线标示轮次边界,紧凑的行内标记标识步骤,主记录表仅保留索引、事件和内容;选择记录则会打开局部检查器,查看 token 用量、耗时、输入、输出和计时。可滚动的概述区域默认保持滚动条滑块透明,直到鼠标悬停该区域或其中包含键盘焦点时才显示,同时不改变滚动条预留的几何空间。独立运行的压缩(compaction)请求会按时间顺序显示在自己的 `Between turns` 区段中,而带编号的压缩仍位于其所属轮次内。长记录表打开时定位于当前尾部,用户到达已加载范围顶部时加载一页更早的历史,并且只挂载可见行窗口和少量额外缓冲行;仅含请求的分隔行并入下一个具备可测高度的虚拟项,语义行键和 ARIA 索引在向前补页后保持不变。选择、时间线导航、折叠、搜索和请求汇总只覆盖当前已加载的窗口。初始尾部完成定位前以及更早页面仍在等待时,记录表会用明确的加载行遮住真实记录。固定在记录表上方的 Overview 区域从左到右投影记录的真实开始时间与耗时;仍有更早记录未加载且 viewport 包含已加载时间域起点时,中性的省略号控件会标识被省略的前缀,并可加载一页更早历史,而不会为未知部分虚构耗时。助手时间条会区分记录到的 TTFT 与解码时间,悬停 500 ms 后可查看精确时刻和耗时详情。拖选一个区间会将记录表聚焦到活动区间与该闭区间有重叠的所有记录,清除选择则恢复完整的已加载记录表。滚轮手势用于缩放时间域。右键单击会清除所选区间;在已放大的 viewport 上按住右键拖动则只会平移视图,不会改变该区间。初始视图和流式更新都会停留在尾部;向上滚动会暂停跟随,因此新记录不会打断对旧记录的检查。仅含内容更新的流式帧会保持虚拟行的键和高度不变、复用测量结果,并且不会重复写入末尾滚动位置。工具栏的 “Export” 按钮会将会话日志——根会话及其全部子代理——下载为宿主流式返回的 ZIP(`GET /api/session.export`):每个文件都是会话存储工件的逐字原文(根为 `session.jsonl`,子代理为 `subagents/<id>/session.jsonl`;无清单,与后端持久化工件逐字节一致),每个被包含日志引用的图片则放在 `media/<attachmentId>.<ext>` 下。fixture 模式(无宿主)对导出应答 404。已完成的回复会在 Trajectory target State 中保留组装后的 blocks、计时与用量,共享 Session 窗口则保留原始 Event。Trajectory 要求会话壳将 composer 作为浮层置于全高记录表上方;其响应式纵向滚动容器会预留 composer 的实时高度,确保仍可滚动到最后几行。Trajectory 自有的 Definition 从共享 Session 窗口组装业务记录,其中包括因取消而冻结的助手和工具记录,因此 Trajectory 既不读取也不改变 Chat 会话快照。该包不提供 service,也不声明 Context 合并;它会注册 target 专属 Event Definition、Trajectory view builder,以及会话 `'conversation.view'` slot 环中的一个视图标签页。约定:api-contracts v3 §8。
|
||||
Trajectory 渲染按轮次组织的事件记录表,其中可选择用户、助手、工具和嵌套子工具记录。较粗的分割线标示轮次边界,紧凑的行内标记标识步骤,主记录表仅保留索引、事件和内容;选择记录则会打开局部检查器,查看 token 用量、耗时、输入、输出和计时。可滚动的概述区域默认保持滚动条滑块透明,直到鼠标悬停该区域或其中包含键盘焦点时才显示,同时不改变滚动条预留的几何空间。独立运行的压缩(compaction)请求会按时间顺序显示在自己的 `Between turns` 区段中,而带编号的压缩仍位于其所属轮次内。长记录表打开时定位于当前尾部,用户到达已加载范围顶部时加载一页更早的历史,并且只挂载可见行窗口和少量额外缓冲行;仅含请求的分隔行并入下一个具备可测高度的虚拟项,语义行键和 ARIA 索引在向前补页后保持不变。选择、时间线导航、折叠、搜索和请求汇总只覆盖当前已加载的窗口。初始尾部完成定位前以及更早页面仍在等待时,记录表会用明确的加载行遮住真实记录。固定在记录表上方的 Overview 区域从左到右投影记录的真实开始时间与耗时;仍有更早记录未加载且 viewport 包含已加载时间域起点时,中性的省略号控件会标识被省略的前缀,并可加载一页更早历史,而不会为未知部分虚构耗时。助手时间条会区分记录到的 TTFT 与解码时间,悬停 500 ms 后可查看精确时刻和耗时详情。拖选一个区间会将记录表聚焦到活动区间与该闭区间有重叠的所有记录,清除选择则恢复完整的已加载记录表。滚轮手势用于缩放时间域。右键单击会清除所选区间;在已放大的 viewport 上按住右键拖动则只会平移视图,不会改变该区间。初始视图和流式更新都会停留在尾部;向上滚动会暂停跟随,因此新记录不会打断对旧记录的检查。仅含内容更新的流式帧会保持虚拟行的键和高度不变、复用测量结果,并且不会重复写入末尾滚动位置。工具栏的 “Export” 按钮会将会话日志——根会话及其全部子代理——作为宿主流式返回的 ZIP(`GET /api/session.export`)直接交给浏览器下载管理器,因此 JavaScript 不会缓冲响应:每个文件都是会话存储工件的逐字原文(根为 `session.jsonl`,子代理为 `subagents/<id>/session.jsonl`;无清单,与后端持久化工件逐字节一致),每个被包含日志引用的图片则放在 `media/<attachmentId>.<ext>` 下。fixture 模式(无宿主)对导出应答 404。已完成的回复会在 Trajectory target State 中保留组装后的 blocks、计时与用量,共享 Session 窗口则保留原始 Event。Trajectory 要求会话壳将 composer 作为浮层置于全高记录表上方;其响应式纵向滚动容器会预留 composer 的实时高度,确保仍可滚动到最后几行。Trajectory 自有的 Definition 从共享 Session 窗口组装业务记录,其中包括因取消而冻结的助手和工具记录,因此 Trajectory 既不读取也不改变 Chat 会话快照。该包不提供 service,也不声明 Context 合并;它会注册 target 专属 Event Definition、Trajectory view builder,以及会话 `'conversation.view'` slot 环中的一个视图标签页。约定:api-contracts v3 §8。
|
||||
|
||||
## 模型体验
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
/**
|
||||
* Session log export: browser download of the host-streamed ZIP. The archive
|
||||
* itself is produced and streamed by the host (GET /api/session.export); this
|
||||
* module only derives the download filename and triggers the browser save.
|
||||
* Session log export delivery. The host streams the archive from
|
||||
* `GET /api/session.export`; this module owns the browser-native download
|
||||
* handoff so the browser can stream the response directly to its download
|
||||
* manager instead of buffering the ZIP in JavaScript.
|
||||
* @module
|
||||
*/
|
||||
|
||||
@@ -27,16 +28,18 @@ export function sessionLogZipFilename(sessionId: string): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger a browser download of a blob response.
|
||||
* @param blob - the response body to save (passed straight through, no copy).
|
||||
* @param filename - the download filename.
|
||||
* Hand one host-streamed session archive to the browser download manager.
|
||||
* The operation resolves after dispatching the native download; HTTP delivery
|
||||
* continues outside JavaScript and is reported by the browser itself.
|
||||
* @param sessionId - the root session id to export with all descendants.
|
||||
* @returns a promise that rejects if the browser handoff itself fails.
|
||||
*/
|
||||
export function downloadBlob(blob: Blob, filename: string): void {
|
||||
const url = URL.createObjectURL(blob)
|
||||
const anchor = document.createElement('a')
|
||||
anchor.href = url
|
||||
anchor.download = filename
|
||||
anchor.click()
|
||||
// Revoke one tick later: some browsers read the blob URL after click().
|
||||
setTimeout(() => { URL.revokeObjectURL(url) }, 0)
|
||||
export function downloadSessionLog(sessionId: string): Promise<void> {
|
||||
return Promise.resolve().then(() => {
|
||||
const query = new URLSearchParams({ sessionId, includeDescendants: 'true' })
|
||||
const anchor = document.createElement('a')
|
||||
anchor.href = `/api/session.export?${query.toString()}`
|
||||
anchor.download = sessionLogZipFilename(sessionId)
|
||||
anchor.click()
|
||||
})
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ import type {} from '@deepseek-ai/dsh-client-locale/client'
|
||||
// owning package) must be in the program for the register calls to type.
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import { createTrajectoryDurationStore } from './duration-store.ts'
|
||||
import { downloadBlob, sessionLogZipFilename } from './export-log.ts'
|
||||
import { downloadSessionLog } from './export-log.ts'
|
||||
import { en, NS, zh } from './locales.ts'
|
||||
import { registerTrajectoryAssistantDefinition } from './trajectory-assistant-definition.ts'
|
||||
import { registerTrajectoryCompactionDefinitions } from './trajectory-compaction-definition.ts'
|
||||
@@ -60,23 +60,7 @@ export function apply(ctx: Context): void {
|
||||
return session.getSnapshot().views.get('trajectory') !== before
|
||||
},
|
||||
setActualDuration: (value) => { duration.set(value) },
|
||||
exportLog: async () => {
|
||||
// The host streams the ZIP (root + descendant artifacts verbatim)
|
||||
// from GET /api/session.export; the browser downloads the response.
|
||||
// A null origin (no-location Node contexts) falls back like the
|
||||
// carrier's resolveBase so the URL stays valid.
|
||||
const loc = (globalThis as { location?: { origin?: string } }).location
|
||||
const origin = loc?.origin !== undefined && loc.origin !== 'null' ? loc.origin : 'http://dsh.internal'
|
||||
const url = new URL('/api/session.export', origin)
|
||||
url.searchParams.set('sessionId', sessionId)
|
||||
url.searchParams.set('includeDescendants', 'true')
|
||||
const response = await fetch(url)
|
||||
if (!response.ok) {
|
||||
const detail = await response.text().catch(() => '')
|
||||
throw new Error(`Export failed: HTTP ${response.status}${detail === '' ? '' : ` ${detail}`}`)
|
||||
}
|
||||
downloadBlob(await response.blob(), sessionLogZipFilename(sessionId))
|
||||
},
|
||||
exportLog: () => downloadSessionLog(sessionId),
|
||||
}
|
||||
},
|
||||
}, TrajectoryView))
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
// @vitest-environment node
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Session-log export filename derivation. The archive itself is produced and
|
||||
* streamed by the host (GET /api/session.export); this package only derives
|
||||
* the download filename and triggers the browser save.
|
||||
* Session-log export browser delivery: safe filename derivation and a native
|
||||
* download handoff that leaves the streamed response outside JavaScript.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { sessionLogZipFilename } from '../src/client/export-log.ts'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { downloadSessionLog, sessionLogZipFilename } from '../src/client/export-log.ts'
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('sessionLogZipFilename', () => {
|
||||
it('keeps safe session ids verbatim', () => {
|
||||
@@ -22,3 +25,27 @@ describe('sessionLogZipFilename', () => {
|
||||
expect(sessionLogZipFilename('..')).toBe('dsh-session-__.zip')
|
||||
})
|
||||
})
|
||||
|
||||
describe('downloadSessionLog', () => {
|
||||
it('hands the descendant-inclusive endpoint directly to the browser', async () => {
|
||||
const click = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {})
|
||||
|
||||
await downloadSessionLog('session/with spaces')
|
||||
|
||||
expect(click).toHaveBeenCalledOnce()
|
||||
const anchor = click.mock.contexts[0] as HTMLAnchorElement
|
||||
const url = new URL(anchor.href)
|
||||
expect(url.pathname).toBe('/api/session.export')
|
||||
expect(url.searchParams.get('sessionId')).toBe('session/with spaces')
|
||||
expect(url.searchParams.get('includeDescendants')).toBe('true')
|
||||
expect(anchor.download).toBe('dsh-session-session_with_spaces.zip')
|
||||
})
|
||||
|
||||
it('rejects when the browser download handoff fails', async () => {
|
||||
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {
|
||||
throw new Error('download denied')
|
||||
})
|
||||
|
||||
await expect(downloadSessionLog('session-root')).rejects.toThrow('download denied')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1141,39 +1141,26 @@ describe('timeline projection', () => {
|
||||
describe('session log export', () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
Reflect.deleteProperty(URL, 'createObjectURL')
|
||||
Reflect.deleteProperty(HTMLAnchorElement.prototype, 'click')
|
||||
})
|
||||
|
||||
it('downloads the host-streamed ZIP with descendants on click', async () => {
|
||||
// exportLog always fetches a URL instance, so the mock's shape stays narrow.
|
||||
const fetchMock = vi.fn(async (input: URL) => {
|
||||
expect(input.pathname).toBe('/api/session.export')
|
||||
expect(input.searchParams.get('sessionId')).toBe(SID)
|
||||
expect(input.searchParams.get('includeDescendants')).toBe('true')
|
||||
return new Response('zip-bytes')
|
||||
})
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
const createObjectURL = vi.fn(() => 'blob:export')
|
||||
URL.createObjectURL = createObjectURL
|
||||
const clickAnchor = vi.fn()
|
||||
HTMLAnchorElement.prototype.click = clickAnchor
|
||||
const b = await bench(historySnapshot(NODES))
|
||||
mount(b.slots)
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'Trajectory' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Export session log' }))
|
||||
await vi.waitFor(() => {
|
||||
expect(fetchMock).toHaveBeenCalledOnce()
|
||||
})
|
||||
// The blob download lands a few microtasks after the fetch settles.
|
||||
await vi.waitFor(() => {
|
||||
expect(createObjectURL).toHaveBeenCalled()
|
||||
})
|
||||
expect(clickAnchor).toHaveBeenCalled()
|
||||
await vi.waitFor(() => { expect(clickAnchor).toHaveBeenCalledOnce() })
|
||||
const anchor = clickAnchor.mock.contexts[0] as HTMLAnchorElement
|
||||
const url = new URL(anchor.href)
|
||||
expect(url.pathname).toBe('/api/session.export')
|
||||
expect(url.searchParams.get('sessionId')).toBe(SID)
|
||||
expect(url.searchParams.get('includeDescendants')).toBe('true')
|
||||
})
|
||||
|
||||
it('surfaces the download failure in the visible alert bar', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn(async () => new Response('boom', { status: 404 })))
|
||||
it('surfaces a browser handoff failure in the visible alert bar', async () => {
|
||||
HTMLAnchorElement.prototype.click = vi.fn(() => { throw new Error('download denied') })
|
||||
const b = await bench(historySnapshot(NODES))
|
||||
mount(b.slots)
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'Trajectory' }))
|
||||
@@ -1181,7 +1168,7 @@ describe('session log export', () => {
|
||||
await vi.waitFor(() => {
|
||||
const alert = screen.queryByRole('alert')
|
||||
expect(alert).not.toBeNull()
|
||||
expect(alert!.textContent).toContain('HTTP 404')
|
||||
expect(alert!.textContent).toContain('download denied')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
6
packages/client/ui-workflow-run/README.i18n.yaml
Normal file
6
packages/client/ui-workflow-run/README.i18n.yaml
Normal file
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/ui-workflow-run/README.md
|
||||
README.md: 66539e0c16ac4102f9e1fe881106e6881b36a7d5
|
||||
README.zh.md: a803857af24802e8a4645c4d5aca56c04424c85e
|
||||
35
packages/client/ui-workflow-run/README.md
Normal file
35
packages/client/ui-workflow-run/README.md
Normal file
@@ -0,0 +1,35 @@
|
||||
# @deepseek-ai/dsh-client-ui-workflow-run
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
The browser plugin that reconstructs durable top-level workflow runs as independent Chat nodes. It consumes the four `tool-workflow/*` Session events owned by [`dsh-tool-workflow`](../../workflow/tool-workflow/README.md), registers one `ConversationNodeDefinition`, and renders through the keyed `conversation.chat.node` slot without changing the existing workflow tool card.
|
||||
|
||||
## Durable state and replay
|
||||
|
||||
`tool-workflow/run-start` creates one Context keyed by `runId`; member starts, member endings, and the run ending update that Context in log order. A history tail containing only updates remains pending until an older page supplies the unique start, after which prepend, complete replay, and live append produce the same state. A closed Turn or Step with missing terminal events presents the affected run or members as interrupted without changing the tool result.
|
||||
|
||||
Phase groups come only from members that actually started. Exact phase strings share a group, an omitted phase is distinct from the empty string, and settlement changes status without removing or reordering members.
|
||||
|
||||
## Presentation and navigation
|
||||
|
||||
The run and each phase have independent disclosure state. The run uses a 32-pixel `--dsw-alias-bg-module-platform` row with persistent right/down chevrons and an inline state dot plus status text, without a badge. Phases use 32-pixel disclosure rows with title and member count in the flexible main area and a fixed precise aggregate-status tail, without another dot. Members use a 16-pixel dot slot, a truncating name area, and a fixed 64-pixel status column. A running run initially expands; a terminal run loaded from history initially collapses. Local choices survive data updates while the keyed node remains mounted and reset only on a full remount.
|
||||
|
||||
A member opens a child Session only while every current fact agrees: the member is running, the child id is in the ordinary Session list, the row has `origin: 'subagent'`, its `parentId` is the current Session, and the list row is still running. Underlined member text is the only visible navigation affordance; keyboard focus draws a two-pixel business-primary ring around the name area, while status copy remains `Running`. The component calls only the injected ordinary `sessions.open(id)` action; remote, addressed-only, wrong-parent, or terminal rows remain non-interactive.
|
||||
|
||||
## Composition
|
||||
|
||||
The package registers its Definition, locale dictionary, and `workflow-run` renderer as Cordis effects. Removing the client entry retracts all three contributions. The shipped Web bundle includes the plugin after `ui-conversation` and `ui-tool`.
|
||||
|
||||
## Model Experience
|
||||
|
||||
None, as this package renders durable Session facts for humans and adds no prompt, tool schema, request content, or model-visible result.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
None.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- Only top-level calls through `dsh-tool-workflow` produce these records; nested Code Mode calls and direct `WorkflowService` consumers do not.
|
||||
- Navigation is intentionally live-only. Terminal members remain visible for review but never expose a cold-session opener from this node.
|
||||
- The node shows run, phase, member identity, and status only; scripts, outputs, errors, logs, usage, static topology, and controls remain outside this surface.
|
||||
35
packages/client/ui-workflow-run/README.zh.md
Normal file
35
packages/client/ui-workflow-run/README.zh.md
Normal file
@@ -0,0 +1,35 @@
|
||||
# @deepseek-ai/dsh-client-ui-workflow-run
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
这个浏览器插件把持久化的顶层工作流运行重建为独立 Chat 节点。它消费由 [`dsh-tool-workflow`](../../workflow/tool-workflow/README.md) 拥有的四类 `tool-workflow/*` Session 事件,注册一个 `ConversationNodeDefinition`,并通过 keyed `conversation.chat.node` slot 渲染,不改变现有工作流工具卡。
|
||||
|
||||
## 持久状态与回放
|
||||
|
||||
`tool-workflow/run-start` 以 `runId` 创建唯一 Context;成员开始、成员结束和运行结束事件按日志顺序更新该 Context。只有 update 的历史尾页会保持 pending,直到更早页面补入唯一 start;此后 prepend、完整回放和实时 append 得到相同状态。若所属 Turn 或 Step 已关闭但终点事件缺失,界面把相应运行或成员显示为已中断,而不改写工具结果。
|
||||
|
||||
阶段组只来自真正开始过的成员。完全相同的阶段字符串归入同一组,字段缺省与空字符串保持不同身份;成员结算只改变状态,不删除或重排成员。
|
||||
|
||||
## 展示与导航
|
||||
|
||||
运行和每个阶段分别拥有本地 disclosure 状态。运行使用 32 像素 `--dsw-alias-bg-module-platform` 背景行,常驻向右/向下 chevron,并以内联状态点加状态文字表达结局,不使用胶囊。阶段使用 32 像素 disclosure 行,在可伸缩主区显示标题与成员数,在固定尾部精确显示聚合状态且不重复状态点。成员使用 16 像素状态点槽、可省略名称区和固定 64 像素状态列。运行中记录首次挂载时展开,从历史加载的终态记录首次挂载时折叠。只要 keyed 节点仍挂载,本地选择就在数据更新时保持;只有完整 remount 才重新初始化。
|
||||
|
||||
只有所有实时事实同时成立时,成员才可打开子 Session:成员仍在运行、子 id 位于普通 Session 列表、列表行为 `origin: 'subagent'`、`parentId` 等于当前 Session,且列表行仍标记运行。带下划线的成员文字是唯一可见导航提示;键盘聚焦时,名称区显示 2 像素 business-primary 焦点环,右侧状态仍只显示“运行中”。组件只调用注入的普通 `sessions.open(id)`;远程、仅地址化、父级不符或终态的行都不可交互。
|
||||
|
||||
## 装配
|
||||
|
||||
本包把 Definition、locale 字典和 `workflow-run` renderer 都注册为 Cordis effect;移除客户端 entry 会撤销三者。shipped Web bundle 在 `ui-conversation` 与 `ui-tool` 之后装配该插件。
|
||||
|
||||
## Model Experience
|
||||
|
||||
无,因为本包只为人类展示持久 Session 事实,不增加 prompt、工具 schema、请求内容或模型可见结果。
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
无。
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- 只有经 `dsh-tool-workflow` 发起的顶层调用会生成这些记录;嵌套 Code Mode 调用和直接 `WorkflowService` 消费方不会生成。
|
||||
- 导航刻意只面向实时运行。终态成员继续保留供复盘,但本节点永不为其提供冷 Session 入口。
|
||||
- 节点只显示运行、阶段、成员身份与状态;脚本、输出、错误、日志、用量、静态拓扑和控制操作都不属于本界面。
|
||||
82
packages/client/ui-workflow-run/package.json
Normal file
82
packages/client/ui-workflow-run/package.json
Normal file
@@ -0,0 +1,82 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-client-ui-workflow-run",
|
||||
"description": "Durable workflow-run Conversation Node and nested member disclosure for dsh web",
|
||||
"version": "0.0.1-rc.1",
|
||||
"publishConfig": {
|
||||
"access": "restricted"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
|
||||
"directory": "packages/client/ui-workflow-run"
|
||||
},
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./invariant": {
|
||||
"types": "./lib/types/invariant.d.ts",
|
||||
"default": "./lib/invariant.js"
|
||||
},
|
||||
"./client": {
|
||||
"types": "./lib/types/client/index.d.ts",
|
||||
"default": "./lib/client.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"dsh": {
|
||||
"client": {
|
||||
"inject": [
|
||||
"@deepseek-ai/dsh-client-locale",
|
||||
"@deepseek-ai/dsh-client-runtime",
|
||||
"@deepseek-ai/dsh-client-ui-conversation"
|
||||
],
|
||||
"platform": "web"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"bundle": "tsdown",
|
||||
"watch": "tsdown --watch"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/client.js",
|
||||
"lib/types/**/*.d.ts"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"react": "^18.2.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-client-locale": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-runtime": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-workflow": "workspace:^",
|
||||
"@deepseek-ai/dsh-workflow": "workspace:^",
|
||||
"@deepseek-ai/cordis": "workspace:^"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-client-locale": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-runtime": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-test-runtime": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-workflow": "workspace:^",
|
||||
"@deepseek-ai/dsh-workflow": "workspace:^",
|
||||
"@types/react": "~18.3.1",
|
||||
"@deepseek-ai/cordis": "workspace:^"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
.root {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.runHeader {
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
height: 32px;
|
||||
padding: 0 8px;
|
||||
border-radius: 8px;
|
||||
background: var(--dsw-alias-bg-module-platform);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.runHeader:focus-visible {
|
||||
outline: 2px solid var(--dsw-alias-state-business-primary);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.runLeading {
|
||||
display: inline-flex;
|
||||
flex: none;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-right: 0;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
.runTitle {
|
||||
overflow: hidden;
|
||||
flex: none;
|
||||
max-width: 42%;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
font-size: 14px;
|
||||
font-weight: 510;
|
||||
line-height: 24px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.runSummary {
|
||||
overflow: hidden;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.statusTail {
|
||||
display: inline-flex;
|
||||
flex: none;
|
||||
height: 20px;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
overflow: hidden;
|
||||
font-size: 11px;
|
||||
font-weight: 510;
|
||||
line-height: 16px;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.phaseHeader {
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
height: 32px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.phaseHeader:focus-visible {
|
||||
outline: 2px solid var(--dsw-alias-state-business-primary);
|
||||
outline-offset: -2px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.phaseLeading {
|
||||
display: inline-flex;
|
||||
flex: none;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-right: 0;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
.phaseTitle {
|
||||
overflow: hidden;
|
||||
flex: 0 1 auto;
|
||||
min-width: 0;
|
||||
max-width: 42%;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
font-size: 14px;
|
||||
line-height: 24px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.phaseCount {
|
||||
overflow: hidden;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.phaseStatus {
|
||||
overflow: hidden;
|
||||
flex: none;
|
||||
width: 132px;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
text-align: right;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.separator {
|
||||
flex: none;
|
||||
width: 2px;
|
||||
height: 2px;
|
||||
border-radius: 50%;
|
||||
background: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
.phaseList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
padding: 4px 0 0 16px;
|
||||
}
|
||||
|
||||
.phase {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.members {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
padding: 0 0 0 16px;
|
||||
}
|
||||
|
||||
.memberRow,
|
||||
.memberButton {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
min-height: 24px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.memberButton {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.memberButton .memberLabel {
|
||||
color: var(--dsw-alias-state-business-primary);
|
||||
text-decoration: underline;
|
||||
text-underline-position: from-font;
|
||||
}
|
||||
|
||||
.dotSlot {
|
||||
display: inline-flex;
|
||||
flex: none;
|
||||
width: 16px;
|
||||
height: 24px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.memberButton:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.memberButton:focus-visible .memberLabelWrap {
|
||||
outline: 2px solid var(--dsw-alias-state-business-primary);
|
||||
outline-offset: -1px;
|
||||
}
|
||||
|
||||
.memberLabelWrap {
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
height: 24px;
|
||||
align-items: center;
|
||||
padding: 0 2px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.memberLabel {
|
||||
overflow: hidden;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
font-size: 14px;
|
||||
line-height: 24px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.memberStatus {
|
||||
flex: none;
|
||||
overflow: hidden;
|
||||
width: 64px;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
text-align: right;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.empty {
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.phaseList,
|
||||
.members {
|
||||
padding-left: 12px;
|
||||
}
|
||||
}
|
||||
245
packages/client/ui-workflow-run/src/client/WorkflowRunPanel.tsx
Normal file
245
packages/client/ui-workflow-run/src/client/WorkflowRunPanel.tsx
Normal file
@@ -0,0 +1,245 @@
|
||||
import { useState } from 'react'
|
||||
import {
|
||||
DisclosureRow, IconChevronRightOutline14, StateDot, type StateDotState,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { shallowEqual, type SessionId, type SessionListState } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { WorkflowRunKey } from './locales.ts'
|
||||
import type {
|
||||
WorkflowRunMemberData, WorkflowRunPhaseData, WorkflowRunStatus,
|
||||
} from './workflow-definition.ts'
|
||||
import css from './WorkflowRunPanel.module.css'
|
||||
|
||||
/** Navigation action injected from the plugin's own SessionsService access. */
|
||||
export interface WorkflowRunInjected {
|
||||
readonly openSession: (id: SessionId) => void
|
||||
}
|
||||
|
||||
/** Complete keyed Chat renderer props. */
|
||||
export type WorkflowRunPanelProps =
|
||||
PropsRuntime<'conversation.chat.node', 'workflow-run'>
|
||||
& PropsLocale<'workflowRun'>
|
||||
& WorkflowRunInjected
|
||||
|
||||
const STATUS_KEYS = {
|
||||
running: 'status.running',
|
||||
completed: 'status.completed',
|
||||
failed: 'status.failed',
|
||||
cancelled: 'status.cancelled',
|
||||
interrupted: 'status.interrupted',
|
||||
} as const satisfies Record<WorkflowRunStatus, WorkflowRunKey>
|
||||
|
||||
function dotState(status: WorkflowRunStatus): StateDotState {
|
||||
switch (status) {
|
||||
case 'running': return 'ongoing'
|
||||
case 'completed': return 'done'
|
||||
case 'failed': return 'error'
|
||||
case 'cancelled':
|
||||
case 'interrupted': return 'warning'
|
||||
/* v8 ignore next -- WorkflowRunStatus is closed and every variant is handled above. */
|
||||
default: return status satisfies never
|
||||
}
|
||||
}
|
||||
|
||||
function readablePhase(phase: string | null, t: WorkflowRunPanelProps['t']): string {
|
||||
if (phase === null) return t('phase.unassigned')
|
||||
return phase === '' ? t('phase.empty') : phase
|
||||
}
|
||||
|
||||
function readableMember(label: string, t: WorkflowRunPanelProps['t']): string {
|
||||
return label === '' ? t('member.empty') : label
|
||||
}
|
||||
|
||||
function statusCount(
|
||||
status: WorkflowRunStatus,
|
||||
count: number,
|
||||
t: WorkflowRunPanelProps['t'],
|
||||
): string {
|
||||
return t(`statusCount.${status}`, { count })
|
||||
}
|
||||
|
||||
function memberCount(count: number, t: WorkflowRunPanelProps['t']): string {
|
||||
return t(count === 1 ? 'run.members.one' : 'run.members.other', { count })
|
||||
}
|
||||
|
||||
function phaseStatusSummary(members: readonly WorkflowRunMemberData[], t: WorkflowRunPanelProps['t']): string {
|
||||
const counts = new Map<WorkflowRunStatus, number>()
|
||||
for (const member of members) counts.set(member.status, (counts.get(member.status) ?? 0) + 1)
|
||||
const count = (status: WorkflowRunStatus): number => counts.get(status) ?? 0
|
||||
const active = (['running', 'failed', 'cancelled', 'interrupted'] as const)
|
||||
.filter(status => count(status) > 0)
|
||||
if (active.length === 0) return statusCount('completed', count('completed'), t)
|
||||
const visible = active.includes('interrupted') && count('completed') > 0
|
||||
? ['completed' as const, ...active]
|
||||
: active
|
||||
return visible.map(status => statusCount(status, count(status), t)).join(' · ')
|
||||
}
|
||||
|
||||
function navigableMembers(
|
||||
sessions: SessionListState,
|
||||
phases: readonly WorkflowRunPhaseData[],
|
||||
parentId: SessionId,
|
||||
): readonly SessionId[] {
|
||||
const ordinary = new Set(sessions.ids)
|
||||
const result: SessionId[] = []
|
||||
for (const phase of phases) {
|
||||
for (const member of phase.members) {
|
||||
const summary = sessions.byId[member.childId]
|
||||
if (member.status === 'running'
|
||||
&& ordinary.has(member.childId)
|
||||
&& summary?.origin === 'subagent'
|
||||
&& summary.parentId === parentId
|
||||
&& summary.running) {
|
||||
result.push(member.childId)
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
function RunHeader({ count, name, onToggle, open, status, t }: {
|
||||
readonly count: number
|
||||
readonly name: string
|
||||
readonly onToggle: () => void
|
||||
readonly open: boolean
|
||||
readonly status: WorkflowRunStatus
|
||||
readonly t: WorkflowRunPanelProps['t']
|
||||
}) {
|
||||
return (
|
||||
<DisclosureRow
|
||||
icon={<IconChevronRightOutline14 />}
|
||||
title={t('run.title', { name })}
|
||||
open={open}
|
||||
expandable
|
||||
onToggle={onToggle}
|
||||
expandOnRowClick
|
||||
previewChevron={false}
|
||||
keepContentWhenOpen
|
||||
rowClassName={css.runHeader}
|
||||
leadingClassName={css.runLeading}
|
||||
titleClassName={css.runTitle}
|
||||
collapsedContent={(
|
||||
<>
|
||||
<span className={css.separator} aria-hidden />
|
||||
<span className={css.runSummary}>{memberCount(count, t)}</span>
|
||||
<span className={css.statusTail} data-status={status}>
|
||||
<StateDot state={dotState(status)} />
|
||||
<span>{t(STATUS_KEYS[status])}</span>
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function MemberRow({ member, navigable, openSession, t }: {
|
||||
readonly member: WorkflowRunMemberData
|
||||
readonly navigable: boolean
|
||||
readonly openSession: WorkflowRunInjected['openSession']
|
||||
readonly t: WorkflowRunPanelProps['t']
|
||||
}) {
|
||||
const name = readableMember(member.label, t)
|
||||
const content = (
|
||||
<>
|
||||
<span className={css.dotSlot}><StateDot state={dotState(member.status)} /></span>
|
||||
<span className={css.memberLabelWrap} data-member-label-wrap><span className={css.memberLabel} data-member-label>{name}</span></span>
|
||||
<span className={css.memberStatus} data-member-status-text>{t(STATUS_KEYS[member.status])}</span>
|
||||
</>
|
||||
)
|
||||
if (!navigable) {
|
||||
return <div className={css.memberRow} data-member-status={member.status}>{content}</div>
|
||||
}
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={css.memberButton}
|
||||
data-member-status={member.status}
|
||||
aria-label={t('member.open', { name })}
|
||||
onClick={() => { openSession(member.childId) }}
|
||||
>
|
||||
{content}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function PhaseSection({ phase, navigable, openSession, t }: {
|
||||
readonly phase: WorkflowRunPhaseData
|
||||
readonly navigable: readonly SessionId[]
|
||||
readonly openSession: WorkflowRunInjected['openSession']
|
||||
readonly t: WorkflowRunPanelProps['t']
|
||||
}) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const toggle = (): void => { setOpen(value => !value) }
|
||||
return (
|
||||
<DisclosureRow
|
||||
icon={<IconChevronRightOutline14 />}
|
||||
title={readablePhase(phase.phase, t)}
|
||||
open={open}
|
||||
expandable
|
||||
onToggle={toggle}
|
||||
expandOnRowClick
|
||||
previewChevron={false}
|
||||
keepContentWhenOpen
|
||||
className={css.phase}
|
||||
rowClassName={css.phaseHeader}
|
||||
leadingClassName={css.phaseLeading}
|
||||
titleClassName={css.phaseTitle}
|
||||
collapsedContent={(
|
||||
<>
|
||||
<span className={css.separator} aria-hidden />
|
||||
<span className={css.phaseCount} data-phase-count>{memberCount(phase.members.length, t)}</span>
|
||||
<span className={css.phaseStatus} data-phase-status-text>{phaseStatusSummary(phase.members, t)}</span>
|
||||
</>
|
||||
)}
|
||||
>
|
||||
<div className={css.members}>
|
||||
{phase.members.map(member => (
|
||||
<MemberRow
|
||||
key={member.seq}
|
||||
member={member}
|
||||
navigable={navigable.includes(member.childId)}
|
||||
openSession={openSession}
|
||||
t={t}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</DisclosureRow>
|
||||
)
|
||||
}
|
||||
|
||||
/** Render one durable workflow run with independent run and phase disclosure. */
|
||||
export function WorkflowRunPanel({ node, sessionId, useSessions, openSession, t }: WorkflowRunPanelProps) {
|
||||
const [open, setOpen] = useState(() => node.data.status === 'running')
|
||||
const memberCount = node.data.phases.reduce((count, phase) => count + phase.members.length, 0)
|
||||
const navigable = useSessions(
|
||||
sessions => navigableMembers(sessions, node.data.phases, sessionId),
|
||||
shallowEqual,
|
||||
)
|
||||
return (
|
||||
<section className={css.root} data-workflow-run data-run-status={node.data.status}>
|
||||
<RunHeader
|
||||
count={memberCount}
|
||||
name={node.data.name}
|
||||
open={open}
|
||||
status={node.data.status}
|
||||
t={t}
|
||||
onToggle={() => { setOpen(value => !value) }}
|
||||
/>
|
||||
{open && (
|
||||
<div className={css.phaseList}>
|
||||
{node.data.phases.length === 0
|
||||
? <span className={css.empty}>{t('run.empty')}</span>
|
||||
: node.data.phases.map(phase => (
|
||||
<PhaseSection
|
||||
key={phase.key}
|
||||
phase={phase}
|
||||
navigable={navigable}
|
||||
openSession={openSession}
|
||||
t={t}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
32
packages/client/ui-workflow-run/src/client/index.ts
Normal file
32
packages/client/ui-workflow-run/src/client/index.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
/** Browser plugin for durable workflow-run Conversation Nodes. */
|
||||
|
||||
import type { ClientContext, SessionId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type {} from '@deepseek-ai/dsh-client-locale/client'
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import { WorkflowRunPanel, type WorkflowRunInjected } from './WorkflowRunPanel.tsx'
|
||||
import { en, NS, type WorkflowRunKey, zh } from './locales.ts'
|
||||
import { workflowRunDefinition } from './workflow-definition.ts'
|
||||
|
||||
declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
interface LocaleNamespaceMap {
|
||||
/** Durable workflow-run node copy. */
|
||||
workflowRun: WorkflowRunKey
|
||||
}
|
||||
}
|
||||
|
||||
/** Required services for Definition, keyed renderer, navigation, and copy. */
|
||||
export const inject = ['conversationEvents', 'slots', 'sessions', 'locale']
|
||||
|
||||
/** Register the workflow Definition, dictionary, and keyed Chat renderer. */
|
||||
export function apply(ctx: ClientContext): void {
|
||||
ctx.conversationEvents.register(workflowRunDefinition)
|
||||
ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-workflow-run: dictionaries')
|
||||
ctx.slots.inject('conversation.chat.node', () => ctx.slots.register({
|
||||
name: 'conversation.chat.node',
|
||||
key: 'workflow-run',
|
||||
locale: NS,
|
||||
inject: (): WorkflowRunInjected => ({
|
||||
openSession: (id: SessionId) => { ctx.sessions.open(id) },
|
||||
}),
|
||||
}, WorkflowRunPanel))
|
||||
}
|
||||
51
packages/client/ui-workflow-run/src/client/locales.ts
Normal file
51
packages/client/ui-workflow-run/src/client/locales.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
/** `workflowRun` namespace dictionaries. */
|
||||
|
||||
/** Dictionary namespace owned by this plugin. */
|
||||
export const NS = 'workflowRun'
|
||||
|
||||
/** Simplified Chinese dictionary (the key-set source of truth). */
|
||||
export const zh = {
|
||||
'run.title': '{name}',
|
||||
'run.members.one': '{count} 个成员',
|
||||
'run.members.other': '{count} 个成员',
|
||||
'run.empty': '没有启动成员',
|
||||
'phase.unassigned': '未分阶段',
|
||||
'phase.empty': '空阶段名',
|
||||
'statusCount.running': '运行中 {count}',
|
||||
'statusCount.completed': '已完成 {count}',
|
||||
'statusCount.failed': '失败 {count}',
|
||||
'statusCount.cancelled': '已取消 {count}',
|
||||
'statusCount.interrupted': '已中断 {count}',
|
||||
'member.empty': '空成员名',
|
||||
'member.open': '打开 {name}',
|
||||
'status.running': '运行中',
|
||||
'status.completed': '已完成',
|
||||
'status.failed': '失败',
|
||||
'status.cancelled': '已取消',
|
||||
'status.interrupted': '已中断',
|
||||
}
|
||||
|
||||
/** English dictionary (same key set). */
|
||||
export const en: Record<WorkflowRunKey, string> = {
|
||||
'run.title': '{name}',
|
||||
'run.members.one': '{count} member',
|
||||
'run.members.other': '{count} members',
|
||||
'run.empty': 'No members started',
|
||||
'phase.unassigned': 'Unphased',
|
||||
'phase.empty': 'Empty phase name',
|
||||
'statusCount.running': 'Running {count}',
|
||||
'statusCount.completed': 'Completed {count}',
|
||||
'statusCount.failed': 'Failed {count}',
|
||||
'statusCount.cancelled': 'Cancelled {count}',
|
||||
'statusCount.interrupted': 'Interrupted {count}',
|
||||
'member.empty': 'Empty member name',
|
||||
'member.open': 'Open {name}',
|
||||
'status.running': 'Running',
|
||||
'status.completed': 'Completed',
|
||||
'status.failed': 'Failed',
|
||||
'status.cancelled': 'Cancelled',
|
||||
'status.interrupted': 'Interrupted',
|
||||
}
|
||||
|
||||
/** Union of this namespace's dictionary keys. */
|
||||
export type WorkflowRunKey = keyof typeof zh
|
||||
@@ -0,0 +1,193 @@
|
||||
import type {
|
||||
ChatConversationViewNode, ConversationLocation, ConversationNodeContext,
|
||||
ConversationNodeDefinition,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
import type {
|
||||
ToolWorkflowAgentEndData, ToolWorkflowAgentStartData,
|
||||
} from '@deepseek-ai/dsh-tool-workflow/types'
|
||||
import type { WorkflowAgentOutcome, WorkflowStopReason } from '@deepseek-ai/dsh-workflow/types'
|
||||
|
||||
/** Status shown for a workflow, phase, or member. */
|
||||
export type WorkflowRunStatus = 'running' | 'completed' | 'failed' | 'cancelled' | 'interrupted'
|
||||
|
||||
/** Final renderer data for one member. */
|
||||
export interface WorkflowRunMemberData {
|
||||
readonly seq: number
|
||||
readonly label: string
|
||||
readonly childId: SessionId
|
||||
readonly status: WorkflowRunStatus
|
||||
}
|
||||
|
||||
/** Final renderer data for one exact phase identity. */
|
||||
export interface WorkflowRunPhaseData {
|
||||
readonly key: string
|
||||
/** `null` is the absent field; the empty string remains a distinct identity. */
|
||||
readonly phase: string | null
|
||||
readonly members: readonly WorkflowRunMemberData[]
|
||||
}
|
||||
|
||||
/** Final keyed Chat payload for one workflow run. */
|
||||
export interface WorkflowRunChatData {
|
||||
readonly name: string
|
||||
readonly status: WorkflowRunStatus
|
||||
readonly phases: readonly WorkflowRunPhaseData[]
|
||||
}
|
||||
|
||||
declare module '@deepseek-ai/dsh-client-ui-conversation/client' {
|
||||
interface ChatNodeDataMap {
|
||||
/** Durable top-level workflow run and all members that actually started. */
|
||||
'workflow-run': WorkflowRunChatData
|
||||
}
|
||||
}
|
||||
|
||||
interface WorkflowMemberState extends Omit<ToolWorkflowAgentStartData, 'runId'> {
|
||||
readonly outcome?: WorkflowAgentOutcome
|
||||
}
|
||||
|
||||
interface WorkflowState {
|
||||
readonly name: string
|
||||
readonly stopReason?: WorkflowStopReason
|
||||
readonly members: readonly WorkflowMemberState[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a collision-free phase key preserving absent versus empty identity.
|
||||
* @param phase - exact phase string, or null for an omitted field.
|
||||
* @returns the stable renderer key for that phase identity.
|
||||
*/
|
||||
export function workflowPhaseKey(phase: string | null): string {
|
||||
return phase === null ? 'missing' : `value:${phase.length}:${phase}`
|
||||
}
|
||||
|
||||
function statusFromStopReason(stopReason: WorkflowStopReason): WorkflowRunStatus {
|
||||
switch (stopReason) {
|
||||
case 'completed': return 'completed'
|
||||
case 'cancelled': return 'cancelled'
|
||||
case 'error': return 'failed'
|
||||
/* v8 ignore next -- WorkflowStopReason is closed and every variant is handled above. */
|
||||
default: return stopReason satisfies never
|
||||
}
|
||||
}
|
||||
|
||||
function statusFromOutcome(outcome: WorkflowAgentOutcome): WorkflowRunStatus {
|
||||
switch (outcome) {
|
||||
case 'completed': return 'completed'
|
||||
case 'cancelled': return 'cancelled'
|
||||
case 'failed': return 'failed'
|
||||
/* v8 ignore next -- WorkflowAgentOutcome is closed and every variant is handled above. */
|
||||
default: return outcome satisfies never
|
||||
}
|
||||
}
|
||||
|
||||
function locationClosed(location: ConversationLocation): boolean {
|
||||
if (location.kind === 'step') {
|
||||
return location.step.status === 'closed' || location.turn.status === 'closed'
|
||||
}
|
||||
return location.kind === 'turn' && location.turn.status === 'closed'
|
||||
}
|
||||
|
||||
function projectWorkflow(
|
||||
context: ConversationNodeContext<WorkflowState>,
|
||||
location: ConversationLocation,
|
||||
): WorkflowRunChatData {
|
||||
const state = context.state as WorkflowState
|
||||
const interrupted = state.stopReason === undefined
|
||||
&& locationClosed(location)
|
||||
const phases = new Map<string, { phase: string | null; members: WorkflowRunMemberData[] }>()
|
||||
for (const member of state.members) {
|
||||
const phase = member.phase === undefined ? null : member.phase
|
||||
const key = workflowPhaseKey(phase)
|
||||
let group = phases.get(key)
|
||||
if (group === undefined) {
|
||||
group = { phase, members: [] }
|
||||
phases.set(key, group)
|
||||
}
|
||||
group.members.push({
|
||||
seq: member.seq,
|
||||
label: member.label,
|
||||
childId: member.childId,
|
||||
status: member.outcome === undefined
|
||||
? interrupted ? 'interrupted' : 'running'
|
||||
: statusFromOutcome(member.outcome),
|
||||
})
|
||||
}
|
||||
const projectedPhases = [...phases].map(([key, phase]) => ({
|
||||
key,
|
||||
phase: phase.phase,
|
||||
members: phase.members,
|
||||
}))
|
||||
return {
|
||||
name: state.name,
|
||||
status: state.stopReason === undefined
|
||||
? interrupted ? 'interrupted' : 'running'
|
||||
: statusFromStopReason(state.stopReason),
|
||||
phases: projectedPhases,
|
||||
}
|
||||
}
|
||||
|
||||
function updateAgentStart(state: WorkflowState, data: ToolWorkflowAgentStartData): WorkflowState {
|
||||
const member: WorkflowMemberState = {
|
||||
seq: data.seq,
|
||||
label: data.label,
|
||||
...data.phase === undefined ? {} : { phase: data.phase },
|
||||
childId: data.childId,
|
||||
}
|
||||
return { ...state, members: [...state.members, member] }
|
||||
}
|
||||
|
||||
function updateAgentEnd(state: WorkflowState, data: ToolWorkflowAgentEndData): WorkflowState {
|
||||
return {
|
||||
...state,
|
||||
members: state.members.map(member => member.seq === data.seq
|
||||
? { ...member, outcome: data.outcome }
|
||||
: member),
|
||||
}
|
||||
}
|
||||
|
||||
/** Durable workflow event family folded into one keyed Chat node. */
|
||||
export const workflowRunDefinition: ConversationNodeDefinition<WorkflowState> = {
|
||||
kind: 'workflow-run',
|
||||
target: 'chat',
|
||||
match: (event) => {
|
||||
if (event.type === 'tool-workflow/run-start') return { id: String(event.data.runId), role: 'start' }
|
||||
if (event.type === 'tool-workflow/agent-start'
|
||||
|| event.type === 'tool-workflow/agent-end'
|
||||
|| event.type === 'tool-workflow/run-end') {
|
||||
return { id: String(event.data.runId), role: 'update' }
|
||||
}
|
||||
return null
|
||||
},
|
||||
start: (_context, match) => {
|
||||
if (match.event.type !== 'tool-workflow/run-start') {
|
||||
throw new Error('workflow-run start requires tool-workflow/run-start')
|
||||
}
|
||||
return { name: match.event.data.name, members: [] }
|
||||
},
|
||||
update: (context, match) => {
|
||||
if (match.event.type === 'tool-workflow/agent-start') {
|
||||
return updateAgentStart(context.state, match.event.data)
|
||||
}
|
||||
if (match.event.type === 'tool-workflow/agent-end') {
|
||||
return updateAgentEnd(context.state, match.event.data)
|
||||
}
|
||||
if (match.event.type === 'tool-workflow/run-end') {
|
||||
return { ...context.state, stopReason: match.event.data.stopReason }
|
||||
}
|
||||
return context.state
|
||||
},
|
||||
buildViewNode: (context): ChatConversationViewNode | null => {
|
||||
if (context.start === undefined) return null
|
||||
const data = projectWorkflow(context, context.start.location)
|
||||
return {
|
||||
key: context.key,
|
||||
kind: 'workflow-run',
|
||||
id: context.id,
|
||||
target: 'chat',
|
||||
anchorSeq: context.start.event.seq,
|
||||
location: context.start.location,
|
||||
visibility: 'visible',
|
||||
data,
|
||||
}
|
||||
},
|
||||
}
|
||||
6
packages/client/ui-workflow-run/src/css-modules.d.ts
vendored
Normal file
6
packages/client/ui-workflow-run/src/css-modules.d.ts
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
declare module '*.module.css' {
|
||||
const classes: Record<string, string>
|
||||
export default classes
|
||||
}
|
||||
|
||||
declare module '*.css'
|
||||
4
packages/client/ui-workflow-run/src/index.ts
Normal file
4
packages/client/ui-workflow-run/src/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
/** Durable workflow-run UI plugin, node half. */
|
||||
|
||||
/** Host plugin body; the feature is entirely browser-side. */
|
||||
export function apply(): void {}
|
||||
24
packages/client/ui-workflow-run/src/invariant.ts
Normal file
24
packages/client/ui-workflow-run/src/invariant.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
/** Package-owned invariant companion for the workflow-run UI plugin. */
|
||||
|
||||
/* jscpd:ignore-start */
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-client-ui-workflow-run'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'client-ui-workflow-run-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/**
|
||||
* No runtime invariant: the browser plugin contributes one effect-owned
|
||||
* Conversation Definition, keyed renderer, and dictionary; tests prove their
|
||||
* disposal and the Host tool package owns the durable event invariant.
|
||||
*/
|
||||
const install: InvariantInstaller = () => {}
|
||||
|
||||
/** Register this package's invariant companion. */
|
||||
export const apply = (ctx: Context): Promise<() => void> =>
|
||||
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
|
||||
/* jscpd:ignore-end */
|
||||
521
packages/client/ui-workflow-run/tests/workflow-run.spec.tsx
Normal file
521
packages/client/ui-workflow-run/tests/workflow-run.spec.tsx
Normal file
@@ -0,0 +1,521 @@
|
||||
// @vitest-environment jsdom
|
||||
import { Context, Service } from '@deepseek-ai/cordis'
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
ConversationEventRegistry, ConversationNodeAssembler, SlotsService,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type {
|
||||
ChatConversationViewNode, ConversationEventInput, ConversationMatch, ConversationNodeDefinition,
|
||||
ConversationViewDefinition, SessionId, SessionListState,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { apply as applyLocale } from '@deepseek-ai/dsh-client-locale/client'
|
||||
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import {
|
||||
WorkflowRunPanel, type WorkflowRunInjected, type WorkflowRunPanelProps,
|
||||
} from '../src/client/WorkflowRunPanel.tsx'
|
||||
import { apply, inject } from '../src/client/index.ts'
|
||||
import { zh } from '../src/client/locales.ts'
|
||||
import {
|
||||
workflowRunDefinition, type WorkflowRunChatData,
|
||||
} from '../src/client/workflow-definition.ts'
|
||||
import { apply as applyNode } from '../src/index.ts'
|
||||
import { apply as applyInvariant } from '../src/invariant.ts'
|
||||
import type {} from '../src/client/index.ts'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
const PARENT_ID = 'parent' as SessionId
|
||||
const CHILD_ID = 'child-1' as SessionId
|
||||
|
||||
interface ChatSnapshot {
|
||||
readonly nodes: ReadonlyMap<string, ChatConversationViewNode>
|
||||
}
|
||||
|
||||
class TestEventDefinitions {
|
||||
entries(): readonly ConversationNodeDefinition[] { return [workflowRunDefinition] }
|
||||
fallbackEntry(): undefined { return undefined }
|
||||
}
|
||||
|
||||
class TestViewDefinitions {
|
||||
entries(): readonly ConversationViewDefinition[] { return [chatViewDefinition] }
|
||||
}
|
||||
|
||||
const chatViewDefinition: ConversationViewDefinition<ChatConversationViewNode, ChatSnapshot> = {
|
||||
target: 'chat',
|
||||
create: () => {
|
||||
let nodes = new Map<string, ChatConversationViewNode>()
|
||||
const snapshot = (): ChatSnapshot => ({ nodes })
|
||||
return {
|
||||
empty: snapshot(),
|
||||
replace: ({ nodes: values }) => {
|
||||
nodes = new Map(values.map(node => [node.key, node]))
|
||||
return snapshot()
|
||||
},
|
||||
apply: ({ upserts }) => {
|
||||
nodes = new Map(nodes)
|
||||
for (const node of upserts) nodes.set(node.key, node)
|
||||
return snapshot()
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
function at(seq: number, type: string, data: unknown): ConversationEventInput {
|
||||
return { event: { seq, time: seq * 100, type, data } as ConversationEventInput['event'], view: undefined }
|
||||
}
|
||||
|
||||
function matched(input: ConversationEventInput, role: ConversationMatch['role']): ConversationMatch {
|
||||
return { ...input, role, location: { kind: 'unresolved' } }
|
||||
}
|
||||
|
||||
function assembler(entries: readonly ConversationEventInput[], hasMore = false): ConversationNodeAssembler {
|
||||
const value = new ConversationNodeAssembler(new TestEventDefinitions(), new TestViewDefinitions())
|
||||
value.replaceWindow(entries, hasMore)
|
||||
value.flush()
|
||||
return value
|
||||
}
|
||||
|
||||
function workflowData(value: ConversationNodeAssembler): WorkflowRunChatData | undefined {
|
||||
const snapshot = value.snapshot('chat') as ChatSnapshot
|
||||
return [...snapshot.nodes.values()][0]?.data as WorkflowRunChatData | undefined
|
||||
}
|
||||
|
||||
function completeEvents(): ConversationEventInput[] {
|
||||
return [
|
||||
at(1, 'turn/start', { turn: 1 }),
|
||||
at(2, 'step/start', { turn: 1, step: 1 }),
|
||||
at(3, 'tool-workflow/run-start', { runId: 'run-1', name: 'audit' }),
|
||||
at(4, 'tool-workflow/agent-start', {
|
||||
runId: 'run-1', seq: 1, label: 'first', phase: '', childId: 'child-1',
|
||||
}),
|
||||
at(5, 'tool-workflow/agent-start', {
|
||||
runId: 'run-1', seq: 2, label: 'second', childId: 'child-2',
|
||||
}),
|
||||
at(6, 'tool-workflow/agent-end', { runId: 'run-1', seq: 1, outcome: 'completed' }),
|
||||
at(7, 'tool-workflow/agent-end', { runId: 'run-1', seq: 2, outcome: 'failed' }),
|
||||
at(8, 'tool-workflow/run-end', { runId: 'run-1', stopReason: 'error' }),
|
||||
at(9, 'step/end', { turn: 1, step: 1 }),
|
||||
at(10, 'turn/end', { turn: 1, reason: { kind: 'completed' } }),
|
||||
]
|
||||
}
|
||||
|
||||
describe('workflow-run Conversation Definition', () => {
|
||||
it('groups exact phase identities in first-member order and preserves terminal members', () => {
|
||||
const value = assembler(completeEvents())
|
||||
const data = workflowData(value)
|
||||
expect(data).toEqual({
|
||||
name: 'audit',
|
||||
status: 'failed',
|
||||
phases: [
|
||||
{
|
||||
key: 'value:0:', phase: '',
|
||||
members: [{ seq: 1, label: 'first', childId: 'child-1', status: 'completed' }],
|
||||
},
|
||||
{
|
||||
key: 'missing', phase: null,
|
||||
members: [{ seq: 2, label: 'second', childId: 'child-2', status: 'failed' }],
|
||||
},
|
||||
],
|
||||
})
|
||||
const node = [...(value.snapshot('chat') as ChatSnapshot).nodes.values()][0]!
|
||||
expect(node.anchorSeq).toBe(3)
|
||||
expect(node.kind).toBe('workflow-run')
|
||||
})
|
||||
|
||||
it('keeps an update-only tail pending until prepend supplies the unique start', () => {
|
||||
const tail = completeEvents().slice(3)
|
||||
const value = assembler(tail, true)
|
||||
expect(workflowData(value)).toBeUndefined()
|
||||
value.prepend(completeEvents().slice(0, 3), false)
|
||||
value.flush()
|
||||
expect(workflowData(value)).toEqual(workflowData(assembler(completeEvents())))
|
||||
})
|
||||
|
||||
it('produces the same final data through live append as complete replay', () => {
|
||||
const events = completeEvents()
|
||||
const value = assembler(events.slice(0, 3))
|
||||
for (const event of events.slice(3)) value.append(event)
|
||||
value.flush()
|
||||
expect(workflowData(value)).toEqual(workflowData(assembler(events)))
|
||||
})
|
||||
|
||||
it('shows missing terminal facts as interrupted only after the owning Location closes', () => {
|
||||
const value = assembler([
|
||||
at(1, 'turn/start', { turn: 1 }),
|
||||
at(2, 'step/start', { turn: 1, step: 1 }),
|
||||
at(3, 'tool-workflow/run-start', { runId: 'run-1', name: 'audit' }),
|
||||
at(4, 'tool-workflow/agent-start', {
|
||||
runId: 'run-1', seq: 1, label: 'worker', childId: 'child-1',
|
||||
}),
|
||||
])
|
||||
expect(workflowData(value)?.status).toBe('running')
|
||||
value.append(at(5, 'step/end', { turn: 1, step: 1 }))
|
||||
value.flush()
|
||||
expect(workflowData(value)).toMatchObject({
|
||||
status: 'interrupted',
|
||||
phases: [{ members: [{ status: 'interrupted' }] }],
|
||||
})
|
||||
})
|
||||
|
||||
it('retains a zero-member run as its own completed node', () => {
|
||||
const value = assembler([
|
||||
at(1, 'turn/start', { turn: 1 }),
|
||||
at(2, 'step/start', { turn: 1, step: 1 }),
|
||||
at(3, 'tool-workflow/run-start', { runId: 'empty', name: 'empty' }),
|
||||
at(4, 'tool-workflow/run-end', { runId: 'empty', stopReason: 'completed' }),
|
||||
])
|
||||
expect(workflowData(value)).toEqual({
|
||||
name: 'empty', status: 'completed', phases: [],
|
||||
})
|
||||
})
|
||||
|
||||
it('folds same-phase cancellation and a turn-level interruption', () => {
|
||||
const cancelled = assembler([
|
||||
at(1, 'turn/start', { turn: 1 }),
|
||||
at(2, 'tool-workflow/run-start', { runId: 'cancelled', name: 'cancelled' }),
|
||||
at(3, 'tool-workflow/agent-start', {
|
||||
runId: 'cancelled', seq: 1, label: 'one', phase: 'Research', childId: 'child-1',
|
||||
}),
|
||||
at(4, 'tool-workflow/agent-start', {
|
||||
runId: 'cancelled', seq: 2, label: 'two', phase: 'Research', childId: 'child-2',
|
||||
}),
|
||||
at(5, 'tool-workflow/agent-end', { runId: 'cancelled', seq: 1, outcome: 'cancelled' }),
|
||||
at(6, 'tool-workflow/agent-end', { runId: 'cancelled', seq: 2, outcome: 'completed' }),
|
||||
at(7, 'tool-workflow/run-end', { runId: 'cancelled', stopReason: 'cancelled' }),
|
||||
])
|
||||
expect(workflowData(cancelled)).toMatchObject({
|
||||
status: 'cancelled',
|
||||
phases: [{ phase: 'Research', members: [{ status: 'cancelled' }, { status: 'completed' }] }],
|
||||
})
|
||||
|
||||
const interruptedTurn = assembler([
|
||||
at(1, 'turn/start', { turn: 1 }),
|
||||
at(2, 'tool-workflow/run-start', { runId: 'turn', name: 'turn' }),
|
||||
at(3, 'tool-workflow/agent-start', {
|
||||
runId: 'turn', seq: 1, label: 'open', childId: 'child-1',
|
||||
}),
|
||||
at(4, 'turn/end', { turn: 1, reason: { kind: 'completed' } }),
|
||||
])
|
||||
expect(workflowData(interruptedTurn)?.status).toBe('interrupted')
|
||||
})
|
||||
|
||||
it('handles session/unresolved placement and defensive Definition calls', () => {
|
||||
const sessionLevel = assembler([
|
||||
at(1, 'tool-workflow/run-start', { runId: 'session', name: 'session' }),
|
||||
at(2, 'tool-workflow/agent-start', {
|
||||
runId: 'session', seq: 1, label: 'open', childId: 'child-1',
|
||||
}),
|
||||
])
|
||||
expect(workflowData(sessionLevel)?.status).toBe('running')
|
||||
|
||||
const invalidStart = matched(at(1, 'tool-workflow/agent-start', {
|
||||
runId: 'direct', seq: 1, label: 'member', childId: 'child-1',
|
||||
}), 'start')
|
||||
const emptyContext: Parameters<typeof workflowRunDefinition.start>[0] = {
|
||||
key: 'workflow-run:direct', kind: 'workflow-run', id: 'direct',
|
||||
matches: [invalidStart], start: invalidStart, state: undefined, current: new Map(),
|
||||
}
|
||||
const reader: Parameters<typeof workflowRunDefinition.start>[2] = { previous: () => undefined }
|
||||
expect(() => workflowRunDefinition.start(emptyContext, invalidStart, reader))
|
||||
.toThrow('workflow-run start requires tool-workflow/run-start')
|
||||
|
||||
const start = matched(at(2, 'tool-workflow/run-start', { runId: 'direct', name: 'direct' }), 'start')
|
||||
const startedContext = { ...emptyContext, matches: [start], start }
|
||||
const state = workflowRunDefinition.start(startedContext, start, reader)
|
||||
const updateContext: Parameters<typeof workflowRunDefinition.update>[0] = { ...startedContext, state }
|
||||
const unrelated = matched(at(3, 'turn/start', { turn: 1 }), 'update')
|
||||
expect(workflowRunDefinition.update(updateContext, unrelated)).toBe(state)
|
||||
expect(workflowRunDefinition.target).toBe('chat')
|
||||
expect(workflowRunDefinition.buildViewNode?.({
|
||||
...updateContext, matches: [], start: undefined,
|
||||
})).toBeNull()
|
||||
const directNode = workflowRunDefinition.buildViewNode?.(updateContext) as ChatConversationViewNode | null | undefined
|
||||
if (directNode === null) throw new Error('expected direct workflow Chat node')
|
||||
if (directNode === undefined) throw new Error('expected workflow Chat view builder')
|
||||
expect(directNode.kind).toBe('workflow-run')
|
||||
expect((directNode.data as WorkflowRunChatData).status).toBe('running')
|
||||
})
|
||||
})
|
||||
|
||||
function node(data: WorkflowRunChatData): WorkflowRunPanelProps['node'] {
|
||||
return {
|
||||
key: '12:workflow-runrun-1',
|
||||
kind: 'workflow-run',
|
||||
id: 'run-1',
|
||||
target: 'chat',
|
||||
anchorSeq: 3,
|
||||
location: { kind: 'unresolved' },
|
||||
visibility: 'visible',
|
||||
data,
|
||||
}
|
||||
}
|
||||
|
||||
const phase = (overrides: Partial<WorkflowRunChatData['phases'][number]> = {}): WorkflowRunChatData['phases'][number] => ({
|
||||
key: 'missing',
|
||||
phase: null,
|
||||
members: [{ seq: 1, label: 'worker', childId: 'child-1' as SessionId, status: 'running' }],
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const listState = (overrides: Partial<SessionListState> = {}): SessionListState => ({
|
||||
ids: [PARENT_ID, CHILD_ID],
|
||||
byId: {
|
||||
[PARENT_ID]: {
|
||||
id: PARENT_ID, displayTitle: 'parent', running: true, blank: false, updatedAt: 0,
|
||||
},
|
||||
[CHILD_ID]: {
|
||||
id: CHILD_ID, displayTitle: 'child', parentId: PARENT_ID, origin: 'subagent',
|
||||
running: true, blank: false, updatedAt: 0,
|
||||
},
|
||||
},
|
||||
current: PARENT_ID,
|
||||
phase: 'ready',
|
||||
subagentsByParent: {},
|
||||
tasksBySession: {},
|
||||
currentAddress: undefined,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
function panelProps(data: WorkflowRunChatData, sessions = listState(), openSession = vi.fn()): WorkflowRunPanelProps {
|
||||
return {
|
||||
node: node(data),
|
||||
sessionId: PARENT_ID,
|
||||
useSessions: selector => selector(sessions),
|
||||
useSession: (() => undefined) as WorkflowRunPanelProps['useSession'],
|
||||
useProjection: () => undefined,
|
||||
useInput: () => { throw new Error('unused') },
|
||||
inputActions: { setDraft: () => {}, submit: () => {} } as unknown as WorkflowRunPanelProps['inputActions'],
|
||||
useWorkspaces: (() => undefined) as WorkflowRunPanelProps['useWorkspaces'],
|
||||
useTurnData: () => undefined,
|
||||
selectedCallId: undefined,
|
||||
cwd: undefined,
|
||||
openFile: () => {},
|
||||
inspectCall: () => {},
|
||||
forkAt: () => {},
|
||||
loadImage: () => Promise.reject(new Error('unused')),
|
||||
fileMentions: () => undefined,
|
||||
openSession,
|
||||
t: makeTranslate(zh),
|
||||
}
|
||||
}
|
||||
|
||||
describe('WorkflowRunPanel', () => {
|
||||
it('defaults running runs open, terminal history closed, and keeps the current choice across data updates', () => {
|
||||
const running: WorkflowRunChatData = {
|
||||
name: 'audit', status: 'running', phases: [phase()],
|
||||
}
|
||||
const view = render(<WorkflowRunPanel {...panelProps(running)} />)
|
||||
expect(screen.getByText('未分阶段')).toBeTruthy()
|
||||
fireEvent.click(screen.getByRole('button', { name: /^audit/ }))
|
||||
expect(screen.queryByText('未分阶段')).toBeNull()
|
||||
|
||||
const terminal: WorkflowRunChatData = { ...running, status: 'completed' }
|
||||
view.rerender(<WorkflowRunPanel {...panelProps(terminal)} />)
|
||||
expect(screen.queryByText('未分阶段')).toBeNull()
|
||||
|
||||
cleanup()
|
||||
render(<WorkflowRunPanel {...panelProps(terminal)} />)
|
||||
expect(screen.queryByText('未分阶段')).toBeNull()
|
||||
})
|
||||
|
||||
it('supports root keyboard disclosure and renders a zero-member running state', () => {
|
||||
render(<WorkflowRunPanel {...panelProps({
|
||||
name: 'keyboard', status: 'running',
|
||||
phases: [phase({ key: 'research', phase: 'Research' })],
|
||||
})} />)
|
||||
const header = screen.getByRole('button', { name: /^keyboard/ })
|
||||
expect(header.getAttribute('aria-expanded')).toBe('true')
|
||||
fireEvent.keyDown(header, { key: 'ArrowDown' })
|
||||
expect(header.getAttribute('aria-expanded')).toBe('true')
|
||||
fireEvent.keyDown(header, { key: 'Enter' })
|
||||
expect(header.getAttribute('aria-expanded')).toBe('false')
|
||||
fireEvent.keyDown(header, { key: ' ' })
|
||||
expect(header.getAttribute('aria-expanded')).toBe('true')
|
||||
expect(screen.getByText('Research')).toBeTruthy()
|
||||
expect(screen.getByText('运行中 1')).toBeTruthy()
|
||||
const phaseHeader = screen.getByRole('button', { name: /Research/ })
|
||||
fireEvent.keyDown(phaseHeader, { key: 'ArrowDown' })
|
||||
expect(phaseHeader.getAttribute('aria-expanded')).toBe('false')
|
||||
fireEvent.keyDown(phaseHeader, { key: 'Enter' })
|
||||
expect(phaseHeader.getAttribute('aria-expanded')).toBe('true')
|
||||
fireEvent.keyDown(phaseHeader, { key: ' ' })
|
||||
expect(phaseHeader.getAttribute('aria-expanded')).toBe('false')
|
||||
|
||||
cleanup()
|
||||
render(<WorkflowRunPanel {...panelProps({
|
||||
name: 'empty', status: 'running', phases: [],
|
||||
})} />)
|
||||
expect(screen.getByText('没有启动成员')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('keeps phase disclosure independent and preserves empty versus absent names', () => {
|
||||
render(<WorkflowRunPanel {...panelProps({
|
||||
name: 'audit', status: 'running',
|
||||
phases: [
|
||||
phase({ key: 'value:0:', phase: '', members: [{
|
||||
seq: 1, label: '', childId: 'child-1' as SessionId, status: 'running',
|
||||
}] }),
|
||||
phase({ key: 'missing', phase: null, members: [{
|
||||
seq: 2, label: 'second', childId: 'child-2' as SessionId, status: 'running',
|
||||
}] }),
|
||||
],
|
||||
})} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: /空阶段名/ }))
|
||||
expect(screen.getByText('空成员名')).toBeTruthy()
|
||||
expect(screen.queryByText('second')).toBeNull()
|
||||
fireEvent.click(screen.getByRole('button', { name: /未分阶段/ }))
|
||||
expect(screen.getByText('second')).toBeTruthy()
|
||||
fireEvent.click(screen.getByRole('button', { name: /空阶段名/ }))
|
||||
expect(screen.queryByText('空成员名')).toBeNull()
|
||||
expect(screen.getByText('second')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('covers the Figma completed, failed/cancelled, and interrupted state boards', () => {
|
||||
const completed: WorkflowRunChatData = {
|
||||
name: 'repo-audit', status: 'completed',
|
||||
phases: [phase({
|
||||
members: [{ seq: 1, label: 'done', childId: 'child-1' as SessionId, status: 'completed' }],
|
||||
})],
|
||||
}
|
||||
const completedView = render(<WorkflowRunPanel {...panelProps(completed)} />)
|
||||
const completedHeader = screen.getByRole('button', { name: /^repo-audit/ })
|
||||
expect(completedHeader.getAttribute('aria-expanded')).toBe('false')
|
||||
fireEvent.click(completedHeader)
|
||||
expect(completedHeader.getAttribute('aria-expanded')).toBe('true')
|
||||
completedView.unmount()
|
||||
|
||||
const mixed: WorkflowRunChatData = {
|
||||
name: 'repo-audit', status: 'failed',
|
||||
phases: [phase({
|
||||
members: [
|
||||
{ seq: 1, label: 'failed', childId: 'child-1' as SessionId, status: 'failed' },
|
||||
{ seq: 2, label: 'cancelled', childId: 'child-2' as SessionId, status: 'cancelled' },
|
||||
],
|
||||
})],
|
||||
}
|
||||
const mixedView = render(<WorkflowRunPanel {...panelProps(mixed)} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: /^repo-audit/ }))
|
||||
fireEvent.click(screen.getByRole('button', { name: /未分阶段/ }))
|
||||
expect(screen.getByText('失败 1 · 已取消 1')).toBeTruthy()
|
||||
expect([...mixedView.container.querySelectorAll('[data-member-status]')]
|
||||
.map(row => row.getAttribute('data-member-status'))).toEqual(['failed', 'cancelled'])
|
||||
expect(mixedView.container.querySelectorAll('[data-state="error"]')).toHaveLength(2)
|
||||
expect(mixedView.container.querySelectorAll('[data-state="warning"]')).toHaveLength(1)
|
||||
mixedView.unmount()
|
||||
|
||||
const interrupted: WorkflowRunChatData = {
|
||||
name: 'repo-audit', status: 'interrupted',
|
||||
phases: [
|
||||
phase({
|
||||
members: [
|
||||
{ seq: 1, label: 'done', childId: 'child-1' as SessionId, status: 'completed' },
|
||||
{ seq: 2, label: 'interrupted', childId: 'child-2' as SessionId, status: 'interrupted' },
|
||||
],
|
||||
}),
|
||||
phase({
|
||||
key: 'interrupted-only', phase: 'Interrupted only',
|
||||
members: [{
|
||||
seq: 3, label: 'interrupted', childId: 'child-3' as SessionId, status: 'interrupted',
|
||||
}],
|
||||
}),
|
||||
],
|
||||
}
|
||||
const interruptedView = render(<WorkflowRunPanel {...panelProps(interrupted)} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: /^repo-audit/ }))
|
||||
expect(screen.getByText('已完成 1 · 已中断 1')).toBeTruthy()
|
||||
expect(interruptedView.container.querySelector('[data-run-status="interrupted"]')).toBeTruthy()
|
||||
expect(interruptedView.container.querySelectorAll('[data-state="warning"]')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('opens only a running ordinary-list subagent proven to have this parent', () => {
|
||||
const data: WorkflowRunChatData = {
|
||||
name: 'audit', status: 'running', phases: [phase()],
|
||||
}
|
||||
const openSession = vi.fn()
|
||||
render(<WorkflowRunPanel {...panelProps(data, listState(), openSession)} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: /未分阶段/ }))
|
||||
fireEvent.click(screen.getByRole('button', { name: '打开 worker' }))
|
||||
expect(openSession).toHaveBeenCalledWith('child-1')
|
||||
})
|
||||
|
||||
it.each([
|
||||
['not in ordinary list', listState({ ids: [PARENT_ID] }), 'running'],
|
||||
['remote row', listState({ byId: {
|
||||
...listState().byId,
|
||||
[CHILD_ID]: { ...listState().byId[CHILD_ID]!, origin: undefined },
|
||||
} }), 'running'],
|
||||
['wrong parent', listState({ byId: {
|
||||
...listState().byId,
|
||||
[CHILD_ID]: { ...listState().byId[CHILD_ID]!, parentId: 'other' as SessionId },
|
||||
} }), 'running'],
|
||||
['list terminal', listState({ byId: {
|
||||
...listState().byId,
|
||||
[CHILD_ID]: { ...listState().byId[CHILD_ID]!, running: false },
|
||||
} }), 'running'],
|
||||
['member terminal', listState(), 'completed'],
|
||||
] as const)('does not navigate when %s', (_name, sessions, memberStatus) => {
|
||||
const data: WorkflowRunChatData = {
|
||||
name: 'audit', status: 'running',
|
||||
phases: [phase({
|
||||
members: [{
|
||||
seq: 1, label: 'worker', childId: 'child-1' as SessionId, status: memberStatus,
|
||||
}],
|
||||
})],
|
||||
}
|
||||
render(<WorkflowRunPanel {...panelProps(data, sessions)} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: /未分阶段/ }))
|
||||
expect(screen.queryByRole('button', { name: '打开 worker' })).toBeNull()
|
||||
cleanup()
|
||||
})
|
||||
})
|
||||
|
||||
class TestSessions extends Service {
|
||||
readonly opened: SessionId[] = []
|
||||
constructor(ctx: Context) { super(ctx, 'sessions') }
|
||||
open(id: SessionId): void { this.opened.push(id) }
|
||||
}
|
||||
|
||||
describe('plugin lifecycle', () => {
|
||||
it('registers and removes the Definition and keyed renderer with its fiber', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SlotsService).await()
|
||||
ctx.provide('connection', { api: { settings: {} }, isLoopback: false } as never)
|
||||
await ctx.plugin(ConversationEventRegistry).await()
|
||||
await ctx.plugin(TestSessions).await()
|
||||
ctx.slots.register({
|
||||
name: 'root',
|
||||
children: { 'conversation.chat.node': { kind: 'keyed', scope: 'session' } },
|
||||
} as never, () => null)
|
||||
await ctx.plugin({ inject: ['slots'], apply: applyLocale }).await()
|
||||
const fiber = ctx.plugin({ inject: [...inject], apply })
|
||||
await fiber.await()
|
||||
expect(ctx.conversationEvents.entries().map(entry => entry.kind)).toEqual(['workflow-run'])
|
||||
expect(ctx.slots.entries('conversation.chat.node')).toHaveLength(1)
|
||||
const entry = ctx.slots.entries('conversation.chat.node')[0]!
|
||||
const face = entry.inject?.() as unknown as WorkflowRunInjected
|
||||
face.openSession(CHILD_ID)
|
||||
expect((ctx.sessions as unknown as TestSessions).opened).toEqual([CHILD_ID])
|
||||
await fiber.dispose()
|
||||
expect(ctx.conversationEvents.entries()).toEqual([])
|
||||
expect(ctx.slots.entries('conversation.chat.node')).toEqual([])
|
||||
|
||||
const replacement = ctx.plugin({ inject: [...inject], apply })
|
||||
await replacement.await()
|
||||
expect(ctx.conversationEvents.entries().map(entry => entry.kind)).toEqual(['workflow-run'])
|
||||
expect(ctx.slots.entries('conversation.chat.node')).toHaveLength(1)
|
||||
await replacement.dispose()
|
||||
})
|
||||
|
||||
it('keeps the node half inert and registers invariant ownership', async () => {
|
||||
applyNode()
|
||||
const registered: string[] = []
|
||||
const ctx = new Context()
|
||||
ctx.provide('invariants')
|
||||
ctx.set('invariants', {
|
||||
register: (pkg: string) => { registered.push(pkg); return () => {} },
|
||||
} as never)
|
||||
await applyInvariant(ctx)
|
||||
expect(registered).toEqual(['@deepseek-ai/dsh-client-ui-workflow-run'])
|
||||
})
|
||||
})
|
||||
42
packages/client/ui-workflow-run/tsconfig.json
Normal file
42
packages/client/ui-workflow-run/tsconfig.json
Normal file
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.client.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../locale"
|
||||
},
|
||||
{
|
||||
"path": "../runtime"
|
||||
},
|
||||
{
|
||||
"path": "../ui-conversation"
|
||||
},
|
||||
{
|
||||
"path": "../ui-primitives"
|
||||
},
|
||||
{
|
||||
"path": "../ui-slots"
|
||||
},
|
||||
{
|
||||
"path": "../../core/session"
|
||||
},
|
||||
{
|
||||
"path": "../../workflow/workflow"
|
||||
},
|
||||
{
|
||||
"path": "../../workflow/tool-workflow"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
}
|
||||
]
|
||||
}
|
||||
3
packages/client/ui-workflow-run/tsdown.config.ts
Normal file
3
packages/client/ui-workflow-run/tsdown.config.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { clientBundle } from '../tsdown.client.ts'
|
||||
|
||||
export default clientBundle('@deepseek-ai/dsh-client-ui-workflow-run', ['lib/types/index.js', 'lib/types/invariant.js'])
|
||||
Reference in New Issue
Block a user