Files
qwen-code/packages/web-shell/client/utils/taskActivity.test.ts
ce634b8905 feat(web-shell): visualize and manage dynamic workflow runs (#10594)
* feat(serve): expose workflow tasks and controls

* feat(web-shell): visualize workflow runs

* fix(serve): align workflow capability gating

* fix(cli): reject live workflow history deletion

* fix(web-shell): harden workflow history UX

* fix(serve): start controlled workflows in background

* fix(serve): address workflow control review blockers

* fix(serve): make workflow history deletion race-safe

* fix(serve): enforce workspace trust on the daemon Workflow surfaces (#9546)

* feat(web-shell): add workflow creation entry

* style(web-shell): flatten the workflow execution graph

Rework the Workflow run card as a flat surface: one background, one
hairline, status carried by a single accent per node and edge. Drops the
radial/dot-grid canvas, gradient node fills, drop shadows and glow
filters, and raises the 9–10px type to 11–12px.

- Lanes share the viewport width (196–336px) so a three-phase run fills
  the card instead of ending in a blank strip; the last lane absorbs any
  remaining width. Nodes grow with their lane up to 300px.
- Node: 3px status bar on the left, lucide status icon, label on its own
  line with status and elapsed time beneath it. Queued nodes are dashed.
- Run controls (pause / stop / retry / rerun) move from above the card
  into the graph's metrics strip so they sit next to what they act on.
- Inspector drops its tinted panel and boxed key/value cells for a
  hairline-separated key/value list; callouts keep only a 2px bar.
- Saved workflows list becomes one bordered list with row dividers
  instead of individually bordered, striped cards.

Claude-Session: https://claude.ai/code/session_01Umbt74AQ1QDT8bGwFhRYks

* feat(web-shell): open a saved workflow's definition from the Saved tab

A saved workflow row was name + scope + Run; the definition itself was
unreachable from the browser because the daemon only forwarded `{ name,
source }` and the `/file` route stops at the workspace boundary, which
excludes user-scope `~/.qwen/workflows`.

Add a read-only definition surface end to end:

- daemon: `GET /session/:id/saved-workflows/:name` →
  `_qwen/session/saved_workflow` → `qwen/status/session/saved_workflow`.
  The ACP child resolves the name through `resolveSavedWorkflowScript`
  (so the existing saved-workflow-directory guard applies), parses the
  `export const meta` block with `extractAndStripMeta`, and returns
  `{ source, scriptPath, script, meta, metaError? }`. Every miss —
  unknown name, illegal name, unreadable file, Workflow controls
  unavailable, untrusted workspace — fails closed to `workflow: null`
  with the same envelope on both transports, so the route cannot be used
  to probe the filesystem.
- sdk / webui: `DaemonSessionSavedWorkflowStatus`, route-table entry,
  `DaemonSessionClient.savedWorkflow`, and a `readSavedWorkflow` action.
- web-shell: the Saved row's identity block is now a disclosure button.
  Expanding it shows the description, "when to use", the phase list with
  optional model badges, the script path, the definition's recent runs in
  this session (with a jump to History), and a folded, highlighted copy of
  the source. Stale reads across session switches are dropped, and a
  definition that disappears from the list closes its detail.
- e2e: the mock daemon learns `/session/:id/tasks`, a configurable
  `supported-commands`, and `/session/:id/saved-workflows/:name`, and a
  `workflow-page` visuals spec captures Saved, the expanded definition,
  Running, and History in both themes for the CI preview.

Claude-Session: https://claude.ai/code/session_01Umbt74AQ1QDT8bGwFhRYks

* fix(serve): close cross-session workflow deletion races and untrusted task reads (#9546)

- Session history merge now treats a persisted snapshot as authoritative
  over a stale callback cache, and retires the cache once the runner
  confirms the snapshot write (new registry snapshot-persisted hook), so
  a sibling's deletion is not resurrected on refresh.
- History deletion consults every sibling session's run registry (live
  entries and settling handles) before deleting from the shared store,
  and a successful deletion purges sibling terminal entries so retries
  cannot re-persist a deleted run.
- Workflow holds mirror the registry's hasRunningEntries: paused runs no
  longer pin the session indefinitely.
- The includeWorkflows opt-in is gated on workspace trust at the daemon
  boundary in both the ACP-HTTP dispatch and the REST tasks route,
  matching the fail-closed shape of the other workflow surfaces.

* fix(cli): close the four cross-session workflow-history consistency holes

Addresses R5-9, R7-4, R7-5 and R7-10 — all four Criticals open on this PR.
They are one family: a run's history can be deleted, retried, or listed
from any session, and each gate had a different idea of which runs exist.

R5-9 — the mutation claim is task-global, not session-scoped. Keyed
`sessionId\0taskId`, it serialized nothing that mattered: every session
shares one snapshot store. A sibling's retry passed canStart (`failed`,
no handle), took its own per-session claim, then awaited journal
load/compile before `register()`; a delete-history landing in that
structural window found the run terminal and handle-less in every
registry, removed the journal directory and snapshot, and answered
`{changed: true}` — after which the retry re-registered and its
settlement re-persisted the history the user was told was deleted. The
claim is now keyed by taskId alone and taken by delete-history, retry,
rerun and run-saved. `run-saved` keys off a saved workflow's NAME, so it
claims in its own `saved\0` namespace rather than colliding with runIds.

R7-4 — deletion tests membership against the uncapped merged set.
`buildSessionTasksStatus` serializes every registry entry
unconditionally while `refreshWorkflowHistory` truncates to
MAX_RETAINED_SNAPSHOTS by startTime, so a long run that settled after
~30 newer ones started stayed listed via the registry but fell out of
the window — terminal, handle-free, live in no sibling, and permanently
undeletable. `refreshWorkflowHistory` now records the merged id set
before the cap, and deletion gates on that, the registry, or the
unpersisted cache. `deleteWorkflowSnapshot` already tolerates an absent
target, so the wider gate cannot delete what is not there.

R7-5 — snapshot retirement is a latch. The registry's dispatch-drain
callbacks emit status changes on TERMINAL entries with no status gate,
and in-flight dispatches keep draining across the snapshot write, so a
terminal emission routinely landed after `notifySnapshotPersisted` had
retired the cache entry — re-inserting the run as "never persisted".
A sibling's deletion was then undone by the next refresh, which reads
"absent on disk, present in cache" as a pending write and republishes.
Persistence is now remembered per runId and `#rememberWorkflowHistory`
returns early for members; the latch releases when the runId goes active
again, so a genuine re-run is still cached.

R7-10 — the liveness gate sees runs whose session is gone. It iterated
`this.sessions` only, but close/kill/shutdown use force semantics and a
background run owns a detached controller, so after
`removeStoredSessionEntry` a still-settling run was invisible to the
gate and unreachable by the delete handler's sibling `removeTerminal`
loop: a sibling delete-history removed the LIVE run's journal and
snapshot, and the orphan's settlement write recreated it. Two halves —
`Session.dispose()` now aborts its workflow registry the way it already
aborts the agent registry (before the callbacks are torn down), and the
registry of a removed session is retained here until its runs drain, so
the gate still answers across the settlement window an abort cannot
compress to zero. Retention is bookkeeping: a Config that cannot answer
is logged, never turned into a shutdown failure.

Regressions, each mutation-checked against its own repair:
- acpAgent: a sibling's parked retry makes delete-history answer
  `{changed: false}` without reaching the store, and the deletion goes
  through once the claim releases
- acpAgent: a live run's registry stays visible to the gate across its
  session's close, and is dropped once the handle is released
- Session: a status emission after `snapshotPersisted` no longer
  resurrects a sibling-deleted run (reverting the latch reproduces the
  reviewer's probe verbatim), and a re-registered runId is remembered
  again
- Session: a run with the oldest startTime behind 30 newer snapshots is
  deletable
- Session: dispose aborts the workflow registry before clearing its
  callbacks

Verification: Session.test.ts 733 passed, acpAgent.test.ts 514 passed,
`tsc --noEmit -p packages/cli` clean, eslint and prettier clean. The 14
`packages/cli/src/serve` failures (fast-path import boundary,
capabilities-docs contract, workspace fs/agents/memory, conversation
runtime ownership) reproduce identically on the unmodified head — base
skew, untouched by this change.

Claude-Session: https://claude.ai/code/session_01M7z4PccYfDPyyfg3oGr8V1

* fix(core): drop the duplicated telemetry-swap mock property

Merging upstream/main (053f17b775) clears the 9 `client.telemetrySwap`
failures this branch had from predating #10220, but main carries its own
TS1117 on the same file: two commits added `getToolRegistry` to the same
object literal independently and neither saw the other.

  032b9078e6 feat(serve): backfill session PR bindings ... (#9729)
  8241905392 test(core): give the telemetry-swap client mock a
             getToolRegistry (#10220)

Checking that file out from upstream/main here and running
`tsc --noEmit -p packages/core` reproduces
`client.telemetrySwap.test.ts(103,5): error TS1117` verbatim, so taking
the merge unmodified would have traded 9 test failures for a build that
never reaches the tests at all.

Removed as part of the merge rather than left for a follow-up: keeps
#10220's copy, which was added for this purpose and carries the
explanation, and drops #9729's incidental one. main still needs the same
removal — this only keeps it out of the branch.

Verified after the merge: `tsc --noEmit` clean for both packages/core
and packages/cli; client.telemetrySwap 10 passed, Session.test.ts and
acpAgent.test.ts 1260 passed together.

Claude-Session: https://claude.ai/code/session_01M7z4PccYfDPyyfg3oGr8V1

* fix(workflows): guard pending run lifecycle

* fix(workflows): let a starting run be cancelled, and stop two slow leaks

Four of the behavioural items the review deferred across rounds 8-9; the
test-coverage-only entries stay deferred.

- Cancel during the start window (R9 acpAgent.ts:10884 + workflow-runner.ts:181).
  Between `reserveStart` and `register` the runner loads the script and
  replays the journal — seconds, for a resume of a large one — and the
  registry has no entry yet. `sessionTaskCancel` answered `not_found` for a
  run the client could see starting, and `registry.cancel` could not reach
  the reserved controller either. New `cancelStarting` aborts it (the
  reservation stays the runner's to release, as after `abortAll`), and the
  cancel handler routes there when the liveness gate would say "starting".
  Doing that exposed the second half: the runner threw a bare `Error` for
  an abort during start, and the tool's catch only recognised the CALLER's
  signal — a registry-side abort surfaced as an unexplained failure. It is
  now a typed `WorkflowStartCancelledError`, mapped to the same
  "cancelled before it could start" result.
- `detachedWorkflowRegistries` (R8 acpAgent.ts:3386) was pruned only inside
  the delete-history liveness check. A daemon that closes sessions mid-run
  and never deletes history retained every registry for its lifetime. Prune
  on session close as well.
- Refresh/delete race (R9 Session.ts:3428). `refreshWorkflowHistory` reads
  the directory and merges without a claim; a delete landing between the
  read and the merge was overwritten by the stale listing and the run
  reappeared until the next refresh. Deletions are now sequenced, and a
  refresh drops any run deleted after its read began — keyed by runId and
  compared against the refresh's own mark, so a later retry that reuses
  the id is not suppressed.
- The "Register a new run" JSDoc sat on `reserveStart` (R9 registry:594).

Mutation-verified, all four at once against the full suites: disabling the
starting-window branch, the prune-on-close, the deletion filter, and the
typed-error mapping reddens exactly the four new tests and nothing else.

Claude-Session: https://claude.ai/code/session_01VXsC4f71S6U6YkW82NRw7m

* fix(core): close the round-5 review findings on workflow task controls

- Key the starting-window cancel on a live reservation rather than on the
  absence of an entry: a retry reuses its runId, so its terminal entry
  shadowed the reservation and cancel answered `not_running` about a run
  that was actively starting.
- Answer `changed: false` from retry when `execute()` reports a start that
  never registered (no `workflowRunId`), mirroring rerun and run-saved.
- Classify a registry-side abort of the reserved controller as a cancel in
  foreground starts too, not only background ones; the tool maps
  `WorkflowStartCancelledError` in either mode.
- Report reserved-but-unregistered runs as workflow active-work holds
  (`WorkflowRunRegistry.listStartingRunIds`), so a daemon conditional close
  cannot dispose the session under a start it just accepted.
- Propagate a successful history deletion into every sibling session's
  deletion marker and cached history, symmetric to the `removeTerminal`
  sweep, so a sibling refresh that had already read the directory cannot
  republish the deleted run.

Claude-Session: https://claude.ai/code/session_018dYE4LwSMeMPFchXk5UBdM

* fix(serve): keep workflow retry and the workflowsEnabled flag consistent across sessions

Two cross-session gaps in the workflow control surface:

- A retry consulted only the requesting session's registry. Every
  session shares one journal/snapshot store and the task-global claim is
  released as soon as the background start returns, so a sibling whose
  registry still showed the run `failed` started a second runner under
  the same runId. Retry now refuses while the runId is live in any
  session (or in its own starting window), checked synchronously beside
  canStart so the answer cannot go stale before the claim is taken.

- Workspace reload updated `tools.workflowsEnabled` for `/capabilities`
  but never told existing sessions; `Config.workflowsEnabled` was set
  once at construction. The reload's `tools` branch now propagates the
  flag and pushes an available-commands update when it flips.

Claude-Session: https://claude.ai/code/session_01NkW1J2aBKcsKS62dkPcWbT

* fix(serve): refuse to report a workflow history deletion whose registry entry survived

`deleteWorkflowHistory` ignored `removeTerminal()`'s answer. The registry
refuses to remove a live or handle-held entry — its own last word on
whether the run is still active in this session — so a `false` for an
entry that exists meant the run re-registered under the deletion and
would re-persist the history the client was just told was gone. The
entry is now retired before the store is touched, and a refusal fails
the deletion; a persisted-only run has no entry and is unaffected.

Claude-Session: https://claude.ai/code/session_01NkW1J2aBKcsKS62dkPcWbT

* fix: settle the merge with main after #10411 landed

The daemon layer merged as #10411 with later review fixes this branch's
copy predated, and main renamed surfaces underneath the Web Shell layer:

- drop the leftover options.beforeSessionCreate call in acpAgent.ts;
  main renamed the hook to beforeSessionPublish (#10269) and invokes it
  at its new location
- rename @qwen-code/webui/daemon-react-sdk imports in the three new
  workflow client files to @qwen-code/web-shell/daemon-react-sdk
- count the saved-workflow route in the telemetry catalog assertions
  (63 routes, 61 handler_resolved)
- bump the SDK browser daemon bundle budget 215KB -> 216KB for the
  saved-workflow read surface (measured ~110 bytes over the old cap)

Claude-Session: https://claude.ai/code/session_01NkW1J2aBKcsKS62dkPcWbT

* fix(web-shell): remove stale task helper import

* fix(web-shell): restore the collapsed-group style and pin workflow fixtures

Review round 1 findings R1-1, R1-5, R1-7 and R1-11.

`ToolGroup` stopped hiding collapsed groups with an unconditional inline
`display: none` and moved workflow groups onto
`styles.chatSummaryContentCollapsed`, but that class was dropped from
`ToolChrome.module.css` by #9914. A CSS module miss resolves to
`undefined`, so the collapsed workflow group rendered its content in
full. Restore the class with the `grid-template-rows: 0fr` collapse it
had before, which keeps the group mounted — what the workflow detail
view needs — instead of unmounting it.

`WorkflowRunsPage` references `styles.detailRunTime` for the flexible
middle column of `.detailRuns li`; the class was never defined, leaving
a long timestamp free to widen the row. Define it with the truncation
the `minmax(0, 1fr)` column implies.

The workflow task fixtures declare `DaemonSessionWorkflowTaskStatus` but
omit `tokenBudgetTotal` (and `currentPhase` in `composerTasks`), which a
trailing `Partial<...>` spread cannot satisfy, and `workflowTasks`
annotates its array `DaemonSessionTaskStatus[]` — the agent | shell |
monitor union that has no workflow member. Both are TS2322 under the
package's own strict options today, masked only by the test-file
typecheck exclusion.

* fix(web-shell): address workflow review blockers

* fix(web-shell): close the five Critical findings from review round 3

Each fix was mutation-probed: the test named below fails on the reverted
behaviour and passes with it.

R3-1 useBackgroundTasks read the activity fact by re-parsing the rendered
taskActivityKey with a regex. `callId` is unconstrained text and the key
is `callId:status` joined with '|', so the rendering is not injective: a
completed call whose id contains ':in_progress|' reads as active, which
pins BOTH polling stop conditions open and leaves the session polling
every 3s for as long as the view is mounted. taskActivity.ts now exports
hasActiveTaskActivity(), derived by the same walk that builds the key,
and the hook takes the boolean instead. The key stays, for change
detection only. (taskActivity.test.ts)

R3-2 ToolGroup passed `inert={true}` as a JSX prop. react-dom 18 — inside
this package's peer range — has no `inert` property entry and drops a
boolean on an unknown attribute, so no attribute rendered at all: the
collapsed content stays mounted, and its focusable ToolLine rows stayed
in the tab order while aria-hidden hid them from assistive tech. Now set
on the node via toggleAttribute, the pattern ParallelAgentsGroup already
uses.

R1-43 Rows became focusable in this PR while the global `x` shortcut
still acts on the selection, so focus and selection were two independent
cursors: Tab to task B, press x, and task A stops. Focus now syncs the
selection for embedded rows too, except while a row is expanded — the
pinned "focus does not expand" behaviour must not change.

R1-44 tasksPanelActive was latched per hook instance and never reset on
session change. Split view keeps the old pane mounted, so switching
primary fires no `active: false`, and when that pane's panel finally
closes its event carries the OLD sessionId and is filtered out — the new
session's polling stayed blocked for good. Reset it in the existing
[connected, owner, sessionId] effect; the same-session pause contract is
unchanged.

R2-1 isComposerTask counted every workflow task, including the retained
history getWorkflowTasks() merges in. One poll was enough to make the
status-bar pill announce the project's whole saved history ("30 tasks
done") for the rest of the session, uncleareable, and to list those runs
in the environment panel. History is excluded here and at the two inline
filters, which now reuse the predicate. Consumers that need the history
(the workflow details provider) still read the unfiltered list.

* test(web-shell): align the useBackgroundTasks mock with its new signature

The R3-1 fix threaded a `taskActivityActive` boolean into
`useBackgroundTasks` between `taskActivityKey` and `connected`, but
App.test.tsx mocks the hook by position and its stub was not updated —
so `refreshTrigger` received `connected` (true) and seven assertions on
the captured trigger failed with "expected true to be 0/1".

Caught by CI, not locally: the earlier verification ran
`vitest run client/`, and that path filter selects 234 of the package's
245 test files — App.test.tsx is one of the eleven it leaves out.

* fix(web-shell): keep the workflow runs page loadable, current and per-session

Three production findings from review round 3 on #10594, each pinned by a
test verified to fail with the fix reverted.

R1-41 `reload` had the auxiliary `refreshCommands()` in the same
Promise.all as the task fetch, so a supported-commands failure rejected
the whole load, discarded an already-fetched snapshot and hid all three
tabs behind the failure banner — a persistently failing refresh made the
page unusable while task status worked. Only the task fetch gates the
page now; refreshCommands still reports itself through its own notice.

R1-42 The session-switch reset cleared five states but not `loadError`,
and reload clears it only on success — so session A's failure banner
rendered over session B until B's load settled, or indefinitely if it
hung.

R2-8 The snapshot's only live update path is the poller inside the
active/history panes, which Radix unmounts while inactive — so on the
default Saved tab the Active/History badges and the saved cards'
recent-runs list froze at whatever the last reload saw. Both panes are
force-mounted now. Note Radix's forceMount does NOT hide inactive
content, contrary to the finding's suggestion (verified: the forced pane
renders with data-state="inactive" and no hidden attribute), so the
hiding is done by a page-scoped CSS rule; the panes stay mounted, so
their effects keep running while nothing renders.

The tab-separation test asserted on the whole container, which now also
holds the hidden pane. It scopes to the pane Radix marks active — a
signal independent of how the hiding is implemented, which matters
because jsdom does not apply the CSS module rule.

* test(web-shell): make the workflow runs page assertions able to fail

Five findings from review round 3 on #10594, all of them assertions that
passed no matter what the page did.

R1-22 The suite bound getTasks and getWorkflowTasks to one shared mock,
so it could not see which route the page reads. That difference is
load-bearing: the legacy route builds no workflow tasks at all, so a flip
to it empties every tab in production. Split into two mocks with a
mount-time assertion on the workflow route; the no-session path now
checks both. Verified: flipping reload() to getTasks reds 12 of 14 tests,
where the aliased suite passed all 12.

R1-15 'opens directly on compact workflow navigation' guarded two strings
that exist nowhere in the repo, so both negatives passed unconditionally
and the only live assertion (the Refresh button) is satisfied by the
toolbar alone. Asserts rendered tab content instead.

R1-16 The stale-completion test checked for 'Workflow could not be
started', but the copy is 'The saved workflow could not be started' —
toContain is case-sensitive, so it passed with the banner up. It also
resolved the stale start with started:true, leaving the error path
unexercised: the banner is only reachable through !started or the catch.
Corrected string, and the stale start now resolves as a failure.

R1-38 The same test clicked the Run button through an optional chain with
no assertion that it existed and none that a start was ever issued — a
missing button made every later assertion pass vacuously.

R1-19 The rich start payload was programmed before renderPage, which
resets that mock, so taskId/status never reached the component. Moved
after the mount.

* fix(web-shell): remove dead workflow render paths and unify the tool predicate

Four findings from review round 3 on #10594.

R3-3 The workflow token entry pushed into `compactFields` never rendered:
`compactFields` has one read site, and the same diff makes `headerContent`
short-circuit to null for workflow tasks before reaching it. Built and
pushed on every render for no effect, and misleading to anyone editing the
inline workflow row. The live `subtitleParts` push stays.

R1-20 `toolName.toLowerCase() === 'workflow'` was hand-inlined three times
in ToolGroup — gating the detail view, the ToolLine route and the
collapsed keep-mounted behaviour. A rename that missed one would make
routing disagree with mounting, with no compile error. Extracted
`isWorkflowToolName` beside `isShellToolName` and used at all three.

R1-46 The toolbar comment promised colour was left to the owner while the
rule below flattened every non-primary variant's border and background.
The override is deliberate — the strip should read as one toolbar — so
the comment now says that instead of the opposite.

R1-47 Three `opacity: 1` declarations were dead: the queued/cancelled,
related and dimmed rules have identical specificity and come later, so
they win wherever they co-occur, and neither `.node` nor `.edge` sets a
base opacity, so there is nothing to restore otherwise. Dropped rather
than promoted — making an approval hold full opacity through those states
is a visual change, not a cleanup, and is noted as such at the site.

* fix: stop unbounded polling, misattributed runs and stolen selections

Five more findings from review round 3 on #10594, each with a test
verified to fail on the reverted behaviour.

R2-2 `getTaskActivityKey` counts in-flight workflow tool calls, but with
the workflows endpoint off the hook polls `getTasks()`, whose response
structurally excludes workflow tasks — so the call could never appear in
a snapshot and BOTH stop conditions became unsatisfiable: the 3s poll ran
for the rest of the run (foreground runs cap at 30 minutes). The activity
fact is now gated on the same flag that picks the endpoint, so a workflow
call is simply unobservable in that mode and the empty-poll budget ends
the poll. Both call sites pass the flag.

R3-6 The approval auto-focus tracked one last-observed id, which cannot
tell "a new approval arrived" from "the newest was settled and an older
one is now last" — answering the newest of two re-stole the inspector
from the node the user had deliberately selected. Tracks an acknowledged
set instead, seeded at mount and reset with the rest of the run-change
state, so polling ticks never re-fire it.

R3-12 The `resumeFromRunId` fallback paired a resume that never launched
— a script compile error, or cancelled before start — with the SOURCE
run, rendering that run's graph as the failed call's detail instead of
its error text. Gated to live calls, which is the branch's argued use.

R1-12 `_qwen/session/saved_workflow` was missing from WS_READ_METHODS, so
definition reads were billed to the mutation tier (30/min) and shared a
bucket with real mutations — ~30 reads a minute then had the user's next
pause/stop/cancel rejected as rate-limited.

R3-11 The `resumeFromRunId` branch had no coverage at all; both its live
and never-launched cases are pinned now.

Not addressed here: R3-5 (ChatPane's poller duplicates App's for the same
session when the default split view is open). The fix is architectural —
lift polling to App, or share it through a per-session subscription cache
— and panes can host sessions other than the current one, so it needs its
own slice rather than being folded into this batch.

* test(web-shell): cover the task panel's refusal, release and switch paths

Four findings from review round 3 on #10594. Each new assertion was
verified against the mutant the finding names.

R3-13 No `changed: false` appears anywhere in the suite, so both refusal
branches were uncovered. The daemon answers that when a transition is no
longer valid — Retry clicked while a concurrent poll finalizes the run.
Dropping the guards would fire onWorkflowRunStarted for a run that never
started (App bumps its refresh trigger on it) and the user would never see
why the click did nothing. Retry and delete-history cases added; with the
guards removed both go red.

R3-14 Nothing pinned that `busy` is released after an action settles in
the same session, so inverting the session-guarded finally left every
control permanently disabled with the suite green. Asserted through Stop
rather than Pause: once the run reports 'pausing', Pause is disabled by
status, so a `pause.disabled === false` assertion would fail for a reason
that has nothing to do with `busy`.

R3-8 The stale-action test never pinned the [snapshot.sessionId] reset,
which is the only thing freeing the new session's panel — the stale
action's own finally skips the release because its session guard no longer
matches. It now expands the session-B row, asserts Pause is enabled and
that a second control call is accepted.

R3-9 The stale-polling test stopped at "the stale response is dropped".
The polling effect keeps sessionId out of its deps on purpose and re-reads
the ref per tick, so a refactor that fixes staleness by not re-arming
leaves the mounted panel never refreshing again. One more interval after
the switch now asserts a second fetch and an onTasksChange carrying the
new session.

* fix(workflows): address review blockers

* fix(web-shell): remove duplicate ChatEditor test bindings

* fix(cli): name running saved workflows

* fix(cli): preserve saved workflow labels in history

* fix(workflows): address review blockers

---------

Co-authored-by: qqqys <[email protected]>
Co-authored-by: Qwen Code Autofix <[email protected]>
Co-authored-by: qwen-code-dev-bot <[email protected]>
Co-authored-by: qqqys <[email protected]>
Co-authored-by: Shaojin Wen <[email protected]>
2026-09-03 11:58:38 +00:00

78 lines
2.9 KiB
TypeScript

import { describe, expect, it } from 'vitest';
import type { ACPToolCall, Message } from '../adapters/types';
import { getTaskActivityKey, hasActiveTaskActivity } from './taskActivity';
function monitorCall(
callId: string,
status: ACPToolCall['status'],
): ACPToolCall {
return { callId, toolName: 'monitor', status, kind: 'execute' };
}
function group(...tools: ACPToolCall[]): Message {
return { id: `m-${tools[0]?.callId ?? 'empty'}`, role: 'tool_group', tools };
}
describe('hasActiveTaskActivity', () => {
it('reports activity from the tool call status, not from the rendered key', () => {
// `callId` is unconstrained text and the key is `callId:status` joined
// with '|', so the rendering is not injective: this completed call
// renders a key that reads as in_progress to any regex over it. Both
// polling stop conditions hang off this verdict, so a false positive
// keeps the session polling every 3s for as long as the view is
// mounted.
const messages = [group(monitorCall('x:in_progress|y', 'completed'))];
expect(getTaskActivityKey(messages)).toBe('x:in_progress|y:completed');
expect(
/:(?:pending|in_progress)(?:\||$)/.test(getTaskActivityKey(messages)),
).toBe(true);
expect(hasActiveTaskActivity(messages)).toBe(false);
});
it('reports a genuinely running call as active', () => {
expect(
hasActiveTaskActivity([group(monitorCall('a', 'in_progress'))]),
).toBe(true);
expect(hasActiveTaskActivity([group(monitorCall('a', 'pending'))])).toBe(
true,
);
expect(hasActiveTaskActivity([group(monitorCall('a', 'completed'))])).toBe(
false,
);
expect(hasActiveTaskActivity([])).toBe(false);
});
it('ignores workflow calls when the workflows endpoint is off', () => {
// With the flag off the hook polls getTasks(), which structurally
// excludes workflow tasks — so a live workflow call can never show up
// in a snapshot and holding activity open on it would make both
// polling stop conditions unsatisfiable for the rest of the run.
const workflow: ACPToolCall = {
callId: 'wf-1',
toolName: 'workflow',
status: 'in_progress',
kind: 'execute',
};
expect(hasActiveTaskActivity([group(workflow)])).toBe(false);
expect(
hasActiveTaskActivity([group(workflow)], { workflowsEnabled: true }),
).toBe(true);
// Other background kinds are observable through either endpoint.
expect(
hasActiveTaskActivity([group(monitorCall('m', 'in_progress'))]),
).toBe(true);
});
it('walks nested sub-tools, like the key does', () => {
const parent: ACPToolCall = {
...monitorCall('parent', 'completed'),
subTools: [monitorCall('child', 'in_progress')],
};
expect(hasActiveTaskActivity([group(parent)])).toBe(true);
expect(getTaskActivityKey([group(parent)])).toBe(
'parent:completed|child:in_progress',
);
});
});