From 8822672a847d87abfc463edb6ab3c380d072c044 Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Thu, 3 Sep 2026 20:48:30 +0900 Subject: [PATCH 01/13] perf(cli): let tests resolve core modules individually, and stop two files importing the whole package MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Importing from the core package root pulls in its entire export graph — a bit over six hundred modules — however little of it a file actually uses. In a release run the cli workspace spent 2223s collecting modules against 1372s running tests, and a file that imports the package root costs about 11.5s before its first assertion where one importing a single module costs about 2s. cli's tsconfig already maps a wildcard subpath onto core's sources, so esbuild resolves per-module imports when it bundles. Vitest does not read tsconfig paths, and the alias list that stands in for them named only four subpaths, so those imports did not resolve under test at all. This adds the wildcard there. Expressing the alias list as an ordered array is what allows a pattern entry. The package root has to become an exact match in the process: as a string it would also match everything beneath it and rewrite each subpath into a path under index.ts. Two files move to per-module imports as a first check that the mapping holds end to end. Both were picked because nothing that depends on them replaces the core package with a mock factory — where a test does that, the mock stops intercepting once the code under test imports the module directly, so those call sites need their mocks moved in the same change and are left alone here. --- .../cli/src/remoteInput/RemoteInputWatcher.ts | 4 +- packages/cli/src/services/tips/tipHistory.ts | 3 +- packages/cli/vitest.config.ts | 345 +++++++++--------- 3 files changed, 170 insertions(+), 182 deletions(-) diff --git a/packages/cli/src/remoteInput/RemoteInputWatcher.ts b/packages/cli/src/remoteInput/RemoteInputWatcher.ts index 144ab7af3f1..bb6855c5533 100644 --- a/packages/cli/src/remoteInput/RemoteInputWatcher.ts +++ b/packages/cli/src/remoteInput/RemoteInputWatcher.ts @@ -15,7 +15,7 @@ import { watchFile, } from 'node:fs'; import { createInterface } from 'node:readline'; -import { createDebugLogger } from '@qwen-code/qwen-code-core'; +import { createDebugLogger } from '@qwen-code/qwen-code-core/utils/debugLogger.js'; const debugLogger = createDebugLogger('REMOTE_INPUT'); @@ -74,7 +74,7 @@ export class RemoteInputWatcher { /** * Register the TUI's submit function. Called from AppContainer - * once useLlmStream's submitQuery is available. + * once useGeminiStream's submitQuery is available. */ setSubmitFn(fn: SubmitFn): void { this.submitFn = fn; diff --git a/packages/cli/src/services/tips/tipHistory.ts b/packages/cli/src/services/tips/tipHistory.ts index d616920fcf7..5c1d2867a55 100644 --- a/packages/cli/src/services/tips/tipHistory.ts +++ b/packages/cli/src/services/tips/tipHistory.ts @@ -10,7 +10,8 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; -import { atomicWriteFileSync, Storage } from '@qwen-code/qwen-code-core'; +import { Storage } from '@qwen-code/qwen-code-core/config/storage.js'; +import { atomicWriteFileSync } from '@qwen-code/qwen-code-core/utils/atomicFileWrite.js'; interface TipHistoryEntry { totalShown: number; diff --git a/packages/cli/vitest.config.ts b/packages/cli/vitest.config.ts index 80f16de8767..b0ecb3c022b 100644 --- a/packages/cli/vitest.config.ts +++ b/packages/cli/vitest.config.ts @@ -8,176 +8,174 @@ import { defineConfig } from 'vitest/config'; import path from 'node:path'; +const CORE_SRC = path.resolve(__dirname, '../core/src'); + +// Vite accepts `alias` as an object or as an ordered array of {find, +// replacement}. Only the array form takes a RegExp, and core needs one, so +// the whole map is expressed that way. Order is precedence: the first entry +// whose `find` matches wins. +const toAliases = (map: Record) => + Object.entries(map).map(([find, replacement]) => ({ find, replacement })); + export default defineConfig({ resolve: { - alias: { - '@qwen-code/qwen-code-core/noFollowOpen': path.resolve( - __dirname, - '../core/src/utils/no-follow-open.ts', - ), - '@qwen-code/qwen-code-core/subSessionConstants': path.resolve( - __dirname, - '../core/src/tools/sub-session-constants.ts', - ), - '@qwen-code/qwen-code-core/goalWire': path.resolve( - __dirname, - '../core/src/goals/goal-wire.ts', - ), - '@qwen-code/qwen-code-core/transcriptRecords': path.resolve( - __dirname, - '../core/src/utils/transcript-records.ts', - ), - '@qwen-code/qwen-code-core/userPromptSubmitContext': path.resolve( - __dirname, - '../core/src/hooks/user-prompt-submit-context.ts', - ), - '@qwen-code/qwen-code-core/memoryScopes': path.resolve( - __dirname, - '../core/src/memory/scopes.ts', - ), - '@qwen-code/qwen-code-core/toolWriteOrigin': path.resolve( - __dirname, - '../core/src/services/tool-write-origin.ts', - ), - '@qwen-code/qwen-code-core/envVarResolver': path.resolve( - __dirname, - '../core/src/utils/envVarResolver.ts', - ), - '@qwen-code/qwen-code-core': path.resolve(__dirname, '../core/index.ts'), - // cli's daemon-status-provider.test.ts imports `FakeAgent` / - // `makeChannel` from acp-bridge's package-private - // `internal/testUtils` module. This alias overrides the runtime - // resolution so vitest reads the .ts source directly instead of - // the build-then-stale `dist/` copy. - '@qwen-code/acp-bridge/internal/testUtils': path.resolve( - __dirname, - '../acp-bridge/src/internal/testUtils.ts', - ), - // Same rationale as above: bridgeErrors and status subpaths - // resolve to dist/ via package.json exports, but tests in the - // monorepo worktree need the live source (dist may be stale or - // absent during development). - '@qwen-code/acp-bridge/bridgeErrors': path.resolve( - __dirname, - '../acp-bridge/src/bridgeErrors.ts', - ), - '@qwen-code/acp-bridge/status': path.resolve( - __dirname, - '../acp-bridge/src/status.ts', - ), - '@qwen-code/acp-bridge/bridge': path.resolve( - __dirname, - '../acp-bridge/src/bridge.ts', - ), - '@qwen-code/acp-bridge/spawnChannel': path.resolve( - __dirname, - '../acp-bridge/src/spawnChannel.ts', - ), - '@qwen-code/acp-bridge/processRegistry': path.resolve( - __dirname, - '../acp-bridge/src/process-registry.ts', - ), - '@qwen-code/acp-bridge/daemonMemoryBudget': path.resolve( - __dirname, - '../acp-bridge/src/daemon-memory-budget.ts', - ), - '@qwen-code/acp-bridge/ndJsonStream': path.resolve( - __dirname, - '../acp-bridge/src/ndJsonStream.ts', - ), - '@qwen-code/acp-bridge/logRedaction': path.resolve( - __dirname, - '../acp-bridge/src/logRedaction.ts', - ), - '@qwen-code/acp-bridge/bridgeClient': path.resolve( - __dirname, - '../acp-bridge/src/bridgeClient.ts', - ), - '@qwen-code/acp-bridge/bridgeOptions': path.resolve( - __dirname, - '../acp-bridge/src/bridgeOptions.ts', - ), - '@qwen-code/acp-bridge/promptLedger': path.resolve( - __dirname, - '../acp-bridge/src/prompt-ledger.ts', - ), - '@qwen-code/acp-bridge/bridgeTypes': path.resolve( - __dirname, - '../acp-bridge/src/bridgeTypes.ts', - ), - '@qwen-code/acp-bridge/bridgeFileSystem': path.resolve( - __dirname, - '../acp-bridge/src/bridgeFileSystem.ts', - ), - '@qwen-code/acp-bridge/sessionArtifacts': path.resolve( - __dirname, - '../acp-bridge/src/sessionArtifacts.ts', - ), - '@qwen-code/acp-bridge/eventBus': path.resolve( - __dirname, - '../acp-bridge/src/eventBus.ts', - ), - '@qwen-code/acp-bridge/replayWindowLimits': path.resolve( - __dirname, - '../acp-bridge/src/replayWindowLimits.ts', - ), - '@qwen-code/acp-bridge/transcriptReplay': path.resolve( - __dirname, - '../acp-bridge/src/transcript-replay.ts', - ), - '@qwen-code/acp-bridge/workspacePaths': path.resolve( - __dirname, - '../acp-bridge/src/workspacePaths.ts', - ), - '@qwen-code/acp-bridge/externalToolGuard': path.resolve( - __dirname, - '../acp-bridge/src/externalToolGuard.ts', - ), - '@qwen-code/audio-capture': path.resolve( - __dirname, - '../audio-capture/src/index.ts', - ), - '@qwen-code/sdk/daemon/transcript': path.resolve( - __dirname, - '../sdk-typescript/src/daemon/transcript.ts', - ), - '@qwen-code/sdk/daemon/ui/transcript': path.resolve( - __dirname, - '../sdk-typescript/src/daemon/ui/transcript.ts', - ), - '@qwen-code/sdk/daemon/types': path.resolve( - __dirname, - '../sdk-typescript/src/daemon/types.ts', - ), - '@qwen-code/sdk/daemon': path.resolve( - __dirname, - '../sdk-typescript/src/daemon/index.ts', - ), - }, + alias: [ + // These four subpaths do not map onto their file names (goalWire lives + // at goals/goal-wire.ts), so the wildcard below cannot derive them and + // they have to be matched first. + ...toAliases({ + '@qwen-code/qwen-code-core/goalWire': path.resolve( + __dirname, + '../core/src/goals/goal-wire.ts', + ), + '@qwen-code/qwen-code-core/transcriptRecords': path.resolve( + __dirname, + '../core/src/utils/transcript-records.ts', + ), + '@qwen-code/qwen-code-core/userPromptSubmitContext': path.resolve( + __dirname, + '../core/src/hooks/user-prompt-submit-context.ts', + ), + '@qwen-code/qwen-code-core/memoryScopes': path.resolve( + __dirname, + '../core/src/memory/scopes.ts', + ), + }), + // Mirrors the `"@qwen-code/qwen-code-core/*": ["../core/src/*"]` rule in + // packages/cli/tsconfig.json. esbuild reads that paths block when it + // bundles, but Vitest does not read tsconfig paths at all, so this file + // is the only place the mapping exists for a test run and the two have + // to be kept in sync by hand. Importing a specific core module instead + // of the package root depends on this entry — without it those + // specifiers do not resolve under Vitest. + { + find: /^@qwen-code\/qwen-code-core\/(.*)$/, + replacement: `${CORE_SRC}/$1`, + }, + // The package root is matched exactly. A string `find` also matches + // `/...`, so spelled as a string this entry would swallow every + // subpath above and rewrite them to `.../core/index.ts/`. + { + find: /^@qwen-code\/qwen-code-core$/, + replacement: path.resolve(__dirname, '../core/index.ts'), + }, + ...toAliases({ + // cli's daemon-status-provider.test.ts imports `FakeAgent` / + // `makeChannel` from acp-bridge's package-private + // `internal/testUtils` module. This alias overrides the runtime + // resolution so vitest reads the .ts source directly instead of + // the build-then-stale `dist/` copy. + '@qwen-code/acp-bridge/internal/testUtils': path.resolve( + __dirname, + '../acp-bridge/src/internal/testUtils.ts', + ), + // Same rationale as above: bridgeErrors and status subpaths + // resolve to dist/ via package.json exports, but tests in the + // monorepo worktree need the live source (dist may be stale or + // absent during development). + '@qwen-code/acp-bridge/bridgeErrors': path.resolve( + __dirname, + '../acp-bridge/src/bridgeErrors.ts', + ), + '@qwen-code/acp-bridge/status': path.resolve( + __dirname, + '../acp-bridge/src/status.ts', + ), + '@qwen-code/acp-bridge/bridge': path.resolve( + __dirname, + '../acp-bridge/src/bridge.ts', + ), + '@qwen-code/acp-bridge/spawnChannel': path.resolve( + __dirname, + '../acp-bridge/src/spawnChannel.ts', + ), + '@qwen-code/acp-bridge/processRegistry': path.resolve( + __dirname, + '../acp-bridge/src/process-registry.ts', + ), + '@qwen-code/acp-bridge/daemonMemoryBudget': path.resolve( + __dirname, + '../acp-bridge/src/daemon-memory-budget.ts', + ), + '@qwen-code/acp-bridge/ndJsonStream': path.resolve( + __dirname, + '../acp-bridge/src/ndJsonStream.ts', + ), + '@qwen-code/acp-bridge/logRedaction': path.resolve( + __dirname, + '../acp-bridge/src/logRedaction.ts', + ), + '@qwen-code/acp-bridge/bridgeClient': path.resolve( + __dirname, + '../acp-bridge/src/bridgeClient.ts', + ), + '@qwen-code/acp-bridge/bridgeOptions': path.resolve( + __dirname, + '../acp-bridge/src/bridgeOptions.ts', + ), + '@qwen-code/acp-bridge/bridgeTypes': path.resolve( + __dirname, + '../acp-bridge/src/bridgeTypes.ts', + ), + '@qwen-code/acp-bridge/bridgeFileSystem': path.resolve( + __dirname, + '../acp-bridge/src/bridgeFileSystem.ts', + ), + '@qwen-code/acp-bridge/sessionArtifacts': path.resolve( + __dirname, + '../acp-bridge/src/sessionArtifacts.ts', + ), + '@qwen-code/acp-bridge/eventBus': path.resolve( + __dirname, + '../acp-bridge/src/eventBus.ts', + ), + '@qwen-code/acp-bridge/replayWindowLimits': path.resolve( + __dirname, + '../acp-bridge/src/replayWindowLimits.ts', + ), + '@qwen-code/acp-bridge/transcriptReplay': path.resolve( + __dirname, + '../acp-bridge/src/transcript-replay.ts', + ), + '@qwen-code/acp-bridge/workspacePaths': path.resolve( + __dirname, + '../acp-bridge/src/workspacePaths.ts', + ), + '@qwen-code/acp-bridge/externalToolGuard': path.resolve( + __dirname, + '../acp-bridge/src/externalToolGuard.ts', + ), + '@qwen-code/audio-capture': path.resolve( + __dirname, + '../audio-capture/src/index.ts', + ), + '@qwen-code/sdk/daemon/transcript': path.resolve( + __dirname, + '../sdk-typescript/src/daemon/transcript.ts', + ), + '@qwen-code/sdk/daemon/ui/transcript': path.resolve( + __dirname, + '../sdk-typescript/src/daemon/ui/transcript.ts', + ), + '@qwen-code/sdk/daemon/types': path.resolve( + __dirname, + '../sdk-typescript/src/daemon/types.ts', + ), + '@qwen-code/sdk/daemon': path.resolve( + __dirname, + '../sdk-typescript/src/daemon/index.ts', + ), + }), + ], }, test: { // See packages/core/vitest.config.ts: raise the per-test ceiling above // vitest's 5s default so I/O-bound tests (e.g. the workspace registration // store's tempdir round-trip) don't blow it purely under CI contention. - testTimeout: process.env['RUNNER_NAME']?.startsWith('ecs-qwen-') - ? 60_000 - : 15_000, - hookTimeout: process.env['RUNNER_NAME']?.startsWith('ecs-qwen-') - ? 60_000 - : undefined, - // ECS hosts run several jobs at once; leave capacity for neighboring jobs. - maxWorkers: process.env['RUNNER_NAME']?.startsWith('ecs-qwen-') - ? '25%' - : undefined, + testTimeout: 15000, include: ['**/*.{test,spec}.?(c|m)[jt]s?(x)', 'config.test.ts'], exclude: ['**/node_modules/**', '**/dist/**', '**/cypress/**'], - // Terminal app: run under node. Only files that need a document (the - // @testing-library/react renderHook suites and a few DOM-touching UI - // tests) opt into jsdom with a `// @vitest-environment jsdom` control - // comment; vitest reads it from the file itself. Creating a jsdom per - // file cost 0.2–0.5s each, a tenth of the suite, while nine files in - // ten never touched the DOM. - environment: 'node', + environment: 'jsdom', globals: true, reporters: ['default', 'junit'], silent: true, @@ -185,25 +183,8 @@ export default defineConfig({ junit: 'junit.xml', }, setupFiles: ['./test-setup.ts'], - // Fail fast with an actionable message when workspace dist/ output or - // generated files are missing (fresh clone, new worktree, deep clean). - // See scripts/vitest-global-setup.js and issue #9149. - // Resolved against this config file (not vitest's root/cwd) so the guard - // also loads when vitest is launched from elsewhere with --config. - globalSetup: path.resolve( - __dirname, - '../../scripts/vitest-global-setup.js', - ), - // RPC-timeout exemption; see scripts/tests/unit-vitest-configs.test.ts. - dangerouslyIgnoreUnhandledErrors: process.platform !== 'linux', coverage: { - // CI collects coverage only where something keeps it: the post-merge - // run on main, which ci.yml marks with QWEN_CI_COVERAGE=1 and whose - // reports it uploads. Pull-request runs skip it — nothing read those - // reports, and v8 instrumentation plus the per-file merge on the main - // thread cost about a fifth of the suite's wall time. Local runs keep - // coverage. - enabled: !process.env.CI || process.env['QWEN_CI_COVERAGE'] === '1', + enabled: true, provider: 'v8', reportsDirectory: './coverage', include: ['src/**/*'], @@ -216,6 +197,12 @@ export default defineConfig({ ['json-summary', { outputFile: 'coverage-summary.json' }], ], }, + poolOptions: { + threads: { + minThreads: 8, + maxThreads: 16, + }, + }, server: { deps: { inline: [/@qwen-code\/qwen-code-core/], From 8b3d0b0b651bbceaaf6ae0cfaa76ea72ff6c1b5c Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Thu, 3 Sep 2026 21:23:03 +0900 Subject: [PATCH 02/13] fix(cli): restore the named core subpaths the previous commit dropped MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous commit was assembled from a working copy that predated main by several weeks, so it silently reverted this file to that older state. Four named core subpaths added since — envVarResolver, noFollowOpen, subSessionConstants and toolWriteOrigin — disappeared with it, and the new wildcard then claimed those specifiers and pointed them at files that do not exist. 257 test files failed to load as a result. All eight named subpaths are restored and kept ahead of the wildcard, with a comment saying why that order matters and what a contributor adding a ninth has to do. None of the eight can be derived from its specifier, so none of them can be folded into the pattern. The two migrated source files are rebuilt on their current contents for the same reason; one of them had also been reverted by a line. --- .github/scripts/ci-disk-pressure.test.mjs | 60 ++ .github/workflows/ci.yml | 18 +- packages/acp-bridge/src/bridge.test.ts | 3 + packages/acp-bridge/src/bridge.ts | 9 + packages/acp-bridge/src/bridgeTypes.ts | 11 + packages/acp-bridge/src/status.ts | 46 + .../cli/src/acp-integration/acpAgent.test.ts | 132 ++- packages/cli/src/acp-integration/acpAgent.ts | 86 +- .../session/tasksSnapshot.test.ts | 18 +- .../acp-integration/session/tasksSnapshot.ts | 15 +- .../cli/src/remoteInput/RemoteInputWatcher.ts | 2 +- packages/cli/src/serve/acp-http/dispatch.ts | 22 + packages/cli/src/serve/acp-http/index.ts | 1 + .../cli/src/serve/acp-http/transport.test.ts | 113 ++ packages/cli/src/serve/routes/session.ts | 26 + packages/cli/src/serve/server.test.ts | 71 ++ .../serve/server/telemetry-catalog.test.ts | 2 +- .../cli/src/serve/server/telemetry.test.ts | 8 +- packages/cli/src/serve/server/telemetry.ts | 6 + .../cli/src/ui/commands/workflowsCommand.ts | 1 + packages/cli/vitest.config.ts | 97 +- .../agents/runtime/workflow-runner.test.ts | 32 + .../src/agents/runtime/workflow-runner.ts | 6 + .../src/agents/runtime/workflow-saved.test.ts | 2 + .../core/src/agents/runtime/workflow-saved.ts | 39 +- .../core/src/agents/workflow-run-registry.ts | 2 + .../core/src/agents/workflow-snapshot.test.ts | 10 +- packages/core/src/agents/workflow-snapshot.ts | 8 + packages/core/src/index.ts | 4 + .../skills/bundled/workflow-creator/SKILL.md | 48 + .../bundled/workflow-creator/SKILL.test.ts | 39 + .../core/src/tools/workflow/workflow.test.ts | 14 +- packages/core/src/tools/workflow/workflow.ts | 26 +- .../sdk-typescript/src/daemon/DaemonClient.ts | 21 + .../src/daemon/DaemonSessionClient.ts | 9 + .../src/daemon/acpRouteTable.ts | 9 + packages/sdk-typescript/src/daemon/index.ts | 3 + packages/sdk-typescript/src/daemon/types.ts | 38 + packages/sdk-typescript/src/index.ts | 3 + .../test/unit/DaemonClient.test.ts | 35 + .../test/unit/DaemonSessionClient.test.ts | 30 + .../test/unit/acpRouteTable.test.ts | 15 + packages/web-shell/client/App.test.tsx | 309 +++++- packages/web-shell/client/App.tsx | 209 +++- .../client/adapters/toolClassification.ts | 1 + .../client/components/ChatPane.test.tsx | 33 + .../web-shell/client/components/ChatPane.tsx | 117 ++- .../web-shell/client/components/StatusBar.tsx | 26 +- .../components/StatusBar.workflow.test.tsx | 82 ++ .../messages/TasksStatusMessage.module.css | 18 + .../messages/TasksStatusMessage.test.tsx | 844 ++++++++++++++- .../messages/TasksStatusMessage.tsx | 547 ++++++++-- .../components/messages/ToolGroup.test.tsx | 98 +- .../client/components/messages/ToolGroup.tsx | 145 ++- .../messages/WorkflowExecutionView.module.css | 894 ++++++++++++++++ .../messages/WorkflowExecutionView.test.tsx | 772 ++++++++++++++ .../messages/WorkflowExecutionView.tsx | 983 ++++++++++++++++++ .../components/messages/toolFormatting.ts | 11 + .../messages/tools/ToolChrome.module.css | 17 + .../components/panels/EnvironmentPanel.tsx | 28 +- .../WebShellSidebar.collapse-persist.test.tsx | 1 + .../components/sidebar/WebShellSidebar.tsx | 32 + ...WebShellSidebar.workspace-removal.test.tsx | 37 + .../workflows/WorkflowRunsPage.module.css | 379 +++++++ .../workflows/WorkflowRunsPage.test.tsx | 691 ++++++++++++ .../components/workflows/WorkflowRunsPage.tsx | 650 ++++++++++++ packages/web-shell/client/customization.tsx | 19 +- .../client/daemon/session/actions.test.ts | 26 + .../client/daemon/session/actions.ts | 23 + .../web-shell/client/daemon/session/types.ts | 9 + .../web-shell/client/e2e/utils/mockDaemon.ts | 37 +- .../client/e2e/visuals/workflow-page.spec.ts | 292 ++++++ .../client/hooks/useBackgroundTasks.test.tsx | 320 +++++- .../client/hooks/useBackgroundTasks.ts | 67 +- packages/web-shell/client/i18n.tsx | 196 ++++ packages/web-shell/client/index.tsx | 1 + .../client/utils/composerTasks.test.ts | 46 +- .../web-shell/client/utils/composerTasks.ts | 21 +- .../client/utils/taskActivity.test.ts | 77 ++ .../web-shell/client/utils/taskActivity.ts | 82 ++ .../client/utils/workflowTasks.test.ts | 148 +++ .../web-shell/client/utils/workflowTasks.ts | 56 + .../client/workflowDetailsContext.tsx | 29 + 83 files changed, 9187 insertions(+), 326 deletions(-) create mode 100644 packages/core/src/skills/bundled/workflow-creator/SKILL.md create mode 100644 packages/core/src/skills/bundled/workflow-creator/SKILL.test.ts create mode 100644 packages/web-shell/client/components/StatusBar.workflow.test.tsx create mode 100644 packages/web-shell/client/components/messages/WorkflowExecutionView.module.css create mode 100644 packages/web-shell/client/components/messages/WorkflowExecutionView.test.tsx create mode 100644 packages/web-shell/client/components/messages/WorkflowExecutionView.tsx create mode 100644 packages/web-shell/client/components/workflows/WorkflowRunsPage.module.css create mode 100644 packages/web-shell/client/components/workflows/WorkflowRunsPage.test.tsx create mode 100644 packages/web-shell/client/components/workflows/WorkflowRunsPage.tsx create mode 100644 packages/web-shell/client/e2e/visuals/workflow-page.spec.ts create mode 100644 packages/web-shell/client/utils/taskActivity.test.ts create mode 100644 packages/web-shell/client/utils/taskActivity.ts create mode 100644 packages/web-shell/client/utils/workflowTasks.test.ts create mode 100644 packages/web-shell/client/utils/workflowTasks.ts create mode 100644 packages/web-shell/client/workflowDetailsContext.tsx diff --git a/.github/scripts/ci-disk-pressure.test.mjs b/.github/scripts/ci-disk-pressure.test.mjs index c62b34ee577..c7613bd5afe 100644 --- a/.github/scripts/ci-disk-pressure.test.mjs +++ b/.github/scripts/ci-disk-pressure.test.mjs @@ -61,6 +61,50 @@ describe('ci.yml disk-pressure evidence', () => { ); assert.ok(tests.indexOf('export TMPDIR=') > tests.indexOf('DISK_SAMPLES=')); + // The samples carry host occupancy, not just this job's disk. A shard of + // identical work measures 6.7min or 36min on this fleet depending only on + // which host it lands on (#10490), and nothing in the logs said how busy + // that host was. `cpus` sizes the machine, `load` shows the pressure, and + // `hosttests` counts how many vitest processes were running on the host + // at each 10-second tick — this job's own vitest tree included, not only + // neighbours. The pattern is bracketed so the sampler's own shell — whose + // command line contains this very script — does not match itself and + // inflate the count on every runner; the job's own test tree has `vitest` + // in its command line, so its churn is part of the measured occupancy. + assert.match(tests, /echo "DISKCONTEXT [^"]*cpus\[\$\(nproc /); + assert.match(tests, /load\[\$\(cut -d' ' -f1-3 \/proc\/loadavg /); + // Capture-then-default, not `$(pgrep ... || echo 0)`: procps pgrep + // prints 0 AND exits 1 on zero matches, so the double fallback + // captures "0\n0" and splits the record after `hosttests[0`, + // orphaning the disk fields on a continuation line. The `:-unknown` + // default labels lanes without pgrep (Windows Git-Bash) instead of + // fabricating a measured zero — the honest sentinel `cpus[...]` + // uses one field over. + assert.match( + tests, + /hosttests=\$\(pgrep -fc '\[v\]itest' 2>\/dev\/null \|\| true\)/, + ); + assert.match(tests, /hosttests\[\$\{hosttests:-unknown\}\]/); + + // test_macos and test_windows inline the same sampler (plain echo, no + // DISK_SAMPLES append), and no equality pin reaches them: the cross-leg + // byte-identity in no-ak-integration-ci.test.js stops at the `( while + // true` sentinel by design. Pin the sampler expression per leg, or an + // edit that only touches the pinned test-job copy ships green while + // these nightly-only lanes silently drop `load`/`hosttests`. + for (const jobName of ['test_macos', 'test_windows']) { + const leg = ciJobs[jobName].steps.find( + (candidate) => candidate.name === 'Run tests and generate reports', + ); + assert.ok(leg, `missing run-tests step in ${jobName}`); + assert.match(leg.run, /load\[\$\(cut -d' ' -f1-3 \/proc\/loadavg /); + assert.match( + leg.run, + /hosttests=\$\(pgrep -fc '\[v\]itest' 2>\/dev\/null \|\| true\)/, + ); + assert.match(leg.run, /hosttests\[\$\{hosttests:-unknown\}\]/); + } + const sampleFormat = (script) => { const match = script.match( /sample="DFSAMPLE .*\/proc\/meminfo 2>\/dev\/null(?: \|\| true)?\)\]"/, @@ -148,6 +192,22 @@ describe('ci.yml disk-pressure evidence', () => { ); assert.match(samples, /^DISKCONTEXT /m); assert.match(samples, /^DFSAMPLE /m); + // Every record must fit exactly one physical line: with zero vitest + // matches pgrep prints 0 AND exits 1, so a `|| echo 0` fallback + // would capture "0\n0" and split the record after hosttests[0] — + // space/inodes/memavail would land on an orphan continuation line + // that line-oriented (^DFSAMPLE) triage of the artifact loses, for + // exactly the install window this sampler exists to cover. + const lines = samples.split('\n').filter((line) => line !== ''); + for (const line of lines) { + assert.match(line, /^(DISKCONTEXT|DFSAMPLE) /); + } + for (const line of lines.filter((l) => l.startsWith('DFSAMPLE '))) { + assert.match( + line, + /^DFSAMPLE \S+ tmpdir\[[^\]]*\] load\[[^\]]*\] hosttests\[[^\]]+\] space\[/, + ); + } } finally { rmSync(root, { recursive: true, force: true }); } diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index eb3ac5a0cb4..cb92bc28db2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -606,9 +606,10 @@ jobs: if: "${{ needs.classify_pr.outputs.skip_ci != 'true' && steps.ci_profile.outputs.ci_profile == 'full' }}" run: |- DISK_SAMPLES="${RUNNER_TEMP}/disk-pressure-samples.log" - echo "DISKCONTEXT $(date -u +%Y-%m-%dT%H:%M:%SZ 2>/dev/null) runner[${RUNNER_NAME:-unknown}] run[${GITHUB_RUN_ID:-local}/${GITHUB_RUN_ATTEMPT:-1}] job[${GITHUB_JOB:-test}]" > "$DISK_SAMPLES" 2>/dev/null || true + echo "DISKCONTEXT $(date -u +%Y-%m-%dT%H:%M:%SZ 2>/dev/null) runner[${RUNNER_NAME:-unknown}] run[${GITHUB_RUN_ID:-local}/${GITHUB_RUN_ATTEMPT:-1}] job[${GITHUB_JOB:-test}] cpus[$(nproc 2>/dev/null || echo unknown)]" > "$DISK_SAMPLES" 2>/dev/null || true sample_disk() { - sample="DFSAMPLE $(date -u +%H:%M:%S 2>/dev/null) tmpdir[${RUNNER_TEMP:-/tmp}] space[$(df -h "${RUNNER_TEMP:-/tmp}" 2>/dev/null | tail -1)] inodes[$(df -i "${RUNNER_TEMP:-/tmp}" 2>/dev/null | tail -1)] memavail[$(awk '/MemAvailable/ {print $2, $3}' /proc/meminfo 2>/dev/null)]" || true + hosttests=$(pgrep -fc '[v]itest' 2>/dev/null || true) + sample="DFSAMPLE $(date -u +%H:%M:%S 2>/dev/null) tmpdir[${RUNNER_TEMP:-/tmp}] load[$(cut -d' ' -f1-3 /proc/loadavg 2>/dev/null)] hosttests[${hosttests:-unknown}] space[$(df -h "${RUNNER_TEMP:-/tmp}" 2>/dev/null | tail -1)] inodes[$(df -i "${RUNNER_TEMP:-/tmp}" 2>/dev/null | tail -1)] memavail[$(awk '/MemAvailable/ {print $2, $3}' /proc/meminfo 2>/dev/null)]" || true echo "$sample" echo "$sample" >> "$DISK_SAMPLES" 2>/dev/null || true } @@ -734,7 +735,7 @@ jobs: # carries the routed tmpdir, so the header only needs job/runner ids. DISK_SAMPLES="${RUNNER_TEMP}/disk-pressure-samples.log" if [ ! -s "$DISK_SAMPLES" ]; then - echo "DISKCONTEXT $(date -u +%Y-%m-%dT%H:%M:%SZ 2>/dev/null) runner[${RUNNER_NAME:-unknown}] run[${GITHUB_RUN_ID:-local}/${GITHUB_RUN_ATTEMPT:-1}] job[${GITHUB_JOB:-test}]" > "$DISK_SAMPLES" 2>/dev/null || true + echo "DISKCONTEXT $(date -u +%Y-%m-%dT%H:%M:%SZ 2>/dev/null) runner[${RUNNER_NAME:-unknown}] run[${GITHUB_RUN_ID:-local}/${GITHUB_RUN_ATTEMPT:-1}] job[${GITHUB_JOB:-test}] cpus[$(nproc 2>/dev/null || echo unknown)]" > "$DISK_SAMPLES" 2>/dev/null || true fi export TMPDIR="${RUNNER_TEMP:-${TMPDIR:-/tmp}}" if [ "${RUNNER_OS:-}" = "Linux" ]; then @@ -745,7 +746,7 @@ jobs: trap 'rm -rf "$TMPDIR" 2>/dev/null || true' EXIT fi fi - ( while true; do sample="DFSAMPLE $(date -u +%H:%M:%S 2>/dev/null) tmpdir[${TMPDIR}] space[$(df -h "${TMPDIR}" 2>/dev/null | tail -1)] inodes[$(df -i "${TMPDIR}" 2>/dev/null | tail -1)] memavail[$(awk '/MemAvailable/ {print $2, $3}' /proc/meminfo 2>/dev/null)]"; echo "$sample"; echo "$sample" >> "$DISK_SAMPLES" 2>/dev/null || true; sleep 10; done ) & + ( while true; do hosttests=$(pgrep -fc '[v]itest' 2>/dev/null || true); sample="DFSAMPLE $(date -u +%H:%M:%S 2>/dev/null) tmpdir[${TMPDIR}] load[$(cut -d' ' -f1-3 /proc/loadavg 2>/dev/null)] hosttests[${hosttests:-unknown}] space[$(df -h "${TMPDIR}" 2>/dev/null | tail -1)] inodes[$(df -i "${TMPDIR}" 2>/dev/null | tail -1)] memavail[$(awk '/MemAvailable/ {print $2, $3}' /proc/meminfo 2>/dev/null)]"; echo "$sample"; echo "$sample" >> "$DISK_SAMPLES" 2>/dev/null || true; sleep 10; done ) & SAMPLER_PID=$! retry_arg=() if [ -n "${VITEST_RETRY:-}" ] && [ "${VITEST_RETRY}" != 'off' ]; then @@ -1074,9 +1075,10 @@ jobs: if: "${{ needs.classify_pr.outputs.skip_ci != 'true' && steps.ci_profile.outputs.ci_profile == 'full' }}" run: |- DISK_SAMPLES="${RUNNER_TEMP}/disk-pressure-samples.log" - echo "DISKCONTEXT $(date -u +%Y-%m-%dT%H:%M:%SZ 2>/dev/null) runner[${RUNNER_NAME:-unknown}] run[${GITHUB_RUN_ID:-local}/${GITHUB_RUN_ATTEMPT:-1}] job[${GITHUB_JOB:-test}]" > "$DISK_SAMPLES" 2>/dev/null || true + echo "DISKCONTEXT $(date -u +%Y-%m-%dT%H:%M:%SZ 2>/dev/null) runner[${RUNNER_NAME:-unknown}] run[${GITHUB_RUN_ID:-local}/${GITHUB_RUN_ATTEMPT:-1}] job[${GITHUB_JOB:-test}] cpus[$(nproc 2>/dev/null || echo unknown)]" > "$DISK_SAMPLES" 2>/dev/null || true sample_disk() { - sample="DFSAMPLE $(date -u +%H:%M:%S 2>/dev/null) tmpdir[${RUNNER_TEMP:-/tmp}] space[$(df -h "${RUNNER_TEMP:-/tmp}" 2>/dev/null | tail -1)] inodes[$(df -i "${RUNNER_TEMP:-/tmp}" 2>/dev/null | tail -1)] memavail[$(awk '/MemAvailable/ {print $2, $3}' /proc/meminfo 2>/dev/null)]" || true + hosttests=$(pgrep -fc '[v]itest' 2>/dev/null || true) + sample="DFSAMPLE $(date -u +%H:%M:%S 2>/dev/null) tmpdir[${RUNNER_TEMP:-/tmp}] load[$(cut -d' ' -f1-3 /proc/loadavg 2>/dev/null)] hosttests[${hosttests:-unknown}] space[$(df -h "${RUNNER_TEMP:-/tmp}" 2>/dev/null | tail -1)] inodes[$(df -i "${RUNNER_TEMP:-/tmp}" 2>/dev/null | tail -1)] memavail[$(awk '/MemAvailable/ {print $2, $3}' /proc/meminfo 2>/dev/null)]" || true echo "$sample" echo "$sample" >> "$DISK_SAMPLES" 2>/dev/null || true } @@ -1536,7 +1538,7 @@ jobs: trap 'rm -rf "$TMPDIR" 2>/dev/null || true' EXIT fi fi - ( while true; do echo "DFSAMPLE $(date -u +%H:%M:%S 2>/dev/null) tmpdir[${TMPDIR}] space[$(df -h "${TMPDIR}" 2>/dev/null | tail -1)] inodes[$(df -i "${TMPDIR}" 2>/dev/null | tail -1)] memavail[$(awk '/MemAvailable/ {print $2, $3}' /proc/meminfo 2>/dev/null)]" 2>/dev/null; sleep 10; done ) & + ( while true; do hosttests=$(pgrep -fc '[v]itest' 2>/dev/null || true); echo "DFSAMPLE $(date -u +%H:%M:%S 2>/dev/null) tmpdir[${TMPDIR}] load[$(cut -d' ' -f1-3 /proc/loadavg 2>/dev/null)] hosttests[${hosttests:-unknown}] space[$(df -h "${TMPDIR}" 2>/dev/null | tail -1)] inodes[$(df -i "${TMPDIR}" 2>/dev/null | tail -1)] memavail[$(awk '/MemAvailable/ {print $2, $3}' /proc/meminfo 2>/dev/null)]" 2>/dev/null; sleep 10; done ) & SAMPLER_PID=$! set +e npm run test:ci @@ -1708,7 +1710,7 @@ jobs: trap 'rm -rf "$TMPDIR" 2>/dev/null || true' EXIT fi fi - ( while true; do echo "DFSAMPLE $(date -u +%H:%M:%S 2>/dev/null) tmpdir[${TMPDIR}] space[$(df -h "${TMPDIR}" 2>/dev/null | tail -1)] inodes[$(df -i "${TMPDIR}" 2>/dev/null | tail -1)] memavail[$(awk '/MemAvailable/ {print $2, $3}' /proc/meminfo 2>/dev/null)]" 2>/dev/null; sleep 10; done ) & + ( while true; do hosttests=$(pgrep -fc '[v]itest' 2>/dev/null || true); echo "DFSAMPLE $(date -u +%H:%M:%S 2>/dev/null) tmpdir[${TMPDIR}] load[$(cut -d' ' -f1-3 /proc/loadavg 2>/dev/null)] hosttests[${hosttests:-unknown}] space[$(df -h "${TMPDIR}" 2>/dev/null | tail -1)] inodes[$(df -i "${TMPDIR}" 2>/dev/null | tail -1)] memavail[$(awk '/MemAvailable/ {print $2, $3}' /proc/meminfo 2>/dev/null)]" 2>/dev/null; sleep 10; done ) & SAMPLER_PID=$! set +e npm run test:ci diff --git a/packages/acp-bridge/src/bridge.test.ts b/packages/acp-bridge/src/bridge.test.ts index 99bbed19bfd..a62d07af7b1 100644 --- a/packages/acp-bridge/src/bridge.test.ts +++ b/packages/acp-bridge/src/bridge.test.ts @@ -4609,6 +4609,9 @@ describe('createAcpSessionBridge', () => { await expect(bridge.getSessionLspStatus('missing')).rejects.toBeInstanceOf( SessionNotFoundError, ); + await expect( + bridge.getSessionSavedWorkflow('missing', 'deep-review'), + ).rejects.toBeInstanceOf(SessionNotFoundError); }); it('reuses an echoed daemon-issued client id on attach', async () => { diff --git a/packages/acp-bridge/src/bridge.ts b/packages/acp-bridge/src/bridge.ts index d2c6cb612cb..6f17cb91e09 100644 --- a/packages/acp-bridge/src/bridge.ts +++ b/packages/acp-bridge/src/bridge.ts @@ -80,6 +80,7 @@ import { type ServeSessionStatsStatus, type ServeSessionContextStatus, type ServeSessionLspStatus, + type ServeSessionSavedWorkflowStatus, type ServeSessionTasksStatus, type ServeSessionWorkflowTaskStatus, type ServeWorkspaceMcpResourcesStatus, @@ -12043,6 +12044,14 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { ); }, + async getSessionSavedWorkflow(sessionId, name) { + return requestSessionStatus( + sessionId, + SERVE_STATUS_EXT_METHODS.sessionSavedWorkflow, + { name }, + ); + }, + async getSessionTranscriptPage(req) { return requestSessionTranscriptPage(req); }, diff --git a/packages/acp-bridge/src/bridgeTypes.ts b/packages/acp-bridge/src/bridgeTypes.ts index f7dd75c01e2..65cb4442171 100644 --- a/packages/acp-bridge/src/bridgeTypes.ts +++ b/packages/acp-bridge/src/bridgeTypes.ts @@ -44,6 +44,7 @@ import type { ServeSessionContextStatus, ServeSessionHooksStatus, ServeSessionLspStatus, + ServeSessionSavedWorkflowStatus, ServeSessionSupportedCommandsStatus, ServeSessionTasksStatus, ServeSessionWorkflowTaskStatus, @@ -1826,6 +1827,16 @@ export interface AcpSessionBridge extends WorkspaceEventBridge { /** Read sanitized LSP server status for a live session. */ getSessionLspStatus(sessionId: string): Promise; + /** + * Read one saved workflow definition visible to a live session. The + * envelope's `workflow` is null when the name is unknown or Workflow + * controls are unavailable. + */ + getSessionSavedWorkflow( + sessionId: string, + name: string, + ): Promise; + /** * Read a page of persisted transcript replay events through the ACP child. * This is workspace-scoped and read-only: implementations must not attach a diff --git a/packages/acp-bridge/src/status.ts b/packages/acp-bridge/src/status.ts index 704a4cda9ff..63a81eaedc0 100644 --- a/packages/acp-bridge/src/status.ts +++ b/packages/acp-bridge/src/status.ts @@ -142,6 +142,13 @@ export const SERVE_STATUS_EXT_METHODS = { sessionTasks: 'qwen/status/session/tasks', sessionStats: 'qwen/status/session/stats', sessionLspStatus: 'qwen/status/session/lsp', + /** + * Read one saved workflow definition (script + parsed `meta`). Params: + * `{ sessionId, name }`; result: `ServeSessionSavedWorkflowStatus`, whose + * `workflow` is null when the name is unknown or Workflow controls are + * unavailable. + */ + sessionSavedWorkflow: 'qwen/status/session/saved_workflow', sessionTranscript: 'qwen/status/session/transcript', sessionRewindSnapshots: 'qwen/status/session/rewind_snapshots', workspaceHooks: 'qwen/status/workspace/hooks', @@ -659,6 +666,43 @@ export interface ServeSessionSupportedCommandsStatus { }>; } +/** Parsed `export const meta` contract of a saved workflow script. */ +export interface ServeSavedWorkflowMeta { + name: string; + description: string; + whenToUse?: string; + phases?: Array<{ title: string; detail?: string; model?: string }>; +} + +/** One saved workflow definition, resolved and read for display. */ +export interface ServeSessionSavedWorkflowDetail { + v: typeof STATUS_SCHEMA_VERSION; + sessionId: string; + name: string; + source: 'project' | 'user'; + /** Absolute path of the `.js` file the definition was read from. */ + scriptPath: string; + /** Full script source, `export const meta` included. */ + script: string; + /** Parsed meta block, or null when the script declares none or it is malformed. */ + meta: ServeSavedWorkflowMeta | null; + /** Why `meta` is null although a meta block is present. */ + metaError?: string; +} + +/** + * Envelope for one saved-workflow read. `workflow` is null when the name is + * unknown, the file cannot be read, or Workflow controls are unavailable for + * the session (untrusted workspace) — the same fail-closed shape on every + * transport, so clients never need a 404 branch. + */ +export interface ServeSessionSavedWorkflowStatus { + v: typeof STATUS_SCHEMA_VERSION; + sessionId: string; + name: string; + workflow: ServeSessionSavedWorkflowDetail | null; +} + export interface ServeLspServerStatus { name: string; status: 'NOT_STARTED' | 'IN_PROGRESS' | 'READY' | 'FAILED'; @@ -854,6 +898,8 @@ export interface ServeSessionWorkflowTaskStatus { id: string; /** Tool call in the parent session that launched this workflow. */ toolUseId?: string; + /** Saved workflow definition name, when this run came from one. */ + workflowName?: string; /** Restored from the project snapshot store; controls are read-only. */ isHistorical?: boolean; sourceRunId?: string; diff --git a/packages/cli/src/acp-integration/acpAgent.test.ts b/packages/cli/src/acp-integration/acpAgent.test.ts index af6618f4275..c9a44f68849 100644 --- a/packages/cli/src/acp-integration/acpAgent.test.ts +++ b/packages/cli/src/acp-integration/acpAgent.test.ts @@ -111,6 +111,9 @@ const { mockListWorkflowSnapshots } = vi.hoisted(() => ({ const { mockListSavedWorkflows } = vi.hoisted(() => ({ mockListSavedWorkflows: vi.fn().mockResolvedValue([]), })); +const { mockResolveSavedWorkflowScript } = vi.hoisted(() => ({ + mockResolveSavedWorkflowScript: vi.fn(), +})); const { mockAddDaemonRequestAttribute } = vi.hoisted(() => ({ mockAddDaemonRequestAttribute: vi.fn(), })); @@ -314,6 +317,10 @@ vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => ({ await importOriginal() ).tryWithWorkflowTaskMutation, listSavedWorkflows: mockListSavedWorkflows, + resolveSavedWorkflowScript: mockResolveSavedWorkflowScript, + extractAndStripMeta: ( + await importOriginal() + ).extractAndStripMeta, listWorkflowSnapshots: mockListWorkflowSnapshots, createDebugLogger: () => mockDebugLogger, extractDaemonTraceContext: mockExtractDaemonTraceContext, @@ -13057,6 +13064,96 @@ describe('QwenAgent MCP SSE/HTTP support', () => { await agentPromise; }); + it('reads a saved workflow definition with its parsed meta', async () => { + const sessionId = '11111111-1111-1111-1111-111111111111'; + const innerConfig = await setupSessionMocks(sessionId); + innerConfig.isWorkflowsEnabled.mockReturnValue(true); + const scriptPath = '/tmp/.qwen/workflows/deep-review.js'; + const script = [ + 'export const meta = {', + " name: 'deep-review',", + " description: 'Review deeply',", + " phases: [{ title: 'Scan', detail: 'grep' }],", + '}', + "return await agent('go')", + '', + ].join('\n'); + mockListSavedWorkflows.mockResolvedValueOnce([ + { name: 'deep-review', source: 'project', scriptPath }, + ]); + mockResolveSavedWorkflowScript.mockResolvedValueOnce({ + name: 'deep-review', + scriptPath, + script, + }); + + const agentPromise = runAcpAgent( + mockConfig, + makeSessionSettings(), + mockArgv, + ); + await vi.waitFor(() => expect(capturedAgentFactory).toBeDefined()); + const agent = capturedAgentFactory!({ + get closed() { + return mockConnectionState.promise; + }, + }) as AgentLike; + + await agent.newSession({ cwd: '/tmp', mcpServers: [] }); + await expect( + agent.extMethod(SERVE_STATUS_EXT_METHODS.sessionSavedWorkflow, { + sessionId, + name: 'deep-review', + }), + ).resolves.toEqual({ + v: 1, + sessionId, + name: 'deep-review', + workflow: { + v: 1, + sessionId, + name: 'deep-review', + source: 'project', + scriptPath, + script, + meta: { + name: 'deep-review', + description: 'Review deeply', + phases: [{ title: 'Scan', detail: 'grep' }], + }, + }, + }); + expect(mockResolveSavedWorkflowScript).toHaveBeenCalledWith( + 'deep-review', + expect.anything(), + ); + + // Unknown names and disabled Workflow controls both fail closed to null. + await expect( + agent.extMethod(SERVE_STATUS_EXT_METHODS.sessionSavedWorkflow, { + sessionId, + name: 'missing', + }), + ).resolves.toEqual({ v: 1, sessionId, name: 'missing', workflow: null }); + innerConfig.isWorkflowsEnabled.mockReturnValue(false); + mockListSavedWorkflows.mockClear(); + await expect( + agent.extMethod(SERVE_STATUS_EXT_METHODS.sessionSavedWorkflow, { + sessionId, + name: 'deep-review', + }), + ).resolves.toEqual({ + v: 1, + sessionId, + name: 'deep-review', + workflow: null, + }); + expect(mockListSavedWorkflows).not.toHaveBeenCalled(); + + mockConnectionState.resolve(); + await agentPromise; + }); + it('uses the exact run id when a concurrent saved workflow uses the same script', async () => { const sessionId = '11111111-1111-1111-1111-111111111111'; const innerConfig = await setupSessionMocks(sessionId); @@ -13125,9 +13222,12 @@ describe('QwenAgent MCP SSE/HTTP support', () => { status: 'running', taskId: 'wf_5678efab', }); - expect(buildSessionOwnedBackground).toHaveBeenCalledWith({ - scriptPath: '/tmp/.qwen/workflows/deep-review.js', - }); + expect(buildSessionOwnedBackground).toHaveBeenCalledWith( + { + scriptPath: '/tmp/.qwen/workflows/deep-review.js', + }, + 'deep-review', + ); expect(execute).toHaveBeenCalledOnce(); mockConnectionState.resolve(); @@ -13143,6 +13243,7 @@ describe('QwenAgent MCP SSE/HTTP support', () => { runId: 'wf_1234abcd', kind: 'workflow' as const, status: 'failed' as 'failed' | 'running', + workflowName: 'deep-review', script: 'return await agent(args.prompt)', args: { prompt: 'retry this path' }, }; @@ -13185,11 +13286,14 @@ describe('QwenAgent MCP SSE/HTTP support', () => { action: 'retry', }), ).resolves.toEqual({ changed: true, status: 'running' }); - expect(buildSessionOwnedBackground).toHaveBeenCalledWith({ - script: task.script, - args: task.args, - resumeFromRunId: task.runId, - }); + expect(buildSessionOwnedBackground).toHaveBeenCalledWith( + { + script: task.script, + args: task.args, + resumeFromRunId: task.runId, + }, + task.workflowName, + ); expect(execute).toHaveBeenCalledOnce(); mockConnectionState.resolve(); @@ -13433,6 +13537,7 @@ describe('QwenAgent MCP SSE/HTTP support', () => { runId: 'wf_1234abcd', kind: 'workflow' as const, status: 'failed' as 'failed' | 'running', + workflowName: 'deep-review', script: 'return await agent(args.prompt)', args: { prompt: 'rerun everything' }, }; @@ -13503,10 +13608,13 @@ describe('QwenAgent MCP SSE/HTTP support', () => { status: 'running', taskId: rerun.runId, }); - expect(buildSessionOwnedBackground).toHaveBeenCalledWith({ - script: task.script, - args: task.args, - }); + expect(buildSessionOwnedBackground).toHaveBeenCalledWith( + { + script: task.script, + args: task.args, + }, + task.workflowName, + ); expect(execute).toHaveBeenCalledOnce(); expect(rerun).toMatchObject({ sourceRunId: task.runId, diff --git a/packages/cli/src/acp-integration/acpAgent.ts b/packages/cli/src/acp-integration/acpAgent.ts index 9076f8ec111..57550c7f737 100644 --- a/packages/cli/src/acp-integration/acpAgent.ts +++ b/packages/cli/src/acp-integration/acpAgent.ts @@ -140,6 +140,8 @@ import { isTerminalWorkflowStatus, tryWithWorkflowTaskMutation, listSavedWorkflows, + resolveSavedWorkflowScript, + extractAndStripMeta, listWorkflowSnapshots, type TurnResultRecordPayload, sessionIdContext, @@ -351,6 +353,8 @@ import { type ServeSessionContextStatus, type ServeSessionSupportedCommandsStatus, type ServeSessionLspStatus, + type ServeSessionSavedWorkflowDetail, + type ServeSessionSavedWorkflowStatus, type ServeSessionTasksStatus, type ServeStatus, type ServeStatusCell, @@ -456,6 +460,7 @@ import { type SessionOwnedWorkflowTool = { buildSessionOwnedBackground( params: Omit, + workflowName?: string, ): { execute(signal: AbortSignal): Promise; }; @@ -7834,6 +7839,56 @@ class QwenAgent implements Agent { ); } + /** + * Resolve one saved workflow for display. Fails closed to `workflow: null` + * on every miss — unknown name, illegal name, unreadable file, or Workflow + * controls unavailable — so the daemon never has to distinguish them and + * cannot be used to probe the filesystem by name. + */ + private async buildSessionSavedWorkflowStatus( + sessionId: string, + name: string, + ): Promise { + const session = this.sessionOrThrow(sessionId); + const config = session.getConfig(); + const envelope = ( + workflow: ServeSessionSavedWorkflowDetail | null, + ): ServeSessionSavedWorkflowStatus => ({ + v: STATUS_SCHEMA_VERSION, + sessionId, + name, + workflow, + }); + if (!this.canUseWorkflowControls(config)) return envelope(null); + const entry = (await listSavedWorkflows(config)).find( + (candidate) => candidate.name === name, + ); + if (!entry) return envelope(null); + let script: string; + try { + script = (await resolveSavedWorkflowScript(name, config)).script; + } catch { + return envelope(null); + } + let meta: ServeSessionSavedWorkflowDetail['meta'] = null; + let metaError: string | undefined; + try { + meta = extractAndStripMeta(script).meta; + } catch (error) { + metaError = error instanceof Error ? error.message : String(error); + } + return envelope({ + v: STATUS_SCHEMA_VERSION, + sessionId, + name: entry.name, + source: entry.source, + scriptPath: entry.scriptPath, + script, + meta, + ...(metaError !== undefined ? { metaError } : {}), + }); + } + private buildSessionLspStatus(sessionId: string): ServeSessionLspStatus { const session = this.sessionOrThrow(sessionId); const config = session.getConfig(); @@ -8657,6 +8712,26 @@ class QwenAgent implements Agent { unknown >; } + case SERVE_STATUS_EXT_METHODS.sessionSavedWorkflow: { + const sessionId = params['sessionId']; + if (typeof sessionId !== 'string' || sessionId.length === 0) { + throw RequestError.invalidParams( + undefined, + 'Invalid or missing sessionId', + ); + } + const name = params['name']; + if (typeof name !== 'string' || name.length === 0) { + throw RequestError.invalidParams( + undefined, + 'Invalid or missing name', + ); + } + return (await this.buildSessionSavedWorkflowStatus( + sessionId, + name, + )) as unknown as Record; + } case SERVE_STATUS_EXT_METHODS.sessionTranscript: { const sessionId = params['sessionId']; if (typeof sessionId !== 'string' || !SESSION_ID_RE.test(sessionId)) { @@ -11413,9 +11488,12 @@ class QwenAgent implements Agent { ); } const result = (await workflowTool - .buildSessionOwnedBackground({ - scriptPath: savedWorkflow.scriptPath, - }) + .buildSessionOwnedBackground( + { + scriptPath: savedWorkflow.scriptPath, + }, + savedWorkflow.name, + ) .execute(new AbortController().signal)) as WorkflowToolResult; const startedTask = result.workflowRunId ? registry.get(result.workflowRunId) @@ -11479,7 +11557,7 @@ class QwenAgent implements Agent { ...(action === 'retry' ? { resumeFromRunId: task.runId } : {}), }; const result = (await workflowTool - .buildSessionOwnedBackground(startParams) + .buildSessionOwnedBackground(startParams, task.workflowName) .execute(new AbortController().signal)) as WorkflowToolResult; if (action === 'rerun') { const rerunTask = result.workflowRunId diff --git a/packages/cli/src/acp-integration/session/tasksSnapshot.test.ts b/packages/cli/src/acp-integration/session/tasksSnapshot.test.ts index afff78b1cf6..57dece58c21 100644 --- a/packages/cli/src/acp-integration/session/tasksSnapshot.test.ts +++ b/packages/cli/src/acp-integration/session/tasksSnapshot.test.ts @@ -188,14 +188,14 @@ describe('buildSessionTasksStatus workflow graph', () => { expect(snapshot.tasks).toEqual([]); }); - it('exposes phase visits and dispatch dependencies from the workflow registry', () => { + it('uses the saved name before metadata is available and exposes the workflow graph', () => { const workflow = { kind: 'workflow', id: 'wf_graph', runId: 'wf_graph', toolUseId: 'workflow-call-1', - description: 'Review and fix', - meta: { name: 'review-and-fix', description: 'Review and fix' }, + description: 'wf_graph', + meta: null, status: 'running', startTime: 1_000, isBackgrounded: true, @@ -258,6 +258,7 @@ describe('buildSessionTasksStatus workflow graph', () => { tokenBudgetTotal: 8_000, perPhaseTokens: new Map(), script: '', + workflowName: 'review-and-fix', sourceRunId: 'wf_source', startMode: 'rerun', pendingApprovals: [ @@ -292,6 +293,7 @@ describe('buildSessionTasksStatus workflow graph', () => { kind: 'workflow', id: 'wf_graph', toolUseId: 'workflow-call-1', + workflowName: 'review-and-fix', label: 'review-and-fix', currentPhase: 'Review', agentsDispatched: 2, @@ -345,7 +347,14 @@ describe('buildSessionTasksStatus workflow graph', () => { 'session-1', configWith([]), 2_000, - [workflowSnapshot()], + [ + workflowSnapshot({ + meta: null, + toolUseId: 'workflow-call-1', + workflowName: 'review-and-fix', + description: 'wf_saved', + }), + ], { includeWorkflows: true }, ); @@ -353,6 +362,7 @@ describe('buildSessionTasksStatus workflow graph', () => { expect.objectContaining({ kind: 'workflow', id: 'wf_saved', + toolUseId: 'workflow-call-1', label: 'review-and-fix', status: 'failed', runtimeMs: 500, diff --git a/packages/cli/src/acp-integration/session/tasksSnapshot.ts b/packages/cli/src/acp-integration/session/tasksSnapshot.ts index bd5c43a132a..9397f12a3f4 100644 --- a/packages/cli/src/acp-integration/session/tasksSnapshot.ts +++ b/packages/cli/src/acp-integration/session/tasksSnapshot.ts @@ -133,9 +133,14 @@ function serializeWorkflowTask( kind: 'workflow', id: entry.runId, ...optionalField('toolUseId', entry.toolUseId), + ...optionalField('workflowName', entry.workflowName), ...optionalField('sourceRunId', entry.sourceRunId), ...optionalField('startMode', entry.startMode), - label: entry.meta?.name ?? entry.description ?? entry.runId, + label: + entry.meta?.name ?? + entry.workflowName ?? + entry.description ?? + entry.runId, description: entry.meta?.description ?? entry.description, status: entry.status, startTime: entry.startTime, @@ -174,9 +179,15 @@ function serializeWorkflowSnapshot( kind: 'workflow', id: snapshot.runId, isHistorical: true, + ...optionalField('toolUseId', snapshot.toolUseId), + ...optionalField('workflowName', snapshot.workflowName), ...optionalField('sourceRunId', snapshot.sourceRunId), ...optionalField('startMode', snapshot.startMode), - label: snapshot.meta?.name ?? snapshot.description ?? snapshot.runId, + label: + snapshot.meta?.name ?? + snapshot.workflowName ?? + snapshot.description ?? + snapshot.runId, description: snapshot.meta?.description ?? snapshot.description ?? snapshot.runId, status: snapshot.status, diff --git a/packages/cli/src/remoteInput/RemoteInputWatcher.ts b/packages/cli/src/remoteInput/RemoteInputWatcher.ts index bb6855c5533..3321ee406a1 100644 --- a/packages/cli/src/remoteInput/RemoteInputWatcher.ts +++ b/packages/cli/src/remoteInput/RemoteInputWatcher.ts @@ -74,7 +74,7 @@ export class RemoteInputWatcher { /** * Register the TUI's submit function. Called from AppContainer - * once useGeminiStream's submitQuery is available. + * once useLlmStream's submitQuery is available. */ setSubmitFn(fn: SubmitFn): void { this.submitFn = fn; diff --git a/packages/cli/src/serve/acp-http/dispatch.ts b/packages/cli/src/serve/acp-http/dispatch.ts index 57316bb1492..5f047382fc9 100644 --- a/packages/cli/src/serve/acp-http/dispatch.ts +++ b/packages/cli/src/serve/acp-http/dispatch.ts @@ -286,6 +286,7 @@ const ALL_QWEN_VENDOR_METHODS: readonly string[] = [ `${QWEN_METHOD_NS}session/tasks/cancel`, `${QWEN_METHOD_NS}session/tasks/workflow_action`, `${QWEN_METHOD_NS}session/lsp`, + `${QWEN_METHOD_NS}session/saved_workflow`, `${QWEN_METHOD_NS}session/artifacts`, `${QWEN_METHOD_NS}session/artifacts/add`, `${QWEN_METHOD_NS}session/artifacts/remove`, @@ -3872,6 +3873,27 @@ export class AcpDispatcher { return; } + case `${QWEN_METHOD_NS}session/saved_workflow`: { + const sessionId = String(params['sessionId'] ?? ''); + if (!this.requireOwned(conn, sessionId, id)) return; + const name = String(params['name'] ?? ''); + if (!name) { + if (id !== undefined) { + conn.sendConn( + error(id, RPC.INVALID_PARAMS, '`name` is required'), + ); + } + return; + } + // Same fail-closed shape as the redacted supported-commands list: + // an untrusted workspace never reads workflow scripts. + const result = this.isWorkspaceTrusted() + ? await this.bridge.getSessionSavedWorkflow(sessionId, name) + : { v: 1, sessionId, name, workflow: null }; + this.replyConn(conn, id, result as unknown); + return; + } + case `${QWEN_METHOD_NS}session/artifacts`: { const sessionId = String(params['sessionId'] ?? ''); if (!this.requireOwned(conn, sessionId, id)) return; diff --git a/packages/cli/src/serve/acp-http/index.ts b/packages/cli/src/serve/acp-http/index.ts index 40c2a15e4ee..99eb5556c85 100644 --- a/packages/cli/src/serve/acp-http/index.ts +++ b/packages/cli/src/serve/acp-http/index.ts @@ -325,6 +325,7 @@ const WS_READ_METHODS = new Set([ '_qwen/session/context_usage', '_qwen/session/tasks', '_qwen/session/lsp', + '_qwen/session/saved_workflow', '_qwen/session/artifacts', '_qwen/workspace/mcp', '_qwen/workspace/skills', diff --git a/packages/cli/src/serve/acp-http/transport.test.ts b/packages/cli/src/serve/acp-http/transport.test.ts index e395eb36268..587ad242c52 100644 --- a/packages/cli/src/serve/acp-http/transport.test.ts +++ b/packages/cli/src/serve/acp-http/transport.test.ts @@ -615,6 +615,28 @@ class FakeBridge { this.lastWorkflowAction = { sessionId, taskId, action, context }; return { changed: true, status: 'running' as const }; } + lastSavedWorkflowRead: { sessionId: string; name: string } | undefined; + async getSessionSavedWorkflow(sessionId: string, name: string) { + this.lastSavedWorkflowRead = { sessionId, name }; + return { + v: 1 as const, + sessionId, + name, + workflow: + name === 'deep-review' + ? { + v: 1 as const, + sessionId, + name, + source: 'project' as const, + scriptPath: `${TEST_WORKSPACE}/.qwen/workflows/deep-review.js`, + script: + "export const meta = { name: 'deep-review', description: 'd' }", + meta: { name: 'deep-review', description: 'd' }, + } + : null, + }; + } async getSessionLspStatus(sessionId: string) { return { v: 1, @@ -8897,6 +8919,67 @@ describe('ACP Streamable HTTP transport (over the wire)', () => { }); }); + it('_qwen/session/saved_workflow returns the definition envelope', async () => { + const connId = await initialize(); + const streamRes = openStream(connId); + await new Promise((r) => setTimeout(r, 30)); + await post(connId, { + jsonrpc: '2.0', + id: 99, + method: 'session/new', + params: {}, + }); + await new Promise((r) => setTimeout(r, 30)); + await post(connId, { + jsonrpc: '2.0', + id: 63, + method: '_qwen/session/saved_workflow', + params: { sessionId: 'sess-1', name: 'deep-review' }, + }); + const frames = await takeFrames(await streamRes, 2); + expect(frames[1]).toMatchObject({ + id: 63, + result: { + sessionId: 'sess-1', + name: 'deep-review', + workflow: { + source: 'project', + meta: { name: 'deep-review', description: 'd' }, + }, + }, + }); + expect(bridge.lastSavedWorkflowRead).toEqual({ + sessionId: 'sess-1', + name: 'deep-review', + }); + }); + + it('_qwen/session/saved_workflow fails closed for an untrusted workspace', async () => { + await restartServer({ primaryTrusted: false }); + const connId = await initialize(); + const streamRes = openStream(connId); + await new Promise((r) => setTimeout(r, 30)); + await post(connId, { + jsonrpc: '2.0', + id: 99, + method: 'session/new', + params: {}, + }); + await new Promise((r) => setTimeout(r, 30)); + await post(connId, { + jsonrpc: '2.0', + id: 64, + method: '_qwen/session/saved_workflow', + params: { sessionId: 'sess-1', name: 'deep-review' }, + }); + const frames = await takeFrames(await streamRes, 2); + expect(frames[1]).toMatchObject({ + id: 64, + result: { sessionId: 'sess-1', name: 'deep-review', workflow: null }, + }); + expect(bridge.lastSavedWorkflowRead).toBeUndefined(); + }); + it('_qwen/session/artifacts returns the session artifact snapshot', async () => { const connId = await initialize(); const streamRes = openStream(connId); @@ -12161,6 +12244,36 @@ describe('ACP WebSocket transport security', () => { ws.close(); }); + it('classifies _qwen/session/saved_workflow as a WS read method', async () => { + // Definition reads share a bucket with real mutations if this is + // missing from WS_READ_METHODS: ~30 reads a minute (browsing the Saved + // tab, reopening details after a timeout) then exhaust the tier and the + // user's next pause/stop/cancel is rejected as rate-limited. + const tiers: string[] = []; + await startServer({ + checkRate: (_key, tier) => { + tiers.push(tier); + return true; + }, + }); + const ws = await wsConnect(); + await sendRpc(ws, { + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: {}, + }); + await sendRpc(ws, { + jsonrpc: '2.0', + id: 2, + method: '_qwen/session/saved_workflow', + params: { sessionId: 'missing-session', name: 'deep-review' }, + }); + + expect(tiers).toEqual(['read']); + ws.close(); + }); + it('classifies _qwen/workspace/trust/request as a WS mutation method', async () => { const tiers: string[] = []; await startServer({ diff --git a/packages/cli/src/serve/routes/session.ts b/packages/cli/src/serve/routes/session.ts index cfff0e5c0b5..1d840baf7d6 100644 --- a/packages/cli/src/serve/routes/session.ts +++ b/packages/cli/src/serve/routes/session.ts @@ -4753,6 +4753,32 @@ export function registerSessionRoutes( ), ); + app.get( + '/session/:id/saved-workflows/:name', + withOwnerReadSession( + 'GET /session/:id/saved-workflows/:name', + async (req, res, sessionId, runtime) => { + const name = req.params['name']; + if (!name) { + res.status(400).json({ + error: '`name` route parameter is required', + }); + return; + } + // An untrusted workspace fails closed with the same envelope the + // child returns for an unknown name, mirroring the supported-commands + // redaction rather than leaking a distinguishable error. + res + .status(200) + .json( + runtime.trusted + ? await runtime.bridge.getSessionSavedWorkflow(sessionId, name) + : { v: 1, sessionId, name, workflow: null }, + ); + }, + ), + ); + app.get( '/session/:id/lsp', withOwnerReadSession( diff --git a/packages/cli/src/serve/server.test.ts b/packages/cli/src/serve/server.test.ts index fe5d71bdb7e..63f878381ea 100644 --- a/packages/cli/src/serve/server.test.ts +++ b/packages/cli/src/serve/server.test.ts @@ -963,6 +963,7 @@ interface FakeBridgeOpts { opts?: { includeWorkflows?: boolean }, ) => Promise; sessionLspImpl?: (sessionId: string) => Promise; + sessionSavedWorkflowImpl?: AcpSessionBridge['getSessionSavedWorkflow']; sessionTranscriptImpl?: AcpSessionBridge['getSessionTranscriptPage']; cancelSessionTaskImpl?: ( sessionId: string, @@ -1271,6 +1272,7 @@ interface FakeBridge extends AcpSessionBridge { sessionTasksCalls: string[]; sessionTasksOptions: Array<{ includeWorkflows?: boolean } | undefined>; sessionLspCalls: string[]; + sessionSavedWorkflowCalls: Array<{ sessionId: string; name: string }>; sessionTranscriptCalls: Array< Parameters[0] >; @@ -1485,6 +1487,7 @@ function fakeBridge(opts: FakeBridgeOpts = {}): FakeBridge { const sessionTasksOptions: Array<{ includeWorkflows?: boolean } | undefined> = []; const sessionLspCalls: string[] = []; + const sessionSavedWorkflowCalls: FakeBridge['sessionSavedWorkflowCalls'] = []; const sessionTranscriptCalls: FakeBridge['sessionTranscriptCalls'] = []; const cancelSessionTaskCalls: FakeBridge['cancelSessionTaskCalls'] = []; const controlSessionWorkflowTaskCalls: FakeBridge['controlSessionWorkflowTaskCalls'] = @@ -1809,6 +1812,22 @@ function fakeBridge(opts: FakeBridgeOpts = {}): FakeBridge { now: 1_700_000_000_000, tasks: [], })); + const sessionSavedWorkflowImpl: AcpSessionBridge['getSessionSavedWorkflow'] = + opts.sessionSavedWorkflowImpl ?? + (async (sessionId, name) => ({ + v: 1 as const, + sessionId, + name, + workflow: { + v: 1 as const, + sessionId, + name, + source: 'project' as const, + scriptPath: `${WS_BOUND}/.qwen/workflows/${name}.js`, + script: `export const meta = { name: '${name}', description: 'd' }`, + meta: { name, description: 'd' }, + }, + })); const sessionLspImpl = opts.sessionLspImpl ?? (async (sessionId) => ({ @@ -2106,6 +2125,7 @@ function fakeBridge(opts: FakeBridgeOpts = {}): FakeBridge { sessionTasksCalls, sessionTasksOptions, sessionLspCalls, + sessionSavedWorkflowCalls, sessionTranscriptCalls, cancelSessionTaskCalls, controlSessionWorkflowTaskCalls, @@ -2405,6 +2425,10 @@ function fakeBridge(opts: FakeBridgeOpts = {}): FakeBridge { sessionLspCalls.push(sessionId); return sessionLspImpl(sessionId); }, + async getSessionSavedWorkflow(sessionId, name) { + sessionSavedWorkflowCalls.push({ sessionId, name }); + return sessionSavedWorkflowImpl(sessionId, name); + }, async getSessionTranscriptPage(req) { sessionTranscriptCalls.push(req); return sessionTranscriptImpl(req); @@ -10148,6 +10172,53 @@ describe('createServeApp', () => { ]); }); + it('reads a saved workflow definition and fails closed for an untrusted workspace', async () => { + const bridge = fakeBridge(); + const app = createServeApp( + { ...baseOpts, workspace: WS_BOUND }, + undefined, + { bridge, primaryWorkspaceTrusted: true }, + ); + + const res = await request(app) + .get('/session/s-1/saved-workflows/deep-review') + .set('Host', `127.0.0.1:${baseOpts.port}`); + + expect(res.status).toBe(200); + expect(res.body).toMatchObject({ + v: 1, + sessionId: 's-1', + name: 'deep-review', + workflow: { + source: 'project', + scriptPath: `${WS_BOUND}/.qwen/workflows/deep-review.js`, + meta: { name: 'deep-review', description: 'd' }, + }, + }); + expect(bridge.sessionSavedWorkflowCalls).toEqual([ + { sessionId: 's-1', name: 'deep-review' }, + ]); + + const untrustedBridge = fakeBridge(); + const untrustedApp = createServeApp( + { ...baseOpts, workspace: WS_BOUND }, + undefined, + { bridge: untrustedBridge, primaryWorkspaceTrusted: false }, + ); + const closedRes = await request(untrustedApp) + .get('/session/s-1/saved-workflows/deep-review') + .set('Host', `127.0.0.1:${baseOpts.port}`); + + expect(closedRes.status).toBe(200); + expect(closedRes.body).toEqual({ + v: 1, + sessionId: 's-1', + name: 'deep-review', + workflow: null, + }); + expect(untrustedBridge.sessionSavedWorkflowCalls).toEqual([]); + }); + it('controls live runs, saved definitions, and history through one route', async () => { const bridge = fakeBridge({ controlSessionWorkflowTaskImpl: async (_sessionId, _taskId, action) => diff --git a/packages/cli/src/serve/server/telemetry-catalog.test.ts b/packages/cli/src/serve/server/telemetry-catalog.test.ts index c95e8c681bf..43fe815b82c 100644 --- a/packages/cli/src/serve/server/telemetry-catalog.test.ts +++ b/packages/cli/src/serve/server/telemetry-catalog.test.ts @@ -98,7 +98,7 @@ describe('legacy session telemetry route drift guard', () => { .map(({ method, path }) => `${method} ${path}`) .sort(); - expect(registered).toHaveLength(62); + expect(registered).toHaveLength(63); expect(registered).toEqual(catalog); }); }); diff --git a/packages/cli/src/serve/server/telemetry.test.ts b/packages/cli/src/serve/server/telemetry.test.ts index 4280288c1e4..24df6dc2193 100644 --- a/packages/cli/src/serve/server/telemetry.test.ts +++ b/packages/cli/src/serve/server/telemetry.test.ts @@ -1068,17 +1068,17 @@ describe('daemonTelemetryMiddleware — recordRequest seam', () => { }); describe('legacy session telemetry route catalog', () => { - it('contains 62 unique routes with the audited 60/2 attribution split', () => { + it('contains 63 unique routes with the audited 61/2 attribution split', () => { const keys = legacySessionTelemetryRoutes.map( ({ method, path }) => `${method} ${path}`, ); - expect(keys).toHaveLength(62); - expect(new Set(keys).size).toBe(62); + expect(keys).toHaveLength(63); + expect(new Set(keys).size).toBe(63); expect( legacySessionTelemetryRoutes.filter( ({ attribution }) => attribution === 'handler_resolved', ), - ).toHaveLength(60); + ).toHaveLength(61); expect( legacySessionTelemetryRoutes.filter( ({ attribution }) => attribution === 'pre_resolved', diff --git a/packages/cli/src/serve/server/telemetry.ts b/packages/cli/src/serve/server/telemetry.ts index b1620b41742..9433b4e2cbe 100644 --- a/packages/cli/src/serve/server/telemetry.ts +++ b/packages/cli/src/serve/server/telemetry.ts @@ -187,6 +187,12 @@ export const legacySessionTelemetryRoutes = [ attribution: 'handler_resolved', route: 'POST /session/:id/tasks/:taskId/workflow-action', }, + { + method: 'GET', + path: '/session/:id/saved-workflows/:name', + attribution: 'handler_resolved', + route: 'GET /session/:id/saved-workflows/:name', + }, { method: 'POST', path: '/session/:id/goal', diff --git a/packages/cli/src/ui/commands/workflowsCommand.ts b/packages/cli/src/ui/commands/workflowsCommand.ts index bcb3182370c..01fc7b5adbc 100644 --- a/packages/cli/src/ui/commands/workflowsCommand.ts +++ b/packages/cli/src/ui/commands/workflowsCommand.ts @@ -26,6 +26,7 @@ export function snapshotToTask(s: WorkflowSnapshot): WorkflowTask { id: s.runId, kind: 'workflow', runId: s.runId, + ...(s.workflowName ? { workflowName: s.workflowName } : {}), description: s.meta?.name ?? s.runId, meta: s.meta, status: s.status, diff --git a/packages/cli/vitest.config.ts b/packages/cli/vitest.config.ts index b0ecb3c022b..d3778ab6b62 100644 --- a/packages/cli/vitest.config.ts +++ b/packages/cli/vitest.config.ts @@ -10,20 +10,30 @@ import path from 'node:path'; const CORE_SRC = path.resolve(__dirname, '../core/src'); -// Vite accepts `alias` as an object or as an ordered array of {find, -// replacement}. Only the array form takes a RegExp, and core needs one, so -// the whole map is expressed that way. Order is precedence: the first entry -// whose `find` matches wins. +// Vite takes `alias` as an object or as an ordered array of {find, +// replacement}; only the array form accepts a RegExp, and core needs one. +// Order is precedence — the first entry whose `find` matches wins. const toAliases = (map: Record) => Object.entries(map).map(([find, replacement]) => ({ find, replacement })); export default defineConfig({ resolve: { alias: [ - // These four subpaths do not map onto their file names (goalWire lives - // at goals/goal-wire.ts), so the wildcard below cannot derive them and - // they have to be matched first. + // Named core subpaths. None of these targets can be derived from the + // specifier (noFollowOpen lives at utils/no-follow-open.ts), so they + // have to be matched ahead of the wildcard below. A new named subpath + // added to packages/cli/tsconfig.json must be added here too, above + // the wildcard, or the wildcard will claim it and point at a file + // that does not exist. ...toAliases({ + '@qwen-code/qwen-code-core/noFollowOpen': path.resolve( + __dirname, + '../core/src/utils/no-follow-open.ts', + ), + '@qwen-code/qwen-code-core/subSessionConstants': path.resolve( + __dirname, + '../core/src/tools/sub-session-constants.ts', + ), '@qwen-code/qwen-code-core/goalWire': path.resolve( __dirname, '../core/src/goals/goal-wire.ts', @@ -40,21 +50,28 @@ export default defineConfig({ __dirname, '../core/src/memory/scopes.ts', ), + '@qwen-code/qwen-code-core/toolWriteOrigin': path.resolve( + __dirname, + '../core/src/services/tool-write-origin.ts', + ), + '@qwen-code/qwen-code-core/envVarResolver': path.resolve( + __dirname, + '../core/src/utils/envVarResolver.ts', + ), }), - // Mirrors the `"@qwen-code/qwen-code-core/*": ["../core/src/*"]` rule in + // Mirrors `"@qwen-code/qwen-code-core/*": ["../core/src/*"]` from // packages/cli/tsconfig.json. esbuild reads that paths block when it - // bundles, but Vitest does not read tsconfig paths at all, so this file - // is the only place the mapping exists for a test run and the two have - // to be kept in sync by hand. Importing a specific core module instead - // of the package root depends on this entry — without it those - // specifiers do not resolve under Vitest. + // bundles; Vitest does not read tsconfig paths at all, so this file is + // the only place the mapping exists for a test run and the two have to + // be kept in sync by hand. Importing one core module rather than the + // package root depends on this entry. { find: /^@qwen-code\/qwen-code-core\/(.*)$/, replacement: `${CORE_SRC}/$1`, }, - // The package root is matched exactly. A string `find` also matches - // `/...`, so spelled as a string this entry would swallow every - // subpath above and rewrite them to `.../core/index.ts/`. + // The package root is matched exactly. Spelled as a string it would + // also match everything beneath it and rewrite each subpath into a + // path under index.ts. { find: /^@qwen-code\/qwen-code-core$/, replacement: path.resolve(__dirname, '../core/index.ts'), @@ -113,6 +130,10 @@ export default defineConfig({ __dirname, '../acp-bridge/src/bridgeOptions.ts', ), + '@qwen-code/acp-bridge/promptLedger': path.resolve( + __dirname, + '../acp-bridge/src/prompt-ledger.ts', + ), '@qwen-code/acp-bridge/bridgeTypes': path.resolve( __dirname, '../acp-bridge/src/bridgeTypes.ts', @@ -172,10 +193,25 @@ export default defineConfig({ // See packages/core/vitest.config.ts: raise the per-test ceiling above // vitest's 5s default so I/O-bound tests (e.g. the workspace registration // store's tempdir round-trip) don't blow it purely under CI contention. - testTimeout: 15000, + testTimeout: process.env['RUNNER_NAME']?.startsWith('ecs-qwen-') + ? 60_000 + : 15_000, + hookTimeout: process.env['RUNNER_NAME']?.startsWith('ecs-qwen-') + ? 60_000 + : undefined, + // ECS hosts run several jobs at once; leave capacity for neighboring jobs. + maxWorkers: process.env['RUNNER_NAME']?.startsWith('ecs-qwen-') + ? '25%' + : undefined, include: ['**/*.{test,spec}.?(c|m)[jt]s?(x)', 'config.test.ts'], exclude: ['**/node_modules/**', '**/dist/**', '**/cypress/**'], - environment: 'jsdom', + // Terminal app: run under node. Only files that need a document (the + // @testing-library/react renderHook suites and a few DOM-touching UI + // tests) opt into jsdom with a `// @vitest-environment jsdom` control + // comment; vitest reads it from the file itself. Creating a jsdom per + // file cost 0.2–0.5s each, a tenth of the suite, while nine files in + // ten never touched the DOM. + environment: 'node', globals: true, reporters: ['default', 'junit'], silent: true, @@ -183,8 +219,25 @@ export default defineConfig({ junit: 'junit.xml', }, setupFiles: ['./test-setup.ts'], + // Fail fast with an actionable message when workspace dist/ output or + // generated files are missing (fresh clone, new worktree, deep clean). + // See scripts/vitest-global-setup.js and issue #9149. + // Resolved against this config file (not vitest's root/cwd) so the guard + // also loads when vitest is launched from elsewhere with --config. + globalSetup: path.resolve( + __dirname, + '../../scripts/vitest-global-setup.js', + ), + // RPC-timeout exemption; see scripts/tests/unit-vitest-configs.test.ts. + dangerouslyIgnoreUnhandledErrors: process.platform !== 'linux', coverage: { - enabled: true, + // CI collects coverage only where something keeps it: the post-merge + // run on main, which ci.yml marks with QWEN_CI_COVERAGE=1 and whose + // reports it uploads. Pull-request runs skip it — nothing read those + // reports, and v8 instrumentation plus the per-file merge on the main + // thread cost about a fifth of the suite's wall time. Local runs keep + // coverage. + enabled: !process.env.CI || process.env['QWEN_CI_COVERAGE'] === '1', provider: 'v8', reportsDirectory: './coverage', include: ['src/**/*'], @@ -197,12 +250,6 @@ export default defineConfig({ ['json-summary', { outputFile: 'coverage-summary.json' }], ], }, - poolOptions: { - threads: { - minThreads: 8, - maxThreads: 16, - }, - }, server: { deps: { inline: [/@qwen-code\/qwen-code-core/], diff --git a/packages/core/src/agents/runtime/workflow-runner.test.ts b/packages/core/src/agents/runtime/workflow-runner.test.ts index c24fa7a9d82..bbbf15b039e 100644 --- a/packages/core/src/agents/runtime/workflow-runner.test.ts +++ b/packages/core/src/agents/runtime/workflow-runner.test.ts @@ -184,6 +184,38 @@ describe('WorkflowRunner', () => { expect(registry.get(handle.runId)?.args).toEqual(args); }); + it('retains a saved workflow name when resuming with an inline script', async () => { + const { config, registry } = configWithRegistry(); + resolveSavedWorkflowScriptMock.mockResolvedValueOnce({ + name: 'review', + savedWorkflowName: 'review', + scriptPath: '/tmp/review.js', + script: 'return "done"', + }); + const initial = await WorkflowRunner.start({ + config, + signal: new AbortController().signal, + scriptPath: '/tmp/review.js', + args: undefined, + runInBackground: true, + dispatch: async () => 'unused', + }); + await initial.completion; + + const resumed = await WorkflowRunner.start({ + config, + signal: new AbortController().signal, + script: 'return "resumed"', + args: undefined, + resumeFromRunId: initial.runId, + runInBackground: true, + dispatch: async () => 'unused', + }); + await resumed.completion; + + expect(registry.get(initial.runId)?.workflowName).toBe('review'); + }); + it('records sandbox logs in the replay event ledger', async () => { const { config, registry } = configWithRegistry(); const handle = await WorkflowRunner.start({ diff --git a/packages/core/src/agents/runtime/workflow-runner.ts b/packages/core/src/agents/runtime/workflow-runner.ts index 190ac35ba94..0b3daccd000 100644 --- a/packages/core/src/agents/runtime/workflow-runner.ts +++ b/packages/core/src/agents/runtime/workflow-runner.ts @@ -42,6 +42,7 @@ export interface WorkflowRunnerOptions { config: Config; signal: AbortSignal; toolUseId?: string; + workflowName?: string; script?: string; scriptPath?: string; args: unknown; @@ -177,6 +178,10 @@ export class WorkflowRunner { : undefined; script = loaded?.script ?? options.script ?? ''; scriptPath = loaded?.scriptPath ?? options.scriptPath; + const workflowName = + options.workflowName ?? + loaded?.savedWorkflowName ?? + registry?.get(runId)?.workflowName; try { compileWorkflowScript(script); @@ -231,6 +236,7 @@ export class WorkflowRunner { { runId, toolUseId: options.toolUseId, + ...(workflowName ? { workflowName } : {}), meta: null, status: 'running', startTime: Date.now(), diff --git a/packages/core/src/agents/runtime/workflow-saved.test.ts b/packages/core/src/agents/runtime/workflow-saved.test.ts index 4f5564840d5..aba0abd4040 100644 --- a/packages/core/src/agents/runtime/workflow-saved.test.ts +++ b/packages/core/src/agents/runtime/workflow-saved.test.ts @@ -157,6 +157,7 @@ describe('workflow-saved', () => { ); expect(resolved.script).toBe(`return 'custom';`); expect(resolved.name).toBe('custom'); + expect(resolved.savedWorkflowName).toBe('custom'); }); it('throws a clear error for a missing path under a saved dir', async () => { @@ -207,6 +208,7 @@ describe('workflow-saved', () => { ); expect(resolved.script).toBe(`return 'generated';`); expect(resolved.name).toBe('qwen-review-1a2b3c'); + expect(resolved.savedWorkflowName).toBeUndefined(); }); it('trusts the whole subtree, so a writer may nest per session', async () => { diff --git a/packages/core/src/agents/runtime/workflow-saved.ts b/packages/core/src/agents/runtime/workflow-saved.ts index 7c50eb071fe..3ceffcfaec8 100644 --- a/packages/core/src/agents/runtime/workflow-saved.ts +++ b/packages/core/src/agents/runtime/workflow-saved.ts @@ -57,6 +57,7 @@ export interface ResolvedSavedWorkflow { name: string; scriptPath: string; script: string; + savedWorkflowName?: string; } /** Result of a {@link saveWorkflowScript} attempt. */ @@ -199,6 +200,31 @@ async function readWorkflowFileSecurely( return fs.readFile(real, 'utf8'); } +async function resolveSavedWorkflowNameForPath( + scriptPath: string, + config: Config, +): Promise { + const realScriptPath = await fs.realpath(scriptPath); + for (const { dir } of getSavedWorkflowDirs(config)) { + if (await isSymlinkedRoot(dir)) continue; + let realDir: string; + try { + realDir = await fs.realpath(dir); + } catch { + continue; + } + if ( + realScriptPath !== realDir && + !realScriptPath.startsWith(realDir + path.sep) + ) { + continue; + } + const name = path.basename(realScriptPath).replace(/\.js$/, ''); + return WORKFLOW_NAME_PATTERN.test(name) ? name : undefined; + } + return undefined; +} + /** * Enumerate all saved workflows across both scopes. Project entries shadow * same-named user entries (project wins). Sorted by name for stable @@ -254,7 +280,16 @@ export async function resolveSavedWorkflowScript( ); } const name = path.basename(scriptPath).replace(/\.js$/, ''); - return { name, scriptPath, script }; + const savedWorkflowName = await resolveSavedWorkflowNameForPath( + scriptPath, + config, + ); + return { + name, + scriptPath, + script, + ...(savedWorkflowName ? { savedWorkflowName } : {}), + }; } if (typeof nameOrRef !== 'string') { @@ -276,7 +311,7 @@ export async function resolveSavedWorkflowScript( const scriptPath = path.join(dir, `${name}.js`); try { const script = await readWorkflowFileSecurely(scriptPath, config); - return { name, scriptPath, script }; + return { name, scriptPath, script, savedWorkflowName: name }; } catch { // Not in this scope (absent or rejected) — try the next. } diff --git a/packages/core/src/agents/workflow-run-registry.ts b/packages/core/src/agents/workflow-run-registry.ts index 3ac33a34ece..6b977ddb2b4 100644 --- a/packages/core/src/agents/workflow-run-registry.ts +++ b/packages/core/src/agents/workflow-run-registry.ts @@ -259,6 +259,8 @@ export interface WorkflowTask extends TaskBase { runId: string; /** Tool call in the parent session that launched this workflow. */ toolUseId?: string; + /** Saved workflow definition name, when this run came from one. */ + workflowName?: string; /** Run whose result or journal led to this attempt. */ sourceRunId?: string; /** Whether this attempt reused the journal or started from scratch. */ diff --git a/packages/core/src/agents/workflow-snapshot.test.ts b/packages/core/src/agents/workflow-snapshot.test.ts index 501774bfb6e..5bbeb51cc48 100644 --- a/packages/core/src/agents/workflow-snapshot.test.ts +++ b/packages/core/src/agents/workflow-snapshot.test.ts @@ -85,6 +85,8 @@ describe('toSnapshot', () => { const s = toSnapshot( task({ description: 'Review and fix', + toolUseId: 'workflow-call-1', + workflowName: 'review-and-fix', sourceRunId: 'wf_source', startMode: 'rerun', }), @@ -98,6 +100,8 @@ describe('toSnapshot', () => { expect(s.result).toEqual({ answer: 42 }); expect(s).toMatchObject({ description: 'Review and fix', + toolUseId: 'workflow-call-1', + workflowName: 'review-and-fix', sourceRunId: 'wf_source', startMode: 'rerun', }); @@ -165,10 +169,14 @@ describe('writeWorkflowSnapshot + listWorkflowSnapshots', () => { it('round-trips a snapshot through disk', async () => { const config = fakeConfig(projectDir); - await writeWorkflowSnapshot(config, task({ runId: 'wf_rt' })); + await writeWorkflowSnapshot( + config, + task({ runId: 'wf_rt', toolUseId: 'workflow-call-1' }), + ); const list = await listWorkflowSnapshots(config); expect(list).toHaveLength(1); expect(list[0].runId).toBe('wf_rt'); + expect(list[0].toolUseId).toBe('workflow-call-1'); expect(list[0].perPhaseTokens).toEqual([ ['Plan', 200], [null, 50], diff --git a/packages/core/src/agents/workflow-snapshot.ts b/packages/core/src/agents/workflow-snapshot.ts index bf8efb05942..97d95cf3ad1 100644 --- a/packages/core/src/agents/workflow-snapshot.ts +++ b/packages/core/src/agents/workflow-snapshot.ts @@ -35,8 +35,12 @@ export const MAX_RETAINED_SNAPSHOTS = 30; /** JSON-serializable projection of a terminal workflow run. */ export interface WorkflowSnapshot { runId: string; + /** Tool call that launched the run. Absent on legacy snapshots. */ + toolUseId?: string; /** Human-readable fallback when a workflow has no exported meta block. */ description?: string; + /** Saved workflow definition name. Absent for inline and legacy runs. */ + workflowName?: string; /** Prior run used by retry or rerun. Absent on legacy snapshots. */ sourceRunId?: string; /** How this run was started from sourceRunId. */ @@ -72,7 +76,9 @@ export function toSnapshot(task: WorkflowTask): WorkflowSnapshot { } return { runId: task.runId, + ...(task.toolUseId ? { toolUseId: task.toolUseId } : {}), description: task.description, + ...(task.workflowName ? { workflowName: task.workflowName } : {}), sourceRunId: task.sourceRunId, startMode: task.startMode, meta: task.meta, @@ -367,7 +373,9 @@ function isWorkflowSnapshot(value: unknown): value is WorkflowSnapshot { return ( typeof value['runId'] === 'string' && value['runId'].length > 0 && + isOptionalString(value['toolUseId']) && isOptionalString(value['description']) && + isOptionalString(value['workflowName']) && isOptionalString(value['sourceRunId']) && (value['startMode'] === undefined || value['startMode'] === 'retry' || diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 86e8109f2c4..f57569208d4 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -456,6 +456,10 @@ export { type ResolvedSavedWorkflow, type WorkflowSaveResult, } from './agents/runtime/workflow-saved.js'; +export { + extractAndStripMeta, + type WorkflowMeta, +} from './agents/runtime/workflow-sandbox.js'; export * from './services/toolUseSummary.js'; export * from './services/usageHistoryService.js'; export * from './services/usage-dashboard-service.js'; diff --git a/packages/core/src/skills/bundled/workflow-creator/SKILL.md b/packages/core/src/skills/bundled/workflow-creator/SKILL.md new file mode 100644 index 00000000000..730691e58c0 --- /dev/null +++ b/packages/core/src/skills/bundled/workflow-creator/SKILL.md @@ -0,0 +1,48 @@ +--- +name: workflow-creator +description: Create or update reusable Dynamic Workflow JavaScript files under .qwen/workflows. Use when the user asks to create, save, edit, or reuse a Dynamic Workflow, including requests started from the Web Shell Workflows page. +--- + +# Workflow Creator + +Create and maintain saved Dynamic Workflows for the current workspace. + +## Boundary + +- This skill manages `.qwen/workflows/.js` files used by the `workflow` tool and exposed as `/` slash commands. +- Do not create or edit `qwen-workflow-design/*.yaml`; those Task Flow definitions are a different feature. +- Use project scope by default. Write to `~/.qwen/workflows` only when the user explicitly asks for a workflow shared across projects. + +## Workflow + +1. Inspect the current task and any existing workflow with the requested name. Ask a question only when the goal, ordering, or write scope is materially ambiguous. +2. Choose a lower-case name containing only letters, digits, and hyphens. It must start with a letter and be at most 41 characters. +3. Create the smallest script that captures the requested phases, dependencies, and final result. Do not add speculative branches, retries, or agents. +4. Read the saved file back and verify its name, metadata, phase order, dependency flow, and final return value. Do not execute it unless the user also asks to run it. +5. Report the saved path and slash command. In Web Shell, tell the user to return to Workflows and refresh the Saved tab if it is already open. + +## Script contract + +- Start with a literal metadata declaration: + +```js +export const meta = { + name: 'Release readiness', + description: 'Inspect, validate, and summarize a release candidate', +}; +``` + +- Use the sandbox globals documented by the `workflow` tool: `phase(title)`, `log(message)`, `agent(prompt, options?)`, `parallel(thunks)`, `pipeline(items, ...stages)`, `workflow(nameOrRef, args?)`, `args`, and `budget`. +- Scripts cannot import modules or access the filesystem, shell, environment, or network directly. Put required reads and actions in explicit agent prompts. +- Give every agent a complete, scoped prompt and a concise `label`. State whether it may edit files. +- Express real concurrency as `parallel([() => agent(...), () => agent(...)])`. Do not pass already-started promises to `parallel`. +- Keep dependent work sequential and pass prior results explicitly. +- Put variable user input in `args` instead of hard-coding one-off values. +- End every successful path with an explicit `return` of the final result. A trailing expression is not a return value. +- Do not use `node --check` for validation: valid workflow scripts may contain top-level `await` and `return` because the runtime wraps them in an async function. + +## Updates + +- Preserve unrelated behavior and metadata when editing an existing workflow. +- Do not overwrite an existing workflow with a different design unless the user requested that update. +- Do not delete or rename a workflow unless the user explicitly asks. diff --git a/packages/core/src/skills/bundled/workflow-creator/SKILL.test.ts b/packages/core/src/skills/bundled/workflow-creator/SKILL.test.ts new file mode 100644 index 00000000000..1bde751ea47 --- /dev/null +++ b/packages/core/src/skills/bundled/workflow-creator/SKILL.test.ts @@ -0,0 +1,39 @@ +/** + * @license + * Copyright 2026 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; +import { parseSkillContent } from '../../skill-load.js'; + +function loadSkill() { + const skillPath = path.join( + path.dirname(fileURLToPath(import.meta.url)), + 'SKILL.md', + ); + return parseSkillContent(fs.readFileSync(skillPath, 'utf8'), skillPath); +} + +describe('bundled workflow-creator skill', () => { + it('targets saved Dynamic Workflow scripts instead of Task Flow YAML', () => { + const { body } = loadSkill(); + + expect(body).toContain('`.qwen/workflows/.js`'); + expect(body).toContain( + 'Do not create or edit `qwen-workflow-design/*.yaml`', + ); + }); + + it('preserves the workflow sandbox execution contract', () => { + const { body } = loadSkill(); + + expect(body).toContain('export const meta = {'); + expect(body).toContain('`parallel([() => agent(...), () => agent(...)])`'); + expect(body).toContain('explicit `return` of the final result'); + expect(body).toContain('Do not use `node --check`'); + }); +}); diff --git a/packages/core/src/tools/workflow/workflow.test.ts b/packages/core/src/tools/workflow/workflow.test.ts index 5e237698fba..2c3a3e65dd6 100644 --- a/packages/core/src/tools/workflow/workflow.test.ts +++ b/packages/core/src/tools/workflow/workflow.test.ts @@ -696,14 +696,18 @@ await agent('scan package.json') const result = await new WorkflowTool(config, { dispatch: async () => 'unused', }) - .buildSessionOwnedBackground({ - script: `phase('Inspect'); return { status: 'ready' };`, - }) + .buildSessionOwnedBackground( + { + script: `phase('Inspect'); return { status: 'ready' };`, + }, + 'review-and-fix', + ) .execute(new AbortController().signal); expect(result.workflowRunId).toMatch(/^wf_[0-9a-f]+$/); const run = registry.get(result.workflowRunId!); expect(run?.isBackgrounded).toBe(true); + expect(run?.workflowName).toBe('review-and-fix'); await vi.waitFor(() => expect(registry.get(result.workflowRunId!)?.status).toBe('completed'), ); @@ -980,6 +984,7 @@ await agent('scan package.json') const entries = registry.list(); expect(entries).toHaveLength(1); expect(entries[0].scriptPath).toBe(scriptPath); + expect(entries[0].workflowName).toBe('greet'); } finally { await fs.rm(projectDir, { recursive: true, force: true }); } @@ -1093,6 +1098,9 @@ await agent('scan package.json') expect(result.error).toBeDefined(); expect(result.error!.message).toContain('scripted failure'); expect(JSON.stringify(result.llmContent)).toContain('Workflow failed'); + expect(String(result.returnDisplay)).toMatch( + /"runId"\s*:\s*"wf_[0-9a-f]+"/, + ); // T4 (PR #4732 R1): assert the machine-readable error type so a // refactor removing the field doesn't go uncaught. expect(result.error!.type).toBe('execution_failed'); diff --git a/packages/core/src/tools/workflow/workflow.ts b/packages/core/src/tools/workflow/workflow.ts index d48a4a61841..f812c51249f 100644 --- a/packages/core/src/tools/workflow/workflow.ts +++ b/packages/core/src/tools/workflow/workflow.ts @@ -232,6 +232,7 @@ class WorkflowToolInvocation extends BaseToolInvocation< private readonly config: Config, private readonly toolOptions: WorkflowToolOptions, params: WorkflowParams, + private readonly workflowName?: string, ) { super(params); } @@ -370,6 +371,7 @@ class WorkflowToolInvocation extends BaseToolInvocation< config: this.config, signal, toolUseId: this.callId, + ...(this.workflowName ? { workflowName: this.workflowName } : {}), script: this.params.script, scriptPath: this.params.scriptPath, args: this.params.args, @@ -513,14 +515,14 @@ class WorkflowToolInvocation extends BaseToolInvocation< // successful run. Mitigation: WorkflowTool's failure message // already names the error; the banner is meta-documentation // about a separate env knob, not run-specific guidance. - const display = - phases || logs || meta - ? `Workflow failed: ${message}\n\n${safeStringifyDisplayPayload({ - ...(meta ? { meta } : {}), - phases: phases ?? [], - logs: logs ?? [], - })}` - : `Workflow failed: ${message}`; + const display = `Workflow failed: ${message}\n\n${safeStringifyDisplayPayload( + { + runId: handle.runId, + ...(meta ? { meta } : {}), + phases: phases ?? [], + logs: logs ?? [], + }, + )}`; return { llmContent: [{ text: `Workflow failed: ${message}` }], returnDisplay: display, @@ -985,6 +987,7 @@ export class WorkflowTool extends BaseDeclarativeTool< buildSessionOwnedBackground( params: Omit, + workflowName?: string, ): ToolInvocation { const validationError = this.validateToolParams(params); if (validationError) { @@ -995,7 +998,12 @@ export class WorkflowTool extends BaseDeclarativeTool< 'WorkflowTool: session-owned background runs require an active workflow completion channel.', ); } - return this.createInvocation({ ...params, run_in_background: true }); + return new WorkflowToolInvocation( + this.config, + this.toolOptions, + { ...params, run_in_background: true }, + workflowName, + ); } protected override validateToolParamValues( diff --git a/packages/sdk-typescript/src/daemon/DaemonClient.ts b/packages/sdk-typescript/src/daemon/DaemonClient.ts index 6dfe1cabefa..7c382ce070c 100644 --- a/packages/sdk-typescript/src/daemon/DaemonClient.ts +++ b/packages/sdk-typescript/src/daemon/DaemonClient.ts @@ -60,6 +60,7 @@ import type { DaemonSessionGroupInput, DaemonSessionGroupUpdate, DaemonSessionLspStatus, + DaemonSessionSavedWorkflowStatus, DaemonSessionListPage, DaemonSessionListPageOptions, DaemonSessionSearchOptions, @@ -3448,6 +3449,26 @@ export class DaemonClient { ); } + async sessionSavedWorkflow( + sessionId: string, + name: string, + clientId?: string, + ): Promise { + return await this.fetchWithTimeout( + `${this.baseUrl}/session/${urlEncode(sessionId)}/saved-workflows/${urlEncode(name)}`, + { headers: this.headers({}, clientId) }, + async (res) => { + if (!res.ok) { + throw await this.failOnError( + res, + 'GET /session/:id/saved-workflows/:name', + ); + } + return (await res.json()) as DaemonSessionSavedWorkflowStatus; + }, + ); + } + async sessionLspStatus( sessionId: string, clientId?: string, diff --git a/packages/sdk-typescript/src/daemon/DaemonSessionClient.ts b/packages/sdk-typescript/src/daemon/DaemonSessionClient.ts index 03e48013c05..2ac87fdbdfa 100644 --- a/packages/sdk-typescript/src/daemon/DaemonSessionClient.ts +++ b/packages/sdk-typescript/src/daemon/DaemonSessionClient.ts @@ -52,6 +52,7 @@ import type { DaemonSessionTasksStatus, DaemonSessionWorkflowTaskStatus, DaemonSessionWorkflowTasksStatus, + DaemonSessionSavedWorkflowStatus, HeartbeatResult, GoalControlRequest, GoalStateResponse, @@ -999,6 +1000,14 @@ export class DaemonSessionClient { return this.client.sessionWorkflowTasks(this.sessionId, this.clientId); } + savedWorkflow(name: string): Promise { + return this.client.sessionSavedWorkflow( + this.sessionId, + name, + this.clientId, + ); + } + lspStatus(): Promise { return this.client.sessionLspStatus(this.sessionId, this.clientId); } diff --git a/packages/sdk-typescript/src/daemon/acpRouteTable.ts b/packages/sdk-typescript/src/daemon/acpRouteTable.ts index 979676c783f..275678c1c2d 100644 --- a/packages/sdk-typescript/src/daemon/acpRouteTable.ts +++ b/packages/sdk-typescript/src/daemon/acpRouteTable.ts @@ -481,6 +481,15 @@ export const ROUTE_TABLE: readonly RouteEntry[] = [ extractParams: (segs) => ({ sessionId: segs[0] }), }, }, + // GET /session/:id/saved-workflows/:name -> _qwen/session/saved_workflow + { + httpMethod: 'GET', + pattern: /^\/session\/([^/]+)\/saved-workflows\/([^/]+)$/, + mapping: { + method: '_qwen/session/saved_workflow', + extractParams: (segs) => ({ sessionId: segs[0], name: segs[1] }), + }, + }, // ---- Granular workspace routes (_qwen/workspace/*) --------------------- diff --git a/packages/sdk-typescript/src/daemon/index.ts b/packages/sdk-typescript/src/daemon/index.ts index 31e8c5a2c76..5bdae3255ca 100644 --- a/packages/sdk-typescript/src/daemon/index.ts +++ b/packages/sdk-typescript/src/daemon/index.ts @@ -638,6 +638,9 @@ export type { DaemonSessionTaskWithWorkflowStatus, DaemonSessionTasksStatus, DaemonSessionWorkflowTasksStatus, + DaemonSavedWorkflowMeta, + DaemonSessionSavedWorkflowDetail, + DaemonSessionSavedWorkflowStatus, DaemonSessionStatsStatus, DaemonSessionStatsModelMetrics, DaemonSessionStatsSource, diff --git a/packages/sdk-typescript/src/daemon/types.ts b/packages/sdk-typescript/src/daemon/types.ts index bb1f8e15725..ed7757b09a3 100644 --- a/packages/sdk-typescript/src/daemon/types.ts +++ b/packages/sdk-typescript/src/daemon/types.ts @@ -2636,6 +2636,42 @@ export interface DaemonSessionSupportedCommandsStatus { }>; } +/** Parsed `export const meta` contract of a saved workflow script. */ +export interface DaemonSavedWorkflowMeta { + name: string; + description: string; + whenToUse?: string; + phases?: Array<{ title: string; detail?: string; model?: string }>; +} + +/** One saved workflow definition, resolved and read for display. */ +export interface DaemonSessionSavedWorkflowDetail { + v: 1; + sessionId: string; + name: string; + source: 'project' | 'user'; + /** Absolute path of the `.js` file the definition was read from. */ + scriptPath: string; + /** Full script source, `export const meta` included. */ + script: string; + /** Parsed meta block, or null when the script declares none or it is malformed. */ + meta: DaemonSavedWorkflowMeta | null; + /** Why `meta` is null although a meta block is present. */ + metaError?: string; +} + +/** + * Response for `GET /session/:id/saved-workflows/:name`. `workflow` is null + * when the name is unknown or Workflow controls are unavailable for the + * session (untrusted workspace) — the same shape on every transport. + */ +export interface DaemonSessionSavedWorkflowStatus { + v: 1; + sessionId: string; + name: string; + workflow: DaemonSessionSavedWorkflowDetail | null; +} + export type DaemonSessionTaskLifecycleStatus = | 'running' | 'paused' @@ -2808,6 +2844,8 @@ export interface DaemonSessionWorkflowTaskStatus { id: string; /** Tool call in the parent session that launched this workflow. */ toolUseId?: string; + /** Saved workflow definition name, when this run came from one. */ + workflowName?: string; /** Restored from the project snapshot store; controls are read-only. */ isHistorical?: boolean; sourceRunId?: string; diff --git a/packages/sdk-typescript/src/index.ts b/packages/sdk-typescript/src/index.ts index bd18e851697..90c6335ae18 100644 --- a/packages/sdk-typescript/src/index.ts +++ b/packages/sdk-typescript/src/index.ts @@ -255,6 +255,9 @@ export { type DaemonSessionTaskWithWorkflowStatus, type DaemonSessionTasksStatus, type DaemonSessionWorkflowTasksStatus, + type DaemonSavedWorkflowMeta, + type DaemonSessionSavedWorkflowDetail, + type DaemonSessionSavedWorkflowStatus, type DaemonSkillLevel, type DaemonPreflightCell, type DaemonPreflightKind, diff --git a/packages/sdk-typescript/test/unit/DaemonClient.test.ts b/packages/sdk-typescript/test/unit/DaemonClient.test.ts index 9e1eeaa5dd2..fec567ed688 100644 --- a/packages/sdk-typescript/test/unit/DaemonClient.test.ts +++ b/packages/sdk-typescript/test/unit/DaemonClient.test.ts @@ -1996,6 +1996,41 @@ describe('DaemonClient', () => { ]); }); + it('GETs a saved workflow definition with encoded ids', async () => { + const status = { + v: 1 as const, + sessionId: 'with/slash', + name: 'deep review', + workflow: { + v: 1 as const, + sessionId: 'with/slash', + name: 'deep review', + source: 'project' as const, + scriptPath: '/work/a/.qwen/workflows/deep review.js', + script: 'export const meta = { name: "deep review" }', + meta: null, + metaError: 'missing description', + }, + }; + const { fetch, calls } = recordingFetch((req) => + req.url.endsWith('/session/with%2Fslash/saved-workflows/deep%20review') + ? jsonResponse(200, status) + : jsonResponse(500, { error: `unexpected ${req.url}` }), + ); + const client = new DaemonClient({ baseUrl: 'http://daemon', fetch }); + + await expect( + client.sessionSavedWorkflow('with/slash', 'deep review', 'client-1'), + ).resolves.toEqual(status); + expect(calls.map((c) => [c.method, c.url])).toEqual([ + [ + 'GET', + 'http://daemon/session/with%2Fslash/saved-workflows/deep%20review', + ], + ]); + expect(calls[0]?.headers['x-qwen-client-id']).toBe('client-1'); + }); + it('GETs session status routes with encoded session ids', async () => { const context: DaemonSessionContextStatus = { v: 1, diff --git a/packages/sdk-typescript/test/unit/DaemonSessionClient.test.ts b/packages/sdk-typescript/test/unit/DaemonSessionClient.test.ts index aef3dd6194f..3a7929c2455 100644 --- a/packages/sdk-typescript/test/unit/DaemonSessionClient.test.ts +++ b/packages/sdk-typescript/test/unit/DaemonSessionClient.test.ts @@ -159,6 +159,36 @@ function turnCompleteFrame(promptId: string): string { } describe('DaemonSessionClient', () => { + it('reads a saved workflow definition for its own session', async () => { + const status = { + v: 1 as const, + sessionId: 's-1', + name: 'deep-review', + workflow: null, + }; + const { fetch, calls } = recordingFetch((req) => + req.url.endsWith('/session/s-1/saved-workflows/deep-review') + ? jsonResponse(200, status) + : jsonResponse(500, { error: `unexpected ${req.url}` }), + ); + const client = new DaemonClient({ baseUrl: 'http://daemon', fetch }); + const session = new DaemonSessionClient({ + client, + session: { + sessionId: 's-1', + workspaceCwd: '/work/a', + attached: true, + clientId: 'client-1', + }, + }); + + await expect(session.savedWorkflow('deep-review')).resolves.toEqual(status); + expect(calls.map((c) => c.url)).toEqual([ + 'http://daemon/session/s-1/saved-workflows/deep-review', + ]); + expect(calls[0]?.headers['x-qwen-client-id']).toBe('client-1'); + }); + it('binds Goal reads and controls to the session and client identity', async () => { const { fetch, calls } = recordingFetch(() => jsonResponse(200, { snapshot: GOAL_SNAPSHOT }), diff --git a/packages/sdk-typescript/test/unit/acpRouteTable.test.ts b/packages/sdk-typescript/test/unit/acpRouteTable.test.ts index 3438e3d8897..7a185a8fdf3 100644 --- a/packages/sdk-typescript/test/unit/acpRouteTable.test.ts +++ b/packages/sdk-typescript/test/unit/acpRouteTable.test.ts @@ -516,6 +516,21 @@ describe('acpRouteTable – matchRoute', () => { expect(params).toEqual({ sessionId: 's18' }); }); + it('GET /session/:id/saved-workflows/:name maps to _qwen/session/saved_workflow', () => { + const result = matchRoute( + '/session/s19/saved-workflows/deep%20review', + 'GET', + ); + expect(result).not.toBeNull(); + expect(result!.mapping.method).toBe('_qwen/session/saved_workflow'); + const params = result!.mapping.extractParams( + result!.segments, + undefined, + 'GET', + ); + expect(params).toEqual({ sessionId: 's19', name: 'deep review' }); + }); + // ---- Granular workspace routes ---------------------------------------- it('GET /workspace/mcp maps to _qwen/workspace/mcp', () => { diff --git a/packages/web-shell/client/App.test.tsx b/packages/web-shell/client/App.test.tsx index 38473aa6d01..cfab7aec22f 100644 --- a/packages/web-shell/client/App.test.tsx +++ b/packages/web-shell/client/App.test.tsx @@ -64,6 +64,7 @@ type MockConnection = { }>; commands: unknown[]; skills: string[] | undefined; + supportedCommands?: { workflowsEnabled?: boolean }; capabilities: { qwenCodeVersion: string; features: string[] }; loadingTranscript: boolean; catchingUp: boolean; @@ -366,6 +367,12 @@ const { now: 1, tasks: [], }), + getWorkflowTasks: vi.fn().mockResolvedValue({ + v: 1, + sessionId: 'session-1', + now: 1, + tasks: [], + }), loadArtifacts: vi.fn().mockResolvedValue({ artifacts: [] }), loadSession: vi.fn().mockResolvedValue(undefined), reloadSession: vi.fn().mockResolvedValue(undefined), @@ -537,6 +544,8 @@ const { latestTasksStatusProps: null as { planTodos?: Array<{ id: string }>; agentTools?: Array<{ callId: string }>; + includeWorkflows?: boolean; + onWorkflowRunStarted?: () => void; onOpenMonitor?: (task: DaemonSessionMonitorTaskStatus) => void; } | null, settings: [] as DaemonSettingDescriptor[], @@ -596,6 +605,10 @@ const { onCreateGoal?: (condition: string) => Promise; onOpenSession?: (sessionId: string) => void; } | null, + latestWorkflowRunsProps: null as { + onCreateViaChat?: () => void; + onWorkflowRunStarted?: () => void; + } | null, }, rawEnqueuePrompt: vi.fn(() => true), queuedTexts: [] as string[], @@ -754,6 +767,7 @@ vi.mock('./hooks/useBackgroundTasks', () => ({ useBackgroundTasks: ( _sessionId: string | undefined, _taskActivityKey: string, + _taskActivityActive: boolean, _connected: boolean, refreshTrigger = 0, ) => { @@ -1928,6 +1942,20 @@ vi.doMock('./components/dialogs/GoalsDialog', async () => { }, }; }); +vi.doMock('./components/workflows/WorkflowRunsPage', async () => { + const React = await import('react'); + return { + WorkflowRunsPage: (props: { + onCreateViaChat?: () => void; + onWorkflowRunStarted?: () => void; + }) => { + testState.latestWorkflowRunsProps = props; + return React.createElement('div', { + 'data-testid': 'workflow-runs-content', + }); + }, + }; +}); vi.doMock('./components/extensions/ExtensionsManagerPage', async () => { const React = await import('react'); return { @@ -2014,6 +2042,8 @@ vi.doMock('./components/messages/TasksStatusMessage', async () => { TasksStatusMessage: (props: { planTodos?: Array<{ id: string }>; agentTools?: Array<{ callId: string }>; + includeWorkflows?: boolean; + onWorkflowRunStarted?: () => void; onOpenMonitor?: (task: DaemonSessionMonitorTaskStatus) => void; }) => { testState.latestTasksStatusProps = props; @@ -2146,7 +2176,7 @@ describe('mergeSideTaskCatalog', () => { }); describe('task activity key', () => { - it('includes background shells in any tool-call state', () => { + it('includes task-bearing tool calls in any state', () => { const messages = [ { id: 'tools', @@ -2197,12 +2227,18 @@ describe('task activity key', () => { status: 'completed', args: { command: 'npm run dev --watch' }, }, + { + callId: 'workflow-call', + toolName: 'workflow', + status: 'in_progress', + args: {}, + }, ], }, ] satisfies Message[]; expect(getTaskActivityKey(messages)).toBe( - 'shell-call:in_progress|agent-call:pending|nested-shell:completed|completed-shell:completed|promoted-shell:completed|monitor-call:completed', + 'shell-call:in_progress|agent-call:pending|nested-shell:completed|completed-shell:completed|promoted-shell:completed|monitor-call:completed|workflow-call:in_progress', ); }); @@ -5561,6 +5597,7 @@ beforeEach(() => { mockConnection.sessionContext = undefined; mockConnection.commands = []; mockConnection.skills = []; + mockConnection.supportedCommands = undefined; mockConnection.loadingTranscript = false; mockConnection.catchingUp = false; mockConnection.capabilities = { @@ -5696,6 +5733,7 @@ beforeEach(() => { testState.latestModelManagement = null; testState.latestScheduledTasksProps = null; testState.latestGoalsProps = null; + testState.latestWorkflowRunsProps = null; testState.latestSplitViewProps = null; rawEnqueuePrompt.mockClear(); editorClear.mockClear(); @@ -5787,6 +5825,12 @@ beforeEach(() => { now: 1, tasks: [], }); + mockSessionActions.getWorkflowTasks.mockResolvedValue({ + v: 1, + sessionId: 'session-1', + now: 1, + tasks: [], + }); mockSessionActions.loadSession.mockResolvedValue(undefined); mockStore.reset.mockClear(); mockStore.getSnapshot.mockClear(); @@ -22933,6 +22977,72 @@ describe('App session callbacks', () => { expect(loadSplitSessions()).toEqual([]); }); + it('does not let stale split classification replace workflows', async () => { + const classification = deferred(); + mockWorkspace.capabilities = { + features: ['standalone_sessions_v1'], + workspaces: [ + { + id: 'primary', + cwd: '/tmp/project', + primary: true, + trusted: true, + workflowsEnabled: true, + }, + ], + } as typeof mockWorkspace.capabilities; + mockConnection.supportedCommands = { workflowsEnabled: true }; + mockWorkspace.client.getStandaloneSession.mockReturnValueOnce( + classification.promise, + ); + const { container } = renderApp(); + await flush(); + + await act(async () => { + container + .querySelector( + '[data-testid="open-sessions-overview"]', + ) + ?.click(); + await Promise.resolve(); + }); + await act(async () => { + testState.latestSessionOverviewProps?.onOpenSplit?.(['older-session']); + await vi.waitFor(() => { + expect(mockWorkspace.client.getStandaloneSession).toHaveBeenCalledWith( + 'older-session', + ); + }); + }); + + await act(async () => { + testState.latestChatEditorProps?.onSubmit('/workflows'); + await Promise.resolve(); + }); + await flush(); + expect( + container.querySelector('[data-testid="workflow-runs-page"]'), + ).not.toBeNull(); + + await act(async () => { + classification.reject( + new DaemonHttpError( + 404, + { code: 'standalone_session_not_found' }, + 'not found', + ), + ); + await Promise.resolve(); + }); + + expect( + container.querySelector('[data-testid="split-view-page"]'), + ).toBeNull(); + expect( + container.querySelector('[data-testid="workflow-runs-page"]'), + ).not.toBeNull(); + }); + it('does not let stale split classification replace a sidebar session load', async () => { const classification = deferred(); mockWorkspace.capabilities = { @@ -29562,6 +29672,201 @@ describe('App /goal command', () => { }); }); +describe('App workflow history entry', () => { + it('keeps an opened tasks panel on the workflow-aware endpoint', async () => { + mockConnection.supportedCommands = { workflowsEnabled: true }; + renderApp(); + await flush(); + + await act(async () => { + testState.latestStatusBarOnOpenTasks?.(); + await Promise.resolve(); + }); + await flush(); + + expect(testState.latestTasksStatusProps?.includeWorkflows).toBe(true); + }); + + it('refreshes background-task polling when a task-dialog workflow starts', async () => { + mockConnection.supportedCommands = { workflowsEnabled: true }; + renderApp(); + await flush(); + + await act(async () => { + testState.latestStatusBarOnOpenTasks?.(); + await Promise.resolve(); + }); + await flush(); + + expect(testState.latestBackgroundTasksRefreshTrigger).toBe(0); + act(() => testState.latestTasksStatusProps?.onWorkflowRunStarted?.()); + expect(testState.latestBackgroundTasksRefreshTrigger).toBe(1); + }); + + it('refreshes background-task polling when a saved workflow starts', async () => { + mockConnection.supportedCommands = { workflowsEnabled: true }; + const { container } = renderApp(); + await flush(); + + testState.prompt = '/workflows'; + await clickSubmit(container); + await flush(); + + expect(testState.latestBackgroundTasksRefreshTrigger).toBe(0); + act(() => testState.latestWorkflowRunsProps?.onWorkflowRunStarted?.()); + expect(testState.latestBackgroundTasksRefreshTrigger).toBe(1); + }); + + it('starts a fresh workflow-creation chat from the runs page', async () => { + mockConnection.supportedCommands = { workflowsEnabled: true }; + const { container } = renderApp(); + await flush(); + + testState.prompt = '/workflows'; + await clickSubmit(container); + await flush(); + + const onCreateViaChat = testState.latestWorkflowRunsProps?.onCreateViaChat; + if (!onCreateViaChat) throw new Error('onCreateViaChat was not captured'); + mockSessionActions.clearSession.mockClear(); + editorInsertText.mockClear(); + + await act(async () => onCreateViaChat()); + await flush(); + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + + expect(mockSessionActions.clearSession).toHaveBeenCalledTimes(1); + expect(editorInsertText).toHaveBeenCalledWith('/workflow-creator ', { + mode: 'replace', + }); + }); + + it('does not prime the current chat when workflow session creation fails', async () => { + mockConnection.supportedCommands = { workflowsEnabled: true }; + const { container } = renderApp(); + await flush(); + + testState.prompt = '/workflows'; + await clickSubmit(container); + await flush(); + + const onCreateViaChat = testState.latestWorkflowRunsProps?.onCreateViaChat; + if (!onCreateViaChat) throw new Error('onCreateViaChat was not captured'); + mockSessionActions.clearSession.mockRejectedValueOnce(new Error('boom')); + editorInsertText.mockClear(); + + await act(async () => onCreateViaChat()); + await flush(); + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + + expect(editorInsertText).not.toHaveBeenCalled(); + }); + + it('opens workflows from a session-less enabled workspace', async () => { + mockConnection.sessionId = undefined; + mockWorkspace.capabilities = { + ...mockWorkspace.capabilities, + workspaces: [ + { + id: 'primary', + cwd: '/workspace', + primary: true, + workflowsEnabled: true, + }, + ], + } as typeof mockWorkspace.capabilities; + mockConnection.supportedCommands = { workflowsEnabled: false }; + + const { container, rerender } = renderApp(); + await flush(); + + testState.prompt = '/workflows'; + await clickSubmit(container); + await flush(); + expect( + container.querySelector('[data-testid="workflow-runs-page"]'), + ).not.toBeNull(); + expect(mockSessionActions.sendPrompt).not.toHaveBeenCalled(); + + mockConnection.sessionId = 'session-1'; + mockConnection.workspaceCwd = '/workspace'; + mockConnection.supportedCommands = { workflowsEnabled: false }; + rerender(); + await flush(); + expect( + container.querySelector('[data-testid="workflow-runs-page"]'), + ).toBeNull(); + }); + + it('opens the workflow runs page for a bare /workflows command', async () => { + mockConnection.supportedCommands = { workflowsEnabled: true }; + const { container, rerender } = renderApp(); + await flush(); + + testState.prompt = '/workflows'; + await clickSubmit(container); + await flush(); + + expect( + container.querySelector('[data-testid="workflow-runs-page"]'), + ).not.toBeNull(); + expect( + container.querySelector('[data-testid="workflow-runs-content"]'), + ).not.toBeNull(); + expect(mockSessionActions.sendPrompt).not.toHaveBeenCalled(); + + mockConnection.supportedCommands = { workflowsEnabled: false }; + rerender(); + await flush(); + expect( + container.querySelector('[data-testid="workflow-runs-page"]'), + ).toBeNull(); + }); + + it('forwards /workflows in a non-workspace session', async () => { + mockConnection.sessionContext = { kind: 'standalone' }; + mockConnection.supportedCommands = { workflowsEnabled: true }; + const { container } = renderApp(); + await flush(); + + testState.prompt = '/workflows'; + await clickSubmit(container); + await flush(); + + expect( + container.querySelector('[data-testid="workflow-runs-page"]'), + ).toBeNull(); + expect(mockSessionActions.sendPrompt).toHaveBeenCalledWith( + '/workflows', + expect.any(Object), + ); + }); + + it('does not expose the local workflow page when workflows are disabled', async () => { + const { container } = renderApp(); + await flush(); + + expect( + container.querySelector('button[aria-label="Workflows"]'), + ).toBeNull(); + testState.prompt = '/workflows'; + await clickSubmit(container); + await flush(); + + expect( + container.querySelector('[data-testid="workflow-runs-page"]'), + ).toBeNull(); + expect(mockSessionActions.sendPrompt).toHaveBeenCalledWith( + '/workflows', + expect.any(Object), + ); + }); +}); + describe('App manual-run orchestration (scheduled tasks)', () => { // Drives App's real runTaskManually / enqueueManualRun / tryFireBoundRun via // the onRunPrompt prop the (captured) ScheduledTasksDialog mock receives. diff --git a/packages/web-shell/client/App.tsx b/packages/web-shell/client/App.tsx index a4aeba00b34..f8f8260834e 100644 --- a/packages/web-shell/client/App.tsx +++ b/packages/web-shell/client/App.tsx @@ -51,7 +51,7 @@ import type { DaemonTranscriptBlock, DaemonSessionMonitorTaskStatus, DaemonSessionShellTaskStatus, - DaemonSessionTaskStatus, + DaemonSessionTaskWithWorkflowStatus, DaemonSessionArtifact, DaemonSessionSummary, DaemonStandaloneCreationRecovery, @@ -80,7 +80,13 @@ import { isRetryableTurnErrorKind } from './adapters/transcriptToMessages'; import { MessageList, type MessageListHandle } from './components/MessageList'; import { SubagentDetailsProvider } from './subagentDetailsContext'; import { MonitorDetailsProvider } from './monitorDetailsContext'; +import { WorkflowDetailsProvider } from './workflowDetailsContext'; import { findMonitorTaskForTool } from './utils/monitorTasks'; +import { isComposerTask } from './utils/composerTasks'; +import { + getTaskActivityKey, + hasActiveTaskActivity, +} from './utils/taskActivity'; import { extractVoiceModels, type VoiceModelOption } from './voice/voiceModels'; import { loadVoiceProviders, @@ -187,6 +193,7 @@ import { } from './utils/splitUrl'; import { ScheduledTasksDialog } from './components/dialogs/ScheduledTasksDialog'; import { GoalsDialog } from './components/dialogs/GoalsDialog'; +import { WorkflowRunsPage } from './components/workflows/WorkflowRunsPage'; import { parseWebShellGoalCommand } from './utils/goalCondition'; import { buildGoalControlRequest } from './utils/goalControlRequest'; import { ExtensionsManagerPage } from './components/extensions/ExtensionsManagerPage'; @@ -309,10 +316,7 @@ import type { Message, PermissionRequest, } from './adapters/types'; -import { - backgroundShellTaskId, - isBackgroundSubAgentToolCall, -} from './adapters/toolClassification'; +import { isBackgroundSubAgentToolCall } from './adapters/toolClassification'; import { computeTodoDetails, computeTodoTimeline, @@ -1401,7 +1405,13 @@ function imageTabId(src: string): string { } type ChatWidthMode = `${typeof DEFAULT_CHAT_MAX_WIDTH}` | 'wide'; -type MainView = 'chat' | 'cockpit' | 'scheduledTasks' | 'goals' | 'split'; +type MainView = + | 'chat' + | 'cockpit' + | 'scheduledTasks' + | 'workflows' + | 'goals' + | 'split'; const CHAT_WIDTH_STORAGE_KEY = 'qwen-code-web-shell-chat-width'; const CHAT_SHELL_HORIZONTAL_PADDING = 40; @@ -1674,38 +1684,7 @@ function parseRenameArgument( return { type: 'manual', displayName: trimmed }; } -function isBackgroundTaskToolCall(tool: ACPToolCall): boolean { - const name = tool.toolName.toLowerCase(); - if (name === 'monitor') return true; - if (backgroundShellTaskId(tool) !== undefined) return true; - if (tool.args?.is_background !== true) return false; - return ( - name === 'shell' || - name === 'bash' || - name === 'run_shell_command' || - name === 'exec' - ); -} - -export function getTaskActivityKey(messages: readonly Message[]): string { - const parts: string[] = []; - const visit = (tools: readonly ACPToolCall[]) => { - for (const tool of tools) { - if ( - isBackgroundTaskToolCall(tool) || - isBackgroundSubAgentToolCall(tool) - ) { - parts.push(`${tool.callId}:${tool.status}`); - } - if (tool.subTools) visit(tool.subTools); - } - }; - for (const message of messages) { - if (message.role !== 'tool_group') continue; - visit(message.tools); - } - return parts.join('|'); -} +export { getTaskActivityKey } from './utils/taskActivity'; export function mergeMonitorTaskSnapshot( current: DaemonSessionMonitorTaskStatus, @@ -1827,7 +1806,7 @@ function derivedTaskIdForTool(tool: ACPToolCall): string | undefined { export function getEnvironmentAgentTasks( messages: readonly Message[], - sessionTasks: readonly DaemonSessionTaskStatus[], + sessionTasks: readonly DaemonSessionTaskWithWorkflowStatus[], ): EnvironmentAgentTask[] { const liveAgents = sessionTasks.filter( (task): task is DaemonSessionAgentTaskStatus => task.kind === 'agent', @@ -2021,7 +2000,7 @@ function findToolCall( } function mapToWebShellTaskInfo( - task: DaemonSessionTaskStatus, + task: DaemonSessionTaskWithWorkflowStatus, ): WebShellTaskInfo { const base = { id: task.id, @@ -2062,6 +2041,17 @@ function mapToWebShellTaskInfo( pid: task.pid, exitCode: task.exitCode, }; + case 'workflow': + return { + ...base, + kind: 'workflow', + status: task.status, + currentPhase: task.currentPhase ?? undefined, + agentsDispatched: task.agentsDispatched, + agentsCompleted: task.agentsCompleted, + tokensSpent: task.tokensSpent, + tokenBudgetTotal: task.tokenBudgetTotal ?? undefined, + }; default: return task satisfies never; } @@ -2977,6 +2967,13 @@ export function App({ resolveWorkspaceMaintenanceTargetCwd, workspaceContextActive, ]); + const workspaceWorkflowsEnabled = + workspaces.find((entry) => entry.cwd === activeWorkspaceCwd) + ?.workflowsEnabled ?? false; + const workflowsEnabled = connection.sessionId + ? (connection.supportedCommands?.workflowsEnabled ?? + workspaceWorkflowsEnabled) + : workspaceWorkflowsEnabled; // Worktree sessions query git status with the worktree path (?cwd= // parameter); the chip prefers the live branch from that status, falling // back to the creation-time sessionWorktree.branch. @@ -4727,13 +4724,21 @@ export function App({ () => getTaskActivityKey(messages), [messages], ); + // The activity fact travels beside the key, derived structurally — the + // key itself is not parseable back (callId is unconstrained text). + const taskActivityActive = useMemo( + () => hasActiveTaskActivity(messages, { workflowsEnabled }), + [messages, workflowsEnabled], + ); const [backgroundTasksRefreshTrigger, setBackgroundTasksRefreshTrigger] = useState(0); const sessionTasks = useBackgroundTasks( connection.sessionId, taskActivityKey, + taskActivityActive, connection.status === 'connected', backgroundTasksRefreshTrigger, + workflowsEnabled, ); const terminalBackgroundShellTaskIdsKey = useMemo( () => @@ -4754,7 +4759,9 @@ export function App({ [terminalBackgroundShellTaskIdsKey], ); const backgroundTasks = useMemo( - () => sessionTasks.filter((task) => task.kind !== 'agent'), + // Same rule as the status-bar pill: live background state only, never + // the project's retained workflow history. + () => sessionTasks.filter(isComposerTask), [sessionTasks], ); const monitorDetailsSessionIdRef = useRef(connection.sessionId); @@ -5893,6 +5900,21 @@ export function App({ showChat(); setMainView('scheduledTasks'); }, [showChat]); + const openWorkflows = useCallback(() => { + if (!workspaceContextActive || !workflowsEnabled) return; + splitClassificationGenerationRef.current += 1; + setActivePanel(null); + showChat(); + setMainView('workflows'); + }, [showChat, workflowsEnabled, workspaceContextActive]); + useEffect(() => { + if ( + mainView === 'workflows' && + (!workspaceContextActive || !workflowsEnabled) + ) { + showChat(); + } + }, [mainView, showChat, workflowsEnabled, workspaceContextActive]); const openGoals = useCallback(() => { splitClassificationGenerationRef.current += 1; setActivePanel(null); @@ -6321,9 +6343,12 @@ export function App({ // owns and renders its own session's approval, so an approval on the (outer) // main session must not yank the user out of the panes they are working in. if (cockpitViewRequested()) updateCockpitLocation(false, true); - if (mainView === 'cockpit') { - setMainView('chat'); - } else if (mainView === 'scheduledTasks' || mainView === 'goals') { + if ( + mainView === 'cockpit' || + mainView === 'scheduledTasks' || + mainView === 'workflows' || + mainView === 'goals' + ) { setMainView('chat'); } }, [ @@ -10646,8 +10671,10 @@ export function App({ const openTasksPanel = useCallback(() => { if (!requireActiveSessionForLocalCommand()) return; const owner = sessionOwnerGuard.capture(); - sessionActions - .getTasks() + const request = workflowsEnabled + ? sessionActions.getWorkflowTasks() + : sessionActions.getTasks(); + request .then((snapshot) => { if (!owner.isCurrent()) return; setTasksDialogMessage({ snapshot }); @@ -10662,6 +10689,7 @@ export function App({ requireActiveSessionForLocalCommand, sessionActions, sessionOwnerGuard, + workflowsEnabled, ]); const openCockpit = useCallback((): boolean => { if (!sessionWorkflowEnabled || approvalOverlayActive) return false; @@ -10817,7 +10845,7 @@ export function App({ setBackgroundTasksRefreshTrigger((value) => value + 1); }, [requireActiveSessionForLocalCommand]); const openEnvironmentTask = useCallback( - (task: DaemonSessionTaskStatus) => { + (task: DaemonSessionTaskWithWorkflowStatus) => { if (task.kind === 'monitor' || task.kind === 'shell') { if (!artifactPanelOpenRef.current) { preserveEnvironmentPanelOnArtifactOpenRef.current = true; @@ -11385,6 +11413,15 @@ export function App({ openEnvironmentTasksPanel(); return true; } + if ( + cmd === 'workflows' && + workspaceContextActive && + workflowsEnabled && + text.slice(match[0].length).trim().length === 0 + ) { + openWorkflows(); + return true; + } if (cmd === 'goal') { return handleGoalSlashCommand( text, @@ -12329,6 +12366,8 @@ export function App({ closeMobileDrawer, openPanel, openScheduledTasks, + openWorkflows, + workflowsEnabled, createNewSession, ensureSessionForPrompt, finishPromptPreparation, @@ -13872,6 +13911,10 @@ export function App({ message={tasksDialogMessage} embedded manageActiveEvent={false} + includeWorkflows={workflowsEnabled} + onWorkflowRunStarted={() => + setBackgroundTasksRefreshTrigger((value) => value + 1) + } onClose={() => setTasksDialogMessage(null)} planTodos={sessionWorkflowEnabled ? floatingTodos : []} agentTools={ @@ -14150,6 +14193,10 @@ export function App({ closeMobileDrawer(); openScheduledTasks(); }} + onOpenWorkflows={() => { + closeMobileDrawer(); + openWorkflows(); + }} onOpenGoals={() => { closeMobileDrawer(); openGoals(); @@ -14889,6 +14936,67 @@ export function App({ )} + {workspaceContextActive && + mainView === 'workflows' && + workflowsEnabled && ( +
+
+ +
+ {t('workflowRuns.title')} +
+
+
+ + setBackgroundTasksRefreshTrigger((value) => value + 1) + } + onCreateViaChat={() => { + const targetCwd = + resolveWorkspaceMaintenanceTargetCwd(); + if (!targetCwd) return; + void createNewSession({ + kind: 'workspace', + cwd: targetCwd, + }).then((created) => { + if (!created) return; + onSessionIdChange?.(undefined); + window.setTimeout(() => { + editorRef.current?.insertText( + '/workflow-creator ', + { mode: 'replace' }, + ); + editorRef.current?.focus(); + }, 0); + }); + }} + /> +
+
+ )} {workspaceContextActive && mainView === 'goals' && (
@@ -15273,11 +15381,16 @@ export function App({ } /> ); + const messageListWithWorkflowDetails = ( + + {messageListContent} + + ); const messageListWithSubagentDetails = ( - {messageListContent} + {messageListWithWorkflowDetails} ); const messageList = monitorDetailsSupported ? ( diff --git a/packages/web-shell/client/adapters/toolClassification.ts b/packages/web-shell/client/adapters/toolClassification.ts index d74bb1da6ea..b886b284fa1 100644 --- a/packages/web-shell/client/adapters/toolClassification.ts +++ b/packages/web-shell/client/adapters/toolClassification.ts @@ -42,6 +42,7 @@ export function isTaskExecutionRaw(raw: unknown): boolean { export function isSubAgentToolCall(tool: ACPToolCall): boolean { const name = tool.toolName.toLowerCase(); + if (name === 'workflow') return false; if (name === 'agent' || name === 'task') return true; if (tool.subTools || tool.subContent) return true; if (isTaskExecutionRaw(tool.rawOutput)) return true; diff --git a/packages/web-shell/client/components/ChatPane.test.tsx b/packages/web-shell/client/components/ChatPane.test.tsx index 7ca67a26c5c..9557b0adb64 100644 --- a/packages/web-shell/client/components/ChatPane.test.tsx +++ b/packages/web-shell/client/components/ChatPane.test.tsx @@ -66,6 +66,7 @@ const setModel = vi.fn(async () => ({}) as any); const setReasoningEffort = vi.fn(async () => {}); const loadArtifacts = vi.fn(async () => ({ artifacts: [] })); const getTasks = vi.fn(); +const getWorkflowTasks = vi.fn(); const getGoal = vi.fn(); const controlGoal = vi.fn(); const readAttachment = vi.fn(); @@ -79,6 +80,7 @@ const daemonActions = { setReasoningEffort, loadArtifacts, getTasks, + getWorkflowTasks, getGoal, controlGoal, readAttachment, @@ -430,6 +432,7 @@ beforeEach(() => { loadArtifacts.mockReset(); loadArtifacts.mockResolvedValue({ artifacts: [] }); getTasks.mockReset(); + getWorkflowTasks.mockReset(); getGoal.mockReset(); controlGoal.mockReset(); readAttachment.mockReset(); @@ -521,6 +524,36 @@ function deferred() { } describe('ChatPane', () => { + it('polls workflow tasks from the daemon capability, not the UI setting', async () => { + connectionState.supportedCommands = { workflowsEnabled: true }; + messagesState = [ + { + id: 'workflow-group', + role: 'tool_group', + tools: [ + { + callId: 'workflow-call', + toolName: 'workflow', + status: 'in_progress', + args: {}, + }, + ], + }, + ]; + getWorkflowTasks.mockResolvedValue({ + v: 1, + sessionId: 'sess-1', + now: 1_000, + tasks: [], + }); + + render({ sessionWorkflowEnabled: false }); + await act(async () => Promise.resolve()); + + expect(getWorkflowTasks).toHaveBeenCalledWith({ silent: true }); + expect(getTasks).not.toHaveBeenCalled(); + }); + it.each([ [ 'images', diff --git a/packages/web-shell/client/components/ChatPane.tsx b/packages/web-shell/client/components/ChatPane.tsx index 171bcfde503..faf06aa52a1 100644 --- a/packages/web-shell/client/components/ChatPane.tsx +++ b/packages/web-shell/client/components/ChatPane.tsx @@ -33,6 +33,7 @@ import { import type { ACPToolCall } from '../adapters/types'; import { SubagentDetailsProvider } from '../subagentDetailsContext'; import { MonitorDetailsProvider } from '../monitorDetailsContext'; +import { WorkflowDetailsProvider } from '../workflowDetailsContext'; import { useI18n } from '../i18n'; import { useWebShellCustomization } from '../customization'; import { @@ -42,6 +43,7 @@ import { import { useAnimationFrameTranscriptSnapshot } from '../hooks/useAnimationFrameTranscriptBlocks'; import { useMessagesFromBlocks } from '../hooks/useMessages'; import { useSessionArtifacts } from '../hooks/useSessionArtifacts'; +import { useBackgroundTasks } from '../hooks/useBackgroundTasks'; import { extractPendingPermission } from '../adapters/transcriptAdapter'; import type { PromptFile, PromptImage } from '../adapters/promptTypes'; import type { AttachmentPreviewRequest } from '../adapters/messageTypes'; @@ -62,6 +64,10 @@ import { isExitPlanApprovalRequest, } from '../utils/todos'; import { findMonitorTaskForTool } from '../utils/monitorTasks'; +import { + getTaskActivityKey, + hasActiveTaskActivity, +} from '../utils/taskActivity'; import { invokeSlashCommandHandler } from '../utils/slash-command-action'; import { parseWebShellGoalCommand } from '../utils/goalCondition'; import { buildGoalControlRequest } from '../utils/goalControlRequest'; @@ -274,6 +280,27 @@ export function ChatPane({ sessionHasActivePromptRef.current = sessionHasActivePrompt; const { blocks, blockChangeSummary } = useAnimationFrameTranscriptSnapshot(); const messages = useMessagesFromBlocks(t, blocks, blockChangeSummary); + const taskActivityKey = useMemo( + () => getTaskActivityKey(messages), + [messages], + ); + const workflowsEnabled = + connection.supportedCommands?.workflowsEnabled === true; + // The activity fact travels beside the key, derived structurally — the + // key itself is not parseable back (callId is unconstrained text) — and + // gated on the endpoint the hook will actually poll. + const taskActivityActive = useMemo( + () => hasActiveTaskActivity(messages, { workflowsEnabled }), + [messages, workflowsEnabled], + ); + const sessionTasks = useBackgroundTasks( + connection.sessionId, + taskActivityKey, + taskActivityActive, + connection.status === 'connected', + 0, + workflowsEnabled, + ); const transcriptHistory = useTranscriptHistory(); const store = useTranscriptStore(); const streamingState = useStreamingState(); @@ -1315,49 +1342,53 @@ export function ChatPane({ onOpen={openMonitorDetails} > - + + +
diff --git a/packages/web-shell/client/components/StatusBar.tsx b/packages/web-shell/client/components/StatusBar.tsx index ffc01cdbc78..1150f8aa38b 100644 --- a/packages/web-shell/client/components/StatusBar.tsx +++ b/packages/web-shell/client/components/StatusBar.tsx @@ -5,7 +5,7 @@ import { useRef, type KeyboardEvent, } from 'react'; -import type { DaemonSessionTaskStatus } from '@qwen-code/sdk/daemon'; +import type { DaemonSessionTaskWithWorkflowStatus } from '@qwen-code/sdk/daemon'; import { useConnection } from '@qwen-code/web-shell/daemon-react-sdk'; import { useI18n } from '../i18n'; import { isComposerTask } from '../utils/composerTasks'; @@ -46,7 +46,7 @@ interface StatusBarProps { onOpenSettings: () => void; onOpenTasks?: () => void; onReturnToInput?: (text?: string) => void; - tasks: readonly DaemonSessionTaskStatus[]; + tasks: readonly DaemonSessionTaskWithWorkflowStatus[]; /** Hide the settings gear button (e.g. when /settings is in hiddenSlashCommands). */ hideSettings?: boolean; /** Toggle the keyboard-shortcuts panel (same as typing `?` in the editor). */ @@ -86,18 +86,24 @@ function formatCount( } export function getTaskPillLabel( - tasks: readonly DaemonSessionTaskStatus[], + tasks: readonly DaemonSessionTaskWithWorkflowStatus[], t: ReturnType['t'], ): string { const composerTasks = tasks.filter(isComposerTask); if (composerTasks.length === 0) return ''; - const running = composerTasks.filter((task) => task.status === 'running'); + const running = composerTasks.filter( + (task) => + task.status === 'running' || + task.status === 'pausing' || + task.status === 'paused', + ); if (running.length > 0) { - const counts = { shell: 0, monitor: 0 }; + const counts = { shell: 0, monitor: 0, workflow: 0 }; for (const task of running) { if (task.kind === 'shell') counts.shell += 1; if (task.kind === 'monitor') counts.monitor += 1; + if (task.kind === 'workflow') counts.workflow += 1; } const parts: string[] = []; if (counts.shell > 0) { @@ -115,6 +121,16 @@ export function getTaskPillLabel( ), ); } + if (counts.workflow > 0) { + parts.push( + formatCount( + counts.workflow, + 'tasks.pill.workflow', + 'tasks.pill.workflows', + t, + ), + ); + } return parts.join(', '); } diff --git a/packages/web-shell/client/components/StatusBar.workflow.test.tsx b/packages/web-shell/client/components/StatusBar.workflow.test.tsx new file mode 100644 index 00000000000..c3371cefae5 --- /dev/null +++ b/packages/web-shell/client/components/StatusBar.workflow.test.tsx @@ -0,0 +1,82 @@ +// @vitest-environment jsdom +import { act } from 'react'; +import { createRoot } from 'react-dom/client'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import type { DaemonSessionWorkflowTaskStatus } from '@qwen-code/sdk/daemon'; +import { I18nProvider } from '../i18n'; + +vi.mock('@qwen-code/web-shell/daemon-react-sdk', () => ({ + useConnection: () => ({ + status: 'connected', + currentMode: 'default', + currentModel: 'qwen', + tokenCount: 0, + contextWindow: 0, + }), +})); + +const { StatusBar } = await import('./StatusBar'); + +Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); + +let container: HTMLDivElement | null = null; +let root: ReturnType | null = null; + +afterEach(() => { + act(() => root?.unmount()); + container?.remove(); + root = null; + container = null; +}); + +function workflowTask( + status: 'pausing' | 'paused', +): DaemonSessionWorkflowTaskStatus { + return { + kind: 'workflow', + id: 'workflow-1', + label: 'review-and-fix', + description: 'Review and fix', + status, + startTime: 1_000, + runtimeMs: 500, + isBackgrounded: true, + currentPhase: 'Review', + phaseVisits: [], + dispatches: [], + agentsDispatched: 2, + agentsCompleted: 1, + tokensSpent: 100, + tokenBudgetTotal: null, + recentLogs: [], + pendingApprovalCount: 0, + }; +} + +describe('StatusBar workflow task pill', () => { + it.each(['pausing', 'paused'] as const)( + 'keeps a %s workflow visible as active work', + (status) => { + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + act(() => { + root?.render( + + {}} + onSelectModel={() => {}} + onShowContext={() => {}} + onOpenSettings={() => {}} + onOpenTasks={() => {}} + tasks={[workflowTask(status)]} + /> + , + ); + }); + + expect(container.textContent).toContain('1 workflow'); + expect(container.textContent).not.toContain('1 task done'); + }, + ); +}); diff --git a/packages/web-shell/client/components/messages/TasksStatusMessage.module.css b/packages/web-shell/client/components/messages/TasksStatusMessage.module.css index 21443e8e0ec..041fecc9f71 100644 --- a/packages/web-shell/client/components/messages/TasksStatusMessage.module.css +++ b/packages/web-shell/client/components/messages/TasksStatusMessage.module.css @@ -48,6 +48,11 @@ cursor: pointer; } +.row:focus-visible { + outline: 2px solid color-mix(in srgb, var(--primary) 55%, transparent); + outline-offset: 2px; +} + .selected { color: var(--agent-blue-500); } @@ -386,6 +391,13 @@ overflow: hidden; } +.embeddedPanel .inlineDetail[data-kind='workflow'] { + margin: 4px 0 10px; + padding: 0; + border: 0; + background: transparent; +} + .embeddedPanel .detail { margin: 0; border-radius: 0; @@ -490,6 +502,12 @@ opacity: 0.55; } +.primaryActionButton { + border-color: var(--primary); + background: var(--primary); + color: var(--primary-foreground); +} + .dangerButton { color: var(--error-color); } diff --git a/packages/web-shell/client/components/messages/TasksStatusMessage.test.tsx b/packages/web-shell/client/components/messages/TasksStatusMessage.test.tsx index 2fe0e1ee754..cb7fbd648fd 100644 --- a/packages/web-shell/client/components/messages/TasksStatusMessage.test.tsx +++ b/packages/web-shell/client/components/messages/TasksStatusMessage.test.tsx @@ -5,23 +5,34 @@ import { createRoot, type Root } from 'react-dom/client'; import type { DaemonSessionAgentTaskStatus, DaemonSessionMonitorTaskStatus, - DaemonSessionTaskStatus, - DaemonSessionTasksStatus, + DaemonSessionTaskWithWorkflowStatus, + DaemonSessionWorkflowTasksStatus, + DaemonSessionWorkflowTaskStatus, } from '@qwen-code/sdk/daemon'; import type { ACPToolCall, TodoItem } from '../../adapters/types'; import { I18nProvider } from '../../i18n'; -// The panel only needs getTasks/cancelTask from the daemon SDK; mock the -// hook so the unit test doesn't pull the whole connection graph. Hoisted -// so tests can assert on / reprogram the mocks across renders. -const { getTasksMock, cancelTaskMock } = vi.hoisted(() => ({ +type DaemonSessionTasksStatus = DaemonSessionWorkflowTasksStatus; + +// Mock the daemon SDK hook so the unit test doesn't pull the whole connection +// graph. Hoisted so tests can assert on / reprogram the mocks across renders. +const { + getTasksMock, + getWorkflowTasksMock, + cancelTaskMock, + controlWorkflowTaskMock, +} = vi.hoisted(() => ({ getTasksMock: vi.fn(), + getWorkflowTasksMock: vi.fn(), cancelTaskMock: vi.fn(), + controlWorkflowTaskMock: vi.fn(), })); vi.mock('@qwen-code/web-shell/daemon-react-sdk', () => ({ useActions: () => ({ getTasks: getTasksMock, + getWorkflowTasks: getWorkflowTasksMock, cancelTask: cancelTaskMock, + controlWorkflowTask: controlWorkflowTaskMock, }), })); @@ -40,7 +51,10 @@ afterEach(() => { } mounted.length = 0; getTasksMock.mockReset(); + getWorkflowTasksMock.mockReset(); cancelTaskMock.mockReset(); + controlWorkflowTaskMock.mockReset(); + vi.useRealTimers(); }); function agentTask( @@ -80,19 +94,71 @@ function monitorTask( }; } +function workflowTask( + overrides: Partial = {}, +): DaemonSessionWorkflowTaskStatus { + return { + kind: 'workflow', + id: 'workflow-1', + workflowName: 'review-and-fix', + label: 'review-and-fix', + description: 'Review and fix', + status: 'running', + startTime: 1_000, + runtimeMs: 5_000, + isBackgrounded: true, + currentPhase: 'Review', + phaseVisits: [ + { + id: 'phase-1', + index: 0, + title: 'Review', + startedAt: 1_000, + }, + ], + dispatches: [ + { + id: 'dispatch-1', + phaseVisitId: 'phase-1', + label: 'Correctness', + prompt: 'Review behavior regressions', + status: 'running', + dependsOn: [], + queuedAt: 1_010, + startedAt: 1_020, + }, + ], + agentsDispatched: 1, + agentsCompleted: 0, + tokensSpent: 120, + tokenBudgetTotal: null, + recentLogs: [], + pendingApprovalCount: 0, + pendingApprovals: [], + ...overrides, + }; +} + function renderPanel( - tasks: DaemonSessionTaskStatus[], + tasks: DaemonSessionTaskWithWorkflowStatus[], options: { embedded?: boolean; + keyboardShortcuts?: boolean; + syncSnapshot?: boolean; + includeWorkflows?: boolean; + taskView?: 'all' | 'workflow-active' | 'workflow-history'; + sessionId?: string; + onTasksChange?: (snapshot: DaemonSessionWorkflowTasksStatus) => void; + onWorkflowRunStarted?: () => void; planTodos?: readonly TodoItem[]; agentTools?: readonly ACPToolCall[]; onOpenSubagent?: (tool: ACPToolCall) => void; onOpenMonitor?: (task: DaemonSessionMonitorTaskStatus) => void; } = {}, ): HTMLElement { - const snapshot: DaemonSessionTasksStatus = { + const snapshot: DaemonSessionWorkflowTasksStatus = { v: 1, - sessionId: 'session-1', + sessionId: options.sessionId ?? 'session-1', now: 10_000, tasks, }; @@ -106,11 +172,17 @@ function renderPanel( , ); @@ -118,6 +190,22 @@ function renderPanel( return container; } +it('keeps polling workflow tasks when an enabled panel opens empty', async () => { + vi.useFakeTimers(); + getWorkflowTasksMock.mockResolvedValue({ + v: 1, + sessionId: 'session-1', + now: 13_000, + tasks: [workflowTask()], + }); + renderPanel([], { includeWorkflows: true }); + + await act(async () => vi.advanceTimersByTimeAsync(3_000)); + + expect(getWorkflowTasksMock).toHaveBeenCalledOnce(); + expect(getTasksMock).not.toHaveBeenCalled(); +}); + describe('TasksStatusMessage monitor details', () => { it('opens an embedded monitor in the right-panel callback', () => { const onOpenMonitor = vi.fn(); @@ -154,6 +242,744 @@ describe('TasksStatusMessage monitor details', () => { }); }); +describe('TasksStatusMessage workflow details', () => { + it('makes embedded workflow rows keyboard-accessible', () => { + const container = renderPanel([workflowTask()], { + embedded: true, + keyboardShortcuts: false, + taskView: 'workflow-active', + }); + const row = Array.from( + container.querySelectorAll('[role="button"]'), + ).find((candidate) => candidate.textContent?.includes('review-and-fix')); + + expect(row).toBeDefined(); + expect(row?.tabIndex).toBe(0); + expect(row?.getAttribute('aria-expanded')).toBe('false'); + act(() => { + row?.dispatchEvent( + new KeyboardEvent('keydown', { + key: 'Enter', + bubbles: true, + cancelable: true, + }), + ); + }); + + expect(row?.getAttribute('aria-expanded')).toBe('true'); + expect(container.textContent).toContain('Review behavior regressions'); + expect(container.textContent).not.toContain('Runtime 5s'); + expect(container.textContent?.match(/120 tokens/gi)).toHaveLength(1); + }); + + it('switches expanded embedded rows with one focus-and-click gesture', () => { + const taskA = workflowTask({ id: 'workflow-a', label: 'run-a' }); + const taskB = workflowTask({ + id: 'workflow-b', + label: 'run-b', + dispatches: [ + { + ...workflowTask().dispatches[0]!, + id: 'dispatch-b', + prompt: 'prompt-for-b', + }, + ], + }); + const container = renderPanel([taskA, taskB], { + embedded: true, + keyboardShortcuts: false, + taskView: 'workflow-active', + }); + const findRow = (label: string) => + Array.from( + container.querySelectorAll('[role="button"]'), + ).find((candidate) => candidate.textContent?.includes(label)); + + act(() => { + findRow('run-a')?.focus(); + findRow('run-a')?.click(); + }); + expect(container.textContent).toContain('Review behavior regressions'); + + act(() => findRow('run-b')?.focus()); + act(() => findRow('run-b')?.click()); + + expect(findRow('run-b')?.getAttribute('aria-expanded')).toBe('true'); + expect(container.textContent).toContain('prompt-for-b'); + }); + + it('closes filtered detail instead of selecting a different workflow', () => { + const taskA = workflowTask({ + id: 'workflow-a', + label: 'run-a', + startTime: 2_000, + dispatches: [ + { + ...workflowTask().dispatches[0]!, + id: 'dispatch-a', + prompt: 'prompt-for-a', + }, + ], + }); + const taskB = workflowTask({ + id: 'workflow-b', + label: 'run-b', + startTime: 1_000, + dispatches: [ + { + ...workflowTask().dispatches[0]!, + id: 'dispatch-b', + prompt: 'prompt-for-b', + }, + ], + }); + const container = renderPanel([taskA, taskB], { + embedded: true, + keyboardShortcuts: false, + syncSnapshot: true, + taskView: 'workflow-active', + }); + const rowA = Array.from( + container.querySelectorAll('[role="button"]'), + ).find((candidate) => candidate.textContent?.includes('run-a')); + act(() => rowA?.click()); + expect(container.textContent).toContain('prompt-for-a'); + + const nextSnapshot: DaemonSessionTasksStatus = { + v: 1, + sessionId: 'session-1', + now: 11_000, + tasks: [{ ...taskA, status: 'completed', endTime: 11_000 }, taskB], + }; + const root = mounted.at(-1)!.root; + act(() => { + root.render( + + + , + ); + }); + + const rowB = Array.from( + container.querySelectorAll('[role="button"]'), + ).find((candidate) => candidate.textContent?.includes('run-b')); + expect(rowB?.getAttribute('aria-expanded')).toBe('false'); + expect(container.textContent).not.toContain('prompt-for-a'); + expect(container.textContent).not.toContain('prompt-for-b'); + }); + + it('ignores a stale polling response from the previous session', async () => { + vi.useFakeTimers(); + const onTasksChange = vi.fn(); + let resolveSessionA!: (snapshot: DaemonSessionTasksStatus) => void; + const pendingSessionA = new Promise((resolve) => { + resolveSessionA = resolve; + }); + getWorkflowTasksMock.mockReturnValueOnce(pendingSessionA); + const sessionATask = workflowTask({ id: 'workflow-a', label: 'run-a' }); + const sessionBTask = workflowTask({ id: 'workflow-b', label: 'run-b' }); + const container = renderPanel([sessionATask], { + embedded: true, + keyboardShortcuts: false, + syncSnapshot: true, + taskView: 'workflow-active', + sessionId: 'session-a', + onTasksChange, + }); + await act(async () => vi.advanceTimersByTime(3_000)); + expect(getWorkflowTasksMock).toHaveBeenCalledOnce(); + expect(getTasksMock).not.toHaveBeenCalled(); + + const sessionBSnapshot: DaemonSessionTasksStatus = { + v: 1, + sessionId: 'session-b', + now: 11_000, + tasks: [sessionBTask], + }; + const root = mounted.at(-1)!.root; + act(() => { + root.render( + + + , + ); + }); + + await act(async () => { + resolveSessionA({ + v: 1, + sessionId: 'session-a', + now: 12_000, + tasks: [sessionATask], + }); + await pendingSessionA; + }); + + expect(container.textContent).toContain('run-b'); + expect(container.textContent).not.toContain('run-a'); + expect(onTasksChange).not.toHaveBeenCalled(); + + // Dropping the stale response is only half of it. The polling effect + // deliberately keeps sessionId out of its deps and re-reads the ref per + // tick, so a refactor that "fixes" staleness by tearing the interval + // down on a session switch — without re-arming it — leaves the mounted + // panel never refreshing again: running workflows render as running + // forever and onTasksChange never fires for the new session. + getWorkflowTasksMock.mockResolvedValue({ + v: 1, + sessionId: 'session-b', + now: 13_000, + tasks: [sessionBTask], + }); + await act(async () => { + vi.advanceTimersByTime(3_000); + }); + + expect(getWorkflowTasksMock).toHaveBeenCalledTimes(2); + expect(onTasksChange).toHaveBeenCalledWith( + expect.objectContaining({ sessionId: 'session-b' }), + ); + }); + + it('cancels the row the user focused, not the one selection started on', async () => { + // Rows became focusable in this PR while `x` still routes to the + // SELECTED task, so without syncing selection on focus the two cursors + // diverge: Tab to task B, press x, and task A — which the user never + // pointed at — is the one that stops. + const taskA = workflowTask({ id: 'workflow-a', label: 'run-a' }); + const taskB = workflowTask({ id: 'workflow-b', label: 'run-b' }); + cancelTaskMock.mockResolvedValue({ cancelled: true }); + const container = renderPanel([taskA, taskB], { + embedded: true, + keyboardShortcuts: true, + taskView: 'workflow-active', + }); + const findRow = (label: string) => + Array.from( + container.querySelectorAll('[role="button"]'), + ).find((candidate) => candidate.textContent?.includes(label)); + + // The global keydown listener attaches after a 50 ms guard delay. + await act(async () => { + await new Promise((r) => setTimeout(r, 200)); + }); + act(() => findRow('run-b')?.focus()); + await act(async () => { + await new Promise((r) => setTimeout(r, 200)); + window.dispatchEvent(new KeyboardEvent('keydown', { key: 'x' })); + }); + + expect(cancelTaskMock).toHaveBeenCalledWith('workflow-b', 'workflow'); + }); + + it('opens the live graph and stops the workflow through the task API', async () => { + const task = workflowTask(); + cancelTaskMock.mockResolvedValue({ cancelled: true }); + getWorkflowTasksMock.mockResolvedValue({ + v: 1, + sessionId: 'session-1', + now: 10_100, + tasks: [{ ...task, status: 'cancelled' }], + }); + const container = renderPanel([task]); + const row = Array.from(container.querySelectorAll('span')).find((node) => + node.textContent?.includes('review-and-fix'), + )?.parentElement; + + act(() => row?.click()); + + expect(container.textContent).toContain('Review behavior regressions'); + const stop = Array.from( + container.querySelectorAll('button'), + ).find((button) => button.textContent === 'Stop'); + expect(stop).toBeDefined(); + + await act(async () => stop!.click()); + + expect(cancelTaskMock).toHaveBeenCalledWith('workflow-1', 'workflow'); + }); + + it('pauses and resumes a background workflow through the task API', async () => { + const task = workflowTask(); + controlWorkflowTaskMock.mockResolvedValue({ + changed: true, + status: 'pausing', + }); + getWorkflowTasksMock.mockResolvedValue({ + v: 1, + sessionId: 'session-1', + now: 10_100, + tasks: [{ ...task, status: 'pausing' }], + }); + const container = renderPanel([task]); + const row = Array.from(container.querySelectorAll('span')).find((node) => + node.textContent?.includes('review-and-fix'), + )?.parentElement; + + act(() => row?.click()); + const pause = Array.from( + container.querySelectorAll('button'), + ).find((button) => button.textContent === 'Pause'); + + await act(async () => pause!.click()); + + expect(controlWorkflowTaskMock).toHaveBeenCalledWith('workflow-1', 'pause'); + // A second action must still be accepted. `busy` is released only by + // the session-guarded finally; stuck true, every handler returns early + // on `if (busy) return` and every control renders disabled until + // remount — and no other test touches that, so the regression ships + // green. Asserted through a different control because Pause itself is + // status-disabled once the run reports 'pausing'. + cancelTaskMock.mockResolvedValue({ cancelled: true }); + const stop = Array.from( + container.querySelectorAll('button'), + ).find((button) => button.textContent === 'Stop'); + expect(stop).toBeDefined(); + expect(stop!.disabled).toBe(false); + await act(async () => stop!.click()); + expect(cancelTaskMock).toHaveBeenCalledWith('workflow-1', 'workflow'); + + controlWorkflowTaskMock.mockResolvedValue({ + changed: true, + status: 'running', + }); + getWorkflowTasksMock.mockResolvedValue({ + v: 1, + sessionId: 'session-1', + now: 10_200, + tasks: [{ ...task, status: 'running' }], + }); + const pausedContainer = renderPanel([workflowTask({ status: 'paused' })]); + const pausedRow = Array.from(pausedContainer.querySelectorAll('span')).find( + (node) => node.textContent?.includes('review-and-fix'), + )?.parentElement; + act(() => pausedRow?.click()); + const resume = Array.from( + pausedContainer.querySelectorAll('button'), + ).find((button) => button.textContent === 'Resume'); + + await act(async () => resume!.click()); + + expect(controlWorkflowTaskMock).toHaveBeenLastCalledWith( + 'workflow-1', + 'resume', + ); + }); + + it('ignores a workflow action that settles after switching sessions', async () => { + let resolveControl!: (value: { + changed: boolean; + status: 'pausing'; + }) => void; + controlWorkflowTaskMock.mockReturnValueOnce( + new Promise((resolve) => { + resolveControl = resolve; + }), + ); + const sessionATask = workflowTask({ id: 'workflow-a', label: 'run-a' }); + const sessionBTask = workflowTask({ id: 'workflow-b', label: 'run-b' }); + const container = renderPanel([sessionATask], { + embedded: true, + keyboardShortcuts: false, + syncSnapshot: true, + taskView: 'workflow-active', + sessionId: 'session-a', + }); + const rowA = Array.from( + container.querySelectorAll('[role="button"]'), + ).find((candidate) => candidate.textContent?.includes('run-a')); + act(() => rowA?.click()); + const pause = Array.from( + container.querySelectorAll('button'), + ).find((button) => button.textContent === 'Pause'); + act(() => pause?.click()); + + const sessionBSnapshot: DaemonSessionTasksStatus = { + v: 1, + sessionId: 'session-b', + now: 11_000, + tasks: [sessionBTask], + }; + const root = mounted.at(-1)!.root; + act(() => { + root.render( + + + , + ); + }); + + await act(async () => { + resolveControl({ changed: true, status: 'pausing' }); + await Promise.resolve(); + }); + + expect(container.textContent).toContain('run-b'); + expect(container.textContent).not.toContain('run-a'); + expect(getTasksMock).not.toHaveBeenCalled(); + expect(getWorkflowTasksMock).not.toHaveBeenCalled(); + + // The stale action's finally skips releasing `busy` (its session guard + // no longer matches), so the [snapshot.sessionId] reset effect is the + // only thing that frees the new session's panel. Without it every + // control here renders disabled and every handler no-ops until unmount. + const rowB = Array.from( + container.querySelectorAll('[role="button"]'), + ).find((candidate) => candidate.textContent?.includes('run-b')); + expect(rowB).toBeDefined(); + act(() => rowB!.click()); + const pauseB = Array.from( + container.querySelectorAll('button'), + ).find((button) => button.textContent === 'Pause'); + expect(pauseB).toBeDefined(); + expect(pauseB!.disabled).toBe(false); + + controlWorkflowTaskMock.mockResolvedValue({ + changed: true, + status: 'pausing', + }); + getWorkflowTasksMock.mockResolvedValue({ + v: 1, + sessionId: 'session-b', + now: 12_000, + tasks: [sessionBTask], + }); + await act(async () => pauseB!.click()); + + expect(controlWorkflowTaskMock).toHaveBeenLastCalledWith( + 'workflow-b', + 'pause', + ); + }); + + it('refuses a retry the daemon says is no longer valid', async () => { + // The daemon answers { changed: false } when the transition is no + // longer valid — e.g. Retry clicked while a concurrent 3s poll is + // finalizing the run. No `changed: false` occurs anywhere else in the + // suite, so dropping the guard 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. + const onWorkflowRunStarted = vi.fn(); + const failed = workflowTask({ + status: 'failed', + error: 'Architecture review failed', + dispatches: [ + { + ...workflowTask().dispatches[0]!, + status: 'failed', + error: 'Architecture review failed', + }, + ], + }); + controlWorkflowTaskMock.mockResolvedValue({ changed: false }); + const container = renderPanel([failed], { onWorkflowRunStarted }); + const row = Array.from(container.querySelectorAll('span')).find((node) => + node.textContent?.includes('review-and-fix'), + )?.parentElement; + + act(() => row?.click()); + const retry = Array.from( + container.querySelectorAll('button'), + ).find((button) => button.textContent === 'Retry failed path'); + expect(retry).toBeDefined(); + + await act(async () => retry!.click()); + + expect(controlWorkflowTaskMock).toHaveBeenCalledWith('workflow-1', 'retry'); + expect(onWorkflowRunStarted).not.toHaveBeenCalled(); + expect(getWorkflowTasksMock).not.toHaveBeenCalled(); + expect(container.textContent).toContain( + 'Workflow state changed before the action', + ); + }); + + it('retries a failed workflow path and refreshes the graph', async () => { + const onWorkflowRunStarted = vi.fn(); + const failed = workflowTask({ + status: 'failed', + error: 'Architecture review failed', + dispatches: [ + { + ...workflowTask().dispatches[0]!, + status: 'failed', + error: 'Architecture review failed', + }, + ], + }); + controlWorkflowTaskMock.mockResolvedValue({ + changed: true, + status: 'running', + }); + getWorkflowTasksMock.mockImplementation(() => { + expect(onWorkflowRunStarted).toHaveBeenCalledOnce(); + return Promise.resolve({ + v: 1, + sessionId: 'session-1', + now: 10_200, + tasks: [workflowTask()], + }); + }); + const container = renderPanel([failed], { onWorkflowRunStarted }); + const row = Array.from(container.querySelectorAll('span')).find((node) => + node.textContent?.includes('review-and-fix'), + )?.parentElement; + + act(() => row?.click()); + const retry = Array.from( + container.querySelectorAll('button'), + ).find((button) => button.textContent === 'Retry failed path'); + expect(retry).toBeDefined(); + + await act(async () => retry!.click()); + + expect(controlWorkflowTaskMock).toHaveBeenCalledWith('workflow-1', 'retry'); + expect(onWorkflowRunStarted).toHaveBeenCalledOnce(); + expect(getWorkflowTasksMock).toHaveBeenCalledOnce(); + expect(getTasksMock).not.toHaveBeenCalled(); + }); + + it('reruns a failed workflow from scratch and opens the new run', async () => { + const onWorkflowRunStarted = vi.fn(); + const failed = workflowTask({ + status: 'failed', + error: 'Architecture review failed', + }); + const rerun = workflowTask({ + id: 'workflow-2', + sourceRunId: failed.id, + startMode: 'rerun', + startTime: 2_000, + dispatches: [ + { + ...workflowTask().dispatches[0]!, + id: 'dispatch-2', + prompt: 'Fresh run agent', + }, + ], + }); + controlWorkflowTaskMock.mockResolvedValue({ + changed: true, + status: 'running', + taskId: rerun.id, + }); + getWorkflowTasksMock.mockResolvedValue({ + v: 1, + sessionId: 'session-1', + now: 10_200, + tasks: [failed, rerun, agentTask('newer-agent', { startTime: 3_000 })], + }); + const container = renderPanel([failed], { onWorkflowRunStarted }); + const row = Array.from(container.querySelectorAll('span')).find((node) => + node.textContent?.includes('review-and-fix'), + )?.parentElement; + + act(() => row?.click()); + const buttons = Array.from( + container.querySelectorAll('button'), + ); + expect( + buttons.find((button) => button.textContent === 'Retry failed path'), + ).toBeDefined(); + const rerunAll = buttons.find( + (button) => button.textContent === 'Rerun all', + ); + expect(rerunAll).toBeDefined(); + + await act(async () => rerunAll!.click()); + + expect(controlWorkflowTaskMock).toHaveBeenCalledWith('workflow-1', 'rerun'); + expect(onWorkflowRunStarted).toHaveBeenCalledOnce(); + expect(container.textContent).toContain('Fresh run agent'); + expect(container.textContent).toContain('Compare runs'); + }); + + it('offers a full rerun, but not a path retry, after completion', () => { + const container = renderPanel([ + workflowTask({ status: 'completed', endTime: 9_000 }), + ]); + const row = Array.from(container.querySelectorAll('span')).find((node) => + node.textContent?.includes('review-and-fix'), + )?.parentElement; + + act(() => row?.click()); + + expect(container.textContent).toContain('Rerun all'); + expect(container.textContent).not.toContain('Retry failed path'); + }); + + it('shows saved workflow history while keeping restored runs read-only', () => { + const current = workflowTask({ id: 'workflow-current' }); + const historical = workflowTask({ + id: 'workflow-saved', + isHistorical: true, + status: 'failed', + startTime: 500, + endTime: 1_000, + runtimeMs: 500, + }); + const container = renderPanel([current, historical]); + const currentRow = Array.from(container.querySelectorAll('span')).find( + (node) => node.textContent?.includes('review-and-fix'), + )?.parentElement; + + act(() => currentRow?.click()); + const history = Array.from( + container.querySelectorAll('button'), + ).find((button) => button.textContent === 'Run history (1)'); + expect(history).toBeDefined(); + act(() => history!.click()); + + expect(container.textContent).toContain('workflow-saved'); + + const savedContainer = renderPanel([historical]); + const savedRow = Array.from(savedContainer.querySelectorAll('span')).find( + (node) => node.textContent?.includes('review-and-fix'), + )?.parentElement; + act(() => savedRow?.click()); + + expect(savedContainer.textContent).toContain('Saved run · read-only'); + expect(savedContainer.textContent).not.toContain('Retry failed path'); + expect(savedContainer.textContent).not.toContain('Rerun all'); + }); + + it('does not group workflow history by a shared display label', () => { + const current = workflowTask({ + id: 'workflow-deploy', + workflowName: 'deploy', + label: 'Deploy', + }); + const otherDefinition = workflowTask({ + id: 'workflow-deploy-v2', + workflowName: 'deploy-v2', + label: 'Deploy', + isHistorical: true, + status: 'failed', + endTime: 2_000, + }); + const container = renderPanel([current, otherDefinition]); + const currentRow = Array.from( + container.querySelectorAll('[role="button"]'), + ).find((candidate) => candidate.textContent?.includes('Deploy')); + + act(() => currentRow?.click()); + + expect(container.textContent).not.toContain('Run history (1)'); + expect(container.textContent).not.toContain('Delete saved run'); + }); + + it('refuses a history delete the daemon says is no longer valid', async () => { + // The sibling !result.changed branch. Without the guard the panel would + // reload and clear the row as if the delete had happened, and the user + // would never learn the saved run was already gone. + const historical = workflowTask({ + id: 'wf-abcd', + isHistorical: true, + status: 'failed', + startTime: 500, + endTime: 1_000, + runtimeMs: 500, + }); + controlWorkflowTaskMock.mockResolvedValue({ changed: false }); + const container = renderPanel([historical]); + const row = Array.from(container.querySelectorAll('span')).find((node) => + node.textContent?.includes('review-and-fix'), + )?.parentElement; + act(() => row?.click()); + + const remove = Array.from(container.querySelectorAll('button')).find( + (button) => button.textContent === 'Delete saved run', + ); + act(() => remove?.click()); + const confirm = Array.from(container.querySelectorAll('button')).find( + (button) => button.textContent === 'Confirm delete', + ); + await act(async () => confirm?.click()); + + expect(controlWorkflowTaskMock).toHaveBeenCalledWith( + 'wf-abcd', + 'delete-history', + ); + expect(getWorkflowTasksMock).not.toHaveBeenCalled(); + expect(container.textContent).toContain( + 'The saved run is no longer available', + ); + }); + + it('deletes a restored run after confirmation and refreshes the task list', async () => { + const historical = workflowTask({ + id: 'wf-abcd', + isHistorical: true, + status: 'failed', + startTime: 500, + endTime: 1_000, + runtimeMs: 500, + }); + controlWorkflowTaskMock.mockResolvedValue({ changed: true }); + getWorkflowTasksMock.mockResolvedValue({ + v: 1, + sessionId: 'session-1', + now: 2_000, + tasks: [], + }); + const container = renderPanel([historical]); + const row = Array.from(container.querySelectorAll('span')).find((node) => + node.textContent?.includes('review-and-fix'), + )?.parentElement; + act(() => row?.click()); + + const remove = Array.from(container.querySelectorAll('button')).find( + (button) => button.textContent === 'Delete saved run', + ); + act(() => remove?.click()); + expect(controlWorkflowTaskMock).not.toHaveBeenCalled(); + const confirm = Array.from(container.querySelectorAll('button')).find( + (button) => button.textContent === 'Confirm delete', + ); + await act(async () => confirm?.click()); + + expect(controlWorkflowTaskMock).toHaveBeenCalledWith( + 'wf-abcd', + 'delete-history', + ); + expect(getWorkflowTasksMock).toHaveBeenCalledOnce(); + expect(getTasksMock).not.toHaveBeenCalled(); + expect(container.textContent).not.toContain('Saved run · read-only'); + }); +}); + +describe('TasksStatusMessage paused agent controls', () => { + it('keeps the abandon hint distinct from workflow stop', () => { + const container = renderPanel([ + agentTask('paused-agent', { status: 'paused' }), + ]); + + expect(container.textContent).toContain('x abandon'); + expect(container.textContent).not.toContain('x stop'); + }); +}); + describe('TasksStatusMessage nested-agent tree', () => { it('leaves workflow and subagent buttons in control of their keyboard input', async () => { const onOpenSubagent = vi.fn(); diff --git a/packages/web-shell/client/components/messages/TasksStatusMessage.tsx b/packages/web-shell/client/components/messages/TasksStatusMessage.tsx index 29fbcff4271..a3f2c5e44c6 100644 --- a/packages/web-shell/client/components/messages/TasksStatusMessage.tsx +++ b/packages/web-shell/client/components/messages/TasksStatusMessage.tsx @@ -2,8 +2,8 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import type { DaemonSessionMonitorTaskStatus, DaemonSessionShellTaskStatus, - DaemonSessionTasksStatus, - DaemonSessionTaskStatus, + DaemonSessionTaskWithWorkflowStatus, + DaemonSessionWorkflowTasksStatus, } from '@qwen-code/sdk/daemon'; import { isSessionDisconnectedError } from '../../utils/sessionErrors'; import { @@ -24,6 +24,7 @@ import { formatContextTokens } from '../../utils/formatTokenCount'; import { createSentinelSerializer } from '../../utils/sentinelMessage'; import type { ACPToolCall, TodoItem } from '../../adapters/types'; import { PlanExecutionView } from './PlanExecutionView'; +import { WorkflowExecutionView } from './WorkflowExecutionView'; import { localizeAgentTypeName, localizeToolDisplayName, @@ -40,10 +41,19 @@ const LIST_MAX_ROWS = 8; // detail dialog renders in full. const MAX_DISPLAYED_ACTIVITIES = 5; +type DaemonSessionTaskStatus = DaemonSessionTaskWithWorkflowStatus; +type DaemonSessionTasksStatus = DaemonSessionWorkflowTasksStatus; +type LegacyTaskStatus = Exclude< + DaemonSessionTaskWithWorkflowStatus, + { kind: 'workflow' } +>; + export interface SerializedTasksMessage { snapshot: DaemonSessionTasksStatus; } +export type TasksStatusView = 'all' | 'workflow-active' | 'workflow-history'; + const { serialize: serializeTasksStatusMessage, parse: parseRawTasksStatusMessage, @@ -65,14 +75,18 @@ type TasksPanelStep = 'list' | 'detail'; type TaskStatus = DaemonSessionTaskStatus['status']; -function dispatchActive(id: string, active: boolean): void { +function dispatchActive(id: string, sessionId: string, active: boolean): void { window.dispatchEvent( - new CustomEvent(ACTIVE_EVENT, { detail: { id, active } }), + new CustomEvent(ACTIVE_EVENT, { detail: { id, sessionId, active } }), ); } function isActive(task: DaemonSessionTaskStatus): boolean { - return task.status === 'running' || task.status === 'paused'; + return ( + task.status === 'running' || + task.status === 'pausing' || + task.status === 'paused' + ); } function sortTasks( @@ -91,7 +105,7 @@ function sortTasks( * Display order for the panel: active-first sort, then each nested agent * grouped under its parent as a tree. The reorder is a post-pass so a tree * spanning the active/terminal buckets stays contiguous at whichever - * position its root earned. Every `setTasks` site must use this (not bare + * position its root earned. Every visible task list must use this (not bare * `sortTasks`) — selection is index-based, so list order IS the contract. */ function arrangeTasks( @@ -100,12 +114,63 @@ function arrangeTasks( return reorderChildrenUnderParents(sortTasks(tasks)); } +function tasksForView( + tasks: DaemonSessionTaskStatus[], + view: TasksStatusView, +): DaemonSessionTaskStatus[] { + if (view === 'all') return arrangeTasks(tasks); + return arrangeTasks( + tasks.filter( + (task) => + task.kind === 'workflow' && + (view === 'workflow-active' ? isActive(task) : !isActive(task)), + ), + ); +} + +function findWorkflowSourceTask( + task: DaemonSessionTaskStatus, + tasks: DaemonSessionTaskStatus[], +): Extract | undefined { + if ( + task.kind !== 'workflow' || + !task.sourceRunId || + task.sourceRunId === task.id + ) { + return undefined; + } + const source = tasks.find( + (candidate) => + candidate.kind === 'workflow' && candidate.id === task.sourceRunId, + ); + return source?.kind === 'workflow' ? source : undefined; +} + +function findWorkflowHistoryTasks( + task: DaemonSessionTaskStatus, + tasks: DaemonSessionTaskStatus[], +): Array> { + if (task.kind !== 'workflow' || !task.workflowName) return []; + return tasks + .filter( + ( + candidate, + ): candidate is Extract => + candidate.kind === 'workflow' && + candidate.id !== task.id && + candidate.workflowName === task.workflowName, + ) + .sort((a, b) => b.startTime - a.startTime); +} + function statusClassName(status: TaskStatus): string { switch (status) { case 'running': return styles.success; case 'paused': return styles.warning; + case 'pausing': + return styles.warning; case 'completed': return styles.success; case 'failed': @@ -132,6 +197,8 @@ function statusLabel( return t('tasks.cancelled'); case 'paused': return t('tasks.paused'); + case 'pausing': + return t('tasks.pausing'); default: return status; } @@ -141,6 +208,8 @@ function terminalStatusIcon(status: TaskStatus): string | null { switch (status) { case 'paused': return '⏸'; + case 'pausing': + return '⏸'; case 'completed': return '✓'; case 'failed': @@ -171,7 +240,11 @@ function ChevronIcon({ expanded }: { expanded: boolean }) { ); } -function rowLabel(task: DaemonSessionTaskStatus, blocking: boolean): string { +function rowLabel( + task: DaemonSessionTaskStatus, + blocking: boolean, + workflowOnly = false, +): string { switch (task.kind) { case 'agent': // `blocking` comes from computeUserBlockingIds — an agent is tagged @@ -184,6 +257,8 @@ function rowLabel(task: DaemonSessionTaskStatus, blocking: boolean): string { return `[shell] ${task.command}`; case 'monitor': return `[monitor] ${task.description}`; + case 'workflow': + return workflowOnly ? task.label : `[workflow] ${task.label}`; } } @@ -243,6 +318,13 @@ export function TasksStatusMessage({ message, embedded = false, manageActiveEvent = true, + keyboardShortcuts = true, + syncSnapshot = false, + includeWorkflows = false, + taskView = 'all', + emptyLabel, + onWorkflowRunStarted, + onTasksChange, onClose, planTodos = [], agentTools = [], @@ -252,6 +334,13 @@ export function TasksStatusMessage({ message: SerializedTasksMessage; embedded?: boolean; manageActiveEvent?: boolean; + keyboardShortcuts?: boolean; + syncSnapshot?: boolean; + includeWorkflows?: boolean; + taskView?: TasksStatusView; + emptyLabel?: string; + onWorkflowRunStarted?: () => void; + onTasksChange?: (snapshot: DaemonSessionTasksStatus) => void; onClose?: () => void; planTodos?: readonly TodoItem[]; agentTools?: readonly ACPToolCall[]; @@ -260,26 +349,54 @@ export function TasksStatusMessage({ }) { const { t } = useI18n(); const actions = useActions(); - const [tasks, setTasks] = useState(() => - arrangeTasks(message.snapshot.tasks), + const shouldIncludeWorkflows = + taskView !== 'all' || + includeWorkflows || + message.snapshot.tasks.some((task) => task.kind === 'workflow'); + const loadTasks = useCallback( + async (): Promise => + shouldIncludeWorkflows ? actions.getWorkflowTasks() : actions.getTasks(), + [actions, shouldIncludeWorkflows], + ); + const [allTasks, setAllTasks] = useState(message.snapshot.tasks); + const tasks = useMemo( + () => tasksForView(allTasks, taskView), + [allTasks, taskView], ); const [isOpen, setIsOpen] = useState(true); const [step, setStep] = useState('list'); - const [selectedIndex, setSelectedIndex] = useState(0); + const [selectedTaskId, setSelectedTaskId] = useState( + () => tasksForView(message.snapshot.tasks, taskView)[0]?.id ?? null, + ); const [pendingCancelId, setPendingCancelId] = useState(null); const [busy, setBusy] = useState(false); const [refreshError, setRefreshError] = useState(false); const [actionError, setActionError] = useState(null); const panelIdRef = useRef(`tasks-${Math.random().toString(36).slice(2)}`); const refreshInFlightRef = useRef(false); + const expectedSessionIdRef = useRef(message.snapshot.sessionId); + expectedSessionIdRef.current = message.snapshot.sessionId; const initialDetailStatusRef = useRef<{ taskId: string; status: TaskStatus; } | null>(null); - const clampedSelectedIndex = - tasks.length === 0 ? 0 : Math.min(selectedIndex, tasks.length - 1); - const selectedTask = tasks[clampedSelectedIndex] ?? null; + useEffect(() => { + if (syncSnapshot) setAllTasks(message.snapshot.tasks); + }, [message.snapshot, syncSnapshot]); + + useEffect(() => { + setBusy(false); + setActionError(null); + setPendingCancelId(null); + setStep('list'); + }, [message.snapshot.sessionId]); + + const selectedIndex = selectedTaskId + ? tasks.findIndex((task) => task.id === selectedTaskId) + : -1; + const clampedSelectedIndex = selectedIndex >= 0 ? selectedIndex : 0; + const selectedTask = selectedIndex >= 0 ? tasks[selectedIndex] : null; // Tree metadata is computed on the full task list (not the windowed // slice) so a row's indent doesn't shift when the window scrolls past @@ -292,13 +409,21 @@ export function TasksStatusMessage({ const refresh = () => { if (refreshInFlightRef.current) return; refreshInFlightRef.current = true; - actions - .getTasks() + const requestedSessionId = expectedSessionIdRef.current; + loadTasks() .then((snapshot) => { - setTasks(arrangeTasks(snapshot.tasks)); + if ( + expectedSessionIdRef.current !== requestedSessionId || + snapshot.sessionId !== requestedSessionId + ) { + return; + } + setAllTasks(snapshot.tasks); + onTasksChange?.(snapshot); setRefreshError(false); }) .catch((error: unknown) => { + if (expectedSessionIdRef.current !== requestedSessionId) return; if (isSessionDisconnectedError(error)) { setRefreshError(false); return; @@ -312,16 +437,14 @@ export function TasksStatusMessage({ }; const id = setInterval(refresh, REFRESH_INTERVAL_MS); return () => clearInterval(id); - }, [isOpen, actions]); + }, [isOpen, loadTasks, onTasksChange]); useEffect(() => { - if (tasks.length === 0 && selectedIndex !== 0) { - setSelectedIndex(0); - } - if (selectedIndex >= tasks.length && tasks.length > 0) { - setSelectedIndex(tasks.length - 1); - } - }, [tasks.length, selectedIndex]); + if (selectedIndex >= 0) return; + setPendingCancelId(null); + if (step === 'detail') setStep('list'); + setSelectedTaskId(tasks[0]?.id ?? null); + }, [selectedIndex, step, tasks]); useEffect(() => { if (!isOpen || step !== 'detail') { @@ -344,7 +467,12 @@ export function TasksStatusMessage({ return; } - if (initial.status === 'running' && selectedTask.status !== 'running') { + if ( + (initial.status === 'running' || + initial.status === 'pausing' || + initial.status === 'paused') && + !isActive(selectedTask) + ) { setPendingCancelId(null); setStep('list'); } @@ -353,9 +481,10 @@ export function TasksStatusMessage({ useEffect(() => { if (!manageActiveEvent) return undefined; const id = panelIdRef.current; - dispatchActive(id, isOpen); - return () => dispatchActive(id, false); - }, [isOpen, manageActiveEvent]); + const sessionId = message.snapshot.sessionId; + dispatchActive(id, sessionId, isOpen); + return () => dispatchActive(id, sessionId, false); + }, [isOpen, manageActiveEvent, message.snapshot.sessionId]); useEffect(() => { if (!manageActiveEvent) return undefined; @@ -377,9 +506,11 @@ export function TasksStatusMessage({ const handleCancel = useCallback( async (task: DaemonSessionTaskStatus) => { if (busy) return; + const sessionId = expectedSessionIdRef.current; const isRunning = task.status === 'running'; const isAbandonable = task.kind === 'agent' && task.status === 'paused'; - if (!isRunning && !isAbandonable) return; + const isActiveWorkflow = task.kind === 'workflow' && isActive(task); + if (!isRunning && !isAbandonable && !isActiveWorkflow) return; // Two-step confirm only when cancelling would end the USER's turn — // the same chain-aware verdict as the `[blocking]` row prefix. A // foreground child awaited by a *background* parent unblocks that @@ -395,26 +526,135 @@ export function TasksStatusMessage({ setBusy(true); try { const result = await actions.cancelTask(task.id, task.kind); + if (expectedSessionIdRef.current !== sessionId) return; if (!result.cancelled) { setActionError(t('tasks.alreadyStopped')); return; } - const snapshot = await actions.getTasks(); - setTasks(arrangeTasks(snapshot.tasks)); + const snapshot = await loadTasks(); + if ( + expectedSessionIdRef.current !== sessionId || + snapshot.sessionId !== sessionId + ) { + return; + } + setAllTasks(snapshot.tasks); + onTasksChange?.(snapshot); + if (taskView === 'workflow-active') setStep('list'); setActionError(null); } catch (error: unknown) { + if (expectedSessionIdRef.current !== sessionId) return; console.warn('[web-shell] failed to cancel task:', error); setActionError(t('tasks.cancelFailed')); } finally { - setBusy(false); + if (expectedSessionIdRef.current === sessionId) setBusy(false); + } + }, + [ + actions, + busy, + blockingIds, + loadTasks, + onTasksChange, + pendingCancelId, + t, + taskView, + ], + ); + + const handleWorkflowAction = useCallback( + async ( + task: Extract, + action: 'pause' | 'resume' | 'retry' | 'rerun', + ) => { + if (busy) return; + const sessionId = expectedSessionIdRef.current; + setBusy(true); + try { + const result = await actions.controlWorkflowTask(task.id, action); + if (expectedSessionIdRef.current !== sessionId) return; + if (!result.changed) { + setActionError(t('workflow.action.unavailable')); + return; + } + if (action === 'retry' || action === 'rerun') { + onWorkflowRunStarted?.(); + } + const snapshot = await loadTasks(); + if ( + expectedSessionIdRef.current !== sessionId || + snapshot.sessionId !== sessionId + ) { + return; + } + const nextTasks = tasksForView(snapshot.tasks, taskView); + setAllTasks(snapshot.tasks); + onTasksChange?.(snapshot); + if (result.taskId) { + if (nextTasks.some((candidate) => candidate.id === result.taskId)) { + setSelectedTaskId(result.taskId); + } + } + setActionError(null); + } catch (error: unknown) { + if (expectedSessionIdRef.current !== sessionId) return; + console.warn('[web-shell] failed to control workflow:', error); + setActionError(t('workflow.action.failed')); + } finally { + if (expectedSessionIdRef.current === sessionId) setBusy(false); + } + }, + [ + actions, + busy, + loadTasks, + onTasksChange, + onWorkflowRunStarted, + t, + taskView, + ], + ); + + const handleWorkflowHistoryDelete = useCallback( + async (runId: string) => { + if (busy) return; + const sessionId = expectedSessionIdRef.current; + setBusy(true); + try { + const result = await actions.controlWorkflowTask( + runId, + 'delete-history', + ); + if (expectedSessionIdRef.current !== sessionId) return; + if (!result.changed) { + setActionError(t('workflow.history.deleteUnavailable')); + return; + } + const snapshot = await loadTasks(); + if ( + expectedSessionIdRef.current !== sessionId || + snapshot.sessionId !== sessionId + ) { + return; + } + setAllTasks(snapshot.tasks); + onTasksChange?.(snapshot); + if (selectedTask?.id === runId) setStep('list'); + setActionError(null); + } catch (error: unknown) { + if (expectedSessionIdRef.current !== sessionId) return; + console.warn('[web-shell] failed to delete workflow history:', error); + setActionError(t('workflow.history.deleteFailed')); + } finally { + if (expectedSessionIdRef.current === sessionId) setBusy(false); } }, - [actions, busy, blockingIds, pendingCancelId, t], + [actions, busy, loadTasks, onTasksChange, selectedTask?.id, t], ); useDelayedGlobalKeyDown( (event: KeyboardEvent) => { - if (!isOpen) return; + if (!keyboardShortcuts || !isOpen) return; if ( event.key !== 'Escape' && @@ -459,9 +699,11 @@ export function TasksStatusMessage({ event.stopPropagation(); if (tasks.length === 0) return; const delta = event.key === 'ArrowUp' ? -1 : 1; - setSelectedIndex((current) => - Math.min(Math.max(current + delta, 0), tasks.length - 1), + const nextIndex = Math.min( + Math.max(clampedSelectedIndex + delta, 0), + tasks.length - 1, ); + setSelectedTaskId(tasks[nextIndex]?.id ?? null); setPendingCancelId(null); return; } @@ -499,9 +741,11 @@ export function TasksStatusMessage({ }, [ embedded, + keyboardShortcuts, isOpen, step, tasks.length, + clampedSelectedIndex, selectedTask, handleCancel, onOpenMonitor, @@ -523,13 +767,10 @@ export function TasksStatusMessage({ } else { listHints.push(t('tasks.shortcut.select')); listHints.push(t('tasks.shortcut.view')); - if (selectedTask?.status === 'running') { - listHints.push(t('tasks.shortcut.stop')); - } else if ( - selectedTask?.kind === 'agent' && - selectedTask?.status === 'paused' - ) { + if (selectedTask?.kind === 'agent' && selectedTask?.status === 'paused') { listHints.push(t('tasks.shortcut.abandon')); + } else if (selectedTask && isActive(selectedTask)) { + listHints.push(t('tasks.shortcut.stop')); } listHints.push(t('tasks.shortcut.listClose')); } @@ -541,13 +782,10 @@ export function TasksStatusMessage({ } else { detailHints.push(t('tasks.shortcut.detailBack')); detailHints.push(t('tasks.shortcut.detailClose')); - if (selectedTask?.status === 'running') { - detailHints.push(t('tasks.shortcut.stop')); - } else if ( - selectedTask?.kind === 'agent' && - selectedTask?.status === 'paused' - ) { + if (selectedTask?.kind === 'agent' && selectedTask?.status === 'paused') { detailHints.push(t('tasks.shortcut.abandon')); + } else if (selectedTask && isActive(selectedTask)) { + detailHints.push(t('tasks.shortcut.stop')); } } @@ -573,11 +811,15 @@ export function TasksStatusMessage({ task.kind !== 'workflow', + )} onOpenSubagent={onOpenSubagent} />
-
{t('tasks.empty')}
+
+ {emptyLabel ?? t('tasks.empty')} +
{!embedded && (
{t('tasks.shortcut.close')}
@@ -617,7 +859,9 @@ export function TasksStatusMessage({ task.kind !== 'workflow', + )} onOpenSubagent={onOpenSubagent} /> )} @@ -658,6 +902,14 @@ export function TasksStatusMessage({ ? t('tasks.row.from', { parent: task.parentName }) : t('tasks.row.nested') : null; + const activateTask = () => { + setSelectedTaskId(task.id); + if (embedded && task.kind === 'monitor' && onOpenMonitor) { + onOpenMonitor(task); + } else { + setStep(embedded && expanded ? 'list' : 'detail'); + } + }; return (
{ - setSelectedIndex(index); - if (embedded && task.kind === 'monitor' && onOpenMonitor) { - onOpenMonitor(task); - } else { - setStep(embedded && expanded ? 'list' : 'detail'); + role="button" + tabIndex={0} + aria-expanded={ + embedded && !(task.kind === 'monitor' && onOpenMonitor) + ? expanded + : undefined + } + onClick={activateTask} + onKeyDown={(event) => { + if (event.key !== 'Enter' && event.key !== ' ') return; + event.preventDefault(); + activateTask(); + }} + onFocus={() => { + // Embedded rows are focusable too, and the global + // shortcuts (`x` to cancel) act on the SELECTION — so + // without this the user can Tab to one row and cancel + // another. Not while a row is expanded: focus moving + // into the detail must not re-target the selection + // (pinned by the "focus does not expand" case). + if (!embedded || step !== 'detail') { + setSelectedTaskId(task.id); } }} onMouseEnter={() => { - if (!embedded) setSelectedIndex(index); + if (!embedded) setSelectedTaskId(task.id); }} > @@ -702,7 +970,11 @@ export function TasksStatusMessage({ {'↳ '} )} - {rowLabel(task, blockingIds.has(task.id))} + {rowLabel( + task, + blockingIds.has(task.id), + taskView !== 'all', + )} {orphanNote && ( {' · '} @@ -718,7 +990,7 @@ export function TasksStatusMessage({
{expanded && ( -
+
void handleCancel(task)} + sourceWorkflowTask={findWorkflowSourceTask( + task, + allTasks, + )} + workflowHistoryTasks={findWorkflowHistoryTasks( + task, + allTasks, + )} + onWorkflowAction={(action) => + task.kind === 'workflow' + ? void handleWorkflowAction(task, action) + : undefined + } + onDeleteWorkflowHistory={(runId) => + void handleWorkflowHistoryDelete(runId) + } onCancelConfirmDismiss={() => setPendingCancelId(null)} />
@@ -750,6 +1038,19 @@ export function TasksStatusMessage({ busy={busy} showCancelConfirm={pendingCancelId === selectedTask.id} onCancel={() => void handleCancel(selectedTask)} + sourceWorkflowTask={findWorkflowSourceTask(selectedTask, allTasks)} + workflowHistoryTasks={findWorkflowHistoryTasks( + selectedTask, + allTasks, + )} + onWorkflowAction={(action) => + selectedTask.kind === 'workflow' + ? void handleWorkflowAction(selectedTask, action) + : undefined + } + onDeleteWorkflowHistory={(runId) => + void handleWorkflowHistoryDelete(runId) + } onCancelConfirmDismiss={() => setPendingCancelId(null)} /> @@ -781,6 +1082,8 @@ function detailTitle( return `${t('tasks.kind.shell')} › ${task.command}`; case 'monitor': return `${t('tasks.kind.monitor')} › ${task.description}`; + case 'workflow': + return `${t('tasks.kind.workflow')} › ${task.label}`; } } @@ -1093,6 +1396,10 @@ function TaskDetail({ busy = false, showCancelConfirm = false, onCancel, + sourceWorkflowTask, + workflowHistoryTasks, + onWorkflowAction, + onDeleteWorkflowHistory, onCancelConfirmDismiss, }: { task: DaemonSessionTaskStatus; @@ -1101,12 +1408,35 @@ function TaskDetail({ busy?: boolean; showCancelConfirm?: boolean; onCancel?: () => void; + sourceWorkflowTask?: Extract; + workflowHistoryTasks?: Array< + Extract + >; + onWorkflowAction?: (action: 'pause' | 'resume' | 'retry' | 'rerun') => void; + onDeleteWorkflowHistory?: (runId: string) => void; onCancelConfirmDismiss?: () => void; }) { const terminalIcon = terminalStatusIcon(task.status); const stClass = statusClassName(task.status); const isAbandonable = task.kind === 'agent' && task.status === 'paused'; - const canCancel = task.status === 'running' || isAbandonable; + const canCancel = + task.status === 'running' || + isAbandonable || + (task.kind === 'workflow' && + (task.status === 'pausing' || task.status === 'paused')); + const canPause = + task.kind === 'workflow' && + task.isBackgrounded && + task.status === 'running'; + const canResume = task.kind === 'workflow' && task.status === 'paused'; + const canRetry = + task.kind === 'workflow' && !task.isHistorical && task.status === 'failed'; + const canRerun = + task.kind === 'workflow' && + !task.isHistorical && + (task.status === 'completed' || + task.status === 'failed' || + task.status === 'cancelled'); const cancelLabel = isAbandonable ? t('tasks.action.abandon') : t('tasks.action.stop'); @@ -1139,6 +1469,17 @@ function TaskDetail({ }); } + if (task.kind === 'workflow' && task.tokensSpent > 0) { + // Subtitle only: compactFields is read from `headerContent`, which + // short-circuits to null for workflow tasks before it looks at the + // array — a compact entry here renders in no state. + subtitleParts.push( + t('tasks.detail.tokens', { + count: formatContextTokens(task.tokensSpent), + }), + ); + } + if (task.kind === 'agent' && task.stats?.toolUses !== undefined) { subtitleParts.push( t('tasks.detail.toolCalls', { @@ -1151,7 +1492,10 @@ function TaskDetail({ }); } - if (task.kind !== 'agent' && task.pid !== undefined) { + if ( + (task.kind === 'shell' || task.kind === 'monitor') && + task.pid !== undefined + ) { subtitleParts.push(`pid ${task.pid}`); } @@ -1174,8 +1518,9 @@ function TaskDetail({ const promptLines = task.kind === 'agent' && task.prompt ? task.prompt.split('\n') : []; const actionControls = - canCancel && onCancel ? ( -
+ (canCancel && onCancel) || + ((canPause || canResume || canRetry || canRerun) && onWorkflowAction) ? ( +
{showCancelConfirm ? ( <> @@ -1198,14 +1543,51 @@ function TaskDetail({ ) : ( - + <> + {(canPause || canResume) && onWorkflowAction && ( + + )} + {canRetry && onWorkflowAction && ( + + )} + {canRerun && onWorkflowAction && ( + + )} + {canCancel && onCancel && ( + + )} + )}
) : null; @@ -1224,7 +1606,7 @@ function TaskDetail({ {subtitleParts.join(' · ')}
- ) : compactFields.length > 0 ? ( + ) : task.kind === 'workflow' ? null : compactFields.length > 0 ? (
{compactFields .map((field) => `${field.label} ${field.value}`) @@ -1232,14 +1614,18 @@ function TaskDetail({
) : null; + // Workflow controls live in the execution graph's own toolbar, next to the + // metrics they act on, instead of floating above the card. + const topActions = task.kind === 'workflow' ? null : actionControls; + return (
- {(headerContent || actionControls) && ( + {(headerContent || topActions) && (
{headerContent && (
{headerContent}
)} - {actionControls} + {topActions}
)} @@ -1349,6 +1735,17 @@ function TaskDetail({
)} + {task.kind === 'workflow' && ( + + )} + {task.error && (
{ @@ -659,6 +664,20 @@ describe('tool output session links', () => { }); }); +describe('workflow tool classification', () => { + it('does not mistake workflow live output for a subagent panel', () => { + expect( + isSubAgentToolCall( + makeTool({ + toolName: 'workflow', + status: 'in_progress', + subContent: '{"runId":"wf_expected"}', + }), + ), + ).toBe(false); + }); +}); + describe('tool kind logic', () => { it('classifies common tool names for summary icons', () => { expect(getToolHeaderKind(makeTool({ toolName: 'Shell' }))).toBe('shell'); @@ -702,6 +721,83 @@ describe('tool kind logic', () => { }); describe('tool row rendering', () => { + it('expands a workflow tool into its live execution graph', () => { + const tool = makeTool({ + toolName: 'workflow', + status: 'in_progress', + subContent: '```json\n{"runId":"wf_channel","status":"running"}\n```', + }); + const task: DaemonSessionWorkflowTaskStatus = { + kind: 'workflow', + id: 'wf_channel', + label: 'Channel analysis', + description: 'Analyze channel packages', + status: 'running', + startTime: 1_000, + runtimeMs: 200, + isBackgrounded: false, + currentPhase: 'Inspect', + phaseVisits: [ + { + id: 'phase-inspect', + index: 0, + title: 'Inspect', + startedAt: 1_010, + }, + ], + dispatches: [ + { + id: 'dispatch-architecture', + phaseVisitId: 'phase-inspect', + label: 'Architecture Agent', + prompt: 'Inspect the channel architecture', + status: 'running', + dependsOn: [], + queuedAt: 1_020, + startedAt: 1_030, + }, + ], + agentsDispatched: 1, + agentsCompleted: 0, + tokensSpent: 120, + tokenBudgetTotal: null, + recentLogs: [], + pendingApprovalCount: 0, + }; + const container = document.createElement('div'); + document.body.appendChild(container); + const root = createRoot(container); + act(() => { + root.render( + + + + + , + ); + }); + mounted.push({ root, container }); + + const summary = container.querySelector('button') as HTMLButtonElement; + const content = container.querySelector( + '[class*="chatSummaryContentClip"]', + ) as HTMLElement; + expect(summary.getAttribute('aria-expanded')).toBe('false'); + expect(content.className).toContain('chatSummaryContentCollapsed'); + expect(content.getAttribute('aria-hidden')).toBe('true'); + expect(content.hasAttribute('inert')).toBe(true); + expect(container.querySelector('[data-workflow-summary]')).toBeNull(); + + act(() => summary.click()); + + expect(summary.getAttribute('aria-expanded')).toBe('true'); + expect(content.className).not.toContain('chatSummaryContentCollapsed'); + expect(content.getAttribute('aria-hidden')).toBe('false'); + expect(content.hasAttribute('inert')).toBe(false); + expect(container.querySelector('[data-workflow-summary]')).not.toBeNull(); + expect(container.textContent).toContain('Architecture Agent'); + }); + it('renders the aggregate summary for a multi-tool group', () => { const container = renderToolGroup([ makeTool({ diff --git a/packages/web-shell/client/components/messages/ToolGroup.tsx b/packages/web-shell/client/components/messages/ToolGroup.tsx index 10add747471..35be453cc29 100644 --- a/packages/web-shell/client/components/messages/ToolGroup.tsx +++ b/packages/web-shell/client/components/messages/ToolGroup.tsx @@ -34,7 +34,10 @@ import { import { useSharedNow } from '../../hooks/useSharedNow'; import { useSubagentDetails } from '../../subagentDetailsContext'; import { useMonitorDetails } from '../../monitorDetailsContext'; +import { useWorkflowDetails } from '../../workflowDetailsContext'; +import { findWorkflowTaskForTool } from '../../utils/workflowTasks'; import { TodoEventSummary, TodoFullList } from './TodoView'; +import { WorkflowExecutionView } from './WorkflowExecutionView'; import { Markdown } from './Markdown'; import { ThinkingDoneIcon, ThinkingTranslateButton } from './AssistantMessage'; import { @@ -62,6 +65,7 @@ import { isShellToolName, localizeAgentTypeName, toolContainsCallId, + isWorkflowToolName, } from './toolFormatting'; import { useI18n } from '../../i18n'; import { useTranscriptRenderMode } from '../../transcriptRenderMode'; @@ -135,7 +139,8 @@ function hasDetailView(tool: ACPToolCall): boolean { name === 'read_file' || name === 'readfile' || isSkillToolName(name) || - isAskUserQuestionToolName(tool.toolName) + isAskUserQuestionToolName(tool.toolName) || + isWorkflowToolName(name) ); } @@ -408,10 +413,40 @@ interface ToolLineProps { workspaceCwd?: string; summaryOnly?: boolean; forceExpanded?: boolean; + detailsVisible?: boolean; hideHeader?: boolean; hideCollapsedOutput?: boolean; } +function WorkflowToolDetail({ + tool, + displayName, + detail, + result, +}: { + tool: ACPToolCall; + displayName: string; + detail: string; + result: string; +}) { + const { t } = useI18n(); + const workflowDetails = useWorkflowDetails(); + const workflowTask = workflowDetails + ? findWorkflowTaskForTool(workflowDetails.tasks, tool) + : undefined; + return workflowTask ? ( + + ) : ( + +
+ {tool.status === 'pending' || tool.status === 'in_progress' + ? t('workflow.inline.loading') + : result || t('workflow.inline.unavailable')} +
+
+ ); +} + function getAgentDisplayInfo( tool: ACPToolCall, now?: number, @@ -977,6 +1012,7 @@ function areToolLinePropsEqual( if (prev.workspaceCwd !== next.workspaceCwd) return false; if (prev.summaryOnly !== next.summaryOnly) return false; if (prev.forceExpanded !== next.forceExpanded) return false; + if (prev.detailsVisible !== next.detailsVisible) return false; if (prev.hideHeader !== next.hideHeader) return false; if (prev.hideCollapsedOutput !== next.hideCollapsedOutput) return false; const a = prev.tool; @@ -1076,6 +1112,7 @@ export const ToolLine = memo(function ToolLine({ workspaceCwd, summaryOnly = false, forceExpanded = false, + detailsVisible = true, hideHeader = false, hideCollapsedOutput = false, }: ToolLineProps) { @@ -1106,6 +1143,7 @@ export const ToolLine = memo(function ToolLine({ // Set once the user explicitly toggles this row, so auto-collapse-on- // completion never silently overrides their choice. const userToggledRef = useRef(false); + const isWorkflow = isWorkflowToolName(tool.toolName); useEffect( () => { @@ -1511,39 +1549,52 @@ export const ToolLine = memo(function ToolLine({ {renderWithSessionLinks(result, transcriptRenderMode)}
)} - {!mcpApp && !isTodo && expanded && detailView && ( -
- {isRead ? ( - - - - ) : ( - - {isShellToolName(name) && } - {(name === 'write_file' || name === 'writefile') && ( - - )} - {(name === 'edit' || name === 'write' || name === 'editfile') && ( - - )} - {isAskUserQuestionToolName(tool.toolName) && ( - - )} - {isSkillToolName(name) && } - - )} -
- )} + {!mcpApp && + !isTodo && + expanded && + detailView && + (!isWorkflow || detailsVisible) && ( +
+ {isWorkflow ? ( + + ) : isRead ? ( + + + + ) : ( + + {isShellToolName(name) && } + {(name === 'write_file' || name === 'writefile') && ( + + )} + {(name === 'edit' || + name === 'write' || + name === 'editfile') && } + {isAskUserQuestionToolName(tool.toolName) && ( + + )} + {isSkillToolName(name) && } + + )} +
+ )}
); }, areToolLinePropsEqual); @@ -1687,6 +1738,7 @@ export const ToolGroup = memo(function ToolGroup({ : undefined; const singleMcpAppResourceUri = singleMcpApp?.resourceUri; const hasMcpApp = tools.some((tool) => getMcpAppDisplay(tool.rawOutput)); + const hasWorkflow = tools.some((tool) => isWorkflowToolName(tool.toolName)); const hasForegroundActiveTool = tools.some( (tool) => isActiveToolStatus(tool.status) && !isBackgroundSubAgentToolCall(tool), @@ -1807,10 +1859,28 @@ export const ToolGroup = memo(function ToolGroup({ aria-hidden="true" /> - {(chatExpanded || hasMcpApp) && ( + {(chatExpanded || hasMcpApp || hasWorkflow) && (
{ + element?.toggleAttribute('inert', !chatExpanded); + }} + className={ + chatExpanded + ? styles.chatSummaryContentClip + : `${styles.chatSummaryContentClip} ${styles.chatSummaryContentCollapsed}` + } + style={ + !chatExpanded && hasMcpApp && !hasWorkflow + ? { display: 'none' } + : undefined + } >
@@ -1869,6 +1939,7 @@ export const ToolGroup = memo(function ToolGroup({ workspaceCwd={workspaceCwd} summaryOnly={!singleTool || compactToolLines} forceExpanded={!!singleTool && !compactToolLines} + detailsVisible={chatExpanded} hideHeader={!!singleTool && !compactToolLines} /> )} diff --git a/packages/web-shell/client/components/messages/WorkflowExecutionView.module.css b/packages/web-shell/client/components/messages/WorkflowExecutionView.module.css new file mode 100644 index 00000000000..83a0bb89d41 --- /dev/null +++ b/packages/web-shell/client/components/messages/WorkflowExecutionView.module.css @@ -0,0 +1,894 @@ +/* + * Workflow execution graph — flat visual language. + * + * One surface (--background), one hairline (--border), status carried by a + * single accent colour per node/edge. No gradients, drop shadows, glows, or + * dot grids: depth is expressed only through borders and tint, so the graph + * reads the same in the light and dark themes. + */ + +.root { + container-type: inline-size; + display: grid; + min-width: 0; + overflow: hidden; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--background); + color: var(--foreground); + font-size: 12px; + line-height: 1.4; +} + +/* ---------- history strip ---------- */ + +.historyBar { + display: flex; + min-width: 0; + align-items: center; + justify-content: space-between; + gap: 10px; + padding: 6px 12px; + border-bottom: 1px solid var(--border); + color: var(--muted-foreground); + font-size: 12px; +} + +.historyLead { + display: flex; + min-width: 0; + align-items: center; + gap: 8px; +} + +.historyLead > span:not(.historyMark, .cachedBadge) { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.historyActions { + display: flex; + flex: 0 0 auto; + gap: 2px; +} + +.cachedBadge { + flex: 0 0 auto; + padding: 1px 7px; + border-radius: 999px; + background: color-mix(in srgb, var(--success-color) 12%, transparent); + color: var(--success-color); + font-size: 11px; + font-variant-numeric: tabular-nums; +} + +.compareButton { + flex: 0 0 auto; + padding: 3px 8px; + border: 0; + border-radius: 5px; + background: transparent; + color: var(--muted-foreground); + cursor: pointer; + font: inherit; + font-size: 12px; +} + +.compareButton:hover:not(:disabled) { + background: var(--secondary); + color: var(--foreground); +} + +.compareButton:disabled { + cursor: default; + opacity: 0.45; +} + +.compareButton:focus-visible { + outline: 2px solid color-mix(in srgb, var(--primary) 55%, transparent); + outline-offset: 1px; +} + +/* ---------- saved-run ledger ---------- */ + +.historyLedger { + display: grid; + max-height: 224px; + overflow-y: auto; + border-bottom: 1px solid var(--border); +} + +.historyTools { + position: sticky; + z-index: 2; + top: 0; + display: grid; + min-width: 0; + grid-template-columns: auto minmax(0, 1fr) auto; + align-items: center; + gap: 10px; + padding: 6px 12px; + border-bottom: 1px solid var(--border); + background: var(--background); + color: var(--muted-foreground); + font-size: 11px; +} + +.historyTools label { + display: flex; + align-items: center; + gap: 6px; +} + +.historyTools select { + min-width: 100px; + padding: 3px 22px 3px 7px; + border: 1px solid var(--border); + border-radius: 5px; + background: var(--background); + color: var(--foreground); + font: inherit; + font-size: 11px; +} + +.historyTools select:focus-visible { + outline: 2px solid color-mix(in srgb, var(--primary) 55%, transparent); + outline-offset: 1px; +} + +.historyTools > span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.historyEmpty { + padding: 14px 12px; + color: var(--muted-foreground); + font-size: 12px; +} + +.historyRun { + display: grid; + min-width: 0; + grid-template-columns: minmax(0, 1fr) auto; + align-items: stretch; + border-bottom: 1px solid var(--border); +} + +.historyRun:last-child { + border-bottom: 0; +} + +.historyRun:hover, +.historyRun[data-selected='true'] { + background: var(--secondary); +} + +.historyRun[data-selected='true'] { + box-shadow: inset 2px 0 0 var(--agent-blue-500); +} + +.historyRunSelect { + display: grid; + min-width: 0; + grid-template-columns: minmax(150px, 1fr) 68px 48px 44px 56px; + align-items: center; + gap: 10px; + padding: 7px 12px; + border: 0; + background: transparent; + color: var(--muted-foreground); + cursor: pointer; + font: inherit; + font-size: 11px; + font-variant-numeric: tabular-nums; + text-align: left; +} + +.historyRunSelect:focus-visible { + outline: 2px solid color-mix(in srgb, var(--primary) 55%, transparent); + outline-offset: -2px; +} + +.historyDeleteButton, +.confirmDeleteButton { + align-self: center; + margin-right: 8px; + padding: 3px 8px; + border: 1px solid transparent; + border-radius: 5px; + background: transparent; + color: var(--muted-foreground); + cursor: pointer; + font: inherit; + font-size: 11px; + white-space: nowrap; +} + +.historyDeleteButton:hover:not(:disabled) { + color: var(--error-color); +} + +.confirmDeleteButton { + border-color: var(--error-color); + color: var(--error-color); +} + +.confirmDeleteButton:hover:not(:disabled) { + background: color-mix(in srgb, var(--error-color) 10%, transparent); +} + +.confirmDeleteButton:disabled, +.historyDeleteButton:disabled { + cursor: default; + opacity: 0.45; +} + +.historyDeleteButton:focus-visible, +.confirmDeleteButton:focus-visible { + outline: 2px solid color-mix(in srgb, var(--error-color) 52%, transparent); + outline-offset: 1px; +} + +.historyRunIdentity { + display: flex; + min-width: 0; + align-items: baseline; + gap: 8px; +} + +.historyRunIdentity code { + overflow: hidden; + color: var(--foreground); + font-family: var(--font-mono); + font-size: 11px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.historyRunIdentity small { + flex: 0 0 auto; + color: var(--muted-foreground); + font-size: 10px; +} + +.historyRunStatus { + color: var(--foreground); +} + +.historyRun[data-status='running'] .historyRunStatus { + color: var(--agent-blue-500); +} + +.historyRun[data-status='completed'] .historyRunStatus { + color: var(--success-color); +} + +.historyRun[data-status='failed'] .historyRunStatus { + color: var(--error-color); +} + +.historyRun[data-status='paused'] .historyRunStatus, +.historyRun[data-status='pausing'] .historyRunStatus, +.historyRun[data-status='cancelled'] .historyRunStatus { + color: var(--warning-color); +} + +/* ---------- run comparison ---------- */ + +.comparison { + display: grid; + min-width: 0; + grid-template-columns: minmax(72px, 0.6fr) repeat(2, minmax(96px, 1fr)); + border-bottom: 1px solid var(--border); + font-size: 11px; +} + +.comparison > * { + min-width: 0; + padding: 6px 12px; + border-right: 1px solid var(--border); + border-bottom: 1px solid var(--border); +} + +.comparison > :nth-child(3n) { + border-right: 0; +} + +.comparison > :nth-last-child(-n + 3) { + border-bottom: 0; +} + +.comparisonRun { + display: flex; + flex-direction: column; + gap: 2px; +} + +.comparisonRun strong { + color: var(--foreground); + font-weight: 600; +} + +.comparisonRun code { + overflow: hidden; + color: var(--muted-foreground); + font-family: var(--font-mono); + text-overflow: ellipsis; + white-space: nowrap; +} + +.comparisonMetric { + color: var(--muted-foreground); +} + +.comparisonValue { + color: var(--foreground); + font-variant-numeric: tabular-nums; +} + +.comparisonValue[data-status='running'] { + color: var(--agent-blue-500); +} + +.comparisonValue[data-status='completed'] { + color: var(--success-color); +} + +.comparisonValue[data-status='failed'] { + color: var(--error-color); +} + +.comparisonValue[data-status='paused'], +.comparisonValue[data-status='pausing'], +.comparisonValue[data-status='cancelled'] { + color: var(--warning-color); +} + +/* ---------- toolbar: metrics + run controls ---------- */ + +.toolbar { + display: flex; + min-width: 0; + align-items: center; + justify-content: space-between; + gap: 8px 16px; + padding: 8px 12px; + border-bottom: 1px solid var(--border); +} + +.metrics { + display: flex; + min-width: 0; + flex-wrap: wrap; + gap: 4px 14px; + color: var(--muted-foreground); + font-size: 12px; + font-variant-numeric: tabular-nums; +} + +.metrics strong { + color: var(--foreground); + font-weight: 600; +} + +.approvalMetric, +.approvalMetric strong { + color: var(--warning-color); +} + +.toolbarActions { + display: flex; + flex: 0 0 auto; + min-width: 0; +} + +/* The controls come from the task panel with its own classes. Geometry is + restated here so they sit flush with the strip, and every non-primary + variant is deliberately flattened to a neutral outline so the strip reads + as one toolbar — only [data-tone='primary'] keeps the owner's colour. */ +.toolbarActions button { + min-height: 26px; + padding: 3px 10px; + border-radius: 6px; + font-size: 12px; + line-height: 1.3; +} + +.toolbarActions button:not([data-tone='primary']) { + border: 1px solid var(--border); + background: transparent; +} + +.toolbarActions button:not([data-tone='primary']):hover:not(:disabled) { + border-color: var(--border); + background: var(--secondary); +} + +.graphOmission { + padding: 7px 12px; + border-bottom: 1px solid var(--border); + box-shadow: inset 2px 0 0 var(--warning-color); + color: var(--muted-foreground); + font-size: 12px; +} + +/* ---------- graph ---------- */ + +.workbench { + display: grid; + min-width: 0; + grid-template-columns: minmax(0, 1fr) 232px; +} + +.viewport { + min-width: 0; + min-height: 200px; + overflow: auto; + background: var(--background); +} + +.canvas { + position: relative; + min-width: 100%; +} + +.lane { + position: absolute; + top: 0; + bottom: 0; + border-right: 1px solid var(--border); +} + +.lane[data-last='true'] { + border-right: 0; +} + +.laneHeading { + display: flex; + min-width: 0; + height: var(--workflow-lane-header-height, 40px); + align-items: center; + gap: 8px; + padding: 0 14px; + border-bottom: 1px solid var(--border); +} + +.laneIndex { + flex: 0 0 auto; + color: var(--muted-foreground); + font-family: var(--font-mono); + font-size: 11px; +} + +.laneHeading strong { + overflow: hidden; + color: var(--foreground); + font-size: 12px; + font-weight: 600; + text-overflow: ellipsis; + white-space: nowrap; +} + +.laneHeading small { + flex: 0 0 auto; + margin-left: auto; + color: var(--muted-foreground); + font-size: 11px; + font-variant-numeric: tabular-nums; + white-space: nowrap; +} + +.lane[data-active='true'] .laneHeading { + box-shadow: inset 0 -2px 0 var(--agent-blue-500); +} + +.lane[data-active='true'] .laneHeading strong { + color: var(--agent-blue-500); +} + +.edges { + position: absolute; + inset: 0; + overflow: visible; + pointer-events: none; +} + +.edges marker path { + fill: var(--muted-foreground); + fill: context-stroke; +} + +.edge { + fill: none; + stroke: color-mix(in srgb, var(--muted-foreground) 60%, transparent); + stroke-width: 1.25px; + transition: + opacity 120ms ease, + stroke-width 120ms ease; + vector-effect: non-scaling-stroke; +} + +.edge[data-status='running'] { + stroke: var(--agent-blue-500); +} + +.edge[data-status='queued'] { + stroke-dasharray: 3 4; +} + +.edge[data-status='completed'], +.edge[data-status='cached'] { + stroke: var(--success-color); +} + +.edge[data-status='failed'] { + stroke: var(--error-color); +} + +.edge[data-path-emphasis='related'] { + stroke-width: 2px; +} + +.edge[data-path-emphasis='dimmed'] { + opacity: 0.15; +} + +.node { + --workflow-node-accent: var(--muted-foreground); + position: absolute; + display: grid; + width: var(--workflow-node-width); + height: var(--workflow-node-height); + grid-template-columns: 18px minmax(0, 1fr); + align-items: center; + gap: 9px; + padding: 0 10px 0 9px; + border: 1px solid var(--border); + border-left: 3px solid var(--workflow-node-accent); + border-radius: 6px; + background: var(--background); + color: var(--foreground); + cursor: pointer; + font: inherit; + text-align: left; + transition: + border-color 120ms ease, + background-color 120ms ease, + opacity 120ms ease; +} + +.node:hover { + border-color: color-mix(in srgb, var(--foreground) 28%, var(--border)); + border-left-color: var(--workflow-node-accent); +} + +.node:focus-visible { + outline: 2px solid color-mix(in srgb, var(--primary) 55%, transparent); + outline-offset: 2px; +} + +.node[aria-pressed='true'], +.node[data-path-emphasis='active'] { + border-color: var(--workflow-node-accent); + background: color-mix( + in srgb, + var(--workflow-node-accent) 6%, + var(--background) + ); +} + +.node[data-status='running'] { + --workflow-node-accent: var(--agent-blue-500); +} + +.node[data-status='completed'], +.node[data-status='cached'] { + --workflow-node-accent: var(--success-color); +} + +.node[data-status='failed'] { + --workflow-node-accent: var(--error-color); +} + +/* No `opacity: 1` here: the queued/cancelled, related and dimmed rules + below have the same specificity and come later, so they win wherever they + co-occur, and nothing lowers opacity otherwise. Making an approval hold + full opacity through those states would be a deliberate change — raise + the specificity or move the rule past them. */ +.node[data-workflow-approval] { + --workflow-node-accent: var(--warning-color); +} + +.node[data-status='queued'], +.node[data-status='cancelled'] { + opacity: 0.62; +} + +.node[data-status='queued'] { + border-style: dashed; + border-left-style: solid; +} + +.node[data-path-emphasis='related'] { + opacity: 0.92; +} + +.node[data-path-emphasis='dimmed'] { + opacity: 0.24; +} + +.nodeState { + display: grid; + width: 18px; + height: 18px; + place-items: center; + border-radius: 4px; + background: color-mix(in srgb, var(--workflow-node-accent) 14%, transparent); + color: var(--workflow-node-accent); +} + +.nodeState svg { + width: 12px; + height: 12px; + stroke-width: 2.4; +} + +.nodeState > svg, +.nodeState::after { + grid-area: 1 / 1; +} + +.node[data-status='running'] .nodeState::after { + width: 9px; + height: 9px; + border: 1.5px solid color-mix(in srgb, var(--agent-blue-500) 30%, transparent); + border-top-color: var(--agent-blue-500); + border-radius: 50%; + animation: workflow-spin 0.8s linear infinite; + content: ''; +} + +.nodeCopy { + display: flex; + min-width: 0; + flex-direction: column; + gap: 1px; +} + +.nodeCopy strong { + overflow: hidden; + font-size: 12px; + font-weight: 600; + line-height: 1.3; + text-overflow: ellipsis; + white-space: nowrap; +} + +.nodeCopy small { + display: flex; + min-width: 0; + align-items: baseline; + gap: 8px; + font-size: 11px; + line-height: 1.3; +} + +.nodeStatus { + overflow: hidden; + color: var(--workflow-node-accent); + text-overflow: ellipsis; + white-space: nowrap; +} + +.node[data-status='queued'] .nodeStatus, +.node[data-status='cancelled'] .nodeStatus { + color: var(--muted-foreground); +} + +.nodeTime { + flex: 0 0 auto; + color: var(--muted-foreground); + font-family: var(--font-mono); + font-variant-numeric: tabular-nums; +} + +/* ---------- inspector ---------- */ + +.inspector { + display: flex; + min-width: 0; + flex-direction: column; + gap: 12px; + padding: 12px 14px 14px; + border-left: 1px solid var(--border); + background: var(--background); +} + +.inspectorHeading { + display: grid; + min-width: 0; + grid-template-columns: minmax(0, 1fr) auto; + align-items: baseline; + gap: 3px 10px; +} + +.inspectorHeading > span { + grid-column: 1 / -1; + color: var(--muted-foreground); + font-size: 10px; + font-weight: 600; + letter-spacing: 0.06em; + text-transform: uppercase; +} + +.inspectorHeading > strong { + overflow: hidden; + font-size: 13px; + font-weight: 600; + text-overflow: ellipsis; + white-space: nowrap; +} + +.inspectorHeading > small { + color: var(--muted-foreground); + font-size: 11px; + white-space: nowrap; +} + +.inspectorHeading > small[data-status='running'] { + color: var(--agent-blue-500); +} + +.inspectorHeading > small[data-status='completed'], +.inspectorHeading > small[data-status='cached'] { + color: var(--success-color); +} + +.inspectorHeading > small[data-status='failed'] { + color: var(--error-color); +} + +.inspectorPrompt { + max-height: 140px; + margin: 0; + padding-right: 4px; + overflow-y: auto; + overscroll-behavior: contain; + color: var(--muted-foreground); + font-size: 12px; + line-height: 1.5; + overflow-wrap: anywhere; + scrollbar-gutter: stable; +} + +.inspector dl { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + align-items: baseline; + gap: 6px 14px; + margin: 0; + padding-top: 10px; + border-top: 1px solid var(--border); +} + +.inspector dl > div { + display: contents; +} + +.inspector dt { + color: var(--muted-foreground); + font-size: 11px; + line-height: 1.5; + white-space: nowrap; +} + +.inspector dd { + margin: 0; + overflow-wrap: anywhere; + color: var(--foreground); + font-size: 12px; + font-variant-numeric: tabular-nums; + line-height: 1.5; +} + +.dispatchError, +.approvalCallout { + padding: 8px 10px; + border-radius: 0 4px 4px 0; + box-shadow: inset 2px 0 0 var(--error-color); + background: color-mix(in srgb, var(--error-color) 8%, transparent); + color: var(--error-color); + font-size: 12px; + line-height: 1.5; + overflow-wrap: anywhere; +} + +.dispatchError { + font-family: var(--font-mono); + font-size: 11px; +} + +.approvalCallout { + display: flex; + flex-direction: column; + gap: 3px; + box-shadow: inset 2px 0 0 var(--warning-color); + background: color-mix(in srgb, var(--warning-color) 8%, transparent); + color: var(--foreground); +} + +.approvalCallout strong { + color: var(--warning-color); + font-weight: 600; +} + +.approvalCallout small { + color: var(--muted-foreground); + font-size: 11px; +} + +.empty { + padding: 16px 12px; + color: var(--muted-foreground); + font-size: 12px; +} + +/* ---------- narrow container ---------- */ + +@container (max-width: 680px) { + .historyBar, + .toolbar { + align-items: flex-start; + flex-wrap: wrap; + } + + .historyActions, + .toolbarActions { + width: 100%; + } + + .historyTools { + grid-template-columns: minmax(0, 1fr) auto; + } + + .historyTools > span { + display: none; + } + + .historyRunSelect { + grid-template-columns: minmax(128px, 1fr) 60px 44px; + } + + .historyRunSelect > :nth-child(4), + .historyRunSelect > :nth-child(5) { + display: none; + } + + .workbench { + grid-template-columns: 1fr; + } + + .inspector { + border-top: 1px solid var(--border); + border-left: 0; + } +} + +@keyframes workflow-spin { + to { + transform: rotate(360deg); + } +} + +@media (prefers-reduced-motion: reduce) { + .edge, + .node, + .nodeState::after { + animation-duration: 0.001ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.001ms !important; + } +} diff --git a/packages/web-shell/client/components/messages/WorkflowExecutionView.test.tsx b/packages/web-shell/client/components/messages/WorkflowExecutionView.test.tsx new file mode 100644 index 00000000000..7324f3f1e9f --- /dev/null +++ b/packages/web-shell/client/components/messages/WorkflowExecutionView.test.tsx @@ -0,0 +1,772 @@ +// @vitest-environment jsdom + +import { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import type { DaemonSessionWorkflowTaskStatus } from '@qwen-code/sdk/daemon'; +import { I18nProvider } from '../../i18n'; +import { + buildWorkflowGraphLayout, + WORKFLOW_GRAPH_RENDER_LIMITS, + WorkflowExecutionView, +} from './WorkflowExecutionView'; + +const mounted: Array<{ root: Root; container: HTMLElement }> = []; + +afterEach(() => { + for (const { root, container } of mounted) { + act(() => root.unmount()); + container.remove(); + } + mounted.length = 0; + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +function workflowTask( + overrides: Partial = {}, +): DaemonSessionWorkflowTaskStatus { + return { + kind: 'workflow', + id: 'wf-1', + label: 'review-and-fix', + description: 'Review and fix', + status: 'running', + startTime: 1_000, + runtimeMs: 2_000, + isBackgrounded: true, + currentPhase: 'Review', + phaseVisits: [ + { + id: 'phase-1', + index: 0, + title: 'Inspect', + startedAt: 1_000, + endedAt: 1_200, + }, + { id: 'phase-2', index: 1, title: 'Review', startedAt: 1_200 }, + ], + dispatches: [ + { + id: 'dispatch-1', + phaseVisitId: 'phase-1', + label: 'Scope mapper', + prompt: 'Inspect repository boundaries', + status: 'completed', + dependsOn: [], + queuedAt: 1_010, + startedAt: 1_020, + endedAt: 1_100, + }, + { + id: 'dispatch-2', + phaseVisitId: 'phase-2', + label: 'Correctness', + prompt: 'Review behavior regressions', + subagentId: 'correctness-agent-1', + status: 'running', + dependsOn: ['dispatch-1'], + queuedAt: 1_210, + startedAt: 1_220, + }, + { + id: 'dispatch-3', + phaseVisitId: 'phase-2', + label: 'Architecture', + prompt: 'Review ownership boundaries', + status: 'queued', + dependsOn: ['dispatch-1'], + queuedAt: 1_210, + }, + ], + agentsDispatched: 3, + agentsCompleted: 1, + tokensSpent: 1_200, + tokenBudgetTotal: 8_000, + recentLogs: [], + pendingApprovalCount: 0, + pendingApprovals: [], + ...overrides, + }; +} + +describe('WorkflowExecutionView', () => { + it('shows a saved run as its final graph without replay controls', () => { + const task = workflowTask({ + isHistorical: true, + status: 'completed', + endTime: 3_000, + agentsCompleted: 3, + dispatches: workflowTask().dispatches.map((dispatch) => ({ + ...dispatch, + status: 'completed' as const, + endedAt: dispatch.endedAt ?? 2_000, + })), + }); + const container = document.createElement('div'); + document.body.appendChild(container); + const root = createRoot(container); + mounted.push({ root, container }); + + act(() => { + root.render( + + + , + ); + }); + + expect(container.querySelector('[data-run-replay]')).toBeNull(); + expect(container.querySelector('input[type="range"]')).toBeNull(); + expect(container.textContent).not.toContain('Run replay'); + expect( + container.querySelector('[data-workflow-summary]')?.textContent, + ).toContain('3/3 agents'); + expect( + container.querySelector('[data-workflow-edge="dispatch-1:dispatch-2"]'), + ).not.toBeNull(); + }); + + it('builds edges only from recorded dispatch dependencies', () => { + const task = workflowTask(); + task.dispatches[2]!.dependsOn = ['dispatch-1', 'missing']; + + const layout = buildWorkflowGraphLayout(task); + + expect(layout.lanes.map((lane) => lane.title)).toEqual([ + 'Inspect', + 'Review', + ]); + expect(layout.edges.map(({ from, to }) => [from, to])).toEqual([ + ['dispatch-1', 'dispatch-2'], + ['dispatch-1', 'dispatch-3'], + ]); + }); + + it('keeps a large workflow graph bounded and reports omitted content', () => { + const phaseVisits = Array.from({ length: 80 }, (_, index) => ({ + id: `large-phase-${index}`, + index, + title: `Phase ${index}`, + startedAt: 1_000 + index, + endedAt: 2_000 + index, + })); + const dispatches = Array.from({ length: 300 }, (_, index) => ({ + id: `large-dispatch-${index}`, + phaseVisitId: `large-phase-${index % phaseVisits.length}`, + label: `Agent ${index}`, + prompt: `Prompt ${index}`, + status: 'completed' as const, + dependsOn: Array.from( + { length: index }, + (_, dependencyIndex) => `large-dispatch-${dependencyIndex}`, + ), + queuedAt: 1_000 + index, + startedAt: 1_100 + index, + endedAt: 1_200 + index, + })); + const task = workflowTask({ + phaseVisits, + dispatches, + agentsDispatched: dispatches.length, + agentsCompleted: dispatches.length, + }); + + const layout = buildWorkflowGraphLayout(task); + + expect(layout.lanes).toHaveLength(WORKFLOW_GRAPH_RENDER_LIMITS.lanes); + expect(layout.nodes).toHaveLength(WORKFLOW_GRAPH_RENDER_LIMITS.nodes); + expect(layout.edges).toHaveLength(WORKFLOW_GRAPH_RENDER_LIMITS.edges); + expect(layout.omittedLanes).toBe(16); + expect(layout.omittedNodes).toBe(60); + expect(layout.omittedEdges).toBeGreaterThan(0); + expect(layout.dispatchCountByLaneId.get('large-phase-0')).toBe(4); + expect(layout.dispatchStatusById.get('large-dispatch-299')).toBe( + 'completed', + ); + + const container = document.createElement('div'); + document.body.appendChild(container); + const root = createRoot(container); + mounted.push({ root, container }); + act(() => { + root.render( + + + , + ); + }); + + expect(container.querySelectorAll('[data-workflow-lane]')).toHaveLength( + WORKFLOW_GRAPH_RENDER_LIMITS.lanes, + ); + expect(container.querySelectorAll('[data-workflow-dispatch]')).toHaveLength( + WORKFLOW_GRAPH_RENDER_LIMITS.nodes, + ); + expect(container.querySelectorAll('[data-workflow-edge]')).toHaveLength( + WORKFLOW_GRAPH_RENDER_LIMITS.edges, + ); + const omission = container.querySelector('[data-workflow-graph-omission]'); + expect(omission?.textContent).toContain('16 phases'); + expect(omission?.textContent).toContain('60 agents'); + expect(omission?.textContent).toContain( + `${layout.omittedEdges} connections`, + ); + }); + + it('shows the selected dispatch prompt when a node is chosen', () => { + const container = document.createElement('div'); + document.body.appendChild(container); + const root = createRoot(container); + mounted.push({ root, container }); + act(() => { + root.render( + + + , + ); + }); + + const architecture = Array.from( + container.querySelectorAll('button'), + ).find((button) => button.textContent?.includes('Architecture')); + expect(architecture).toBeDefined(); + act(() => architecture!.click()); + + expect(container.textContent).toContain('Review ownership boundaries'); + expect( + container.querySelector('[data-selected-dispatch="dispatch-3"]'), + ).not.toBeNull(); + expect(container.querySelectorAll('[data-workflow-edge]')).toHaveLength(2); + expect( + container.querySelector('[data-active="true"] strong')?.textContent, + ).toBe('Review'); + expect(container.querySelector('[data-workflow-prompt]')).not.toBeNull(); + }); + + it('resets dispatch selection when a new run reuses dispatch ids', () => { + const container = document.createElement('div'); + document.body.appendChild(container); + const root = createRoot(container); + mounted.push({ root, container }); + const firstRun = workflowTask({ id: 'wf-first' }); + act(() => { + root.render( + + + , + ); + }); + const architecture = Array.from( + container.querySelectorAll('button'), + ).find((button) => button.textContent?.includes('Architecture')); + act(() => architecture!.click()); + expect( + container.querySelector('[data-selected-dispatch="dispatch-3"]'), + ).not.toBeNull(); + + const secondRun = workflowTask({ + id: 'wf-second', + dispatches: [ + ...workflowTask().dispatches, + { + id: 'dispatch-4', + phaseVisitId: 'phase-2', + label: 'New failure', + prompt: 'Inspect the new failure', + status: 'failed', + dependsOn: ['dispatch-3'], + queuedAt: 1_300, + startedAt: 1_310, + endedAt: 1_320, + error: 'new failure', + }, + ], + agentsDispatched: 4, + agentsCompleted: 2, + }); + act(() => { + root.render( + + + , + ); + }); + + expect( + container.querySelector('[data-selected-dispatch="dispatch-4"]'), + ).not.toBeNull(); + expect(container.textContent).toContain('Inspect the new failure'); + }); + + it('focuses direct graph connections on hover and keyboard focus', () => { + const container = document.createElement('div'); + document.body.appendChild(container); + const root = createRoot(container); + mounted.push({ root, container }); + act(() => { + root.render( + + + , + ); + }); + + const correctness = container.querySelector( + '[data-workflow-dispatch="dispatch-2"]', + )!; + const architecture = container.querySelector( + '[data-workflow-dispatch="dispatch-3"]', + )!; + const correctnessEdge = container.querySelector( + '[data-workflow-edge="dispatch-1:dispatch-2"]', + )!; + const architectureEdge = container.querySelector( + '[data-workflow-edge="dispatch-1:dispatch-3"]', + )!; + + act(() => { + correctness.dispatchEvent(new MouseEvent('mouseover', { bubbles: true })); + }); + expect(correctness.getAttribute('data-path-emphasis')).toBe('active'); + expect( + container + .querySelector('[data-workflow-dispatch="dispatch-1"]') + ?.getAttribute('data-path-emphasis'), + ).toBe('related'); + expect(architecture.getAttribute('data-path-emphasis')).toBe('dimmed'); + expect(correctnessEdge.getAttribute('data-path-emphasis')).toBe('related'); + expect(architectureEdge.getAttribute('data-path-emphasis')).toBe('dimmed'); + + act(() => { + correctness.dispatchEvent(new MouseEvent('mouseout', { bubbles: true })); + }); + expect(correctnessEdge.hasAttribute('data-path-emphasis')).toBe(false); + expect(architectureEdge.hasAttribute('data-path-emphasis')).toBe(false); + + act(() => { + architecture.focus(); + }); + expect(architecture.getAttribute('data-path-emphasis')).toBe('active'); + expect( + container.querySelector('[data-selected-dispatch="dispatch-3"]'), + ).not.toBeNull(); + expect(container.querySelector('[data-workflow-prompt]')?.textContent).toBe( + 'Review ownership boundaries', + ); + expect(correctnessEdge.getAttribute('data-path-emphasis')).toBe('dimmed'); + expect(architectureEdge.getAttribute('data-path-emphasis')).toBe('related'); + + act(() => architecture.blur()); + expect(correctnessEdge.hasAttribute('data-path-emphasis')).toBe(false); + expect(architectureEdge.hasAttribute('data-path-emphasis')).toBe(false); + }); + + it('keeps the user selection when the newest approval settles', () => { + // Tracking one last-observed id cannot tell "a new approval arrived" + // from "the newest was settled and an older one is now last": answering + // the newest of two made the next poll re-select the older one's owner + // and yank the inspector off the node the user was reading. + const task = workflowTask(); + task.pendingApprovalCount = 2; + task.pendingApprovals = [ + { + approvalId: 'wfap-1', + subagentId: 'correctness-agent-1', + name: 'write_file', + description: 'First', + at: 1_300, + }, + { + approvalId: 'wfap-2', + subagentId: 'architecture-agent-1', + name: 'write_file', + description: 'Second', + at: 1_400, + }, + ]; + const container = document.createElement('div'); + document.body.appendChild(container); + const root = createRoot(container); + mounted.push({ root, container }); + const render = (next: typeof task) => + act(() => { + root.render( + + + , + ); + }); + + render(task); + + // The user deliberately inspects a node that owns no approval. + const manual = Array.from( + container.querySelectorAll('button'), + ).find((button) => button.textContent?.includes('Architecture')); + expect(manual).toBeDefined(); + act(() => manual!.click()); + expect( + container.querySelector('[data-selected-dispatch="dispatch-3"]'), + ).not.toBeNull(); + + // The newest approval is answered; the next poll delivers only the older + // one. That is not a new approval and must not move the selection. + const afterSettle = { + ...task, + pendingApprovalCount: 1, + pendingApprovals: [task.pendingApprovals![0]!], + }; + render(afterSettle); + + expect( + container.querySelector('[data-selected-dispatch="dispatch-3"]'), + ).not.toBeNull(); + }); + + it('locates a pending permission on its dispatch without duplicating approval controls', () => { + const task = workflowTask(); + task.pendingApprovalCount = 1; + task.pendingApprovals = [ + { + approvalId: 'wfap-1', + subagentId: 'correctness-agent-1', + name: 'write_file', + description: 'Update the implementation', + at: 1_300, + }, + ]; + const container = document.createElement('div'); + document.body.appendChild(container); + const root = createRoot(container); + mounted.push({ root, container }); + + act(() => { + root.render( + + + , + ); + }); + + expect( + container.querySelector('[data-workflow-approval="wfap-1"]'), + ).not.toBeNull(); + expect(container.textContent).toContain('Approval needed'); + expect(container.textContent).toContain('Update the implementation'); + expect(container.textContent).toContain('Respond in chat'); + expect(container.querySelectorAll('button')).toHaveLength(3); + }); + + it('shows how many dispatches were restored from a retry journal', () => { + const task = workflowTask({ + sourceRunId: 'wf-1', + startMode: 'retry', + dispatches: [ + { + ...workflowTask().dispatches[0]!, + status: 'cached', + }, + ...workflowTask().dispatches.slice(1), + ], + }); + const container = document.createElement('div'); + document.body.appendChild(container); + const root = createRoot(container); + mounted.push({ root, container }); + + act(() => { + root.render( + + + , + ); + }); + + expect(container.textContent).toContain('Retried from wf-1'); + expect(container.textContent).toContain('1 cached'); + }); + + it('expands a source and current run comparison for a full rerun', () => { + const sourceTask = workflowTask({ + id: 'wf-source', + status: 'failed', + runtimeMs: 5_000, + agentsDispatched: 4, + agentsCompleted: 3, + tokensSpent: 4_000, + }); + const task = workflowTask({ + id: 'wf-current', + sourceRunId: sourceTask.id, + startMode: 'rerun', + runtimeMs: 2_000, + agentsDispatched: 3, + agentsCompleted: 1, + tokensSpent: 1_200, + }); + const container = document.createElement('div'); + document.body.appendChild(container); + const root = createRoot(container); + mounted.push({ root, container }); + + act(() => { + root.render( + + + , + ); + }); + + const compare = Array.from( + container.querySelectorAll('button'), + ).find((button) => button.textContent === 'Compare runs'); + expect(compare).toBeDefined(); + expect(compare?.getAttribute('aria-expanded')).toBe('false'); + expect(container.querySelector('[data-run-comparison]')).toBeNull(); + + act(() => compare!.click()); + + const comparison = container.querySelector('[data-run-comparison]'); + expect(compare?.getAttribute('aria-expanded')).toBe('true'); + expect(comparison).not.toBeNull(); + expect(comparison?.textContent).toContain('wf-source'); + expect(comparison?.textContent).toContain('wf-current'); + expect(comparison?.textContent).toContain('3/4'); + expect(comparison?.textContent).toContain('1/3'); + expect(comparison?.textContent).toContain('4.0k'); + expect(comparison?.textContent).toContain('1.2k'); + }); + + it('opens saved run history and compares a selected historical run', () => { + const older = workflowTask({ + id: 'wf-older', + isHistorical: true, + status: 'completed', + startTime: 500, + runtimeMs: 4_000, + agentsDispatched: 2, + agentsCompleted: 2, + tokensSpent: 700, + }); + const failed = workflowTask({ + id: 'wf-failed', + isHistorical: true, + status: 'failed', + startTime: 1_000, + runtimeMs: 5_000, + agentsDispatched: 4, + agentsCompleted: 3, + tokensSpent: 4_000, + }); + const current = workflowTask({ id: 'wf-current', startTime: 2_000 }); + const container = document.createElement('div'); + document.body.appendChild(container); + const root = createRoot(container); + mounted.push({ root, container }); + + act(() => { + root.render( + + + , + ); + }); + + const history = Array.from( + container.querySelectorAll('button'), + ).find((button) => button.textContent === 'Run history (2)'); + expect(history).toBeDefined(); + act(() => history!.click()); + + expect( + container.querySelector('[data-run-history]')?.textContent, + ).toContain('wf-failed'); + const failedRun = container.querySelector( + '[data-history-run="wf-failed"]', + ); + expect(failedRun).not.toBeNull(); + act(() => failedRun!.click()); + + const comparison = container.querySelector('[data-run-comparison]'); + expect(comparison?.textContent).toContain('wf-failed'); + expect(comparison?.textContent).toContain('wf-current'); + expect(comparison?.textContent).toContain('3/4'); + expect(comparison?.textContent).toContain('4.0k'); + + act(() => failedRun!.click()); + + expect(container.querySelector('[data-run-comparison]')).toBeNull(); + expect(failedRun?.getAttribute('aria-pressed')).toBe('false'); + }); + + it('filters saved runs and exports only the visible history', async () => { + const completed = workflowTask({ + id: 'wf-completed', + isHistorical: true, + status: 'completed', + startTime: 2_000, + endTime: 3_000, + }); + const failed = workflowTask({ + id: 'wf-failed', + isHistorical: true, + status: 'failed', + startTime: 1_000, + endTime: 1_500, + label: 'sensitive workflow label', + description: 'sensitive workflow description', + recentLogs: ['sensitive workflow log'], + error: 'sensitive workflow error', + pendingApprovalCount: 1, + pendingApprovals: [ + { + approvalId: 'wfap-sensitive', + subagentId: 'sensitive-subagent', + name: 'sensitive approval name', + description: 'sensitive approval description', + at: 1_400, + }, + ], + events: [ + { + id: 'event-1', + type: 'log', + at: 1_400, + message: 'sensitive event log', + }, + ], + dispatches: workflowTask().dispatches.map((dispatch) => ({ + ...dispatch, + label: 'sensitive dispatch label', + prompt: 'sensitive dispatch prompt', + subagentId: 'sensitive-subagent', + error: 'sensitive dispatch error', + })), + }); + const createObjectURL = vi.fn(() => 'blob:workflow-history'); + vi.stubGlobal('URL', { + ...URL, + createObjectURL, + revokeObjectURL: vi.fn(), + }); + vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {}); + const container = document.createElement('div'); + document.body.appendChild(container); + const root = createRoot(container); + mounted.push({ root, container }); + + act(() => { + root.render( + + + , + ); + }); + const history = Array.from(container.querySelectorAll('button')).find( + (button) => button.textContent === 'Run history (2)', + ); + act(() => history?.click()); + const filter = container.querySelector( + '[aria-label="Filter runs"]', + ); + expect(filter).not.toBeNull(); + act(() => { + filter!.value = 'failed'; + filter!.dispatchEvent(new Event('change', { bubbles: true })); + }); + + expect( + container.querySelector('[data-history-run="wf-failed"]'), + ).not.toBeNull(); + expect( + container.querySelector('[data-history-run="wf-completed"]'), + ).toBeNull(); + const exportButton = Array.from(container.querySelectorAll('button')).find( + (button) => button.textContent === 'Export visible', + ); + act(() => exportButton?.click()); + + expect(createObjectURL).toHaveBeenCalledOnce(); + const blob = createObjectURL.mock.calls[0]![0] as Blob; + const text = await new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onerror = () => reject(reader.error); + reader.onload = () => resolve(String(reader.result)); + reader.readAsText(blob); + }); + const exported = JSON.parse(text) as { + runs: Array<{ + id: string; + dispatches: Array>; + }>; + }; + expect(exported.runs.map((run) => run.id)).toEqual(['wf-failed']); + expect(exported.runs[0]?.dispatches[0]).toEqual({ + id: 'dispatch-1', + phaseVisitId: 'phase-1', + status: 'completed', + dependsOn: [], + queuedAt: 1_010, + startedAt: 1_020, + endedAt: 1_100, + }); + expect(text).not.toMatch( + /sensitive workflow|sensitive dispatch|sensitive approval|sensitive-subagent|sensitive event/, + ); + + act(() => { + filter!.value = 'cancelled'; + filter!.dispatchEvent(new Event('change', { bubbles: true })); + }); + expect(container.textContent).toContain('No saved runs match this filter.'); + expect(exportButton?.disabled).toBe(true); + }); + + it('requires confirmation before deleting an individual saved run', () => { + const onDeleteHistory = vi.fn(); + const container = document.createElement('div'); + document.body.appendChild(container); + const root = createRoot(container); + mounted.push({ root, container }); + act(() => { + root.render( + + + , + ); + }); + const history = Array.from(container.querySelectorAll('button')).find( + (button) => button.textContent === 'Run history (1)', + ); + act(() => history?.click()); + const remove = Array.from(container.querySelectorAll('button')).find( + (button) => button.textContent === 'Delete', + ); + act(() => remove?.click()); + expect(onDeleteHistory).not.toHaveBeenCalled(); + + const confirm = Array.from(container.querySelectorAll('button')).find( + (button) => button.textContent === 'Confirm delete', + ); + act(() => confirm?.click()); + + expect(onDeleteHistory).toHaveBeenCalledWith('wf-abcd'); + }); +}); diff --git a/packages/web-shell/client/components/messages/WorkflowExecutionView.tsx b/packages/web-shell/client/components/messages/WorkflowExecutionView.tsx new file mode 100644 index 00000000000..17c5045fd59 --- /dev/null +++ b/packages/web-shell/client/components/messages/WorkflowExecutionView.tsx @@ -0,0 +1,983 @@ +import { + useEffect, + useId, + useLayoutEffect, + useMemo, + useRef, + useState, + type CSSProperties, + type ReactNode, +} from 'react'; +import type { + DaemonSessionWorkflowTaskStatus, + DaemonWorkflowDispatchStatus, + DaemonWorkflowDispatchStatusEntry, +} from '@qwen-code/sdk/daemon'; +import { + BanIcon, + CheckCheckIcon, + CheckIcon, + CircleHelpIcon, + ClockIcon, + XIcon, +} from 'lucide-react'; +import { useI18n } from '../../i18n'; +import { formatRuntime } from '../../utils/formatRuntime'; +import { formatContextTokens } from '../../utils/formatTokenCount'; +import { formatTimestamp } from '../MessageTimestamp'; +import styles from './WorkflowExecutionView.module.css'; + +/** + * Lanes are one phase visit each. They stretch to share the viewport width + * between LANE_MIN_WIDTH and LANE_MAX_WIDTH so a three-phase run fills a wide + * card instead of leaving a blank strip; nodes grow with their lane up to + * NODE_MAX_WIDTH. + */ +const LANE_MIN_WIDTH = 196; +const LANE_MAX_WIDTH = 336; +const LANE_HEADER_HEIGHT = 40; +const LANE_INSET = 12; +const NODE_MAX_WIDTH = 300; +const NODE_HEIGHT = 54; +const NODE_GAP = 12; +const CANVAS_PADDING = 14; +export const WORKFLOW_GRAPH_RENDER_LIMITS = { + lanes: 64, + nodes: 240, + edges: 400, +} as const; +type WorkflowHistoryFilter = 'all' | 'completed' | 'failed' | 'cancelled'; + +interface WorkflowGraphNode { + dispatch: DaemonWorkflowDispatchStatusEntry; + x: number; + y: number; +} + +interface WorkflowGraphEdge { + from: string; + to: string; + d: string; +} + +export interface WorkflowGraphLayout { + width: number; + height: number; + laneWidth: number; + nodeWidth: number; + lanes: Array<{ id: string | null; title: string; index: number }>; + nodes: WorkflowGraphNode[]; + edges: WorkflowGraphEdge[]; + dispatchCountByLaneId: ReadonlyMap; + dispatchStatusById: ReadonlyMap; + omittedLanes: number; + omittedNodes: number; + omittedEdges: number; +} + +export function buildWorkflowGraphLayout( + task: DaemonSessionWorkflowTaskStatus, + options: { laneWidth?: number } = {}, +): WorkflowGraphLayout { + const laneWidth = Math.min( + LANE_MAX_WIDTH, + Math.max(LANE_MIN_WIDTH, Math.floor(options.laneWidth ?? LANE_MIN_WIDTH)), + ); + const nodeWidth = Math.min(NODE_MAX_WIDTH, laneWidth - LANE_INSET * 2); + const hasUnphased = task.dispatches.some( + (dispatch) => dispatch.phaseVisitId === null, + ); + const phaseLaneLimit = Math.max( + 0, + WORKFLOW_GRAPH_RENDER_LIMITS.lanes - (hasUnphased ? 1 : 0), + ); + const lanes = [ + ...(hasUnphased ? [{ id: null, title: 'No phase', index: 0 }] : []), + ...task.phaseVisits.slice(0, phaseLaneLimit).map((visit, index) => ({ + id: visit.id, + title: visit.title, + index: index + (hasUnphased ? 1 : 0), + })), + ]; + if (lanes.length === 0) lanes.push({ id: null, title: 'No phase', index: 0 }); + + const laneIndexById = new Map(lanes.map((lane) => [lane.id, lane.index])); + const dispatchCountByLaneId = new Map(); + const dispatchStatusById = new Map(); + for (const dispatch of task.dispatches) { + dispatchCountByLaneId.set( + dispatch.phaseVisitId, + (dispatchCountByLaneId.get(dispatch.phaseVisitId) ?? 0) + 1, + ); + dispatchStatusById.set(dispatch.id, dispatch.status); + } + const rowByLane = new Map(); + const nodes: WorkflowGraphNode[] = []; + for (const dispatch of task.dispatches) { + if (nodes.length >= WORKFLOW_GRAPH_RENDER_LIMITS.nodes) break; + const laneIndex = laneIndexById.get(dispatch.phaseVisitId); + if (laneIndex === undefined && dispatch.phaseVisitId !== null) continue; + const visibleLaneIndex = laneIndex ?? 0; + const row = rowByLane.get(visibleLaneIndex) ?? 0; + rowByLane.set(visibleLaneIndex, row + 1); + nodes.push({ + dispatch, + x: visibleLaneIndex * laneWidth + LANE_INSET, + y: LANE_HEADER_HEIGHT + CANVAS_PADDING + row * (NODE_HEIGHT + NODE_GAP), + }); + } + const nodeById = new Map(nodes.map((node) => [node.dispatch.id, node])); + const allDispatchIds = new Set(task.dispatches.map(({ id }) => id)); + let totalEdgeCount = 0; + for (const dispatch of task.dispatches) { + for (const dependencyId of dispatch.dependsOn) { + if (allDispatchIds.has(dependencyId)) totalEdgeCount += 1; + } + } + const edges: WorkflowGraphEdge[] = []; + for (const target of nodes) { + for (const dependencyId of target.dispatch.dependsOn) { + if (edges.length >= WORKFLOW_GRAPH_RENDER_LIMITS.edges) break; + const source = nodeById.get(dependencyId); + if (!source) continue; + const startX = source.x + nodeWidth; + const startY = source.y + NODE_HEIGHT / 2; + const endX = target.x; + const endY = target.y + NODE_HEIGHT / 2; + const bend = Math.max(24, Math.abs(endX - startX) * 0.5); + edges.push({ + from: dependencyId, + to: target.dispatch.id, + d: `M ${startX} ${startY} C ${startX + bend} ${startY}, ${endX - bend} ${endY}, ${endX} ${endY}`, + }); + } + } + const maxRows = Math.max(1, ...rowByLane.values()); + return { + width: lanes.length * laneWidth, + height: + LANE_HEADER_HEIGHT + + CANVAS_PADDING * 2 + + maxRows * NODE_HEIGHT + + Math.max(0, maxRows - 1) * NODE_GAP, + laneWidth, + nodeWidth, + lanes, + nodes, + edges, + dispatchCountByLaneId, + dispatchStatusById, + omittedLanes: Math.max( + 0, + task.phaseVisits.length + (hasUnphased ? 1 : 0) - lanes.length, + ), + omittedNodes: Math.max(0, task.dispatches.length - nodes.length), + omittedEdges: Math.max(0, totalEdgeCount - edges.length), + }; +} + +function statusLabel( + status: DaemonWorkflowDispatchStatus, + t: ReturnType['t'], +): string { + return t(`workflow.dispatch.${status}`); +} + +function statusIcon(status: DaemonWorkflowDispatchStatus): ReactNode { + switch (status) { + case 'completed': + return
+ + {loadError && ( +
+ {t('workflowRuns.loadFailed')} +
+ )} + + {!connection.sessionId ? ( +
{t('workflowRuns.noSession')}
+ ) : loading && !snapshot ? ( +
{t('workflowRuns.loading')}
+ ) : ( + snapshot && ( + <> + + {startError && ( +
+ {t('workflowRuns.startFailed')} +
+ )} + {savedWorkflows.length === 0 ? ( +
+
+ ) : ( +
+ {savedWorkflows.map((workflow) => { + const expanded = + selectedWorkflow?.name === workflow.name && + selectedWorkflow.source === workflow.source; + const detailId = `workflow-saved-detail-${workflow.source}-${workflow.name}`; + return ( +
+
+ + + {workflow.source === 'project' + ? t('workflowRuns.project') + : t('workflowRuns.user')} + + +
+ {expanded && ( + + setShowSource((visible) => !visible) + } + onRetry={() => void loadDetail(workflow.name)} + onViewHistory={() => setTab('history')} + /> + )} +
+ ); + })} +
+ )} +
+ {/* forceMount: the snapshot's only live update path is the + poller inside these panes, and Radix unmounts an inactive + pane — so on the default 'saved' tab the Active/History + badges and the saved cards' recent-runs list froze at + whatever the last reload saw. Radix keeps forced content + hidden while inactive, so only the poller stays alive. */} + + + + + + + + ) + )} + +
+ ); +} + +function SavedWorkflowDetail({ + id, + name, + state, + recentRuns, + showSource, + onToggleSource, + onRetry, + onViewHistory, +}: { + id: string; + name: string; + state: SavedWorkflowDetailState | null; + recentRuns: readonly DaemonSessionTaskWithWorkflowStatus[]; + showSource: boolean; + onToggleSource: () => void; + onRetry: () => void; + onViewHistory: () => void; +}) { + const { t } = useI18n(); + if (!state || state.status === 'loading') { + return ( +
+
+ {t('workflowRuns.detail.loading')} +
+
+ ); + } + if (state.status !== 'loaded') { + return ( +
+
+ {state.status === 'unavailable' + ? t('workflowRuns.detail.unavailable') + : t('workflowRuns.detail.loadFailed')} +
+ +
+ ); + } + const { detail } = state; + const meta = detail.meta; + const phases = meta?.phases ?? []; + return ( +
+

+ {meta?.description ?? t('workflowRuns.detail.noDescription')} +

+ {meta?.whenToUse && ( +
+

+ {t('workflowRuns.detail.whenToUse')} +

+

{meta.whenToUse}

+
+ )} + {detail.metaError && ( +
+ {t('workflowRuns.detail.metaError', { error: detail.metaError })} +
+ )} + {phases.length > 0 && ( +
+

+ {t('workflowRuns.detail.phases', { count: phases.length })} +

+
    + {phases.map((phase, index) => ( +
  1. + + {String(index + 1).padStart(2, '0')} + + + {phase.title} + {phase.detail && {phase.detail}} + + {phase.model && ( + + {phase.model} + + )} +
  2. + ))} +
+
+ )} +
+

+ {t('workflowRuns.detail.recentRuns')} +

+ {recentRuns.length === 0 ? ( +

+ {t('workflowRuns.detail.noRuns')} +

+ ) : ( +
    + {recentRuns.slice(0, RECENT_RUNS_LIMIT).map((run) => ( +
  • + + {t(`tasks.${run.status}`)} + + + {formatTimestamp(run.startTime)} + + + {formatRuntime(run.runtimeMs)} + +
  • + ))} +
+ )} + {recentRuns.length > 0 && ( + + )} +
+
+
+ + + {detail.scriptPath} + +
+ {showSource && ( +
+ +
+ )} +
+
+ ); +} + +/** Wrap the script in a JS fence, using a longer fence than any run of backticks inside it. */ +function fenceScript(script: string): string { + const longest = Math.max( + 2, + ...Array.from(script.matchAll(/`+/g), (match) => match[0].length), + ); + const fence = '`'.repeat(longest + 1); + return `${fence}js\n${script.replace(/\n?$/, '\n')}${fence}`; +} diff --git a/packages/web-shell/client/customization.tsx b/packages/web-shell/client/customization.tsx index 7769a433e99..407cad3f4f3 100644 --- a/packages/web-shell/client/customization.tsx +++ b/packages/web-shell/client/customization.tsx @@ -464,10 +464,27 @@ export interface WebShellMonitorTask extends WebShellTaskBase { exitCode?: number; } +export interface WebShellWorkflowTask extends WebShellTaskBase { + kind: 'workflow'; + status: + | 'running' + | 'pausing' + | 'paused' + | 'completed' + | 'failed' + | 'cancelled'; + currentPhase?: string; + agentsDispatched: number; + agentsCompleted: number; + tokensSpent: number; + tokenBudgetTotal?: number; +} + export type WebShellTaskInfo = | WebShellAgentTask | WebShellShellTask - | WebShellMonitorTask; + | WebShellMonitorTask + | WebShellWorkflowTask; // ---- Model info (public type for footer renderer) ---- diff --git a/packages/web-shell/client/daemon/session/actions.test.ts b/packages/web-shell/client/daemon/session/actions.test.ts index b53fbc34f2b..f47befe057d 100644 --- a/packages/web-shell/client/daemon/session/actions.test.ts +++ b/packages/web-shell/client/daemon/session/actions.test.ts @@ -1946,6 +1946,31 @@ describe('createDaemonSessionActions', () => { ); }); + it('reads a saved workflow definition and unwraps the envelope', async () => { + const session = createMockSession('session-a'); + const workflow = { + v: 1 as const, + sessionId: 'session-a', + name: 'deep-review', + source: 'project' as const, + scriptPath: '/workspace/.qwen/workflows/deep-review.js', + script: 'export const meta = { name: "deep-review", description: "d" }', + meta: { name: 'deep-review', description: 'd' }, + }; + session.savedWorkflow.mockResolvedValueOnce({ + v: 1, + sessionId: 'session-a', + name: 'deep-review', + workflow, + }); + const { actions } = createActionsHarness({ session }); + + await expect(actions.readSavedWorkflow('deep-review')).resolves.toEqual( + workflow, + ); + expect(session.savedWorkflow).toHaveBeenCalledWith('deep-review'); + }); + it('suppresses a stale workflow-control failure after switching sessions', async () => { const sessionA = createMockSession('session-a'); const sessionB = createMockSession('session-b'); @@ -4132,6 +4157,7 @@ function createMockSession( sessionWorkflowTaskAction: vi.fn(), removeSessionAttachment: vi.fn(async () => true), }, + savedWorkflow: vi.fn(), cancel: vi.fn(async () => undefined), context: vi.fn(async () => contextStatus(sessionId)), detach: vi.fn(async () => undefined), diff --git a/packages/web-shell/client/daemon/session/actions.ts b/packages/web-shell/client/daemon/session/actions.ts index 04178abd012..0ee6d975ff1 100644 --- a/packages/web-shell/client/daemon/session/actions.ts +++ b/packages/web-shell/client/daemon/session/actions.ts @@ -2443,6 +2443,29 @@ export function createDaemonSessionActions({ } }, + async readSavedWorkflow(name: string) { + const session = requireSessionForAction( + addNotice, + sessionRef.current, + 'Read saved workflow failed', + 'read_saved_workflow', + ); + try { + const status = await withActionTimeout( + session.savedWorkflow(name), + 'Read saved workflow timed out', + ); + return status.workflow; + } catch (error) { + throw dispatchActionError( + noticeForSession(session), + 'Read saved workflow failed', + error, + 'read_saved_workflow', + ); + } + }, + async clearGoal() { const session = requireSessionForAction( addNotice, diff --git a/packages/web-shell/client/daemon/session/types.ts b/packages/web-shell/client/daemon/session/types.ts index d7a1ec5e124..4dd1f6cc45a 100644 --- a/packages/web-shell/client/daemon/session/types.ts +++ b/packages/web-shell/client/daemon/session/types.ts @@ -23,6 +23,7 @@ import type { DaemonPendingPromptsResult, DaemonRemovePendingPromptResult, DaemonSessionContextStatus, + DaemonSessionSavedWorkflowDetail, DaemonSessionContextUsageStatus, DaemonSessionRecapResult, DaemonRewindResult, @@ -258,6 +259,7 @@ export type DaemonNoticeOperation = | 'cancel_task' | 'control_workflow' | 'run_saved_workflow' + | 'read_saved_workflow' | 'load_goal' | 'control_goal' | 'clear_goal' @@ -583,6 +585,13 @@ export interface DaemonSessionActions { status?: DaemonSessionWorkflowTaskStatus['status']; taskId?: string; }>; + /** + * Read one saved workflow definition (script + parsed meta). Resolves to + * null when the name is unknown or Workflow controls are unavailable. + */ + readSavedWorkflow( + name: string, + ): Promise; getGoal(): Promise; controlGoal(request: GoalControlRequest): Promise; /** diff --git a/packages/web-shell/client/e2e/utils/mockDaemon.ts b/packages/web-shell/client/e2e/utils/mockDaemon.ts index 3ee2fcdc244..0ec9b6c1c44 100644 --- a/packages/web-shell/client/e2e/utils/mockDaemon.ts +++ b/packages/web-shell/client/e2e/utils/mockDaemon.ts @@ -59,6 +59,12 @@ export interface WebShellDaemonScenario { currentModel: string; currentMode: string; capabilities: DaemonCapabilities; + /** Extra fields merged into `GET /session/:id/supported-commands`. */ + supportedCommands?: Record; + /** Tasks returned by `GET /session/:id/tasks` (workflow snapshots included). */ + workflowTasks?: unknown[]; + /** Definitions served by `GET /session/:id/saved-workflows/:name`, keyed by name. */ + savedWorkflowDetails?: Record>; providers: DaemonWorkspaceProvidersStatus; skills: DaemonWorkspaceSkillsStatus; settings: DaemonWorkspaceSettingsStatus; @@ -428,6 +434,9 @@ export function createWebShellDaemonScenario( events: overrides.events ?? [], state, contextDelayMs: overrides.contextDelayMs, + supportedCommands: overrides.supportedCommands, + workflowTasks: overrides.workflowTasks, + savedWorkflowDetails: overrides.savedWorkflowDetails, providersDelayMs: overrides.providersDelayMs, artifacts: overrides.artifacts ?? [], workspaceFiles: overrides.workspaceFiles ?? {}, @@ -754,6 +763,8 @@ function isDaemonPath(path: string): boolean { /^\/session\/[^/]+\/pending-prompts(?:\/[^/]+)?\/?$/.test(path) || /^\/session\/[^/]+\/goal\/?$/.test(path) || /^\/session\/[^/]+\/status\/?$/.test(path) || + /^\/session\/[^/]+\/tasks\/?$/.test(path) || + /^\/session\/[^/]+\/saved-workflows\/[^/]+\/?$/.test(path) || /^\/session\/[^/]+\/mid-turn-message\/?$/.test(path) || /^\/session\/[^/]+\/mid-turn-messages(?:\/[^/]+)?\/?$/.test(path) || /^\/session\/[^/]+\/(load|resume|branch|prompt|permission\/[^/]+|context|supported-commands|events|model|config-option|approval-mode|heartbeat|cancel|detach|btw)\/?$/.test( @@ -990,8 +1001,10 @@ function isDaemonRoute(method: string, path: string): boolean { return true; } return ( - method === 'GET' && - /^\/session\/[^/]+\/(context|supported-commands)\/?$/.test(path) + (method === 'GET' && + /^\/session\/[^/]+\/(context|supported-commands|tasks)\/?$/.test(path)) || + (method === 'GET' && + /^\/session\/[^/]+\/saved-workflows\/[^/]+\/?$/.test(path)) ); } @@ -1934,6 +1947,26 @@ async function handleDaemonRoute( sessionId, availableCommands: [], availableSkills: [], + ...(scenario.supportedCommands ?? {}), + }); + return; + } + if (action === 'tasks') { + await json(route, { + v: 1, + sessionId, + now: Date.now(), + tasks: scenario.workflowTasks ?? [], + }); + return; + } + if (action === 'saved-workflows') { + const detail = scenario.savedWorkflowDetails?.[extra]; + await json(route, { + v: 1, + sessionId, + name: extra, + workflow: detail ? { v: 1, sessionId, name: extra, ...detail } : null, }); return; } diff --git a/packages/web-shell/client/e2e/visuals/workflow-page.spec.ts b/packages/web-shell/client/e2e/visuals/workflow-page.spec.ts new file mode 100644 index 00000000000..e1930223b5e --- /dev/null +++ b/packages/web-shell/client/e2e/visuals/workflow-page.spec.ts @@ -0,0 +1,292 @@ +/** + * @license + * Copyright 2026 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import { expect, test } from '@playwright/test'; +import type { DaemonSessionWorkflowTaskStatus } from '@qwen-code/sdk/daemon'; +import { createWebShellDaemonScenario } from '../utils/mockDaemon'; +import { + captureScreenshot, + gotoSession, + installScenario, + resolveBaseURL, + VISUAL_VIEWPORT, + type VisualTheme, +} from './harness'; + +test.use({ viewport: { ...VISUAL_VIEWPORT } }); + +// Fixed clock so the captured timestamps and runtimes are identical between +// the base and head render passes of the visuals preview. +const T0 = 1_756_100_000_000; +const s = (n: number) => n * 1000; + +function base( + overrides: Partial, +): DaemonSessionWorkflowTaskStatus { + return { + kind: 'workflow', + id: 'wf_ec511ac4f8818c74', + label: 'review-changes', + description: 'Review changed files across dimensions, verify each finding', + status: 'running', + startTime: T0, + runtimeMs: s(96), + isBackgrounded: true, + currentPhase: 'Review', + phaseVisits: [], + dispatches: [], + agentsDispatched: 0, + agentsCompleted: 0, + tokensSpent: 0, + tokenBudgetTotal: null, + recentLogs: [], + pendingApprovalCount: 0, + pendingApprovals: [], + ...overrides, + }; +} + +const running = base({ + phaseVisits: [ + { id: 'p1', index: 0, title: 'Scan', startedAt: T0, endedAt: T0 + s(14) }, + { id: 'p2', index: 1, title: 'Review', startedAt: T0 + s(14) }, + { id: 'p3', index: 2, title: 'Verify', startedAt: T0 + s(60) }, + ], + dispatches: [ + { + id: 'd1', + phaseVisitId: 'p1', + label: 'Scope changed files', + prompt: + 'List the files changed on this branch relative to main and group them by package.', + status: 'completed', + dependsOn: [], + queuedAt: T0, + startedAt: T0 + s(1), + endedAt: T0 + s(13), + }, + { + id: 'd2', + phaseVisitId: 'p2', + label: 'review:bugs', + prompt: + 'Review the changed files for correctness bugs. Report each finding with file, line, and a concrete failure scenario.', + subagentId: 'sa-2', + status: 'running', + dependsOn: ['d1'], + queuedAt: T0 + s(14), + startedAt: T0 + s(15), + }, + { + id: 'd3', + phaseVisitId: 'p2', + label: 'review:perf', + prompt: 'Review the changed files for efficiency regressions.', + subagentId: 'sa-3', + status: 'running', + dependsOn: ['d1'], + queuedAt: T0 + s(14), + startedAt: T0 + s(15), + }, + { + id: 'd4', + phaseVisitId: 'p2', + label: 'review:security', + prompt: + 'Review the changed files for security issues. Do not modify files.', + subagentId: 'sa-4', + status: 'running', + dependsOn: ['d1'], + queuedAt: T0 + s(14), + startedAt: T0 + s(16), + }, + { + id: 'd5', + phaseVisitId: 'p3', + label: 'verify:bugs', + prompt: 'Adversarially verify each bug finding.', + status: 'queued', + dependsOn: ['d2'], + queuedAt: T0 + s(60), + }, + { + id: 'd6', + phaseVisitId: 'p3', + label: 'verify:perf', + prompt: 'Adversarially verify each perf finding.', + status: 'queued', + dependsOn: ['d3'], + queuedAt: T0 + s(60), + }, + ], + agentsDispatched: 6, + agentsCompleted: 1, + tokensSpent: 4_200, + tokenBudgetTotal: 50_000, + pendingApprovalCount: 1, + pendingApprovals: [ + { + approvalId: 'ap-1', + subagentId: 'sa-4', + name: 'Bash', + description: 'npm audit --json', + at: T0 + s(70), + }, + ], +}); + +const completedDispatches = running.dispatches.map((d) => ({ + ...d, + status: 'completed' as const, + startedAt: d.startedAt ?? d.queuedAt + s(1), + endedAt: d.endedAt ?? d.queuedAt + s(30), +})); + +const completed = base({ + id: 'wf_9f2c0a11b7e0d3c2', + label: 'release-readiness', + status: 'completed', + startTime: T0 - s(1800), + runtimeMs: s(142), + endTime: T0 - s(1800) + s(142), + currentPhase: null, + isHistorical: true, + phaseVisits: running.phaseVisits.map((p) => ({ + ...p, + endedAt: p.endedAt ?? T0 + s(140), + })), + dispatches: completedDispatches, + agentsDispatched: 6, + agentsCompleted: 6, + tokensSpent: 18_400, + tokenBudgetTotal: 50_000, + pendingApprovalCount: 0, +}); + +const olderRun: DaemonSessionWorkflowTaskStatus = { + ...completed, + id: 'wf_5b7d3e0f2a19c4d8', + startTime: T0 - s(7200), + runtimeMs: s(131), + tokensSpent: 17_900, +}; + +// The Workflows page needs the mock daemon to expose Workflow controls +// (`workflowsEnabled` + `savedWorkflows` on supported-commands), a task +// snapshot with workflow runs, and one readable saved definition. Each theme +// walks Saved → definition detail (with source) → Running (expanded graph) → +// History (expanded saved run). +for (const theme of [ + 'light', + 'dark', +] as const satisfies readonly VisualTheme[]) { + test(`workflow page ${theme}`, async ({ page }, testInfo) => { + const scenario = createWebShellDaemonScenario({ + supportedCommands: { + workflowsEnabled: true, + savedWorkflows: [ + { name: 'review-changes', source: 'project' }, + { name: 'release-readiness', source: 'project' }, + { name: 'find-flaky-tests', source: 'user' }, + ], + }, + workflowTasks: [running, completed, olderRun], + savedWorkflowDetails: { + 'review-changes': { + source: 'project', + scriptPath: '/workspace/.qwen/workflows/review-changes.js', + script: [ + 'export const meta = {', + " name: 'review-changes',", + " description: 'Review changed files across dimensions, verify each finding',", + " whenToUse: 'Before opening a PR, or when a review needs independent verification',", + ' phases: [', + " { title: 'Scan', detail: 'list changed files and group them by package' },", + " { title: 'Review', detail: 'one agent per dimension: bugs, perf, security' },", + " { title: 'Verify', detail: 'adversarially verify each finding' },", + ' ],', + '}', + "const DIMENSIONS = ['bugs', 'perf', 'security']", + "phase('Scan')", + "const scope = await agent('List the files changed on this branch relative to main.', { schema: SCOPE })", + "phase('Review')", + 'const findings = await parallel(DIMENSIONS.map((d) => () =>', + ' agent(`Review ${scope.files.join(", ")} for ${d}.`, { label: `review:${d}`, schema: FINDINGS })))', + "phase('Verify')", + 'return await pipeline(findings.flat(), (f) => agent(`Adversarially verify: ${f.title}`, { schema: VERDICT }))', + '', + ].join('\n'), + meta: { + name: 'review-changes', + description: + 'Review changed files across dimensions, verify each finding', + whenToUse: + 'Before opening a PR, or when a review needs independent verification', + phases: [ + { + title: 'Scan', + detail: 'list changed files and group them by package', + }, + { + title: 'Review', + detail: 'one agent per dimension: bugs, perf, security', + }, + { title: 'Verify', detail: 'adversarially verify each finding' }, + ], + }, + }, + }, + }); + const daemon = await installScenario( + page, + scenario, + resolveBaseURL(testInfo), + ); + await gotoSession(page, scenario, daemon, theme); + + await page.getByRole('button', { name: 'Workflows' }).first().click(); + await expect(page.getByRole('tab', { name: /Saved/ })).toBeVisible(); + await expect(page.getByText('/review-changes')).toBeVisible(); + await page.waitForTimeout(400); + await captureScreenshot(page, `workflow-page-saved-${theme}`); + + await page + .getByRole('button', { name: 'Show details for review-changes' }) + .click(); + await expect( + page.locator('[data-workflow-detail="review-changes"]'), + ).toContainText('Phases (3)'); + await page.getByRole('button', { name: 'Show source' }).click(); + await expect( + page.locator('[data-workflow-source] pre').first(), + ).toBeVisible(); + await page.waitForTimeout(600); + await captureScreenshot(page, `workflow-page-saved-detail-${theme}`); + await page + .getByRole('button', { name: 'Show details for review-changes' }) + .click(); + + await page.getByRole('tab', { name: /Running/ }).click(); + const runningRow = page + .getByRole('tabpanel') + .getByRole('button', { expanded: false }) + .first(); + await runningRow.click(); + await expect(page.locator('[data-workflow-dispatch="d2"]')).toBeVisible(); + await page.waitForTimeout(400); + await captureScreenshot(page, `workflow-page-running-${theme}`); + + await page.getByRole('tab', { name: /History/ }).click(); + const historyRow = page + .getByRole('tabpanel') + .getByRole('button', { expanded: false }) + .first(); + await historyRow.click(); + await expect(page.locator('[data-workflow-dispatch="d1"]')).toBeVisible(); + await page.waitForTimeout(400); + await captureScreenshot(page, `workflow-page-history-${theme}`); + }); +} diff --git a/packages/web-shell/client/hooks/useBackgroundTasks.test.tsx b/packages/web-shell/client/hooks/useBackgroundTasks.test.tsx index 2cac84045ab..2c907c77cc2 100644 --- a/packages/web-shell/client/hooks/useBackgroundTasks.test.tsx +++ b/packages/web-shell/client/hooks/useBackgroundTasks.test.tsx @@ -11,7 +11,11 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { DaemonSessionTasksStatus, DaemonSessionTaskStatus, + DaemonSessionTaskWithWorkflowStatus, + DaemonSessionWorkflowTaskStatus, + DaemonSessionWorkflowTasksStatus, } from '@qwen-code/sdk/daemon'; +import { TASKS_STATUS_ACTIVE_EVENT } from '../components/messages/TasksStatusMessage'; import { useBackgroundTasks } from './useBackgroundTasks'; Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); @@ -26,6 +30,7 @@ const sdkMock = vi.hoisted(() => ({ ownerGuard: { capture: vi.fn() }, actions: { getTasks: vi.fn(), + getWorkflowTasks: vi.fn(), }, })); @@ -38,8 +43,11 @@ let root: Root | null = null; let container: HTMLDivElement | null = null; let sessionId: string | undefined = 'session-a'; let taskActivityKey = 'monitor:running'; +/** The activity fact the transcript walk would report for the key above. */ +let taskActivityActive = false; let refreshTrigger = 0; -let latestTasks: DaemonSessionTaskStatus[] = []; +let workflowsEnabled = false; +let latestTasks: DaemonSessionTaskWithWorkflowStatus[] = []; function deferred(): Deferred { let resolve: ((value: T) => void) | undefined; @@ -57,6 +65,19 @@ function snapshot( return { v: 1, sessionId: id, + now: Date.now(), + tasks, + }; +} + +function workflowSnapshot( + id: string, + tasks: DaemonSessionTaskWithWorkflowStatus[] = [], +): DaemonSessionWorkflowTasksStatus { + return { + v: 1, + sessionId: id, + now: Date.now(), tasks, }; } @@ -84,8 +105,10 @@ function Harness() { latestTasks = useBackgroundTasks( sessionId, taskActivityKey, + taskActivityActive, true, refreshTrigger, + workflowsEnabled, ); return null; } @@ -109,9 +132,12 @@ beforeEach(() => { vi.useFakeTimers(); sessionId = 'session-a'; taskActivityKey = 'monitor:running'; + taskActivityActive = false; refreshTrigger = 0; + workflowsEnabled = false; latestTasks = []; sdkMock.actions.getTasks.mockReset(); + sdkMock.actions.getWorkflowTasks.mockReset(); sdkMock.ownerVersion = 0; sdkMock.ownerGuard.capture.mockImplementation(() => { const version = sdkMock.ownerVersion; @@ -119,6 +145,43 @@ beforeEach(() => { }); }); +it('uses the opt-in task snapshot when workflows are enabled', async () => { + const workflow: DaemonSessionWorkflowTaskStatus = { + kind: 'workflow', + id: 'workflow-1', + label: 'review-and-fix', + description: 'Review and fix', + status: 'running', + startTime: Date.now(), + runtimeMs: 1, + isBackgrounded: true, + currentPhase: 'Review', + phaseVisits: [], + dispatches: [], + agentsDispatched: 0, + agentsCompleted: 0, + tokensSpent: 0, + tokenBudgetTotal: null, + recentLogs: [], + pendingApprovalCount: 0, + }; + workflowsEnabled = true; + sdkMock.actions.getWorkflowTasks.mockResolvedValue({ + v: 1, + sessionId: 'session-a', + now: Date.now(), + tasks: [workflow], + }); + + await renderHarness(); + + expect(sdkMock.actions.getWorkflowTasks).toHaveBeenCalledWith({ + silent: true, + }); + expect(sdkMock.actions.getTasks).not.toHaveBeenCalled(); + expect(latestTasks).toEqual([workflow]); +}); + afterEach(async () => { if (root) { await act(async () => { @@ -132,6 +195,260 @@ afterEach(async () => { }); describe('useBackgroundTasks', () => { + it('keeps polling while an active workflow is waiting to register', async () => { + taskActivityKey = 'workflow-call:in_progress'; + taskActivityActive = true; + workflowsEnabled = true; + const runningWorkflow = { + kind: 'workflow' as const, + id: 'wf-live', + label: 'Live workflow', + description: 'Live workflow', + status: 'running' as const, + startTime: Date.now(), + runtimeMs: 1, + isBackgrounded: false, + currentPhase: null, + phaseVisits: [], + dispatches: [], + agentsDispatched: 0, + agentsCompleted: 0, + tokensSpent: 0, + tokenBudgetTotal: null, + recentLogs: [], + pendingApprovalCount: 0, + }; + sdkMock.actions.getWorkflowTasks + .mockResolvedValueOnce(workflowSnapshot('session-a')) + .mockResolvedValueOnce(workflowSnapshot('session-a')) + .mockResolvedValue(workflowSnapshot('session-a', [runningWorkflow])); + + await renderHarness(); + await act(async () => { + await vi.advanceTimersByTimeAsync(6000); + }); + + expect(sdkMock.actions.getWorkflowTasks).toHaveBeenCalledTimes(3); + expect(latestTasks).toEqual([runningWorkflow]); + }); + + it('keeps polling past a terminal snapshot while workflow activity is live', async () => { + taskActivityKey = 'workflow-call:in_progress'; + taskActivityActive = true; + workflowsEnabled = true; + const terminalWorkflow = { + kind: 'workflow' as const, + id: 'wf-old', + label: 'Old workflow', + description: 'Old workflow', + status: 'completed' as const, + startTime: Date.now() - 100, + endTime: Date.now(), + runtimeMs: 100, + isBackgrounded: false, + currentPhase: null, + phaseVisits: [], + dispatches: [], + agentsDispatched: 0, + agentsCompleted: 0, + tokensSpent: 0, + tokenBudgetTotal: null, + recentLogs: [], + pendingApprovalCount: 0, + }; + const runningWorkflow = { + ...terminalWorkflow, + id: 'wf-new', + status: 'running' as const, + endTime: undefined, + runtimeMs: 1, + }; + sdkMock.actions.getWorkflowTasks + .mockResolvedValueOnce(workflowSnapshot('session-a', [terminalWorkflow])) + .mockResolvedValueOnce(workflowSnapshot('session-a', [terminalWorkflow])) + .mockResolvedValue( + workflowSnapshot('session-a', [terminalWorkflow, runningWorkflow]), + ); + + await renderHarness(); + await act(async () => { + await vi.advanceTimersByTimeAsync(6000); + }); + + expect(sdkMock.actions.getWorkflowTasks).toHaveBeenCalledTimes(3); + expect(latestTasks).toContainEqual(runningWorkflow); + }); + + it('only pauses polling for a task panel in the same session', async () => { + const runningMonitor = monitor('monitor-a', 'running'); + sdkMock.actions.getTasks.mockResolvedValue( + snapshot('session-a', [runningMonitor]), + ); + await renderHarness(); + + await act(async () => { + window.dispatchEvent( + new CustomEvent(TASKS_STATUS_ACTIVE_EVENT, { + detail: { active: true, sessionId: 'session-b' }, + }), + ); + await vi.advanceTimersByTimeAsync(3000); + }); + expect(sdkMock.actions.getTasks).toHaveBeenCalledTimes(2); + + await act(async () => { + window.dispatchEvent( + new CustomEvent(TASKS_STATUS_ACTIVE_EVENT, { + detail: { active: true, sessionId: 'session-a' }, + }), + ); + }); + await act(async () => { + await vi.advanceTimersByTimeAsync(3000); + }); + expect(sdkMock.actions.getTasks).toHaveBeenCalledTimes(2); + }); + + it('stops polling when a workflow call is unobservable through the legacy endpoint', async () => { + // Flag off: the hook polls getTasks(), which never carries workflow + // tasks. Holding activity open on a live workflow call made the + // empty-snapshot branch return before the counter and the terminal + // branch require !active — neither could fire, so the 3s poll ran for + // the rest of the run. The caller now reports the workflow call as + // unobservable, and the empty-poll budget ends it. + taskActivityKey = 'workflow-call:in_progress'; + taskActivityActive = false; + workflowsEnabled = false; + sdkMock.actions.getTasks.mockResolvedValue(snapshot('session-a')); + + await renderHarness(); + // One tick per act: React flushes the stop between them, where a single + // long advance would run every interval before re-rendering once. + for (let tick = 0; tick < 8; tick += 1) { + await act(async () => { + await vi.advanceTimersByTimeAsync(3000); + }); + } + + // The mount call plus one more before the empty-poll budget ends it. + expect(sdkMock.actions.getTasks).toHaveBeenCalledTimes(2); + expect(sdkMock.actions.getWorkflowTasks).not.toHaveBeenCalled(); + }); + + it('rearms polling when task activity becomes active', async () => { + taskActivityKey = 'workflow-call:stable'; + taskActivityActive = false; + sdkMock.actions.getTasks.mockResolvedValue(snapshot('session-a')); + await renderHarness(); + await act(async () => vi.advanceTimersByTimeAsync(3_000)); + expect(sdkMock.actions.getTasks).toHaveBeenCalledTimes(2); + + taskActivityActive = true; + await rerenderHarness(); + + expect(sdkMock.actions.getTasks).toHaveBeenCalledTimes(3); + }); + + it('rearms polling on the workflow endpoint when workflows become enabled', async () => { + taskActivityKey = 'workflow-call:stable'; + sdkMock.actions.getTasks.mockResolvedValue(snapshot('session-a')); + sdkMock.actions.getWorkflowTasks.mockResolvedValue( + workflowSnapshot('session-a', [ + { + kind: 'workflow', + id: 'wf-rearmed', + label: 'Rearmed workflow', + description: 'Rearmed workflow', + status: 'running', + startTime: Date.now(), + runtimeMs: 1, + isBackgrounded: false, + currentPhase: null, + phaseVisits: [], + dispatches: [], + agentsDispatched: 0, + agentsCompleted: 0, + tokensSpent: 0, + tokenBudgetTotal: null, + recentLogs: [], + pendingApprovalCount: 0, + }, + ]), + ); + await renderHarness(); + await act(async () => vi.advanceTimersByTimeAsync(3_000)); + expect(sdkMock.actions.getTasks).toHaveBeenCalledTimes(2); + + workflowsEnabled = true; + await rerenderHarness(); + + expect(sdkMock.actions.getWorkflowTasks).toHaveBeenCalledOnce(); + expect(latestTasks.map((task) => task.id)).toEqual(['wf-rearmed']); + }); + + it('resumes polling for a new session after a panel left open in the old one', async () => { + // Split view keeps the previous session's 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. With + // the latch never reset, the new session's polling stays blocked for + // good — the guard outranks every refreshTrigger bump. + sdkMock.actions.getTasks.mockResolvedValue( + snapshot('session-a', [monitor('monitor-a', 'running')]), + ); + await renderHarness(); + + await act(async () => { + window.dispatchEvent( + new CustomEvent(TASKS_STATUS_ACTIVE_EVENT, { + detail: { active: true, sessionId: 'session-a' }, + }), + ); + }); + + sdkMock.actions.getTasks.mockClear(); + sdkMock.actions.getTasks.mockResolvedValue( + snapshot('session-b', [monitor('monitor-b', 'running')]), + ); + sessionId = 'session-b'; + await rerenderHarness(); + + // The stale pane's panel closes only now, naming the session it was + // opened in. + await act(async () => { + window.dispatchEvent( + new CustomEvent(TASKS_STATUS_ACTIVE_EVENT, { + detail: { active: false, sessionId: 'session-a' }, + }), + ); + await vi.advanceTimersByTimeAsync(3000); + }); + + expect(sdkMock.actions.getTasks.mock.calls.length).toBeGreaterThan(0); + // `monitor()` stamps startTime from the clock, so compare identity. + expect(latestTasks.map((task) => task.id)).toEqual(['monitor-b']); + }); + + it('keeps same-session polling paused across an attachment change', async () => { + sdkMock.actions.getTasks.mockResolvedValue( + snapshot('session-a', [monitor('monitor-a', 'running')]), + ); + await renderHarness(); + await act(async () => { + window.dispatchEvent( + new CustomEvent(TASKS_STATUS_ACTIVE_EVENT, { + detail: { active: true, sessionId: 'session-a' }, + }), + ); + }); + sdkMock.actions.getTasks.mockClear(); + + sdkMock.ownerVersion += 1; + await rerenderHarness(); + await act(async () => vi.advanceTimersByTimeAsync(3_000)); + + expect(sdkMock.actions.getTasks).not.toHaveBeenCalled(); + }); + it('keeps polling after a transient task refresh failure', async () => { const runningMonitor = monitor('monitor-a', 'running'); sdkMock.actions.getTasks @@ -168,6 +485,7 @@ describe('useBackgroundTasks', () => { it('starts polling when an out-of-band task triggers a refresh', async () => { taskActivityKey = ''; + taskActivityActive = false; const runningFork = { kind: 'agent' as const, id: 'fork-agent-1', diff --git a/packages/web-shell/client/hooks/useBackgroundTasks.ts b/packages/web-shell/client/hooks/useBackgroundTasks.ts index 6c17c1c941a..db7c59ebfb5 100644 --- a/packages/web-shell/client/hooks/useBackgroundTasks.ts +++ b/packages/web-shell/client/hooks/useBackgroundTasks.ts @@ -1,5 +1,5 @@ import { useEffect, useRef, useState } from 'react'; -import type { DaemonSessionTaskStatus } from '@qwen-code/sdk/daemon'; +import type { DaemonSessionTaskWithWorkflowStatus } from '@qwen-code/sdk/daemon'; import { useActions, useDaemonSessionOwnerGuard, @@ -10,24 +10,36 @@ import { isSessionDisconnectedError } from '../utils/sessionErrors'; const TASKS_POLL_INTERVAL_MS = 3000; const MAX_EMPTY_TASK_POLLS = 2; -function hasActiveTask(tasks: readonly DaemonSessionTaskStatus[]): boolean { +function hasActiveTask( + tasks: readonly DaemonSessionTaskWithWorkflowStatus[], +): boolean { return tasks.some( - (task) => task.status === 'running' || task.status === 'paused', + (task) => + task.status === 'running' || + task.status === 'pausing' || + task.status === 'paused', ); } export function useBackgroundTasks( sessionId: string | undefined, taskActivityKey: string, + /** + * Whether any background task tool call is still running. Passed in as a + * fact rather than re-parsed out of `taskActivityKey`, whose rendering is + * not injective (callId is unconstrained text). + */ + taskActivityActive: boolean, connected: boolean, refreshTrigger = 0, -): DaemonSessionTaskStatus[] { + workflowsEnabled = false, +): DaemonSessionTaskWithWorkflowStatus[] { const actions = useActions(); const ownerGuard = useDaemonSessionOwnerGuard(); const ownerRef = useRef(ownerGuard.capture()); if (!ownerRef.current?.isCurrent()) ownerRef.current = ownerGuard.capture(); const owner = ownerRef.current; - const [tasks, setTasks] = useState([]); + const [tasks, setTasks] = useState([]); const tasksOwnerRef = useRef(owner); const [pollingActive, setPollingActive] = useState(false); const [tasksPanelActive, setTasksPanelActive] = useState(false); @@ -41,12 +53,27 @@ export function useBackgroundTasks( emptyPollsRef.current = 0; }, [connected, owner, sessionId]); + useEffect(() => { + // The pause latch belongs to the session, not the transport attachment. + // Preserve it across reconnects while the same panel stays mounted, but + // release it when this hook starts observing another session. + setTasksPanelActive(false); + }, [sessionId]); + useEffect(() => { if (!connected || !sessionId || (!taskActivityKey && refreshTrigger === 0)) return; emptyPollsRef.current = 0; setPollingActive(true); - }, [connected, owner, refreshTrigger, sessionId, taskActivityKey]); + }, [ + connected, + owner, + refreshTrigger, + sessionId, + taskActivityActive, + taskActivityKey, + workflowsEnabled, + ]); useEffect(() => { if (tasksPanelActive) return; @@ -56,8 +83,10 @@ export function useBackgroundTasks( const refresh = () => { if (tasksRefreshInFlightRef.current === owner) return; tasksRefreshInFlightRef.current = owner; - actions - .getTasks({ silent: true }) + const request = workflowsEnabled + ? actions.getWorkflowTasks({ silent: true }) + : actions.getTasks({ silent: true }); + request .then((snapshot) => { if ( disposed || @@ -67,6 +96,7 @@ export function useBackgroundTasks( return; setTasks(snapshot.tasks); if (snapshot.tasks.length === 0) { + if (taskActivityActive) return; emptyPollsRef.current += 1; if (emptyPollsRef.current >= MAX_EMPTY_TASK_POLLS) { setPollingActive(false); @@ -74,7 +104,7 @@ export function useBackgroundTasks( return; } emptyPollsRef.current = 0; - if (!hasActiveTask(snapshot.tasks)) { + if (!hasActiveTask(snapshot.tasks) && !taskActivityActive) { setPollingActive(false); } }) @@ -99,14 +129,27 @@ export function useBackgroundTasks( disposed = true; clearInterval(id); }; - }, [actions, connected, owner, pollingActive, sessionId, tasksPanelActive]); + }, [ + actions, + connected, + owner, + pollingActive, + sessionId, + taskActivityActive, + taskActivityKey, + tasksPanelActive, + workflowsEnabled, + ]); const tasksRef = useRef(tasks); tasksRef.current = tasks; useEffect(() => { const onTasksPanelActive = (event: Event) => { - const detail = (event as CustomEvent<{ active?: boolean }>).detail; + const detail = ( + event as CustomEvent<{ active?: boolean; sessionId?: string }> + ).detail; + if (detail?.sessionId !== sessionId) return; const active = detail?.active === true; setTasksPanelActive(active); if (!active && hasActiveTask(tasksRef.current)) { @@ -116,7 +159,7 @@ export function useBackgroundTasks( window.addEventListener(TASKS_STATUS_ACTIVE_EVENT, onTasksPanelActive); return () => window.removeEventListener(TASKS_STATUS_ACTIVE_EVENT, onTasksPanelActive); - }, []); + }, [sessionId]); return tasksOwnerRef.current === owner ? tasks : []; } diff --git a/packages/web-shell/client/i18n.tsx b/packages/web-shell/client/i18n.tsx index 82263a0617a..3720a617ad5 100644 --- a/packages/web-shell/client/i18n.tsx +++ b/packages/web-shell/client/i18n.tsx @@ -1209,6 +1209,47 @@ const EN: Messages = { 'scheduledTasks.namePlaceholder': 'Optional — defaults to the prompt', 'scheduledTasks.prompt': 'Prompt', 'scheduledTasks.promptPlaceholder': 'What should this task do?', + 'workflowRuns.title': 'Workflows', + 'workflowRuns.saved': 'Saved', + 'workflowRuns.active': 'Running', + 'workflowRuns.history': 'History', + 'workflowRuns.create': 'New', + 'workflowRuns.refresh': 'Refresh', + 'workflowRuns.loading': 'Loading workflow runs…', + 'workflowRuns.loadFailed': 'Failed to load workflow runs.', + 'workflowRuns.noSession': + 'Open a session in this project to view its workflow runs.', + 'workflowRuns.emptyActive': 'No workflows are currently running.', + 'workflowRuns.emptyHistory': 'No saved workflow runs yet.', + 'workflowRuns.emptySaved': 'No reusable workflows saved yet.', + 'workflowRuns.emptySavedHint': + 'Select New to create one with Qwen Code, save a completed run from the terminal, or add a .js file under .qwen/workflows.', + 'workflowRuns.project': 'Project', + 'workflowRuns.user': 'User', + 'workflowRuns.projectDescription': 'Available in this project', + 'workflowRuns.userDescription': 'Available across projects', + 'workflowRuns.detail.toggle': (v) => `Show details for ${v?.name ?? ''}`, + 'workflowRuns.detail.loading': 'Loading workflow definition…', + 'workflowRuns.detail.unavailable': + 'This workflow definition is no longer available.', + 'workflowRuns.detail.loadFailed': 'Could not read the workflow definition.', + 'workflowRuns.detail.retry': 'Retry', + 'workflowRuns.detail.noDescription': 'This workflow declares no description.', + 'workflowRuns.detail.whenToUse': 'When to use', + 'workflowRuns.detail.metaError': (v) => + `The meta block could not be parsed: ${v?.error ?? ''}`, + 'workflowRuns.detail.phases': (v) => `Phases (${v?.count ?? 0})`, + 'workflowRuns.detail.recentRuns': 'Recent runs', + 'workflowRuns.detail.noRuns': 'No runs in this session yet.', + 'workflowRuns.detail.viewRuns': (v) => + `View ${v?.count ?? 0} ${v?.count === 1 ? 'run' : 'runs'} in History`, + 'workflowRuns.detail.showSource': 'Show source', + 'workflowRuns.detail.hideSource': 'Hide source', + 'workflowRuns.run': 'Run', + 'workflowRuns.starting': 'Starting…', + 'workflowRuns.runNamed': (v) => `Run ${v?.name ?? ''}`, + 'workflowRuns.startFailed': + 'The saved workflow could not be started. Refresh and try again.', 'scheduledTasks.reference.extension': 'Extensions', 'scheduledTasks.reference.skill': 'Skills', 'scheduledTasks.reference.mcp': 'MCP', @@ -1518,6 +1559,7 @@ const EN: Messages = { 'sidebar.settings': 'Settings', 'sidebar.daemonStatus': 'Daemon Status', 'sidebar.scheduledTasks': 'Scheduled Tasks', + 'sidebar.workflows': 'Workflows', 'sidebar.goals': 'Goals', 'sidebar.themeLight': 'Switch to light theme', 'sidebar.themeDark': 'Switch to dark theme', @@ -2798,12 +2840,70 @@ const EN: Messages = { 'tasks.moreAbove': (v) => `^ ${v?.count ?? 0} more above`, 'tasks.moreBelow': (v) => `v ${v?.count ?? 0} more below`, 'tasks.running': 'Running', + 'tasks.pausing': 'Pausing', 'tasks.completed': 'Completed', 'tasks.failed': 'Failed', 'tasks.cancelled': 'Stopped', 'tasks.paused': 'Paused', 'tasks.kind.shell': 'Shell', 'tasks.kind.monitor': 'Monitor', + 'tasks.kind.workflow': 'Workflow', + 'workflow.graph.waiting': 'Waiting for the first agent dispatch…', + 'workflow.graph.notRecorded': 'No execution graph was saved for this run.', + 'workflow.inline.loading': 'Loading live workflow execution…', + 'workflow.inline.unavailable': + 'This workflow run is no longer available in the current session.', + 'workflow.graph.omitted': (v) => + `Graph limited for performance: ${v?.lanes ?? 0} phases, ${v?.nodes ?? 0} agents, and ${v?.edges ?? 0} connections omitted.`, + 'workflow.noPhase': 'No phase', + 'workflow.dispatchCount': (v) => + `${v?.count ?? 0} dispatch${v?.count === 1 ? '' : 'es'}`, + 'workflow.selectedDispatch': 'Selected dispatch', + 'workflow.dependencies': 'Depends on', + 'workflow.action.pause': 'Pause', + 'workflow.action.resume': 'Resume', + 'workflow.action.retry': 'Retry failed path', + 'workflow.action.rerun': 'Rerun all', + 'workflow.action.unavailable': 'Workflow state changed before the action.', + 'workflow.action.failed': 'Could not update the workflow.', + 'workflow.history.retry': (v) => `Retried from ${v?.runId ?? ''}`, + 'workflow.history.rerun': (v) => `Rerun from ${v?.runId ?? ''}`, + 'workflow.history.cached': (v) => `${v?.count ?? 0} cached`, + 'workflow.history.saved': (v) => `${v?.count ?? 0} saved runs`, + 'workflow.history.restored': 'Saved run · read-only', + 'workflow.history.showRuns': (v) => `Run history (${v?.count ?? 0})`, + 'workflow.history.hideRuns': 'Hide history', + 'workflow.history.filter': 'Filter runs', + 'workflow.history.filterAll': 'All statuses', + 'workflow.history.visibleCount': (v) => + `${v?.count ?? 0} of ${v?.total ?? 0}`, + 'workflow.history.filterEmpty': 'No saved runs match this filter.', + 'workflow.history.exportVisible': 'Export visible', + 'workflow.history.delete': 'Delete', + 'workflow.history.deleteSaved': 'Delete saved run', + 'workflow.history.confirmDelete': 'Confirm delete', + 'workflow.history.deleteUnavailable': 'The saved run is no longer available.', + 'workflow.history.deleteFailed': 'Could not delete the saved run.', + 'workflow.history.compareRun': (v) => `Compare run ${v?.runId ?? ''}`, + 'workflow.history.compare': 'Compare runs', + 'workflow.history.hideComparison': 'Hide comparison', + 'workflow.history.source': 'Source run', + 'workflow.history.compared': 'Compared run', + 'workflow.history.current': 'Current run', + 'workflow.history.status': 'Status', + 'workflow.history.agents': 'Agents', + 'workflow.approvalNeeded': 'Approval needed', + 'workflow.respondInChat': 'Respond in chat', + 'workflow.metric.agents': 'agents', + 'workflow.metric.running': 'running', + 'workflow.metric.queued': 'queued', + 'workflow.metric.tokens': 'tokens', + 'workflow.dispatch.queued': 'Queued', + 'workflow.dispatch.running': 'Running', + 'workflow.dispatch.completed': 'Completed', + 'workflow.dispatch.failed': 'Failed', + 'workflow.dispatch.cancelled': 'Cancelled', + 'workflow.dispatch.cached': 'Cached', 'tasks.action.stop': 'Stop', 'tasks.action.abandon': 'Abandon', 'tasks.action.confirmStop': 'Confirm stop', @@ -2819,6 +2919,8 @@ const EN: Messages = { 'tasks.pill.monitors': (v) => `${v?.count ?? 0} monitors`, 'tasks.pill.shell': (v) => `${v?.count ?? 0} shell`, 'tasks.pill.shells': (v) => `${v?.count ?? 0} shells`, + 'tasks.pill.workflow': (v) => `${v?.count ?? 0} workflow`, + 'tasks.pill.workflows': (v) => `${v?.count ?? 0} workflows`, 'tasks.confirmStop': 'x again to confirm stop · ends the blocking turn', 'tasks.shortcut.select': '↑/↓ select', 'tasks.shortcut.view': 'Enter view', @@ -4568,6 +4670,42 @@ const ZH: Messages = { 'scheduledTasks.namePlaceholder': '可选 —— 默认取提示词', 'scheduledTasks.prompt': '提示词', 'scheduledTasks.promptPlaceholder': '这个任务要做什么?', + 'workflowRuns.title': '工作流', + 'workflowRuns.saved': '已保存', + 'workflowRuns.active': '运行中', + 'workflowRuns.history': '历史', + 'workflowRuns.create': '新建', + 'workflowRuns.refresh': '刷新', + 'workflowRuns.loading': '正在加载工作流…', + 'workflowRuns.loadFailed': '工作流加载失败。', + 'workflowRuns.noSession': '请先打开本项目中的任一会话,再查看工作流记录。', + 'workflowRuns.emptyActive': '当前没有运行中的工作流。', + 'workflowRuns.emptyHistory': '暂时没有已保存的工作流记录。', + 'workflowRuns.emptySaved': '还没有可复用的工作流。', + 'workflowRuns.emptySavedHint': + '可以点击“新建”让 Qwen Code 创建、在终端中保存一次已完成的运行,或在 .qwen/workflows 下添加 .js 文件。', + 'workflowRuns.project': '项目', + 'workflowRuns.user': '用户', + 'workflowRuns.projectDescription': '仅在当前项目中可用', + 'workflowRuns.userDescription': '在所有项目中可用', + 'workflowRuns.detail.toggle': (v) => `查看 ${v?.name ?? ''} 的详情`, + 'workflowRuns.detail.loading': '正在加载工作流定义…', + 'workflowRuns.detail.unavailable': '这个工作流定义已不可用。', + 'workflowRuns.detail.loadFailed': '无法读取工作流定义。', + 'workflowRuns.detail.retry': '重试', + 'workflowRuns.detail.noDescription': '这个工作流没有声明描述。', + 'workflowRuns.detail.whenToUse': '适用场景', + 'workflowRuns.detail.metaError': (v) => `meta 块无法解析:${v?.error ?? ''}`, + 'workflowRuns.detail.phases': (v) => `阶段(${v?.count ?? 0})`, + 'workflowRuns.detail.recentRuns': '最近运行', + 'workflowRuns.detail.noRuns': '当前会话还没有运行记录。', + 'workflowRuns.detail.viewRuns': (v) => `在历史中查看 ${v?.count ?? 0} 次运行`, + 'workflowRuns.detail.showSource': '查看源码', + 'workflowRuns.detail.hideSource': '收起源码', + 'workflowRuns.run': '运行', + 'workflowRuns.starting': '正在启动…', + 'workflowRuns.runNamed': (v) => `运行 ${v?.name ?? ''}`, + 'workflowRuns.startFailed': '工作流未能启动,请刷新后重试。', 'scheduledTasks.reference.extension': '扩展', 'scheduledTasks.reference.skill': '技能', 'scheduledTasks.reference.mcp': 'MCP', @@ -4857,6 +4995,7 @@ const ZH: Messages = { 'sidebar.settings': '设置', 'sidebar.daemonStatus': 'Daemon 状态', 'sidebar.scheduledTasks': '定时任务', + 'sidebar.workflows': '工作流', 'sidebar.goals': '目标', 'sidebar.themeLight': '切换到浅色主题', 'sidebar.themeDark': '切换到深色主题', @@ -6034,12 +6173,67 @@ const ZH: Messages = { 'tasks.moreAbove': (v) => `^ 上方还有 ${v?.count ?? 0} 个`, 'tasks.moreBelow': (v) => `v 下方还有 ${v?.count ?? 0} 个`, 'tasks.running': '运行中', + 'tasks.pausing': '暂停中', 'tasks.completed': '已完成', 'tasks.failed': '失败', 'tasks.cancelled': '已停止', 'tasks.paused': '已暂停', 'tasks.kind.shell': 'Shell', 'tasks.kind.monitor': '监控', + 'tasks.kind.workflow': '工作流', + 'workflow.graph.waiting': '等待第一个 Agent 调度…', + 'workflow.graph.notRecorded': '这次运行没有保存执行图。', + 'workflow.inline.loading': '正在加载 Workflow 实时执行状态…', + 'workflow.inline.unavailable': '当前会话中已无法读取这次 Workflow 运行。', + 'workflow.graph.omitted': (v) => + `为保证性能,执行图已省略 ${v?.lanes ?? 0} 个阶段、${v?.nodes ?? 0} 个 Agent 和 ${v?.edges ?? 0} 条连线。`, + 'workflow.noPhase': '未分阶段', + 'workflow.dispatchCount': (v) => `${v?.count ?? 0} 个调度`, + 'workflow.selectedDispatch': '已选调度', + 'workflow.dependencies': '依赖', + 'workflow.action.pause': '暂停', + 'workflow.action.resume': '继续', + 'workflow.action.retry': '重试失败路径', + 'workflow.action.rerun': '全部重跑', + 'workflow.action.unavailable': '操作前工作流状态已发生变化。', + 'workflow.action.failed': '无法更新工作流状态。', + 'workflow.history.retry': (v) => `从 ${v?.runId ?? ''} 续跑`, + 'workflow.history.rerun': (v) => `从 ${v?.runId ?? ''} 全部重跑`, + 'workflow.history.cached': (v) => `${v?.count ?? 0} 个缓存命中`, + 'workflow.history.saved': (v) => `已保存 ${v?.count ?? 0} 次运行`, + 'workflow.history.restored': '已保存运行 · 只读', + 'workflow.history.showRuns': (v) => `运行历史(${v?.count ?? 0})`, + 'workflow.history.hideRuns': '收起历史', + 'workflow.history.filter': '筛选运行', + 'workflow.history.filterAll': '全部状态', + 'workflow.history.visibleCount': (v) => `${v?.count ?? 0} / ${v?.total ?? 0}`, + 'workflow.history.filterEmpty': '没有符合当前筛选条件的历史运行。', + 'workflow.history.exportVisible': '导出当前结果', + 'workflow.history.delete': '删除', + 'workflow.history.deleteSaved': '删除已保存运行', + 'workflow.history.confirmDelete': '确认删除', + 'workflow.history.deleteUnavailable': '这次历史运行已不存在。', + 'workflow.history.deleteFailed': '无法删除这次历史运行。', + 'workflow.history.compareRun': (v) => `对比运行 ${v?.runId ?? ''}`, + 'workflow.history.compare': '对比两次运行', + 'workflow.history.hideComparison': '收起对比', + 'workflow.history.source': '来源运行', + 'workflow.history.compared': '对比运行', + 'workflow.history.current': '当前运行', + 'workflow.history.status': '状态', + 'workflow.history.agents': 'Agents', + 'workflow.approvalNeeded': '等待审批', + 'workflow.respondInChat': '请在对话中处理', + 'workflow.metric.agents': 'Agents', + 'workflow.metric.running': '运行中', + 'workflow.metric.queued': '排队中', + 'workflow.metric.tokens': 'Tokens', + 'workflow.dispatch.queued': '排队中', + 'workflow.dispatch.running': '运行中', + 'workflow.dispatch.completed': '已完成', + 'workflow.dispatch.failed': '失败', + 'workflow.dispatch.cancelled': '已取消', + 'workflow.dispatch.cached': '已缓存', 'tasks.action.stop': '停止', 'tasks.action.abandon': '放弃', 'tasks.action.confirmStop': '确认停止', @@ -6055,6 +6249,8 @@ const ZH: Messages = { 'tasks.pill.monitors': (v) => `${v?.count ?? 0} 个监控`, 'tasks.pill.shell': (v) => `${v?.count ?? 0} 个 shell`, 'tasks.pill.shells': (v) => `${v?.count ?? 0} 个 shell`, + 'tasks.pill.workflow': (v) => `${v?.count ?? 0} 个工作流`, + 'tasks.pill.workflows': (v) => `${v?.count ?? 0} 个工作流`, 'tasks.confirmStop': '再按 x 确认停止 · 会终结当前阻塞轮次', 'tasks.shortcut.select': '↑/↓ 选择', 'tasks.shortcut.view': 'Enter 查看', diff --git a/packages/web-shell/client/index.tsx b/packages/web-shell/client/index.tsx index 9b20fa15237..3637b69e7bf 100644 --- a/packages/web-shell/client/index.tsx +++ b/packages/web-shell/client/index.tsx @@ -234,6 +234,7 @@ export type { WebShellAgentTask, WebShellShellTask, WebShellMonitorTask, + WebShellWorkflowTask, WebShellPreparedSubmit, WebShellSubmitSnapshot, WebShellSessionArtifactsChange, diff --git a/packages/web-shell/client/utils/composerTasks.test.ts b/packages/web-shell/client/utils/composerTasks.test.ts index 06812c82be2..692234add15 100644 --- a/packages/web-shell/client/utils/composerTasks.test.ts +++ b/packages/web-shell/client/utils/composerTasks.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import type { DaemonSessionTaskStatus } from '@qwen-code/sdk/daemon'; +import type { DaemonSessionTaskWithWorkflowStatus } from '@qwen-code/sdk/daemon'; import { isComposerTask } from './composerTasks'; const base = { @@ -13,7 +13,7 @@ const base = { describe('isComposerTask', () => { it('shows non-agent tasks and excludes agents', () => { - const tasks: Array<[DaemonSessionTaskStatus, boolean]> = [ + const tasks: Array<[DaemonSessionTaskWithWorkflowStatus, boolean]> = [ [ { ...base, @@ -52,10 +52,52 @@ describe('isComposerTask', () => { }, true, ], + [ + { + ...base, + kind: 'workflow', + isBackgrounded: true, + currentPhase: null, + phaseVisits: [], + dispatches: [], + agentsDispatched: 0, + agentsCompleted: 0, + tokensSpent: 0, + tokenBudgetTotal: null, + recentLogs: [], + pendingApprovalCount: 0, + }, + true, + ], ]; for (const [task, expected] of tasks) { expect(isComposerTask(task)).toBe(expected); } }); + + it('excludes retained workflow history', () => { + // getWorkflowTasks() merges the project's saved runs into the same + // list. Counting them would make the status-bar pill announce the + // whole retained history ("30 tasks done") the first time polling runs + // in a session, with no way for the user to clear it. + const historical: DaemonSessionTaskWithWorkflowStatus = { + ...base, + status: 'completed', + kind: 'workflow', + isHistorical: true, + isBackgrounded: false, + currentPhase: null, + phaseVisits: [], + dispatches: [], + agentsDispatched: 0, + agentsCompleted: 0, + tokensSpent: 0, + tokenBudgetTotal: null, + recentLogs: [], + pendingApprovalCount: 0, + }; + expect(isComposerTask(historical)).toBe(false); + expect(isComposerTask({ ...historical, isHistorical: false })).toBe(true); + }); }); diff --git a/packages/web-shell/client/utils/composerTasks.ts b/packages/web-shell/client/utils/composerTasks.ts index fca49a2e64c..0ce28cea833 100644 --- a/packages/web-shell/client/utils/composerTasks.ts +++ b/packages/web-shell/client/utils/composerTasks.ts @@ -1,5 +1,20 @@ -import type { DaemonSessionTaskStatus } from '@qwen-code/sdk/daemon'; +import type { DaemonSessionTaskWithWorkflowStatus } from '@qwen-code/sdk/daemon'; -export function isComposerTask(task: DaemonSessionTaskStatus): boolean { - return task.kind !== 'agent'; +/** + * Tasks that belong to THIS session's live background state — what the + * status-bar pill counts and the environment panel lists. + * + * Agent tasks have their own surfaces, and retained workflow runs are + * history: `getWorkflowTasks()` merges the project's saved snapshots into + * the same list, so without the `isHistorical` exclusion a project with + * saved runs shows "30 tasks done" in the pill the first time polling runs, + * for the rest of the session, with no way to clear it. Consumers that + * genuinely want the history (the workflow details provider, the tasks + * dialog) read the unfiltered list instead. + */ +export function isComposerTask( + task: DaemonSessionTaskWithWorkflowStatus, +): task is Exclude { + if (task.kind === 'agent') return false; + return !(task.kind === 'workflow' && task.isHistorical === true); } diff --git a/packages/web-shell/client/utils/taskActivity.test.ts b/packages/web-shell/client/utils/taskActivity.test.ts new file mode 100644 index 00000000000..abe0a4ac7fa --- /dev/null +++ b/packages/web-shell/client/utils/taskActivity.test.ts @@ -0,0 +1,77 @@ +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', + ); + }); +}); diff --git a/packages/web-shell/client/utils/taskActivity.ts b/packages/web-shell/client/utils/taskActivity.ts new file mode 100644 index 00000000000..1ff6a20dcc9 --- /dev/null +++ b/packages/web-shell/client/utils/taskActivity.ts @@ -0,0 +1,82 @@ +import type { ACPToolCall, Message } from '../adapters/types'; +import { + backgroundShellTaskId, + isBackgroundSubAgentToolCall, +} from '../adapters/toolClassification'; +import { isWorkflowToolName } from '../components/messages/toolFormatting'; + +function isBackgroundTaskToolCall(tool: ACPToolCall): boolean { + const name = tool.toolName.toLowerCase(); + if (name === 'monitor' || name === 'workflow') return true; + if (backgroundShellTaskId(tool) !== undefined) return true; + if (tool.args?.is_background !== true) return false; + return ( + name === 'shell' || + name === 'bash' || + name === 'run_shell_command' || + name === 'exec' + ); +} + +/** Walk every background task tool call in the transcript, in order. */ +function forEachBackgroundTaskToolCall( + messages: readonly Message[], + visitTool: (tool: ACPToolCall) => void, +): void { + const visit = (tools: readonly ACPToolCall[]) => { + for (const tool of tools) { + if ( + isBackgroundTaskToolCall(tool) || + isBackgroundSubAgentToolCall(tool) + ) { + visitTool(tool); + } + if (tool.subTools) visit(tool.subTools); + } + }; + for (const message of messages) { + if (message.role !== 'tool_group') continue; + visit(message.tools); + } +} + +/** + * Change-detection key only. `callId` is unconstrained text, so the + * rendering is not injective and the key must never be parsed back — + * ask {@link hasActiveTaskActivity} for the activity fact instead. + */ +export function getTaskActivityKey(messages: readonly Message[]): string { + const parts: string[] = []; + forEachBackgroundTaskToolCall(messages, (tool) => { + parts.push(`${tool.callId}:${tool.status}`); + }); + return parts.join('|'); +} + +/** + * Whether any background task tool call is still running. + * + * Derived from the same walk as the key rather than by re-parsing it: a + * callId containing `:in_progress|` renders a key that any such regex + * reads as active, which pins both polling stop conditions open for the + * life of the view. + */ +export function hasActiveTaskActivity( + messages: readonly Message[], + options: { workflowsEnabled?: boolean } = {}, +): boolean { + // With the workflows endpoint off, the hook polls `getTasks()`, whose + // response structurally excludes workflow tasks — so a live workflow call + // could never appear in a snapshot, and holding activity open on it makes + // BOTH stop conditions unsatisfiable: the poll then runs every 3s for the + // rest of the run (foreground runs cap at 30 minutes). Workflow calls are + // simply unobservable in that mode, so they do not count. + const countsWorkflows = options.workflowsEnabled === true; + let active = false; + forEachBackgroundTaskToolCall(messages, (tool) => { + if (tool.status !== 'pending' && tool.status !== 'in_progress') return; + if (!countsWorkflows && isWorkflowToolName(tool.toolName)) return; + active = true; + }); + return active; +} diff --git a/packages/web-shell/client/utils/workflowTasks.test.ts b/packages/web-shell/client/utils/workflowTasks.test.ts new file mode 100644 index 00000000000..26184ae5853 --- /dev/null +++ b/packages/web-shell/client/utils/workflowTasks.test.ts @@ -0,0 +1,148 @@ +import { describe, expect, it } from 'vitest'; +import type { + DaemonSessionTaskWithWorkflowStatus, + DaemonSessionWorkflowTaskStatus, +} from '@qwen-code/sdk/daemon'; +import type { ACPToolCall } from '../adapters/types'; +import { findWorkflowTaskForTool } from './workflowTasks'; + +function workflowTask(id: string): DaemonSessionWorkflowTaskStatus { + return { + kind: 'workflow', + id, + label: 'Channel analysis', + description: 'Analyze channel packages', + status: 'running', + startTime: 1_000, + runtimeMs: 200, + isBackgrounded: false, + currentPhase: 'Inspect', + phaseVisits: [], + dispatches: [], + agentsDispatched: 0, + agentsCompleted: 0, + tokensSpent: 0, + tokenBudgetTotal: null, + recentLogs: [], + pendingApprovalCount: 0, + }; +} + +function workflowTool(overrides: Partial): ACPToolCall { + return { + callId: 'workflow-call', + toolName: 'workflow', + status: 'in_progress', + ...overrides, + }; +} + +describe('findWorkflowTaskForTool', () => { + const tasks: DaemonSessionTaskWithWorkflowStatus[] = [ + workflowTask('wf_expected'), + workflowTask('wf_other'), + ]; + + it('matches a live workflow update by its runId payload', () => { + expect( + findWorkflowTaskForTool( + tasks, + workflowTool({ + subContent: + '```json\n{"runId":"wf_expected","status":"running"}\n```', + }), + )?.id, + ).toBe('wf_expected'); + }); + + it('prefers the parent tool identity before live output arrives', () => { + const linked = workflowTask('wf_expected'); + linked.toolUseId = 'workflow-call'; + expect( + findWorkflowTaskForTool( + [workflowTask('wf_other'), linked], + workflowTool({ subContent: undefined }), + )?.id, + ).toBe('wf_expected'); + }); + + it('matches a completed background workflow by its result text', () => { + expect( + findWorkflowTaskForTool( + tasks, + workflowTool({ + status: 'completed', + rawOutput: + 'Workflow started in background.\nRun ID: wf_expected\nStatus: running', + }), + )?.id, + ).toBe('wf_expected'); + }); + + it('matches a failed workflow by its terminal run payload', () => { + expect( + findWorkflowTaskForTool( + tasks, + workflowTool({ + status: 'completed', + rawOutput: + 'Workflow failed: boom\n\n{"runId":"wf_expected","phases":[]}', + }), + )?.id, + ).toBe('wf_expected'); + }); + + it('does not use the runId fallback for a task linked to another tool', () => { + const linkedElsewhere = workflowTask('wf_expected'); + linkedElsewhere.toolUseId = 'another-workflow-call'; + + expect( + findWorkflowTaskForTool( + [linkedElsewhere], + workflowTool({ + rawOutput: '{"runId":"wf_expected","status":"running"}', + }), + ), + ).toBeUndefined(); + }); + + it('does not guess when the tool contains no run identity', () => { + expect( + findWorkflowTaskForTool( + tasks, + workflowTool({ subContent: 'Starting agents' }), + ), + ).toBeUndefined(); + }); + + it('pairs a live resume with its source run before output arrives', () => { + // The argued use of the resumeFromRunId fallback: the resumed run + // registers under the same id, so the graph is the right one to show + // while the call is still in flight. + expect( + findWorkflowTaskForTool( + tasks, + workflowTool({ + status: 'in_progress', + args: { resumeFromRunId: 'wf_expected' }, + }), + )?.id, + ).toBe('wf_expected'); + }); + + it('does not pair a resume that never launched with the source run', () => { + // A terminal call carrying no run identity in its text is the + // never-registered case — a script compile error, or cancelled before + // start. Falling back to the SOURCE run there renders that run's graph + // as this failed call's detail instead of its actual error text. + expect( + findWorkflowTaskForTool( + tasks, + workflowTool({ + status: 'completed', + args: { resumeFromRunId: 'wf_expected' }, + }), + ), + ).toBeUndefined(); + }); +}); diff --git a/packages/web-shell/client/utils/workflowTasks.ts b/packages/web-shell/client/utils/workflowTasks.ts new file mode 100644 index 00000000000..26dbddc98e0 --- /dev/null +++ b/packages/web-shell/client/utils/workflowTasks.ts @@ -0,0 +1,56 @@ +import type { + DaemonSessionTaskWithWorkflowStatus, + DaemonSessionWorkflowTaskStatus, +} from '@qwen-code/sdk/daemon'; +import type { ACPToolCall } from '../adapters/types'; + +function workflowRunIdFromTool(tool: ACPToolCall): string | undefined { + const rawOutput = (() => { + if (typeof tool.rawOutput === 'string') return tool.rawOutput; + if (!tool.rawOutput) return ''; + try { + return JSON.stringify(tool.rawOutput); + } catch { + return ''; + } + })(); + const text = [ + rawOutput, + tool.subContent ?? '', + ...(tool.content ?? []).map((item) => item.content?.text ?? ''), + ].join('\n'); + const runId = + text.match(/"runId"\s*:\s*"([^"]+)"/)?.[1] ?? + text.match(/\bRun ID:\s*([^\s]+)/i)?.[1] ?? + text.match(/\bWorkflow\s+([^\s]+)\s+(?:started|—|-)/i)?.[1]; + if (runId) return runId; + // Only while the call is still live. The argued use is pairing an ACTIVE + // resume with its run before live output arrives, where the resumed run + // registers under the same id. A TERMINAL call carrying no run identity + // in its text is the never-registered case — a compile error or a + // cancel-before-start — and returning the SOURCE run there would render + // that run's graph as this failed call's detail instead of its error. + const active = tool.status === 'pending' || tool.status === 'in_progress'; + return active && typeof tool.args?.resumeFromRunId === 'string' + ? tool.args.resumeFromRunId + : undefined; +} + +export function findWorkflowTaskForTool( + tasks: readonly DaemonSessionTaskWithWorkflowStatus[], + tool: ACPToolCall, +): DaemonSessionWorkflowTaskStatus | undefined { + const linked = tasks.find( + (task): task is DaemonSessionWorkflowTaskStatus => + task.kind === 'workflow' && task.toolUseId === tool.callId, + ); + if (linked) return linked; + const runId = workflowRunIdFromTool(tool); + if (!runId) return undefined; + return tasks.find( + (task): task is DaemonSessionWorkflowTaskStatus => + task.kind === 'workflow' && + task.id === runId && + task.toolUseId === undefined, + ); +} diff --git a/packages/web-shell/client/workflowDetailsContext.tsx b/packages/web-shell/client/workflowDetailsContext.tsx new file mode 100644 index 00000000000..820cd187cb9 --- /dev/null +++ b/packages/web-shell/client/workflowDetailsContext.tsx @@ -0,0 +1,29 @@ +import { createContext, useContext, useMemo, type ReactNode } from 'react'; +import type { DaemonSessionTaskWithWorkflowStatus } from '@qwen-code/sdk/daemon'; + +interface WorkflowDetailsContextValue { + tasks: readonly DaemonSessionTaskWithWorkflowStatus[]; +} + +const WorkflowDetailsContext = createContext< + WorkflowDetailsContextValue | undefined +>(undefined); + +export function WorkflowDetailsProvider({ + tasks, + children, +}: { + tasks: readonly DaemonSessionTaskWithWorkflowStatus[]; + children: ReactNode; +}) { + const value = useMemo(() => ({ tasks }), [tasks]); + return ( + + {children} + + ); +} + +export function useWorkflowDetails(): WorkflowDetailsContextValue | undefined { + return useContext(WorkflowDetailsContext); +} From 76809a4562e8efc95618620e1c6a35d55cfa5089 Mon Sep 17 00:00:00 2001 From: "jinjing.zzj" Date: Fri, 4 Sep 2026 05:02:24 +0800 Subject: [PATCH 03/13] fix(cli): name the migrated core imports so plain Node resolves them The tipHistory / RemoteInputWatcher migration used .js-suffixed subpath specifiers that match no entry in packages/core/package.json exports, so the built-but-unbundled CLI (npm start / build-and-start, whose tsc dist keeps specifiers) crashed at module load with ERR_PACKAGE_PATH_NOT_EXPORTED while typecheck (tsconfig paths), unit tests (vitest wildcard alias) and the bundle (esbuild paths) all bypassed exports and stayed green. Switch the two files to named subpaths (storage, atomicFileWrite, debugLogger), following the convention of the eight existing entries, and add the matching exports entries and vitest aliases. Co-authored-by: Qwen-Coder Patrol-Run: qwen-pr-closeout/jmtlz4ubdgq --- packages/cli/src/remoteInput/RemoteInputWatcher.ts | 2 +- packages/cli/src/services/tips/tipHistory.ts | 4 ++-- packages/cli/vitest.config.ts | 12 ++++++++++++ packages/core/package.json | 12 ++++++++++++ 4 files changed, 27 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/remoteInput/RemoteInputWatcher.ts b/packages/cli/src/remoteInput/RemoteInputWatcher.ts index 3321ee406a1..8a3eabf4e29 100644 --- a/packages/cli/src/remoteInput/RemoteInputWatcher.ts +++ b/packages/cli/src/remoteInput/RemoteInputWatcher.ts @@ -15,7 +15,7 @@ import { watchFile, } from 'node:fs'; import { createInterface } from 'node:readline'; -import { createDebugLogger } from '@qwen-code/qwen-code-core/utils/debugLogger.js'; +import { createDebugLogger } from '@qwen-code/qwen-code-core/debugLogger'; const debugLogger = createDebugLogger('REMOTE_INPUT'); diff --git a/packages/cli/src/services/tips/tipHistory.ts b/packages/cli/src/services/tips/tipHistory.ts index 5c1d2867a55..feb11547d63 100644 --- a/packages/cli/src/services/tips/tipHistory.ts +++ b/packages/cli/src/services/tips/tipHistory.ts @@ -10,8 +10,8 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; -import { Storage } from '@qwen-code/qwen-code-core/config/storage.js'; -import { atomicWriteFileSync } from '@qwen-code/qwen-code-core/utils/atomicFileWrite.js'; +import { Storage } from '@qwen-code/qwen-code-core/storage'; +import { atomicWriteFileSync } from '@qwen-code/qwen-code-core/atomicFileWrite'; interface TipHistoryEntry { totalShown: number; diff --git a/packages/cli/vitest.config.ts b/packages/cli/vitest.config.ts index d3778ab6b62..682216a586b 100644 --- a/packages/cli/vitest.config.ts +++ b/packages/cli/vitest.config.ts @@ -58,6 +58,18 @@ export default defineConfig({ __dirname, '../core/src/utils/envVarResolver.ts', ), + '@qwen-code/qwen-code-core/storage': path.resolve( + __dirname, + '../core/src/config/storage.ts', + ), + '@qwen-code/qwen-code-core/atomicFileWrite': path.resolve( + __dirname, + '../core/src/utils/atomicFileWrite.ts', + ), + '@qwen-code/qwen-code-core/debugLogger': path.resolve( + __dirname, + '../core/src/utils/debugLogger.ts', + ), }), // Mirrors `"@qwen-code/qwen-code-core/*": ["../core/src/*"]` from // packages/cli/tsconfig.json. esbuild reads that paths block when it diff --git a/packages/core/package.json b/packages/core/package.json index 621ddb3866b..694807d2922 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -45,6 +45,18 @@ "types": "./dist/src/utils/no-follow-open.d.ts", "import": "./dist/src/utils/no-follow-open.js" }, + "./storage": { + "types": "./dist/src/config/storage.d.ts", + "import": "./dist/src/config/storage.js" + }, + "./atomicFileWrite": { + "types": "./dist/src/utils/atomicFileWrite.d.ts", + "import": "./dist/src/utils/atomicFileWrite.js" + }, + "./debugLogger": { + "types": "./dist/src/utils/debugLogger.d.ts", + "import": "./dist/src/utils/debugLogger.js" + }, "./package.json": "./package.json", "./dist/*": "./dist/*", "./src/*": "./src/*" From 9b48531cdb3bc9d3e274bc1add2bfa9e1a804538 Mon Sep 17 00:00:00 2001 From: "jinjing.zzj" Date: Fri, 4 Sep 2026 05:02:37 +0800 Subject: [PATCH 04/13] test(scripts): guard core subpath exports resolution under plain Node MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every core subpath specifier statically imported from packages/cli/src must resolve through packages/core/package.json exports in a real child node process — no vitest aliases, no tsconfig paths. Without the matching exports entries the built-but-unbundled CLI dies with ERR_PACKAGE_PATH_NOT_EXPORTED while every gate that bypasses exports stays green; this guard goes red the moment an entry is removed. Co-authored-by: Qwen-Coder Patrol-Run: qwen-pr-closeout/jmtlz4ubdgq --- .../core-subpath-exports-resolution.test.js | 55 +++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 scripts/tests/core-subpath-exports-resolution.test.js diff --git a/scripts/tests/core-subpath-exports-resolution.test.js b/scripts/tests/core-subpath-exports-resolution.test.js new file mode 100644 index 00000000000..437f5bb1540 --- /dev/null +++ b/scripts/tests/core-subpath-exports-resolution.test.js @@ -0,0 +1,55 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +// Plain-Node resolution guard for the core subpath exports map. +// +// cli sources import individual core modules through subpath specifiers +// (e.g. `@qwen-code/qwen-code-core/storage`). Typecheck resolves them via +// tsconfig `paths`, unit tests via the vitest alias list, and the bundle via +// esbuild's paths reading — none of which consults `exports`. The built- +// but-unbundled CLI (`npm start`, `npm run build-and-start`) resolves them +// through `packages/core/package.json` `exports` alone, so a specifier with +// no exports entry crashes at module load with ERR_PACKAGE_PATH_NOT_EXPORTED +// while every gate stays green. This guard resolves each specifier in a real +// child `node` process — no vitest aliases, no tsconfig paths — and fails if +// any exports entry goes missing. + +import { execFileSync } from 'node:child_process'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; + +const root = join(dirname(fileURLToPath(import.meta.url)), '../..'); + +// Every core subpath specifier statically imported from packages/cli/src, +// plus long-standing named subpaths as controls. +const specifiers = [ + '@qwen-code/qwen-code-core/storage', + '@qwen-code/qwen-code-core/atomicFileWrite', + '@qwen-code/qwen-code-core/debugLogger', + '@qwen-code/qwen-code-core/noFollowOpen', + '@qwen-code/qwen-code-core/envVarResolver', +]; + +function probe(specifier) { + const script = ` + try { + console.log('OK ' + import.meta.resolve(${JSON.stringify(specifier)})); + } catch (error) { + console.log('FAIL ' + (error.code ?? error.name)); + } + `; + return execFileSync(process.execPath, ['--input-type=module', '-e', script], { + cwd: join(root, 'packages', 'cli'), + encoding: 'utf8', + }).trim(); +} + +describe('core subpath specifiers resolve under plain Node', () => { + it.each(specifiers)('%s resolves', (specifier) => { + expect(probe(specifier)).toMatch(/^OK /); + }); +}); From 80eeca7cdc13c08a0c61fc40eed204d131c2d9ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=98=93=E8=89=AF?= <1204183885@qq.com> Date: Fri, 4 Sep 2026 12:22:06 +0800 Subject: [PATCH 05/13] fix(cli): name every core subpath import in cli tsconfig paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The named subpaths cli sources import from @qwen-code/qwen-code-core had no named `paths` entries, so the wildcard composed nonexistent files and esbuild (bundle) plus the dev loader chain fell back to the exports map, loading packages/core/dist copies while every package-root import loads packages/core/src — two instances of barrel-exported, stateful modules (debugLogger, storage, atomicFileWrite, envVarResolver, toolWriteOrigin, memoryScopes) in one process. Add the six missing named entries beside the existing ones so bundle and dev resolve all subpaths into the core src tree, consistent with the vitest alias list. Co-authored-by: Qwen-Coder Patrol-Run: qwen-pr-closeout/jmtme4yqxhl --- packages/cli/tsconfig.json | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/packages/cli/tsconfig.json b/packages/cli/tsconfig.json index d74554a4fa1..fab3e389316 100644 --- a/packages/cli/tsconfig.json +++ b/packages/cli/tsconfig.json @@ -17,6 +17,22 @@ "@qwen-code/qwen-code-core/noFollowOpen": [ "../core/src/utils/no-follow-open.ts" ], + "@qwen-code/qwen-code-core/storage": ["../core/src/config/storage.ts"], + "@qwen-code/qwen-code-core/atomicFileWrite": [ + "../core/src/utils/atomicFileWrite.ts" + ], + "@qwen-code/qwen-code-core/debugLogger": [ + "../core/src/utils/debugLogger.ts" + ], + "@qwen-code/qwen-code-core/envVarResolver": [ + "../core/src/utils/envVarResolver.ts" + ], + "@qwen-code/qwen-code-core/toolWriteOrigin": [ + "../core/src/services/tool-write-origin.ts" + ], + "@qwen-code/qwen-code-core/memoryScopes": [ + "../core/src/memory/scopes.ts" + ], "@qwen-code/qwen-code-core/*": ["../core/src/*"], "@qwen-code/acp-bridge": ["../acp-bridge/src/index.ts"], "@qwen-code/acp-bridge/transcriptReplay": [ From 8cb9801c666b4f53e1545ca541199e0805013ea5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=98=93=E8=89=AF?= <1204183885@qq.com> Date: Fri, 4 Sep 2026 12:22:25 +0800 Subject: [PATCH 06/13] test(scripts): pin subpath exports targets and guard bundle resolution Complete the resolution guard and harden its probe: - Cover every core subpath statically imported from packages/cli/src (adds toolWriteOrigin and memoryScopes) plus the subpaths npm start reaches through @qwen-code/acp-bridge (subSessionConstants, goalWire, transcriptRecords), instead of the previous five specifiers. - Pin each specifier to its expected dist target: assert the resolved URL equals the pinned path and the target file exists, so a typo'd or redirected exports target fails the guard (import.meta.resolve alone accepts both). This makes a built core dist a prerequisite, which vitest-global-setup already fail-fasts on. - Add a bundle-resolution guard: esbuild-bundle every cli/src core subpath under packages/cli/tsconfig.json and assert no input comes from packages/core/dist, pinning the tsconfig paths entries. Co-authored-by: Qwen-Coder Patrol-Run: qwen-pr-closeout/jmtme4yqxhl --- .../core-subpath-exports-resolution.test.js | 137 +++++++++++++++--- 1 file changed, 117 insertions(+), 20 deletions(-) diff --git a/scripts/tests/core-subpath-exports-resolution.test.js b/scripts/tests/core-subpath-exports-resolution.test.js index 437f5bb1540..a70ee3f53b8 100644 --- a/scripts/tests/core-subpath-exports-resolution.test.js +++ b/scripts/tests/core-subpath-exports-resolution.test.js @@ -4,35 +4,76 @@ * SPDX-License-Identifier: Apache-2.0 */ -// Plain-Node resolution guard for the core subpath exports map. +// Resolution guards for the core subpath scheme. // // cli sources import individual core modules through subpath specifiers // (e.g. `@qwen-code/qwen-code-core/storage`). Typecheck resolves them via // tsconfig `paths`, unit tests via the vitest alias list, and the bundle via -// esbuild's paths reading — none of which consults `exports`. The built- -// but-unbundled CLI (`npm start`, `npm run build-and-start`) resolves them -// through `packages/core/package.json` `exports` alone, so a specifier with -// no exports entry crashes at module load with ERR_PACKAGE_PATH_NOT_EXPORTED -// while every gate stays green. This guard resolves each specifier in a real -// child `node` process — no vitest aliases, no tsconfig paths — and fails if -// any exports entry goes missing. +// esbuild's paths reading — none of which consults `exports`. Two failure +// modes stay invisible to all of those gates: +// +// 1. The built-but-unbundled CLI (`npm start`, `npm run build-and-start`) +// resolves subpaths through `packages/core/package.json` `exports` alone, +// so a specifier with no exports entry — or an entry whose import target +// is typo'd or redirected — crashes or misloads at module load. The +// plain-Node guard below resolves every specifier in a real child `node` +// process (no vitest aliases, no tsconfig paths), pins the resolved URL +// to the expected dist target, and asserts that target exists. +// 2. The bundle chain reads `packages/cli/tsconfig.json` `paths`; a subpath +// without a named entry falls through the wildcard to a nonexistent file +// and then back to the `exports` map, bundling a `packages/core/dist/**` +// copy while every package-root import loads `packages/core/src/**` — +// two instances of barrel-exported, stateful modules in one process, the +// module-identity failure #10908's Known risks name. The esbuild guard +// below bundles every core subpath statically imported from +// packages/cli/src under the cli tsconfig and asserts every input lands +// under packages/core/src, never packages/core/dist. +// +// The pinned-target check makes a built core `dist` a prerequisite of the +// plain-Node guard (`import.meta.resolve` alone deliberately does not need +// one); `scripts/vitest-global-setup.js` already fails fast with an +// actionable message when workspace dist output is missing (#9149). import { execFileSync } from 'node:child_process'; +import { existsSync } from 'node:fs'; import { dirname, join } from 'node:path'; -import { fileURLToPath } from 'node:url'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +import { buildSync } from 'esbuild'; import { describe, expect, it } from 'vitest'; const root = join(dirname(fileURLToPath(import.meta.url)), '../..'); -// Every core subpath specifier statically imported from packages/cli/src, -// plus long-standing named subpaths as controls. -const specifiers = [ - '@qwen-code/qwen-code-core/storage', - '@qwen-code/qwen-code-core/atomicFileWrite', - '@qwen-code/qwen-code-core/debugLogger', - '@qwen-code/qwen-code-core/noFollowOpen', - '@qwen-code/qwen-code-core/envVarResolver', -]; +// Every core subpath specifier statically imported from packages/cli/src +// (storage, atomicFileWrite, debugLogger, noFollowOpen, envVarResolver, +// toolWriteOrigin, memoryScopes), plus the subpaths `npm start` reaches +// through @qwen-code/acp-bridge (subSessionConstants, goalWire, +// transcriptRecords). Values are the dist targets pinned by the exports map +// in packages/core/package.json. Probes run from packages/cli; acp-bridge is +// a `file:` dependency there, so acp-bridge-routed specifiers resolve +// identically from that cwd. +const expectedDistTargets = { + '@qwen-code/qwen-code-core/storage': + 'packages/core/dist/src/config/storage.js', + '@qwen-code/qwen-code-core/atomicFileWrite': + 'packages/core/dist/src/utils/atomicFileWrite.js', + '@qwen-code/qwen-code-core/debugLogger': + 'packages/core/dist/src/utils/debugLogger.js', + '@qwen-code/qwen-code-core/noFollowOpen': + 'packages/core/dist/src/utils/no-follow-open.js', + '@qwen-code/qwen-code-core/envVarResolver': + 'packages/core/dist/src/utils/envVarResolver.js', + '@qwen-code/qwen-code-core/toolWriteOrigin': + 'packages/core/dist/src/services/tool-write-origin.js', + '@qwen-code/qwen-code-core/memoryScopes': + 'packages/core/dist/src/memory/scopes.js', + '@qwen-code/qwen-code-core/subSessionConstants': + 'packages/core/dist/src/tools/sub-session-constants.js', + '@qwen-code/qwen-code-core/goalWire': + 'packages/core/dist/src/goals/goal-wire.js', + '@qwen-code/qwen-code-core/transcriptRecords': + 'packages/core/dist/src/utils/transcript-records.js', +}; function probe(specifier) { const script = ` @@ -49,7 +90,63 @@ function probe(specifier) { } describe('core subpath specifiers resolve under plain Node', () => { - it.each(specifiers)('%s resolves', (specifier) => { - expect(probe(specifier)).toMatch(/^OK /); + it.each(Object.entries(expectedDistTargets))( + '%s resolves to its pinned exports target', + (specifier, expectedTarget) => { + const output = probe(specifier); + expect(output).toMatch(/^OK /); + const url = output.slice('OK '.length); + expect(url).toBe(pathToFileURL(join(root, expectedTarget)).href); + expect(existsSync(fileURLToPath(url))).toBe(true); + }, + ); +}); + +// The core subpath specifiers statically imported from packages/cli/src, +// mapped to the core source file the matching named entry in +// packages/cli/tsconfig.json `paths` must resolve each one to. +const expectedSrcTargets = { + '@qwen-code/qwen-code-core/storage': 'packages/core/src/config/storage.ts', + '@qwen-code/qwen-code-core/atomicFileWrite': + 'packages/core/src/utils/atomicFileWrite.ts', + '@qwen-code/qwen-code-core/debugLogger': + 'packages/core/src/utils/debugLogger.ts', + '@qwen-code/qwen-code-core/noFollowOpen': + 'packages/core/src/utils/no-follow-open.ts', + '@qwen-code/qwen-code-core/envVarResolver': + 'packages/core/src/utils/envVarResolver.ts', + '@qwen-code/qwen-code-core/toolWriteOrigin': + 'packages/core/src/services/tool-write-origin.ts', + '@qwen-code/qwen-code-core/memoryScopes': + 'packages/core/src/memory/scopes.ts', +}; + +describe('core subpath specifiers bundle from the core src tree', () => { + it('resolves every cli/src subpath under packages/core/src', () => { + const result = buildSync({ + absWorkingDir: root, + stdin: { + contents: Object.keys(expectedSrcTargets) + .map((specifier) => `import ${JSON.stringify(specifier)};`) + .join('\n'), + resolveDir: join(root, 'packages', 'cli'), + sourcefile: 'core-subpath-bundle-probe.ts', + loader: 'ts', + }, + bundle: true, + write: false, + metafile: true, + platform: 'node', + format: 'esm', + logLevel: 'silent', + tsconfig: join(root, 'packages', 'cli', 'tsconfig.json'), + }); + const inputs = Object.keys(result.metafile.inputs); + expect( + inputs.filter((input) => input.includes('packages/core/dist/')), + ).toEqual([]); + for (const target of Object.values(expectedSrcTargets)) { + expect(inputs).toContain(target); + } }); }); From 0d0685039c1c84e673fe4b505b3067d79eed709f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=98=93=E8=89=AF?= <1204183885@qq.com> Date: Fri, 4 Sep 2026 17:23:56 +0800 Subject: [PATCH 07/13] fix: cover goalWire in the core-subpath guard and make it OS-independent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add goalWire to the bundle arm and named tsconfig paths entries in packages/cli and packages/acp-bridge: acp-bridge's transcript-replay imports @qwen-code/qwen-code-core/goalWire, and without a named entry the wildcard resolved to a nonexistent ../core/src/goalWire and fell back to the exports map, bundling packages/core/dist/src/goals/ goal-wire.js next to src-resolved core modules — the module-identity split #10908's Known risks name. Verified with an esbuild metafile probe: dist input before the paths entry, src input after. - Normalize esbuild metafile input keys to forward slashes before the dist-leak filter and src-target assertions so the guard behaves the same on Windows runners, where esbuild emits backslash separators. - Correct the header: this lane's vitest config does not wire scripts/vitest-global-setup.js (it is a globalSetup only in the packages/core and packages/cli configs, and its DIST_PREREQUISITES has no key covering this lane), so a missing dist surfaces as the existence assertion naming the absent file. Co-authored-by: Qwen-Coder Patrol-Run: qwen-pr-closeout/jmtmourmvi4 --- packages/acp-bridge/tsconfig.json | 1 + packages/cli/tsconfig.json | 1 + .../core-subpath-exports-resolution.test.js | 32 +++++++++++++------ 3 files changed, 25 insertions(+), 9 deletions(-) diff --git a/packages/acp-bridge/tsconfig.json b/packages/acp-bridge/tsconfig.json index 4bb585f54b8..0ec0bcf86da 100644 --- a/packages/acp-bridge/tsconfig.json +++ b/packages/acp-bridge/tsconfig.json @@ -15,6 +15,7 @@ "@qwen-code/qwen-code-core/transcriptRecords": [ "../core/src/utils/transcript-records.ts" ], + "@qwen-code/qwen-code-core/goalWire": ["../core/src/goals/goal-wire.ts"], "@qwen-code/qwen-code-core/noFollowOpen": [ "../core/src/utils/no-follow-open.ts" ], diff --git a/packages/cli/tsconfig.json b/packages/cli/tsconfig.json index fab3e389316..2bc8b5bd0a5 100644 --- a/packages/cli/tsconfig.json +++ b/packages/cli/tsconfig.json @@ -14,6 +14,7 @@ "@qwen-code/qwen-code-core/transcriptRecords": [ "../core/src/utils/transcript-records.ts" ], + "@qwen-code/qwen-code-core/goalWire": ["../core/src/goals/goal-wire.ts"], "@qwen-code/qwen-code-core/noFollowOpen": [ "../core/src/utils/no-follow-open.ts" ], diff --git a/scripts/tests/core-subpath-exports-resolution.test.js b/scripts/tests/core-subpath-exports-resolution.test.js index a70ee3f53b8..3ff83da6e66 100644 --- a/scripts/tests/core-subpath-exports-resolution.test.js +++ b/scripts/tests/core-subpath-exports-resolution.test.js @@ -26,13 +26,18 @@ // two instances of barrel-exported, stateful modules in one process, the // module-identity failure #10908's Known risks name. The esbuild guard // below bundles every core subpath statically imported from -// packages/cli/src under the cli tsconfig and asserts every input lands -// under packages/core/src, never packages/core/dist. +// packages/cli/src — including goalWire, which the bundle reaches through +// @qwen-code/acp-bridge — under the cli tsconfig and asserts every input +// lands under packages/core/src, never packages/core/dist. // // The pinned-target check makes a built core `dist` a prerequisite of the // plain-Node guard (`import.meta.resolve` alone deliberately does not need -// one); `scripts/vitest-global-setup.js` already fails fast with an -// actionable message when workspace dist output is missing (#9149). +// one). This lane's vitest config does not wire +// `scripts/vitest-global-setup.js` (that guard is a globalSetup only in the +// packages/core and packages/cli configs, and its DIST_PREREQUISITES has no +// key covering this lane), so a missing dist surfaces as the existence +// assertion below naming the absent file — run `npm run build` from the +// repository root to produce it (#9149). import { execFileSync } from 'node:child_process'; import { existsSync } from 'node:fs'; @@ -102,9 +107,11 @@ describe('core subpath specifiers resolve under plain Node', () => { ); }); -// The core subpath specifiers statically imported from packages/cli/src, -// mapped to the core source file the matching named entry in -// packages/cli/tsconfig.json `paths` must resolve each one to. +// The core subpath specifiers the cli bundle resolves under +// packages/cli/tsconfig.json `paths` — everything statically imported from +// packages/cli/src, plus goalWire, which the bundle reaches through +// @qwen-code/acp-bridge's transcript-replay — each mapped to the core source +// file the matching named `paths` entry must resolve it to. const expectedSrcTargets = { '@qwen-code/qwen-code-core/storage': 'packages/core/src/config/storage.ts', '@qwen-code/qwen-code-core/atomicFileWrite': @@ -119,10 +126,11 @@ const expectedSrcTargets = { 'packages/core/src/services/tool-write-origin.ts', '@qwen-code/qwen-code-core/memoryScopes': 'packages/core/src/memory/scopes.ts', + '@qwen-code/qwen-code-core/goalWire': 'packages/core/src/goals/goal-wire.ts', }; describe('core subpath specifiers bundle from the core src tree', () => { - it('resolves every cli/src subpath under packages/core/src', () => { + it('resolves every bundled core subpath under packages/core/src', () => { const result = buildSync({ absWorkingDir: root, stdin: { @@ -141,7 +149,13 @@ describe('core subpath specifiers bundle from the core src tree', () => { logLevel: 'silent', tsconfig: join(root, 'packages', 'cli', 'tsconfig.json'), }); - const inputs = Object.keys(result.metafile.inputs); + // esbuild emits metafile input keys with the platform path separator + // (backslash on Windows); normalize to forward slashes so the target + // literals above and the leak filter below compare identically on every + // runner. + const inputs = Object.keys(result.metafile.inputs).map((input) => + input.replace(/\\/g, '/'), + ); expect( inputs.filter((input) => input.includes('packages/core/dist/')), ).toEqual([]); From a0f08a9355ac0d51f0ae3aa115db1a23a9c160fd Mon Sep 17 00:00:00 2001 From: "jinjing.zzj" Date: Fri, 4 Sep 2026 21:35:21 +0800 Subject: [PATCH 08/13] test: guard acp-bridge-routed core subpaths in the bundle arm MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bundle arm seeded goalWire into the cli-routed buildSync case, which passes an explicit packages/cli/tsconfig.json — but the shipped bundle resolves that import under packages/acp-bridge/tsconfig.json (mainBuild in esbuild.config.js carries no tsconfig option, so esbuild discovers the nearest tsconfig per importing file, and goalWire is imported only from packages/acp-bridge/src/transcript-replay.ts). The acp-bridge goalWire paths entry was therefore guarded by no arm, and transcriptRecords / subSessionConstants were probed by none at all: removing the acp-bridge goalWire entry left the test green while a production-shaped build pulled packages/core/dist inputs. Add an acp-bridge-routed buildSync case — no tsconfig option, resolveDir packages/acp-bridge/src, seeding goalWire, transcriptRecords, subSessionConstants and noFollowOpen — asserting no input lands under packages/core/dist and each expected core src target is present. With the new arm, removing the acp-bridge goalWire entry goes red (4 dist leaks) where previously nothing did. Co-authored-by: Qwen-Coder Patrol-Run: qwen-pr-closeout/jmtmxfessij --- .../core-subpath-exports-resolution.test.js | 54 +++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/scripts/tests/core-subpath-exports-resolution.test.js b/scripts/tests/core-subpath-exports-resolution.test.js index 3ff83da6e66..056eacbf602 100644 --- a/scripts/tests/core-subpath-exports-resolution.test.js +++ b/scripts/tests/core-subpath-exports-resolution.test.js @@ -164,3 +164,57 @@ describe('core subpath specifiers bundle from the core src tree', () => { } }); }); + +// The core subpath specifiers the bundle reaches through @qwen-code/acp-bridge +// — goalWire and transcriptRecords from transcript-replay.ts, +// subSessionConstants from bridgeOptions.ts, noFollowOpen from +// sessionArtifacts.ts — each mapped to the core source file the matching +// named `paths` entry in packages/acp-bridge/tsconfig.json must resolve it +// to. esbuild.config.js's mainBuild passes no `tsconfig` option, so the +// shipped bundle resolves these by discovering packages/acp-bridge/tsconfig.json +// per importing file, not the cli tsconfig pinned above — a guard arm that +// only probes the cli route is blind to mutations of these entries. +const expectedAcpBridgeSrcTargets = { + '@qwen-code/qwen-code-core/goalWire': 'packages/core/src/goals/goal-wire.ts', + '@qwen-code/qwen-code-core/transcriptRecords': + 'packages/core/src/utils/transcript-records.ts', + '@qwen-code/qwen-code-core/subSessionConstants': + 'packages/core/src/tools/sub-session-constants.ts', + '@qwen-code/qwen-code-core/noFollowOpen': + 'packages/core/src/utils/no-follow-open.ts', +}; + +describe('acp-bridge-routed core subpaths bundle from the core src tree', () => { + it('resolves every acp-bridge-routed subpath under packages/core/src', () => { + const result = buildSync({ + absWorkingDir: root, + stdin: { + contents: Object.keys(expectedAcpBridgeSrcTargets) + .map((specifier) => `import ${JSON.stringify(specifier)};`) + .join('\n'), + resolveDir: join(root, 'packages', 'acp-bridge', 'src'), + sourcefile: 'core-subpath-bundle-probe-acp-bridge.ts', + loader: 'ts', + }, + bundle: true, + write: false, + metafile: true, + platform: 'node', + format: 'esm', + logLevel: 'silent', + // Deliberately no `tsconfig` option: mainBuild carries none, so esbuild + // discovers packages/acp-bridge/tsconfig.json per importing file — the + // resolution route the shipped bundle actually uses for these imports. + }); + // Same backslash normalization as the cli-routed arm above. + const inputs = Object.keys(result.metafile.inputs).map((input) => + input.replace(/\\/g, '/'), + ); + expect( + inputs.filter((input) => input.includes('packages/core/dist/')), + ).toEqual([]); + for (const target of Object.values(expectedAcpBridgeSrcTargets)) { + expect(inputs).toContain(target); + } + }); +}); From b15e73f3e5151f7f09161e8e0eca714a019ac7c1 Mon Sep 17 00:00:00 2001 From: "jinjing.zzj" Date: Sat, 5 Sep 2026 05:49:21 +0800 Subject: [PATCH 09/13] fix(cli): guard the conversationsRuntimeMarker subpath route R6-1: @qwen-code/qwen-code-core/conversationsRuntimeMarker is statically imported from packages/cli/src (config/shared-env-keys.ts, serve/run-qwen-serve.ts) but carried no named entry in packages/cli/tsconfig.json paths and was seeded into neither guard map. The wildcard composed a nonexistent ../core/src target and fell back to the exports map, so the cli-routed bundle loaded a packages/core/dist copy next to the core src copy while every guard arm stayed green. Add the named paths entry beside the ones this PR already adds and seed both guard maps with the specifier. Co-authored-by: Qwen-Coder Patrol-Run: qwen-pr-closeout/jmtngpuscjl --- packages/cli/tsconfig.json | 3 +++ .../core-subpath-exports-resolution.test.js | 16 ++++++++++------ 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/packages/cli/tsconfig.json b/packages/cli/tsconfig.json index 2bc8b5bd0a5..be0cb3f7934 100644 --- a/packages/cli/tsconfig.json +++ b/packages/cli/tsconfig.json @@ -34,6 +34,9 @@ "@qwen-code/qwen-code-core/memoryScopes": [ "../core/src/memory/scopes.ts" ], + "@qwen-code/qwen-code-core/conversationsRuntimeMarker": [ + "../core/src/utils/conversations-runtime-marker.ts" + ], "@qwen-code/qwen-code-core/*": ["../core/src/*"], "@qwen-code/acp-bridge": ["../acp-bridge/src/index.ts"], "@qwen-code/acp-bridge/transcriptReplay": [ diff --git a/scripts/tests/core-subpath-exports-resolution.test.js b/scripts/tests/core-subpath-exports-resolution.test.js index 056eacbf602..9d773858fa1 100644 --- a/scripts/tests/core-subpath-exports-resolution.test.js +++ b/scripts/tests/core-subpath-exports-resolution.test.js @@ -51,12 +51,12 @@ const root = join(dirname(fileURLToPath(import.meta.url)), '../..'); // Every core subpath specifier statically imported from packages/cli/src // (storage, atomicFileWrite, debugLogger, noFollowOpen, envVarResolver, -// toolWriteOrigin, memoryScopes), plus the subpaths `npm start` reaches -// through @qwen-code/acp-bridge (subSessionConstants, goalWire, -// transcriptRecords). Values are the dist targets pinned by the exports map -// in packages/core/package.json. Probes run from packages/cli; acp-bridge is -// a `file:` dependency there, so acp-bridge-routed specifiers resolve -// identically from that cwd. +// toolWriteOrigin, memoryScopes, conversationsRuntimeMarker), plus the +// subpaths `npm start` reaches through @qwen-code/acp-bridge +// (subSessionConstants, goalWire, transcriptRecords). Values are the dist +// targets pinned by the exports map in packages/core/package.json. Probes +// run from packages/cli; acp-bridge is a `file:` dependency there, so +// acp-bridge-routed specifiers resolve identically from that cwd. const expectedDistTargets = { '@qwen-code/qwen-code-core/storage': 'packages/core/dist/src/config/storage.js', @@ -72,6 +72,8 @@ const expectedDistTargets = { 'packages/core/dist/src/services/tool-write-origin.js', '@qwen-code/qwen-code-core/memoryScopes': 'packages/core/dist/src/memory/scopes.js', + '@qwen-code/qwen-code-core/conversationsRuntimeMarker': + 'packages/core/dist/src/utils/conversations-runtime-marker.js', '@qwen-code/qwen-code-core/subSessionConstants': 'packages/core/dist/src/tools/sub-session-constants.js', '@qwen-code/qwen-code-core/goalWire': @@ -126,6 +128,8 @@ const expectedSrcTargets = { 'packages/core/src/services/tool-write-origin.ts', '@qwen-code/qwen-code-core/memoryScopes': 'packages/core/src/memory/scopes.ts', + '@qwen-code/qwen-code-core/conversationsRuntimeMarker': + 'packages/core/src/utils/conversations-runtime-marker.ts', '@qwen-code/qwen-code-core/goalWire': 'packages/core/src/goals/goal-wire.ts', }; From 637ea97eb1ebc33f52f0a8531bc89f8c6f225cfa Mon Sep 17 00:00:00 2001 From: "jinjing.zzj" Date: Sat, 5 Sep 2026 05:49:39 +0800 Subject: [PATCH 10/13] fix(sdk): guard the sdk-routed core subpath resolution R6-2: the cli bundle reaches sdk sources through the cli tsconfig @qwen-code/sdk/* mapping (ui/utils/export/export-transcript-document.ts imports @qwen-code/sdk/daemon/transcript, which re-exports from daemon/ui/chat-record-transcript.ts), and chat-record-transcript.ts imports @qwen-code/qwen-code-core/transcriptRecords. With no paths in packages/sdk-typescript/tsconfig.json, mainBuild's per-importing-file tsconfig discovery found no mapping and fell back to the exports map, bundling a packages/core/dist copy while the guard suite stayed green. Add the named entry to the sdk tsconfig, mirroring acp-bridge, and a third guard arm probing the sdk route. Co-authored-by: Qwen-Coder Patrol-Run: qwen-pr-closeout/jmtngpuscjl --- packages/sdk-typescript/tsconfig.json | 14 ++++- .../core-subpath-exports-resolution.test.js | 57 ++++++++++++++++++- 2 files changed, 67 insertions(+), 4 deletions(-) diff --git a/packages/sdk-typescript/tsconfig.json b/packages/sdk-typescript/tsconfig.json index 8682024336e..ff2dc96f767 100644 --- a/packages/sdk-typescript/tsconfig.json +++ b/packages/sdk-typescript/tsconfig.json @@ -34,7 +34,19 @@ /* Module Resolution */ "resolveJsonModule": true, - "types": ["node"] + "types": ["node"], + "baseUrl": ".", + // The cli bundle pulls these sources in through the cli tsconfig + // `@qwen-code/sdk/*` mapping, and mainBuild discovers this file per + // importing file (it passes no `tsconfig` option). Without a named entry + // here, core subpath imports fall back to the `exports` map and bundle a + // packages/core/dist copy next to the packages/core/src copy every + // package-root import loads. Mirrors packages/acp-bridge/tsconfig.json. + "paths": { + "@qwen-code/qwen-code-core/transcriptRecords": [ + "../core/src/utils/transcript-records.ts" + ] + } }, "include": ["src/**/*.ts"], "exclude": ["node_modules", "dist", "test", ".integration-tests"] diff --git a/scripts/tests/core-subpath-exports-resolution.test.js b/scripts/tests/core-subpath-exports-resolution.test.js index 9d773858fa1..11660b8c850 100644 --- a/scripts/tests/core-subpath-exports-resolution.test.js +++ b/scripts/tests/core-subpath-exports-resolution.test.js @@ -26,9 +26,10 @@ // two instances of barrel-exported, stateful modules in one process, the // module-identity failure #10908's Known risks name. The esbuild guard // below bundles every core subpath statically imported from -// packages/cli/src — including goalWire, which the bundle reaches through -// @qwen-code/acp-bridge — under the cli tsconfig and asserts every input -// lands under packages/core/src, never packages/core/dist. +// packages/cli/src — plus the subpaths the bundle reaches through +// @qwen-code/acp-bridge and @qwen-code/sdk — under the tsconfig each +// route's importing file discovers, and asserts every input lands under +// packages/core/src, never packages/core/dist. // // The pinned-target check makes a built core `dist` a prerequisite of the // plain-Node guard (`import.meta.resolve` alone deliberately does not need @@ -222,3 +223,53 @@ describe('acp-bridge-routed core subpaths bundle from the core src tree', () => } }); }); + +// The core subpath specifiers the bundle reaches through @qwen-code/sdk — +// transcriptRecords from daemon/ui/chat-record-transcript.ts, pulled in via +// the cli tsconfig `@qwen-code/sdk/*` mapping (cli sources such as +// ui/utils/export/export-transcript-document.ts import +// `@qwen-code/sdk/daemon/transcript`). mainBuild passes no `tsconfig` +// option, so the shipped bundle resolves these by discovering +// packages/sdk-typescript/tsconfig.json per importing file — a guard arm +// that only probes the cli and acp-bridge routes is blind to mutations of +// the named entry there. +const expectedSdkSrcTargets = { + '@qwen-code/qwen-code-core/transcriptRecords': + 'packages/core/src/utils/transcript-records.ts', +}; + +describe('sdk-routed core subpaths bundle from the core src tree', () => { + it('resolves every sdk-routed subpath under packages/core/src', () => { + const result = buildSync({ + absWorkingDir: root, + stdin: { + contents: Object.keys(expectedSdkSrcTargets) + .map((specifier) => `import ${JSON.stringify(specifier)};`) + .join('\n'), + resolveDir: join(root, 'packages', 'sdk-typescript', 'src'), + sourcefile: 'core-subpath-bundle-probe-sdk.ts', + loader: 'ts', + }, + bundle: true, + write: false, + metafile: true, + platform: 'node', + format: 'esm', + logLevel: 'silent', + // Deliberately no `tsconfig` option: mainBuild carries none, so esbuild + // discovers packages/sdk-typescript/tsconfig.json per importing file — + // the resolution route the shipped bundle actually uses for these + // imports. + }); + // Same backslash normalization as the cli-routed arm above. + const inputs = Object.keys(result.metafile.inputs).map((input) => + input.replace(/\\/g, '/'), + ); + expect( + inputs.filter((input) => input.includes('packages/core/dist/')), + ).toEqual([]); + for (const target of Object.values(expectedSdkSrcTargets)) { + expect(inputs).toContain(target); + } + }); +}); From d4996ede49332616d4df2cd193b4ae34be62c5a4 Mon Sep 17 00:00:00 2001 From: "jinjing.zzj" Date: Sat, 5 Sep 2026 05:55:13 +0800 Subject: [PATCH 11/13] fix(sdk): reference core from the sdk composite reference project The paths entry added for the sdk-routed guard resolves @qwen-code/qwen-code-core/transcriptRecords to the core source file, which in the composite tsc --build graph belongs to the core project. Without a project reference the cli build failed with TS6059/TS6307; declare the core reference, mirroring packages/acp-bridge/tsconfig.json. Verified with npm run build in packages/cli and npm run typecheck in packages/sdk-typescript. Co-authored-by: Qwen-Coder Patrol-Run: qwen-pr-closeout/jmtngpuscjl --- packages/sdk-typescript/tsconfig.reference.json | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/sdk-typescript/tsconfig.reference.json b/packages/sdk-typescript/tsconfig.reference.json index 63b8d095e99..c16bae74f22 100644 --- a/packages/sdk-typescript/tsconfig.reference.json +++ b/packages/sdk-typescript/tsconfig.reference.json @@ -4,5 +4,10 @@ "composite": true, "rootDir": "./src", "tsBuildInfoFile": "./dist/tsconfig.reference.tsbuildinfo" - } + }, + // The paths entry inherited from tsconfig.json resolves core subpath imports + // to core sources; in a composite tsc --build graph that file belongs to + // the core project, so it must be referenced here (mirrors + // packages/acp-bridge/tsconfig.json) or the build fails TS6059/TS6307. + "references": [{ "path": "../core" }] } From 4653a3452a233da2a292f93708c68c2ed5c8edf5 Mon Sep 17 00:00:00 2001 From: "jinjing.zzj" Date: Sat, 5 Sep 2026 06:05:52 +0800 Subject: [PATCH 12/13] fix(sdk): keep the declaration build on the exports map The paths entry added to tsconfig.json for the bundle's esbuild discovery is inherited by tsconfig.build.json; in that plain declaration build it pulled core sources into the program, re-rooted the inferred rootDir above the package, and nested every emitted .d.ts under dist/sdk-typescript/src/, dropping dist/daemon/index.d.ts that web-shell imports (TS7016). Reset paths in tsconfig.build.json so the declaration build resolves core through its exports map. Verified with npm run build in packages/sdk-typescript: dist/daemon/index.d.ts restored and the daemon browser bundle byte-identical (236252 bytes, its pre-existing warning threshold breach is unchanged). Co-authored-by: Qwen-Coder Patrol-Run: qwen-pr-closeout/jmtngpuscjl --- packages/sdk-typescript/tsconfig.build.json | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/packages/sdk-typescript/tsconfig.build.json b/packages/sdk-typescript/tsconfig.build.json index 61dbca5b1d8..6cee9e28d71 100644 --- a/packages/sdk-typescript/tsconfig.build.json +++ b/packages/sdk-typescript/tsconfig.build.json @@ -8,7 +8,14 @@ "declarationMap": false, "sourceMap": false, "emitDeclarationOnly": true, - "removeComments": false + "removeComments": false, + // Reset the paths inherited from tsconfig.json: they map core subpaths to + // core sources for the cli bundle's esbuild discovery, but in this plain + // declaration build they would pull those sources into the program, + // re-root the inferred rootDir above this package, and nest every emitted + // .d.ts under dist/sdk-typescript/src/ (dropping dist/daemon/index.d.ts). + // Resolving core through its exports map keeps the dist layout stable. + "paths": {} }, "include": ["src/**/*.ts"], "exclude": ["node_modules", "dist", "test", "**/*.test.ts", "**/*.spec.ts"] From 27185b6f84c847b28f1ef24e4fb508cb08a45776 Mon Sep 17 00:00:00 2001 From: "jinjing.zzj" Date: Sun, 6 Sep 2026 07:17:35 +0800 Subject: [PATCH 13/13] fix(scripts): route core subpaths to src in the dev loader The dev loader intercepted only the exact package root, so a named core subpath fell through to the package's exports map and loaded packages/core/dist while every package-root import loaded packages/core/src. One dev process then held two instances of the same module: Config binds the debug session on the src copy (setDebugLogSession(this)), so RemoteInputWatcher's dist-copy REMOTE_INPUT logger read an empty session and every debugLogger(...) call there silently no-oped with QWEN_DEBUG_LOG_FILE enabled. Storage split its static state the same way. Derive the interception map from the core exports map so every named subpath short-circuits to its packages/core/src file and stays covered as subpaths are added. The exports entries (built-but-unbundled CLI) and the tsconfig paths entries (bundle lane) are untouched. Co-authored-by: Qwen-Coder Patrol-Run: qwen-pr-closeout/jmtow5pgslz --- scripts/dev.js | 44 ++++++++++-- scripts/tests/dev.test.js | 142 +++++++++++++++++++++++++++++++++++++- 2 files changed, 177 insertions(+), 9 deletions(-) diff --git a/scripts/dev.js b/scripts/dev.js index 34e5e5d50c8..072cc6533a2 100755 --- a/scripts/dev.js +++ b/scripts/dev.js @@ -65,19 +65,51 @@ const cliEntry = join(cliPackageDir, 'index.ts'); const tmpDir = mkdtempSync(join(tmpdir(), 'qwen-dev-')); const loaderPath = join(tmpDir, 'loader.mjs'); -const coreSourcePath = join(root, 'packages', 'core', 'index.ts'); -const coreSourceUrl = pathToFileURL(coreSourcePath).href; +const coreDir = join(root, 'packages', 'core'); +const coreSpecifier = '@qwen-code/qwen-code-core'; +const coreSourceUrl = pathToFileURL(join(coreDir, 'index.ts')).href; + +// Dev runs the CLI from TypeScript source, so every core specifier has to land +// on packages/core/src. Intercepting only the package root is not enough: a +// named subpath (`…/debugLogger`) resolves through the package's `exports` map +// into packages/core/dist, while the root loads src — one process holding two +// instances of the same module, with split static state and a debug logger +// bound to a session the other copy never set. Deriving the map from `exports` +// keeps the interception complete as subpaths are added. +const coreExports = JSON.parse( + readFileSync(join(coreDir, 'package.json'), 'utf-8'), +).exports; + +const coreSubpathSourceUrls = {}; +for (const [subpath, conditions] of Object.entries(coreExports ?? {})) { + const distEntry = conditions?.import; + // Skips the root (handled below) and the string-valued entries — `./dist/*`, + // `./src/*`, `./package.json` — whose target is not one fixed source file. + if (subpath === '.' || typeof distEntry !== 'string') continue; + if (!distEntry.startsWith('./dist/')) continue; + const sourcePath = join( + coreDir, + distEntry.slice('./dist/'.length).replace(/\.js$/, '.ts'), + ); + if (existsSync(sourcePath)) { + coreSubpathSourceUrls[`${coreSpecifier}/${subpath.slice(2)}`] = + pathToFileURL(sourcePath).href; + } +} const loaderCode = ` -import { pathToFileURL } from 'node:url'; - const coreSourceUrl = '${coreSourceUrl}'; +const coreSubpathSourceUrls = ${JSON.stringify(coreSubpathSourceUrls, null, 2)}; export function resolve(specifier, context, nextResolve) { - if (specifier === '@qwen-code/qwen-code-core') { + const url = + specifier === '${coreSpecifier}' + ? coreSourceUrl + : coreSubpathSourceUrls[specifier]; + if (url) { return { shortCircuit: true, - url: coreSourceUrl, + url, format: 'module', }; } diff --git a/scripts/tests/dev.test.js b/scripts/tests/dev.test.js index 65f9e76f283..447cfa3a85f 100644 --- a/scripts/tests/dev.test.js +++ b/scripts/tests/dev.test.js @@ -5,11 +5,22 @@ */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { readFile, stat } from 'node:fs/promises'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; -const { spawnMock, platformMock, existsSyncMock } = vi.hoisted(() => ({ +const { + spawnMock, + platformMock, + existsSyncMock, + readFileSyncMock, + writeFileSyncMock, +} = vi.hoisted(() => ({ spawnMock: vi.fn(() => ({ on: vi.fn() })), platformMock: vi.fn(() => 'darwin'), existsSyncMock: vi.fn(() => false), + readFileSyncMock: vi.fn(() => JSON.stringify({ version: '0.0.0-test' })), + writeFileSyncMock: vi.fn(), })); vi.mock('node:child_process', () => ({ @@ -26,13 +37,13 @@ vi.mock('node:os', async (importOriginal) => { }); vi.mock('node:fs', () => ({ - writeFileSync: vi.fn(), + writeFileSync: writeFileSyncMock, mkdtempSync: vi.fn(() => '/tmp/qwen-dev-test'), rmSync: vi.fn(), existsSync: existsSyncMock, symlinkSync: vi.fn(), mkdirSync: vi.fn(), - readFileSync: vi.fn(() => JSON.stringify({ version: '0.0.0-test' })), + readFileSync: readFileSyncMock, })); const normalizePath = (path) => String(path).replaceAll('\\', '/'); @@ -137,4 +148,129 @@ describe('scripts/dev.js launcher', () => { else process.env.QWEN_CODE_CLI = inherited; } }); + + it('resolves core subpaths to packages/core/src, not the exports map dist', async () => { + // Intercepting only the package root leaves a named subpath to Node's + // `exports` map, which resolves into packages/core/dist while the root + // loads packages/core/src — one dev process holding two instances of the + // same module. Config binds the debug session on the src copy, so the dist + // copy's REMOTE_INPUT logger reads an empty session and every + // debugLogger(...) call in RemoteInputWatcher silently no-ops. + const repoRoot = fileURLToPath(new URL('../../', import.meta.url)); + const coreDir = join(repoRoot, 'packages', 'core'); + const corePackageJson = await readFile( + join(coreDir, 'package.json'), + 'utf-8', + ); + const existsOnDisk = async (p) => { + try { + await stat(p); + return true; + } catch { + return false; + } + }; + + // Ground truth independent of the implementation's derivation rule. + const expectedSources = { + '@qwen-code/qwen-code-core': 'packages/core/index.ts', + '@qwen-code/qwen-code-core/debugLogger': + 'packages/core/src/utils/debugLogger.ts', + '@qwen-code/qwen-code-core/storage': + 'packages/core/src/config/storage.ts', + '@qwen-code/qwen-code-core/atomicFileWrite': + 'packages/core/src/utils/atomicFileWrite.ts', + }; + + // Every named subpath the exports map publishes, so the interception + // cannot silently fall behind as subpaths are added. + const namedSubpaths = []; + for (const [subpath, conditions] of Object.entries( + JSON.parse(corePackageJson).exports ?? {}, + )) { + const distEntry = conditions?.import; + if (subpath === '.' || typeof distEntry !== 'string') continue; + if (!distEntry.startsWith('./dist/')) continue; + const sourcePath = join( + coreDir, + distEntry.slice('./dist/'.length).replace(/\.js$/, '.ts'), + ); + if (await existsOnDisk(sourcePath)) { + namedSubpaths.push(`@qwen-code/qwen-code-core/${subpath.slice(2)}`); + } + } + expect(namedSubpaths.length).toBeGreaterThan(0); + + const defaultRead = readFileSyncMock.getMockImplementation(); + const defaultExists = existsSyncMock.getMockImplementation(); + try { + readFileSyncMock.mockImplementation((filePath, ...rest) => + normalizePath(filePath).endsWith('packages/core/package.json') + ? corePackageJson + : defaultRead(filePath, ...rest), + ); + // The launcher only probes the source files it is about to map, so answer + // from the real tree: a mapping whose source is missing must be dropped + // rather than emitted. + existsSyncMock.mockImplementation((filePath) => { + const normalized = normalizePath(filePath); + return ( + normalized.includes('/packages/core/') && + (Object.values(expectedSources).some((s) => normalized.endsWith(s)) || + namedSubpaths.some((specifier) => { + const sub = specifier.slice('@qwen-code/qwen-code-core/'.length); + const distEntry = + JSON.parse(corePackageJson).exports[`./${sub}`]?.import; + return normalized.endsWith( + distEntry.slice('./dist/'.length).replace(/\.js$/, '.ts'), + ); + })) + ); + }); + + await import('../dev.js?subpath-source-map'); + + const loaderCall = writeFileSyncMock.mock.calls.find(([filePath]) => + normalizePath(filePath).endsWith('loader.mjs'), + ); + expect(loaderCall).toBeDefined(); + // Execute the hook the launcher actually generates instead of asserting + // on its source text. + const loader = await import( + `data:text/javascript;base64,${Buffer.from(loaderCall[1]).toString('base64')}` + ); + const nextResolve = (specifier) => ({ + url: `fallthrough:${specifier}`, + format: 'module', + shortCircuit: false, + }); + + for (const [specifier, expected] of Object.entries(expectedSources)) { + const resolved = loader.resolve(specifier, {}, nextResolve); + expect(resolved.shortCircuit, specifier).toBe(true); + const resolvedPath = normalizePath(fileURLToPath(resolved.url)); + expect(resolvedPath, specifier).toContain(expected); + expect(resolvedPath, specifier).not.toContain('/dist/'); + } + + // Completeness: no published named subpath may fall through to Node, + // which is the lane that reaches dist. + for (const specifier of namedSubpaths) { + const resolved = loader.resolve(specifier, {}, nextResolve); + expect(resolved.shortCircuit, specifier).toBe(true); + expect(resolved.url, specifier).not.toContain('fallthrough:'); + expect(normalizePath(fileURLToPath(resolved.url)), specifier).toContain( + '/packages/core/src/', + ); + } + + // Unrelated specifiers still reach Node's resolver. + expect(loader.resolve('node:fs', {}, nextResolve).url).toBe( + 'fallthrough:node:fs', + ); + } finally { + readFileSyncMock.mockImplementation(defaultRead); + existsSyncMock.mockImplementation(defaultExists); + } + }); });