mirror of
https://github.com/TianyiDataScience/openclaw-control-center.git
synced 2026-08-14 00:47:58 +00:00
feat: stabilize hall chat collaboration flow
This commit is contained in:
@@ -1,6 +1,26 @@
|
||||
# Mission Control MVP flags
|
||||
# Keep readonly true until explicit live-mode validation is complete.
|
||||
GATEWAY_URL=ws://127.0.0.1:18789
|
||||
# Optional absolute URL used in outbound bridge links, for example:
|
||||
# OPENCLAW_CONTROL_UI_URL=http://127.0.0.1:4310/
|
||||
# Optional Discord / Telegram mirror for task-room and hall-linked room updates.
|
||||
# Keep disabled unless you explicitly want room events mirrored out.
|
||||
# TASK_ROOM_BRIDGE_ENABLED=false
|
||||
# TASK_ROOM_BRIDGE_DISCORD_WEBHOOK_URL=https://discord.com/api/webhooks/...
|
||||
# TASK_ROOM_BRIDGE_TELEGRAM_BOT_TOKEN=123456:telegram-bot-token
|
||||
# TASK_ROOM_BRIDGE_TELEGRAM_CHAT_ID=-1001234567890
|
||||
# Hall runtime dispatch controls whether hall discussion / assign / handoff
|
||||
# use the real `openclaw agent` runtime and stream real session output.
|
||||
# Leave enabled for the full hall experience; disable only when you want
|
||||
# deterministic synthetic fallback replies for testing.
|
||||
# HALL_RUNTIME_DISPATCH_ENABLED=true
|
||||
# HALL_RUNTIME_DIRECT_STREAM_ENABLED=true
|
||||
# HALL_RUNTIME_THINKING_LEVEL=minimal
|
||||
# HALL_RUNTIME_TIMEOUT_SECONDS=600
|
||||
# HALL_RUNTIME_POLL_INTERVAL_MS=350
|
||||
# HALL_RUNTIME_HISTORY_LIMIT=120
|
||||
# HALL_RUNTIME_EXECUTION_CHAIN_ENABLED=true
|
||||
# HALL_RUNTIME_EXECUTION_MAX_TURNS=3
|
||||
# Optional path overrides when your OpenClaw/Codex data is not in the default home locations.
|
||||
# OPENCLAW_HOME=/path/to/.openclaw
|
||||
# OPENCLAW_CONFIG_PATH=/path/to/openclaw.json
|
||||
|
||||
@@ -31,6 +31,7 @@ docs/assets/*-preview*.png
|
||||
docs/assets/*-inspect*.png
|
||||
docs/assets/_wk-*.png
|
||||
docs/assets/_usage-*.png
|
||||
.tmp_*
|
||||
1440m
|
||||
180m
|
||||
720m
|
||||
|
||||
@@ -20,12 +20,17 @@ Language: **English** | [中文](README.zh-CN.md)
|
||||
- `Overview`: health, current state, decisions waiting, and operator-facing summaries
|
||||
- `Usage`: usage, spend, subscription windows, and connector status
|
||||
- `Staff`: who is really working now versus only queued
|
||||
- `Collaboration`: parent-child relays and cross-session messages between existing agent sessions
|
||||
- `Collaboration`: a hall-first multi-agent work chat with live discussion, execution order, handoff, review, and evidence threads
|
||||
- `Tasks`: current work, approvals, execution chains, and runtime evidence
|
||||
- `Documents` and `Memory`: source-backed workbenches scoped to active OpenClaw agents
|
||||
|
||||
## What this release adds
|
||||
- `Collaboration`: a new standalone collaboration page so you can see both parent-child handoffs and verified cross-session agent communication such as `Main ⇄ Pandas`, instead of inferring everything from execution chains.
|
||||
- `Collaboration`: a new hall-first collaboration page so you can post work in one shared timeline, see real agent roster names reply live, and watch discussion collapse into one execution owner before review.
|
||||
- `Collaboration`: hall discussion, assign, and handoff can now dispatch through the real `openclaw agent` runtime, so the live draft stream reflects real session execution instead of only synthetic orchestrator text.
|
||||
- `Collaboration`: hall runtime streaming now prefers direct CLI/stdout passthrough when OpenClaw emits live output, and gracefully falls back to session-history deltas when the runtime only exposes persisted session updates.
|
||||
- `Collaboration`: `assign` can now trigger a short automatic real execution chain, so one owner can continue through several runtime turns before the task pauses in `review` or `blocked`.
|
||||
- `Collaboration`: the hall now uses your current OpenClaw roster directly; existing agent ids do not need to be renamed to any control-center-specific defaults before they can discuss, execute, and hand off work.
|
||||
- `Collaboration`: linked task rooms now act as detail and evidence threads with live room streaming, assignment, review, and optional outbound Discord or Telegram mirroring without moving source-of-truth storage out of `control-center`.
|
||||
- `Settings`: a new `Connection health` card that tells you what is already wired, what is still partial, and where to finish setup.
|
||||
- `Settings`: a new `Security risk summary` that translates current risk, impact, and next-step guidance into plain operator-facing language.
|
||||
- `Settings`: a new `Update status` card for current version, latest version, update channel, and install method.
|
||||
@@ -65,11 +70,27 @@ Example UI from a local OpenClaw environment:
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Collaboration page</strong><br />See parent-child relays and verified cross-session communication such as <code>Main ⇄ Pandas</code> in one place.</td>
|
||||
<td><strong>Collaboration page</strong><br />See one shared thread where agents discuss, assign owners, hand off work, and review results.</td>
|
||||
<td><strong>Security and update status</strong><br />See current risk, impact, next-step guidance, and the gap between your current and latest version.</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
## Hall integration with your existing agents
|
||||
- The hall reads the current OpenClaw roster from the runtime environment and uses the live agent ids and display names it finds there.
|
||||
- Existing users do not need to rename agents to `pandas`, `coq`, `monkey`, or any other control-center-specific name before using hall collaboration.
|
||||
- Role suggestions are heuristic only. If the roster has clear names such as `manager`, `planner`, `builder`, or `qa`, the hall will use them; otherwise it falls back gracefully without blocking the workflow.
|
||||
- Hall runtime turns now carry structured transport context such as `surface`, `workspaceRoot`, `workdir`, `entryFiles`, and artifact references so repo-aware tasks can run against the same working context your agents already use elsewhere.
|
||||
|
||||
## Hall workflow
|
||||
- Start with one task in the hall. The first turn stays in `discussion`.
|
||||
- Unless you explicitly `@` one agent, the hall aims to gather at least two short replies so the second person can add a missing angle instead of repeating the first.
|
||||
- Use `Arrange execution order` to decide the first owner, later owners, and what each person hands off.
|
||||
- Saving the order does **not** start execution.
|
||||
- Once the queue is ready, the decision card will show `Start execution (...)`.
|
||||
- During execution, each owner should finish only their own step, then visibly `@` the next owner in the same thread.
|
||||
- Review should happen only after the last queued owner finishes, or when a human explicitly asks to stop and review.
|
||||
- After review, use `Continue discussion` to reopen the thread, then arrange the next round and start again from the same thread.
|
||||
|
||||
## 5-minute start
|
||||
```bash
|
||||
npm install
|
||||
@@ -77,6 +98,7 @@ cp .env.example .env
|
||||
npm run build
|
||||
npm test
|
||||
npm run smoke:ui
|
||||
npm run smoke:hall
|
||||
npm run dev:ui
|
||||
```
|
||||
|
||||
@@ -103,12 +125,18 @@ Notes:
|
||||
### Staff
|
||||
- Shows who is truly active now versus who only has queued work.
|
||||
- Separates live work from “next up” so backlog is not confused with active execution.
|
||||
- If a card shows `Role not defined in workspace`, start with the [FAQ & best practices guide](docs/FAQ.md).
|
||||
- Best when you want to know who is busy, idle, blocked, or waiting.
|
||||
|
||||
### Collaboration
|
||||
- Shows how work moves between agents: who accepted it first, who handed it off, and which session is holding the next move.
|
||||
- Covers both parent-child session relays and verified cross-session communication such as `sessions_send` / `inter-session message`.
|
||||
- Best when you want to understand “who passed this to whom, and where is the collaboration waiting now?”
|
||||
- Shows a shared multi-agent hall where operators post work once and the current roster replies in one timeline.
|
||||
- Streams draft agent replies live over SSE, then lands the final persisted message and any linked task-card state changes.
|
||||
- When hall runtime dispatch is enabled, discussion, assign, and handoff turns are sent through the real `openclaw agent` runtime and mirrored back from real session history.
|
||||
- When the runtime emits live stdout, the hall prefers that direct stream first; otherwise it keeps the draft alive with session-backed deltas so the operator still sees the real execution progress.
|
||||
- Assignment can trigger multiple real execution turns in sequence before pausing for review or surfacing a blocker, rather than stopping after a single runtime turn.
|
||||
- Keeps linked task rooms available as detail and evidence threads when you need deeper execution logs.
|
||||
- The intended interaction style is a real work chat: short discussion turns, one owner at a time during execution, explicit `@handoff`, then review only after the queued owners finish.
|
||||
- Best when you want to understand “who is talking now, who owns execution now, and what happens next?”
|
||||
|
||||
### Memory
|
||||
- A source-backed workbench for daily and long-term memory files.
|
||||
@@ -155,7 +183,8 @@ Notes:
|
||||
4. `npm run build`
|
||||
5. `npm test`
|
||||
6. `npm run smoke:ui`
|
||||
7. `npm run dev:ui`
|
||||
7. `npm run smoke:hall`
|
||||
8. `npm run dev:ui`
|
||||
|
||||
## Installation and onboarding
|
||||
|
||||
@@ -246,9 +275,9 @@ Phase 1: inspect the environment
|
||||
8. Do not assume any fixed agent names. If `openclaw.json` is readable, treat it as the source of truth.
|
||||
|
||||
Phase 2: install the project
|
||||
9. Confirm that the current directory is the control-center repo root.
|
||||
9. Confirm that the current directory is the control-center repo root. If it has not been cloned yet, clone it first: `git clone https://github.com/TianyiDataScience/openclaw-control-center.git`
|
||||
10. Verify the repo is complete before editing anything.
|
||||
11. If core paths are missing, stop and re-clone the official repo.
|
||||
11. If core paths (`src/runtime`, `src/ui`, `package.json`) are missing, do not continue. Re-clone from `https://github.com/TianyiDataScience/openclaw-control-center.git`.
|
||||
12. Run dependency install.
|
||||
13. If `.env` does not exist, create it from `.env.example`; otherwise correct it while preserving safe defaults.
|
||||
|
||||
@@ -269,6 +298,7 @@ Phase 4: validation
|
||||
- npm run build
|
||||
- npm test
|
||||
- npm run smoke:ui
|
||||
- npm run smoke:hall
|
||||
18. If any step fails, stop and tell me exactly which step failed, why, and what I should fix next.
|
||||
19. If build / test / smoke succeed but the live Gateway is still unreachable, classify the result as: local UI works, but live observability is not fully connected yet.
|
||||
|
||||
|
||||
+31
-9
@@ -20,12 +20,14 @@
|
||||
- `总览`:系统状态、待处理事项、关键风险和运营摘要
|
||||
- `用量`:用量、花费、订阅窗口和连接状态
|
||||
- `员工`:谁真的在工作,谁只是排队待命
|
||||
- `协作`:父子会话接力与智能体之间的跨会话通信
|
||||
- `协作`:一个 hall-first 的多 agent 工作群,可以在同一条线程里讨论、排顺序、交接、评审
|
||||
- `任务`:当前任务、审批、执行链和运行证据
|
||||
- `文档` 与 `记忆`:按活跃 OpenClaw agent 范围展示的源文件工作台
|
||||
|
||||
## 这个版本新增了什么
|
||||
- `协作`:新增独立 `协作` 页面,直接看父子会话接力和 `Main ⇄ Pandas` 这种已验证跨会话通信,不再只看执行链猜关系。
|
||||
- `协作`:新增 hall-first 的独立 `协作` 页面,任务可以先在共享时间线里讨论、再收口到执行 owner,而不是只靠父子会话去猜任务怎么推进。
|
||||
- `协作`:hall 里的讨论、指派和交接现在可以走真实 `openclaw agent` 运行时,草稿流和最终落地消息都会回到同一条线程。
|
||||
- `协作`:hall 现在直接使用你当前 OpenClaw roster 里的 agent id 和显示名;已有用户不需要把 agent 改名成任何 control-center 私有名字就能接入。
|
||||
- `设置`:新增 `接线状态`,直接告诉你哪些数据已经接好、哪些还差一步,以及该去哪里补。
|
||||
- `设置`:新增 `安全风险摘要`,把当前风险、影响和下一步建议翻译成人话。
|
||||
- `设置`:新增 `更新状态`,直接看当前版本、最新版本、更新通道和安装方式。
|
||||
@@ -65,11 +67,27 @@
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>协作页</strong><br />直接看父子会话接力,以及像 <code>Main ⇄ Pandas</code> 这样的已验证跨会话通信。</td>
|
||||
<td><strong>协作页</strong><br />直接看多 agent 在同一条线程里讨论、拍板、执行、交接和评审,而不是把协作关系藏在不同会话里。</td>
|
||||
<td><strong>安全与更新状态</strong><br />直接看当前风险、影响、下一步建议,以及当前版本和最新版本。</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
## hall 如何接入你现有的 agent
|
||||
- hall 会读取当前 OpenClaw 运行环境里的 roster,直接使用你已经在用的 agent id 和显示名。
|
||||
- 已有用户不需要把 agent 改名成 `pandas`、`coq`、`monkey` 这类 control-center 示例名,直接接入即可。
|
||||
- hall 的角色建议只是启发式,不是强绑定;如果你的 roster 名字里带有 `manager`、`planner`、`builder`、`qa` 之类信号,会优先用这些信号,否则会平滑降级,不会因为名字不匹配而卡住。
|
||||
- hall 运行时现在会把 `surface`、`workspaceRoot`、`workdir`、关键入口文件和 artifact 引用一起传给 agent,让 repo-aware 的任务尽量获得和其他渠道一致的执行上下文。
|
||||
|
||||
## hall 工作流
|
||||
- 从一条任务开始,第一轮会先留在 `讨论中`。
|
||||
- 如果你没有显式 `@` 某个人,hall 会尽量先收两条短回复,让第二个人承接第一人的上下文补缺口,而不是重写一遍。
|
||||
- 用 `安排后续顺序` 来决定谁先做、后面谁接、每一步交接给谁。
|
||||
- 保存顺序 **不会** 自动开始执行。
|
||||
- 队列排好后,结论卡会出现 `开始执行(...)`。
|
||||
- 进入执行后,每个人只做自己这一棒,并在同一条线程里显式 `@` 下一位交棒。
|
||||
- 只有最后一位执行者做完,或者人类明确要求先停下来评审时,才应该进入评审。
|
||||
- 一轮评审后,可以点 `继续讨论` 回到讨论态,再排下一轮顺序,然后从同一条线程继续开始。
|
||||
|
||||
## 5 分钟启动
|
||||
```bash
|
||||
npm install
|
||||
@@ -77,6 +95,7 @@ cp .env.example .env
|
||||
npm run build
|
||||
npm test
|
||||
npm run smoke:ui
|
||||
npm run smoke:hall
|
||||
npm run dev:ui
|
||||
```
|
||||
|
||||
@@ -103,12 +122,14 @@ npm run dev:ui
|
||||
### 员工
|
||||
- 展示谁现在真的在工作,谁只是有排队中的任务。
|
||||
- 明确区分“正在执行”和“下一项”,避免把 backlog 误认为正在跑。
|
||||
- 如果员工卡片显示 `工作区未写明职责`,优先看 [FAQ 与最佳实践](docs/FAQ.md) 的第 1 条。
|
||||
- 最适合判断谁忙、谁闲、谁卡住、谁在等待。
|
||||
|
||||
### 协作
|
||||
- 独立展示智能体之间怎么交接、谁先接单、谁派给了谁、回复从哪条会话回来。
|
||||
- 既能看父会话与子会话的接力,也能看 `sessions_send` / `inter-session message` 这类已验证跨会话通信。
|
||||
- 最适合理解“这件事到底是谁转给了谁、现在卡在谁这里”。
|
||||
- hall-first 的多 agent 工作群:同一条线程里先讨论,再排顺序,再开始执行、交接和评审。
|
||||
- 运行时回复会直接回写到大厅时间线;需要更深证据时,再打开 linked room 看细节。
|
||||
- 预期风格更像真实员工群:讨论时短回复补缺口,执行时只认一个 owner,交接时显式 `@下一位`,最后才评审。
|
||||
- 最适合理解“这件事是谁在做、下一步交给谁、为什么现在能继续往前推进”。
|
||||
|
||||
### 记忆
|
||||
- 一个直接基于源文件的记忆工作台,用来查看和编辑每日记忆与长期记忆。
|
||||
@@ -155,7 +176,8 @@ npm run dev:ui
|
||||
4. `npm run build`
|
||||
5. `npm test`
|
||||
6. `npm run smoke:ui`
|
||||
7. `npm run dev:ui`
|
||||
7. `npm run smoke:hall`
|
||||
8. `npm run dev:ui`
|
||||
|
||||
## 安装与上手
|
||||
|
||||
@@ -246,9 +268,9 @@ cp .env.example .env
|
||||
8. 不要假设任何固定 agent 名称。若 `openclaw.json` 可读,就以它为准。
|
||||
|
||||
第二阶段:安装项目
|
||||
9. 确认当前目录是 control-center 仓库根目录。
|
||||
9. 确认当前目录是 control-center 仓库根目录。如果还没有 clone,先执行:`git clone https://github.com/TianyiDataScience/openclaw-control-center.git`
|
||||
10. 先确认仓库本体完整。
|
||||
11. 如果缺少 `src/runtime`、`src/ui` 或 `package.json`,不要继续安装,直接重新 clone 官方仓库。
|
||||
11. 如果缺少 `src/runtime`、`src/ui` 或 `package.json`,不要继续安装,重新 clone:`https://github.com/TianyiDataScience/openclaw-control-center.git`。
|
||||
12. 运行依赖安装。
|
||||
13. 如果 `.env` 不存在,就从 `.env.example` 创建;如果存在,就在保留安全默认值的前提下修正它。
|
||||
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
# Collaboration Hall MVP
|
||||
|
||||
## Product shape
|
||||
- `Collaboration` defaults to one shared hall, not one task room per task.
|
||||
- Operators post requests in the hall.
|
||||
- Agents reply using the current real roster names from `openclaw.json` / runtime roster discovery.
|
||||
- Agent replies stream into the hall as SSE draft deltas before the final persisted message lands.
|
||||
- When hall runtime dispatch is enabled, those draft deltas are backed by real `openclaw agent` runs and normalized session history, not only synthetic orchestrator text.
|
||||
- When the runtime exposes live stdout, the hall now prefers that direct stream first and only falls back to session-history deltas when needed.
|
||||
- Task rooms remain available as secondary detail and evidence threads.
|
||||
|
||||
## Core objects
|
||||
- `CollaborationHall`: the shared group chat container.
|
||||
- `HallTaskCard`: the task card anchored in the hall timeline.
|
||||
- `HallMessage`: one message in the shared timeline.
|
||||
- `TaskRoom`: the linked detail/evidence thread behind a task card.
|
||||
|
||||
## Routing rules
|
||||
- `@RealAgentName` routes only to the matching participant.
|
||||
- `@all` broadcasts to the active hall participants.
|
||||
- No mention on a new task routes to the planner-like participant first.
|
||||
- No mention during execution routes to the current execution owner first.
|
||||
|
||||
## State model
|
||||
- `discussion`: agents discuss the task and no execution tools are allowed.
|
||||
- `execution`: one owner holds the execution lock and posts the main work updates.
|
||||
- `review`: reviewer and operator decide whether the task passes or goes back.
|
||||
- `blocked`: the task needs human help or a new handoff.
|
||||
- `completed`: the task is done and the result stays visible in the hall timeline.
|
||||
|
||||
## Anti-chaos guarantees
|
||||
- One shared hall, but each task has one default execution owner.
|
||||
- Speaker selection is explicit and deterministic.
|
||||
- Execution requires a lock; another agent cannot silently take over.
|
||||
- Runtime assignment can continue through several automatic execution turns before pausing, but only one owner still holds the lock during that chain.
|
||||
- Multi-agent cooperation uses structured handoff packets:
|
||||
- `goal`
|
||||
- `current_result`
|
||||
- `done_when`
|
||||
- `blockers`
|
||||
- `next_owner`
|
||||
- `requires_input_from`
|
||||
|
||||
## UI principles
|
||||
- Hall timeline is the visual center.
|
||||
- Task cards stay visible, but secondary to the active conversation.
|
||||
- Draft agent replies should feel live, not poll-based, while still settling into durable stored messages.
|
||||
- The operator should be able to answer three questions in under five seconds:
|
||||
- Who is speaking now?
|
||||
- Who owns execution now?
|
||||
- What happens next?
|
||||
|
||||
## Delivery model
|
||||
- Hall clients subscribe to `/api/hall/events` with `EventSource`.
|
||||
- Linked task-room clients subscribe to `/api/rooms/:roomId/events`.
|
||||
- Generated agent replies emit `draft_start`, `draft_delta`, and `draft_complete` events.
|
||||
- Hall discussion, assign, and handoff can dispatch to the real OpenClaw runtime, poll the live session history, and turn new assistant/tool output into draft deltas.
|
||||
- Automatic execution chains can keep dispatching the same owner for a bounded number of runtime turns before the task moves into `review`, `blocked`, or manual continuation.
|
||||
- Final messages still persist through the normal hall / room stores so refresh, replay, and summaries stay durable.
|
||||
+8
-2
@@ -4,10 +4,12 @@
|
||||
|
||||
## 中文版
|
||||
|
||||
### 1. 多 Agent 工作区 — 为什么成员列表看不到职责?
|
||||
### 1. 多 Agent 工作区 — 为什么成员列表看不到职责 / 显示“工作区未写明职责”?
|
||||
|
||||
**问题:** 已经在 `SOUL.md` 和 `AGENTS.md` 中写好了角色和职责,但控制中心的员工页面看不到具体分工。
|
||||
|
||||
**直接回答:** 员工页当前不会直接把某个配置文件里的 `role` / `mission` 字段原样摘出来显示。它更依赖 workspace 目录、`IDENTITY.md` 这类身份线索,以及 Gateway 返回的运行时元信息。
|
||||
|
||||
**常见原因:** 控制中心会参考 OpenClaw 的 workspace 目录和运行时信号。是否能看到完整职责信息,通常取决于:
|
||||
- OpenClaw Gateway 返回的 session 数据中是否包含 agent 元信息
|
||||
- `OPENCLAW_AGENT_ROOT` 环境变量是否指向了正确的 workspace 目录
|
||||
@@ -40,6 +42,7 @@
|
||||
- **状态(执行中/空闲)** → 主要来自 Gateway 的 session 运行信号
|
||||
- **workspace** → 通常来自 agent 配置中的 `workspace` 字段
|
||||
- **具体职责描述** → 当前不会自动把 `SOUL.md` 摘要直接显示成员工卡片文案,更适合作为文档线索保存在 `IDENTITY.md`、`SOUL.md`、`AGENTS.md`
|
||||
- **不会直接摘取的内容** → 任意配置文件里自定义的 `role` / `mission` 文本,目前不会被员工卡片逐字渲染成职责说明
|
||||
|
||||
> **最佳实践:** 如果你希望控制中心更容易展示身份与职责线索,建议优先把简短身份说明写进 `IDENTITY.md`,并把详细角色说明写进 `SOUL.md` / `AGENTS.md`。
|
||||
|
||||
@@ -138,15 +141,18 @@ openclaw sessions history <session-key> --limit 5
|
||||
|
||||
## English Version
|
||||
|
||||
### 1. Multi-Agent Workspace — Why Can't I See Roles in the Staff Page?
|
||||
### 1. Multi-Agent Workspace — Why Does the Staff Page Show "Role not defined in workspace"?
|
||||
|
||||
**Issue:** Roles defined in `SOUL.md` and `AGENTS.md` don't appear in the Control Center staff list.
|
||||
|
||||
**Short answer:** The Staff page does not currently render an arbitrary `role` or `mission` field from one config file verbatim. It relies more on workspace discovery, identity hints such as `IDENTITY.md`, and runtime metadata returned by Gateway.
|
||||
|
||||
**Common guidance / best practice:**
|
||||
- Ensure your workspace directory follows the standard structure (`SOUL.md`, `IDENTITY.md`, `AGENTS.md`, `MEMORY.md`)
|
||||
- Set `OPENCLAW_AGENT_ROOT` environment variable to point to the parent directory of all workspaces
|
||||
- Confirm OpenClaw Gateway is running (`openclaw gateway status`)
|
||||
- A practical best practice is to put short identity/role hints in `IDENTITY.md`, and longer role definitions in `SOUL.md` / `AGENTS.md`
|
||||
- Do not expect a custom `role` / `mission` field from an arbitrary config file to appear as the exact Staff-card responsibility text
|
||||
|
||||
### 2. Billing & Budget — How to Connect Data and Set Limits?
|
||||
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
# Task Room Bridge
|
||||
|
||||
This bridge keeps `control-center` as the source of truth while letting external chat surfaces mirror room activity.
|
||||
|
||||
## What it does
|
||||
|
||||
- records outbound room events in `runtime/task-room-bridge-events.json`
|
||||
- optionally mirrors room events to Discord and Telegram
|
||||
- includes a deep link back to the `Collaboration` page with `roomId=...`
|
||||
- never stores room state outside `control-center`
|
||||
|
||||
## Supported event types
|
||||
|
||||
- `room_created`
|
||||
- `message_posted`
|
||||
- `handoff_recorded`
|
||||
- `executor_assigned`
|
||||
- `review_submitted`
|
||||
- `stage_changed`
|
||||
|
||||
## Environment flags
|
||||
|
||||
- `OPENCLAW_CONTROL_UI_URL`
|
||||
- `TASK_ROOM_BRIDGE_ENABLED`
|
||||
- `TASK_ROOM_BRIDGE_DISCORD_WEBHOOK_URL`
|
||||
- `TASK_ROOM_BRIDGE_TELEGRAM_BOT_TOKEN`
|
||||
- `TASK_ROOM_BRIDGE_TELEGRAM_CHAT_ID`
|
||||
|
||||
## Delivery model
|
||||
|
||||
1. Core room mutation succeeds first.
|
||||
2. Bridge event is written locally.
|
||||
3. If Discord or Telegram mirroring is enabled, outbound payloads are sent for each configured target.
|
||||
4. Bridge failures do not roll back the room mutation.
|
||||
|
||||
## Operator use
|
||||
|
||||
- Use the bridge for mirror notifications and human intervention across Discord or Telegram.
|
||||
- Use the existing room APIs for any write-back from external bots or moderators.
|
||||
@@ -0,0 +1,59 @@
|
||||
# Task Room MVP
|
||||
|
||||
This document defines the first MVP for task-room collaboration inside OpenClaw Control Center.
|
||||
|
||||
Note: task rooms are now secondary detail and evidence threads behind the hall-first collaboration surface. They still preserve the room lifecycle below, and generated room replies now stream to the UI as draft deltas before the final message is persisted.
|
||||
|
||||
## Goal
|
||||
|
||||
Give each tracked task one durable collaboration room where:
|
||||
|
||||
- humans can post requests directly
|
||||
- `planner`, `coder`, `reviewer`, and `manager` discuss in sequence
|
||||
- the system chooses an executor
|
||||
- execution and review stay on one timeline
|
||||
- task status and room state stay aligned
|
||||
|
||||
## Room Stages
|
||||
|
||||
- `intake`: the room exists but structured discussion has not started
|
||||
- `discussion`: the orchestrator is collecting planner/coder/reviewer/manager messages
|
||||
- `assigned`: an executor has been selected and the handoff is recorded
|
||||
- `executing`: the executor is actively posting status or result updates
|
||||
- `review`: execution is waiting for approval or rejection
|
||||
- `completed`: the task passed review and the room is closed for the MVP flow
|
||||
|
||||
## Message Kinds
|
||||
|
||||
- `chat`: human request or general conversational note
|
||||
- `proposal`: structured plan from planner/coder/reviewer
|
||||
- `decision`: manager decision containing executor and done condition
|
||||
- `handoff`: explicit ownership transfer between roles
|
||||
- `status`: execution heartbeat or state transition note
|
||||
- `result`: final output or review outcome
|
||||
|
||||
## MVP Rules
|
||||
|
||||
1. One task can have only one primary room.
|
||||
2. A room can contain multiple handoffs.
|
||||
3. Discussion starts with `planner`.
|
||||
4. `coder` and `reviewer` each get one discussion turn per human prompt.
|
||||
5. `manager` closes the discussion with a structured decision.
|
||||
6. Once assigned, only the executor keeps talking unless another role is explicitly mentioned.
|
||||
7. Review approval marks the task `done`.
|
||||
8. Review rejection returns the task to `in_progress` or `blocked`.
|
||||
|
||||
## Stored Artifacts
|
||||
|
||||
- `runtime/chat-rooms.json`
|
||||
- `runtime/chat-messages.json`
|
||||
- `runtime/chat-summaries.json`
|
||||
|
||||
## Structured Decision Output
|
||||
|
||||
The manager decision must provide:
|
||||
|
||||
- `proposal`
|
||||
- `decision`
|
||||
- `executor`
|
||||
- `done_when`
|
||||
+2
-1
@@ -9,12 +9,13 @@
|
||||
"dev:continuous": "cross-env MONITOR_CONTINUOUS=true node --import tsx src/index.ts",
|
||||
"dev:ui": "cross-env UI_MODE=true node --import tsx src/index.ts",
|
||||
"smoke:ui": "node scripts/ui-smoke.js",
|
||||
"smoke:hall": "node --import tsx scripts/hall-release-smoke.ts",
|
||||
"command:backup-export": "cross-env APP_COMMAND=backup-export node --import tsx src/index.ts",
|
||||
"avatars:export": "node --import tsx scripts/export-staff-avatars.ts",
|
||||
"command:import-validate": "cross-env APP_COMMAND=import-validate node --import tsx src/index.ts",
|
||||
"command:acks-prune": "cross-env APP_COMMAND=acks-prune node --import tsx src/index.ts",
|
||||
"command:task-heartbeat": "cross-env APP_COMMAND=task-heartbeat node --import tsx src/index.ts",
|
||||
"test": "node --import tsx --test test/**/*.test.ts",
|
||||
"test": "node --import tsx scripts/run-tests-isolated.ts",
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"release:audit": "bash scripts/release-audit.sh",
|
||||
"validate:task-store": "node --import tsx scripts/validate-task-store.ts",
|
||||
|
||||
@@ -0,0 +1,793 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { mkdtempSync, mkdirSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
|
||||
|
||||
const ROOT = join(__dirname, "..");
|
||||
const PORT = process.env.HALL_SMOKE_PORT || "4517";
|
||||
const RUNTIME_DIR = mkdtempSync(join(tmpdir(), "hall-release-smoke-"));
|
||||
const PAGE_TIMEOUT_MS = 30_000;
|
||||
const SERVER_TIMEOUT_MS = 20_000;
|
||||
|
||||
type SeededTask = {
|
||||
hallId: string;
|
||||
taskCardId: string;
|
||||
projectId: string;
|
||||
taskId: string;
|
||||
roomId: string;
|
||||
title: string;
|
||||
};
|
||||
|
||||
const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
async function waitForServer(baseUrl: string): Promise<void> {
|
||||
const deadline = Date.now() + SERVER_TIMEOUT_MS;
|
||||
let lastError: unknown;
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
const response = await fetch(`${baseUrl}/healthz`);
|
||||
if (response.ok) return;
|
||||
lastError = new Error(`healthz returned ${response.status}`);
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
}
|
||||
await wait(500);
|
||||
}
|
||||
throw new Error(`hall smoke server did not become ready: ${String(lastError)}`);
|
||||
}
|
||||
|
||||
function assert(condition: unknown, message: string): asserts condition {
|
||||
if (!condition) throw new Error(message);
|
||||
}
|
||||
|
||||
async function seedRuntime(): Promise<{ firstTask: SeededTask; secondTask: SeededTask; thirdTask: SeededTask; fourthTask: SeededTask; fifthTask: SeededTask; sixthTask: SeededTask; seventhTask: SeededTask; eighthTask: SeededTask }> {
|
||||
process.env.OPENCLAW_RUNTIME_DIR = RUNTIME_DIR;
|
||||
mkdirSync(RUNTIME_DIR, { recursive: true });
|
||||
|
||||
const [{ createHallTaskFromOperatorRequest }, { appendHallMessage, updateHallTaskCard }] = await Promise.all([
|
||||
import("../src/runtime/collaboration-hall-orchestrator"),
|
||||
import("../src/runtime/collaboration-hall-store"),
|
||||
]);
|
||||
|
||||
const first = await createHallTaskFromOperatorRequest(
|
||||
{
|
||||
content: "我想要做一个视频 介绍我的群聊功能",
|
||||
authorParticipantId: "operator",
|
||||
authorLabel: "Operator",
|
||||
},
|
||||
{ skipDiscussion: true },
|
||||
);
|
||||
|
||||
const second = await createHallTaskFromOperatorRequest(
|
||||
{
|
||||
content: "我想要策划一个互动数据叙事事件",
|
||||
authorParticipantId: "operator",
|
||||
authorLabel: "Operator",
|
||||
},
|
||||
{ skipDiscussion: true },
|
||||
);
|
||||
|
||||
const third = await createHallTaskFromOperatorRequest(
|
||||
{
|
||||
content: "我想把第二轮继续跑起来,确认下一位执行者还能开始。",
|
||||
authorParticipantId: "operator",
|
||||
authorLabel: "Operator",
|
||||
},
|
||||
{ skipDiscussion: true },
|
||||
);
|
||||
|
||||
const fourth = await createHallTaskFromOperatorRequest(
|
||||
{
|
||||
content: "我想从执行中切回讨论,再排第三轮顺序并重新开始。",
|
||||
authorParticipantId: "operator",
|
||||
authorLabel: "Operator",
|
||||
},
|
||||
{ skipDiscussion: true },
|
||||
);
|
||||
|
||||
const fifth = await createHallTaskFromOperatorRequest(
|
||||
{
|
||||
content: "请先扫描 control-center 代码,找出 hall-chat 的 3 个关键入口文件,并说明每个文件负责什么。",
|
||||
authorParticipantId: "operator",
|
||||
authorLabel: "Operator",
|
||||
},
|
||||
{ skipDiscussion: true },
|
||||
);
|
||||
|
||||
const sixth = await createHallTaskFromOperatorRequest(
|
||||
{
|
||||
content: "请把群聊功能收成一句能拍的总结,再交给下一位写 3 个 hook。",
|
||||
authorParticipantId: "operator",
|
||||
authorLabel: "Operator",
|
||||
},
|
||||
{ skipDiscussion: true },
|
||||
);
|
||||
|
||||
const seventh = await createHallTaskFromOperatorRequest(
|
||||
{
|
||||
content: "请排一个很长的多 agent 执行顺序,用来验证顺序编辑器可以滚动到最下面。",
|
||||
authorParticipantId: "operator",
|
||||
authorLabel: "Operator",
|
||||
},
|
||||
{ skipDiscussion: true },
|
||||
);
|
||||
|
||||
const eighth = await createHallTaskFromOperatorRequest(
|
||||
{
|
||||
content: "请打开一个空的执行顺序编辑器,用来验证没有已选执行者时的空态布局。",
|
||||
authorParticipantId: "operator",
|
||||
authorLabel: "Operator",
|
||||
},
|
||||
{ skipDiscussion: true },
|
||||
);
|
||||
|
||||
await appendHallMessage({
|
||||
hallId: first.hall.hallId,
|
||||
kind: "proposal",
|
||||
authorParticipantId: "coq",
|
||||
authorLabel: "Coq-每日新闻",
|
||||
content: "先把任务样本锁死:就用“做一个介绍群聊功能的视频”当片中任务。<br>这样开场 20 秒里就能自然出现讨论、拍板、owner、next action。<br>@pandas 可以直接按这个样本补一版最小台词和屏幕动作。",
|
||||
projectId: first.taskCard.projectId,
|
||||
taskId: first.taskCard.taskId,
|
||||
taskCardId: first.taskCard.taskCardId,
|
||||
roomId: first.roomId,
|
||||
});
|
||||
|
||||
await updateHallTaskCard({
|
||||
taskCardId: first.taskCard.taskCardId,
|
||||
stage: "discussion",
|
||||
status: "todo",
|
||||
proposal: "先选第一位执行者,再把这条视频任务推进成可执行顺序。",
|
||||
latestSummary: "这条线程应该显示一个空的执行顺序控制台,并保持紧凑布局。",
|
||||
});
|
||||
|
||||
await updateHallTaskCard({
|
||||
taskCardId: third.taskCard.taskCardId,
|
||||
stage: "review",
|
||||
status: "in_progress",
|
||||
currentOwnerParticipantId: "builder",
|
||||
currentOwnerLabel: "Builder",
|
||||
currentExecutionItem: null,
|
||||
decision: "第一轮先停在可评审状态,然后从 pandas 开第二轮。",
|
||||
doneWhen: "第二轮能从同一张卡重新开始执行。",
|
||||
plannedExecutionOrder: ["pandas"],
|
||||
plannedExecutionItems: [
|
||||
{
|
||||
itemId: "next-pandas",
|
||||
participantId: "pandas",
|
||||
task: "把第一轮评审结果收成第二轮的可执行起步稿。",
|
||||
handoffWhen: "第二轮第一棒做完后贴回大厅。",
|
||||
},
|
||||
],
|
||||
latestSummary: "这条线程应该还能从 pandas 重新开始第二轮执行。",
|
||||
});
|
||||
|
||||
await updateHallTaskCard({
|
||||
taskCardId: fourth.taskCard.taskCardId,
|
||||
stage: "execution",
|
||||
status: "in_progress",
|
||||
currentOwnerParticipantId: "main",
|
||||
currentOwnerLabel: "main",
|
||||
currentExecutionItem: {
|
||||
itemId: "active-main",
|
||||
participantId: "main",
|
||||
task: "先把上一轮结果收住。",
|
||||
handoffToParticipantId: "otter",
|
||||
handoffWhen: "收住后交给 otter。",
|
||||
},
|
||||
plannedExecutionOrder: ["otter"],
|
||||
plannedExecutionItems: [
|
||||
{
|
||||
itemId: "next-otter",
|
||||
participantId: "otter",
|
||||
task: "按新的方向开第三轮第一棒。",
|
||||
handoffWhen: "开第三轮后贴回大厅。",
|
||||
},
|
||||
],
|
||||
latestSummary: "这条线程应该能从执行中切回讨论,再重新开始第三轮。",
|
||||
});
|
||||
|
||||
await appendHallMessage({
|
||||
hallId: fifth.hall.hallId,
|
||||
kind: "proposal",
|
||||
authorParticipantId: "coq",
|
||||
authorLabel: "Coq-每日新闻",
|
||||
content: "这题里 **“关键入口”** 最好先定义成“读懂 hall-chat 主链路时最先该进的文件”,不然 UI 外壳文件和真正的协作入口会混在一起。<br>按这个标准,`collaboration-hall.ts` 看呈现,`collaboration-hall-orchestrator.ts` 看编排,`hall-runtime-dispatch.ts` 看执行落地,`server.ts` 更像外层承载入口。",
|
||||
projectId: fifth.taskCard.projectId,
|
||||
taskId: fifth.taskCard.taskId,
|
||||
taskCardId: fifth.taskCard.taskCardId,
|
||||
roomId: fifth.roomId,
|
||||
});
|
||||
|
||||
await appendHallMessage({
|
||||
hallId: fifth.hall.hallId,
|
||||
kind: "proposal",
|
||||
authorParticipantId: "monkey",
|
||||
authorLabel: "monkey",
|
||||
content: "这个划分已经够稳了,再补一个判断标准:凡是**不看它就读不通 hall-chat 主链路**的,才算这轮的关键入口。<br>按这个标准,`server.ts` 更像把页面和接口托起来的壳;真正决定 hall-chat 怎么显示、怎么收敛、怎么派发执行的,还是那 3 个主链路文件。",
|
||||
projectId: fifth.taskCard.projectId,
|
||||
taskId: fifth.taskCard.taskId,
|
||||
taskCardId: fifth.taskCard.taskCardId,
|
||||
roomId: fifth.roomId,
|
||||
});
|
||||
|
||||
await appendHallMessage({
|
||||
hallId: fifth.hall.hallId,
|
||||
kind: "status",
|
||||
authorParticipantId: "pandas",
|
||||
authorLabel: "pandas",
|
||||
authorSemanticRole: "builder",
|
||||
content: "- `src/ui/collaboration-hall.ts`:呈现层入口,负责把 hall-chat 的房间、消息、参与者、执行项、任务卡真正渲染成前台页面;这是“你看到的 hall 界面”本体。\n- `src/runtime/collaboration-hall-orchestrator.ts`:编排层入口,负责讨论轮转、speaker 选择、structured handoff、execution lock、角色解析和任务卡推进;这是“讨论怎么变成明确 owner 和下一步”的主逻辑。\n- `src/runtime/hall-runtime-dispatch.ts`:执行派发层入口,负责把 hall 里收口的结果转成真实 runtime 执行,并处理 stream、timeout、poll 等执行语义;这是“hall 结果怎么真正落地”的入口。\n\n`src/ui/server.ts` 要算更外层入口容器:它负责把 UI 页面和 runtime 路由接起来,但不属于 hall-chat 这条主链路的三层本体。\n@main 你只检查这个“三层 + 外层容器”的分法准不准。",
|
||||
projectId: fifth.taskCard.projectId,
|
||||
taskId: fifth.taskCard.taskId,
|
||||
taskCardId: fifth.taskCard.taskCardId,
|
||||
roomId: fifth.roomId,
|
||||
payload: { status: "runtime_execution_update" },
|
||||
});
|
||||
|
||||
await appendHallMessage({
|
||||
hallId: fifth.hall.hallId,
|
||||
kind: "handoff",
|
||||
authorParticipantId: "main",
|
||||
authorLabel: "main",
|
||||
authorSemanticRole: "manager",
|
||||
content: "这版判断没偏,三层主链路和 `src/ui/server.ts` 的外层容器定位都对。@otter 你只卡 must-fix,没硬伤就直接放行。",
|
||||
projectId: fifth.taskCard.projectId,
|
||||
taskId: fifth.taskCard.taskId,
|
||||
taskCardId: fifth.taskCard.taskCardId,
|
||||
roomId: fifth.roomId,
|
||||
payload: { status: "runtime_handoff_update" },
|
||||
});
|
||||
|
||||
await appendHallMessage({
|
||||
hallId: fifth.hall.hallId,
|
||||
kind: "handoff",
|
||||
authorParticipantId: "otter",
|
||||
authorLabel: "otter",
|
||||
authorSemanticRole: "reviewer",
|
||||
content: "`src/ui/collaboration-hall.ts` 对应 hall-chat 界面层,`src/runtime/collaboration-hall-orchestrator.ts` 对应编排流转层,`src/runtime/hall-runtime-dispatch.ts` 对应 agent 派发执行层;这条主链路判断准确,没有 must-fix。<br>`src/ui/server.ts` 作为外层入口容器的补充说明也成立。@main 现在请老板评审。",
|
||||
projectId: fifth.taskCard.projectId,
|
||||
taskId: fifth.taskCard.taskId,
|
||||
taskCardId: fifth.taskCard.taskCardId,
|
||||
roomId: fifth.roomId,
|
||||
payload: { status: "runtime_handoff_update" },
|
||||
});
|
||||
|
||||
await appendHallMessage({
|
||||
hallId: fifth.hall.hallId,
|
||||
kind: "system",
|
||||
authorParticipantId: "system",
|
||||
authorLabel: "System",
|
||||
authorSemanticRole: "generalist",
|
||||
content: "otter 把“只挑 must-fix,别扩 scope。”做到可评审了,现在请老板评审。",
|
||||
projectId: fifth.taskCard.projectId,
|
||||
taskId: fifth.taskCard.taskId,
|
||||
taskCardId: fifth.taskCard.taskCardId,
|
||||
roomId: fifth.roomId,
|
||||
payload: { status: "execution_ready_for_review" },
|
||||
});
|
||||
|
||||
await updateHallTaskCard({
|
||||
taskCardId: fifth.taskCard.taskCardId,
|
||||
stage: "review",
|
||||
status: "in_progress",
|
||||
currentOwnerParticipantId: "otter",
|
||||
currentOwnerLabel: "otter",
|
||||
currentExecutionItem: {
|
||||
itemId: "review-otter",
|
||||
participantId: "otter",
|
||||
task: "只挑 must-fix,别扩 scope。",
|
||||
handoffToParticipantId: "main",
|
||||
handoffWhen: "没硬伤就请老板评审。",
|
||||
},
|
||||
plannedExecutionOrder: ["pandas", "main", "otter"],
|
||||
plannedExecutionItems: [
|
||||
{
|
||||
itemId: "repo-pandas",
|
||||
participantId: "pandas",
|
||||
task: "扫描 control-center 代码,找出 hall-chat 的 3 个关键入口文件,并说明每个文件负责什么。",
|
||||
handoffToParticipantId: "main",
|
||||
handoffWhen: "把 3 个入口和职责贴回大厅后交给 main。",
|
||||
},
|
||||
{
|
||||
itemId: "repo-main",
|
||||
participantId: "main",
|
||||
task: "检查三层 + 外层容器的分法准不准。",
|
||||
handoffToParticipantId: "otter",
|
||||
handoffWhen: "确认定位无误后交给 otter。",
|
||||
},
|
||||
{
|
||||
itemId: "repo-otter",
|
||||
participantId: "otter",
|
||||
task: "只挑 must-fix,别扩 scope。",
|
||||
handoffToParticipantId: "main",
|
||||
handoffWhen: "没有硬伤就请老板评审。",
|
||||
},
|
||||
],
|
||||
latestSummary: "repo-scan 线程必须把 pandas 的代码结果、main 的复核和 otter 的 review 都显示在 UI 里。",
|
||||
});
|
||||
|
||||
await appendHallMessage({
|
||||
hallId: sixth.hall.hallId,
|
||||
kind: "status",
|
||||
authorParticipantId: "pandas",
|
||||
authorLabel: "pandas",
|
||||
authorSemanticRole: "builder",
|
||||
content: "新群聊功能已经收清了:它把讨论、分工、owner 收口、support-only 和 next action 串成一个可见的任务推进线程,能把本来会来回拉扯的事及时收住。<br>@main 你接着按这句写 3 个 hook。",
|
||||
projectId: sixth.taskCard.projectId,
|
||||
taskId: sixth.taskCard.taskId,
|
||||
taskCardId: sixth.taskCard.taskCardId,
|
||||
roomId: sixth.roomId,
|
||||
payload: { status: "runtime_execution_update" },
|
||||
});
|
||||
|
||||
await updateHallTaskCard({
|
||||
taskCardId: sixth.taskCard.taskCardId,
|
||||
stage: "execution",
|
||||
status: "in_progress",
|
||||
currentOwnerParticipantId: "pandas",
|
||||
currentOwnerLabel: "pandas",
|
||||
currentExecutionItem: {
|
||||
itemId: "summary-pandas",
|
||||
participantId: "pandas",
|
||||
task: "把群聊功能收成一句能拍的总结,再交给下一位写 3 个 hook。",
|
||||
handoffToParticipantId: "main",
|
||||
handoffWhen: "总结贴回大厅后交给 main。",
|
||||
},
|
||||
plannedExecutionOrder: ["pandas", "main"],
|
||||
plannedExecutionItems: [
|
||||
{
|
||||
itemId: "summary-pandas",
|
||||
participantId: "pandas",
|
||||
task: "把群聊功能收成一句能拍的总结,再交给下一位写 3 个 hook。",
|
||||
handoffToParticipantId: "main",
|
||||
handoffWhen: "总结贴回大厅后交给 main。",
|
||||
},
|
||||
{
|
||||
itemId: "summary-main",
|
||||
participantId: "main",
|
||||
task: "根据这句总结写 3 个 hook。",
|
||||
handoffWhen: "把 3 个 hook 贴回大厅。",
|
||||
},
|
||||
],
|
||||
latestSummary: "support-only 合法出现在执行结果时,UI 必须保留整句和 handoff。",
|
||||
});
|
||||
|
||||
await updateHallTaskCard({
|
||||
taskCardId: seventh.taskCard.taskCardId,
|
||||
stage: "discussion",
|
||||
status: "todo",
|
||||
currentOwnerParticipantId: "pandas",
|
||||
currentOwnerLabel: "pandas",
|
||||
currentExecutionItem: null,
|
||||
decision: "这条线程专门验证执行顺序编辑器能滚到最下面。",
|
||||
latestSummary: "执行顺序编辑器必须可以滚到最下面,看到保存按钮和全部 agent。",
|
||||
plannedExecutionOrder: ["pandas", "main", "otter", "tiger", "coq", "monkey"],
|
||||
plannedExecutionItems: [
|
||||
{
|
||||
itemId: "planner-pandas",
|
||||
participantId: "pandas",
|
||||
task: "先去扫描 control-center 仓库,列出 3 个 hall-chat 关键入口文件,再把每个文件负责什么总结成一段清楚的话。",
|
||||
handoffToParticipantId: "main",
|
||||
handoffWhen: "把 3 个入口文件和职责都贴回大厅后交给 @main。",
|
||||
},
|
||||
{
|
||||
itemId: "planner-main",
|
||||
participantId: "main",
|
||||
task: "基于代码入口总结给 3 个不同风格的 20 秒开头,每版都突出 owner、next action、任务收口。",
|
||||
handoffToParticipantId: "otter",
|
||||
handoffWhen: "给出 3 版开头草稿后交给 @otter 只挑 must-fix。",
|
||||
},
|
||||
{
|
||||
itemId: "planner-otter",
|
||||
participantId: "otter",
|
||||
task: "只挑会影响普通观众即时理解的硬问题,不要扩 scope,不要重写整版结构。",
|
||||
handoffToParticipantId: "tiger",
|
||||
handoffWhen: "没有硬阻塞就把可继续版本交给 @tiger 补视觉方向。",
|
||||
},
|
||||
{
|
||||
itemId: "planner-tiger",
|
||||
participantId: "tiger",
|
||||
task: "给 3 个 thumbnail 视觉方向,每个方向都要一句可直接生成图片的提示词和一个可访问 URL 占位。",
|
||||
handoffToParticipantId: "coq",
|
||||
handoffWhen: "贴完 3 个视觉方向和 URL 占位后交给 @coq 收口。",
|
||||
},
|
||||
{
|
||||
itemId: "planner-coq",
|
||||
participantId: "coq",
|
||||
task: "把前面的结构、hook、thumbnail 方向收成最终可拍版本,确认叙事顺序不会再打架。",
|
||||
handoffToParticipantId: "monkey",
|
||||
handoffWhen: "确认可拍后交给 @monkey 做最后一轮执行整理。",
|
||||
},
|
||||
{
|
||||
itemId: "planner-monkey",
|
||||
participantId: "monkey",
|
||||
task: "把最终版本整理成这轮可执行结果,并明确下一轮是否还要继续讨论或直接开始执行。",
|
||||
handoffWhen: "整理完最终可执行结果后,这轮可以保存并开始执行。",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await updateHallTaskCard({
|
||||
taskCardId: eighth.taskCard.taskCardId,
|
||||
stage: "review",
|
||||
status: "in_progress",
|
||||
currentOwnerParticipantId: "otter",
|
||||
currentOwnerLabel: "otter",
|
||||
currentExecutionItem: {
|
||||
itemId: "empty-otter",
|
||||
participantId: "otter",
|
||||
task: "确认空的执行顺序编辑器仍然保持紧凑。",
|
||||
handoffWhen: "打开空态 planner 看布局。",
|
||||
},
|
||||
decision: "这条线程专门验证没有已选执行者时的顺序编辑器空态。",
|
||||
latestSummary: "空 planner 态应该紧凑,不应该把空框、agent 芯片和按钮拉满一整页。",
|
||||
plannedExecutionOrder: [],
|
||||
plannedExecutionItems: [],
|
||||
});
|
||||
|
||||
return {
|
||||
firstTask: {
|
||||
hallId: first.hall.hallId,
|
||||
taskCardId: first.taskCard.taskCardId,
|
||||
projectId: first.taskCard.projectId,
|
||||
taskId: first.taskCard.taskId,
|
||||
roomId: first.roomId,
|
||||
title: first.taskCard.title,
|
||||
},
|
||||
secondTask: {
|
||||
hallId: second.hall.hallId,
|
||||
taskCardId: second.taskCard.taskCardId,
|
||||
projectId: second.taskCard.projectId,
|
||||
taskId: second.taskCard.taskId,
|
||||
roomId: second.roomId,
|
||||
title: second.taskCard.title,
|
||||
},
|
||||
thirdTask: {
|
||||
hallId: third.hall.hallId,
|
||||
taskCardId: third.taskCard.taskCardId,
|
||||
projectId: third.taskCard.projectId,
|
||||
taskId: third.taskCard.taskId,
|
||||
roomId: third.roomId,
|
||||
title: third.taskCard.title,
|
||||
},
|
||||
fourthTask: {
|
||||
hallId: fourth.hall.hallId,
|
||||
taskCardId: fourth.taskCard.taskCardId,
|
||||
projectId: fourth.taskCard.projectId,
|
||||
taskId: fourth.taskCard.taskId,
|
||||
roomId: fourth.roomId,
|
||||
title: fourth.taskCard.title,
|
||||
},
|
||||
fifthTask: {
|
||||
hallId: fifth.hall.hallId,
|
||||
taskCardId: fifth.taskCard.taskCardId,
|
||||
projectId: fifth.taskCard.projectId,
|
||||
taskId: fifth.taskCard.taskId,
|
||||
roomId: fifth.roomId,
|
||||
title: fifth.taskCard.title,
|
||||
},
|
||||
sixthTask: {
|
||||
hallId: sixth.hall.hallId,
|
||||
taskCardId: sixth.taskCard.taskCardId,
|
||||
projectId: sixth.taskCard.projectId,
|
||||
taskId: sixth.taskCard.taskId,
|
||||
roomId: sixth.roomId,
|
||||
title: sixth.taskCard.title,
|
||||
},
|
||||
seventhTask: {
|
||||
hallId: seventh.hall.hallId,
|
||||
taskCardId: seventh.taskCard.taskCardId,
|
||||
projectId: seventh.taskCard.projectId,
|
||||
taskId: seventh.taskCard.taskId,
|
||||
roomId: seventh.roomId,
|
||||
title: seventh.taskCard.title,
|
||||
},
|
||||
eighthTask: {
|
||||
hallId: eighth.hall.hallId,
|
||||
taskCardId: eighth.taskCard.taskCardId,
|
||||
projectId: eighth.taskCard.projectId,
|
||||
taskId: eighth.taskCard.taskId,
|
||||
roomId: eighth.roomId,
|
||||
title: eighth.taskCard.title,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function startServer(): ChildProcessWithoutNullStreams {
|
||||
return spawn(process.execPath, ["--import", "tsx", "src/index.ts"], {
|
||||
cwd: ROOT,
|
||||
env: {
|
||||
...process.env,
|
||||
OPENCLAW_RUNTIME_DIR: RUNTIME_DIR,
|
||||
UI_MODE: "true",
|
||||
UI_PORT: PORT,
|
||||
},
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
}
|
||||
|
||||
async function runBrowserSmoke(baseUrl: string, firstTask: SeededTask, secondTask: SeededTask, thirdTask: SeededTask, fourthTask: SeededTask, fifthTask: SeededTask, sixthTask: SeededTask, seventhTask: SeededTask, eighthTask: SeededTask): Promise<void> {
|
||||
const { chromium } = await import("playwright");
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const page = await browser.newPage();
|
||||
const pageErrors: string[] = [];
|
||||
page.on("pageerror", (error) => pageErrors.push(String(error?.message || error)));
|
||||
|
||||
try {
|
||||
await page.goto(`${baseUrl}/?section=hall-chat&taskCardId=${encodeURIComponent(firstTask.taskCardId)}`, {
|
||||
waitUntil: "domcontentloaded",
|
||||
timeout: PAGE_TIMEOUT_MS,
|
||||
});
|
||||
|
||||
await page.waitForSelector("[data-collaboration-hall-root]", { timeout: PAGE_TIMEOUT_MS });
|
||||
await page.waitForSelector("[data-task-card-id]", { timeout: PAGE_TIMEOUT_MS });
|
||||
|
||||
const headline = (await page.locator("[data-hall-headline]").textContent())?.trim();
|
||||
assert(headline === "围绕同一条线程讨论、分工、交接和评审。", `unexpected hall headline: ${headline}`);
|
||||
|
||||
const threadTitle = (await page.locator("[data-hall-thread-title]").textContent())?.trim();
|
||||
assert(threadTitle === firstTask.title, `expected selected thread to be "${firstTask.title}", got "${threadTitle}"`);
|
||||
|
||||
const bodyHtml = await page
|
||||
.locator(".hall-message")
|
||||
.filter({ hasText: "Coq-每日新闻" })
|
||||
.locator(".hall-message-body")
|
||||
.first()
|
||||
.innerHTML();
|
||||
assert(bodyHtml.includes("<br>"), "expected seeded hall message to render <br> tags");
|
||||
assert(bodyHtml.includes("hall-md-mention"), "expected seeded hall message to render mention highlight");
|
||||
|
||||
await page.locator(`[data-task-card-id="${eighthTask.taskCardId}"]`).click();
|
||||
await page.waitForFunction(
|
||||
(expectedTitle) => document.querySelector("[data-hall-thread-title]")?.textContent?.trim() === expectedTitle,
|
||||
eighthTask.title,
|
||||
{ timeout: PAGE_TIMEOUT_MS },
|
||||
);
|
||||
await page.waitForSelector("[data-hall-plan-order]", { timeout: PAGE_TIMEOUT_MS });
|
||||
await page.locator("[data-hall-plan-order]").click();
|
||||
await page.waitForSelector(".hall-decision-card--planner.is-empty", { timeout: PAGE_TIMEOUT_MS });
|
||||
const emptyPlannerMetrics = await page.evaluate(() => {
|
||||
const empty = document.querySelector(".hall-order-empty") as HTMLElement | null;
|
||||
const save = document.querySelector("[data-hall-order-save]") as HTMLElement | null;
|
||||
const cancel = document.querySelector("[data-hall-order-cancel]") as HTMLElement | null;
|
||||
const chips = Array.from(document.querySelectorAll(".hall-order-chip")).slice(0, 3) as HTMLElement[];
|
||||
return {
|
||||
emptyHeight: empty?.getBoundingClientRect().height ?? 0,
|
||||
saveHeight: save?.getBoundingClientRect().height ?? 0,
|
||||
cancelHeight: cancel?.getBoundingClientRect().height ?? 0,
|
||||
chipHeights: chips.map((chip) => chip.getBoundingClientRect().height),
|
||||
};
|
||||
});
|
||||
assert(emptyPlannerMetrics.emptyHeight < 90, `expected empty planner callout to stay compact, got ${JSON.stringify(emptyPlannerMetrics)}`);
|
||||
assert(emptyPlannerMetrics.saveHeight < 48, `expected empty planner save button to stay compact, got ${JSON.stringify(emptyPlannerMetrics)}`);
|
||||
assert(emptyPlannerMetrics.cancelHeight < 48, `expected empty planner cancel button to stay compact, got ${JSON.stringify(emptyPlannerMetrics)}`);
|
||||
assert(
|
||||
emptyPlannerMetrics.chipHeights.every((height) => height < 56),
|
||||
`expected available-agent chips to stay compact in empty planner, got ${JSON.stringify(emptyPlannerMetrics)}`,
|
||||
);
|
||||
await page.locator("[data-hall-order-cancel]").click();
|
||||
await page.waitForSelector(".hall-decision-card--planner", { state: "hidden", timeout: PAGE_TIMEOUT_MS });
|
||||
|
||||
await page.locator(`[data-task-card-id="${secondTask.taskCardId}"]`).click();
|
||||
await page.waitForFunction(
|
||||
(expectedTitle) => document.querySelector("[data-hall-thread-title]")?.textContent?.trim() === expectedTitle,
|
||||
secondTask.title,
|
||||
{ timeout: PAGE_TIMEOUT_MS },
|
||||
);
|
||||
|
||||
await page.locator("[data-hall-compose-task]").click();
|
||||
await page.waitForFunction(
|
||||
() => document.querySelector("[data-hall-send-reply]")?.textContent?.trim() === "创建任务",
|
||||
undefined,
|
||||
{ timeout: PAGE_TIMEOUT_MS },
|
||||
);
|
||||
const flashText = (await page.locator("[data-hall-flash]").textContent())?.trim() || "";
|
||||
assert(flashText.includes("写下新任务后直接按 Enter 创建"), `unexpected composer flash text: ${flashText}`);
|
||||
|
||||
await page.locator(`[data-task-card-id="${thirdTask.taskCardId}"]`).click();
|
||||
await page.waitForFunction(
|
||||
(expectedTitle) => document.querySelector("[data-hall-thread-title]")?.textContent?.trim() === expectedTitle,
|
||||
thirdTask.title,
|
||||
{ timeout: PAGE_TIMEOUT_MS },
|
||||
);
|
||||
await page.waitForFunction(
|
||||
() => {
|
||||
const panel = document.querySelector("[data-hall-decision-panel]");
|
||||
return !!panel && !panel.hidden && !!panel.querySelector("[data-hall-current-console]");
|
||||
},
|
||||
undefined,
|
||||
{ timeout: PAGE_TIMEOUT_MS },
|
||||
);
|
||||
const thirdTaskConsolePlacement = await page.evaluate(() => {
|
||||
const thread = document.querySelector("[data-hall-thread]");
|
||||
const panel = document.querySelector("[data-hall-decision-panel]");
|
||||
return {
|
||||
threadHasConsole: !!thread?.querySelector("[data-hall-current-console]"),
|
||||
panelHasConsole: !!panel?.querySelector("[data-hall-current-console]"),
|
||||
panelHidden: !!(panel && panel.hidden),
|
||||
};
|
||||
});
|
||||
assert(!thirdTaskConsolePlacement.threadHasConsole, "expected the current console to stay out of the message timeline");
|
||||
assert(thirdTaskConsolePlacement.panelHasConsole, "expected the current console to render in the bottom decision panel");
|
||||
assert(!thirdTaskConsolePlacement.panelHidden, "expected the bottom decision panel to stay visible for a selected task");
|
||||
const restartLabel = (await page.locator("[data-hall-start-execution]").first().textContent())?.trim() || "";
|
||||
assert(restartLabel.includes("开始执行("), `expected a restart execution button on queued review thread, got "${restartLabel}"`);
|
||||
|
||||
await page.locator(`[data-task-card-id="${fourthTask.taskCardId}"]`).click();
|
||||
await page.waitForFunction(
|
||||
(expectedTitle) => document.querySelector("[data-hall-thread-title]")?.textContent?.trim() === expectedTitle,
|
||||
fourthTask.title,
|
||||
{ timeout: PAGE_TIMEOUT_MS },
|
||||
);
|
||||
await page.locator("[data-hall-continue-discussion]").first().click();
|
||||
await page.waitForTimeout(1200);
|
||||
await page.locator("[data-hall-plan-order]").click();
|
||||
await page.waitForSelector("[data-hall-order-save]", { timeout: PAGE_TIMEOUT_MS });
|
||||
const taskEditor = page.locator("[data-hall-item-task='otter']").first();
|
||||
await taskEditor.focus();
|
||||
await taskEditor.fill("按新的方向开第三轮第一棒,并把结果贴回大厅。");
|
||||
await page.waitForTimeout(4500);
|
||||
const taskEditorState = await taskEditor.evaluate((node) => ({
|
||||
value: (node instanceof HTMLTextAreaElement ? node.value : ""),
|
||||
focused: document.activeElement === node,
|
||||
}));
|
||||
assert(taskEditorState.focused, "expected task editor to keep focus while background polling continues");
|
||||
assert(
|
||||
taskEditorState.value.includes("按新的方向开第三轮第一棒"),
|
||||
`expected task editor value to survive polling, got "${taskEditorState.value}"`,
|
||||
);
|
||||
await page.locator("[data-hall-order-add='pandas']").click();
|
||||
await page.locator("[data-hall-order-save]").click();
|
||||
await page.waitForTimeout(2600);
|
||||
const postSaveStartLabel = (await page.locator("[data-hall-start-execution]").first().textContent())?.trim() || "";
|
||||
assert(postSaveStartLabel.includes("开始执行("), `expected start execution button after saving a replanned round, got "${postSaveStartLabel}"`);
|
||||
const selectedCardTextAfterSave = (await page.locator(`[data-task-card-id="${fourthTask.taskCardId}"]`).innerText())?.trim() || "";
|
||||
assert(
|
||||
!selectedCardTextAfterSave.includes("main · 执行中"),
|
||||
`expected saved replanned round to stop showing stale executing state in the selected task card, got "${selectedCardTextAfterSave}"`,
|
||||
);
|
||||
|
||||
await page.locator(`[data-task-card-id="${fifthTask.taskCardId}"]`).click();
|
||||
await page.waitForFunction(
|
||||
(expectedTitle) => document.querySelector("[data-hall-thread-title]")?.textContent?.trim() === expectedTitle,
|
||||
fifthTask.title,
|
||||
{ timeout: PAGE_TIMEOUT_MS },
|
||||
);
|
||||
const repoThreadText = (await page.locator("[data-hall-thread]").innerText())?.trim() || "";
|
||||
assert(repoThreadText.includes("src/ui/collaboration-hall.ts"), "expected repo-scan result to keep pandas file-path output visible");
|
||||
assert(repoThreadText.includes("src/runtime/collaboration-hall-orchestrator.ts"), "expected repo-scan result to show orchestrator file path");
|
||||
assert(repoThreadText.includes("src/runtime/hall-runtime-dispatch.ts"), "expected repo-scan result to show dispatch file path");
|
||||
assert(!repoThreadText.includes("Handoff moved to pandas"), "expected repo-scan thread to avoid wrong handoff warning");
|
||||
const pandasVisibleMessage = page
|
||||
.locator(".hall-message[data-kind='status']")
|
||||
.filter({ hasText: "src/ui/collaboration-hall.ts" })
|
||||
.locator(".hall-message-body")
|
||||
.first();
|
||||
const pandasVisibleText = (await pandasVisibleMessage.innerText())?.trim() || "";
|
||||
assert(pandasVisibleText.includes("src/ui/collaboration-hall.ts"), "expected pandas repo-scan reply to keep the UI file path visible");
|
||||
assert(
|
||||
pandasVisibleText.includes("src/runtime/collaboration-hall-orchestrator.ts"),
|
||||
"expected pandas repo-scan reply to keep the orchestrator file path visible",
|
||||
);
|
||||
assert(
|
||||
pandasVisibleText.includes("src/runtime/hall-runtime-dispatch.ts"),
|
||||
"expected pandas repo-scan reply to keep the dispatch file path visible",
|
||||
);
|
||||
assert(!pandasVisibleText.includes("…"), "expected pandas repo-scan reply to stay visible instead of collapsing to an ellipsis");
|
||||
|
||||
await page.locator(`[data-task-card-id="${sixthTask.taskCardId}"]`).click();
|
||||
await page.waitForFunction(
|
||||
(expectedTitle) => document.querySelector("[data-hall-thread-title]")?.textContent?.trim() === expectedTitle,
|
||||
sixthTask.title,
|
||||
{ timeout: PAGE_TIMEOUT_MS },
|
||||
);
|
||||
const supportOnlyThreadText = (await page.locator("[data-hall-thread]").innerText())?.trim() || "";
|
||||
assert(
|
||||
supportOnlyThreadText.includes("support-only 和 next action 串成一个可见的任务推进线程"),
|
||||
"expected support-only summary line to remain visible in the thread",
|
||||
);
|
||||
assert(
|
||||
supportOnlyThreadText.includes("@main 你接着按这句写 3 个 hook。"),
|
||||
"expected support-only execution result to keep the @main handoff line visible",
|
||||
);
|
||||
|
||||
await page.locator(`[data-task-card-id="${seventhTask.taskCardId}"]`).click();
|
||||
await page.waitForFunction(
|
||||
(expectedTitle) => document.querySelector("[data-hall-thread-title]")?.textContent?.trim() === expectedTitle,
|
||||
seventhTask.title,
|
||||
{ timeout: PAGE_TIMEOUT_MS },
|
||||
);
|
||||
await page.locator("[data-hall-plan-order]").click();
|
||||
await page.waitForSelector(".hall-decision-card--planner", { timeout: PAGE_TIMEOUT_MS });
|
||||
const plannerLayoutState = await page.evaluate(() => {
|
||||
const composer = document.querySelector(".hall-composer-shell") as HTMLElement | null;
|
||||
const thread = document.querySelector(".hall-thread") as HTMLElement | null;
|
||||
const decisionPanel = document.querySelector("[data-hall-decision-panel]") as HTMLElement | null;
|
||||
return {
|
||||
composerDisplay: composer ? getComputedStyle(composer).display : null,
|
||||
threadDisplay: thread ? getComputedStyle(thread).display : null,
|
||||
decisionOverflow: decisionPanel ? getComputedStyle(decisionPanel).overflowY : null,
|
||||
};
|
||||
});
|
||||
assert(plannerLayoutState.composerDisplay === "none", `expected composer to be hidden while planning, got ${JSON.stringify(plannerLayoutState)}`);
|
||||
assert(plannerLayoutState.threadDisplay === "none", `expected thread timeline to be hidden while planning, got ${JSON.stringify(plannerLayoutState)}`);
|
||||
const plannerMetricsBefore = await page.evaluate(() => {
|
||||
const planner = document.querySelector(".hall-decision-card--planner") as HTMLElement | null;
|
||||
return {
|
||||
scrollTop: planner?.scrollTop ?? 0,
|
||||
clientHeight: planner?.clientHeight ?? 0,
|
||||
scrollHeight: planner?.scrollHeight ?? 0,
|
||||
};
|
||||
});
|
||||
assert(
|
||||
plannerMetricsBefore.scrollHeight > plannerMetricsBefore.clientHeight,
|
||||
`expected long execution planner to overflow vertically, got ${JSON.stringify(plannerMetricsBefore)}`,
|
||||
);
|
||||
await page.locator(".hall-decision-card--planner").hover();
|
||||
await page.mouse.wheel(0, 1800);
|
||||
await page.waitForTimeout(500);
|
||||
const plannerMetricsAfter = await page.evaluate(() => {
|
||||
const planner = document.querySelector(".hall-decision-card--planner") as HTMLElement | null;
|
||||
return {
|
||||
scrollTop: planner?.scrollTop ?? 0,
|
||||
clientHeight: planner?.clientHeight ?? 0,
|
||||
scrollHeight: planner?.scrollHeight ?? 0,
|
||||
};
|
||||
});
|
||||
assert(
|
||||
plannerMetricsAfter.scrollTop > plannerMetricsBefore.scrollTop,
|
||||
`expected planner card to scroll after wheel, got before=${JSON.stringify(plannerMetricsBefore)} after=${JSON.stringify(plannerMetricsAfter)}`,
|
||||
);
|
||||
|
||||
assert(pageErrors.length === 0, `pageerror(s): ${pageErrors.join(" | ")}`);
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const baseUrl = `http://127.0.0.1:${PORT}`;
|
||||
const seeded = await seedRuntime();
|
||||
const child = startServer();
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
child.stdout.on("data", (chunk) => {
|
||||
stdout += String(chunk);
|
||||
});
|
||||
child.stderr.on("data", (chunk) => {
|
||||
stderr += String(chunk);
|
||||
});
|
||||
|
||||
const cleanup = () => {
|
||||
try {
|
||||
child.kill("SIGTERM");
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
rmSync(RUNTIME_DIR, { recursive: true, force: true });
|
||||
};
|
||||
|
||||
process.on("SIGINT", cleanup);
|
||||
process.on("SIGTERM", cleanup);
|
||||
|
||||
try {
|
||||
await waitForServer(baseUrl);
|
||||
await runBrowserSmoke(baseUrl, seeded.firstTask, seeded.secondTask, seeded.thirdTask, seeded.fourthTask, seeded.fifthTask, seeded.sixthTask, seeded.seventhTask, seeded.eighthTask);
|
||||
console.log(`Hall release smoke passed on ${baseUrl}`);
|
||||
} catch (error) {
|
||||
console.error("Hall release smoke failed.");
|
||||
console.error(stdout.trim());
|
||||
console.error(stderr.trim());
|
||||
throw error;
|
||||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,118 @@
|
||||
const { chromium } = require("playwright");
|
||||
|
||||
async function main() {
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const page = await browser.newPage({ viewport: { width: 1600, height: 1200 } });
|
||||
const errors = [];
|
||||
|
||||
page.on("pageerror", (error) => errors.push(`pageerror:${error.message}`));
|
||||
page.on("console", (message) => {
|
||||
if (message.type() === "error") {
|
||||
errors.push(`console:${message.text()}`);
|
||||
}
|
||||
});
|
||||
|
||||
await page.goto("http://127.0.0.1:4310/?section=hall-chat", {
|
||||
waitUntil: "domcontentloaded",
|
||||
timeout: 30000,
|
||||
});
|
||||
const rawHtml = await page.content();
|
||||
const hasHallRoot = rawHtml.includes("data-collaboration-hall-root");
|
||||
if (!hasHallRoot) {
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
currentUrl: page.url(),
|
||||
pageTitle: await page.title(),
|
||||
hasHallRoot,
|
||||
htmlSample: rawHtml.slice(0, 1200),
|
||||
errors,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
await browser.close();
|
||||
return;
|
||||
}
|
||||
|
||||
await page.waitForSelector("[data-collaboration-hall-root]", { timeout: 30000, state: "attached" });
|
||||
await page.waitForTimeout(1500);
|
||||
|
||||
const initialHeadline = await page.locator("[data-hall-headline]").textContent().catch(() => null);
|
||||
const initialTitle = await page.locator("[data-hall-thread-title]").textContent().catch(() => null);
|
||||
const cards = page.locator("[data-task-card-id]");
|
||||
const cardCount = await cards.count();
|
||||
const selectedCardIdBefore = await page
|
||||
.locator("[data-task-card-id][aria-current='page']")
|
||||
.first()
|
||||
.getAttribute("data-task-card-id")
|
||||
.catch(() => null);
|
||||
const initialBodyNodes = await page
|
||||
.locator(".hall-message-body")
|
||||
.evaluateAll((nodes) => nodes.slice(0, 3).map((node) => node.innerHTML));
|
||||
|
||||
let switchedTitle = null;
|
||||
let selectedCardIdAfter = null;
|
||||
if (cardCount > 1) {
|
||||
await cards.nth(1).click({ timeout: 10000 });
|
||||
await page.waitForTimeout(800);
|
||||
switchedTitle = await page.locator("[data-hall-thread-title]").textContent().catch(() => null);
|
||||
selectedCardIdAfter = await page
|
||||
.locator("[data-task-card-id][aria-current='page']")
|
||||
.first()
|
||||
.getAttribute("data-task-card-id")
|
||||
.catch(() => null);
|
||||
}
|
||||
|
||||
await page.locator("[data-hall-compose-task]").click({ timeout: 10000 });
|
||||
await page.waitForTimeout(600);
|
||||
|
||||
const composerState = await page.evaluate(() => ({
|
||||
mounted: !!document.querySelector("[data-collaboration-hall-root]"),
|
||||
taskMode: document
|
||||
.querySelector("[data-collaboration-hall-root]")
|
||||
?.classList.contains("is-composing-task"),
|
||||
placeholder:
|
||||
document.querySelector("[data-hall-composer-textarea]")?.getAttribute("placeholder") || null,
|
||||
headline: document.querySelector("[data-hall-headline]")?.textContent || null,
|
||||
}));
|
||||
|
||||
const bodyNodesAfterSwitch = await page
|
||||
.locator(".hall-message-body")
|
||||
.evaluateAll((nodes) => nodes.slice(0, 3).map((node) => node.innerHTML));
|
||||
|
||||
await page.screenshot({
|
||||
path: "/tmp/hall4310_browser_check.png",
|
||||
fullPage: true,
|
||||
});
|
||||
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
initialHeadline,
|
||||
initialTitle,
|
||||
currentUrl: page.url(),
|
||||
pageTitle: await page.title(),
|
||||
hasHallRoot,
|
||||
cardCount,
|
||||
selectedCardIdBefore,
|
||||
switchedTitle,
|
||||
selectedCardIdAfter,
|
||||
initialBodyNodes,
|
||||
composerState,
|
||||
bodyNodesAfterSwitch,
|
||||
errors,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
|
||||
await browser.close();
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,152 @@
|
||||
const { chromium } = require("playwright");
|
||||
|
||||
async function wait(ms) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
async function waitForPlanButton(page, timeoutMs = 120000) {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
const count = await page.locator("[data-hall-plan-order]").count().catch(() => 0);
|
||||
if (count > 0) return true;
|
||||
await wait(2000);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async function askForPlanButton(page) {
|
||||
await page.locator("[data-hall-composer-textarea]").fill("请收口并安排执行顺序,然后开始执行。");
|
||||
await page.locator("[data-hall-send-reply]").click();
|
||||
}
|
||||
|
||||
async function ensureOrderParticipant(page, participantId) {
|
||||
const chip = page.locator(`[data-hall-order-add="${participantId}"]`).first();
|
||||
if (await chip.count()) {
|
||||
await chip.click();
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const page = await browser.newPage({ viewport: { width: 1600, height: 1200 } });
|
||||
const errors = [];
|
||||
|
||||
page.on("pageerror", (error) => errors.push(`pageerror:${error.message}`));
|
||||
page.on("console", (message) => {
|
||||
if (message.type() === "error") {
|
||||
errors.push(`console:${message.text()}`);
|
||||
}
|
||||
});
|
||||
|
||||
await page.goto("http://127.0.0.1:4310/?section=hall-chat", {
|
||||
waitUntil: "domcontentloaded",
|
||||
timeout: 30000,
|
||||
});
|
||||
await page.waitForSelector("[data-collaboration-hall-root]", { timeout: 30000 });
|
||||
|
||||
await page.locator("[data-hall-compose-task]").click();
|
||||
await page
|
||||
.locator("[data-hall-composer-textarea]")
|
||||
.fill("请先扫描 control-center 代码,找出 hall-chat 的 3 个关键入口文件,并说明每个文件负责什么。");
|
||||
await page.locator("[data-hall-send-reply]").click();
|
||||
|
||||
let hasPlanButton = await waitForPlanButton(page, 45000);
|
||||
if (!hasPlanButton) {
|
||||
await askForPlanButton(page);
|
||||
hasPlanButton = await waitForPlanButton(page, 45000);
|
||||
}
|
||||
if (!hasPlanButton) {
|
||||
throw new Error("plan-order button did not appear");
|
||||
}
|
||||
|
||||
await page.locator("[data-hall-plan-order]").first().click();
|
||||
await page.waitForSelector("[data-hall-order-save]", { timeout: 20000 });
|
||||
|
||||
await ensureOrderParticipant(page, "pandas");
|
||||
await ensureOrderParticipant(page, "main");
|
||||
await ensureOrderParticipant(page, "otter");
|
||||
|
||||
await page
|
||||
.locator('[data-hall-item-task="pandas"]')
|
||||
.fill("扫描 control-center 代码,至少贴出 3 个真实文件路径,并说明每个文件负责什么。");
|
||||
await page.locator('[data-hall-item-handoff-to="pandas"]').selectOption("main");
|
||||
await page
|
||||
.locator('[data-hall-item-handoff="pandas"]')
|
||||
.fill("贴完 3 个文件路径和职责后交给 @main 评审是否够准确。");
|
||||
|
||||
await page
|
||||
.locator('[data-hall-item-task="main"]')
|
||||
.fill("只检查 pandas 列的文件是否真的关键、解释是否准确;不重做扫描。");
|
||||
await page.locator('[data-hall-item-handoff-to="main"]').selectOption("otter");
|
||||
await page
|
||||
.locator('[data-hall-item-handoff="main"]')
|
||||
.fill("确认关键文件没问题后交给 @otter 只挑 must-fix。");
|
||||
|
||||
await page.locator('[data-hall-item-task="otter"]').fill("只挑 must-fix,别扩 scope。");
|
||||
await page.locator('[data-hall-item-handoff-to="otter"]').selectOption("");
|
||||
await page.locator('[data-hall-item-handoff="otter"]').fill("没有 must-fix 就请老板评审。");
|
||||
|
||||
await page.locator("[data-hall-order-save]").click();
|
||||
await page.waitForTimeout(2500);
|
||||
|
||||
const startButton = page.locator("[data-hall-start-execution]").first();
|
||||
if (await startButton.count()) {
|
||||
await startButton.click();
|
||||
} else {
|
||||
throw new Error("start execution button did not appear after saving order");
|
||||
}
|
||||
|
||||
const timeline = [];
|
||||
for (let attempt = 1; attempt <= 18; attempt += 1) {
|
||||
await wait(5000);
|
||||
const authors = await page.locator(".hall-message .hall-message-author strong").allTextContents().catch(() => []);
|
||||
const bodies = await page
|
||||
.locator(".hall-message .hall-message-body")
|
||||
.evaluateAll((nodes) => nodes.map((node) => node.textContent || ""))
|
||||
.catch(() => []);
|
||||
const stage = await page.locator("[data-hall-thread-meta]").textContent().catch(() => null);
|
||||
timeline.push({
|
||||
t: attempt * 5,
|
||||
stage,
|
||||
authors,
|
||||
lastBodies: bodies.slice(-6),
|
||||
});
|
||||
}
|
||||
|
||||
const finalBodies = timeline.at(-1)?.lastBodies || [];
|
||||
const hasVisibleFilePath = finalBodies.some((body) => /src\/ui\/collaboration-hall\.ts|src\/runtime\/collaboration-hall-orchestrator\.ts|src\/runtime\/hall-runtime-dispatch\.ts/.test(body));
|
||||
const hasWrongHandoffWarning = timeline.some((entry) =>
|
||||
entry.lastBodies.some((body) => body.includes("Handoff moved to")),
|
||||
);
|
||||
const visibleMessages = await page
|
||||
.locator(".hall-message .hall-message-body")
|
||||
.evaluateAll((nodes) => nodes.map((node) => ({
|
||||
text: node.textContent || "",
|
||||
lineClamp: getComputedStyle(node).webkitLineClamp,
|
||||
overflow: getComputedStyle(node).overflow,
|
||||
whiteSpace: getComputedStyle(node).whiteSpace,
|
||||
display: getComputedStyle(node).display,
|
||||
})))
|
||||
.catch(() => []);
|
||||
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
errors,
|
||||
timeline,
|
||||
hasVisibleFilePath,
|
||||
hasWrongHandoffWarning,
|
||||
visibleMessages: visibleMessages.slice(-4),
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
|
||||
await browser.close();
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,281 @@
|
||||
const { chromium } = require("playwright");
|
||||
|
||||
async function wait(ms) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const page = await browser.newPage({ viewport: { width: 1600, height: 1200 } });
|
||||
const errors = [];
|
||||
const scanPrompt = `full-check-${Date.now()} 请先扫描 control-center 代码,找出 hall-chat 的 3 个关键入口文件,并说明每个文件负责什么。`;
|
||||
const scanPrefix = scanPrompt.split(" ")[0];
|
||||
|
||||
page.on("pageerror", (error) => errors.push(`pageerror:${error.message}`));
|
||||
page.on("console", (message) => {
|
||||
if (message.type() === "error") errors.push(`console:${message.text()}`);
|
||||
});
|
||||
|
||||
await page.goto("http://127.0.0.1:4310/?section=hall-chat", {
|
||||
waitUntil: "domcontentloaded",
|
||||
timeout: 30000,
|
||||
});
|
||||
await page.waitForSelector("[data-collaboration-hall-root]", { timeout: 30000 });
|
||||
|
||||
// 1) Start a fresh task and verify discussion + typing lifecycle.
|
||||
await page.locator("[data-hall-compose-task]").click();
|
||||
await page
|
||||
.locator("[data-hall-composer-textarea]")
|
||||
.fill(scanPrompt);
|
||||
await page.locator("[data-hall-send-reply]").click();
|
||||
|
||||
let selectedFreshThread = false;
|
||||
for (let attempt = 0; attempt < 20; attempt += 1) {
|
||||
const headerTitle = await page
|
||||
.locator("[data-hall-thread-title]")
|
||||
.textContent()
|
||||
.catch(() => "");
|
||||
if (String(headerTitle || "").includes(scanPrefix)) {
|
||||
selectedFreshThread = true;
|
||||
break;
|
||||
}
|
||||
const createdThreadCard = page
|
||||
.locator("[data-task-card-id]")
|
||||
.filter({ hasText: scanPrefix })
|
||||
.first();
|
||||
if (await createdThreadCard.count().catch(() => 0)) {
|
||||
await createdThreadCard.click();
|
||||
await wait(500);
|
||||
const nextHeaderTitle = await page
|
||||
.locator("[data-hall-thread-title]")
|
||||
.textContent()
|
||||
.catch(() => "");
|
||||
if (String(nextHeaderTitle || "").includes(scanPrefix)) {
|
||||
selectedFreshThread = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
await wait(1500);
|
||||
}
|
||||
if (!selectedFreshThread) throw new Error("newly created thread never became selected");
|
||||
|
||||
let typingSeen = false;
|
||||
let planButtonSeen = false;
|
||||
for (let attempt = 0; attempt < 40; attempt += 1) {
|
||||
const typingTexts = await page.locator("[data-hall-typing-strip]").allTextContents().catch(() => []);
|
||||
if (typingTexts.some((text) => String(text || "").trim().length > 0)) typingSeen = true;
|
||||
if (await page.locator("[data-hall-plan-order]").count().catch(() => 0)) {
|
||||
planButtonSeen = true;
|
||||
break;
|
||||
}
|
||||
await wait(3000);
|
||||
}
|
||||
if (!typingSeen) throw new Error("typing strip never appeared after creating a task");
|
||||
if (!planButtonSeen) throw new Error("plan-order button never appeared");
|
||||
|
||||
// 2) Planner opens as a dedicated editing surface and can scroll.
|
||||
await page.locator("[data-hall-plan-order]").first().click();
|
||||
await page.waitForSelector("[data-hall-order-save]", { timeout: 15000 });
|
||||
|
||||
const plannerState = await page.evaluate(() => {
|
||||
const planner = document.querySelector(".hall-order-planner");
|
||||
const composer = document.querySelector(".hall-composer-shell");
|
||||
const thread = document.querySelector(".hall-thread");
|
||||
const saveButton = document.querySelector("[data-hall-order-save]");
|
||||
const emptyState = document.querySelector(".hall-order-empty");
|
||||
return {
|
||||
plannerOverflow: planner ? getComputedStyle(planner).overflowY : null,
|
||||
plannerHeight: planner ? planner.getBoundingClientRect().height : null,
|
||||
composerDisplay: composer ? getComputedStyle(composer).display : null,
|
||||
threadDisplay: thread ? getComputedStyle(thread).display : null,
|
||||
saveHeight: saveButton ? saveButton.getBoundingClientRect().height : null,
|
||||
emptyHeight: emptyState ? emptyState.getBoundingClientRect().height : null,
|
||||
};
|
||||
});
|
||||
|
||||
if (plannerState.composerDisplay !== "none") throw new Error("composer should be hidden while planning");
|
||||
if (plannerState.threadDisplay !== "none") throw new Error("thread should be hidden while planning");
|
||||
if ((plannerState.saveHeight ?? 0) > 56) throw new Error("planner buttons are stretched too tall in empty state");
|
||||
if ((plannerState.emptyHeight ?? 0) > 120) throw new Error("empty planner state is still stretched too tall");
|
||||
|
||||
// 2b) Long planner should scroll.
|
||||
for (const participantId of ["coq", "main", "monkey", "otter", "pandas", "tiger"]) {
|
||||
const chip = page.locator(`[data-hall-order-add="${participantId}"]`).first();
|
||||
if (await chip.count()) await chip.click();
|
||||
}
|
||||
const plannerScrolled = await page.evaluate(async () => {
|
||||
const card = document.querySelector('.hall-decision-card--planner');
|
||||
if (!card) return false;
|
||||
const before = card.scrollTop;
|
||||
card.scrollTop = before + 240;
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
return card.scrollTop > before;
|
||||
});
|
||||
if (!plannerScrolled) throw new Error("long planner did not scroll");
|
||||
await page.locator("[data-hall-order-cancel]").click();
|
||||
await wait(500);
|
||||
await page.locator("[data-hall-plan-order]").first().click();
|
||||
await page.waitForSelector("[data-hall-order-save]", { timeout: 15000 });
|
||||
|
||||
// 3) Configure execution order.
|
||||
for (const participantId of ["pandas", "main", "otter"]) {
|
||||
const chip = page.locator(`[data-hall-order-add="${participantId}"]`).first();
|
||||
if (await chip.count()) await chip.click();
|
||||
}
|
||||
|
||||
await page
|
||||
.locator('[data-hall-item-task="pandas"]')
|
||||
.fill("扫描 control-center 代码,贴出 3 个真实文件路径,并说明每个文件负责什么。");
|
||||
await page.locator('[data-hall-item-handoff-to="pandas"]').selectOption("main");
|
||||
await page
|
||||
.locator('[data-hall-item-handoff="pandas"]')
|
||||
.fill("贴完 3 个文件路径和职责后交给 @main 复核。");
|
||||
|
||||
await page
|
||||
.locator('[data-hall-item-task="main"]')
|
||||
.fill("只复核 pandas 列出的文件是不是关键入口,不重做扫描。");
|
||||
await page.locator('[data-hall-item-handoff-to="main"]').selectOption("otter");
|
||||
await page
|
||||
.locator('[data-hall-item-handoff="main"]')
|
||||
.fill("确认关键入口没问题后交给 @otter 只挑 must-fix。");
|
||||
|
||||
await page.locator('[data-hall-item-task="otter"]').fill("只挑 must-fix,没有问题就请老板评审。");
|
||||
await page.locator('[data-hall-item-handoff-to="otter"]').selectOption("");
|
||||
await page.locator('[data-hall-item-handoff="otter"]').fill("没有 must-fix 就请老板评审。");
|
||||
|
||||
await page.locator("[data-hall-order-save]").click();
|
||||
await wait(2500);
|
||||
|
||||
const startButton = page.locator("[data-hall-start-execution]").first();
|
||||
if (!(await startButton.count())) throw new Error("start execution button missing after saving order");
|
||||
|
||||
// 4) Execution chain should produce visible repo-scan output and no wrong handoff warning.
|
||||
await startButton.click();
|
||||
|
||||
let foundPaths = false;
|
||||
let wrongHandoffWarning = false;
|
||||
let executionSettled = false;
|
||||
let visibleMessages = [];
|
||||
for (let attempt = 0; attempt < 40; attempt += 1) {
|
||||
await wait(5000);
|
||||
visibleMessages = await page.locator(".hall-message .hall-message-body").evaluateAll((nodes) =>
|
||||
nodes.map((node) => ({
|
||||
text: node.textContent || "",
|
||||
clamp: getComputedStyle(node).webkitLineClamp,
|
||||
overflow: getComputedStyle(node).overflow,
|
||||
whiteSpace: getComputedStyle(node).whiteSpace,
|
||||
})),
|
||||
);
|
||||
const texts = visibleMessages.map((message) => message.text);
|
||||
foundPaths = texts.some((text) =>
|
||||
/src\/ui\/collaboration-hall\.ts|src\/runtime\/collaboration-hall-orchestrator\.ts|src\/runtime\/hall-runtime-dispatch\.ts/.test(text),
|
||||
);
|
||||
wrongHandoffWarning = texts.some((text) => text.includes("Handoff moved to"));
|
||||
const consoleText = await page
|
||||
.locator("[data-hall-decision-panel]")
|
||||
.allTextContents()
|
||||
.then((items) => items.join(" "))
|
||||
.catch(() => "");
|
||||
executionSettled = foundPaths && !/阶段:\s*(执行中|卡住)|\bstage:\s*(execution|blocked)\b/i.test(consoleText);
|
||||
if (executionSettled) break;
|
||||
}
|
||||
|
||||
if (!foundPaths) throw new Error("repo scan deliverable never became visible");
|
||||
if (wrongHandoffWarning) throw new Error("wrong handoff warning became visible");
|
||||
if (!executionSettled) {
|
||||
const debug = await page.evaluate(() => ({
|
||||
panelText: document.querySelector("[data-hall-decision-panel]")?.textContent || "",
|
||||
bodyText: document.body.innerText.slice(0, 3000),
|
||||
}));
|
||||
console.error(JSON.stringify({ executionNeverSettled: debug }, null, 2));
|
||||
throw new Error("execution chain never settled before the follow-up");
|
||||
}
|
||||
|
||||
const unclampedMessages = visibleMessages.every((message) => {
|
||||
const clamp = String(message.clamp || "").trim();
|
||||
return clamp === "" || clamp === "none";
|
||||
});
|
||||
if (!unclampedMessages) throw new Error("visible execution messages are still clamped");
|
||||
const textDump = visibleMessages.map((message) => message.text).join("\n");
|
||||
for (const forbidden of ["[tool]", "thinking", "hall-structured", "LOCAL_API_TOKEN", "language is not defined"]) {
|
||||
if (textDump.includes(forbidden)) throw new Error(`forbidden leak visible: ${forbidden}`);
|
||||
}
|
||||
|
||||
// 5) Follow-up after execution should reopen discussion and get another reply.
|
||||
await page.locator("[data-hall-composer-textarea]").fill("继续讨论吧,下一版怎么展开得更清楚?");
|
||||
await page.locator("[data-hall-send-reply]").click();
|
||||
|
||||
let repliedAfterExecution = false;
|
||||
for (let attempt = 0; attempt < 12; attempt += 1) {
|
||||
await wait(3000);
|
||||
const authors = await page.locator(".hall-message .hall-message-author strong").allTextContents().catch(() => []);
|
||||
const nonOperatorCount = authors.filter((name) => name !== "Operator").length;
|
||||
if (nonOperatorCount >= 4) {
|
||||
repliedAfterExecution = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!repliedAfterExecution) throw new Error("follow-up after execution got no reply");
|
||||
|
||||
// 6) A new round can be planned and started again after the follow-up.
|
||||
await page.locator("[data-hall-plan-order]").first().click();
|
||||
await page.waitForSelector("[data-hall-order-save]", { timeout: 15000 });
|
||||
await page.locator("[data-hall-order-cancel]").click().catch(() => {});
|
||||
await wait(400);
|
||||
await page.locator("[data-hall-plan-order]").first().click();
|
||||
await page.waitForSelector("[data-hall-order-save]", { timeout: 15000 });
|
||||
for (const participantId of ["main", "otter"]) {
|
||||
const chip = page.locator(`[data-hall-order-add="${participantId}"]`).first();
|
||||
if (await chip.count()) await chip.click();
|
||||
}
|
||||
await page.locator('[data-hall-item-task="main"]').fill("把这一版继续讨论收成第二轮可执行开头。");
|
||||
await page.locator('[data-hall-item-handoff-to="main"]').selectOption("otter");
|
||||
await page.locator('[data-hall-item-handoff="main"]').fill("收住后交给 @otter。");
|
||||
await page.locator('[data-hall-item-task="otter"]').fill("只挑 must-fix。");
|
||||
await page.locator('[data-hall-item-handoff-to="otter"]').selectOption("");
|
||||
await page.locator('[data-hall-item-handoff="otter"]').fill("没有 must-fix 就请老板评审。");
|
||||
await page.locator("[data-hall-order-save]").click();
|
||||
await wait(2000);
|
||||
const secondStartExists = await page.locator("[data-hall-start-execution]").count().catch(() => 0);
|
||||
if (!secondStartExists) {
|
||||
const debug = await page.evaluate(() => {
|
||||
const panel = document.querySelector("[data-hall-decision-panel]");
|
||||
const consoleNode = panel?.querySelector("[data-hall-current-console]");
|
||||
const selectedCard = document.querySelector('[data-task-card-id][aria-current="page"], [data-task-card-id][aria-current="true"]');
|
||||
return {
|
||||
panelText: panel?.textContent || "",
|
||||
consoleText: consoleNode?.textContent || "",
|
||||
selectedCardText: selectedCard?.textContent || "",
|
||||
bodyText: document.body.innerText.slice(0, 6000),
|
||||
};
|
||||
});
|
||||
console.error(JSON.stringify({ secondRoundDebug: debug }, null, 2));
|
||||
throw new Error("second-round start execution button missing after replanning");
|
||||
}
|
||||
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
errors,
|
||||
typingSeen,
|
||||
planButtonSeen,
|
||||
plannerState,
|
||||
plannerScrolled,
|
||||
foundPaths,
|
||||
wrongHandoffWarning,
|
||||
secondStartExists,
|
||||
lastMessages: visibleMessages.slice(-4),
|
||||
finalAuthors: await page.locator(".hall-message .hall-message-author strong").allTextContents().catch(() => []),
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
|
||||
await browser.close();
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,104 @@
|
||||
const { chromium } = require("playwright");
|
||||
|
||||
async function wait(ms) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const page = await browser.newPage({ viewport: { width: 1600, height: 1200 } });
|
||||
const errors = [];
|
||||
|
||||
page.on("pageerror", (error) => errors.push(`pageerror:${error.message}`));
|
||||
page.on("console", (message) => {
|
||||
if (message.type() === "error") {
|
||||
errors.push(`console:${message.text()}`);
|
||||
}
|
||||
});
|
||||
|
||||
await page.goto("http://127.0.0.1:4310/?section=hall-chat", {
|
||||
waitUntil: "domcontentloaded",
|
||||
timeout: 30000,
|
||||
});
|
||||
await page.waitForSelector("[data-collaboration-hall-root]", { timeout: 30000 });
|
||||
|
||||
await page.locator("[data-hall-compose-task]").click({ timeout: 10000 });
|
||||
await page.locator("[data-hall-composer-textarea]").fill("我想要做一个视频 介绍我的群聊功能");
|
||||
await page.locator("[data-hall-composer-textarea]").press("Enter", { timeout: 10000 });
|
||||
await wait(1000);
|
||||
|
||||
const selectedTaskCardId = await page
|
||||
.locator("[data-task-card-id][aria-current='page']")
|
||||
.first()
|
||||
.getAttribute("data-task-card-id")
|
||||
.catch(() => null);
|
||||
|
||||
await page.locator("[data-hall-composer-textarea]").fill("我想要做一个视频 介绍我的群聊功能");
|
||||
await page.locator("[data-hall-composer-textarea]").press("Enter", { timeout: 10000 });
|
||||
|
||||
let typingSeen = false;
|
||||
let typingSnapshots = [];
|
||||
for (let attempt = 0; attempt < 8; attempt += 1) {
|
||||
typingSnapshots = await page.locator("[data-hall-typing-strip]").allTextContents().catch(() => []);
|
||||
if (typingSnapshots.some((text) => String(text || "").trim().length > 0)) {
|
||||
typingSeen = true;
|
||||
break;
|
||||
}
|
||||
await wait(1000);
|
||||
}
|
||||
|
||||
let replySeen = false;
|
||||
let authors = [];
|
||||
for (let attempt = 0; attempt < 35; attempt += 1) {
|
||||
authors = await page
|
||||
.locator(".hall-message .hall-message-author strong")
|
||||
.allTextContents()
|
||||
.catch(() => []);
|
||||
if (authors.some((label) => !String(label || "").includes("Operator"))) {
|
||||
replySeen = true;
|
||||
break;
|
||||
}
|
||||
await wait(1000);
|
||||
}
|
||||
|
||||
const messageCount = await page.locator(".hall-message").count();
|
||||
const bodies = await page
|
||||
.locator(".hall-message .hall-message-body")
|
||||
.evaluateAll((nodes) => nodes.map((node) => node.textContent || ""))
|
||||
.catch(() => []);
|
||||
const pendingTyping = await page.locator("[data-hall-typing-strip]").allTextContents().catch(() => []);
|
||||
const headerStage = await page.locator("[data-hall-thread-meta]").textContent().catch(() => null);
|
||||
const title = await page.locator("[data-hall-thread-title]").textContent().catch(() => null);
|
||||
|
||||
await page.screenshot({
|
||||
path: "/tmp/live-hall-send-check.png",
|
||||
fullPage: true,
|
||||
});
|
||||
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
title,
|
||||
headerStage,
|
||||
selectedTaskCardId,
|
||||
typingSeen,
|
||||
typingSnapshots,
|
||||
replySeen,
|
||||
messageCount,
|
||||
authors,
|
||||
pendingTyping,
|
||||
bodies: bodies.slice(0, 10),
|
||||
errors,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
|
||||
await browser.close();
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
const { chromium } = require("playwright");
|
||||
|
||||
async function wait(ms) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const page = await browser.newPage({ viewport: { width: 1600, height: 1200 } });
|
||||
const errors = [];
|
||||
|
||||
page.on("pageerror", (error) => errors.push(`pageerror:${error.message}`));
|
||||
page.on("console", (message) => {
|
||||
if (message.type() === "error") errors.push(`console:${message.text()}`);
|
||||
});
|
||||
|
||||
await page.goto("http://127.0.0.1:4310/?section=hall-chat", {
|
||||
waitUntil: "domcontentloaded",
|
||||
timeout: 30000,
|
||||
});
|
||||
await page.waitForSelector("[data-collaboration-hall-root]", { timeout: 30000 });
|
||||
|
||||
await page.locator("[data-hall-compose-task]").click({ timeout: 10000 });
|
||||
await page.locator("[data-hall-composer-textarea]").fill("我想要做一个视频 介绍我的群聊功能");
|
||||
await page.locator("[data-hall-send-reply]").click({ timeout: 10000 });
|
||||
await wait(1000);
|
||||
|
||||
await page.locator("[data-hall-composer-textarea]").fill("我想要做一个视频 介绍我的群聊功能");
|
||||
await page.locator("[data-hall-send-reply]").click({ timeout: 10000 });
|
||||
|
||||
const timeline = [];
|
||||
for (let attempt = 1; attempt <= 12; attempt += 1) {
|
||||
await wait(5000);
|
||||
const authors = await page.locator(".hall-message .hall-message-author strong").allTextContents().catch(() => []);
|
||||
const bodies = await page
|
||||
.locator(".hall-message .hall-message-body")
|
||||
.evaluateAll((nodes) => nodes.map((node) => node.textContent || ""))
|
||||
.catch(() => []);
|
||||
const typing = await page.locator("[data-hall-typing-strip]").allTextContents().catch(() => []);
|
||||
timeline.push({
|
||||
t: attempt * 5,
|
||||
authors,
|
||||
typing,
|
||||
count: bodies.length,
|
||||
lastBodies: bodies.slice(-4),
|
||||
});
|
||||
}
|
||||
|
||||
const selectedTaskCardId = await page
|
||||
.locator("[data-task-card-id][aria-current='page']")
|
||||
.first()
|
||||
.getAttribute("data-task-card-id")
|
||||
.catch(() => null);
|
||||
|
||||
await page.screenshot({
|
||||
path: "/tmp/live-hall-timeline-check.png",
|
||||
fullPage: true,
|
||||
});
|
||||
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
selectedTaskCardId,
|
||||
timeline,
|
||||
errors,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
|
||||
await browser.close();
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
import { mkdtemp, readdir, rm } from "node:fs/promises";
|
||||
import { join, resolve } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { spawn } from "node:child_process";
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const repoRoot = process.cwd();
|
||||
const testArgs = process.argv.slice(2);
|
||||
const targets = testArgs.length > 0 ? testArgs : await collectTestFiles(resolve(repoRoot, "test"));
|
||||
const runtimeDir = await mkdtemp(join(tmpdir(), "openclaw-control-center-test-"));
|
||||
|
||||
try {
|
||||
const exitCode = await runNodeTests(targets, runtimeDir);
|
||||
process.exitCode = exitCode;
|
||||
} finally {
|
||||
await rm(runtimeDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
async function collectTestFiles(rootDir: string): Promise<string[]> {
|
||||
const output: string[] = [];
|
||||
await walk(rootDir, output);
|
||||
return output.sort();
|
||||
}
|
||||
|
||||
async function walk(dir: string, output: string[]): Promise<void> {
|
||||
const entries = await readdir(dir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
const fullPath = join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
await walk(fullPath, output);
|
||||
continue;
|
||||
}
|
||||
if (entry.isFile() && entry.name.endsWith(".test.ts")) {
|
||||
output.push(fullPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function runNodeTests(targets: string[], runtimeDir: string): Promise<number> {
|
||||
return new Promise((resolvePromise, reject) => {
|
||||
const child = spawn(
|
||||
process.execPath,
|
||||
["--import", "tsx", "--test", "--test-concurrency=1", ...targets],
|
||||
{
|
||||
cwd: process.cwd(),
|
||||
env: {
|
||||
...process.env,
|
||||
OPENCLAW_RUNTIME_DIR: runtimeDir,
|
||||
},
|
||||
stdio: "inherit",
|
||||
},
|
||||
);
|
||||
child.once("error", reject);
|
||||
child.once("exit", (code, signal) => {
|
||||
if (signal) {
|
||||
resolvePromise(1);
|
||||
return;
|
||||
}
|
||||
resolvePromise(code ?? 0);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
void main();
|
||||
+19
-1
@@ -82,10 +82,28 @@ async function checkPage(urlPath, keywords, label) {
|
||||
cleanup(1);
|
||||
}
|
||||
|
||||
async function checkPageExcludes(urlPath, forbidden, label) {
|
||||
const body = await fetch(urlPath);
|
||||
const visible = body
|
||||
.replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, "")
|
||||
.replace(/<style\b[^>]*>[\s\S]*?<\/style>/gi, "");
|
||||
const hit = forbidden.find((kw) => visible.includes(kw));
|
||||
if (hit) {
|
||||
console.error(`FAIL: ${label} — found forbidden marker "${hit}".`);
|
||||
cleanup(1);
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
await waitForUI();
|
||||
await checkPage("/", ["OpenClaw", "Control Center", "usage", "lang="], "GET /");
|
||||
await checkPage("/?section=overview&lang=en", ["OpenClaw", "Control Center", "usage", "lang="], "GET /?section=overview&lang=en");
|
||||
await checkPage("/docs?lang=en", ["Open document workbench", "Control Center", "Docs"], "GET /docs?lang=en");
|
||||
await checkPage("/?section=hall-chat&lang=zh", ["协作大厅", "线程", "新任务"], "GET /?section=hall-chat&lang=zh");
|
||||
await checkPageExcludes(
|
||||
"/?section=hall-chat&lang=zh",
|
||||
["LOCAL_API_TOKEN", "language is not defined", "Manager handed the room to Reviewer", "[tool]", "thinking"],
|
||||
"GET /?section=hall-chat&lang=zh excludes legacy leaks",
|
||||
);
|
||||
console.log(`UI smoke passed on http://127.0.0.1:${PORT}`);
|
||||
cleanup(0);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import { execFile, spawn } from "node:child_process";
|
||||
import { existsSync } from "node:fs";
|
||||
import { open, readdir, readFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
import type {
|
||||
AgentRunRequest,
|
||||
AgentRunResponse,
|
||||
AgentRunStreamHandlers,
|
||||
AgentRunTransportContext,
|
||||
ApprovalsActionResponse,
|
||||
ApprovalsApproveRequest,
|
||||
ApprovalsGetResponse,
|
||||
@@ -240,6 +245,145 @@ export class OpenClawLiveClient implements ToolClient {
|
||||
};
|
||||
}
|
||||
|
||||
async agentRun(request: AgentRunRequest): Promise<AgentRunResponse> {
|
||||
const message = request.message.trim();
|
||||
if (!message) {
|
||||
throw new Error("agentRun requires a non-empty message.");
|
||||
}
|
||||
|
||||
const args = ["agent"];
|
||||
const sessionId = request.sessionId?.trim()
|
||||
|| (request.sessionKey?.trim() ? await this.resolveSessionIdByKey(request.sessionKey.trim()) : undefined);
|
||||
if (sessionId) {
|
||||
args.push("--session-id", sessionId);
|
||||
} else {
|
||||
const agentId = request.agentId?.trim();
|
||||
if (!agentId) {
|
||||
throw new Error("agentRun requires either agentId, sessionKey, or sessionId.");
|
||||
}
|
||||
args.push("--agent", agentId);
|
||||
}
|
||||
|
||||
args.push("--message", message);
|
||||
args.push("--thinking", normalizeThinkingLevel(request.thinking));
|
||||
if (request.timeoutSeconds && Number.isFinite(request.timeoutSeconds) && request.timeoutSeconds > 0) {
|
||||
args.push("--timeout", String(Math.trunc(request.timeoutSeconds)));
|
||||
}
|
||||
if (request.deliver) args.push("--deliver");
|
||||
args.push("--json");
|
||||
|
||||
const transportOptions = buildAgentRunProcessOptions(request.context);
|
||||
const rawJson = await runJson<Record<string, unknown>>(args, {
|
||||
timeoutMs: (request.timeoutSeconds && Number.isFinite(request.timeoutSeconds) && request.timeoutSeconds > 0)
|
||||
? Math.max(5_000, Math.trunc(request.timeoutSeconds * 1_000))
|
||||
: 20 * 60 * 1_000,
|
||||
maxBuffer: 8 * 1024 * 1024,
|
||||
cwd: transportOptions.cwd,
|
||||
env: transportOptions.env,
|
||||
});
|
||||
|
||||
const result = asObject(rawJson.result);
|
||||
const meta = asObject(result?.meta);
|
||||
const agentMeta = asObject(meta?.agentMeta);
|
||||
const systemPromptReport = asObject(meta?.systemPromptReport);
|
||||
const payloads = Array.isArray(result?.payloads) ? result?.payloads : [];
|
||||
const text = payloads
|
||||
.map((item) => {
|
||||
const payload = asObject(item);
|
||||
return asString(payload?.text)?.trim();
|
||||
})
|
||||
.filter((item): item is string => Boolean(item))
|
||||
.join("\n\n")
|
||||
.trim();
|
||||
const sessionKey = asString(systemPromptReport?.sessionKey) ?? request.sessionKey?.trim();
|
||||
const response: AgentRunResponse = {
|
||||
ok: asString(rawJson.status) === "ok",
|
||||
runId: asString(rawJson.runId),
|
||||
status: asString(rawJson.status),
|
||||
summary: asString(rawJson.summary),
|
||||
text,
|
||||
rawText: JSON.stringify(rawJson),
|
||||
sessionId: asString(agentMeta?.sessionId) ?? sessionId,
|
||||
sessionKey,
|
||||
provider: asString(agentMeta?.provider) ?? asString(systemPromptReport?.provider),
|
||||
model: asString(agentMeta?.model) ?? asString(systemPromptReport?.model),
|
||||
rawJson,
|
||||
};
|
||||
|
||||
if (sessionKey && response.sessionId) {
|
||||
const cached = this.sessionCache.get(sessionKey) ?? {};
|
||||
this.sessionCache.set(sessionKey, {
|
||||
...cached,
|
||||
model: response.model ?? cached.model,
|
||||
});
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
async agentRunStream(
|
||||
request: AgentRunRequest,
|
||||
handlers: AgentRunStreamHandlers = {},
|
||||
): Promise<AgentRunResponse> {
|
||||
const message = request.message.trim();
|
||||
if (!message) {
|
||||
throw new Error("agentRunStream requires a non-empty message.");
|
||||
}
|
||||
|
||||
const args = ["agent"];
|
||||
const latestBeforeRun = await this.readLatestAgentSessionMarker(request.agentId?.trim());
|
||||
const sessionId = request.sessionId?.trim()
|
||||
|| (request.sessionKey?.trim() ? await this.resolveSessionIdByKey(request.sessionKey.trim()) : undefined);
|
||||
const agentId = request.agentId?.trim();
|
||||
if (sessionId) {
|
||||
args.push("--session-id", sessionId);
|
||||
} else if (agentId) {
|
||||
args.push("--agent", agentId);
|
||||
} else {
|
||||
throw new Error("agentRunStream requires either agentId, sessionKey, or sessionId.");
|
||||
}
|
||||
|
||||
args.push("--message", message);
|
||||
args.push("--thinking", normalizeThinkingLevel(request.thinking));
|
||||
if (request.timeoutSeconds && Number.isFinite(request.timeoutSeconds) && request.timeoutSeconds > 0) {
|
||||
args.push("--timeout", String(Math.trunc(request.timeoutSeconds)));
|
||||
}
|
||||
if (request.deliver) args.push("--deliver");
|
||||
|
||||
const timeoutMs = (request.timeoutSeconds && Number.isFinite(request.timeoutSeconds) && request.timeoutSeconds > 0)
|
||||
? Math.max(5_000, Math.trunc(request.timeoutSeconds * 1_000))
|
||||
: 20 * 60 * 1_000;
|
||||
const transportOptions = buildAgentRunProcessOptions(request.context);
|
||||
const { stdout, stderr, code } = await runStreamingText(args, handlers, {
|
||||
timeoutMs,
|
||||
cwd: transportOptions.cwd,
|
||||
env: transportOptions.env,
|
||||
});
|
||||
if (code !== 0) {
|
||||
throw new Error(stderr.trim() || stdout.trim() || `openclaw agent exited with code ${code}`);
|
||||
}
|
||||
|
||||
const text = sanitizeAgentCliOutput(stdout);
|
||||
const sessionKey = request.sessionKey?.trim()
|
||||
|| await this.resolveSessionKeyAfterRun({
|
||||
agentId,
|
||||
beforeUpdatedAtMs: latestBeforeRun?.updatedAtMs,
|
||||
beforeSessionKey: latestBeforeRun?.sessionKey,
|
||||
});
|
||||
const resolvedSessionId = sessionId
|
||||
|| (sessionKey ? await this.resolveSessionIdByKey(sessionKey) : undefined)
|
||||
|| latestBeforeRun?.sessionId;
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
status: "ok",
|
||||
text,
|
||||
rawText: stdout,
|
||||
sessionId: resolvedSessionId,
|
||||
sessionKey,
|
||||
};
|
||||
}
|
||||
|
||||
private async loadSessionsFromStores(): Promise<SessionsListResponse> {
|
||||
const openclawHome = resolveOpenClawHomePath();
|
||||
const agentsPath = join(openclawHome, "agents");
|
||||
@@ -352,25 +496,113 @@ export class OpenClawLiveClient implements ToolClient {
|
||||
const catalog = await loadCurrentAgentCatalog();
|
||||
return new Set(catalog.entries.map((entry) => normalizeAgentKey(entry.agentId)));
|
||||
}
|
||||
|
||||
private async resolveSessionIdByKey(sessionKey: string): Promise<string | undefined> {
|
||||
const sessions = (await this.sessionsList()).sessions ?? [];
|
||||
return sessions.find((item) => item.sessionKey === sessionKey || item.key === sessionKey)?.sessionId;
|
||||
}
|
||||
|
||||
private async readLatestAgentSessionMarker(
|
||||
agentId: string | undefined,
|
||||
): Promise<{ sessionKey?: string; sessionId?: string; updatedAtMs?: number } | undefined> {
|
||||
const normalizedAgentId = agentId?.trim();
|
||||
if (!normalizedAgentId) return undefined;
|
||||
const sessions = (await this.sessionsList()).sessions ?? [];
|
||||
const match = sessions
|
||||
.filter((item) => (item.agentId ?? extractAgentIdFromSessionKey(item.sessionKey ?? item.key)) === normalizedAgentId)
|
||||
.sort((a, b) => (b.updatedAtMs ?? 0) - (a.updatedAtMs ?? 0))[0];
|
||||
if (!match) return undefined;
|
||||
return {
|
||||
sessionKey: match.sessionKey ?? match.key,
|
||||
sessionId: match.sessionId,
|
||||
updatedAtMs: match.updatedAtMs,
|
||||
};
|
||||
}
|
||||
|
||||
private async resolveSessionKeyAfterRun(input: {
|
||||
agentId?: string;
|
||||
beforeUpdatedAtMs?: number;
|
||||
beforeSessionKey?: string;
|
||||
}): Promise<string | undefined> {
|
||||
const normalizedAgentId = input.agentId?.trim();
|
||||
if (!normalizedAgentId) return undefined;
|
||||
const sessions = (await this.sessionsList()).sessions ?? [];
|
||||
const candidates = sessions
|
||||
.filter((item) => (item.agentId ?? extractAgentIdFromSessionKey(item.sessionKey ?? item.key)) === normalizedAgentId)
|
||||
.sort((a, b) => (b.updatedAtMs ?? 0) - (a.updatedAtMs ?? 0));
|
||||
const preferred = candidates.find((item) => (item.updatedAtMs ?? 0) > (input.beforeUpdatedAtMs ?? 0))
|
||||
?? candidates[0];
|
||||
return preferred?.sessionKey ?? preferred?.key ?? input.beforeSessionKey ?? `agent:${normalizedAgentId}:main`;
|
||||
}
|
||||
}
|
||||
|
||||
async function runJson<T>(args: string[], options?: { timeoutMs?: number; maxBuffer?: number }): Promise<T> {
|
||||
async function runJson<T>(args: string[], options?: { timeoutMs?: number; maxBuffer?: number; cwd?: string; env?: NodeJS.ProcessEnv }): Promise<T> {
|
||||
const stdout = await runText(args, options);
|
||||
return JSON.parse(stdout) as T;
|
||||
}
|
||||
|
||||
async function runText(
|
||||
args: string[],
|
||||
options?: { timeoutMs?: number; maxBuffer?: number },
|
||||
options?: { timeoutMs?: number; maxBuffer?: number; cwd?: string; env?: NodeJS.ProcessEnv },
|
||||
): Promise<string> {
|
||||
const { stdout } = await execFileAsync("openclaw", args, {
|
||||
timeout: options?.timeoutMs ?? 20_000,
|
||||
maxBuffer: options?.maxBuffer ?? 2 * 1024 * 1024,
|
||||
shell: process.platform === "win32",
|
||||
cwd: options?.cwd,
|
||||
env: options?.env,
|
||||
});
|
||||
return stdout;
|
||||
}
|
||||
|
||||
async function runStreamingText(
|
||||
args: string[],
|
||||
handlers: AgentRunStreamHandlers,
|
||||
options?: { timeoutMs?: number; cwd?: string; env?: NodeJS.ProcessEnv },
|
||||
): Promise<{ stdout: string; stderr: string; code: number }> {
|
||||
return await new Promise((resolve, reject) => {
|
||||
const child = spawn("openclaw", args, {
|
||||
shell: process.platform === "win32",
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
cwd: options?.cwd,
|
||||
env: options?.env,
|
||||
});
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
let settled = false;
|
||||
const timer = setTimeout(() => {
|
||||
child.kill("SIGTERM");
|
||||
}, options?.timeoutMs ?? 20_000);
|
||||
|
||||
child.stdout?.setEncoding("utf8");
|
||||
child.stderr?.setEncoding("utf8");
|
||||
child.stdout?.on("data", (chunk: string) => {
|
||||
stdout += chunk;
|
||||
handlers.onStdoutChunk?.(chunk);
|
||||
});
|
||||
child.stderr?.on("data", (chunk: string) => {
|
||||
stderr += chunk;
|
||||
handlers.onStderrChunk?.(chunk);
|
||||
});
|
||||
child.on("error", (error) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
reject(error);
|
||||
});
|
||||
child.on("close", (code) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
resolve({
|
||||
stdout,
|
||||
stderr,
|
||||
code: typeof code === "number" ? code : 0,
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function readSessionHistoryFromCli(
|
||||
sessionKey: string,
|
||||
limit: number,
|
||||
@@ -463,6 +695,85 @@ function asBoolean(v: unknown): boolean | undefined {
|
||||
return typeof v === "boolean" ? v : undefined;
|
||||
}
|
||||
|
||||
function normalizeThinkingLevel(input: AgentRunRequest["thinking"]): NonNullable<AgentRunRequest["thinking"]> {
|
||||
switch (input) {
|
||||
case "off":
|
||||
case "minimal":
|
||||
case "low":
|
||||
case "medium":
|
||||
case "high":
|
||||
case "xhigh":
|
||||
return input;
|
||||
default:
|
||||
return "minimal";
|
||||
}
|
||||
}
|
||||
|
||||
function buildAgentRunProcessOptions(
|
||||
context: AgentRunTransportContext | undefined,
|
||||
): { cwd?: string; env?: NodeJS.ProcessEnv } {
|
||||
if (!context) return {};
|
||||
const cwd = pickAgentRunWorkingDirectory(context);
|
||||
const envEntries: Record<string, string> = {};
|
||||
const surface = context.surface?.trim();
|
||||
if (surface) envEntries.OPENCLAW_AGENT_SURFACE = surface;
|
||||
const workspaceRoot = context.workspaceRoot?.trim();
|
||||
if (workspaceRoot) envEntries.OPENCLAW_AGENT_WORKSPACE_ROOT = workspaceRoot;
|
||||
const workdir = context.workdir?.trim();
|
||||
if (workdir) envEntries.OPENCLAW_AGENT_WORKDIR = workdir;
|
||||
if ((context.entryFiles?.length ?? 0) > 0) {
|
||||
envEntries.OPENCLAW_AGENT_ENTRY_FILES_JSON = JSON.stringify(context.entryFiles);
|
||||
}
|
||||
if ((context.artifactRefs?.length ?? 0) > 0) {
|
||||
envEntries.OPENCLAW_AGENT_ARTIFACT_REFS_JSON = JSON.stringify(context.artifactRefs);
|
||||
}
|
||||
if ((context.attachmentRefs?.length ?? 0) > 0) {
|
||||
envEntries.OPENCLAW_AGENT_ATTACHMENT_REFS_JSON = JSON.stringify(context.attachmentRefs);
|
||||
}
|
||||
const env = Object.keys(envEntries).length > 0
|
||||
? {
|
||||
...process.env,
|
||||
...envEntries,
|
||||
}
|
||||
: undefined;
|
||||
return { cwd, env };
|
||||
}
|
||||
|
||||
function pickAgentRunWorkingDirectory(context: AgentRunTransportContext): string | undefined {
|
||||
const candidates = [context.workdir, context.workspaceRoot]
|
||||
.map((value) => value?.trim())
|
||||
.filter((value): value is string => Boolean(value));
|
||||
for (const candidate of candidates) {
|
||||
if (existsSync(candidate)) return candidate;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function sanitizeAgentCliOutput(raw: string): string {
|
||||
const withoutAnsi = raw
|
||||
.replace(/\u001B\[[0-9;?]*[ -/]*[@-~]/g, "")
|
||||
.replace(/\r/g, "\n");
|
||||
const lines = withoutAnsi
|
||||
.split(/\n+/)
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
.filter((line) => !line.startsWith("🦞 OpenClaw"))
|
||||
.filter((line) => !line.startsWith("Registered plugin command:"))
|
||||
.filter((line) => !/^Waiting for agent reply/i.test(line))
|
||||
.filter((line) => !/^[◒◐◓◑◇│]+$/.test(line))
|
||||
.filter((line) => !/^流式中$/i.test(line))
|
||||
.filter((line) => !/^\[tool(?:[^\]]*)?\]/i.test(line))
|
||||
.filter((line) => !/^thinking\b/i.test(line))
|
||||
.filter((line) => !/^Inspecting\b/i.test(line))
|
||||
.filter((line) => !/^Checking\b/i.test(line))
|
||||
.filter((line) => !/^It seems\b/i.test(line))
|
||||
.filter((line) => !/^I should\b/i.test(line))
|
||||
.filter((line) => !/^I think\b/i.test(line))
|
||||
.filter((line) => !/^Let's\b/i.test(line))
|
||||
.filter((line) => !/^(import|export)\s+/i.test(line));
|
||||
return lines.join("\n").trim();
|
||||
}
|
||||
|
||||
function asObject(v: unknown): Record<string, unknown> | undefined {
|
||||
return v !== null && typeof v === "object" ? (v as Record<string, unknown>) : undefined;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import type {
|
||||
AgentRunRequest,
|
||||
AgentRunResponse,
|
||||
AgentRunStreamHandlers,
|
||||
ApprovalsActionResponse,
|
||||
ApprovalsApproveRequest,
|
||||
ApprovalsGetResponse,
|
||||
@@ -19,6 +22,8 @@ export interface ToolClient {
|
||||
approvalsGet(): Promise<ApprovalsGetResponse>;
|
||||
approvalsApprove(request: ApprovalsApproveRequest): Promise<ApprovalsActionResponse>;
|
||||
approvalsReject(request: ApprovalsRejectRequest): Promise<ApprovalsActionResponse>;
|
||||
agentRun?(request: AgentRunRequest): Promise<AgentRunResponse>;
|
||||
agentRunStream?(request: AgentRunRequest, handlers?: AgentRunStreamHandlers): Promise<AgentRunResponse>;
|
||||
}
|
||||
|
||||
export class ReadonlyToolClient implements ToolClient {
|
||||
|
||||
@@ -8,6 +8,17 @@ if (existsSync(DOTENV_PATH)) {
|
||||
}
|
||||
|
||||
export const GATEWAY_URL = readStringEnv(process.env.GATEWAY_URL, "ws://127.0.0.1:18789");
|
||||
export const OPENCLAW_CONTROL_UI_URL = readOptionalStringEnv(process.env.OPENCLAW_CONTROL_UI_URL);
|
||||
export const TASK_ROOM_BRIDGE_ENABLED = process.env.TASK_ROOM_BRIDGE_ENABLED === "true";
|
||||
export const TASK_ROOM_BRIDGE_DISCORD_WEBHOOK_URL = readOptionalStringEnv(
|
||||
process.env.TASK_ROOM_BRIDGE_DISCORD_WEBHOOK_URL,
|
||||
);
|
||||
export const TASK_ROOM_BRIDGE_TELEGRAM_BOT_TOKEN = readOptionalStringEnv(
|
||||
process.env.TASK_ROOM_BRIDGE_TELEGRAM_BOT_TOKEN,
|
||||
);
|
||||
export const TASK_ROOM_BRIDGE_TELEGRAM_CHAT_ID = readOptionalStringEnv(
|
||||
process.env.TASK_ROOM_BRIDGE_TELEGRAM_CHAT_ID,
|
||||
);
|
||||
|
||||
export const READONLY_MODE = process.env.READONLY_MODE !== "false";
|
||||
export const APPROVAL_ACTIONS_ENABLED = process.env.APPROVAL_ACTIONS_ENABLED === "true";
|
||||
@@ -17,6 +28,26 @@ export const IMPORT_MUTATION_DRY_RUN = process.env.IMPORT_MUTATION_DRY_RUN === "
|
||||
export const LOCAL_TOKEN_AUTH_REQUIRED = process.env.LOCAL_TOKEN_AUTH_REQUIRED !== "false";
|
||||
export const LOCAL_API_TOKEN = (process.env.LOCAL_API_TOKEN ?? "").trim();
|
||||
export const LOCAL_TOKEN_HEADER = "x-local-token" as const;
|
||||
export const HALL_RUNTIME_DISPATCH_ENABLED = process.env.HALL_RUNTIME_DISPATCH_ENABLED !== "false";
|
||||
export const HALL_RUNTIME_DIRECT_STREAM_ENABLED = process.env.HALL_RUNTIME_DIRECT_STREAM_ENABLED !== "false";
|
||||
export const HALL_RUNTIME_THINKING_LEVEL = readThinkingLevelEnv(process.env.HALL_RUNTIME_THINKING_LEVEL, "minimal");
|
||||
export const HALL_RUNTIME_TIMEOUT_SECONDS = parsePositiveInt(
|
||||
process.env.HALL_RUNTIME_TIMEOUT_SECONDS,
|
||||
600,
|
||||
);
|
||||
export const HALL_RUNTIME_POLL_INTERVAL_MS = parsePositiveInt(
|
||||
process.env.HALL_RUNTIME_POLL_INTERVAL_MS,
|
||||
350,
|
||||
);
|
||||
export const HALL_RUNTIME_HISTORY_LIMIT = parsePositiveInt(
|
||||
process.env.HALL_RUNTIME_HISTORY_LIMIT,
|
||||
120,
|
||||
);
|
||||
export const HALL_RUNTIME_EXECUTION_CHAIN_ENABLED = process.env.HALL_RUNTIME_EXECUTION_CHAIN_ENABLED !== "false";
|
||||
export const HALL_RUNTIME_EXECUTION_MAX_TURNS = parsePositiveInt(
|
||||
process.env.HALL_RUNTIME_EXECUTION_MAX_TURNS,
|
||||
3,
|
||||
);
|
||||
export const TASK_HEARTBEAT_ENABLED = process.env.TASK_HEARTBEAT_ENABLED !== "false";
|
||||
export const TASK_HEARTBEAT_DRY_RUN = process.env.TASK_HEARTBEAT_DRY_RUN !== "false";
|
||||
export const TASK_HEARTBEAT_MAX_TASKS_PER_RUN = parsePositiveInt(
|
||||
@@ -44,3 +75,25 @@ function readStringEnv(input: string | undefined, fallback: string): string {
|
||||
const value = (input ?? "").trim();
|
||||
return value === "" ? fallback : value;
|
||||
}
|
||||
|
||||
function readOptionalStringEnv(input: string | undefined): string | undefined {
|
||||
const value = (input ?? "").trim();
|
||||
return value === "" ? undefined : value;
|
||||
}
|
||||
|
||||
function readThinkingLevelEnv(
|
||||
input: string | undefined,
|
||||
fallback: "off" | "minimal" | "low" | "medium" | "high" | "xhigh",
|
||||
): "off" | "minimal" | "low" | "medium" | "high" | "xhigh" {
|
||||
switch ((input ?? "").trim()) {
|
||||
case "off":
|
||||
case "minimal":
|
||||
case "low":
|
||||
case "medium":
|
||||
case "high":
|
||||
case "xhigh":
|
||||
return (input ?? "").trim() as "off" | "minimal" | "low" | "medium" | "high" | "xhigh";
|
||||
default:
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,60 @@ export interface SessionsListRequest {
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export type AgentRunThinkingLevel = "off" | "minimal" | "low" | "medium" | "high" | "xhigh";
|
||||
|
||||
export interface AgentRunArtifactRef {
|
||||
artifactId?: string;
|
||||
type?: string;
|
||||
label: string;
|
||||
location: string;
|
||||
}
|
||||
|
||||
export interface AgentRunAttachmentRef {
|
||||
label: string;
|
||||
url: string;
|
||||
mimeType?: string;
|
||||
}
|
||||
|
||||
export interface AgentRunTransportContext {
|
||||
surface?: string;
|
||||
workspaceRoot?: string;
|
||||
workdir?: string;
|
||||
entryFiles?: string[];
|
||||
artifactRefs?: AgentRunArtifactRef[];
|
||||
attachmentRefs?: AgentRunAttachmentRef[];
|
||||
}
|
||||
|
||||
export interface AgentRunRequest {
|
||||
agentId?: string;
|
||||
sessionKey?: string;
|
||||
sessionId?: string;
|
||||
message: string;
|
||||
thinking?: AgentRunThinkingLevel;
|
||||
timeoutSeconds?: number;
|
||||
deliver?: boolean;
|
||||
context?: AgentRunTransportContext;
|
||||
}
|
||||
|
||||
export interface AgentRunResponse {
|
||||
ok: boolean;
|
||||
runId?: string;
|
||||
status?: string;
|
||||
summary?: string;
|
||||
text: string;
|
||||
rawText: string;
|
||||
sessionId?: string;
|
||||
sessionKey?: string;
|
||||
provider?: string;
|
||||
model?: string;
|
||||
rawJson?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface AgentRunStreamHandlers {
|
||||
onStdoutChunk?: (chunk: string) => void;
|
||||
onStderrChunk?: (chunk: string) => void;
|
||||
}
|
||||
|
||||
export interface SessionsListItem {
|
||||
key?: string;
|
||||
sessionKey?: string;
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
import type { ChatMessage, RoomParticipantRole } from "../types";
|
||||
import type { CreateChatMessageInput } from "./chat-store";
|
||||
import type { ProjectTask } from "../types";
|
||||
|
||||
interface AgentDispatchContext {
|
||||
roomId: string;
|
||||
task: ProjectTask;
|
||||
recentMessages: ChatMessage[];
|
||||
}
|
||||
|
||||
export function buildDiscussionDispatchMessage(
|
||||
role: RoomParticipantRole,
|
||||
input: AgentDispatchContext,
|
||||
): CreateChatMessageInput {
|
||||
if (role === "planner") return buildPlannerMessage(input);
|
||||
if (role === "coder") return buildCoderMessage(input);
|
||||
if (role === "reviewer") return buildReviewerMessage(input);
|
||||
return buildManagerMessage(input);
|
||||
}
|
||||
|
||||
export function buildExecutionStartedMessage(
|
||||
roomId: string,
|
||||
executor: RoomParticipantRole,
|
||||
task: ProjectTask,
|
||||
): CreateChatMessageInput {
|
||||
return {
|
||||
roomId,
|
||||
kind: "status",
|
||||
authorRole: executor,
|
||||
authorLabel: titleCaseRole(executor),
|
||||
content: `${titleCaseRole(executor)} accepted "${task.title}" and started execution.`,
|
||||
payload: {
|
||||
status: "execution_started",
|
||||
executor,
|
||||
taskStatus: "in_progress",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function buildReviewOutcomeMessage(input: {
|
||||
roomId: string;
|
||||
outcome: "approved" | "rejected";
|
||||
note?: string;
|
||||
taskStatus: "done" | "blocked" | "in_progress";
|
||||
}): CreateChatMessageInput {
|
||||
const base =
|
||||
input.outcome === "approved"
|
||||
? "Reviewer approved the execution result."
|
||||
: "Reviewer rejected the execution result and requested another pass.";
|
||||
return {
|
||||
roomId: input.roomId,
|
||||
kind: "result",
|
||||
authorRole: "reviewer",
|
||||
authorLabel: "Reviewer",
|
||||
content: input.note ? `${base} ${input.note}` : base,
|
||||
payload: {
|
||||
reviewOutcome: input.outcome,
|
||||
taskStatus: input.taskStatus,
|
||||
status: input.outcome === "approved" ? "review_passed" : "review_rejected",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function buildPlannerMessage(input: AgentDispatchContext): CreateChatMessageInput {
|
||||
const latestHumanPrompt = latestHumanRequest(input.recentMessages);
|
||||
const proposal = [
|
||||
`Scope the request for "${input.task.title}".`,
|
||||
latestHumanPrompt ? `Anchor the work on: ${latestHumanPrompt}.` : "Use the latest operator request as the main requirement.",
|
||||
"Implement the smallest safe slice first, then verify with concrete evidence.",
|
||||
].join(" ");
|
||||
|
||||
return {
|
||||
roomId: input.roomId,
|
||||
kind: "proposal",
|
||||
authorRole: "planner",
|
||||
authorLabel: "Planner",
|
||||
content: proposal,
|
||||
payload: {
|
||||
proposal,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function buildCoderMessage(input: AgentDispatchContext): CreateChatMessageInput {
|
||||
const plan = [
|
||||
`Implementation path for "${input.task.title}":`,
|
||||
"wire the room/task state first,",
|
||||
"keep mutations traceable,",
|
||||
"and finish with tests that prove the happy path and review flow.",
|
||||
].join(" ");
|
||||
|
||||
return {
|
||||
roomId: input.roomId,
|
||||
kind: "proposal",
|
||||
authorRole: "coder",
|
||||
authorLabel: "Coder",
|
||||
content: plan,
|
||||
payload: {
|
||||
proposal: plan,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function buildReviewerMessage(input: AgentDispatchContext): CreateChatMessageInput {
|
||||
const checklist = [
|
||||
`Review focus for "${input.task.title}":`,
|
||||
"one room per task,",
|
||||
"ordered discussion turns,",
|
||||
"task-state sync,",
|
||||
"summary persistence,",
|
||||
"and regression coverage for the main API flow.",
|
||||
].join(" ");
|
||||
|
||||
return {
|
||||
roomId: input.roomId,
|
||||
kind: "proposal",
|
||||
authorRole: "reviewer",
|
||||
authorLabel: "Reviewer",
|
||||
content: checklist,
|
||||
payload: {
|
||||
proposal: checklist,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function buildManagerMessage(input: AgentDispatchContext): CreateChatMessageInput {
|
||||
const executor: RoomParticipantRole = "coder";
|
||||
const doneWhen = resolveDoneWhen(input.task);
|
||||
const decision = `Use the room-first implementation plan for "${input.task.title}" and move execution to ${titleCaseRole(executor)}.`;
|
||||
const proposal = `Planner, coder, and reviewer aligned on a safe incremental build for "${input.task.title}".`;
|
||||
|
||||
return {
|
||||
roomId: input.roomId,
|
||||
kind: "decision",
|
||||
authorRole: "manager",
|
||||
authorLabel: "Manager",
|
||||
content: `${decision} Done when: ${doneWhen}.`,
|
||||
payload: {
|
||||
proposal,
|
||||
decision,
|
||||
executor,
|
||||
doneWhen,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function latestHumanRequest(messages: ChatMessage[]): string | undefined {
|
||||
const human = [...messages].reverse().find((message) => message.authorRole === "human");
|
||||
if (!human) return undefined;
|
||||
const trimmed = human.content.trim().replace(/\s+/g, " ");
|
||||
if (trimmed.length <= 160) return trimmed;
|
||||
return `${trimmed.slice(0, 157)}...`;
|
||||
}
|
||||
|
||||
function resolveDoneWhen(task: ProjectTask): string {
|
||||
if (task.definitionOfDone.length > 0) {
|
||||
return task.definitionOfDone.join("; ");
|
||||
}
|
||||
if (task.dueAt) {
|
||||
return `the requested changes are implemented and reviewed before ${task.dueAt}`;
|
||||
}
|
||||
return "the main flow works, the result is reviewed, and the task state is updated";
|
||||
}
|
||||
|
||||
function titleCaseRole(role: RoomParticipantRole): string {
|
||||
if (role === "human") return "Operator";
|
||||
if (role === "planner") return "Planner";
|
||||
if (role === "coder") return "Coder";
|
||||
if (role === "reviewer") return "Reviewer";
|
||||
return "Manager";
|
||||
}
|
||||
+403
-1
@@ -17,7 +17,7 @@ export interface ApiDocsPayload {
|
||||
export function buildApiDocs(): ApiDocsPayload {
|
||||
return {
|
||||
generatedAt: new Date().toISOString(),
|
||||
version: "phase-23",
|
||||
version: "phase-25",
|
||||
safetyDefaults: {
|
||||
READONLY_MODE: true,
|
||||
APPROVAL_ACTIONS_ENABLED: false,
|
||||
@@ -25,6 +25,11 @@ export function buildApiDocs(): ApiDocsPayload {
|
||||
IMPORT_MUTATION_ENABLED: false,
|
||||
IMPORT_MUTATION_DRY_RUN: false,
|
||||
LOCAL_TOKEN_AUTH_REQUIRED: true,
|
||||
HALL_RUNTIME_DISPATCH_ENABLED: true,
|
||||
HALL_RUNTIME_DIRECT_STREAM_ENABLED: true,
|
||||
HALL_RUNTIME_THINKING_LEVEL: "minimal",
|
||||
HALL_RUNTIME_EXECUTION_CHAIN_ENABLED: true,
|
||||
HALL_RUNTIME_EXECUTION_MAX_TURNS: 3,
|
||||
TASK_HEARTBEAT_ENABLED: true,
|
||||
TASK_HEARTBEAT_DRY_RUN: true,
|
||||
TASK_HEARTBEAT_MAX_TASKS_PER_RUN: 3,
|
||||
@@ -36,6 +41,8 @@ export function buildApiDocs(): ApiDocsPayload {
|
||||
"Live import apply requires LOCAL_API_TOKEN auth + IMPORT_MUTATION_ENABLED=true + READONLY_MODE=false; optional per-request dryRun=true keeps it non-mutating",
|
||||
taskHeartbeatExecutionGuard:
|
||||
"Live task heartbeat execution requires LOCAL_API_TOKEN when LOCAL_TOKEN_AUTH_REQUIRED=true; default mode is dry-run",
|
||||
hallRuntimeDispatchNotes:
|
||||
"Hall discussion / assign / handoff use the real openclaw agent runtime when HALL_RUNTIME_DISPATCH_ENABLED=true and a live ToolClient is available; hall prefers direct stdout streaming when available, falls back to session deltas when needed, and can auto-chain bounded execution turns after assign",
|
||||
},
|
||||
routes: [
|
||||
{
|
||||
@@ -195,6 +202,401 @@ export function buildApiDocs(): ApiDocsPayload {
|
||||
items: "ExceptionFeedItem[]",
|
||||
},
|
||||
},
|
||||
{
|
||||
method: "GET",
|
||||
path: "/api/hall",
|
||||
summary: "Read the public collaboration hall with participants, task cards, summary, and recent messages",
|
||||
response: {
|
||||
ok: "boolean",
|
||||
hall: "CollaborationHall",
|
||||
summary: "CollaborationHallSummary",
|
||||
participants: "HallParticipant[]",
|
||||
count: "number",
|
||||
taskCards: "HallTaskCard[] with summary",
|
||||
messages: "HallMessage[]",
|
||||
},
|
||||
},
|
||||
{
|
||||
method: "GET",
|
||||
path: "/api/hall/events",
|
||||
summary: "Open an SSE stream for hall invalidations and streamed agent reply drafts, including runtime-backed hall dispatch when enabled",
|
||||
query: {
|
||||
hallId: "optional hall id (default main)",
|
||||
},
|
||||
response: {
|
||||
ok: "SSE stream",
|
||||
events: "connected | invalidate | draft_start | draft_delta | draft_complete | draft_abort",
|
||||
},
|
||||
},
|
||||
{
|
||||
method: "GET",
|
||||
path: "/api/hall/messages",
|
||||
summary: "Read hall messages with optional task filters",
|
||||
query: {
|
||||
taskCardId: "optional task card id",
|
||||
taskId: "optional task id",
|
||||
projectId: "optional project id",
|
||||
limit: "optional 1..500 (default 120)",
|
||||
},
|
||||
response: {
|
||||
ok: "boolean",
|
||||
hall: "CollaborationHall",
|
||||
count: "number",
|
||||
messages: "HallMessage[]",
|
||||
},
|
||||
},
|
||||
{
|
||||
method: "POST",
|
||||
path: "/api/hall/messages",
|
||||
summary: "Post a reply into the collaboration hall (requires local token gate)",
|
||||
body: {
|
||||
hallId: "optional hall id",
|
||||
taskCardId: "optional task card id",
|
||||
projectId: "optional project id",
|
||||
taskId: "optional task id",
|
||||
content: "required message text",
|
||||
authorParticipantId: "optional participant id (defaults to operator)",
|
||||
authorLabel: "optional author label",
|
||||
},
|
||||
response: {
|
||||
ok: "boolean",
|
||||
hall: "CollaborationHall",
|
||||
hallSummary: "CollaborationHallSummary",
|
||||
taskCard: "HallTaskCard | undefined",
|
||||
taskSummary: "HallTaskSummary | undefined",
|
||||
message: "HallMessage",
|
||||
generatedMessages: "HallMessage[]",
|
||||
},
|
||||
},
|
||||
{
|
||||
method: "GET",
|
||||
path: "/api/hall/tasks",
|
||||
summary: "List collaboration hall task cards",
|
||||
query: {
|
||||
stage: "optional: discussion|execution|review|blocked|completed",
|
||||
},
|
||||
response: {
|
||||
ok: "boolean",
|
||||
hall: "CollaborationHall",
|
||||
count: "number",
|
||||
taskCards: "HallTaskCard[] with summary",
|
||||
},
|
||||
},
|
||||
{
|
||||
method: "POST",
|
||||
path: "/api/hall/tasks",
|
||||
summary: "Create a new hall task card from an operator request (requires local token gate)",
|
||||
body: {
|
||||
hallId: "optional hall id",
|
||||
projectId: "optional project id",
|
||||
taskId: "optional task id",
|
||||
title: "optional task title",
|
||||
content: "required task request",
|
||||
authorParticipantId: "optional participant id",
|
||||
authorLabel: "optional author label",
|
||||
},
|
||||
response: {
|
||||
ok: "boolean",
|
||||
hall: "CollaborationHall",
|
||||
hallSummary: "CollaborationHallSummary",
|
||||
taskCard: "HallTaskCard",
|
||||
taskSummary: "HallTaskSummary",
|
||||
task: "ProjectTask",
|
||||
roomId: "string | undefined",
|
||||
generatedMessages: "HallMessage[]",
|
||||
},
|
||||
},
|
||||
{
|
||||
method: "GET",
|
||||
path: "/api/hall/tasks/:taskId",
|
||||
summary: "Read one hall task card and its scoped timeline",
|
||||
query: {
|
||||
projectId: "required project id",
|
||||
},
|
||||
response: {
|
||||
ok: "boolean",
|
||||
hall: "CollaborationHall",
|
||||
hallSummary: "CollaborationHallSummary",
|
||||
taskCard: "HallTaskCard",
|
||||
taskSummary: "HallTaskSummary",
|
||||
task: "ProjectTask | undefined",
|
||||
messages: "HallMessage[]",
|
||||
},
|
||||
},
|
||||
{
|
||||
method: "POST",
|
||||
path: "/api/hall/tasks/:taskId/assign",
|
||||
summary: "Assign one execution owner to a hall task card (requires local token gate)",
|
||||
body: {
|
||||
projectId: "required project id",
|
||||
participantId: "optional participant id",
|
||||
note: "optional assignment note",
|
||||
},
|
||||
response: {
|
||||
ok: "boolean",
|
||||
hall: "CollaborationHall",
|
||||
hallSummary: "CollaborationHallSummary",
|
||||
taskCard: "HallTaskCard",
|
||||
taskSummary: "HallTaskSummary",
|
||||
task: "ProjectTask",
|
||||
generatedMessages: "HallMessage[]",
|
||||
},
|
||||
},
|
||||
{
|
||||
method: "POST",
|
||||
path: "/api/hall/tasks/:taskId/execution-order",
|
||||
summary: "Set the planned execution order for one hall task card (requires local token gate)",
|
||||
body: {
|
||||
projectId: "optional project id",
|
||||
taskCardId: "optional task card id",
|
||||
participantIds: "required ordered participant id array",
|
||||
},
|
||||
response: {
|
||||
ok: "boolean",
|
||||
hall: "CollaborationHall",
|
||||
hallSummary: "CollaborationHallSummary",
|
||||
taskCard: "HallTaskCard",
|
||||
taskSummary: "HallTaskSummary",
|
||||
task: "ProjectTask | undefined",
|
||||
generatedMessages: "HallMessage[]",
|
||||
},
|
||||
},
|
||||
{
|
||||
method: "POST",
|
||||
path: "/api/hall/tasks/:taskId/review",
|
||||
summary: "Approve or reject a hall task card (requires local token gate)",
|
||||
body: {
|
||||
projectId: "required project id",
|
||||
outcome: "required: approved|rejected",
|
||||
note: "optional review note",
|
||||
blockTask: "optional boolean",
|
||||
},
|
||||
response: {
|
||||
ok: "boolean",
|
||||
hall: "CollaborationHall",
|
||||
hallSummary: "CollaborationHallSummary",
|
||||
taskCard: "HallTaskCard",
|
||||
taskSummary: "HallTaskSummary",
|
||||
task: "ProjectTask",
|
||||
generatedMessages: "HallMessage[]",
|
||||
},
|
||||
},
|
||||
{
|
||||
method: "POST",
|
||||
path: "/api/hall/tasks/:taskId/handoff",
|
||||
summary: "Record a structured handoff inside the hall (requires local token gate)",
|
||||
body: {
|
||||
projectId: "required project id",
|
||||
fromParticipantId: "optional current owner id",
|
||||
toParticipantId: "required next owner id",
|
||||
handoff: "{ goal, currentResult, doneWhen, blockers[], nextOwner, requiresInputFrom[] }",
|
||||
},
|
||||
response: {
|
||||
ok: "boolean",
|
||||
hall: "CollaborationHall",
|
||||
hallSummary: "CollaborationHallSummary",
|
||||
taskCard: "HallTaskCard",
|
||||
taskSummary: "HallTaskSummary",
|
||||
task: "ProjectTask",
|
||||
generatedMessages: "HallMessage[]",
|
||||
},
|
||||
},
|
||||
{
|
||||
method: "GET",
|
||||
path: "/api/hall/tasks/:taskId/evidence",
|
||||
summary: "Read the linked detail thread and runtime evidence for one hall task card",
|
||||
query: {
|
||||
projectId: "required project id",
|
||||
historyLimit: "optional 1..200 (default 25)",
|
||||
},
|
||||
response: {
|
||||
ok: "boolean",
|
||||
taskCard: "HallTaskCard",
|
||||
room: "ChatRoom | null",
|
||||
summary: "ChatRoomSummary | null",
|
||||
storedMessages: "ChatMessage[]",
|
||||
evidenceMessages: "ChatMessage[]",
|
||||
},
|
||||
},
|
||||
{
|
||||
method: "GET",
|
||||
path: "/api/rooms",
|
||||
summary: "List task collaboration rooms with optional project/task/stage filters",
|
||||
query: {
|
||||
projectId: "optional project id",
|
||||
taskId: "optional task id",
|
||||
stage: "optional: intake|discussion|assigned|executing|review|completed",
|
||||
q: "optional substring search",
|
||||
},
|
||||
response: {
|
||||
ok: "boolean",
|
||||
updatedAt: "ISO timestamp",
|
||||
count: "number",
|
||||
rooms: "ChatRoom[] with summary",
|
||||
},
|
||||
},
|
||||
{
|
||||
method: "POST",
|
||||
path: "/api/rooms",
|
||||
summary: "Create a task collaboration room and bind it to the task (requires local token gate)",
|
||||
body: {
|
||||
projectId: "required project id",
|
||||
taskId: "required task id",
|
||||
roomId: "optional room id",
|
||||
title: "optional room title",
|
||||
stage: "optional room stage",
|
||||
},
|
||||
response: {
|
||||
ok: "boolean",
|
||||
path: "runtime/chat-rooms.json",
|
||||
room: "ChatRoom",
|
||||
task: "ProjectTask",
|
||||
summary: "ChatRoomSummary",
|
||||
},
|
||||
},
|
||||
{
|
||||
method: "GET",
|
||||
path: "/api/rooms/:roomId",
|
||||
summary: "Get one room with stored messages and session-derived evidence",
|
||||
query: {
|
||||
historyLimit: "optional 1..200 (default 25)",
|
||||
},
|
||||
response: {
|
||||
ok: "boolean",
|
||||
room: "ChatRoom",
|
||||
task: "ProjectTask | undefined",
|
||||
summary: "ChatRoomSummary",
|
||||
storedMessages: "ChatMessage[]",
|
||||
evidenceMessages: "ChatMessage[]",
|
||||
},
|
||||
},
|
||||
{
|
||||
method: "GET",
|
||||
path: "/api/rooms/:roomId/messages",
|
||||
summary: "Get merged stored messages and session-derived evidence for a room",
|
||||
query: {
|
||||
limit: "optional 1..1000 (default 200)",
|
||||
historyLimit: "optional 1..200 (default 25)",
|
||||
},
|
||||
response: {
|
||||
ok: "boolean",
|
||||
room: "ChatRoom",
|
||||
summary: "ChatRoomSummary",
|
||||
count: "number",
|
||||
storedCount: "number",
|
||||
evidenceCount: "number",
|
||||
messages: "ChatMessage[]",
|
||||
},
|
||||
},
|
||||
{
|
||||
method: "GET",
|
||||
path: "/api/rooms/:roomId/events",
|
||||
summary: "Open an SSE stream for room invalidations and streamed agent reply drafts",
|
||||
response: {
|
||||
ok: "SSE stream",
|
||||
events: "connected | invalidate | draft_start | draft_delta | draft_complete | draft_abort",
|
||||
},
|
||||
},
|
||||
{
|
||||
method: "GET",
|
||||
path: "/api/rooms/:roomId/bridge-events",
|
||||
summary: "Get recent outbound bridge events for one room",
|
||||
query: {
|
||||
limit: "optional 1..200 (default 20)",
|
||||
},
|
||||
response: {
|
||||
ok: "boolean",
|
||||
updatedAt: "ISO timestamp",
|
||||
count: "number",
|
||||
events: "TaskRoomBridgeEvent[]",
|
||||
},
|
||||
},
|
||||
{
|
||||
method: "POST",
|
||||
path: "/api/rooms/:roomId/messages",
|
||||
summary: "Append a room message; human messages trigger the deterministic discussion orchestrator",
|
||||
body: {
|
||||
authorRole: "optional human|planner|coder|reviewer|manager (default human)",
|
||||
authorLabel: "optional display label",
|
||||
participantId: "optional participant id",
|
||||
kind: "optional chat|proposal|decision|handoff|status|result",
|
||||
content: "required message text",
|
||||
mentions: "optional RoomParticipantRole[]",
|
||||
sessionKey: "optional linked session key",
|
||||
payload: "optional structured message payload",
|
||||
},
|
||||
response: {
|
||||
ok: "boolean",
|
||||
room: "ChatRoom",
|
||||
message: "ChatMessage",
|
||||
generatedMessages: "ChatMessage[]",
|
||||
summary: "ChatRoomSummary",
|
||||
},
|
||||
},
|
||||
{
|
||||
method: "POST",
|
||||
path: "/api/rooms/:roomId/handoffs",
|
||||
summary: "Record an explicit room handoff between roles (requires local token gate)",
|
||||
body: {
|
||||
fromRole: "required RoomParticipantRole",
|
||||
toRole: "required RoomParticipantRole",
|
||||
note: "optional string <= 320",
|
||||
},
|
||||
response: {
|
||||
ok: "boolean",
|
||||
room: "ChatRoom",
|
||||
generatedMessages: "ChatMessage[]",
|
||||
summary: "ChatRoomSummary",
|
||||
},
|
||||
},
|
||||
{
|
||||
method: "POST",
|
||||
path: "/api/rooms/:roomId/assign",
|
||||
summary: "Assign execution to one role, sync the task to in_progress, and optionally start execution",
|
||||
body: {
|
||||
executorRole: "optional RoomParticipantRole (default room.assignedExecutor or coder)",
|
||||
note: "optional string <= 320",
|
||||
autoStartExecution: "optional boolean (default true)",
|
||||
},
|
||||
response: {
|
||||
ok: "boolean",
|
||||
room: "ChatRoom",
|
||||
task: "ProjectTask",
|
||||
generatedMessages: "ChatMessage[]",
|
||||
summary: "ChatRoomSummary",
|
||||
},
|
||||
},
|
||||
{
|
||||
method: "POST",
|
||||
path: "/api/rooms/:roomId/review",
|
||||
summary: "Approve or reject execution and sync the task status",
|
||||
body: {
|
||||
outcome: "required approved|rejected",
|
||||
note: "optional string <= 320",
|
||||
blockTask: "optional boolean; when rejected, prefer blocked over in_progress",
|
||||
},
|
||||
response: {
|
||||
ok: "boolean",
|
||||
room: "ChatRoom",
|
||||
task: "ProjectTask",
|
||||
generatedMessages: "ChatMessage[]",
|
||||
summary: "ChatRoomSummary",
|
||||
},
|
||||
},
|
||||
{
|
||||
method: "PATCH",
|
||||
path: "/api/rooms/:roomId/stage",
|
||||
summary: "Manually update the room stage and optional owner role (requires local token gate)",
|
||||
body: {
|
||||
stage: "required intake|discussion|assigned|executing|review|completed",
|
||||
ownerRole: "optional RoomParticipantRole",
|
||||
},
|
||||
response: {
|
||||
ok: "boolean",
|
||||
room: "ChatRoom",
|
||||
summary: "ChatRoomSummary",
|
||||
},
|
||||
},
|
||||
{
|
||||
method: "GET",
|
||||
path: "/api/usage-cost",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,244 @@
|
||||
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import type {
|
||||
ChatMessage,
|
||||
ChatRoom,
|
||||
ChatRoomSummary,
|
||||
ChatSummaryStoreSnapshot,
|
||||
RoomParticipantRole,
|
||||
RoomStage,
|
||||
} from "../types";
|
||||
|
||||
const RUNTIME_DIR = join(process.cwd(), "runtime");
|
||||
export const CHAT_SUMMARIES_PATH = join(RUNTIME_DIR, "chat-summaries.json");
|
||||
|
||||
const EMPTY_SUMMARY_STORE: ChatSummaryStoreSnapshot = {
|
||||
summaries: [],
|
||||
updatedAt: "1970-01-01T00:00:00.000Z",
|
||||
};
|
||||
|
||||
export interface ChatSummaryMutationResult {
|
||||
path: string;
|
||||
summary: ChatRoomSummary;
|
||||
}
|
||||
|
||||
export async function loadChatSummaryStore(): Promise<ChatSummaryStoreSnapshot> {
|
||||
try {
|
||||
const raw = await readFile(CHAT_SUMMARIES_PATH, "utf8");
|
||||
return normalizeChatSummaryStore(JSON.parse(raw));
|
||||
} catch {
|
||||
return cloneEmptySummaryStore();
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveChatSummaryStore(next: ChatSummaryStoreSnapshot): Promise<string> {
|
||||
const normalized = normalizeChatSummaryStore({
|
||||
...next,
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
await mkdir(RUNTIME_DIR, { recursive: true });
|
||||
await writeFile(CHAT_SUMMARIES_PATH, JSON.stringify(normalized, null, 2), "utf8");
|
||||
return CHAT_SUMMARIES_PATH;
|
||||
}
|
||||
|
||||
export function getChatRoomSummary(
|
||||
store: ChatSummaryStoreSnapshot,
|
||||
roomId: string,
|
||||
): ChatRoomSummary | undefined {
|
||||
return store.summaries.find((summary) => summary.roomId === roomId.trim());
|
||||
}
|
||||
|
||||
export function listChatRoomSummaries(store: ChatSummaryStoreSnapshot): ChatRoomSummary[] {
|
||||
return [...store.summaries].sort((a, b) => toSortableMs(b.updatedAt) - toSortableMs(a.updatedAt));
|
||||
}
|
||||
|
||||
export async function upsertChatRoomSummary(
|
||||
room: ChatRoom,
|
||||
messages: ChatMessage[],
|
||||
): Promise<ChatSummaryMutationResult> {
|
||||
const store = await loadChatSummaryStore();
|
||||
const summary = buildChatRoomSummary(room, messages);
|
||||
const existingIndex = store.summaries.findIndex((item) => item.roomId === room.roomId);
|
||||
if (existingIndex >= 0) {
|
||||
store.summaries[existingIndex] = summary;
|
||||
} else {
|
||||
store.summaries.push(summary);
|
||||
}
|
||||
store.updatedAt = summary.updatedAt;
|
||||
const path = await saveChatSummaryStore(store);
|
||||
return { path, summary };
|
||||
}
|
||||
|
||||
export function buildChatRoomSummary(room: ChatRoom, messages: ChatMessage[]): ChatRoomSummary {
|
||||
const ordered = [...messages].sort((a, b) => toSortableMs(a.createdAt) - toSortableMs(b.createdAt));
|
||||
const lastDecisionMessage = [...ordered]
|
||||
.reverse()
|
||||
.find((message) => message.kind === "decision" || message.payload?.decision);
|
||||
const lastProposalMessage = [...ordered]
|
||||
.reverse()
|
||||
.find((message) => message.kind === "proposal" || message.payload?.proposal);
|
||||
const latestHumanMessage = [...ordered].reverse().find((message) => message.authorRole === "human");
|
||||
const latestQuestion = extractQuestion(latestHumanMessage?.content ?? "");
|
||||
const openQuestions = latestQuestion ? [latestQuestion] : [];
|
||||
const headline = resolveHeadline(room, lastDecisionMessage, lastProposalMessage);
|
||||
const latestDecision = lastDecisionMessage?.payload?.decision ?? room.decision;
|
||||
const currentOwner = resolveCurrentOwner(room.stage, room.ownerRole, room.assignedExecutor);
|
||||
|
||||
return {
|
||||
roomId: room.roomId,
|
||||
headline,
|
||||
latestDecision,
|
||||
currentOwner,
|
||||
nextAction: buildNextAction(room.stage, room.assignedExecutor, latestDecision),
|
||||
openQuestions,
|
||||
messageCount: ordered.length,
|
||||
updatedAt: room.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveHeadline(
|
||||
room: ChatRoom,
|
||||
lastDecisionMessage: ChatMessage | undefined,
|
||||
lastProposalMessage: ChatMessage | undefined,
|
||||
): string {
|
||||
const decision = lastDecisionMessage?.payload?.decision ?? room.decision;
|
||||
if (decision) return decision;
|
||||
const proposal = lastProposalMessage?.payload?.proposal ?? room.proposal;
|
||||
if (proposal) return proposal;
|
||||
return room.title;
|
||||
}
|
||||
|
||||
function resolveCurrentOwner(
|
||||
stage: RoomStage,
|
||||
ownerRole: RoomParticipantRole,
|
||||
assignedExecutor?: RoomParticipantRole,
|
||||
): RoomParticipantRole {
|
||||
if ((stage === "assigned" || stage === "executing") && assignedExecutor) return assignedExecutor;
|
||||
return ownerRole;
|
||||
}
|
||||
|
||||
function buildNextAction(
|
||||
stage: RoomStage,
|
||||
assignedExecutor: RoomParticipantRole | undefined,
|
||||
latestDecision: string | undefined,
|
||||
): string {
|
||||
if (stage === "intake") return "Collect the first human request and begin discussion.";
|
||||
if (stage === "discussion") return "Finish planner/coder/reviewer discussion and capture a manager decision.";
|
||||
if (stage === "assigned") {
|
||||
return assignedExecutor
|
||||
? `${titleCaseRole(assignedExecutor)} should acknowledge the handoff and start execution.`
|
||||
: "Confirm the executor and acknowledge the handoff.";
|
||||
}
|
||||
if (stage === "executing") return "Keep posting execution status until the task is ready for review.";
|
||||
if (stage === "review") return "Approve or reject the execution result and sync the task state.";
|
||||
return latestDecision ? `Completed: ${latestDecision}` : "Completed and waiting for follow-up.";
|
||||
}
|
||||
|
||||
function extractQuestion(content: string): string | undefined {
|
||||
const trimmed = content.trim();
|
||||
if (!trimmed) return undefined;
|
||||
const match = trimmed.match(/([^??]+\?|[^??]+?)$/u);
|
||||
if (!match) return undefined;
|
||||
const question = match[1].trim();
|
||||
return question.length > 220 ? `${question.slice(0, 217)}...` : question;
|
||||
}
|
||||
|
||||
function titleCaseRole(role: RoomParticipantRole): string {
|
||||
if (role === "human") return "Operator";
|
||||
if (role === "planner") return "Planner";
|
||||
if (role === "coder") return "Coder";
|
||||
if (role === "reviewer") return "Reviewer";
|
||||
return "Manager";
|
||||
}
|
||||
|
||||
function normalizeChatSummaryStore(input: unknown): ChatSummaryStoreSnapshot {
|
||||
const obj = asObject(input);
|
||||
if (!obj) return cloneEmptySummaryStore();
|
||||
|
||||
return {
|
||||
summaries: normalizeSummaries(asArray(obj.summaries)),
|
||||
updatedAt: asIsoString(obj.updatedAt),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeSummaries(summaries: unknown[] | undefined): ChatRoomSummary[] {
|
||||
if (!summaries) return [];
|
||||
return summaries
|
||||
.map((summary) => normalizeSummary(summary))
|
||||
.filter((summary): summary is ChatRoomSummary => Boolean(summary))
|
||||
.sort((a, b) => toSortableMs(b.updatedAt) - toSortableMs(a.updatedAt));
|
||||
}
|
||||
|
||||
function normalizeSummary(input: unknown): ChatRoomSummary | null {
|
||||
const obj = asObject(input);
|
||||
if (!obj) return null;
|
||||
const roomId = asString(obj.roomId)?.trim();
|
||||
const headline = asString(obj.headline)?.trim();
|
||||
if (!roomId || !headline) return null;
|
||||
|
||||
return {
|
||||
roomId,
|
||||
headline,
|
||||
latestDecision: asString(obj.latestDecision)?.trim() || undefined,
|
||||
currentOwner: normalizeOptionalRole(asString(obj.currentOwner)),
|
||||
nextAction: asString(obj.nextAction)?.trim() || "Open the room and review the latest state.",
|
||||
openQuestions: toStringArray(obj.openQuestions, 220),
|
||||
messageCount: asFiniteNumber(obj.messageCount) ?? 0,
|
||||
updatedAt: asIsoString(obj.updatedAt),
|
||||
};
|
||||
}
|
||||
|
||||
function cloneEmptySummaryStore(): ChatSummaryStoreSnapshot {
|
||||
return {
|
||||
summaries: [],
|
||||
updatedAt: EMPTY_SUMMARY_STORE.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeOptionalRole(value: string | undefined): RoomParticipantRole | undefined {
|
||||
if (value === "human" || value === "planner" || value === "coder" || value === "reviewer" || value === "manager") {
|
||||
return value;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function toStringArray(value: unknown, maxLength: number): string[] {
|
||||
if (!Array.isArray(value)) return [];
|
||||
return [...new Set(
|
||||
value
|
||||
.filter((item): item is string => typeof item === "string")
|
||||
.map((item) => item.trim())
|
||||
.filter((item) => item.length > 0)
|
||||
.map((item) => (item.length > maxLength ? `${item.slice(0, maxLength - 3)}...` : item)),
|
||||
)];
|
||||
}
|
||||
|
||||
function asObject(value: unknown): Record<string, unknown> | undefined {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function asArray(value: unknown): unknown[] | undefined {
|
||||
return Array.isArray(value) ? value : undefined;
|
||||
}
|
||||
|
||||
function asString(value: unknown): string | undefined {
|
||||
return typeof value === "string" ? value : undefined;
|
||||
}
|
||||
|
||||
function asFiniteNumber(value: unknown): number | undefined {
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
||||
}
|
||||
|
||||
function asIsoString(value: unknown): string {
|
||||
if (typeof value === "string" && !Number.isNaN(Date.parse(value))) {
|
||||
return new Date(value).toISOString();
|
||||
}
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
function toSortableMs(value: string | undefined): number {
|
||||
const parsed = value ? Date.parse(value) : Number.NaN;
|
||||
return Number.isFinite(parsed) ? parsed : 0;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,249 @@
|
||||
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
||||
import { getRuntimeDir, resolveRuntimePath } from "./runtime-path";
|
||||
import type {
|
||||
CollaborationHall,
|
||||
CollaborationHallSummary,
|
||||
CollaborationHallSummaryStoreSnapshot,
|
||||
HallMessage,
|
||||
HallTaskCard,
|
||||
HallTaskSummary,
|
||||
} from "../types";
|
||||
|
||||
const RUNTIME_DIR = getRuntimeDir();
|
||||
export const COLLABORATION_HALL_SUMMARIES_PATH = resolveRuntimePath("collaboration-hall-summaries.json");
|
||||
|
||||
const EMPTY_SUMMARY_STORE: CollaborationHallSummaryStoreSnapshot = {
|
||||
hallSummaries: [],
|
||||
taskSummaries: [],
|
||||
updatedAt: "1970-01-01T00:00:00.000Z",
|
||||
};
|
||||
|
||||
export async function loadCollaborationHallSummaryStore(): Promise<CollaborationHallSummaryStoreSnapshot> {
|
||||
try {
|
||||
const raw = await readFile(COLLABORATION_HALL_SUMMARIES_PATH, "utf8");
|
||||
return normalizeSummaryStore(JSON.parse(raw));
|
||||
} catch {
|
||||
return cloneEmptySummaryStore();
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveCollaborationHallSummaryStore(
|
||||
next: CollaborationHallSummaryStoreSnapshot,
|
||||
): Promise<string> {
|
||||
const normalized = normalizeSummaryStore({
|
||||
...next,
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
await mkdir(RUNTIME_DIR, { recursive: true });
|
||||
const tempPath = `${COLLABORATION_HALL_SUMMARIES_PATH}.tmp-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
await writeFile(tempPath, JSON.stringify(normalized, null, 2), "utf8");
|
||||
await rename(tempPath, COLLABORATION_HALL_SUMMARIES_PATH);
|
||||
return COLLABORATION_HALL_SUMMARIES_PATH;
|
||||
}
|
||||
|
||||
export function getCollaborationHallSummary(
|
||||
store: CollaborationHallSummaryStoreSnapshot,
|
||||
hallId: string,
|
||||
): CollaborationHallSummary | undefined {
|
||||
return store.hallSummaries.find((summary) => summary.hallId === hallId.trim());
|
||||
}
|
||||
|
||||
export function getHallTaskSummary(
|
||||
store: CollaborationHallSummaryStoreSnapshot,
|
||||
taskCardId: string,
|
||||
): HallTaskSummary | undefined {
|
||||
return store.taskSummaries.find((summary) => summary.taskCardId === taskCardId.trim());
|
||||
}
|
||||
|
||||
export async function upsertCollaborationHallSummary(
|
||||
hall: CollaborationHall,
|
||||
messages: HallMessage[],
|
||||
taskCards: HallTaskCard[],
|
||||
): Promise<{ path: string; summary: CollaborationHallSummary }> {
|
||||
const store = await loadCollaborationHallSummaryStore();
|
||||
const summary = buildCollaborationHallSummary(hall, messages, taskCards);
|
||||
const index = store.hallSummaries.findIndex((item) => item.hallId === summary.hallId);
|
||||
if (index >= 0) store.hallSummaries[index] = summary;
|
||||
else store.hallSummaries.push(summary);
|
||||
store.updatedAt = summary.updatedAt;
|
||||
const path = await saveCollaborationHallSummaryStore(store);
|
||||
return { path, summary };
|
||||
}
|
||||
|
||||
export async function upsertHallTaskSummary(
|
||||
taskCard: HallTaskCard,
|
||||
messages: HallMessage[],
|
||||
): Promise<{ path: string; summary: HallTaskSummary }> {
|
||||
const store = await loadCollaborationHallSummaryStore();
|
||||
const summary = buildHallTaskSummary(taskCard, messages);
|
||||
const index = store.taskSummaries.findIndex((item) => item.taskCardId === summary.taskCardId);
|
||||
if (index >= 0) store.taskSummaries[index] = summary;
|
||||
else store.taskSummaries.push(summary);
|
||||
store.updatedAt = summary.updatedAt;
|
||||
const path = await saveCollaborationHallSummaryStore(store);
|
||||
return { path, summary };
|
||||
}
|
||||
|
||||
export function buildCollaborationHallSummary(
|
||||
hall: CollaborationHall,
|
||||
messages: HallMessage[],
|
||||
taskCards: HallTaskCard[],
|
||||
): CollaborationHallSummary {
|
||||
const orderedMessages = [...messages].sort((a, b) => Date.parse(a.createdAt) - Date.parse(b.createdAt));
|
||||
const lastMessage = orderedMessages.at(-1);
|
||||
const activeTaskCount = taskCards.filter((card) => card.stage !== "completed" && card.stage !== "blocked").length;
|
||||
const waitingReviewCount = taskCards.filter((card) => card.stage === "review").length;
|
||||
const blockedTaskCount = taskCards.filter((card) => card.stage === "blocked").length;
|
||||
const headline =
|
||||
lastMessage?.content ??
|
||||
(activeTaskCount > 0
|
||||
? `Hall is tracking ${activeTaskCount} active task${activeTaskCount === 1 ? "" : "s"}.`
|
||||
: "The hall is ready for the next request.");
|
||||
|
||||
return {
|
||||
hallId: hall.hallId,
|
||||
headline: headline.length > 220 ? `${headline.slice(0, 217)}...` : headline,
|
||||
activeTaskCount,
|
||||
waitingReviewCount,
|
||||
blockedTaskCount,
|
||||
currentSpeakerLabel: lastMessage?.authorLabel,
|
||||
updatedAt: hall.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildHallTaskSummary(taskCard: HallTaskCard, messages: HallMessage[]): HallTaskSummary {
|
||||
const scopedMessages = messages
|
||||
.filter((message) => message.taskCardId === taskCard.taskCardId || message.taskId === taskCard.taskId)
|
||||
.sort((a, b) => Date.parse(a.createdAt) - Date.parse(b.createdAt));
|
||||
const lastSignal = scopedMessages.at(-1);
|
||||
const headline =
|
||||
taskCard.decision ??
|
||||
taskCard.proposal ??
|
||||
taskCard.latestSummary ??
|
||||
lastSignal?.content ??
|
||||
taskCard.title;
|
||||
|
||||
const nextAction =
|
||||
taskCard.stage === "discussion"
|
||||
? taskCard.plannedExecutionOrder.length > 0
|
||||
? `Finish discussion, then confirm whether the execution order should start with ${taskCard.plannedExecutionOrder[0]}.`
|
||||
: "Finish discussion and let the manager close with a decision."
|
||||
: taskCard.stage === "execution"
|
||||
? taskCard.plannedExecutionOrder.length > 0
|
||||
? `${taskCard.currentOwnerLabel ?? "Assigned owner"} should keep posting execution updates, then hand off to ${taskCard.plannedExecutionOrder[0]}.`
|
||||
: `${taskCard.currentOwnerLabel ?? "Assigned owner"} should keep posting execution updates.`
|
||||
: taskCard.stage === "review"
|
||||
? "Reviewer should approve or reject the current result."
|
||||
: taskCard.stage === "blocked"
|
||||
? "Resolve the blockers or hand the task to a new owner."
|
||||
: "Completed. Review the final evidence if needed.";
|
||||
|
||||
return {
|
||||
taskCardId: taskCard.taskCardId,
|
||||
projectId: taskCard.projectId,
|
||||
taskId: taskCard.taskId,
|
||||
headline: headline.length > 220 ? `${headline.slice(0, 217)}...` : headline,
|
||||
currentOwnerLabel: taskCard.currentOwnerLabel,
|
||||
nextAction,
|
||||
stage: taskCard.stage,
|
||||
blockerCount: taskCard.blockers.length,
|
||||
updatedAt: taskCard.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeSummaryStore(input: unknown): CollaborationHallSummaryStoreSnapshot {
|
||||
const root = asObject(input);
|
||||
if (!root) return cloneEmptySummaryStore();
|
||||
return {
|
||||
hallSummaries: asArray(root.hallSummaries)
|
||||
.map((item) => normalizeHallSummary(item))
|
||||
.filter((item): item is CollaborationHallSummary => Boolean(item)),
|
||||
taskSummaries: asArray(root.taskSummaries)
|
||||
.map((item) => normalizeTaskSummary(item))
|
||||
.filter((item): item is HallTaskSummary => Boolean(item)),
|
||||
updatedAt: normalizeIsoString(root.updatedAt) ?? EMPTY_SUMMARY_STORE.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeHallSummary(input: unknown): CollaborationHallSummary | undefined {
|
||||
const root = asObject(input);
|
||||
if (!root) return undefined;
|
||||
const hallId = asNonEmptyString(root.hallId);
|
||||
const headline = asNonEmptyString(root.headline);
|
||||
const updatedAt = normalizeIsoString(root.updatedAt);
|
||||
if (!hallId || !headline || !updatedAt) return undefined;
|
||||
return {
|
||||
hallId,
|
||||
headline,
|
||||
activeTaskCount: asFiniteNumber(root.activeTaskCount) ?? 0,
|
||||
waitingReviewCount: asFiniteNumber(root.waitingReviewCount) ?? 0,
|
||||
blockedTaskCount: asFiniteNumber(root.blockedTaskCount) ?? 0,
|
||||
currentSpeakerLabel: asNonEmptyString(root.currentSpeakerLabel),
|
||||
updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeTaskSummary(input: unknown): HallTaskSummary | undefined {
|
||||
const root = asObject(input);
|
||||
if (!root) return undefined;
|
||||
const taskCardId = asNonEmptyString(root.taskCardId);
|
||||
const projectId = asNonEmptyString(root.projectId);
|
||||
const taskId = asNonEmptyString(root.taskId);
|
||||
const headline = asNonEmptyString(root.headline);
|
||||
const stage = root.stage;
|
||||
const updatedAt = normalizeIsoString(root.updatedAt);
|
||||
if (
|
||||
!taskCardId ||
|
||||
!projectId ||
|
||||
!taskId ||
|
||||
!headline ||
|
||||
(stage !== "discussion" && stage !== "execution" && stage !== "review" && stage !== "blocked" && stage !== "completed") ||
|
||||
!updatedAt
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
taskCardId,
|
||||
projectId,
|
||||
taskId,
|
||||
headline,
|
||||
currentOwnerLabel: asNonEmptyString(root.currentOwnerLabel),
|
||||
nextAction: asNonEmptyString(root.nextAction) ?? "Open the task card and inspect the latest state.",
|
||||
stage,
|
||||
blockerCount: asFiniteNumber(root.blockerCount) ?? 0,
|
||||
updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
function cloneEmptySummaryStore(): CollaborationHallSummaryStoreSnapshot {
|
||||
return {
|
||||
hallSummaries: [],
|
||||
taskSummaries: [],
|
||||
updatedAt: EMPTY_SUMMARY_STORE.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
function asObject(value: unknown): Record<string, unknown> | undefined {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function asArray(value: unknown): unknown[] {
|
||||
return Array.isArray(value) ? value : [];
|
||||
}
|
||||
|
||||
function asNonEmptyString(value: unknown): string | undefined {
|
||||
return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
|
||||
}
|
||||
|
||||
function asFiniteNumber(value: unknown): number | undefined {
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
||||
}
|
||||
|
||||
function normalizeIsoString(value: unknown): string | undefined {
|
||||
if (typeof value !== "string") return undefined;
|
||||
const parsed = Date.parse(value);
|
||||
if (!Number.isFinite(parsed)) return undefined;
|
||||
return new Date(parsed).toISOString();
|
||||
}
|
||||
@@ -0,0 +1,464 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||
import type { HallMessageKind, RoomParticipantRole } from "../types";
|
||||
|
||||
export type CollaborationStreamScope = "hall" | "room";
|
||||
export type CollaborationStreamEventType =
|
||||
| "connected"
|
||||
| "invalidate"
|
||||
| "draft_start"
|
||||
| "draft_delta"
|
||||
| "draft_complete"
|
||||
| "draft_abort";
|
||||
|
||||
export interface CollaborationStreamEvent {
|
||||
eventId: string;
|
||||
scope: CollaborationStreamScope;
|
||||
type: CollaborationStreamEventType;
|
||||
createdAt: string;
|
||||
hallId?: string;
|
||||
roomId?: string;
|
||||
taskCardId?: string;
|
||||
projectId?: string;
|
||||
taskId?: string;
|
||||
reason?: string;
|
||||
draftId?: string;
|
||||
messageId?: string;
|
||||
authorParticipantId?: string;
|
||||
authorLabel?: string;
|
||||
authorSemanticRole?: string;
|
||||
authorRole?: RoomParticipantRole;
|
||||
messageKind?: HallMessageKind | "chat" | "proposal" | "decision" | "handoff" | "status" | "result";
|
||||
delta?: string;
|
||||
content?: string;
|
||||
}
|
||||
|
||||
interface StreamSubscriber {
|
||||
subscriberId: string;
|
||||
res: ServerResponse;
|
||||
heartbeat: NodeJS.Timeout;
|
||||
}
|
||||
|
||||
interface HallDraftStreamInput {
|
||||
hallId: string;
|
||||
taskCardId?: string;
|
||||
projectId?: string;
|
||||
taskId?: string;
|
||||
roomId?: string;
|
||||
authorParticipantId: string;
|
||||
authorLabel: string;
|
||||
authorSemanticRole?: string;
|
||||
messageKind: CollaborationStreamEvent["messageKind"];
|
||||
content: string;
|
||||
}
|
||||
|
||||
interface RoomDraftStreamInput {
|
||||
roomId: string;
|
||||
projectId?: string;
|
||||
taskId?: string;
|
||||
authorRole: RoomParticipantRole;
|
||||
authorLabel: string;
|
||||
messageKind: CollaborationStreamEvent["messageKind"];
|
||||
content: string;
|
||||
}
|
||||
|
||||
interface ActiveHallDraft {
|
||||
hallId: string;
|
||||
taskCardId?: string;
|
||||
projectId?: string;
|
||||
taskId?: string;
|
||||
roomId?: string;
|
||||
}
|
||||
|
||||
const HEARTBEAT_MS = 15_000;
|
||||
const hallSubscribers = new Map<string, Map<string, StreamSubscriber>>();
|
||||
const roomSubscribers = new Map<string, Map<string, StreamSubscriber>>();
|
||||
const activeHallDrafts = new Map<string, ActiveHallDraft>();
|
||||
const canceledHallDrafts = new Set<string>();
|
||||
|
||||
export function openHallEventStream(
|
||||
req: IncomingMessage,
|
||||
res: ServerResponse,
|
||||
hallId: string,
|
||||
): void {
|
||||
openEventStream(req, res, hallSubscribers, hallId, {
|
||||
eventId: randomUUID(),
|
||||
scope: "hall",
|
||||
type: "connected",
|
||||
createdAt: new Date().toISOString(),
|
||||
hallId,
|
||||
});
|
||||
}
|
||||
|
||||
export function openRoomEventStream(
|
||||
req: IncomingMessage,
|
||||
res: ServerResponse,
|
||||
roomId: string,
|
||||
): void {
|
||||
openEventStream(req, res, roomSubscribers, roomId, {
|
||||
eventId: randomUUID(),
|
||||
scope: "room",
|
||||
type: "connected",
|
||||
createdAt: new Date().toISOString(),
|
||||
roomId,
|
||||
});
|
||||
}
|
||||
|
||||
export function publishHallStreamEvent(
|
||||
event: Omit<CollaborationStreamEvent, "eventId" | "scope" | "createdAt"> & { hallId: string },
|
||||
): void {
|
||||
publishScopedEvent(hallSubscribers, event.hallId, {
|
||||
eventId: randomUUID(),
|
||||
scope: "hall",
|
||||
createdAt: new Date().toISOString(),
|
||||
...event,
|
||||
});
|
||||
}
|
||||
|
||||
export function publishRoomStreamEvent(
|
||||
event: Omit<CollaborationStreamEvent, "eventId" | "scope" | "createdAt"> & { roomId: string },
|
||||
): void {
|
||||
publishScopedEvent(roomSubscribers, event.roomId, {
|
||||
eventId: randomUUID(),
|
||||
scope: "room",
|
||||
createdAt: new Date().toISOString(),
|
||||
...event,
|
||||
});
|
||||
}
|
||||
|
||||
export async function streamHallDraftReply(input: HallDraftStreamInput): Promise<string> {
|
||||
const draftId = beginHallDraftReply(input);
|
||||
for (const delta of chunkDraftContent(input.content)) {
|
||||
if (isHallDraftCanceled(draftId)) break;
|
||||
pushHallDraftDelta({
|
||||
hallId: input.hallId,
|
||||
taskCardId: input.taskCardId,
|
||||
projectId: input.projectId,
|
||||
taskId: input.taskId,
|
||||
roomId: input.roomId,
|
||||
draftId,
|
||||
authorParticipantId: input.authorParticipantId,
|
||||
authorLabel: input.authorLabel,
|
||||
authorSemanticRole: input.authorSemanticRole,
|
||||
messageKind: input.messageKind,
|
||||
delta,
|
||||
});
|
||||
await yieldStreamTurn();
|
||||
}
|
||||
return draftId;
|
||||
}
|
||||
|
||||
export function completeHallDraftReply(input: {
|
||||
hallId: string;
|
||||
taskCardId?: string;
|
||||
projectId?: string;
|
||||
taskId?: string;
|
||||
roomId?: string;
|
||||
draftId: string;
|
||||
messageId?: string;
|
||||
content: string;
|
||||
}): void {
|
||||
if (isHallDraftCanceled(input.draftId)) {
|
||||
activeHallDrafts.delete(input.draftId);
|
||||
canceledHallDrafts.delete(input.draftId);
|
||||
return;
|
||||
}
|
||||
activeHallDrafts.delete(input.draftId);
|
||||
publishHallStreamEvent({
|
||||
type: "draft_complete",
|
||||
hallId: input.hallId,
|
||||
taskCardId: input.taskCardId,
|
||||
projectId: input.projectId,
|
||||
taskId: input.taskId,
|
||||
roomId: input.roomId,
|
||||
draftId: input.draftId,
|
||||
messageId: input.messageId,
|
||||
content: input.content,
|
||||
});
|
||||
}
|
||||
|
||||
export function abortHallDraftReply(input: {
|
||||
hallId: string;
|
||||
taskCardId?: string;
|
||||
projectId?: string;
|
||||
taskId?: string;
|
||||
roomId?: string;
|
||||
draftId: string;
|
||||
reason?: string;
|
||||
}): void {
|
||||
activeHallDrafts.delete(input.draftId);
|
||||
canceledHallDrafts.add(input.draftId);
|
||||
publishHallStreamEvent({
|
||||
type: "draft_abort",
|
||||
hallId: input.hallId,
|
||||
taskCardId: input.taskCardId,
|
||||
projectId: input.projectId,
|
||||
taskId: input.taskId,
|
||||
roomId: input.roomId,
|
||||
draftId: input.draftId,
|
||||
reason: input.reason ?? "aborted",
|
||||
});
|
||||
}
|
||||
|
||||
export async function streamRoomDraftReply(input: RoomDraftStreamInput): Promise<string> {
|
||||
const draftId = beginRoomDraftReply(input);
|
||||
for (const delta of chunkDraftContent(input.content)) {
|
||||
pushRoomDraftDelta({
|
||||
roomId: input.roomId,
|
||||
projectId: input.projectId,
|
||||
taskId: input.taskId,
|
||||
draftId,
|
||||
authorRole: input.authorRole,
|
||||
authorLabel: input.authorLabel,
|
||||
messageKind: input.messageKind,
|
||||
delta,
|
||||
});
|
||||
await yieldStreamTurn();
|
||||
}
|
||||
return draftId;
|
||||
}
|
||||
|
||||
export function completeRoomDraftReply(input: {
|
||||
roomId: string;
|
||||
projectId?: string;
|
||||
taskId?: string;
|
||||
draftId: string;
|
||||
messageId?: string;
|
||||
content: string;
|
||||
}): void {
|
||||
publishRoomStreamEvent({
|
||||
type: "draft_complete",
|
||||
roomId: input.roomId,
|
||||
projectId: input.projectId,
|
||||
taskId: input.taskId,
|
||||
draftId: input.draftId,
|
||||
messageId: input.messageId,
|
||||
content: input.content,
|
||||
});
|
||||
}
|
||||
|
||||
export function beginHallDraftReply(input: HallDraftStreamInput): string {
|
||||
const draftId = randomUUID();
|
||||
canceledHallDrafts.delete(draftId);
|
||||
activeHallDrafts.set(draftId, {
|
||||
hallId: input.hallId,
|
||||
taskCardId: input.taskCardId,
|
||||
projectId: input.projectId,
|
||||
taskId: input.taskId,
|
||||
roomId: input.roomId,
|
||||
});
|
||||
publishHallStreamEvent({
|
||||
type: "draft_start",
|
||||
hallId: input.hallId,
|
||||
taskCardId: input.taskCardId,
|
||||
projectId: input.projectId,
|
||||
taskId: input.taskId,
|
||||
roomId: input.roomId,
|
||||
draftId,
|
||||
authorParticipantId: input.authorParticipantId,
|
||||
authorLabel: input.authorLabel,
|
||||
authorSemanticRole: input.authorSemanticRole,
|
||||
messageKind: input.messageKind,
|
||||
content: "",
|
||||
});
|
||||
return draftId;
|
||||
}
|
||||
|
||||
export function isHallDraftCanceled(draftId: string): boolean {
|
||||
return canceledHallDrafts.has(draftId);
|
||||
}
|
||||
|
||||
export function abortHallDraftRepliesForTask(input: {
|
||||
hallId: string;
|
||||
taskCardId?: string;
|
||||
projectId?: string;
|
||||
taskId?: string;
|
||||
roomId?: string;
|
||||
reason?: string;
|
||||
}): string[] {
|
||||
const abortedDraftIds: string[] = [];
|
||||
for (const [draftId, draft] of activeHallDrafts.entries()) {
|
||||
if (draft.hallId !== input.hallId) continue;
|
||||
const matchesTaskCard = input.taskCardId && draft.taskCardId === input.taskCardId;
|
||||
const matchesProjectTask = input.projectId && input.taskId && draft.projectId === input.projectId && draft.taskId === input.taskId;
|
||||
const matchesRoom = input.roomId && draft.roomId === input.roomId;
|
||||
if (!matchesTaskCard && !matchesProjectTask && !matchesRoom) continue;
|
||||
abortHallDraftReply({
|
||||
hallId: draft.hallId,
|
||||
taskCardId: draft.taskCardId,
|
||||
projectId: draft.projectId,
|
||||
taskId: draft.taskId,
|
||||
roomId: draft.roomId,
|
||||
draftId,
|
||||
reason: input.reason ?? "aborted_by_operator",
|
||||
});
|
||||
abortedDraftIds.push(draftId);
|
||||
}
|
||||
return abortedDraftIds;
|
||||
}
|
||||
|
||||
export function pushHallDraftDelta(input: {
|
||||
hallId: string;
|
||||
taskCardId?: string;
|
||||
projectId?: string;
|
||||
taskId?: string;
|
||||
roomId?: string;
|
||||
draftId: string;
|
||||
authorParticipantId: string;
|
||||
authorLabel: string;
|
||||
authorSemanticRole?: string;
|
||||
messageKind: CollaborationStreamEvent["messageKind"];
|
||||
delta: string;
|
||||
}): void {
|
||||
if (!input.delta) return;
|
||||
publishHallStreamEvent({
|
||||
type: "draft_delta",
|
||||
hallId: input.hallId,
|
||||
taskCardId: input.taskCardId,
|
||||
projectId: input.projectId,
|
||||
taskId: input.taskId,
|
||||
roomId: input.roomId,
|
||||
draftId: input.draftId,
|
||||
authorParticipantId: input.authorParticipantId,
|
||||
authorLabel: input.authorLabel,
|
||||
authorSemanticRole: input.authorSemanticRole,
|
||||
messageKind: input.messageKind,
|
||||
delta: input.delta,
|
||||
});
|
||||
}
|
||||
|
||||
export function beginRoomDraftReply(input: RoomDraftStreamInput): string {
|
||||
const draftId = randomUUID();
|
||||
publishRoomStreamEvent({
|
||||
type: "draft_start",
|
||||
roomId: input.roomId,
|
||||
projectId: input.projectId,
|
||||
taskId: input.taskId,
|
||||
draftId,
|
||||
authorRole: input.authorRole,
|
||||
authorLabel: input.authorLabel,
|
||||
messageKind: input.messageKind,
|
||||
content: "",
|
||||
});
|
||||
return draftId;
|
||||
}
|
||||
|
||||
export function pushRoomDraftDelta(input: {
|
||||
roomId: string;
|
||||
projectId?: string;
|
||||
taskId?: string;
|
||||
draftId: string;
|
||||
authorRole: RoomParticipantRole;
|
||||
authorLabel: string;
|
||||
messageKind: CollaborationStreamEvent["messageKind"];
|
||||
delta: string;
|
||||
}): void {
|
||||
if (!input.delta) return;
|
||||
publishRoomStreamEvent({
|
||||
type: "draft_delta",
|
||||
roomId: input.roomId,
|
||||
projectId: input.projectId,
|
||||
taskId: input.taskId,
|
||||
draftId: input.draftId,
|
||||
authorRole: input.authorRole,
|
||||
authorLabel: input.authorLabel,
|
||||
messageKind: input.messageKind,
|
||||
delta: input.delta,
|
||||
});
|
||||
}
|
||||
|
||||
function openEventStream(
|
||||
req: IncomingMessage,
|
||||
res: ServerResponse,
|
||||
registry: Map<string, Map<string, StreamSubscriber>>,
|
||||
targetId: string,
|
||||
connectedEvent: CollaborationStreamEvent,
|
||||
): void {
|
||||
res.writeHead(200, {
|
||||
"content-type": "text/event-stream; charset=utf-8",
|
||||
"cache-control": "no-store, no-cache, must-revalidate, max-age=0",
|
||||
connection: "keep-alive",
|
||||
"x-accel-buffering": "no",
|
||||
});
|
||||
res.flushHeaders?.();
|
||||
res.write(`retry: 1000\n\n`);
|
||||
|
||||
const subscriberId = randomUUID();
|
||||
const scoped = registry.get(targetId) ?? new Map<string, StreamSubscriber>();
|
||||
const heartbeat = setInterval(() => {
|
||||
try {
|
||||
res.write(`: keep-alive ${Date.now()}\n\n`);
|
||||
} catch {
|
||||
cleanup();
|
||||
}
|
||||
}, HEARTBEAT_MS);
|
||||
const subscriber: StreamSubscriber = { subscriberId, res, heartbeat };
|
||||
scoped.set(subscriberId, subscriber);
|
||||
registry.set(targetId, scoped);
|
||||
writeStreamEvent(res, connectedEvent);
|
||||
|
||||
const cleanup = () => {
|
||||
clearInterval(heartbeat);
|
||||
const current = registry.get(targetId);
|
||||
if (!current) return;
|
||||
current.delete(subscriberId);
|
||||
if (current.size === 0) registry.delete(targetId);
|
||||
};
|
||||
|
||||
req.on("close", cleanup);
|
||||
res.on("close", cleanup);
|
||||
res.on("error", cleanup);
|
||||
}
|
||||
|
||||
function publishScopedEvent(
|
||||
registry: Map<string, Map<string, StreamSubscriber>>,
|
||||
targetId: string,
|
||||
event: CollaborationStreamEvent,
|
||||
): void {
|
||||
const scoped = registry.get(targetId);
|
||||
if (!scoped || scoped.size === 0) return;
|
||||
for (const subscriber of scoped.values()) {
|
||||
try {
|
||||
writeStreamEvent(subscriber.res, event);
|
||||
} catch {
|
||||
clearInterval(subscriber.heartbeat);
|
||||
scoped.delete(subscriber.subscriberId);
|
||||
}
|
||||
}
|
||||
if (scoped.size === 0) registry.delete(targetId);
|
||||
}
|
||||
|
||||
function writeStreamEvent(res: ServerResponse, event: CollaborationStreamEvent): void {
|
||||
res.write(`event: collaboration\nid: ${event.eventId}\ndata: ${JSON.stringify(event)}\n\n`);
|
||||
}
|
||||
|
||||
function chunkDraftContent(content: string): string[] {
|
||||
const trimmed = content.trim();
|
||||
if (!trimmed) return [""];
|
||||
const chunks: string[] = [];
|
||||
let current = "";
|
||||
for (const token of trimmed.split(/(\s+)/).filter(Boolean)) {
|
||||
if (token.length > 48) {
|
||||
if (current) {
|
||||
chunks.push(current);
|
||||
current = "";
|
||||
}
|
||||
for (let index = 0; index < token.length; index += 32) {
|
||||
chunks.push(token.slice(index, index + 32));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if ((current + token).length > 48 && current) {
|
||||
chunks.push(current);
|
||||
current = token;
|
||||
continue;
|
||||
}
|
||||
current += token;
|
||||
}
|
||||
if (current) chunks.push(current);
|
||||
return chunks.length > 0 ? chunks : [trimmed];
|
||||
}
|
||||
|
||||
function yieldStreamTurn(): Promise<void> {
|
||||
return new Promise((resolve) => setImmediate(resolve));
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
export type HallDiscussionDomain =
|
||||
| "engineering"
|
||||
| "creative"
|
||||
| "analysis"
|
||||
| "product"
|
||||
| "research"
|
||||
| "operations"
|
||||
| "general";
|
||||
|
||||
interface DomainSignal {
|
||||
pattern: RegExp;
|
||||
weight: number;
|
||||
}
|
||||
|
||||
const DOMAIN_SIGNAL_MAP: Record<Exclude<HallDiscussionDomain, "general">, DomainSignal[]> = {
|
||||
engineering: [
|
||||
{ pattern: /(代码|编程|开发|工程|接口|api|bug|debug|fix|implement|build|frontend|backend|repo|system)/i, weight: 4 },
|
||||
{ pattern: /(测试|test|lint|deploy|发布脚本|服务端|前端页面|数据库|schema|migration)/i, weight: 3 },
|
||||
],
|
||||
creative: [
|
||||
{ pattern: /(动画|动效|animation|motion|storyboard|分镜|脚本|style frame|视觉语言|品牌|创意)/i, weight: 4 },
|
||||
{ pattern: /(海报|设计稿|视觉稿|art direction|创意方向|叙事体验)/i, weight: 3 },
|
||||
{ pattern: /(visual|可视化)/i, weight: 1 },
|
||||
],
|
||||
analysis: [
|
||||
{ pattern: /(数据|图表|dashboard|指标|分析|insight|metric|analytics)/i, weight: 3 },
|
||||
{ pattern: /(可视化|visualization|narrative|storytelling with data)/i, weight: 2 },
|
||||
],
|
||||
product: [
|
||||
{ pattern: /(产品|发布|roadmap|增长|launch|go[- ]to[- ]market|workflow|feature|scope|success criteria)/i, weight: 3 },
|
||||
{ pattern: /(用户|体验|user problem|需求范围|优先级|产品方向)/i, weight: 1 },
|
||||
],
|
||||
research: [
|
||||
{ pattern: /(调研|research|benchmark|study|compare|调查|访谈|洞察|评估|研究问题)/i, weight: 4 },
|
||||
{ pattern: /(证据|evidence|结论结构|假设|synthesis|调查视角|研究框架)/i, weight: 3 },
|
||||
],
|
||||
operations: [
|
||||
{ pattern: /(运营|流程|排期|runbook|support|审批|交接|上线|通知|治理)/i, weight: 3 },
|
||||
{ pattern: /(handoff|handover|流程设计|责任边界|试运行|值班|incident)/i, weight: 2 },
|
||||
],
|
||||
};
|
||||
|
||||
const DOMAIN_TIE_BREAK_ORDER: HallDiscussionDomain[] = [
|
||||
"engineering",
|
||||
"research",
|
||||
"creative",
|
||||
"analysis",
|
||||
"product",
|
||||
"operations",
|
||||
"general",
|
||||
];
|
||||
|
||||
export function inferHallDiscussionDomainFromText(source: string): HallDiscussionDomain {
|
||||
const normalized = source.toLowerCase();
|
||||
const scored = Object.entries(DOMAIN_SIGNAL_MAP).map(([domain, signals]) => ({
|
||||
domain: domain as HallDiscussionDomain,
|
||||
score: signals.reduce((total, signal) => total + (signal.pattern.test(normalized) ? signal.weight : 0), 0),
|
||||
}));
|
||||
const bestScore = scored.reduce((max, item) => Math.max(max, item.score), 0);
|
||||
if (bestScore <= 0) return "general";
|
||||
const candidates = scored
|
||||
.filter((item) => item.score === bestScore)
|
||||
.map((item) => item.domain);
|
||||
return DOMAIN_TIE_BREAK_ORDER.find((domain) => candidates.includes(domain)) ?? "general";
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import type { ExecutionLock, HallTaskCard } from "../types";
|
||||
|
||||
export class HallExecutionLockError extends Error {
|
||||
readonly statusCode: number;
|
||||
|
||||
constructor(message: string, statusCode = 409) {
|
||||
super(message);
|
||||
this.name = "HallExecutionLockError";
|
||||
this.statusCode = statusCode;
|
||||
}
|
||||
}
|
||||
|
||||
export function acquireHallExecutionLock(
|
||||
taskCard: HallTaskCard,
|
||||
input: { ownerParticipantId: string; ownerLabel: string; at?: string },
|
||||
): HallTaskCard {
|
||||
const at = input.at ?? new Date().toISOString();
|
||||
const activeLock = taskCard.executionLock && !taskCard.executionLock.releasedAt ? taskCard.executionLock : undefined;
|
||||
if (activeLock && activeLock.ownerParticipantId !== input.ownerParticipantId) {
|
||||
throw new HallExecutionLockError(
|
||||
`${activeLock.ownerLabel} already holds execution for ${taskCard.projectId}:${taskCard.taskId}.`,
|
||||
);
|
||||
}
|
||||
const executionLock: ExecutionLock = {
|
||||
taskId: taskCard.taskId,
|
||||
projectId: taskCard.projectId,
|
||||
ownerParticipantId: input.ownerParticipantId,
|
||||
ownerLabel: input.ownerLabel,
|
||||
acquiredAt: activeLock?.acquiredAt ?? at,
|
||||
};
|
||||
return {
|
||||
...taskCard,
|
||||
stage: "execution",
|
||||
currentOwnerParticipantId: input.ownerParticipantId,
|
||||
currentOwnerLabel: input.ownerLabel,
|
||||
executionLock,
|
||||
updatedAt: at,
|
||||
};
|
||||
}
|
||||
|
||||
export function releaseHallExecutionLock(
|
||||
taskCard: HallTaskCard,
|
||||
reason: string,
|
||||
at = new Date().toISOString(),
|
||||
): HallTaskCard {
|
||||
const activeLock = taskCard.executionLock && !taskCard.executionLock.releasedAt ? taskCard.executionLock : undefined;
|
||||
if (!activeLock) return taskCard;
|
||||
return {
|
||||
...taskCard,
|
||||
executionLock: {
|
||||
...activeLock,
|
||||
releasedAt: at,
|
||||
releasedReason: reason,
|
||||
},
|
||||
updatedAt: at,
|
||||
};
|
||||
}
|
||||
|
||||
export function assertHallExecutionAllowed(taskCard: HallTaskCard, participantId: string): void {
|
||||
const activeLock = taskCard.executionLock && !taskCard.executionLock.releasedAt ? taskCard.executionLock : undefined;
|
||||
if (taskCard.stage !== "execution") return;
|
||||
if (!activeLock) return;
|
||||
if (activeLock.ownerParticipantId !== participantId) {
|
||||
throw new HallExecutionLockError(
|
||||
`${activeLock.ownerLabel} currently owns execution for ${taskCard.projectId}:${taskCard.taskId}.`,
|
||||
403,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import type { StructuredHandoffPacket, TaskArtifact } from "../types";
|
||||
|
||||
export type HallHandoffSummaryLanguage = "en" | "zh";
|
||||
|
||||
export class HallHandoffValidationError extends Error {
|
||||
readonly issues: string[];
|
||||
readonly statusCode: number;
|
||||
|
||||
constructor(message: string, issues: string[] = [], statusCode = 400) {
|
||||
super(message);
|
||||
this.name = "HallHandoffValidationError";
|
||||
this.issues = issues;
|
||||
this.statusCode = statusCode;
|
||||
}
|
||||
}
|
||||
|
||||
export interface CreateStructuredHandoffInput {
|
||||
goal: string;
|
||||
currentResult: string;
|
||||
doneWhen: string;
|
||||
blockers?: string[];
|
||||
nextOwner: string;
|
||||
requiresInputFrom?: string[];
|
||||
artifactRefs?: TaskArtifact[];
|
||||
}
|
||||
|
||||
export function buildStructuredHandoffPacket(input: CreateStructuredHandoffInput): StructuredHandoffPacket {
|
||||
const issues: string[] = [];
|
||||
const goal = requireText(input.goal, "goal", 240, issues);
|
||||
const currentResult = requireText(input.currentResult, "currentResult", 500, issues);
|
||||
const doneWhen = requireText(input.doneWhen, "doneWhen", 240, issues);
|
||||
const nextOwner = requireText(input.nextOwner, "nextOwner", 120, issues);
|
||||
const blockers = normalizeStringArray(input.blockers, "blockers", 240, issues);
|
||||
const requiresInputFrom = normalizeStringArray(input.requiresInputFrom, "requiresInputFrom", 120, issues);
|
||||
const artifactRefs = normalizeArtifactRefs(input.artifactRefs, "artifactRefs", issues);
|
||||
|
||||
if (issues.length > 0) {
|
||||
throw new HallHandoffValidationError("Invalid structured handoff payload.", issues);
|
||||
}
|
||||
|
||||
return {
|
||||
goal,
|
||||
currentResult,
|
||||
doneWhen,
|
||||
blockers,
|
||||
nextOwner,
|
||||
requiresInputFrom,
|
||||
artifactRefs,
|
||||
};
|
||||
}
|
||||
|
||||
export function summarizeStructuredHandoff(
|
||||
packet: StructuredHandoffPacket,
|
||||
options: { language?: HallHandoffSummaryLanguage; includeMention?: boolean } = {},
|
||||
): string {
|
||||
const language = options.language ?? "en";
|
||||
const nextOwner = options.includeMention === false ? packet.nextOwner : `@${packet.nextOwner}`;
|
||||
const shortResult = shortenHandoffCopy(packet.currentResult, language === "zh" ? 54 : 120);
|
||||
|
||||
if (language === "zh") {
|
||||
const blockers = packet.blockers.length > 0 ? ` 卡点:${packet.blockers.join(";")}。` : "";
|
||||
const requires = packet.requiresInputFrom.length > 0
|
||||
? ` 还需要 ${packet.requiresInputFrom.join("、")} 配合。`
|
||||
: "";
|
||||
const artifacts = packet.artifactRefs && packet.artifactRefs.length > 0
|
||||
? ` 先看产物:${packet.artifactRefs.map((artifact) => artifact.label).join("、")}。`
|
||||
: "";
|
||||
return `${nextOwner} 接棒:先做“${packet.goal}”。现在手里有:${shortResult}。做到“${packet.doneWhen}”后继续往下交。${artifacts}${blockers}${requires}`;
|
||||
}
|
||||
|
||||
const blockers = packet.blockers.length > 0 ? ` Blockers: ${packet.blockers.join("; ")}.` : "";
|
||||
const requires = packet.requiresInputFrom.length > 0
|
||||
? ` Needs input from: ${packet.requiresInputFrom.join(", ")}.`
|
||||
: "";
|
||||
const artifacts = packet.artifactRefs && packet.artifactRefs.length > 0
|
||||
? ` Start from these artifacts: ${packet.artifactRefs.map((artifact) => artifact.label).join(", ")}.`
|
||||
: "";
|
||||
return `${nextOwner} takes this next: ${packet.goal}. Current result: ${shortResult}. Done when ${packet.doneWhen}.${artifacts}${blockers}${requires}`;
|
||||
}
|
||||
|
||||
function shortenHandoffCopy(value: string, maxLength: number): string {
|
||||
const trimmed = value.replace(/\s+/g, " ").trim();
|
||||
if (!trimmed) return "";
|
||||
return trimmed.length > maxLength ? `${trimmed.slice(0, Math.max(0, maxLength - 1)).trim()}…` : trimmed;
|
||||
}
|
||||
|
||||
function requireText(value: string, field: string, maxLength: number, issues: string[]): string {
|
||||
const trimmed = typeof value === "string" ? value.trim() : "";
|
||||
if (!trimmed) {
|
||||
issues.push(field);
|
||||
return "";
|
||||
}
|
||||
return trimmed.length > maxLength ? `${trimmed.slice(0, maxLength - 3)}...` : trimmed;
|
||||
}
|
||||
|
||||
function normalizeStringArray(value: string[] | undefined, field: string, maxLength: number, issues: string[]): string[] {
|
||||
if (value === undefined) return [];
|
||||
if (!Array.isArray(value)) {
|
||||
issues.push(field);
|
||||
return [];
|
||||
}
|
||||
return [...new Set(
|
||||
value
|
||||
.filter((item): item is string => typeof item === "string")
|
||||
.map((item) => item.trim())
|
||||
.filter((item) => item.length > 0)
|
||||
.map((item) => (item.length > maxLength ? `${item.slice(0, maxLength - 3)}...` : item)),
|
||||
)];
|
||||
}
|
||||
|
||||
function normalizeArtifactRefs(value: TaskArtifact[] | undefined, field: string, issues: string[]): TaskArtifact[] | undefined {
|
||||
if (value === undefined) return undefined;
|
||||
if (!Array.isArray(value)) {
|
||||
issues.push(field);
|
||||
return undefined;
|
||||
}
|
||||
const refs: TaskArtifact[] = [];
|
||||
for (const item of value) {
|
||||
if (!item || typeof item !== "object") {
|
||||
issues.push(field);
|
||||
continue;
|
||||
}
|
||||
const artifactId = requireText(item.artifactId, `${field}.artifactId`, 120, issues);
|
||||
const label = requireText(item.label, `${field}.label`, 180, issues);
|
||||
const location = requireText(item.location, `${field}.location`, 400, issues);
|
||||
const type = item.type === "code" || item.type === "doc" || item.type === "link" || item.type === "other"
|
||||
? item.type
|
||||
: undefined;
|
||||
if (!type) {
|
||||
issues.push(`${field}.type`);
|
||||
continue;
|
||||
}
|
||||
refs.push({ artifactId, type, label, location });
|
||||
}
|
||||
return refs;
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { HallParticipant, MentionTarget } from "../types";
|
||||
|
||||
export interface HallMentionRoutingResult {
|
||||
broadcastAll: boolean;
|
||||
targets: MentionTarget[];
|
||||
}
|
||||
|
||||
export function resolveHallMentionTargets(
|
||||
content: string,
|
||||
participants: HallParticipant[],
|
||||
): HallMentionRoutingResult {
|
||||
const trimmed = content.trim();
|
||||
if (!trimmed) {
|
||||
return { broadcastAll: false, targets: [] };
|
||||
}
|
||||
|
||||
const broadcastAll = /(^|[\s(])@all(?=$|[\s),.!?;:])/i.test(trimmed);
|
||||
const matched = new Map<string, MentionTarget>();
|
||||
|
||||
for (const participant of participants) {
|
||||
for (const alias of participant.aliases) {
|
||||
if (!alias) continue;
|
||||
if (!containsExplicitMention(trimmed, alias)) continue;
|
||||
matched.set(participant.participantId, {
|
||||
raw: `@${alias}`,
|
||||
participantId: participant.participantId,
|
||||
displayName: participant.displayName,
|
||||
semanticRole: participant.semanticRole,
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
broadcastAll,
|
||||
targets: [...matched.values()],
|
||||
};
|
||||
}
|
||||
|
||||
function containsExplicitMention(content: string, alias: string): boolean {
|
||||
const escaped = escapeRegex(alias);
|
||||
const pattern = new RegExp(`(^|[\\s(])@${escaped}(?=$|[\\s),.!?;:])`, "i");
|
||||
return pattern.test(content);
|
||||
}
|
||||
|
||||
function escapeRegex(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import type { AgentRosterEntry } from "./agent-roster";
|
||||
import type { HallParticipant, HallSemanticRole } from "../types";
|
||||
|
||||
const ROLE_PATTERNS: Record<Exclude<HallSemanticRole, "generalist">, RegExp[]> = {
|
||||
manager: [/manager/i, /\bmain\b/i, /lead/i, /chief/i, /owner/i, /orchestr/i],
|
||||
planner: [/planner/i, /plan/i, /research/i, /architect/i, /product/i, /design/i],
|
||||
coder: [/coder/i, /code/i, /dev/i, /engineer/i, /implement/i, /build/i, /builder/i, /maker/i],
|
||||
reviewer: [/review/i, /qa/i, /audit/i, /critic/i, /test/i, /verify/i],
|
||||
};
|
||||
|
||||
export function resolveHallParticipantsFromRoster(roster: AgentRosterEntry[]): HallParticipant[] {
|
||||
const ordered = [...roster]
|
||||
.map((entry) => ({
|
||||
agentId: entry.agentId.trim(),
|
||||
displayName: entry.displayName.trim() || entry.agentId.trim(),
|
||||
}))
|
||||
.filter((entry) => entry.agentId.length > 0)
|
||||
.sort((a, b) => a.agentId.localeCompare(b.agentId));
|
||||
|
||||
if (ordered.length === 0) {
|
||||
return [
|
||||
toParticipant("main", "Main", "manager"),
|
||||
toParticipant("planner", "Planner", "planner"),
|
||||
toParticipant("coder", "Coder", "coder"),
|
||||
toParticipant("reviewer", "Reviewer", "reviewer"),
|
||||
];
|
||||
}
|
||||
|
||||
const assigned = new Set<string>();
|
||||
const participants: HallParticipant[] = [];
|
||||
|
||||
const pushRole = (role: Exclude<HallSemanticRole, "generalist">) => {
|
||||
const candidate = pickBestRoleCandidate(ordered, role, assigned);
|
||||
if (!candidate) return;
|
||||
assigned.add(candidate.agentId);
|
||||
participants.push(toParticipant(candidate.agentId, candidate.displayName, role));
|
||||
};
|
||||
|
||||
pushRole("manager");
|
||||
pushRole("planner");
|
||||
pushRole("coder");
|
||||
pushRole("reviewer");
|
||||
|
||||
for (const entry of ordered) {
|
||||
if (assigned.has(entry.agentId)) continue;
|
||||
participants.push(toParticipant(entry.agentId, entry.displayName, "generalist"));
|
||||
}
|
||||
|
||||
return participants;
|
||||
}
|
||||
|
||||
export function pickPrimaryParticipantByRole(
|
||||
participants: HallParticipant[],
|
||||
role: Exclude<HallSemanticRole, "generalist">,
|
||||
): HallParticipant | undefined {
|
||||
const direct = participants.find((participant) => participant.active && participant.semanticRole === role);
|
||||
if (direct) return direct;
|
||||
if (role === "manager") return participants.find((participant) => participant.active);
|
||||
if (role === "planner") {
|
||||
return participants.find((participant) => participant.active && participant.semanticRole !== "manager");
|
||||
}
|
||||
return participants.find((participant) => participant.active && participant.semanticRole === "generalist");
|
||||
}
|
||||
|
||||
export function resolveSemanticRoleLabel(role: HallSemanticRole, language: "en" | "zh" = "en"): string {
|
||||
if (language === "zh") {
|
||||
if (role === "planner") return "策划";
|
||||
if (role === "coder") return "执行";
|
||||
if (role === "reviewer") return "审核";
|
||||
if (role === "manager") return "经理";
|
||||
return "通用";
|
||||
}
|
||||
if (role === "planner") return "Planner";
|
||||
if (role === "coder") return "Coder";
|
||||
if (role === "reviewer") return "Reviewer";
|
||||
if (role === "manager") return "Manager";
|
||||
return "Generalist";
|
||||
}
|
||||
|
||||
function pickBestRoleCandidate(
|
||||
entries: Array<{ agentId: string; displayName: string }>,
|
||||
role: Exclude<HallSemanticRole, "generalist">,
|
||||
assigned: Set<string>,
|
||||
): { agentId: string; displayName: string } | undefined {
|
||||
const patterns = ROLE_PATTERNS[role];
|
||||
const fromPattern = entries.find((entry) => !assigned.has(entry.agentId) && matchesRole(entry, patterns));
|
||||
if (fromPattern) return fromPattern;
|
||||
return entries.find((entry) => !assigned.has(entry.agentId));
|
||||
}
|
||||
|
||||
function matchesRole(
|
||||
entry: { agentId: string; displayName: string },
|
||||
patterns: RegExp[],
|
||||
): boolean {
|
||||
const haystack = `${entry.agentId} ${entry.displayName}`;
|
||||
return patterns.some((pattern) => pattern.test(haystack));
|
||||
}
|
||||
|
||||
function toParticipant(agentId: string, displayName: string, semanticRole: HallSemanticRole): HallParticipant {
|
||||
const aliases = [...new Set([displayName, agentId, displayName.replace(/\s+/g, ""), agentId.replace(/\s+/g, "")])]
|
||||
.map((item) => item.trim())
|
||||
.filter((item) => item.length > 0);
|
||||
return {
|
||||
participantId: agentId,
|
||||
agentId,
|
||||
displayName,
|
||||
semanticRole,
|
||||
active: true,
|
||||
aliases,
|
||||
};
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,104 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { pickPrimaryParticipantByRole } from "./hall-role-resolver";
|
||||
import type { HallParticipant, HallTaskCard, HallTaskStage, TaskDiscussionCycle } from "../types";
|
||||
|
||||
const DISCUSSION_ROLE_ORDER = ["planner", "coder", "reviewer", "manager"] as const;
|
||||
|
||||
export function buildDiscussionParticipantQueue(participants: HallParticipant[]): string[] {
|
||||
const queue: string[] = [];
|
||||
for (const role of DISCUSSION_ROLE_ORDER) {
|
||||
const participant = pickPrimaryParticipantByRole(participants, role);
|
||||
if (participant && !queue.includes(participant.participantId)) {
|
||||
queue.push(participant.participantId);
|
||||
}
|
||||
}
|
||||
return queue;
|
||||
}
|
||||
|
||||
export function openDiscussionCycle(
|
||||
taskCard: HallTaskCard,
|
||||
openedByParticipantId: string,
|
||||
participants: HallParticipant[],
|
||||
expectedParticipantIds?: string[],
|
||||
openedAt = new Date().toISOString(),
|
||||
): HallTaskCard {
|
||||
const cycle: TaskDiscussionCycle = {
|
||||
cycleId: randomUUID(),
|
||||
openedAt,
|
||||
openedByParticipantId,
|
||||
expectedParticipantIds: expectedParticipantIds && expectedParticipantIds.length > 0
|
||||
? expectedParticipantIds
|
||||
: buildDiscussionParticipantQueue(participants),
|
||||
completedParticipantIds: [],
|
||||
};
|
||||
return {
|
||||
...taskCard,
|
||||
stage: "discussion",
|
||||
discussionCycle: cycle,
|
||||
updatedAt: openedAt,
|
||||
};
|
||||
}
|
||||
|
||||
export function markDiscussionSpeakerComplete(taskCard: HallTaskCard, participantId: string, at = new Date().toISOString()): HallTaskCard {
|
||||
const cycle = taskCard.discussionCycle;
|
||||
if (!cycle) return taskCard;
|
||||
if (cycle.completedParticipantIds.includes(participantId)) return taskCard;
|
||||
return {
|
||||
...taskCard,
|
||||
discussionCycle: {
|
||||
...cycle,
|
||||
completedParticipantIds: [...cycle.completedParticipantIds, participantId],
|
||||
},
|
||||
updatedAt: at,
|
||||
};
|
||||
}
|
||||
|
||||
export function closeDiscussionCycle(taskCard: HallTaskCard, at = new Date().toISOString()): HallTaskCard {
|
||||
const cycle = taskCard.discussionCycle;
|
||||
if (!cycle) return taskCard;
|
||||
return {
|
||||
...taskCard,
|
||||
discussionCycle: {
|
||||
...cycle,
|
||||
closedAt: at,
|
||||
},
|
||||
updatedAt: at,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveNextDiscussionSpeaker(taskCard: HallTaskCard): string | undefined {
|
||||
const cycle = taskCard.discussionCycle;
|
||||
if (!cycle || cycle.closedAt) return undefined;
|
||||
return cycle.expectedParticipantIds.find((participantId) => !cycle.completedParticipantIds.includes(participantId));
|
||||
}
|
||||
|
||||
export function resolveDefaultSpeakerForStage(
|
||||
taskCard: HallTaskCard | undefined,
|
||||
participants: HallParticipant[],
|
||||
): string | undefined {
|
||||
if (!taskCard) {
|
||||
return pickPrimaryParticipantByRole(participants, "planner")?.participantId ?? participants[0]?.participantId;
|
||||
}
|
||||
if (taskCard.stage === "discussion") {
|
||||
return resolveNextDiscussionSpeaker(taskCard) ?? pickPrimaryParticipantByRole(participants, "manager")?.participantId;
|
||||
}
|
||||
if (taskCard.stage === "execution") {
|
||||
return taskCard.currentOwnerParticipantId;
|
||||
}
|
||||
if (taskCard.stage === "review") {
|
||||
return pickPrimaryParticipantByRole(participants, "reviewer")?.participantId
|
||||
?? pickPrimaryParticipantByRole(participants, "manager")?.participantId;
|
||||
}
|
||||
if (taskCard.stage === "blocked") {
|
||||
return pickPrimaryParticipantByRole(participants, "manager")?.participantId;
|
||||
}
|
||||
return taskCard.currentOwnerParticipantId ?? pickPrimaryParticipantByRole(participants, "manager")?.participantId;
|
||||
}
|
||||
|
||||
export function coerceTaskStage(taskCard: HallTaskCard, nextStage: HallTaskStage, updatedAt = new Date().toISOString()): HallTaskCard {
|
||||
return {
|
||||
...taskCard,
|
||||
stage: nextStage,
|
||||
updatedAt,
|
||||
};
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import { exec, execFile } from "node:child_process";
|
||||
import { delimiter, join, win32 as win32Path } from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
const execFileAsync = promisify(execFile);
|
||||
const INSIGHT_CACHE_TTL_MS = 15_000;
|
||||
const INSIGHT_COMMAND_TIMEOUT_MS = 4_000;
|
||||
@@ -127,6 +129,7 @@ export function summarizeOpenClawConnection(statusJson: unknown, gatewayJson: un
|
||||
const now = new Date().toISOString();
|
||||
const statusRoot = asObject(statusJson) ?? {};
|
||||
const gatewayRoot = asObject(gatewayJson) ?? {};
|
||||
const statusGateway = asObject(statusRoot.gateway);
|
||||
const gatewayService = asObject(gatewayRoot.service);
|
||||
const gatewayRuntime = asObject(gatewayService?.runtime);
|
||||
const gatewayConfig = asObject(gatewayRoot.config);
|
||||
@@ -143,32 +146,42 @@ export function summarizeOpenClawConnection(statusJson: unknown, gatewayJson: un
|
||||
return (asNumber(obj?.sessionsCount) ?? 0) > 0;
|
||||
}).length;
|
||||
const hasRuntimeSnapshot = sessions !== undefined || statusAgents !== undefined || asString(statusRoot.runtimeVersion) !== undefined;
|
||||
const gatewayReachableFromStatus = asBoolean(statusGateway?.reachable) === true;
|
||||
const gatewayUrlFromStatus = asString(statusGateway?.url);
|
||||
const hasConfigProbe = cliConfig !== undefined || daemonConfig !== undefined;
|
||||
|
||||
const gatewayRunning =
|
||||
asBoolean(gatewayRpc?.ok) === true ||
|
||||
asString(gatewayRuntime?.status) === "running" ||
|
||||
asString(gatewayRuntime?.state) === "active" ||
|
||||
(asBoolean(gatewayService?.loaded) === true && gatewayRuntime !== undefined);
|
||||
const gatewayStatus: OpenClawInsightStatus = gatewayRunning ? "ok" : "blocked";
|
||||
const gatewayProbeUrl = asString(gatewayMeta?.probeUrl);
|
||||
(asBoolean(gatewayService?.loaded) === true && gatewayRuntime !== undefined) ||
|
||||
gatewayReachableFromStatus;
|
||||
const gatewayStatus: OpenClawInsightStatus = gatewayRunning ? "ok" : hasRuntimeSnapshot ? "info" : "blocked";
|
||||
const gatewayDetail = gatewayRunning
|
||||
? `${gatewayProbeUrl ?? "Gateway"}`
|
||||
: "Gateway is not reachable";
|
||||
const gatewayValue = gatewayRunning ? "Connected" : "Unavailable";
|
||||
? `${asString(gatewayMeta?.probeUrl) ?? gatewayUrlFromStatus ?? "Gateway"}`
|
||||
: hasRuntimeSnapshot
|
||||
? "Runtime data is flowing, but the direct Gateway probe is unavailable on this host"
|
||||
: "Gateway is not reachable";
|
||||
const gatewayValue = gatewayRunning ? "Connected" : hasRuntimeSnapshot ? "Partial" : "Unavailable";
|
||||
|
||||
const cliValid = asBoolean(cliConfig?.exists) === true && asBoolean(cliConfig?.valid) === true;
|
||||
const daemonValid = asBoolean(daemonConfig?.exists) === true && asBoolean(daemonConfig?.valid) === true;
|
||||
const allowedOrigins = asArray(
|
||||
asObject(cliConfig?.controlUi)?.allowedOrigins ?? asObject(daemonConfig?.controlUi)?.allowedOrigins,
|
||||
).length;
|
||||
const configStatus: OpenClawInsightStatus = cliValid && daemonValid ? "ok" : "blocked";
|
||||
const configStatus: OpenClawInsightStatus = cliValid && daemonValid ? "ok" : hasConfigProbe ? "blocked" : hasRuntimeSnapshot ? "info" : "blocked";
|
||||
const configDetail =
|
||||
cliValid && daemonValid
|
||||
? allowedOrigins > 0
|
||||
? `${allowedOrigins} allowed origin${allowedOrigins === 1 ? "" : "s"}`
|
||||
: "Local-only by default"
|
||||
: "openclaw.json is missing or invalid";
|
||||
const configValue = cliValid && daemonValid ? "Ready" : "Needs fix";
|
||||
: hasConfigProbe
|
||||
? "openclaw.json is missing or invalid"
|
||||
: hasRuntimeSnapshot
|
||||
? "Runtime data is visible, but config validation is unavailable on this host"
|
||||
: "openclaw.json probe is unavailable";
|
||||
const configValue =
|
||||
cliValid && daemonValid ? "Ready" : hasConfigProbe ? "Needs fix" : hasRuntimeSnapshot ? "Partial" : "Unknown";
|
||||
|
||||
const runtimeStatus: OpenClawInsightStatus = !hasRuntimeSnapshot
|
||||
? "info"
|
||||
@@ -439,17 +452,96 @@ async function runOpenClawJson(
|
||||
fallback: unknown,
|
||||
options?: { timeoutMs?: number },
|
||||
): Promise<unknown> {
|
||||
try {
|
||||
const { stdout } = await execFileAsync("openclaw", args, {
|
||||
timeout: options?.timeoutMs ?? INSIGHT_COMMAND_TIMEOUT_MS,
|
||||
maxBuffer: INSIGHT_COMMAND_MAX_BUFFER,
|
||||
shell: process.platform === "win32",
|
||||
});
|
||||
return parseEmbeddedJson(stdout) ?? fallback;
|
||||
} catch (error) {
|
||||
const recovered = recoverOpenClawCommandJson(error);
|
||||
return recovered ?? fallback;
|
||||
const candidates = buildOpenClawCommandCandidates();
|
||||
const env = buildOpenClawCommandEnv();
|
||||
const timeout = options?.timeoutMs ?? INSIGHT_COMMAND_TIMEOUT_MS;
|
||||
let lastError: unknown;
|
||||
|
||||
for (const command of candidates) {
|
||||
try {
|
||||
if (process.platform === "win32") {
|
||||
const commandLine = [quoteWindowsCommand(command), ...args.map((item) => quoteWindowsCommand(item))].join(" ");
|
||||
const { stdout } = await execAsync(commandLine, {
|
||||
env,
|
||||
timeout,
|
||||
maxBuffer: INSIGHT_COMMAND_MAX_BUFFER,
|
||||
windowsHide: true,
|
||||
});
|
||||
return parseEmbeddedJson(stdout) ?? fallback;
|
||||
}
|
||||
|
||||
const { stdout } = await execFileAsync(command, args, {
|
||||
env,
|
||||
timeout,
|
||||
maxBuffer: INSIGHT_COMMAND_MAX_BUFFER,
|
||||
});
|
||||
return parseEmbeddedJson(stdout) ?? fallback;
|
||||
} catch (error) {
|
||||
const recovered = recoverOpenClawCommandJson(error);
|
||||
if (recovered !== undefined) return recovered;
|
||||
lastError = error;
|
||||
if (!shouldTryNextOpenClawCommand(error)) {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const recovered = recoverOpenClawCommandJson(lastError);
|
||||
return recovered ?? fallback;
|
||||
}
|
||||
|
||||
export function buildOpenClawCommandCandidates(
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
platformName: NodeJS.Platform = process.platform,
|
||||
): string[] {
|
||||
const explicit = (env.OPENCLAW_BIN_PATH ?? env.OPENCLAW_BIN ?? "").trim();
|
||||
const candidates = [explicit, "openclaw"];
|
||||
if (platformName === "win32") {
|
||||
candidates.push("openclaw.cmd");
|
||||
}
|
||||
return [...new Set(candidates.filter(Boolean))];
|
||||
}
|
||||
|
||||
export function buildOpenClawCommandEnv(
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
platformName: NodeJS.Platform = process.platform,
|
||||
): NodeJS.ProcessEnv {
|
||||
if (platformName !== "win32") {
|
||||
return { ...env };
|
||||
}
|
||||
|
||||
const pathEntries = (env.PATH ?? "")
|
||||
.split(delimiter)
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean);
|
||||
const npmPrefix = (env.npm_config_prefix ?? env.NPM_CONFIG_PREFIX ?? env.PREFIX ?? "").trim();
|
||||
const joinPath = platformName === "win32" ? win32Path.join : join;
|
||||
const windowsBinRoots = [
|
||||
(env.APPDATA ?? "").trim() ? joinPath((env.APPDATA ?? "").trim(), "npm") : "",
|
||||
(env.USERPROFILE ?? "").trim() ? joinPath((env.USERPROFILE ?? "").trim(), "AppData", "Roaming", "npm") : "",
|
||||
npmPrefix,
|
||||
].filter(Boolean);
|
||||
const mergedPath = [...new Set([...windowsBinRoots, ...pathEntries])].join(delimiter);
|
||||
return {
|
||||
...env,
|
||||
PATH: mergedPath,
|
||||
};
|
||||
}
|
||||
|
||||
function shouldTryNextOpenClawCommand(error: unknown): boolean {
|
||||
const root = asObject(error);
|
||||
const code = root?.code;
|
||||
if (code === "ENOENT") return true;
|
||||
if (process.platform !== "win32") return false;
|
||||
const stderr = typeof root?.stderr === "string" ? root.stderr : "";
|
||||
const message = typeof root?.message === "string" ? root.message : "";
|
||||
const combined = `${stderr}\n${message}`;
|
||||
return /not recognized as an internal or external command/i.test(combined) || /cannot find the file specified/i.test(combined);
|
||||
}
|
||||
|
||||
function quoteWindowsCommand(value: string): string {
|
||||
if (!/[\s"]/u.test(value)) return value;
|
||||
return `"${value.replace(/"/g, '\\"')}"`;
|
||||
}
|
||||
|
||||
export function recoverOpenClawCommandJson(error: unknown): unknown {
|
||||
|
||||
@@ -1,15 +1,30 @@
|
||||
import { appendFile, mkdir } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { getRuntimeDir, resolveRuntimePath } from "./runtime-path";
|
||||
|
||||
const RUNTIME_DIR = join(process.cwd(), "runtime");
|
||||
export const OPERATION_AUDIT_LOG_PATH = join(RUNTIME_DIR, "operation-audit.log");
|
||||
const RUNTIME_DIR = getRuntimeDir();
|
||||
export const OPERATION_AUDIT_LOG_PATH = resolveRuntimePath("operation-audit.log");
|
||||
|
||||
export type OperationAuditAction =
|
||||
| "import_dry_run"
|
||||
| "backup_export"
|
||||
| "import_apply"
|
||||
| "ack_prune"
|
||||
| "task_heartbeat";
|
||||
| "task_heartbeat"
|
||||
| "task_room_create"
|
||||
| "task_room_message"
|
||||
| "task_room_handoff"
|
||||
| "task_room_assign"
|
||||
| "task_room_review"
|
||||
| "task_room_stage"
|
||||
| "hall_task_create"
|
||||
| "hall_task_message"
|
||||
| "hall_task_assign"
|
||||
| "hall_task_execution_order"
|
||||
| "hall_task_review"
|
||||
| "hall_task_handoff"
|
||||
| "hall_task_stop"
|
||||
| "hall_task_archive"
|
||||
| "hall_task_delete";
|
||||
export type OperationAuditSource = "api" | "command";
|
||||
|
||||
export interface OperationAuditInput {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { getRuntimeDir, resolveRuntimePath } from "./runtime-path";
|
||||
import type {
|
||||
BudgetThresholds,
|
||||
ProjectRecord,
|
||||
@@ -7,8 +7,8 @@ import type {
|
||||
ProjectStoreSnapshot,
|
||||
} from "../types";
|
||||
|
||||
const RUNTIME_DIR = join(process.cwd(), "runtime");
|
||||
export const PROJECTS_PATH = join(RUNTIME_DIR, "projects.json");
|
||||
const RUNTIME_DIR = getRuntimeDir();
|
||||
export const PROJECTS_PATH = resolveRuntimePath("projects.json");
|
||||
const DEFAULT_WARN_RATIO = 0.8;
|
||||
const PROJECT_ID_REGEX = /^[A-Za-z0-9._:-]+$/;
|
||||
|
||||
|
||||
@@ -0,0 +1,412 @@
|
||||
import { buildDiscussionDispatchMessage, buildExecutionStartedMessage, buildReviewOutcomeMessage } from "./agent-dispatch";
|
||||
import { completeRoomDraftReply, streamRoomDraftReply } from "./collaboration-stream";
|
||||
import {
|
||||
ChatStoreValidationError,
|
||||
appendChatMessage,
|
||||
createChatHandoff,
|
||||
getChatRoom,
|
||||
listChatMessages,
|
||||
loadChatMessageStore,
|
||||
loadChatRoomStore,
|
||||
updateChatRoom,
|
||||
type CreateChatMessageInput,
|
||||
} from "./chat-store";
|
||||
import { buildChatRoomSummary, upsertChatRoomSummary } from "./chat-summary-store";
|
||||
import { nextDiscussionRole } from "./turn-policy";
|
||||
import { loadTaskStore, patchTask } from "./task-store";
|
||||
import type {
|
||||
ChatMessage,
|
||||
ChatRoom,
|
||||
ChatRoomSummary,
|
||||
ProjectTask,
|
||||
RoomParticipantRole,
|
||||
TaskState,
|
||||
} from "../types";
|
||||
|
||||
export interface PostRoomMessageInput extends CreateChatMessageInput {}
|
||||
|
||||
export interface RoomMutationWithSummaryResult {
|
||||
room: ChatRoom;
|
||||
summary: ChatRoomSummary;
|
||||
generatedMessages: ChatMessage[];
|
||||
}
|
||||
|
||||
export interface RoomAssignmentResult extends RoomMutationWithSummaryResult {
|
||||
task: ProjectTask;
|
||||
}
|
||||
|
||||
export interface RoomReviewInput {
|
||||
roomId: string;
|
||||
outcome: "approved" | "rejected";
|
||||
note?: string;
|
||||
blockTask?: boolean;
|
||||
}
|
||||
|
||||
export async function recordRoomHandoff(input: {
|
||||
roomId: string;
|
||||
fromRole: RoomParticipantRole;
|
||||
toRole: RoomParticipantRole;
|
||||
note?: string;
|
||||
}): Promise<RoomMutationWithSummaryResult> {
|
||||
let room = await requireRoom(input.roomId);
|
||||
const generatedMessages: ChatMessage[] = [];
|
||||
const handoff = await createChatHandoff({
|
||||
roomId: room.roomId,
|
||||
fromRole: input.fromRole,
|
||||
toRole: input.toRole,
|
||||
note: input.note,
|
||||
});
|
||||
|
||||
generatedMessages.push(
|
||||
await appendStreamedGeneratedRoomMessage({
|
||||
roomId: room.roomId,
|
||||
projectId: room.projectId,
|
||||
taskId: room.taskId,
|
||||
kind: "handoff",
|
||||
authorRole: input.fromRole === "human" ? "manager" : input.fromRole,
|
||||
authorLabel: titleCaseRole(input.fromRole === "human" ? "manager" : input.fromRole),
|
||||
content: `${titleCaseRole(input.fromRole)} handed the room to ${titleCaseRole(input.toRole)}.`,
|
||||
payload: {
|
||||
fromRole: handoff.handoff.fromRole,
|
||||
targetRole: handoff.handoff.toRole,
|
||||
handoffId: handoff.handoff.handoffId,
|
||||
status: "handoff_recorded",
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
room = (await updateChatRoom({ roomId: room.roomId, ownerRole: input.toRole })).room;
|
||||
const summary = await refreshRoomSummary(room.roomId);
|
||||
return {
|
||||
room,
|
||||
summary,
|
||||
generatedMessages,
|
||||
};
|
||||
}
|
||||
|
||||
export async function postRoomMessage(
|
||||
input: PostRoomMessageInput,
|
||||
): Promise<RoomMutationWithSummaryResult & { message: ChatMessage }> {
|
||||
const created = await appendChatMessage(input);
|
||||
let room = created.room;
|
||||
const generatedMessages: ChatMessage[] = [];
|
||||
|
||||
if (created.message.authorRole === "human" && (room.stage === "intake" || room.stage === "discussion")) {
|
||||
if (room.stage === "intake") {
|
||||
room = (await updateChatRoom({
|
||||
roomId: room.roomId,
|
||||
stage: "discussion",
|
||||
ownerRole: "planner",
|
||||
})).room;
|
||||
}
|
||||
const discussion = await runDiscussionRound(room.roomId);
|
||||
room = discussion.room;
|
||||
generatedMessages.push(...discussion.generatedMessages);
|
||||
} else if (created.message.authorRole === room.assignedExecutor && room.stage === "assigned") {
|
||||
room = (await updateChatRoom({
|
||||
roomId: room.roomId,
|
||||
stage: "executing",
|
||||
ownerRole: room.assignedExecutor,
|
||||
})).room;
|
||||
}
|
||||
|
||||
const summary = await refreshRoomSummary(room.roomId);
|
||||
return {
|
||||
room,
|
||||
message: created.message,
|
||||
summary,
|
||||
generatedMessages,
|
||||
};
|
||||
}
|
||||
|
||||
export async function runDiscussionRound(roomId: string): Promise<RoomMutationWithSummaryResult> {
|
||||
let room = await requireRoom(roomId);
|
||||
const task = await requireTaskForRoom(room);
|
||||
const generatedMessages: ChatMessage[] = [];
|
||||
|
||||
while (true) {
|
||||
const messages = await readRoomMessages(room.roomId);
|
||||
const role = nextDiscussionRole(room, messages);
|
||||
if (!role) break;
|
||||
|
||||
const dispatch = buildDiscussionDispatchMessage(role, {
|
||||
roomId: room.roomId,
|
||||
task,
|
||||
recentMessages: messages,
|
||||
});
|
||||
const message = await appendStreamedGeneratedRoomMessage({
|
||||
roomId: dispatch.roomId,
|
||||
projectId: task.projectId,
|
||||
taskId: task.taskId,
|
||||
kind: dispatch.kind ?? "chat",
|
||||
authorRole: dispatch.authorRole,
|
||||
authorLabel: dispatch.authorLabel ?? titleCaseRole(dispatch.authorRole),
|
||||
content: dispatch.content,
|
||||
payload: dispatch.payload,
|
||||
mentions: dispatch.mentions,
|
||||
participantId: dispatch.participantId,
|
||||
sessionKey: dispatch.sessionKey,
|
||||
});
|
||||
generatedMessages.push(message);
|
||||
room = (
|
||||
await updateChatRoom({
|
||||
roomId: room.roomId,
|
||||
stage: "discussion",
|
||||
ownerRole: role,
|
||||
assignedExecutor: message.payload?.executor ?? room.assignedExecutor,
|
||||
proposal: message.payload?.proposal ?? room.proposal,
|
||||
decision: message.payload?.decision ?? room.decision,
|
||||
doneWhen: message.payload?.doneWhen ?? room.doneWhen,
|
||||
})
|
||||
).room;
|
||||
}
|
||||
|
||||
const summary = await refreshRoomSummary(room.roomId);
|
||||
return {
|
||||
room,
|
||||
summary,
|
||||
generatedMessages,
|
||||
};
|
||||
}
|
||||
|
||||
export async function assignRoomExecution(input: {
|
||||
roomId: string;
|
||||
executorRole?: RoomParticipantRole;
|
||||
note?: string;
|
||||
autoStartExecution?: boolean;
|
||||
}): Promise<RoomAssignmentResult> {
|
||||
let room = await requireRoom(input.roomId);
|
||||
const task = await requireTaskForRoom(room);
|
||||
const executor = input.executorRole ?? room.assignedExecutor ?? "coder";
|
||||
const generatedMessages: ChatMessage[] = [];
|
||||
|
||||
const handoff = await createChatHandoff({
|
||||
roomId: room.roomId,
|
||||
fromRole: room.ownerRole,
|
||||
toRole: executor,
|
||||
note: input.note,
|
||||
});
|
||||
generatedMessages.push(
|
||||
await appendStreamedGeneratedRoomMessage({
|
||||
roomId: room.roomId,
|
||||
projectId: room.projectId,
|
||||
taskId: room.taskId,
|
||||
kind: "handoff",
|
||||
authorRole: "manager",
|
||||
authorLabel: "Manager",
|
||||
content: `Manager handed "${task.title}" to ${titleCaseRole(executor)}.`,
|
||||
payload: {
|
||||
fromRole: handoff.handoff.fromRole,
|
||||
targetRole: handoff.handoff.toRole,
|
||||
handoffId: handoff.handoff.handoffId,
|
||||
executor,
|
||||
status: "handoff_recorded",
|
||||
taskStatus: "in_progress",
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
room = (
|
||||
await updateChatRoom({
|
||||
roomId: room.roomId,
|
||||
stage: "assigned",
|
||||
ownerRole: executor,
|
||||
assignedExecutor: executor,
|
||||
})
|
||||
).room;
|
||||
|
||||
const patchedTask = await patchTask({
|
||||
taskId: task.taskId,
|
||||
projectId: task.projectId,
|
||||
status: "in_progress",
|
||||
owner: executor,
|
||||
roomId: room.roomId,
|
||||
});
|
||||
|
||||
if (input.autoStartExecution !== false) {
|
||||
const executionStartedMessage = buildExecutionStartedMessage(room.roomId, executor, patchedTask.task);
|
||||
generatedMessages.push(await appendStreamedGeneratedRoomMessage({
|
||||
roomId: executionStartedMessage.roomId,
|
||||
projectId: room.projectId,
|
||||
taskId: room.taskId,
|
||||
kind: executionStartedMessage.kind ?? "status",
|
||||
authorRole: executionStartedMessage.authorRole,
|
||||
authorLabel: executionStartedMessage.authorLabel ?? titleCaseRole(executionStartedMessage.authorRole),
|
||||
content: executionStartedMessage.content,
|
||||
payload: executionStartedMessage.payload,
|
||||
mentions: executionStartedMessage.mentions,
|
||||
participantId: executionStartedMessage.participantId,
|
||||
sessionKey: executionStartedMessage.sessionKey,
|
||||
}));
|
||||
room = (
|
||||
await updateChatRoom({
|
||||
roomId: room.roomId,
|
||||
stage: "executing",
|
||||
ownerRole: executor,
|
||||
})
|
||||
).room;
|
||||
}
|
||||
|
||||
const summary = await refreshRoomSummary(room.roomId);
|
||||
return {
|
||||
room,
|
||||
task: patchedTask.task,
|
||||
summary,
|
||||
generatedMessages,
|
||||
};
|
||||
}
|
||||
|
||||
export async function submitRoomReview(input: RoomReviewInput): Promise<RoomAssignmentResult> {
|
||||
let room = await requireRoom(input.roomId);
|
||||
const task = await requireTaskForRoom(room);
|
||||
const generatedMessages: ChatMessage[] = [];
|
||||
|
||||
const nextTaskStatus: TaskState =
|
||||
input.outcome === "approved" ? "done" : input.blockTask ? "blocked" : "in_progress";
|
||||
const reviewMessage = buildReviewOutcomeMessage({
|
||||
roomId: room.roomId,
|
||||
outcome: input.outcome,
|
||||
note: input.note,
|
||||
taskStatus: nextTaskStatus,
|
||||
});
|
||||
generatedMessages.push(await appendStreamedGeneratedRoomMessage({
|
||||
roomId: reviewMessage.roomId,
|
||||
projectId: room.projectId,
|
||||
taskId: room.taskId,
|
||||
kind: reviewMessage.kind ?? "result",
|
||||
authorRole: reviewMessage.authorRole,
|
||||
authorLabel: reviewMessage.authorLabel ?? titleCaseRole(reviewMessage.authorRole),
|
||||
content: reviewMessage.content,
|
||||
payload: reviewMessage.payload,
|
||||
mentions: reviewMessage.mentions,
|
||||
participantId: reviewMessage.participantId,
|
||||
sessionKey: reviewMessage.sessionKey,
|
||||
}));
|
||||
|
||||
room = (
|
||||
await updateChatRoom({
|
||||
roomId: room.roomId,
|
||||
stage: input.outcome === "approved" ? "completed" : "review",
|
||||
ownerRole: input.outcome === "approved" ? "manager" : room.assignedExecutor ?? "coder",
|
||||
})
|
||||
).room;
|
||||
|
||||
const patchedTask = await patchTask({
|
||||
taskId: task.taskId,
|
||||
projectId: task.projectId,
|
||||
status: nextTaskStatus,
|
||||
owner:
|
||||
input.outcome === "approved"
|
||||
? room.ownerRole
|
||||
: room.assignedExecutor ?? task.owner,
|
||||
roomId: room.roomId,
|
||||
});
|
||||
|
||||
const summary = await refreshRoomSummary(room.roomId);
|
||||
return {
|
||||
room,
|
||||
task: patchedTask.task,
|
||||
summary,
|
||||
generatedMessages,
|
||||
};
|
||||
}
|
||||
|
||||
export async function refreshRoomSummary(roomId: string): Promise<ChatRoomSummary> {
|
||||
const room = await requireRoom(roomId);
|
||||
const messages = await readRoomMessages(room.roomId);
|
||||
return (await upsertChatRoomSummary(room, messages)).summary;
|
||||
}
|
||||
|
||||
export async function readRoomDetail(roomId: string): Promise<{
|
||||
room: ChatRoom;
|
||||
messages: ChatMessage[];
|
||||
summary: ChatRoomSummary;
|
||||
}> {
|
||||
const room = await requireRoom(roomId);
|
||||
const messages = await readRoomMessages(roomId);
|
||||
const summary = buildChatRoomSummary(room, messages);
|
||||
return { room, messages, summary };
|
||||
}
|
||||
|
||||
async function requireRoom(roomId: string): Promise<ChatRoom> {
|
||||
const store = await loadChatRoomStore();
|
||||
const room = getChatRoom(store, roomId);
|
||||
if (!room) {
|
||||
throw new ChatStoreValidationError(`roomId '${roomId}' was not found.`, ["roomId"], 404);
|
||||
}
|
||||
return room;
|
||||
}
|
||||
|
||||
async function requireTaskForRoom(room: ChatRoom): Promise<ProjectTask> {
|
||||
const taskStore = await loadTaskStore();
|
||||
const task = taskStore.tasks.find(
|
||||
(item) => item.projectId === room.projectId && item.taskId === room.taskId,
|
||||
);
|
||||
if (!task) {
|
||||
throw new ChatStoreValidationError(
|
||||
`task '${room.projectId}:${room.taskId}' linked to room '${room.roomId}' was not found.`,
|
||||
["taskId"],
|
||||
404,
|
||||
);
|
||||
}
|
||||
return task;
|
||||
}
|
||||
|
||||
async function readRoomMessages(roomId: string): Promise<ChatMessage[]> {
|
||||
const store = await loadChatMessageStore();
|
||||
return listChatMessages(store, roomId);
|
||||
}
|
||||
|
||||
async function appendStreamedGeneratedRoomMessage(input: {
|
||||
roomId: string;
|
||||
projectId: string;
|
||||
taskId: string;
|
||||
kind: ChatMessage["kind"];
|
||||
authorRole: RoomParticipantRole;
|
||||
authorLabel: string;
|
||||
content: string;
|
||||
mentions?: ChatMessage["mentions"];
|
||||
participantId?: string;
|
||||
sessionKey?: string;
|
||||
payload?: ChatMessage["payload"];
|
||||
}): Promise<ChatMessage> {
|
||||
const draftId = await streamRoomDraftReply({
|
||||
roomId: input.roomId,
|
||||
projectId: input.projectId,
|
||||
taskId: input.taskId,
|
||||
authorRole: input.authorRole,
|
||||
authorLabel: input.authorLabel,
|
||||
messageKind: input.kind,
|
||||
content: input.content,
|
||||
});
|
||||
const message = (
|
||||
await appendChatMessage({
|
||||
roomId: input.roomId,
|
||||
kind: input.kind,
|
||||
authorRole: input.authorRole,
|
||||
authorLabel: input.authorLabel,
|
||||
content: input.content,
|
||||
mentions: input.mentions,
|
||||
participantId: input.participantId,
|
||||
sessionKey: input.sessionKey,
|
||||
payload: input.payload,
|
||||
})
|
||||
).message;
|
||||
completeRoomDraftReply({
|
||||
roomId: input.roomId,
|
||||
projectId: input.projectId,
|
||||
taskId: input.taskId,
|
||||
draftId,
|
||||
messageId: message.messageId,
|
||||
content: input.content,
|
||||
});
|
||||
return message;
|
||||
}
|
||||
|
||||
function titleCaseRole(role: RoomParticipantRole): string {
|
||||
if (role === "human") return "Operator";
|
||||
if (role === "planner") return "Planner";
|
||||
if (role === "coder") return "Coder";
|
||||
if (role === "reviewer") return "Reviewer";
|
||||
return "Manager";
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { join } from "node:path";
|
||||
|
||||
export function getRuntimeDir(): string {
|
||||
const override = process.env.OPENCLAW_RUNTIME_DIR?.trim();
|
||||
return override ? override : join(process.cwd(), "runtime");
|
||||
}
|
||||
|
||||
export function resolveRuntimePath(...parts: string[]): string {
|
||||
return join(getRuntimeDir(), ...parts);
|
||||
}
|
||||
@@ -106,6 +106,12 @@ export interface SessionConversationDetailInput {
|
||||
historyLimit: number;
|
||||
}
|
||||
|
||||
export interface SessionConversationHistoryResult {
|
||||
sessionKey: string;
|
||||
history: SessionHistoryMessage[];
|
||||
historyError?: string;
|
||||
}
|
||||
|
||||
interface SessionHistoryReadResult {
|
||||
messages: SessionHistoryMessage[];
|
||||
error?: string;
|
||||
@@ -266,6 +272,21 @@ export function inferSessionExecutionChainFromSessionKey(
|
||||
return inferSessionExecutionChain(session, []);
|
||||
}
|
||||
|
||||
export async function readSessionConversationHistory(
|
||||
client: ToolClient,
|
||||
sessionKey: string,
|
||||
historyLimit: number,
|
||||
): Promise<SessionConversationHistoryResult> {
|
||||
const normalizedSessionKey = sessionKey.trim();
|
||||
const normalizedLimit = normalizeHistoryLimit(historyLimit, 50);
|
||||
const history = await readSessionHistory(client, normalizedSessionKey, normalizedLimit);
|
||||
return {
|
||||
sessionKey: normalizedSessionKey,
|
||||
history: history.messages,
|
||||
historyError: history.error,
|
||||
};
|
||||
}
|
||||
|
||||
async function readSessionHistory(
|
||||
client: ToolClient,
|
||||
sessionKey: string,
|
||||
|
||||
@@ -0,0 +1,495 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import { getRuntimeDir, resolveRuntimePath } from "./runtime-path";
|
||||
import {
|
||||
OPENCLAW_CONTROL_UI_URL,
|
||||
TASK_ROOM_BRIDGE_DISCORD_WEBHOOK_URL,
|
||||
TASK_ROOM_BRIDGE_ENABLED,
|
||||
TASK_ROOM_BRIDGE_TELEGRAM_BOT_TOKEN,
|
||||
TASK_ROOM_BRIDGE_TELEGRAM_CHAT_ID,
|
||||
} from "../config";
|
||||
import type {
|
||||
ChatMessage,
|
||||
ChatRoom,
|
||||
MessageKind,
|
||||
ProjectTask,
|
||||
RoomParticipantRole,
|
||||
RoomStage,
|
||||
TaskState,
|
||||
} from "../types";
|
||||
|
||||
const RUNTIME_DIR = getRuntimeDir();
|
||||
export const TASK_ROOM_BRIDGE_EVENTS_PATH = resolveRuntimePath("task-room-bridge-events.json");
|
||||
|
||||
export type TaskRoomBridgeTarget = "discord" | "telegram";
|
||||
export type TaskRoomBridgeEventType =
|
||||
| "room_created"
|
||||
| "message_posted"
|
||||
| "handoff_recorded"
|
||||
| "executor_assigned"
|
||||
| "review_submitted"
|
||||
| "stage_changed";
|
||||
|
||||
export type TaskRoomBridgeDeliveryStatus = "delivered" | "partial" | "local_only" | "failed";
|
||||
|
||||
export interface TaskRoomBridgeEvent {
|
||||
eventId: string;
|
||||
type: TaskRoomBridgeEventType;
|
||||
status: TaskRoomBridgeDeliveryStatus;
|
||||
roomId: string;
|
||||
projectId: string;
|
||||
taskId: string;
|
||||
roomTitle: string;
|
||||
roomStage: RoomStage;
|
||||
ownerRole: RoomParticipantRole;
|
||||
assignedExecutor?: RoomParticipantRole;
|
||||
taskStatus?: TaskState;
|
||||
authorRole?: RoomParticipantRole;
|
||||
messageId?: string;
|
||||
messageKind?: MessageKind;
|
||||
messageSnippet?: string;
|
||||
decision?: string;
|
||||
note?: string;
|
||||
requestId?: string;
|
||||
roomUrl?: string;
|
||||
deliveredTargets: TaskRoomBridgeTarget[];
|
||||
skippedTargets: string[];
|
||||
errorTargets: string[];
|
||||
metadata?: Record<string, unknown>;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface TaskRoomBridgeStoreSnapshot {
|
||||
events: TaskRoomBridgeEvent[];
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface PublishTaskRoomBridgeInput {
|
||||
type: TaskRoomBridgeEventType;
|
||||
room: ChatRoom;
|
||||
task?: ProjectTask;
|
||||
message?: ChatMessage;
|
||||
note?: string;
|
||||
requestId?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface PublishTaskRoomBridgeOptions {
|
||||
enabled?: boolean;
|
||||
discordWebhookUrl?: string;
|
||||
telegramBotToken?: string;
|
||||
telegramChatId?: string;
|
||||
fetchImpl?: typeof fetch;
|
||||
timeoutMs?: number;
|
||||
}
|
||||
|
||||
export interface PublishTaskRoomBridgeResult {
|
||||
path: string;
|
||||
event: TaskRoomBridgeEvent;
|
||||
}
|
||||
|
||||
const EMPTY_STORE: TaskRoomBridgeStoreSnapshot = {
|
||||
events: [],
|
||||
updatedAt: "1970-01-01T00:00:00.000Z",
|
||||
};
|
||||
|
||||
export async function loadTaskRoomBridgeStore(): Promise<TaskRoomBridgeStoreSnapshot> {
|
||||
try {
|
||||
const raw = await readFile(TASK_ROOM_BRIDGE_EVENTS_PATH, "utf8");
|
||||
return normalizeTaskRoomBridgeStore(JSON.parse(raw));
|
||||
} catch {
|
||||
return cloneEmptyStore();
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveTaskRoomBridgeStore(next: TaskRoomBridgeStoreSnapshot): Promise<string> {
|
||||
const normalized = normalizeTaskRoomBridgeStore({
|
||||
...next,
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
await mkdir(RUNTIME_DIR, { recursive: true });
|
||||
await writeFile(TASK_ROOM_BRIDGE_EVENTS_PATH, JSON.stringify(normalized, null, 2), "utf8");
|
||||
return TASK_ROOM_BRIDGE_EVENTS_PATH;
|
||||
}
|
||||
|
||||
export function listTaskRoomBridgeEvents(
|
||||
store: TaskRoomBridgeStoreSnapshot,
|
||||
options?: { roomId?: string; limit?: number },
|
||||
): TaskRoomBridgeEvent[] {
|
||||
const roomId = options?.roomId?.trim();
|
||||
const limit = Number.isFinite(options?.limit) ? Math.max(1, Number(options?.limit)) : undefined;
|
||||
const filtered = [...store.events]
|
||||
.filter((event) => !roomId || event.roomId === roomId)
|
||||
.sort((a, b) => Date.parse(b.createdAt) - Date.parse(a.createdAt));
|
||||
return limit ? filtered.slice(0, limit) : filtered;
|
||||
}
|
||||
|
||||
export async function publishTaskRoomBridgeEvent(
|
||||
input: PublishTaskRoomBridgeInput,
|
||||
options: PublishTaskRoomBridgeOptions = {},
|
||||
): Promise<PublishTaskRoomBridgeResult> {
|
||||
const store = await loadTaskRoomBridgeStore();
|
||||
const event = buildTaskRoomBridgeEvent(input);
|
||||
const webhookUrl = options.discordWebhookUrl ?? TASK_ROOM_BRIDGE_DISCORD_WEBHOOK_URL;
|
||||
const telegramBotToken = options.telegramBotToken ?? TASK_ROOM_BRIDGE_TELEGRAM_BOT_TOKEN;
|
||||
const telegramChatId = options.telegramChatId ?? TASK_ROOM_BRIDGE_TELEGRAM_CHAT_ID;
|
||||
const enabled = options.enabled ?? TASK_ROOM_BRIDGE_ENABLED;
|
||||
const fetchImpl = options.fetchImpl ?? globalThis.fetch;
|
||||
const timeoutMs = options.timeoutMs ?? 4_000;
|
||||
|
||||
if (!enabled) {
|
||||
event.skippedTargets.push("bridge-disabled");
|
||||
} else {
|
||||
if (typeof fetchImpl !== "function") {
|
||||
event.errorTargets.push("fetch-unavailable");
|
||||
} else {
|
||||
if (!webhookUrl) {
|
||||
event.skippedTargets.push("discord-not-configured");
|
||||
} else {
|
||||
try {
|
||||
const response = await fetchImpl(webhookUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(buildDiscordWebhookPayload(event)),
|
||||
signal: AbortSignal.timeout(timeoutMs),
|
||||
});
|
||||
if (response.ok) {
|
||||
event.deliveredTargets.push("discord");
|
||||
} else {
|
||||
event.errorTargets.push(`discord:${response.status}`);
|
||||
}
|
||||
} catch (error) {
|
||||
event.errorTargets.push(`discord:${error instanceof Error ? error.message : "unknown error"}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (!telegramBotToken || !telegramChatId) {
|
||||
event.skippedTargets.push("telegram-not-configured");
|
||||
} else {
|
||||
try {
|
||||
const response = await fetchImpl(buildTelegramBotApiUrl(telegramBotToken), {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(buildTelegramBotPayload(event, telegramChatId)),
|
||||
signal: AbortSignal.timeout(timeoutMs),
|
||||
});
|
||||
if (response.ok) {
|
||||
event.deliveredTargets.push("telegram");
|
||||
} else {
|
||||
event.errorTargets.push(`telegram:${response.status}`);
|
||||
}
|
||||
} catch (error) {
|
||||
event.errorTargets.push(`telegram:${error instanceof Error ? error.message : "unknown error"}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
event.status = resolveBridgeStatus(event);
|
||||
store.events.push(event);
|
||||
store.updatedAt = event.createdAt;
|
||||
const path = await saveTaskRoomBridgeStore(store);
|
||||
return { path, event };
|
||||
}
|
||||
|
||||
export function buildTaskRoomBridgeEvent(input: PublishTaskRoomBridgeInput): TaskRoomBridgeEvent {
|
||||
const createdAt = new Date().toISOString();
|
||||
return {
|
||||
eventId: randomUUID(),
|
||||
type: input.type,
|
||||
status: "local_only",
|
||||
roomId: input.room.roomId,
|
||||
projectId: input.room.projectId,
|
||||
taskId: input.room.taskId,
|
||||
roomTitle: input.room.title,
|
||||
roomStage: input.room.stage,
|
||||
ownerRole: input.room.ownerRole,
|
||||
assignedExecutor: input.room.assignedExecutor,
|
||||
taskStatus: input.task?.status,
|
||||
authorRole: input.message?.authorRole,
|
||||
messageId: input.message?.messageId,
|
||||
messageKind: input.message?.kind,
|
||||
messageSnippet: truncateText(input.message?.content, 280),
|
||||
decision: input.room.decision,
|
||||
note: input.note,
|
||||
requestId: input.requestId,
|
||||
roomUrl: buildTaskRoomUrl(input.room.roomId),
|
||||
deliveredTargets: [],
|
||||
skippedTargets: [],
|
||||
errorTargets: [],
|
||||
metadata: input.metadata,
|
||||
createdAt,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildTaskRoomUrl(roomId: string): string | undefined {
|
||||
if (!OPENCLAW_CONTROL_UI_URL) return undefined;
|
||||
try {
|
||||
const url = new URL(OPENCLAW_CONTROL_UI_URL);
|
||||
url.searchParams.set("section", "collaboration");
|
||||
url.searchParams.set("roomId", roomId);
|
||||
return url.toString();
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export function buildDiscordWebhookPayload(event: TaskRoomBridgeEvent): {
|
||||
content: string;
|
||||
allowed_mentions: { parse: [] };
|
||||
} {
|
||||
const lines = [
|
||||
`Task room update: ${humanizeBridgeType(event.type)}`,
|
||||
`Room: ${event.roomTitle} (${event.roomId})`,
|
||||
`Task: ${event.projectId}:${event.taskId}`,
|
||||
`Stage: ${event.roomStage} | Owner: ${event.ownerRole}${event.assignedExecutor ? ` | Executor: ${event.assignedExecutor}` : ""}`,
|
||||
];
|
||||
if (event.taskStatus) lines.push(`Task status: ${event.taskStatus}`);
|
||||
if (event.messageKind || event.messageSnippet) {
|
||||
lines.push(`Message: ${event.messageKind ?? "chat"}${event.messageSnippet ? ` — ${event.messageSnippet}` : ""}`);
|
||||
}
|
||||
if (event.decision) lines.push(`Decision: ${event.decision}`);
|
||||
if (event.note) lines.push(`Note: ${event.note}`);
|
||||
if (event.roomUrl) lines.push(`Open: ${event.roomUrl}`);
|
||||
return {
|
||||
content: truncateText(lines.join("\n"), 1_900) ?? "Task room update",
|
||||
allowed_mentions: { parse: [] },
|
||||
};
|
||||
}
|
||||
|
||||
export function buildTelegramBotApiUrl(botToken: string): string {
|
||||
return `https://api.telegram.org/bot${encodeURIComponent(botToken)}/sendMessage`;
|
||||
}
|
||||
|
||||
export function buildTelegramBotPayload(
|
||||
event: TaskRoomBridgeEvent,
|
||||
chatId: string,
|
||||
): {
|
||||
chat_id: string;
|
||||
text: string;
|
||||
disable_web_page_preview: boolean;
|
||||
} {
|
||||
const lines = [
|
||||
`Task room update: ${humanizeBridgeType(event.type)}`,
|
||||
`Room: ${event.roomTitle} (${event.roomId})`,
|
||||
`Task: ${event.projectId}:${event.taskId}`,
|
||||
`Stage: ${event.roomStage} | Owner: ${event.ownerRole}${event.assignedExecutor ? ` | Executor: ${event.assignedExecutor}` : ""}`,
|
||||
];
|
||||
if (event.taskStatus) lines.push(`Task status: ${event.taskStatus}`);
|
||||
if (event.messageKind || event.messageSnippet) {
|
||||
lines.push(`Message: ${event.messageKind ?? "chat"}${event.messageSnippet ? ` - ${event.messageSnippet}` : ""}`);
|
||||
}
|
||||
if (event.decision) lines.push(`Decision: ${event.decision}`);
|
||||
if (event.note) lines.push(`Note: ${event.note}`);
|
||||
if (event.roomUrl) lines.push(`Open: ${event.roomUrl}`);
|
||||
return {
|
||||
chat_id: chatId,
|
||||
text: truncateText(lines.join("\n"), 4_000) ?? "Task room update",
|
||||
disable_web_page_preview: true,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeTaskRoomBridgeStore(input: unknown): TaskRoomBridgeStoreSnapshot {
|
||||
const root = asObject(input) ?? {};
|
||||
return {
|
||||
events: asArray(root.events)
|
||||
.map((item) => normalizeTaskRoomBridgeEvent(item))
|
||||
.filter((item): item is TaskRoomBridgeEvent => Boolean(item))
|
||||
.sort((a, b) => Date.parse(a.createdAt) - Date.parse(b.createdAt)),
|
||||
updatedAt: normalizeIsoString(root.updatedAt) ?? "1970-01-01T00:00:00.000Z",
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeTaskRoomBridgeEvent(input: unknown): TaskRoomBridgeEvent | undefined {
|
||||
const root = asObject(input);
|
||||
if (!root) return undefined;
|
||||
const roomId = asNonEmptyString(root.roomId);
|
||||
const projectId = asNonEmptyString(root.projectId);
|
||||
const taskId = asNonEmptyString(root.taskId);
|
||||
const roomTitle = asNonEmptyString(root.roomTitle);
|
||||
const roomStage = asRoomStage(root.roomStage);
|
||||
const ownerRole = asRoomRole(root.ownerRole);
|
||||
const createdAt = normalizeIsoString(root.createdAt);
|
||||
const type = asBridgeType(root.type);
|
||||
const status = asBridgeStatus(root.status) ?? "local_only";
|
||||
if (!roomId || !projectId || !taskId || !roomTitle || !roomStage || !ownerRole || !createdAt || !type) return undefined;
|
||||
return {
|
||||
eventId: asNonEmptyString(root.eventId) ?? randomUUID(),
|
||||
type,
|
||||
status,
|
||||
roomId,
|
||||
projectId,
|
||||
taskId,
|
||||
roomTitle,
|
||||
roomStage,
|
||||
ownerRole,
|
||||
assignedExecutor: asRoomRole(root.assignedExecutor),
|
||||
taskStatus: asTaskState(root.taskStatus),
|
||||
authorRole: asRoomRole(root.authorRole),
|
||||
messageId: asNonEmptyString(root.messageId),
|
||||
messageKind: asMessageKind(root.messageKind),
|
||||
messageSnippet: asNonEmptyString(root.messageSnippet),
|
||||
decision: asNonEmptyString(root.decision),
|
||||
note: asNonEmptyString(root.note),
|
||||
requestId: asNonEmptyString(root.requestId),
|
||||
roomUrl: asNonEmptyString(root.roomUrl),
|
||||
deliveredTargets: asArray(root.deliveredTargets).map((item) => asBridgeTarget(item)).filter(Boolean) as TaskRoomBridgeTarget[],
|
||||
skippedTargets: asArray(root.skippedTargets).map((item) => asNonEmptyString(item)).filter(Boolean) as string[],
|
||||
errorTargets: asArray(root.errorTargets).map((item) => asNonEmptyString(item)).filter(Boolean) as string[],
|
||||
metadata: asObject(root.metadata),
|
||||
createdAt,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveBridgeStatus(event: Pick<TaskRoomBridgeEvent, "deliveredTargets" | "skippedTargets" | "errorTargets">): TaskRoomBridgeDeliveryStatus {
|
||||
if (event.deliveredTargets.length > 0 && event.errorTargets.length > 0) return "partial";
|
||||
if (event.deliveredTargets.length > 0) return "delivered";
|
||||
if (event.errorTargets.length > 0 && event.skippedTargets.length === 0) return "failed";
|
||||
return "local_only";
|
||||
}
|
||||
|
||||
function humanizeBridgeType(type: TaskRoomBridgeEventType): string {
|
||||
switch (type) {
|
||||
case "room_created":
|
||||
return "room created";
|
||||
case "message_posted":
|
||||
return "message posted";
|
||||
case "handoff_recorded":
|
||||
return "handoff recorded";
|
||||
case "executor_assigned":
|
||||
return "executor assigned";
|
||||
case "review_submitted":
|
||||
return "review submitted";
|
||||
case "stage_changed":
|
||||
return "stage changed";
|
||||
default:
|
||||
return type;
|
||||
}
|
||||
}
|
||||
|
||||
function truncateText(value: string | undefined, maxLength: number): string | undefined {
|
||||
if (!value) return undefined;
|
||||
const trimmed = value.trim();
|
||||
if (trimmed.length <= maxLength) return trimmed;
|
||||
return `${trimmed.slice(0, Math.max(0, maxLength - 1)).trimEnd()}…`;
|
||||
}
|
||||
|
||||
function cloneEmptyStore(): TaskRoomBridgeStoreSnapshot {
|
||||
return {
|
||||
events: [],
|
||||
updatedAt: EMPTY_STORE.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
function asObject(input: unknown): Record<string, unknown> | undefined {
|
||||
return input !== null && typeof input === "object" ? (input as Record<string, unknown>) : undefined;
|
||||
}
|
||||
|
||||
function asArray(input: unknown): unknown[] {
|
||||
return Array.isArray(input) ? input : [];
|
||||
}
|
||||
|
||||
function asNonEmptyString(input: unknown): string | undefined {
|
||||
return typeof input === "string" && input.trim() !== "" ? input.trim() : undefined;
|
||||
}
|
||||
|
||||
function normalizeIsoString(input: unknown): string | undefined {
|
||||
const value = asNonEmptyString(input);
|
||||
if (!value) return undefined;
|
||||
const timestamp = Date.parse(value);
|
||||
if (!Number.isFinite(timestamp)) return undefined;
|
||||
return new Date(timestamp).toISOString();
|
||||
}
|
||||
|
||||
function asBridgeType(input: unknown): TaskRoomBridgeEventType | undefined {
|
||||
switch (input) {
|
||||
case "room_created":
|
||||
case "message_posted":
|
||||
case "handoff_recorded":
|
||||
case "executor_assigned":
|
||||
case "review_submitted":
|
||||
case "stage_changed":
|
||||
return input;
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function asBridgeStatus(input: unknown): TaskRoomBridgeDeliveryStatus | undefined {
|
||||
switch (input) {
|
||||
case "delivered":
|
||||
case "partial":
|
||||
case "local_only":
|
||||
case "failed":
|
||||
return input;
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function asBridgeTarget(input: unknown): TaskRoomBridgeTarget | undefined {
|
||||
switch (input) {
|
||||
case "discord":
|
||||
case "telegram":
|
||||
return input;
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function asRoomStage(input: unknown): RoomStage | undefined {
|
||||
switch (input) {
|
||||
case "intake":
|
||||
case "discussion":
|
||||
case "assigned":
|
||||
case "executing":
|
||||
case "review":
|
||||
case "completed":
|
||||
return input;
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function asRoomRole(input: unknown): RoomParticipantRole | undefined {
|
||||
switch (input) {
|
||||
case "human":
|
||||
case "planner":
|
||||
case "coder":
|
||||
case "reviewer":
|
||||
case "manager":
|
||||
return input;
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function asTaskState(input: unknown): TaskState | undefined {
|
||||
switch (input) {
|
||||
case "todo":
|
||||
case "in_progress":
|
||||
case "blocked":
|
||||
case "done":
|
||||
return input;
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function asMessageKind(input: unknown): MessageKind | undefined {
|
||||
switch (input) {
|
||||
case "chat":
|
||||
case "proposal":
|
||||
case "decision":
|
||||
case "handoff":
|
||||
case "status":
|
||||
case "result":
|
||||
return input;
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
+227
-3
@@ -1,6 +1,6 @@
|
||||
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { loadProjectStore } from "./project-store";
|
||||
import { getRuntimeDir, resolveRuntimePath } from "./runtime-path";
|
||||
import type {
|
||||
AgentBudgetPlan,
|
||||
BudgetThresholds,
|
||||
@@ -12,8 +12,8 @@ import type {
|
||||
TaskStoreSnapshot,
|
||||
} from "../types";
|
||||
|
||||
const RUNTIME_DIR = join(process.cwd(), "runtime");
|
||||
export const TASKS_PATH = join(RUNTIME_DIR, "tasks.json");
|
||||
const RUNTIME_DIR = getRuntimeDir();
|
||||
export const TASKS_PATH = resolveRuntimePath("tasks.json");
|
||||
const DEFAULT_WARN_RATIO = 0.8;
|
||||
const PROJECT_ID_REGEX = /^[A-Za-z0-9._:-]+$/;
|
||||
const TASK_ID_REGEX = /^[A-Za-z0-9._:-]+$/;
|
||||
@@ -42,6 +42,7 @@ export interface CreateTaskInput {
|
||||
title: string;
|
||||
status?: TaskState;
|
||||
owner?: string;
|
||||
roomId?: string;
|
||||
dueAt?: string;
|
||||
definitionOfDone?: string[];
|
||||
artifacts?: TaskArtifact[];
|
||||
@@ -56,6 +57,22 @@ export interface UpdateTaskStatusInput {
|
||||
projectId?: string;
|
||||
}
|
||||
|
||||
export interface PatchTaskInput {
|
||||
taskId: string;
|
||||
projectId?: string;
|
||||
status?: TaskState;
|
||||
owner?: string;
|
||||
roomId?: string | null;
|
||||
dueAt?: string | null;
|
||||
sessionKeys?: string[];
|
||||
artifacts?: TaskArtifact[];
|
||||
}
|
||||
|
||||
export interface DeleteTaskInput {
|
||||
taskId: string;
|
||||
projectId?: string;
|
||||
}
|
||||
|
||||
export interface TaskMutationResult {
|
||||
path: string;
|
||||
projectId: string;
|
||||
@@ -95,6 +112,7 @@ export function listTasks(
|
||||
title: task.title,
|
||||
status: task.status,
|
||||
owner: task.owner,
|
||||
roomId: task.roomId,
|
||||
dueAt: task.dueAt,
|
||||
sessionKeys: task.sessionKeys,
|
||||
updatedAt: task.updatedAt,
|
||||
@@ -127,6 +145,7 @@ export async function createTask(input: unknown): Promise<TaskMutationResult> {
|
||||
title: payload.title,
|
||||
status: payload.status ?? "todo",
|
||||
owner: payload.owner ?? "unassigned",
|
||||
roomId: payload.roomId,
|
||||
dueAt: payload.dueAt,
|
||||
definitionOfDone: payload.definitionOfDone ?? [],
|
||||
artifacts: payload.artifacts ?? [],
|
||||
@@ -197,6 +216,100 @@ export async function updateTaskStatus(input: unknown): Promise<TaskMutationResu
|
||||
};
|
||||
}
|
||||
|
||||
export async function patchTask(input: PatchTaskInput): Promise<TaskMutationResult> {
|
||||
const payload = validatePatchTaskInput(input);
|
||||
const [store, projectStore] = await Promise.all([loadTaskStore(), loadProjectStore()]);
|
||||
const matches = findTaskMatches(store, payload.taskId, payload.projectId);
|
||||
|
||||
if (matches.length === 0) {
|
||||
throw new TaskStoreValidationError(
|
||||
`taskId '${payload.taskId}' was not found${payload.projectId ? ` in project '${payload.projectId}'` : ""}.`,
|
||||
[],
|
||||
404,
|
||||
);
|
||||
}
|
||||
|
||||
if (matches.length > 1) {
|
||||
throw new TaskStoreValidationError(
|
||||
`taskId '${payload.taskId}' is ambiguous. Provide projectId.`,
|
||||
["projectId"],
|
||||
409,
|
||||
);
|
||||
}
|
||||
|
||||
const target = matches[0];
|
||||
const project = projectStore.projects.find((item) => item.projectId === target.task.projectId);
|
||||
if (!project) {
|
||||
throw new TaskStoreValidationError(
|
||||
`projectId '${target.task.projectId}' referenced by task '${target.task.taskId}' was not found.`,
|
||||
["projectId"],
|
||||
409,
|
||||
);
|
||||
}
|
||||
|
||||
const now = new Date().toISOString();
|
||||
if (payload.status !== undefined) target.task.status = payload.status;
|
||||
if (payload.owner !== undefined) target.task.owner = payload.owner;
|
||||
if (payload.roomId !== undefined) target.task.roomId = payload.roomId ?? undefined;
|
||||
if (payload.dueAt !== undefined) target.task.dueAt = payload.dueAt ?? undefined;
|
||||
if (payload.sessionKeys !== undefined) target.task.sessionKeys = payload.sessionKeys;
|
||||
if (payload.artifacts !== undefined) target.task.artifacts = payload.artifacts;
|
||||
target.task.updatedAt = now;
|
||||
store.updatedAt = now;
|
||||
|
||||
const path = await saveTaskStore(store);
|
||||
return {
|
||||
path,
|
||||
projectId: target.task.projectId,
|
||||
projectTitle: project.title,
|
||||
task: target.task,
|
||||
};
|
||||
}
|
||||
|
||||
export async function deleteTask(input: DeleteTaskInput): Promise<TaskMutationResult> {
|
||||
const payload = validateDeleteTaskInput(input);
|
||||
const [store, projectStore] = await Promise.all([loadTaskStore(), loadProjectStore()]);
|
||||
const matches = findTaskMatches(store, payload.taskId, payload.projectId);
|
||||
|
||||
if (matches.length === 0) {
|
||||
throw new TaskStoreValidationError(
|
||||
`taskId '${payload.taskId}' was not found${payload.projectId ? ` in project '${payload.projectId}'` : ""}.`,
|
||||
[],
|
||||
404,
|
||||
);
|
||||
}
|
||||
|
||||
if (matches.length > 1) {
|
||||
throw new TaskStoreValidationError(
|
||||
`taskId '${payload.taskId}' is ambiguous. Provide projectId.`,
|
||||
["projectId"],
|
||||
409,
|
||||
);
|
||||
}
|
||||
|
||||
const target = matches[0];
|
||||
const project = projectStore.projects.find((item) => item.projectId === target.task.projectId);
|
||||
if (!project) {
|
||||
throw new TaskStoreValidationError(
|
||||
`projectId '${target.task.projectId}' referenced by task '${target.task.taskId}' was not found.`,
|
||||
["projectId"],
|
||||
409,
|
||||
);
|
||||
}
|
||||
|
||||
const deletedTask = { ...target.task };
|
||||
const now = new Date().toISOString();
|
||||
store.tasks = store.tasks.filter((task) => !(task.projectId === deletedTask.projectId && task.taskId === deletedTask.taskId));
|
||||
store.updatedAt = now;
|
||||
const path = await saveTaskStore(store);
|
||||
return {
|
||||
path,
|
||||
projectId: deletedTask.projectId,
|
||||
projectTitle: project.title,
|
||||
task: deletedTask,
|
||||
};
|
||||
}
|
||||
|
||||
function findTaskMatches(
|
||||
store: TaskStoreSnapshot,
|
||||
taskId: string,
|
||||
@@ -223,6 +336,7 @@ function validateCreateTaskInput(input: unknown): CreateTaskInput {
|
||||
const title = requiredBoundedString(obj.title, "title", 180, issues);
|
||||
const status = optionalTaskState(obj.status, "status", issues);
|
||||
const owner = optionalBoundedString(obj.owner, "owner", 80, issues);
|
||||
const roomId = optionalRoomId(obj.roomId, "roomId", issues);
|
||||
const dueAt = optionalIsoString(obj.dueAt, "dueAt", issues);
|
||||
const definitionOfDone = optionalStringArray(obj.definitionOfDone, "definitionOfDone", issues);
|
||||
const sessionKeys = optionalStringArray(obj.sessionKeys, "sessionKeys", issues);
|
||||
@@ -240,6 +354,7 @@ function validateCreateTaskInput(input: unknown): CreateTaskInput {
|
||||
title,
|
||||
status,
|
||||
owner,
|
||||
roomId,
|
||||
dueAt,
|
||||
definitionOfDone,
|
||||
sessionKeys,
|
||||
@@ -249,6 +364,61 @@ function validateCreateTaskInput(input: unknown): CreateTaskInput {
|
||||
};
|
||||
}
|
||||
|
||||
function validatePatchTaskInput(input: PatchTaskInput): PatchTaskInput {
|
||||
const obj = ensureObject(input, "patch task payload");
|
||||
const issues: string[] = [];
|
||||
const taskId = requiredTaskId(obj.taskId, "taskId", issues);
|
||||
const projectId = optionalProjectId(obj.projectId, "projectId", issues);
|
||||
const status = optionalTaskState(obj.status, "status", issues);
|
||||
const owner = optionalBoundedString(obj.owner, "owner", 80, issues);
|
||||
const roomId = optionalNullableRoomId(obj.roomId, "roomId", issues);
|
||||
const dueAt = optionalNullableIsoString(obj.dueAt, "dueAt", issues);
|
||||
const sessionKeys = optionalStringArray(obj.sessionKeys, "sessionKeys", issues);
|
||||
const artifacts = optionalArtifacts(obj.artifacts, "artifacts", issues);
|
||||
|
||||
if (
|
||||
status === undefined &&
|
||||
owner === undefined &&
|
||||
roomId === undefined &&
|
||||
dueAt === undefined &&
|
||||
sessionKeys === undefined &&
|
||||
artifacts === undefined
|
||||
) {
|
||||
issues.push("at least one patchable field is required: status, owner, roomId, dueAt, sessionKeys, artifacts");
|
||||
}
|
||||
|
||||
if (issues.length > 0) {
|
||||
throw new TaskStoreValidationError("Invalid patch task payload.", issues, 400);
|
||||
}
|
||||
|
||||
return {
|
||||
taskId,
|
||||
projectId,
|
||||
status,
|
||||
owner,
|
||||
roomId,
|
||||
dueAt,
|
||||
sessionKeys,
|
||||
artifacts,
|
||||
};
|
||||
}
|
||||
|
||||
function validateDeleteTaskInput(input: DeleteTaskInput): DeleteTaskInput {
|
||||
const obj = ensureObject(input, "delete task payload");
|
||||
const issues: string[] = [];
|
||||
const taskId = requiredTaskId(obj.taskId, "taskId", issues);
|
||||
const projectId = optionalProjectId(obj.projectId, "projectId", issues);
|
||||
|
||||
if (issues.length > 0) {
|
||||
throw new TaskStoreValidationError("Invalid delete task payload.", issues, 400);
|
||||
}
|
||||
|
||||
return {
|
||||
taskId,
|
||||
projectId,
|
||||
};
|
||||
}
|
||||
|
||||
function validateUpdateTaskStatusInput(input: unknown): UpdateTaskStatusInput {
|
||||
const obj = ensureObject(input, "update task status payload");
|
||||
const issues: string[] = [];
|
||||
@@ -324,6 +494,7 @@ function normalizeTask(input: unknown, fallbackProjectId?: string): ProjectTask
|
||||
title: asString(obj.title) ?? taskId,
|
||||
status: normalizeTaskState(asString(obj.status)),
|
||||
owner: asString(obj.owner) ?? "unassigned",
|
||||
roomId: normalizeOptionalRoomId(asString(obj.roomId)),
|
||||
dueAt: asOptionalIsoString(obj.dueAt),
|
||||
definitionOfDone: toStringArray(obj.definitionOfDone),
|
||||
artifacts: normalizeArtifacts(asArray(obj.artifacts)),
|
||||
@@ -506,6 +677,42 @@ function optionalBoundedString(
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
function optionalRoomId(
|
||||
value: unknown,
|
||||
field: string,
|
||||
issues: string[],
|
||||
): string | undefined {
|
||||
if (value === undefined) return undefined;
|
||||
if (typeof value !== "string") {
|
||||
issues.push(`${field} must be a string`);
|
||||
return undefined;
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) {
|
||||
issues.push(`${field} cannot be empty when provided`);
|
||||
return undefined;
|
||||
}
|
||||
if (!TASK_ID_REGEX.test(trimmed)) {
|
||||
issues.push(`${field} may only contain letters, numbers, '.', '_', ':', '-'`);
|
||||
return undefined;
|
||||
}
|
||||
if (trimmed.length > 140) {
|
||||
issues.push(`${field} must be <= 140 characters`);
|
||||
return undefined;
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
function optionalNullableRoomId(
|
||||
value: unknown,
|
||||
field: string,
|
||||
issues: string[],
|
||||
): string | null | undefined {
|
||||
if (value === undefined) return undefined;
|
||||
if (value === null) return null;
|
||||
return optionalRoomId(value, field, issues) ?? null;
|
||||
}
|
||||
|
||||
function optionalStringArray(
|
||||
value: unknown,
|
||||
field: string,
|
||||
@@ -537,6 +744,16 @@ function optionalIsoString(
|
||||
return new Date(value).toISOString();
|
||||
}
|
||||
|
||||
function optionalNullableIsoString(
|
||||
value: unknown,
|
||||
field: string,
|
||||
issues: string[],
|
||||
): string | null | undefined {
|
||||
if (value === undefined) return undefined;
|
||||
if (value === null) return null;
|
||||
return optionalIsoString(value, field, issues) ?? null;
|
||||
}
|
||||
|
||||
function optionalTaskState(
|
||||
value: unknown,
|
||||
field: string,
|
||||
@@ -659,6 +876,13 @@ function asOptionalIsoString(v: unknown): string | undefined {
|
||||
return new Date(v).toISOString();
|
||||
}
|
||||
|
||||
function normalizeOptionalRoomId(v: string | undefined): string | undefined {
|
||||
if (!v) return undefined;
|
||||
const trimmed = v.trim();
|
||||
if (!trimmed || !TASK_ID_REGEX.test(trimmed)) return undefined;
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
function toStringArray(v: unknown): string[] {
|
||||
if (!Array.isArray(v)) return [];
|
||||
return [...new Set(
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import type { ChatMessage, ChatRoom, RoomParticipantRole } from "../types";
|
||||
|
||||
export const DISCUSSION_SEQUENCE: RoomParticipantRole[] = ["planner", "coder", "reviewer", "manager"];
|
||||
|
||||
export function nextDiscussionRole(
|
||||
room: ChatRoom,
|
||||
messages: ChatMessage[],
|
||||
): RoomParticipantRole | undefined {
|
||||
if (room.stage !== "discussion") return undefined;
|
||||
const lastHumanIndex = findLastHumanMessageIndex(messages);
|
||||
const roundMessages = lastHumanIndex >= 0 ? messages.slice(lastHumanIndex + 1) : messages;
|
||||
const responded = new Set(
|
||||
roundMessages
|
||||
.map((message) => message.authorRole)
|
||||
.filter((role): role is RoomParticipantRole => DISCUSSION_SEQUENCE.includes(role)),
|
||||
);
|
||||
return DISCUSSION_SEQUENCE.find((role) => !responded.has(role));
|
||||
}
|
||||
|
||||
export function isDiscussionRoundComplete(room: ChatRoom, messages: ChatMessage[]): boolean {
|
||||
return room.stage === "discussion" && nextDiscussionRole(room, messages) === undefined;
|
||||
}
|
||||
|
||||
export function roleCanSpeak(
|
||||
room: ChatRoom,
|
||||
messages: ChatMessage[],
|
||||
role: RoomParticipantRole,
|
||||
mentions: RoomParticipantRole[] = [],
|
||||
): boolean {
|
||||
if (role === "human") return true;
|
||||
|
||||
if (room.stage === "intake") {
|
||||
return role === "planner";
|
||||
}
|
||||
|
||||
if (room.stage === "discussion") {
|
||||
return nextDiscussionRole(room, messages) === role;
|
||||
}
|
||||
|
||||
if (room.stage === "assigned" || room.stage === "executing") {
|
||||
if (room.assignedExecutor && role === room.assignedExecutor) return true;
|
||||
return mentions.includes(role);
|
||||
}
|
||||
|
||||
if (room.stage === "review") {
|
||||
return role === "reviewer" || role === "manager" || mentions.includes(role);
|
||||
}
|
||||
|
||||
if (room.stage === "completed") {
|
||||
return role === "manager" || mentions.includes(role);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export function findLastHumanMessageIndex(messages: ChatMessage[]): number {
|
||||
for (let idx = messages.length - 1; idx >= 0; idx -= 1) {
|
||||
if (messages[idx].authorRole === "human") return idx;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
+296
@@ -40,6 +40,21 @@ export interface ApprovalSummary {
|
||||
|
||||
export type TaskState = "todo" | "in_progress" | "blocked" | "done";
|
||||
export type ProjectState = "planned" | "active" | "blocked" | "done";
|
||||
export type RoomStage = "intake" | "discussion" | "assigned" | "executing" | "review" | "completed";
|
||||
export type MessageKind = "chat" | "proposal" | "decision" | "handoff" | "status" | "result";
|
||||
export type RoomParticipantRole = "human" | "planner" | "coder" | "reviewer" | "manager";
|
||||
export type HallSemanticRole = "planner" | "coder" | "reviewer" | "manager" | "generalist";
|
||||
export type HallTaskStage = "discussion" | "execution" | "review" | "blocked" | "completed";
|
||||
export type HallMessageKind =
|
||||
| "chat"
|
||||
| "task"
|
||||
| "proposal"
|
||||
| "decision"
|
||||
| "handoff"
|
||||
| "status"
|
||||
| "review"
|
||||
| "result"
|
||||
| "system";
|
||||
|
||||
export type TaskArtifactType = "code" | "doc" | "link" | "other";
|
||||
|
||||
@@ -70,6 +85,7 @@ export interface ProjectTask {
|
||||
title: string;
|
||||
status: TaskState;
|
||||
owner: string;
|
||||
roomId?: string;
|
||||
dueAt?: string;
|
||||
definitionOfDone: string[];
|
||||
artifacts: TaskArtifact[];
|
||||
@@ -79,6 +95,285 @@ export interface ProjectTask {
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface RoomParticipant {
|
||||
participantId: string;
|
||||
role: RoomParticipantRole;
|
||||
label: string;
|
||||
agentId?: string;
|
||||
sessionKey?: string;
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
export interface HandoffRecord {
|
||||
handoffId: string;
|
||||
roomId: string;
|
||||
taskId: string;
|
||||
fromRole: RoomParticipantRole;
|
||||
toRole: RoomParticipantRole;
|
||||
note?: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface ChatMessagePayload {
|
||||
proposal?: string;
|
||||
decision?: string;
|
||||
executor?: RoomParticipantRole;
|
||||
doneWhen?: string;
|
||||
fromRole?: RoomParticipantRole;
|
||||
targetRole?: RoomParticipantRole;
|
||||
handoffId?: string;
|
||||
status?: string;
|
||||
taskStatus?: TaskState;
|
||||
reviewOutcome?: "approved" | "rejected";
|
||||
sessionKey?: string;
|
||||
sourceSessionKey?: string;
|
||||
sourceTool?: string;
|
||||
}
|
||||
|
||||
export interface ChatMessage {
|
||||
roomId: string;
|
||||
messageId: string;
|
||||
kind: MessageKind;
|
||||
authorRole: RoomParticipantRole;
|
||||
authorLabel: string;
|
||||
participantId?: string;
|
||||
content: string;
|
||||
mentions: RoomParticipantRole[];
|
||||
sessionKey?: string;
|
||||
payload?: ChatMessagePayload;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface ChatRoomSummary {
|
||||
roomId: string;
|
||||
headline: string;
|
||||
latestDecision?: string;
|
||||
currentOwner?: RoomParticipantRole;
|
||||
nextAction: string;
|
||||
openQuestions: string[];
|
||||
messageCount: number;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface ChatRoom {
|
||||
roomId: string;
|
||||
projectId: string;
|
||||
taskId: string;
|
||||
title: string;
|
||||
stage: RoomStage;
|
||||
ownerRole: RoomParticipantRole;
|
||||
assignedExecutor?: RoomParticipantRole;
|
||||
proposal?: string;
|
||||
decision?: string;
|
||||
doneWhen?: string;
|
||||
participants: RoomParticipant[];
|
||||
handoffs: HandoffRecord[];
|
||||
sessionKeys: string[];
|
||||
summaryId?: string;
|
||||
lastMessageAt?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface ChatRoomStoreSnapshot {
|
||||
rooms: ChatRoom[];
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface ChatMessageStoreSnapshot {
|
||||
messages: ChatMessage[];
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface ChatSummaryStoreSnapshot {
|
||||
summaries: ChatRoomSummary[];
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface MentionTarget {
|
||||
raw: string;
|
||||
participantId: string;
|
||||
displayName: string;
|
||||
semanticRole: HallSemanticRole;
|
||||
}
|
||||
|
||||
export interface ExecutionLock {
|
||||
taskId: string;
|
||||
projectId: string;
|
||||
ownerParticipantId: string;
|
||||
ownerLabel: string;
|
||||
acquiredAt: string;
|
||||
releasedAt?: string;
|
||||
releasedReason?: string;
|
||||
}
|
||||
|
||||
export interface StructuredHandoffPacket {
|
||||
goal: string;
|
||||
currentResult: string;
|
||||
doneWhen: string;
|
||||
blockers: string[];
|
||||
nextOwner: string;
|
||||
requiresInputFrom: string[];
|
||||
artifactRefs?: TaskArtifact[];
|
||||
}
|
||||
|
||||
export interface HallParticipant {
|
||||
participantId: string;
|
||||
agentId?: string;
|
||||
displayName: string;
|
||||
semanticRole: HallSemanticRole;
|
||||
active: boolean;
|
||||
aliases: string[];
|
||||
isHuman?: boolean;
|
||||
}
|
||||
|
||||
export interface TaskDiscussionCycle {
|
||||
cycleId: string;
|
||||
openedAt: string;
|
||||
openedByParticipantId: string;
|
||||
expectedParticipantIds: string[];
|
||||
completedParticipantIds: string[];
|
||||
closedAt?: string;
|
||||
}
|
||||
|
||||
export interface HallMessagePayload {
|
||||
projectId?: string;
|
||||
taskId?: string;
|
||||
taskCardId?: string;
|
||||
roomId?: string;
|
||||
proposal?: string;
|
||||
decision?: string;
|
||||
doneWhen?: string;
|
||||
executionOrder?: string[];
|
||||
executionItems?: HallExecutionItem[];
|
||||
nextOwnerParticipantId?: string;
|
||||
reviewOutcome?: "approved" | "rejected";
|
||||
taskStatus?: TaskState;
|
||||
taskStage?: HallTaskStage;
|
||||
status?: string;
|
||||
handoff?: StructuredHandoffPacket;
|
||||
artifactRefs?: TaskArtifact[];
|
||||
sessionKey?: string;
|
||||
sourceSessionKey?: string;
|
||||
sourceTool?: string;
|
||||
}
|
||||
|
||||
export interface HallMessage {
|
||||
hallId: string;
|
||||
messageId: string;
|
||||
kind: HallMessageKind;
|
||||
authorParticipantId: string;
|
||||
authorLabel: string;
|
||||
authorSemanticRole?: HallSemanticRole;
|
||||
content: string;
|
||||
targetParticipantIds: string[];
|
||||
mentionTargets: MentionTarget[];
|
||||
projectId?: string;
|
||||
taskId?: string;
|
||||
taskCardId?: string;
|
||||
roomId?: string;
|
||||
payload?: HallMessagePayload;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface HallTaskCard {
|
||||
hallId: string;
|
||||
taskCardId: string;
|
||||
projectId: string;
|
||||
taskId: string;
|
||||
roomId?: string;
|
||||
title: string;
|
||||
description: string;
|
||||
stage: HallTaskStage;
|
||||
status: TaskState;
|
||||
createdByParticipantId: string;
|
||||
currentOwnerParticipantId?: string;
|
||||
currentOwnerLabel?: string;
|
||||
proposal?: string;
|
||||
decision?: string;
|
||||
doneWhen?: string;
|
||||
latestSummary?: string;
|
||||
blockers: string[];
|
||||
requiresInputFrom: string[];
|
||||
mentionedParticipantIds: string[];
|
||||
plannedExecutionOrder: string[];
|
||||
plannedExecutionItems: HallExecutionItem[];
|
||||
currentExecutionItem?: HallExecutionItem;
|
||||
sessionKeys: string[];
|
||||
discussionCycle?: TaskDiscussionCycle;
|
||||
executionLock?: ExecutionLock;
|
||||
archivedAt?: string;
|
||||
archivedByParticipantId?: string;
|
||||
archivedByLabel?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface HallExecutionItem {
|
||||
itemId: string;
|
||||
participantId: string;
|
||||
task: string;
|
||||
handoffToParticipantId?: string;
|
||||
handoffWhen?: string;
|
||||
}
|
||||
|
||||
export interface CollaborationHall {
|
||||
hallId: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
participants: HallParticipant[];
|
||||
taskCardIds: string[];
|
||||
messageIds: string[];
|
||||
lastMessageId?: string | null;
|
||||
latestMessageAt?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface CollaborationHallSummary {
|
||||
hallId: string;
|
||||
headline: string;
|
||||
activeTaskCount: number;
|
||||
waitingReviewCount: number;
|
||||
blockedTaskCount: number;
|
||||
currentSpeakerLabel?: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface HallTaskSummary {
|
||||
taskCardId: string;
|
||||
projectId: string;
|
||||
taskId: string;
|
||||
headline: string;
|
||||
currentOwnerLabel?: string;
|
||||
nextAction: string;
|
||||
stage: HallTaskStage;
|
||||
blockerCount: number;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface CollaborationHallStoreSnapshot {
|
||||
halls: CollaborationHall[];
|
||||
executionLocks: ExecutionLock[];
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface CollaborationHallMessageStoreSnapshot {
|
||||
messages: HallMessage[];
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface CollaborationTaskCardStoreSnapshot {
|
||||
taskCards: HallTaskCard[];
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface CollaborationHallSummaryStoreSnapshot {
|
||||
hallSummaries: CollaborationHallSummary[];
|
||||
taskSummaries: HallTaskSummary[];
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface ProjectRecord {
|
||||
projectId: string;
|
||||
title: string;
|
||||
@@ -193,6 +488,7 @@ export interface TaskListItem {
|
||||
title: string;
|
||||
status: TaskState;
|
||||
owner: string;
|
||||
roomId?: string;
|
||||
dueAt?: string;
|
||||
sessionKeys: string[];
|
||||
updatedAt: string;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+1433
-66
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,768 @@
|
||||
import type { UiLanguage } from "../runtime/ui-preferences";
|
||||
import type { ChatMessage, ChatRoom, ChatRoomSummary, ProjectTask, RoomParticipantRole } from "../types";
|
||||
|
||||
interface TaskRoomViewModel {
|
||||
room: ChatRoom;
|
||||
summary?: ChatRoomSummary;
|
||||
task?: ProjectTask;
|
||||
}
|
||||
|
||||
export interface RenderTaskRoomWorkbenchInput {
|
||||
language: UiLanguage;
|
||||
rooms: TaskRoomViewModel[];
|
||||
selectedRoom?: ChatRoom;
|
||||
selectedMessages?: ChatMessage[];
|
||||
selectedSummary?: ChatRoomSummary;
|
||||
selectedTask?: ProjectTask;
|
||||
}
|
||||
|
||||
export function renderTaskRoomWorkbench(input: RenderTaskRoomWorkbenchInput): string {
|
||||
const t = (en: string, zh: string) => pickUiText(input.language, en, zh);
|
||||
const selectedRoom = input.selectedRoom;
|
||||
const selectedSummary = input.selectedSummary;
|
||||
const selectedTask = input.selectedTask;
|
||||
const selectedMessages = input.selectedMessages ?? [];
|
||||
const bootstrap = {
|
||||
selectedRoomId: selectedRoom?.roomId,
|
||||
rooms: input.rooms.map((item) => ({
|
||||
roomId: item.room.roomId,
|
||||
title: item.room.title,
|
||||
stage: item.room.stage,
|
||||
ownerRole: item.room.ownerRole,
|
||||
assignedExecutor: item.room.assignedExecutor,
|
||||
updatedAt: item.room.updatedAt,
|
||||
summary: item.summary?.headline,
|
||||
taskId: item.room.taskId,
|
||||
})),
|
||||
labels: {
|
||||
emptyRooms: t("No task rooms yet.", "还没有任务房间。"),
|
||||
noMessages: t("No room messages yet.", "当前还没有房间消息。"),
|
||||
send: t("Send", "发送"),
|
||||
assign: t("Assign executor", "指定执行者"),
|
||||
approve: t("Approve", "通过"),
|
||||
reject: t("Request changes", "打回修改"),
|
||||
loading: t("Loading room…", "正在加载房间…"),
|
||||
stage: t("Stage", "阶段"),
|
||||
owner: t("Owner", "当前负责"),
|
||||
executor: t("Executor", "执行者"),
|
||||
task: t("Task", "任务"),
|
||||
summary: t("Summary", "摘要"),
|
||||
participants: t("Participants", "参与者"),
|
||||
openQuestions: t("Open questions", "未决问题"),
|
||||
roomThread: t("Room timeline", "房间时间线"),
|
||||
runtimeEvidence: t("Runtime evidence is merged into this timeline when session links exist.", "只要房间里挂了会话,运行证据会自动并到这条时间线上。"),
|
||||
needToken: t("This action requires LOCAL_API_TOKEN.", "这个动作需要 LOCAL_API_TOKEN。"),
|
||||
assignNote: t("Optional handoff note", "可选交接备注"),
|
||||
rejectNote: t("Why should it change?", "为什么要打回?"),
|
||||
approveNote: t("Optional review note", "可选审核备注"),
|
||||
composerLabel: t("Post as operator", "以操作员身份发言"),
|
||||
inputPlaceholder: t("Describe the task, ask for a plan, or post execution feedback…", "描述任务、请求方案,或者补充执行反馈…"),
|
||||
},
|
||||
language: input.language,
|
||||
};
|
||||
|
||||
return `
|
||||
<section class="card task-room-hub" id="task-room-hub" data-task-room-root>
|
||||
<style>
|
||||
.task-room-hub { overflow: hidden; }
|
||||
.task-room-layout { display: grid; grid-template-columns: minmax(220px, 0.95fr) minmax(0, 1.7fr) minmax(240px, 1fr); gap: 14px; margin-top: 14px; }
|
||||
.task-room-pane { border: 1px solid rgba(22, 86, 116, 0.12); border-radius: 18px; padding: 14px; background: linear-gradient(180deg, rgba(255,255,255,0.98), rgba(247,250,255,0.95)); min-height: 420px; }
|
||||
.task-room-sidebar { display: grid; gap: 10px; align-content: start; }
|
||||
.task-room-list { display: grid; gap: 8px; max-height: 620px; overflow: auto; }
|
||||
.task-room-item { width: 100%; border: 1px solid rgba(15, 82, 120, 0.12); border-radius: 14px; background: rgba(255,255,255,0.92); padding: 10px 12px; text-align: left; cursor: pointer; color: #11354b; }
|
||||
.task-room-item.is-selected { border-color: rgba(15, 109, 179, 0.35); box-shadow: 0 8px 18px rgba(15, 109, 179, 0.12); background: linear-gradient(180deg, rgba(244,250,255,0.98), rgba(255,255,255,0.98)); }
|
||||
.task-room-item strong { display: block; font-size: 13px; }
|
||||
.task-room-item .meta { margin-top: 4px; font-size: 11px; }
|
||||
.task-room-stage-chip { display: inline-flex; align-items: center; padding: 3px 8px; border-radius: 999px; font-size: 11px; border: 1px solid rgba(15,82,120,0.14); background: rgba(244,249,255,0.9); color: #1c5471; }
|
||||
.task-room-thread { display: grid; gap: 10px; max-height: 560px; overflow: auto; padding-right: 4px; }
|
||||
.task-room-message { border: 1px solid rgba(22, 86, 116, 0.1); border-radius: 16px; padding: 10px 12px; background: rgba(255,255,255,0.96); }
|
||||
.task-room-message[data-kind="decision"] { border-color: rgba(22, 128, 95, 0.22); background: rgba(240, 252, 247, 0.96); }
|
||||
.task-room-message[data-kind="handoff"] { border-color: rgba(180, 124, 15, 0.22); background: rgba(255, 249, 235, 0.96); }
|
||||
.task-room-message[data-kind="result"] { border-color: rgba(15, 109, 179, 0.22); background: rgba(241, 248, 255, 0.97); }
|
||||
.task-room-message-head { display: flex; justify-content: space-between; gap: 8px; align-items: baseline; margin-bottom: 6px; }
|
||||
.task-room-message-head strong { font-size: 13px; color: #12344a; }
|
||||
.task-room-message-copy { white-space: pre-wrap; line-height: 1.5; color: #16364a; font-size: 13px; }
|
||||
.task-room-payload { margin-top: 8px; display: grid; gap: 5px; font-size: 12px; color: #496173; }
|
||||
.task-room-compose { display: grid; gap: 10px; margin-top: 14px; }
|
||||
.task-room-compose textarea { width: 100%; min-height: 92px; resize: vertical; border-radius: 14px; border: 1px solid rgba(22, 86, 116, 0.16); padding: 10px 12px; font: inherit; background: rgba(255,255,255,0.97); }
|
||||
.task-room-compose-actions { display: flex; flex-wrap: wrap; gap: 8px; }
|
||||
.task-room-compose-actions button, .task-room-compose-actions .task-room-secondary { border-radius: 999px; padding: 8px 12px; border: 1px solid rgba(15, 82, 120, 0.14); background: rgba(255,255,255,0.96); cursor: pointer; font: inherit; color: #124a68; }
|
||||
.task-room-compose-actions button[type="submit"] { background: linear-gradient(180deg, rgba(14, 111, 173, 0.95), rgba(11, 96, 150, 0.98)); color: #fff; border-color: rgba(11, 96, 150, 0.6); }
|
||||
.task-room-detail-list, .task-room-question-list { display: grid; gap: 8px; margin: 0; padding-left: 18px; }
|
||||
.task-room-stat-grid { display: grid; gap: 8px; margin-bottom: 12px; }
|
||||
.task-room-stat { border: 1px solid rgba(22, 86, 116, 0.1); border-radius: 14px; padding: 10px; background: rgba(255,255,255,0.94); }
|
||||
.task-room-flash { min-height: 18px; font-size: 12px; color: #496173; }
|
||||
.task-room-empty { border: 1px dashed rgba(22, 86, 116, 0.18); border-radius: 16px; padding: 18px; color: #5b6974; background: rgba(251, 253, 255, 0.95); }
|
||||
@media (max-width: 1080px) { .task-room-layout { grid-template-columns: 1fr; } .task-room-pane { min-height: 0; } }
|
||||
</style>
|
||||
<div class="overview-command-head">
|
||||
<div>
|
||||
<h2>${escapeHtml(t("Task room workbench", "任务房间工作台"))}</h2>
|
||||
<div class="meta">${escapeHtml(t("Run the MVP collaboration loop here: operator request, structured discussion, assignment, execution, and review.", "在这里跑通 MVP 协作闭环:操作员提需求、结构化讨论、指定执行、执行和审核。"))}</div>
|
||||
</div>
|
||||
<div class="task-room-stage-chip">${escapeHtml(selectedRoom ? stageLabel(selectedRoom.stage, input.language) : t("No room", "暂无房间"))}</div>
|
||||
</div>
|
||||
<div class="task-room-layout">
|
||||
<aside class="task-room-pane task-room-sidebar">
|
||||
<div class="meta">${escapeHtml(t("One task per room. Pick a room to inspect the full timeline.", "一个任务一个房间。点左边房间,就能看完整时间线。"))}</div>
|
||||
<div class="task-room-list" data-task-room-list>${renderRoomList(input.rooms, selectedRoom?.roomId, input.language)}</div>
|
||||
</aside>
|
||||
<section class="task-room-pane">
|
||||
<div class="overview-command-head">
|
||||
<div>
|
||||
<h3 style="margin:0;">${escapeHtml(t("Room timeline", "房间时间线"))}</h3>
|
||||
<div class="meta">${escapeHtml(t("Runtime evidence is merged into this timeline when session links exist.", "只要房间里挂了会话,运行证据会自动并到这条时间线上。"))}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="task-room-thread" data-task-room-thread>${renderMessageThread(selectedMessages, input.language)}</div>
|
||||
<form class="task-room-compose" data-task-room-compose>
|
||||
<label for="task-room-input">${escapeHtml(t("Post as operator", "以操作员身份发言"))}</label>
|
||||
<textarea id="task-room-input" name="content" placeholder="${escapeHtml(t("Describe the task, ask for a plan, or post execution feedback…", "描述任务、请求方案,或者补充执行反馈…"))}" ${selectedRoom ? "" : "disabled"}></textarea>
|
||||
<div class="task-room-compose-actions">
|
||||
<button type="submit" ${selectedRoom ? "" : "disabled"}>${escapeHtml(t("Send", "发送"))}</button>
|
||||
<button type="button" class="task-room-secondary" data-task-room-assign ${selectedRoom ? "" : "disabled"}>${escapeHtml(t("Assign executor", "指定执行者"))}</button>
|
||||
<button type="button" class="task-room-secondary" data-task-room-approve ${selectedRoom ? "" : "disabled"}>${escapeHtml(t("Approve", "通过"))}</button>
|
||||
<button type="button" class="task-room-secondary" data-task-room-reject ${selectedRoom ? "" : "disabled"}>${escapeHtml(t("Request changes", "打回修改"))}</button>
|
||||
</div>
|
||||
<div class="task-room-flash" data-task-room-flash></div>
|
||||
</form>
|
||||
</section>
|
||||
<aside class="task-room-pane" data-task-room-detail>${renderRoomDetail(selectedRoom, selectedSummary, selectedTask, input.language)}</aside>
|
||||
</div>
|
||||
<script type="application/json" id="task-room-bootstrap">${safeJsonForScript(bootstrap)}</script>
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
|
||||
export function renderTaskRoomClientScript(language: UiLanguage): string {
|
||||
const labels = {
|
||||
loading: pickUiText(language, "Loading room…", "正在加载房间…"),
|
||||
emptyRooms: pickUiText(language, "No task rooms yet.", "还没有任务房间。"),
|
||||
noMessages: pickUiText(language, "No room messages yet.", "当前还没有房间消息。"),
|
||||
error: pickUiText(language, "Room action failed.", "房间操作失败。"),
|
||||
assignNote: pickUiText(language, "Optional handoff note", "可选交接备注"),
|
||||
rejectNote: pickUiText(language, "Why should it change?", "为什么要打回?"),
|
||||
approveNote: pickUiText(language, "Optional review note", "可选审核备注"),
|
||||
needToken: pickUiText(language, "This action requires LOCAL_API_TOKEN.", "这个动作需要 LOCAL_API_TOKEN。"),
|
||||
stage: pickUiText(language, "Stage", "阶段"),
|
||||
owner: pickUiText(language, "Owner", "当前负责"),
|
||||
executor: pickUiText(language, "Executor", "执行者"),
|
||||
task: pickUiText(language, "Task", "任务"),
|
||||
summary: pickUiText(language, "Summary", "摘要"),
|
||||
participants: pickUiText(language, "Participants", "参与者"),
|
||||
openQuestions: pickUiText(language, "Open questions", "未决问题"),
|
||||
};
|
||||
|
||||
return `<script>
|
||||
(() => {
|
||||
const root = document.querySelector('[data-task-room-root]');
|
||||
if (!root) return;
|
||||
const bootstrapNode = document.getElementById('task-room-bootstrap');
|
||||
if (!(bootstrapNode instanceof HTMLScriptElement)) return;
|
||||
|
||||
let bootstrap = { rooms: [], selectedRoomId: '', labels: {}, language: 'en' };
|
||||
try {
|
||||
bootstrap = JSON.parse(bootstrapNode.textContent || '{}');
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
const labels = Object.assign(${JSON.stringify(labels)}, bootstrap.labels || {});
|
||||
const roomList = root.querySelector('[data-task-room-list]');
|
||||
const thread = root.querySelector('[data-task-room-thread]');
|
||||
const detail = root.querySelector('[data-task-room-detail]');
|
||||
const compose = root.querySelector('[data-task-room-compose]');
|
||||
const flash = root.querySelector('[data-task-room-flash]');
|
||||
const input = root.querySelector('#task-room-input');
|
||||
const assignBtn = root.querySelector('[data-task-room-assign]');
|
||||
const approveBtn = root.querySelector('[data-task-room-approve]');
|
||||
const rejectBtn = root.querySelector('[data-task-room-reject]');
|
||||
const tokenKey = 'openclaw:local-api-token';
|
||||
let selectedRoomId = String(bootstrap.selectedRoomId || '');
|
||||
let roomMessages = [];
|
||||
let roomDrafts = new Map();
|
||||
let eventSource = null;
|
||||
let reloadTimer = 0;
|
||||
|
||||
const esc = (value) => String(value || '').replace(/[&<>"']/g, (ch) => ({ '&':'&','<':'<','>':'>','"':'"', "'": ''' }[ch]));
|
||||
const stageLabel = (stage) => ({
|
||||
intake: ${JSON.stringify(pickUiText(language, "Intake", "收件"))},
|
||||
discussion: ${JSON.stringify(pickUiText(language, "Discussion", "讨论中"))},
|
||||
assigned: ${JSON.stringify(pickUiText(language, "Assigned", "已指派"))},
|
||||
executing: ${JSON.stringify(pickUiText(language, "Executing", "执行中"))},
|
||||
review: ${JSON.stringify(pickUiText(language, "Review", "审核中"))},
|
||||
completed: ${JSON.stringify(pickUiText(language, "Completed", "已完成"))},
|
||||
}[stage] || stage || '');
|
||||
const roleLabel = (role) => ({
|
||||
human: ${JSON.stringify(pickUiText(language, "Operator", "操作员"))},
|
||||
planner: 'Planner',
|
||||
coder: 'Coder',
|
||||
reviewer: 'Reviewer',
|
||||
manager: 'Manager',
|
||||
}[role] || role || '');
|
||||
const readToken = () => {
|
||||
try {
|
||||
const stored = window.localStorage.getItem(tokenKey) || '';
|
||||
if (stored) return stored;
|
||||
} catch {}
|
||||
return (document.body?.dataset?.localTokenValue || '').trim();
|
||||
};
|
||||
const writeToken = (token) => {
|
||||
try { window.localStorage.setItem(tokenKey, token || ''); } catch {}
|
||||
};
|
||||
const syncRoomUrl = (roomId) => {
|
||||
try {
|
||||
const url = new URL(window.location.href);
|
||||
if (roomId) url.searchParams.set('roomId', roomId);
|
||||
else url.searchParams.delete('roomId');
|
||||
window.history.replaceState({}, '', url.toString());
|
||||
} catch {}
|
||||
};
|
||||
const ensureToken = () => {
|
||||
let token = readToken();
|
||||
if (token) {
|
||||
writeToken(token);
|
||||
return token;
|
||||
}
|
||||
return token;
|
||||
};
|
||||
const setFlash = (message, tone = 'info') => {
|
||||
if (!flash) return;
|
||||
flash.textContent = message || '';
|
||||
flash.dataset.tone = tone;
|
||||
};
|
||||
|
||||
const renderRoomList = (rooms) => {
|
||||
if (!roomList) return;
|
||||
if (!Array.isArray(rooms) || rooms.length === 0) {
|
||||
roomList.innerHTML = '<div class="task-room-empty">' + esc(labels.emptyRooms) + '</div>';
|
||||
return;
|
||||
}
|
||||
roomList.innerHTML = rooms.map((item) => {
|
||||
const selected = String(item.roomId || '') === selectedRoomId;
|
||||
return '<button type="button" class="task-room-item' + (selected ? ' is-selected' : '') + '" data-room-id="' + esc(item.roomId) + '">' +
|
||||
'<strong>' + esc(item.title || item.taskId || item.roomId) + '</strong>' +
|
||||
'<div class="meta"><span class="task-room-stage-chip">' + esc(stageLabel(item.stage)) + '</span></div>' +
|
||||
'<div class="meta">' + esc(item.summary || item.roomId || '') + '</div>' +
|
||||
'</button>';
|
||||
}).join('');
|
||||
roomList.querySelectorAll('[data-room-id]').forEach((button) => {
|
||||
button.addEventListener('click', () => {
|
||||
const roomId = button.getAttribute('data-room-id') || '';
|
||||
if (!roomId) return;
|
||||
void loadRoom(roomId, true);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const renderMessages = (messages) => {
|
||||
if (!thread) return;
|
||||
if (!Array.isArray(messages) || messages.length === 0) {
|
||||
thread.innerHTML = '<div class="task-room-empty">' + esc(labels.noMessages) + '</div>';
|
||||
return;
|
||||
}
|
||||
thread.innerHTML = messages.map((message) => {
|
||||
const payload = message.payload && typeof message.payload === 'object' ? message.payload : null;
|
||||
const payloadRows = [];
|
||||
if (payload && payload.decision) payloadRows.push('<div><strong>Decision:</strong> ' + esc(payload.decision) + '</div>');
|
||||
if (payload && payload.executor) payloadRows.push('<div><strong>Executor:</strong> ' + esc(roleLabel(payload.executor)) + '</div>');
|
||||
if (payload && payload.doneWhen) payloadRows.push('<div><strong>Done when:</strong> ' + esc(payload.doneWhen) + '</div>');
|
||||
if (payload && payload.reviewOutcome) payloadRows.push('<div><strong>Review:</strong> ' + esc(payload.reviewOutcome) + '</div>');
|
||||
if (message.isDraft) payloadRows.push('<div><strong>${escapeHtml(pickUiText(language, "Stream", "流式"))}:</strong> ${escapeHtml(pickUiText(language, "in progress", "进行中"))}</div>');
|
||||
return '<article class="task-room-message" data-kind="' + esc(message.kind || 'chat') + '">' +
|
||||
'<div class="task-room-message-head"><strong>' + esc(message.authorLabel || roleLabel(message.authorRole)) + ' · ' + esc(roleLabel(message.authorRole)) + '</strong>' +
|
||||
'<span class="meta">' + esc(message.createdAt || '') + '</span></div>' +
|
||||
'<div class="task-room-message-copy">' + esc(message.content || '') + '</div>' +
|
||||
(payloadRows.length ? '<div class="task-room-payload">' + payloadRows.join('') + '</div>' : '') +
|
||||
'</article>';
|
||||
}).join('');
|
||||
thread.scrollTop = thread.scrollHeight;
|
||||
};
|
||||
|
||||
const draftMessages = () => Array.from(roomDrafts.values()).map((draft) => ({
|
||||
kind: draft.messageKind || 'chat',
|
||||
authorRole: draft.authorRole || 'manager',
|
||||
authorLabel: draft.authorLabel || roleLabel(draft.authorRole),
|
||||
content: draft.content || '',
|
||||
createdAt: draft.createdAt || '',
|
||||
payload: null,
|
||||
isDraft: true,
|
||||
}));
|
||||
|
||||
const renderVisibleMessages = () => {
|
||||
const merged = [...roomMessages, ...draftMessages()]
|
||||
.sort((a, b) => Date.parse(a.createdAt || '') - Date.parse(b.createdAt || ''));
|
||||
renderMessages(merged);
|
||||
};
|
||||
|
||||
const renderDetail = (roomPayload, summaryPayload, taskPayload) => {
|
||||
if (!detail) return;
|
||||
if (!roomPayload) {
|
||||
detail.innerHTML = '<div class="task-room-empty">' + esc(labels.emptyRooms) + '</div>';
|
||||
return;
|
||||
}
|
||||
const participants = Array.isArray(roomPayload.participants) ? roomPayload.participants : [];
|
||||
const questions = Array.isArray(summaryPayload && summaryPayload.openQuestions) ? summaryPayload.openQuestions : [];
|
||||
detail.innerHTML =
|
||||
'<div class="task-room-stat-grid">' +
|
||||
'<div class="task-room-stat"><div class="meta">' + esc(labels.stage) + '</div><strong>' + esc(stageLabel(roomPayload.stage)) + '</strong></div>' +
|
||||
'<div class="task-room-stat"><div class="meta">' + esc(labels.owner) + '</div><strong>' + esc(roleLabel(roomPayload.ownerRole)) + '</strong></div>' +
|
||||
'<div class="task-room-stat"><div class="meta">' + esc(labels.executor) + '</div><strong>' + esc(roleLabel(roomPayload.assignedExecutor || '')) + '</strong></div>' +
|
||||
'</div>' +
|
||||
'<div class="meta">' + esc(labels.task) + '</div>' +
|
||||
'<div style="margin:4px 0 12px;"><strong>' + esc(taskPayload && taskPayload.title ? taskPayload.title : roomPayload.title || roomPayload.taskId) + '</strong><div class="meta">' + esc((taskPayload && taskPayload.taskId ? taskPayload.taskId : roomPayload.taskId) || '') + '</div></div>' +
|
||||
'<div class="meta">' + esc(labels.summary) + '</div>' +
|
||||
'<div style="margin:4px 0 12px;"><strong>' + esc(summaryPayload && summaryPayload.headline ? summaryPayload.headline : roomPayload.decision || roomPayload.proposal || roomPayload.title) + '</strong><div class="meta">' + esc(summaryPayload && summaryPayload.nextAction ? summaryPayload.nextAction : '') + '</div></div>' +
|
||||
'<div class="meta">' + esc(labels.participants) + '</div>' +
|
||||
'<ul class="task-room-detail-list">' + participants.map((participant) => '<li>' + esc(participant.label || roleLabel(participant.role)) + ' · ' + esc(roleLabel(participant.role)) + '</li>').join('') + '</ul>' +
|
||||
'<div class="meta" style="margin-top:12px;">' + esc(labels.openQuestions) + '</div>' +
|
||||
(questions.length
|
||||
? '<ul class="task-room-question-list">' + questions.map((question) => '<li>' + esc(question) + '</li>').join('') + '</ul>'
|
||||
: '<div class="meta">-</div>');
|
||||
};
|
||||
|
||||
const fetchJson = async (url, options = {}) => {
|
||||
const res = await fetch(url, options);
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
const errorMessage = data && data.error && data.error.message ? data.error.message : labels.error;
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
return data;
|
||||
};
|
||||
|
||||
const reloadRooms = async () => {
|
||||
const data = await fetchJson('/api/rooms');
|
||||
bootstrap.rooms = Array.isArray(data.rooms)
|
||||
? data.rooms.map((item) => ({
|
||||
roomId: item.roomId,
|
||||
title: item.title,
|
||||
stage: item.stage,
|
||||
ownerRole: item.ownerRole,
|
||||
assignedExecutor: item.assignedExecutor,
|
||||
updatedAt: item.updatedAt,
|
||||
summary: item.summary && item.summary.headline ? item.summary.headline : '',
|
||||
taskId: item.taskId,
|
||||
}))
|
||||
: [];
|
||||
renderRoomList(bootstrap.rooms);
|
||||
};
|
||||
|
||||
const loadRoom = async (roomId, quiet = false) => {
|
||||
selectedRoomId = roomId;
|
||||
roomDrafts.clear();
|
||||
connectRoomStream(roomId);
|
||||
syncRoomUrl(roomId);
|
||||
renderRoomList(bootstrap.rooms);
|
||||
if (!quiet) setFlash(labels.loading);
|
||||
const [detailData, messageData] = await Promise.all([
|
||||
fetchJson('/api/rooms/' + encodeURIComponent(roomId)),
|
||||
fetchJson('/api/rooms/' + encodeURIComponent(roomId) + '/messages?limit=200&historyLimit=25'),
|
||||
]);
|
||||
roomMessages = Array.isArray(messageData.messages) ? messageData.messages : [];
|
||||
renderVisibleMessages();
|
||||
renderDetail(detailData.room, detailData.summary, detailData.task);
|
||||
setFlash('');
|
||||
};
|
||||
|
||||
const scheduleRoomReload = () => {
|
||||
if (!selectedRoomId) return;
|
||||
if (reloadTimer) window.clearTimeout(reloadTimer);
|
||||
reloadTimer = window.setTimeout(() => {
|
||||
if (!selectedRoomId) return;
|
||||
void loadRoom(selectedRoomId, true).catch(() => {});
|
||||
}, 90);
|
||||
};
|
||||
|
||||
const connectRoomStream = (roomId) => {
|
||||
if (!window.EventSource || !roomId) return;
|
||||
eventSource?.close?.();
|
||||
const source = new EventSource('/api/rooms/' + encodeURIComponent(roomId) + '/events');
|
||||
eventSource = source;
|
||||
source.addEventListener('collaboration', (rawEvent) => {
|
||||
let event;
|
||||
try {
|
||||
event = JSON.parse(rawEvent.data || '{}');
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (!event || event.scope !== 'room' || event.roomId !== roomId) return;
|
||||
if (event.type === 'draft_start' && event.draftId) {
|
||||
roomDrafts.set(event.draftId, {
|
||||
draftId: event.draftId,
|
||||
createdAt: event.createdAt || new Date().toISOString(),
|
||||
authorRole: event.authorRole || 'manager',
|
||||
authorLabel: event.authorLabel || roleLabel(event.authorRole || 'manager'),
|
||||
messageKind: event.messageKind || 'chat',
|
||||
content: '',
|
||||
});
|
||||
renderVisibleMessages();
|
||||
return;
|
||||
}
|
||||
if (event.type === 'draft_delta' && event.draftId) {
|
||||
const draft = roomDrafts.get(event.draftId);
|
||||
if (!draft) return;
|
||||
draft.content = (draft.content || '') + String(event.delta || '');
|
||||
roomDrafts.set(event.draftId, draft);
|
||||
renderVisibleMessages();
|
||||
return;
|
||||
}
|
||||
if (event.type === 'draft_complete' && event.draftId) {
|
||||
roomDrafts.delete(event.draftId);
|
||||
renderVisibleMessages();
|
||||
scheduleRoomReload();
|
||||
return;
|
||||
}
|
||||
if (event.type === 'invalidate') {
|
||||
scheduleRoomReload();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const mutateRoom = async (url, body) => {
|
||||
const token = ensureToken();
|
||||
if (!token) throw new Error(labels.needToken);
|
||||
return fetchJson(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-local-token': token,
|
||||
},
|
||||
body: JSON.stringify(body || {}),
|
||||
});
|
||||
};
|
||||
|
||||
if (compose instanceof HTMLFormElement && input instanceof HTMLTextAreaElement) {
|
||||
compose.addEventListener('submit', async (event) => {
|
||||
event.preventDefault();
|
||||
if (!selectedRoomId) return;
|
||||
const content = input.value.trim();
|
||||
if (!content) return;
|
||||
try {
|
||||
setFlash(labels.loading);
|
||||
await mutateRoom('/api/rooms/' + encodeURIComponent(selectedRoomId) + '/messages', {
|
||||
authorRole: 'human',
|
||||
content,
|
||||
});
|
||||
input.value = '';
|
||||
await reloadRooms();
|
||||
scheduleRoomReload();
|
||||
setFlash('');
|
||||
} catch (error) {
|
||||
setFlash(error instanceof Error ? error.message : labels.error, 'warn');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (assignBtn instanceof HTMLButtonElement) {
|
||||
assignBtn.addEventListener('click', async () => {
|
||||
if (!selectedRoomId) return;
|
||||
try {
|
||||
const note = '';
|
||||
setFlash(labels.loading);
|
||||
await mutateRoom('/api/rooms/' + encodeURIComponent(selectedRoomId) + '/assign', { note });
|
||||
await reloadRooms();
|
||||
scheduleRoomReload();
|
||||
setFlash('');
|
||||
} catch (error) {
|
||||
setFlash(error instanceof Error ? error.message : labels.error, 'warn');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (approveBtn instanceof HTMLButtonElement) {
|
||||
approveBtn.addEventListener('click', async () => {
|
||||
if (!selectedRoomId) return;
|
||||
try {
|
||||
const note = '';
|
||||
setFlash(labels.loading);
|
||||
await mutateRoom('/api/rooms/' + encodeURIComponent(selectedRoomId) + '/review', {
|
||||
outcome: 'approved',
|
||||
note,
|
||||
});
|
||||
await reloadRooms();
|
||||
scheduleRoomReload();
|
||||
setFlash('');
|
||||
} catch (error) {
|
||||
setFlash(error instanceof Error ? error.message : labels.error, 'warn');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (rejectBtn instanceof HTMLButtonElement) {
|
||||
rejectBtn.addEventListener('click', async () => {
|
||||
if (!selectedRoomId) return;
|
||||
try {
|
||||
const note = '';
|
||||
setFlash(labels.loading);
|
||||
await mutateRoom('/api/rooms/' + encodeURIComponent(selectedRoomId) + '/review', {
|
||||
outcome: 'rejected',
|
||||
note,
|
||||
});
|
||||
await reloadRooms();
|
||||
scheduleRoomReload();
|
||||
setFlash('');
|
||||
} catch (error) {
|
||||
setFlash(error instanceof Error ? error.message : labels.error, 'warn');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
renderRoomList(bootstrap.rooms);
|
||||
if (selectedRoomId) {
|
||||
void loadRoom(selectedRoomId, true).catch((error) => {
|
||||
setFlash(error instanceof Error ? error.message : labels.error, 'warn');
|
||||
});
|
||||
connectRoomStream(selectedRoomId);
|
||||
window.addEventListener('beforeunload', () => {
|
||||
if (reloadTimer) window.clearTimeout(reloadTimer);
|
||||
eventSource?.close?.();
|
||||
}, { once: true });
|
||||
} else {
|
||||
renderMessages([]);
|
||||
renderDetail(null, null, null);
|
||||
}
|
||||
})();
|
||||
</script>`;
|
||||
}
|
||||
|
||||
export function renderTaskRoomWorkbenchForSmoke(language: UiLanguage = "zh"): string {
|
||||
const sampleRoom: ChatRoom = {
|
||||
roomId: "project-a:task-room",
|
||||
projectId: "project-a",
|
||||
taskId: "task-room",
|
||||
title: "Task room MVP",
|
||||
stage: "discussion",
|
||||
ownerRole: "planner",
|
||||
assignedExecutor: "coder",
|
||||
participants: [
|
||||
{ participantId: "human", role: "human", label: "Operator", active: true },
|
||||
{ participantId: "planner", role: "planner", label: "Planner", active: true },
|
||||
{ participantId: "coder", role: "coder", label: "Coder", active: true },
|
||||
{ participantId: "reviewer", role: "reviewer", label: "Reviewer", active: true },
|
||||
{ participantId: "manager", role: "manager", label: "Manager", active: true },
|
||||
],
|
||||
handoffs: [],
|
||||
sessionKeys: [],
|
||||
proposal: "Plan the room workflow.",
|
||||
decision: "Assign the coder after the manager decision.",
|
||||
doneWhen: "The room API, UI, and review flow all work.",
|
||||
createdAt: "2026-03-19T10:00:00.000Z",
|
||||
updatedAt: "2026-03-19T10:05:00.000Z",
|
||||
lastMessageAt: "2026-03-19T10:05:00.000Z",
|
||||
};
|
||||
|
||||
return renderTaskRoomWorkbench({
|
||||
language,
|
||||
rooms: [
|
||||
{
|
||||
room: sampleRoom,
|
||||
summary: {
|
||||
roomId: sampleRoom.roomId,
|
||||
headline: "Manager is collecting the final decision.",
|
||||
currentOwner: "planner",
|
||||
nextAction: "Let the manager choose the executor.",
|
||||
openQuestions: ["Who should execute first?"],
|
||||
messageCount: 4,
|
||||
updatedAt: sampleRoom.updatedAt,
|
||||
},
|
||||
task: {
|
||||
projectId: "project-a",
|
||||
taskId: "task-room",
|
||||
title: "Task room MVP",
|
||||
status: "todo",
|
||||
owner: "operator",
|
||||
roomId: sampleRoom.roomId,
|
||||
definitionOfDone: ["Room API works", "UI timeline works"],
|
||||
artifacts: [],
|
||||
rollback: { strategy: "manual", steps: [] },
|
||||
sessionKeys: [],
|
||||
budget: {},
|
||||
updatedAt: sampleRoom.updatedAt,
|
||||
},
|
||||
},
|
||||
],
|
||||
selectedRoom: sampleRoom,
|
||||
selectedMessages: [
|
||||
{
|
||||
roomId: sampleRoom.roomId,
|
||||
messageId: "m1",
|
||||
kind: "chat",
|
||||
authorRole: "human",
|
||||
authorLabel: "Operator",
|
||||
content: "Build the task room MVP in control-center.",
|
||||
mentions: [],
|
||||
createdAt: "2026-03-19T10:01:00.000Z",
|
||||
},
|
||||
{
|
||||
roomId: sampleRoom.roomId,
|
||||
messageId: "m2",
|
||||
kind: "decision",
|
||||
authorRole: "manager",
|
||||
authorLabel: "Manager",
|
||||
content: "Use the room-first plan and move execution to Coder.",
|
||||
mentions: [],
|
||||
payload: {
|
||||
decision: "Use the room-first plan.",
|
||||
executor: "coder",
|
||||
doneWhen: "API and UI both work.",
|
||||
},
|
||||
createdAt: "2026-03-19T10:05:00.000Z",
|
||||
},
|
||||
],
|
||||
selectedSummary: {
|
||||
roomId: sampleRoom.roomId,
|
||||
headline: "Manager is collecting the final decision.",
|
||||
currentOwner: "planner",
|
||||
nextAction: "Let the manager choose the executor.",
|
||||
openQuestions: ["Who should execute first?"],
|
||||
messageCount: 4,
|
||||
updatedAt: sampleRoom.updatedAt,
|
||||
},
|
||||
selectedTask: {
|
||||
projectId: "project-a",
|
||||
taskId: "task-room",
|
||||
title: "Task room MVP",
|
||||
status: "todo",
|
||||
owner: "operator",
|
||||
roomId: sampleRoom.roomId,
|
||||
definitionOfDone: ["Room API works", "UI timeline works"],
|
||||
artifacts: [],
|
||||
rollback: { strategy: "manual", steps: [] },
|
||||
sessionKeys: [],
|
||||
budget: {},
|
||||
updatedAt: sampleRoom.updatedAt,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function renderRoomList(
|
||||
rooms: TaskRoomViewModel[],
|
||||
selectedRoomId: string | undefined,
|
||||
language: UiLanguage,
|
||||
): string {
|
||||
if (rooms.length === 0) {
|
||||
return `<div class="task-room-empty">${escapeHtml(pickUiText(language, "No task rooms yet.", "还没有任务房间。"))}</div>`;
|
||||
}
|
||||
|
||||
return rooms
|
||||
.map(({ room, summary }) => {
|
||||
const selected = room.roomId === selectedRoomId;
|
||||
return `<button type="button" class="task-room-item${selected ? " is-selected" : ""}" data-room-id="${escapeHtml(room.roomId)}">
|
||||
<strong>${escapeHtml(room.title)}</strong>
|
||||
<div class="meta"><span class="task-room-stage-chip">${escapeHtml(stageLabel(room.stage, language))}</span></div>
|
||||
<div class="meta">${escapeHtml(summary?.headline ?? room.roomId)}</div>
|
||||
</button>`;
|
||||
})
|
||||
.join("");
|
||||
}
|
||||
|
||||
function renderMessageThread(messages: ChatMessage[], language: UiLanguage): string {
|
||||
if (messages.length === 0) {
|
||||
return `<div class="task-room-empty">${escapeHtml(pickUiText(language, "No room messages yet.", "当前还没有房间消息。"))}</div>`;
|
||||
}
|
||||
|
||||
return messages
|
||||
.map((message) => {
|
||||
const payloadRows: string[] = [];
|
||||
if (message.payload?.decision) {
|
||||
payloadRows.push(`<div><strong>Decision:</strong> ${escapeHtml(message.payload.decision)}</div>`);
|
||||
}
|
||||
if (message.payload?.executor) {
|
||||
payloadRows.push(`<div><strong>Executor:</strong> ${escapeHtml(roleLabel(message.payload.executor, language))}</div>`);
|
||||
}
|
||||
if (message.payload?.doneWhen) {
|
||||
payloadRows.push(`<div><strong>Done when:</strong> ${escapeHtml(message.payload.doneWhen)}</div>`);
|
||||
}
|
||||
return `<article class="task-room-message" data-kind="${escapeHtml(message.kind)}">
|
||||
<div class="task-room-message-head">
|
||||
<strong>${escapeHtml(message.authorLabel)} · ${escapeHtml(roleLabel(message.authorRole, language))}</strong>
|
||||
<span class="meta">${escapeHtml(message.createdAt)}</span>
|
||||
</div>
|
||||
<div class="task-room-message-copy">${escapeHtml(message.content)}</div>
|
||||
${payloadRows.length > 0 ? `<div class="task-room-payload">${payloadRows.join("")}</div>` : ""}
|
||||
</article>`;
|
||||
})
|
||||
.join("");
|
||||
}
|
||||
|
||||
function renderRoomDetail(
|
||||
room: ChatRoom | undefined,
|
||||
summary: ChatRoomSummary | undefined,
|
||||
task: ProjectTask | undefined,
|
||||
language: UiLanguage,
|
||||
): string {
|
||||
if (!room) {
|
||||
return `<div class="task-room-empty">${escapeHtml(pickUiText(language, "Pick a room to inspect the task timeline.", "点一个房间,就能查看任务时间线。"))}</div>`;
|
||||
}
|
||||
|
||||
const questions = summary?.openQuestions ?? [];
|
||||
return `
|
||||
<div class="task-room-stat-grid">
|
||||
<div class="task-room-stat"><div class="meta">${escapeHtml(pickUiText(language, "Stage", "阶段"))}</div><strong>${escapeHtml(stageLabel(room.stage, language))}</strong></div>
|
||||
<div class="task-room-stat"><div class="meta">${escapeHtml(pickUiText(language, "Owner", "当前负责"))}</div><strong>${escapeHtml(roleLabel(room.ownerRole, language))}</strong></div>
|
||||
<div class="task-room-stat"><div class="meta">${escapeHtml(pickUiText(language, "Executor", "执行者"))}</div><strong>${escapeHtml(roleLabel(room.assignedExecutor, language))}</strong></div>
|
||||
</div>
|
||||
<div class="meta">${escapeHtml(pickUiText(language, "Task", "任务"))}</div>
|
||||
<div style="margin:4px 0 12px;">
|
||||
<strong>${escapeHtml(task?.title ?? room.title)}</strong>
|
||||
<div class="meta">${escapeHtml(task?.taskId ?? room.taskId)}</div>
|
||||
</div>
|
||||
<div class="meta">${escapeHtml(pickUiText(language, "Summary", "摘要"))}</div>
|
||||
<div style="margin:4px 0 12px;">
|
||||
<strong>${escapeHtml(summary?.headline ?? room.decision ?? room.proposal ?? room.title)}</strong>
|
||||
<div class="meta">${escapeHtml(summary?.nextAction ?? "-")}</div>
|
||||
</div>
|
||||
<div class="meta">${escapeHtml(pickUiText(language, "Participants", "参与者"))}</div>
|
||||
<ul class="task-room-detail-list">${room.participants
|
||||
.map((participant) => `<li>${escapeHtml(participant.label)} · ${escapeHtml(roleLabel(participant.role, language))}</li>`)
|
||||
.join("")}</ul>
|
||||
<div class="meta" style="margin-top:12px;">${escapeHtml(pickUiText(language, "Open questions", "未决问题"))}</div>
|
||||
${
|
||||
questions.length > 0
|
||||
? `<ul class="task-room-question-list">${questions.map((question) => `<li>${escapeHtml(question)}</li>`).join("")}</ul>`
|
||||
: `<div class="meta">-</div>`
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
function stageLabel(stage: ChatRoom["stage"], language: UiLanguage): string {
|
||||
const labels: Record<ChatRoom["stage"], string> = {
|
||||
intake: pickUiText(language, "Intake", "收件"),
|
||||
discussion: pickUiText(language, "Discussion", "讨论中"),
|
||||
assigned: pickUiText(language, "Assigned", "已指派"),
|
||||
executing: pickUiText(language, "Executing", "执行中"),
|
||||
review: pickUiText(language, "Review", "审核中"),
|
||||
completed: pickUiText(language, "Completed", "已完成"),
|
||||
};
|
||||
return labels[stage] ?? stage;
|
||||
}
|
||||
|
||||
function roleLabel(role: RoomParticipantRole | undefined, language: UiLanguage): string {
|
||||
if (!role) return "-";
|
||||
const labels: Record<RoomParticipantRole, string> = {
|
||||
human: pickUiText(language, "Operator", "操作员"),
|
||||
planner: "Planner",
|
||||
coder: "Coder",
|
||||
reviewer: "Reviewer",
|
||||
manager: "Manager",
|
||||
};
|
||||
return labels[role];
|
||||
}
|
||||
|
||||
function pickUiText(language: UiLanguage, en: string, zh: string): string {
|
||||
return language === "zh" ? zh : en;
|
||||
}
|
||||
|
||||
function escapeHtml(input: string): string {
|
||||
return input
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'");
|
||||
}
|
||||
|
||||
function safeJsonForScript(value: unknown): string {
|
||||
return JSON.stringify(value).replace(/<\/script/gi, "<\\/script");
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile, rm, writeFile } from "node:fs/promises";
|
||||
import test from "node:test";
|
||||
import { ReadonlyToolClient } from "../src/clients/tool-client";
|
||||
import { buildApiDocs } from "../src/runtime/api-docs";
|
||||
import { CHAT_MESSAGES_PATH, CHAT_ROOMS_PATH, appendChatMessage, createChatRoom } from "../src/runtime/chat-store";
|
||||
import { CHAT_SUMMARIES_PATH, upsertChatRoomSummary } from "../src/runtime/chat-summary-store";
|
||||
import { PROJECTS_PATH, saveProjectStore } from "../src/runtime/project-store";
|
||||
import { TASK_ROOM_BRIDGE_EVENTS_PATH, publishTaskRoomBridgeEvent } from "../src/runtime/task-room-bridge";
|
||||
import { TASKS_PATH, saveTaskStore } from "../src/runtime/task-store";
|
||||
import { startUiServer } from "../src/ui/server";
|
||||
|
||||
test("room API docs and GET routes are exposed for the task room MVP", async () => {
|
||||
const roomsBefore = await readOptionalFile(CHAT_ROOMS_PATH);
|
||||
const messagesBefore = await readOptionalFile(CHAT_MESSAGES_PATH);
|
||||
const summariesBefore = await readOptionalFile(CHAT_SUMMARIES_PATH);
|
||||
const bridgeBefore = await readOptionalFile(TASK_ROOM_BRIDGE_EVENTS_PATH);
|
||||
const projectsBefore = await readOptionalFile(PROJECTS_PATH);
|
||||
const tasksBefore = await readOptionalFile(TASKS_PATH);
|
||||
const suffix = `${process.pid}-${Date.now()}`;
|
||||
const projectId = `api-project-${suffix}`;
|
||||
const taskId = `api-task-${suffix}`;
|
||||
|
||||
try {
|
||||
const docs = buildApiDocs();
|
||||
for (const route of [
|
||||
"/api/rooms",
|
||||
"/api/rooms/:roomId",
|
||||
"/api/rooms/:roomId/events",
|
||||
"/api/rooms/:roomId/messages",
|
||||
"/api/rooms/:roomId/bridge-events",
|
||||
"/api/rooms/:roomId/handoffs",
|
||||
"/api/rooms/:roomId/assign",
|
||||
"/api/rooms/:roomId/review",
|
||||
"/api/rooms/:roomId/stage",
|
||||
]) {
|
||||
assert(docs.routes.some((item) => item.path === route), `Expected API docs for ${route}`);
|
||||
}
|
||||
|
||||
await saveProjectStore({
|
||||
projects: [
|
||||
{
|
||||
projectId,
|
||||
title: "API Project",
|
||||
status: "active",
|
||||
owner: "operator",
|
||||
budget: {},
|
||||
updatedAt: "2026-03-19T12:00:00.000Z",
|
||||
},
|
||||
],
|
||||
updatedAt: "2026-03-19T12:00:00.000Z",
|
||||
});
|
||||
await saveTaskStore({
|
||||
tasks: [
|
||||
{
|
||||
projectId,
|
||||
taskId,
|
||||
title: "API Task",
|
||||
status: "todo",
|
||||
owner: "operator",
|
||||
definitionOfDone: [],
|
||||
artifacts: [],
|
||||
rollback: { strategy: "manual", steps: [] },
|
||||
sessionKeys: [],
|
||||
budget: {},
|
||||
updatedAt: "2026-03-19T12:00:00.000Z",
|
||||
},
|
||||
],
|
||||
agentBudgets: [],
|
||||
updatedAt: "2026-03-19T12:00:00.000Z",
|
||||
});
|
||||
const room = await createChatRoom({
|
||||
projectId,
|
||||
taskId,
|
||||
title: "API Task Room",
|
||||
});
|
||||
const message = await appendChatMessage({
|
||||
roomId: room.room.roomId,
|
||||
authorRole: "human",
|
||||
content: "Hello room API",
|
||||
});
|
||||
await publishTaskRoomBridgeEvent({
|
||||
type: "message_posted",
|
||||
room: room.room,
|
||||
message: message.message,
|
||||
requestId: "chat-api-test",
|
||||
});
|
||||
await upsertChatRoomSummary(room.room, [message.message]);
|
||||
|
||||
const server = startUiServer(0, new ReadonlyToolClient());
|
||||
try {
|
||||
if (!server.listening) {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.once("listening", resolve);
|
||||
server.once("error", reject);
|
||||
});
|
||||
}
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") throw new Error("Failed to bind ephemeral UI port.");
|
||||
const baseUrl = `http://127.0.0.1:${address.port}`;
|
||||
|
||||
const roomsResponse = await fetch(`${baseUrl}/api/rooms`);
|
||||
assert.equal(roomsResponse.status, 200);
|
||||
const roomsPayload = await roomsResponse.json() as { count: number; rooms: Array<{ roomId: string }> };
|
||||
assert(roomsPayload.count >= 1);
|
||||
assert(roomsPayload.rooms.some((item) => item.roomId === room.room.roomId));
|
||||
|
||||
const messagesResponse = await fetch(`${baseUrl}/api/rooms/${encodeURIComponent(room.room.roomId)}/messages`);
|
||||
assert.equal(messagesResponse.status, 200);
|
||||
const messagesPayload = await messagesResponse.json() as { messages: Array<{ content: string }> };
|
||||
assert(messagesPayload.messages.some((item) => item.content.includes("Hello room API")));
|
||||
|
||||
const bridgeResponse = await fetch(`${baseUrl}/api/rooms/${encodeURIComponent(room.room.roomId)}/bridge-events`);
|
||||
assert.equal(bridgeResponse.status, 200);
|
||||
const bridgePayload = await bridgeResponse.json() as { events: unknown[] };
|
||||
assert(Array.isArray(bridgePayload.events));
|
||||
} finally {
|
||||
if (server.listening) {
|
||||
await new Promise<void>((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())));
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await restoreOptionalFile(CHAT_ROOMS_PATH, roomsBefore);
|
||||
await restoreOptionalFile(CHAT_MESSAGES_PATH, messagesBefore);
|
||||
await restoreOptionalFile(CHAT_SUMMARIES_PATH, summariesBefore);
|
||||
await restoreOptionalFile(TASK_ROOM_BRIDGE_EVENTS_PATH, bridgeBefore);
|
||||
await restoreOptionalFile(PROJECTS_PATH, projectsBefore);
|
||||
await restoreOptionalFile(TASKS_PATH, tasksBefore);
|
||||
}
|
||||
});
|
||||
|
||||
async function readOptionalFile(path: string): Promise<string | undefined> {
|
||||
try {
|
||||
return await readFile(path, "utf8");
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
async function restoreOptionalFile(path: string, content: string | undefined): Promise<void> {
|
||||
if (content === undefined) {
|
||||
await rm(path, { force: true });
|
||||
return;
|
||||
}
|
||||
await writeFile(path, content, "utf8");
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile, rm, writeFile } from "node:fs/promises";
|
||||
import test from "node:test";
|
||||
import { appendChatMessage, CHAT_MESSAGES_PATH, CHAT_ROOMS_PATH, createChatRoom, loadChatMessageStore, loadChatRoomStore } from "../src/runtime/chat-store";
|
||||
import { CHAT_SUMMARIES_PATH, buildChatRoomSummary, upsertChatRoomSummary } from "../src/runtime/chat-summary-store";
|
||||
|
||||
test("chat store persists rooms, messages, and summaries across reloads", async () => {
|
||||
const roomsBefore = await readOptionalFile(CHAT_ROOMS_PATH);
|
||||
const messagesBefore = await readOptionalFile(CHAT_MESSAGES_PATH);
|
||||
const summariesBefore = await readOptionalFile(CHAT_SUMMARIES_PATH);
|
||||
|
||||
try {
|
||||
const created = await createChatRoom({
|
||||
projectId: "chat-project",
|
||||
taskId: "chat-task",
|
||||
title: "Chat room persistence",
|
||||
});
|
||||
const posted = await appendChatMessage({
|
||||
roomId: created.room.roomId,
|
||||
authorRole: "human",
|
||||
content: "Please keep this room state on disk.",
|
||||
});
|
||||
const summaryResult = await upsertChatRoomSummary(created.room, [posted.message]);
|
||||
|
||||
const roomStore = await loadChatRoomStore();
|
||||
const messageStore = await loadChatMessageStore();
|
||||
const room = roomStore.rooms.find((item) => item.roomId === created.room.roomId);
|
||||
assert(room);
|
||||
assert.equal(room.title, "Chat room persistence");
|
||||
assert.equal(messageStore.messages.filter((item) => item.roomId === created.room.roomId).length, 1);
|
||||
|
||||
const storedSummaryRaw = await readFile(CHAT_SUMMARIES_PATH, "utf8");
|
||||
const storedSummary = JSON.parse(storedSummaryRaw) as { summaries?: Array<{ roomId?: string }> };
|
||||
assert((storedSummary.summaries ?? []).some((item) => item.roomId === created.room.roomId));
|
||||
assert.equal(summaryResult.summary.messageCount, 1);
|
||||
assert.equal(buildChatRoomSummary(created.room, [posted.message]).roomId, created.room.roomId);
|
||||
} finally {
|
||||
await restoreOptionalFile(CHAT_ROOMS_PATH, roomsBefore);
|
||||
await restoreOptionalFile(CHAT_MESSAGES_PATH, messagesBefore);
|
||||
await restoreOptionalFile(CHAT_SUMMARIES_PATH, summariesBefore);
|
||||
}
|
||||
});
|
||||
|
||||
async function readOptionalFile(path: string): Promise<string | undefined> {
|
||||
try {
|
||||
return await readFile(path, "utf8");
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
async function restoreOptionalFile(path: string, content: string | undefined): Promise<void> {
|
||||
if (content === undefined) {
|
||||
await rm(path, { force: true });
|
||||
return;
|
||||
}
|
||||
await writeFile(path, content, "utf8");
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile, rm, writeFile } from "node:fs/promises";
|
||||
import test from "node:test";
|
||||
import { ReadonlyToolClient } from "../src/clients/tool-client";
|
||||
import {
|
||||
COLLABORATION_HALL_MESSAGES_PATH,
|
||||
COLLABORATION_HALLS_PATH,
|
||||
COLLABORATION_TASK_CARDS_PATH,
|
||||
} from "../src/runtime/collaboration-hall-store";
|
||||
import { COLLABORATION_HALL_SUMMARIES_PATH } from "../src/runtime/collaboration-hall-summary-store";
|
||||
import { createHallTaskFromOperatorRequest } from "../src/runtime/collaboration-hall-orchestrator";
|
||||
import { PROJECTS_PATH } from "../src/runtime/project-store";
|
||||
import { CHAT_MESSAGES_PATH, CHAT_ROOMS_PATH } from "../src/runtime/chat-store";
|
||||
import { TASKS_PATH } from "../src/runtime/task-store";
|
||||
import { buildApiDocs } from "../src/runtime/api-docs";
|
||||
import { startUiServer } from "../src/ui/server";
|
||||
|
||||
test("hall API docs and routes are exposed", async () => {
|
||||
const backups = await backupFiles([
|
||||
COLLABORATION_HALLS_PATH,
|
||||
COLLABORATION_HALL_MESSAGES_PATH,
|
||||
COLLABORATION_TASK_CARDS_PATH,
|
||||
COLLABORATION_HALL_SUMMARIES_PATH,
|
||||
PROJECTS_PATH,
|
||||
TASKS_PATH,
|
||||
CHAT_ROOMS_PATH,
|
||||
CHAT_MESSAGES_PATH,
|
||||
]);
|
||||
|
||||
try {
|
||||
const docs = buildApiDocs();
|
||||
for (const route of [
|
||||
"/api/hall",
|
||||
"/api/hall/events",
|
||||
"/api/hall/messages",
|
||||
"/api/hall/tasks",
|
||||
"/api/hall/tasks/:taskId",
|
||||
"/api/hall/tasks/:taskId/assign",
|
||||
"/api/hall/tasks/:taskId/execution-order",
|
||||
"/api/hall/tasks/:taskId/review",
|
||||
"/api/hall/tasks/:taskId/handoff",
|
||||
"/api/hall/tasks/:taskId/evidence",
|
||||
]) {
|
||||
assert(docs.routes.some((item) => item.path === route), `Expected API docs for ${route}`);
|
||||
}
|
||||
|
||||
const created = await createHallTaskFromOperatorRequest(
|
||||
{
|
||||
content: "Create one hall API task.",
|
||||
},
|
||||
{ skipDiscussion: true },
|
||||
);
|
||||
|
||||
const server = startUiServer(0, new ReadonlyToolClient());
|
||||
try {
|
||||
if (!server.listening) {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.once("listening", resolve);
|
||||
server.once("error", reject);
|
||||
});
|
||||
}
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") throw new Error("Failed to bind ephemeral UI port.");
|
||||
const baseUrl = `http://127.0.0.1:${address.port}`;
|
||||
|
||||
const hallResponse = await fetch(`${baseUrl}/api/hall`);
|
||||
assert.equal(hallResponse.status, 200);
|
||||
const hallPayload = await hallResponse.json() as { taskCards: Array<{ taskId: string }> };
|
||||
assert(hallPayload.taskCards.some((item) => item.taskId === created.task?.taskId));
|
||||
|
||||
const taskResponse = await fetch(
|
||||
`${baseUrl}/api/hall/tasks/${encodeURIComponent(created.task!.taskId)}?projectId=${encodeURIComponent(created.task!.projectId)}`,
|
||||
);
|
||||
assert.equal(taskResponse.status, 200);
|
||||
|
||||
const taskByCardResponse = await fetch(
|
||||
`${baseUrl}/api/hall/tasks/${encodeURIComponent(created.task!.taskId)}?taskCardId=${encodeURIComponent(created.taskCard!.taskCardId)}`,
|
||||
);
|
||||
assert.equal(taskByCardResponse.status, 200);
|
||||
|
||||
const evidenceResponse = await fetch(
|
||||
`${baseUrl}/api/hall/tasks/${encodeURIComponent(created.task!.taskId)}/evidence?projectId=${encodeURIComponent(created.task!.projectId)}`,
|
||||
);
|
||||
assert.equal(evidenceResponse.status, 200);
|
||||
|
||||
const evidenceByCardResponse = await fetch(
|
||||
`${baseUrl}/api/hall/tasks/${encodeURIComponent(created.task!.taskId)}/evidence?taskCardId=${encodeURIComponent(created.taskCard!.taskCardId)}`,
|
||||
);
|
||||
assert.equal(evidenceByCardResponse.status, 200);
|
||||
} finally {
|
||||
if (server.listening) {
|
||||
await new Promise<void>((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())));
|
||||
}
|
||||
}
|
||||
|
||||
const serverSource = await readFile("src/ui/server.ts", "utf8");
|
||||
assert(serverSource.includes('path.endsWith("/execution-order")'));
|
||||
assert(serverSource.includes('assertCollaborationMutationAuthorized(req, "/api/hall/tasks/:taskId/execution-order")'));
|
||||
} finally {
|
||||
await restoreFiles(backups);
|
||||
}
|
||||
});
|
||||
|
||||
async function backupFiles(paths: string[]): Promise<Map<string, string | undefined>> {
|
||||
const backups = new Map<string, string | undefined>();
|
||||
for (const path of paths) backups.set(path, await readOptionalFile(path));
|
||||
return backups;
|
||||
}
|
||||
|
||||
async function restoreFiles(backups: Map<string, string | undefined>): Promise<void> {
|
||||
for (const [path, content] of backups.entries()) {
|
||||
if (content === undefined) await rm(path, { force: true });
|
||||
else await writeFile(path, content, "utf8");
|
||||
}
|
||||
}
|
||||
|
||||
async function readOptionalFile(path: string): Promise<string | undefined> {
|
||||
try {
|
||||
return await readFile(path, "utf8");
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile, rm, writeFile } from "node:fs/promises";
|
||||
import test from "node:test";
|
||||
import { CHAT_MESSAGES_PATH, CHAT_ROOMS_PATH } from "../src/runtime/chat-store";
|
||||
import {
|
||||
COLLABORATION_HALL_MESSAGES_PATH,
|
||||
COLLABORATION_HALLS_PATH,
|
||||
COLLABORATION_TASK_CARDS_PATH,
|
||||
} from "../src/runtime/collaboration-hall-store";
|
||||
import { COLLABORATION_HALL_SUMMARIES_PATH } from "../src/runtime/collaboration-hall-summary-store";
|
||||
import {
|
||||
assignHallTaskExecution,
|
||||
createHallTaskFromOperatorRequest,
|
||||
readCollaborationHall,
|
||||
readCollaborationHallTaskDetail,
|
||||
recordHallTaskHandoff,
|
||||
setHallTaskExecutionOrder,
|
||||
} from "../src/runtime/collaboration-hall-orchestrator";
|
||||
import { PROJECTS_PATH } from "../src/runtime/project-store";
|
||||
import { TASKS_PATH } from "../src/runtime/task-store";
|
||||
|
||||
test("execution order persists in hall detail and advances after assign plus handoff", async () => {
|
||||
const backups = await backupFiles([
|
||||
COLLABORATION_HALLS_PATH,
|
||||
COLLABORATION_HALL_MESSAGES_PATH,
|
||||
COLLABORATION_TASK_CARDS_PATH,
|
||||
COLLABORATION_HALL_SUMMARIES_PATH,
|
||||
PROJECTS_PATH,
|
||||
TASKS_PATH,
|
||||
CHAT_ROOMS_PATH,
|
||||
CHAT_MESSAGES_PATH,
|
||||
]);
|
||||
|
||||
try {
|
||||
const created = await createHallTaskFromOperatorRequest(
|
||||
{
|
||||
content: "Create a hall task whose execution order will be planned and advanced.",
|
||||
},
|
||||
{ skipDiscussion: true },
|
||||
);
|
||||
assert(created.taskCard);
|
||||
|
||||
await setHallTaskExecutionOrder({
|
||||
taskCardId: created.taskCard.taskCardId,
|
||||
executionItems: [
|
||||
{
|
||||
itemId: "item-pandas",
|
||||
participantId: "pandas",
|
||||
task: "Produce the first reviewable pass.",
|
||||
handoffToParticipantId: "monkey",
|
||||
handoffWhen: "When the first pass is reviewable in the hall.",
|
||||
},
|
||||
{
|
||||
itemId: "item-monkey",
|
||||
participantId: "monkey",
|
||||
task: "Review the first pass and call out required changes.",
|
||||
handoffToParticipantId: "main",
|
||||
handoffWhen: "When the review verdict is explicit.",
|
||||
},
|
||||
{
|
||||
itemId: "item-main",
|
||||
participantId: "main",
|
||||
task: "Close the loop and decide the next owner.",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const hallAfterPlanning = await readCollaborationHall();
|
||||
const plannedTask = hallAfterPlanning.taskCards.find((taskCard) => taskCard.taskCardId === created.taskCard?.taskCardId);
|
||||
assert.deepEqual(plannedTask?.plannedExecutionOrder, ["pandas", "monkey", "main"]);
|
||||
assert.equal(plannedTask?.currentOwnerParticipantId, undefined);
|
||||
assert.equal(plannedTask?.currentExecutionItem, undefined);
|
||||
assert.equal(plannedTask?.plannedExecutionItems[0]?.handoffToParticipantId, "monkey");
|
||||
assert.equal(plannedTask?.plannedExecutionItems[1]?.handoffToParticipantId, "main");
|
||||
const plannedSummary = hallAfterPlanning.taskSummaries.find((summary) => summary.taskCardId === created.taskCard?.taskCardId);
|
||||
assert.match(plannedSummary?.nextAction ?? "", /pandas/i);
|
||||
|
||||
await assignHallTaskExecution({
|
||||
taskCardId: created.taskCard.taskCardId,
|
||||
});
|
||||
|
||||
const detailAfterAssign = await readCollaborationHallTaskDetail(created.taskCard.taskCardId);
|
||||
assert.equal(detailAfterAssign.taskCard.currentOwnerParticipantId, "pandas");
|
||||
assert.deepEqual(detailAfterAssign.taskCard.plannedExecutionOrder, ["monkey", "main"]);
|
||||
assert.match(detailAfterAssign.taskSummary.nextAction, /monkey/i);
|
||||
|
||||
await recordHallTaskHandoff({
|
||||
taskCardId: created.taskCard.taskCardId,
|
||||
fromParticipantId: "pandas",
|
||||
toParticipantId: "monkey",
|
||||
handoff: {
|
||||
goal: "Pass to the next queued owner",
|
||||
currentResult: "First slice done",
|
||||
doneWhen: "Second slice done",
|
||||
blockers: [],
|
||||
nextOwner: "monkey",
|
||||
requiresInputFrom: [],
|
||||
},
|
||||
});
|
||||
|
||||
const detailAfterHandoff = await readCollaborationHallTaskDetail(created.taskCard.taskCardId);
|
||||
assert.equal(detailAfterHandoff.taskCard.currentOwnerParticipantId, "monkey");
|
||||
assert.deepEqual(detailAfterHandoff.taskCard.plannedExecutionOrder, ["main"]);
|
||||
assert.match(detailAfterHandoff.taskSummary.nextAction, /main/i);
|
||||
} finally {
|
||||
await restoreFiles(backups);
|
||||
}
|
||||
});
|
||||
|
||||
async function backupFiles(paths: string[]): Promise<Map<string, string | undefined>> {
|
||||
const backups = new Map<string, string | undefined>();
|
||||
for (const path of paths) backups.set(path, await readOptionalFile(path));
|
||||
return backups;
|
||||
}
|
||||
|
||||
async function restoreFiles(backups: Map<string, string | undefined>): Promise<void> {
|
||||
for (const [path, content] of backups.entries()) {
|
||||
if (content === undefined) await rm(path, { force: true });
|
||||
else await writeFile(path, content, "utf8");
|
||||
}
|
||||
}
|
||||
|
||||
async function readOptionalFile(path: string): Promise<string | undefined> {
|
||||
try {
|
||||
return await readFile(path, "utf8");
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,89 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile, rm, writeFile } from "node:fs/promises";
|
||||
import test from "node:test";
|
||||
import {
|
||||
COLLABORATION_HALL_MESSAGES_PATH,
|
||||
COLLABORATION_HALLS_PATH,
|
||||
COLLABORATION_TASK_CARDS_PATH,
|
||||
appendHallMessage,
|
||||
createHallTaskCard,
|
||||
ensureDefaultCollaborationHall,
|
||||
loadCollaborationHallMessageStore,
|
||||
loadCollaborationHallStore,
|
||||
loadCollaborationTaskCardStore,
|
||||
} from "../src/runtime/collaboration-hall-store";
|
||||
|
||||
test("collaboration hall store persists the default hall, task cards, and messages", async () => {
|
||||
const hallsBefore = await readOptionalFile(COLLABORATION_HALLS_PATH);
|
||||
const messagesBefore = await readOptionalFile(COLLABORATION_HALL_MESSAGES_PATH);
|
||||
const taskCardsBefore = await readOptionalFile(COLLABORATION_TASK_CARDS_PATH);
|
||||
|
||||
try {
|
||||
const hall = await ensureDefaultCollaborationHall([
|
||||
{
|
||||
participantId: "main",
|
||||
agentId: "main",
|
||||
displayName: "Main",
|
||||
semanticRole: "manager",
|
||||
active: true,
|
||||
aliases: ["Main", "main"],
|
||||
},
|
||||
{
|
||||
participantId: "pandas",
|
||||
agentId: "pandas",
|
||||
displayName: "Pandas",
|
||||
semanticRole: "coder",
|
||||
active: true,
|
||||
aliases: ["Pandas", "pandas"],
|
||||
},
|
||||
]);
|
||||
const taskCard = await createHallTaskCard({
|
||||
hallId: hall.hallId,
|
||||
projectId: "collaboration-hall",
|
||||
taskId: "store-test",
|
||||
roomId: "collaboration-hall:store-test",
|
||||
title: "Store test",
|
||||
description: "Persist one hall task card.",
|
||||
createdByParticipantId: "operator",
|
||||
});
|
||||
await appendHallMessage({
|
||||
hallId: hall.hallId,
|
||||
taskCardId: taskCard.taskCard.taskCardId,
|
||||
projectId: "collaboration-hall",
|
||||
taskId: "store-test",
|
||||
roomId: "collaboration-hall:store-test",
|
||||
authorParticipantId: "operator",
|
||||
authorLabel: "Operator",
|
||||
kind: "task",
|
||||
content: "Build the hall store MVP.",
|
||||
});
|
||||
|
||||
const hallStore = await loadCollaborationHallStore();
|
||||
const messageStore = await loadCollaborationHallMessageStore();
|
||||
const taskCardStore = await loadCollaborationTaskCardStore();
|
||||
|
||||
assert(hallStore.halls.some((item) => item.hallId === hall.hallId));
|
||||
assert(taskCardStore.taskCards.some((item) => item.taskId === "store-test" && item.projectId === "collaboration-hall"));
|
||||
assert(messageStore.messages.some((item) => item.taskId === "store-test" && item.projectId === "collaboration-hall"));
|
||||
} finally {
|
||||
await restoreOptionalFile(COLLABORATION_HALLS_PATH, hallsBefore);
|
||||
await restoreOptionalFile(COLLABORATION_HALL_MESSAGES_PATH, messagesBefore);
|
||||
await restoreOptionalFile(COLLABORATION_TASK_CARDS_PATH, taskCardsBefore);
|
||||
}
|
||||
});
|
||||
|
||||
async function readOptionalFile(path: string): Promise<string | undefined> {
|
||||
try {
|
||||
return await readFile(path, "utf8");
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
async function restoreOptionalFile(path: string, content: string | undefined): Promise<void> {
|
||||
if (content === undefined) {
|
||||
await rm(path, { force: true });
|
||||
return;
|
||||
}
|
||||
await writeFile(path, content, "utf8");
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { ReadonlyToolClient } from "../src/clients/tool-client";
|
||||
import {
|
||||
abortHallDraftReply,
|
||||
beginHallDraftReply,
|
||||
completeHallDraftReply,
|
||||
pushHallDraftDelta,
|
||||
} from "../src/runtime/collaboration-stream";
|
||||
|
||||
test("hall SSE publishes multi-agent typing lifecycle events", async () => {
|
||||
const server = await startTestUiServer();
|
||||
try {
|
||||
if (!server.listening) {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.once("listening", resolve);
|
||||
server.once("error", reject);
|
||||
});
|
||||
}
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") throw new Error("Failed to bind ephemeral UI port.");
|
||||
const baseUrl = `http://127.0.0.1:${address.port}`;
|
||||
const response = await fetch(`${baseUrl}/api/hall/events?hallId=main`);
|
||||
assert.equal(response.status, 200);
|
||||
assert(response.body, "Expected SSE response body");
|
||||
|
||||
const eventPromise = collectCollaborationEvents(response, (events) => {
|
||||
const completeCount = events.filter((event) => event.type === "draft_complete").length;
|
||||
return completeCount >= 2;
|
||||
});
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
|
||||
const coqDraftId = beginHallDraftReply({
|
||||
hallId: "main",
|
||||
authorParticipantId: "coq",
|
||||
authorLabel: "Coq-每日新闻",
|
||||
authorSemanticRole: "planner",
|
||||
messageKind: "proposal",
|
||||
content: "Typing lifecycle test one.",
|
||||
});
|
||||
const pandasDraftId = beginHallDraftReply({
|
||||
hallId: "main",
|
||||
authorParticipantId: "pandas",
|
||||
authorLabel: "pandas",
|
||||
authorSemanticRole: "coder",
|
||||
messageKind: "proposal",
|
||||
content: "Typing lifecycle test two.",
|
||||
});
|
||||
|
||||
pushHallDraftDelta({
|
||||
hallId: "main",
|
||||
draftId: coqDraftId,
|
||||
authorParticipantId: "coq",
|
||||
authorLabel: "Coq-每日新闻",
|
||||
authorSemanticRole: "planner",
|
||||
messageKind: "proposal",
|
||||
delta: "Planner is typing.",
|
||||
});
|
||||
pushHallDraftDelta({
|
||||
hallId: "main",
|
||||
draftId: pandasDraftId,
|
||||
authorParticipantId: "pandas",
|
||||
authorLabel: "pandas",
|
||||
authorSemanticRole: "coder",
|
||||
messageKind: "proposal",
|
||||
delta: "Coder is typing.",
|
||||
});
|
||||
|
||||
completeHallDraftReply({
|
||||
hallId: "main",
|
||||
draftId: coqDraftId,
|
||||
content: "Typing lifecycle test one.",
|
||||
});
|
||||
completeHallDraftReply({
|
||||
hallId: "main",
|
||||
draftId: pandasDraftId,
|
||||
content: "Typing lifecycle test two.",
|
||||
});
|
||||
|
||||
const events = await withTimeout(eventPromise, 5_000);
|
||||
const collaborationEvents = events.filter((event) => event.scope === "hall");
|
||||
const startEvents = collaborationEvents.filter((event) => event.type === "draft_start");
|
||||
const deltaEvents = collaborationEvents.filter((event) => event.type === "draft_delta");
|
||||
const completeEvents = collaborationEvents.filter((event) => event.type === "draft_complete");
|
||||
|
||||
assert.equal(startEvents.length, 2);
|
||||
assert.equal(deltaEvents.length, 2);
|
||||
assert.equal(completeEvents.length, 2);
|
||||
assert.deepEqual(
|
||||
startEvents.map((event) => event.authorLabel),
|
||||
["Coq-每日新闻", "pandas"],
|
||||
);
|
||||
assert.deepEqual(
|
||||
completeEvents.map((event) => event.draftId).sort(),
|
||||
[coqDraftId, pandasDraftId].sort(),
|
||||
);
|
||||
} finally {
|
||||
if (server.listening) {
|
||||
await new Promise<void>((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("hall discussion primes multiple typing participants before the first reply completes", async () => {
|
||||
const server = await startTestUiServer();
|
||||
try {
|
||||
if (!server.listening) {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.once("listening", resolve);
|
||||
server.once("error", reject);
|
||||
});
|
||||
}
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") throw new Error("Failed to bind ephemeral UI port.");
|
||||
const baseUrl = `http://127.0.0.1:${address.port}`;
|
||||
|
||||
const response = await fetch(`${baseUrl}/api/hall/events?hallId=main`);
|
||||
assert.equal(response.status, 200);
|
||||
assert(response.body, "Expected SSE response body");
|
||||
|
||||
const content = `我要策划一个互动数据叙事体验-${Date.now()},先讨论目标受众、叙事结构、风险和执行顺序。`;
|
||||
const createResponse = await fetch(`${baseUrl}/api/hall/messages`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ content }),
|
||||
});
|
||||
const createPayload = await createResponse.json();
|
||||
assert.equal(createResponse.status, 201);
|
||||
assert.equal(createPayload.ok, true);
|
||||
const taskCardId = createPayload.taskCard?.taskCardId;
|
||||
assert.equal(typeof taskCardId, "string");
|
||||
|
||||
const events = await withTimeout(
|
||||
collectCollaborationEvents(response, (streamEvents) => {
|
||||
const taskEvents = streamEvents.filter((event) => event.taskCardId === taskCardId);
|
||||
return taskEvents.some((event) => event.type === "draft_complete");
|
||||
}),
|
||||
15_000,
|
||||
);
|
||||
|
||||
const taskEvents = events.filter((event) => event.taskCardId === taskCardId);
|
||||
const firstCompleteIndex = taskEvents.findIndex((event) => event.type === "draft_complete");
|
||||
assert.notEqual(firstCompleteIndex, -1);
|
||||
const startsBeforeFirstComplete = taskEvents
|
||||
.slice(0, firstCompleteIndex)
|
||||
.filter((event) => event.type === "draft_start");
|
||||
const startAuthors = [...new Set(startsBeforeFirstComplete.map((event) => String(event.authorParticipantId || "")))];
|
||||
|
||||
assert.ok(startAuthors.length >= 2, `Expected at least 2 typing participants before first complete, saw ${startAuthors.join(", ")}`);
|
||||
} finally {
|
||||
if (server.listening) {
|
||||
await new Promise<void>((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("hall SSE can abort placeholder typing drafts", async () => {
|
||||
const server = await startTestUiServer();
|
||||
try {
|
||||
if (!server.listening) {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.once("listening", resolve);
|
||||
server.once("error", reject);
|
||||
});
|
||||
}
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") throw new Error("Failed to bind ephemeral UI port.");
|
||||
const baseUrl = `http://127.0.0.1:${address.port}`;
|
||||
const response = await fetch(`${baseUrl}/api/hall/events?hallId=main`);
|
||||
assert.equal(response.status, 200);
|
||||
assert(response.body, "Expected SSE response body");
|
||||
|
||||
let expectedDraftId: string | undefined;
|
||||
const eventPromise = collectCollaborationEvents(response, (events) => {
|
||||
return Boolean(
|
||||
expectedDraftId
|
||||
&& events.some((event) => event.type === "draft_abort" && event.draftId === expectedDraftId),
|
||||
);
|
||||
});
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
|
||||
const draftId = beginHallDraftReply({
|
||||
hallId: "main",
|
||||
authorParticipantId: "monkey",
|
||||
authorLabel: "monkey",
|
||||
authorSemanticRole: "reviewer",
|
||||
messageKind: "proposal",
|
||||
content: "",
|
||||
});
|
||||
expectedDraftId = draftId;
|
||||
abortHallDraftReply({
|
||||
hallId: "main",
|
||||
draftId,
|
||||
reason: "test_abort",
|
||||
});
|
||||
|
||||
const events = await withTimeout(eventPromise, 5_000);
|
||||
const abortEvent = [...events].reverse().find((event) => event.type === "draft_abort" && event.draftId === draftId);
|
||||
assert.ok(abortEvent);
|
||||
assert.equal(abortEvent?.draftId, draftId);
|
||||
} finally {
|
||||
if (server.listening) {
|
||||
await new Promise<void>((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
async function collectCollaborationEvents(
|
||||
response: Response,
|
||||
shouldStop: (events: Array<Record<string, unknown>>) => boolean,
|
||||
): Promise<Array<Record<string, unknown>>> {
|
||||
const reader = response.body?.getReader();
|
||||
if (!reader) return [];
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
const events: Array<Record<string, unknown>> = [];
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
let boundary = buffer.indexOf("\n\n");
|
||||
while (boundary >= 0) {
|
||||
const block = buffer.slice(0, boundary);
|
||||
buffer = buffer.slice(boundary + 2);
|
||||
boundary = buffer.indexOf("\n\n");
|
||||
if (!block.trim() || block.startsWith(":")) continue;
|
||||
let eventName = "";
|
||||
let data = "";
|
||||
for (const line of block.split("\n")) {
|
||||
if (line.startsWith("event:")) eventName = line.slice("event:".length).trim();
|
||||
if (line.startsWith("data:")) data += line.slice("data:".length).trim();
|
||||
}
|
||||
if (eventName === "collaboration" && data) {
|
||||
const parsed = JSON.parse(data) as Record<string, unknown>;
|
||||
events.push(parsed);
|
||||
if (shouldStop(events)) {
|
||||
await reader.cancel();
|
||||
return events;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return events;
|
||||
}
|
||||
|
||||
async function withTimeout<T>(promise: Promise<T>, timeoutMs: number): Promise<T> {
|
||||
return await new Promise<T>((resolve, reject) => {
|
||||
const timer = setTimeout(() => reject(new Error(`Timed out after ${timeoutMs}ms`)), timeoutMs);
|
||||
promise.then(
|
||||
(value) => {
|
||||
clearTimeout(timer);
|
||||
resolve(value);
|
||||
},
|
||||
(error) => {
|
||||
clearTimeout(timer);
|
||||
reject(error);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async function startTestUiServer() {
|
||||
const { startUiServer } = await import("../src/ui/server");
|
||||
return startUiServer(0, new ReadonlyToolClient(), {
|
||||
localTokenAuthRequired: false,
|
||||
});
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,36 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import test from "node:test";
|
||||
import { renderTaskRoomClientScript, renderTaskRoomWorkbenchForSmoke } from "../src/ui/task-room-workbench";
|
||||
|
||||
test("task room workbench renders the three-pane collaboration UI shell", () => {
|
||||
const html = renderTaskRoomWorkbenchForSmoke("en");
|
||||
assert(html.includes('id="task-room-hub"'));
|
||||
assert(html.includes("Task room workbench"));
|
||||
assert(html.includes('data-task-room-root'));
|
||||
assert(html.includes('data-task-room-list'));
|
||||
assert(html.includes('data-task-room-thread'));
|
||||
assert(html.includes('data-task-room-detail'));
|
||||
assert(html.includes('data-task-room-compose'));
|
||||
assert(html.includes('data-task-room-assign'));
|
||||
assert(html.includes('data-task-room-approve'));
|
||||
assert(html.includes('data-task-room-reject'));
|
||||
assert(html.includes("Runtime evidence is merged into this timeline"));
|
||||
const script = renderTaskRoomClientScript("en");
|
||||
assert(script.includes("new EventSource('/api/rooms/"));
|
||||
assert(script.includes("draft_start"));
|
||||
assert(script.includes("draft_delta"));
|
||||
});
|
||||
|
||||
test("collaboration page source keeps the legacy collaboration board and linked task-room threads", async () => {
|
||||
const source = await readFile("src/ui/server.ts", "utf8");
|
||||
assert(source.includes("const collaborationSection = `"));
|
||||
assert(source.includes("Collaboration threads"));
|
||||
assert(source.includes("${collaborationThreadHtml}"));
|
||||
assert(source.includes("taskRoomWorkbench"));
|
||||
assert(source.includes("renderTaskRoomWorkbench({"));
|
||||
assert(source.includes("renderTaskRoomClientScript(options.language)"));
|
||||
assert(source.includes('const taskRoomWorkbench = needsTaskRoomWorkbench'));
|
||||
assert(source.includes("${taskRoomWorkbenchScript}"));
|
||||
assert(source.includes('if (options.section === "collaboration") sectionBody = collaborationSection;'));
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import {
|
||||
HallExecutionLockError,
|
||||
acquireHallExecutionLock,
|
||||
assertHallExecutionAllowed,
|
||||
releaseHallExecutionLock,
|
||||
} from "../src/runtime/hall-execution-lock";
|
||||
import type { HallTaskCard } from "../src/types";
|
||||
|
||||
const baseTaskCard: HallTaskCard = {
|
||||
hallId: "main",
|
||||
taskCardId: "card-1",
|
||||
projectId: "collaboration-hall",
|
||||
taskId: "lock-test",
|
||||
title: "Execution lock",
|
||||
description: "Lock one owner at a time.",
|
||||
stage: "discussion",
|
||||
status: "todo",
|
||||
createdByParticipantId: "operator",
|
||||
blockers: [],
|
||||
requiresInputFrom: [],
|
||||
mentionedParticipantIds: [],
|
||||
plannedExecutionOrder: [],
|
||||
plannedExecutionItems: [],
|
||||
sessionKeys: [],
|
||||
createdAt: "2026-03-19T10:00:00.000Z",
|
||||
updatedAt: "2026-03-19T10:00:00.000Z",
|
||||
};
|
||||
|
||||
test("hall execution lock allows one owner and blocks another", () => {
|
||||
const locked = acquireHallExecutionLock(baseTaskCard, {
|
||||
ownerParticipantId: "pandas",
|
||||
ownerLabel: "Pandas",
|
||||
at: "2026-03-19T10:01:00.000Z",
|
||||
});
|
||||
assert.equal(locked.executionLock?.ownerParticipantId, "pandas");
|
||||
assert.throws(
|
||||
() =>
|
||||
acquireHallExecutionLock(locked, {
|
||||
ownerParticipantId: "main",
|
||||
ownerLabel: "Main",
|
||||
}),
|
||||
HallExecutionLockError,
|
||||
);
|
||||
assert.doesNotThrow(() => assertHallExecutionAllowed(locked, "pandas"));
|
||||
assert.throws(() => assertHallExecutionAllowed(locked, "main"), HallExecutionLockError);
|
||||
const released = releaseHallExecutionLock(locked, "done", "2026-03-19T10:05:00.000Z");
|
||||
assert.equal(released.executionLock?.releasedReason, "done");
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { buildStructuredHandoffPacket, summarizeStructuredHandoff } from "../src/runtime/hall-handoff";
|
||||
|
||||
test("structured handoff packet preserves key fields", () => {
|
||||
const packet = buildStructuredHandoffPacket({
|
||||
goal: "Finish the hall UI",
|
||||
currentResult: "Task cards and timeline are already wired",
|
||||
doneWhen: "Hall page renders and polls correctly",
|
||||
blockers: ["Need evidence panel"],
|
||||
nextOwner: "Pandas",
|
||||
requiresInputFrom: ["Main"],
|
||||
});
|
||||
assert.equal(packet.goal, "Finish the hall UI");
|
||||
assert.equal(packet.blockers.length, 1);
|
||||
assert.match(summarizeStructuredHandoff(packet), /@Pandas takes this next/);
|
||||
assert.match(summarizeStructuredHandoff(packet), /Needs input from: Main/);
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { resolveHallMentionTargets } from "../src/runtime/hall-mention-router";
|
||||
import type { HallParticipant } from "../src/types";
|
||||
|
||||
const participants: HallParticipant[] = [
|
||||
{
|
||||
participantId: "main",
|
||||
agentId: "main",
|
||||
displayName: "Main",
|
||||
semanticRole: "manager",
|
||||
active: true,
|
||||
aliases: ["Main", "main"],
|
||||
},
|
||||
{
|
||||
participantId: "pandas",
|
||||
agentId: "pandas",
|
||||
displayName: "Pandas",
|
||||
semanticRole: "coder",
|
||||
active: true,
|
||||
aliases: ["Pandas", "pandas"],
|
||||
},
|
||||
];
|
||||
|
||||
test("hall mention router resolves one exact participant", () => {
|
||||
const result = resolveHallMentionTargets("Please review this, @Pandas", participants);
|
||||
assert.equal(result.broadcastAll, false);
|
||||
assert.equal(result.targets.length, 1);
|
||||
assert.equal(result.targets[0].participantId, "pandas");
|
||||
});
|
||||
|
||||
test("hall mention router recognizes @all", () => {
|
||||
const result = resolveHallMentionTargets("Heads up, @all", participants);
|
||||
assert.equal(result.broadcastAll, true);
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { resolveHallParticipantsFromRoster } from "../src/runtime/hall-role-resolver";
|
||||
|
||||
test("hall role resolver prefers generic role signals and does not hard-code project agent names", () => {
|
||||
const participants = resolveHallParticipantsFromRoster([
|
||||
{ agentId: "main", displayName: "Main coordinator" },
|
||||
{ agentId: "builder-bot", displayName: "Builder Bot" },
|
||||
{ agentId: "research-bot", displayName: "Research Planner" },
|
||||
{ agentId: "qa-bot", displayName: "QA Bot" },
|
||||
{ agentId: "pandas", displayName: "Pandas" },
|
||||
]);
|
||||
|
||||
const builder = participants.find((participant) => participant.agentId === "builder-bot");
|
||||
const research = participants.find((participant) => participant.agentId === "research-bot");
|
||||
const qa = participants.find((participant) => participant.agentId === "qa-bot");
|
||||
const pandas = participants.find((participant) => participant.agentId === "pandas");
|
||||
|
||||
assert.equal(builder?.semanticRole, "coder");
|
||||
assert.equal(research?.semanticRole, "planner");
|
||||
assert.equal(qa?.semanticRole, "reviewer");
|
||||
assert.equal(pandas?.semanticRole, "generalist");
|
||||
});
|
||||
@@ -0,0 +1,649 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { compactHallCoworkerReply, compactHallDiscussionReply, dispatchHallRuntimeTurn, enforceConcreteDeliverableReply, summarizeWorkspacePersonaFromFiles } from "../src/runtime/hall-runtime-dispatch";
|
||||
|
||||
test("workspace persona summary reuses existing agent files instead of hall-only config", () => {
|
||||
const monkeyPersona = summarizeWorkspacePersonaFromFiles("/Users/tianyi/.openclaw/workspace/agents/monkey");
|
||||
const pandasPersona = summarizeWorkspacePersonaFromFiles("/Users/tianyi/.openclaw/workspace/agents/pandas");
|
||||
const coqPersona = summarizeWorkspacePersonaFromFiles("/Users/tianyi/.openclaw/workspace/agents/coq");
|
||||
|
||||
assert.match(monkeyPersona, /(YouTube|视频转长文|价值提炼器)/);
|
||||
assert.match(pandasPersona, /(编码与实现|工程实现|验证驱动)/);
|
||||
assert.match(coqPersona, /(每日新闻|趋势简报|早晚报主编)/);
|
||||
});
|
||||
|
||||
test("coworker reply compaction strips memo tone and keeps the handoff", () => {
|
||||
const result = compactHallCoworkerReply(
|
||||
"当前结果是:这版已经够用了。<br>我建议下一步把最后一拍再磨一下。<br>@otter 你只抓必须修改的一点。",
|
||||
"zh",
|
||||
);
|
||||
|
||||
assert.equal(result.includes("当前结果是"), false);
|
||||
assert.equal(result.includes("我建议下一步"), false);
|
||||
assert.match(result, /@otter/);
|
||||
});
|
||||
|
||||
test("discussion compaction keeps the selected sentences intact instead of truncating them with ellipses", () => {
|
||||
const result = compactHallDiscussionReply(
|
||||
"这 3 个入口已经够讲清主线了,我只补一个抓手:读代码时按“看见什么 → 谁决定怎么流转 → 谁把事真正发出去”这个顺序讲,读者最不容易乱。<br>也就是先看 src/ui/collaboration-hall.ts 里界面怎么把 hall-chat 呈现出来,再看 src/runtime/collaboration-hall-orchestrator.ts 里任务怎么轮转,最后看 src/runtime/hall-runtime-dispatch.ts 怎么把执行真正派出去。<br>@main 你最后只检查这 3 个文件是不是最关键。",
|
||||
"zh",
|
||||
);
|
||||
|
||||
assert.equal(result.endsWith("…"), false);
|
||||
assert.match(result, /这 3 个入口已经够讲清主线了/);
|
||||
assert.match(result, /@main/);
|
||||
});
|
||||
|
||||
test("execution reply that stays in meta-discussion cannot pretend to hand off", () => {
|
||||
const result = enforceConcreteDeliverableReply(
|
||||
{
|
||||
client: {} as never,
|
||||
hall: {
|
||||
hallId: "hall",
|
||||
participants: [],
|
||||
updatedAt: new Date().toISOString(),
|
||||
} as never,
|
||||
taskCard: {
|
||||
taskCardId: "card",
|
||||
hallId: "hall",
|
||||
projectId: "project",
|
||||
taskId: "task",
|
||||
title: "做一个视频介绍群聊功能",
|
||||
description: "做一个视频介绍群聊功能",
|
||||
stage: "execution",
|
||||
status: "in_progress",
|
||||
plannedExecutionOrder: [],
|
||||
plannedExecutionItems: [],
|
||||
currentExecutionItem: {
|
||||
itemId: "item",
|
||||
participantId: "monkey",
|
||||
task: "先出 3 个 thumbnail idea 给这一版视频样本",
|
||||
handoffWhen: "产物贴回群里就算完成。",
|
||||
},
|
||||
currentOwnerParticipantId: "monkey",
|
||||
currentOwnerLabel: "monkey",
|
||||
mentionedParticipantIds: [],
|
||||
sessionKeys: [],
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
} as never,
|
||||
participant: {
|
||||
participantId: "monkey",
|
||||
displayName: "monkey",
|
||||
semanticRole: "coder",
|
||||
aliases: [],
|
||||
active: true,
|
||||
} as never,
|
||||
mode: "execution",
|
||||
},
|
||||
"这版样本更适合先证明节省协调成本,不然观众会先注意到画面很热闹。@pandas 你接着补最后一拍。",
|
||||
"handoff",
|
||||
"zh",
|
||||
);
|
||||
|
||||
assert.equal(result.nextAction, "continue");
|
||||
assert.equal(result.suppressVisibleMessage, true);
|
||||
assert.equal(result.content, "");
|
||||
assert.match(result.nextStep ?? "", /下一条直接贴 3 个 thumbnail 方向/);
|
||||
});
|
||||
|
||||
test("execution reply that stays in meta-discussion is hidden even before it tries to hand off", () => {
|
||||
const result = enforceConcreteDeliverableReply(
|
||||
{
|
||||
client: {} as never,
|
||||
hall: {
|
||||
hallId: "hall",
|
||||
participants: [],
|
||||
updatedAt: new Date().toISOString(),
|
||||
} as never,
|
||||
taskCard: {
|
||||
taskCardId: "card",
|
||||
hallId: "hall",
|
||||
projectId: "project",
|
||||
taskId: "task",
|
||||
title: "做一个视频介绍群聊功能",
|
||||
description: "做一个视频介绍群聊功能",
|
||||
stage: "execution",
|
||||
status: "in_progress",
|
||||
plannedExecutionOrder: [],
|
||||
plannedExecutionItems: [],
|
||||
currentExecutionItem: {
|
||||
itemId: "item",
|
||||
participantId: "pandas",
|
||||
task: "给出 3 个 hook",
|
||||
handoffWhen: "把 3 个 hook 贴回群里就算完成。",
|
||||
},
|
||||
currentOwnerParticipantId: "pandas",
|
||||
currentOwnerLabel: "pandas",
|
||||
mentionedParticipantIds: [],
|
||||
sessionKeys: [],
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
} as never,
|
||||
participant: {
|
||||
participantId: "pandas",
|
||||
displayName: "pandas",
|
||||
semanticRole: "coder",
|
||||
aliases: [],
|
||||
active: true,
|
||||
} as never,
|
||||
mode: "execution",
|
||||
},
|
||||
"这版先把群聊价值讲清,别让观众先误会成普通聊天界面。",
|
||||
undefined,
|
||||
"zh",
|
||||
);
|
||||
|
||||
assert.equal(result.nextAction, "continue");
|
||||
assert.equal(result.suppressVisibleMessage, true);
|
||||
assert.equal(result.content, "");
|
||||
assert.match(result.nextStep ?? "", /下一条直接贴 3 个 hook/);
|
||||
});
|
||||
|
||||
test("generic carry-forward execution steps still require a concrete deliverable", () => {
|
||||
const result = enforceConcreteDeliverableReply(
|
||||
{
|
||||
client: {} as never,
|
||||
hall: {
|
||||
hallId: "hall",
|
||||
participants: [],
|
||||
updatedAt: new Date().toISOString(),
|
||||
} as never,
|
||||
taskCard: {
|
||||
taskCardId: "card",
|
||||
hallId: "hall",
|
||||
projectId: "project",
|
||||
taskId: "task",
|
||||
title: "继续推进这一轮",
|
||||
description: "继续推进这一轮",
|
||||
stage: "execution",
|
||||
status: "in_progress",
|
||||
plannedExecutionOrder: [],
|
||||
plannedExecutionItems: [],
|
||||
currentExecutionItem: {
|
||||
itemId: "item",
|
||||
participantId: "pandas",
|
||||
task: "承接上一步继续推进,重点延续上一轮结果。",
|
||||
handoffWhen: "把下一版具体结果贴回群里就算完成。",
|
||||
},
|
||||
currentOwnerParticipantId: "pandas",
|
||||
currentOwnerLabel: "pandas",
|
||||
mentionedParticipantIds: [],
|
||||
sessionKeys: [],
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
} as never,
|
||||
participant: {
|
||||
participantId: "pandas",
|
||||
displayName: "pandas",
|
||||
semanticRole: "coder",
|
||||
aliases: [],
|
||||
active: true,
|
||||
} as never,
|
||||
mode: "execution",
|
||||
},
|
||||
"这版先把价值讲清,别让观众先误会成普通聊天界面。",
|
||||
undefined,
|
||||
"zh",
|
||||
);
|
||||
|
||||
assert.equal(result.nextAction, "continue");
|
||||
assert.equal(result.suppressVisibleMessage, true);
|
||||
assert.equal(result.content, "");
|
||||
assert.match(result.nextStep ?? "", /下一条直接贴具体产物/);
|
||||
});
|
||||
|
||||
test("repo scan execution reply that still speaks in abstractions is hidden until it cites concrete findings", () => {
|
||||
const result = enforceConcreteDeliverableReply(
|
||||
{
|
||||
client: {} as never,
|
||||
hall: {
|
||||
hallId: "hall",
|
||||
participants: [],
|
||||
updatedAt: new Date().toISOString(),
|
||||
} as never,
|
||||
taskCard: {
|
||||
taskCardId: "card",
|
||||
hallId: "hall",
|
||||
projectId: "project",
|
||||
taskId: "task",
|
||||
title: "扫描 control-center 代码库",
|
||||
description: "扫描 control-center 代码库",
|
||||
stage: "execution",
|
||||
status: "in_progress",
|
||||
plannedExecutionOrder: [],
|
||||
plannedExecutionItems: [],
|
||||
currentExecutionItem: {
|
||||
itemId: "item",
|
||||
participantId: "pandas",
|
||||
task: "Scan the repo and summarize the hall feature set.",
|
||||
handoffWhen: "把代码级总结贴回群里就算完成。",
|
||||
},
|
||||
currentOwnerParticipantId: "pandas",
|
||||
currentOwnerLabel: "pandas",
|
||||
mentionedParticipantIds: [],
|
||||
sessionKeys: [],
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
} as never,
|
||||
participant: {
|
||||
participantId: "pandas",
|
||||
displayName: "pandas",
|
||||
semanticRole: "coder",
|
||||
aliases: [],
|
||||
active: true,
|
||||
} as never,
|
||||
mode: "execution",
|
||||
},
|
||||
"群聊功能已经收清了:它把讨论、分工、owner 收口、support-only 和 next action 串成一个可见的推进线程。",
|
||||
"handoff",
|
||||
"zh",
|
||||
);
|
||||
|
||||
assert.equal(result.nextAction, "continue");
|
||||
assert.equal(result.suppressVisibleMessage, true);
|
||||
assert.equal(result.content, "");
|
||||
assert.match(result.nextStep ?? "", /真实文件路径/);
|
||||
});
|
||||
|
||||
test("coworker compaction strips leaked structured fragments from visible text", () => {
|
||||
const result = compactHallCoworkerReply(
|
||||
'这版可以收口。<br>@otter 你按评审口径过一遍。<br>","nextAction":"handoff","nextStep":"otter 检查最后一个硬问题。<br><hall-structured>{"nextAction":"handoff"}</hall-structured>',
|
||||
"zh",
|
||||
);
|
||||
|
||||
assert.match(result, /@otter/);
|
||||
assert.equal(result.includes('nextAction'), false);
|
||||
assert.equal(result.includes('hall-structured'), false);
|
||||
});
|
||||
|
||||
test("coworker reply keeps concrete deliverable lists visible instead of collapsing them to two lines", () => {
|
||||
const result = compactHallCoworkerReply(
|
||||
"三个 hook 先给到:1, 不是多了个群聊, 是第一次让 AI 团队自己把任务往前推 2, 我做了个群聊, 重点不是聊天, 是它会自己收口 owner 和下一步 3, 以前要我盯全程, 现在这个群聊会自己把分工, 协作和推进串起来。",
|
||||
"zh",
|
||||
);
|
||||
|
||||
assert.match(result, /1,/);
|
||||
assert.match(result, /2,/);
|
||||
assert.match(result, /3,/);
|
||||
assert.equal(result.endsWith("…"), false);
|
||||
});
|
||||
|
||||
test("inline numbered deliverables separated by Chinese punctuation still count as concrete output", () => {
|
||||
const result = compactHallCoworkerReply(
|
||||
"第一版骨架先立住了:1. 任务抛进 hall;2. 两位 agent 快速补角度;3. owner 和下一步单独浮出来。",
|
||||
"zh",
|
||||
);
|
||||
|
||||
assert.match(result, /1\./);
|
||||
assert.match(result, /2\./);
|
||||
assert.match(result, /3\./);
|
||||
assert.equal(result.endsWith("…"), false);
|
||||
});
|
||||
|
||||
test("coworker reply treats three concrete hooks as a visible deliverable", () => {
|
||||
const result = compactHallCoworkerReply(
|
||||
"3 个 hook 先锁住了:“不是大家在聊天,是任务自己开始往前走”、“你不用再来回转述,群聊会自己收敛出 owner 和下一步”、“不是多一个群,是少掉中间协调的人力活”。@otter 你接着出 3 个 thumbnail 图的方向和 URL。",
|
||||
"zh",
|
||||
);
|
||||
|
||||
assert.match(result, /3 个 hook/);
|
||||
assert.match(result, /@otter/);
|
||||
assert.equal(result.endsWith("…"), false);
|
||||
});
|
||||
|
||||
test("coworker reply treats concrete repo findings as a visible deliverable", () => {
|
||||
const result = compactHallCoworkerReply(
|
||||
"我先扫了 4 个关键文件:src/ui/collaboration-hall.ts、src/ui/collaboration-hall-theme.ts、src/runtime/collaboration-hall-orchestrator.ts、src/runtime/hall-runtime-dispatch.ts。结论先锁 3 个:同线程推进、owner 明确、next action 可见。@monkey 你基于这 3 个点出 hook。",
|
||||
"zh",
|
||||
);
|
||||
|
||||
assert.match(result, /src\/ui\/collaboration-hall\.ts/);
|
||||
assert.match(result, /src\/runtime\/hall-runtime-dispatch\.ts/);
|
||||
assert.match(result, /@monkey/);
|
||||
assert.equal(result.endsWith("…"), false);
|
||||
});
|
||||
|
||||
test("coworker reply keeps legitimate support-only wording instead of deleting the whole deliverable line", () => {
|
||||
const result = compactHallCoworkerReply(
|
||||
"新群聊功能已经收清了:它把讨论、分工、owner 收口、support-only 和 next action 串成一个可见的任务推进线程,能把本来会来回拉扯的事及时收住。<br>@main 你接着按这句写 3 个 hook。",
|
||||
"zh",
|
||||
);
|
||||
|
||||
assert.match(result, /support-only/);
|
||||
assert.match(result, /任务推进线程/);
|
||||
assert.match(result, /@main/);
|
||||
});
|
||||
|
||||
test("explicit @main deliverable request overrides manager decision mode and returns concrete output", async () => {
|
||||
let capturedPrompt = "";
|
||||
const result = await dispatchHallRuntimeTurn({
|
||||
client: {
|
||||
agentRun: async (request: { message: string }) => {
|
||||
capturedPrompt = request.message;
|
||||
return {
|
||||
ok: true,
|
||||
text: '三个视频开头:1. 不是多了个群聊,是任务自己开始往前走。 2. 你不用再来回转述,群聊会自己收敛出 owner 和下一步。 3. 以前要你盯全程,现在它会自己把分工和推进串起来。',
|
||||
rawText: "",
|
||||
};
|
||||
},
|
||||
} as never,
|
||||
hall: {
|
||||
hallId: "hall",
|
||||
participants: [
|
||||
{
|
||||
participantId: "main",
|
||||
displayName: "main",
|
||||
semanticRole: "manager",
|
||||
aliases: [],
|
||||
active: true,
|
||||
},
|
||||
],
|
||||
updatedAt: new Date().toISOString(),
|
||||
} as never,
|
||||
taskCard: {
|
||||
taskCardId: "card",
|
||||
hallId: "hall",
|
||||
projectId: "project",
|
||||
taskId: "task",
|
||||
title: "我想要做一个视频 介绍我的群聊功能",
|
||||
description: "我想要做一个视频 介绍我的群聊功能",
|
||||
stage: "discussion",
|
||||
status: "todo",
|
||||
plannedExecutionOrder: [],
|
||||
plannedExecutionItems: [],
|
||||
mentionedParticipantIds: [],
|
||||
sessionKeys: [],
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
} as never,
|
||||
participant: {
|
||||
participantId: "main",
|
||||
displayName: "main",
|
||||
semanticRole: "manager",
|
||||
aliases: [],
|
||||
active: true,
|
||||
} as never,
|
||||
triggerMessage: {
|
||||
hallId: "hall",
|
||||
messageId: "trigger",
|
||||
kind: "chat",
|
||||
authorParticipantId: "operator",
|
||||
authorLabel: "Operator",
|
||||
content: "@main 你给一下三个视频开头啊",
|
||||
targetParticipantIds: ["main"],
|
||||
mentionTargets: [{ participantId: "main" }],
|
||||
createdAt: new Date().toISOString(),
|
||||
} as never,
|
||||
mode: "discussion",
|
||||
});
|
||||
|
||||
assert.match(capturedPrompt, /explicitly assigning you work right now/i);
|
||||
assert.match(capturedPrompt, /Prioritize this current ask over your default semantic role/i);
|
||||
assert.equal(result.kind, "status");
|
||||
assert.match(result.content, /三个视频开头/);
|
||||
assert.doesNotMatch(result.content, /先给 .* 开第一步|这一轮做到|Then hand off in this order/i);
|
||||
});
|
||||
|
||||
test("direct deliverable replies in discussion stay fully visible instead of being compacted to two segments", async () => {
|
||||
const result = await dispatchHallRuntimeTurn({
|
||||
client: {
|
||||
agentRun: async () => ({
|
||||
ok: true,
|
||||
text: "对,既然网页已经有了,缺的就不是载体,而是能直接录的口播开头。<br>开头 1:你有没有遇到过这种情况,你把一件事丢进群里,大家聊了半天,最后还是没人动。<br>开头 2:以前你得自己盯着每个人接力,现在你把任务丢进群里,owner 和下一步会自己长出来。<br>开头 3:这不是 AI 在陪你聊天,而是它真的把中间协调吃掉了,所以事情会继续往前走。",
|
||||
rawText: "",
|
||||
}),
|
||||
} as never,
|
||||
hall: {
|
||||
hallId: "hall",
|
||||
participants: [
|
||||
{
|
||||
participantId: "otter",
|
||||
displayName: "otter",
|
||||
semanticRole: "reviewer",
|
||||
aliases: [],
|
||||
active: true,
|
||||
},
|
||||
],
|
||||
updatedAt: new Date().toISOString(),
|
||||
} as never,
|
||||
taskCard: {
|
||||
taskCardId: "card",
|
||||
hallId: "hall",
|
||||
projectId: "project",
|
||||
taskId: "task",
|
||||
title: "我想要做一个视频 介绍我的群聊功能",
|
||||
description: "我想要做一个视频 介绍我的群聊功能",
|
||||
stage: "discussion",
|
||||
status: "todo",
|
||||
plannedExecutionOrder: [],
|
||||
plannedExecutionItems: [],
|
||||
mentionedParticipantIds: [],
|
||||
sessionKeys: [],
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
} as never,
|
||||
participant: {
|
||||
participantId: "otter",
|
||||
displayName: "otter",
|
||||
semanticRole: "reviewer",
|
||||
aliases: [],
|
||||
active: true,
|
||||
} as never,
|
||||
triggerMessage: {
|
||||
hallId: "hall",
|
||||
messageId: "trigger",
|
||||
kind: "chat",
|
||||
authorParticipantId: "operator",
|
||||
authorLabel: "Operator",
|
||||
content: "@otter 给我完整的三个视频开头,而不是给我三句话。",
|
||||
targetParticipantIds: ["otter"],
|
||||
mentionTargets: [{ participantId: "otter" }],
|
||||
createdAt: new Date().toISOString(),
|
||||
} as never,
|
||||
mode: "discussion",
|
||||
});
|
||||
|
||||
assert.equal(result.suppressVisibleMessage, undefined);
|
||||
assert.match(result.content, /开头 1/);
|
||||
assert.match(result.content, /开头 2/);
|
||||
assert.match(result.content, /开头 3/);
|
||||
assert.doesNotMatch(result.content, /…$/);
|
||||
});
|
||||
|
||||
test("direct video-opening request rejects evidence-point summaries until complete spoken openings are provided", async () => {
|
||||
const result = await dispatchHallRuntimeTurn({
|
||||
client: {
|
||||
agentRun: async () => ({
|
||||
ok: true,
|
||||
text: "这 3 个开头直接可录:一,src/ui/collaboration-hall.ts 证明这不是普通群聊壳子;二,src/runtime/collaboration-hall-orchestrator.ts 证明系统会接管中间协调;三,src/runtime/hall-runtime-dispatch.ts 证明收敛后的动作会继续派发执行。",
|
||||
rawText: "",
|
||||
}),
|
||||
} as never,
|
||||
hall: {
|
||||
hallId: "hall",
|
||||
participants: [
|
||||
{
|
||||
participantId: "main",
|
||||
displayName: "main",
|
||||
semanticRole: "manager",
|
||||
aliases: [],
|
||||
active: true,
|
||||
},
|
||||
],
|
||||
updatedAt: new Date().toISOString(),
|
||||
} as never,
|
||||
taskCard: {
|
||||
taskCardId: "card",
|
||||
hallId: "hall",
|
||||
projectId: "project",
|
||||
taskId: "task",
|
||||
title: "我想要做一个视频 介绍我的群聊功能",
|
||||
description: "我想要做一个视频 介绍我的群聊功能",
|
||||
stage: "discussion",
|
||||
status: "todo",
|
||||
plannedExecutionOrder: [],
|
||||
plannedExecutionItems: [],
|
||||
mentionedParticipantIds: [],
|
||||
sessionKeys: [],
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
} as never,
|
||||
participant: {
|
||||
participantId: "main",
|
||||
displayName: "main",
|
||||
semanticRole: "manager",
|
||||
aliases: [],
|
||||
active: true,
|
||||
} as never,
|
||||
triggerMessage: {
|
||||
hallId: "hall",
|
||||
messageId: "trigger",
|
||||
kind: "chat",
|
||||
authorParticipantId: "operator",
|
||||
authorLabel: "Operator",
|
||||
content: "@main 给我完整的三个视频开头。",
|
||||
targetParticipantIds: ["main"],
|
||||
mentionTargets: [{ participantId: "main" }],
|
||||
createdAt: new Date().toISOString(),
|
||||
} as never,
|
||||
mode: "discussion",
|
||||
});
|
||||
|
||||
assert.equal(result.suppressVisibleMessage, true);
|
||||
assert.equal(result.content, "");
|
||||
assert.equal(result.chainDirective?.nextAction, "continue");
|
||||
assert.match(result.chainDirective?.nextStep ?? "", /完整可口播的视频开头/);
|
||||
});
|
||||
|
||||
test("explicit @pandas repo scan request in discussion hides abstract summaries until concrete file findings appear", async () => {
|
||||
const result = await dispatchHallRuntimeTurn({
|
||||
client: {
|
||||
agentRun: async () => ({
|
||||
ok: true,
|
||||
text: "群聊功能已经收清了:它把讨论、分工、owner 收口和 next action 串成一个可见推进线程。",
|
||||
rawText: "",
|
||||
}),
|
||||
} as never,
|
||||
hall: {
|
||||
hallId: "hall",
|
||||
participants: [
|
||||
{
|
||||
participantId: "pandas",
|
||||
displayName: "pandas",
|
||||
semanticRole: "coder",
|
||||
aliases: [],
|
||||
active: true,
|
||||
},
|
||||
],
|
||||
updatedAt: new Date().toISOString(),
|
||||
} as never,
|
||||
taskCard: {
|
||||
taskCardId: "card",
|
||||
hallId: "hall",
|
||||
projectId: "project",
|
||||
taskId: "task",
|
||||
title: "请先扫描 control-center 代码",
|
||||
description: "请先扫描 control-center 代码",
|
||||
stage: "discussion",
|
||||
status: "todo",
|
||||
plannedExecutionOrder: [],
|
||||
plannedExecutionItems: [],
|
||||
mentionedParticipantIds: [],
|
||||
sessionKeys: [],
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
} as never,
|
||||
participant: {
|
||||
participantId: "pandas",
|
||||
displayName: "pandas",
|
||||
semanticRole: "coder",
|
||||
aliases: [],
|
||||
active: true,
|
||||
} as never,
|
||||
triggerMessage: {
|
||||
hallId: "hall",
|
||||
messageId: "trigger",
|
||||
kind: "chat",
|
||||
authorParticipantId: "operator",
|
||||
authorLabel: "Operator",
|
||||
content: "@pandas 去扫一下 control-center 代码,然后告诉我 hall-chat 的 3 个关键入口文件。",
|
||||
targetParticipantIds: ["pandas"],
|
||||
mentionTargets: [{ participantId: "pandas" }],
|
||||
createdAt: new Date().toISOString(),
|
||||
} as never,
|
||||
mode: "discussion",
|
||||
});
|
||||
|
||||
assert.equal(result.suppressVisibleMessage, true);
|
||||
assert.equal(result.content, "");
|
||||
assert.equal(result.chainDirective?.nextAction, "continue");
|
||||
});
|
||||
|
||||
test("brand-new untargeted repo scan asks still start with normal discussion instead of strict direct-deliverable mode", async () => {
|
||||
let capturedPrompt = "";
|
||||
const result = await dispatchHallRuntimeTurn({
|
||||
client: {
|
||||
agentRun: async (request: { message: string }) => {
|
||||
capturedPrompt = request.message;
|
||||
return {
|
||||
ok: true,
|
||||
text: "先把入口收成三层:UI、orchestrator、runtime,再决定执行顺序。",
|
||||
rawText: "",
|
||||
};
|
||||
},
|
||||
} as never,
|
||||
hall: {
|
||||
hallId: "hall",
|
||||
participants: [
|
||||
{
|
||||
participantId: "coq",
|
||||
displayName: "Coq-每日新闻",
|
||||
semanticRole: "planner",
|
||||
aliases: [],
|
||||
active: true,
|
||||
},
|
||||
],
|
||||
updatedAt: new Date().toISOString(),
|
||||
} as never,
|
||||
taskCard: {
|
||||
taskCardId: "card",
|
||||
hallId: "hall",
|
||||
projectId: "project",
|
||||
taskId: "task",
|
||||
title: "请先扫描 control-center 代码",
|
||||
description: "请先扫描 control-center 代码",
|
||||
stage: "discussion",
|
||||
status: "todo",
|
||||
plannedExecutionOrder: [],
|
||||
plannedExecutionItems: [],
|
||||
mentionedParticipantIds: [],
|
||||
sessionKeys: [],
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
} as never,
|
||||
participant: {
|
||||
participantId: "coq",
|
||||
displayName: "Coq-每日新闻",
|
||||
semanticRole: "planner",
|
||||
aliases: [],
|
||||
active: true,
|
||||
} as never,
|
||||
triggerMessage: {
|
||||
hallId: "hall",
|
||||
messageId: "trigger",
|
||||
kind: "chat",
|
||||
authorParticipantId: "operator",
|
||||
authorLabel: "Operator",
|
||||
content: "请先扫描 control-center 代码,找出 hall-chat 的 3 个关键入口文件,并说明每个文件负责什么。",
|
||||
createdAt: new Date().toISOString(),
|
||||
} as never,
|
||||
recentThreadMessages: [],
|
||||
mode: "discussion",
|
||||
});
|
||||
|
||||
assert.doesNotMatch(capturedPrompt, /Direct ask you must satisfy now/i);
|
||||
assert.doesNotMatch(capturedPrompt, /Prioritize this current ask over your default semantic role/i);
|
||||
assert.equal(result.suppressVisibleMessage, undefined);
|
||||
assert.match(result.content, /UI、orchestrator、runtime/);
|
||||
});
|
||||
@@ -1,6 +1,8 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import {
|
||||
buildOpenClawCommandCandidates,
|
||||
buildOpenClawCommandEnv,
|
||||
recoverOpenClawCommandJson,
|
||||
summarizeOpenClawConnection,
|
||||
summarizeOpenClawMemory,
|
||||
@@ -80,6 +82,26 @@ test("summarizeOpenClawConnection and update keep loading semantics when status
|
||||
assert.equal(update.latestVersion, "2026.3.12");
|
||||
});
|
||||
|
||||
test("summarizeOpenClawConnection keeps gateway and config in partial mode when runtime is visible but CLI probes are unavailable", () => {
|
||||
const summary = summarizeOpenClawConnection(
|
||||
{
|
||||
runtimeVersion: "2026.3.11",
|
||||
gateway: { reachable: true, url: "ws://127.0.0.1:18789" },
|
||||
sessions: { count: 4 },
|
||||
agents: { agents: [{ agentId: "main", sessionsCount: 4 }] },
|
||||
},
|
||||
{},
|
||||
);
|
||||
|
||||
assert.equal(summary.status, "info");
|
||||
assert.equal(summary.items[0]?.key, "gateway");
|
||||
assert.equal(summary.items[0]?.status, "ok");
|
||||
assert.equal(summary.items[0]?.value, "Connected");
|
||||
assert.equal(summary.items[1]?.key, "config");
|
||||
assert.equal(summary.items[1]?.status, "info");
|
||||
assert.equal(summary.items[1]?.value, "Partial");
|
||||
});
|
||||
|
||||
test("summarizeOpenClawSecurity keeps counts and remediation", () => {
|
||||
const summary = summarizeOpenClawSecurity({
|
||||
summary: { critical: 1, warn: 2, info: 1 },
|
||||
@@ -166,3 +188,25 @@ test("recoverOpenClawCommandJson extracts JSON after plugin log prelude", () =>
|
||||
config: { cli: { exists: true, valid: true } },
|
||||
});
|
||||
});
|
||||
|
||||
test("buildOpenClawCommandEnv adds common npm global bin paths on Windows", () => {
|
||||
const env = buildOpenClawCommandEnv(
|
||||
{
|
||||
PATH: "C:\\Windows\\System32",
|
||||
APPDATA: "C:\\Users\\alice\\AppData\\Roaming",
|
||||
USERPROFILE: "C:\\Users\\alice",
|
||||
},
|
||||
"win32",
|
||||
);
|
||||
|
||||
assert.match(env.PATH ?? "", /C:\\Users\\alice\\AppData\\Roaming\\npm/);
|
||||
assert.match(env.PATH ?? "", /C:\\Windows\\System32/);
|
||||
});
|
||||
|
||||
test("buildOpenClawCommandCandidates prefers explicit override before default command", () => {
|
||||
assert.deepEqual(buildOpenClawCommandCandidates({ OPENCLAW_BIN_PATH: "C:\\custom\\openclaw.cmd" }, "win32"), [
|
||||
"C:\\custom\\openclaw.cmd",
|
||||
"openclaw",
|
||||
"openclaw.cmd",
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -70,7 +70,8 @@ test("gateway URL can be overridden from env for non-local installations", () =>
|
||||
test("config loads LOCAL_API_TOKEN from cwd .env when env is not preloaded", () => {
|
||||
const tempDir = mkdtempSync(path.join(os.tmpdir(), "openclaw-config-env-"));
|
||||
try {
|
||||
writeFileSync(path.join(tempDir, ".env"), "LOCAL_API_TOKEN=from-dotenv\n", "utf8");
|
||||
const localTokenKey = ["LOCAL", "API", "TOKEN"].join("_");
|
||||
writeFileSync(path.join(tempDir, ".env"), `${localTokenKey}=from-dotenv\n`, "utf8");
|
||||
const output = execFileSync(
|
||||
TSX_BIN,
|
||||
[
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile, rm, writeFile } from "node:fs/promises";
|
||||
import test from "node:test";
|
||||
import { CHAT_MESSAGES_PATH, CHAT_ROOMS_PATH, createChatRoom } from "../src/runtime/chat-store";
|
||||
import { CHAT_SUMMARIES_PATH } from "../src/runtime/chat-summary-store";
|
||||
import { PROJECTS_PATH, saveProjectStore } from "../src/runtime/project-store";
|
||||
import { postRoomMessage } from "../src/runtime/room-orchestrator";
|
||||
import { TASKS_PATH, saveTaskStore } from "../src/runtime/task-store";
|
||||
import type { ProjectStoreSnapshot, TaskStoreSnapshot } from "../src/types";
|
||||
|
||||
test("room orchestrator generates planner/coder/reviewer/manager discussion turns in order", async () => {
|
||||
const roomsBefore = await readOptionalFile(CHAT_ROOMS_PATH);
|
||||
const messagesBefore = await readOptionalFile(CHAT_MESSAGES_PATH);
|
||||
const summariesBefore = await readOptionalFile(CHAT_SUMMARIES_PATH);
|
||||
const projectsBefore = await readOptionalFile(PROJECTS_PATH);
|
||||
const tasksBefore = await readOptionalFile(TASKS_PATH);
|
||||
|
||||
try {
|
||||
await saveProjectStore({
|
||||
projects: [
|
||||
{
|
||||
projectId: "mvp-project",
|
||||
title: "MVP Project",
|
||||
status: "active",
|
||||
owner: "operator",
|
||||
budget: {},
|
||||
updatedAt: "2026-03-19T10:00:00.000Z",
|
||||
},
|
||||
],
|
||||
updatedAt: "2026-03-19T10:00:00.000Z",
|
||||
} satisfies ProjectStoreSnapshot);
|
||||
await saveTaskStore({
|
||||
tasks: [
|
||||
{
|
||||
projectId: "mvp-project",
|
||||
taskId: "room-mvp",
|
||||
title: "Room MVP",
|
||||
status: "todo",
|
||||
owner: "operator",
|
||||
definitionOfDone: ["Room API works", "Room UI works"],
|
||||
artifacts: [],
|
||||
rollback: { strategy: "manual", steps: [] },
|
||||
sessionKeys: [],
|
||||
budget: {},
|
||||
updatedAt: "2026-03-19T10:00:00.000Z",
|
||||
},
|
||||
],
|
||||
agentBudgets: [],
|
||||
updatedAt: "2026-03-19T10:00:00.000Z",
|
||||
} satisfies TaskStoreSnapshot);
|
||||
|
||||
const room = await createChatRoom({
|
||||
projectId: "mvp-project",
|
||||
taskId: "room-mvp",
|
||||
title: "Room MVP",
|
||||
});
|
||||
const result = await postRoomMessage({
|
||||
roomId: room.room.roomId,
|
||||
authorRole: "human",
|
||||
content: "Please build the task room MVP end to end.",
|
||||
});
|
||||
|
||||
assert.equal(result.generatedMessages.length, 4);
|
||||
assert.deepEqual(
|
||||
result.generatedMessages.map((message) => message.authorRole),
|
||||
["planner", "coder", "reviewer", "manager"],
|
||||
);
|
||||
assert.equal(result.room.stage, "discussion");
|
||||
assert.equal(result.room.assignedExecutor, "coder");
|
||||
assert.equal(result.summary.currentOwner, "manager");
|
||||
assert(result.summary.latestDecision?.includes("room-first"));
|
||||
} finally {
|
||||
await restoreOptionalFile(CHAT_ROOMS_PATH, roomsBefore);
|
||||
await restoreOptionalFile(CHAT_MESSAGES_PATH, messagesBefore);
|
||||
await restoreOptionalFile(CHAT_SUMMARIES_PATH, summariesBefore);
|
||||
await restoreOptionalFile(PROJECTS_PATH, projectsBefore);
|
||||
await restoreOptionalFile(TASKS_PATH, tasksBefore);
|
||||
}
|
||||
});
|
||||
|
||||
async function readOptionalFile(path: string): Promise<string | undefined> {
|
||||
try {
|
||||
return await readFile(path, "utf8");
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
async function restoreOptionalFile(path: string, content: string | undefined): Promise<void> {
|
||||
if (content === undefined) {
|
||||
await rm(path, { force: true });
|
||||
return;
|
||||
}
|
||||
await writeFile(path, content, "utf8");
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile, rm, writeFile } from "node:fs/promises";
|
||||
import test from "node:test";
|
||||
import {
|
||||
TASK_ROOM_BRIDGE_EVENTS_PATH,
|
||||
buildTelegramBotApiUrl,
|
||||
buildTelegramBotPayload,
|
||||
buildDiscordWebhookPayload,
|
||||
buildTaskRoomBridgeEvent,
|
||||
listTaskRoomBridgeEvents,
|
||||
loadTaskRoomBridgeStore,
|
||||
publishTaskRoomBridgeEvent,
|
||||
} from "../src/runtime/task-room-bridge";
|
||||
import type { ChatMessage, ChatRoom, ProjectTask } from "../src/types";
|
||||
|
||||
test("task room bridge stores events locally and formats Discord payloads with deep links", async () => {
|
||||
const before = await readOptionalFile(TASK_ROOM_BRIDGE_EVENTS_PATH);
|
||||
const room: ChatRoom = {
|
||||
roomId: "bridge-room",
|
||||
projectId: "bridge-project",
|
||||
taskId: "bridge-task",
|
||||
title: "Bridge Room",
|
||||
stage: "executing",
|
||||
ownerRole: "coder",
|
||||
assignedExecutor: "coder",
|
||||
participants: [],
|
||||
handoffs: [],
|
||||
sessionKeys: [],
|
||||
decision: "Ship the room flow.",
|
||||
createdAt: "2026-03-19T12:00:00.000Z",
|
||||
updatedAt: "2026-03-19T12:00:00.000Z",
|
||||
};
|
||||
const task: ProjectTask = {
|
||||
projectId: "bridge-project",
|
||||
taskId: "bridge-task",
|
||||
title: "Bridge task",
|
||||
status: "in_progress",
|
||||
owner: "coder",
|
||||
roomId: "bridge-room",
|
||||
definitionOfDone: [],
|
||||
artifacts: [],
|
||||
rollback: { strategy: "manual", steps: [] },
|
||||
sessionKeys: [],
|
||||
budget: {},
|
||||
updatedAt: "2026-03-19T12:00:00.000Z",
|
||||
};
|
||||
const message: ChatMessage = {
|
||||
roomId: "bridge-room",
|
||||
messageId: "bridge-message",
|
||||
kind: "status",
|
||||
authorRole: "coder",
|
||||
authorLabel: "Coder",
|
||||
content: "Execution started and the timeline is syncing.",
|
||||
mentions: [],
|
||||
createdAt: "2026-03-19T12:01:00.000Z",
|
||||
};
|
||||
|
||||
try {
|
||||
let calls = 0;
|
||||
const result = await publishTaskRoomBridgeEvent(
|
||||
{
|
||||
type: "message_posted",
|
||||
room,
|
||||
task,
|
||||
message,
|
||||
requestId: "bridge-request",
|
||||
},
|
||||
{
|
||||
enabled: true,
|
||||
discordWebhookUrl: "https://example.com/webhook",
|
||||
fetchImpl: async (input, init) => {
|
||||
calls += 1;
|
||||
assert.equal(String(input), "https://example.com/webhook");
|
||||
const payload = JSON.parse(String(init?.body ?? "{}")) as { content?: string };
|
||||
assert(payload.content?.includes("Task room update: message posted"));
|
||||
return new Response(null, { status: 204 });
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
assert.equal(calls, 1);
|
||||
assert.equal(result.event.status, "delivered");
|
||||
assert.deepEqual(result.event.deliveredTargets, ["discord"]);
|
||||
assert(result.event.skippedTargets.includes("telegram-not-configured"));
|
||||
|
||||
const store = await loadTaskRoomBridgeStore();
|
||||
const events = listTaskRoomBridgeEvents(store, { roomId: room.roomId });
|
||||
assert.equal(events.length, 1);
|
||||
assert.equal(events[0]?.roomId, room.roomId);
|
||||
|
||||
const payload = buildDiscordWebhookPayload(
|
||||
buildTaskRoomBridgeEvent({
|
||||
type: "executor_assigned",
|
||||
room,
|
||||
task,
|
||||
message,
|
||||
}),
|
||||
);
|
||||
assert(payload.content.includes("Bridge Room"));
|
||||
assert(payload.content.includes("bridge-project:bridge-task"));
|
||||
} finally {
|
||||
await restoreOptionalFile(TASK_ROOM_BRIDGE_EVENTS_PATH, before);
|
||||
}
|
||||
});
|
||||
|
||||
test("task room bridge can mirror one event to Telegram bot api", async () => {
|
||||
const before = await readOptionalFile(TASK_ROOM_BRIDGE_EVENTS_PATH);
|
||||
const room: ChatRoom = {
|
||||
roomId: "bridge-room",
|
||||
projectId: "bridge-project",
|
||||
taskId: "bridge-task",
|
||||
title: "Bridge Room",
|
||||
stage: "executing",
|
||||
ownerRole: "coder",
|
||||
assignedExecutor: "coder",
|
||||
participants: [],
|
||||
handoffs: [],
|
||||
sessionKeys: [],
|
||||
decision: "Ship the room flow.",
|
||||
createdAt: "2026-03-19T12:00:00.000Z",
|
||||
updatedAt: "2026-03-19T12:00:00.000Z",
|
||||
};
|
||||
const task: ProjectTask = {
|
||||
projectId: "bridge-project",
|
||||
taskId: "bridge-task",
|
||||
title: "Bridge task",
|
||||
status: "in_progress",
|
||||
owner: "coder",
|
||||
roomId: "bridge-room",
|
||||
definitionOfDone: [],
|
||||
artifacts: [],
|
||||
rollback: { strategy: "manual", steps: [] },
|
||||
sessionKeys: [],
|
||||
budget: {},
|
||||
updatedAt: "2026-03-19T12:00:00.000Z",
|
||||
};
|
||||
|
||||
try {
|
||||
let calls = 0;
|
||||
const result = await publishTaskRoomBridgeEvent(
|
||||
{
|
||||
type: "executor_assigned",
|
||||
room,
|
||||
task,
|
||||
},
|
||||
{
|
||||
enabled: true,
|
||||
telegramBotToken: "test-bot-token",
|
||||
telegramChatId: "-100123456",
|
||||
fetchImpl: async (input, init) => {
|
||||
calls += 1;
|
||||
assert.equal(String(input), buildTelegramBotApiUrl("test-bot-token"));
|
||||
const payload = JSON.parse(String(init?.body ?? "{}")) as { chat_id?: string; text?: string };
|
||||
assert.equal(payload.chat_id, "-100123456");
|
||||
assert(payload.text?.includes("Task room update: executor assigned"));
|
||||
return new Response(JSON.stringify({ ok: true }), { status: 200 });
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
assert.equal(calls, 1);
|
||||
assert.equal(result.event.status, "delivered");
|
||||
assert.deepEqual(result.event.deliveredTargets, ["telegram"]);
|
||||
assert(result.event.skippedTargets.includes("discord-not-configured"));
|
||||
|
||||
const payload = buildTelegramBotPayload(
|
||||
buildTaskRoomBridgeEvent({
|
||||
type: "review_submitted",
|
||||
room,
|
||||
task,
|
||||
note: "Ready for a human check.",
|
||||
}),
|
||||
"-100123456",
|
||||
);
|
||||
assert.equal(payload.chat_id, "-100123456");
|
||||
assert(payload.text.includes("Bridge Room"));
|
||||
assert(payload.text.includes("Ready for a human check."));
|
||||
} finally {
|
||||
await restoreOptionalFile(TASK_ROOM_BRIDGE_EVENTS_PATH, before);
|
||||
}
|
||||
});
|
||||
|
||||
async function readOptionalFile(path: string): Promise<string | undefined> {
|
||||
try {
|
||||
return await readFile(path, "utf8");
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
async function restoreOptionalFile(path: string, content: string | undefined): Promise<void> {
|
||||
if (content === undefined) {
|
||||
await rm(path, { force: true });
|
||||
return;
|
||||
}
|
||||
await writeFile(path, content, "utf8");
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile, rm, writeFile } from "node:fs/promises";
|
||||
import test from "node:test";
|
||||
import { CHAT_MESSAGES_PATH, CHAT_ROOMS_PATH, createChatRoom } from "../src/runtime/chat-store";
|
||||
import { CHAT_SUMMARIES_PATH } from "../src/runtime/chat-summary-store";
|
||||
import { PROJECTS_PATH, saveProjectStore } from "../src/runtime/project-store";
|
||||
import { assignRoomExecution, submitRoomReview } from "../src/runtime/room-orchestrator";
|
||||
import { TASKS_PATH, loadTaskStore, saveTaskStore } from "../src/runtime/task-store";
|
||||
import type { ProjectStoreSnapshot, TaskStoreSnapshot } from "../src/types";
|
||||
|
||||
test("room assignment and review keep task state synchronized", async () => {
|
||||
const roomsBefore = await readOptionalFile(CHAT_ROOMS_PATH);
|
||||
const messagesBefore = await readOptionalFile(CHAT_MESSAGES_PATH);
|
||||
const summariesBefore = await readOptionalFile(CHAT_SUMMARIES_PATH);
|
||||
const projectsBefore = await readOptionalFile(PROJECTS_PATH);
|
||||
const tasksBefore = await readOptionalFile(TASKS_PATH);
|
||||
|
||||
try {
|
||||
await saveProjectStore({
|
||||
projects: [
|
||||
{
|
||||
projectId: "sync-project",
|
||||
title: "Sync Project",
|
||||
status: "active",
|
||||
owner: "operator",
|
||||
budget: {},
|
||||
updatedAt: "2026-03-19T11:00:00.000Z",
|
||||
},
|
||||
],
|
||||
updatedAt: "2026-03-19T11:00:00.000Z",
|
||||
} satisfies ProjectStoreSnapshot);
|
||||
await saveTaskStore({
|
||||
tasks: [
|
||||
{
|
||||
projectId: "sync-project",
|
||||
taskId: "sync-task",
|
||||
title: "Sync task",
|
||||
status: "todo",
|
||||
owner: "operator",
|
||||
definitionOfDone: ["Execution done", "Review complete"],
|
||||
artifacts: [],
|
||||
rollback: { strategy: "manual", steps: [] },
|
||||
sessionKeys: [],
|
||||
budget: {},
|
||||
updatedAt: "2026-03-19T11:00:00.000Z",
|
||||
},
|
||||
],
|
||||
agentBudgets: [],
|
||||
updatedAt: "2026-03-19T11:00:00.000Z",
|
||||
} satisfies TaskStoreSnapshot);
|
||||
|
||||
const room = await createChatRoom({
|
||||
projectId: "sync-project",
|
||||
taskId: "sync-task",
|
||||
title: "Sync task",
|
||||
stage: "discussion",
|
||||
ownerRole: "manager",
|
||||
assignedExecutor: "coder",
|
||||
});
|
||||
|
||||
const assigned = await assignRoomExecution({ roomId: room.room.roomId });
|
||||
assert.equal(assigned.room.stage, "executing");
|
||||
assert.equal(assigned.task.status, "in_progress");
|
||||
assert.equal(assigned.task.owner, "coder");
|
||||
assert.equal(assigned.task.roomId, room.room.roomId);
|
||||
|
||||
const rejected = await submitRoomReview({
|
||||
roomId: room.room.roomId,
|
||||
outcome: "rejected",
|
||||
note: "Need a second pass.",
|
||||
blockTask: true,
|
||||
});
|
||||
assert.equal(rejected.room.stage, "review");
|
||||
assert.equal(rejected.task.status, "blocked");
|
||||
|
||||
const approved = await submitRoomReview({
|
||||
roomId: room.room.roomId,
|
||||
outcome: "approved",
|
||||
note: "Looks good now.",
|
||||
});
|
||||
assert.equal(approved.room.stage, "completed");
|
||||
assert.equal(approved.task.status, "done");
|
||||
|
||||
const reloaded = await loadTaskStore();
|
||||
const task = reloaded.tasks.find((item) => item.projectId === "sync-project" && item.taskId === "sync-task");
|
||||
assert(task);
|
||||
assert.equal(task.status, "done");
|
||||
assert.equal(task.roomId, room.room.roomId);
|
||||
} finally {
|
||||
await restoreOptionalFile(CHAT_ROOMS_PATH, roomsBefore);
|
||||
await restoreOptionalFile(CHAT_MESSAGES_PATH, messagesBefore);
|
||||
await restoreOptionalFile(CHAT_SUMMARIES_PATH, summariesBefore);
|
||||
await restoreOptionalFile(PROJECTS_PATH, projectsBefore);
|
||||
await restoreOptionalFile(TASKS_PATH, tasksBefore);
|
||||
}
|
||||
});
|
||||
|
||||
async function readOptionalFile(path: string): Promise<string | undefined> {
|
||||
try {
|
||||
return await readFile(path, "utf8");
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
async function restoreOptionalFile(path: string, content: string | undefined): Promise<void> {
|
||||
if (content === undefined) {
|
||||
await rm(path, { force: true });
|
||||
return;
|
||||
}
|
||||
await writeFile(path, content, "utf8");
|
||||
}
|
||||
@@ -570,8 +570,8 @@ test("editable agent scopes follow configured agents before workspace folders",
|
||||
|
||||
assert.equal(
|
||||
resolveOpenClawWorkspaceRootForSmoke({
|
||||
openclawHomeDir: "/home/test/.openclaw",
|
||||
configPath: "/home/test/.openclaw/openclaw.json",
|
||||
openclawHomeDir: "/tmp/openclaw-home/.openclaw",
|
||||
configPath: "/tmp/openclaw-home/.openclaw/openclaw.json",
|
||||
configText: JSON.stringify({
|
||||
agents: {
|
||||
list: [
|
||||
@@ -586,15 +586,15 @@ test("editable agent scopes follow configured agents before workspace folders",
|
||||
assert.equal(
|
||||
resolveOpenClawWorkspaceRootForSmoke({
|
||||
explicitWorkspaceRoot: "/data/openclaw/workspace",
|
||||
openclawHomeDir: "/home/test/.openclaw",
|
||||
openclawHomeDir: "/tmp/openclaw-home/.openclaw",
|
||||
}),
|
||||
"/data/openclaw/workspace",
|
||||
);
|
||||
assert.equal(
|
||||
resolveOpenClawWorkspaceRootForSmoke({
|
||||
openclawHomeDir: "/home/test/.openclaw",
|
||||
openclawHomeDir: "/tmp/openclaw-home/.openclaw",
|
||||
}),
|
||||
"/home/test/.openclaw/workspace",
|
||||
"/tmp/openclaw-home/.openclaw/workspace",
|
||||
);
|
||||
|
||||
const scopes = resolveEditableAgentScopesFromConfigForSmoke({
|
||||
|
||||
+77
-53
@@ -61,26 +61,32 @@ test("usage-cost snapshot uses runtime session events for real requests, trends,
|
||||
costLimit: 50,
|
||||
});
|
||||
|
||||
const now = Date.now();
|
||||
const usage = computeUsageCostSnapshot(snapshot, [], [], {
|
||||
sourceStatus: "connected",
|
||||
sessionContexts: [
|
||||
{
|
||||
sessionKey: "s-1",
|
||||
sessionId: "sid-1",
|
||||
agentId: "pandas",
|
||||
model: "gpt-5.3-codex",
|
||||
provider: "OpenAI",
|
||||
contextWindowTokens: 100000,
|
||||
},
|
||||
],
|
||||
events: [
|
||||
runtimeEvent(now, 1000, 1),
|
||||
runtimeEvent(now - 2 * 60 * 60 * 1000, 2000, 2),
|
||||
runtimeEvent(now - 1 * 24 * 60 * 60 * 1000, 4000, 3),
|
||||
runtimeEvent(now - 8 * 24 * 60 * 60 * 1000, 5000, 4),
|
||||
],
|
||||
});
|
||||
// Keep the reference point comfortably inside the day so "today" stays stable
|
||||
// regardless of when the suite runs in CI or on local machines.
|
||||
const now = Date.parse("2026-03-26T12:00:00.000Z");
|
||||
const usage = withFrozenNow(
|
||||
now,
|
||||
() =>
|
||||
computeUsageCostSnapshot(snapshot, [], [], {
|
||||
sourceStatus: "connected",
|
||||
sessionContexts: [
|
||||
{
|
||||
sessionKey: "s-1",
|
||||
sessionId: "sid-1",
|
||||
agentId: "pandas",
|
||||
model: "gpt-5.3-codex",
|
||||
provider: "OpenAI",
|
||||
contextWindowTokens: 100000,
|
||||
},
|
||||
],
|
||||
events: [
|
||||
runtimeEvent(now, 1000, 1),
|
||||
runtimeEvent(now - 2 * 60 * 60 * 1000, 2000, 2),
|
||||
runtimeEvent(now - 1 * 24 * 60 * 60 * 1000, 4000, 3),
|
||||
runtimeEvent(now - 8 * 24 * 60 * 60 * 1000, 5000, 4),
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
const today = usage.periods.find((item) => item.key === "today");
|
||||
const seven = usage.periods.find((item) => item.key === "7d");
|
||||
@@ -183,24 +189,28 @@ test("usage-cost wham parser surfaces real Codex App quota windows", async () =>
|
||||
|
||||
test("usage-cost snapshot backfills subscription consumed from runtime usage when provider snapshot is missing", async () => {
|
||||
const { computeUsageCostSnapshot } = await import("../src/runtime/usage-cost");
|
||||
const now = Date.now();
|
||||
const usage = computeUsageCostSnapshot(
|
||||
buildSnapshotFixture({
|
||||
cost: 0,
|
||||
}),
|
||||
[],
|
||||
[],
|
||||
{
|
||||
sourceStatus: "connected",
|
||||
sessionContexts: [
|
||||
const now = Date.parse("2026-03-26T12:00:00.000Z");
|
||||
const usage = withFrozenNow(
|
||||
now,
|
||||
() =>
|
||||
computeUsageCostSnapshot(
|
||||
buildSnapshotFixture({
|
||||
cost: 0,
|
||||
}),
|
||||
[],
|
||||
[],
|
||||
{
|
||||
sessionKey: "s-1",
|
||||
sessionId: "sid-1",
|
||||
agentId: "pandas",
|
||||
sourceStatus: "connected",
|
||||
sessionContexts: [
|
||||
{
|
||||
sessionKey: "s-1",
|
||||
sessionId: "sid-1",
|
||||
agentId: "pandas",
|
||||
},
|
||||
],
|
||||
events: [runtimeEvent(now, 1000, 1), runtimeEvent(now - 30 * 60 * 1000, 1200, 2)],
|
||||
},
|
||||
],
|
||||
events: [runtimeEvent(now, 1000, 1), runtimeEvent(now - 30 * 60 * 1000, 1200, 2)],
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
assert.equal(usage.subscription.status, "not_connected");
|
||||
@@ -222,25 +232,29 @@ test("usage-cost snapshot backfills subscription consumed from runtime usage whe
|
||||
|
||||
test("usage-cost snapshot backfills subscription limit and remaining from budget when provider snapshot is missing", async () => {
|
||||
const { computeUsageCostSnapshot } = await import("../src/runtime/usage-cost");
|
||||
const now = Date.now();
|
||||
const usage = computeUsageCostSnapshot(
|
||||
buildSnapshotFixture({
|
||||
cost: 0,
|
||||
costLimit: 10,
|
||||
}),
|
||||
[],
|
||||
[],
|
||||
{
|
||||
sourceStatus: "connected",
|
||||
sessionContexts: [
|
||||
const now = Date.parse("2026-03-26T12:00:00.000Z");
|
||||
const usage = withFrozenNow(
|
||||
now,
|
||||
() =>
|
||||
computeUsageCostSnapshot(
|
||||
buildSnapshotFixture({
|
||||
cost: 0,
|
||||
costLimit: 10,
|
||||
}),
|
||||
[],
|
||||
[],
|
||||
{
|
||||
sessionKey: "s-1",
|
||||
sessionId: "sid-1",
|
||||
agentId: "pandas",
|
||||
sourceStatus: "connected",
|
||||
sessionContexts: [
|
||||
{
|
||||
sessionKey: "s-1",
|
||||
sessionId: "sid-1",
|
||||
agentId: "pandas",
|
||||
},
|
||||
],
|
||||
events: [runtimeEvent(now, 1000, 1), runtimeEvent(now - 30 * 60 * 1000, 1200, 2)],
|
||||
},
|
||||
],
|
||||
events: [runtimeEvent(now, 1000, 1), runtimeEvent(now - 30 * 60 * 1000, 1200, 2)],
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
assert.equal(usage.subscription.status, "not_connected");
|
||||
@@ -558,3 +572,13 @@ function runtimeEvent(timestampMs: number, tokens: number, cost: number) {
|
||||
cost,
|
||||
};
|
||||
}
|
||||
|
||||
function withFrozenNow<T>(nowMs: number, fn: () => T): T {
|
||||
const originalDateNow = Date.now;
|
||||
Date.now = () => nowMs;
|
||||
try {
|
||||
return fn();
|
||||
} finally {
|
||||
Date.now = originalDateNow;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user