feat: stabilize hall chat collaboration flow

This commit is contained in:
OpenClaw Local
2026-03-29 18:19:51 +02:00
parent 473f42bb41
commit 4a473b5534
71 changed files with 27932 additions and 181 deletions
+20
View File
@@ -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
+1
View File
@@ -31,6 +31,7 @@ docs/assets/*-preview*.png
docs/assets/*-inspect*.png
docs/assets/_wk-*.png
docs/assets/_usage-*.png
.tmp_*
1440m
180m
720m
+39 -9
View File
@@ -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
View File
@@ -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` 创建;如果存在,就在保留安全默认值的前提下修正它。
+59
View File
@@ -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
View File
@@ -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?
+39
View File
@@ -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.
+59
View File
@@ -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
View File
@@ -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",
+793
View File
@@ -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);
});
+118
View File
@@ -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);
});
+152
View File
@@ -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);
});
+281
View File
@@ -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);
});
+104
View File
@@ -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);
});
+78
View File
@@ -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);
});
+65
View File
@@ -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
View File
@@ -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);
}
+314 -3
View File
@@ -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;
}
+5
View File
@@ -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 {
+53
View File
@@ -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;
}
}
+54
View File
@@ -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;
+171
View File
@@ -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
View File
@@ -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
+244
View File
@@ -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();
}
+464
View File
@@ -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));
}
+65
View File
@@ -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";
}
+69
View File
@@ -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,
);
}
}
+136
View File
@@ -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;
}
+48
View File
@@ -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, "\\$&");
}
+111
View File
@@ -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
+104
View File
@@ -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,
};
}
+112 -20
View File
@@ -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 {
+19 -4
View File
@@ -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 {
+3 -3
View File
@@ -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._:-]+$/;
+412
View File
@@ -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";
}
+10
View File
@@ -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);
}
+21
View File
@@ -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,
+495
View File
@@ -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
View File
@@ -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(
+61
View File
@@ -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
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+768
View File
@@ -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) => ({ '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;', "'": '&#39;' }[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("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;");
}
function safeJsonForScript(value: unknown): string {
return JSON.stringify(value).replace(/<\/script/gi, "<\\/script");
}
+146
View File
@@ -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");
}
+58
View File
@@ -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");
}
+123
View File
@@ -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
+89
View File
@@ -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");
}
+271
View File
@@ -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
+36
View File
@@ -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;'));
});
+50
View File
@@ -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");
});
+18
View File
@@ -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/);
});
+35
View File
@@ -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);
});
+23
View File
@@ -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");
});
+649
View File
@@ -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/);
});
+44
View File
@@ -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",
]);
});
+2 -1
View File
@@ -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,
[
+95
View File
@@ -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");
}
+197
View File
@@ -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");
}
+112
View File
@@ -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");
}
+5 -5
View File
@@ -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
View File
@@ -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;
}
}