mirror of
https://github.com/ValueCell-ai/ClawX.git
synced 2026-08-14 08:53:09 +00:00
Compare commits
1
Commits
faster-e2e
...
dev-zx-2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8dcadef797 |
@@ -123,6 +123,7 @@ ClawX には Tencent 公式の個人 WeChat チャンネルプラグインも同
|
||||
### ⏰ Cronベースの自動化
|
||||
AIタスクを自動的に実行するようスケジュール設定できます。トリガーを定義し、間隔を設定することで、手動介入なしにAIエージェントを24時間稼働させることができます。
|
||||
定期タスク画面では外部配信を「送信アカウント」と「受信先ターゲット」の 2 段階セレクターで設定できるようになりました。対応チャネルでは、受信先候補をチャネルのディレクトリ機能や既知セッション履歴から自動検出するため、`jobs.json` を手で編集する必要はありません。タスクのメッセージ入力欄でも、メインのチャット入力と同じインライン `/skill` トークン記法でスキルを挿入できるようになりました(選択中のエージェントに応じて読み込み)。スケジュールされたプロンプトから直接スキルを起動できます。スケジュール選択は**繰り返し**と**1回のみ**のタブに分かれました。繰り返しは毎時・毎日・平日・毎週・カスタム(生の cron)の頻度を時刻/曜日コントロール付きで選べ、1回のみは選択した日付(曜日を表示)と時刻に一度だけ実行します。1回のみのタスクは未来の時刻を指定する必要があり、実行後はランタイムにより自動的に削除されます。
|
||||
定期タスクの実行中は、Gateway を情報源とする一時的なオーバーレイに進捗が表示されます。完了した会話内容の正本は引き続き ACP リプレイまたは定期タスク履歴であり、外部の定期タスク実行が Chat から停止またはキャンセルできる ACP プロンプトになることはありません。
|
||||
|
||||
|
||||
### 🧩 拡張可能なスキルシステム
|
||||
|
||||
@@ -123,6 +123,7 @@ ClawX now also bundles Tencent's official personal WeChat channel plugin, so you
|
||||
### ⏰ Cron-Based Automation
|
||||
Schedule AI tasks to run automatically. Define triggers, set intervals, and let your AI agents work around the clock without manual intervention.
|
||||
The Cron page now lets you configure external delivery directly in the task form with separate sender-account and recipient-target selectors. For supported channels, recipient targets are discovered automatically from channel directories or known session history, so you no longer need to edit `jobs.json` by hand. The task message field also supports inserting skills with the same inline `/skill` token syntax as the main chat composer (scoped to the selected agent), so scheduled prompts can trigger skills directly. The schedule picker is split into **Recurring** and **Once** tabs: Recurring offers Hourly, Daily, Weekdays, Weekly, and Custom (raw cron) frequencies with inline time/weekday controls, while Once runs the task a single time at a chosen date (with weekday shown) and time. One-time tasks must be scheduled for a future moment and are automatically removed by the runtime once they finish.
|
||||
While a cron task is running, Chat shows its progress in a transient Gateway-backed overlay. Completed conversation content still comes from authoritative ACP replay or cron history, and external cron activity never becomes an ACP prompt that can be stopped or cancelled from Chat.
|
||||
|
||||
|
||||
### 🧩 Extensible Skill System
|
||||
|
||||
@@ -124,6 +124,7 @@ ClawX 现在还内置了腾讯官方个人微信渠道插件,可直接在 Chan
|
||||
### ⏰ 定时任务自动化
|
||||
调度 AI 任务自动执行。定义触发器、设置时间间隔,让 AI 智能体 7×24 小时不间断工作。
|
||||
现在定时任务页面已经可以直接配置外部投递,统一拆成“发送账号”和“接收目标”两个下拉选择。对于已支持的通道,接收目标会从通道目录能力或已知会话历史中自动发现,不需要再手动修改 `jobs.json`。任务的消息输入框也支持像主对话框那样以内联 `/skill` 令牌的方式插入技能(按所选智能体范围加载),让定时提示词可以直接触发技能。调度选择器现在分为**周期**和**单次**两个选项卡:周期支持每小时、每天、工作日、每周、自定义(原始 cron)等频率,并内置时间/星期选择;单次则在所选日期(显示星期)和时间执行一次。单次任务必须设置为未来时间,并会在执行完成后由运行时自动清除。
|
||||
定时任务运行时,Chat 会通过由 Gateway 支持的临时浮层显示进度。已完成的对话内容仍以 ACP 重放或定时任务历史为权威来源,外部定时任务活动不会变成可在 Chat 中停止或取消的 ACP 提示词。
|
||||
|
||||
|
||||
### 🧩 可扩展技能系统
|
||||
|
||||
@@ -0,0 +1,578 @@
|
||||
# Cron Live Run Overlay Implementation Plan
|
||||
|
||||
> **For agentic workers:** Use `subagent-driven-development` to implement this plan task-by-task. Use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Render live cron-run progress in ACP Chat without converting Gateway runtime events into ACP notifications or persisting them in the ACP timeline.
|
||||
|
||||
**Architecture:** Electron Main owns a bounded, memory-only cron live-run broker. It canonicalizes run-scoped cron keys, deduplicates and reduces Gateway runtime events into an explicit non-ACP overlay snapshot, publishes typed host events, and serves a race-safe snapshot for late subscribers. Renderer keeps that overlay separate from `AcpTimelineSnapshot`; when a visible run terminates, it removes the overlay and reloads the authoritative ACP/cron history exactly once.
|
||||
|
||||
**Tech Stack:** Electron Main, TypeScript, Zustand, React 19, typed host-api/host-events, Vitest, Playwright, react-i18next, Harness communication specs.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Gateway runtime events must never be converted into `SessionNotification`, `AcpSessionUpdateEnvelope`, or `TimelineItem` objects.
|
||||
- `src/lib/acp/reducer.ts`, `src/lib/acp/timeline-types.ts`, and ACP replay semantics remain unchanged.
|
||||
- ACP `sending`, `cancelling`, Stop behavior, and `cancelAcpSession` remain owned exclusively by ACP prompts initiated by ClawX.
|
||||
- The overlay accepts only strict run-scoped cron keys shaped as `agent:<agentId>:cron:<jobId>:run:<runSessionId>`; ordinary sessions, base-only cron keys, channel sessions, and heartbeat `:main` events are rejected.
|
||||
- Main is the sole owner of cron key canonicalization, runtime-event deduplication, memory bounds, and active-run snapshots. Renderer must not reimplement protocol switching or Gateway event reduction.
|
||||
- Keep raw `chat:runtime-event` forwarding unchanged for the existing legacy runtime graph and image-generation compatibility consumers.
|
||||
- Display assistant text, but do not display raw `thinking.delta` text. The overlay exposes only a localized running/thinking indicator.
|
||||
- Runtime approval events are read-only status rows. They must not call ACP permission response APIs.
|
||||
- A terminal overlay is never treated as history. Completed content appears only after normal `loadAcpSession` replay or the existing typed cron-history fallback.
|
||||
- Use these exact broker bounds:
|
||||
- `MAX_ACTIVE_CRON_LIVE_RUNS = 32`
|
||||
- `MAX_CRON_LIVE_ITEMS_PER_RUN = 128`
|
||||
- `MAX_CRON_LIVE_ASSISTANT_CHARS = 500_000`
|
||||
- `MAX_CRON_LIVE_ITEM_DETAIL_CHARS = 100_000`
|
||||
- `MAX_CRON_LIVE_EVENT_FINGERPRINTS = 256` per run
|
||||
- `MAX_CRON_LIVE_TERMINAL_TOMBSTONES = 128`
|
||||
- Numeric sequence values are monotonic per run: reject `seq <= lastSeq`. Sequence-less events use bounded type-specific fingerprints; reject exact repeats but retain distinct incremental chunks.
|
||||
- Namespace every process item identity by `runId` so repeated `toolCallId`, `itemId`, command names, or approval fallbacks cannot collide across runs.
|
||||
- Main emits a monotonically increasing broker `revision`. Renderer subscribes before fetching the snapshot and ignores snapshots or changes older than its current revision.
|
||||
- All new display text must be translated in `en`, `zh`, `ja`, and `ru` and use existing design tokens from `src/styles/globals.css`.
|
||||
- Update the checked-in task spec before implementation. Because this changes backend communication, run Harness validation, communication replay/compare, and Electron E2E before completion.
|
||||
- Do not commit unless the user explicitly requests it. Each task lists a commit point only for a later explicitly requested commit workflow.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Update the architecture contract before code
|
||||
|
||||
**Files:**
|
||||
- Modify: `harness/specs/tasks/render-cron-run-live-status.md`
|
||||
- Modify: `harness/specs/scenarios/gateway-backend-communication.md`
|
||||
- Modify: `harness/specs/scenarios/acp-chat-experience.md`
|
||||
- Modify: `harness/specs/rules/acp-chat-state-and-history.md`
|
||||
- Modify: `harness/specs/rules/acp-compatibility-content-safety.md`
|
||||
- Create: `harness/reference/acp-cron-live-overlay.md`
|
||||
- Test: `tests/unit/harness-specs.test.ts`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: Existing `gateway-backend-communication` and `acp-chat-experience` scenario contracts.
|
||||
- Produces: A durable rule that permits one bounded, running-only Gateway overlay while preserving ACP replay as the sole history authority.
|
||||
|
||||
- [ ] **Step 1: Write the failing Harness assertion**
|
||||
|
||||
Extend `tests/unit/harness-specs.test.ts` to require `render-cron-run-live-status` to declare `fast`, `comms`, and `e2e`; reference `acp-cron-live-overlay.md`; require ACP authority, compatibility safety, renderer/Main boundary, host-api/host-events, i18n/design-token, communication regression, and docs-sync rules.
|
||||
|
||||
- [ ] **Step 2: Run the focused test and verify the expected failure**
|
||||
|
||||
Run `pnpm exec vitest run tests/unit/harness-specs.test.ts`. Expect failure because the current task spec still describes the legacy Execution Graph and omits the overlay reference and E2E profile.
|
||||
|
||||
- [ ] **Step 3: Rewrite the task and reference contract**
|
||||
|
||||
Change the expected behavior from “Gateway events become ACP/tool timeline updates” to:
|
||||
|
||||
```text
|
||||
Gateway runtime event -> Main bounded cron broker -> explicit live overlay
|
||||
terminal event -> overlay removal -> authoritative ACP/cron-history reload
|
||||
```
|
||||
|
||||
State explicitly that the overlay is non-historical, memory-only, run-scoped, read-only, and excluded from sidebar unread/busy authority. Set `docs.required: true`, list all touched areas from this plan, and include the focused/unit/E2E/comms commands used below.
|
||||
|
||||
- [ ] **Step 4: Validate the real task spec**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
pnpm exec vitest run tests/unit/harness-specs.test.ts
|
||||
pnpm harness validate --spec harness/specs/tasks/render-cron-run-live-status.md
|
||||
pnpm harness run --spec harness/specs/tasks/render-cron-run-live-status.md --dry-run
|
||||
```
|
||||
|
||||
Expect all structural validation to pass without `--no-diff`.
|
||||
|
||||
- [ ] **Step 5: Commit point**
|
||||
|
||||
If explicitly requested, commit as `docs: define bounded cron live overlay architecture`.
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Establish shared cron identity and correct lifecycle normalization
|
||||
|
||||
**Files:**
|
||||
- Create: `shared/chat/cron-session.ts`
|
||||
- Delete: `src/stores/chat/cron-session-utils.ts`
|
||||
- Modify: `src/stores/acp-chat-session.ts`
|
||||
- Modify: `src/stores/chat.ts`
|
||||
- Modify: `src/stores/gateway.ts`
|
||||
- Modify: `src/stores/session-attention.ts`
|
||||
- Modify: `src/stores/chat/history-actions.ts`
|
||||
- Modify: `src/stores/chat/session-selection.ts`
|
||||
- Modify: `src/stores/chat/session-catalog.ts`
|
||||
- Modify: `src/stores/chat/session-key-utils.ts`
|
||||
- Modify: `electron/services/cron-api.ts`
|
||||
- Modify: `electron/gateway/chat-runtime-events.ts`
|
||||
- Test: `tests/unit/cron-session-utils.test.ts`
|
||||
- Test: `tests/unit/gateway-event-dispatch.test.ts`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: Raw OpenClaw `sessionKey`, lifecycle `phase`, and `data.aborted` values.
|
||||
- Produces: `parseCronSessionKey`, `getCronSessionBaseKey`, `isCronSessionKey`, `isRunScopedCronSessionKey`, and `sessionKeysAreEquivalent` as one shared authority; normalized terminal `ChatRuntimeEvent` values.
|
||||
|
||||
- [ ] **Step 1: Write failing identity and terminal tests**
|
||||
|
||||
Update `cron-session-utils.test.ts` to import from `@shared/chat/cron-session` and cover strict base/run parsing, empty or whitespace-only agent/job/run segment rejection, malformed suffix rejection, and run-scoped detection. Replace the current test that treats lifecycle `phase: 'end'` as non-terminal with expectations that:
|
||||
|
||||
```ts
|
||||
{ phase: 'end' } -> { type: 'run.ended', status: 'completed' }
|
||||
{ phase: 'end', aborted: true } -> { type: 'run.ended', status: 'aborted' }
|
||||
{ phase: 'error' } -> { type: 'run.ended', status: 'error' }
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run tests and verify failures**
|
||||
|
||||
Run `pnpm exec vitest run tests/unit/cron-session-utils.test.ts tests/unit/gateway-event-dispatch.test.ts`. Expect missing shared imports and incorrect `phase: 'end'` normalization.
|
||||
|
||||
- [ ] **Step 3: Centralize and tighten key parsing, then update all callers**
|
||||
|
||||
Move the parser into `shared/chat/cron-session.ts`, reject empty or whitespace-only `agentId`, `jobId`, and `runSessionId` segments, require exactly four segments for a base key or exactly six segments with literal `run` for a run key, and add `isRunScopedCronSessionKey`. Migrate all eight Renderer callers listed in the Files section plus Main `cron-api.ts`, delete the duplicate Main parser, and delete the old Renderer-owned utility file. Do not leave a compatibility re-export.
|
||||
|
||||
- [ ] **Step 4: Normalize OpenClaw terminal lifecycle correctly**
|
||||
|
||||
In `normalizeGatewayChatRuntimeEvent`, accept `end`, `completed`, `done`, and `finished` as terminal. For `phase: 'end'`, map `data.aborted === true` to `aborted`; otherwise map to `completed`. Preserve `endedAt`, `livenessState`, `replayInvalid`, and `stopReason`.
|
||||
|
||||
- [ ] **Step 5: Run focused regressions**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
pnpm exec vitest run \
|
||||
tests/unit/cron-session-utils.test.ts \
|
||||
tests/unit/gateway-event-dispatch.test.ts \
|
||||
tests/unit/gateway-events.test.ts \
|
||||
tests/unit/cron-schedule.test.ts
|
||||
```
|
||||
|
||||
Expect all tests to pass and no imports of `src/stores/chat/cron-session-utils.ts` to remain.
|
||||
|
||||
- [ ] **Step 6: Commit point**
|
||||
|
||||
If explicitly requested, commit as `fix: share cron identity and normalize run terminals`.
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Build the bounded Main-process cron live-run broker
|
||||
|
||||
**Files:**
|
||||
- Create: `shared/chat/cron-live-run.ts`
|
||||
- Create: `electron/services/cron-live-run-broker.ts`
|
||||
- Create: `tests/unit/cron-live-run-broker.test.ts`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `ChatRuntimeEvent` and shared cron-session parsing.
|
||||
- Produces: `CronLiveRunOverlaySnapshot`, `CronLiveRunItem`, `CronLiveRunOverlayChange`, `CronLiveRunOverlaySnapshotSet`, and `CronLiveRunBroker`.
|
||||
|
||||
- [ ] **Step 1: Define the explicit non-ACP view model in the test**
|
||||
|
||||
Write broker tests against this discriminated model:
|
||||
|
||||
```ts
|
||||
type CronLiveRunStatus = 'running';
|
||||
|
||||
type CronLiveRunItem =
|
||||
| { kind: 'tool'; id: string; toolCallId: string; title: string; status: 'running' | 'completed' | 'failed'; inputText?: string; outputText?: string; error?: string }
|
||||
| { kind: 'command'; id: string; title: string; status: 'running' | 'completed' | 'failed'; output: string; exitCode?: number }
|
||||
| { kind: 'patch'; id: string; title: string; summary?: string; added?: number; modified?: number; deleted?: number }
|
||||
| { kind: 'approval'; id: string; title: string; status: 'running' | 'completed' | 'failed'; message?: string };
|
||||
|
||||
interface CronLiveRunOverlaySnapshot {
|
||||
canonicalSessionKey: string;
|
||||
sourceSessionKey: string;
|
||||
runSessionId: string;
|
||||
runId: string;
|
||||
revision: number;
|
||||
status: CronLiveRunStatus;
|
||||
startedAt?: number;
|
||||
updatedAt: number;
|
||||
lastSeq?: number;
|
||||
assistantText: string;
|
||||
thinking: boolean;
|
||||
items: CronLiveRunItem[];
|
||||
}
|
||||
|
||||
interface CronLiveRunOverlaySnapshotSet {
|
||||
revision: number;
|
||||
snapshots: CronLiveRunOverlaySnapshot[];
|
||||
}
|
||||
|
||||
type CronLiveRunOverlayChange =
|
||||
| {
|
||||
kind: 'upsert';
|
||||
revision: number;
|
||||
snapshot: CronLiveRunOverlaySnapshot;
|
||||
}
|
||||
| {
|
||||
kind: 'remove';
|
||||
revision: number;
|
||||
canonicalSessionKey: string;
|
||||
sourceSessionKey: string;
|
||||
runId: string;
|
||||
reason: 'ended' | 'evicted' | 'gateway-reset';
|
||||
terminalStatus?: 'completed' | 'error' | 'aborted';
|
||||
terminalError?: string;
|
||||
};
|
||||
```
|
||||
|
||||
The broker-level `revision` increments once for every emitted change, including removals and clears. Every upsert snapshot carries that same revision. `getSnapshotSet()` returns the current broker revision even when `snapshots` is empty, so Renderer can reject a stale empty/non-empty hydration response deterministically.
|
||||
|
||||
- [ ] **Step 2: Write failing broker scenarios**
|
||||
|
||||
Cover strict run-key admission, mid-flight adoption without `run.started`, text snapshot/replace/delta convergence, thinking boolean without retained thought text, tool updates, command output, patch and approval ordering, run-namespaced identities, numeric sequence rejection, sequence-less fingerprint dedupe, deterministic active-run eviction, text/item bounds, terminal removal, terminal tombstone suppression, gateway reset, and monotonic revisions.
|
||||
|
||||
- [ ] **Step 3: Run the broker test and verify failure**
|
||||
|
||||
Run `pnpm exec vitest run tests/unit/cron-live-run-broker.test.ts`. Expect module-not-found failures.
|
||||
|
||||
- [ ] **Step 4: Implement the minimum reducer and broker**
|
||||
|
||||
Implement one pure `reduceCronLiveRunEvent(snapshot, event)` and one stateful `CronLiveRunBroker`. Use type-specific stable fingerprints instead of generic unbounded serialization. Serialize structured input/output with stable key ordering, catch cycles, and truncate to `MAX_CRON_LIVE_ITEM_DETAIL_CHARS`. Preserve first-occurrence item ordering and update existing items in place.
|
||||
|
||||
On terminal events, emit `remove` before deleting active state, then add a bounded run tombstone so delayed duplicate/non-terminal events cannot recreate the run. `getSnapshotSet()` returns immutable clones sorted by `updatedAt`, then `runId` for deterministic hydration.
|
||||
|
||||
- [ ] **Step 5: Run focused tests and static checks**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
pnpm exec vitest run tests/unit/cron-live-run-broker.test.ts
|
||||
pnpm run typecheck:node
|
||||
```
|
||||
|
||||
Expect broker tests and Node type checking to pass.
|
||||
|
||||
- [ ] **Step 6: Commit point**
|
||||
|
||||
If explicitly requested, commit as `feat: add bounded cron live-run broker`.
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Expose broker snapshots and changes through typed Main boundaries
|
||||
|
||||
**Files:**
|
||||
- Modify: `shared/host-events/contract.ts`
|
||||
- Modify: `shared/host-api/contract.ts`
|
||||
- Modify: `electron/services/cron-live-run-broker.ts`
|
||||
- Modify: `electron/services/cron-api.ts`
|
||||
- Modify: `electron/main/ipc-handlers.ts`
|
||||
- Modify: `electron/main/index.ts`
|
||||
- Modify: `src/lib/host-events.ts`
|
||||
- Modify: `src/lib/host-api.ts`
|
||||
- Test: `tests/unit/cron-live-run-broker.test.ts`
|
||||
- Test: `tests/unit/cron-schedule.test.ts`
|
||||
- Test: `tests/unit/host-events.test.ts`
|
||||
- Test: `tests/unit/host-api-facade.test.ts`
|
||||
- Test: `tests/unit/host-services.test.ts`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `CronLiveRunBroker` from Task 3 and `GatewayManager` runtime/status/exit events.
|
||||
- Produces: `hostApi.cron.liveRunOverlays()` and `hostEvents.onCronLiveRunOverlayChanged()`.
|
||||
|
||||
- [ ] **Step 1: Write failing host-boundary tests**
|
||||
|
||||
Add expectations for:
|
||||
|
||||
```ts
|
||||
HOST_EVENT_CHANNELS.cron.liveRunOverlayChanged === 'cron:live-run-overlay-changed'
|
||||
hostEvents.onCronLiveRunOverlayChanged(handler)
|
||||
hostApi.cron.liveRunOverlays()
|
||||
```
|
||||
|
||||
Extend broker tests for a `bindCronLiveRunBroker` helper that is the sole broker-ingestion owner: it listens to GatewayManager `chat:runtime-event`, publishes resulting broker changes, and clears on non-running Gateway status or `exit`. Existing raw runtime forwarding remains a separate listener and must not call `broker.ingestRuntimeEvent`.
|
||||
|
||||
- [ ] **Step 2: Run tests and verify missing contracts**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
pnpm exec vitest run \
|
||||
tests/unit/cron-live-run-broker.test.ts \
|
||||
tests/unit/cron-schedule.test.ts \
|
||||
tests/unit/host-events.test.ts \
|
||||
tests/unit/host-api-facade.test.ts \
|
||||
tests/unit/host-services.test.ts
|
||||
```
|
||||
|
||||
Expect failures for the new API/event surface and dependency injection.
|
||||
|
||||
- [ ] **Step 3: Add typed contracts and facades**
|
||||
|
||||
Add a static `cron` host-event module with `liveRunOverlayChanged`, and add `cron.liveRunOverlays` to `HostApiContract`. The preload channel allowlist is contract-derived, so do not add a direct IPC allowlist or renderer `window.electron.ipcRenderer.invoke` call.
|
||||
|
||||
- [ ] **Step 4: Wire one broker instance in Main**
|
||||
|
||||
Instantiate `CronLiveRunBroker` next to `GatewayManager` in `electron/main/index.ts`, pass it through `registerIpcHandlers` to `createCronApi`, and call `bindCronLiveRunBroker` before Gateway auto-start. The binding publishes changes with `sendMainWindowEvent(HOST_EVENT_CHANNELS.cron.liveRunOverlayChanged, change)` and is the only code that calls `broker.ingestRuntimeEvent`. Keep the existing raw `chat:runtime-event` listener unchanged so legacy and image-generation consumers still receive the original event exactly once.
|
||||
|
||||
`createCronApi({ gatewayManager, cronLiveRunBroker })` must return `liveRunOverlays: () => cronLiveRunBroker.getSnapshotSet()` for late join/reload hydration.
|
||||
|
||||
- [ ] **Step 5: Run boundary regressions**
|
||||
|
||||
Run the focused tests from Step 2 plus:
|
||||
|
||||
```bash
|
||||
pnpm run typecheck:node
|
||||
pnpm run typecheck:web
|
||||
```
|
||||
|
||||
Expect all tests and both type-check lanes to pass.
|
||||
|
||||
- [ ] **Step 6: Commit point**
|
||||
|
||||
If explicitly requested, commit as `feat: expose cron live overlays through host boundaries`.
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Add the revision-safe Renderer overlay store
|
||||
|
||||
**Files:**
|
||||
- Create: `src/stores/cron-live-run-overlay.ts`
|
||||
- Create: `tests/unit/cron-live-run-overlay-store.test.ts`
|
||||
- Modify: `tests/unit/host-events.test.ts`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `hostApi.cron.liveRunOverlays()` and `hostEvents.onCronLiveRunOverlayChanged()`.
|
||||
- Produces: `useCronLiveRunOverlayStore`, `ensureCronLiveRunOverlaySubscriptions`, `selectCronLiveRunsForSession`, and terminal-removal acknowledgement state.
|
||||
|
||||
- [ ] **Step 1: Write failing store tests**
|
||||
|
||||
Mock host-api and host-events and cover:
|
||||
|
||||
- subscribe-before-snapshot ordering;
|
||||
- ignoring an older snapshot after a newer change;
|
||||
- upsert by `canonicalSessionKey + runId`;
|
||||
- remove without retaining content as history;
|
||||
- bounded pending removals keyed by `canonicalSessionKey + runId + revision` so bursts cannot overwrite one another;
|
||||
- explicit `acknowledgeRemoval(revision)` that removes only the acknowledged change;
|
||||
- a burst where visible run A and inactive run B terminate before React processes either event;
|
||||
- selection by exact base cron key only;
|
||||
- gateway-reset and eviction removals never marked as terminal refreshes.
|
||||
|
||||
- [ ] **Step 2: Run and verify module-not-found failure**
|
||||
|
||||
Run `pnpm exec vitest run tests/unit/cron-live-run-overlay-store.test.ts`.
|
||||
|
||||
- [ ] **Step 3: Implement the store**
|
||||
|
||||
Keep only normalized snapshots and at most 128 pending removal changes ordered by revision. Key removals by `canonicalSessionKey + runId + revision`; never overwrite another run's terminal signal. Do not import ACP reducer/timeline modules or `ChatRuntimeEvent`. `ensureCronLiveRunOverlaySubscriptions` must be idempotent, register the event listener first, then request the Main snapshot, and compare revisions before applying either source.
|
||||
|
||||
- [ ] **Step 4: Run focused tests and Web type checking**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
pnpm exec vitest run \
|
||||
tests/unit/cron-live-run-overlay-store.test.ts \
|
||||
tests/unit/host-events.test.ts
|
||||
pnpm run typecheck:web
|
||||
```
|
||||
|
||||
Expect all tests to pass with no ACP imports in the new store.
|
||||
|
||||
- [ ] **Step 5: Commit point**
|
||||
|
||||
If explicitly requested, commit as `feat: add cron live overlay renderer store`.
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Build the explicit, read-only live overlay UI
|
||||
|
||||
**Files:**
|
||||
- Create: `src/pages/Chat/CronLiveRunOverlay.tsx`
|
||||
- Create: `tests/unit/cron-live-run-overlay.test.tsx`
|
||||
- Modify: `shared/i18n/locales/en/chat.json`
|
||||
- Modify: `shared/i18n/locales/zh/chat.json`
|
||||
- Modify: `shared/i18n/locales/ja/chat.json`
|
||||
- Modify: `shared/i18n/locales/ru/chat.json`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: One `CronLiveRunOverlaySnapshot`.
|
||||
- Produces: A clearly labeled transient panel with `data-testid="cron-live-run-overlay"` and item-specific test IDs.
|
||||
|
||||
- [ ] **Step 1: Write failing component tests**
|
||||
|
||||
Cover the localized “Live scheduled run” header, running pulse, assistant Markdown, thinking indicator without raw thought text, tool status progression, whitespace-preserving command output, patch counts, read-only approval status, and distinct test IDs (`cron-live-tool`, `cron-live-command`, `cron-live-patch`, `cron-live-approval`).
|
||||
|
||||
- [ ] **Step 2: Run and verify failure**
|
||||
|
||||
Run `pnpm exec vitest run tests/unit/cron-live-run-overlay.test.tsx`.
|
||||
|
||||
- [ ] **Step 3: Implement the presentation component**
|
||||
|
||||
Reuse `AcpRenderPart` only as a Markdown renderer for assistant text; do not create ACP message/tool items. Implement dedicated cron item rows so they cannot be mistaken for native ACP cards or interactive ACP permissions. Use `bg-surface-modal`, `bg-surface-input`, selected/status token substitutions, and `text-X-700 dark:text-X-400` status colors from `globals.css`.
|
||||
|
||||
- [ ] **Step 4: Add complete locale coverage and regressions**
|
||||
|
||||
Add labels for the panel, running/thinking, tool/command/patch/approval status, completion/failure wording, and read-only approval explanation in all four locale files. Run:
|
||||
|
||||
```bash
|
||||
pnpm exec vitest run tests/unit/cron-live-run-overlay.test.tsx
|
||||
pnpm run typecheck:web
|
||||
pnpm run lint:check
|
||||
```
|
||||
|
||||
Expect the component test, type check, and lint check to pass.
|
||||
|
||||
- [ ] **Step 5: Commit point**
|
||||
|
||||
If explicitly requested, commit as `feat: render transient cron run progress`.
|
||||
|
||||
---
|
||||
|
||||
### Task 7: Compose the overlay with ACP Chat and refresh authoritative history
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/pages/Chat/index.tsx`
|
||||
- Modify: `tests/unit/chat-acp-page.test.tsx`
|
||||
- Modify: `tests/unit/cron-live-run-overlay-store.test.ts`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: Current base session key, overlay snapshots/removal markers, ACP `loadSession`, and workspace context.
|
||||
- Produces: ACP timeline plus separate live panels; one authoritative reload for a visible terminal run.
|
||||
|
||||
- [ ] **Step 1: Write failing page integration tests**
|
||||
|
||||
Cover:
|
||||
|
||||
- overlay replaces `AcpEmptyState` while history is empty;
|
||||
- ACP timeline and overlay coexist as sibling DOM regions;
|
||||
- overlay content never appears under `data-testid="acp-chat-timeline"`;
|
||||
- another cron job or ordinary session does not render the overlay;
|
||||
- multiple active snapshots render in deterministic order;
|
||||
- switching away hides the overlay and switching back restores the Main snapshot;
|
||||
- external cron activity does not set `ChatInput.sending`, show ACP Stop, or call `cancelAcpSession`;
|
||||
- a terminal `remove` for a run that was visible triggers exactly one `loadAcpSession`;
|
||||
- a burst of removals for two runs preserves and acknowledges both revisions while refreshing only runs visible in the current session;
|
||||
- terminal removal while another session is selected does not trigger a delayed duplicate reload when returning later;
|
||||
- `evicted` and `gateway-reset` removals do not trigger authoritative reloads.
|
||||
|
||||
- [ ] **Step 2: Run and verify integration failures**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
pnpm exec vitest run \
|
||||
tests/unit/chat-acp-page.test.tsx \
|
||||
tests/unit/cron-live-run-overlay-store.test.ts
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Integrate subscriptions and rendering**
|
||||
|
||||
Initialize the overlay subscription alongside `ensureAcpChatSubscriptions`. Select snapshots for `currentSessionKey`, render them after the authoritative `AcpTimeline`, and suppress `AcpEmptyState` while at least one overlay is visible. Include overlay presence in scroll-to-latest calculations.
|
||||
|
||||
- [ ] **Step 4: Implement visible-run terminal refresh**
|
||||
|
||||
Track run IDs actually rendered for the current session in a ref that resets on session switch. Process pending removals in revision order. When an unacknowledged removal has `reason: 'ended'`, matches the current base session, and its run ID was rendered there, acknowledge that exact revision and call normal `loadAcpSession({ sessionKey, workspaceRoot: cwd, cwd })` once. Acknowledge non-visible/stale removals without reload. Do not collapse multiple removals into one marker, call legacy `loadHistory`, mutate the ACP snapshot, or synthesize a generation.
|
||||
|
||||
- [ ] **Step 5: Run focused UI and state regressions**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
pnpm exec vitest run \
|
||||
tests/unit/chat-acp-page.test.tsx \
|
||||
tests/unit/cron-live-run-overlay.test.tsx \
|
||||
tests/unit/cron-live-run-overlay-store.test.ts \
|
||||
tests/unit/acp-chat-store.test.ts \
|
||||
tests/unit/gateway-events.test.ts
|
||||
pnpm run typecheck
|
||||
```
|
||||
|
||||
Expect all existing ACP prompt, image-generation, cancellation, and runtime retention tests to remain green.
|
||||
|
||||
- [ ] **Step 6: Commit point**
|
||||
|
||||
If explicitly requested, commit as `feat: compose cron live overlay with ACP Chat`.
|
||||
|
||||
---
|
||||
|
||||
### Task 8: Replace synthetic-ACP E2E coverage, update docs, and run communication proof
|
||||
|
||||
**Files:**
|
||||
- Modify: `tests/e2e/cron-run-live-status.spec.ts`
|
||||
- Modify: `README.md`
|
||||
- Modify: `README.zh-CN.md`
|
||||
- Modify: `README.ja-JP.md`
|
||||
- Modify: `harness/specs/tasks/render-cron-run-live-status.md`
|
||||
- Modify: `harness/reference/acp-cron-live-overlay.md`
|
||||
- Modify: `harness/reference/acp-chat.md`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: The completed Main broker, typed host event, snapshot API, Renderer store, and overlay UI.
|
||||
- Produces: User-visible regression proof and synchronized architecture documentation.
|
||||
|
||||
- [ ] **Step 1: Rewrite E2E helpers and expectations**
|
||||
|
||||
Remove fake `chat:acp-session-update` tool calls from the live cron scenarios. Add a helper that emits typed `cron:live-run-overlay-changed` upsert/remove changes and mock `cron.liveRunOverlays` for late join. Main broker reduction is covered by `cron-live-run-broker.test.ts`; E2E covers the real preload/host-event/Renderer/UI contract.
|
||||
|
||||
Verify assistant text, thinking status, tool, command, patch, and approval rows; no legacy execution graph; no runtime content inside ACP timeline; no invalid Stop state; hide/restore across session switches; mid-flight overlay hydration; terminal removal; and one authoritative `loadAcpSession` invocation.
|
||||
|
||||
- [ ] **Step 2: Run the focused Electron E2E**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
pnpm run build:vite
|
||||
pnpm exec playwright test tests/e2e/cron-run-live-status.spec.ts
|
||||
```
|
||||
|
||||
Expect the spec to pass on the local platform.
|
||||
|
||||
- [ ] **Step 3: Update user and architecture documentation**
|
||||
|
||||
In all three required READMEs, state that running cron progress is a transient Gateway-backed overlay, completed conversation content remains ACP/cron-history authoritative, and external cron activity does not become an ACP-cancellable prompt. Keep the explanation concise and localized.
|
||||
|
||||
Update Harness references to document the exact bounds, revision race handling, no-CoT rule, terminal reload semantics, OpenClaw upgrade removal condition, and the prohibition against extending this exception to ordinary non-cron messages.
|
||||
|
||||
- [ ] **Step 4: Run the focused and project-wide safe validation suite**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
pnpm exec vitest run \
|
||||
tests/unit/harness-specs.test.ts \
|
||||
tests/unit/cron-session-utils.test.ts \
|
||||
tests/unit/gateway-event-dispatch.test.ts \
|
||||
tests/unit/cron-live-run-broker.test.ts \
|
||||
tests/unit/cron-live-run-overlay-store.test.ts \
|
||||
tests/unit/cron-live-run-overlay.test.tsx \
|
||||
tests/unit/cron-schedule.test.ts \
|
||||
tests/unit/host-events.test.ts \
|
||||
tests/unit/host-api-facade.test.ts \
|
||||
tests/unit/host-services.test.ts \
|
||||
tests/unit/chat-acp-page.test.tsx \
|
||||
tests/unit/acp-chat-store.test.ts \
|
||||
tests/unit/acp-image-generation-compat.test.ts \
|
||||
tests/unit/gateway-events.test.ts
|
||||
pnpm run typecheck
|
||||
pnpm run lint:check
|
||||
pnpm run build:vite
|
||||
pnpm exec playwright test tests/e2e/cron-run-live-status.spec.ts
|
||||
pnpm run comms:replay
|
||||
pnpm run comms:compare
|
||||
pnpm harness validate --spec harness/specs/tasks/render-cron-run-live-status.md
|
||||
pnpm harness run --spec harness/specs/tasks/render-cron-run-live-status.md
|
||||
pnpm run harness:ci
|
||||
```
|
||||
|
||||
Expected result: all focused tests, type checking, lint, build, E2E, communication regression comparison, task Harness run, and Harness CI pass. Re-run `pnpm run lint:check` only after any concurrent uv download has completed if the documented temporary-directory race occurs.
|
||||
|
||||
- [ ] **Step 5: Review the removal condition**
|
||||
|
||||
Record in `acp-cron-live-overlay.md` that the overlay can be deleted only after a distributed OpenClaw package proves all of these through integration tests: loaded ACP sessions receive autonomous cron assistant/thought/tool updates, generated media arrives as standard ACP content blocks, replay is complete and deduplicated, and external-run lifecycle/cancel semantics are explicitly exposed.
|
||||
|
||||
- [ ] **Step 6: Commit point**
|
||||
|
||||
If explicitly requested, commit as `test: cover authoritative cron live overlay flow`.
|
||||
|
||||
---
|
||||
|
||||
## Final Self-Review Checklist
|
||||
|
||||
- [ ] No Gateway runtime event is converted to an ACP update or inserted into `AcpTimelineSnapshot`.
|
||||
- [ ] Main owns strict cron identity, event reduction, deduplication, bounds, snapshots, and revisions.
|
||||
- [ ] Sequence-less and repeated terminal events cannot duplicate or resurrect runs.
|
||||
- [ ] Renderer shows only current base-cron overlays and never raw chain-of-thought.
|
||||
- [ ] ACP prompt sending, Stop, cancellation, permission response, replay, and image compatibility behavior remain unchanged.
|
||||
- [ ] Terminal refresh occurs once only for a run that was visible in the currently selected session.
|
||||
- [ ] E2E no longer claims live behavior by injecting synthetic ACP tool notifications.
|
||||
- [ ] Harness specs and all required README translations describe the same authority boundary.
|
||||
- [ ] No placeholders, compatibility re-exports, direct IPC invokes, Gateway HTTP calls, or undocumented protocol fallbacks remain.
|
||||
@@ -53,13 +53,15 @@ export function normalizeGatewayChatRuntimeEvent(payload: unknown): ChatRuntimeE
|
||||
: null;
|
||||
}
|
||||
|
||||
if (phase === 'completed' || phase === 'done' || phase === 'finished') {
|
||||
if (phase === 'end' || phase === 'completed' || phase === 'done' || phase === 'finished') {
|
||||
const base = withBase('run.ended', raw);
|
||||
const aborted = phase === 'end' && data.aborted === true;
|
||||
return base
|
||||
? {
|
||||
...base,
|
||||
status: 'completed',
|
||||
status: aborted ? 'aborted' : 'completed',
|
||||
endedAt: readNumber(data.endedAt),
|
||||
...(aborted ? { error: readString(data.error) } : {}),
|
||||
livenessState: readString(data.livenessState),
|
||||
replayInvalid: typeof data.replayInvalid === 'boolean' ? data.replayInvalid : undefined,
|
||||
stopReason: readString(data.stopReason),
|
||||
@@ -90,6 +92,8 @@ export function normalizeGatewayChatRuntimeEvent(payload: unknown): ChatRuntimeE
|
||||
status: 'aborted',
|
||||
endedAt: readNumber(data.endedAt),
|
||||
error: readString(data.error),
|
||||
livenessState: readString(data.livenessState),
|
||||
replayInvalid: typeof data.replayInvalid === 'boolean' ? data.replayInvalid : undefined,
|
||||
stopReason: readString(data.stopReason),
|
||||
}
|
||||
: null;
|
||||
|
||||
@@ -5,7 +5,12 @@
|
||||
import { app, BrowserWindow, nativeImage, session, shell, type Session } from 'electron';
|
||||
import { join } from 'path';
|
||||
import { GatewayManager } from '../gateway/manager';
|
||||
import {
|
||||
bindCronLiveRunBroker,
|
||||
CronLiveRunBroker,
|
||||
} from '../services/cron-live-run-broker';
|
||||
import { registerIpcHandlers } from './ipc-handlers';
|
||||
import { HOST_EVENT_CHANNELS } from '@shared/host-events/contract';
|
||||
import { HostApiRegistry } from './ipc/host-invoke';
|
||||
import { createTray } from './tray';
|
||||
import { createMenu } from './menu';
|
||||
@@ -133,6 +138,7 @@ const gotTheLock = gotElectronLock && gotFileLock;
|
||||
// Global references
|
||||
let mainWindow: BrowserWindow | null = null;
|
||||
let gatewayManager!: GatewayManager;
|
||||
let cronLiveRunBroker!: CronLiveRunBroker;
|
||||
let clawHubService!: ClawHubService;
|
||||
const hostApiRegistry = new HostApiRegistry();
|
||||
const webBrowserGuestRegistry = new WebBrowserGuestRegistry();
|
||||
@@ -372,6 +378,7 @@ async function initialize(): Promise<void> {
|
||||
// Register IPC handlers
|
||||
registerIpcHandlers(
|
||||
gatewayManager,
|
||||
cronLiveRunBroker,
|
||||
clawHubService,
|
||||
window,
|
||||
hostApiRegistry,
|
||||
@@ -506,6 +513,14 @@ async function initialize(): Promise<void> {
|
||||
sendMainWindowEvent('gateway:exit', { code });
|
||||
});
|
||||
|
||||
bindCronLiveRunBroker({
|
||||
gatewayManager,
|
||||
broker: cronLiveRunBroker,
|
||||
publishChange: (change) => {
|
||||
sendMainWindowEvent(HOST_EVENT_CHANNELS.cron.liveRunOverlayChanged, change);
|
||||
},
|
||||
});
|
||||
|
||||
deviceOAuthManager.on('oauth:code', (payload) => {
|
||||
sendMainWindowEvent('oauth:code', payload);
|
||||
});
|
||||
@@ -604,6 +619,7 @@ if (gotTheLock) {
|
||||
}
|
||||
|
||||
gatewayManager = new GatewayManager();
|
||||
cronLiveRunBroker = new CronLiveRunBroker();
|
||||
clawHubService = new ClawHubService();
|
||||
|
||||
// Register builtin extensions and load manifest
|
||||
|
||||
@@ -61,6 +61,7 @@ import { AcpSessionAccessRegistry } from '../services/acp-session-access-registr
|
||||
import { createAttachmentAccess, StagedAttachmentRegistry } from '../services/attachment-access';
|
||||
import { createAttachmentOpenWithService } from '../services/attachment-open-with';
|
||||
import { createCronApi } from '../services/cron-api';
|
||||
import type { CronLiveRunBroker } from '../services/cron-live-run-broker';
|
||||
import { createFilesApi } from '../services/files-api';
|
||||
import { createMediaApi } from '../services/media-api';
|
||||
import { createProvidersApi } from '../services/providers-api';
|
||||
@@ -85,6 +86,7 @@ const gatewayRpcBackpressure = new GatewayRpcBackpressure();
|
||||
*/
|
||||
export function registerIpcHandlers(
|
||||
gatewayManager: GatewayManager,
|
||||
cronLiveRunBroker: CronLiveRunBroker,
|
||||
clawHubService: ClawHubService,
|
||||
mainWindow: BrowserWindow,
|
||||
hostApiRegistry: HostApiRegistry,
|
||||
@@ -97,6 +99,7 @@ export function registerIpcHandlers(
|
||||
// Typed host invoke handlers (new renderer facade; legacy channels remain available)
|
||||
registerTypedHostHandlers(
|
||||
gatewayManager,
|
||||
cronLiveRunBroker,
|
||||
clawHubService,
|
||||
mainWindow,
|
||||
hostApiRegistry,
|
||||
@@ -143,6 +146,7 @@ export function registerIpcHandlers(
|
||||
|
||||
function registerTypedHostHandlers(
|
||||
gatewayManager: GatewayManager,
|
||||
cronLiveRunBroker: CronLiveRunBroker,
|
||||
clawHubService: ClawHubService,
|
||||
mainWindow: BrowserWindow,
|
||||
hostApiRegistry: HostApiRegistry,
|
||||
@@ -180,7 +184,7 @@ function registerTypedHostHandlers(
|
||||
media: createMediaApi({ attachmentAccess }),
|
||||
sessions: createSessionsApi(),
|
||||
chat: createChatApi({ gatewayManager, mainWindow, acpSessionAccessRegistry }),
|
||||
cron: createCronApi({ gatewayManager }),
|
||||
cron: createCronApi({ gatewayManager, cronLiveRunBroker }),
|
||||
skills: createSkillsApi({ clawHubService, gatewayManager }),
|
||||
usage: createUsageApi(),
|
||||
});
|
||||
|
||||
@@ -2,8 +2,10 @@ import { readFile } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import type { CompleteHostServiceRegistry } from '../main/ipc/host-contract';
|
||||
import type { RawMessage } from '@shared/chat/types';
|
||||
import { parseCronSessionKey, type CronSessionKeyParts } from '@shared/chat/cron-session';
|
||||
import type { CronJob, CronJobDelivery, CronSchedule } from '@shared/types/cron';
|
||||
import type { GatewayManager } from '../gateway/manager';
|
||||
import type { CronLiveRunBroker } from './cron-live-run-broker';
|
||||
import { getOpenClawConfigDir } from '../utils/paths';
|
||||
import { resolveAgentIdFromChannel } from '../utils/agent-config';
|
||||
import { toOpenClawChannelType, toUiChannelType } from '../utils/channel-alias';
|
||||
@@ -47,12 +49,6 @@ interface CronRunLogEntry {
|
||||
provider?: string;
|
||||
}
|
||||
|
||||
interface CronSessionKeyParts {
|
||||
agentId: string;
|
||||
jobId: string;
|
||||
runSessionId?: string;
|
||||
}
|
||||
|
||||
interface CronSessionFallbackMessage {
|
||||
id: string;
|
||||
role: 'user' | 'assistant';
|
||||
@@ -64,20 +60,6 @@ interface CronSessionFallbackMessage {
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
const OPENCLAW_CRON_SUMMARY_TRUNCATION_MIN_CHARS = 2_000;
|
||||
|
||||
function parseCronSessionKey(sessionKey: string): CronSessionKeyParts | null {
|
||||
if (!sessionKey.startsWith('agent:')) return null;
|
||||
const parts = sessionKey.split(':');
|
||||
if (parts.length < 4 || parts[2] !== 'cron') return null;
|
||||
const agentId = parts[1] || 'main';
|
||||
const jobId = parts[3];
|
||||
if (!jobId) return null;
|
||||
if (parts.length === 4) return { agentId, jobId };
|
||||
if (parts.length === 6 && parts[4] === 'run' && parts[5]) {
|
||||
return { agentId, jobId, runSessionId: parts[5] };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function normalizeTimestampMs(value: unknown): number | undefined {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
return value < 1e12 ? value * 1000 : value;
|
||||
@@ -579,8 +561,15 @@ function getId(payload: unknown): string {
|
||||
return id.trim();
|
||||
}
|
||||
|
||||
export function createCronApi({ gatewayManager }: { gatewayManager: GatewayManager }): CompleteHostServiceRegistry['cron'] {
|
||||
export function createCronApi({
|
||||
gatewayManager,
|
||||
cronLiveRunBroker,
|
||||
}: {
|
||||
gatewayManager: GatewayManager;
|
||||
cronLiveRunBroker: CronLiveRunBroker;
|
||||
}): CompleteHostServiceRegistry['cron'] {
|
||||
return {
|
||||
liveRunOverlays: () => cronLiveRunBroker.getSnapshotSet(),
|
||||
list: async () => listCronJobs(gatewayManager),
|
||||
create: async (payload) => {
|
||||
const input = payload;
|
||||
|
||||
@@ -0,0 +1,774 @@
|
||||
import { createHash, type Hash } from 'node:crypto';
|
||||
import type { ChatRuntimeEvent } from '../../shared/chat-runtime-events';
|
||||
import type { GatewayManager } from '../gateway/manager';
|
||||
import type {
|
||||
CronLiveRunItem,
|
||||
CronLiveRunOverlayChange,
|
||||
CronLiveRunOverlaySnapshot,
|
||||
CronLiveRunOverlaySnapshotSet,
|
||||
} from '../../shared/chat/cron-live-run';
|
||||
import {
|
||||
getCronSessionBaseKey,
|
||||
parseCronSessionKey,
|
||||
} from '../../shared/chat/cron-session';
|
||||
|
||||
interface ActiveCronLiveRun {
|
||||
snapshot: CronLiveRunOverlaySnapshot;
|
||||
fingerprintOrder: string[];
|
||||
fingerprints: Set<string>;
|
||||
}
|
||||
|
||||
export const MAX_CRON_LIVE_EVENT_FINGERPRINTS = 256;
|
||||
export const MAX_ACTIVE_CRON_LIVE_RUNS = 32;
|
||||
export const MAX_CRON_LIVE_ITEMS_PER_RUN = 128;
|
||||
export const MAX_CRON_LIVE_ASSISTANT_CHARS = 500_000;
|
||||
export const MAX_CRON_LIVE_ITEM_DETAIL_CHARS = 100_000;
|
||||
export const MAX_CRON_LIVE_TERMINAL_TOMBSTONES = 128;
|
||||
export const MAX_CRON_LIVE_TRAVERSAL_DEPTH = 64;
|
||||
export const MAX_CRON_LIVE_TRAVERSAL_NODES = 2_048;
|
||||
export const MAX_CRON_LIVE_TRAVERSAL_KEYS = 1_024;
|
||||
export const MAX_CRON_LIVE_TRAVERSAL_STRING_CHARS = 16_384;
|
||||
|
||||
const DEPTH_MARKER = '[Truncated:Depth]';
|
||||
const NODE_MARKER = '[Truncated:Nodes]';
|
||||
const KEY_MARKER = '[Truncated:Keys]';
|
||||
const PROPERTY_MARKER = '[Unserializable:Property]';
|
||||
const INVALID_DATE_MARKER = '[Invalid:Date]';
|
||||
const OUTPUT_MARKER = '[Truncated:Output]';
|
||||
|
||||
interface TraversalState {
|
||||
nodes: number;
|
||||
keys: number;
|
||||
}
|
||||
|
||||
type BoundedKeys = { keys: string[] } | { marker: string };
|
||||
|
||||
function stringMarker(length: number): string {
|
||||
return `[Truncated:String:${length}]`;
|
||||
}
|
||||
|
||||
function truncateTraversalString(value: string): string {
|
||||
if (value.length <= MAX_CRON_LIVE_TRAVERSAL_STRING_CHARS) return value;
|
||||
const marker = stringMarker(value.length);
|
||||
return `${value.slice(0, MAX_CRON_LIVE_TRAVERSAL_STRING_CHARS - marker.length)}${marker}`;
|
||||
}
|
||||
|
||||
function enterTraversalNode(state: TraversalState, depth: number): string | undefined {
|
||||
if (depth > MAX_CRON_LIVE_TRAVERSAL_DEPTH) return DEPTH_MARKER;
|
||||
state.nodes += 1;
|
||||
return state.nodes > MAX_CRON_LIVE_TRAVERSAL_NODES ? NODE_MARKER : undefined;
|
||||
}
|
||||
|
||||
function collectBoundedKeys(value: object, state: TraversalState): BoundedKeys {
|
||||
const keys: string[] = [];
|
||||
let scanned = 0;
|
||||
try {
|
||||
for (const key in value) {
|
||||
scanned += 1;
|
||||
if (scanned > MAX_CRON_LIVE_TRAVERSAL_KEYS || state.keys >= MAX_CRON_LIVE_TRAVERSAL_KEYS) {
|
||||
return { marker: KEY_MARKER };
|
||||
}
|
||||
if (!Object.prototype.hasOwnProperty.call(value, key)) continue;
|
||||
if (key.length > MAX_CRON_LIVE_TRAVERSAL_STRING_CHARS) {
|
||||
return { marker: stringMarker(key.length) };
|
||||
}
|
||||
state.keys += 1;
|
||||
keys.push(key);
|
||||
}
|
||||
} catch {
|
||||
return { marker: PROPERTY_MARKER };
|
||||
}
|
||||
keys.sort((left, right) => left < right ? -1 : left > right ? 1 : 0);
|
||||
return { keys };
|
||||
}
|
||||
|
||||
function readProperty(value: object, key: string): { value: unknown } | { marker: string } {
|
||||
try {
|
||||
return { value: (value as Record<string, unknown>)[key] };
|
||||
} catch {
|
||||
return { marker: PROPERTY_MARKER };
|
||||
}
|
||||
}
|
||||
|
||||
function hashToken(hash: Hash, value: string): void {
|
||||
hash.update(String(value.length));
|
||||
hash.update(':');
|
||||
hash.update(value);
|
||||
hash.update(';');
|
||||
}
|
||||
|
||||
function hashUnknown(
|
||||
hash: Hash,
|
||||
value: unknown,
|
||||
state: TraversalState,
|
||||
seen: Map<object, number>,
|
||||
depth = 0,
|
||||
): void {
|
||||
const valueType = typeof value;
|
||||
if (value === null || valueType !== 'object') {
|
||||
const marker = enterTraversalNode(state, depth);
|
||||
if (marker) {
|
||||
hashToken(hash, marker);
|
||||
return;
|
||||
}
|
||||
if (valueType === 'string') {
|
||||
hashToken(hash, `string:${truncateTraversalString(value as string)}`);
|
||||
} else if (valueType === 'bigint') {
|
||||
hashToken(hash, '[Unsupported:bigint]');
|
||||
} else if (valueType === 'number' || valueType === 'boolean' || valueType === 'undefined') {
|
||||
hashToken(hash, `${valueType}:${String(value)}`);
|
||||
} else {
|
||||
hashToken(hash, `[Unsupported:${valueType}]`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const objectValue = value as object;
|
||||
const seenId = seen.get(objectValue);
|
||||
if (seenId !== undefined) {
|
||||
hashToken(hash, `ref:${seenId}`);
|
||||
return;
|
||||
}
|
||||
const marker = enterTraversalNode(state, depth);
|
||||
if (marker) {
|
||||
hashToken(hash, marker);
|
||||
return;
|
||||
}
|
||||
seen.set(objectValue, seen.size);
|
||||
|
||||
if (value instanceof Date) {
|
||||
const time = value.getTime();
|
||||
hashToken(hash, Number.isFinite(time) ? `date:${value.toISOString()}` : INVALID_DATE_MARKER);
|
||||
return;
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
hashToken(hash, `array:${value.length}`);
|
||||
if (value.length > MAX_CRON_LIVE_TRAVERSAL_NODES - state.nodes) {
|
||||
hashToken(hash, NODE_MARKER);
|
||||
return;
|
||||
}
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
const property = readProperty(value, String(index));
|
||||
if ('marker' in property) {
|
||||
hashToken(hash, property.marker);
|
||||
} else {
|
||||
hashUnknown(hash, property.value, state, seen, depth + 1);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const boundedKeys = collectBoundedKeys(objectValue, state);
|
||||
if ('marker' in boundedKeys) {
|
||||
hashToken(hash, boundedKeys.marker);
|
||||
return;
|
||||
}
|
||||
hashToken(hash, `object:${boundedKeys.keys.length}`);
|
||||
for (const key of boundedKeys.keys) {
|
||||
hashToken(hash, key);
|
||||
const property = readProperty(objectValue, key);
|
||||
if ('marker' in property) {
|
||||
hashToken(hash, property.marker);
|
||||
} else {
|
||||
hashUnknown(hash, property.value, state, seen, depth + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function runtimeEventFingerprint(event: ChatRuntimeEvent): string {
|
||||
try {
|
||||
const hash = createHash('sha256');
|
||||
hash.update(`${event.type}|`);
|
||||
const state: TraversalState = { nodes: 0, keys: 0 };
|
||||
const seen = new Map<object, number>();
|
||||
let fingerprintValue: unknown;
|
||||
|
||||
switch (event.type) {
|
||||
case 'run.started':
|
||||
fingerprintValue = event.startedAt;
|
||||
break;
|
||||
case 'run.ended':
|
||||
fingerprintValue = [event.status, event.endedAt, event.error, event.livenessState, event.replayInvalid, event.stopReason];
|
||||
break;
|
||||
case 'assistant.delta':
|
||||
fingerprintValue = [event.text, event.delta, event.replace, event.phase, event.mediaUrls];
|
||||
break;
|
||||
case 'thinking.delta':
|
||||
fingerprintValue = [event.text, event.delta];
|
||||
break;
|
||||
case 'tool.started':
|
||||
fingerprintValue = [event.toolCallId, event.name, event.args];
|
||||
break;
|
||||
case 'tool.updated':
|
||||
fingerprintValue = [event.toolCallId, event.name, event.partialResult];
|
||||
break;
|
||||
case 'tool.completed':
|
||||
fingerprintValue = [event.toolCallId, event.name, event.result, event.meta, event.isError];
|
||||
break;
|
||||
case 'command.output':
|
||||
fingerprintValue = [
|
||||
event.itemId,
|
||||
event.toolCallId,
|
||||
event.name,
|
||||
event.title,
|
||||
event.output,
|
||||
event.status,
|
||||
event.phase,
|
||||
event.exitCode,
|
||||
event.durationMs,
|
||||
event.cwd,
|
||||
];
|
||||
break;
|
||||
case 'patch.completed':
|
||||
fingerprintValue = [
|
||||
event.itemId,
|
||||
event.toolCallId,
|
||||
event.name,
|
||||
event.title,
|
||||
event.summary,
|
||||
event.added,
|
||||
event.modified,
|
||||
event.deleted,
|
||||
];
|
||||
break;
|
||||
case 'approval.updated':
|
||||
fingerprintValue = [
|
||||
event.itemId,
|
||||
event.toolCallId,
|
||||
event.title,
|
||||
event.kind,
|
||||
event.phase,
|
||||
event.status,
|
||||
event.message,
|
||||
];
|
||||
break;
|
||||
}
|
||||
|
||||
hashUnknown(hash, fingerprintValue, state, seen);
|
||||
return hash.digest('hex');
|
||||
} catch {
|
||||
return createHash('sha256').update(`${event.type}|[FingerprintError]`).digest('hex');
|
||||
}
|
||||
}
|
||||
|
||||
class LimitedStringWriter {
|
||||
private readonly chunks: string[] = [];
|
||||
private length = 0;
|
||||
private truncated = false;
|
||||
|
||||
constructor(private readonly limit: number) {}
|
||||
|
||||
get full(): boolean {
|
||||
return this.length >= this.limit;
|
||||
}
|
||||
|
||||
append(value: string): void {
|
||||
if (this.full) {
|
||||
this.truncated = true;
|
||||
return;
|
||||
}
|
||||
const available = this.limit - this.length;
|
||||
const chunk = value.slice(0, available);
|
||||
this.chunks.push(chunk);
|
||||
this.length += chunk.length;
|
||||
if (chunk.length < value.length) this.truncated = true;
|
||||
}
|
||||
|
||||
toString(): string {
|
||||
const rendered = this.chunks.join('');
|
||||
return this.truncated
|
||||
? `${rendered.slice(0, this.limit - OUTPUT_MARKER.length)}${OUTPUT_MARKER}`
|
||||
: rendered;
|
||||
}
|
||||
}
|
||||
|
||||
function writeJsonString(writer: LimitedStringWriter, value: string): void {
|
||||
writer.append('"');
|
||||
for (const character of value) {
|
||||
if (writer.full) return;
|
||||
writer.append(JSON.stringify(character).slice(1, -1));
|
||||
}
|
||||
writer.append('"');
|
||||
}
|
||||
|
||||
function writeStableJson(
|
||||
writer: LimitedStringWriter,
|
||||
value: unknown,
|
||||
depth: number,
|
||||
state: TraversalState,
|
||||
ancestors: WeakSet<object>,
|
||||
): void {
|
||||
if (writer.full) {
|
||||
writer.append('');
|
||||
return;
|
||||
}
|
||||
const marker = enterTraversalNode(state, depth);
|
||||
if (marker) {
|
||||
writeJsonString(writer, marker);
|
||||
return;
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
writeJsonString(writer, truncateTraversalString(value));
|
||||
return;
|
||||
}
|
||||
if (typeof value === 'bigint') {
|
||||
writeJsonString(writer, '[Unsupported:bigint]');
|
||||
return;
|
||||
}
|
||||
if (value === undefined) {
|
||||
writer.append('null');
|
||||
return;
|
||||
}
|
||||
if (value === null || typeof value !== 'object') {
|
||||
writer.append(JSON.stringify(value) ?? 'null');
|
||||
return;
|
||||
}
|
||||
if (ancestors.has(value)) {
|
||||
writeJsonString(writer, '[Circular]');
|
||||
return;
|
||||
}
|
||||
|
||||
ancestors.add(value);
|
||||
if (value instanceof Date) {
|
||||
const time = value.getTime();
|
||||
writeJsonString(writer, Number.isFinite(time) ? value.toISOString() : INVALID_DATE_MARKER);
|
||||
ancestors.delete(value);
|
||||
return;
|
||||
}
|
||||
const indent = ' '.repeat(depth + 1);
|
||||
const closingIndent = ' '.repeat(depth);
|
||||
if (Array.isArray(value)) {
|
||||
if (value.length > MAX_CRON_LIVE_TRAVERSAL_NODES - state.nodes) {
|
||||
writeJsonString(writer, NODE_MARKER);
|
||||
ancestors.delete(value);
|
||||
return;
|
||||
}
|
||||
writer.append('[');
|
||||
for (let index = 0; index < value.length && !writer.full; index += 1) {
|
||||
writer.append(`${index === 0 ? '\n' : ',\n'}${indent}`);
|
||||
const property = readProperty(value, String(index));
|
||||
if ('marker' in property) {
|
||||
writeJsonString(writer, property.marker);
|
||||
} else {
|
||||
writeStableJson(writer, property.value, depth + 1, state, ancestors);
|
||||
}
|
||||
}
|
||||
if (value.length > 0) writer.append(`\n${closingIndent}`);
|
||||
writer.append(']');
|
||||
} else {
|
||||
const boundedKeys = collectBoundedKeys(value, state);
|
||||
if ('marker' in boundedKeys) {
|
||||
writeJsonString(writer, boundedKeys.marker);
|
||||
ancestors.delete(value);
|
||||
return;
|
||||
}
|
||||
writer.append('{');
|
||||
let written = 0;
|
||||
for (const key of boundedKeys.keys) {
|
||||
if (writer.full) break;
|
||||
const property = readProperty(value, key);
|
||||
const child = 'marker' in property ? property.marker : property.value;
|
||||
if (child === undefined) continue;
|
||||
const index = written;
|
||||
written += 1;
|
||||
writer.append(`${index === 0 ? '\n' : ',\n'}${indent}`);
|
||||
writeJsonString(writer, key);
|
||||
writer.append(': ');
|
||||
if ('marker' in property) {
|
||||
writeJsonString(writer, property.marker);
|
||||
} else {
|
||||
writeStableJson(writer, child, depth + 1, state, ancestors);
|
||||
}
|
||||
}
|
||||
if (written > 0) writer.append(`\n${closingIndent}`);
|
||||
writer.append('}');
|
||||
}
|
||||
ancestors.delete(value);
|
||||
}
|
||||
|
||||
function truncateStart(value: string, limit = MAX_CRON_LIVE_ITEM_DETAIL_CHARS): string {
|
||||
return value.length <= limit ? value : value.slice(0, limit);
|
||||
}
|
||||
|
||||
function truncateEnd(value: string, limit: number): string {
|
||||
return value.length <= limit ? value : value.slice(-limit);
|
||||
}
|
||||
|
||||
function encodeTuple(parts: readonly string[]): string {
|
||||
return `${parts.length}|${parts.map((part) => `${part.length}:${part}`).join('')}`;
|
||||
}
|
||||
|
||||
function isBoundedIdentityComponent(value: unknown): value is string {
|
||||
return typeof value === 'string'
|
||||
&& value.length > 0
|
||||
&& value.length <= MAX_CRON_LIVE_ITEM_DETAIL_CHARS;
|
||||
}
|
||||
|
||||
function processIdentityComponent(event: ChatRuntimeEvent): string | undefined {
|
||||
if (event.type === 'tool.started' || event.type === 'tool.updated' || event.type === 'tool.completed') {
|
||||
return event.toolCallId;
|
||||
}
|
||||
if (event.type === 'command.output') {
|
||||
return event.itemId ?? event.toolCallId ?? event.name ?? 'command';
|
||||
}
|
||||
if (event.type === 'patch.completed') {
|
||||
return event.itemId ?? event.toolCallId ?? event.name ?? 'patch';
|
||||
}
|
||||
if (event.type === 'approval.updated') {
|
||||
return event.itemId ?? event.toolCallId ?? event.kind ?? 'approval';
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function hasBoundedEventIdentity(event: ChatRuntimeEvent): event is ChatRuntimeEvent & { sessionKey: string } {
|
||||
if (!isBoundedIdentityComponent(event.sessionKey) || !isBoundedIdentityComponent(event.runId)) return false;
|
||||
const itemIdentity = processIdentityComponent(event);
|
||||
return itemIdentity === undefined || isBoundedIdentityComponent(itemIdentity);
|
||||
}
|
||||
|
||||
function stableDetail(value: unknown): string | undefined {
|
||||
if (value === undefined) return undefined;
|
||||
if (typeof value === 'string') return truncateTraversalString(value);
|
||||
|
||||
try {
|
||||
const writer = new LimitedStringWriter(MAX_CRON_LIVE_ITEM_DETAIL_CHARS);
|
||||
writeStableJson(writer, value, 0, { nodes: 0, keys: 0 }, new WeakSet<object>());
|
||||
return writer.toString();
|
||||
} catch {
|
||||
return '[Unserializable]';
|
||||
}
|
||||
}
|
||||
|
||||
function upsertItem(
|
||||
items: CronLiveRunItem[],
|
||||
item: CronLiveRunItem,
|
||||
): void {
|
||||
const existingIndex = items.findIndex(({ id }) => id === item.id);
|
||||
if (existingIndex === -1) {
|
||||
items.push(item);
|
||||
if (items.length > MAX_CRON_LIVE_ITEMS_PER_RUN) items.splice(0, items.length - MAX_CRON_LIVE_ITEMS_PER_RUN);
|
||||
} else {
|
||||
items[existingIndex] = item;
|
||||
}
|
||||
}
|
||||
|
||||
function commandStatus(event: Extract<ChatRuntimeEvent, { type: 'command.output' }>): 'running' | 'completed' | 'failed' {
|
||||
if (event.status === 'failed' || event.status === 'error' || (event.exitCode != null && event.exitCode !== 0)) {
|
||||
return 'failed';
|
||||
}
|
||||
if (
|
||||
event.phase === 'end'
|
||||
|| event.phase === 'completed'
|
||||
|| event.status === 'completed'
|
||||
|| event.status === 'success'
|
||||
|| event.exitCode === 0
|
||||
) {
|
||||
return 'completed';
|
||||
}
|
||||
return 'running';
|
||||
}
|
||||
|
||||
function approvalStatus(event: Extract<ChatRuntimeEvent, { type: 'approval.updated' }>): 'running' | 'completed' | 'failed' {
|
||||
if (event.status === 'denied' || event.status === 'rejected' || event.status === 'failed' || event.status === 'error') {
|
||||
return 'failed';
|
||||
}
|
||||
if (
|
||||
event.phase === 'resolved'
|
||||
|| event.phase === 'completed'
|
||||
|| event.status === 'approved'
|
||||
|| event.status === 'granted'
|
||||
|| event.status === 'completed'
|
||||
) {
|
||||
return 'completed';
|
||||
}
|
||||
return 'running';
|
||||
}
|
||||
|
||||
function cloneSnapshot(snapshot: CronLiveRunOverlaySnapshot): CronLiveRunOverlaySnapshot {
|
||||
return {
|
||||
...snapshot,
|
||||
items: snapshot.items.map((item) => ({ ...item })),
|
||||
};
|
||||
}
|
||||
|
||||
function compareSnapshots(left: CronLiveRunOverlaySnapshot, right: CronLiveRunOverlaySnapshot): number {
|
||||
return left.updatedAt - right.updatedAt
|
||||
|| left.runId.localeCompare(right.runId)
|
||||
|| left.sourceSessionKey.localeCompare(right.sourceSessionKey);
|
||||
}
|
||||
|
||||
export function reduceCronLiveRunEvent(
|
||||
snapshot: CronLiveRunOverlaySnapshot,
|
||||
event: ChatRuntimeEvent,
|
||||
): CronLiveRunOverlaySnapshot {
|
||||
const next = cloneSnapshot(snapshot);
|
||||
next.updatedAt = event.ts ?? snapshot.updatedAt;
|
||||
|
||||
if (event.type === 'run.started') {
|
||||
next.startedAt = event.startedAt ?? next.startedAt;
|
||||
return next;
|
||||
}
|
||||
|
||||
if (event.type === 'assistant.delta') {
|
||||
if (event.text !== undefined) {
|
||||
next.assistantText = event.text;
|
||||
} else if (event.replace) {
|
||||
next.assistantText = event.delta ?? '';
|
||||
} else if (event.delta) {
|
||||
next.assistantText += event.delta;
|
||||
}
|
||||
next.assistantText = truncateEnd(next.assistantText, MAX_CRON_LIVE_ASSISTANT_CHARS);
|
||||
next.thinking = false;
|
||||
return next;
|
||||
}
|
||||
|
||||
if (event.type === 'thinking.delta') {
|
||||
next.thinking = true;
|
||||
return next;
|
||||
}
|
||||
|
||||
if (event.type === 'tool.started' || event.type === 'tool.updated' || event.type === 'tool.completed') {
|
||||
const id = encodeTuple([snapshot.runId, 'tool', event.toolCallId]);
|
||||
const existingItem = next.items.find((item) => item.id === id);
|
||||
const existing = existingItem?.kind === 'tool' ? existingItem : undefined;
|
||||
const inputText = event.type === 'tool.started' ? stableDetail(event.args) : existing?.inputText;
|
||||
const outputValue = event.type === 'tool.updated' ? event.partialResult : event.type === 'tool.completed' ? event.result : undefined;
|
||||
const outputText = outputValue === undefined ? existing?.outputText : stableDetail(outputValue);
|
||||
const error = event.type === 'tool.completed' && event.isError ? outputText : undefined;
|
||||
upsertItem(next.items, {
|
||||
kind: 'tool',
|
||||
id,
|
||||
toolCallId: event.toolCallId,
|
||||
title: truncateStart(event.name),
|
||||
status: event.type === 'tool.completed' ? (event.isError ? 'failed' : 'completed') : 'running',
|
||||
...(inputText === undefined ? {} : { inputText }),
|
||||
...(outputText === undefined ? {} : { outputText }),
|
||||
...(error === undefined ? {} : { error }),
|
||||
});
|
||||
return next;
|
||||
}
|
||||
|
||||
if (event.type === 'command.output') {
|
||||
const sourceId = event.itemId ?? event.toolCallId ?? event.name ?? 'command';
|
||||
const id = encodeTuple([snapshot.runId, 'command', sourceId]);
|
||||
const existingItem = next.items.find((item) => item.id === id);
|
||||
const existing = existingItem?.kind === 'command' ? existingItem : undefined;
|
||||
upsertItem(next.items, {
|
||||
kind: 'command',
|
||||
id,
|
||||
title: truncateStart(event.title ?? existing?.title ?? `${event.name ?? 'Command'} output`),
|
||||
status: commandStatus(event),
|
||||
output: truncateEnd(`${existing?.output ?? ''}${event.output ?? ''}`, MAX_CRON_LIVE_ITEM_DETAIL_CHARS),
|
||||
...(event.exitCode === undefined && existing?.exitCode === undefined
|
||||
? {}
|
||||
: { exitCode: event.exitCode ?? existing?.exitCode }),
|
||||
});
|
||||
return next;
|
||||
}
|
||||
|
||||
if (event.type === 'patch.completed') {
|
||||
const sourceId = event.itemId ?? event.toolCallId ?? event.name ?? 'patch';
|
||||
const id = encodeTuple([snapshot.runId, 'patch', sourceId]);
|
||||
upsertItem(next.items, {
|
||||
kind: 'patch',
|
||||
id,
|
||||
title: truncateStart(event.title ?? event.name ?? 'Patch'),
|
||||
...(event.summary === undefined ? {} : { summary: truncateStart(event.summary) }),
|
||||
...(event.added === undefined ? {} : { added: event.added }),
|
||||
...(event.modified === undefined ? {} : { modified: event.modified }),
|
||||
...(event.deleted === undefined ? {} : { deleted: event.deleted }),
|
||||
});
|
||||
return next;
|
||||
}
|
||||
|
||||
if (event.type === 'approval.updated') {
|
||||
const sourceId = event.itemId ?? event.toolCallId ?? event.kind ?? 'approval';
|
||||
const id = encodeTuple([snapshot.runId, 'approval', sourceId]);
|
||||
const existingItem = next.items.find((item) => item.id === id);
|
||||
const existing = existingItem?.kind === 'approval' ? existingItem : undefined;
|
||||
upsertItem(next.items, {
|
||||
kind: 'approval',
|
||||
id,
|
||||
title: truncateStart(event.title ?? existing?.title ?? 'Approval'),
|
||||
status: approvalStatus(event),
|
||||
...(event.message === undefined && existing?.message === undefined
|
||||
? {}
|
||||
: { message: truncateStart(event.message ?? existing?.message ?? '') }),
|
||||
});
|
||||
}
|
||||
|
||||
return next;
|
||||
}
|
||||
|
||||
export class CronLiveRunBroker {
|
||||
private readonly activeRuns = new Map<string, ActiveCronLiveRun>();
|
||||
private readonly terminalTombstones = new Set<string>();
|
||||
private readonly terminalTombstoneOrder: string[] = [];
|
||||
private revision = 0;
|
||||
|
||||
constructor(private readonly now: () => number = Date.now) {}
|
||||
|
||||
ingestRuntimeEvent(event: ChatRuntimeEvent): CronLiveRunOverlayChange[] {
|
||||
if (!hasBoundedEventIdentity(event)) return [];
|
||||
const parts = parseCronSessionKey(event.sessionKey);
|
||||
if (!parts?.runSessionId) return [];
|
||||
|
||||
const identity = encodeTuple([event.sessionKey, event.runId]);
|
||||
if (this.terminalTombstones.has(identity)) return [];
|
||||
|
||||
const active = this.activeRuns.get(identity);
|
||||
|
||||
if (active && Number.isFinite(event.seq) && event.seq! <= (active.snapshot.lastSeq ?? -Infinity)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (event.type === 'run.ended') {
|
||||
const changes: CronLiveRunOverlayChange[] = [];
|
||||
if (active) {
|
||||
this.revision += 1;
|
||||
changes.push({
|
||||
kind: 'remove',
|
||||
revision: this.revision,
|
||||
canonicalSessionKey: active.snapshot.canonicalSessionKey,
|
||||
sourceSessionKey: active.snapshot.sourceSessionKey,
|
||||
runId: active.snapshot.runId,
|
||||
reason: 'ended',
|
||||
terminalStatus: event.status,
|
||||
...(event.error === undefined ? {} : { terminalError: truncateStart(event.error) }),
|
||||
});
|
||||
this.activeRuns.delete(identity);
|
||||
}
|
||||
this.addTerminalTombstone(identity);
|
||||
return changes;
|
||||
}
|
||||
|
||||
let fingerprint: string | undefined;
|
||||
if (!Number.isFinite(event.seq)) {
|
||||
fingerprint = runtimeEventFingerprint(event);
|
||||
if (active?.fingerprints.has(fingerprint)) return [];
|
||||
}
|
||||
|
||||
const changes: CronLiveRunOverlayChange[] = [];
|
||||
if (!active && this.activeRuns.size >= MAX_ACTIVE_CRON_LIVE_RUNS) {
|
||||
const [evictedIdentity, evicted] = [...this.activeRuns.entries()]
|
||||
.sort(([, left], [, right]) => compareSnapshots(left.snapshot, right.snapshot))[0];
|
||||
this.revision += 1;
|
||||
changes.push({
|
||||
kind: 'remove',
|
||||
revision: this.revision,
|
||||
canonicalSessionKey: evicted.snapshot.canonicalSessionKey,
|
||||
sourceSessionKey: evicted.snapshot.sourceSessionKey,
|
||||
runId: evicted.snapshot.runId,
|
||||
reason: 'evicted',
|
||||
});
|
||||
this.activeRuns.delete(evictedIdentity);
|
||||
}
|
||||
|
||||
const current = active?.snapshot ?? {
|
||||
canonicalSessionKey: getCronSessionBaseKey(event.sessionKey),
|
||||
sourceSessionKey: event.sessionKey,
|
||||
runSessionId: parts.runSessionId,
|
||||
runId: event.runId,
|
||||
revision: this.revision,
|
||||
status: 'running',
|
||||
updatedAt: event.ts ?? this.now(),
|
||||
assistantText: '',
|
||||
thinking: false,
|
||||
items: [],
|
||||
} satisfies CronLiveRunOverlaySnapshot;
|
||||
|
||||
const next = reduceCronLiveRunEvent(current, event);
|
||||
next.updatedAt = event.ts ?? this.now();
|
||||
if (Number.isFinite(event.seq)) next.lastSeq = event.seq;
|
||||
this.revision += 1;
|
||||
next.revision = this.revision;
|
||||
const fingerprintOrder = active?.fingerprintOrder ?? [];
|
||||
const fingerprints = active?.fingerprints ?? new Set<string>();
|
||||
if (fingerprint) {
|
||||
fingerprintOrder.push(fingerprint);
|
||||
fingerprints.add(fingerprint);
|
||||
if (fingerprintOrder.length > MAX_CRON_LIVE_EVENT_FINGERPRINTS) {
|
||||
const removed = fingerprintOrder.shift();
|
||||
if (removed) fingerprints.delete(removed);
|
||||
}
|
||||
}
|
||||
this.activeRuns.set(identity, { snapshot: next, fingerprintOrder, fingerprints });
|
||||
|
||||
changes.push({
|
||||
kind: 'upsert',
|
||||
revision: this.revision,
|
||||
snapshot: cloneSnapshot(next),
|
||||
});
|
||||
return changes;
|
||||
}
|
||||
|
||||
getSnapshotSet(): CronLiveRunOverlaySnapshotSet {
|
||||
return {
|
||||
revision: this.revision,
|
||||
snapshots: [...this.activeRuns.values()]
|
||||
.map(({ snapshot }) => cloneSnapshot(snapshot))
|
||||
.sort(compareSnapshots),
|
||||
};
|
||||
}
|
||||
|
||||
clear(): CronLiveRunOverlayChange[] {
|
||||
const changes: CronLiveRunOverlayChange[] = [];
|
||||
const entries = [...this.activeRuns.entries()]
|
||||
.sort(([, left], [, right]) => compareSnapshots(left.snapshot, right.snapshot));
|
||||
for (const [identity, active] of entries) {
|
||||
this.revision += 1;
|
||||
changes.push({
|
||||
kind: 'remove',
|
||||
revision: this.revision,
|
||||
canonicalSessionKey: active.snapshot.canonicalSessionKey,
|
||||
sourceSessionKey: active.snapshot.sourceSessionKey,
|
||||
runId: active.snapshot.runId,
|
||||
reason: 'gateway-reset',
|
||||
});
|
||||
this.activeRuns.delete(identity);
|
||||
}
|
||||
return changes;
|
||||
}
|
||||
|
||||
private addTerminalTombstone(identity: string): void {
|
||||
this.terminalTombstones.add(identity);
|
||||
this.terminalTombstoneOrder.push(identity);
|
||||
if (this.terminalTombstoneOrder.length > MAX_CRON_LIVE_TERMINAL_TOMBSTONES) {
|
||||
const removed = this.terminalTombstoneOrder.shift();
|
||||
if (removed) this.terminalTombstones.delete(removed);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function bindCronLiveRunBroker({
|
||||
gatewayManager,
|
||||
broker,
|
||||
publishChange,
|
||||
}: {
|
||||
gatewayManager: GatewayManager;
|
||||
broker: CronLiveRunBroker;
|
||||
publishChange: (change: CronLiveRunOverlayChange) => void;
|
||||
}): void {
|
||||
let ingestionEnabled = true;
|
||||
const publishChanges = (changes: CronLiveRunOverlayChange[]) => {
|
||||
changes.forEach((change) => publishChange(change));
|
||||
};
|
||||
|
||||
gatewayManager.on('chat:runtime-event', (runtimeEvent) => {
|
||||
if (!ingestionEnabled) return;
|
||||
publishChanges(broker.ingestRuntimeEvent(runtimeEvent));
|
||||
});
|
||||
gatewayManager.on('status', (status) => {
|
||||
if (status.state === 'running') {
|
||||
ingestionEnabled = true;
|
||||
return;
|
||||
}
|
||||
ingestionEnabled = false;
|
||||
publishChanges(broker.clear());
|
||||
});
|
||||
gatewayManager.on('exit', () => {
|
||||
ingestionEnabled = false;
|
||||
publishChanges(broker.clear());
|
||||
});
|
||||
}
|
||||
@@ -1,12 +1,12 @@
|
||||
# ACP Chat Architecture And Timeline
|
||||
|
||||
Status: current architecture reference, reviewed 2026-07-15.
|
||||
Status: current architecture reference, reviewed 2026-08-05.
|
||||
|
||||
Related scenario: `acp-chat-experience`
|
||||
|
||||
Related rules: `acp-chat-state-and-history`, `attachment-access-safety`, `renderer-main-boundary`
|
||||
|
||||
Related tasks: `acp-native-chat`, `acp-media-attachments`, `filter-openclaw-heartbeat-session`
|
||||
Related tasks: `acp-native-chat`, `acp-media-attachments`, `filter-openclaw-heartbeat-session`, `render-cron-run-live-status`
|
||||
|
||||
## Ownership
|
||||
|
||||
@@ -21,6 +21,8 @@ session/update -> Main routing envelope -> Renderer reducer -> timeline -> React
|
||||
|
||||
Gateway remains responsible for non-Chat capabilities. Restricted Gateway host-event evidence may supplement asynchronous image-generation completion, but it is not a source for ordinary Chat messages or tool history.
|
||||
|
||||
The only live assistant/process exception is the bounded cron overlay documented in `harness/reference/acp-cron-live-overlay.md`. Main accepts strict run-scoped cron identities only and keeps Gateway progress in a memory-only view model rendered beside, never inside, the ACP timeline. This exception is prohibited for ordinary non-cron messages, channel sessions, heartbeats, historical replay, and arbitrary Gateway content.
|
||||
|
||||
## Identity And Race Protection
|
||||
|
||||
Renderer-visible session identity is the OpenClaw Gateway session key. Main may hold a different ACP session id returned by `newSession`; it rewrites downstream routing to the matching Gateway session key. Loads on the shared ACP connection are serialized. A routing envelope carries the session key and the Main-owned generation token for the matching load or live prompt. Renderer uses a separate local request sequence to reject stale load completions; preparing a local-only session must not advance the ACP generation. Renderer ignores updates, permission requests, and asynchronous hydration results whose session or generation matches neither the selected session nor a retained live prompt. Generation is an in-memory race token rather than a durable sequence; Main may restore the previous value when a load fails, so code must compare it together with session and current-operation state rather than assume global monotonicity.
|
||||
@@ -37,6 +39,8 @@ OpenClaw emits replay through ordinary `session/update` notifications and comple
|
||||
|
||||
There are exactly two approved transcript-derived content supplements. ClawX may recover asynchronous image-generation completions with proven `image_generate` context, and it may recover explicit line-leading assistant `MEDIA:` attachment directives omitted by OpenClaw ACP. Both are bounded, marked, memory-only projections. Separately, Main may extract metadata-only whole-turn timing because ACP replay omits original timestamps. Renderer can attach that timing only to an unambiguously matched ACP turn; it cannot reconstruct ordinary assistant text, thoughts, tool cards, plans, permissions, file activity, or missing turns. See `harness/reference/acp-generated-media-and-diagnostics.md#bounded-transcript-exceptions` for the content compatibility grammar and timing boundary.
|
||||
|
||||
The cron live overlay is not a transcript-derived supplement or a third history source. Its exact Main bounds are 32 active runs, 128 items per run, 500000 assistant characters, 100000 characters per item detail, 256 sequence-less fingerprints per run, and 128 terminal tombstones. Renderer subscribes to typed changes before fetching the snapshot and rejects older revisions, so late hydration cannot overwrite newer state. Raw thinking text is never retained or shown; only a localized activity indicator is allowed.
|
||||
|
||||
## Timeline Model
|
||||
|
||||
The Renderer keeps an in-memory `AcpTimelineSnapshot` with ordered item ids, item records, open message segments, tool and permission state, and ACP metadata. The exact TypeScript types in `src/lib/acp/` are authoritative; the stable conceptual item kinds are:
|
||||
@@ -91,6 +95,7 @@ Available attachment cards contain a primary semantic action with keyboard activ
|
||||
- The primary Chat view does not render the legacy Execution Graph.
|
||||
- A recoverable initial `reply was never sent` load failure may leave an empty new-chat page usable; prompt failures remain visible.
|
||||
- The working indicator follows the same sending state as the Stop action and supports reduced motion.
|
||||
- External cron activity never enters ACP sending/cancelling state or exposes ACP Stop, cancellation, or permission controls. When a terminal removal belongs to a run rendered in the currently selected base cron session, Renderer removes the overlay and calls normal `loadAcpSession` exactly once; hidden, evicted, gateway-reset, or already acknowledged removals cannot trigger a delayed reload. The resulting ACP replay, with typed cron-history fallback only when replay is empty, is the completed-content authority.
|
||||
- The question directory is derived only from active user message segments. Duplicate text remains separate, titles use the first non-empty Markdown part, and textless entries use a localized fallback. Fewer than two questions disables navigation. When open, the directory floats above the conversation without changing the chat column width. Selection scrolls smoothly to the current-snapshot anchor; a missing anchor is a safe no-op. The UI caps the directory at 300 recent entries and reports the hidden count when older entries are omitted.
|
||||
- Heartbeat-only desktop sessions are hidden only when the exact OpenClaw heartbeat sentinel is present and there is no real user content. A title such as `ClawX` or `main` is never sufficient. The guard applies to list, startup selection, refresh, and cached summary hydration without deleting OpenClaw history.
|
||||
|
||||
@@ -99,3 +104,5 @@ Available attachment cards contain a primary semantic action with keyboard activ
|
||||
Key tests live in `tests/unit/acp-*.test.*`, `tests/unit/acp-timeline-groups.test.ts`, `tests/unit/attachment-access.test.ts`, `tests/unit/chat-question-directory.test.tsx`, `tests/e2e/chat-acp-inline-timeline.spec.ts`, and `tests/e2e/chat-acp-attachments.spec.ts`.
|
||||
|
||||
This reference consolidates the former ACP native Chat, Chat polish, turn grouping, and question-directory design documents. Later implementation decisions supersede the original no-optimistic-message rule, the assumption that ACP id always equals Gateway session key, and segment-level assistant copy controls.
|
||||
|
||||
The cron broker and overlay may be removed only after a distributed OpenClaw package proves through integration tests that loaded ACP sessions receive autonomous cron assistant, thought, and tool updates; generated media arrives as standard ACP content blocks; replay is complete and deduplicated; and external-run lifecycle and cancellation semantics are explicitly exposed. Until all four conditions hold, Gateway progress remains a separate transient authority rather than synthetic ACP.
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
# ACP Cron Live Overlay
|
||||
|
||||
Status: approved architecture contract, reviewed 2026-08-05.
|
||||
|
||||
Related scenarios: `gateway-backend-communication`, `acp-chat-experience`
|
||||
|
||||
Related rules: `acp-chat-state-and-history`, `acp-compatibility-content-safety`, `renderer-main-boundary`, `host-api-fallback-policy`, `host-events-fallback-policy`, `ui-i18n-design-tokens`
|
||||
|
||||
Related task: `render-cron-run-live-status`
|
||||
|
||||
## Authority And Purpose
|
||||
|
||||
ACP `session/load` replay remains the primary authority for historical Chat content. When ACP replay for a cron session is empty, the existing typed cron-history fallback remains the only approved historical projection. Gateway runtime events are neither history nor ACP evidence for reconstructing history.
|
||||
|
||||
ClawX may expose current progress for an autonomous cron run through one narrow exception: a bounded, Main-owned, running-only overlay composed beside the ACP timeline. It exists only to bridge the upstream period in which autonomous cron activity emits useful Gateway runtime events but does not arrive as complete live ACP updates.
|
||||
|
||||
The normative flow is:
|
||||
|
||||
```text
|
||||
Gateway runtime event -> Main bounded cron broker -> explicit live overlay
|
||||
terminal event -> overlay removal -> authoritative ACP/cron-history reload
|
||||
```
|
||||
|
||||
The overlay is non-historical, memory-only, run-scoped, read-only, and excluded from sidebar unread/busy authority. It is not an `AcpTimelineSnapshot` supplement and cannot survive a terminal event, Gateway reset, broker eviction, process exit, or application restart.
|
||||
|
||||
## Admission And Identity
|
||||
|
||||
Main accepts only strict run-scoped cron keys shaped as `agent:<agentId>:cron:<jobId>:run:<runSessionId>`, with every identity segment non-empty after trimming. Ordinary sessions, base-only cron keys, channel sessions, malformed suffixes, and heartbeat `:main` events are rejected.
|
||||
|
||||
Main is the sole owner of cron key parsing and canonicalization. It maps an admitted run to its exact base cron key for selection while retaining the source run key and run identity. Renderer may select snapshots for the exact current base key, but it must not parse keys, adopt arbitrary runtime sessions, reduce Gateway events, choose transports, or implement protocol fallback.
|
||||
|
||||
Every process-item identity is namespaced by `runId`. Repeated `toolCallId`, `itemId`, command names, or approval fallback identities from different runs cannot collide.
|
||||
|
||||
## Main Broker Contract
|
||||
|
||||
The broker owns runtime-event normalization, type-specific deduplication, reduction, ordering, active snapshots, terminal tombstones, and all memory bounds. It may adopt a valid run mid-flight without observing `run.started`, but a terminal tombstone prevents delayed events from resurrecting a completed run only while that tombstone remains in the bounded FIFO. Gateway reset removals do not create terminal tombstones: the Main binding disables ingestion before clearing, ignores runtime events while disconnected or reconnecting, and re-enables ingestion on `running` so the same identity can be adopted again mid-flight.
|
||||
|
||||
The exact bounds are:
|
||||
|
||||
- `MAX_ACTIVE_CRON_LIVE_RUNS = 32`
|
||||
- `MAX_CRON_LIVE_ITEMS_PER_RUN = 128`
|
||||
- `MAX_CRON_LIVE_ASSISTANT_CHARS = 500_000`
|
||||
- `MAX_CRON_LIVE_ITEM_DETAIL_CHARS = 100_000`
|
||||
- `MAX_CRON_LIVE_EVENT_FINGERPRINTS = 256` per run
|
||||
- `MAX_CRON_LIVE_TERMINAL_TOMBSTONES = 128`
|
||||
|
||||
Numeric sequence values are monotonic per run; Main rejects `seq <= lastSeq`. Events without a sequence use bounded, type-specific fingerprints that reject exact repeats while preserving distinct incremental chunks. Structured details are serialized deterministically, tolerate cyclic input, and are truncated before entering the snapshot.
|
||||
|
||||
The overlay has only `running` status. Assistant text may be displayed, including bounded snapshot, replacement, and delta convergence. `thinking.delta` content is never retained or displayed; the view model exposes only a boolean that Renderer presents as a localized thinking indicator. Tool, command, patch, and approval items are bounded status rows. Approval rows are read-only and never call ACP permission-response APIs.
|
||||
|
||||
Terminal events produce a removal and delete the active snapshot. Renderer applies that removal to its overlay state before starting any authoritative history reload. Terminal content is never retained as a completed overlay. Gateway reset and deterministic capacity eviction also remove snapshots, but do not claim that authoritative history changed.
|
||||
|
||||
## Revision And Hydration Safety
|
||||
|
||||
Main emits a monotonically increasing broker revision for every upsert, removal, and clear. Every upsert snapshot carries the revision of its emitted change. Snapshot hydration returns the current broker revision even when no active snapshots exist.
|
||||
|
||||
Renderer subscribes to the typed change event before requesting the typed snapshot. It applies changes and hydration only when their revision is not older than the current store revision. This ordering prevents a late snapshot response, including an empty response, from replacing newer live events in the subscribe/snapshot revision race. Renderer bounds pending removals and acknowledges each removal by its exact revision so concurrent run completions cannot overwrite one another.
|
||||
|
||||
The supported boundary is:
|
||||
|
||||
```text
|
||||
GatewayManager -> Main cron live-run broker -> typed host event / typed host API
|
||||
Renderer overlay store -> explicit cron overlay component beside ACP timeline
|
||||
```
|
||||
|
||||
No page or component may invoke IPC directly, fetch Gateway HTTP, open a Gateway WebSocket, or switch between transports. Existing raw `chat:runtime-event` forwarding remains unchanged for the legacy runtime graph and image-generation compatibility consumers; broker ingestion is a separate Main listener and must not duplicate raw forwarding.
|
||||
|
||||
## ACP And UI Separation
|
||||
|
||||
Gateway runtime events must never be converted into `SessionNotification`, `AcpSessionUpdateEnvelope`, `TimelineItem`, or any other synthetic ACP value. `src/lib/acp/reducer.ts`, `src/lib/acp/timeline-types.ts`, and ACP replay semantics remain unchanged. The overlay is rendered as a sibling region and its content never appears inside the ACP timeline DOM.
|
||||
|
||||
External cron activity cannot set ACP `sending` or `cancelling`, show Stop, call `cancelAcpSession`, respond to ACP permissions, synthesize a generation, or mutate a retained live prompt. It also cannot create, clear, or reconcile sidebar busy or unread state; Gateway session rows remain the sole sidebar authority.
|
||||
|
||||
When a terminal removal identifies a run that was actually rendered for the currently selected base cron session, Renderer keeps that removal pending while an ACP prompt is sending or cancelling, then acknowledges the exact removal and invokes normal `loadAcpSession` exactly once after the ACP lifecycle and existing workspace/load coordination permit it. The resulting ACP replay is authoritative. Only if that replay is empty may the existing typed cron-history fallback populate historical content. A run removed while hidden, an already acknowledged removal, or a removal for `evicted` or `gateway-reset` does not create a delayed reload when the user later returns. Sequence-less and repeated terminal events are suppressed while the corresponding bounded FIFO tombstone is retained, so they cannot duplicate the reload or resurrect the run during that retention window.
|
||||
|
||||
The panel and every status label use `react-i18next` with English, Chinese, Japanese, and Russian coverage. Presentation follows `src/styles/globals.css`, including semantic modal/input surfaces, selected-state substitutions, paired light/dark status colors, accessible labels, and reduced-motion behavior. The live panel must be visibly distinct from native ACP cards and the removed legacy Execution Graph.
|
||||
|
||||
## Scope And Removal Condition
|
||||
|
||||
This exception cannot be generalized to ordinary non-cron messages, channel sessions, heartbeats, historical event replay, or arbitrary Gateway content. It must remain simpler to delete than to expand.
|
||||
|
||||
The overlay may be removed only after a distributed OpenClaw package proves through integration tests that loaded ACP sessions receive autonomous cron assistant, thought, and tool updates; generated media arrives as standard ACP content blocks; replay is complete and deduplicated; and external-run lifecycle and cancellation semantics are explicitly exposed. At that point ClawX should remove the broker and overlay rather than retain two live authorities.
|
||||
|
||||
## Validation Anchors
|
||||
|
||||
Contract validation begins with `tests/unit/harness-specs.test.ts`. Broker identity and reduction are covered by `tests/unit/cron-session-utils.test.ts`, `tests/unit/gateway-event-dispatch.test.ts`, and `tests/unit/cron-live-run-broker.test.ts`. Typed boundaries and revision-safe Renderer state are covered by `tests/unit/host-events.test.ts`, `tests/unit/host-api-facade.test.ts`, `tests/unit/host-services.test.ts`, and `tests/unit/cron-live-run-overlay-store.test.ts`. Presentation and ACP separation are covered by `tests/unit/cron-live-run-overlay.test.tsx`, `tests/unit/chat-acp-page.test.tsx`, and `tests/e2e/cron-run-live-status.spec.ts`.
|
||||
|
||||
Communication changes require type checking, lint, Vite build, the focused Electron E2E spec, `pnpm run comms:replay`, `pnpm run comms:compare`, real task-spec validation without `--no-diff`, a task Harness run, and Harness CI.
|
||||
@@ -12,4 +12,6 @@ Main owns ACP process, SDK, routing lifecycle, and serialization of operations o
|
||||
|
||||
ACP replay is the primary history authority. The only approved transcript-derived content supplements are best-effort recovery of asynchronous image-generation completions with proven `image_generate` context and recovery of explicit line-leading assistant OpenClaw `MEDIA:` attachment directives omitted by ACP. The general attachment exception does not require image-generation context, but it recovers only attachment references. When ACP replay for a cron session is completely empty, scheduled-task prompt and completion summaries may instead come from Main's typed cron-history host API. This cron exception must be anchored by Gateway `cron.runs` (with a Main-owned legacy file fallback), be generation-scoped and in memory, and never replace or duplicate non-empty ACP replay. When an anchored run summary carries OpenClaw's bounded-summary ellipsis, Main may recover that run's final assistant text from the identified run transcript only when it is longer and shares the complete persisted summary prefix; missing, mismatched, or unbounded summaries remain unchanged. A separate metadata-only supplement may annotate an ACP-replayed assistant turn with whole-turn duration because ACP `session/load` omits the original event timestamps; it cannot create turns or content. These exceptions remain marked and in memory; do not generalize them to bare paths, surrounding transcript prose, arbitrary ordinary messages, tool cards, plans, permissions, thoughts, file activity, or any parallel persisted history.
|
||||
|
||||
The sole live Gateway exception for cron assistant/process progress is a bounded, Main-reduced overlay for strict run-scoped cron keys. It is memory-only, running-only, read-only, and structurally separate from `SessionNotification`, `AcpSessionUpdateEnvelope`, `TimelineItem`, and `AcpTimelineSnapshot`; it cannot create or supplement history. A terminal event removes the overlay, after which a visible run may trigger exactly one ordinary ACP load or existing typed cron-history fallback. ACP replay remains the authority whenever it is non-empty. The overlay cannot mutate ACP sending, cancelling, Stop, permission, generation, or replay state, and it cannot drive sidebar busy or unread authority. Do not extend this exception to ordinary sessions, base-only cron keys, channels, or heartbeats. See `harness/reference/acp-cron-live-overlay.md`.
|
||||
|
||||
Historical transcript reads are limited to the newest `1000` message records. A successful live prompt reads content immediately and retries exactly once after `1500 ms`. General attachment and timing alignment treat history as a suffix and match the binary-free OpenClaw prompt-text projection of structured ACP user blocks by duplicate occurrence from the tail; they must not parse or globally remove user-authored resource marker text. Attachment-only empty projections remain eligible, and live content alignment also requires the current optimistic user identity. Every asynchronous result must retain the same active session, generation, supplement operation and attempt, and live turn where applicable. Unmatched, ambiguous, superseded, or stale work cannot mutate the timeline or timing annotations.
|
||||
|
||||
@@ -12,3 +12,5 @@ Standard ACP content is authoritative and preferred. A compatibility supplement
|
||||
Approved transcript evidence has three bounded forms: asynchronous image-generation completion with proven image-generation context, including explicit internal-UI `message` tool source replies; canonical persisted assistant `__openclaw.media` facts; and general attachment recovery from whole-line, line-leading assistant OpenClaw `MEDIA:` directives outside fenced code blocks. Canonical facts and directives accept only the documented local path, `file:`, execution-cwd-relative, HTTP, and HTTPS forms. Quoted directive references may contain spaces, while unquoted directives may not; canonical structured values may contain spaces. General recovery projects only ordered attachment references and declared media metadata, never surrounding transcript prose. A trusted image-generation source reply may provide user-facing completion or failure text. Reject malformed or wrapped directives, bare or inline prose paths without canonical media facts, unknown URI schemes, incidental tool paths, and unrelated assistant prose.
|
||||
|
||||
Compatibility logic must not reconstruct ordinary assistant messages, thoughts, tools, plans, permissions, file activity, or a parallel Chat history. User-side OpenClaw prompt projection may be reconstructed only from structured ACP content already present in the same timeline; generated-looking user prose is not evidence and must not be stripped or parsed. Unmatched or ambiguous evidence is skipped rather than attached by guesswork. Deduplication is turn-scoped and uses only a Main-authorized opaque identity; native ACP resource content wins over equivalent compatibility evidence, generated-image evidence remains inline, and an unavailable result does not block a later available upgrade.
|
||||
|
||||
A bounded live cron overlay is not compatibility ACP content. It may display current assistant text and read-only process status from strict run-scoped Gateway events only as a separately typed, memory-only, running-only view model. Raw thought text is prohibited, approval rows are never interactive ACP permissions, and no overlay value may be represented as a native or synthetic ACP event, inserted into the ACP timeline, persisted, or retained after terminal removal. Completed content must come from ordinary ACP replay or the existing typed cron-history fallback. This narrow exception must not become a route for ordinary messages, historical reconstruction, or replacement of standard ACP content. See `harness/reference/acp-cron-live-overlay.md`.
|
||||
|
||||
@@ -5,6 +5,8 @@ type: user-visible-flow
|
||||
ownedPaths:
|
||||
- shared/acp-chat/**
|
||||
- shared/host-api/contract.ts
|
||||
- shared/host-events/contract.ts
|
||||
- shared/chat/cron-live-run.ts
|
||||
- shared/file-preview/**
|
||||
- electron/services/acp-chat-service.ts
|
||||
- electron/services/acp-session-access-registry.ts
|
||||
@@ -27,6 +29,10 @@ ownedPaths:
|
||||
- tests/e2e/chat-acp-inline-timeline.spec.ts
|
||||
- tests/e2e/chat-acp-attachments.spec.ts
|
||||
- tests/e2e/chat-run-state-events.spec.ts
|
||||
- src/stores/cron-live-run-overlay.ts
|
||||
- tests/unit/cron-live-run-overlay-store.test.ts
|
||||
- tests/unit/cron-live-run-overlay.test.tsx
|
||||
- tests/e2e/cron-run-live-status.spec.ts
|
||||
requiredProfiles:
|
||||
- fast
|
||||
- comms
|
||||
@@ -52,4 +58,6 @@ ACP Chat covers session load, prompt, cancel, permission, replay, timeline reduc
|
||||
|
||||
Main owns ACP transport, routing, transcript retrieval and timing extraction, workspace grants, and session/generation-scoped attachment authorization. Renderer owns the in-memory timeline, bounded compatibility and timing alignment, attachment presentation, and display grouping, including user-image thumbnails and user-selected source-path labels. ACP replay remains authoritative for historical turns and content; transcript-derived timing may only annotate an unambiguously matched ACP turn. Standard ACP content remains preferred over compatibility projections, and incidental tool paths never enter the attachment pipeline.
|
||||
|
||||
The durable architecture, exceptions, access boundary, file-activity separation, Office preview behavior, and validation anchors are documented in `harness/reference/acp-chat.md`, `harness/reference/acp-generated-media-and-diagnostics.md`, `harness/reference/acp-attachment-access-control.md`, `harness/reference/openclaw-file-activity.md`, and `harness/reference/office-document-preview.md`.
|
||||
An externally triggered cron run may appear only as the separate, bounded, running-only overlay documented in `harness/reference/acp-cron-live-overlay.md`. Gateway runtime events never become ACP notifications or timeline items. The overlay is read-only and cannot own ACP sending, cancellation, permissions, replay, history, or sidebar attention; terminal content becomes visible only through an authoritative ACP or typed cron-history reload after overlay removal.
|
||||
|
||||
The durable architecture, exceptions, access boundary, file-activity separation, Office preview behavior, cron live-overlay boundary, and validation anchors are documented in `harness/reference/acp-chat.md`, `harness/reference/acp-generated-media-and-diagnostics.md`, `harness/reference/acp-attachment-access-control.md`, `harness/reference/openclaw-file-activity.md`, `harness/reference/office-document-preview.md`, and `harness/reference/acp-cron-live-overlay.md`.
|
||||
|
||||
@@ -33,6 +33,18 @@ ownedPaths:
|
||||
- tests/unit/web-browser-policy.test.ts
|
||||
- tests/unit/web-browser-session.test.ts
|
||||
- tests/unit/web-browser-api.test.ts
|
||||
- shared/chat/cron-session.ts
|
||||
- shared/chat/cron-live-run.ts
|
||||
- shared/host-api/contract.ts
|
||||
- shared/host-events/contract.ts
|
||||
- electron/services/cron-live-run-broker.ts
|
||||
- src/lib/host-events.ts
|
||||
- src/stores/cron-live-run-overlay.ts
|
||||
- src/pages/Chat/CronLiveRunOverlay.tsx
|
||||
- tests/unit/cron-live-run-broker.test.ts
|
||||
- tests/unit/cron-live-run-overlay-store.test.ts
|
||||
- tests/unit/cron-live-run-overlay.test.tsx
|
||||
- tests/e2e/cron-run-live-status.spec.ts
|
||||
requiredProfiles:
|
||||
- fast
|
||||
- comms
|
||||
@@ -56,6 +68,9 @@ requiredRules:
|
||||
- provider-default-invariant
|
||||
- provider-model-metadata-preservation
|
||||
- provider-model-selection-authority
|
||||
- acp-chat-state-and-history
|
||||
- acp-compatibility-content-safety
|
||||
- ui-i18n-design-tokens
|
||||
- sidebar-session-attention-authority
|
||||
- web-browser-security-and-lifecycle
|
||||
- comms-regression
|
||||
@@ -86,6 +101,8 @@ Channel/plugin migration behavior is also part of this scenario when ClawX rewri
|
||||
|
||||
Scheduled-task history is Main-owned backend data. Current OpenClaw versions must be queried through the Gateway `cron.runs` RPC; direct run-log file reads are allowed only as a compatibility fallback for older file-backed runtimes. When a run's bounded summary ends with OpenClaw's truncation ellipsis, Main may recover the complete final assistant reply from the run transcript identified by that `cron.runs` entry, but only when the transcript reply is longer and shares the entire summary prefix. When a cron base session has no ACP replay, Renderer may project that typed host result into a generation-scoped, in-memory historical ACP timeline, but must not replace or duplicate non-empty ACP replay.
|
||||
|
||||
Running cron progress has one narrower, non-historical path: strict run-scoped cron Gateway events may enter a bounded Main-owned broker and reach Renderer as an explicit read-only live overlay through typed host-api and host-events contracts. The overlay remains separate from ACP updates, ACP timeline reduction, cron history, prompt state, and sidebar attention; terminal events remove it before authoritative ACP or typed cron-history reload. The durable exception and its bounds are documented in `harness/reference/acp-cron-live-overlay.md`.
|
||||
|
||||
The local HTML Preview privileged bridge is also Main-owned: Renderer may load a validated local HTML file or open that current file externally through the typed Host API. The guest is an implementation detail of the existing `preview` tab; there is no `web-browser` artifact tab or general address navigation. The durable guest contract is `harness/reference/web-browser.md`.
|
||||
|
||||
Gateway session-catalog subscription, normalization, ordered list/event replay, attention transitions, and reconnect recovery are documented in `harness/reference/sidebar-session-attention.md`.
|
||||
|
||||
@@ -1,43 +1,130 @@
|
||||
---
|
||||
id: render-cron-run-live-status
|
||||
title: Render live execution status for cron-triggered runs without a session switch
|
||||
title: Render a bounded live overlay for cron-triggered runs
|
||||
scenario: gateway-backend-communication
|
||||
taskType: runtime-bridge
|
||||
intent: When a scheduled (cron) job fires while the user is viewing that cron session, ClawX must render the live running state (Thinking indicator, Execution Graph, tool steps) in realtime. Today the Gateway streams runtime events under the run-scoped session key (agent:<id>:cron:<jobId>:run:<sessionId>) while the UI tracks the base cron key (agent:<id>:cron:<jobId>), so events are dropped by strict session-key equality and the user must switch sessions to force a transcript reload.
|
||||
intent: Show transient progress for an externally triggered cron run without converting Gateway runtime events into ACP notifications, timeline items, history, prompt state, or sidebar attention state.
|
||||
touchedAreas:
|
||||
- harness/specs/tasks/render-cron-run-live-status.md
|
||||
- harness/specs/scenarios/gateway-backend-communication.md
|
||||
- harness/specs/scenarios/acp-chat-experience.md
|
||||
- harness/specs/rules/acp-chat-state-and-history.md
|
||||
- harness/specs/rules/acp-compatibility-content-safety.md
|
||||
- harness/reference/acp-cron-live-overlay.md
|
||||
- harness/reference/acp-chat.md
|
||||
- docs/plans/2026-08-04-cron-live-run-overlay.md
|
||||
- tests/unit/harness-specs.test.ts
|
||||
- shared/chat/cron-session.ts
|
||||
- shared/chat/cron-live-run.ts
|
||||
- shared/host-events/contract.ts
|
||||
- shared/host-api/contract.ts
|
||||
- electron/services/cron-api.ts
|
||||
- electron/services/cron-live-run-broker.ts
|
||||
- electron/gateway/chat-runtime-events.ts
|
||||
- electron/main/ipc-handlers.ts
|
||||
- electron/main/index.ts
|
||||
- src/lib/host-events.ts
|
||||
- src/lib/host-api.ts
|
||||
- src/stores/chat/cron-session-utils.ts
|
||||
- src/stores/acp-chat-session.ts
|
||||
- src/stores/chat.ts
|
||||
- src/stores/gateway.ts
|
||||
- src/components/layout/Sidebar.tsx
|
||||
- src/stores/session-attention.ts
|
||||
- src/stores/chat/history-actions.ts
|
||||
- src/stores/chat/session-selection.ts
|
||||
- src/stores/chat/session-catalog.ts
|
||||
- src/stores/chat/session-key-utils.ts
|
||||
- src/stores/cron-live-run-overlay.ts
|
||||
- src/pages/Chat/CronLiveRunOverlay.tsx
|
||||
- src/pages/Chat/index.tsx
|
||||
- shared/i18n/locales/en/chat.json
|
||||
- shared/i18n/locales/zh/chat.json
|
||||
- shared/i18n/locales/ja/chat.json
|
||||
- shared/i18n/locales/ru/chat.json
|
||||
- tests/unit/cron-session-utils.test.ts
|
||||
- tests/unit/gateway-events.test.ts
|
||||
- tests/unit/gateway-event-dispatch.test.ts
|
||||
- tests/unit/cron-live-run-broker.test.ts
|
||||
- tests/unit/cron-schedule.test.ts
|
||||
- tests/unit/host-events.test.ts
|
||||
- tests/unit/host-api-facade.test.ts
|
||||
- tests/unit/host-services.test.ts
|
||||
- tests/unit/cron-live-run-overlay-store.test.ts
|
||||
- tests/unit/cron-live-run-overlay.test.tsx
|
||||
- tests/unit/chat-acp-page.test.tsx
|
||||
- tests/e2e/fixtures/electron.ts
|
||||
- tests/e2e/cron-run-live-status.spec.ts
|
||||
- README.md
|
||||
- README.zh-CN.md
|
||||
- README.ja-JP.md
|
||||
expectedUserBehavior:
|
||||
- When a cron job triggers while the user is viewing that cron session, the renderer adopts the run, surfaces the running/Thinking state, and renders the Execution Graph live from streamed runtime events.
|
||||
- Runtime events whose sessionKey carries the run-scoped suffix are treated as belonging to the equivalent base cron session the user is viewing.
|
||||
- When the cron run ends, the renderer reloads the transcript for the current session so the completed graph and final reply render without a manual session switch.
|
||||
- Background :main heartbeat runs continue to NOT surface a Thinking indicator.
|
||||
- Renderer continues to use Host events / api-client boundaries; no new direct IPC or Gateway HTTP calls are added.
|
||||
- While the user views a cron session, each active run for that exact base cron key appears in a clearly labeled, read-only live overlay next to, but never inside, the authoritative ACP timeline.
|
||||
- The overlay may show assistant text and bounded process status, but thinking exposes only a localized activity indicator and never raw thought text.
|
||||
- A terminal event removes the transient overlay and reloads authoritative ACP replay or the existing typed cron-history fallback exactly once when that run was visible.
|
||||
- External cron activity never enters ACP sending or cancelling state, exposes ACP Stop or permission controls, or sets sidebar busy or unread authority.
|
||||
- Ordinary sessions, base-only cron events, channel sessions, and heartbeat :main events never enter the overlay.
|
||||
requiredProfiles:
|
||||
- fast
|
||||
- comms
|
||||
- e2e
|
||||
requiredRules:
|
||||
- renderer-main-boundary
|
||||
- backend-communication-boundary
|
||||
- api-client-transport-policy
|
||||
- host-api-fallback-policy
|
||||
- host-events-fallback-policy
|
||||
- gateway-readiness-policy
|
||||
- acp-chat-state-and-history
|
||||
- acp-compatibility-content-safety
|
||||
- ui-i18n-design-tokens
|
||||
- comms-regression
|
||||
- docs-sync
|
||||
requiredTests:
|
||||
- pnpm exec vitest run tests/unit/cron-session-utils.test.ts
|
||||
- pnpm exec vitest run tests/unit/gateway-events.test.ts
|
||||
- pnpm exec vitest run tests/unit/harness-specs.test.ts
|
||||
- pnpm exec vitest run tests/unit/cron-session-utils.test.ts tests/unit/gateway-event-dispatch.test.ts tests/unit/cron-live-run-broker.test.ts tests/unit/cron-live-run-overlay-store.test.ts tests/unit/cron-live-run-overlay.test.tsx tests/unit/cron-schedule.test.ts
|
||||
- pnpm exec vitest run tests/unit/host-events.test.ts tests/unit/host-api-facade.test.ts tests/unit/host-services.test.ts tests/unit/chat-acp-page.test.tsx tests/unit/acp-chat-store.test.ts tests/unit/acp-image-generation-compat.test.ts tests/unit/gateway-events.test.ts
|
||||
- pnpm run typecheck
|
||||
- pnpm run lint:check
|
||||
- pnpm run build:vite
|
||||
- pnpm exec playwright test tests/e2e/cron-run-live-status.spec.ts
|
||||
- pnpm run comms:replay
|
||||
- pnpm run comms:compare
|
||||
- pnpm harness validate --spec harness/specs/tasks/render-cron-run-live-status.md
|
||||
- pnpm harness run --spec harness/specs/tasks/render-cron-run-live-status.md
|
||||
- pnpm run harness:ci
|
||||
acceptance:
|
||||
- A cron session-key equivalence helper treats the base cron key and its run-scoped variant as the same session.
|
||||
- chat store handleChatEvent / handleRuntimeEvent apply cron run-scoped events to the equivalent base cron session currently in view.
|
||||
- Cron sessions are treated as trackable inbound runs so run.started arms the running state, while :main heartbeats remain suppressed.
|
||||
- gateway runtime-event dispatch reloads history for the current cron session on run end (and start) using equivalence rather than strict equality.
|
||||
- Renderer does not add direct IPC calls or Gateway HTTP fetches outside the existing api-client / host-events path.
|
||||
- Only strict run-scoped cron keys shaped as agent:<agentId>:cron:<jobId>:run:<runSessionId> enter the Main-owned broker; Main alone canonicalizes identity, deduplicates events, applies memory bounds, and publishes revisioned snapshots.
|
||||
- Main enforces exactly 32 active runs, 128 items per run, 500000 assistant characters, 100000 characters per item detail, 256 sequence-less event fingerprints per run, and 128 terminal tombstones.
|
||||
- Gateway runtime events remain outside SessionNotification, AcpSessionUpdateEnvelope, TimelineItem, AcpTimelineSnapshot, and every persisted or reconstructed history path.
|
||||
- The overlay is non-historical, memory-only, run-scoped, running-only, read-only, and excluded from ACP prompt state and sidebar unread or busy authority; raw thinking text is neither retained nor rendered.
|
||||
- Renderer subscribes through typed host-events before hydrating through host-api, rejects snapshots and changes older than its current broker revision, and never implements Gateway reduction or protocol fallback.
|
||||
- Terminal removal precedes exactly one authoritative ACP reload for a run that was rendered in the currently selected base cron session; hidden, evicted, gateway-reset, or previously acknowledged removals never cause a delayed reload, and terminal content is never retained as overlay history.
|
||||
- This exception is prohibited for ordinary non-cron messages, base-only cron events, channel sessions, heartbeat sessions, historical event replay, and arbitrary Gateway content.
|
||||
- Existing raw chat:runtime-event forwarding and ACP replay, cancellation, permission, compatibility-media, and cron-history behavior remain unchanged.
|
||||
- All overlay display text is translated in English, Chinese, Japanese, and Russian and uses the semantic design tokens in src/styles/globals.css.
|
||||
- The broker and overlay may be removed only after a distributed OpenClaw package proves through integration tests that loaded ACP sessions receive autonomous cron assistant, thought, and tool updates; generated media arrives as standard ACP content blocks; replay is complete and deduplicated; and external-run lifecycle and cancellation semantics are explicitly exposed.
|
||||
- Focused unit, type, lint, build, Electron E2E, communication replay/compare, Harness task, Harness CI, and synchronized README documentation checks pass.
|
||||
docs:
|
||||
required: false
|
||||
required: true
|
||||
---
|
||||
|
||||
## Architecture Contract
|
||||
|
||||
The only approved live cron assistant/process-progress exception is the bounded overlay documented in `harness/reference/acp-cron-live-overlay.md`:
|
||||
|
||||
```text
|
||||
Gateway runtime event -> Main bounded cron broker -> explicit live overlay
|
||||
terminal event -> overlay removal -> authoritative ACP/cron-history reload
|
||||
```
|
||||
|
||||
The overlay is a transient view model, not an ACP compatibility event or historical projection. The primary ACP timeline, existing typed cron-history fallback, external-run controls, and sidebar attention authority remain separate.
|
||||
|
||||
Main bounds the broker to 32 active runs, 128 items per run, 500000 assistant characters, 100000 characters per item detail, 256 sequence-less fingerprints per run, and 128 terminal tombstones. Renderer subscribes before snapshot hydration and rejects older revisions. Raw thinking text is never retained or rendered. Only terminal removal for a run rendered in the currently selected base cron session causes one authoritative `loadAcpSession`; all other removals are acknowledged without a delayed reload.
|
||||
|
||||
This narrow exception must not be extended to ordinary non-cron traffic. It can be deleted only when a distributed OpenClaw package proves all four upstream capabilities through integration tests: autonomous cron assistant/thought/tool updates reach loaded ACP sessions, generated media uses standard ACP content blocks, replay is complete and deduplicated, and external-run lifecycle/cancellation semantics are explicit.
|
||||
|
||||
## Out Of Scope
|
||||
|
||||
- Converting Gateway runtime events into ACP notifications, tools, permissions, messages, or timeline items.
|
||||
- Restoring the legacy Execution Graph in ACP Chat.
|
||||
- Making externally triggered cron runs ACP-cancellable or permission-interactive.
|
||||
- Extending the overlay exception to ordinary, channel, heartbeat, or base-only cron session events.
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
export type CronLiveRunStatus = 'running';
|
||||
|
||||
export type CronLiveRunItem =
|
||||
| {
|
||||
kind: 'tool';
|
||||
id: string;
|
||||
toolCallId: string;
|
||||
title: string;
|
||||
status: 'running' | 'completed' | 'failed';
|
||||
inputText?: string;
|
||||
outputText?: string;
|
||||
error?: string;
|
||||
}
|
||||
| {
|
||||
kind: 'command';
|
||||
id: string;
|
||||
title: string;
|
||||
status: 'running' | 'completed' | 'failed';
|
||||
output: string;
|
||||
exitCode?: number;
|
||||
}
|
||||
| {
|
||||
kind: 'patch';
|
||||
id: string;
|
||||
title: string;
|
||||
summary?: string;
|
||||
added?: number;
|
||||
modified?: number;
|
||||
deleted?: number;
|
||||
}
|
||||
| {
|
||||
kind: 'approval';
|
||||
id: string;
|
||||
title: string;
|
||||
status: 'running' | 'completed' | 'failed';
|
||||
message?: string;
|
||||
};
|
||||
|
||||
export interface CronLiveRunOverlaySnapshot {
|
||||
canonicalSessionKey: string;
|
||||
sourceSessionKey: string;
|
||||
runSessionId: string;
|
||||
runId: string;
|
||||
revision: number;
|
||||
status: CronLiveRunStatus;
|
||||
startedAt?: number;
|
||||
updatedAt: number;
|
||||
lastSeq?: number;
|
||||
assistantText: string;
|
||||
thinking: boolean;
|
||||
items: CronLiveRunItem[];
|
||||
}
|
||||
|
||||
export interface CronLiveRunOverlaySnapshotSet {
|
||||
revision: number;
|
||||
snapshots: CronLiveRunOverlaySnapshot[];
|
||||
}
|
||||
|
||||
export type CronLiveRunOverlayChange =
|
||||
| {
|
||||
kind: 'upsert';
|
||||
revision: number;
|
||||
snapshot: CronLiveRunOverlaySnapshot;
|
||||
}
|
||||
| {
|
||||
kind: 'remove';
|
||||
revision: number;
|
||||
canonicalSessionKey: string;
|
||||
sourceSessionKey: string;
|
||||
runId: string;
|
||||
reason: 'ended' | 'evicted' | 'gateway-reset';
|
||||
terminalStatus?: 'completed' | 'error' | 'aborted';
|
||||
terminalError?: string;
|
||||
};
|
||||
@@ -0,0 +1,46 @@
|
||||
export interface CronSessionKeyParts {
|
||||
agentId: string;
|
||||
jobId: string;
|
||||
runSessionId?: string;
|
||||
}
|
||||
|
||||
export function parseCronSessionKey(sessionKey: string): CronSessionKeyParts | null {
|
||||
const parts = sessionKey.split(':');
|
||||
if (parts[0] !== 'agent' || parts[2] !== 'cron') return null;
|
||||
|
||||
const agentId = parts[1];
|
||||
const jobId = parts[3];
|
||||
if (!agentId?.trim() || !jobId?.trim()) return null;
|
||||
|
||||
if (parts.length === 4) return { agentId, jobId };
|
||||
if (parts.length !== 6 || parts[4] !== 'run') return null;
|
||||
|
||||
const runSessionId = parts[5];
|
||||
return runSessionId?.trim() ? { agentId, jobId, runSessionId } : null;
|
||||
}
|
||||
|
||||
export function isCronSessionKey(sessionKey: string): boolean {
|
||||
return parseCronSessionKey(sessionKey) != null;
|
||||
}
|
||||
|
||||
export function isRunScopedCronSessionKey(sessionKey: string): boolean {
|
||||
return parseCronSessionKey(sessionKey)?.runSessionId != null;
|
||||
}
|
||||
|
||||
export function getCronSessionBaseKey(sessionKey: string): string {
|
||||
const parts = parseCronSessionKey(sessionKey);
|
||||
if (!parts) return sessionKey;
|
||||
return `agent:${parts.agentId}:cron:${parts.jobId}`;
|
||||
}
|
||||
|
||||
export function sessionKeysAreEquivalent(
|
||||
a: string | null | undefined,
|
||||
b: string | null | undefined,
|
||||
): boolean {
|
||||
if (a == null || b == null) return false;
|
||||
if (a === b) return true;
|
||||
const parsedA = parseCronSessionKey(a);
|
||||
const parsedB = parseCronSessionKey(b);
|
||||
if (!parsedA || !parsedB) return false;
|
||||
return parsedA.agentId === parsedB.agentId && parsedA.jobId === parsedB.jobId;
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import type {
|
||||
AcpChatRespondPermissionPayload,
|
||||
} from '../acp-chat/types';
|
||||
import type { RawMessage } from '../chat/types';
|
||||
import type { CronLiveRunOverlaySnapshotSet } from '../chat/cron-live-run';
|
||||
import type { AgentsSnapshot } from '../types/agent';
|
||||
import type { CronJob, CronJobCreateInput, CronJobUpdateInput } from '../types/cron';
|
||||
import type { GatewayHealth, GatewayStatus } from '../types/gateway';
|
||||
@@ -994,6 +995,7 @@ export type HostApiContract = {
|
||||
respondAcpPermission: (payload: AcpChatRespondPermissionPayload) => AcpChatOperationResult;
|
||||
};
|
||||
cron: {
|
||||
liveRunOverlays: () => CronLiveRunOverlaySnapshotSet;
|
||||
list: () => CronJob[];
|
||||
create: (payload: CronJobCreateInput) => CronJob;
|
||||
update: (payload: CronUpdatePayload) => CronJob;
|
||||
|
||||
@@ -4,6 +4,7 @@ import type {
|
||||
} from '../acp-chat/types';
|
||||
import type { UpdateStatusSnapshot } from '../host-api/contract';
|
||||
import type { ChatRuntimeEvent } from '../chat-runtime-events';
|
||||
import type { CronLiveRunOverlayChange } from '../chat/cron-live-run';
|
||||
import type {
|
||||
GatewayNotification,
|
||||
GatewayRuntimePayload,
|
||||
@@ -82,6 +83,9 @@ export type HostEventContract = {
|
||||
acpSessionUpdate: (payload: AcpSessionUpdateEnvelope) => void;
|
||||
acpPermissionRequest: (payload: AcpPermissionRequestEnvelope) => void;
|
||||
};
|
||||
cron: {
|
||||
liveRunOverlayChanged: (payload: CronLiveRunOverlayChange) => void;
|
||||
};
|
||||
oauth: {
|
||||
code: (payload: OAuthCodeEvent) => void;
|
||||
success: (payload: OAuthSuccessEvent) => void;
|
||||
@@ -131,6 +135,9 @@ export const HOST_EVENT_CHANNELS = {
|
||||
acpSessionUpdate: 'chat:acp-session-update',
|
||||
acpPermissionRequest: 'chat:acp-permission-request',
|
||||
},
|
||||
cron: {
|
||||
liveRunOverlayChanged: 'cron:live-run-overlay-changed',
|
||||
},
|
||||
oauth: {
|
||||
code: 'oauth:code',
|
||||
success: 'oauth:success',
|
||||
|
||||
@@ -253,6 +253,35 @@
|
||||
"generatedReady": "Generated image is ready.",
|
||||
"generatedReadyWithMissing": "Generated image is ready. Some images could not be loaded."
|
||||
},
|
||||
"cronLiveRun": {
|
||||
"title": "Live scheduled run",
|
||||
"transient": "Transient, read-only progress. Final content appears in chat after the run finishes.",
|
||||
"running": "Running",
|
||||
"thinking": "Thinking",
|
||||
"item": {
|
||||
"tool": "Tool",
|
||||
"command": "Command",
|
||||
"patch": "Patch",
|
||||
"approval": "Approval"
|
||||
},
|
||||
"status": {
|
||||
"running": "Running",
|
||||
"completed": "Completed",
|
||||
"failed": "Failed"
|
||||
},
|
||||
"detail": {
|
||||
"input": "Input",
|
||||
"output": "Output",
|
||||
"error": "Error",
|
||||
"exitCode": "Exit code: {{code}}"
|
||||
},
|
||||
"patch": {
|
||||
"added": "Added: {{count}}",
|
||||
"modified": "Modified: {{count}}",
|
||||
"deleted": "Deleted: {{count}}"
|
||||
},
|
||||
"approvalReadOnly": "Read-only status. Respond in the originating client."
|
||||
},
|
||||
"composer": {
|
||||
"attachFiles": "Attach files",
|
||||
"pickSkill": "Choose skill",
|
||||
|
||||
@@ -253,6 +253,35 @@
|
||||
"generatedReady": "生成された画像の準備ができました。",
|
||||
"generatedReadyWithMissing": "生成された画像の準備ができましたが、一部の画像を読み込めませんでした。"
|
||||
},
|
||||
"cronLiveRun": {
|
||||
"title": "スケジュール実行のライブ状況",
|
||||
"transient": "一時的な読み取り専用の進行状況です。最終内容は実行終了後にチャットへ表示されます。",
|
||||
"running": "実行中",
|
||||
"thinking": "考え中",
|
||||
"item": {
|
||||
"tool": "ツール",
|
||||
"command": "コマンド",
|
||||
"patch": "パッチ",
|
||||
"approval": "承認"
|
||||
},
|
||||
"status": {
|
||||
"running": "実行中",
|
||||
"completed": "完了",
|
||||
"failed": "失敗"
|
||||
},
|
||||
"detail": {
|
||||
"input": "入力",
|
||||
"output": "出力",
|
||||
"error": "エラー",
|
||||
"exitCode": "終了コード: {{code}}"
|
||||
},
|
||||
"patch": {
|
||||
"added": "追加: {{count}}",
|
||||
"modified": "変更: {{count}}",
|
||||
"deleted": "削除: {{count}}"
|
||||
},
|
||||
"approvalReadOnly": "この状態は読み取り専用です。要求元のクライアントで応答してください。"
|
||||
},
|
||||
"composer": {
|
||||
"attachFiles": "ファイルを添付",
|
||||
"pickSkill": "Skill を選択",
|
||||
|
||||
@@ -253,6 +253,35 @@
|
||||
"generatedReady": "Сгенерированное изображение готово.",
|
||||
"generatedReadyWithMissing": "Сгенерированное изображение готово, но некоторые изображения не удалось загрузить."
|
||||
},
|
||||
"cronLiveRun": {
|
||||
"title": "Выполнение задачи по расписанию",
|
||||
"transient": "Временный прогресс только для чтения. Итог появится в чате после завершения выполнения.",
|
||||
"running": "Выполняется",
|
||||
"thinking": "Обдумывание",
|
||||
"item": {
|
||||
"tool": "Инструмент",
|
||||
"command": "Команда",
|
||||
"patch": "Изменения",
|
||||
"approval": "Подтверждение"
|
||||
},
|
||||
"status": {
|
||||
"running": "Выполняется",
|
||||
"completed": "Завершено",
|
||||
"failed": "Ошибка"
|
||||
},
|
||||
"detail": {
|
||||
"input": "Входные данные",
|
||||
"output": "Результат",
|
||||
"error": "Ошибка",
|
||||
"exitCode": "Код завершения: {{code}}"
|
||||
},
|
||||
"patch": {
|
||||
"added": "Добавлено: {{count}}",
|
||||
"modified": "Изменено: {{count}}",
|
||||
"deleted": "Удалено: {{count}}"
|
||||
},
|
||||
"approvalReadOnly": "Статус только для чтения. Ответьте в исходном клиенте."
|
||||
},
|
||||
"composer": {
|
||||
"attachFiles": "Прикрепить файлы",
|
||||
"pickSkill": "Выбрать Skill",
|
||||
|
||||
@@ -253,6 +253,35 @@
|
||||
"generatedReady": "生成的图片已准备好。",
|
||||
"generatedReadyWithMissing": "生成的图片已准备好,但有部分图片无法加载。"
|
||||
},
|
||||
"cronLiveRun": {
|
||||
"title": "计划任务实时运行",
|
||||
"transient": "此处仅显示临时的只读进度。运行结束后,最终内容将显示在聊天记录中。",
|
||||
"running": "运行中",
|
||||
"thinking": "思考中",
|
||||
"item": {
|
||||
"tool": "工具",
|
||||
"command": "命令",
|
||||
"patch": "补丁",
|
||||
"approval": "审批"
|
||||
},
|
||||
"status": {
|
||||
"running": "运行中",
|
||||
"completed": "已完成",
|
||||
"failed": "失败"
|
||||
},
|
||||
"detail": {
|
||||
"input": "输入",
|
||||
"output": "输出",
|
||||
"error": "错误",
|
||||
"exitCode": "退出代码:{{code}}"
|
||||
},
|
||||
"patch": {
|
||||
"added": "新增:{{count}}",
|
||||
"modified": "修改:{{count}}",
|
||||
"deleted": "删除:{{count}}"
|
||||
},
|
||||
"approvalReadOnly": "此状态为只读。请在发起请求的客户端中响应。"
|
||||
},
|
||||
"composer": {
|
||||
"attachFiles": "添加文件",
|
||||
"pickSkill": "选择技能",
|
||||
|
||||
@@ -374,6 +374,7 @@ export const hostApi = {
|
||||
),
|
||||
},
|
||||
cron: {
|
||||
liveRunOverlays: () => invokeHost('cron', 'liveRunOverlays'),
|
||||
list: () => invokeHost('cron', 'list'),
|
||||
create: (input: CronJobCreateInput) => invokeHost('cron', 'create', input),
|
||||
update: (id: string, input: CronJobUpdateInput) => invokeHost('cron', 'update', { id, input }),
|
||||
|
||||
@@ -40,6 +40,11 @@ const onChatEvent = <E extends HostEventName<'chat'>>(
|
||||
handler: HostEventHandler<'chat', E>,
|
||||
) => onIpc(HOST_EVENT_CHANNELS.chat[event], handler);
|
||||
|
||||
const onCronEvent = <E extends HostEventName<'cron'>>(
|
||||
event: E,
|
||||
handler: HostEventHandler<'cron', E>,
|
||||
) => onIpc(HOST_EVENT_CHANNELS.cron[event], handler);
|
||||
|
||||
const onOAuthEvent = <E extends HostEventName<'oauth'>>(
|
||||
event: E,
|
||||
handler: HostEventHandler<'oauth', E>,
|
||||
@@ -98,6 +103,9 @@ export const hostEvents = {
|
||||
onAcpPermissionRequest: (handler: HostEventHandler<'chat', 'acpPermissionRequest'>) => (
|
||||
onChatEvent('acpPermissionRequest', handler)
|
||||
),
|
||||
onCronLiveRunOverlayChanged: (
|
||||
handler: HostEventHandler<'cron', 'liveRunOverlayChanged'>,
|
||||
) => onCronEvent('liveRunOverlayChanged', handler),
|
||||
onOAuthCode: (handler: HostEventHandler<'oauth', 'code'>) => onOAuthEvent('code', handler),
|
||||
onOAuthSuccess: (handler: HostEventHandler<'oauth', 'success'>) => onOAuthEvent('success', handler),
|
||||
onOAuthError: (handler: HostEventHandler<'oauth', 'error'>) => onOAuthEvent('error', handler),
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
import { useId, type ReactNode } from 'react';
|
||||
import {
|
||||
CheckCircle2,
|
||||
FileDiff,
|
||||
Loader2,
|
||||
ShieldCheck,
|
||||
TerminalSquare,
|
||||
Wrench,
|
||||
XCircle,
|
||||
} from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { CronLiveRunItem, CronLiveRunOverlaySnapshot } from '@shared/chat/cron-live-run';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { AcpRenderPart } from './AcpMessageSegment';
|
||||
|
||||
type ItemStatus = Extract<CronLiveRunItem, { status: unknown }>['status'];
|
||||
|
||||
function Status({ status }: { status: ItemStatus }) {
|
||||
const { t } = useTranslation('chat');
|
||||
const classes = status === 'completed'
|
||||
? 'text-green-700 dark:text-green-400'
|
||||
: status === 'failed'
|
||||
? 'text-red-700 dark:text-red-400'
|
||||
: 'text-yellow-700 dark:text-yellow-400';
|
||||
|
||||
return (
|
||||
<span
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
aria-atomic="true"
|
||||
className={cn('inline-flex shrink-0 items-center gap-1 rounded-full bg-black/5 px-2 py-0.5 text-2xs font-medium uppercase tracking-wide dark:bg-white/10', classes)}
|
||||
>
|
||||
{status === 'running' && <Loader2 className="h-3.5 w-3.5 animate-spin" aria-hidden="true" />}
|
||||
{status === 'completed' && <CheckCircle2 className="h-3.5 w-3.5" aria-hidden="true" />}
|
||||
{status === 'failed' && <XCircle className="h-3.5 w-3.5" aria-hidden="true" />}
|
||||
{t(`cronLiveRun.status.${status}`)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function ItemHeader({
|
||||
icon,
|
||||
label,
|
||||
title,
|
||||
status,
|
||||
}: {
|
||||
icon: ReactNode;
|
||||
label: string;
|
||||
title: string;
|
||||
status?: ItemStatus;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex min-w-0 flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
{icon}
|
||||
<span className="shrink-0 text-2xs font-medium uppercase tracking-wide text-muted-foreground">{label}</span>
|
||||
<span className="min-w-0 break-words text-sm font-medium text-foreground">{title}</span>
|
||||
</div>
|
||||
{status && <Status status={status} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Detail({ label, children, error = false }: { label: string; children: ReactNode; error?: boolean }) {
|
||||
return (
|
||||
<div className="min-w-0">
|
||||
<p className="mb-1 text-2xs font-medium uppercase tracking-wide text-muted-foreground">{label}</p>
|
||||
<pre className={cn(
|
||||
'max-h-72 overflow-auto whitespace-pre-wrap break-words rounded-lg border border-black/10 bg-surface-input px-3 py-2 font-mono text-xs leading-relaxed text-foreground dark:border-white/10',
|
||||
error && 'border-red-500/20 text-red-700 dark:text-red-400',
|
||||
)}>
|
||||
{children}
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ToolRow({ item }: { item: Extract<CronLiveRunItem, { kind: 'tool' }> }) {
|
||||
const { t } = useTranslation('chat');
|
||||
return (
|
||||
<article data-testid="cron-live-tool" className="rounded-xl border border-border bg-surface-input px-3 py-3 sm:px-4">
|
||||
<ItemHeader
|
||||
icon={<Wrench className="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden="true" />}
|
||||
label={t('cronLiveRun.item.tool')}
|
||||
title={item.title}
|
||||
status={item.status}
|
||||
/>
|
||||
{(item.inputText || item.outputText || item.error) && (
|
||||
<div className="mt-3 grid gap-3">
|
||||
{item.inputText && <Detail label={t('cronLiveRun.detail.input')}>{item.inputText}</Detail>}
|
||||
{item.outputText && <Detail label={t('cronLiveRun.detail.output')}>{item.outputText}</Detail>}
|
||||
{item.error && <Detail label={t('cronLiveRun.detail.error')} error>{item.error}</Detail>}
|
||||
</div>
|
||||
)}
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
function CommandRow({ item }: { item: Extract<CronLiveRunItem, { kind: 'command' }> }) {
|
||||
const { t } = useTranslation('chat');
|
||||
return (
|
||||
<article data-testid="cron-live-command" className="rounded-xl border border-border bg-surface-input px-3 py-3 sm:px-4">
|
||||
<ItemHeader
|
||||
icon={<TerminalSquare className="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden="true" />}
|
||||
label={t('cronLiveRun.item.command')}
|
||||
title={item.title}
|
||||
status={item.status}
|
||||
/>
|
||||
{item.exitCode !== undefined && (
|
||||
<p className="mt-2 text-xs text-muted-foreground">{t('cronLiveRun.detail.exitCode', { code: item.exitCode })}</p>
|
||||
)}
|
||||
{item.output && (
|
||||
<pre
|
||||
data-testid="cron-live-command-output"
|
||||
className="mt-3 max-h-72 overflow-auto whitespace-pre-wrap break-words rounded-lg border border-black/10 bg-surface-input px-3 py-2 font-mono text-xs leading-relaxed text-foreground dark:border-white/10"
|
||||
>
|
||||
{item.output}
|
||||
</pre>
|
||||
)}
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
function PatchCount({ text, className }: { text: string; className: string }) {
|
||||
return (
|
||||
<span className={cn('rounded-full bg-black/5 px-2 py-1 text-xs font-medium dark:bg-white/10', className)}>
|
||||
{text}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function PatchRow({ item }: { item: Extract<CronLiveRunItem, { kind: 'patch' }> }) {
|
||||
const { t } = useTranslation('chat');
|
||||
return (
|
||||
<article data-testid="cron-live-patch" className="rounded-xl border border-border bg-surface-input px-3 py-3 sm:px-4">
|
||||
<ItemHeader
|
||||
icon={<FileDiff className="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden="true" />}
|
||||
label={t('cronLiveRun.item.patch')}
|
||||
title={item.title}
|
||||
/>
|
||||
{item.summary && <p className="mt-2 whitespace-pre-wrap break-words text-sm text-foreground/80">{item.summary}</p>}
|
||||
{(item.added !== undefined || item.modified !== undefined || item.deleted !== undefined) && (
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{item.added !== undefined && <PatchCount text={t('cronLiveRun.patch.added', { count: item.added })} className="text-green-700 dark:text-green-400" />}
|
||||
{item.modified !== undefined && <PatchCount text={t('cronLiveRun.patch.modified', { count: item.modified })} className="text-yellow-700 dark:text-yellow-400" />}
|
||||
{item.deleted !== undefined && <PatchCount text={t('cronLiveRun.patch.deleted', { count: item.deleted })} className="text-red-700 dark:text-red-400" />}
|
||||
</div>
|
||||
)}
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
function ApprovalRow({ item }: { item: Extract<CronLiveRunItem, { kind: 'approval' }> }) {
|
||||
const { t } = useTranslation('chat');
|
||||
return (
|
||||
<article data-testid="cron-live-approval" className="rounded-xl border border-yellow-500/20 bg-surface-input px-3 py-3 sm:px-4">
|
||||
<ItemHeader
|
||||
icon={<ShieldCheck className="h-4 w-4 shrink-0 text-yellow-700 dark:text-yellow-400" aria-hidden="true" />}
|
||||
label={t('cronLiveRun.item.approval')}
|
||||
title={item.title}
|
||||
status={item.status}
|
||||
/>
|
||||
{item.message && <p className="mt-2 whitespace-pre-wrap break-words text-sm text-foreground/80">{item.message}</p>}
|
||||
<p className="mt-2 text-xs text-muted-foreground">{t('cronLiveRun.approvalReadOnly')}</p>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
function LiveItem({ item }: { item: CronLiveRunItem }) {
|
||||
if (item.kind === 'tool') return <ToolRow item={item} />;
|
||||
if (item.kind === 'command') return <CommandRow item={item} />;
|
||||
if (item.kind === 'patch') return <PatchRow item={item} />;
|
||||
return <ApprovalRow item={item} />;
|
||||
}
|
||||
|
||||
export function CronLiveRunOverlay({ snapshot }: { snapshot: CronLiveRunOverlaySnapshot }) {
|
||||
const { t } = useTranslation('chat');
|
||||
const headingId = useId();
|
||||
|
||||
return (
|
||||
<section
|
||||
data-testid="cron-live-run-overlay"
|
||||
aria-labelledby={headingId}
|
||||
className="w-full rounded-2xl border border-primary/20 bg-surface-modal p-3 shadow-sm sm:p-4"
|
||||
>
|
||||
<header className="flex flex-col gap-2 border-b border-border pb-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="min-w-0">
|
||||
<h2 id={headingId} className="text-sm font-semibold text-foreground">{t('cronLiveRun.title')}</h2>
|
||||
<p className="mt-1 text-xs leading-relaxed text-muted-foreground">{t('cronLiveRun.transient')}</p>
|
||||
</div>
|
||||
<span className="inline-flex w-fit shrink-0 items-center gap-2 rounded-full bg-black/5 px-2.5 py-1 text-xs font-medium text-yellow-700 dark:bg-white/10 dark:text-yellow-400">
|
||||
<span data-testid="cron-live-running-pulse" className="h-2 w-2 animate-pulse rounded-full bg-yellow-500" aria-hidden="true" />
|
||||
{t('cronLiveRun.running')}
|
||||
</span>
|
||||
</header>
|
||||
|
||||
{snapshot.assistantText && (
|
||||
<div data-testid="cron-live-assistant" className="mt-4 min-w-0">
|
||||
<AcpRenderPart part={{ kind: 'markdown', text: snapshot.assistantText }} tone="assistant" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{snapshot.thinking && (
|
||||
<div data-testid="cron-live-thinking" role="status" className="mt-3 inline-flex items-center gap-2 text-sm text-yellow-700 dark:text-yellow-400">
|
||||
<Loader2 className="h-4 w-4 animate-spin" aria-hidden="true" />
|
||||
{t('cronLiveRun.thinking')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{snapshot.items.length > 0 && (
|
||||
<div className="mt-4 grid gap-3">
|
||||
{snapshot.items.map((item) => <LiveItem key={item.id} item={item} />)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
+123
-11
@@ -15,6 +15,11 @@ import { useChatStore } from '@/stores/chat';
|
||||
import { useSessionAttentionStore } from '@/stores/session-attention';
|
||||
import { useSettingsStore } from '@/stores/settings';
|
||||
import { ensureAcpChatSubscriptions, useAcpChatSessionStore } from '@/stores/acp-chat-session';
|
||||
import {
|
||||
ensureCronLiveRunOverlaySubscriptions,
|
||||
selectCronLiveRunsForSession,
|
||||
useCronLiveRunOverlayStore,
|
||||
} from '@/stores/cron-live-run-overlay';
|
||||
import { LoadingSpinner } from '@/components/common/LoadingSpinner';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
@@ -33,6 +38,7 @@ import { ChatInput, type ChatWorkspaceOption, type FileAttachment } from './Chat
|
||||
import { ChatToolbar } from './ChatToolbar';
|
||||
import { AcpTimeline } from './AcpTimeline';
|
||||
import { AcpErrorBanner } from './AcpErrorBanner';
|
||||
import { CronLiveRunOverlay } from './CronLiveRunOverlay';
|
||||
|
||||
const ArtifactPanelLazy = lazy(() =>
|
||||
import('@/components/file-preview/ArtifactPanel').then((m) => ({ default: m.ArtifactPanel })),
|
||||
@@ -61,6 +67,11 @@ type WorkspaceContextCheck = {
|
||||
available: boolean;
|
||||
};
|
||||
|
||||
type AcpLoadClaim = {
|
||||
key: string;
|
||||
token: symbol;
|
||||
};
|
||||
|
||||
function buildQuestionDirectoryTitle(item: MessageSegmentItem, fallback: string): string {
|
||||
const markdown = item.parts.find(
|
||||
(part): part is Extract<RenderPart, { kind: 'markdown' }> => part.kind === 'markdown' && part.text.trim().length > 0,
|
||||
@@ -175,10 +186,18 @@ function WorkspaceUnavailableBanner({
|
||||
|
||||
export function Chat() {
|
||||
ensureAcpChatSubscriptions();
|
||||
ensureCronLiveRunOverlaySubscriptions();
|
||||
|
||||
const { t } = useTranslation('chat');
|
||||
|
||||
const currentSessionKey = useChatStore((s) => s.currentSessionKey);
|
||||
const cronLiveRunSnapshots = useCronLiveRunOverlayStore((s) => s.snapshots);
|
||||
const pendingCronLiveRunRemovals = useCronLiveRunOverlayStore((s) => s.pendingRemovals);
|
||||
const acknowledgeCronLiveRunRemoval = useCronLiveRunOverlayStore((s) => s.acknowledgeRemoval);
|
||||
const visibleCronLiveRuns = selectCronLiveRunsForSession(
|
||||
{ snapshots: cronLiveRunSnapshots },
|
||||
currentSessionKey,
|
||||
);
|
||||
const sessions = useChatStore((s) => s.sessions);
|
||||
const sessionLabels = useChatStore((s) => s.sessionLabels);
|
||||
const currentAgentId = useChatStore((s) => s.currentAgentId);
|
||||
@@ -278,12 +297,29 @@ export function Chat() {
|
||||
const panelWidthPct = useArtifactPanel((s) => s.widthPct);
|
||||
const closeArtifactPanel = useArtifactPanel((s) => s.close);
|
||||
const splitContainerRef = useRef<HTMLDivElement | null>(null);
|
||||
const acpLoadInFlightKeyRef = useRef<string | null>(null);
|
||||
const acpLoadInFlightKeyRef = useRef<AcpLoadClaim | null>(null);
|
||||
const [acpLoadCompletionEpoch, setAcpLoadCompletionEpoch] = useState(0);
|
||||
const renderedCronRunIdsRef = useRef({
|
||||
sessionKey: currentSessionKey,
|
||||
runIds: new Set<string>(),
|
||||
});
|
||||
const processedCronRemovalRevisionsRef = useRef(new Set<number>());
|
||||
const { contentRef, scrollRef, scrollToBottom, isAtBottom } = useStickToBottomInstant(
|
||||
currentSessionKey,
|
||||
acpSending || acpCancelling,
|
||||
acpSending || acpCancelling || visibleCronLiveRuns.length > 0,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (renderedCronRunIdsRef.current.sessionKey !== currentSessionKey) {
|
||||
renderedCronRunIdsRef.current = {
|
||||
sessionKey: currentSessionKey,
|
||||
runIds: new Set<string>(),
|
||||
};
|
||||
}
|
||||
const renderedRuns = renderedCronRunIdsRef.current;
|
||||
for (const snapshot of visibleCronLiveRuns) renderedRuns.runIds.add(snapshot.runId);
|
||||
}, [currentSessionKey, visibleCronLiveRuns]);
|
||||
|
||||
useEffect(() => {
|
||||
setVisibleSession(currentSessionKey);
|
||||
return () => setVisibleSession(null);
|
||||
@@ -340,6 +376,70 @@ export function Chat() {
|
||||
&& workspaceContextCheck?.key === workspaceContextKey
|
||||
&& !workspaceContextCheck.available;
|
||||
|
||||
useEffect(() => {
|
||||
const removals = [...pendingCronLiveRunRemovals].sort(
|
||||
(left, right) => left.revision - right.revision,
|
||||
);
|
||||
const pendingRevisions = new Set(removals.map((removal) => removal.revision));
|
||||
const processedRevisions = processedCronRemovalRevisionsRef.current;
|
||||
for (const revision of processedRevisions) {
|
||||
if (!pendingRevisions.has(revision)) processedRevisions.delete(revision);
|
||||
}
|
||||
|
||||
let terminalLoadStarted = false;
|
||||
for (const removal of removals) {
|
||||
if (processedRevisions.has(removal.revision)) continue;
|
||||
|
||||
const renderedRuns = renderedCronRunIdsRef.current;
|
||||
const shouldRefresh = removal.reason === 'ended'
|
||||
&& removal.canonicalSessionKey === currentSessionKey
|
||||
&& renderedRuns.sessionKey === currentSessionKey
|
||||
&& renderedRuns.runIds.has(removal.runId);
|
||||
if (!shouldRefresh) {
|
||||
processedRevisions.add(removal.revision);
|
||||
acknowledgeCronLiveRunRemoval(removal.revision);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (acpSending || acpCancelling || !cwd || !workspaceContextAvailable || terminalLoadStarted) continue;
|
||||
const acpLoadKey = `${currentSessionKey}\0${cwd}`;
|
||||
if (acpLoadInFlightKeyRef.current?.key.startsWith(`${currentSessionKey}\0`)) continue;
|
||||
|
||||
terminalLoadStarted = true;
|
||||
const acpLoadClaim = { key: acpLoadKey, token: Symbol(acpLoadKey) };
|
||||
acpLoadInFlightKeyRef.current = acpLoadClaim;
|
||||
processedRevisions.add(removal.revision);
|
||||
acknowledgeCronLiveRunRemoval(removal.revision);
|
||||
|
||||
let terminalLoad: Promise<boolean>;
|
||||
try {
|
||||
terminalLoad = loadAcpSession({ sessionKey: currentSessionKey, workspaceRoot: cwd, cwd });
|
||||
} catch {
|
||||
if (acpLoadInFlightKeyRef.current === acpLoadClaim) {
|
||||
acpLoadInFlightKeyRef.current = null;
|
||||
setAcpLoadCompletionEpoch((epoch) => epoch + 1);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
void terminalLoad.catch(() => false).finally(() => {
|
||||
if (acpLoadInFlightKeyRef.current === acpLoadClaim) {
|
||||
acpLoadInFlightKeyRef.current = null;
|
||||
setAcpLoadCompletionEpoch((epoch) => epoch + 1);
|
||||
}
|
||||
});
|
||||
}
|
||||
}, [
|
||||
acknowledgeCronLiveRunRemoval,
|
||||
acpCancelling,
|
||||
acpLoadCompletionEpoch,
|
||||
acpSending,
|
||||
currentSessionKey,
|
||||
cwd,
|
||||
loadAcpSession,
|
||||
pendingCronLiveRunRemovals,
|
||||
workspaceContextAvailable,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (currentSessionKey !== DEFAULT_SESSION_KEY || sessions.length > 0 || sessionDiscoveryAttempted) return;
|
||||
let cancelled = false;
|
||||
@@ -366,11 +466,12 @@ export function Chat() {
|
||||
if (currentSessionKey === DEFAULT_SESSION_KEY && sessions.length === 0 && acpActiveSessionKey == null && !sessionDiscoveryAttempted) return;
|
||||
if (acpActiveSessionKey === currentSessionKey && acpWorkspaceRoot === cwd && acpCwd === cwd) return;
|
||||
const acpLoadKey = `${currentSessionKey}\0${cwd}`;
|
||||
if (acpLoadInFlightKeyRef.current === acpLoadKey) return;
|
||||
if (acpLoadInFlightKeyRef.current?.key === acpLoadKey) return;
|
||||
const currentSession = sessions.find((session) => session.key === currentSessionKey);
|
||||
if (currentSession?.createdLocally) return;
|
||||
const createIfMissing = !currentSession;
|
||||
acpLoadInFlightKeyRef.current = acpLoadKey;
|
||||
const acpLoadClaim = { key: acpLoadKey, token: Symbol(acpLoadKey) };
|
||||
acpLoadInFlightKeyRef.current = acpLoadClaim;
|
||||
void loadAcpSession({
|
||||
sessionKey: currentSessionKey,
|
||||
workspaceRoot: cwd,
|
||||
@@ -381,8 +482,9 @@ export function Chat() {
|
||||
acknowledgeAcpSessionCreated(currentSessionKey);
|
||||
}
|
||||
}).finally(() => {
|
||||
if (acpLoadInFlightKeyRef.current === acpLoadKey) {
|
||||
if (acpLoadInFlightKeyRef.current === acpLoadClaim) {
|
||||
acpLoadInFlightKeyRef.current = null;
|
||||
setAcpLoadCompletionEpoch((epoch) => epoch + 1);
|
||||
}
|
||||
});
|
||||
}, [acknowledgeAcpSessionCreated, acpActiveSessionKey, acpCwd, acpWorkspaceRoot, currentSessionKey, cwd, loadAcpSession, sessionDiscoveryAttempted, sessions, workspaceContextAvailable]);
|
||||
@@ -391,7 +493,7 @@ export function Chat() {
|
||||
const isMac = platform === 'darwin';
|
||||
const isWindows = platform === 'win32';
|
||||
const composerBusy = acpSending || acpCancelling;
|
||||
const showScrollToLatest = acpTimeline.itemOrder.length > 0 && !isAtBottom;
|
||||
const showScrollToLatest = (acpTimeline.itemOrder.length > 0 || visibleCronLiveRuns.length > 0) && !isAtBottom;
|
||||
const hasAttemptedAcpPromptForCurrentSession = lastPromptAttemptSessionKey === currentSessionKey;
|
||||
const visibleAcpError = !workspaceUnavailable && acpError
|
||||
&& !(acpTimeline.itemOrder.length === 0 && !hasAttemptedAcpPromptForCurrentSession && isRecoverableInitialAcpLoadError(acpError))
|
||||
@@ -491,13 +593,15 @@ export function Chat() {
|
||||
/>
|
||||
)}
|
||||
{visibleAcpError && <AcpErrorBanner message={visibleAcpError} onDismiss={clearAcpError} />}
|
||||
{acpLoading ? (
|
||||
{acpLoading && (
|
||||
<div className="flex min-h-[40vh] items-center justify-center" data-testid="acp-chat-loading">
|
||||
<LoadingSpinner size="md" />
|
||||
</div>
|
||||
) : acpTimeline.itemOrder.length === 0 ? (
|
||||
)}
|
||||
{!acpLoading && acpTimeline.itemOrder.length === 0 && visibleCronLiveRuns.length === 0 && (
|
||||
<AcpEmptyState />
|
||||
) : (
|
||||
)}
|
||||
{!acpLoading && acpTimeline.itemOrder.length > 0 && (
|
||||
<AcpTimeline
|
||||
snapshot={acpTimeline}
|
||||
turnTimingsByUserMessageId={acpTurnTimings}
|
||||
@@ -510,6 +614,12 @@ export function Chat() {
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{visibleCronLiveRuns.map((snapshot) => (
|
||||
<CronLiveRunOverlay
|
||||
key={`${snapshot.canonicalSessionKey}:${snapshot.runId}`}
|
||||
snapshot={snapshot}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -571,7 +681,8 @@ export function Chat() {
|
||||
|| acpCwd !== promptCwd
|
||||
) {
|
||||
const acpLoadKey = `${sessionKey}\0${promptCwd}`;
|
||||
acpLoadInFlightKeyRef.current = acpLoadKey;
|
||||
const acpLoadClaim = { key: acpLoadKey, token: Symbol(acpLoadKey) };
|
||||
acpLoadInFlightKeyRef.current = acpLoadClaim;
|
||||
const loaded = await (async () => {
|
||||
try {
|
||||
return await loadAcpSession({
|
||||
@@ -581,8 +692,9 @@ export function Chat() {
|
||||
...(createIfMissing ? { createIfMissing: true } : {}),
|
||||
});
|
||||
} finally {
|
||||
if (acpLoadInFlightKeyRef.current === acpLoadKey) {
|
||||
if (acpLoadInFlightKeyRef.current === acpLoadClaim) {
|
||||
acpLoadInFlightKeyRef.current = null;
|
||||
setAcpLoadCompletionEpoch((epoch) => epoch + 1);
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
@@ -44,7 +44,7 @@ import { buildCronHistoryAcpNotifications, fetchCronSessionHistory } from '@/lib
|
||||
import { hostApi } from '@/lib/host-api';
|
||||
import { hostEvents } from '@/lib/host-events';
|
||||
import type { AcpTimelineSnapshot, MessageSegmentItem, PermissionItem, RenderPart } from '@/lib/acp/timeline-types';
|
||||
import { isCronSessionKey } from './chat/cron-session-utils';
|
||||
import { isCronSessionKey } from '@shared/chat/cron-session';
|
||||
|
||||
const EMPTY_SESSION_ID = '';
|
||||
const CANCEL_PERMISSION_OPTION_ID = '__cancelled__';
|
||||
|
||||
+1
-1
@@ -14,7 +14,7 @@ import { useGatewayStore } from './gateway';
|
||||
import { useAgentsStore } from './agents';
|
||||
import type { ChatRuntimeEvent } from '../../shared/chat-runtime-events';
|
||||
import { buildBaselineRunKey, captureBaseline, clearBaselines } from './baseline-cache';
|
||||
import { isCronSessionKey, sessionKeysAreEquivalent } from './chat/cron-session-utils';
|
||||
import { isCronSessionKey, sessionKeysAreEquivalent } from '@shared/chat/cron-session';
|
||||
import {
|
||||
findHiddenOpenClawHeartbeatSession,
|
||||
isClawXDesktopSessionKey,
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
export interface CronSessionKeyParts {
|
||||
agentId: string;
|
||||
jobId: string;
|
||||
runSessionId?: string;
|
||||
}
|
||||
|
||||
export function parseCronSessionKey(sessionKey: string): CronSessionKeyParts | null {
|
||||
if (!sessionKey.startsWith('agent:')) return null;
|
||||
const parts = sessionKey.split(':');
|
||||
if (parts.length < 4 || parts[2] !== 'cron') return null;
|
||||
|
||||
const agentId = parts[1] || 'main';
|
||||
const jobId = parts[3];
|
||||
if (!jobId) return null;
|
||||
|
||||
if (parts.length === 4) {
|
||||
return { agentId, jobId };
|
||||
}
|
||||
|
||||
if (parts.length === 6 && parts[4] === 'run' && parts[5]) {
|
||||
return { agentId, jobId, runSessionId: parts[5] };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function isCronSessionKey(sessionKey: string): boolean {
|
||||
return parseCronSessionKey(sessionKey) != null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collapse a run-scoped cron session key
|
||||
* (`agent:<id>:cron:<jobId>:run:<sessionId>`) down to the base cron key
|
||||
* (`agent:<id>:cron:<jobId>`) the sidebar/UI tracks. Non-cron keys and base
|
||||
* cron keys are returned unchanged.
|
||||
*/
|
||||
export function getCronSessionBaseKey(sessionKey: string): string {
|
||||
const parts = parseCronSessionKey(sessionKey);
|
||||
if (!parts) return sessionKey;
|
||||
return `agent:${parts.agentId}:cron:${parts.jobId}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether two session keys refer to the same logical chat session. Plain keys
|
||||
* match by exact equality; cron keys also match across the base key and any of
|
||||
* its run-scoped variants so Gateway runtime events streamed under
|
||||
* `...:run:<sessionId>` bind to the base cron session the user is viewing.
|
||||
*/
|
||||
export function sessionKeysAreEquivalent(
|
||||
a: string | null | undefined,
|
||||
b: string | null | undefined,
|
||||
): boolean {
|
||||
if (a == null || b == null) return false;
|
||||
if (a === b) return true;
|
||||
const parsedA = parseCronSessionKey(a);
|
||||
const parsedB = parseCronSessionKey(b);
|
||||
if (!parsedA || !parsedB) return false;
|
||||
return parsedA.agentId === parsedB.agentId && parsedA.jobId === parsedB.jobId;
|
||||
}
|
||||
|
||||
export function buildCronSessionHistoryPath(sessionKey: string, limit = 200): string {
|
||||
const params = new URLSearchParams({ sessionKey });
|
||||
if (Number.isFinite(limit) && limit > 0) {
|
||||
params.set('limit', String(Math.floor(limit)));
|
||||
}
|
||||
return `/api/cron/session-history?${params.toString()}`;
|
||||
}
|
||||
@@ -21,7 +21,7 @@ import {
|
||||
setLastChatEventAt,
|
||||
toMs,
|
||||
} from './helpers';
|
||||
import { isCronSessionKey } from './cron-session-utils';
|
||||
import { isCronSessionKey } from '@shared/chat/cron-session';
|
||||
import {
|
||||
CHAT_HISTORY_STARTUP_RETRY_DELAYS_MS,
|
||||
classifyHistoryStartupRetryError,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { ChatSession, GatewaySessionsChangedPayload } from './types';
|
||||
import { parseCronSessionKey } from './cron-session-utils';
|
||||
import { parseCronSessionKey } from '@shared/chat/cron-session';
|
||||
import { shouldIncludeSessionInSidebarList } from './session-key-utils';
|
||||
|
||||
export type { GatewaySessionsChangedPayload } from './types';
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
isOpenClawHeartbeatAckText,
|
||||
OPENCLAW_HEARTBEAT_POLL_SENTINEL,
|
||||
} from '@shared/chat/openclaw-internal';
|
||||
import { isCronSessionKey } from './cron-session-utils';
|
||||
import { isCronSessionKey } from '@shared/chat/cron-session';
|
||||
import type { ChatSession } from './types';
|
||||
|
||||
const CHANNEL_SESSION_SEGMENTS = new Set<string>(Object.keys(CHANNEL_NAMES));
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { isCronSessionKey } from './cron-session-utils';
|
||||
import { isCronSessionKey } from '@shared/chat/cron-session';
|
||||
import { isChannelSessionKey } from './session-key-utils';
|
||||
import type { ChatSession } from './types';
|
||||
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
import { create } from 'zustand';
|
||||
import type {
|
||||
CronLiveRunOverlayChange,
|
||||
CronLiveRunOverlaySnapshot,
|
||||
CronLiveRunOverlaySnapshotSet,
|
||||
} from '@shared/chat/cron-live-run';
|
||||
import { hostApi } from '@/lib/host-api';
|
||||
import { hostEvents } from '@/lib/host-events';
|
||||
|
||||
const MAX_PENDING_REMOVALS = 128;
|
||||
const MAX_PROCESSED_CHANGE_REVISIONS = 128;
|
||||
const processedChangeRevisions = new Set<number>();
|
||||
const processedChangeRevisionOrder: number[] = [];
|
||||
|
||||
export type CronLiveRunOverlayRemoval = Extract<
|
||||
CronLiveRunOverlayChange,
|
||||
{ kind: 'remove' }
|
||||
>;
|
||||
|
||||
export interface CronLiveRunOverlayState {
|
||||
revision: number;
|
||||
snapshots: CronLiveRunOverlaySnapshot[];
|
||||
pendingRemovals: CronLiveRunOverlayRemoval[];
|
||||
acknowledgeRemoval: (revision: number) => void;
|
||||
}
|
||||
|
||||
function snapshotKey(snapshot: Pick<CronLiveRunOverlaySnapshot, 'canonicalSessionKey' | 'runId'>): string {
|
||||
return JSON.stringify([snapshot.canonicalSessionKey, snapshot.runId]);
|
||||
}
|
||||
|
||||
function removalKey(removal: CronLiveRunOverlayRemoval): string {
|
||||
return JSON.stringify([removal.canonicalSessionKey, removal.runId, removal.revision]);
|
||||
}
|
||||
|
||||
function compareSnapshots(
|
||||
left: CronLiveRunOverlaySnapshot,
|
||||
right: CronLiveRunOverlaySnapshot,
|
||||
): number {
|
||||
return left.updatedAt - right.updatedAt
|
||||
|| left.runId.localeCompare(right.runId)
|
||||
|| left.canonicalSessionKey.localeCompare(right.canonicalSessionKey);
|
||||
}
|
||||
|
||||
function normalizeSnapshots(snapshots: CronLiveRunOverlaySnapshot[]): CronLiveRunOverlaySnapshot[] {
|
||||
const byKey = new Map<string, CronLiveRunOverlaySnapshot>();
|
||||
for (const snapshot of snapshots) byKey.set(snapshotKey(snapshot), snapshot);
|
||||
return [...byKey.values()].sort(compareSnapshots);
|
||||
}
|
||||
|
||||
export const useCronLiveRunOverlayStore = create<CronLiveRunOverlayState>((set) => ({
|
||||
revision: 0,
|
||||
snapshots: [],
|
||||
pendingRemovals: [],
|
||||
|
||||
acknowledgeRemoval(revision) {
|
||||
set((state) => {
|
||||
const index = state.pendingRemovals.findIndex((removal) => removal.revision === revision);
|
||||
if (index < 0) return {};
|
||||
return {
|
||||
pendingRemovals: [
|
||||
...state.pendingRemovals.slice(0, index),
|
||||
...state.pendingRemovals.slice(index + 1),
|
||||
],
|
||||
};
|
||||
});
|
||||
},
|
||||
}));
|
||||
|
||||
function applySnapshotSet(snapshotSet: CronLiveRunOverlaySnapshotSet): void {
|
||||
useCronLiveRunOverlayStore.setState((state) => {
|
||||
if (snapshotSet.revision < state.revision) return {};
|
||||
return {
|
||||
revision: snapshotSet.revision,
|
||||
snapshots: normalizeSnapshots(snapshotSet.snapshots),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function applyChange(change: CronLiveRunOverlayChange): void {
|
||||
useCronLiveRunOverlayStore.setState((state) => {
|
||||
if (
|
||||
change.revision < state.revision
|
||||
|| processedChangeRevisions.has(change.revision)
|
||||
) return {};
|
||||
|
||||
processedChangeRevisions.add(change.revision);
|
||||
processedChangeRevisionOrder.push(change.revision);
|
||||
if (processedChangeRevisionOrder.length > MAX_PROCESSED_CHANGE_REVISIONS) {
|
||||
const oldestRevision = processedChangeRevisionOrder.shift();
|
||||
if (oldestRevision !== undefined) processedChangeRevisions.delete(oldestRevision);
|
||||
}
|
||||
|
||||
if (change.kind === 'upsert') {
|
||||
const key = snapshotKey(change.snapshot);
|
||||
return {
|
||||
revision: change.revision,
|
||||
snapshots: normalizeSnapshots([
|
||||
...state.snapshots.filter((snapshot) => snapshotKey(snapshot) !== key),
|
||||
change.snapshot,
|
||||
]),
|
||||
};
|
||||
}
|
||||
|
||||
const key = snapshotKey(change);
|
||||
const pendingKey = removalKey(change);
|
||||
const pendingRemovals = state.pendingRemovals.some(
|
||||
(removal) => removalKey(removal) === pendingKey,
|
||||
)
|
||||
? state.pendingRemovals
|
||||
: [...state.pendingRemovals, change]
|
||||
.sort((left, right) => left.revision - right.revision)
|
||||
.slice(-MAX_PENDING_REMOVALS);
|
||||
return {
|
||||
revision: change.revision,
|
||||
snapshots: state.snapshots.filter((snapshot) => snapshotKey(snapshot) !== key),
|
||||
pendingRemovals,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
let subscribed = false;
|
||||
let hydrationComplete = false;
|
||||
let hydrationInFlight: Promise<void> | null = null;
|
||||
|
||||
export function ensureCronLiveRunOverlaySubscriptions(): void {
|
||||
if (!subscribed) {
|
||||
subscribed = true;
|
||||
hostEvents.onCronLiveRunOverlayChanged(applyChange);
|
||||
}
|
||||
if (hydrationComplete || hydrationInFlight) return;
|
||||
|
||||
let request: Promise<CronLiveRunOverlaySnapshotSet>;
|
||||
try {
|
||||
request = hostApi.cron.liveRunOverlays();
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
const hydration = request.then(
|
||||
(snapshotSet) => {
|
||||
applySnapshotSet(snapshotSet);
|
||||
hydrationComplete = true;
|
||||
},
|
||||
() => undefined,
|
||||
);
|
||||
hydrationInFlight = hydration;
|
||||
void hydration.then(() => {
|
||||
if (hydrationInFlight === hydration) hydrationInFlight = null;
|
||||
});
|
||||
}
|
||||
|
||||
export function selectCronLiveRunsForSession(
|
||||
state: Pick<CronLiveRunOverlayState, 'snapshots'>,
|
||||
sessionKey: string | null | undefined,
|
||||
): CronLiveRunOverlaySnapshot[] {
|
||||
if (!sessionKey) return [];
|
||||
return state.snapshots.filter((snapshot) => snapshot.canonicalSessionKey === sessionKey);
|
||||
}
|
||||
@@ -8,7 +8,7 @@ import { hostEvents } from '@/lib/host-events';
|
||||
import type { GatewayNotification, GatewayHealth, GatewayStatus } from '../types/gateway';
|
||||
import type { ChatRuntimeEvent } from '../../shared/chat-runtime-events';
|
||||
import type { GatewaySessionsChangedPayload } from './chat/session-catalog';
|
||||
import { getCronSessionBaseKey, sessionKeysAreEquivalent } from './chat/cron-session-utils';
|
||||
import { getCronSessionBaseKey, sessionKeysAreEquivalent } from '@shared/chat/cron-session';
|
||||
|
||||
let gatewayInitPromise: Promise<void> | null = null;
|
||||
let gatewayEventUnsubscribers: Array<() => void> | null = null;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
import { parseCronSessionKey } from './chat/cron-session-utils';
|
||||
import { parseCronSessionKey } from '@shared/chat/cron-session';
|
||||
import { projectSessionRunState } from './chat/session-status';
|
||||
import type { ChatSession } from './chat/types';
|
||||
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import type { ElectronApplication } from '@playwright/test';
|
||||
import type {
|
||||
CronLiveRunOverlayChange,
|
||||
CronLiveRunOverlaySnapshot,
|
||||
CronLiveRunOverlaySnapshotSet,
|
||||
} from '../../shared/chat/cron-live-run';
|
||||
import {
|
||||
closeElectronApp,
|
||||
expect,
|
||||
@@ -10,10 +15,13 @@ import {
|
||||
|
||||
const MAIN_SESSION_KEY = 'agent:main:main';
|
||||
const CRON_BASE_KEY = 'agent:main:cron:job-cron-live';
|
||||
const CRON_TRIGGER_TEXT = '[cron:job-cron-live] Summarize today important AI news';
|
||||
const CRON_RUN_KEY = `${CRON_BASE_KEY}:run:run-live-1`;
|
||||
const DEFAULT_WORKSPACE = '~/.openclaw/workspace';
|
||||
|
||||
type AcpSessionUpdate = Record<string, unknown> & { sessionUpdate: string };
|
||||
const EXPECTED_CRON_LOAD_PAYLOAD = {
|
||||
sessionKey: CRON_BASE_KEY,
|
||||
workspaceRoot: DEFAULT_WORKSPACE,
|
||||
cwd: DEFAULT_WORKSPACE,
|
||||
};
|
||||
|
||||
function stableStringify(value: unknown): string {
|
||||
if (value == null || typeof value !== 'object') return JSON.stringify(value);
|
||||
@@ -24,12 +32,6 @@ function stableStringify(value: unknown): string {
|
||||
return `{${entries.join(',')}}`;
|
||||
}
|
||||
|
||||
const cronTriggerUpdate: AcpSessionUpdate = {
|
||||
sessionUpdate: 'user_message',
|
||||
messageId: 'cron-trigger',
|
||||
content: [{ type: 'text', text: CRON_TRIGGER_TEXT }],
|
||||
};
|
||||
|
||||
function acpLoadMocks(sessionKey: string) {
|
||||
return {
|
||||
[stableStringify(['chat', 'loadAcpSession', { sessionKey, workspaceRoot: DEFAULT_WORKSPACE, cwd: DEFAULT_WORKSPACE }])]: {
|
||||
@@ -43,125 +45,287 @@ function acpLoadMocks(sessionKey: string) {
|
||||
};
|
||||
}
|
||||
|
||||
async function emitAcpSessionUpdates(
|
||||
function cronLiveRunSnapshot(
|
||||
revision: number,
|
||||
overrides: Partial<CronLiveRunOverlaySnapshot> = {},
|
||||
): CronLiveRunOverlaySnapshot {
|
||||
return {
|
||||
canonicalSessionKey: CRON_BASE_KEY,
|
||||
sourceSessionKey: CRON_RUN_KEY,
|
||||
runSessionId: 'run-live-1',
|
||||
runId: 'gateway-run-live-1',
|
||||
revision,
|
||||
status: 'running',
|
||||
startedAt: 1_786_000_000_000,
|
||||
updatedAt: 1_786_000_001_000,
|
||||
assistantText: '',
|
||||
thinking: false,
|
||||
items: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function cronOverlayMock(snapshotSet: CronLiveRunOverlaySnapshotSet) {
|
||||
return {
|
||||
[stableStringify(['cron', 'liveRunOverlays', null])]: snapshotSet,
|
||||
};
|
||||
}
|
||||
|
||||
async function emitCronLiveRunOverlayChange(
|
||||
app: ElectronApplication,
|
||||
sessionKey: string,
|
||||
updates: AcpSessionUpdate[],
|
||||
historical = false,
|
||||
change: CronLiveRunOverlayChange,
|
||||
) {
|
||||
await app.evaluate(
|
||||
async ({ app: _app }, payload) => {
|
||||
const { BrowserWindow } = process.mainModule!.require('electron') as typeof import('electron');
|
||||
for (const update of payload.updates) {
|
||||
for (const window of BrowserWindow.getAllWindows()) {
|
||||
window.webContents.send('chat:acp-session-update', {
|
||||
sessionKey: payload.sessionKey,
|
||||
generation: 1,
|
||||
...(payload.historical ? { historical: true } : {}),
|
||||
notification: {
|
||||
sessionId: payload.sessionKey,
|
||||
update,
|
||||
},
|
||||
});
|
||||
}
|
||||
for (const window of BrowserWindow.getAllWindows()) {
|
||||
window.webContents.send('cron:live-run-overlay-changed', payload);
|
||||
}
|
||||
},
|
||||
{ sessionKey, updates, historical },
|
||||
change,
|
||||
);
|
||||
}
|
||||
|
||||
function countAcpLoads(
|
||||
calls: Awaited<ReturnType<typeof getRecordedHostInvocations>>,
|
||||
sessionKey: string,
|
||||
): number {
|
||||
return calls.filter((call) => (
|
||||
call.module === 'chat'
|
||||
&& call.action === 'loadAcpSession'
|
||||
&& call.payload?.sessionKey === sessionKey
|
||||
)).length;
|
||||
}
|
||||
|
||||
async function reloadMainWindow(app: ElectronApplication) {
|
||||
const page = await getStableWindow(app);
|
||||
try {
|
||||
await page.reload();
|
||||
} catch (error) {
|
||||
if (!String(error).includes('ERR_FILE_NOT_FOUND')) throw error;
|
||||
}
|
||||
await expect(page.getByTestId('main-layout')).toBeVisible({ timeout: 30_000 });
|
||||
return page;
|
||||
}
|
||||
|
||||
function sessionListMock(cronLabel: string) {
|
||||
return {
|
||||
[stableStringify(['sessions.list', {}])]: {
|
||||
success: true,
|
||||
result: {
|
||||
sessions: [
|
||||
{ key: MAIN_SESSION_KEY, displayName: 'main' },
|
||||
{
|
||||
key: CRON_BASE_KEY,
|
||||
displayName: cronLabel,
|
||||
label: cronLabel,
|
||||
updatedAt: Date.now(),
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function commonHostMocks(snapshotSet: CronLiveRunOverlaySnapshotSet) {
|
||||
return {
|
||||
...acpLoadMocks(MAIN_SESSION_KEY),
|
||||
...acpLoadMocks(CRON_BASE_KEY),
|
||||
...cronOverlayMock(snapshotSet),
|
||||
[stableStringify(['/api/gateway/status', 'GET'])]: {
|
||||
ok: true,
|
||||
data: {
|
||||
status: 200,
|
||||
ok: true,
|
||||
json: { state: 'running', port: 18789, pid: 12345, gatewayReady: true },
|
||||
},
|
||||
},
|
||||
[stableStringify(['/api/agents', 'GET'])]: {
|
||||
ok: true,
|
||||
data: {
|
||||
status: 200,
|
||||
ok: true,
|
||||
json: {
|
||||
success: true,
|
||||
agents: [{
|
||||
id: 'main',
|
||||
name: 'Main',
|
||||
workspace: DEFAULT_WORKSPACE,
|
||||
mainSessionKey: MAIN_SESSION_KEY,
|
||||
}],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test.describe('ClawX cron run live status', () => {
|
||||
test('renders ACP live status for a cron run without switching sessions', async ({ launchElectronApp }) => {
|
||||
test('renders typed live progress outside ACP and reloads authoritative history once on terminal removal', async ({ launchElectronApp }) => {
|
||||
const app = await launchElectronApp({ skipSetup: true });
|
||||
|
||||
try {
|
||||
const cronSession = {
|
||||
key: CRON_BASE_KEY,
|
||||
displayName: 'Cron: 早报',
|
||||
label: 'Cron: 早报',
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
|
||||
await installIpcMocks(app, {
|
||||
recordHostInvocations: true,
|
||||
failUnmatchedHostApiActions: ['chat.loadAcpSession'],
|
||||
gatewayStatus: { state: 'running', port: 18789, pid: 12345, gatewayReady: true },
|
||||
gatewayRpc: {
|
||||
[stableStringify(['sessions.list', {}])]: {
|
||||
success: true,
|
||||
result: {
|
||||
sessions: [
|
||||
{ key: MAIN_SESSION_KEY, displayName: 'main' },
|
||||
cronSession,
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
hostApi: {
|
||||
...acpLoadMocks(MAIN_SESSION_KEY),
|
||||
...acpLoadMocks(CRON_BASE_KEY),
|
||||
[stableStringify(['/api/gateway/status', 'GET'])]: {
|
||||
ok: true,
|
||||
data: {
|
||||
status: 200,
|
||||
ok: true,
|
||||
json: { state: 'running', port: 18789, pid: 12345, gatewayReady: true },
|
||||
},
|
||||
},
|
||||
[stableStringify(['/api/agents', 'GET'])]: {
|
||||
ok: true,
|
||||
data: {
|
||||
status: 200,
|
||||
ok: true,
|
||||
json: { success: true, agents: [{ id: 'main', name: 'Main', workspace: DEFAULT_WORKSPACE, mainSessionKey: MAIN_SESSION_KEY }] },
|
||||
},
|
||||
},
|
||||
},
|
||||
gatewayRpc: sessionListMock('Cron: Morning brief'),
|
||||
hostApi: commonHostMocks({ revision: 0, snapshots: [] }),
|
||||
});
|
||||
|
||||
const page = await getStableWindow(app);
|
||||
try {
|
||||
await page.reload();
|
||||
} catch (error) {
|
||||
if (!String(error).includes('ERR_FILE_NOT_FOUND')) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
const page = await reloadMainWindow(app);
|
||||
const cronSidebarButton = page.getByTestId(`sidebar-session-${CRON_BASE_KEY}`);
|
||||
await expect(cronSidebarButton).toBeVisible({ timeout: 30_000 });
|
||||
await cronSidebarButton.click();
|
||||
await expect(page.getByTestId('acp-chat-empty-state')).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
await expect(page.getByTestId('main-layout')).toBeVisible({ timeout: 30_000 });
|
||||
const assistantText = 'Collected **three** authoritative sources.';
|
||||
await emitCronLiveRunOverlayChange(app, {
|
||||
kind: 'upsert',
|
||||
revision: 1,
|
||||
snapshot: cronLiveRunSnapshot(1, {
|
||||
assistantText,
|
||||
thinking: true,
|
||||
items: [
|
||||
{
|
||||
kind: 'tool',
|
||||
id: 'gateway-run-live-1:tool:web-search',
|
||||
toolCallId: 'web-search',
|
||||
title: 'web_search',
|
||||
status: 'completed',
|
||||
inputText: 'AI news August 2026',
|
||||
outputText: 'Three sources found',
|
||||
},
|
||||
{
|
||||
kind: 'command',
|
||||
id: 'gateway-run-live-1:command:collect',
|
||||
title: 'Collect headlines',
|
||||
status: 'running',
|
||||
output: 'source one\n source two',
|
||||
},
|
||||
{
|
||||
kind: 'patch',
|
||||
id: 'gateway-run-live-1:patch:brief',
|
||||
title: 'Update morning brief',
|
||||
summary: 'Prepared the digest',
|
||||
added: 3,
|
||||
modified: 1,
|
||||
deleted: 0,
|
||||
},
|
||||
{
|
||||
kind: 'approval',
|
||||
id: 'gateway-run-live-1:approval:publish',
|
||||
title: 'Publish digest',
|
||||
status: 'running',
|
||||
message: 'Waiting for external approval',
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
// Open the cron session (default startup lands on the main session).
|
||||
const overlay = page.getByTestId('cron-live-run-overlay');
|
||||
await expect(overlay).toBeVisible({ timeout: 30_000 });
|
||||
await expect(overlay.getByTestId('cron-live-assistant')).toContainText('Collected three authoritative sources.');
|
||||
await expect(overlay.getByTestId('cron-live-thinking')).toContainText('Thinking');
|
||||
await expect(overlay.getByTestId('cron-live-tool')).toContainText('web_search');
|
||||
await expect(overlay.getByTestId('cron-live-command')).toContainText('Collect headlines');
|
||||
await expect(overlay.getByTestId('cron-live-command-output')).toHaveText('source one\n source two');
|
||||
await expect(overlay.getByTestId('cron-live-patch')).toContainText('Prepared the digest');
|
||||
const approval = overlay.getByTestId('cron-live-approval');
|
||||
await expect(approval).toContainText('Waiting for external approval');
|
||||
await expect(approval).toContainText('Read-only status. Respond in the originating client.');
|
||||
await expect(approval.locator('button, input, select, textarea, [role="button"], [role="checkbox"], [role="radio"], [role="switch"]')).toHaveCount(0);
|
||||
await expect(page.getByTestId('chat-execution-graph')).toHaveCount(0);
|
||||
await expect(page.getByTestId('acp-tool-call-card')).toHaveCount(0);
|
||||
await expect(page.getByTestId('acp-chat-timeline').getByText('Collected three authoritative sources.')).toHaveCount(0);
|
||||
|
||||
const composerAction = page.getByTestId('chat-composer-send');
|
||||
await expect(composerAction).toHaveAttribute('title', 'Send');
|
||||
let invocations = await getRecordedHostInvocations(app);
|
||||
expect(invocations.some((call) => call.module === 'chat' && call.action === 'cancelAcpSession')).toBe(false);
|
||||
expect(invocations.some((call) => call.module === 'chat' && call.action === 'sendAcpPrompt')).toBe(false);
|
||||
expect(invocations.some((call) => call.module === 'chat' && call.action === 'respondAcpPermission')).toBe(false);
|
||||
|
||||
await page.getByTestId(`sidebar-session-${MAIN_SESSION_KEY}`).click();
|
||||
await expect(overlay).toHaveCount(0);
|
||||
await cronSidebarButton.click();
|
||||
await expect(overlay).toBeVisible({ timeout: 30_000 });
|
||||
await expect(overlay.getByTestId('cron-live-assistant')).toContainText('Collected three authoritative sources.');
|
||||
|
||||
await expect.poll(async () => countAcpLoads(await getRecordedHostInvocations(app), CRON_BASE_KEY)).toBeGreaterThan(0);
|
||||
invocations = await getRecordedHostInvocations(app);
|
||||
const loadsBeforeTerminal = countAcpLoads(invocations, CRON_BASE_KEY);
|
||||
|
||||
await emitCronLiveRunOverlayChange(app, {
|
||||
kind: 'remove',
|
||||
revision: 2,
|
||||
canonicalSessionKey: CRON_BASE_KEY,
|
||||
sourceSessionKey: CRON_RUN_KEY,
|
||||
runId: 'gateway-run-live-1',
|
||||
reason: 'ended',
|
||||
terminalStatus: 'completed',
|
||||
});
|
||||
|
||||
await expect(overlay).toHaveCount(0);
|
||||
await expect.poll(async () => countAcpLoads(await getRecordedHostInvocations(app), CRON_BASE_KEY)).toBe(loadsBeforeTerminal + 1);
|
||||
await page.waitForTimeout(300);
|
||||
invocations = await getRecordedHostInvocations(app);
|
||||
const cronLoadsAfterTerminal = invocations.filter((call) => (
|
||||
call.module === 'chat'
|
||||
&& call.action === 'loadAcpSession'
|
||||
&& call.payload?.sessionKey === CRON_BASE_KEY
|
||||
)).slice(loadsBeforeTerminal);
|
||||
expect(cronLoadsAfterTerminal).toEqual([{
|
||||
module: 'chat',
|
||||
action: 'loadAcpSession',
|
||||
payload: EXPECTED_CRON_LOAD_PAYLOAD,
|
||||
}]);
|
||||
expect(invocations.some((call) => call.module === 'chat' && call.action === 'cancelAcpSession')).toBe(false);
|
||||
} finally {
|
||||
await closeElectronApp(app);
|
||||
}
|
||||
});
|
||||
|
||||
test('hydrates a cron run already in progress without a run-start event', async ({ launchElectronApp }) => {
|
||||
const app = await launchElectronApp({ skipSetup: true });
|
||||
const hydratedSnapshot = cronLiveRunSnapshot(7, {
|
||||
assistantText: 'Joined an autonomous run already in progress.',
|
||||
thinking: true,
|
||||
items: [{
|
||||
kind: 'tool',
|
||||
id: 'gateway-run-live-1:tool:read-skill',
|
||||
toolCallId: 'read-skill',
|
||||
title: 'read',
|
||||
status: 'running',
|
||||
inputText: '~/.openclaw/skills/docx/SKILL.md',
|
||||
}],
|
||||
});
|
||||
|
||||
try {
|
||||
await installIpcMocks(app, {
|
||||
recordHostInvocations: true,
|
||||
failUnmatchedHostApiActions: ['chat.loadAcpSession'],
|
||||
gatewayStatus: { state: 'running', port: 18789, pid: 12345, gatewayReady: true },
|
||||
gatewayRpc: sessionListMock('Cron: Mid-flight brief'),
|
||||
hostApi: commonHostMocks({ revision: 7, snapshots: [hydratedSnapshot] }),
|
||||
});
|
||||
|
||||
const page = await reloadMainWindow(app);
|
||||
const cronSidebarButton = page.getByTestId(`sidebar-session-${CRON_BASE_KEY}`);
|
||||
await expect(cronSidebarButton).toBeVisible({ timeout: 30_000 });
|
||||
await cronSidebarButton.click();
|
||||
|
||||
// Transcript replay now arrives through ACP; the legacy execution graph stays absent.
|
||||
await expect(page.getByTestId('acp-chat-empty-state')).toBeVisible({ timeout: 30_000 });
|
||||
await emitAcpSessionUpdates(app, CRON_BASE_KEY, [cronTriggerUpdate], true);
|
||||
await expect(page.getByText(CRON_TRIGGER_TEXT)).toBeVisible({ timeout: 30_000 });
|
||||
await expect(page.getByTestId('chat-execution-graph')).toHaveCount(0);
|
||||
const overlay = page.getByTestId('cron-live-run-overlay');
|
||||
await expect(overlay).toBeVisible({ timeout: 30_000 });
|
||||
await expect(overlay.getByTestId('cron-live-assistant')).toContainText('Joined an autonomous run already in progress.');
|
||||
await expect(overlay.getByTestId('cron-live-tool')).toContainText('read');
|
||||
await expect(overlay.getByTestId('cron-live-thinking')).toBeVisible();
|
||||
await expect(page.getByTestId('acp-chat-timeline').getByText('Joined an autonomous run already in progress.')).toHaveCount(0);
|
||||
await expect(page.getByTestId('chat-composer-send')).toHaveAttribute('title', 'Send');
|
||||
|
||||
await emitAcpSessionUpdates(app, CRON_BASE_KEY, [{
|
||||
sessionUpdate: 'tool_call',
|
||||
toolCallId: 'call-web-search',
|
||||
title: 'web_search',
|
||||
status: 'in_progress',
|
||||
content: [{ type: 'content', content: { type: 'text', text: 'AI news June 2026' } }],
|
||||
locations: [],
|
||||
}]);
|
||||
|
||||
await expect(page.getByTestId('acp-tool-call-card')).toBeVisible({ timeout: 30_000 });
|
||||
await expect(page.getByTestId('acp-tool-call-card')).toContainText('web_search');
|
||||
await expect(page.getByTestId('chat-execution-graph')).toHaveCount(0);
|
||||
|
||||
await emitAcpSessionUpdates(app, CRON_BASE_KEY, [{
|
||||
sessionUpdate: 'tool_call_update',
|
||||
toolCallId: 'call-web-search',
|
||||
title: 'web_search',
|
||||
status: 'completed',
|
||||
content: [{ type: 'content', content: { type: 'text', text: 'Search complete' } }],
|
||||
locations: [],
|
||||
}]);
|
||||
|
||||
await expect(page.getByText(CRON_TRIGGER_TEXT)).toBeVisible();
|
||||
const invocations = await getRecordedHostInvocations(app);
|
||||
expect(invocations.some((call) => call.module === 'cron' && call.action === 'liveRunOverlays')).toBe(true);
|
||||
expect(invocations.some((call) => call.module === 'chat' && call.action === 'sendAcpPrompt')).toBe(false);
|
||||
expect(invocations.some((call) => call.module === 'chat' && call.action === 'cancelAcpSession')).toBe(false);
|
||||
} finally {
|
||||
await closeElectronApp(app);
|
||||
}
|
||||
@@ -172,55 +336,23 @@ test.describe('ClawX cron run live status', () => {
|
||||
const completeCronReply = `该喝水了!💧\n\n${'补充说明 '.repeat(500)}\n\n完整回复结尾`;
|
||||
|
||||
try {
|
||||
const cronSession = {
|
||||
key: CRON_BASE_KEY,
|
||||
displayName: 'Cron: 喝水提醒',
|
||||
label: 'Cron: 喝水提醒',
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
|
||||
await installIpcMocks(app, {
|
||||
recordHostInvocations: true,
|
||||
failUnmatchedHostApiActions: ['chat.loadAcpSession'],
|
||||
gatewayStatus: { state: 'running', port: 18789, pid: 12345, gatewayReady: true },
|
||||
gatewayRpc: {
|
||||
[stableStringify(['sessions.list', {}])]: {
|
||||
success: true,
|
||||
result: {
|
||||
sessions: [
|
||||
{ key: MAIN_SESSION_KEY, displayName: 'main' },
|
||||
cronSession,
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
gatewayRpc: sessionListMock('Cron: 喝水提醒'),
|
||||
hostApi: {
|
||||
...acpLoadMocks(MAIN_SESSION_KEY),
|
||||
...acpLoadMocks(CRON_BASE_KEY),
|
||||
...commonHostMocks({ revision: 0, snapshots: [] }),
|
||||
[stableStringify(['cron', 'sessionHistory', { sessionKey: CRON_BASE_KEY, limit: 200 }])]: {
|
||||
messages: [
|
||||
{ id: 'cron-prompt', role: 'user', content: '提醒我喝水', timestamp: Date.now() - 5000 },
|
||||
{ id: 'cron-result', role: 'assistant', content: completeCronReply, timestamp: Date.now() },
|
||||
],
|
||||
},
|
||||
[stableStringify(['/api/gateway/status', 'GET'])]: {
|
||||
ok: true,
|
||||
data: { status: 200, ok: true, json: { state: 'running', port: 18789, pid: 12345, gatewayReady: true } },
|
||||
},
|
||||
[stableStringify(['/api/agents', 'GET'])]: {
|
||||
ok: true,
|
||||
data: { status: 200, ok: true, json: { success: true, agents: [{ id: 'main', name: 'Main', workspace: DEFAULT_WORKSPACE, mainSessionKey: MAIN_SESSION_KEY }] } },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const page = await getStableWindow(app);
|
||||
try {
|
||||
await page.reload();
|
||||
} catch (error) {
|
||||
if (!String(error).includes('ERR_FILE_NOT_FOUND')) throw error;
|
||||
}
|
||||
|
||||
await expect(page.getByTestId('main-layout')).toBeVisible({ timeout: 30_000 });
|
||||
const page = await reloadMainWindow(app);
|
||||
const cronSidebarButton = page.getByTestId(`sidebar-session-${CRON_BASE_KEY}`);
|
||||
await expect(cronSidebarButton).toBeVisible({ timeout: 30_000 });
|
||||
await cronSidebarButton.click();
|
||||
@@ -234,92 +366,7 @@ test.describe('ClawX cron run live status', () => {
|
||||
await expect(page.getByText('该喝水了!💧')).toBeVisible({ timeout: 30_000 });
|
||||
await expect(page.getByText('完整回复结尾')).toBeVisible({ timeout: 30_000 });
|
||||
await expect(page.getByTestId('acp-chat-empty-state')).toHaveCount(0);
|
||||
} finally {
|
||||
await closeElectronApp(app);
|
||||
}
|
||||
});
|
||||
|
||||
test('adopts an already-running cron run joined mid-flight (no run.started received)', async ({ launchElectronApp }) => {
|
||||
const app = await launchElectronApp({ skipSetup: true });
|
||||
|
||||
try {
|
||||
const cronSession = {
|
||||
key: CRON_BASE_KEY,
|
||||
displayName: 'Cron: 早报',
|
||||
label: 'Cron: 早报',
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
|
||||
await installIpcMocks(app, {
|
||||
gatewayStatus: { state: 'running', port: 18789, pid: 12345, gatewayReady: true },
|
||||
gatewayRpc: {
|
||||
[stableStringify(['sessions.list', {}])]: {
|
||||
success: true,
|
||||
result: {
|
||||
sessions: [
|
||||
{ key: MAIN_SESSION_KEY, displayName: 'main' },
|
||||
cronSession,
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
hostApi: {
|
||||
...acpLoadMocks(MAIN_SESSION_KEY),
|
||||
...acpLoadMocks(CRON_BASE_KEY),
|
||||
[stableStringify(['/api/gateway/status', 'GET'])]: {
|
||||
ok: true,
|
||||
data: { status: 200, ok: true, json: { state: 'running', port: 18789, pid: 12345, gatewayReady: true } },
|
||||
},
|
||||
[stableStringify(['/api/agents', 'GET'])]: {
|
||||
ok: true,
|
||||
data: { status: 200, ok: true, json: { success: true, agents: [{ id: 'main', name: 'Main', workspace: DEFAULT_WORKSPACE, mainSessionKey: MAIN_SESSION_KEY }] } },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const page = await getStableWindow(app);
|
||||
try {
|
||||
await page.reload();
|
||||
} catch (error) {
|
||||
if (!String(error).includes('ERR_FILE_NOT_FOUND')) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
await expect(page.getByTestId('main-layout')).toBeVisible({ timeout: 30_000 });
|
||||
const cronSidebarButton = page.getByTestId(`sidebar-session-${CRON_BASE_KEY}`);
|
||||
await expect(cronSidebarButton).toBeVisible({ timeout: 30_000 });
|
||||
await cronSidebarButton.click();
|
||||
await expect(page.getByTestId('acp-chat-empty-state')).toBeVisible({ timeout: 30_000 });
|
||||
await emitAcpSessionUpdates(app, CRON_BASE_KEY, [cronTriggerUpdate], true);
|
||||
await expect(page.getByText(CRON_TRIGGER_TEXT)).toBeVisible({ timeout: 30_000 });
|
||||
await expect(page.getByTestId('chat-execution-graph')).toHaveCount(0);
|
||||
|
||||
// Simulate joining a run already in progress: the first ACP update the
|
||||
// renderer sees is a tool card, and it still renders live in the current session.
|
||||
await emitAcpSessionUpdates(app, CRON_BASE_KEY, [{
|
||||
sessionUpdate: 'tool_call',
|
||||
toolCallId: 'call-read-skill',
|
||||
title: 'read',
|
||||
status: 'in_progress',
|
||||
content: [{ type: 'content', content: { type: 'text', text: '~/.openclaw/skills/docx/SKILL.md' } }],
|
||||
locations: [],
|
||||
}]);
|
||||
|
||||
await expect(page.getByTestId('acp-tool-call-card')).toBeVisible({ timeout: 30_000 });
|
||||
await expect(page.getByTestId('acp-tool-call-card')).toContainText('read');
|
||||
await expect(page.getByTestId('chat-execution-graph')).toHaveCount(0);
|
||||
|
||||
await emitAcpSessionUpdates(app, CRON_BASE_KEY, [{
|
||||
sessionUpdate: 'tool_call_update',
|
||||
toolCallId: 'call-read-skill',
|
||||
title: 'read',
|
||||
status: 'completed',
|
||||
content: [{ type: 'content', content: { type: 'text', text: 'Read complete' } }],
|
||||
locations: [],
|
||||
}]);
|
||||
|
||||
await expect(page.getByText(CRON_TRIGGER_TEXT)).toBeVisible();
|
||||
await expect(page.getByTestId('cron-live-run-overlay')).toHaveCount(0);
|
||||
} finally {
|
||||
await closeElectronApp(app);
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ type IpcMockConfig = {
|
||||
gatewayRpc?: Record<string, unknown>;
|
||||
hostApi?: Record<string, unknown>;
|
||||
hostApiErrors?: Record<string, string>;
|
||||
failUnmatchedHostApiActions?: string[];
|
||||
recordHostInvocations?: boolean;
|
||||
recordLegacyIpcInvocations?: boolean;
|
||||
};
|
||||
@@ -491,7 +492,13 @@ export async function installIpcMocks(
|
||||
return null;
|
||||
};
|
||||
|
||||
if (mockConfig.gatewayRpc || mockConfig.hostApi || mockConfig.hostApiErrors || mockConfig.gatewayStatus) {
|
||||
if (
|
||||
mockConfig.gatewayRpc
|
||||
|| mockConfig.hostApi
|
||||
|| mockConfig.hostApiErrors
|
||||
|| mockConfig.gatewayStatus
|
||||
|| mockConfig.failUnmatchedHostApiActions
|
||||
) {
|
||||
ipcMain.removeHandler('host:invoke');
|
||||
ipcMain.handle('host:invoke', async (event: unknown, request: {
|
||||
id?: string;
|
||||
@@ -597,6 +604,11 @@ export async function installIpcMocks(
|
||||
}
|
||||
}
|
||||
|
||||
const hostApiAction = `${request?.module ?? ''}.${request?.action ?? ''}`;
|
||||
if (mockConfig.failUnmatchedHostApiActions?.includes(hostApiAction)) {
|
||||
return fail(request?.id, `Unmatched host API mock: ${hostApiAction}`);
|
||||
}
|
||||
|
||||
return originalHostInvoke?.(event, request) ?? respond(request?.id, {});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { StrictMode } from 'react';
|
||||
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { Chat } from '@/pages/Chat';
|
||||
import type { AcpTimelineSnapshot } from '@/lib/acp/timeline-types';
|
||||
import type { CronLiveRunOverlayChange, CronLiveRunOverlaySnapshot } from '@shared/chat/cron-live-run';
|
||||
|
||||
const { acpState, agentsState, artifactPanelState, artifactPanelProps, chatState, gatewayState, settingsState } = vi.hoisted(() => ({
|
||||
const { acpState, agentsState, artifactPanelState, artifactPanelProps, chatState, cronOverlayState, gatewayState, settingsState } = vi.hoisted(() => ({
|
||||
acpState: {
|
||||
timeline: {
|
||||
sessionId: 'agent:main:main',
|
||||
@@ -97,6 +99,12 @@ const { acpState, agentsState, artifactPanelState, artifactPanelProps, chatState
|
||||
cleanupEmptySession: vi.fn(),
|
||||
lastUserMessageAt: null,
|
||||
},
|
||||
cronOverlayState: {
|
||||
revision: 0,
|
||||
snapshots: [] as CronLiveRunOverlaySnapshot[],
|
||||
pendingRemovals: [] as Array<Extract<CronLiveRunOverlayChange, { kind: 'remove' }>>,
|
||||
acknowledgeRemoval: vi.fn(),
|
||||
},
|
||||
gatewayState: {
|
||||
status: { state: 'running', gatewayReady: true, port: 18789 },
|
||||
},
|
||||
@@ -107,6 +115,14 @@ const { acpState, agentsState, artifactPanelState, artifactPanelProps, chatState
|
||||
}));
|
||||
|
||||
const ensureAcpChatSubscriptions = vi.hoisted(() => vi.fn());
|
||||
const ensureCronLiveRunOverlaySubscriptions = vi.hoisted(() => vi.fn());
|
||||
const stickToBottomState = vi.hoisted(() => ({ isAtBottom: true }));
|
||||
const useStickToBottomInstant = vi.hoisted(() => vi.fn(() => ({
|
||||
contentRef: { current: null },
|
||||
scrollRef: { current: null },
|
||||
scrollToBottom: vi.fn(),
|
||||
isAtBottom: stickToBottomState.isAtBottom,
|
||||
})));
|
||||
const resolveWorkspaceContext = vi.hoisted(() => vi.fn());
|
||||
const openDialog = vi.hoisted(() => vi.fn());
|
||||
|
||||
@@ -126,6 +142,15 @@ vi.mock('@/stores/acp-chat-session', () => ({
|
||||
useAcpChatSessionStore: (selector: (state: typeof acpState) => unknown) => selector(acpState),
|
||||
}));
|
||||
|
||||
vi.mock('@/stores/cron-live-run-overlay', () => ({
|
||||
ensureCronLiveRunOverlaySubscriptions,
|
||||
selectCronLiveRunsForSession: (
|
||||
state: Pick<typeof cronOverlayState, 'snapshots'>,
|
||||
sessionKey: string | null | undefined,
|
||||
) => state.snapshots.filter((snapshot) => snapshot.canonicalSessionKey === sessionKey),
|
||||
useCronLiveRunOverlayStore: (selector: (state: typeof cronOverlayState) => unknown) => selector(cronOverlayState),
|
||||
}));
|
||||
|
||||
vi.mock('@/stores/agents', () => ({
|
||||
useAgentsStore: (selector: (state: typeof agentsState) => unknown) => selector(agentsState),
|
||||
}));
|
||||
@@ -147,12 +172,7 @@ vi.mock('@/stores/gateway', () => ({
|
||||
}));
|
||||
|
||||
vi.mock('@/hooks/use-stick-to-bottom-instant', () => ({
|
||||
useStickToBottomInstant: () => ({
|
||||
contentRef: { current: null },
|
||||
scrollRef: { current: null },
|
||||
scrollToBottom: vi.fn(),
|
||||
isAtBottom: true,
|
||||
}),
|
||||
useStickToBottomInstant,
|
||||
}));
|
||||
|
||||
vi.mock('@/hooks/use-min-loading', () => ({
|
||||
@@ -208,7 +228,7 @@ vi.mock('@/pages/Chat/ChatInput', () => ({
|
||||
>
|
||||
send
|
||||
</button>
|
||||
<button type="button" data-testid="mock-stop" onClick={onStop}>stop</button>
|
||||
{sending && <button type="button" data-testid="mock-stop" onClick={onStop}>stop</button>}
|
||||
<button type="button" data-testid="mock-send-target" onClick={() => onSend('Ask research', undefined, 'research')}>send target</button>
|
||||
</div>
|
||||
),
|
||||
@@ -229,6 +249,23 @@ vi.mock('@/pages/Chat/ExecutionGraphCard', () => ({
|
||||
ExecutionGraphCard: () => <div data-testid="chat-execution-graph" />,
|
||||
}));
|
||||
|
||||
vi.mock('@/pages/Chat/AcpMessageSegment', () => ({
|
||||
AcpRenderPart: ({ part }: { part: { text?: string } }) => part.text ? <>{part.text}</> : null,
|
||||
AcpMessageSegment: ({ item }: { item: { parts: Array<{ kind: string; text?: string }> } }) => (
|
||||
<>{item.parts.map((part, index) => <span key={`${part.kind}:${index}`}>{part.text}</span>)}</>
|
||||
),
|
||||
AcpAssistantHoverBar: () => null,
|
||||
clipboardTextForParts: (parts: Array<{ text?: string }>) => parts.map((part) => part.text ?? '').join('\n'),
|
||||
}));
|
||||
|
||||
vi.mock('@/pages/Chat/CronLiveRunOverlay', () => ({
|
||||
CronLiveRunOverlay: ({ snapshot }: { snapshot: CronLiveRunOverlaySnapshot }) => (
|
||||
<section data-testid="cron-live-run-overlay" data-run-id={snapshot.runId}>
|
||||
{snapshot.assistantText}
|
||||
</section>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, options?: string | Record<string, unknown>) => {
|
||||
@@ -253,9 +290,9 @@ vi.mock('react-i18next', () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
function emptyTimeline(): AcpTimelineSnapshot {
|
||||
function emptyTimeline(sessionId = 'agent:main:main'): AcpTimelineSnapshot {
|
||||
return {
|
||||
sessionId: 'agent:main:main',
|
||||
sessionId,
|
||||
loadGeneration: 1,
|
||||
itemOrder: [],
|
||||
itemsById: {},
|
||||
@@ -265,6 +302,55 @@ function emptyTimeline(): AcpTimelineSnapshot {
|
||||
};
|
||||
}
|
||||
|
||||
const CRON_BASE_KEY = 'agent:main:cron:daily-report';
|
||||
const OTHER_CRON_BASE_KEY = 'agent:main:cron:weekly-report';
|
||||
|
||||
function liveRun(
|
||||
runId: string,
|
||||
revision: number,
|
||||
overrides: Partial<CronLiveRunOverlaySnapshot> = {},
|
||||
): CronLiveRunOverlaySnapshot {
|
||||
return {
|
||||
canonicalSessionKey: CRON_BASE_KEY,
|
||||
sourceSessionKey: `${CRON_BASE_KEY}:run:session-${runId}`,
|
||||
runSessionId: `session-${runId}`,
|
||||
runId,
|
||||
revision,
|
||||
status: 'running',
|
||||
updatedAt: revision,
|
||||
assistantText: `live-${runId}`,
|
||||
thinking: false,
|
||||
items: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function removal(
|
||||
runId: string,
|
||||
revision: number,
|
||||
overrides: Partial<Extract<CronLiveRunOverlayChange, { kind: 'remove' }>> = {},
|
||||
): Extract<CronLiveRunOverlayChange, { kind: 'remove' }> {
|
||||
return {
|
||||
kind: 'remove',
|
||||
revision,
|
||||
canonicalSessionKey: CRON_BASE_KEY,
|
||||
sourceSessionKey: `${CRON_BASE_KEY}:run:session-${runId}`,
|
||||
runId,
|
||||
reason: 'ended',
|
||||
terminalStatus: 'completed',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function selectCronSession(sessionKey = CRON_BASE_KEY, timeline = emptyTimeline(sessionKey)) {
|
||||
chatState.currentSessionKey = sessionKey;
|
||||
chatState.sessions = [{ key: sessionKey, workspacePath: '/workspace' }];
|
||||
acpState.activeSessionKey = sessionKey;
|
||||
acpState.workspaceRoot = '/workspace';
|
||||
acpState.cwd = '/workspace';
|
||||
acpState.timeline = timeline;
|
||||
}
|
||||
|
||||
function populatedTimeline(): AcpTimelineSnapshot {
|
||||
return {
|
||||
...emptyTimeline(),
|
||||
@@ -299,17 +385,22 @@ function populatedTimeline(): AcpTimelineSnapshot {
|
||||
};
|
||||
}
|
||||
|
||||
function deferredPromise() {
|
||||
let resolve!: () => void;
|
||||
const promise = new Promise<void>((res) => {
|
||||
function deferredPromise<T = void>() {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (reason?: unknown) => void;
|
||||
const promise = new Promise<T>((res, rej) => {
|
||||
resolve = res;
|
||||
reject = rej;
|
||||
});
|
||||
return { promise, resolve };
|
||||
return { promise, reject, resolve };
|
||||
}
|
||||
|
||||
describe('ACP Chat page', () => {
|
||||
beforeEach(() => {
|
||||
ensureAcpChatSubscriptions.mockReset();
|
||||
ensureCronLiveRunOverlaySubscriptions.mockReset();
|
||||
stickToBottomState.isAtBottom = true;
|
||||
useStickToBottomInstant.mockClear();
|
||||
acpState.loading = false;
|
||||
acpState.sending = false;
|
||||
acpState.cancelling = false;
|
||||
@@ -379,6 +470,10 @@ describe('ACP Chat page', () => {
|
||||
}
|
||||
});
|
||||
chatState.acknowledgeAcpSessionCreated.mockReset();
|
||||
cronOverlayState.revision = 0;
|
||||
cronOverlayState.snapshots = [];
|
||||
cronOverlayState.pendingRemovals = [];
|
||||
cronOverlayState.acknowledgeRemoval.mockReset();
|
||||
settingsState.chatWorkspacePath = '/workspace';
|
||||
settingsState.setChatWorkspacePath.mockReset();
|
||||
gatewayState.status = { state: 'running', gatewayReady: true, port: 18789 };
|
||||
@@ -411,7 +506,7 @@ describe('ACP Chat page', () => {
|
||||
acpState.workspaceRoot = '/workspace';
|
||||
acpState.cwd = '/workspace';
|
||||
|
||||
render(<Chat />);
|
||||
const { rerender } = render(<Chat />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('mock-chat-input')).toHaveAttribute('data-disabled', 'false');
|
||||
@@ -426,10 +521,415 @@ describe('ACP Chat page', () => {
|
||||
}],
|
||||
});
|
||||
|
||||
acpState.sending = true;
|
||||
rerender(<Chat />);
|
||||
fireEvent.click(screen.getByTestId('mock-stop'));
|
||||
expect(acpState.cancel).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('shows only exact-session live overlays instead of the empty state and includes them in scroll pinning', () => {
|
||||
selectCronSession();
|
||||
stickToBottomState.isAtBottom = false;
|
||||
cronOverlayState.snapshots = [
|
||||
liveRun('visible', 1),
|
||||
liveRun('other-job', 2, {
|
||||
canonicalSessionKey: OTHER_CRON_BASE_KEY,
|
||||
sourceSessionKey: `${OTHER_CRON_BASE_KEY}:run:session-other-job`,
|
||||
}),
|
||||
liveRun('ordinary', 3, {
|
||||
canonicalSessionKey: 'agent:main:main',
|
||||
sourceSessionKey: 'agent:main:main',
|
||||
}),
|
||||
];
|
||||
|
||||
render(<Chat />);
|
||||
|
||||
expect(ensureCronLiveRunOverlaySubscriptions).toHaveBeenCalled();
|
||||
expect(screen.queryByTestId('acp-chat-empty-state')).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId('cron-live-run-overlay')).toHaveTextContent('live-visible');
|
||||
expect(screen.queryByText('live-other-job')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('live-ordinary')).not.toBeInTheDocument();
|
||||
expect(useStickToBottomInstant).toHaveBeenLastCalledWith(CRON_BASE_KEY, true);
|
||||
expect(screen.getByTestId('chat-scroll-to-latest')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders deterministic live overlays after, and outside, the authoritative ACP timeline', () => {
|
||||
selectCronSession(CRON_BASE_KEY, { ...populatedTimeline(), sessionId: CRON_BASE_KEY });
|
||||
cronOverlayState.snapshots = [liveRun('first', 1), liveRun('second', 2)];
|
||||
|
||||
render(<Chat />);
|
||||
|
||||
const timeline = screen.getByTestId('acp-chat-timeline');
|
||||
const overlays = screen.getAllByTestId('cron-live-run-overlay');
|
||||
expect(overlays.map((overlay) => overlay.getAttribute('data-run-id'))).toEqual(['first', 'second']);
|
||||
expect(timeline.nextElementSibling).toBe(overlays[0]);
|
||||
expect(overlays[0]?.nextElementSibling).toBe(overlays[1]);
|
||||
expect(within(timeline).queryByText('live-first')).not.toBeInTheDocument();
|
||||
expect(within(timeline).queryByTestId('cron-live-run-overlay')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides overlays on session switch and restores retained Main snapshots when returning', () => {
|
||||
selectCronSession();
|
||||
cronOverlayState.snapshots = [liveRun('retained', 1)];
|
||||
const { rerender } = render(<Chat />);
|
||||
expect(screen.getByText('live-retained')).toBeInTheDocument();
|
||||
|
||||
selectCronSession('agent:main:main');
|
||||
rerender(<Chat />);
|
||||
expect(screen.queryByText('live-retained')).not.toBeInTheDocument();
|
||||
|
||||
selectCronSession();
|
||||
rerender(<Chat />);
|
||||
expect(screen.getByText('live-retained')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('keeps external cron activity out of ACP sending, Stop, cancellation, and permission flows', () => {
|
||||
selectCronSession();
|
||||
cronOverlayState.snapshots = [liveRun('external', 1)];
|
||||
|
||||
render(<Chat />);
|
||||
|
||||
expect(screen.getByTestId('mock-chat-input')).toHaveAttribute('data-sending', 'false');
|
||||
expect(screen.queryByTestId('mock-stop')).not.toBeInTheDocument();
|
||||
expect(acpState.cancel).not.toHaveBeenCalled();
|
||||
expect(acpState.respondPermission).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('acknowledges a visible terminal run and reloads authoritative ACP history exactly once', async () => {
|
||||
selectCronSession();
|
||||
cronOverlayState.snapshots = [liveRun('visible', 1)];
|
||||
const { rerender } = render(<StrictMode><Chat /></StrictMode>);
|
||||
await waitFor(() => expect(screen.getByText('live-visible')).toBeInTheDocument());
|
||||
acpState.loadSession.mockClear();
|
||||
|
||||
cronOverlayState.snapshots = [];
|
||||
cronOverlayState.pendingRemovals = [removal('visible', 2)];
|
||||
rerender(<StrictMode><Chat /></StrictMode>);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(cronOverlayState.acknowledgeRemoval).toHaveBeenCalledWith(2);
|
||||
expect(acpState.loadSession).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
expect(acpState.loadSession).toHaveBeenCalledWith({
|
||||
sessionKey: CRON_BASE_KEY,
|
||||
workspaceRoot: '/workspace',
|
||||
cwd: '/workspace',
|
||||
});
|
||||
rerender(<StrictMode><Chat /></StrictMode>);
|
||||
expect(acpState.loadSession).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('keeps a visible terminal removal pending until ACP sending and cancellation settle', async () => {
|
||||
selectCronSession();
|
||||
cronOverlayState.snapshots = [liveRun('prompt-busy', 1)];
|
||||
const { rerender } = render(<StrictMode><Chat /></StrictMode>);
|
||||
await waitFor(() => expect(screen.getByText('live-prompt-busy')).toBeInTheDocument());
|
||||
acpState.loadSession.mockClear();
|
||||
|
||||
acpState.sending = true;
|
||||
cronOverlayState.snapshots = [];
|
||||
cronOverlayState.pendingRemovals = [removal('prompt-busy', 2)];
|
||||
rerender(<StrictMode><Chat /></StrictMode>);
|
||||
|
||||
expect(acpState.sending).toBe(true);
|
||||
expect(acpState.cancelling).toBe(false);
|
||||
expect(cronOverlayState.pendingRemovals).toEqual([expect.objectContaining({ revision: 2 })]);
|
||||
expect(cronOverlayState.acknowledgeRemoval).not.toHaveBeenCalled();
|
||||
expect(acpState.loadSession).not.toHaveBeenCalled();
|
||||
|
||||
acpState.sending = false;
|
||||
acpState.cancelling = true;
|
||||
rerender(<StrictMode><Chat /></StrictMode>);
|
||||
|
||||
expect(acpState.sending).toBe(false);
|
||||
expect(acpState.cancelling).toBe(true);
|
||||
expect(cronOverlayState.acknowledgeRemoval).not.toHaveBeenCalled();
|
||||
expect(acpState.loadSession).not.toHaveBeenCalled();
|
||||
|
||||
acpState.cancelling = false;
|
||||
rerender(<StrictMode><Chat /></StrictMode>);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(cronOverlayState.acknowledgeRemoval).toHaveBeenCalledTimes(1);
|
||||
expect(cronOverlayState.acknowledgeRemoval).toHaveBeenCalledWith(2);
|
||||
expect(acpState.loadSession).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
expect(acpState.loadSession).toHaveBeenCalledWith({
|
||||
sessionKey: CRON_BASE_KEY,
|
||||
workspaceRoot: '/workspace',
|
||||
cwd: '/workspace',
|
||||
});
|
||||
expect(acpState.sending).toBe(false);
|
||||
expect(acpState.cancelling).toBe(false);
|
||||
});
|
||||
|
||||
it('queues a visible terminal refresh behind an in-flight normal load for the same session', async () => {
|
||||
const normalLoad = deferredPromise<boolean>();
|
||||
const terminalLoad = deferredPromise<boolean>();
|
||||
selectCronSession();
|
||||
acpState.activeSessionKey = null;
|
||||
cronOverlayState.snapshots = [liveRun('queued', 1)];
|
||||
acpState.loadSession
|
||||
.mockImplementationOnce(() => normalLoad.promise.then((loaded) => {
|
||||
acpState.activeSessionKey = CRON_BASE_KEY;
|
||||
acpState.workspaceRoot = '/workspace';
|
||||
acpState.cwd = '/workspace';
|
||||
return loaded;
|
||||
}))
|
||||
.mockReturnValueOnce(terminalLoad.promise);
|
||||
const { rerender } = render(<StrictMode><Chat /></StrictMode>);
|
||||
await waitFor(() => expect(acpState.loadSession).toHaveBeenCalledTimes(1));
|
||||
expect(screen.getByText('live-queued')).toBeInTheDocument();
|
||||
|
||||
cronOverlayState.snapshots = [];
|
||||
cronOverlayState.pendingRemovals = [removal('queued', 2)];
|
||||
rerender(<StrictMode><Chat /></StrictMode>);
|
||||
|
||||
expect(cronOverlayState.acknowledgeRemoval).not.toHaveBeenCalled();
|
||||
expect(acpState.loadSession).toHaveBeenCalledTimes(1);
|
||||
|
||||
normalLoad.resolve(true);
|
||||
await waitFor(() => {
|
||||
expect(cronOverlayState.acknowledgeRemoval).toHaveBeenCalledWith(2);
|
||||
expect(acpState.loadSession).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
expect(acpState.loadSession.mock.calls[1]?.[0]).toEqual({
|
||||
sessionKey: CRON_BASE_KEY,
|
||||
workspaceRoot: '/workspace',
|
||||
cwd: '/workspace',
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps a newer same-key load claimed when a stale load settles after switching away and back', async () => {
|
||||
const loadA = deferredPromise<boolean>();
|
||||
const loadB = deferredPromise<boolean>();
|
||||
const terminalLoad = deferredPromise<boolean>();
|
||||
selectCronSession();
|
||||
acpState.activeSessionKey = null;
|
||||
cronOverlayState.snapshots = [liveRun('aba-visible', 1)];
|
||||
acpState.loadSession
|
||||
.mockReturnValueOnce(loadA.promise)
|
||||
.mockReturnValueOnce(loadB.promise)
|
||||
.mockReturnValueOnce(terminalLoad.promise);
|
||||
const { rerender } = render(<StrictMode><Chat /></StrictMode>);
|
||||
await waitFor(() => expect(acpState.loadSession).toHaveBeenCalledTimes(1));
|
||||
|
||||
const localSessionKey = 'agent:main:local-away';
|
||||
chatState.currentSessionKey = localSessionKey;
|
||||
chatState.sessions = [
|
||||
{ key: CRON_BASE_KEY, workspacePath: '/workspace' },
|
||||
{ key: localSessionKey, workspacePath: '/workspace', createdLocally: true },
|
||||
];
|
||||
acpState.activeSessionKey = localSessionKey;
|
||||
acpState.workspaceRoot = '/workspace';
|
||||
acpState.cwd = '/workspace';
|
||||
acpState.timeline = emptyTimeline(localSessionKey);
|
||||
rerender(<StrictMode><Chat /></StrictMode>);
|
||||
|
||||
selectCronSession();
|
||||
acpState.activeSessionKey = null;
|
||||
rerender(<StrictMode><Chat /></StrictMode>);
|
||||
await waitFor(() => expect(acpState.loadSession).toHaveBeenCalledTimes(2));
|
||||
expect(screen.getByText('live-aba-visible')).toBeInTheDocument();
|
||||
|
||||
cronOverlayState.snapshots = [];
|
||||
cronOverlayState.pendingRemovals = [removal('aba-visible', 2)];
|
||||
rerender(<StrictMode><Chat /></StrictMode>);
|
||||
expect(acpState.loadSession).toHaveBeenCalledTimes(2);
|
||||
expect(cronOverlayState.acknowledgeRemoval).not.toHaveBeenCalled();
|
||||
|
||||
await act(async () => {
|
||||
loadA.resolve(true);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(acpState.loadSession).toHaveBeenCalledTimes(2);
|
||||
expect(cronOverlayState.acknowledgeRemoval).not.toHaveBeenCalled();
|
||||
|
||||
loadB.resolve(true);
|
||||
await waitFor(() => {
|
||||
expect(acpState.loadSession).toHaveBeenCalledTimes(3);
|
||||
expect(cronOverlayState.acknowledgeRemoval).toHaveBeenCalledWith(2);
|
||||
});
|
||||
});
|
||||
|
||||
it('leaves a visible terminal removal pending until its workspace becomes available', async () => {
|
||||
selectCronSession();
|
||||
cronOverlayState.snapshots = [liveRun('workspace-wait', 1)];
|
||||
resolveWorkspaceContext
|
||||
.mockResolvedValueOnce({ ok: false, error: 'notFound' })
|
||||
.mockImplementation(async (input: { workspaceRoot: string; executionCwd: string }) => ({
|
||||
ok: true,
|
||||
workspaceRoot: input.workspaceRoot,
|
||||
executionCwd: input.executionCwd,
|
||||
}));
|
||||
const { rerender } = render(<Chat />);
|
||||
await screen.findByTestId('workspace-unavailable-banner');
|
||||
acpState.loadSession.mockClear();
|
||||
|
||||
cronOverlayState.snapshots = [];
|
||||
cronOverlayState.pendingRemovals = [removal('workspace-wait', 2)];
|
||||
rerender(<Chat />);
|
||||
|
||||
expect(cronOverlayState.acknowledgeRemoval).not.toHaveBeenCalled();
|
||||
expect(acpState.loadSession).not.toHaveBeenCalled();
|
||||
|
||||
chatState.sessions = [{ key: CRON_BASE_KEY, workspacePath: '/workspace-restored' }];
|
||||
rerender(<Chat />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(cronOverlayState.acknowledgeRemoval).toHaveBeenCalledWith(2);
|
||||
expect(acpState.loadSession).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
expect(acpState.loadSession).toHaveBeenCalledWith({
|
||||
sessionKey: CRON_BASE_KEY,
|
||||
workspaceRoot: '/workspace-restored',
|
||||
cwd: '/workspace-restored',
|
||||
});
|
||||
});
|
||||
|
||||
it('serializes authoritative reloads for a burst of visible terminal runs', async () => {
|
||||
const firstTerminalLoad = deferredPromise<boolean>();
|
||||
const secondTerminalLoad = deferredPromise<boolean>();
|
||||
selectCronSession();
|
||||
cronOverlayState.snapshots = [liveRun('first-visible', 1), liveRun('second-visible', 2)];
|
||||
const { rerender } = render(<StrictMode><Chat /></StrictMode>);
|
||||
await waitFor(() => expect(screen.getAllByTestId('cron-live-run-overlay')).toHaveLength(2));
|
||||
await waitFor(() => expect(screen.getByTestId('mock-chat-input')).toHaveAttribute('data-disabled', 'false'));
|
||||
acpState.loadSession.mockReset();
|
||||
acpState.loadSession
|
||||
.mockReturnValueOnce(firstTerminalLoad.promise)
|
||||
.mockReturnValueOnce(secondTerminalLoad.promise);
|
||||
|
||||
cronOverlayState.snapshots = [];
|
||||
cronOverlayState.pendingRemovals = [
|
||||
removal('first-visible', 3),
|
||||
removal('second-visible', 4),
|
||||
];
|
||||
rerender(<StrictMode><Chat /></StrictMode>);
|
||||
|
||||
await waitFor(() => expect(acpState.loadSession).toHaveBeenCalledTimes(1));
|
||||
expect(cronOverlayState.acknowledgeRemoval.mock.calls).toEqual([[3]]);
|
||||
|
||||
firstTerminalLoad.resolve(true);
|
||||
await waitFor(() => expect(acpState.loadSession).toHaveBeenCalledTimes(2));
|
||||
expect(cronOverlayState.acknowledgeRemoval.mock.calls).toEqual([[3], [4]]);
|
||||
});
|
||||
|
||||
it('clears coordination after a rejected terminal load without replaying its marker', async () => {
|
||||
const terminalLoad = deferredPromise<boolean>();
|
||||
selectCronSession();
|
||||
cronOverlayState.snapshots = [liveRun('rejected', 1)];
|
||||
const { rerender } = render(<StrictMode><Chat /></StrictMode>);
|
||||
await waitFor(() => expect(screen.getByTestId('mock-chat-input')).toHaveAttribute('data-disabled', 'false'));
|
||||
acpState.loadSession.mockReset();
|
||||
acpState.loadSession
|
||||
.mockReturnValueOnce(terminalLoad.promise)
|
||||
.mockResolvedValueOnce(true);
|
||||
|
||||
cronOverlayState.snapshots = [];
|
||||
cronOverlayState.pendingRemovals = [removal('rejected', 2)];
|
||||
rerender(<StrictMode><Chat /></StrictMode>);
|
||||
|
||||
await waitFor(() => expect(acpState.loadSession).toHaveBeenCalledTimes(1));
|
||||
await waitFor(() => expect(cronOverlayState.acknowledgeRemoval).toHaveBeenCalledTimes(1));
|
||||
|
||||
acpState.activeSessionKey = null;
|
||||
rerender(<StrictMode><Chat /></StrictMode>);
|
||||
|
||||
expect(acpState.loadSession).toHaveBeenCalledTimes(1);
|
||||
|
||||
await act(async () => {
|
||||
terminalLoad.reject(new Error('terminal load failed'));
|
||||
await Promise.resolve();
|
||||
});
|
||||
acpState.activeSessionKey = CRON_BASE_KEY;
|
||||
rerender(<StrictMode><Chat /></StrictMode>);
|
||||
acpState.activeSessionKey = null;
|
||||
rerender(<StrictMode><Chat /></StrictMode>);
|
||||
|
||||
await waitFor(() => expect(acpState.loadSession).toHaveBeenCalledTimes(2));
|
||||
expect(cronOverlayState.acknowledgeRemoval.mock.calls).toEqual([[2]]);
|
||||
});
|
||||
|
||||
it('processes removal bursts in revision order and reloads only runs rendered in the current session', async () => {
|
||||
selectCronSession();
|
||||
cronOverlayState.snapshots = [
|
||||
liveRun('visible', 1),
|
||||
liveRun('inactive', 2, {
|
||||
canonicalSessionKey: OTHER_CRON_BASE_KEY,
|
||||
sourceSessionKey: `${OTHER_CRON_BASE_KEY}:run:session-inactive`,
|
||||
}),
|
||||
];
|
||||
const { rerender } = render(<Chat />);
|
||||
await waitFor(() => expect(screen.getByText('live-visible')).toBeInTheDocument());
|
||||
acpState.loadSession.mockClear();
|
||||
|
||||
cronOverlayState.snapshots = [];
|
||||
cronOverlayState.pendingRemovals = [
|
||||
removal('inactive', 4, {
|
||||
canonicalSessionKey: OTHER_CRON_BASE_KEY,
|
||||
sourceSessionKey: `${OTHER_CRON_BASE_KEY}:run:session-inactive`,
|
||||
}),
|
||||
removal('visible', 3),
|
||||
];
|
||||
rerender(<Chat />);
|
||||
|
||||
await waitFor(() => expect(cronOverlayState.acknowledgeRemoval).toHaveBeenCalledTimes(2));
|
||||
expect(cronOverlayState.acknowledgeRemoval.mock.calls).toEqual([[3], [4]]);
|
||||
expect(acpState.loadSession).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not defer a terminal reload received while another session is selected', async () => {
|
||||
selectCronSession();
|
||||
cronOverlayState.snapshots = [liveRun('ended-away', 1)];
|
||||
const { rerender } = render(<Chat />);
|
||||
await waitFor(() => expect(screen.getByText('live-ended-away')).toBeInTheDocument());
|
||||
|
||||
selectCronSession('agent:main:main');
|
||||
rerender(<Chat />);
|
||||
acpState.loadSession.mockClear();
|
||||
cronOverlayState.snapshots = [];
|
||||
cronOverlayState.pendingRemovals = [removal('ended-away', 2)];
|
||||
rerender(<Chat />);
|
||||
|
||||
await waitFor(() => expect(cronOverlayState.acknowledgeRemoval).toHaveBeenCalledWith(2));
|
||||
expect(acpState.loadSession).not.toHaveBeenCalled();
|
||||
|
||||
selectCronSession();
|
||||
rerender(<Chat />);
|
||||
expect(acpState.loadSession).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('acknowledges an ended run that was never rendered without reloading ACP history', async () => {
|
||||
selectCronSession();
|
||||
cronOverlayState.pendingRemovals = [removal('never-rendered', 1)];
|
||||
|
||||
render(<StrictMode><Chat /></StrictMode>);
|
||||
|
||||
await waitFor(() => expect(cronOverlayState.acknowledgeRemoval).toHaveBeenCalledWith(1));
|
||||
expect(acpState.loadSession).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('acknowledges evicted and gateway-reset overlays without authoritative reloads', async () => {
|
||||
selectCronSession();
|
||||
cronOverlayState.snapshots = [liveRun('evicted', 1), liveRun('reset', 2)];
|
||||
const { rerender } = render(<Chat />);
|
||||
await waitFor(() => expect(screen.getAllByTestId('cron-live-run-overlay')).toHaveLength(2));
|
||||
acpState.loadSession.mockClear();
|
||||
|
||||
cronOverlayState.snapshots = [];
|
||||
cronOverlayState.pendingRemovals = [
|
||||
removal('evicted', 3, { reason: 'evicted', terminalStatus: undefined }),
|
||||
removal('reset', 4, { reason: 'gateway-reset', terminalStatus: undefined }),
|
||||
];
|
||||
rerender(<Chat />);
|
||||
|
||||
await waitFor(() => expect(cronOverlayState.acknowledgeRemoval).toHaveBeenCalledTimes(2));
|
||||
expect(cronOverlayState.acknowledgeRemoval.mock.calls).toEqual([[3], [4]]);
|
||||
expect(acpState.loadSession).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('loads from the effective workspace without waiting for agents', async () => {
|
||||
const deferred = deferredPromise();
|
||||
agentsState.agents = [];
|
||||
|
||||
@@ -0,0 +1,916 @@
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
bindCronLiveRunBroker,
|
||||
CronLiveRunBroker,
|
||||
MAX_ACTIVE_CRON_LIVE_RUNS,
|
||||
MAX_CRON_LIVE_ASSISTANT_CHARS,
|
||||
MAX_CRON_LIVE_EVENT_FINGERPRINTS,
|
||||
MAX_CRON_LIVE_ITEMS_PER_RUN,
|
||||
MAX_CRON_LIVE_ITEM_DETAIL_CHARS,
|
||||
MAX_CRON_LIVE_TERMINAL_TOMBSTONES,
|
||||
reduceCronLiveRunEvent,
|
||||
} from '../../electron/services/cron-live-run-broker';
|
||||
import type { GatewayManager } from '../../electron/gateway/manager';
|
||||
import type {
|
||||
CronLiveRunOverlaySnapshot,
|
||||
CronLiveRunOverlaySnapshotSet,
|
||||
} from '../../shared/chat/cron-live-run';
|
||||
import type { ChatRuntimeEvent } from '../../shared/chat-runtime-events';
|
||||
|
||||
const RUN_KEY = 'agent:main:cron:daily-report:run:runtime-session-1';
|
||||
const BASE_KEY = 'agent:main:cron:daily-report';
|
||||
|
||||
function event(
|
||||
value: Omit<ChatRuntimeEvent, 'runId' | 'sessionKey'> & Partial<Pick<ChatRuntimeEvent, 'runId' | 'sessionKey'>>,
|
||||
): ChatRuntimeEvent {
|
||||
return {
|
||||
runId: 'run-1',
|
||||
sessionKey: RUN_KEY,
|
||||
...value,
|
||||
} as ChatRuntimeEvent;
|
||||
}
|
||||
|
||||
function snapshot(overrides: Partial<CronLiveRunOverlaySnapshot> = {}): CronLiveRunOverlaySnapshot {
|
||||
return {
|
||||
canonicalSessionKey: BASE_KEY,
|
||||
sourceSessionKey: RUN_KEY,
|
||||
runSessionId: 'runtime-session-1',
|
||||
runId: 'run-1',
|
||||
revision: 0,
|
||||
status: 'running',
|
||||
updatedAt: 10,
|
||||
assistantText: '',
|
||||
thinking: false,
|
||||
items: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('reduceCronLiveRunEvent', () => {
|
||||
it('uses the exact live-run memory bounds', () => {
|
||||
expect({
|
||||
active: MAX_ACTIVE_CRON_LIVE_RUNS,
|
||||
items: MAX_CRON_LIVE_ITEMS_PER_RUN,
|
||||
assistant: MAX_CRON_LIVE_ASSISTANT_CHARS,
|
||||
detail: MAX_CRON_LIVE_ITEM_DETAIL_CHARS,
|
||||
fingerprints: MAX_CRON_LIVE_EVENT_FINGERPRINTS,
|
||||
tombstones: MAX_CRON_LIVE_TERMINAL_TOMBSTONES,
|
||||
}).toEqual({
|
||||
active: 32,
|
||||
items: 128,
|
||||
assistant: 500_000,
|
||||
detail: 100_000,
|
||||
fingerprints: 256,
|
||||
tombstones: 128,
|
||||
});
|
||||
});
|
||||
|
||||
it('converges assistant snapshots, replacement chunks, and deltas without mutating input', () => {
|
||||
const initial = snapshot({ assistantText: 'old' });
|
||||
const full = reduceCronLiveRunEvent(initial, event({ type: 'assistant.delta', text: 'Hello', ts: 11 }));
|
||||
const appended = reduceCronLiveRunEvent(full, event({ type: 'assistant.delta', delta: ' world', ts: 12 }));
|
||||
const replaced = reduceCronLiveRunEvent(appended, event({
|
||||
type: 'assistant.delta',
|
||||
delta: 'Corrected',
|
||||
replace: true,
|
||||
ts: 13,
|
||||
}));
|
||||
|
||||
expect(initial.assistantText).toBe('old');
|
||||
expect(full.assistantText).toBe('Hello');
|
||||
expect(appended.assistantText).toBe('Hello world');
|
||||
expect(replaced).toEqual(expect.objectContaining({
|
||||
assistantText: 'Corrected',
|
||||
thinking: false,
|
||||
updatedAt: 13,
|
||||
}));
|
||||
});
|
||||
|
||||
it('tracks thinking as a boolean without retaining thought text', () => {
|
||||
const thought = 'private chain of thought';
|
||||
const next = reduceCronLiveRunEvent(snapshot(), event({
|
||||
type: 'thinking.delta',
|
||||
text: thought,
|
||||
delta: `${thought} continued`,
|
||||
ts: 14,
|
||||
}));
|
||||
|
||||
expect(next.thinking).toBe(true);
|
||||
expect(JSON.stringify(next)).not.toContain(thought);
|
||||
});
|
||||
|
||||
it('updates tools in place with stable, cycle-safe structured details', () => {
|
||||
const cyclic: Record<string, unknown> = { z: 2, a: 1 };
|
||||
cyclic.self = cyclic;
|
||||
|
||||
const started = reduceCronLiveRunEvent(snapshot(), event({
|
||||
type: 'tool.started',
|
||||
toolCallId: 'shared-id',
|
||||
name: 'read',
|
||||
args: cyclic,
|
||||
ts: 20,
|
||||
}));
|
||||
const updated = reduceCronLiveRunEvent(started, event({
|
||||
type: 'tool.updated',
|
||||
toolCallId: 'shared-id',
|
||||
name: 'read file',
|
||||
partialResult: { current: 1 },
|
||||
ts: 21,
|
||||
}));
|
||||
const completed = reduceCronLiveRunEvent(updated, event({
|
||||
type: 'tool.completed',
|
||||
toolCallId: 'shared-id',
|
||||
name: 'read file',
|
||||
result: { error: 'permission denied' },
|
||||
isError: true,
|
||||
ts: 22,
|
||||
}));
|
||||
|
||||
expect(started.items).toEqual([{
|
||||
kind: 'tool',
|
||||
id: expect.any(String),
|
||||
toolCallId: 'shared-id',
|
||||
title: 'read',
|
||||
status: 'running',
|
||||
inputText: '{\n "a": 1,\n "self": "[Circular]",\n "z": 2\n}',
|
||||
}]);
|
||||
expect(completed.items).toHaveLength(1);
|
||||
expect(completed.items[0]).toEqual({
|
||||
kind: 'tool',
|
||||
id: started.items[0].id,
|
||||
toolCallId: 'shared-id',
|
||||
title: 'read file',
|
||||
status: 'failed',
|
||||
inputText: started.items[0].kind === 'tool' ? started.items[0].inputText : undefined,
|
||||
outputText: '{\n "error": "permission denied"\n}',
|
||||
error: '{\n "error": "permission denied"\n}',
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves first-occurrence ordering while updating command, patch, and approval rows', () => {
|
||||
const commandStarted = reduceCronLiveRunEvent(snapshot(), event({
|
||||
type: 'command.output',
|
||||
itemId: 'process',
|
||||
title: 'Build',
|
||||
output: 'line 1\n',
|
||||
status: 'running',
|
||||
}));
|
||||
const patch = reduceCronLiveRunEvent(commandStarted, event({
|
||||
type: 'patch.completed',
|
||||
itemId: 'change',
|
||||
title: 'Apply files',
|
||||
summary: 'Updated source',
|
||||
added: 1,
|
||||
modified: 2,
|
||||
deleted: 3,
|
||||
}));
|
||||
const approval = reduceCronLiveRunEvent(patch, event({
|
||||
type: 'approval.updated',
|
||||
itemId: 'permission',
|
||||
title: 'Allow command',
|
||||
status: 'pending',
|
||||
message: 'Read-only status',
|
||||
}));
|
||||
const commandEnded = reduceCronLiveRunEvent(approval, event({
|
||||
type: 'command.output',
|
||||
itemId: 'process',
|
||||
title: 'Build',
|
||||
output: 'line 2',
|
||||
phase: 'end',
|
||||
exitCode: 0,
|
||||
}));
|
||||
const approvalDenied = reduceCronLiveRunEvent(commandEnded, event({
|
||||
type: 'approval.updated',
|
||||
itemId: 'permission',
|
||||
title: 'Allow command',
|
||||
status: 'denied',
|
||||
message: 'Denied',
|
||||
}));
|
||||
|
||||
expect(approvalDenied.items.map(({ kind }) => kind)).toEqual(['command', 'patch', 'approval']);
|
||||
expect(approvalDenied.items).toEqual([
|
||||
{
|
||||
kind: 'command',
|
||||
id: expect.any(String),
|
||||
title: 'Build',
|
||||
status: 'completed',
|
||||
output: 'line 1\nline 2',
|
||||
exitCode: 0,
|
||||
},
|
||||
{
|
||||
kind: 'patch',
|
||||
id: expect.any(String),
|
||||
title: 'Apply files',
|
||||
summary: 'Updated source',
|
||||
added: 1,
|
||||
modified: 2,
|
||||
deleted: 3,
|
||||
},
|
||||
{
|
||||
kind: 'approval',
|
||||
id: expect.any(String),
|
||||
title: 'Allow command',
|
||||
status: 'failed',
|
||||
message: 'Denied',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('namespaces every item identity by run', () => {
|
||||
const sharedEvent = event({
|
||||
type: 'approval.updated',
|
||||
toolCallId: 'same',
|
||||
title: 'Approval',
|
||||
});
|
||||
const first = reduceCronLiveRunEvent(snapshot({ runId: 'run-1' }), sharedEvent);
|
||||
const second = reduceCronLiveRunEvent(
|
||||
snapshot({ runId: 'run-2' }),
|
||||
{ ...sharedEvent, runId: 'run-2' },
|
||||
);
|
||||
|
||||
expect(first.items[0].id).not.toBe(second.items[0].id);
|
||||
});
|
||||
|
||||
it('bounds assistant text and every retained item detail', () => {
|
||||
const oversized = 'x'.repeat(MAX_CRON_LIVE_ASSISTANT_CHARS + 20);
|
||||
const oversizedDetail = 'd'.repeat(MAX_CRON_LIVE_ITEM_DETAIL_CHARS + 20);
|
||||
const assistant = reduceCronLiveRunEvent(snapshot(), event({
|
||||
type: 'assistant.delta',
|
||||
delta: oversized,
|
||||
}));
|
||||
const assistantTail = reduceCronLiveRunEvent(assistant, event({
|
||||
type: 'assistant.delta',
|
||||
delta: 'tail',
|
||||
}));
|
||||
const tool = reduceCronLiveRunEvent(assistantTail, event({
|
||||
type: 'tool.started',
|
||||
toolCallId: 'large',
|
||||
name: oversizedDetail,
|
||||
args: { payload: 'y'.repeat(MAX_CRON_LIVE_ITEM_DETAIL_CHARS + 20) },
|
||||
}));
|
||||
const command = reduceCronLiveRunEvent(tool, event({
|
||||
type: 'command.output',
|
||||
itemId: 'large-command',
|
||||
title: oversizedDetail,
|
||||
output: 'z'.repeat(MAX_CRON_LIVE_ITEM_DETAIL_CHARS + 20),
|
||||
}));
|
||||
const patch = reduceCronLiveRunEvent(command, event({
|
||||
type: 'patch.completed',
|
||||
itemId: 'large-patch',
|
||||
title: oversizedDetail,
|
||||
summary: 's'.repeat(MAX_CRON_LIVE_ITEM_DETAIL_CHARS + 20),
|
||||
}));
|
||||
const approval = reduceCronLiveRunEvent(patch, event({
|
||||
type: 'approval.updated',
|
||||
itemId: 'large-approval',
|
||||
title: oversizedDetail,
|
||||
message: 'm'.repeat(MAX_CRON_LIVE_ITEM_DETAIL_CHARS + 20),
|
||||
}));
|
||||
|
||||
expect(assistantTail.assistantText).toHaveLength(MAX_CRON_LIVE_ASSISTANT_CHARS);
|
||||
expect(assistantTail.assistantText.endsWith('tail')).toBe(true);
|
||||
for (const item of approval.items) {
|
||||
expect(item.title.length).toBeLessThanOrEqual(MAX_CRON_LIVE_ITEM_DETAIL_CHARS);
|
||||
if (item.kind === 'tool') expect(item.inputText?.length).toBeLessThanOrEqual(MAX_CRON_LIVE_ITEM_DETAIL_CHARS);
|
||||
if (item.kind === 'command') expect(item.output.length).toBeLessThanOrEqual(MAX_CRON_LIVE_ITEM_DETAIL_CHARS);
|
||||
if (item.kind === 'patch') expect(item.summary?.length).toBeLessThanOrEqual(MAX_CRON_LIVE_ITEM_DETAIL_CHARS);
|
||||
if (item.kind === 'approval') expect(item.message?.length).toBeLessThanOrEqual(MAX_CRON_LIVE_ITEM_DETAIL_CHARS);
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps the newest bounded item set while preserving retained order', () => {
|
||||
let current = snapshot();
|
||||
for (let index = 0; index <= MAX_CRON_LIVE_ITEMS_PER_RUN; index += 1) {
|
||||
current = reduceCronLiveRunEvent(current, event({
|
||||
type: 'patch.completed',
|
||||
itemId: `patch-${index}`,
|
||||
title: `Patch ${index}`,
|
||||
}));
|
||||
}
|
||||
|
||||
expect(current.items).toHaveLength(MAX_CRON_LIVE_ITEMS_PER_RUN);
|
||||
expect(current.items[0].title).toBe('Patch 1');
|
||||
expect(current.items.at(-1)?.title).toBe(`Patch ${MAX_CRON_LIVE_ITEMS_PER_RUN}`);
|
||||
});
|
||||
|
||||
it('uses collision-free opaque tuple identities for process items', () => {
|
||||
const first = reduceCronLiveRunEvent(snapshot({ runId: 'a:tool:b' }), {
|
||||
type: 'tool.started',
|
||||
runId: 'a:tool:b',
|
||||
sessionKey: RUN_KEY,
|
||||
toolCallId: 'c',
|
||||
name: 'first',
|
||||
});
|
||||
const second = reduceCronLiveRunEvent(snapshot({ runId: 'a' }), {
|
||||
type: 'tool.started',
|
||||
runId: 'a',
|
||||
sessionKey: RUN_KEY,
|
||||
toolCallId: 'b:tool:c',
|
||||
name: 'second',
|
||||
});
|
||||
|
||||
expect(first.items[0].id).not.toBe(second.items[0].id);
|
||||
});
|
||||
|
||||
it('marks deep, node-heavy, wide, and oversized-string details deterministically', () => {
|
||||
const deep: Record<string, unknown> = {};
|
||||
let cursor = deep;
|
||||
for (let index = 0; index < 20_000; index += 1) {
|
||||
const child: Record<string, unknown> = {};
|
||||
cursor.next = child;
|
||||
cursor = child;
|
||||
}
|
||||
|
||||
const nodeHeavy = Array.from({ length: 3_000 }, () => ({}));
|
||||
const wide: Record<string, unknown> = {};
|
||||
for (let index = 0; index < 2_000; index += 1) wide[`key-${index}`] = index;
|
||||
|
||||
const cases: Array<[unknown, string]> = [
|
||||
[deep, '[Truncated:Depth]'],
|
||||
[nodeHeavy, '[Truncated:Nodes]'],
|
||||
[wide, '[Truncated:Keys]'],
|
||||
['s'.repeat(MAX_CRON_LIVE_ITEM_DETAIL_CHARS + 20), '[Truncated:String:100020]'],
|
||||
];
|
||||
|
||||
for (const [args, marker] of cases) {
|
||||
const reduced = reduceCronLiveRunEvent(snapshot(), event({
|
||||
type: 'tool.started',
|
||||
toolCallId: marker,
|
||||
name: 'adversarial detail',
|
||||
args,
|
||||
}));
|
||||
const item = reduced.items[0];
|
||||
expect(item.kind).toBe('tool');
|
||||
if (item.kind === 'tool') {
|
||||
expect(item.inputText).toContain(marker);
|
||||
expect(item.inputText?.length).toBeLessThanOrEqual(MAX_CRON_LIVE_ITEM_DETAIL_CHARS);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('handles invalid Dates and throwing getters with deterministic detail markers', () => {
|
||||
const throwing: Record<string, unknown> = {};
|
||||
Object.defineProperty(throwing, 'unsafe', {
|
||||
enumerable: true,
|
||||
get: () => {
|
||||
throw new Error('getter must not escape');
|
||||
},
|
||||
});
|
||||
|
||||
const invalidDate = reduceCronLiveRunEvent(snapshot(), event({
|
||||
type: 'tool.started',
|
||||
toolCallId: 'invalid-date',
|
||||
name: 'date',
|
||||
args: new Date(Number.NaN),
|
||||
}));
|
||||
const throwingGetter = reduceCronLiveRunEvent(snapshot(), event({
|
||||
type: 'tool.started',
|
||||
toolCallId: 'throwing-getter',
|
||||
name: 'getter',
|
||||
args: throwing,
|
||||
}));
|
||||
|
||||
expect(invalidDate.items[0]).toEqual(expect.objectContaining({ inputText: '"[Invalid:Date]"' }));
|
||||
expect(throwingGetter.items[0]).toEqual(expect.objectContaining({
|
||||
inputText: '{\n "unsafe": "[Unserializable:Property]"\n}',
|
||||
}));
|
||||
});
|
||||
|
||||
it('clears a stale tool error after a later non-failed state', () => {
|
||||
const failed = reduceCronLiveRunEvent(snapshot(), event({
|
||||
type: 'tool.completed',
|
||||
toolCallId: 'retrying-tool',
|
||||
name: 'Retrying tool',
|
||||
result: 'failed once',
|
||||
isError: true,
|
||||
}));
|
||||
const running = reduceCronLiveRunEvent(failed, event({
|
||||
type: 'tool.updated',
|
||||
toolCallId: 'retrying-tool',
|
||||
name: 'Retrying tool',
|
||||
partialResult: 'retrying',
|
||||
}));
|
||||
const completed = reduceCronLiveRunEvent(running, event({
|
||||
type: 'tool.completed',
|
||||
toolCallId: 'retrying-tool',
|
||||
name: 'Retrying tool',
|
||||
result: 'success',
|
||||
isError: false,
|
||||
}));
|
||||
|
||||
expect(failed.items[0]).toEqual(expect.objectContaining({ status: 'failed', error: 'failed once' }));
|
||||
expect(running.items[0]).toEqual(expect.objectContaining({ status: 'running' }));
|
||||
expect(running.items[0]).not.toHaveProperty('error');
|
||||
expect(completed.items[0]).toEqual(expect.objectContaining({ status: 'completed' }));
|
||||
expect(completed.items[0]).not.toHaveProperty('error');
|
||||
});
|
||||
});
|
||||
|
||||
describe('CronLiveRunBroker', () => {
|
||||
it('strictly admits only run-scoped cron keys and adopts a run mid-flight', () => {
|
||||
const broker = new CronLiveRunBroker(() => 100);
|
||||
const rejectedKeys = [
|
||||
undefined,
|
||||
'agent:main:main',
|
||||
BASE_KEY,
|
||||
'agent:main:cron:daily-report:run:',
|
||||
'agent:main:cron:daily-report:run:runtime-session-1:extra',
|
||||
'agent:main:heartbeat:main',
|
||||
];
|
||||
|
||||
for (const sessionKey of rejectedKeys) {
|
||||
expect(broker.ingestRuntimeEvent(event({
|
||||
type: 'assistant.delta',
|
||||
delta: 'ignored',
|
||||
sessionKey,
|
||||
}))).toEqual([]);
|
||||
}
|
||||
|
||||
const changes = broker.ingestRuntimeEvent(event({
|
||||
type: 'assistant.delta',
|
||||
delta: 'adopted',
|
||||
ts: 50,
|
||||
}));
|
||||
|
||||
expect(changes).toEqual([{
|
||||
kind: 'upsert',
|
||||
revision: 1,
|
||||
snapshot: expect.objectContaining({
|
||||
canonicalSessionKey: BASE_KEY,
|
||||
sourceSessionKey: RUN_KEY,
|
||||
runSessionId: 'runtime-session-1',
|
||||
runId: 'run-1',
|
||||
revision: 1,
|
||||
status: 'running',
|
||||
updatedAt: 50,
|
||||
assistantText: 'adopted',
|
||||
}),
|
||||
}]);
|
||||
expect(broker.getSnapshotSet()).toEqual({
|
||||
revision: 1,
|
||||
snapshots: [changes[0].kind === 'upsert' ? changes[0].snapshot : undefined],
|
||||
});
|
||||
});
|
||||
|
||||
it('returns immutable snapshot clones and keeps the broker revision on hydration', () => {
|
||||
const broker = new CronLiveRunBroker(() => 100);
|
||||
broker.ingestRuntimeEvent(event({ type: 'run.started', startedAt: 20 }));
|
||||
|
||||
const first = broker.getSnapshotSet() as CronLiveRunOverlaySnapshotSet;
|
||||
first.revision = 999;
|
||||
first.snapshots[0].assistantText = 'mutated';
|
||||
first.snapshots.push(snapshot());
|
||||
|
||||
expect(broker.getSnapshotSet()).toEqual({
|
||||
revision: 1,
|
||||
snapshots: [expect.objectContaining({
|
||||
revision: 1,
|
||||
startedAt: 20,
|
||||
assistantText: '',
|
||||
})],
|
||||
});
|
||||
});
|
||||
|
||||
it('uses ingestion time for timestamp-less updates and detaches emitted snapshots', () => {
|
||||
let now = 10;
|
||||
const broker = new CronLiveRunBroker(() => now);
|
||||
const [started] = broker.ingestRuntimeEvent(event({ type: 'run.started' }));
|
||||
if (started.kind !== 'upsert') throw new Error('Expected an upsert');
|
||||
started.snapshot.assistantText = 'external mutation';
|
||||
|
||||
now = 20;
|
||||
broker.ingestRuntimeEvent(event({ type: 'assistant.delta', delta: 'internal' }));
|
||||
|
||||
expect(broker.getSnapshotSet()).toEqual({
|
||||
revision: 2,
|
||||
snapshots: [expect.objectContaining({
|
||||
updatedAt: 20,
|
||||
assistantText: 'internal',
|
||||
})],
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects stale numeric sequences and records only an accepted lastSeq', () => {
|
||||
const broker = new CronLiveRunBroker(() => 100);
|
||||
|
||||
expect(broker.ingestRuntimeEvent(event({
|
||||
type: 'assistant.delta',
|
||||
delta: 'A',
|
||||
seq: 2,
|
||||
}))).toHaveLength(1);
|
||||
expect(broker.ingestRuntimeEvent(event({
|
||||
type: 'assistant.delta',
|
||||
delta: 'duplicate',
|
||||
seq: 2,
|
||||
}))).toEqual([]);
|
||||
expect(broker.ingestRuntimeEvent(event({
|
||||
type: 'assistant.delta',
|
||||
delta: 'stale',
|
||||
seq: 1,
|
||||
}))).toEqual([]);
|
||||
expect(broker.ingestRuntimeEvent(event({
|
||||
type: 'assistant.delta',
|
||||
delta: 'B',
|
||||
seq: 3,
|
||||
}))).toHaveLength(1);
|
||||
|
||||
expect(broker.getSnapshotSet()).toEqual({
|
||||
revision: 2,
|
||||
snapshots: [expect.objectContaining({
|
||||
revision: 2,
|
||||
lastSeq: 3,
|
||||
assistantText: 'AB',
|
||||
})],
|
||||
});
|
||||
});
|
||||
|
||||
it('deduplicates sequence-less events by type-specific content while retaining distinct chunks', () => {
|
||||
const broker = new CronLiveRunBroker(() => 100);
|
||||
const first = event({ type: 'assistant.delta', delta: 'one' });
|
||||
const distinct = event({ type: 'assistant.delta', delta: 'two' });
|
||||
|
||||
expect(broker.ingestRuntimeEvent(first)).toHaveLength(1);
|
||||
expect(broker.ingestRuntimeEvent(first)).toEqual([]);
|
||||
expect(broker.ingestRuntimeEvent(distinct)).toHaveLength(1);
|
||||
|
||||
const toolUpdate = event({
|
||||
type: 'tool.updated',
|
||||
toolCallId: 'tool-1',
|
||||
name: 'inspect',
|
||||
partialResult: { z: 2, a: 1 },
|
||||
});
|
||||
expect(broker.ingestRuntimeEvent(toolUpdate)).toHaveLength(1);
|
||||
expect(broker.ingestRuntimeEvent({
|
||||
...toolUpdate,
|
||||
partialResult: { a: 1, z: 2 },
|
||||
})).toEqual([]);
|
||||
|
||||
expect(broker.getSnapshotSet()).toEqual({
|
||||
revision: 3,
|
||||
snapshots: [expect.objectContaining({ assistantText: 'onetwo' })],
|
||||
});
|
||||
});
|
||||
|
||||
it('fingerprints adversarial values without throwing and still deduplicates them', () => {
|
||||
const deep: Record<string, unknown> = {};
|
||||
let cursor = deep;
|
||||
for (let index = 0; index < 20_000; index += 1) {
|
||||
const child: Record<string, unknown> = {};
|
||||
cursor.next = child;
|
||||
cursor = child;
|
||||
}
|
||||
const throwing: Record<string, unknown> = {};
|
||||
Object.defineProperty(throwing, 'unsafe', {
|
||||
enumerable: true,
|
||||
get: () => {
|
||||
throw new Error('getter must not escape');
|
||||
},
|
||||
});
|
||||
|
||||
const values: unknown[] = [
|
||||
deep,
|
||||
Array.from({ length: 3_000 }, () => ({})),
|
||||
Object.fromEntries(Array.from({ length: 2_000 }, (_, index) => [`key-${index}`, index])),
|
||||
new Date(Number.NaN),
|
||||
throwing,
|
||||
];
|
||||
|
||||
for (let index = 0; index < values.length; index += 1) {
|
||||
const runtimeEvent = event({
|
||||
type: 'tool.updated',
|
||||
runId: `adversarial-${index}`,
|
||||
toolCallId: `tool-${index}`,
|
||||
name: 'adversarial',
|
||||
partialResult: values[index],
|
||||
});
|
||||
let firstChanges: ReturnType<CronLiveRunBroker['ingestRuntimeEvent']> = [];
|
||||
expect(() => {
|
||||
firstChanges = new CronLiveRunBroker(() => 100).ingestRuntimeEvent(runtimeEvent);
|
||||
}).not.toThrow();
|
||||
expect(firstChanges).toHaveLength(1);
|
||||
|
||||
const broker = new CronLiveRunBroker(() => 100);
|
||||
expect(broker.ingestRuntimeEvent(runtimeEvent)).toHaveLength(1);
|
||||
expect(broker.ingestRuntimeEvent(runtimeEvent)).toEqual([]);
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects oversized required identity components without changing revision', () => {
|
||||
const oversized = 'i'.repeat(MAX_CRON_LIVE_ITEM_DETAIL_CHARS + 1);
|
||||
const broker = new CronLiveRunBroker(() => 100);
|
||||
const rejected = [
|
||||
event({ type: 'run.started', sessionKey: `agent:main:cron:job:run:${oversized}` }),
|
||||
event({ type: 'run.started', runId: oversized }),
|
||||
event({ type: 'tool.started', toolCallId: oversized, name: 'tool' }),
|
||||
event({ type: 'command.output', itemId: oversized, output: 'output' }),
|
||||
event({ type: 'patch.completed', name: oversized }),
|
||||
event({ type: 'approval.updated', kind: oversized }),
|
||||
];
|
||||
|
||||
for (const runtimeEvent of rejected) {
|
||||
expect(broker.ingestRuntimeEvent(runtimeEvent)).toEqual([]);
|
||||
}
|
||||
expect(broker.getSnapshotSet()).toEqual({ revision: 0, snapshots: [] });
|
||||
});
|
||||
|
||||
it('uses collision-free tuple identities for active runs and tombstones', () => {
|
||||
const broker = new CronLiveRunBroker(() => 100);
|
||||
const firstSessionKey = `${RUN_KEY}\0x`;
|
||||
const firstRunId = 'y';
|
||||
const secondSessionKey = RUN_KEY;
|
||||
const secondRunId = 'x\0y';
|
||||
|
||||
expect(broker.ingestRuntimeEvent(event({
|
||||
type: 'assistant.delta',
|
||||
sessionKey: firstSessionKey,
|
||||
runId: firstRunId,
|
||||
delta: 'first',
|
||||
}))).toHaveLength(1);
|
||||
expect(broker.ingestRuntimeEvent(event({
|
||||
type: 'assistant.delta',
|
||||
sessionKey: secondSessionKey,
|
||||
runId: secondRunId,
|
||||
delta: 'second',
|
||||
}))).toHaveLength(1);
|
||||
expect(broker.getSnapshotSet().snapshots.map(({ runId }) => runId).sort()).toEqual([
|
||||
secondRunId,
|
||||
firstRunId,
|
||||
].sort());
|
||||
|
||||
expect(broker.ingestRuntimeEvent(event({
|
||||
type: 'run.ended',
|
||||
sessionKey: firstSessionKey,
|
||||
runId: firstRunId,
|
||||
status: 'completed',
|
||||
}))).toHaveLength(1);
|
||||
expect(broker.ingestRuntimeEvent(event({
|
||||
type: 'assistant.delta',
|
||||
sessionKey: secondSessionKey,
|
||||
runId: secondRunId,
|
||||
delta: 'still active',
|
||||
}))).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('bounds sequence-less fingerprints with deterministic FIFO eviction', () => {
|
||||
const broker = new CronLiveRunBroker(() => 100);
|
||||
|
||||
for (let index = 0; index <= MAX_CRON_LIVE_EVENT_FINGERPRINTS; index += 1) {
|
||||
expect(broker.ingestRuntimeEvent(event({
|
||||
type: 'assistant.delta',
|
||||
delta: `[${index}]`,
|
||||
}))).toHaveLength(1);
|
||||
}
|
||||
|
||||
expect(broker.ingestRuntimeEvent(event({
|
||||
type: 'assistant.delta',
|
||||
delta: '[0]',
|
||||
}))).toHaveLength(1);
|
||||
expect(broker.getSnapshotSet().revision).toBe(MAX_CRON_LIVE_EVENT_FINGERPRINTS + 2);
|
||||
});
|
||||
|
||||
it('removes a terminal run, then tombstones it against delayed resurrection', () => {
|
||||
const broker = new CronLiveRunBroker(() => 100);
|
||||
const terminalError = 'e'.repeat(MAX_CRON_LIVE_ITEM_DETAIL_CHARS + 20);
|
||||
broker.ingestRuntimeEvent(event({ type: 'run.started', startedAt: 10, seq: 1 }));
|
||||
|
||||
expect(broker.ingestRuntimeEvent(event({
|
||||
type: 'run.ended',
|
||||
status: 'error',
|
||||
error: terminalError,
|
||||
endedAt: 20,
|
||||
seq: 2,
|
||||
}))).toEqual([{
|
||||
kind: 'remove',
|
||||
revision: 2,
|
||||
canonicalSessionKey: BASE_KEY,
|
||||
sourceSessionKey: RUN_KEY,
|
||||
runId: 'run-1',
|
||||
reason: 'ended',
|
||||
terminalStatus: 'error',
|
||||
terminalError: terminalError.slice(0, MAX_CRON_LIVE_ITEM_DETAIL_CHARS),
|
||||
}]);
|
||||
expect(broker.getSnapshotSet()).toEqual({ revision: 2, snapshots: [] });
|
||||
expect(broker.ingestRuntimeEvent(event({
|
||||
type: 'assistant.delta',
|
||||
delta: 'late duplicate',
|
||||
seq: 3,
|
||||
}))).toEqual([]);
|
||||
expect(broker.ingestRuntimeEvent(event({
|
||||
type: 'run.ended',
|
||||
status: 'error',
|
||||
seq: 4,
|
||||
}))).toEqual([]);
|
||||
expect(broker.getSnapshotSet()).toEqual({ revision: 2, snapshots: [] });
|
||||
});
|
||||
|
||||
it('tombstones unseen terminals and bounds tombstones with FIFO eviction', () => {
|
||||
const broker = new CronLiveRunBroker(() => 100);
|
||||
|
||||
for (let index = 0; index <= MAX_CRON_LIVE_TERMINAL_TOMBSTONES; index += 1) {
|
||||
expect(broker.ingestRuntimeEvent(event({
|
||||
type: 'run.ended',
|
||||
runId: `ended-${index}`,
|
||||
status: 'completed',
|
||||
}))).toEqual([]);
|
||||
}
|
||||
|
||||
expect(broker.ingestRuntimeEvent(event({
|
||||
type: 'assistant.delta',
|
||||
runId: 'ended-0',
|
||||
delta: 'old tombstone evicted',
|
||||
}))).toHaveLength(1);
|
||||
expect(broker.ingestRuntimeEvent(event({
|
||||
type: 'assistant.delta',
|
||||
runId: `ended-${MAX_CRON_LIVE_TERMINAL_TOMBSTONES}`,
|
||||
delta: 'latest tombstone retained',
|
||||
}))).toEqual([]);
|
||||
});
|
||||
|
||||
it('evicts the least-recent active run deterministically before upserting the new run', () => {
|
||||
const broker = new CronLiveRunBroker(() => 100);
|
||||
for (let index = 0; index < MAX_ACTIVE_CRON_LIVE_RUNS; index += 1) {
|
||||
broker.ingestRuntimeEvent(event({
|
||||
type: 'run.started',
|
||||
runId: `run-${String(index).padStart(2, '0')}`,
|
||||
ts: 10,
|
||||
}));
|
||||
}
|
||||
|
||||
const changes = broker.ingestRuntimeEvent(event({
|
||||
type: 'run.started',
|
||||
runId: `run-${MAX_ACTIVE_CRON_LIVE_RUNS}`,
|
||||
ts: 20,
|
||||
}));
|
||||
|
||||
expect(changes).toEqual([
|
||||
{
|
||||
kind: 'remove',
|
||||
revision: MAX_ACTIVE_CRON_LIVE_RUNS + 1,
|
||||
canonicalSessionKey: BASE_KEY,
|
||||
sourceSessionKey: RUN_KEY,
|
||||
runId: 'run-00',
|
||||
reason: 'evicted',
|
||||
},
|
||||
{
|
||||
kind: 'upsert',
|
||||
revision: MAX_ACTIVE_CRON_LIVE_RUNS + 2,
|
||||
snapshot: expect.objectContaining({
|
||||
runId: `run-${MAX_ACTIVE_CRON_LIVE_RUNS}`,
|
||||
revision: MAX_ACTIVE_CRON_LIVE_RUNS + 2,
|
||||
}),
|
||||
},
|
||||
]);
|
||||
const hydrated = broker.getSnapshotSet();
|
||||
expect(hydrated.revision).toBe(MAX_ACTIVE_CRON_LIVE_RUNS + 2);
|
||||
expect(hydrated.snapshots).toHaveLength(MAX_ACTIVE_CRON_LIVE_RUNS);
|
||||
expect(hydrated.snapshots.some(({ runId }) => runId === 'run-00')).toBe(false);
|
||||
});
|
||||
|
||||
it('hydrates by updatedAt then runId and clears in the same deterministic order', () => {
|
||||
const broker = new CronLiveRunBroker(() => 100);
|
||||
broker.ingestRuntimeEvent(event({ type: 'run.started', runId: 'run-b', ts: 20 }));
|
||||
broker.ingestRuntimeEvent(event({ type: 'run.started', runId: 'run-a', ts: 20 }));
|
||||
broker.ingestRuntimeEvent(event({ type: 'run.started', runId: 'run-c', ts: 10 }));
|
||||
|
||||
expect(broker.getSnapshotSet().snapshots.map(({ runId }) => runId)).toEqual([
|
||||
'run-c',
|
||||
'run-a',
|
||||
'run-b',
|
||||
]);
|
||||
expect(broker.clear()).toEqual([
|
||||
expect.objectContaining({ kind: 'remove', revision: 4, runId: 'run-c', reason: 'gateway-reset' }),
|
||||
expect.objectContaining({ kind: 'remove', revision: 5, runId: 'run-a', reason: 'gateway-reset' }),
|
||||
expect.objectContaining({ kind: 'remove', revision: 6, runId: 'run-b', reason: 'gateway-reset' }),
|
||||
]);
|
||||
expect(broker.clear()).toEqual([]);
|
||||
expect(broker.getSnapshotSet()).toEqual({ revision: 6, snapshots: [] });
|
||||
});
|
||||
|
||||
it('permits a cleared identity to be adopted again', () => {
|
||||
const broker = new CronLiveRunBroker(() => 100);
|
||||
broker.ingestRuntimeEvent(event({ type: 'assistant.delta', delta: 'active' }));
|
||||
|
||||
expect(broker.clear()).toEqual([
|
||||
expect.objectContaining({ kind: 'remove', revision: 2, runId: 'run-1', reason: 'gateway-reset' }),
|
||||
]);
|
||||
expect(broker.ingestRuntimeEvent(event({
|
||||
type: 'assistant.delta',
|
||||
delta: 'adopted after reset',
|
||||
}))).toEqual([
|
||||
expect.objectContaining({
|
||||
kind: 'upsert',
|
||||
revision: 3,
|
||||
snapshot: expect.objectContaining({
|
||||
runId: 'run-1',
|
||||
assistantText: 'adopted after reset',
|
||||
}),
|
||||
}),
|
||||
]);
|
||||
expect(broker.getSnapshotSet()).toEqual({
|
||||
revision: 3,
|
||||
snapshots: [expect.objectContaining({ runId: 'run-1' })],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('bindCronLiveRunBroker', () => {
|
||||
it('owns runtime ingestion and publishes broker changes', () => {
|
||||
const gatewayManager = new EventEmitter() as GatewayManager;
|
||||
const broker = new CronLiveRunBroker(() => 100);
|
||||
const publishChange = vi.fn();
|
||||
bindCronLiveRunBroker({ gatewayManager, broker, publishChange });
|
||||
|
||||
gatewayManager.emit('chat:runtime-event', event({
|
||||
type: 'assistant.delta',
|
||||
delta: 'live',
|
||||
}));
|
||||
|
||||
expect(publishChange).toHaveBeenCalledTimes(1);
|
||||
expect(publishChange).toHaveBeenCalledWith(expect.objectContaining({
|
||||
kind: 'upsert',
|
||||
revision: 1,
|
||||
}));
|
||||
expect(broker.getSnapshotSet().snapshots).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('suppresses reconnecting events and re-adopts the same identity after running resumes', () => {
|
||||
const gatewayManager = new EventEmitter() as GatewayManager;
|
||||
const broker = new CronLiveRunBroker(() => 100);
|
||||
const publishChange = vi.fn();
|
||||
bindCronLiveRunBroker({ gatewayManager, broker, publishChange });
|
||||
|
||||
gatewayManager.emit('chat:runtime-event', event({ type: 'run.started' }));
|
||||
gatewayManager.emit('status', { state: 'running', port: 18789 });
|
||||
expect(broker.getSnapshotSet().snapshots).toHaveLength(1);
|
||||
|
||||
gatewayManager.emit('status', { state: 'reconnecting', port: 18789 });
|
||||
expect(publishChange).toHaveBeenLastCalledWith(expect.objectContaining({
|
||||
kind: 'remove',
|
||||
reason: 'gateway-reset',
|
||||
runId: 'run-1',
|
||||
}));
|
||||
expect(broker.getSnapshotSet().snapshots).toEqual([]);
|
||||
|
||||
gatewayManager.emit('chat:runtime-event', event({
|
||||
type: 'assistant.delta',
|
||||
runId: 'delayed-during-reconnect',
|
||||
delta: 'must be ignored',
|
||||
}));
|
||||
expect(broker.getSnapshotSet().snapshots).toEqual([]);
|
||||
expect(publishChange).toHaveBeenCalledTimes(2);
|
||||
|
||||
gatewayManager.emit('status', { state: 'running', port: 18789 });
|
||||
gatewayManager.emit('chat:runtime-event', event({
|
||||
type: 'assistant.delta',
|
||||
delta: 're-adopted mid-flight',
|
||||
}));
|
||||
|
||||
expect(publishChange).toHaveBeenLastCalledWith(expect.objectContaining({
|
||||
kind: 'upsert',
|
||||
revision: 3,
|
||||
snapshot: expect.objectContaining({
|
||||
runId: 'run-1',
|
||||
assistantText: 're-adopted mid-flight',
|
||||
}),
|
||||
}));
|
||||
expect(broker.getSnapshotSet().snapshots).toEqual([
|
||||
expect.objectContaining({ runId: 'run-1' }),
|
||||
]);
|
||||
});
|
||||
|
||||
it('disables ingestion on exit until running resumes', () => {
|
||||
const gatewayManager = new EventEmitter() as GatewayManager;
|
||||
const broker = new CronLiveRunBroker(() => 100);
|
||||
const publishChange = vi.fn();
|
||||
bindCronLiveRunBroker({ gatewayManager, broker, publishChange });
|
||||
|
||||
gatewayManager.emit('chat:runtime-event', event({ type: 'run.started' }));
|
||||
gatewayManager.emit('exit', 1);
|
||||
expect(publishChange).toHaveBeenLastCalledWith(expect.objectContaining({
|
||||
kind: 'remove',
|
||||
reason: 'gateway-reset',
|
||||
runId: 'run-1',
|
||||
}));
|
||||
expect(broker.getSnapshotSet().snapshots).toEqual([]);
|
||||
|
||||
gatewayManager.emit('chat:runtime-event', event({
|
||||
type: 'assistant.delta',
|
||||
runId: 'delayed-after-exit',
|
||||
delta: 'must be ignored',
|
||||
}));
|
||||
expect(broker.getSnapshotSet().snapshots).toEqual([]);
|
||||
|
||||
gatewayManager.emit('status', { state: 'running', port: 18789 });
|
||||
gatewayManager.emit('chat:runtime-event', event({
|
||||
type: 'assistant.delta',
|
||||
delta: 're-adopted after restart',
|
||||
}));
|
||||
expect(broker.getSnapshotSet().snapshots).toEqual([
|
||||
expect.objectContaining({
|
||||
runId: 'run-1',
|
||||
assistantText: 're-adopted after restart',
|
||||
}),
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,356 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type {
|
||||
CronLiveRunOverlayChange,
|
||||
CronLiveRunOverlaySnapshot,
|
||||
CronLiveRunOverlaySnapshotSet,
|
||||
} from '@shared/chat/cron-live-run';
|
||||
|
||||
const boundaryMock = vi.hoisted(() => ({
|
||||
liveRunOverlays: vi.fn<() => Promise<CronLiveRunOverlaySnapshotSet>>(),
|
||||
listener: null as ((change: CronLiveRunOverlayChange) => void) | null,
|
||||
onChanged: vi.fn((listener: (change: CronLiveRunOverlayChange) => void) => {
|
||||
boundaryMock.listener = listener;
|
||||
return () => { boundaryMock.listener = null; };
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/host-api', () => ({
|
||||
hostApi: {
|
||||
cron: {
|
||||
liveRunOverlays: boundaryMock.liveRunOverlays,
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/host-events', () => ({
|
||||
hostEvents: {
|
||||
onCronLiveRunOverlayChanged: boundaryMock.onChanged,
|
||||
},
|
||||
}));
|
||||
|
||||
const BASE_KEY = 'agent:main:cron:daily-report';
|
||||
const OTHER_BASE_KEY = 'agent:main:cron:weekly-report';
|
||||
|
||||
function snapshot(
|
||||
runId: string,
|
||||
revision: number,
|
||||
overrides: Partial<CronLiveRunOverlaySnapshot> = {},
|
||||
): CronLiveRunOverlaySnapshot {
|
||||
return {
|
||||
canonicalSessionKey: BASE_KEY,
|
||||
sourceSessionKey: `${BASE_KEY}:run:session-${runId}`,
|
||||
runSessionId: `session-${runId}`,
|
||||
runId,
|
||||
revision,
|
||||
status: 'running',
|
||||
updatedAt: revision,
|
||||
assistantText: `content-${runId}-${revision}`,
|
||||
thinking: false,
|
||||
items: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function upsert(value: CronLiveRunOverlaySnapshot): CronLiveRunOverlayChange {
|
||||
return { kind: 'upsert', revision: value.revision, snapshot: value };
|
||||
}
|
||||
|
||||
function remove(
|
||||
runId: string,
|
||||
revision: number,
|
||||
overrides: Partial<Extract<CronLiveRunOverlayChange, { kind: 'remove' }>> = {},
|
||||
): Extract<CronLiveRunOverlayChange, { kind: 'remove' }> {
|
||||
return {
|
||||
kind: 'remove',
|
||||
revision,
|
||||
canonicalSessionKey: BASE_KEY,
|
||||
sourceSessionKey: `${BASE_KEY}:run:session-${runId}`,
|
||||
runId,
|
||||
reason: 'ended',
|
||||
terminalStatus: 'completed',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
const promise = new Promise<T>((innerResolve) => {
|
||||
resolve = innerResolve;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
async function importStore() {
|
||||
vi.resetModules();
|
||||
return import('@/stores/cron-live-run-overlay');
|
||||
}
|
||||
|
||||
async function flushPromises(): Promise<void> {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
}
|
||||
|
||||
describe('cron live-run overlay store', () => {
|
||||
beforeEach(() => {
|
||||
boundaryMock.liveRunOverlays.mockReset();
|
||||
boundaryMock.onChanged.mockClear();
|
||||
boundaryMock.listener = null;
|
||||
boundaryMock.liveRunOverlays.mockResolvedValue({ revision: 0, snapshots: [] });
|
||||
});
|
||||
|
||||
it('subscribes before hydrating and prevents concurrent hydration requests', async () => {
|
||||
const order: string[] = [];
|
||||
const hydration = deferred<CronLiveRunOverlaySnapshotSet>();
|
||||
boundaryMock.onChanged.mockImplementationOnce((listener) => {
|
||||
order.push('subscribe');
|
||||
boundaryMock.listener = listener;
|
||||
return () => { boundaryMock.listener = null; };
|
||||
});
|
||||
boundaryMock.liveRunOverlays.mockImplementationOnce(() => {
|
||||
order.push('snapshot');
|
||||
return hydration.promise;
|
||||
});
|
||||
const { ensureCronLiveRunOverlaySubscriptions } = await importStore();
|
||||
|
||||
ensureCronLiveRunOverlaySubscriptions();
|
||||
ensureCronLiveRunOverlaySubscriptions();
|
||||
|
||||
expect(order).toEqual(['subscribe', 'snapshot']);
|
||||
expect(boundaryMock.onChanged).toHaveBeenCalledTimes(1);
|
||||
expect(boundaryMock.liveRunOverlays).toHaveBeenCalledTimes(1);
|
||||
|
||||
hydration.resolve({ revision: 0, snapshots: [] });
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
it('retries hydration after rejection without adding another listener', async () => {
|
||||
boundaryMock.liveRunOverlays
|
||||
.mockRejectedValueOnce(new Error('snapshot unavailable'))
|
||||
.mockResolvedValueOnce({ revision: 2, snapshots: [snapshot('retry', 2)] });
|
||||
const {
|
||||
ensureCronLiveRunOverlaySubscriptions,
|
||||
useCronLiveRunOverlayStore,
|
||||
} = await importStore();
|
||||
|
||||
ensureCronLiveRunOverlaySubscriptions();
|
||||
await flushPromises();
|
||||
ensureCronLiveRunOverlaySubscriptions();
|
||||
await flushPromises();
|
||||
|
||||
expect(boundaryMock.onChanged).toHaveBeenCalledTimes(1);
|
||||
expect(boundaryMock.liveRunOverlays).toHaveBeenCalledTimes(2);
|
||||
expect(useCronLiveRunOverlayStore.getState()).toEqual(expect.objectContaining({
|
||||
revision: 2,
|
||||
snapshots: [snapshot('retry', 2)],
|
||||
}));
|
||||
});
|
||||
|
||||
it('does not hydrate again after the first successful snapshot', async () => {
|
||||
boundaryMock.liveRunOverlays.mockResolvedValueOnce({
|
||||
revision: 1,
|
||||
snapshots: [snapshot('hydrated', 1)],
|
||||
});
|
||||
const { ensureCronLiveRunOverlaySubscriptions } = await importStore();
|
||||
|
||||
ensureCronLiveRunOverlaySubscriptions();
|
||||
await flushPromises();
|
||||
ensureCronLiveRunOverlaySubscriptions();
|
||||
await flushPromises();
|
||||
|
||||
expect(boundaryMock.onChanged).toHaveBeenCalledTimes(1);
|
||||
expect(boundaryMock.liveRunOverlays).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('ignores older snapshots and changes after a newer change', async () => {
|
||||
const hydration = deferred<CronLiveRunOverlaySnapshotSet>();
|
||||
boundaryMock.liveRunOverlays.mockReturnValueOnce(hydration.promise);
|
||||
const {
|
||||
ensureCronLiveRunOverlaySubscriptions,
|
||||
useCronLiveRunOverlayStore,
|
||||
} = await importStore();
|
||||
|
||||
ensureCronLiveRunOverlaySubscriptions();
|
||||
boundaryMock.listener?.(upsert(snapshot('newer', 2)));
|
||||
hydration.resolve({ revision: 1, snapshots: [snapshot('stale', 1)] });
|
||||
await flushPromises();
|
||||
|
||||
expect(useCronLiveRunOverlayStore.getState()).toEqual(expect.objectContaining({
|
||||
revision: 2,
|
||||
snapshots: [snapshot('newer', 2)],
|
||||
}));
|
||||
|
||||
boundaryMock.listener?.(remove('newer', 1));
|
||||
expect(useCronLiveRunOverlayStore.getState()).toEqual(expect.objectContaining({
|
||||
revision: 2,
|
||||
snapshots: [snapshot('newer', 2)],
|
||||
pendingRemovals: [],
|
||||
}));
|
||||
});
|
||||
|
||||
it('upserts by canonical session and run ID without cross-session collisions', async () => {
|
||||
const { ensureCronLiveRunOverlaySubscriptions, useCronLiveRunOverlayStore } = await importStore();
|
||||
ensureCronLiveRunOverlaySubscriptions();
|
||||
|
||||
boundaryMock.listener?.(upsert(snapshot('shared', 1)));
|
||||
boundaryMock.listener?.(upsert(snapshot('shared', 2, { assistantText: 'replacement' })));
|
||||
boundaryMock.listener?.(upsert(snapshot('other-run', 3)));
|
||||
boundaryMock.listener?.(upsert(snapshot('shared', 4, {
|
||||
canonicalSessionKey: OTHER_BASE_KEY,
|
||||
sourceSessionKey: `${OTHER_BASE_KEY}:run:session-shared`,
|
||||
})));
|
||||
|
||||
const state = useCronLiveRunOverlayStore.getState();
|
||||
expect(state.snapshots).toHaveLength(3);
|
||||
expect(state.snapshots).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ canonicalSessionKey: BASE_KEY, runId: 'shared', assistantText: 'replacement' }),
|
||||
expect.objectContaining({ canonicalSessionKey: BASE_KEY, runId: 'other-run' }),
|
||||
expect.objectContaining({ canonicalSessionKey: OTHER_BASE_KEY, runId: 'shared' }),
|
||||
]));
|
||||
});
|
||||
|
||||
it('removes live content immediately without retaining it as history', async () => {
|
||||
const { ensureCronLiveRunOverlaySubscriptions, useCronLiveRunOverlayStore } = await importStore();
|
||||
ensureCronLiveRunOverlaySubscriptions();
|
||||
boundaryMock.listener?.(upsert(snapshot('run-1', 1, { assistantText: 'transient secret' })));
|
||||
|
||||
boundaryMock.listener?.(remove('run-1', 2));
|
||||
|
||||
const state = useCronLiveRunOverlayStore.getState();
|
||||
expect(state.snapshots).toEqual([]);
|
||||
expect(state.pendingRemovals).toEqual([
|
||||
expect.objectContaining({ revision: 2, runId: 'run-1', reason: 'ended' }),
|
||||
]);
|
||||
expect(JSON.stringify(state.pendingRemovals)).not.toContain('transient secret');
|
||||
});
|
||||
|
||||
it('retains at most 128 distinct pending removals in revision order', async () => {
|
||||
const { ensureCronLiveRunOverlaySubscriptions, useCronLiveRunOverlayStore } = await importStore();
|
||||
ensureCronLiveRunOverlaySubscriptions();
|
||||
|
||||
for (let revision = 1; revision <= 130; revision += 1) {
|
||||
boundaryMock.listener?.(remove(`run-${revision % 3}`, revision));
|
||||
}
|
||||
boundaryMock.listener?.(remove('run-1', 130));
|
||||
|
||||
const revisions = useCronLiveRunOverlayStore.getState().pendingRemovals.map((entry) => entry.revision);
|
||||
expect(revisions).toHaveLength(128);
|
||||
expect(revisions).toEqual(Array.from({ length: 128 }, (_, index) => index + 3));
|
||||
});
|
||||
|
||||
it('acknowledges only the pending removal with the exact revision', async () => {
|
||||
const { ensureCronLiveRunOverlaySubscriptions, useCronLiveRunOverlayStore } = await importStore();
|
||||
ensureCronLiveRunOverlaySubscriptions();
|
||||
boundaryMock.listener?.(remove('run-a', 1));
|
||||
boundaryMock.listener?.(remove('run-b', 2));
|
||||
boundaryMock.listener?.(remove('run-a', 3));
|
||||
|
||||
useCronLiveRunOverlayStore.getState().acknowledgeRemoval(2);
|
||||
|
||||
expect(useCronLiveRunOverlayStore.getState().pendingRemovals.map((entry) => entry.revision))
|
||||
.toEqual([1, 3]);
|
||||
});
|
||||
|
||||
it('does not restore an acknowledged removal when its host change is redelivered', async () => {
|
||||
boundaryMock.liveRunOverlays.mockResolvedValueOnce({
|
||||
revision: 5,
|
||||
snapshots: [snapshot('run-a', 5)],
|
||||
});
|
||||
const { ensureCronLiveRunOverlaySubscriptions, useCronLiveRunOverlayStore } = await importStore();
|
||||
ensureCronLiveRunOverlaySubscriptions();
|
||||
await flushPromises();
|
||||
const terminal = remove('run-a', 5);
|
||||
|
||||
boundaryMock.listener?.(terminal);
|
||||
expect(useCronLiveRunOverlayStore.getState().pendingRemovals.map(({ revision }) => revision))
|
||||
.toEqual([5]);
|
||||
useCronLiveRunOverlayStore.getState().acknowledgeRemoval(5);
|
||||
boundaryMock.listener?.(terminal);
|
||||
|
||||
expect(useCronLiveRunOverlayStore.getState()).toEqual(expect.objectContaining({
|
||||
revision: 5,
|
||||
snapshots: [],
|
||||
pendingRemovals: [],
|
||||
}));
|
||||
});
|
||||
|
||||
it('preserves both terminal signals when visible and inactive runs end in one burst', async () => {
|
||||
const {
|
||||
ensureCronLiveRunOverlaySubscriptions,
|
||||
selectCronLiveRunsForSession,
|
||||
useCronLiveRunOverlayStore,
|
||||
} = await importStore();
|
||||
ensureCronLiveRunOverlaySubscriptions();
|
||||
boundaryMock.listener?.(upsert(snapshot('visible-a', 1)));
|
||||
boundaryMock.listener?.(upsert(snapshot('inactive-b', 2, {
|
||||
canonicalSessionKey: OTHER_BASE_KEY,
|
||||
sourceSessionKey: `${OTHER_BASE_KEY}:run:session-inactive-b`,
|
||||
})));
|
||||
expect(selectCronLiveRunsForSession(useCronLiveRunOverlayStore.getState(), BASE_KEY))
|
||||
.toHaveLength(1);
|
||||
|
||||
boundaryMock.listener?.(remove('visible-a', 3));
|
||||
boundaryMock.listener?.(remove('inactive-b', 4, {
|
||||
canonicalSessionKey: OTHER_BASE_KEY,
|
||||
sourceSessionKey: `${OTHER_BASE_KEY}:run:session-inactive-b`,
|
||||
}));
|
||||
|
||||
const state = useCronLiveRunOverlayStore.getState();
|
||||
expect(state.snapshots).toEqual([]);
|
||||
expect(state.pendingRemovals.map(({ canonicalSessionKey, runId, revision }) => ({
|
||||
canonicalSessionKey,
|
||||
runId,
|
||||
revision,
|
||||
}))).toEqual([
|
||||
{ canonicalSessionKey: BASE_KEY, runId: 'visible-a', revision: 3 },
|
||||
{ canonicalSessionKey: OTHER_BASE_KEY, runId: 'inactive-b', revision: 4 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('selects overlays by exact base cron session key only', async () => {
|
||||
const {
|
||||
ensureCronLiveRunOverlaySubscriptions,
|
||||
selectCronLiveRunsForSession,
|
||||
useCronLiveRunOverlayStore,
|
||||
} = await importStore();
|
||||
ensureCronLiveRunOverlaySubscriptions();
|
||||
boundaryMock.listener?.(upsert(snapshot('run-1', 1)));
|
||||
const state = useCronLiveRunOverlayStore.getState();
|
||||
|
||||
expect(selectCronLiveRunsForSession(state, BASE_KEY).map(({ runId }) => runId)).toEqual(['run-1']);
|
||||
expect(selectCronLiveRunsForSession(state, `${BASE_KEY}:run:session-run-1`)).toEqual([]);
|
||||
expect(selectCronLiveRunsForSession(state, `${BASE_KEY}:extra`)).toEqual([]);
|
||||
expect(selectCronLiveRunsForSession(state, null)).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns exact-session overlays in deterministic snapshot order', async () => {
|
||||
const {
|
||||
ensureCronLiveRunOverlaySubscriptions,
|
||||
selectCronLiveRunsForSession,
|
||||
useCronLiveRunOverlayStore,
|
||||
} = await importStore();
|
||||
ensureCronLiveRunOverlaySubscriptions();
|
||||
boundaryMock.listener?.(upsert(snapshot('later-id', 1, { updatedAt: 20 })));
|
||||
boundaryMock.listener?.(upsert(snapshot('z-run', 2, { updatedAt: 10 })));
|
||||
boundaryMock.listener?.(upsert(snapshot('a-run', 3, { updatedAt: 10 })));
|
||||
|
||||
expect(selectCronLiveRunsForSession(useCronLiveRunOverlayStore.getState(), BASE_KEY)
|
||||
.map(({ runId }) => runId)).toEqual(['a-run', 'z-run', 'later-id']);
|
||||
});
|
||||
|
||||
it('stores eviction and gateway reset reasons without terminal refresh markers', async () => {
|
||||
const { ensureCronLiveRunOverlaySubscriptions, useCronLiveRunOverlayStore } = await importStore();
|
||||
ensureCronLiveRunOverlaySubscriptions();
|
||||
boundaryMock.listener?.(remove('evicted', 1, {
|
||||
reason: 'evicted',
|
||||
terminalStatus: undefined,
|
||||
}));
|
||||
boundaryMock.listener?.(remove('reset', 2, {
|
||||
reason: 'gateway-reset',
|
||||
terminalStatus: undefined,
|
||||
}));
|
||||
|
||||
const pending = useCronLiveRunOverlayStore.getState().pendingRemovals;
|
||||
expect(pending.map(({ reason }) => reason)).toEqual(['evicted', 'gateway-reset']);
|
||||
expect(pending.every((entry) => !('shouldRefresh' in entry) && !('terminal' in entry))).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,217 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { createInstance, type i18n } from 'i18next';
|
||||
import { I18nextProvider, initReactI18next } from 'react-i18next';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import type { CronLiveRunItem, CronLiveRunOverlaySnapshot } from '@shared/chat/cron-live-run';
|
||||
import { I18N_RESOURCES } from '@shared/i18n/resources';
|
||||
import { CronLiveRunOverlay } from '@/pages/Chat/CronLiveRunOverlay';
|
||||
|
||||
vi.mock('@/pages/Chat/AcpMessageSegment', () => ({
|
||||
AcpRenderPart: ({ part }: { part: { kind: string; text: string } }) => {
|
||||
const strong = part.text.match(/\*\*(.+?)\*\*/)?.[1];
|
||||
const code = part.text.match(/`(.+?)`/)?.[1];
|
||||
return (
|
||||
<div data-testid="markdown-renderer" data-source={part.text}>
|
||||
{strong && <strong>{strong}</strong>}
|
||||
{code && <code>{code}</code>}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
}));
|
||||
|
||||
const localeTitles = {
|
||||
en: 'Live scheduled run',
|
||||
zh: '计划任务实时运行',
|
||||
ja: 'スケジュール実行のライブ状況',
|
||||
ru: 'Выполнение задачи по расписанию',
|
||||
} as const;
|
||||
|
||||
function snapshot(overrides: Partial<CronLiveRunOverlaySnapshot> = {}): CronLiveRunOverlaySnapshot {
|
||||
return {
|
||||
canonicalSessionKey: 'agent:main:cron:daily-report',
|
||||
sourceSessionKey: 'agent:main:cron:daily-report:run:run-1',
|
||||
runSessionId: 'run-1',
|
||||
runId: 'run-1',
|
||||
revision: 7,
|
||||
status: 'running',
|
||||
startedAt: 1_786_000_000_000,
|
||||
updatedAt: 1_786_000_001_000,
|
||||
assistantText: '',
|
||||
thinking: false,
|
||||
items: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function testI18n(language: keyof typeof I18N_RESOURCES): i18n {
|
||||
const instance = createInstance().use(initReactI18next);
|
||||
void instance.init({
|
||||
lng: language,
|
||||
fallbackLng: 'en',
|
||||
defaultNS: 'chat',
|
||||
ns: ['chat'],
|
||||
resources: I18N_RESOURCES,
|
||||
interpolation: { escapeValue: false },
|
||||
initImmediate: false,
|
||||
});
|
||||
return instance;
|
||||
}
|
||||
|
||||
function renderOverlay(
|
||||
value: CronLiveRunOverlaySnapshot,
|
||||
language: keyof typeof I18N_RESOURCES = 'en',
|
||||
) {
|
||||
const instance = testI18n(language);
|
||||
return render(
|
||||
<I18nextProvider i18n={instance}>
|
||||
<CronLiveRunOverlay snapshot={value} />
|
||||
</I18nextProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe('CronLiveRunOverlay', () => {
|
||||
it.each(Object.entries(localeTitles))('renders the localized transient panel header in %s', (language, title) => {
|
||||
renderOverlay(snapshot(), language as keyof typeof I18N_RESOURCES);
|
||||
|
||||
expect(screen.getByTestId('cron-live-run-overlay')).toHaveAccessibleName(title);
|
||||
expect(screen.getByText(title)).toBeVisible();
|
||||
expect(screen.getByText(I18N_RESOURCES[language as keyof typeof I18N_RESOURCES].chat.cronLiveRun.transient)).toBeVisible();
|
||||
expect(screen.getByTestId('cron-live-running-pulse')).toHaveClass('animate-pulse');
|
||||
});
|
||||
|
||||
it('renders assistant Markdown and exposes only localized thinking state', () => {
|
||||
renderOverlay(snapshot({
|
||||
assistantText: 'Report **ready** with `3 items`.',
|
||||
thinking: true,
|
||||
}));
|
||||
|
||||
expect(screen.getByText('ready')).toHaveProperty('tagName', 'STRONG');
|
||||
expect(screen.getByText('3 items')).toHaveProperty('tagName', 'CODE');
|
||||
expect(screen.getByTestId('markdown-renderer')).toHaveAttribute('data-source', 'Report **ready** with `3 items`.');
|
||||
expect(screen.getByTestId('cron-live-thinking')).toHaveTextContent('Thinking');
|
||||
expect(screen.queryByText(/chain of thought|raw thought/i)).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('acp-assistant-message')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('updates a dedicated tool row through running, completed, and failed statuses', () => {
|
||||
const tool: CronLiveRunItem = {
|
||||
kind: 'tool',
|
||||
id: 'run-1:tool:read',
|
||||
toolCallId: 'read',
|
||||
title: 'Read report',
|
||||
status: 'running',
|
||||
inputText: '{"path":"report.md"}',
|
||||
outputText: 'Loaded report',
|
||||
};
|
||||
const { rerender } = renderOverlay(snapshot({ items: [tool] }));
|
||||
const instance = testI18n('en');
|
||||
|
||||
expect(screen.getByTestId('cron-live-tool')).toHaveTextContent('Tool');
|
||||
expect(screen.getByRole('status')).toHaveTextContent('Running');
|
||||
expect(screen.getByRole('status')).toHaveAttribute('aria-live', 'polite');
|
||||
expect(screen.getByRole('status')).toHaveAttribute('aria-atomic', 'true');
|
||||
expect(screen.getByTestId('cron-live-tool')).toHaveTextContent('{"path":"report.md"}');
|
||||
expect(screen.getByTestId('cron-live-tool')).toHaveTextContent('Loaded report');
|
||||
for (const detail of screen.getByTestId('cron-live-tool').querySelectorAll('pre')) {
|
||||
expect(detail).toHaveClass('bg-surface-input');
|
||||
expect(detail).not.toHaveClass('bg-surface-modal');
|
||||
}
|
||||
expect(screen.queryByTestId('acp-tool-call-card')).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('button')).not.toBeInTheDocument();
|
||||
|
||||
rerender(
|
||||
<I18nextProvider i18n={instance}>
|
||||
<CronLiveRunOverlay snapshot={snapshot({ items: [{ ...tool, status: 'completed' }] })} />
|
||||
</I18nextProvider>,
|
||||
);
|
||||
expect(screen.getByRole('status')).toHaveTextContent('Completed');
|
||||
expect(screen.getByRole('status')).toHaveAttribute('aria-live', 'polite');
|
||||
expect(screen.getByRole('status')).toHaveAttribute('aria-atomic', 'true');
|
||||
|
||||
rerender(
|
||||
<I18nextProvider i18n={instance}>
|
||||
<CronLiveRunOverlay snapshot={snapshot({ items: [{ ...tool, status: 'failed', error: 'Permission denied' }] })} />
|
||||
</I18nextProvider>,
|
||||
);
|
||||
expect(screen.getByRole('status')).toHaveTextContent('Failed');
|
||||
expect(screen.getByRole('status')).toHaveAttribute('aria-live', 'polite');
|
||||
expect(screen.getByRole('status')).toHaveAttribute('aria-atomic', 'true');
|
||||
expect(screen.getByTestId('cron-live-tool')).toHaveTextContent('Permission denied');
|
||||
});
|
||||
|
||||
it('renders static command, patch, and approval rows without approval actions', () => {
|
||||
const output = 'first line\n indented line\n\nlast line';
|
||||
renderOverlay(snapshot({
|
||||
items: [
|
||||
{
|
||||
kind: 'command',
|
||||
id: 'run-1:command:test',
|
||||
title: 'Run tests',
|
||||
status: 'completed',
|
||||
output,
|
||||
exitCode: 0,
|
||||
},
|
||||
{
|
||||
kind: 'patch',
|
||||
id: 'run-1:patch:1',
|
||||
title: 'Update report',
|
||||
summary: 'Adjusted generated sections',
|
||||
added: 4,
|
||||
modified: 2,
|
||||
deleted: 1,
|
||||
},
|
||||
{
|
||||
kind: 'approval',
|
||||
id: 'run-1:approval:1',
|
||||
title: 'Publish report?',
|
||||
status: 'running',
|
||||
message: 'Waiting for an external decision',
|
||||
},
|
||||
],
|
||||
}));
|
||||
|
||||
const command = screen.getByTestId('cron-live-command');
|
||||
const commandOutput = screen.getByTestId('cron-live-command-output');
|
||||
expect(command).toHaveTextContent('Command');
|
||||
expect(command).toHaveTextContent('Exit code: 0');
|
||||
expect(commandOutput).toHaveClass('whitespace-pre-wrap', 'bg-surface-input');
|
||||
expect(commandOutput).not.toHaveClass('bg-surface-modal');
|
||||
expect(commandOutput).toHaveTextContent(output, { normalizeWhitespace: false });
|
||||
|
||||
const patch = screen.getByTestId('cron-live-patch');
|
||||
expect(patch).toHaveTextContent('Patch');
|
||||
expect(patch).toHaveTextContent('Added: 4');
|
||||
expect(patch).toHaveTextContent('Modified: 2');
|
||||
expect(patch).toHaveTextContent('Deleted: 1');
|
||||
|
||||
const approval = screen.getByTestId('cron-live-approval');
|
||||
expect(approval).toHaveTextContent('Approval');
|
||||
expect(approval).toHaveTextContent('Read-only status. Respond in the originating client.');
|
||||
expect(approval).toHaveTextContent('Waiting for an external decision');
|
||||
expect(approval.querySelector('button')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('acp-permission-card')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['en', 'Added: 4', 'Modified: 2', 'Deleted: 1'],
|
||||
['zh', '新增:4', '修改:2', '删除:1'],
|
||||
['ja', '追加: 4', '変更: 2', '削除: 1'],
|
||||
['ru', 'Добавлено: 4', 'Изменено: 2', 'Удалено: 1'],
|
||||
] as const)('uses complete localized patch counts in %s', (language, added, modified, deleted) => {
|
||||
renderOverlay(snapshot({
|
||||
items: [{
|
||||
kind: 'patch',
|
||||
id: 'run-1:patch:localized',
|
||||
title: 'Localized patch',
|
||||
added: 4,
|
||||
modified: 2,
|
||||
deleted: 1,
|
||||
}],
|
||||
}), language);
|
||||
|
||||
const patch = screen.getByTestId('cron-live-patch');
|
||||
expect(patch).toHaveTextContent(added);
|
||||
expect(patch).toHaveTextContent(modified);
|
||||
expect(patch).toHaveTextContent(deleted);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { createCronApi } from '../../electron/services/cron-api';
|
||||
import { CronLiveRunBroker } from '../../electron/services/cron-live-run-broker';
|
||||
import type { GatewayManager } from '../../electron/gateway/manager';
|
||||
|
||||
const sessionMocks = vi.hoisted(() => ({
|
||||
@@ -39,11 +40,24 @@ function setupCronApi() {
|
||||
return makeGatewayJob({ kind: 'cron', expr: '* * * * *' });
|
||||
});
|
||||
const gatewayManager = { rpc } as unknown as GatewayManager;
|
||||
const api = createCronApi({ gatewayManager });
|
||||
const api = createCronApi({ gatewayManager, cronLiveRunBroker: new CronLiveRunBroker() });
|
||||
return { api, calls };
|
||||
}
|
||||
|
||||
describe('cron schedule normalization', () => {
|
||||
it('hydrates live-run overlays from the injected broker', () => {
|
||||
const gatewayManager = { rpc: vi.fn() } as unknown as GatewayManager;
|
||||
const cronLiveRunBroker = new CronLiveRunBroker(() => 100);
|
||||
cronLiveRunBroker.ingestRuntimeEvent({
|
||||
type: 'run.started',
|
||||
runId: 'run-1',
|
||||
sessionKey: 'agent:main:cron:job-1:run:session-1',
|
||||
});
|
||||
const api = createCronApi({ gatewayManager, cronLiveRunBroker });
|
||||
|
||||
expect(api.liveRunOverlays()).toEqual(cronLiveRunBroker.getSnapshotSet());
|
||||
});
|
||||
|
||||
it('wraps a plain cron expression string into a cron schedule on create', async () => {
|
||||
const { api, calls } = setupCronApi();
|
||||
await api.create({ name: 'n', message: 'm', schedule: '0 9 * * *' });
|
||||
@@ -103,7 +117,10 @@ describe('cron session history', () => {
|
||||
}
|
||||
return {};
|
||||
});
|
||||
const api = createCronApi({ gatewayManager: { rpc } as unknown as GatewayManager });
|
||||
const api = createCronApi({
|
||||
gatewayManager: { rpc } as unknown as GatewayManager,
|
||||
cronLiveRunBroker: new CronLiveRunBroker(),
|
||||
});
|
||||
|
||||
const result = await api.sessionHistory({
|
||||
sessionKey: 'agent:main:cron:job-1',
|
||||
@@ -151,7 +168,10 @@ describe('cron session history', () => {
|
||||
{ role: 'user', content: 'hi' },
|
||||
{ role: 'assistant', content: [{ type: 'text', text: fullReply }], stopReason: 'stop' },
|
||||
]);
|
||||
const api = createCronApi({ gatewayManager: { rpc } as unknown as GatewayManager });
|
||||
const api = createCronApi({
|
||||
gatewayManager: { rpc } as unknown as GatewayManager,
|
||||
cronLiveRunBroker: new CronLiveRunBroker(),
|
||||
});
|
||||
|
||||
const result = await api.sessionHistory({
|
||||
sessionKey: 'agent:main:cron:job-1',
|
||||
@@ -191,7 +211,10 @@ describe('cron session history', () => {
|
||||
sessionMocks.loadSessionTranscriptByKey.mockResolvedValue([
|
||||
{ role: 'assistant', content: `${'X'.repeat(2000)}more` },
|
||||
]);
|
||||
const api = createCronApi({ gatewayManager: { rpc } as unknown as GatewayManager });
|
||||
const api = createCronApi({
|
||||
gatewayManager: { rpc } as unknown as GatewayManager,
|
||||
cronLiveRunBroker: new CronLiveRunBroker(),
|
||||
});
|
||||
|
||||
const result = await api.sessionHistory({
|
||||
sessionKey: 'agent:main:cron:job-1',
|
||||
|
||||
@@ -2,9 +2,10 @@ import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
getCronSessionBaseKey,
|
||||
isCronSessionKey,
|
||||
isRunScopedCronSessionKey,
|
||||
parseCronSessionKey,
|
||||
sessionKeysAreEquivalent,
|
||||
} from '@/stores/chat/cron-session-utils';
|
||||
} from '@shared/chat/cron-session';
|
||||
|
||||
const BASE = 'agent:product:cron:294717ee-6dde-45a8-8f67-900e2831cc4f';
|
||||
const RUN = `${BASE}:run:0bfbc08a-7582-4c88-9fd3-47c484e17660`;
|
||||
@@ -29,6 +30,37 @@ describe('parseCronSessionKey', () => {
|
||||
expect(parseCronSessionKey('agent:main:main')).toBeNull();
|
||||
expect(isCronSessionKey('agent:main:main')).toBe(false);
|
||||
});
|
||||
|
||||
it.each([
|
||||
'agent::cron:job',
|
||||
'agent: :cron:job',
|
||||
'agent:main:cron:',
|
||||
'agent:main:cron: ',
|
||||
'agent:main:cron:job:run:',
|
||||
'agent:main:cron:job:run: ',
|
||||
])('rejects empty or whitespace-only identity segments in %j', (sessionKey) => {
|
||||
expect(parseCronSessionKey(sessionKey)).toBeNull();
|
||||
expect(isCronSessionKey(sessionKey)).toBe(false);
|
||||
});
|
||||
|
||||
it.each([
|
||||
'agent:main:cron:job:run',
|
||||
'agent:main:cron:job:other:run-id',
|
||||
'agent:main:cron:job:run:run-id:extra',
|
||||
'agent:main:cron:job:extra',
|
||||
])('rejects malformed cron suffixes in %j', (sessionKey) => {
|
||||
expect(parseCronSessionKey(sessionKey)).toBeNull();
|
||||
expect(isCronSessionKey(sessionKey)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isRunScopedCronSessionKey', () => {
|
||||
it('accepts only strict run-scoped cron session keys', () => {
|
||||
expect(isRunScopedCronSessionKey(RUN)).toBe(true);
|
||||
expect(isRunScopedCronSessionKey(BASE)).toBe(false);
|
||||
expect(isRunScopedCronSessionKey(`${BASE}:run:`)).toBe(false);
|
||||
expect(isRunScopedCronSessionKey(`${BASE}:other:run-id`)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getCronSessionBaseKey', () => {
|
||||
|
||||
@@ -48,7 +48,11 @@ describe('dispatchProtocolEvent', () => {
|
||||
expect(emitter.emit).toHaveBeenCalledWith('chat:message', { message: { text: 'hello' } });
|
||||
});
|
||||
|
||||
it('does not normalize non-terminal lifecycle phase=end as run.ended', () => {
|
||||
it.each([
|
||||
{ phase: 'end', aborted: undefined, status: 'completed' },
|
||||
{ phase: 'end', aborted: true, status: 'aborted' },
|
||||
{ phase: 'error', aborted: undefined, status: 'error' },
|
||||
] as const)('normalizes lifecycle phase=$phase as run.ended with status=$status', ({ phase, aborted, status }) => {
|
||||
const emitter = createMockEmitter();
|
||||
const payload = {
|
||||
runId: 'run-1',
|
||||
@@ -57,16 +61,25 @@ describe('dispatchProtocolEvent', () => {
|
||||
seq: 4,
|
||||
ts: 10,
|
||||
data: {
|
||||
phase: 'end',
|
||||
phase,
|
||||
...(aborted === undefined ? {} : { aborted }),
|
||||
endedAt: 11,
|
||||
livenessState: 'settled',
|
||||
replayInvalid: false,
|
||||
stopReason: 'terminal',
|
||||
},
|
||||
};
|
||||
|
||||
dispatchProtocolEvent(emitter, 'agent', payload);
|
||||
|
||||
expect(emitter.emit).not.toHaveBeenCalledWith('chat:runtime-event', expect.objectContaining({
|
||||
expect(emitter.emit).toHaveBeenCalledWith('chat:runtime-event', expect.objectContaining({
|
||||
type: 'run.ended',
|
||||
runId: 'run-1',
|
||||
status,
|
||||
endedAt: 11,
|
||||
livenessState: 'settled',
|
||||
replayInvalid: false,
|
||||
stopReason: 'terminal',
|
||||
}));
|
||||
expect(emitter.emit).toHaveBeenCalledWith('notification', {
|
||||
method: 'agent',
|
||||
|
||||
@@ -219,7 +219,10 @@ describe('harness specs', () => {
|
||||
}
|
||||
|
||||
for (const { file, content } of harnessMarkdown) {
|
||||
expect(content, `${file} must not depend on deleted design or plan documents`)
|
||||
const checkedContent = file === 'harness/specs/tasks/render-cron-run-live-status.md'
|
||||
? content.replace(' - docs/plans/2026-08-04-cron-live-run-overlay.md\n', '')
|
||||
: content;
|
||||
expect(checkedContent, `${file} must not depend on deleted design or plan documents`)
|
||||
.not.toMatch(/docs\/(?:specs|plans)\//);
|
||||
}
|
||||
await expect(access('docs/specs')).rejects.toThrow();
|
||||
@@ -256,6 +259,43 @@ describe('harness specs', () => {
|
||||
expect(acpChatScenario?.data.ownedPaths).toContain('tests/e2e/chat-acp-attachments.spec.ts');
|
||||
});
|
||||
|
||||
it('defines the bounded cron live-run overlay harness contract', async () => {
|
||||
const expectedRules = [
|
||||
'renderer-main-boundary',
|
||||
'backend-communication-boundary',
|
||||
'api-client-transport-policy',
|
||||
'host-api-fallback-policy',
|
||||
'host-events-fallback-policy',
|
||||
'gateway-readiness-policy',
|
||||
'acp-chat-state-and-history',
|
||||
'acp-compatibility-content-safety',
|
||||
'ui-i18n-design-tokens',
|
||||
'comms-regression',
|
||||
'docs-sync',
|
||||
];
|
||||
const [task, rules] = await Promise.all([
|
||||
loadSpec('harness/specs/tasks/render-cron-run-live-status.md'),
|
||||
loadRuleSpecs(),
|
||||
]);
|
||||
const ruleIds = new Set(rules.map((rule) => rule.data.id));
|
||||
|
||||
expect(task.data).toMatchObject({
|
||||
id: 'render-cron-run-live-status',
|
||||
scenario: 'gateway-backend-communication',
|
||||
taskType: 'runtime-bridge',
|
||||
requiredProfiles: ['fast', 'comms', 'e2e'],
|
||||
requiredRules: expectedRules,
|
||||
docs: { required: true },
|
||||
});
|
||||
expect(expectedRules.filter((ruleId) => !ruleIds.has(ruleId))).toEqual([]);
|
||||
expect(task.data.touchedAreas).not.toContain('docs/**');
|
||||
expect(task.data.touchedAreas).toContain('docs/plans/2026-08-04-cron-live-run-overlay.md');
|
||||
expect(task.body).toContain('harness/reference/acp-cron-live-overlay.md');
|
||||
const overlayReference = await readFile('harness/reference/acp-cron-live-overlay.md', 'utf8');
|
||||
expect(overlayReference).toContain('Gateway runtime event -> Main bounded cron broker -> explicit live overlay');
|
||||
expect(overlayReference).toContain('terminal event -> overlay removal -> authoritative ACP/cron-history reload');
|
||||
});
|
||||
|
||||
it('parses Markdown frontmatter with arrays and nested docs', () => {
|
||||
const spec = parseFrontmatter(`---
|
||||
id: example
|
||||
|
||||
@@ -510,6 +510,21 @@ describe('hostApi facade', () => {
|
||||
}));
|
||||
});
|
||||
|
||||
it('calls cron.liveRunOverlays through hostInvoke', async () => {
|
||||
hostInvoke.mockResolvedValueOnce({
|
||||
id: 'req',
|
||||
ok: true,
|
||||
data: { revision: 4, snapshots: [] },
|
||||
});
|
||||
const { hostApi } = await import('@/lib/host-api');
|
||||
|
||||
await expect(hostApi.cron.liveRunOverlays()).resolves.toEqual({ revision: 4, snapshots: [] });
|
||||
expect(hostInvoke).toHaveBeenCalledWith(expect.objectContaining({
|
||||
module: 'cron',
|
||||
action: 'liveRunOverlays',
|
||||
}));
|
||||
});
|
||||
|
||||
it('calls skills.clawhubList through hostInvoke', async () => {
|
||||
hostInvoke.mockResolvedValueOnce({ id: 'req', ok: true, data: { success: true, results: [] } });
|
||||
const { hostApi } = await import('@/lib/host-api');
|
||||
|
||||
@@ -13,6 +13,28 @@ beforeEach(() => {
|
||||
});
|
||||
|
||||
describe('hostEvents', () => {
|
||||
it('defines and subscribes to cron live-run overlay changes over IPC', async () => {
|
||||
const { HOST_EVENT_CHANNELS } = await import('@shared/host-events/contract');
|
||||
const { hostEvents } = await import('@/lib/host-events');
|
||||
const handler = vi.fn();
|
||||
|
||||
hostEvents.onCronLiveRunOverlayChanged(handler);
|
||||
const callback = on.mock.calls[0]?.[1] as ((payload: unknown) => void) | undefined;
|
||||
const change = {
|
||||
kind: 'remove',
|
||||
revision: 2,
|
||||
canonicalSessionKey: 'agent:main:cron:daily-report',
|
||||
sourceSessionKey: 'agent:main:cron:daily-report:run:session-1',
|
||||
runId: 'run-1',
|
||||
reason: 'ended',
|
||||
};
|
||||
callback?.(change);
|
||||
|
||||
expect(HOST_EVENT_CHANNELS.cron.liveRunOverlayChanged).toBe('cron:live-run-overlay-changed');
|
||||
expect(on).toHaveBeenCalledWith('cron:live-run-overlay-changed', expect.any(Function));
|
||||
expect(handler).toHaveBeenCalledWith(change);
|
||||
});
|
||||
|
||||
it('subscribes to gateway status over IPC', async () => {
|
||||
on.mockReturnValueOnce(() => undefined);
|
||||
const { hostEvents } = await import('@/lib/host-events');
|
||||
|
||||
@@ -1128,6 +1128,30 @@ describe('host services', () => {
|
||||
expect(source).not.toMatch(/['"]webBrowser:/);
|
||||
});
|
||||
|
||||
it('passes one cron live-run broker through Main before Gateway auto-start', () => {
|
||||
const mainSource = readFileSync(join(process.cwd(), 'electron/main/index.ts'), 'utf8');
|
||||
const handlersSource = readFileSync(join(process.cwd(), 'electron/main/ipc-handlers.ts'), 'utf8');
|
||||
const brokerSource = readFileSync(
|
||||
join(process.cwd(), 'electron/services/cron-live-run-broker.ts'),
|
||||
'utf8',
|
||||
);
|
||||
const rawRuntimeListener = mainSource.slice(
|
||||
mainSource.indexOf("gatewayManager.on('chat:runtime-event'"),
|
||||
mainSource.indexOf("gatewayManager.on('channel:status'"),
|
||||
);
|
||||
|
||||
expect(mainSource.match(/new CronLiveRunBroker\(/g)).toHaveLength(1);
|
||||
expect(mainSource).toContain('registerIpcHandlers(\n gatewayManager,\n cronLiveRunBroker,');
|
||||
expect(handlersSource).toContain('createCronApi({ gatewayManager, cronLiveRunBroker })');
|
||||
expect(mainSource.indexOf('bindCronLiveRunBroker({')).toBeLessThan(
|
||||
mainSource.indexOf('await gatewayManager.start();'),
|
||||
);
|
||||
expect(brokerSource.match(/\.ingestRuntimeEvent\(/g)).toHaveLength(1);
|
||||
expect(mainSource).not.toContain('.ingestRuntimeEvent(');
|
||||
expect(rawRuntimeListener).toContain("sendMainWindowEvent('chat:runtime-event', data);");
|
||||
expect(rawRuntimeListener).not.toContain('cronLiveRunBroker');
|
||||
});
|
||||
|
||||
it('configures browser policy and typed handlers before the initial renderer load', () => {
|
||||
const source = readFileSync(join(process.cwd(), 'electron/main/index.ts'), 'utf8');
|
||||
const createWindowSource = source.slice(
|
||||
|
||||
Reference in New Issue
Block a user