diff --git a/packages/acp-bridge/src/bridge.test.ts b/packages/acp-bridge/src/bridge.test.ts index c969bb21cb2..ae5028671bb 100644 --- a/packages/acp-bridge/src/bridge.test.ts +++ b/packages/acp-bridge/src/bridge.test.ts @@ -3161,7 +3161,9 @@ describe('createAcpSessionBridge', () => { availableSkills: [], }); await expect( - bridge.getSessionTasksStatus(session.sessionId), + bridge.getSessionTasksStatus(session.sessionId, { + includeWorkflows: true, + }), ).resolves.toMatchObject({ sessionId: session.sessionId, tasks: [], @@ -3185,6 +3187,10 @@ describe('createAcpSessionBridge', () => { 'qwen/status/session/tasks', 'qwen/status/session/lsp', ]); + expect(handles[0]?.agent.extMethodCalls[2]?.params).toMatchObject({ + sessionId: session.sessionId, + includeWorkflows: true, + }); await bridge.shutdown(); }); @@ -4265,6 +4271,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 () => { @@ -23073,6 +23082,19 @@ describe('createAcpSessionBridge', () => { { clientId: 'client-not-issued' }, ), ).rejects.toBeInstanceOf(InvalidClientIdError); + await expect( + bridge.cancelSessionTask(session.sessionId, 'task-1', 'workflow', { + clientId: 'client-not-issued', + }), + ).rejects.toBeInstanceOf(InvalidClientIdError); + await expect( + bridge.controlSessionWorkflowTask( + session.sessionId, + 'task-1', + 'rerun', + { clientId: 'client-not-issued' }, + ), + ).rejects.toBeInstanceOf(InvalidClientIdError); await bridge.shutdown(); }); diff --git a/packages/acp-bridge/src/bridge.ts b/packages/acp-bridge/src/bridge.ts index 25ae83e8844..7cc54bcbd7d 100644 --- a/packages/acp-bridge/src/bridge.ts +++ b/packages/acp-bridge/src/bridge.ts @@ -79,7 +79,9 @@ import { type ServeSessionStatsStatus, type ServeSessionContextStatus, type ServeSessionLspStatus, + type ServeSessionSavedWorkflowStatus, type ServeSessionTasksStatus, + type ServeSessionWorkflowTaskStatus, type ServeWorkspaceMcpResourcesStatus, type ServeWorkspaceMcpStatus, type ServeWorkspaceMcpToolsStatus, @@ -10919,10 +10921,11 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { ); }, - async getSessionTasksStatus(sessionId) { + async getSessionTasksStatus(sessionId, opts) { return requestSessionStatus( sessionId, SERVE_STATUS_EXT_METHODS.sessionTasks, + { includeWorkflows: opts?.includeWorkflows === true }, ); }, @@ -10933,11 +10936,22 @@ 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); }, - async cancelSessionTask(sessionId, taskId, taskKind) { + async cancelSessionTask(sessionId, taskId, taskKind, context) { + const entry = byId.get(sessionId); + if (!entry) throw new SessionNotFoundError(sessionId); + resolveTrustedClientId(entry, context?.clientId); return requestSessionStatus<{ cancelled: boolean }>( sessionId, SERVE_CONTROL_EXT_METHODS.sessionTaskCancel, @@ -10945,6 +10959,20 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { ); }, + async controlSessionWorkflowTask(sessionId, taskId, action, context) { + const entry = byId.get(sessionId); + if (!entry) throw new SessionNotFoundError(sessionId); + resolveTrustedClientId(entry, context?.clientId); + return requestSessionStatus<{ + changed: boolean; + status?: ServeSessionWorkflowTaskStatus['status']; + taskId?: string; + }>(sessionId, SERVE_CONTROL_EXT_METHODS.sessionWorkflowTaskAction, { + taskId, + action, + }); + }, + async controlSessionGoal(sessionId, request, context) { const entry = byId.get(sessionId); if (!entry) throw new SessionNotFoundError(sessionId); diff --git a/packages/acp-bridge/src/bridgeTypes.ts b/packages/acp-bridge/src/bridgeTypes.ts index 5a8de489d35..d1b95baf3cb 100644 --- a/packages/acp-bridge/src/bridgeTypes.ts +++ b/packages/acp-bridge/src/bridgeTypes.ts @@ -44,8 +44,10 @@ import type { ServeSessionContextStatus, ServeSessionHooksStatus, ServeSessionLspStatus, + ServeSessionSavedWorkflowStatus, ServeSessionSupportedCommandsStatus, ServeSessionTasksStatus, + ServeSessionWorkflowTaskStatus, ServeWorkspaceExtensionsStatus, ServeWorkspaceHooksStatus, ServeWorkspaceMcpToolsStatus, @@ -312,11 +314,15 @@ export const ACTIVE_WORK_MAX_SESSION_HOLDS = 1024; export const WORKTREE_MCP_DEFER_META_KEY = 'qwen.session.deferMcpDiscovery'; /** - * Work categories a child reports holds for. Monitors, workflows, and cron - * remain outside `activeWork`'s declared scope. The category travels on every + * Work categories a child reports holds for. Monitors and cron remain outside + * `activeWork`'s declared scope. The category travels on every * hold so peers can negotiate coverage explicitly when the scope widens. */ -export type ActiveWorkHoldCategory = 'agent' | 'notification' | 'shell'; +export type ActiveWorkHoldCategory = + | 'agent' + | 'notification' + | 'shell' + | 'workflow'; /** Categories understood by active-work v1 before category negotiation was * added to the daemon's initialize request. */ @@ -327,6 +333,7 @@ export const ACTIVE_WORK_HOLD_CATEGORIES: readonly ActiveWorkHoldCategory[] = [ 'agent', 'notification', 'shell', + 'workflow', ]; export interface ActiveWorkHeartbeatCapabilityV1 { @@ -1777,11 +1784,24 @@ export interface AcpSessionBridge extends WorkspaceEventBridge { ): Promise; /** Read the live background task snapshot for a live session. */ - getSessionTasksStatus(sessionId: string): Promise; + getSessionTasksStatus( + sessionId: string, + opts?: { includeWorkflows?: boolean }, + ): Promise; /** 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 @@ -1795,9 +1815,28 @@ export interface AcpSessionBridge extends WorkspaceEventBridge { cancelSessionTask( sessionId: string, taskId: string, - taskKind: 'agent' | 'shell' | 'monitor', + taskKind: 'agent' | 'shell' | 'monitor' | 'workflow', + context?: BridgeClientRequestContext, ): Promise<{ cancelled: boolean }>; + /** Control a run, delete history, or start a saved workflow definition. */ + controlSessionWorkflowTask( + sessionId: string, + taskId: string, + action: + | 'pause' + | 'resume' + | 'retry' + | 'rerun' + | 'delete-history' + | 'run-saved', + context?: BridgeClientRequestContext, + ): Promise<{ + changed: boolean; + status?: ServeSessionWorkflowTaskStatus['status']; + taskId?: string; + }>; + /** Clear an active goal in a live session without cancelling the running prompt. */ clearSessionGoal( sessionId: string, diff --git a/packages/acp-bridge/src/status.ts b/packages/acp-bridge/src/status.ts index c4e1d8960fa..e4f33e2e0a6 100644 --- a/packages/acp-bridge/src/status.ts +++ b/packages/acp-bridge/src/status.ts @@ -124,6 +124,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', @@ -176,6 +183,7 @@ export const SERVE_CONTROL_EXT_METHODS = { workspaceMemoryDream: 'qwen/control/workspace/memory/dream', // Runtime MCP server mutation ext-methods sessionTaskCancel: 'qwen/control/session/task/cancel', + sessionWorkflowTaskAction: 'qwen/control/session/task/workflow-action', sessionGoalControl: 'qwen/control/session/goal/control', sessionGoalClear: 'qwen/control/session/goal/clear', /** @@ -615,6 +623,50 @@ export interface ServeSessionSupportedCommandsStatus { sessionId: string; availableCommands: AvailableCommand[]; availableSkills: string[]; + /** Whether Workflow is available for this session. */ + workflowsEnabled?: boolean; + /** Reusable workflow definitions visible to this session. */ + savedWorkflows?: Array<{ + name: string; + source: 'project' | 'user'; + }>; +} + +/** 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 { @@ -726,10 +778,124 @@ export interface ServeSessionMonitorTaskStatus { toolUseId?: string; } +export interface ServeWorkflowPhaseVisit { + id: string; + index: number; + title: string; + startedAt: number; + endedAt?: number; +} + +export type ServeWorkflowDispatchStatus = + | 'queued' + | 'running' + | 'completed' + | 'failed' + | 'cancelled' + | 'cached'; + +export interface ServeWorkflowDispatchStatusEntry { + id: string; + phaseVisitId: string | null; + label: string; + prompt: string; + subagentId?: string; + status: ServeWorkflowDispatchStatus; + dependsOn: string[]; + queuedAt: number; + startedAt?: number; + endedAt?: number; + error?: string; +} + +export interface ServeWorkflowApprovalStatusEntry { + approvalId: string; + subagentId: string; + name: string; + description: string; + at: number; +} + +interface ServeWorkflowEventBase { + id: string; + at: number; +} + +export type ServeWorkflowEvent = + | (ServeWorkflowEventBase & { + type: 'phase-started'; + phaseVisitId: string; + title: string; + }) + | (ServeWorkflowEventBase & { + type: 'phase-completed'; + phaseVisitId: string; + }) + | (ServeWorkflowEventBase & { + type: + | 'dispatch-queued' + | 'dispatch-started' + | 'dispatch-completed' + | 'dispatch-cancelled' + | 'dispatch-cached'; + dispatchId: string; + }) + | (ServeWorkflowEventBase & { + type: 'dispatch-failed'; + dispatchId: string; + error: string; + }) + | (ServeWorkflowEventBase & { type: 'log'; message: string }) + | (ServeWorkflowEventBase & { + type: 'approval-requested' | 'approval-settled'; + name: string; + dispatchId?: string; + }) + | (ServeWorkflowEventBase & { + type: 'workflow-completed' | 'workflow-cancelled'; + }) + | (ServeWorkflowEventBase & { + type: 'workflow-failed'; + error: string; + }); + +export interface ServeSessionWorkflowTaskStatus { + kind: 'workflow'; + id: string; + /** Tool call in the parent session that launched this workflow. */ + toolUseId?: string; + /** Restored from the project snapshot store; controls are read-only. */ + isHistorical?: boolean; + sourceRunId?: string; + startMode?: 'retry' | 'rerun'; + label: string; + description: string; + status: ServeSessionTaskLifecycleStatus | 'pausing'; + startTime: number; + endTime?: number; + runtimeMs: number; + outputFile?: string; + isBackgrounded: boolean; + currentPhase: string | null; + phaseVisits: ServeWorkflowPhaseVisit[]; + dispatches: ServeWorkflowDispatchStatusEntry[]; + agentsDispatched: number; + agentsCompleted: number; + tokensSpent: number; + tokenBudgetTotal: number | null; + recentLogs: string[]; + /** Ordered runtime facts; absent for snapshots created before event tracing. */ + events?: ServeWorkflowEvent[]; + pendingApprovalCount: number; + pendingApprovals?: ServeWorkflowApprovalStatusEntry[]; + error?: string; +} + export type ServeSessionTaskStatus = | ServeSessionAgentTaskStatus | ServeSessionShellTaskStatus - | ServeSessionMonitorTaskStatus; + | ServeSessionMonitorTaskStatus + | ServeSessionWorkflowTaskStatus; export interface ServeSessionTasksStatus { v: typeof STATUS_SCHEMA_VERSION; diff --git a/packages/cli/src/acp-integration/acpAgent.test.ts b/packages/cli/src/acp-integration/acpAgent.test.ts index 049212e7422..55926dfa57f 100644 --- a/packages/cli/src/acp-integration/acpAgent.test.ts +++ b/packages/cli/src/acp-integration/acpAgent.test.ts @@ -91,6 +91,15 @@ const { mockPreloadContentGenerator } = vi.hoisted(() => ({ mockPreloadContentGenerator: vi.fn().mockResolvedValue(undefined), })); +const { mockListWorkflowSnapshots } = vi.hoisted(() => ({ + mockListWorkflowSnapshots: vi.fn().mockResolvedValue([]), +})); +const { mockListSavedWorkflows } = vi.hoisted(() => ({ + mockListSavedWorkflows: vi.fn().mockResolvedValue([]), +})); +const { mockResolveSavedWorkflowScript } = vi.hoisted(() => ({ + mockResolveSavedWorkflowScript: vi.fn(), +})); const { mockAddDaemonRequestAttribute } = vi.hoisted(() => ({ mockAddDaemonRequestAttribute: vi.fn(), })); @@ -284,6 +293,21 @@ vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => ({ await importOriginal() ).toolResultPartDiagnosticValues, preloadContentGenerator: mockPreloadContentGenerator, + isTerminalWorkflowStatus: ( + await importOriginal() + ).isTerminalWorkflowStatus, + getWorkflowTaskMutationKey: ( + await importOriginal() + ).getWorkflowTaskMutationKey, + tryWithWorkflowTaskMutation: ( + await importOriginal() + ).tryWithWorkflowTaskMutation, + listSavedWorkflows: mockListSavedWorkflows, + resolveSavedWorkflowScript: mockResolveSavedWorkflowScript, + extractAndStripMeta: ( + await importOriginal() + ).extractAndStripMeta, + listWorkflowSnapshots: mockListWorkflowSnapshots, createDebugLogger: () => mockDebugLogger, extractDaemonTraceContext: mockExtractDaemonTraceContext, withDaemonSpan: mockWithDaemonSpan, @@ -2081,6 +2105,9 @@ describe('QwenAgent MCP SSE/HTTP support', () => { dispose: ReturnType; prompt: ReturnType; releaseTodoStopGuardQueuedPromptWait: ReturnType; + refreshWorkflowHistory: ReturnType; + deleteWorkflowHistory: ReturnType; + noteExternalWorkflowDeletion: ReturnType; isIdle: ReturnType; isTurnIdle: ReturnType; getCreatedAt: ReturnType; @@ -4033,6 +4060,7 @@ describe('QwenAgent MCP SSE/HTTP support', () => { getModel: vi.fn().mockReturnValue('m'), storage: { getProjectRoot: vi.fn().mockReturnValue('/tmp'), + getWorkflowRunsDir: vi.fn().mockReturnValue('/tmp/workflows'), }, getProjectRoot: vi.fn().mockReturnValue('/tmp'), getTargetDir: vi.fn().mockReturnValue('/tmp'), @@ -4074,6 +4102,11 @@ describe('QwenAgent MCP SSE/HTTP support', () => { getHookSystem: vi.fn().mockReturnValue(undefined), getDisableAllHooks: vi.fn().mockReturnValue(true), hasHooksForEvent: vi.fn().mockReturnValue(false), + isWorkflowsEnabled: vi.fn().mockReturnValue(false), + setWorkflowsEnabled: vi.fn(), + getBareMode: vi.fn().mockReturnValue(false), + getFolderTrustFeature: vi.fn().mockReturnValue(false), + getFolderTrust: vi.fn().mockReturnValue(true), isTrustedFolder: vi.fn().mockReturnValue(true), }; } @@ -4309,61 +4342,83 @@ describe('QwenAgent MCP SSE/HTTP support', () => { vi.mocked(loadCliConfig).mockResolvedValue( innerConfig as unknown as Config, ); - vi.mocked(Session).mockImplementation((createdSessionId, createdConfig) => { - const sessionMock = { - sessionId: createdSessionId, - getId: vi.fn().mockReturnValue(createdSessionId), - shouldHintAskUserQuestionRestore: vi.fn().mockReturnValue(false), - getConfig: vi.fn().mockReturnValue(createdConfig), - sendAvailableCommandsUpdate: vi.fn().mockResolvedValue(undefined), - replayHistory: vi.fn().mockResolvedValue(undefined), - installRewriter: vi.fn(), - installGoalTerminalObserver: vi.fn(), - startCronScheduler: vi.fn(), - beginClose: vi.fn().mockReturnValue(vi.fn()), - beginCloseIfAvailable: vi.fn().mockReturnValue(vi.fn()), - waitForCloseGateToRelease: vi.fn().mockResolvedValue(undefined), - waitForActiveTurnsToSettle: vi.fn().mockResolvedValue(undefined), - cancelPendingPrompt: vi.fn().mockResolvedValue(undefined), - enqueueBackgroundNotification: vi - .fn() - .mockResolvedValue({ accepted: true }), - enableLiveScreenContext: vi.fn().mockResolvedValue(undefined), - buildAvailableCommandsSnapshot: vi.fn(() => - buildAvailableCommandsSnapshot(createdConfig), - ), - installManagedConversationActivation: vi.fn(), - installPendingManagedConversationBinding: vi.fn(), - commitManagedConversationBinding: vi.fn().mockResolvedValue(undefined), - releaseManagedConversationBinding: vi.fn().mockResolvedValue(undefined), - appendLiveConversationTranscript: vi.fn().mockResolvedValue(undefined), - collectActiveWorkHolds: vi.fn().mockReturnValue([]), - hasStandaloneRelocationBlockers: vi.fn().mockReturnValue(false), - assertCanStartTurn: vi.fn().mockResolvedValue(undefined), - dispose: vi.fn(), - emitGoalStatus: vi.fn(), - captureHistorySnapshot: vi - .fn() - .mockReturnValue([{ role: 'user', parts: [{ text: 'before' }] }]), - restoreHistory: vi.fn(), - rewindToTurn: vi - .fn() - .mockReturnValue({ targetTurnIndex: 1, apiTruncateIndex: 2 }), - beginHistoryMutation: vi.fn().mockImplementation(() => vi.fn()), - getRewindableUserTurnCount: vi.fn().mockReturnValue(1), - clearActiveTodoPlanRevision: vi.fn(), - clearTodoStopGuardTrust: vi.fn(), - hardSuspendTodoStopGuard: vi.fn(), - releaseTodoStopGuardQueuedPromptWait: vi.fn().mockReturnValue(true), - isIdle: vi.fn().mockReturnValue(true), - isTurnIdle: vi.fn().mockReturnValue(true), - getCreatedAt: vi.fn().mockReturnValue(1_700_000_000_000), - getTurnCount: vi.fn().mockReturnValue(3), - prompt: vi.fn().mockResolvedValue({ stopReason: 'end_turn' }), - }; - lastSessionMock = sessionMock; - return sessionMock as unknown as InstanceType; - }); + vi.mocked(Session).mockImplementation( + ( + createdSessionId, + createdConfig, + _client, + _settings, + _runExclusiveAutomaticHistoryMutation, + _onActiveWorkChanged, + workflowHistory = [], + ) => { + const sessionMock = { + sessionId: createdSessionId, + getId: vi.fn().mockReturnValue(createdSessionId), + shouldHintAskUserQuestionRestore: vi.fn().mockReturnValue(false), + getConfig: vi.fn().mockReturnValue(createdConfig), + getWorkflowHistory: vi.fn().mockReturnValue(workflowHistory), + refreshWorkflowHistory: vi.fn(() => + mockListWorkflowSnapshots(createdConfig), + ), + deleteWorkflowHistory: vi.fn().mockResolvedValue(false), + noteExternalWorkflowDeletion: vi.fn(), + sendAvailableCommandsUpdate: vi.fn().mockResolvedValue(undefined), + replayHistory: vi.fn().mockResolvedValue(undefined), + installRewriter: vi.fn(), + installGoalTerminalObserver: vi.fn(), + startCronScheduler: vi.fn(), + beginClose: vi.fn().mockReturnValue(vi.fn()), + beginCloseIfAvailable: vi.fn().mockReturnValue(vi.fn()), + waitForCloseGateToRelease: vi.fn().mockResolvedValue(undefined), + waitForActiveTurnsToSettle: vi.fn().mockResolvedValue(undefined), + cancelPendingPrompt: vi.fn().mockResolvedValue(undefined), + enqueueBackgroundNotification: vi + .fn() + .mockResolvedValue({ accepted: true }), + enableLiveScreenContext: vi.fn().mockResolvedValue(undefined), + buildAvailableCommandsSnapshot: vi.fn(() => + buildAvailableCommandsSnapshot(createdConfig), + ), + installManagedConversationActivation: vi.fn(), + installPendingManagedConversationBinding: vi.fn(), + commitManagedConversationBinding: vi + .fn() + .mockResolvedValue(undefined), + releaseManagedConversationBinding: vi + .fn() + .mockResolvedValue(undefined), + appendLiveConversationTranscript: vi + .fn() + .mockResolvedValue(undefined), + collectActiveWorkHolds: vi.fn().mockReturnValue([]), + hasStandaloneRelocationBlockers: vi.fn().mockReturnValue(false), + assertCanStartTurn: vi.fn().mockResolvedValue(undefined), + dispose: vi.fn(), + emitGoalStatus: vi.fn(), + captureHistorySnapshot: vi + .fn() + .mockReturnValue([{ role: 'user', parts: [{ text: 'before' }] }]), + restoreHistory: vi.fn(), + rewindToTurn: vi + .fn() + .mockReturnValue({ targetTurnIndex: 1, apiTruncateIndex: 2 }), + beginHistoryMutation: vi.fn().mockImplementation(() => vi.fn()), + getRewindableUserTurnCount: vi.fn().mockReturnValue(1), + clearActiveTodoPlanRevision: vi.fn(), + clearTodoStopGuardTrust: vi.fn(), + hardSuspendTodoStopGuard: vi.fn(), + releaseTodoStopGuardQueuedPromptWait: vi.fn().mockReturnValue(true), + isIdle: vi.fn().mockReturnValue(true), + isTurnIdle: vi.fn().mockReturnValue(true), + getCreatedAt: vi.fn().mockReturnValue(1_700_000_000_000), + getTurnCount: vi.fn().mockReturnValue(3), + prompt: vi.fn().mockResolvedValue({ stopReason: 'end_turn' }), + }; + lastSessionMock = sessionMock; + return sessionMock as unknown as InstanceType; + }, + ); return innerConfig; } @@ -8770,6 +8825,9 @@ describe('QwenAgent MCP SSE/HTTP support', () => { const innerConfig = await setupSessionMocks(sessionId); const dateNowSpy = vi.spyOn(Date, 'now').mockReturnValue(5_000); Object.assign(innerConfig, { + getWorkflowRunRegistry: vi.fn().mockReturnValue({ + list: vi.fn().mockReturnValue([]), + }), getBackgroundTaskRegistry: vi.fn().mockReturnValue({ getAll: vi.fn().mockReturnValue([ { @@ -8870,6 +8928,19 @@ describe('QwenAgent MCP SSE/HTTP support', () => { ], availableSkills: ['review'], }); + innerConfig.isWorkflowsEnabled.mockReturnValue(true); + mockListSavedWorkflows.mockResolvedValueOnce([ + { + name: 'deep-review', + source: 'project', + scriptPath: '/tmp/.qwen/workflows/deep-review.js', + }, + { + name: 'release-check', + source: 'user', + scriptPath: '/home/test/.qwen/workflows/release-check.js', + }, + ]); const agentPromise = runAcpAgent( mockConfig, @@ -8930,6 +9001,11 @@ describe('QwenAgent MCP SSE/HTTP support', () => { }, ], availableSkills: ['review'], + workflowsEnabled: true, + savedWorkflows: [ + { name: 'deep-review', source: 'project' }, + { name: 'release-check', source: 'user' }, + ], }); expect(tasks).toEqual({ v: 1, @@ -9032,6 +9108,87 @@ describe('QwenAgent MCP SSE/HTTP support', () => { await agentPromise; }); + it('loads persisted workflow snapshots into the session task history', async () => { + const sessionId = '11111111-1111-1111-1111-111111111111'; + const innerConfig = await setupSessionMocks(sessionId); + innerConfig.isWorkflowsEnabled.mockReturnValue(true); + Object.assign(innerConfig, { + getBackgroundTaskRegistry: vi.fn().mockReturnValue({ + getAll: vi.fn().mockReturnValue([]), + }), + getBackgroundShellRegistry: vi.fn().mockReturnValue({ + getAll: vi.fn().mockReturnValue([]), + }), + getMonitorRegistry: vi.fn().mockReturnValue({ + getAll: vi.fn().mockReturnValue([]), + }), + getWorkflowRunRegistry: vi.fn().mockReturnValue({ + list: vi.fn().mockReturnValue([]), + }), + }); + const persistedSnapshot = { + runId: 'wf_saved', + description: 'Review and fix', + meta: { name: 'review-and-fix', description: 'Review and fix' }, + status: 'completed', + script: 'return 1;', + phases: ['Inspect'], + phaseVisits: [], + dispatches: [], + agentsDispatched: 1, + agentsCompleted: 1, + tokensSpent: 500, + tokenBudgetTotal: 2_000, + perPhaseTokens: [], + recentLogs: [], + startTime: 1_000, + endTime: 2_000, + }; + mockListWorkflowSnapshots + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([persistedSnapshot]); + + 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: [] }); + const legacyTasks = await agent.extMethod( + SERVE_STATUS_EXT_METHODS.sessionTasks, + { sessionId }, + ); + const tasks = await agent.extMethod(SERVE_STATUS_EXT_METHODS.sessionTasks, { + sessionId, + includeWorkflows: true, + }); + + expect(legacyTasks).toMatchObject({ sessionId, tasks: [] }); + expect(tasks).toMatchObject({ + sessionId, + tasks: [ + { + kind: 'workflow', + id: 'wf_saved', + label: 'review-and-fix', + status: 'completed', + isHistorical: true, + }, + ], + }); + expect(mockListWorkflowSnapshots).toHaveBeenCalledTimes(2); + + mockConnectionState.resolve(); + await agentPromise; + }); + it('aggregates subagent token usage into session stats sources', async () => { const sessionId = '11111111-1111-1111-1111-111111111111'; await setupSessionMocks(sessionId); @@ -10966,52 +11123,1286 @@ describe('QwenAgent MCP SSE/HTTP support', () => { mockConfig, makeSessionSettings(), mockArgv, - { - privateParentCapability: 'expected-capability', - externalToolGuardRequired: true, - }, + { + privateParentCapability: 'expected-capability', + externalToolGuardRequired: true, + }, + ); + await vi.waitFor(() => expect(capturedAgentFactory).toBeDefined()); + const agent = capturedAgentFactory!({ + extMethod: vi.fn(), + get closed() { + return mockConnectionState.promise; + }, + } as unknown as AgentSideConnectionLike) as AgentLike; + await agent.initialize({ + clientCapabilities: {}, + _meta: { + 'qwen-code/private-parent-capability': 'expected-capability', + }, + }); + await agent.newSession({ cwd: '/tmp', mcpServers: [] }); + + await expect( + agent.extMethod(SERVE_CONTROL_EXT_METHODS.sessionForkAgent, { + sessionId, + directive: 'review this branch', + }), + ).resolves.toMatchObject({ launched: true }); + expect(build).toHaveBeenCalled(); + + mockConnectionState.resolve(); + await agentPromise; + }); + + it('allows cancelling paused agent tasks', async () => { + const sessionId = '11111111-1111-1111-1111-111111111111'; + const innerConfig = await setupSessionMocks(sessionId); + const cancel = vi.fn(); + const abandon = vi.fn(); + Object.assign(innerConfig, { + getBackgroundTaskRegistry: vi.fn().mockReturnValue({ + get: vi.fn().mockReturnValue({ + id: 'agent-1', + kind: 'agent', + status: 'paused', + }), + cancel, + abandon, + }), + }); + + 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_CONTROL_EXT_METHODS.sessionTaskCancel, { + sessionId, + taskId: 'agent-1', + taskKind: 'agent', + }), + ).resolves.toEqual({ cancelled: true, status: 'paused' }); + expect(abandon).toHaveBeenCalledWith('agent-1'); + expect(cancel).not.toHaveBeenCalled(); + + mockConnectionState.resolve(); + await agentPromise; + }); + + it('rejects sessionTaskCancel with invalid params', async () => { + const agentPromise = runAcpAgent( + mockConfig, + makeSessionSettings(), + mockArgv, + ); + await vi.waitFor(() => expect(capturedAgentFactory).toBeDefined()); + + const agent = capturedAgentFactory!({ + get closed() { + return mockConnectionState.promise; + }, + }) as AgentLike; + + await expect( + agent.extMethod(SERVE_CONTROL_EXT_METHODS.sessionTaskCancel, { + sessionId: 'session-1', + taskId: 'task-1', + taskKind: 'invalid', + }), + ).rejects.toThrow( + 'taskKind must be "agent", "shell", "monitor", or "workflow"', + ); + + mockConnectionState.resolve(); + await agentPromise; + }); + + it('cancels running shell tasks', async () => { + const sessionId = '11111111-1111-1111-1111-111111111111'; + const innerConfig = await setupSessionMocks(sessionId); + const requestCancel = vi.fn(); + Object.assign(innerConfig, { + getBackgroundShellRegistry: vi.fn().mockReturnValue({ + get: vi.fn().mockReturnValue({ + id: 'shell-1', + kind: 'shell', + status: 'running', + }), + requestCancel, + }), + }); + + 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_CONTROL_EXT_METHODS.sessionTaskCancel, { + sessionId, + taskId: 'shell-1', + taskKind: 'shell', + }), + ).resolves.toEqual({ cancelled: true, status: 'running' }); + expect(requestCancel).toHaveBeenCalledWith('shell-1'); + + mockConnectionState.resolve(); + await agentPromise; + }); + + it('cancels running monitor tasks', async () => { + const sessionId = '11111111-1111-1111-1111-111111111111'; + const innerConfig = await setupSessionMocks(sessionId); + const cancel = vi.fn(); + Object.assign(innerConfig, { + getMonitorRegistry: vi.fn().mockReturnValue({ + get: vi.fn().mockReturnValue({ + id: 'monitor-1', + kind: 'monitor', + status: 'running', + }), + cancel, + }), + }); + + 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_CONTROL_EXT_METHODS.sessionTaskCancel, { + sessionId, + taskId: 'monitor-1', + taskKind: 'monitor', + }), + ).resolves.toEqual({ cancelled: true, status: 'running' }); + expect(cancel).toHaveBeenCalledWith('monitor-1'); + + mockConnectionState.resolve(); + await agentPromise; + }); + + it('cancels active workflow tasks', async () => { + const sessionId = '11111111-1111-1111-1111-111111111111'; + const innerConfig = await setupSessionMocks(sessionId); + innerConfig.isWorkflowsEnabled.mockReturnValue(true); + const task = { + id: 'wf-1', + runId: 'wf-1', + kind: 'workflow' as const, + status: 'paused' as 'paused' | 'cancelled', + }; + let resolveCompletion!: () => void; + const completion = new Promise((resolve) => { + resolveCompletion = resolve; + }); + const cancel = vi.fn(() => { + task.status = 'cancelled'; + }); + Object.assign(innerConfig, { + getWorkflowRunRegistry: vi.fn().mockReturnValue({ + get: vi.fn().mockReturnValue(task), + getHandle: vi.fn().mockReturnValue({ completion }), + cancel, + }), + }); + + 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: [] }); + let settled = false; + const request = agent + .extMethod(SERVE_CONTROL_EXT_METHODS.sessionTaskCancel, { + sessionId, + taskId: 'wf-1', + taskKind: 'workflow', + }) + .then((result) => { + settled = true; + return result; + }); + await Promise.resolve(); + expect(settled).toBe(false); + resolveCompletion(); + await expect(request).resolves.toEqual({ + cancelled: true, + status: 'cancelled', + }); + expect(cancel).toHaveBeenCalledWith('wf-1', expect.any(Number)); + + mockConnectionState.resolve(); + await agentPromise; + }); + + it('cancels a workflow that has been reserved but not yet registered', async () => { + // Between `reserveStart` and `register` the runner is loading the + // script or replaying the journal; the registry has no entry yet. + // The liveness gate already counts that window as live — cancel + // answered "not_found" for it, so a client watching the run start + // could not stop it until it registered. + const sessionId = '11111111-1111-1111-1111-111111111111'; + const innerConfig = await setupSessionMocks(sessionId); + innerConfig.isWorkflowsEnabled.mockReturnValue(true); + const cancel = vi.fn(); + const cancelStarting = vi.fn().mockReturnValue(true); + Object.assign(innerConfig, { + getWorkflowRunRegistry: vi.fn().mockReturnValue({ + get: vi.fn().mockReturnValue(undefined), + getHandle: vi.fn().mockReturnValue(undefined), + isStarting: vi.fn((runId: string) => runId === 'wf-starting'), + cancelStarting, + cancel, + }), + }); + + 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_CONTROL_EXT_METHODS.sessionTaskCancel, { + sessionId, + taskId: 'wf-starting', + taskKind: 'workflow', + }), + ).resolves.toEqual({ cancelled: true, status: 'cancelled' }); + expect(cancelStarting).toHaveBeenCalledWith('wf-starting'); + expect(cancel).not.toHaveBeenCalled(); + + // A run that is neither registered nor starting is still not found. + await expect( + agent.extMethod(SERVE_CONTROL_EXT_METHODS.sessionTaskCancel, { + sessionId, + taskId: 'wf-unknown', + taskKind: 'workflow', + }), + ).resolves.toEqual({ cancelled: false, reason: 'not_found' }); + + mockConnectionState.resolve(); + await agentPromise; + }); + + it('cancels a retry whose starting window is shadowed by its terminal entry', async () => { + // A retry reuses its runId, so the old terminal entry stays in the + // registry for the whole starting window (`reserveStart` writes only + // the starting map). Gating the starting branch on `!task` made it + // unreachable for retries: the status guard answered "not_running" + // about a run that was actively starting, and the retry went on to + // register and dispatch agents after the user was told nothing ran. + const sessionId = '11111111-1111-1111-1111-111111111111'; + const innerConfig = await setupSessionMocks(sessionId); + innerConfig.isWorkflowsEnabled.mockReturnValue(true); + const cancel = vi.fn(); + const cancelStarting = vi.fn().mockReturnValue(true); + Object.assign(innerConfig, { + getWorkflowRunRegistry: vi.fn().mockReturnValue({ + get: vi.fn((runId: string) => + runId === 'wf-retrying' ? { runId, status: 'failed' } : undefined, + ), + getHandle: vi.fn().mockReturnValue(undefined), + isStarting: vi.fn((runId: string) => runId === 'wf-retrying'), + cancelStarting, + cancel, + }), + }); + + 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_CONTROL_EXT_METHODS.sessionTaskCancel, { + sessionId, + taskId: 'wf-retrying', + taskKind: 'workflow', + }), + ).resolves.toEqual({ cancelled: true, status: 'cancelled' }); + expect(cancelStarting).toHaveBeenCalledWith('wf-retrying'); + expect(cancel).not.toHaveBeenCalled(); + + mockConnectionState.resolve(); + await agentPromise; + }); + + it.each([ + ['workflows are disabled', false, false, false, true], + ['bare mode is active', true, true, false, true], + ['the workspace is untrusted', true, false, true, false], + ])( + 'does not advertise or execute workflow controls when %s', + async ( + _condition, + workflowsEnabled, + bareMode, + folderTrustFeature, + folderTrust, + ) => { + const sessionId = '11111111-1111-1111-1111-111111111111'; + const innerConfig = await setupSessionMocks(sessionId); + innerConfig.isWorkflowsEnabled.mockReturnValue(workflowsEnabled); + innerConfig.getBareMode.mockReturnValue(bareMode); + innerConfig.getFolderTrustFeature.mockReturnValue(folderTrustFeature); + innerConfig.getFolderTrust.mockReturnValue(folderTrust); + const get = vi.fn(); + const cancel = vi.fn(); + Object.assign(innerConfig, { + getBackgroundTaskRegistry: vi.fn().mockReturnValue({ + getAll: vi.fn().mockReturnValue([]), + }), + getBackgroundShellRegistry: vi.fn().mockReturnValue({ + getAll: vi.fn().mockReturnValue([]), + }), + getMonitorRegistry: vi.fn().mockReturnValue({ + getAll: vi.fn().mockReturnValue([]), + }), + getWorkflowRunRegistry: vi.fn().mockReturnValue({ get, cancel }), + }); + vi.mocked(buildAvailableCommandsSnapshot).mockResolvedValueOnce({ + availableCommands: [ + { + name: 'workflows', + description: 'Custom workflows command', + input: null, + _meta: { source: 'skill-dir-command' }, + }, + { name: 'init', description: 'Initialize', input: null }, + ], + availableSkills: [], + }); + + 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.sessionSupportedCommands, { + sessionId, + }), + ).resolves.toMatchObject({ + availableCommands: workflowsEnabled + ? [{ name: 'init', description: 'Initialize', input: null }] + : [ + { + name: 'workflows', + description: 'Custom workflows command', + input: null, + _meta: { source: 'skill-dir-command' }, + }, + { name: 'init', description: 'Initialize', input: null }, + ], + workflowsEnabled: false, + savedWorkflows: [], + }); + await expect( + agent.extMethod(SERVE_STATUS_EXT_METHODS.sessionTasks, { + sessionId, + includeWorkflows: true, + }), + ).resolves.toMatchObject({ sessionId, tasks: [] }); + await expect( + agent.extMethod(SERVE_CONTROL_EXT_METHODS.sessionTaskCancel, { + sessionId, + taskId: 'wf-1', + taskKind: 'workflow', + }), + ).resolves.toEqual({ cancelled: false, reason: 'disabled' }); + await expect( + agent.extMethod(SERVE_CONTROL_EXT_METHODS.sessionWorkflowTaskAction, { + sessionId, + taskId: 'wf-1', + action: 'delete-history', + }), + ).resolves.toEqual({ changed: false }); + expect(mockListSavedWorkflows).not.toHaveBeenCalled(); + expect(lastSessionMock!.refreshWorkflowHistory).not.toHaveBeenCalled(); + expect(get).not.toHaveBeenCalled(); + expect(cancel).not.toHaveBeenCalled(); + expect(lastSessionMock!.deleteWorkflowHistory).not.toHaveBeenCalled(); + + mockConnectionState.resolve(); + await agentPromise; + }, + ); + + it('pauses and resumes a background workflow task', async () => { + const sessionId = '11111111-1111-1111-1111-111111111111'; + const innerConfig = await setupSessionMocks(sessionId); + innerConfig.isWorkflowsEnabled.mockReturnValue(true); + const task = { + id: 'wf-1', + runId: 'wf-1', + kind: 'workflow' as const, + status: 'running' as 'running' | 'pausing' | 'paused', + isBackgrounded: true, + }; + const pause = vi.fn(() => { + task.status = 'pausing'; + return true; + }); + const resume = vi.fn(() => { + task.status = 'running'; + return true; + }); + Object.assign(innerConfig, { + getWorkflowRunRegistry: vi.fn().mockReturnValue({ + get: vi.fn(() => task), + pause, + resume, + }), + }); + + 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_CONTROL_EXT_METHODS.sessionWorkflowTaskAction, { + sessionId, + taskId: 'wf-1', + action: 'pause', + }), + ).resolves.toEqual({ changed: true, status: 'pausing' }); + expect(pause).toHaveBeenCalledWith('wf-1'); + + task.status = 'paused'; + await expect( + agent.extMethod(SERVE_CONTROL_EXT_METHODS.sessionWorkflowTaskAction, { + sessionId, + taskId: 'wf-1', + action: 'resume', + }), + ).resolves.toEqual({ changed: true, status: 'running' }); + expect(resume).toHaveBeenCalledWith('wf-1'); + + mockConnectionState.resolve(); + 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); + const tasks: Array<{ + runId: string; + status: 'running'; + scriptPath: string; + }> = []; + const registry = { + get: vi.fn((runId: string) => + tasks.find((candidate) => candidate.runId === runId), + ), + }; + const execute = vi.fn().mockImplementation(async () => { + tasks.push({ + runId: 'wf_concurrent', + status: 'running', + scriptPath: '/tmp/.qwen/workflows/deep-review.js', + }); + tasks.push({ + runId: 'wf_5678efab', + status: 'running', + scriptPath: '/tmp/.qwen/workflows/deep-review.js', + }); + return { llmContent: 'started', workflowRunId: 'wf_5678efab' }; + }); + const buildSessionOwnedBackground = vi.fn().mockReturnValue({ execute }); + Object.assign(innerConfig, { + isWorkflowsEnabled: vi.fn().mockReturnValue(true), + getWorkflowRunRegistry: vi.fn().mockReturnValue(registry), + getToolRegistry: vi.fn().mockReturnValue({ + getTool: vi.fn((name: string) => + name === 'workflow' ? { buildSessionOwnedBackground } : undefined, + ), + }), + }); + mockListSavedWorkflows.mockResolvedValueOnce([ + { + name: 'deep-review', + source: 'project', + scriptPath: '/tmp/.qwen/workflows/deep-review.js', + }, + ]); + + 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_CONTROL_EXT_METHODS.sessionWorkflowTaskAction, { + sessionId, + taskId: 'deep-review', + action: 'run-saved', + }), + ).resolves.toEqual({ + changed: true, + status: 'running', + taskId: 'wf_5678efab', + }); + expect(buildSessionOwnedBackground).toHaveBeenCalledWith({ + scriptPath: '/tmp/.qwen/workflows/deep-review.js', + }); + expect(execute).toHaveBeenCalledOnce(); + + mockConnectionState.resolve(); + await agentPromise; + }); + + it('retries a failed workflow with its original script, args, and journal', async () => { + const sessionId = '11111111-1111-1111-1111-111111111111'; + const innerConfig = await setupSessionMocks(sessionId); + innerConfig.isWorkflowsEnabled.mockReturnValue(true); + const task = { + id: 'wf_1234abcd', + runId: 'wf_1234abcd', + kind: 'workflow' as const, + status: 'failed' as 'failed' | 'running', + script: 'return await agent(args.prompt)', + args: { prompt: 'retry this path' }, + }; + const registry = { + get: vi.fn(() => task), + getHandle: vi.fn(() => undefined), + }; + const execute = vi.fn().mockImplementation(async () => { + task.status = 'running'; + return { llmContent: 'started', workflowRunId: task.runId }; + }); + const buildSessionOwnedBackground = vi.fn().mockReturnValue({ execute }); + Object.assign(innerConfig, { + getWorkflowRunRegistry: vi.fn().mockReturnValue(registry), + getToolRegistry: vi.fn().mockReturnValue({ + getTool: vi.fn((name: string) => + name === 'workflow' ? { buildSessionOwnedBackground } : undefined, + ), + }), + }); + + 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_CONTROL_EXT_METHODS.sessionWorkflowTaskAction, { + sessionId, + taskId: 'wf_1234abcd', + action: 'retry', + }), + ).resolves.toEqual({ changed: true, status: 'running' }); + expect(buildSessionOwnedBackground).toHaveBeenCalledWith({ + script: task.script, + args: task.args, + resumeFromRunId: task.runId, + }); + expect(execute).toHaveBeenCalledOnce(); + + mockConnectionState.resolve(); + await agentPromise; + }); + + it('reports a retry cancelled in its starting window as unchanged', async () => { + // `execute()` reports a start that never registered — a session + // dispose or `cancelStarting` landing while the journal was still + // loading — by omitting `workflowRunId`. Answering `changed: true` + // for that shape told the client a run existed that nothing would + // ever progress. + const sessionId = '11111111-1111-1111-1111-111111111111'; + const innerConfig = await setupSessionMocks(sessionId); + innerConfig.isWorkflowsEnabled.mockReturnValue(true); + const task = { + id: 'wf_1234abcd', + runId: 'wf_1234abcd', + kind: 'workflow' as const, + status: 'failed' as const, + script: 'return await agent(args.prompt)', + args: { prompt: 'retry this path' }, + }; + const registry = { + get: vi.fn(() => task), + getHandle: vi.fn(() => undefined), + }; + const execute = vi.fn().mockResolvedValue({ + llmContent: 'Workflow was cancelled before it could start.', + returnDisplay: 'Workflow cancelled.', + }); + const buildSessionOwnedBackground = vi.fn().mockReturnValue({ execute }); + Object.assign(innerConfig, { + getWorkflowRunRegistry: vi.fn().mockReturnValue(registry), + getToolRegistry: vi.fn().mockReturnValue({ + getTool: vi.fn((name: string) => + name === 'workflow' ? { buildSessionOwnedBackground } : undefined, + ), + }), + }); + + 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_CONTROL_EXT_METHODS.sessionWorkflowTaskAction, { + sessionId, + taskId: 'wf_1234abcd', + action: 'retry', + }), + ).resolves.toEqual({ changed: false, status: 'failed' }); + expect(execute).toHaveBeenCalledOnce(); + + mockConnectionState.resolve(); + await agentPromise; + }); + + it.each(['retry', 'rerun'] as const)( + 'admits only one overlapping %s request for a workflow task', + async (action) => { + const sessionId = '11111111-1111-1111-1111-111111111111'; + const innerConfig = await setupSessionMocks(sessionId); + innerConfig.isWorkflowsEnabled.mockReturnValue(true); + const task = { + id: 'wf_1234abcd', + runId: 'wf_1234abcd', + kind: 'workflow' as const, + status: 'failed' as 'failed' | 'running', + script: 'return await agent(args.prompt)', + args: { prompt: 'retry this path' }, + }; + const rerunTask = { + ...task, + id: 'wf_5678efab', + runId: 'wf_5678efab', + status: 'running' as const, + }; + let releaseStart!: () => void; + const startGate = new Promise((resolve) => { + releaseStart = resolve; + }); + let rerunStarted = false; + const registry = { + get: vi.fn((runId: string) => { + if (runId === task.runId) return task; + if (rerunStarted && runId === rerunTask.runId) return rerunTask; + return undefined; + }), + getHandle: vi.fn(() => undefined), + setLineage: vi.fn().mockReturnValue(true), + }; + const execute = vi.fn().mockImplementation(async () => { + await startGate; + if (action === 'retry') task.status = 'running'; + else rerunStarted = true; + return { + llmContent: 'started', + workflowRunId: action === 'rerun' ? rerunTask.runId : task.runId, + }; + }); + const buildSessionOwnedBackground = vi.fn().mockReturnValue({ execute }); + Object.assign(innerConfig, { + getWorkflowRunRegistry: vi.fn().mockReturnValue(registry), + getToolRegistry: vi.fn().mockReturnValue({ + getTool: vi.fn((name: string) => + name === 'workflow' ? { buildSessionOwnedBackground } : undefined, + ), + }), + }); + + 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: [] }); + const first = agent.extMethod( + SERVE_CONTROL_EXT_METHODS.sessionWorkflowTaskAction, + { sessionId, taskId: task.runId, action }, + ); + await vi.waitFor(() => expect(execute).toHaveBeenCalledOnce()); + await expect( + agent.extMethod(SERVE_CONTROL_EXT_METHODS.sessionWorkflowTaskAction, { + sessionId, + taskId: task.runId, + action, + }), + ).resolves.toEqual({ changed: false, status: 'failed' }); + expect(execute).toHaveBeenCalledOnce(); + + releaseStart(); + await expect(first).resolves.toEqual( + action === 'retry' + ? { changed: true, status: 'running' } + : { + changed: true, + status: 'running', + taskId: rerunTask.runId, + }, + ); + + mockConnectionState.resolve(); + await agentPromise; + }, + ); + + it('does not start a retry while deleting the same workflow history', async () => { + const sessionId = '11111111-1111-1111-1111-111111111111'; + const innerConfig = await setupSessionMocks(sessionId); + innerConfig.isWorkflowsEnabled.mockReturnValue(true); + const task = { + id: 'wf_1234abcd', + runId: 'wf_1234abcd', + kind: 'workflow' as const, + status: 'failed' as const, + script: 'return await agent(args.prompt)', + args: { prompt: 'retry this path' }, + }; + const registry = { + get: vi.fn(() => task), + getHandle: vi.fn(() => undefined), + }; + const execute = vi.fn().mockResolvedValue({ llmContent: 'started' }); + const buildSessionOwnedBackground = vi.fn().mockReturnValue({ execute }); + Object.assign(innerConfig, { + getWorkflowRunRegistry: vi.fn().mockReturnValue(registry), + getToolRegistry: vi.fn().mockReturnValue({ + getTool: vi.fn((name: string) => + name === 'workflow' ? { buildSessionOwnedBackground } : undefined, + ), + }), + }); + let finishDeletion!: () => void; + const deletionGate = new Promise((resolve) => { + finishDeletion = resolve; + }); + + 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: [] }); + lastSessionMock!.deleteWorkflowHistory.mockImplementationOnce(async () => { + await deletionGate; + return true; + }); + const deletion = agent.extMethod( + SERVE_CONTROL_EXT_METHODS.sessionWorkflowTaskAction, + { sessionId, taskId: task.runId, action: 'delete-history' }, + ); + await vi.waitFor(() => + expect(lastSessionMock!.deleteWorkflowHistory).toHaveBeenCalledOnce(), + ); + + await expect( + agent.extMethod(SERVE_CONTROL_EXT_METHODS.sessionWorkflowTaskAction, { + sessionId, + taskId: task.runId, + action: 'retry', + }), + ).resolves.toEqual({ changed: false, status: 'failed' }); + expect(execute).not.toHaveBeenCalled(); + + finishDeletion(); + await expect(deletion).resolves.toEqual({ changed: true }); + mockConnectionState.resolve(); + await agentPromise; + }); + + it('reruns a terminal workflow from scratch with a new run id', async () => { + const sessionId = '11111111-1111-1111-1111-111111111111'; + const innerConfig = await setupSessionMocks(sessionId); + innerConfig.isWorkflowsEnabled.mockReturnValue(true); + const task = { + id: 'wf_1234abcd', + runId: 'wf_1234abcd', + kind: 'workflow' as const, + status: 'failed' as 'failed' | 'running', + script: 'return await agent(args.prompt)', + args: { prompt: 'rerun everything' }, + }; + const rerun = { + ...task, + id: 'wf_5678efab', + runId: 'wf_5678efab', + status: 'running' as const, + }; + const concurrentRerun = { + ...task, + id: 'wf_concurrent', + runId: 'wf_concurrent', + status: 'running' as const, + }; + const tasks = [task]; + const setLineage = vi.fn( + (runId: string, sourceRunId: string, startMode: 'rerun') => { + const entry = tasks.find((candidate) => candidate.runId === runId); + if (!entry) return false; + Object.assign(entry, { sourceRunId, startMode }); + return true; + }, + ); + const registry = { + get: vi.fn((runId: string) => + tasks.find((candidate) => candidate.runId === runId), + ), + getHandle: vi.fn(() => undefined), + setLineage, + }; + const execute = vi.fn().mockImplementation(async () => { + tasks.push(concurrentRerun, rerun); + return { llmContent: 'started', workflowRunId: rerun.runId }; + }); + const buildSessionOwnedBackground = vi.fn().mockReturnValue({ execute }); + Object.assign(innerConfig, { + getWorkflowRunRegistry: vi.fn().mockReturnValue(registry), + getToolRegistry: vi.fn().mockReturnValue({ + getTool: vi.fn((name: string) => + name === 'workflow' ? { buildSessionOwnedBackground } : undefined, + ), + }), + }); + + 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_CONTROL_EXT_METHODS.sessionWorkflowTaskAction, { + sessionId, + taskId: task.runId, + action: 'rerun', + }), + ).resolves.toEqual({ + changed: true, + status: 'running', + taskId: rerun.runId, + }); + expect(buildSessionOwnedBackground).toHaveBeenCalledWith({ + script: task.script, + args: task.args, + }); + expect(execute).toHaveBeenCalledOnce(); + expect(rerun).toMatchObject({ + sourceRunId: task.runId, + startMode: 'rerun', + }); + + mockConnectionState.resolve(); + await agentPromise; + }); + + it('deletes a restored workflow from persistent session history', async () => { + const sessionId = '11111111-1111-1111-1111-111111111111'; + const innerConfig = await setupSessionMocks(sessionId); + innerConfig.isWorkflowsEnabled.mockReturnValue(true); + 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: [] }); + lastSessionMock!.deleteWorkflowHistory.mockResolvedValueOnce(true); + + await expect( + agent.extMethod(SERVE_CONTROL_EXT_METHODS.sessionWorkflowTaskAction, { + sessionId, + taskId: 'wf_abcd', + action: 'delete-history', + }), + ).resolves.toEqual({ changed: true }); + expect(lastSessionMock!.deleteWorkflowHistory).toHaveBeenCalledWith( + 'wf_abcd', + ); + + mockConnectionState.resolve(); + await agentPromise; + }); + + it('still sees a live workflow run whose owning session was closed', async () => { + // R7-10: the liveness gate iterated `this.sessions` only, but a run + // outlives its session's removal — close/kill/shutdown use force + // semantics and a background run owns a detached controller. Once + // `removeStoredSessionEntry` deleted the session, a still-settling run + // was invisible here and unreachable by the delete handler's sibling + // `removeTerminal` loop, so a sibling delete-history removed the LIVE + // run's journal and snapshot and answered `{changed: true}` — and the + // orphan's settlement write recreated the file it had just deleted. + const sessionIdA = 'aaaaaaaa-1111-1111-1111-111111111111'; + const sessionIdB = 'bbbbbbbb-2222-2222-2222-222222222222'; + const runId = 'wf_orphan1'; + const innerConfigA = await setupSessionMocks(sessionIdA); + innerConfigA.isWorkflowsEnabled.mockReturnValue(true); + Object.assign(innerConfigA, { + getWorkflowRunRegistry: vi.fn().mockReturnValue({ + get: vi.fn().mockReturnValue({ runId, status: 'failed' }), + getHandle: vi.fn().mockReturnValue(undefined), + removeTerminal: vi.fn().mockReturnValue(true), + }), + }); + + 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: [] }); + const sessionMockA = lastSessionMock!; + + // Session B owns a run that is terminal but still settling: the + // handle lives until the snapshot write lands. + const entryB = { runId, status: 'failed' as const }; + const registryB = { + get: vi.fn().mockReturnValue(entryB), + getHandle: vi.fn().mockReturnValue({ completion: Promise.resolve() }), + removeTerminal: vi.fn().mockReturnValue(false), + hasRunningEntries: vi.fn().mockReturnValue(false), + list: vi.fn().mockReturnValue([entryB]), + abortAll: vi.fn(), + }; + const innerConfigB = { + ...makeInnerConfig(), + getSessionId: vi.fn().mockReturnValue(sessionIdB), + isWorkflowsEnabled: vi.fn().mockReturnValue(true), + getWorkflowRunRegistry: vi.fn().mockReturnValue(registryB), + }; + vi.mocked(loadCliConfig).mockResolvedValue( + innerConfigB as unknown as Config, + ); + await agent.newSession({ cwd: '/tmp', mcpServers: [] }); + + const sessionCalls = vi.mocked(Session).mock.calls; + const predicateA = sessionCalls[0][7] as (runId: string) => boolean; + // While B is in the session map the gate already answered correctly. + expect(predicateA(runId)).toBe(true); + + // Force-close B. Before the fix the registry vanished with the map + // entry and the gate went blind for the rest of the settlement. + await agent.extMethod(SERVE_CONTROL_EXT_METHODS.sessionClose, { + sessionId: sessionIdB, + }); + expect(predicateA(runId)).toBe(true); + + // A delete-history from A must refuse for as long as the handle lives. + sessionMockA.deleteWorkflowHistory.mockResolvedValue(false); + await expect( + agent.extMethod(SERVE_CONTROL_EXT_METHODS.sessionWorkflowTaskAction, { + sessionId: sessionIdA, + taskId: runId, + action: 'delete-history', + }), + ).resolves.toEqual({ changed: false }); + + // Once the orphan settles and releases its handle the retained + // registry is pruned and the gate stops answering for it. + registryB.getHandle.mockReturnValue(undefined); + expect(predicateA(runId)).toBe(false); + + mockConnectionState.resolve(); + await agentPromise; + }); + + it('prunes drained registries of closed sessions when another session closes', async () => { + // Retained registries were pruned only inside the delete-history + // liveness check. A daemon that closes sessions mid-run but never + // deletes history kept every one of them for its whole lifetime. + const sessionIdA = 'aaaaaaaa-1111-1111-1111-111111111111'; + const sessionIdB = 'bbbbbbbb-2222-2222-2222-222222222222'; + const sessionIdC = 'cccccccc-3333-3333-3333-333333333333'; + const innerConfigA = await setupSessionMocks(sessionIdA); + innerConfigA.isWorkflowsEnabled.mockReturnValue(true); + + const agentPromise = runAcpAgent( + mockConfig, + makeSessionSettings(), + mockArgv, ); await vi.waitFor(() => expect(capturedAgentFactory).toBeDefined()); const agent = capturedAgentFactory!({ - extMethod: vi.fn(), get closed() { return mockConnectionState.promise; }, - } as unknown as AgentSideConnectionLike) as AgentLike; - await agent.initialize({ - clientCapabilities: {}, - _meta: { - 'qwen-code/private-parent-capability': 'expected-capability', - }, - }); + }) as AgentLike; await agent.newSession({ cwd: '/tmp', mcpServers: [] }); - await expect( - agent.extMethod(SERVE_CONTROL_EXT_METHODS.sessionForkAgent, { + const drainingRegistry = () => ({ + get: vi.fn().mockReturnValue(undefined), + getHandle: vi.fn().mockReturnValue(undefined), + removeTerminal: vi.fn().mockReturnValue(false), + hasRunningEntries: vi.fn().mockReturnValue(true), + list: vi.fn().mockReturnValue([]), + abortAll: vi.fn(), + }); + const openAndClose = async (sessionId: string, registry: unknown) => { + vi.mocked(loadCliConfig).mockResolvedValue({ + ...makeInnerConfig(), + getSessionId: vi.fn().mockReturnValue(sessionId), + isWorkflowsEnabled: vi.fn().mockReturnValue(true), + getWorkflowRunRegistry: vi.fn().mockReturnValue(registry), + } as unknown as Config); + await agent.newSession({ cwd: '/tmp', mcpServers: [] }); + await agent.extMethod(SERVE_CONTROL_EXT_METHODS.sessionClose, { sessionId, - directive: 'review this branch', - }), - ).resolves.toMatchObject({ launched: true }); - expect(build).toHaveBeenCalled(); + }); + }; + const retained = ( + agent as unknown as { detachedWorkflowRegistries: Set } + ).detachedWorkflowRegistries; + + const registryB = drainingRegistry(); + await openAndClose(sessionIdB, registryB); + expect([...retained]).toEqual([registryB]); + + // B's run settles. Nothing observes that directly — the next close + // is where the retention set gets a chance to let go of it. + registryB.hasRunningEntries.mockReturnValue(false); + const registryC = drainingRegistry(); + await openAndClose(sessionIdC, registryC); + expect([...retained]).toEqual([registryC]); mockConnectionState.resolve(); await agentPromise; }); - it('allows cancelling paused agent tasks', async () => { - const sessionId = '11111111-1111-1111-1111-111111111111'; - const innerConfig = await setupSessionMocks(sessionId); - const cancel = vi.fn(); - const abandon = vi.fn(); - Object.assign(innerConfig, { - getBackgroundTaskRegistry: vi.fn().mockReturnValue({ - get: vi.fn().mockReturnValue({ - id: 'agent-1', - kind: 'agent', - status: 'paused', - }), - cancel, - abandon, + it('retains a closed session registry while a workflow is starting', async () => { + const sessionIdA = 'aaaaaaaa-1111-1111-1111-111111111111'; + const sessionIdB = 'bbbbbbbb-2222-2222-2222-222222222222'; + const runId = 'wf_starting1'; + const innerConfigA = await setupSessionMocks(sessionIdA); + innerConfigA.isWorkflowsEnabled.mockReturnValue(true); + Object.assign(innerConfigA, { + getWorkflowRunRegistry: vi.fn().mockReturnValue({ + get: vi.fn().mockReturnValue({ runId, status: 'failed' }), + getHandle: vi.fn().mockReturnValue(undefined), + isStarting: vi.fn().mockReturnValue(false), + removeTerminal: vi.fn().mockReturnValue(true), }), }); @@ -11021,67 +12412,178 @@ describe('QwenAgent MCP SSE/HTTP support', () => { mockArgv, ); await vi.waitFor(() => expect(capturedAgentFactory).toBeDefined()); - const agent = capturedAgentFactory!({ get closed() { return mockConnectionState.promise; }, }) as AgentLike; + await agent.newSession({ cwd: '/tmp', mcpServers: [] }); + let starting = true; + const registryB = { + get: vi.fn().mockReturnValue(undefined), + getHandle: vi.fn().mockReturnValue(undefined), + isStarting: vi.fn(() => starting), + removeTerminal: vi.fn().mockReturnValue(false), + hasRunningEntries: vi.fn(() => starting), + list: vi.fn().mockReturnValue([]), + abortAll: vi.fn(), + }; + const innerConfigB = { + ...makeInnerConfig(), + getSessionId: vi.fn().mockReturnValue(sessionIdB), + isWorkflowsEnabled: vi.fn().mockReturnValue(true), + getWorkflowRunRegistry: vi.fn().mockReturnValue(registryB), + }; + vi.mocked(loadCliConfig).mockResolvedValue( + innerConfigB as unknown as Config, + ); await agent.newSession({ cwd: '/tmp', mcpServers: [] }); - await expect( - agent.extMethod(SERVE_CONTROL_EXT_METHODS.sessionTaskCancel, { - sessionId, - taskId: 'agent-1', - taskKind: 'agent', - }), - ).resolves.toEqual({ cancelled: true, status: 'paused' }); - expect(abandon).toHaveBeenCalledWith('agent-1'); - expect(cancel).not.toHaveBeenCalled(); + + const predicateA = vi.mocked(Session).mock.calls[0][7] as ( + candidateRunId: string, + ) => boolean; + await agent.extMethod(SERVE_CONTROL_EXT_METHODS.sessionClose, { + sessionId: sessionIdB, + }); + + expect(predicateA(runId)).toBe(true); + starting = false; + expect(predicateA(runId)).toBe(false); mockConnectionState.resolve(); await agentPromise; }); - it('rejects sessionTaskCancel with invalid params', async () => { + it("serializes a sibling session's retry against a history deletion", async () => { + // R5-9: the mutual-exclusion claim used to be keyed `sessionId\0taskId`, + // which serialized nothing that mattered — all sessions share one + // snapshot store. A sibling's retry passes canStart (`failed`, no + // handle), takes its own per-session claim, then awaits journal + // load/compile before `register()`; a delete-history landing in that + // structural window saw the run terminal and handle-less in every + // registry, deleted the journal directory and snapshot, and answered + // `{changed: true}` — after which the retry re-registered and its + // settlement re-persisted the history the user was told was deleted. + // With a task-global claim one of the two must refuse. + const sessionIdA = 'aaaaaaaa-1111-1111-1111-111111111111'; + const sessionIdB = 'bbbbbbbb-2222-2222-2222-222222222222'; + const runId = 'wf_shared1'; + const innerConfigA = await setupSessionMocks(sessionIdA); + innerConfigA.isWorkflowsEnabled.mockReturnValue(true); + Object.assign(innerConfigA, { + getWorkflowRunRegistry: vi.fn().mockReturnValue({ + get: vi.fn().mockReturnValue({ runId, status: 'failed' }), + getHandle: vi.fn().mockReturnValue(undefined), + removeTerminal: vi.fn().mockReturnValue(true), + }), + }); + 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: [] }); + const sessionMockA = lastSessionMock!; - await expect( - agent.extMethod(SERVE_CONTROL_EXT_METHODS.sessionTaskCancel, { - sessionId: 'session-1', - taskId: 'task-1', - taskKind: 'invalid', + // Session B parks its retry exactly where the race lived: past + // canStart, inside the await that precedes `register()`. + let releaseRetry!: () => void; + const retryGate = new Promise((resolve) => { + releaseRetry = resolve; + }); + const execute = vi.fn(async () => { + await retryGate; + return { llmContent: 'started', workflowRunId: runId }; + }); + const innerConfigB = { + ...makeInnerConfig(), + getSessionId: vi.fn().mockReturnValue(sessionIdB), + isWorkflowsEnabled: vi.fn().mockReturnValue(true), + getWorkflowRunRegistry: vi.fn().mockReturnValue({ + get: vi.fn().mockReturnValue({ + runId, + status: 'failed', + script: 'return await agent("x")', + args: undefined, + }), + getHandle: vi.fn().mockReturnValue(undefined), + removeTerminal: vi.fn().mockReturnValue(false), + }), + getToolRegistry: vi.fn().mockReturnValue({ + getTool: vi.fn((name: string) => + name === 'workflow' + ? { buildSessionOwnedBackground: vi.fn(() => ({ execute })) } + : undefined, + ), }), - ).rejects.toThrow('taskKind must be "agent", "shell", or "monitor"'); + }; + vi.mocked(loadCliConfig).mockResolvedValue( + innerConfigB as unknown as Config, + ); + await agent.newSession({ cwd: '/tmp', mcpServers: [] }); - mockConnectionState.resolve(); - await agentPromise; - }); + const retry = agent.extMethod( + SERVE_CONTROL_EXT_METHODS.sessionWorkflowTaskAction, + { sessionId: sessionIdB, taskId: runId, action: 'retry' }, + ); + await vi.waitFor(() => expect(execute).toHaveBeenCalledOnce()); - it('cancels running shell tasks', async () => { - const sessionId = '11111111-1111-1111-1111-111111111111'; - const innerConfig = await setupSessionMocks(sessionId); - const requestCancel = vi.fn(); - Object.assign(innerConfig, { - getBackgroundShellRegistry: vi.fn().mockReturnValue({ - get: vi.fn().mockReturnValue({ - id: 'shell-1', - kind: 'shell', - status: 'running', - }), - requestCancel, + // Session A's deletion of the same run must refuse while B holds the + // claim — and must not reach the store at all. + sessionMockA.deleteWorkflowHistory.mockResolvedValue(true); + await expect( + agent.extMethod(SERVE_CONTROL_EXT_METHODS.sessionWorkflowTaskAction, { + sessionId: sessionIdA, + taskId: runId, + action: 'delete-history', }), + ).resolves.toEqual({ changed: false }); + expect(sessionMockA.deleteWorkflowHistory).not.toHaveBeenCalled(); + + releaseRetry(); + await expect(retry).resolves.toMatchObject({ changed: true }); + + // Once the claim is released the deletion goes through normally. + await expect( + agent.extMethod(SERVE_CONTROL_EXT_METHODS.sessionWorkflowTaskAction, { + sessionId: sessionIdA, + taskId: runId, + action: 'delete-history', + }), + ).resolves.toEqual({ changed: true }); + expect(sessionMockA.deleteWorkflowHistory).toHaveBeenCalledWith(runId); + + mockConnectionState.resolve(); + await agentPromise; + }); + + it('refuses a retry while a sibling session still runs the same runId', async () => { + // R11-1: canStart used to consult only the requesting session's + // private registry, and the task-global claim is released as soon as + // the background start returns — while the run is still live. A + // sibling whose registry still shows the run `failed` with no handle + // therefore started a second runner under the same runId over the + // shared journal and snapshot files. + const sessionIdA = 'aaaaaaaa-1111-1111-1111-111111111111'; + const sessionIdB = 'bbbbbbbb-2222-2222-2222-222222222222'; + const runId = 'wf_live1'; + const innerConfigA = await setupSessionMocks(sessionIdA); + innerConfigA.isWorkflowsEnabled.mockReturnValue(true); + const registryA = { + get: vi.fn().mockReturnValue({ runId, status: 'running' }), + getHandle: vi.fn().mockReturnValue(undefined), + removeTerminal: vi.fn().mockReturnValue(true), + }; + Object.assign(innerConfigA, { + getWorkflowRunRegistry: vi.fn().mockReturnValue(registryA), }); const agentPromise = runAcpAgent( @@ -11090,40 +12592,90 @@ describe('QwenAgent MCP SSE/HTTP support', () => { 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_CONTROL_EXT_METHODS.sessionTaskCancel, { - sessionId, - taskId: 'shell-1', - taskKind: 'shell', + + const execute = vi.fn(async () => ({ + llmContent: 'started', + workflowRunId: runId, + })); + let bStarting = false; + const innerConfigB = { + ...makeInnerConfig(), + getSessionId: vi.fn().mockReturnValue(sessionIdB), + isWorkflowsEnabled: vi.fn().mockReturnValue(true), + getWorkflowRunRegistry: vi.fn().mockReturnValue({ + get: vi.fn().mockReturnValue({ + runId, + status: 'failed', + script: 'return await agent("x")', + args: undefined, + }), + getHandle: vi.fn().mockReturnValue(undefined), + isStarting: vi.fn(() => bStarting), + removeTerminal: vi.fn().mockReturnValue(false), }), - ).resolves.toEqual({ cancelled: true, status: 'running' }); - expect(requestCancel).toHaveBeenCalledWith('shell-1'); + getToolRegistry: vi.fn().mockReturnValue({ + getTool: vi.fn((name: string) => + name === 'workflow' + ? { buildSessionOwnedBackground: vi.fn(() => ({ execute })) } + : undefined, + ), + }), + }; + vi.mocked(loadCliConfig).mockResolvedValue( + innerConfigB as unknown as Config, + ); + await agent.newSession({ cwd: '/tmp', mcpServers: [] }); + + const retry = () => + agent.extMethod(SERVE_CONTROL_EXT_METHODS.sessionWorkflowTaskAction, { + sessionId: sessionIdB, + taskId: runId, + action: 'retry', + }); + + // Session A still runs it: B's retry must refuse without starting. + await expect(retry()).resolves.toEqual({ + changed: false, + status: 'failed', + }); + expect(execute).not.toHaveBeenCalled(); + + // A's run settled, but B's own earlier retry is still in its + // starting window (reservation held, no entry yet): still refused. + registryA.get.mockReturnValue({ runId, status: 'failed' }); + bStarting = true; + await expect(retry()).resolves.toEqual({ + changed: false, + status: 'failed', + }); + expect(execute).not.toHaveBeenCalled(); + + // Nothing live anywhere: the retry goes through. + bStarting = false; + await expect(retry()).resolves.toMatchObject({ changed: true }); + expect(execute).toHaveBeenCalledOnce(); mockConnectionState.resolve(); await agentPromise; }); - it('cancels running monitor tasks', async () => { - const sessionId = '11111111-1111-1111-1111-111111111111'; - const innerConfig = await setupSessionMocks(sessionId); - const cancel = vi.fn(); - Object.assign(innerConfig, { - getMonitorRegistry: vi.fn().mockReturnValue({ - get: vi.fn().mockReturnValue({ - id: 'monitor-1', - kind: 'monitor', - status: 'running', - }), - cancel, - }), + it('wires history-deletion liveness across every sibling session registry', async () => { + const sessionIdA = 'aaaaaaaa-1111-1111-1111-111111111111'; + const sessionIdB = 'bbbbbbbb-2222-2222-2222-222222222222'; + const innerConfigA = await setupSessionMocks(sessionIdA); + const registryA = { + get: vi.fn().mockReturnValue({ runId: 'wf_shared', status: 'running' }), + getHandle: vi.fn().mockReturnValue(undefined), + removeTerminal: vi.fn().mockReturnValue(true), + }; + Object.assign(innerConfigA, { + getWorkflowRunRegistry: vi.fn().mockReturnValue(registryA), }); const agentPromise = runAcpAgent( @@ -11132,7 +12684,6 @@ describe('QwenAgent MCP SSE/HTTP support', () => { mockArgv, ); await vi.waitFor(() => expect(capturedAgentFactory).toBeDefined()); - const agent = capturedAgentFactory!({ get closed() { return mockConnectionState.promise; @@ -11140,14 +12691,66 @@ describe('QwenAgent MCP SSE/HTTP support', () => { }) as AgentLike; await agent.newSession({ cwd: '/tmp', mcpServers: [] }); + const sessionMockA = lastSessionMock!; + + // Every session in this child shares the workflow store but keeps a + // private registry; the second session must see the first's runs. + const innerConfigB = { + ...makeInnerConfig(), + getSessionId: vi.fn().mockReturnValue(sessionIdB), + isWorkflowsEnabled: vi.fn().mockReturnValue(true), + getWorkflowRunRegistry: vi.fn().mockReturnValue({ + get: vi.fn().mockReturnValue(undefined), + getHandle: vi.fn().mockReturnValue(undefined), + removeTerminal: vi.fn().mockReturnValue(false), + }), + }; + vi.mocked(loadCliConfig).mockResolvedValue( + innerConfigB as unknown as Config, + ); + await agent.newSession({ cwd: '/tmp', mcpServers: [] }); + + const sessionCalls = vi.mocked(Session).mock.calls; + expect(sessionCalls).toHaveLength(2); + const predicateA = sessionCalls[0][7] as (runId: string) => boolean; + const predicateB = sessionCalls[1][7] as (runId: string) => boolean; + + // Session A still runs wf_shared: B's deletion view must refuse it. + expect(predicateB('wf_shared')).toBe(true); + expect(registryA.get).toHaveBeenCalledWith('wf_shared'); + // A's own registry is consulted by Session itself, never by its + // sibling predicate. + expect(predicateA('wf_shared')).toBe(false); + // Once A's run settles terminal, B may delete. + registryA.get.mockReturnValue({ runId: 'wf_shared', status: 'failed' }); + expect(predicateB('wf_shared')).toBe(false); + // A handle that outlives the terminal transition (snapshot write in + // flight) still blocks the sibling deletion. + registryA.getHandle.mockReturnValue({ completion: Promise.resolve() }); + expect(predicateB('wf_shared')).toBe(true); + + // Once session B's deletion succeeds, the sibling registries lose the + // terminal entry too — or a retry from the sibling would resurrect + // the deleted run. + registryA.getHandle.mockReturnValue(undefined); + registryA.get.mockReturnValue({ runId: 'wf_shared', status: 'failed' }); + lastSessionMock!.deleteWorkflowHistory.mockResolvedValueOnce(true); await expect( - agent.extMethod(SERVE_CONTROL_EXT_METHODS.sessionTaskCancel, { - sessionId, - taskId: 'monitor-1', - taskKind: 'monitor', + agent.extMethod(SERVE_CONTROL_EXT_METHODS.sessionWorkflowTaskAction, { + sessionId: sessionIdB, + taskId: 'wf_shared', + action: 'delete-history', }), - ).resolves.toEqual({ cancelled: true, status: 'running' }); - expect(cancel).toHaveBeenCalledWith('monitor-1'); + ).resolves.toEqual({ changed: true }); + expect(lastSessionMock!.deleteWorkflowHistory).toHaveBeenCalledWith( + 'wf_shared', + ); + expect(registryA.removeTerminal).toHaveBeenCalledWith('wf_shared'); + // ...and the deletion is marked in the sibling's history, so a refresh + // of A's that had already read the directory cannot republish it. + expect(sessionMockA.noteExternalWorkflowDeletion).toHaveBeenCalledWith( + 'wf_shared', + ); mockConnectionState.resolve(); await agentPromise; @@ -22772,6 +24375,8 @@ describe('sessionLanguage multi-session propagation', () => { refreshHierarchicalMemory: vi.fn().mockResolvedValue(undefined), getWorkspaceContext: vi.fn().mockReturnValue({}), getDebugMode: vi.fn().mockReturnValue(false), + isWorkflowsEnabled: vi.fn().mockReturnValue(false), + setWorkflowsEnabled: vi.fn(), ...overrides, }; } @@ -23219,6 +24824,111 @@ describe('sessionLanguage multi-session propagation', () => { await agentPromise; }); + it('propagates tools.workflowsEnabled to existing sessions on reload', async () => { + // R11-2: /capabilities reads the flag live from the reloaded + // settings, but a session alive before the reload was constructed + // with the old value. Without propagation its control surfaces kept + // answering canUseWorkflowControls with the stale value while the + // capabilities advertisement said the opposite. + let mergedSettings: Record = { + tools: { workflowsEnabled: true }, + }; + const settings = { + get merged() { + return mergedSettings; + }, + reloadScopeFromDisk: vi.fn(() => { + mergedSettings = { tools: { workflowsEnabled: false } }; + }), + getUserHooks: vi.fn().mockReturnValue({}), + getProjectHooks: vi.fn().mockReturnValue({}), + } as unknown as LoadedSettings; + let workflowsEnabled = true; + const registryCancel = vi.fn(); + const cfg = makeConfig({ + getSessionId: vi.fn().mockReturnValue('s-wf-reload'), + isWorkflowsEnabled: vi.fn(() => workflowsEnabled), + setWorkflowsEnabled: vi.fn((enabled: boolean) => { + workflowsEnabled = enabled; + }), + setDisabledTools: vi.fn(), + getBareMode: vi.fn().mockReturnValue(false), + getFolderTrustFeature: vi.fn().mockReturnValue(false), + getFolderTrust: vi.fn().mockReturnValue(true), + getWorkflowRunRegistry: vi.fn().mockReturnValue({ + get: vi.fn().mockReturnValue({ runId: 'wf_1', status: 'running' }), + getHandle: vi.fn().mockReturnValue(undefined), + cancel: registryCancel, + }), + }); + const sendAvailableCommandsUpdate = vi.fn().mockResolvedValue(undefined); + + vi.mocked(loadSettings).mockReturnValue(settings); + vi.mocked(loadCliConfig).mockResolvedValue(cfg as unknown as Config); + vi.mocked(Session).mockImplementation( + () => + ({ + getId: vi.fn().mockReturnValue('s-wf-reload'), + shouldHintAskUserQuestionRestore: vi.fn().mockReturnValue(false), + getConfig: vi.fn().mockReturnValue(cfg), + isIdle: vi.fn().mockReturnValue(true), + sendAvailableCommandsUpdate, + installRewriter: vi.fn(), + installGoalTerminalObserver: vi.fn(), + startCronScheduler: vi.fn(), + dispose: vi.fn(), + }) as unknown as InstanceType, + ); + vi.mocked(buildAvailableCommandsSnapshot).mockResolvedValue({ + availableCommands: [], + availableSkills: [], + }); + + const agentPromise = runAcpAgent( + makeConfig() as unknown as Config, + settings, + mockArgv, + ); + await vi.waitFor(() => expect(capturedAgentFactory).toBeDefined()); + const agent = capturedAgentFactory!({ + get closed() { + return mockConnectionState.promise; + }, + }); + + await agent.newSession({ cwd: '/reload', mcpServers: [] }); + const cancelWorkflow = () => + agent.extMethod(SERVE_CONTROL_EXT_METHODS.sessionTaskCancel, { + sessionId: 's-wf-reload', + taskId: 'wf_1', + taskKind: 'workflow', + }); + await expect(cancelWorkflow()).resolves.toMatchObject({ cancelled: true }); + expect(registryCancel).toHaveBeenCalledOnce(); + + const result = await agent.extMethod( + SERVE_CONTROL_EXT_METHODS.workspaceReload, + {}, + ); + + expect(result).toMatchObject({ sessionsRefreshed: ['s-wf-reload'] }); + expect( + (cfg as typeof cfg & { setWorkflowsEnabled: ReturnType }) + .setWorkflowsEnabled, + ).toHaveBeenCalledWith(false); + // The `workflows` command left with the flag: the client is told. + expect(sendAvailableCommandsUpdate).toHaveBeenCalledOnce(); + // The existing session now answers with the reloaded value. + await expect(cancelWorkflow()).resolves.toEqual({ + cancelled: false, + reason: 'disabled', + }); + expect(registryCancel).toHaveBeenCalledOnce(); + + mockConnectionState.resolve(); + await agentPromise; + }); + it('refreshes busy skill sessions and reports per-session failures', async () => { const bootstrapSettings = { merged: {}, diff --git a/packages/cli/src/acp-integration/acpAgent.ts b/packages/cli/src/acp-integration/acpAgent.ts index 821df1920fb..15547ecf2d8 100644 --- a/packages/cli/src/acp-integration/acpAgent.ts +++ b/packages/cli/src/acp-integration/acpAgent.ts @@ -134,6 +134,16 @@ import { type WorkspaceRememberContextMode, type ChatRecord, type ToolInvocationGuard, + type WorkflowParams, + type WorkflowToolResult, + type WorkflowRunRegistry, + getWorkflowTaskMutationKey, + isTerminalWorkflowStatus, + tryWithWorkflowTaskMutation, + listSavedWorkflows, + resolveSavedWorkflowScript, + extractAndStripMeta, + listWorkflowSnapshots, type TurnResultRecordPayload, sessionIdContext, } from '@qwen-code/qwen-code-core'; @@ -332,6 +342,8 @@ import { type ServeSessionContextStatus, type ServeSessionSupportedCommandsStatus, type ServeSessionLspStatus, + type ServeSessionSavedWorkflowDetail, + type ServeSessionSavedWorkflowStatus, type ServeSessionTasksStatus, type ServeStatus, type ServeStatusCell, @@ -432,6 +444,25 @@ import { type GenerationEvent, } from './generation.js'; +type SessionOwnedWorkflowTool = { + buildSessionOwnedBackground( + params: Omit, + ): { + execute(signal: AbortSignal): Promise; + }; +}; + +function isSessionOwnedWorkflowTool( + value: unknown, +): value is SessionOwnedWorkflowTool { + return ( + typeof value === 'object' && + value !== null && + 'buildSessionOwnedBackground' in value && + typeof value.buildSessionOwnedBackground === 'function' + ); +} + const debugLogger = createDebugLogger('ACP_AGENT'); const QWEN_ACP_LOCAL_READ_ROOTS_ENV = 'QWEN_ACP_LOCAL_READ_ROOTS'; const POSIX_TMP_LOCAL_READ_ROOT = '/tmp'; @@ -3352,6 +3383,13 @@ class QwenAgent implements Agent { private sessions: Map = new Map(); private readonly historyMutationTails = new Map>(); private readonly startingSessionIds = new Set(); + /** + * R7-10: workflow registries of sessions already removed from + * `this.sessions` whose runs have not finished settling. Consulted by + * `isWorkflowRunLiveOutsideSession` so a delete-history cannot delete + * an orphaned live run out from under itself. Pruned as they drain. + */ + private readonly detachedWorkflowRegistries = new Set(); private activePromptCalls = new Map>(); private workspaceMcpDiscoveryConfig: Config | undefined; private workspaceMcpDiscoveryPromise: Promise | undefined; @@ -3991,6 +4029,25 @@ class QwenAgent implements Agent { cleanupErrors.push(error); } this.sessions.delete(sessionId); + // R7-10: the registry outlives the session map entry while its runs + // settle. Keep it reachable so the liveness gate still sees them. + // Retention is bookkeeping, not cleanup: a Config that cannot answer + // must not turn a successful close into a shutdown failure. + try { + // Prune here as well as on the delete-history path: a daemon that + // closes sessions mid-run but never deletes history would otherwise + // retain every one of their registries for its whole lifetime. + this.pruneDrainedWorkflowRegistries(); + const registry = session.getConfig().getWorkflowRunRegistry?.(); + if (registry && QwenAgent.isWorkflowRegistryDraining(registry)) { + this.detachedWorkflowRegistries.add(registry); + } + } catch (error) { + debugLogger.warn( + `Session ${sessionId}: could not retain its workflow registry for liveness checks:`, + error, + ); + } // A Session missing from the next snapshot is how the daemon learns the // child released it — including when it never saw our close response. this.activeWorkReporter?.notifyChanged(); @@ -7488,19 +7545,99 @@ class QwenAgent implements Agent { sessionId: string, ): Promise { const session = this.sessionOrThrow(sessionId); + const config = session.getConfig(); const { availableCommands, availableSkills } = await session.buildAvailableCommandsSnapshot(); + const workflowsEnabled = this.canUseWorkflowControls(config); + const savedWorkflows = workflowsEnabled + ? (await listSavedWorkflows(config)).map(({ name, source }) => ({ + name, + source, + })) + : []; return { v: STATUS_SCHEMA_VERSION, sessionId, - availableCommands, + availableCommands: + workflowsEnabled || !config.isWorkflowsEnabled() + ? availableCommands + : availableCommands.filter((command) => command.name !== 'workflows'), availableSkills: availableSkills ?? [], + workflowsEnabled, + savedWorkflows, }; } - private buildSessionTasksStatus(sessionId: string): ServeSessionTasksStatus { + private canUseWorkflowControls(config: Config): boolean { + return ( + config.isWorkflowsEnabled() && + !config.getBareMode() && + (!config.getFolderTrustFeature() || config.getFolderTrust()) + ); + } + + private async buildSessionTasksStatus( + sessionId: string, + includeWorkflows = false, + ): Promise { const session = this.sessionOrThrow(sessionId); - return buildSessionTasksStatus(sessionId, session.getConfig()); + return buildSessionTasksStatus( + sessionId, + session.getConfig(), + Date.now(), + includeWorkflows ? await session.refreshWorkflowHistory() : [], + { includeWorkflows }, + ); + } + + /** + * 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 { @@ -8297,10 +8434,12 @@ class QwenAgent implements Agent { 'Invalid or missing sessionId', ); } - return this.buildSessionTasksStatus(sessionId) as unknown as Record< - string, - unknown - >; + const session = this.sessionOrThrow(sessionId); + return (await this.buildSessionTasksStatus( + sessionId, + params['includeWorkflows'] === true && + this.canUseWorkflowControls(session.getConfig()), + )) as unknown as Record; } case SERVE_STATUS_EXT_METHODS.sessionLspStatus: { const sessionId = params['sessionId']; @@ -8315,6 +8454,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)) { @@ -10745,11 +10904,12 @@ class QwenAgent implements Agent { if ( taskKind !== 'agent' && taskKind !== 'shell' && - taskKind !== 'monitor' + taskKind !== 'monitor' && + taskKind !== 'workflow' ) { throw RequestError.invalidParams( undefined, - 'taskKind must be "agent", "shell", or "monitor"', + 'taskKind must be "agent", "shell", "monitor", or "workflow"', ); } debugLogger.info( @@ -10810,12 +10970,249 @@ class QwenAgent implements Agent { ); return { cancelled: true, status: task.status }; } + case 'workflow': { + if (!this.canUseWorkflowControls(config)) { + return { cancelled: false, reason: 'disabled' }; + } + const registry = config.getWorkflowRunRegistry(); + const task = registry.get(taskId); + // A reserved-but-unregistered run has no entry yet: the runner + // is still loading its script or replaying its journal. The + // liveness gate already treats that window as live; cancel + // must too, or the client is told "not_found" about a run it + // can see starting, and cannot stop it until it registers. + // A retry reuses its runId, so during ITS starting window the + // old terminal entry is still there — a live reservation + // shadowed by a terminal entry is the same starting run, not + // a "not_running" one. + if ( + (!task || isTerminalWorkflowStatus(task.status)) && + registry.isStarting?.(taskId) + ) { + registry.cancelStarting(taskId); + debugLogger.info( + `sessionTaskCancel completed sessionId=${sessionId} taskId=${taskId} taskKind=${taskKind} status=starting`, + ); + return { cancelled: true, status: 'cancelled' }; + } + if ( + !task || + (task.status !== 'running' && + task.status !== 'pausing' && + task.status !== 'paused') + ) { + const reason = task ? 'not_running' : 'not_found'; + debugLogger.info( + `sessionTaskCancel skipped sessionId=${sessionId} taskId=${taskId} taskKind=${taskKind} reason=${reason} status=${task?.status ?? 'missing'}`, + ); + return { cancelled: false, reason, status: task?.status }; + } + const handle = registry.getHandle(taskId); + registry.cancel(taskId, Date.now()); + if (handle) await handle.completion; + debugLogger.info( + `sessionTaskCancel completed sessionId=${sessionId} taskId=${taskId} taskKind=${taskKind} status=${task.status}`, + ); + return { cancelled: true, status: task.status }; + } default: { const exhaustive: never = taskKind; throw new Error(`Unhandled task kind: ${exhaustive}`); } } } + case SERVE_CONTROL_EXT_METHODS.sessionWorkflowTaskAction: { + const sessionId = params['sessionId']; + if (typeof sessionId !== 'string' || sessionId.length === 0) { + throw RequestError.invalidParams( + undefined, + 'Invalid or missing sessionId', + ); + } + const taskId = params['taskId']; + if (typeof taskId !== 'string' || taskId.length === 0) { + throw RequestError.invalidParams( + undefined, + 'Invalid or missing taskId', + ); + } + const action = params['action']; + if ( + action !== 'pause' && + action !== 'resume' && + action !== 'retry' && + action !== 'rerun' && + action !== 'delete-history' && + action !== 'run-saved' + ) { + throw RequestError.invalidParams( + undefined, + 'action must be "pause", "resume", "retry", "rerun", "delete-history", or "run-saved"', + ); + } + const session = this.sessionOrThrow(sessionId); + const config = session.getConfig(); + if (!this.canUseWorkflowControls(config)) { + return { changed: false }; + } + const mutationClaim = + action === 'run-saved' + ? getWorkflowTaskMutationKey(config, taskId, 'saved') + : getWorkflowTaskMutationKey(config, taskId); + if (action === 'delete-history') { + const attempt = await tryWithWorkflowTaskMutation( + mutationClaim, + async () => { + const changed = await session.deleteWorkflowHistory(taskId); + if (changed) { + // Every session shares the one store: drop the sibling + // registries' terminal entries too, or a retry from a + // sibling re-persists the just-deleted run — and mark the + // deletion in each sibling's history, or a sibling refresh + // that began reading the directory before this delete + // landed merges the stale listing and republishes the run. + for (const [siblingId, sibling] of this.sessions) { + if (siblingId === sessionId) continue; + sibling + .getConfig() + .getWorkflowRunRegistry() + .removeTerminal(taskId); + sibling.noteExternalWorkflowDeletion(taskId); + } + } + return { changed }; + }, + ); + if (!attempt.acquired) { + return { changed: false }; + } + return attempt.value; + } + const registry = config.getWorkflowRunRegistry(); + if (action === 'run-saved') { + const attempt = await tryWithWorkflowTaskMutation( + mutationClaim, + async () => { + const savedWorkflow = (await listSavedWorkflows(config)).find( + (entry) => entry.name === taskId, + ); + if (!savedWorkflow) return { changed: false }; + const workflowTool = config + .getToolRegistry() + .getTool(ToolNames.WORKFLOW); + if (!isSessionOwnedWorkflowTool(workflowTool)) { + throw RequestError.invalidParams( + undefined, + 'The workflow tool is unavailable; cannot run this saved workflow.', + ); + } + const result = (await workflowTool + .buildSessionOwnedBackground({ + scriptPath: savedWorkflow.scriptPath, + }) + .execute(new AbortController().signal)) as WorkflowToolResult; + const startedTask = result.workflowRunId + ? registry.get(result.workflowRunId) + : undefined; + return startedTask + ? { + changed: true, + status: startedTask.status, + taskId: startedTask.runId, + } + : { changed: false }; + }, + ); + if (!attempt.acquired) { + return { changed: false }; + } + return attempt.value; + } + const task = registry.get(taskId); + if (!task) return { changed: false }; + if (action === 'retry' || action === 'rerun') { + // A retry reuses its runId over the one journal/snapshot store + // every session shares, so "failed with no handle" in THIS + // session's registry is not enough: a sibling may have retried + // it already and be running it now — the task-global claim is + // released as soon as the background start returns, while the + // run is still live. Two runners on one runId interleave the + // journal and race the snapshot write. Checked synchronously + // beside canStart — no await precedes the claim — so the answer + // cannot go stale before the claim is taken. + const liveElsewhere = + action === 'retry' && + (registry.isStarting?.(taskId) === true || + this.isWorkflowRunLiveOutsideSession(sessionId, taskId)); + const canStart = + action === 'retry' + ? task.status === 'failed' && + !registry.getHandle(taskId) && + !liveElsewhere + : task.status === 'completed' || + task.status === 'failed' || + task.status === 'cancelled'; + if (!canStart || !task.script) { + return { changed: false, status: task.status }; + } + const attempt = await tryWithWorkflowTaskMutation( + mutationClaim, + async () => { + const workflowTool = config + .getToolRegistry() + .getTool(ToolNames.WORKFLOW); + if (!isSessionOwnedWorkflowTool(workflowTool)) { + throw RequestError.invalidParams( + undefined, + `The workflow tool is unavailable; cannot ${action} this run.`, + ); + } + const startParams: Omit = { + script: task.script, + args: task.args, + ...(action === 'retry' ? { resumeFromRunId: task.runId } : {}), + }; + const result = (await workflowTool + .buildSessionOwnedBackground(startParams) + .execute(new AbortController().signal)) as WorkflowToolResult; + if (action === 'rerun') { + const rerunTask = result.workflowRunId + ? registry.get(result.workflowRunId) + : undefined; + if (rerunTask) { + registry.setLineage(rerunTask.runId, task.runId, 'rerun'); + } + return rerunTask + ? { + changed: true, + status: rerunTask.status, + taskId: rerunTask.runId, + } + : { changed: false, status: task.status }; + } + // `execute()` reports a start that never registered — a + // cancel landing in the retry's starting window, whether from + // `cancelStarting` or a session dispose — by omitting + // `workflowRunId`, the same shape the rerun and run-saved + // branches gate on. Answering `changed: true` there tells the + // client a run exists that nothing will ever progress. + return result.workflowRunId + ? { + changed: true, + status: registry.get(result.workflowRunId)?.status, + } + : { changed: false, status: task.status }; + }, + ); + if (!attempt.acquired) { + return { changed: false, status: task.status }; + } + return attempt.value; + } + const changed = + action === 'pause' ? registry.pause(taskId) : registry.resume(taskId); + return { changed, status: task.status }; + } case SERVE_CONTROL_EXT_METHODS.sessionGoalClear: { const sessionId = params['sessionId']; if (typeof sessionId !== 'string' || sessionId.length === 0) { @@ -12064,6 +12461,30 @@ class QwenAgent implements Agent { ); config.setDisabledTools(new Set(disabled)); + // `/capabilities` reads `tools.workflowsEnabled` live from + // the reloaded settings; a session alive before the reload + // was constructed with the old value and would keep + // answering canUseWorkflowControls with it, so the + // advertisement and the controls it gates would diverge. + // The merged view is already workspace-stripped, so a repo + // cannot self-grant here any more than at construction. + const workflowsWereEnabled = config.isWorkflowsEnabled(); + config.setWorkflowsEnabled( + newMerged.tools?.workflowsEnabled === true, + ); + if (config.isWorkflowsEnabled() !== workflowsWereEnabled) { + // The `workflows` slash command comes and goes with the + // flag; a client holding the old list would keep offering + // (or hiding) it. + try { + await session.sendAvailableCommandsUpdate(); + } catch (err) { + debugLogger.warn( + `reload: sendAvailableCommandsUpdate failed for session ${id}: ${err}`, + ); + } + } + const newMode = newMerged.tools?.approvalMode; if ( newMode && @@ -12696,6 +13117,89 @@ class QwenAgent implements Agent { config.setFileSystemService(acpFileSystemService); } + /** + * All sessions in this child share one workflow snapshot store but each + * keeps a private run registry, so one session's history deletion must + * see every sibling registry — or it can delete a run another session + * is still executing or settling. A handle outlives the terminal + * transition until the snapshot write lands, so it blocks too. + * + * R7-10: iterating `this.sessions` alone left a blind spot the width of + * a whole run. A workflow can outlive its owning session's removal — + * explicit close/kill/shutdown use force semantics, and a background + * run owns a detached controller — so once `removeStoredSessionEntry` + * deleted the session, a still-settling run became invisible here and + * unreachable by the delete handler's sibling `removeTerminal` loop. A + * sibling `delete-history` then passed every check, removed the LIVE + * run's journal directory and snapshot, and answered `{changed: true}`; + * the orphan kept going and its settlement `writeWorkflowSnapshot` + * recreated the file — resurrection, plus a run whose journal was rm'd + * under it. + * + * The repair has two halves. `Session.dispose()` now aborts its + * workflow registry the way it already aborts the agent registry, so an + * orphan settles instead of running on; and the registry of a removed + * session stays reachable here until its runs drain, so the gate keeps + * answering for the settlement window that abort cannot compress to + * zero (the handle is released only after the snapshot write lands). + */ + private isWorkflowRunLiveOutsideSession( + excludeSessionId: string, + runId: string, + ): boolean { + for (const [sessionId, session] of this.sessions) { + if (sessionId === excludeSessionId) continue; + if ( + QwenAgent.isWorkflowRunLiveInRegistry( + session.getConfig().getWorkflowRunRegistry(), + runId, + ) + ) { + return true; + } + } + this.pruneDrainedWorkflowRegistries(); + for (const registry of this.detachedWorkflowRegistries) { + if (QwenAgent.isWorkflowRunLiveInRegistry(registry, runId)) return true; + } + return false; + } + + private static isWorkflowRunLiveInRegistry( + registry: WorkflowRunRegistry, + runId: string, + ): boolean { + if (registry.isStarting?.(runId)) return true; + const entry = registry.get(runId); + if (entry && !isTerminalWorkflowStatus(entry.status)) return true; + return registry.getHandle(runId) !== undefined; + } + + /** + * Registries of removed sessions are retained only while they still + * hold work — an aborted run keeps its handle until settlement releases + * it. Dropping drained ones keeps this from becoming a leak that grows + * with every closed session. + */ + private pruneDrainedWorkflowRegistries(): void { + for (const registry of this.detachedWorkflowRegistries) { + if (!QwenAgent.isWorkflowRegistryDraining(registry)) { + this.detachedWorkflowRegistries.delete(registry); + } + } + } + + /** Still holding work: an active entry, or a handle awaiting settlement. */ + private static isWorkflowRegistryDraining( + registry: WorkflowRunRegistry, + ): boolean { + if (registry.hasRunningEntries?.()) return true; + return ( + registry.list?.().some((entry) => registry.getHandle(entry.runId)) ?? + false + ); + } + private async createAndStoreSession( config: Config, settings: LoadedSettings, @@ -12739,7 +13243,7 @@ class QwenAgent implements Agent { ); } options.beforeSessionCreate?.(); - + const workflowHistory = await listWorkflowSnapshots(config); const session = new Session( sessionId, config, @@ -12747,6 +13251,8 @@ class QwenAgent implements Agent { settings, (operation) => this.runExclusiveHistoryMutation(sessionId, operation), () => this.activeWorkReporter?.notifyChanged(), + workflowHistory, + (runId) => this.isWorkflowRunLiveOutsideSession(sessionId, runId), ); const replaySessionHistory = async () => { if ( diff --git a/packages/cli/src/acp-integration/acpAgent.worktree.test.ts b/packages/cli/src/acp-integration/acpAgent.worktree.test.ts index 9fab2150d42..c78d5db46e8 100644 --- a/packages/cli/src/acp-integration/acpAgent.worktree.test.ts +++ b/packages/cli/src/acp-integration/acpAgent.worktree.test.ts @@ -215,6 +215,7 @@ vi.mock('@qwen-code/qwen-code-core', () => ({ snapshot: vi.fn(() => ({})), })), restoreWorktreeContext: mockRestoreWorktreeContext, + listWorkflowSnapshots: vi.fn().mockResolvedValue([]), HookEventName: { PreToolUse: 'PreToolUse', PostToolUse: 'PostToolUse', diff --git a/packages/cli/src/acp-integration/session/Session.review-lease.test.ts b/packages/cli/src/acp-integration/session/Session.review-lease.test.ts index 93f75bf7822..af1fe3c29b8 100644 --- a/packages/cli/src/acp-integration/session/Session.review-lease.test.ts +++ b/packages/cli/src/acp-integration/session/Session.review-lease.test.ts @@ -168,6 +168,13 @@ describe('Session review-worktree lease sweep', () => { clearStatusChangeCallback: vi.fn(), hasRunningEntries: vi.fn().mockReturnValue(false), }), + getWorkflowRunRegistry: vi.fn().mockReturnValue({ + setStatusChangeCallback: vi.fn(), + clearStatusChangeCallback: vi.fn(), + setCompletionCallback: vi.fn(), + setSnapshotPersistedCallback: vi.fn(), + setApprovalRequestCallback: vi.fn(), + }), setSubSessionSpawner: vi.fn(), getSubSessionSpawner: vi.fn(), // The Session constructor and Session.prompt both reach for the diff --git a/packages/cli/src/acp-integration/session/Session.test.ts b/packages/cli/src/acp-integration/session/Session.test.ts index afd28a48bcd..6af51c1449a 100644 --- a/packages/cli/src/acp-integration/session/Session.test.ts +++ b/packages/cli/src/acp-integration/session/Session.test.ts @@ -76,6 +76,8 @@ const addToolArgumentsAttributesSpy = vi.hoisted(() => vi.fn()); const addToolCallResultAttributesSpy = vi.hoisted(() => vi.fn()); const logLoopDetectedSpy = vi.hoisted(() => vi.fn()); const logRepeatedToolFailureGuardSpy = vi.hoisted(() => vi.fn()); +const deleteWorkflowSnapshotSpy = vi.hoisted(() => vi.fn()); +const listWorkflowSnapshotsSpy = vi.hoisted(() => vi.fn()); const agentTelemetry = vi.hoisted(() => ({ span: {}, getActiveInteractionSpan: vi.fn(), @@ -153,6 +155,8 @@ vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => { logRepeatedToolFailureGuardSpy(...args); return actual.logRepeatedToolFailureGuard(...args); }, + deleteWorkflowSnapshot: deleteWorkflowSnapshotSpy, + listWorkflowSnapshots: listWorkflowSnapshotsSpy, // Transparent recording wrapper: records the constructor deps, then behaves // exactly like the real resolver (subclass → instanceof + methods preserved). LoopTickResolver: class extends actual.LoopTickResolver { @@ -498,8 +502,19 @@ describe('Session', () => { getFunctionDeclarationsFiltered: ReturnType; }; let mockWorkflowRunRegistry: { + setCompletionCallback: ReturnType; + setStatusChangeCallback: ReturnType; + setSnapshotPersistedCallback: ReturnType; + clearStatusChangeCallback: ReturnType; setApprovalRequestCallback: ReturnType; resolvePendingApproval: ReturnType; + get: ReturnType; + getHandle: ReturnType; + isStarting: ReturnType; + listStartingRunIds: ReturnType; + removeTerminal: ReturnType; + list: ReturnType; + abortAll: ReturnType; }; let mockGoalRuntime: { getSnapshot: ReturnType; @@ -614,6 +629,10 @@ describe('Session', () => { addToolCallResultAttributesSpy.mockClear(); logLoopDetectedSpy.mockReset(); logRepeatedToolFailureGuardSpy.mockReset(); + deleteWorkflowSnapshotSpy.mockReset(); + deleteWorkflowSnapshotSpy.mockResolvedValue(true); + listWorkflowSnapshotsSpy.mockReset(); + listWorkflowSnapshotsSpy.mockResolvedValue([]); agentTelemetry.getActiveInteractionSpan.mockReset(); agentTelemetry.addAgentInputMessageAttributes.mockReset(); agentTelemetry.captures.length = 0; @@ -723,8 +742,19 @@ describe('Session', () => { ), }; mockWorkflowRunRegistry = { + setCompletionCallback: vi.fn(), + setStatusChangeCallback: vi.fn(), + setSnapshotPersistedCallback: vi.fn(), + clearStatusChangeCallback: vi.fn(), setApprovalRequestCallback: vi.fn(), resolvePendingApproval: vi.fn().mockResolvedValue(true), + get: vi.fn().mockReturnValue(undefined), + getHandle: vi.fn().mockReturnValue(undefined), + isStarting: vi.fn().mockReturnValue(false), + listStartingRunIds: vi.fn().mockReturnValue([]), + removeTerminal: vi.fn().mockReturnValue(false), + list: vi.fn().mockReturnValue([]), + abortAll: vi.fn(), }; mockChatRecordingService = { @@ -1046,7 +1076,9 @@ describe('Session', () => { ); } - function holdIds(category: 'agent' | 'notification' | 'shell'): string[] { + function holdIds( + category: 'agent' | 'notification' | 'shell' | 'workflow', + ): string[] { return session .collectActiveWorkHolds() .filter((hold) => hold.category === category) @@ -1146,6 +1178,54 @@ describe('Session', () => { session.dispose(); }); + it('holds executing workflow runs but never paused ones', () => { + mockWorkflowRunRegistry.list.mockReturnValue([ + { runId: 'wf-running', status: 'running' }, + { runId: 'wf-pausing', status: 'pausing' }, + { runId: 'wf-paused', status: 'paused' }, + { runId: 'wf-complete', status: 'completed' }, + ]); + createReportingSession(); + + // Mirrors the registry's hasRunningEntries(): a paused run executes + // nothing and no backstop would ever release the hold, so it must + // not pin the session forever. + expect(holdIds('workflow')).toEqual(['wf-running', 'wf-pausing']); + expect(session.isIdle()).toBe(false); + + mockWorkflowRunRegistry.list.mockReturnValue([ + { runId: 'wf-paused', status: 'paused' }, + ]); + expect(session.collectActiveWorkHolds()).toEqual([]); + expect(session.isIdle()).toBe(true); + session.dispose(); + }); + + it('holds a workflow run that is reserved but not yet registered', () => { + // Between `reserveStart` and `register` the run has no `list()` + // entry, yet the registry's hasRunningEntries() and the liveness + // gates already count it as live. A daemon conditional close that + // read no hold here disposed the session and aborted the start + // under the client that just asked for it. + mockWorkflowRunRegistry.listStartingRunIds.mockReturnValue([ + 'wf-starting', + ]); + mockWorkflowRunRegistry.list.mockReturnValue([ + { runId: 'wf-paused', status: 'paused' }, + ]); + createReportingSession(); + + expect(holdIds('workflow')).toEqual(['wf-starting']); + expect(session.isIdle()).toBe(false); + + // Registration takes over with the entry's own running hold; a + // failed or cancelled start drops the reservation. + mockWorkflowRunRegistry.listStartingRunIds.mockReturnValue([]); + expect(session.collectActiveWorkHolds()).toEqual([]); + expect(session.isIdle()).toBe(true); + session.dispose(); + }); + it('tracks shell status changes and retracts only its callback', () => { createReportingSession(); const statusChanged = @@ -1230,6 +1310,37 @@ describe('Session', () => { session.dispose(); }); + it('holds a queued workflow completion notification', async () => { + mockChat.sendMessageStream = vi + .fn() + .mockResolvedValue(createEmptyStream()); + createReportingSession(); + const releaseCloseGate = session.beginClose(); + const notify = + mockWorkflowRunRegistry.setCompletionCallback.mock.calls.at( + -1, + )?.[0] as ( + displayText: string, + modelText: string, + meta: { runId: string; status: 'completed' }, + ) => void; + + notify('Workflow completed.', '', { + runId: 'wf-queued', + status: 'completed', + }); + + expect(mockChat.sendMessageStream).not.toHaveBeenCalled(); + expect(holdIds('notification')).toEqual(['wf-queued']); + expect(session.isIdle()).toBe(false); + + releaseCloseGate(); + await vi.waitFor(() => + expect(session.collectActiveWorkHolds()).toEqual([]), + ); + session.dispose(); + }); + it('does not hold for a Monitor notification', async () => { let finishPersistence!: () => void; mockChatRecordingService.recordNotificationStrict.mockImplementationOnce( @@ -2650,6 +2761,1017 @@ describe('Session', () => { expect(textParts(notificationCall.message)).toContain(reminder); }); + it('delivers background workflow completions through the session queue', async () => { + mockChat.sendMessageStream = vi + .fn() + .mockImplementation(async () => createEmptyStream()); + const callback = mockWorkflowRunRegistry.setCompletionCallback.mock + .calls[0][0] as ( + displayText: string, + modelText: string, + meta: { + runId: string; + status: 'completed' | 'failed'; + todoWorkChainId?: string; + }, + ) => void; + + callback('Workflow completed.', '', { + runId: 'wf_1234abcd', + status: 'completed', + }); + + await vi.waitFor(() => + expect(mockChatRecordingService.recordNotification).toHaveBeenCalledWith( + [{ text: '' }], + 'Workflow completed.', + expect.objectContaining({ + taskId: 'wf_1234abcd', + status: 'completed', + kind: 'workflow', + }), + ), + ); + }); + + it('adds terminal workflow status changes to the session history cache', () => { + const callback = mockWorkflowRunRegistry.setStatusChangeCallback.mock + .calls[0][0] as (entry: core.WorkflowTask) => void; + callback({ + id: 'wf_saved', + kind: 'workflow', + runId: 'wf_saved', + description: 'Review and fix', + meta: { name: 'review-and-fix', description: 'Review and fix' }, + status: 'completed', + startTime: 1_000, + endTime: 2_000, + outputFile: '', + outputOffset: 0, + notified: true, + abortController: new AbortController(), + isBackgrounded: true, + currentPhase: null, + phases: ['Inspect'], + phaseVisits: [], + currentPhaseVisitId: null, + dispatches: [], + agentsDispatched: 1, + agentsCompleted: 1, + recentLogs: [], + events: [], + tokensSpent: 500, + tokenBudgetTotal: 2_000, + perPhaseTokens: new Map(), + pendingApprovals: [], + script: 'return 1;', + }); + + expect(session.getWorkflowHistory()).toEqual([ + expect.objectContaining({ + runId: 'wf_saved', + description: 'Review and fix', + status: 'completed', + }), + ]); + }); + + it('keeps cached terminal workflow history when its disk write is missing', async () => { + const callback = mockWorkflowRunRegistry.setStatusChangeCallback.mock + .calls[0][0] as (entry: core.WorkflowTask) => void; + callback({ + id: 'wf-unpersisted', + kind: 'workflow', + runId: 'wf-unpersisted', + description: 'Unpersisted run', + meta: null, + status: 'completed', + startTime: 1_000, + endTime: 2_000, + outputFile: '', + outputOffset: 0, + notified: true, + abortController: new AbortController(), + isBackgrounded: true, + currentPhase: null, + phases: [], + phaseVisits: [], + currentPhaseVisitId: null, + dispatches: [], + agentsDispatched: 0, + agentsCompleted: 0, + recentLogs: [], + events: [], + tokensSpent: 0, + tokenBudgetTotal: null, + perPhaseTokens: new Map(), + pendingApprovals: [], + script: 'return 1;', + }); + listWorkflowSnapshotsSpy.mockResolvedValueOnce([]); + + await session.refreshWorkflowHistory(); + + expect(session.getWorkflowHistory()).toEqual([ + expect.objectContaining({ runId: 'wf-unpersisted' }), + ]); + }); + + it('does not republish a run deleted while a refresh was still reading the disk', async () => { + // Refresh reads the directory and then merges without a claim, while + // deletion holds one: a delete that lands between the read and the + // merge was overwritten by the stale listing, and the run came back + // until the next refresh. + session.dispose(); + const snapshot = { + runId: 'wf_stale', + meta: null, + status: 'failed' as const, + script: 'return 1;', + phases: [], + agentsDispatched: 0, + agentsCompleted: 0, + tokensSpent: 0, + tokenBudgetTotal: null, + perPhaseTokens: [], + recentLogs: [], + startTime: 1_000, + endTime: 2_000, + }; + session = new Session( + 'racing-session', + mockConfig, + mockClient, + mockSettings, + undefined, + undefined, + [snapshot], + ); + let finishStaleRead!: (snapshots: Array) => void; + listWorkflowSnapshotsSpy.mockImplementationOnce( + () => + new Promise>((resolve) => { + finishStaleRead = resolve; + }), + ); + const staleRefresh = session.refreshWorkflowHistory(); + await vi.waitFor(() => expect(finishStaleRead).toBeDefined()); + + // The delete's own refresh sees the file, then removes it. + listWorkflowSnapshotsSpy.mockResolvedValueOnce([snapshot]); + await expect(session.deleteWorkflowHistory(snapshot.runId)).resolves.toBe( + true, + ); + expect(session.getWorkflowHistory()).toEqual([]); + + // The read that began before the delete now completes with the + // pre-delete listing. + finishStaleRead([snapshot]); + await staleRefresh; + + expect(session.getWorkflowHistory()).toEqual([]); + + // A later refresh that genuinely finds the run again (a retry reuses + // the runId) must not be suppressed by the old deletion. + listWorkflowSnapshotsSpy.mockResolvedValueOnce([snapshot]); + await session.refreshWorkflowHistory(); + expect(session.getWorkflowHistory()).toEqual([ + expect.objectContaining({ runId: 'wf_stale' }), + ]); + }); + + it('does not republish a run a sibling session deleted while this refresh was still reading the disk', async () => { + // The deletion-sequence marker is per-Session, but the store and the + // delete entrance are process-wide: session A's delete landing while + // session B's refresh had already read the directory left nothing in + // B to filter the stale listing, and B republished the run A's + // client was just told was gone. The delete handler now marks the + // deletion in every sibling, under its claim. + session.dispose(); + const snapshot = { + runId: 'wf_cross', + meta: null, + status: 'failed' as const, + script: 'return 1;', + phases: [], + agentsDispatched: 0, + agentsCompleted: 0, + tokensSpent: 0, + tokenBudgetTotal: null, + perPhaseTokens: [], + recentLogs: [], + startTime: 1_000, + endTime: 2_000, + }; + const deleting = new Session( + 'deleting-session', + mockConfig, + mockClient, + mockSettings, + undefined, + undefined, + [snapshot], + ); + session = new Session( + 'observing-session', + mockConfig, + mockClient, + mockSettings, + undefined, + undefined, + [snapshot], + ); + try { + let finishStaleRead!: (snapshots: Array) => void; + listWorkflowSnapshotsSpy.mockImplementationOnce( + () => + new Promise>((resolve) => { + finishStaleRead = resolve; + }), + ); + const staleRefresh = session.refreshWorkflowHistory(); + await vi.waitFor(() => expect(finishStaleRead).toBeDefined()); + + // The deleting session's own refresh sees the file, then removes + // it, and the handler propagates the deletion to the observer. + listWorkflowSnapshotsSpy.mockResolvedValueOnce([snapshot]); + await expect( + deleting.deleteWorkflowHistory(snapshot.runId), + ).resolves.toBe(true); + session.noteExternalWorkflowDeletion(snapshot.runId); + expect(session.getWorkflowHistory()).toEqual([]); + + // The observer's read that began before the delete now completes + // with the pre-delete listing. + finishStaleRead([snapshot]); + await staleRefresh; + + expect(session.getWorkflowHistory()).toEqual([]); + + // A later refresh that genuinely finds the run again (a retry + // reuses the runId) is not suppressed by the old deletion. + listWorkflowSnapshotsSpy.mockResolvedValueOnce([snapshot]); + await session.refreshWorkflowHistory(); + expect(session.getWorkflowHistory()).toEqual([ + expect.objectContaining({ runId: 'wf_cross' }), + ]); + } finally { + deleting.dispose(); + } + }); + + it('drops persisted workflow history deleted by another session', async () => { + session.dispose(); + const snapshot = { + runId: 'wf_abcd', + meta: null, + status: 'failed' as const, + script: 'return 1;', + phases: [], + agentsDispatched: 0, + agentsCompleted: 0, + tokensSpent: 0, + tokenBudgetTotal: null, + perPhaseTokens: [], + recentLogs: [], + startTime: 1_000, + endTime: 2_000, + }; + const deletingSession = new Session( + 'deleting-session', + mockConfig, + mockClient, + mockSettings, + undefined, + undefined, + [snapshot], + ); + session = new Session( + 'observing-session', + mockConfig, + mockClient, + mockSettings, + undefined, + undefined, + [snapshot], + ); + listWorkflowSnapshotsSpy.mockResolvedValueOnce([snapshot]); + + await expect( + deletingSession.deleteWorkflowHistory(snapshot.runId), + ).resolves.toBe(true); + listWorkflowSnapshotsSpy.mockResolvedValueOnce([]); + + await session.refreshWorkflowHistory(); + + expect(session.getWorkflowHistory()).toEqual([]); + }); + + it('lets a newer persisted snapshot win over a stale cached one', async () => { + const callback = mockWorkflowRunRegistry.setStatusChangeCallback.mock + .calls[0][0] as (entry: core.WorkflowTask) => void; + callback({ + id: 'wf_reused', + kind: 'workflow', + runId: 'wf_reused', + description: 'Stale cached run', + meta: null, + status: 'failed', + startTime: 1_000, + endTime: 2_000, + error: 'old error', + outputFile: '', + outputOffset: 0, + notified: true, + abortController: new AbortController(), + isBackgrounded: true, + currentPhase: null, + phases: [], + phaseVisits: [], + currentPhaseVisitId: null, + dispatches: [], + agentsDispatched: 0, + agentsCompleted: 0, + recentLogs: [], + events: [], + tokensSpent: 0, + tokenBudgetTotal: null, + perPhaseTokens: new Map(), + pendingApprovals: [], + script: 'return 1;', + }); + listWorkflowSnapshotsSpy.mockResolvedValueOnce([ + { + runId: 'wf_reused', + meta: null, + status: 'completed' as const, + script: 'return 1;', + phases: [], + agentsDispatched: 0, + agentsCompleted: 0, + tokensSpent: 0, + tokenBudgetTotal: null, + perPhaseTokens: [], + recentLogs: [], + startTime: 3_000, + endTime: 4_000, + }, + ]); + + await session.refreshWorkflowHistory(); + + // The persisted copy is the newer authoritative projection of the + // reused runId; the stale callback cache must not shadow it. + expect(session.getWorkflowHistory()).toEqual([ + expect.objectContaining({ + runId: 'wf_reused', + status: 'completed', + startTime: 3_000, + endTime: 4_000, + }), + ]); + }); + + it('does not resurrect a sibling-deleted run cached through the status callback', async () => { + session.dispose(); + const snapshot = { + runId: 'wf_abcd', + meta: null, + status: 'failed' as const, + script: 'return 1;', + phases: [], + agentsDispatched: 0, + agentsCompleted: 0, + tokensSpent: 0, + tokenBudgetTotal: null, + perPhaseTokens: [], + recentLogs: [], + startTime: 1_000, + endTime: 2_000, + }; + const deletingSession = new Session( + 'deleting-session', + mockConfig, + mockClient, + mockSettings, + undefined, + undefined, + [], + ); + const observingSession = new Session( + 'observing-session', + mockConfig, + mockClient, + mockSettings, + undefined, + undefined, + [], + ); + // The observing session caches the terminal run through the + // status-change callback, exactly as it lands before the runner's + // snapshot write. + const callback = + mockWorkflowRunRegistry.setStatusChangeCallback.mock.calls.at( + -1, + )?.[0] as (entry: core.WorkflowTask) => void; + callback({ + id: 'wf_abcd', + kind: 'workflow', + runId: 'wf_abcd', + description: 'Callback-cached run', + meta: null, + status: 'failed', + startTime: 1_000, + endTime: 2_000, + outputFile: '', + outputOffset: 0, + notified: true, + abortController: new AbortController(), + isBackgrounded: true, + currentPhase: null, + phases: [], + phaseVisits: [], + currentPhaseVisitId: null, + dispatches: [], + agentsDispatched: 0, + agentsCompleted: 0, + recentLogs: [], + events: [], + tokensSpent: 0, + tokenBudgetTotal: null, + perPhaseTokens: new Map(), + pendingApprovals: [], + script: 'return 1;', + }); + expect(observingSession.getWorkflowHistory()).toEqual([ + expect.objectContaining({ runId: 'wf_abcd' }), + ]); + // The runner then persists the snapshot; the registry notification + // retires the observing session's unpersisted cache entry. + const snapshotPersisted = + mockWorkflowRunRegistry.setSnapshotPersistedCallback.mock.calls.at( + -1, + )?.[0] as ((runId: string) => void) | undefined; + snapshotPersisted?.('wf_abcd'); + + listWorkflowSnapshotsSpy.mockResolvedValueOnce([snapshot]); + await expect( + deletingSession.deleteWorkflowHistory(snapshot.runId), + ).resolves.toBe(true); + listWorkflowSnapshotsSpy.mockResolvedValueOnce([]); + + await observingSession.refreshWorkflowHistory(); + + expect(observingSession.getWorkflowHistory()).toEqual([]); + }); + + it('keeps a sibling-deleted run buried when a late status emission lands after persistence', async () => { + // R7-5: the existing non-resurrection test fires the status callback + // only BEFORE snapshotPersisted, so it cannot see the real ordering. + // The registry's dispatch-drain callbacks emit on TERMINAL entries + // with no status gate, and in-flight dispatches keep draining across + // the snapshot write — so a terminal emission routinely lands AFTER + // retirement, re-inserting the run as "never persisted". A sibling's + // deletion was then undone by the next refresh: absent on disk but + // present in the stale cache reads as a pending write. Retirement has + // to be a latch. + session.dispose(); + const snapshot = { + runId: 'wf_abcd', + meta: null, + status: 'failed' as const, + script: 'return 1;', + phases: [], + agentsDispatched: 0, + agentsCompleted: 0, + tokensSpent: 0, + tokenBudgetTotal: null, + perPhaseTokens: [], + recentLogs: [], + startTime: 1_000, + endTime: 2_000, + }; + const deletingSession = new Session( + 'deleting-session', + mockConfig, + mockClient, + mockSettings, + undefined, + undefined, + [], + ); + const observingSession = new Session( + 'observing-session', + mockConfig, + mockClient, + mockSettings, + undefined, + undefined, + [], + ); + const terminalEntry = { + id: 'wf_abcd', + kind: 'workflow' as const, + runId: 'wf_abcd', + description: 'Callback-cached run', + meta: null, + status: 'failed' as const, + startTime: 1_000, + endTime: 2_000, + outputFile: '', + outputOffset: 0, + notified: true, + abortController: new AbortController(), + isBackgrounded: true, + currentPhase: null, + phases: [], + phaseVisits: [], + currentPhaseVisitId: null, + dispatches: [], + agentsDispatched: 0, + agentsCompleted: 0, + recentLogs: [], + events: [], + tokensSpent: 0, + tokenBudgetTotal: null, + perPhaseTokens: new Map(), + pendingApprovals: [], + script: 'return 1;', + }; + const callback = + mockWorkflowRunRegistry.setStatusChangeCallback.mock.calls.at( + -1, + )?.[0] as (entry: core.WorkflowTask) => void; + const snapshotPersisted = + mockWorkflowRunRegistry.setSnapshotPersistedCallback.mock.calls.at( + -1, + )?.[0] as ((runId: string) => void) | undefined; + + callback(terminalEntry); + snapshotPersisted?.('wf_abcd'); + // A draining dispatch emits once more on the already-terminal entry, + // after retirement. This is the ordering the bug lived in. + callback(terminalEntry); + + listWorkflowSnapshotsSpy.mockResolvedValueOnce([snapshot]); + await expect( + deletingSession.deleteWorkflowHistory(snapshot.runId), + ).resolves.toBe(true); + listWorkflowSnapshotsSpy.mockResolvedValueOnce([]); + + await observingSession.refreshWorkflowHistory(); + expect(observingSession.getWorkflowHistory()).toEqual([]); + }); + + it('re-remembers a run whose id is registered again after persistence', async () => { + // The latch must not be permanent: a retry reuses the runId, so once + // the entry goes active again its next settlement has to be cached + // like any other, or a genuine re-run would vanish from the session's + // projection until its own snapshot write lands. + session.dispose(); + const observingSession = new Session( + 'observing-session', + mockConfig, + mockClient, + mockSettings, + undefined, + undefined, + [], + ); + const base = { + id: 'wf_relive', + kind: 'workflow' as const, + runId: 'wf_relive', + description: 'Re-run', + meta: null, + startTime: 1_000, + outputFile: '', + outputOffset: 0, + notified: true, + abortController: new AbortController(), + isBackgrounded: true, + currentPhase: null, + phases: [], + phaseVisits: [], + currentPhaseVisitId: null, + dispatches: [], + agentsDispatched: 0, + agentsCompleted: 0, + recentLogs: [], + events: [], + tokensSpent: 0, + tokenBudgetTotal: null, + perPhaseTokens: new Map(), + pendingApprovals: [], + script: 'return 1;', + }; + const callback = + mockWorkflowRunRegistry.setStatusChangeCallback.mock.calls.at( + -1, + )?.[0] as (entry: core.WorkflowTask) => void; + const snapshotPersisted = + mockWorkflowRunRegistry.setSnapshotPersistedCallback.mock.calls.at( + -1, + )?.[0] as ((runId: string) => void) | undefined; + + callback({ + ...base, + status: 'failed', + endTime: 2_000, + } as core.WorkflowTask); + snapshotPersisted?.('wf_relive'); + // The retry re-registers the same runId and runs. + callback({ + ...base, + status: 'running', + endTime: undefined, + } as core.WorkflowTask); + // Its own settlement must be cached again. + callback({ + ...base, + status: 'completed', + endTime: 3_000, + } as core.WorkflowTask); + + expect(observingSession.getWorkflowHistory()).toEqual([ + expect.objectContaining({ runId: 'wf_relive', status: 'completed' }), + ]); + }); + + it('deletes a run that fell out of the capped history window', async () => { + // R7-4: `buildSessionTasksStatus` serializes every registry entry + // unconditionally, but deletion gated on membership in the + // MAX_RETAINED_SNAPSHOTS window, which `refreshWorkflowHistory` + // truncates by startTime. A long run that settles after ~30 newer + // ones started stayed listed via the registry yet fell out of the + // window — terminal, handle-free, live in no sibling, and permanently + // undeletable. Membership must be tested against the uncapped set. + session.dispose(); + const target = { + runId: 'wf_oldest', + meta: null, + status: 'failed' as const, + script: 'return 1;', + phases: [], + agentsDispatched: 0, + agentsCompleted: 0, + tokensSpent: 0, + tokenBudgetTotal: null, + perPhaseTokens: [], + recentLogs: [], + startTime: 1_000, + endTime: 2_000, + }; + // 30 strictly newer snapshots fill the window ahead of the target. + const newer = Array.from( + { length: core.MAX_RETAINED_SNAPSHOTS }, + (_, i) => ({ + ...target, + runId: `wf_newer${i}`, + startTime: 10_000 + i, + endTime: 20_000 + i, + }), + ); + const deletingSession = new Session( + 'deleting-session', + mockConfig, + mockClient, + mockSettings, + undefined, + undefined, + [], + ); + mockWorkflowRunRegistry.get.mockReturnValue({ + runId: target.runId, + status: 'failed', + }); + mockWorkflowRunRegistry.removeTerminal.mockReturnValueOnce(true); + listWorkflowSnapshotsSpy.mockResolvedValueOnce([...newer, target]); + + await expect( + deletingSession.deleteWorkflowHistory(target.runId), + ).resolves.toBe(true); + expect(deleteWorkflowSnapshotSpy).toHaveBeenCalledWith( + mockConfig, + target.runId, + ); + expect(mockWorkflowRunRegistry.removeTerminal).toHaveBeenCalledWith( + target.runId, + ); + }); + + it('rejects history deletion while a sibling session still owns the run', async () => { + session.dispose(); + const snapshot = { + runId: 'wf_deadbeef', + meta: null, + status: 'failed' as const, + script: 'return 1;', + phases: [], + agentsDispatched: 0, + agentsCompleted: 0, + tokensSpent: 0, + tokenBudgetTotal: null, + perPhaseTokens: [], + recentLogs: [], + startTime: 1_000, + endTime: 2_000, + }; + const siblingRegistry = { + get: vi.fn().mockReturnValue({ runId: 'wf_deadbeef', status: 'running' }), + getHandle: vi.fn().mockReturnValue(undefined), + }; + const isWorkflowRunLiveInSiblingSession = (runId: string): boolean => { + const entry = siblingRegistry.get(runId) as + | { status: core.WorkflowStatus } + | undefined; + if (entry && !core.isTerminalWorkflowStatus(entry.status)) return true; + return siblingRegistry.getHandle(runId) !== undefined; + }; + listWorkflowSnapshotsSpy.mockResolvedValue([snapshot]); + session = new Session( + 'test-session-id', + mockConfig, + mockClient, + mockSettings, + undefined, + undefined, + [snapshot], + isWorkflowRunLiveInSiblingSession, + ); + + await expect(session.deleteWorkflowHistory(snapshot.runId)).resolves.toBe( + false, + ); + expect(deleteWorkflowSnapshotSpy).not.toHaveBeenCalled(); + + // Once the sibling run settles terminal, deletion proceeds. + siblingRegistry.get.mockReturnValue({ + runId: 'wf_deadbeef', + status: 'failed', + }); + await expect(session.deleteWorkflowHistory(snapshot.runId)).resolves.toBe( + true, + ); + expect(deleteWorkflowSnapshotSpy).toHaveBeenCalledWith( + mockConfig, + snapshot.runId, + ); + }); + + it('does not report a deletion whose registry entry could not be retired', async () => { + // `removeTerminal` refuses a live or handle-held entry. Ignoring its + // answer reported success for a run that was still registered here, + // whose settlement then re-persisted the "deleted" history. + session.dispose(); + const snapshot = { + runId: 'wf_stuck', + meta: null, + status: 'failed' as const, + script: 'return 1;', + phases: [], + agentsDispatched: 0, + agentsCompleted: 0, + tokensSpent: 0, + tokenBudgetTotal: null, + perPhaseTokens: [], + recentLogs: [], + startTime: 1_000, + endTime: 2_000, + }; + session = new Session( + 'test-session-id', + mockConfig, + mockClient, + mockSettings, + undefined, + undefined, + [snapshot], + ); + listWorkflowSnapshotsSpy.mockResolvedValue([snapshot]); + mockWorkflowRunRegistry.get.mockReturnValue({ + runId: snapshot.runId, + status: 'failed', + }); + mockWorkflowRunRegistry.removeTerminal.mockReturnValueOnce(false); + + await expect(session.deleteWorkflowHistory(snapshot.runId)).resolves.toBe( + false, + ); + expect(deleteWorkflowSnapshotSpy).not.toHaveBeenCalled(); + expect(session.getWorkflowHistory()).toHaveLength(1); + + mockWorkflowRunRegistry.removeTerminal.mockReturnValueOnce(true); + await expect(session.deleteWorkflowHistory(snapshot.runId)).resolves.toBe( + true, + ); + expect(deleteWorkflowSnapshotSpy).toHaveBeenCalledWith( + mockConfig, + snapshot.runId, + ); + }); + + it('keeps cached history on disk failure and removes it after deletion', async () => { + session.dispose(); + const onActiveWorkChanged = vi.fn(); + session = new Session( + 'test-session-id', + mockConfig, + mockClient, + mockSettings, + undefined, + onActiveWorkChanged, + [ + { + runId: 'wf_abcd', + meta: { name: 'review-and-fix', description: 'Review and fix' }, + status: 'failed', + script: 'return 1;', + phases: ['Inspect'], + agentsDispatched: 1, + agentsCompleted: 0, + tokensSpent: 500, + tokenBudgetTotal: 2_000, + perPhaseTokens: [], + recentLogs: [], + startTime: 1_000, + endTime: 2_000, + }, + ], + ); + listWorkflowSnapshotsSpy.mockResolvedValue(session.getWorkflowHistory()); + + deleteWorkflowSnapshotSpy.mockResolvedValueOnce(false); + await expect(session.deleteWorkflowHistory('wf_abcd')).resolves.toBe(false); + expect(session.getWorkflowHistory()).toHaveLength(1); + expect(onActiveWorkChanged).not.toHaveBeenCalled(); + + await expect(session.deleteWorkflowHistory('wf_abcd')).resolves.toBe(true); + + expect(deleteWorkflowSnapshotSpy).toHaveBeenCalledWith( + mockConfig, + 'wf_abcd', + ); + expect(deleteWorkflowSnapshotSpy).toHaveBeenCalledTimes(2); + expect(session.getWorkflowHistory()).toEqual([]); + expect(onActiveWorkChanged).toHaveBeenCalledOnce(); + }); + + it('keeps history unchanged when the requested saved run is unknown', async () => { + await expect(session.deleteWorkflowHistory('wf_missing')).resolves.toBe( + false, + ); + + expect(deleteWorkflowSnapshotSpy).not.toHaveBeenCalled(); + }); + + it('waits for an active run owner to finish persistence before deletion', async () => { + session.dispose(); + const snapshot = { + runId: 'wf_pending', + meta: null, + status: 'completed' as const, + script: 'return 1;', + phases: [], + agentsDispatched: 0, + agentsCompleted: 0, + tokensSpent: 0, + tokenBudgetTotal: null, + perPhaseTokens: [], + recentLogs: [], + startTime: 1_000, + endTime: 2_000, + }; + let finishPersistence: (() => void) | undefined; + const completion = new Promise((resolve) => { + finishPersistence = resolve; + }); + mockWorkflowRunRegistry.getHandle.mockReturnValue({ completion }); + listWorkflowSnapshotsSpy.mockResolvedValue([snapshot]); + session = new Session( + 'test-session-id', + mockConfig, + mockClient, + mockSettings, + undefined, + undefined, + [snapshot], + ); + + const deletion = session.deleteWorkflowHistory(snapshot.runId); + await Promise.resolve(); + expect(deleteWorkflowSnapshotSpy).not.toHaveBeenCalled(); + + finishPersistence?.(); + await expect(deletion).resolves.toBe(true); + expect(deleteWorkflowSnapshotSpy).toHaveBeenCalledWith( + mockConfig, + snapshot.runId, + ); + }); + + it('stops deletion when a retry activates the run during refresh', async () => { + const snapshot = { + runId: 'wf_abcd', + meta: null, + status: 'failed' as const, + script: 'return 1;', + phases: [], + agentsDispatched: 0, + agentsCompleted: 0, + tokensSpent: 0, + tokenBudgetTotal: null, + perPhaseTokens: [], + recentLogs: [], + startTime: 1_000, + endTime: 2_000, + }; + let finishRefresh!: () => void; + listWorkflowSnapshotsSpy.mockImplementationOnce( + () => + new Promise((resolve) => { + finishRefresh = () => resolve([snapshot]); + }), + ); + mockWorkflowRunRegistry.get.mockReturnValue({ status: 'failed' }); + + const deletion = session.deleteWorkflowHistory(snapshot.runId); + await vi.waitFor(() => + expect(listWorkflowSnapshotsSpy).toHaveBeenCalledOnce(), + ); + mockWorkflowRunRegistry.get.mockReturnValue({ status: 'running' }); + finishRefresh(); + + await expect(deletion).resolves.toBe(false); + expect(deleteWorkflowSnapshotSpy).not.toHaveBeenCalled(); + }); + + it('keeps deletion atomic with a direct workflow resume', async () => { + const snapshot = { + runId: 'wf_atomic', + meta: null, + status: 'failed' as const, + script: 'return 1;', + phases: [], + agentsDispatched: 0, + agentsCompleted: 0, + tokensSpent: 0, + tokenBudgetTotal: null, + perPhaseTokens: [], + recentLogs: [], + startTime: 1_000, + endTime: 2_000, + }; + let finishDeletion: ((deleted: boolean) => void) | undefined; + deleteWorkflowSnapshotSpy.mockImplementationOnce( + () => + new Promise((resolve) => { + finishDeletion = resolve; + }), + ); + listWorkflowSnapshotsSpy.mockResolvedValueOnce([snapshot]); + mockWorkflowRunRegistry.get.mockReturnValue({ status: 'failed' }); + mockWorkflowRunRegistry.removeTerminal.mockReturnValueOnce(true); + + const deletion = session.deleteWorkflowHistory(snapshot.runId); + await vi.waitFor(() => + expect(deleteWorkflowSnapshotSpy).toHaveBeenCalledOnce(), + ); + + const resumeAttempt = await core.tryWithWorkflowTaskMutation( + core.getWorkflowTaskMutationKey(mockConfig, snapshot.runId), + async () => true, + ); + expect(resumeAttempt).toEqual({ acquired: false }); + + finishDeletion?.(true); + await expect(deletion).resolves.toBe(true); + }); + + it('rejects history deletion while the workflow is starting', async () => { + mockWorkflowRunRegistry.get.mockReturnValue({ status: 'failed' }); + mockWorkflowRunRegistry.isStarting.mockReturnValue(true); + + await expect(session.deleteWorkflowHistory('wf_starting')).resolves.toBe( + false, + ); + + expect(listWorkflowSnapshotsSpy).not.toHaveBeenCalled(); + expect(deleteWorkflowSnapshotSpy).not.toHaveBeenCalled(); + }); + + it('rejects history deletion while the workflow is active', async () => { + mockWorkflowRunRegistry.get.mockReturnValue({ status: 'paused' }); + + await expect(session.deleteWorkflowHistory('wf_active')).resolves.toBe( + false, + ); + + expect(mockWorkflowRunRegistry.getHandle).not.toHaveBeenCalled(); + expect(listWorkflowSnapshotsSpy).not.toHaveBeenCalled(); + expect(deleteWorkflowSnapshotSpy).not.toHaveBeenCalled(); + }); + it('does not infer Todo ownership from Todo Stop Guard lineage', async () => { mockChat.sendMessageStream = vi .fn() @@ -31257,6 +32379,26 @@ describe('Session', () => { expect( mockBackgroundShellRegistry.setNotificationCallback, ).toHaveBeenLastCalledWith(undefined); + expect( + mockWorkflowRunRegistry.setCompletionCallback, + ).toHaveBeenLastCalledWith(undefined); + expect( + mockWorkflowRunRegistry.clearStatusChangeCallback, + ).toHaveBeenCalledWith(expect.any(Function)); + // R7-10: mirror the agent registry. A workflow run that outlives + // its session's removal is invisible to the delete-history liveness + // gate, and its settlement snapshot write recreates history a + // sibling session just deleted. Abort must precede the callback + // teardown so the cancellation still reaches this session's own + // bookkeeping. + expect(mockWorkflowRunRegistry.abortAll).toHaveBeenCalled(); + expect( + mockWorkflowRunRegistry.abortAll.mock.invocationCallOrder.at(-1), + ).toBeLessThan( + mockWorkflowRunRegistry.setCompletionCallback.mock.invocationCallOrder.at( + -1, + )!, + ); }); it('aborts an active notificationAbortController and nulls the reference', () => { @@ -35079,6 +36221,77 @@ describe('Session', () => { internals.notificationProcessing = false; }); + it('classifies workflow notifications from the captured baseline', () => { + const baselineWorkflow = { + runId: 'baseline-workflow', + status: 'running', + }; + let currentWorkflow: typeof baselineWorkflow | undefined = + baselineWorkflow; + mockWorkflowRunRegistry.list.mockImplementation(() => + currentWorkflow ? [currentWorkflow] : [], + ); + mockWorkflowRunRegistry.get.mockImplementation((runId: string) => + currentWorkflow?.runId === runId ? currentWorkflow : undefined, + ); + rebuildSessionWithGuard(); + const internals = session as unknown as { + notificationProcessing: boolean; + notificationQueue: Array<{ + taskId: string; + continuesTodoStopGuardWorkChain: boolean; + }>; + }; + internals.notificationProcessing = true; + const callback = + mockWorkflowRunRegistry.setCompletionCallback.mock.calls.at( + -1, + )?.[0] as ( + displayText: string, + modelText: string, + meta: { + runId: string; + status: 'completed'; + todoWorkChainId?: string; + }, + ) => void; + + callback('baseline result', '', { + runId: 'baseline-workflow', + status: 'completed', + todoWorkChainId: 'stale-chain', + }); + currentWorkflow = { + runId: 'baseline-workflow', + status: 'running', + }; + callback('retry result', '', { + runId: 'baseline-workflow', + status: 'completed', + }); + currentWorkflow = undefined; + callback('new result', '', { + runId: 'new-workflow', + status: 'completed', + }); + + expect(internals.notificationQueue).toEqual([ + expect.objectContaining({ + taskId: 'baseline-workflow', + continuesTodoStopGuardWorkChain: false, + }), + expect.objectContaining({ + taskId: 'baseline-workflow', + continuesTodoStopGuardWorkChain: true, + }), + expect.objectContaining({ + taskId: 'new-workflow', + continuesTodoStopGuardWorkChain: true, + }), + ]); + internals.notificationProcessing = false; + }); + it('commits and rolls back existing-agent lineage around send_message execution', async () => { mockBackgroundTaskRegistry.getAll.mockReturnValue([ { diff --git a/packages/cli/src/acp-integration/session/Session.ts b/packages/cli/src/acp-integration/session/Session.ts index 2e19ef3935a..2f106784b85 100644 --- a/packages/cli/src/acp-integration/session/Session.ts +++ b/packages/cli/src/acp-integration/session/Session.ts @@ -52,6 +52,8 @@ import type { ChatRecordingService, TurnResultRecordPayload, WorkflowApproval, + WorkflowSnapshot, + WorkflowTask, BranchPoint, } from '@qwen-code/qwen-code-core'; import { @@ -200,6 +202,13 @@ import { toolResultPartDiagnosticValues, getInvocationContext, runWithInvocationContext, + getWorkflowTaskMutationKey, + isTerminalWorkflowStatus, + tryWithWorkflowTaskMutation, + MAX_RETAINED_SNAPSHOTS, + toSnapshot, + deleteWorkflowSnapshot, + listWorkflowSnapshots, truncateNotificationLabel, buildBackgroundEntryLabel, collectSessionTurnState, @@ -524,6 +533,7 @@ type TodoStopGuardBackgroundBaseline = { agents: Set; shells: Set; monitors: Set; + workflows: Set; wakeups: Set; }; @@ -1385,7 +1395,7 @@ export interface BackgroundNotificationQueueItem { modelText: string; taskId: string; status: string; - kind: 'agent' | 'monitor' | 'shell'; + kind: 'agent' | 'monitor' | 'shell' | 'workflow'; toolUseId?: string; todoWorkChainId?: string; /** Structured fields for i18n rendering on the frontend. */ @@ -1967,13 +1977,14 @@ export class Session implements SessionContext { private notificationAbortController: AbortController | null = null; private notificationCompletion: Promise | null = null; private currentAgentNotificationTaskId: string | null = null; + private currentWorkflowNotificationTaskId: string | null = null; private currentShellNotificationActive = false; private readonly persistedBackgroundNotificationTaskIds = new Set(); private readonly backgroundNotificationAcceptances = new Map< string, Promise >(); - private readonly activeAgentNotificationAcceptances = new Set(); + private readonly activeNotificationAcceptances = new Set(); private readonly goalQueue: AcpGoalTurn[] = []; private goalProcessing = false; @@ -2001,6 +2012,36 @@ export class Session implements SessionContext { /** The exact status-change callback this Session installed, so dispose can * retract its own and nobody else's. */ #statusChangeCallback: (() => void) | undefined; + #workflowStatusChangeCallback: ((entry?: WorkflowTask) => void) | undefined; + private workflowHistory: WorkflowSnapshot[]; + /** + * R7-5: runIds whose snapshot write this session has observed. Latches + * `#rememberWorkflowHistory` off so a post-persistence status emission + * cannot resurrect a sibling-deleted run. See that method. + */ + private readonly persistedWorkflowRunIds = new Set(); + /** + * R7-4: every runId the last `refreshWorkflowHistory` merged, BEFORE the + * MAX_RETAINED_SNAPSHOTS cap. `workflowHistory` is the display window; + * this is what deletion tests membership against. + */ + private mergedWorkflowRunIds = new Set(); + private readonly unpersistedWorkflowHistory = new Map< + string, + WorkflowSnapshot + >(); + /** + * Deletion order, so a refresh can tell which runs were deleted AFTER + * its disk read began. `refreshWorkflowHistory` reads the directory + * and then merges without holding a claim, while deletion holds one — + * a delete that lands between the read and the merge would otherwise + * be overwritten by the stale listing and the run would reappear + * until the next refresh. Keyed by runId so a later re-run of the same + * id (a retry reuses it) is not suppressed: its sequence predates that + * refresh's mark. + */ + private workflowDeletionSeq = 0; + private readonly workflowDeletionSeqByRunId = new Map(); #shellStatusChangeCallback: (() => void) | undefined; private readonly workflowApprovalAbortController = new AbortController(); private activeTodoPlanRevision?: { @@ -2073,8 +2114,19 @@ export class Session implements SessionContext { * a full snapshot; the Session itself keeps no reporting state. */ private readonly onActiveWorkChanged?: () => void, + workflowHistory: readonly WorkflowSnapshot[] = [], + /** + * Reports whether another session in this process owns a live or + * still-settling registry entry for the run. Every session here shares + * one on-disk workflow store but keeps a private registry, so history + * deletion must consult all of them, not just this session's. + */ + private readonly isWorkflowRunLiveInSiblingSession: ( + runId: string, + ) => boolean = () => false, ) { this.sessionId = id; + this.workflowHistory = [...workflowHistory]; this.requiresManagedConversationBinding = isReservedStandaloneSessionSourceType( this.config.getSessionSourceType?.(), @@ -2928,6 +2980,7 @@ export class Session implements SessionContext { const agents = this.config.getBackgroundTaskRegistry?.()?.getAll?.() ?? []; const shells = this.config.getBackgroundShellRegistry?.()?.getAll?.() ?? []; const monitors = this.config.getMonitorRegistry?.()?.getAll?.() ?? []; + const workflows = this.config.getWorkflowRunRegistry?.()?.list?.() ?? []; const wakeups = this.config.isCronEnabled?.() ? (this.config.getCronScheduler?.()?.list?.() ?? []).filter( (job) => job.cronExpr === '@wakeup', @@ -2953,6 +3006,7 @@ export class Session implements SessionContext { .filter((item) => item.kind === 'monitor') .map((item) => item.taskId), ]), + workflows: new Set(workflows), wakeups: new Set([ ...wakeups.map((job) => job.id), ...this.cronQueue.flatMap((item) => @@ -3055,6 +3109,17 @@ export class Session implements SessionContext { return true; } + const workflows = this.config.getWorkflowRunRegistry?.()?.list?.() ?? []; + if ( + workflows.some( + (task) => + !baseline.workflows.has(task) && + !isTerminalWorkflowStatus(task.status), + ) + ) { + return true; + } + if (!this.config.isCronEnabled?.()) return false; const wakeups = this.config.getCronScheduler?.()?.list?.() ?? []; return wakeups.some( @@ -3370,6 +3435,177 @@ export class Session implements SessionContext { return this.config; } + getWorkflowHistory(): readonly WorkflowSnapshot[] { + return this.workflowHistory; + } + + async refreshWorkflowHistory(): Promise { + const deletionMark = this.workflowDeletionSeq; + const persisted = await listWorkflowSnapshots(this.config); + const byRunId = new Map( + persisted.map((snapshot) => [snapshot.runId, snapshot]), + ); + for (const [runId, seq] of this.workflowDeletionSeqByRunId) { + if (seq > deletionMark) byRunId.delete(runId); + } + for (const [runId, snapshot] of this.unpersistedWorkflowHistory) { + const stored = byRunId.get(runId); + if (stored === undefined) { + // Never persisted (write pending or failed): keep the cached + // projection visible. Once persistence is observed the entry is + // retired via the snapshot-persisted callback, so absence here + // afterwards means the run was deleted and must stay gone. + byRunId.set(runId, snapshot); + } else { + // A persisted copy is the newer authoritative projection: the + // runId settled (possibly re-run in another session), so a stale + // cache must not shadow it. + this.unpersistedWorkflowHistory.delete(runId); + } + } + // R7-4: the returned/stored history is the capped display window, but + // deletion must reason about the whole merged set — keep it before the + // slice rather than making callers re-derive it. + this.mergedWorkflowRunIds = new Set(byRunId.keys()); + this.workflowHistory = [...byRunId.values()] + .sort((a, b) => b.startTime - a.startTime) + .slice(0, MAX_RETAINED_SNAPSHOTS); + this.#pruneUnpersistedWorkflowHistory(); + return this.workflowHistory; + } + + async deleteWorkflowHistory(runId: string): Promise { + const attempt = await tryWithWorkflowTaskMutation( + getWorkflowTaskMutationKey(this.config, runId), + () => this.#deleteWorkflowHistoryClaimed(runId), + ); + return attempt.acquired ? attempt.value : false; + } + + async #deleteWorkflowHistoryClaimed(runId: string): Promise { + const registry = this.config.getWorkflowRunRegistry(); + const isDeletable = (): boolean => { + if (this.isWorkflowRunLiveInSiblingSession(runId)) return false; + if (registry.isStarting?.(runId)) return false; + const current = registry.get(runId); + return !current || isTerminalWorkflowStatus(current.status); + }; + if (!isDeletable()) return false; + const handle = registry.getHandle(runId); + if (handle) { + await handle.completion; + if (!isDeletable()) return false; + } + await this.refreshWorkflowHistory(); + if (!isDeletable()) return false; + // R7-4: membership must be tested against everything the client can + // SEE, not against the capped window. `buildSessionTasksStatus` + // serializes every registry entry unconditionally, while + // `refreshWorkflowHistory` truncates to MAX_RETAINED_SNAPSHOTS by + // startTime — so a long run that settles after ~30 newer ones started + // stays listed via the registry but falls out of the window, and the + // capped check answered `{changed: false}` forever. It was terminal, + // handle-free and live in no sibling: nothing but the window kept it + // undeletable. `deleteWorkflowSnapshot` already tolerates an absent + // target, so widening the gate cannot delete something that is not + // there. + if ( + !this.mergedWorkflowRunIds.has(runId) && + registry.get(runId) === undefined && + !this.unpersistedWorkflowHistory.has(runId) + ) { + return false; + } + // Retire the registry entry before touching the store. `removeTerminal` + // refuses a live or handle-held entry — the registry's own last word + // on whether the run is still active here — so `false` for an entry + // that exists means the run re-registered and must not be reported + // deleted; a persisted-only run has no entry to retire. + if (registry.get(runId) !== undefined && !registry.removeTerminal(runId)) { + return false; + } + if (!(await deleteWorkflowSnapshot(this.config, runId))) return false; + this.workflowDeletionSeqByRunId.set(runId, ++this.workflowDeletionSeq); + this.unpersistedWorkflowHistory.delete(runId); + this.mergedWorkflowRunIds.delete(runId); + this.persistedWorkflowRunIds.delete(runId); + this.workflowHistory = this.workflowHistory.filter( + (item) => item.runId !== runId, + ); + this.#activeWorkChanged(); + return true; + } + + /** + * A sibling session deleted `runId` from the shared store. The + * deletion-sequence marker is per-Session — it records deletions THIS + * session issued — while the store and the delete entrance are + * process-wide, so without this a refresh of ours that began reading + * the directory before the sibling's delete landed would merge the + * stale listing and republish the run the sibling's client was just + * told was gone. Called under the sibling's task-mutation claim, + * symmetric to the registry `removeTerminal` sweep. + * + * The R7-5 persisted latch is deliberately kept: a late terminal + * emission for the deleted run must still not re-insert it. + */ + noteExternalWorkflowDeletion(runId: string): void { + this.workflowDeletionSeqByRunId.set(runId, ++this.workflowDeletionSeq); + this.unpersistedWorkflowHistory.delete(runId); + this.mergedWorkflowRunIds.delete(runId); + const retained = this.workflowHistory.filter( + (item) => item.runId !== runId, + ); + if (retained.length === this.workflowHistory.length) return; + this.workflowHistory = retained; + this.#activeWorkChanged(); + } + + #rememberWorkflowHistory(entry: WorkflowTask): void { + if (!isTerminalWorkflowStatus(entry.status)) { + // Back in an active state means this runId was registered afresh + // (a retry/resume reuses it), so its next settlement must be + // remembered again — release the R7-5 latch here rather than + // wiring a second registry callback for it. + this.persistedWorkflowRunIds.delete(entry.runId); + return; + } + // R7-5: retirement is a latch, not a one-shot. The registry's + // dispatch-drain callbacks (onAgentCompleted / onBudgetUpdated / + // onDispatchSettled) emit status changes on TERMINAL entries with no + // status gate, and in-flight dispatches keep draining across the + // snapshot write — so a terminal emission routinely lands AFTER + // `notifySnapshotPersisted` retired the cache entry. Without this + // guard each late emission re-inserted the run as "never persisted", + // and a sibling session's deletion was then undone by the next + // refresh: absent on disk but present in the stale cache reads as a + // pending write, so the deleted run was republished and stayed for + // the life of the session. Cleared on re-registration + // (`#forgetPersistedWorkflowRun`) so a genuine re-run of the same + // runId is remembered again. + if (this.persistedWorkflowRunIds.has(entry.runId)) return; + const snapshot = toSnapshot(entry); + this.unpersistedWorkflowHistory.set(snapshot.runId, snapshot); + this.workflowHistory = [ + snapshot, + ...this.workflowHistory.filter((item) => item.runId !== entry.runId), + ] + .sort((a, b) => b.startTime - a.startTime) + .slice(0, MAX_RETAINED_SNAPSHOTS); + this.#pruneUnpersistedWorkflowHistory(); + } + + #pruneUnpersistedWorkflowHistory(): void { + const retainedRunIds = new Set( + this.workflowHistory.map((item) => item.runId), + ); + for (const runId of this.unpersistedWorkflowHistory.keys()) { + if (!retainedRunIds.has(runId)) { + this.unpersistedWorkflowHistory.delete(runId); + } + } + } + installPendingManagedConversationBinding( expectation: BridgeConversationDirectoryExpectation, assertIdentity: () => Promise, @@ -3611,14 +3847,19 @@ export class Session implements SessionContext { } const notificationIds = new Set(); for (const item of this.notificationQueue) { - if (item.kind === 'agent') notificationIds.add(item.taskId); + if (item.kind === 'agent' || item.kind === 'workflow') { + notificationIds.add(item.taskId); + } } - for (const taskId of this.activeAgentNotificationAcceptances) { + for (const taskId of this.activeNotificationAcceptances) { notificationIds.add(taskId); } if (this.currentAgentNotificationTaskId !== null) { notificationIds.add(this.currentAgentNotificationTaskId); } + if (this.currentWorkflowNotificationTaskId !== null) { + notificationIds.add(this.currentWorkflowNotificationTaskId); + } for (const taskId of notificationIds) { holds.push({ category: 'notification', id: taskId }); } @@ -3629,6 +3870,26 @@ export class Session implements SessionContext { if (shellActive) { holds.push({ category: 'shell', id: 'background-shells' }); } + const workflowRegistry = this.config.getWorkflowRunRegistry(); + // A reserved-but-unregistered run (script loading, journal replay) + // has no `list()` entry yet, but the registry's hasRunningEntries() + // and the delete/cancel liveness gates already count it as live. A + // daemon-initiated conditional close that read no hold here would + // dispose the session and abort the start under the client that just + // asked for it. The hold releases itself: registration takes over + // with the entry's running hold, and a failed or cancelled start + // drops the reservation via `releaseStart`. + for (const runId of workflowRegistry.listStartingRunIds?.() ?? []) { + holds.push({ category: 'workflow', id: runId }); + } + for (const task of workflowRegistry.list()) { + // Mirror the registry's hasRunningEntries(): a paused run executes + // nothing and no backstop would ever release the hold, so it must + // not pin the session the way executing work does. + if (task.status === 'running' || task.status === 'pausing') { + holds.push({ category: 'workflow', id: task.runId }); + } + } return holds; } @@ -3822,6 +4083,24 @@ export class Session implements SessionContext { shellRegistry.clearStatusChangeCallback(this.#shellStatusChangeCallback); this.#shellStatusChangeCallback = undefined; } + // R7-10: mirror the agent registry's treatment above. Without this a + // workflow outlives its session's removal — close/kill/shutdown use + // force semantics and a background run owns a detached controller — + // and an orphan that nothing can see keeps writing its snapshot, + // recreating history a sibling session just deleted. Abort BEFORE the + // callbacks are cleared so the cancellation still reaches this + // session's own bookkeeping. + this.config.getWorkflowRunRegistry().abortAll(); + this.config.getWorkflowRunRegistry().setCompletionCallback(undefined); + this.config + .getWorkflowRunRegistry() + .setSnapshotPersistedCallback(undefined); + if (this.#workflowStatusChangeCallback) { + this.config + .getWorkflowRunRegistry() + .clearStatusChangeCallback(this.#workflowStatusChangeCallback); + this.#workflowStatusChangeCallback = undefined; + } this.config.getChatRecordingService()?.setTitleRecordedCallback(undefined); this.unsubscribeChatRecordingFailure?.(); this.unsubscribeChatRecordingFailure = undefined; @@ -8678,6 +8957,36 @@ export class Session implements SessionContext { }); }); + const workflowRegistry = this.config.getWorkflowRunRegistry(); + this.#workflowStatusChangeCallback = (entry) => { + this.#activeWorkChanged(); + if (entry) this.#rememberWorkflowHistory(entry); + }; + workflowRegistry.setStatusChangeCallback( + this.#workflowStatusChangeCallback, + ); + workflowRegistry.setSnapshotPersistedCallback((runId) => { + // The run is safely on disk now; drop the unpersisted copy so a + // deletion by another session cannot resurrect it on refresh. The + // latch makes that retirement stick against the late terminal + // emissions draining dispatches still produce (R7-5). + this.persistedWorkflowRunIds.add(runId); + this.unpersistedWorkflowHistory.delete(runId); + }); + workflowRegistry.setCompletionCallback((displayText, modelText, meta) => { + const entry = workflowRegistry.get(meta.runId); + this.#enqueueBackgroundNotification({ + displayText, + modelText, + taskId: meta.runId, + status: meta.status, + kind: 'workflow', + continuesTodoStopGuardWorkChain: + !entry || !this.todoStopGuardBackgroundBaseline.workflows.has(entry), + todoWorkChainId: meta.todoWorkChainId, + }); + }); + // Session title recorded (auto-generated after a turn, or an in-process // /rename) → notify attached clients. A title update is NOT an ACP // `SessionUpdate` variant (the external @agentclientprotocol/sdk union @@ -8765,8 +9074,8 @@ export class Session implements SessionContext { const acceptance = this.#persistDaemonBackgroundNotification(item); this.backgroundNotificationAcceptances.set(item.taskId, acceptance); - if (item.kind === 'agent') { - this.activeAgentNotificationAcceptances.add(item.taskId); + if (item.kind === 'agent' || item.kind === 'workflow') { + this.activeNotificationAcceptances.add(item.taskId); this.#activeWorkChanged(); } try { @@ -8776,8 +9085,8 @@ export class Session implements SessionContext { this.backgroundNotificationAcceptances.get(item.taskId) === acceptance ) { this.backgroundNotificationAcceptances.delete(item.taskId); - if (item.kind === 'agent') { - this.activeAgentNotificationAcceptances.delete(item.taskId); + if (item.kind === 'agent' || item.kind === 'workflow') { + this.activeNotificationAcceptances.delete(item.taskId); this.#activeWorkChanged(); } } @@ -8901,6 +9210,8 @@ export class Session implements SessionContext { if (!item) break; this.currentAgentNotificationTaskId = item.kind === 'agent' ? item.taskId : null; + this.currentWorkflowNotificationTaskId = + item.kind === 'workflow' ? item.taskId : null; this.currentShellNotificationActive = item.kind === 'shell'; this.#activeWorkChanged(); try { @@ -8919,6 +9230,7 @@ export class Session implements SessionContext { ); } finally { this.currentAgentNotificationTaskId = null; + this.currentWorkflowNotificationTaskId = null; this.currentShellNotificationActive = false; this.#activeWorkChanged(); } diff --git a/packages/cli/src/acp-integration/session/Session.worktree.test.ts b/packages/cli/src/acp-integration/session/Session.worktree.test.ts index 9176cfda54c..4f55f5c9707 100644 --- a/packages/cli/src/acp-integration/session/Session.worktree.test.ts +++ b/packages/cli/src/acp-integration/session/Session.worktree.test.ts @@ -188,6 +188,13 @@ describe('Session.pendingWorktreeNotice', () => { clearStatusChangeCallback: vi.fn(), hasRunningEntries: vi.fn().mockReturnValue(false), }), + getWorkflowRunRegistry: vi.fn().mockReturnValue({ + setStatusChangeCallback: vi.fn(), + clearStatusChangeCallback: vi.fn(), + setCompletionCallback: vi.fn(), + setSnapshotPersistedCallback: vi.fn(), + setApprovalRequestCallback: vi.fn(), + }), setSubSessionSpawner: vi.fn(), getSubSessionSpawner: vi.fn(), // The Session constructor and Session.prompt both reach for the diff --git a/packages/cli/src/acp-integration/session/tasksSnapshot.test.ts b/packages/cli/src/acp-integration/session/tasksSnapshot.test.ts index 3d34261e020..afff78b1cf6 100644 --- a/packages/cli/src/acp-integration/session/tasksSnapshot.test.ts +++ b/packages/cli/src/acp-integration/session/tasksSnapshot.test.ts @@ -5,7 +5,13 @@ */ import { describe, expect, it } from 'vitest'; -import type { AgentTask, Config, MonitorTask } from '@qwen-code/qwen-code-core'; +import type { + AgentTask, + Config, + MonitorTask, + WorkflowSnapshot, + WorkflowTask, +} from '@qwen-code/qwen-code-core'; import { buildSessionTasksStatus } from './tasksSnapshot.js'; import type { ServeSessionAgentTaskStatus } from '@qwen-code/acp-bridge/status'; @@ -25,14 +31,57 @@ function agentTask(overrides: Partial = {}): AgentTask { } as AgentTask; } -function configWith(agents: AgentTask[]): Config { +function configWith( + agents: AgentTask[], + workflows: WorkflowTask[] = [], +): Config { return { getBackgroundTaskRegistry: () => ({ getAll: () => agents }), getBackgroundShellRegistry: () => ({ getAll: () => [] }), getMonitorRegistry: () => ({ getAll: () => [] }), + getWorkflowRunRegistry: () => ({ list: () => workflows }), } as unknown as Config; } +function workflowSnapshot( + overrides: Partial = {}, +): WorkflowSnapshot { + return { + runId: 'wf_saved', + meta: { name: 'review-and-fix', description: 'Review and fix' }, + status: 'failed', + script: 'return 1;', + phases: ['Inspect'], + phaseVisits: [ + { + id: 'phase-1', + index: 0, + title: 'Inspect', + startedAt: 500, + endedAt: 900, + }, + ], + dispatches: [], + agentsDispatched: 2, + agentsCompleted: 1, + tokensSpent: 900, + tokenBudgetTotal: 4_000, + perPhaseTokens: [], + recentLogs: [], + events: [ + { + id: 'event-1', + type: 'workflow-failed', + at: 1_000, + error: 'Review failed', + }, + ], + startTime: 500, + endTime: 1_000, + ...overrides, + }; +} + function serializedMonitor( monitor: MonitorTask, ): Extract< @@ -43,6 +92,7 @@ function serializedMonitor( getBackgroundTaskRegistry: () => ({ getAll: () => [] }), getBackgroundShellRegistry: () => ({ getAll: () => [] }), getMonitorRegistry: () => ({ getAll: () => [monitor] }), + getWorkflowRunRegistry: () => ({ list: () => [] }), } as unknown as Config; return buildSessionTasksStatus('session-1', config, 2_000).tasks.find( (task) => task.kind === 'monitor', @@ -125,3 +175,251 @@ describe('buildSessionTasksStatus monitor correlation', () => { expect(task.toolUseId).toBe('monitor-call-1'); }); }); + +describe('buildSessionTasksStatus workflow graph', () => { + it('omits workflow tasks unless the caller opts in', () => { + const snapshot = buildSessionTasksStatus( + 'session-1', + configWith([]), + 2_000, + [workflowSnapshot()], + ); + + expect(snapshot.tasks).toEqual([]); + }); + + it('exposes phase visits and dispatch dependencies from the workflow registry', () => { + 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' }, + status: 'running', + startTime: 1_000, + isBackgrounded: true, + currentPhase: 'Review', + phases: ['Inspect', '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 }, + ], + currentPhaseVisitId: 'phase-2', + dispatches: [ + { + id: 'dispatch-1', + phaseVisitId: 'phase-1', + label: 'Scope mapper', + prompt: 'Inspect the repository', + status: 'completed', + dependsOn: [], + queuedAt: 1_010, + startedAt: 1_020, + endedAt: 1_100, + }, + { + id: 'dispatch-2', + phaseVisitId: 'phase-2', + label: 'Correctness', + prompt: 'Review correctness', + subagentId: 'correctness-agent-1', + status: 'running', + dependsOn: ['dispatch-1'], + queuedAt: 1_210, + startedAt: 1_220, + }, + ], + agentsDispatched: 2, + agentsCompleted: 1, + recentLogs: ['Review started'], + events: [ + { + id: 'event-1', + type: 'log', + at: 1_250, + message: 'Review started', + }, + { + id: 'event-2', + type: 'approval-requested', + at: 1_300, + name: 'write_file', + dispatchId: 'dispatch-2', + }, + ], + tokensSpent: 1_200, + tokenBudgetTotal: 8_000, + perPhaseTokens: new Map(), + script: '', + sourceRunId: 'wf_source', + startMode: 'rerun', + pendingApprovals: [ + { + approvalId: 'wfap-1', + subagentId: 'correctness-agent-1', + callId: 'call-1', + name: 'write_file', + description: 'Update the implementation', + confirmationDetails: {} as never, + at: 1_300, + }, + ], + outputOffset: 0, + notified: false, + outputFile: '', + abortController: new AbortController(), + } as WorkflowTask; + + const snapshot = buildSessionTasksStatus( + 'session-1', + configWith([], [workflow]), + 2_000, + [], + { includeWorkflows: true }, + ); + const task = snapshot.tasks.find( + (candidate) => candidate.kind === 'workflow', + ); + + expect(task).toMatchObject({ + kind: 'workflow', + id: 'wf_graph', + toolUseId: 'workflow-call-1', + label: 'review-and-fix', + currentPhase: 'Review', + agentsDispatched: 2, + agentsCompleted: 1, + tokensSpent: 1_200, + tokenBudgetTotal: 8_000, + sourceRunId: 'wf_source', + startMode: 'rerun', + phaseVisits: [ + { id: 'phase-1', title: 'Inspect' }, + { id: 'phase-2', title: 'Review' }, + ], + dispatches: [ + { id: 'dispatch-1', status: 'completed', dependsOn: [] }, + { + id: 'dispatch-2', + status: 'running', + subagentId: 'correctness-agent-1', + dependsOn: ['dispatch-1'], + }, + ], + pendingApprovalCount: 1, + pendingApprovals: [ + { + approvalId: 'wfap-1', + subagentId: 'correctness-agent-1', + name: 'write_file', + description: 'Update the implementation', + }, + ], + events: [ + { + id: 'event-1', + type: 'log', + at: 1_250, + message: 'Review started', + }, + { + id: 'event-2', + type: 'approval-requested', + at: 1_300, + name: 'write_file', + dispatchId: 'dispatch-2', + }, + ], + }); + }); + + it('restores persisted workflow runs as read-only task history', () => { + const snapshot = buildSessionTasksStatus( + 'session-1', + configWith([]), + 2_000, + [workflowSnapshot()], + { includeWorkflows: true }, + ); + + expect(snapshot.tasks).toEqual([ + expect.objectContaining({ + kind: 'workflow', + id: 'wf_saved', + label: 'review-and-fix', + status: 'failed', + runtimeMs: 500, + isHistorical: true, + agentsDispatched: 2, + agentsCompleted: 1, + tokensSpent: 900, + events: [ + { + id: 'event-1', + type: 'workflow-failed', + at: 1_000, + error: 'Review failed', + }, + ], + }), + ]); + }); + + it('prefers the in-memory workflow task over a persisted duplicate', () => { + const workflow = { + kind: 'workflow', + id: 'wf_saved', + runId: 'wf_saved', + description: 'Live entry', + meta: { name: 'review-and-fix', description: 'Review and fix' }, + status: 'completed', + startTime: 500, + endTime: 1_100, + isBackgrounded: true, + currentPhase: null, + phases: [], + phaseVisits: [], + currentPhaseVisitId: null, + dispatches: [], + agentsDispatched: 3, + agentsCompleted: 3, + recentLogs: [], + events: [], + tokensSpent: 1_200, + tokenBudgetTotal: 4_000, + perPhaseTokens: new Map(), + script: '', + pendingApprovals: [], + outputOffset: 0, + notified: true, + outputFile: '', + abortController: new AbortController(), + } as WorkflowTask; + + const snapshot = buildSessionTasksStatus( + 'session-1', + configWith([], [workflow]), + 2_000, + [workflowSnapshot()], + { includeWorkflows: true }, + ); + const workflows = snapshot.tasks.filter( + (candidate) => candidate.kind === 'workflow', + ); + + expect(workflows).toHaveLength(1); + expect(workflows[0]).toMatchObject({ + id: 'wf_saved', + agentsCompleted: 3, + tokensSpent: 1_200, + }); + expect(workflows[0]).not.toHaveProperty('isHistorical'); + }); +}); diff --git a/packages/cli/src/acp-integration/session/tasksSnapshot.ts b/packages/cli/src/acp-integration/session/tasksSnapshot.ts index 5b41a532d95..bd5c43a132a 100644 --- a/packages/cli/src/acp-integration/session/tasksSnapshot.ts +++ b/packages/cli/src/acp-integration/session/tasksSnapshot.ts @@ -10,6 +10,8 @@ import { type Config, type MonitorTask, type ShellTask, + type WorkflowSnapshot, + type WorkflowTask, } from '@qwen-code/qwen-code-core'; import { STATUS_SCHEMA_VERSION, @@ -18,6 +20,7 @@ import { type ServeSessionShellTaskStatus, type ServeSessionTaskStatus, type ServeSessionTasksStatus, + type ServeSessionWorkflowTaskStatus, } from '@qwen-code/acp-bridge/status'; function runtimeMs( @@ -122,11 +125,99 @@ function serializeMonitorTask( }; } +function serializeWorkflowTask( + entry: WorkflowTask, + now: number, +): ServeSessionWorkflowTaskStatus { + return { + kind: 'workflow', + id: entry.runId, + ...optionalField('toolUseId', entry.toolUseId), + ...optionalField('sourceRunId', entry.sourceRunId), + ...optionalField('startMode', entry.startMode), + label: entry.meta?.name ?? entry.description ?? entry.runId, + description: entry.meta?.description ?? entry.description, + status: entry.status, + startTime: entry.startTime, + runtimeMs: runtimeMs(entry, now), + outputFile: entry.outputFile, + ...optionalField('endTime', entry.endTime), + isBackgrounded: entry.isBackgrounded === true, + currentPhase: entry.currentPhase, + phaseVisits: entry.phaseVisits.map((visit) => ({ ...visit })), + dispatches: entry.dispatches.map((dispatch) => ({ + ...dispatch, + dependsOn: [...dispatch.dependsOn], + })), + agentsDispatched: entry.agentsDispatched, + agentsCompleted: entry.agentsCompleted, + tokensSpent: entry.tokensSpent, + tokenBudgetTotal: entry.tokenBudgetTotal, + recentLogs: [...entry.recentLogs], + events: entry.events.map((event) => ({ ...event })), + pendingApprovalCount: entry.pendingApprovals.length, + pendingApprovals: entry.pendingApprovals.map((approval) => ({ + approvalId: approval.approvalId, + subagentId: approval.subagentId, + name: approval.name, + description: approval.description, + at: approval.at, + })), + ...optionalField('error', entry.error), + }; +} + +function serializeWorkflowSnapshot( + snapshot: WorkflowSnapshot, +): ServeSessionWorkflowTaskStatus { + return { + kind: 'workflow', + id: snapshot.runId, + isHistorical: true, + ...optionalField('sourceRunId', snapshot.sourceRunId), + ...optionalField('startMode', snapshot.startMode), + label: snapshot.meta?.name ?? snapshot.description ?? snapshot.runId, + description: + snapshot.meta?.description ?? snapshot.description ?? snapshot.runId, + status: snapshot.status, + startTime: snapshot.startTime, + ...optionalField('endTime', snapshot.endTime), + runtimeMs: runtimeMs(snapshot, snapshot.endTime ?? snapshot.startTime), + isBackgrounded: false, + currentPhase: snapshot.phases.at(-1) ?? null, + phaseVisits: (snapshot.phaseVisits ?? []).map((visit) => ({ ...visit })), + dispatches: (snapshot.dispatches ?? []).map((dispatch) => ({ + ...dispatch, + dependsOn: [...dispatch.dependsOn], + })), + agentsDispatched: snapshot.agentsDispatched, + agentsCompleted: snapshot.agentsCompleted, + tokensSpent: snapshot.tokensSpent, + tokenBudgetTotal: snapshot.tokenBudgetTotal, + recentLogs: [...snapshot.recentLogs], + ...optionalField( + 'events', + snapshot.events?.map((event) => ({ ...event })), + ), + pendingApprovalCount: 0, + ...optionalField('error', snapshot.error), + }; +} + export function buildSessionTasksStatus( sessionId: string, config: Config, now = Date.now(), + workflowHistory: readonly WorkflowSnapshot[] = [], + options: { includeWorkflows?: boolean } = {}, ): ServeSessionTasksStatus { + const includeWorkflows = options.includeWorkflows === true; + const workflowTasks = includeWorkflows + ? config.getWorkflowRunRegistry().list() + : []; + const inMemoryWorkflowIds = new Set( + workflowTasks.map((entry) => entry.runId), + ); const tasks: ServeSessionTaskStatus[] = [ ...config .getBackgroundTaskRegistry() @@ -140,6 +231,14 @@ export function buildSessionTasksStatus( .getMonitorRegistry() .getAll() .map((entry) => serializeMonitorTask(entry, now)), + ...(includeWorkflows + ? workflowTasks.map((entry) => serializeWorkflowTask(entry, now)) + : []), + ...(includeWorkflows + ? workflowHistory + .filter((snapshot) => !inMemoryWorkflowIds.has(snapshot.runId)) + .map(serializeWorkflowSnapshot) + : []), ].sort((a, b) => a.startTime - b.startTime); return { diff --git a/packages/cli/src/serve/acp-http/dispatch.ts b/packages/cli/src/serve/acp-http/dispatch.ts index 1c6b6e6dd3b..9308174b93c 100644 --- a/packages/cli/src/serve/acp-http/dispatch.ts +++ b/packages/cli/src/serve/acp-http/dispatch.ts @@ -107,6 +107,10 @@ import { setupGithubEventData, } from '../routes/workspace-setup-github.js'; import { parseWorkspaceVoiceUpdateParams } from '../routes/workspace-voice.js'; +import { + redactWorkflowsFromAvailableCommandsEvent, + redactWorkflowsFromSupportedCommands, +} from '../workflow-session-gate.js'; import { MAX_TRUST_REASON_LENGTH } from '../validation-limits.js'; import { publicErrorMessage, @@ -277,7 +281,10 @@ const ALL_QWEN_VENDOR_METHODS: readonly string[] = [ `${QWEN_METHOD_NS}session/detach`, `${QWEN_METHOD_NS}session/context_usage`, `${QWEN_METHOD_NS}session/tasks`, + `${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`, @@ -2987,10 +2994,14 @@ export class AcpDispatcher { case `${QWEN_METHOD_NS}session/supported_commands`: { const sessionId = String(params['sessionId'] ?? ''); if (!this.requireOwned(conn, sessionId, id)) return; + const status = + await this.bridge.getSessionSupportedCommandsStatus(sessionId); this.replyConn( conn, id, - await this.bridge.getSessionSupportedCommandsStatus(sessionId), + this.isWorkspaceTrusted() + ? status + : redactWorkflowsFromSupportedCommands(status), ); return; } @@ -3721,11 +3732,112 @@ export class AcpDispatcher { case `${QWEN_METHOD_NS}session/tasks`: { const sessionId = String(params['sessionId'] ?? ''); if (!this.requireOwned(conn, sessionId, id)) return; - const result = await this.bridge.getSessionTasksStatus(sessionId); + const result = await this.bridge.getSessionTasksStatus(sessionId, { + // Same fail-closed shape as the workflow control surfaces: + // opting in here leaks strictly more than the redacted + // supported-commands surface on an untrusted workspace. + includeWorkflows: + this.isWorkspaceTrusted() && params['includeWorkflows'] === true, + }); this.replyConn(conn, id, result as unknown); return; } + case `${QWEN_METHOD_NS}session/tasks/cancel`: { + const sessionId = String(params['sessionId'] ?? ''); + await this.withMutableOwned(conn, sessionId, id, async () => { + const taskId = String(params['taskId'] ?? ''); + if (!taskId) { + if (id !== undefined) { + conn.sendConn( + error(id, RPC.INVALID_PARAMS, '`taskId` is required'), + ); + } + return; + } + const kind = params['kind']; + if ( + kind !== 'agent' && + kind !== 'shell' && + kind !== 'monitor' && + kind !== 'workflow' + ) { + if (id !== undefined) { + conn.sendConn( + error( + id, + RPC.INVALID_PARAMS, + '`kind` must be "agent", "shell", "monitor", or "workflow"', + ), + ); + } + return; + } + if (kind === 'workflow' && !this.isWorkspaceTrusted()) { + this.replyConn(conn, id, { + cancelled: false, + reason: 'disabled', + }); + return; + } + const result = await this.bridge.cancelSessionTask( + sessionId, + taskId, + kind, + this.sessionCtx(conn, sessionId, loopback), + ); + this.replyConn(conn, id, result as unknown); + }); + return; + } + + case `${QWEN_METHOD_NS}session/tasks/workflow_action`: { + const sessionId = String(params['sessionId'] ?? ''); + await this.withMutableOwned(conn, sessionId, id, async () => { + const taskId = String(params['taskId'] ?? ''); + if (!taskId) { + if (id !== undefined) { + conn.sendConn( + error(id, RPC.INVALID_PARAMS, '`taskId` is required'), + ); + } + return; + } + const action = params['action']; + if ( + action !== 'pause' && + action !== 'resume' && + action !== 'retry' && + action !== 'rerun' && + action !== 'delete-history' && + action !== 'run-saved' + ) { + if (id !== undefined) { + conn.sendConn( + error( + id, + RPC.INVALID_PARAMS, + '`action` must be "pause", "resume", "retry", "rerun", "delete-history", or "run-saved"', + ), + ); + } + return; + } + if (!this.isWorkspaceTrusted()) { + this.replyConn(conn, id, { changed: false }); + return; + } + const result = await this.bridge.controlSessionWorkflowTask( + sessionId, + taskId, + action, + this.sessionCtx(conn, sessionId, loopback), + ); + this.replyConn(conn, id, result as unknown); + }); + return; + } + case `${QWEN_METHOD_NS}session/lsp`: { const sessionId = String(params['sessionId'] ?? ''); if (!this.requireOwned(conn, sessionId, id)) return; @@ -3734,6 +3846,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; @@ -5351,9 +5484,12 @@ export class AcpDispatcher { // `event.data` is the ACP `SessionNotification` (params shape). // `event.id` is the bus cursor → SSE `id:` line for `Last-Event-ID` // resume (the content frames §1.8 recovers all flow through here). + const shaped = this.isWorkspaceTrusted() + ? event + : redactWorkflowsFromAvailableCommandsEvent(event); conn.sendSession( sessionId, - notification('session/update', event.data), + notification('session/update', shaped.data), event.id, ); return; diff --git a/packages/cli/src/serve/acp-http/transport.test.ts b/packages/cli/src/serve/acp-http/transport.test.ts index dcf3c45c76b..f05e808c12e 100644 --- a/packages/cli/src/serve/acp-http/transport.test.ts +++ b/packages/cli/src/serve/acp-http/transport.test.ts @@ -20,6 +20,7 @@ import type { BridgeEvent, SessionReplaySnapshot, } from '@qwen-code/acp-bridge/eventBus'; +import type { ServeSessionSupportedCommandsStatus } from '@qwen-code/acp-bridge/status'; import { SessionArtifactAuthorizationError, SessionArtifactValidationError, @@ -417,7 +418,9 @@ class FakeBridge { }, }; } - async getSessionSupportedCommandsStatus(sessionId: string) { + async getSessionSupportedCommandsStatus( + sessionId: string, + ): Promise { return { v: 1, sessionId, availableCommands: [], availableSkills: [] }; } /** Per-session in-memory pr bindings, mirroring the real bridge's @@ -549,9 +552,70 @@ class FakeBridge { async getSessionContextUsageStatus(sessionId: string) { return { sessionId, used: 100, total: 1000 }; } - async getSessionTasksStatus(sessionId: string) { + lastSessionTasksOptions: { includeWorkflows?: boolean } | undefined; + async getSessionTasksStatus( + sessionId: string, + opts?: { includeWorkflows?: boolean }, + ) { + this.lastSessionTasksOptions = opts; return { sessionId, tasks: [] }; } + lastCancelledTask: + | { + sessionId: string; + taskId: string; + kind: Parameters[2]; + context: Parameters[3]; + } + | undefined; + async cancelSessionTask( + sessionId: string, + taskId: string, + kind: Parameters[2], + context: Parameters[3], + ) { + this.lastCancelledTask = { sessionId, taskId, kind, context }; + return { cancelled: true }; + } + lastWorkflowAction: + | { + sessionId: string; + taskId: string; + action: Parameters[2]; + context: Parameters[3]; + } + | undefined; + async controlSessionWorkflowTask( + sessionId: string, + taskId: string, + action: Parameters[2], + context: Parameters[3], + ) { + 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, @@ -7836,7 +7900,10 @@ describe('ACP Streamable HTTP transport (over the wire)', () => { sessionId: 'sess-1', update: { sessionUpdate: 'available_commands_update', - availableCommands: [{ name: 'help', description: 'Help' }], + availableCommands: [ + { name: 'help', description: 'Help' }, + { name: 'workflows', description: 'Manage workflows' }, + ], _meta: { availableSkills: ['bugfix'], availableSkillDetails: skillDetails, @@ -7857,7 +7924,10 @@ describe('ACP Streamable HTTP transport (over the wire)', () => { sessionId: 'sess-1', update: { sessionUpdate: 'available_commands_update', - availableCommands: [{ name: 'help', description: 'Help' }], + availableCommands: [ + { name: 'help', description: 'Help' }, + { name: 'workflows', description: 'Manage workflows' }, + ], _meta: { availableSkills: ['bugfix'], availableSkillDetails: skillDetails, @@ -8530,12 +8600,198 @@ describe('ACP Streamable HTTP transport (over the wire)', () => { jsonrpc: '2.0', id: 57, method: '_qwen/session/tasks', - params: { sessionId: 'sess-1' }, + params: { sessionId: 'sess-1', includeWorkflows: true }, }); const frames = await takeFrames(await streamRes, 2); expect(frames[1]).toMatchObject({ result: { sessionId: 'sess-1', tasks: [] }, }); + expect(bridge.lastSessionTasksOptions).toEqual({ + includeWorkflows: true, + }); + }); + + it('_qwen/session/tasks/cancel forwards the task and trusted client context', 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: 58, + method: '_qwen/session/tasks/cancel', + params: { + sessionId: 'sess-1', + taskId: 'workflow-1', + kind: 'workflow', + clientId: 'forged-client', + }, + }); + const frames = await takeFrames(await streamRes, 2); + expect(frames[1]).toMatchObject({ result: { cancelled: true } }); + expect(bridge.lastCancelledTask).toEqual({ + sessionId: 'sess-1', + taskId: 'workflow-1', + kind: 'workflow', + context: { clientId: 'client-1', fromLoopback: true }, + }); + }); + + it('_qwen/session/tasks/workflow_action forwards the action result and trusted client context', 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: 59, + method: '_qwen/session/tasks/workflow_action', + params: { + sessionId: 'sess-1', + taskId: 'workflow-1', + action: 'retry', + clientId: 'forged-client', + }, + }); + const frames = await takeFrames(await streamRes, 2); + expect(frames[1]).toMatchObject({ + result: { changed: true, status: 'running' }, + }); + expect(bridge.lastWorkflowAction).toEqual({ + sessionId: 'sess-1', + taskId: 'workflow-1', + action: 'retry', + context: { clientId: 'client-1', fromLoopback: true }, + }); + }); + + it('fails the Workflow surfaces closed for an untrusted workspace', async () => { + await restartServer({ primaryTrusted: false }); + bridge.getSessionSupportedCommandsStatus = async ( + sessionId: string, + ): Promise => ({ + v: 1, + sessionId, + availableCommands: [ + { name: 'init', description: 'Initialize', input: null }, + { name: 'workflows', description: 'Manage workflows', input: null }, + ], + availableSkills: [], + workflowsEnabled: true, + savedWorkflows: [{ name: 'slow-phases', source: 'project' as const }], + }); + 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)); + + const sessionStream = await openStream(connId, 'sess-1'); + const sessionFrame = takeFrames(sessionStream, 1); + await new Promise((r) => setTimeout(r, 30)); + bridge.queues.get('sess-1')?.push({ + type: 'session_update', + data: { + sessionId: 'sess-1', + update: { + sessionUpdate: 'available_commands_update', + availableCommands: [ + { name: 'init', description: 'Initialize' }, + { name: 'workflows', description: 'Manage workflows' }, + ], + }, + }, + }); + + await post(connId, { + jsonrpc: '2.0', + id: 60, + method: '_qwen/session/supported_commands', + params: { sessionId: 'sess-1' }, + }); + await post(connId, { + jsonrpc: '2.0', + id: 61, + method: '_qwen/session/tasks/cancel', + params: { sessionId: 'sess-1', taskId: 'wf-1', kind: 'workflow' }, + }); + await post(connId, { + jsonrpc: '2.0', + id: 62, + method: '_qwen/session/tasks/workflow_action', + params: { + sessionId: 'sess-1', + taskId: 'slow-phases', + action: 'run-saved', + }, + }); + await post(connId, { + jsonrpc: '2.0', + id: 63, + method: '_qwen/session/tasks', + params: { sessionId: 'sess-1', includeWorkflows: true }, + }); + const frames = await takeFrames(await streamRes, 7); + const byId = new Map( + frames + .filter( + (frame): frame is { id: number; result?: unknown } => + typeof frame === 'object' && + frame !== null && + 'id' in frame && + typeof frame.id === 'number', + ) + .map((frame) => [frame.id, frame]), + ); + expect(byId.get(60)).toMatchObject({ + result: { + workflowsEnabled: false, + savedWorkflows: [], + availableCommands: [{ name: 'init', description: 'Initialize' }], + }, + }); + expect(byId.get(61)).toMatchObject({ + result: { cancelled: false, reason: 'disabled' }, + }); + expect(byId.get(62)).toMatchObject({ result: { changed: false } }); + expect(byId.get(63)).toMatchObject({ + result: { sessionId: 'sess-1', tasks: [] }, + }); + await expect(sessionFrame).resolves.toEqual([ + expect.objectContaining({ + method: 'session/update', + params: expect.objectContaining({ + update: expect.objectContaining({ + sessionUpdate: 'available_commands_update', + availableCommands: [{ name: 'init', description: 'Initialize' }], + }), + }), + }), + ]); + // The untrusted-workspace gate fail-closes the read path too: the + // includeWorkflows opt-in must not reach the child. + expect(bridge.lastSessionTasksOptions).toEqual({ + includeWorkflows: false, + }); + expect(bridge.lastCancelledTask).toBeUndefined(); + expect(bridge.lastWorkflowAction).toBeUndefined(); }); it('_qwen/session/lsp returns status', async () => { @@ -8567,6 +8823,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); diff --git a/packages/cli/src/serve/multi-workspace-sessions.test.ts b/packages/cli/src/serve/multi-workspace-sessions.test.ts index c5ce468d4ce..01de4f85ce1 100644 --- a/packages/cli/src/serve/multi-workspace-sessions.test.ts +++ b/packages/cli/src/serve/multi-workspace-sessions.test.ts @@ -142,7 +142,7 @@ interface FakeBridge extends AcpSessionBridge { readonly taskCancelCalls: Array<{ sessionId: string; taskId: string; - taskKind: 'agent' | 'shell' | 'monitor'; + taskKind: 'agent' | 'shell' | 'monitor' | 'workflow'; }>; readonly goalClearCalls: string[]; readonly continueCalls: Array<{ @@ -752,11 +752,27 @@ function makeBridge( async cancelSessionTask( sessionId: string, taskId: string, - taskKind: 'agent' | 'shell' | 'monitor', + taskKind: 'agent' | 'shell' | 'monitor' | 'workflow', ) { taskCancelCalls.push({ sessionId, taskId, taskKind }); return { cancelled: workspaceCwd === SECONDARY_CWD }; }, + async controlSessionWorkflowTask( + _sessionId: string, + _taskId: string, + action: + | 'pause' + | 'resume' + | 'retry' + | 'rerun' + | 'delete-history' + | 'run-saved', + ) { + return { + changed: workspaceCwd === SECONDARY_CWD, + status: action === 'pause' ? 'pausing' : 'running', + }; + }, async clearSessionGoal(sessionId: string) { goalClearCalls.push(sessionId); return { @@ -1126,6 +1142,7 @@ function makeHarness(opts?: { undefined, { workspaceRegistry: registry, + daemonEnv: {}, ...(opts?.daemonLog ? { daemonLog: opts.daemonLog } : {}), ...(opts?.liveConversationWorkspace ? { liveConversationWorkspace: opts.liveConversationWorkspace } @@ -1174,13 +1191,20 @@ describe('multi-workspace session dispatch', () => { expect(res.body.features).toContain('workspace_archived_session_export'); expect(res.body.features).toContain('workspace_display_name'); expect(res.body.workspaces).toEqual([ - { id: 'primary-id', cwd: PRIMARY_CWD, primary: true, trusted: true }, + { + id: 'primary-id', + cwd: PRIMARY_CWD, + primary: true, + trusted: true, + workflowsEnabled: false, + }, { id: 'secondary-id', cwd: SECONDARY_CWD, displayName: 'Secondary workspace', primary: false, trusted: true, + workflowsEnabled: false, }, ]); expect(res.body.limits.maxSessionsPerWorkspace).toBe(32); diff --git a/packages/cli/src/serve/routes/capabilities.ts b/packages/cli/src/serve/routes/capabilities.ts index 90116bf7153..5f37e3d179f 100644 --- a/packages/cli/src/serve/routes/capabilities.ts +++ b/packages/cli/src/serve/routes/capabilities.ts @@ -18,7 +18,10 @@ import { type CapabilitiesEnvelope, type ServeOptions, } from '../types.js'; -import type { WorkspaceRegistry } from '../workspace-registry.js'; +import type { + WorkspaceRegistry, + WorkspaceRuntime, +} from '../workspace-registry.js'; interface RegisterCapabilitiesRoutesDeps { qwenCodeVersion?: string; @@ -32,6 +35,23 @@ interface RegisterCapabilitiesRoutesDeps { maxPendingPromptsPerSession: ServeOptions['maxPendingPromptsPerSession']; sessionRestoreTimeoutMs: number; languageCodes: string[]; + daemonEnv: Readonly; +} + +function workflowsEnabledForRuntime( + runtime: WorkspaceRuntime | undefined, + daemonEnv: Readonly, +): boolean { + if (!runtime || !runtime.trusted) return false; + const env = + runtime.env.mode === 'runtime-overlay' + ? (runtime.env.effectiveEnv ?? {}) + : (runtime.env.effectiveEnv ?? daemonEnv); + if (env['QWEN_CODE_DISABLE_WORKFLOWS'] === '1') return false; + return ( + env['QWEN_CODE_ENABLE_WORKFLOWS'] === '1' || + runtime.env.workflowsEnabledBySettings === true + ); } export function registerCapabilitiesRoutes( @@ -103,6 +123,10 @@ export function registerCapabilitiesRoutes( primary: entry.primary, trusted: entry.state === 'active' && entry.current?.runtime.trusted === true, + workflowsEnabled: workflowsEnabledForRuntime( + entry.state === 'active' ? entry.current?.runtime : undefined, + deps.daemonEnv, + ), ...(runtimeRemoval ? { removable: entry.removable } : {}), ...(entry.current?.runtime.provenance === 'live-conversation' ? { kind: 'live' as const } diff --git a/packages/cli/src/serve/routes/session.ts b/packages/cli/src/serve/routes/session.ts index 3ff9d3e2e15..1728dcc3d38 100644 --- a/packages/cli/src/serve/routes/session.ts +++ b/packages/cli/src/serve/routes/session.ts @@ -44,6 +44,7 @@ import { DAEMON_PROMPT_DISPLAY_TEXT_META_KEY, type BridgeBranchedSession, } from '@qwen-code/acp-bridge/bridgeTypes'; +import type { BridgeEvent } from '@qwen-code/acp-bridge/eventBus'; import { parseSessionSource } from '@qwen-code/acp-bridge'; import { isReservedLiveSessionSource, @@ -163,6 +164,11 @@ import { sendUntrustedWorkspaceResponse, sendWorkspaceRuntimeUnavailable, } from '../workspace-route-runtime.js'; +import { + redactWorkflowsFromAvailableCommandsEvent, + redactWorkflowsFromReplayArrays, + redactWorkflowsFromSupportedCommands, +} from '../workflow-session-gate.js'; import type { WorkspaceEntry, WorkspaceRegistry, @@ -192,6 +198,26 @@ import { // storage is case-folding on macOS/Windows. const GIT_RESERVED_BRANCH = 'HEAD'; +function redactSdkSurfaceEvent( + event: T, + workspaceTrusted: boolean, +): T { + const shaped = omitSkillDetailsForSdkSurface(event); + return workspaceTrusted + ? shaped + : redactWorkflowsFromAvailableCommandsEvent(shaped); +} + +function redactSdkSurfaceReplay< + T extends { + compactedReplay?: BridgeEvent[]; + liveJournal?: BridgeEvent[]; + }, +>(session: T, workspaceTrusted: boolean): T { + const shaped = omitSkillDetailsFromReplayArrays(session); + return workspaceTrusted ? shaped : redactWorkflowsFromReplayArrays(shaped); +} + // Byte-length caps for branch names. git creates loose refs as files under // `.git/refs/heads/`, so each `/`-separated component is bounded by the // filesystem's per-component name limit (255 bytes on Linux/macOS, minus the @@ -3397,7 +3423,9 @@ export function registerSessionRoutes( } // Same replay-array shape as the load response; redact skill // bodies for the browser surface (#9234). - res.status(200).json(omitSkillDetailsFromReplayArrays(session)); + res + .status(200) + .json(redactSdkSurfaceReplay(session, runtime.trusted)); } catch (err) { sendBridgeError(res, err, { route, sessionId }); } @@ -3513,7 +3541,9 @@ export function registerSessionRoutes( } return; } - res.status(200).json(omitSkillDetailsFromReplayArrays(session)); + res + .status(200) + .json(redactSdkSurfaceReplay(session, runtime.trusted)); return; } } catch (error) { @@ -3894,7 +3924,7 @@ export function registerSessionRoutes( } // The load response embeds the replay snapshot inline; redact the // skill bodies there just like the SSE egress does (#9234). - res.status(200).json(omitSkillDetailsFromReplayArrays(session)); + res.status(200).json(redactSdkSurfaceReplay(session, runtime.trusted)); } catch (err) { if (err instanceof RequestedSessionIdAdmissionError) { sendRequestedSessionIdAdmissionError(res, err, route); @@ -4093,7 +4123,10 @@ export function registerSessionRoutes( res .status(201) .json( - omitSkillDetailsFromReplayArrays(result as BridgeBranchedSession), + redactSdkSurfaceReplay( + result as BridgeBranchedSession, + runtime.trusted, + ), ); }, { rejectStandalone: true }, @@ -4163,7 +4196,7 @@ export function registerSessionRoutes( } return; } - res.status(201).json(omitSkillDetailsFromReplayArrays(result)); + res.status(201).json(redactSdkSurfaceReplay(result, runtime.trusted)); }, { rejectStandalone: true }, ), @@ -4316,6 +4349,7 @@ export function registerSessionRoutes( return; } + let workspaceTrusted = false; try { const result = await archiveCoordinator.runSharedMany( [sessionId], @@ -4327,6 +4361,7 @@ export function registerSessionRoutes( cursor !== undefined, ); if (!runtime) return undefined; + workspaceTrusted = runtime.trusted; captureRuntimeGenerationAssertion(runtime)?.(); return runtime.bridge.getSessionTranscriptPage({ sessionId, @@ -4342,7 +4377,9 @@ export function registerSessionRoutes( .set('Cache-Control', 'no-store') .json({ ...result, - events: (result.events ?? []).map(omitSkillDetailsForSdkSurface), + events: (result.events ?? []).map((event) => + redactSdkSurfaceEvent(event, workspaceTrusted), + ), }); } catch (err) { sendBridgeError(res, err, { @@ -4471,11 +4508,14 @@ export function registerSessionRoutes( v: 1 as const, sessionId, events: replay.updates.map((update) => - omitSkillDetailsForSdkSurface({ - v: 1 as const, - type: 'session_update' as const, - data: update, - }), + redactSdkSurfaceEvent( + { + v: 1 as const, + type: 'session_update' as const, + data: update, + }, + runtime.trusted, + ), ), ...(replay.nextCursor && !cursorTooLarge ? { nextCursor: replay.nextCursor } @@ -4620,10 +4660,14 @@ export function registerSessionRoutes( withOwnerReadSession( 'GET /session/:id/supported-commands', async (_req, res, sessionId, runtime) => { + const status = + await runtime.bridge.getSessionSupportedCommandsStatus(sessionId); res .status(200) .json( - await runtime.bridge.getSessionSupportedCommandsStatus(sessionId), + runtime.trusted + ? status + : redactWorkflowsFromSupportedCommands(status), ); }, ), @@ -4633,10 +4677,42 @@ export function registerSessionRoutes( '/session/:id/tasks', withOwnerReadSession( 'GET /session/:id/tasks', - async (_req, res, sessionId, runtime) => { + async (req, res, sessionId, runtime) => { + res.status(200).json( + await runtime.bridge.getSessionTasksStatus(sessionId, { + // Same fail-closed shape as the workflow control surfaces: + // opting in here leaks strictly more than the redacted + // supported-commands surface on an untrusted workspace. + includeWorkflows: + runtime.trusted && req.query['includeWorkflows'] === 'true', + }), + ); + }, + ), + ); + + 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(await runtime.bridge.getSessionTasksStatus(sessionId)); + .json( + runtime.trusted + ? await runtime.bridge.getSessionSavedWorkflow(sessionId, name) + : { v: 1, sessionId, name, workflow: null }, + ); }, ), ); @@ -4789,16 +4865,80 @@ export function registerSessionRoutes( } const body = safeBody(req); const kind = body['kind']; - if (kind !== 'agent' && kind !== 'shell' && kind !== 'monitor') { - res - .status(400) - .json({ error: '`kind` must be "agent", "shell", or "monitor"' }); + if ( + kind !== 'agent' && + kind !== 'shell' && + kind !== 'monitor' && + kind !== 'workflow' + ) { + res.status(400).json({ + error: '`kind` must be "agent", "shell", "monitor", or "workflow"', + }); + return; + } + if (kind === 'workflow' && !runtime.trusted) { + res.status(200).json({ cancelled: false, reason: 'disabled' }); return; } + const clientId = parseClientIdHeader(req, res); + if (clientId === null) return; + res + .status(200) + .json( + await runtime.bridge.cancelSessionTask( + sessionId, + taskId, + kind, + clientId !== undefined ? { clientId } : undefined, + ), + ); + }, + ), + ); + + app.post( + '/session/:id/tasks/:taskId/workflow-action', + mutate({ strict: true }), + withOwnerMutableSession( + 'POST /session/:id/tasks/:taskId/workflow-action', + async (req, res, sessionId, runtime) => { + const taskId = req.params['taskId']; + if (!taskId) { + res.status(400).json({ + error: '`taskId` route parameter is required', + }); + return; + } + const action = safeBody(req)['action']; + if ( + action !== 'pause' && + action !== 'resume' && + action !== 'retry' && + action !== 'rerun' && + action !== 'delete-history' && + action !== 'run-saved' + ) { + res.status(400).json({ + error: + '`action` must be "pause", "resume", "retry", "rerun", "delete-history", or "run-saved"', + }); + return; + } + if (!runtime.trusted) { + res.status(200).json({ changed: false }); + return; + } + const clientId = parseClientIdHeader(req, res); + if (clientId === null) return; res .status(200) .json( - await runtime.bridge.cancelSessionTask(sessionId, taskId, kind), + await runtime.bridge.controlSessionWorkflowTask( + sessionId, + taskId, + action, + clientId !== undefined ? { clientId } : undefined, + ), ); }, ), diff --git a/packages/cli/src/serve/routes/sse-events.ts b/packages/cli/src/serve/routes/sse-events.ts index 888484ef921..7aec3622d81 100644 --- a/packages/cli/src/serve/routes/sse-events.ts +++ b/packages/cli/src/serve/routes/sse-events.ts @@ -35,6 +35,7 @@ import { } from '../server/request-helpers.js'; import { parseEventEpochHeader } from '../sse-last-event-id.js'; import { omitSkillDetailsForSdkSurface } from '../skill-details-redaction.js'; +import { redactWorkflowsFromAvailableCommandsEvent } from '../workflow-session-gate.js'; import type { WorkspaceRegistry } from '../workspace-registry.js'; import { isInternalWorkspaceRuntime } from '../workspace-runtime-visibility.js'; import { requireSessionRuntime } from './session-runtime.js'; @@ -128,8 +129,13 @@ interface RegisterSseEventsRoutesDeps { type OmitId = Omit; -function formatSseFrame(event: BridgeEvent | OmitId): string { - const shaped = omitSkillDetailsForSdkSurface(event); +function formatSseFrame( + event: BridgeEvent | OmitId, + workspaceTrusted = false, +): string { + const shaped = omitSkillDetailsForSdkSurface( + workspaceTrusted ? event : redactWorkflowsFromAvailableCommandsEvent(event), + ); // SSE format: id (optional), event (optional), data, blank line. // The `id:` line is intentionally omitted when `event.id` is absent — // terminal/synthetic frames (e.g. daemon-side `stream_error`) must not @@ -333,6 +339,7 @@ export function registerSseEventsRoutes( let iter: AsyncIterator | undefined; let busEpoch: string | undefined; + let workspaceTrusted = false; const abort = new AbortController(); try { const virtualKey = parseVirtualSubagentSessionId(sessionId); @@ -344,6 +351,7 @@ export function registerSseEventsRoutes( daemonLog, }); if (!runtime) return; + workspaceTrusted = runtime.trusted; const snapshot = req.query['snapshot'] === '1'; const openSubscription = async (): Promise< { iter: AsyncIterator; busEpoch?: string } | undefined @@ -981,7 +989,7 @@ export function registerSseEventsRoutes( const liveEvent = liveTimingEnabled; const serverTimestamp = next.value._meta?.['serverTimestamp']; const outcome = await writeWithBackpressure( - formatSseFrame(next.value), + formatSseFrame(next.value, workspaceTrusted), ); if (outcome === 'closed') break; eventFramesWriteSettled += 1; diff --git a/packages/cli/src/serve/run-qwen-serve.test.ts b/packages/cli/src/serve/run-qwen-serve.test.ts index f2ab1987b3b..3a5d437e3d7 100644 --- a/packages/cli/src/serve/run-qwen-serve.test.ts +++ b/packages/cli/src/serve/run-qwen-serve.test.ts @@ -81,6 +81,7 @@ import { import { getDeferredRuntimeRequestTiming } from './server/request-helpers.js'; import type { WorkspaceFileSystemFactory } from './fs/workspace-file-system.js'; import { ConversationWorkspace } from './conversations/conversation-workspace.js'; +import type { WorkspaceRuntimeProvenance } from './managed-scratch-workspace.js'; import * as scheduledTaskKeepalive from './scheduled-task-keepalive.js'; const originalTestRuntimeDir = process.env['QWEN_RUNTIME_DIR']; @@ -6800,6 +6801,7 @@ describe('runQwenServe runtime startup failures', () => { () => ({ merged: { + tools: { workflowsEnabled: !runtimeMounted }, advanced: { runtimeOutputDir: runtimeMounted ? '.runtime-reloaded' @@ -6878,6 +6880,7 @@ describe('runQwenServe runtime startup failures', () => { const pinnedRuntimeBaseDir = path.join(tmpDir, '.runtime-boot'); expect(primaryRuntime?.sessionRuntimeBaseDir).toBe(pinnedRuntimeBaseDir); expect(capturedRuntimeEnv['QWEN_RUNTIME_DIR']).toBe(pinnedRuntimeBaseDir); + expect(primaryRuntime?.env.workflowsEnabledBySettings).toBe(true); await workspace!.reload({ route: 'POST /workspace/reload', @@ -6894,6 +6897,7 @@ describe('runQwenServe runtime startup failures', () => { expect(capturedRuntimeEnv['QWEN_TEST_RELOAD_LEAK']).toBeUndefined(); expect(primaryRuntime?.sessionRuntimeBaseDir).toBe(pinnedRuntimeBaseDir); expect(capturedRuntimeEnv['QWEN_RUNTIME_DIR']).toBe(pinnedRuntimeBaseDir); + expect(primaryRuntime?.env.workflowsEnabledBySettings).toBe(false); } finally { if (originalBase === undefined) { delete process.env['QWEN_TEST_BOOT_BASE']; @@ -7048,21 +7052,28 @@ describe('runQwenServe runtime startup failures', () => { ); const primary = path.join(tmpDir, 'primary'); const secondary = path.join(tmpDir, 'secondary'); + const dynamic = path.join(tmpDir, 'dynamic'); const originalRuntimeDir = process.env['QWEN_RUNTIME_DIR']; delete process.env['QWEN_RUNTIME_DIR']; fs.mkdirSync(primary); fs.mkdirSync(secondary); + fs.mkdirSync(dynamic); vi.spyOn(qwenCore, 'resolveTelemetrySettings').mockResolvedValue({ enabled: false, sensitiveSpanAttributeMaxLength: 1024 * 1024, }); let runtimeMounted = false; + let dynamicReloaded = false; vi.spyOn(settingsRuntime, 'loadSettings').mockImplementation( (...args: Parameters) => { const workspace = args[0]; const isSecondary = workspace === secondary; return { merged: { + tools: { + workflowsEnabled: + workspace === dynamic ? !dynamicReloaded : !runtimeMounted, + }, advanced: { runtimeOutputDir: isSecondary ? runtimeMounted @@ -7102,10 +7113,17 @@ describe('runQwenServe runtime startup failures', () => { let workspaceRegistry: | import('./workspace-registry.js').WorkspaceRegistry | undefined; + let createWorkspaceRuntime: + | (( + cwd: string, + options: { provenance: WorkspaceRuntimeProvenance }, + ) => Promise) + | undefined; vi.spyOn(serverModule, 'createServeApp').mockImplementation( (_opts, _getPort, deps) => { runtimeMounted = true; workspaceRegistry = deps?.workspaceRegistry; + createWorkspaceRuntime = deps?.createWorkspaceRuntime; return express(); }, ); @@ -7141,6 +7159,7 @@ describe('runQwenServe runtime startup failures', () => { pinnedRuntimeBaseDir, ); expect(env.effectiveEnv?.['QWEN_RUNTIME_DIR']).toBe(pinnedRuntimeBaseDir); + expect(env.workflowsEnabledBySettings).toBe(true); await secondaryRuntime!.workspaceService.reload({ route: 'POST /workspace/reload', @@ -7155,6 +7174,18 @@ describe('runQwenServe runtime startup failures', () => { pinnedRuntimeBaseDir, ); expect(env.effectiveEnv?.['QWEN_RUNTIME_DIR']).toBe(pinnedRuntimeBaseDir); + expect(env.workflowsEnabledBySettings).toBe(false); + + const dynamicRuntime = await createWorkspaceRuntime!(dynamic, { + provenance: 'existing', + }); + expect(dynamicRuntime.env.workflowsEnabledBySettings).toBe(true); + dynamicReloaded = true; + await dynamicRuntime.workspaceService.reload({ + route: 'POST /workspace/reload', + workspaceCwd: dynamic, + }); + expect(dynamicRuntime.env.workflowsEnabledBySettings).toBe(false); } finally { await handle.close(); if (originalRuntimeDir === undefined) { diff --git a/packages/cli/src/serve/run-qwen-serve.ts b/packages/cli/src/serve/run-qwen-serve.ts index d470502b46d..dec9f15921e 100644 --- a/packages/cli/src/serve/run-qwen-serve.ts +++ b/packages/cli/src/serve/run-qwen-serve.ts @@ -4647,6 +4647,7 @@ async function runQwenServeImpl( overlayKeys: string[]; envFilePaths: string[]; effectiveEnv: NodeJS.ProcessEnv; + workflowsEnabledBySettings: boolean; envFileReadFailed: boolean; envFileReadFailures: Array<{ path: string; error: string }>; fallbackReason?: string; @@ -4654,6 +4655,8 @@ async function runQwenServeImpl( mode: 'runtime-overlay' as const, overlayKeys: [...runtimeEnvSnapshot.overlayKeys], effectiveEnv: runtimeEffectiveEnv, + workflowsEnabledBySettings: + runtimeBootSettings?.merged.tools?.workflowsEnabled === true, envFilePaths: [...runtimeEnvSnapshot.envFilePaths], envFileReadFailed: runtimeEnvSnapshot.envFileReadFailed, envFileReadFailures: [...runtimeEnvSnapshot.envFileReadFailures], @@ -5404,6 +5407,8 @@ async function runQwenServeImpl( workspace, trustedWorkspace, ); + primaryRuntimeEnv.workflowsEnabledBySettings = + fresh.merged.tools?.workflowsEnabled === true; let refreshedRuntimeEnv: ReturnType< EnvironmentRuntime['buildRuntimeEnvironment'] >; @@ -5509,6 +5514,7 @@ async function runQwenServeImpl( overlayKeys: string[]; envFilePaths: string[]; effectiveEnv: NodeJS.ProcessEnv; + workflowsEnabledBySettings: boolean; envFileReadFailed: boolean; envFileReadFailures: Array<{ path: string; error: string }>; fallbackReason?: string; @@ -5546,6 +5552,7 @@ async function runQwenServeImpl( overlayKeys: string[]; envFilePaths: string[]; effectiveEnv: NodeJS.ProcessEnv; + workflowsEnabledBySettings: boolean; envFileReadFailed: boolean; envFileReadFailures: Array<{ path: string; error: string }>; fallbackReason?: string; @@ -5553,6 +5560,8 @@ async function runQwenServeImpl( mode: 'runtime-overlay', overlayKeys: [...snapshot.overlayKeys], effectiveEnv, + workflowsEnabledBySettings: + settings?.merged.tools?.workflowsEnabled === true, envFilePaths: [...snapshot.envFilePaths], envFileReadFailed: snapshot.envFileReadFailed, envFileReadFailures: [...snapshot.envFileReadFailures], @@ -5862,6 +5871,8 @@ async function runQwenServeImpl( workspace, secondaryTrusted, ); + secondaryEnv.metadata.workflowsEnabledBySettings = + fresh.merged.tools?.workflowsEnabled === true; try { const refreshedRuntimeEnv = settingsRuntime.environment.buildRuntimeEnvironment( @@ -6495,6 +6506,8 @@ async function runQwenServeImpl( workspace, trusted, ); + wsEnv.metadata.workflowsEnabledBySettings = + fresh.merged.tools?.workflowsEnabled === true; // Mirror the startup secondary-workspace path: rebuild the runtime // env snapshot and update the metadata so `.env` changes actually // propagate to child processes spawned by this workspace's bridge. diff --git a/packages/cli/src/serve/server.test.ts b/packages/cli/src/serve/server.test.ts index 3b6ee795c98..fbd8404ff9e 100644 --- a/packages/cli/src/serve/server.test.ts +++ b/packages/cli/src/serve/server.test.ts @@ -921,14 +921,31 @@ interface FakeBridgeOpts { sessionId: string, ) => Promise; sessionStatsImpl?: (sessionId: string) => Promise; - sessionTasksImpl?: (sessionId: string) => Promise; + sessionTasksImpl?: ( + sessionId: string, + opts?: { includeWorkflows?: boolean }, + ) => Promise; sessionLspImpl?: (sessionId: string) => Promise; + sessionSavedWorkflowImpl?: AcpSessionBridge['getSessionSavedWorkflow']; sessionTranscriptImpl?: AcpSessionBridge['getSessionTranscriptPage']; cancelSessionTaskImpl?: ( sessionId: string, taskId: string, - taskKind: 'agent' | 'shell' | 'monitor', + taskKind: 'agent' | 'shell' | 'monitor' | 'workflow', + context?: BridgeClientRequestContext, ) => Promise<{ cancelled: boolean }>; + controlSessionWorkflowTaskImpl?: ( + sessionId: string, + taskId: string, + action: + | 'pause' + | 'resume' + | 'retry' + | 'rerun' + | 'delete-history' + | 'run-saved', + context?: BridgeClientRequestContext, + ) => Promise<{ changed: boolean; status?: string; taskId?: string }>; clearSessionGoalImpl?: ( sessionId: string, ) => Promise<{ cleared: boolean; condition?: string }>; @@ -1216,14 +1233,29 @@ interface FakeBridge extends AcpSessionBridge { sessionSupportedCommandsCalls: string[]; sessionStatsCalls: string[]; sessionTasksCalls: string[]; + sessionTasksOptions: Array<{ includeWorkflows?: boolean } | undefined>; sessionLspCalls: string[]; + sessionSavedWorkflowCalls: Array<{ sessionId: string; name: string }>; sessionTranscriptCalls: Array< Parameters[0] >; cancelSessionTaskCalls: Array<{ sessionId: string; taskId: string; - taskKind: 'agent' | 'shell' | 'monitor'; + taskKind: 'agent' | 'shell' | 'monitor' | 'workflow'; + context?: BridgeClientRequestContext; + }>; + controlSessionWorkflowTaskCalls: Array<{ + sessionId: string; + taskId: string; + action: + | 'pause' + | 'resume' + | 'retry' + | 'rerun' + | 'delete-history' + | 'run-saved'; + context?: BridgeClientRequestContext; }>; clearSessionGoalCalls: string[]; controlSessionGoalCalls: Array<{ @@ -1411,9 +1443,14 @@ function fakeBridge(opts: FakeBridgeOpts = {}): FakeBridge { const sessionSupportedCommandsCalls: string[] = []; const sessionStatsCalls: string[] = []; const sessionTasksCalls: string[] = []; + 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'] = + []; const clearSessionGoalCalls: string[] = []; const controlSessionGoalCalls: FakeBridge['controlSessionGoalCalls'] = []; const continueSessionCalls: string[] = []; @@ -1733,6 +1770,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) => ({ @@ -1757,6 +1810,9 @@ function fakeBridge(opts: FakeBridgeOpts = {}): FakeBridge { })); const cancelSessionTaskImpl = opts.cancelSessionTaskImpl ?? (async () => ({ cancelled: true })); + const controlSessionWorkflowTaskImpl = + opts.controlSessionWorkflowTaskImpl ?? + (async () => ({ changed: true, status: 'pausing' })); const clearSessionGoalImpl = opts.clearSessionGoalImpl ?? (async () => ({ cleared: true })); const controlSessionGoalImpl = @@ -2025,9 +2081,12 @@ function fakeBridge(opts: FakeBridgeOpts = {}): FakeBridge { sessionSupportedCommandsCalls, sessionStatsCalls, sessionTasksCalls, + sessionTasksOptions, sessionLspCalls, + sessionSavedWorkflowCalls, sessionTranscriptCalls, cancelSessionTaskCalls, + controlSessionWorkflowTaskCalls, clearSessionGoalCalls, controlSessionGoalCalls, continueSessionCalls, @@ -2314,21 +2373,40 @@ function fakeBridge(opts: FakeBridgeOpts = {}): FakeBridge { sessionStatsCalls.push(sessionId); return sessionStatsImpl(sessionId); }, - async getSessionTasksStatus(sessionId) { + async getSessionTasksStatus(sessionId, opts) { sessionTasksCalls.push(sessionId); - return sessionTasksImpl(sessionId); + sessionTasksOptions.push(opts); + return sessionTasksImpl(sessionId, opts); }, async getSessionLspStatus(sessionId) { 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); }, - async cancelSessionTask(sessionId, taskId, taskKind) { - cancelSessionTaskCalls.push({ sessionId, taskId, taskKind }); - return cancelSessionTaskImpl(sessionId, taskId, taskKind); + async cancelSessionTask(sessionId, taskId, taskKind, context) { + cancelSessionTaskCalls.push({ + sessionId, + taskId, + taskKind, + ...(context ? { context } : {}), + }); + return cancelSessionTaskImpl(sessionId, taskId, taskKind, context); + }, + async controlSessionWorkflowTask(sessionId, taskId, action, context) { + controlSessionWorkflowTaskCalls.push({ + sessionId, + taskId, + action, + ...(context ? { context } : {}), + }); + return controlSessionWorkflowTaskImpl(sessionId, taskId, action, context); }, async clearSessionGoal(sessionId) { clearSessionGoalCalls.push(sessionId); @@ -4006,6 +4084,90 @@ describe('createServeApp', () => { }); describe('GET /capabilities', () => { + it('advertises workflow availability per workspace before a session exists', async () => { + const primaryBridge = fakeBridge(); + const primary = makeWorkspaceRuntimeForTest({ + workspaceId: 'primary-id', + workspaceCwd: WS_BOUND, + primary: true, + bridge: primaryBridge, + }); + const secondary: WorkspaceRuntime = { + ...makeWorkspaceRuntimeForTest({ + workspaceId: 'secondary-id', + workspaceCwd: '/workspace/secondary', + primary: false, + bridge: fakeBridge(), + }), + env: { + mode: 'runtime-overlay', + overlayKeys: [ + 'QWEN_CODE_ENABLE_WORKFLOWS', + 'QWEN_CODE_DISABLE_WORKFLOWS', + ], + effectiveEnv: { + QWEN_CODE_ENABLE_WORKFLOWS: '1', + QWEN_CODE_DISABLE_WORKFLOWS: '1', + }, + }, + }; + const settingsEnabled = { + ...makeWorkspaceRuntimeForTest({ + workspaceId: 'settings-enabled-id', + workspaceCwd: '/workspace/settings-enabled', + primary: false, + bridge: fakeBridge(), + }), + env: { + mode: 'runtime-overlay' as const, + overlayKeys: [], + effectiveEnv: {}, + workflowsEnabledBySettings: true, + }, + }; + const untrusted = makeWorkspaceRuntimeForTest({ + workspaceId: 'untrusted-id', + workspaceCwd: '/workspace/untrusted', + primary: false, + bridge: fakeBridge(), + trusted: false, + }); + const app = createServeApp(baseOpts, undefined, { + bridge: primaryBridge, + workspaceRegistry: createWorkspaceRegistry([ + primary, + secondary, + settingsEnabled, + untrusted, + ]), + daemonEnv: { QWEN_CODE_ENABLE_WORKFLOWS: '1' }, + }); + + const response = await request(app) + .get('/capabilities') + .set('Host', `127.0.0.1:${baseOpts.port}`); + + expect(response.status).toBe(200); + expect(response.body.workspaces).toEqual([ + expect.objectContaining({ + id: 'primary-id', + workflowsEnabled: true, + }), + expect.objectContaining({ + id: 'secondary-id', + workflowsEnabled: false, + }), + expect.objectContaining({ + id: 'settings-enabled-id', + workflowsEnabled: true, + }), + expect.objectContaining({ + id: 'untrusted-id', + workflowsEnabled: false, + }), + ]); + }); + it('advertises the effective session restore timeout', async () => { const defaultResponse = await request( createServeApp(baseOpts, undefined, { bridge: fakeBridge() }), @@ -4183,6 +4345,7 @@ describe('createServeApp', () => { workspaceRegistry: registry, createWorkspaceRuntime: vi.fn(), workspaceRegistrationStore: {} as unknown as WorkspaceRegistrationStore, + daemonEnv: {}, }); const before = await request(app) @@ -4203,6 +4366,7 @@ describe('createServeApp', () => { cwd: WS_BOUND, primary: true, trusted: true, + workflowsEnabled: false, }, ]); @@ -4246,6 +4410,7 @@ describe('createServeApp', () => { const app = createServeApp(baseOpts, undefined, { bridge: primaryBridge, workspaceRegistry: registry, + daemonEnv: {}, }); const response = await request(app) @@ -4263,6 +4428,7 @@ describe('createServeApp', () => { displayName: 'Conversations', primary: false, trusted: true, + workflowsEnabled: false, kind: 'live', }); expect(response.body.features).not.toContain('multi_workspace_sessions'); @@ -9280,7 +9446,7 @@ describe('createServeApp', () => { const app = createServeApp( { ...baseOpts, workspace: WS_BOUND }, undefined, - { bridge }, + { bridge, primaryWorkspaceTrusted: true }, ); const contextRes = await request(app) @@ -9295,6 +9461,9 @@ describe('createServeApp', () => { const tasksRes = await request(app) .get('/session/s-1/tasks') .set('Host', `127.0.0.1:${baseOpts.port}`); + const workflowTasksRes = await request(app) + .get('/session/s-1/tasks?includeWorkflows=true') + .set('Host', `127.0.0.1:${baseOpts.port}`); const lspRes = await request(app) .get('/session/s-1/lsp') .set('Host', `127.0.0.1:${baseOpts.port}`); @@ -9308,12 +9477,18 @@ describe('createServeApp', () => { expect(statsRes.body).toEqual(stats); expect(tasksRes.status).toBe(200); expect(tasksRes.body).toEqual(tasks); + expect(workflowTasksRes.status).toBe(200); + expect(workflowTasksRes.body).toEqual(tasks); expect(lspRes.status).toBe(200); expect(lspRes.body).toEqual(lsp); expect(bridge.sessionContextCalls).toEqual(['s-1']); expect(bridge.sessionSupportedCommandsCalls).toEqual(['s-1']); expect(bridge.sessionStatsCalls).toEqual(['s-1']); - expect(bridge.sessionTasksCalls).toEqual(['s-1']); + expect(bridge.sessionTasksCalls).toEqual(['s-1', 's-1']); + expect(bridge.sessionTasksOptions).toEqual([ + { includeWorkflows: false }, + { includeWorkflows: true }, + ]); expect(bridge.sessionLspCalls).toEqual(['s-1']); }); @@ -9563,12 +9738,12 @@ describe('createServeApp', () => { expect(res.status).toBe(400); expect(res.body.error).toBe( - '`kind` must be "agent", "shell", or "monitor"', + '`kind` must be "agent", "shell", "monitor", or "workflow"', ); expect(bridge.cancelSessionTaskCalls).toEqual([]); }); - it('cancels a session task through the bridge', async () => { + it('cancels a workflow task through the bridge', async () => { const bridge = fakeBridge({ cancelSessionTaskImpl: async () => ({ cancelled: true }), }); @@ -9576,20 +9751,233 @@ describe('createServeApp', () => { const app = createServeApp( { ...tokenOpts, workspace: WS_BOUND }, undefined, - { bridge }, + { bridge, primaryWorkspaceTrusted: true }, ); const res = await request(app) .post('/session/s-1/tasks/task-1/cancel') .set('Host', `127.0.0.1:${tokenOpts.port}`) .set('Authorization', 'Bearer secret') - .send({ kind: 'agent' }); + .set('X-Qwen-Client-Id', 'client-1') + .send({ kind: 'workflow' }); expect(res.status).toBe(200); expect(res.body).toEqual({ cancelled: true }); expect(bridge.cancelSessionTaskCalls).toEqual([ - { sessionId: 's-1', taskId: 'task-1', taskKind: 'agent' }, + { + sessionId: 's-1', + taskId: 'task-1', + taskKind: 'workflow', + context: { clientId: 'client-1' }, + }, + ]); + }); + + 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) => + action === 'pause' + ? { changed: true, status: 'pausing' } + : { changed: true, status: 'running' }, + }); + const tokenOpts: ServeOptions = { ...baseOpts, token: 'secret' }; + const app = createServeApp( + { ...tokenOpts, workspace: WS_BOUND }, + undefined, + { bridge, primaryWorkspaceTrusted: true }, + ); + + const pauseRes = await request(app) + .post('/session/s-1/tasks/task-1/workflow-action') + .set('Host', `127.0.0.1:${tokenOpts.port}`) + .set('Authorization', 'Bearer secret') + .set('X-Qwen-Client-Id', 'client-1') + .send({ action: 'pause' }); + const resumeRes = await request(app) + .post('/session/s-1/tasks/task-1/workflow-action') + .set('Host', `127.0.0.1:${tokenOpts.port}`) + .set('Authorization', 'Bearer secret') + .send({ action: 'resume' }); + const retryRes = await request(app) + .post('/session/s-1/tasks/task-1/workflow-action') + .set('Host', `127.0.0.1:${tokenOpts.port}`) + .set('Authorization', 'Bearer secret') + .send({ action: 'retry' }); + const rerunRes = await request(app) + .post('/session/s-1/tasks/task-1/workflow-action') + .set('Host', `127.0.0.1:${tokenOpts.port}`) + .set('Authorization', 'Bearer secret') + .send({ action: 'rerun' }); + const deleteRes = await request(app) + .post('/session/s-1/tasks/task-1/workflow-action') + .set('Host', `127.0.0.1:${tokenOpts.port}`) + .set('Authorization', 'Bearer secret') + .send({ action: 'delete-history' }); + const runSavedRes = await request(app) + .post('/session/s-1/tasks/deep-review/workflow-action') + .set('Host', `127.0.0.1:${tokenOpts.port}`) + .set('Authorization', 'Bearer secret') + .send({ action: 'run-saved' }); + + expect(pauseRes.status).toBe(200); + expect(pauseRes.body).toEqual({ changed: true, status: 'pausing' }); + expect(resumeRes.status).toBe(200); + expect(resumeRes.body).toEqual({ changed: true, status: 'running' }); + expect(retryRes.status).toBe(200); + expect(retryRes.body).toEqual({ changed: true, status: 'running' }); + expect(rerunRes.status).toBe(200); + expect(rerunRes.body).toEqual({ changed: true, status: 'running' }); + expect(deleteRes.status).toBe(200); + expect(deleteRes.body).toEqual({ changed: true, status: 'running' }); + expect(runSavedRes.status).toBe(200); + expect(runSavedRes.body).toEqual({ changed: true, status: 'running' }); + expect(bridge.controlSessionWorkflowTaskCalls).toEqual([ + { + sessionId: 's-1', + taskId: 'task-1', + action: 'pause', + context: { clientId: 'client-1' }, + }, + { sessionId: 's-1', taskId: 'task-1', action: 'resume' }, + { sessionId: 's-1', taskId: 'task-1', action: 'retry' }, + { sessionId: 's-1', taskId: 'task-1', action: 'rerun' }, + { sessionId: 's-1', taskId: 'task-1', action: 'delete-history' }, + { sessionId: 's-1', taskId: 'deep-review', action: 'run-saved' }, + ]); + }); + + it('fails the Workflow surfaces closed for an untrusted primary workspace', async () => { + const bridge = fakeBridge({ + sessionSupportedCommandsImpl: async (sessionId) => ({ + v: 1 as const, + sessionId, + availableCommands: [ + { + name: 'init', + description: 'Initialize', + input: null, + _meta: { source: 'builtin' }, + }, + { + name: 'workflows', + description: 'Manage workflows', + input: null, + _meta: { source: 'builtin' }, + }, + ], + availableSkills: [], + workflowsEnabled: true, + savedWorkflows: [{ name: 'slow-phases', source: 'project' as const }], + }), + cancelSessionTaskImpl: async () => ({ cancelled: true }), + controlSessionWorkflowTaskImpl: async () => ({ + changed: true, + status: 'running', + taskId: 'wf-1', + }), + }); + const tokenOpts: ServeOptions = { ...baseOpts, token: 'secret' }; + const app = createServeApp( + { ...tokenOpts, workspace: WS_BOUND }, + undefined, + { + bridge, + workspaceRegistry: createWorkspaceRegistry([ + makeWorkspaceRuntimeForTest({ + workspaceId: 'primary-id', + workspaceCwd: WS_BOUND, + primary: true, + trusted: false, + bridge, + }), + ]), + }, + ); + + const commandsRes = await request(app) + .get('/session/s-1/supported-commands') + .set('Host', `127.0.0.1:${tokenOpts.port}`) + .set('Authorization', 'Bearer secret'); + const runSavedRes = await request(app) + .post('/session/s-1/tasks/slow-phases/workflow-action') + .set('Host', `127.0.0.1:${tokenOpts.port}`) + .set('Authorization', 'Bearer secret') + .send({ action: 'run-saved' }); + const cancelRes = await request(app) + .post('/session/s-1/tasks/wf-1/cancel') + .set('Host', `127.0.0.1:${tokenOpts.port}`) + .set('Authorization', 'Bearer secret') + .send({ kind: 'workflow' }); + const tasksRes = await request(app) + .get('/session/s-1/tasks?includeWorkflows=true') + .set('Host', `127.0.0.1:${tokenOpts.port}`) + .set('Authorization', 'Bearer secret'); + + expect(commandsRes.status).toBe(200); + expect(commandsRes.body).toMatchObject({ + workflowsEnabled: false, + savedWorkflows: [], + }); + expect(commandsRes.body.availableCommands).toEqual([ + expect.objectContaining({ name: 'init' }), ]); + expect(runSavedRes.status).toBe(200); + expect(runSavedRes.body).toEqual({ changed: false }); + expect(cancelRes.status).toBe(200); + expect(cancelRes.body).toEqual({ cancelled: false, reason: 'disabled' }); + expect(tasksRes.status).toBe(200); + // The untrusted-workspace gate fail-closes the read path too: the + // includeWorkflows opt-in must not reach the child. + expect(bridge.sessionTasksOptions).toEqual([{ includeWorkflows: false }]); + expect(bridge.controlSessionWorkflowTaskCalls).toEqual([]); + expect(bridge.cancelSessionTaskCalls).toEqual([]); }); it.each([ @@ -13585,7 +13973,10 @@ describe('createServeApp', () => { sessionId: 'persisted-replay', update: { sessionUpdate: 'available_commands_update', - availableCommands: [{ name: 'help', description: 'Help' }], + availableCommands: [ + { name: 'help', description: 'Help' }, + { name: 'workflows', description: 'Manage workflows' }, + ], _meta: { availableSkills: ['bugfix'], availableSkillDetails: [ @@ -13617,7 +14008,10 @@ describe('createServeApp', () => { sessionId: 'persisted-replay', update: { sessionUpdate: 'available_commands_update', - availableCommands: [{ name: 'help', description: 'Help' }], + availableCommands: [ + { name: 'help', description: 'Help' }, + { name: 'workflows', description: 'Manage workflows' }, + ], _meta: { availableSkills: ['bugfix'], availableSkillDetails: [ @@ -13638,11 +14032,26 @@ describe('createServeApp', () => { liveJournal: [journalCommandsEvent, textEvent], }), }); - const app = createServeApp(baseOpts, undefined, { bridge }); + const app = createServeApp( + { ...baseOpts, workspace: WS_BOUND }, + undefined, + { + bridge, + workspaceRegistry: createWorkspaceRegistry([ + makeWorkspaceRuntimeForTest({ + workspaceId: 'primary-id', + workspaceCwd: WS_BOUND, + primary: true, + trusted: true, + bridge, + }), + ]), + }, + ); const res = await request(app) .post('/session/persisted-replay/load') .set('Host', `127.0.0.1:${baseOpts.port}`) - .send({}); + .send({ cwd: WS_BOUND }); expect(res.status).toBe(200); expect(res.body).toMatchObject({ sessionId: 'persisted-replay' }); @@ -13734,6 +14143,61 @@ describe('createServeApp', () => { expect(JSON.stringify(res.body)).not.toContain('LEAK-CANARY-SKILL-BODY'); }); + it('redacts workflows from untrusted load response replay arrays', async () => { + const commandsEvent = { + id: 1, + v: 1, + type: 'session_update', + data: { + sessionId: 'untrusted-replay', + update: { + sessionUpdate: 'available_commands_update', + availableCommands: [ + { name: 'help', description: 'Help' }, + { name: 'workflows', description: 'Manage workflows' }, + ], + }, + }, + } satisfies BridgeEvent; + const bridge = fakeBridge({ + loadImpl: async (req) => ({ + sessionId: req.sessionId, + workspaceCwd: req.workspaceCwd, + attached: false, + clientId: 'client-load', + state: {}, + compactedReplay: [commandsEvent], + }), + }); + const app = createServeApp( + { ...baseOpts, workspace: WS_BOUND }, + undefined, + { + bridge, + workspaceRegistry: createWorkspaceRegistry([ + makeWorkspaceRuntimeForTest({ + workspaceId: 'primary-id', + workspaceCwd: WS_BOUND, + primary: true, + trusted: false, + bridge, + }), + ]), + }, + ); + + const res = await request(app) + .post('/session/untrusted-replay/load') + .set('Host', `127.0.0.1:${baseOpts.port}`) + .send({ cwd: WS_BOUND }); + + expect(res.status).toBe(200); + expect(res.body.compactedReplay[0].data.update.availableCommands).toEqual( + [{ name: 'help', description: 'Help' }], + ); + expect(commandsEvent.data.update.availableCommands).toHaveLength(2); + }); + it('passes client identity headers through to load/resume bridge calls', async () => { for (const action of ['load', 'resume'] as const) { const bridge = fakeBridge(); @@ -24835,7 +25299,10 @@ describe('createServeApp', () => { type: 'session_update', data: { sessionUpdate: 'available_commands_update', - availableCommands: [{ name: 'help', description: 'Help' }], + availableCommands: [ + { name: 'help', description: 'Help' }, + { name: 'workflows', description: 'Manage workflows' }, + ], _meta: { availableSkills: ['bugfix'], availableSkillDetails: [ @@ -24852,6 +25319,15 @@ describe('createServeApp', () => { const app = createServeApp({ ...baseOpts, workspace: wsDir }, undefined, { bridge, boundWorkspace: wsDir, + workspaceRegistry: createWorkspaceRegistry([ + makeWorkspaceRuntimeForTest({ + workspaceId: 'primary-id', + workspaceCwd: wsDir, + primary: true, + trusted: true, + bridge, + }), + ]), }); const res = await request(app) @@ -24861,12 +25337,63 @@ describe('createServeApp', () => { expect(res.status).toBe(200); const event = res.body.events[0] as { data: Record }; expect(event.data['sessionUpdate']).toBe('available_commands_update'); + expect(event.data['availableCommands']).toEqual([ + { name: 'help', description: 'Help' }, + { name: 'workflows', description: 'Manage workflows' }, + ]); const meta = event.data['_meta'] as Record; expect(meta['availableSkills']).toEqual(['bugfix']); expect(meta).not.toHaveProperty('availableSkillDetails'); expect(JSON.stringify(res.body)).not.toContain('x'.repeat(64)); }); + it('redacts workflows from untrusted transcript events', async () => { + const sid = '55555555-bbbb-cccc-dddd-aaaaaaaaaaad'; + const commandsEvent = { + v: 1, + type: 'session_update', + data: { + sessionUpdate: 'available_commands_update', + availableCommands: [ + { name: 'help', description: 'Help' }, + { name: 'workflows', description: 'Manage workflows' }, + ], + }, + } satisfies Omit; + const bridge = fakeBridge({ + sessionTranscriptImpl: async (req) => ({ + v: 1, + sessionId: req.sessionId, + events: [commandsEvent], + hasMore: false, + }), + }); + await writeTranscriptSession(sid); + const app = createServeApp({ ...baseOpts, workspace: wsDir }, undefined, { + bridge, + boundWorkspace: wsDir, + workspaceRegistry: createWorkspaceRegistry([ + makeWorkspaceRuntimeForTest({ + workspaceId: 'primary-id', + workspaceCwd: wsDir, + primary: true, + trusted: false, + bridge, + }), + ]), + }); + + const res = await request(app) + .get(`/session/${sid}/transcript`) + .set('Host', `127.0.0.1:${baseOpts.port}`); + + expect(res.status).toBe(200); + expect(res.body.events[0].data.availableCommands).toEqual([ + { name: 'help', description: 'Help' }, + ]); + expect(commandsEvent.data.availableCommands).toHaveLength(2); + }); + it('forwards an exclusive persisted-record boundary', async () => { const sid = '55555555-bbbb-cccc-dddd-bbbbbbbbbbbb'; const bridge = fakeBridge({ @@ -30527,7 +31054,10 @@ describe('GET /session/:id/events (SSE)', () => { // entries and the skill name list. const sharedUpdate = { sessionUpdate: 'available_commands_update', - availableCommands: [{ name: 'help', description: 'Help' }], + availableCommands: [ + { name: 'help', description: 'Help' }, + { name: 'workflows', description: 'Manage workflows' }, + ], _meta: { availableSkills: ['bugfix'], availableSkillDetails: [ @@ -30543,6 +31073,13 @@ describe('GET /session/:id/events (SSE)', () => { }, }; const bridge = fakeBridge({ + summaryImpl: (sessionId) => ({ + sessionId, + workspaceCwd: WS_BOUND, + createdAt: '2026-08-26T00:00:00.000Z', + clientCount: 1, + hasActivePrompt: false, + }), async *subscribeImpl() { yield { id: 1, @@ -30564,7 +31101,22 @@ describe('GET /session/:id/events (SSE)', () => { }; }, }); - const app = createServeApp(baseOpts, undefined, { bridge }); + const app = createServeApp( + { ...baseOpts, workspace: WS_BOUND }, + undefined, + { + bridge, + workspaceRegistry: createWorkspaceRegistry([ + makeWorkspaceRuntimeForTest({ + workspaceId: 'primary-id', + workspaceCwd: WS_BOUND, + primary: true, + trusted: true, + bridge, + }), + ]), + }, + ); const res = await request(app) .get('/session/sess-A/events') @@ -30605,6 +31157,7 @@ describe('GET /session/:id/events (SSE)', () => { expect(commandsUpdate['sessionUpdate']).toBe('available_commands_update'); expect(commandsUpdate['availableCommands']).toEqual([ { name: 'help', description: 'Help' }, + { name: 'workflows', description: 'Manage workflows' }, ]); const meta = commandsUpdate['_meta'] as Record; expect(meta['availableSkills']).toEqual(['bugfix']); @@ -30656,6 +31209,80 @@ describe('GET /session/:id/events (SSE)', () => { expect(payload.data!.update).not.toHaveProperty('_meta'); }); + it('redacts workflows from untrusted SSE frames', async () => { + const sharedUpdate = { + sessionUpdate: 'available_commands_update', + availableCommands: [ + { name: 'help', description: 'Help' }, + { name: 'workflows', description: 'Manage workflows' }, + ], + }; + const bridge = fakeBridge({ + summaryImpl: (sessionId) => ({ + sessionId, + workspaceCwd: WS_BOUND, + createdAt: '2026-08-26T00:00:00.000Z', + clientCount: 1, + hasActivePrompt: false, + }), + async *subscribeImpl() { + yield { + id: 1, + v: 1, + type: 'session_update', + data: { sessionId: 'sess-A', update: sharedUpdate }, + }; + }, + }); + const app = createServeApp( + { ...baseOpts, workspace: WS_BOUND }, + undefined, + { + bridge, + workspaceRegistry: createWorkspaceRegistry([ + makeWorkspaceRuntimeForTest({ + workspaceId: 'primary-id', + workspaceCwd: WS_BOUND, + primary: true, + trusted: false, + bridge, + }), + ]), + }, + ); + + const res = await request(app) + .get('/session/sess-A/events') + .set('Host', `127.0.0.1:${baseOpts.port}`); + + expect(res.status).toBe(200); + const payload = res.text + .split('\n') + .filter((line) => line.startsWith('data: ')) + .map( + (line) => + JSON.parse(line.slice('data: '.length)) as { + type?: string; + data?: { + update?: { + sessionUpdate?: string; + availableCommands?: unknown[]; + }; + }; + }, + ) + .find( + (candidate) => + candidate.type === 'session_update' && + candidate.data?.update?.sessionUpdate === 'available_commands_update', + ); + expect(payload).toBeDefined(); + expect(payload!.data!.update!.availableCommands).toEqual([ + { name: 'help', description: 'Help' }, + ]); + expect(sharedUpdate.availableCommands).toHaveLength(2); + }); + it('correlates the SSE response, daemon lifecycle log, and request span', async () => { const predecessor = '019535d9-3df7-7a61-8f6d-6f37c39c5f19'; const setAttribute = vi.fn(); diff --git a/packages/cli/src/serve/server.ts b/packages/cli/src/serve/server.ts index c01b9c37911..d2c14113e70 100644 --- a/packages/cli/src/serve/server.ts +++ b/packages/cli/src/serve/server.ts @@ -2089,6 +2089,7 @@ export function createServeApp( maxPendingPromptsPerSession: opts.maxPendingPromptsPerSession, sessionRestoreTimeoutMs, languageCodes, + daemonEnv: daemonEnvAtBoot, }); if (liveVoiceSurfaceAvailable) { diff --git a/packages/cli/src/serve/server/telemetry-catalog.test.ts b/packages/cli/src/serve/server/telemetry-catalog.test.ts index f04e17b2e91..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(61); + 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 3c351646d96..1418b271ac1 100644 --- a/packages/cli/src/serve/server/telemetry.test.ts +++ b/packages/cli/src/serve/server/telemetry.test.ts @@ -1062,17 +1062,17 @@ describe('daemonTelemetryMiddleware — recordRequest seam', () => { }); describe('legacy session telemetry route catalog', () => { - it('contains 61 unique routes with the audited 59/2 attribution split', () => { + it('contains 62 unique routes with the audited 60/2 attribution split', () => { const keys = legacySessionTelemetryRoutes.map( ({ method, path }) => `${method} ${path}`, ); - expect(keys).toHaveLength(61); - expect(new Set(keys).size).toBe(61); + expect(keys).toHaveLength(62); + expect(new Set(keys).size).toBe(62); expect( legacySessionTelemetryRoutes.filter( ({ attribution }) => attribution === 'handler_resolved', ), - ).toHaveLength(59); + ).toHaveLength(60); 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 a8765e1ee7e..d341416ebdc 100644 --- a/packages/cli/src/serve/server/telemetry.ts +++ b/packages/cli/src/serve/server/telemetry.ts @@ -181,6 +181,18 @@ export const legacySessionTelemetryRoutes = [ attribution: 'handler_resolved', route: 'POST /session/:id/tasks/:taskId/cancel', }, + { + method: 'POST', + path: '/session/:id/tasks/:taskId/workflow-action', + 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/serve/types.ts b/packages/cli/src/serve/types.ts index a36ad601f10..c6a1eb88a41 100644 --- a/packages/cli/src/serve/types.ts +++ b/packages/cli/src/serve/types.ts @@ -441,6 +441,7 @@ export interface CapabilitiesEnvelope { displayName?: string; primary: boolean; trusted: boolean; + workflowsEnabled?: boolean; removable?: boolean; kind?: 'live'; }>; diff --git a/packages/cli/src/serve/workflow-session-gate.test.ts b/packages/cli/src/serve/workflow-session-gate.test.ts new file mode 100644 index 00000000000..58c12ac60bd --- /dev/null +++ b/packages/cli/src/serve/workflow-session-gate.test.ts @@ -0,0 +1,126 @@ +import { describe, expect, it } from 'vitest'; +import type { BridgeEvent } from '@qwen-code/acp-bridge/eventBus'; +import { + redactWorkflowsFromAvailableCommandsEvent, + redactWorkflowsFromReplayArrays, +} from './workflow-session-gate.js'; + +interface CommandsData { + sessionUpdate: string; + availableCommands: Array<{ name: string; description: string }>; +} + +interface WrappedEvent extends BridgeEvent { + data: { sessionId: string; update: CommandsData }; +} + +interface FlatEvent extends BridgeEvent { + data: CommandsData; +} + +function wrappedCommandsEvent(): WrappedEvent { + return { + id: 1, + v: 1, + type: 'session_update', + data: { + sessionId: 'sess-1', + update: { + sessionUpdate: 'available_commands_update', + availableCommands: [ + { name: 'workflows', description: 'Run workflows' }, + { name: 'init', description: 'Initialize' }, + ], + }, + }, + }; +} + +function flatCommandsEvent(): FlatEvent { + return { + id: 2, + v: 1, + type: 'session_update', + data: { + sessionUpdate: 'available_commands_update', + availableCommands: [ + { name: 'workflows', description: 'Run workflows' }, + { name: 'init', description: 'Initialize' }, + ], + }, + }; +} + +describe('redactWorkflowsFromAvailableCommandsEvent', () => { + it('removes workflows from wrapped frames without mutating the source', () => { + const event = wrappedCommandsEvent(); + const shaped = redactWorkflowsFromAvailableCommandsEvent(event); + + expect(shaped.data.update.availableCommands).toEqual([ + { name: 'init', description: 'Initialize' }, + ]); + expect(event.data.update.availableCommands).toHaveLength(2); + }); + + it('removes workflows from flat persisted-transcript frames', () => { + const shaped = + redactWorkflowsFromAvailableCommandsEvent(flatCommandsEvent()); + + expect(shaped.data.availableCommands).toEqual([ + { name: 'init', description: 'Initialize' }, + ]); + }); + + it('passes through unrelated frames unchanged', () => { + const event: BridgeEvent = { + id: 3, + v: 1, + type: 'session_update', + data: { + sessionId: 'sess-1', + update: { + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: 'hi' }, + }, + }, + }; + + expect(redactWorkflowsFromAvailableCommandsEvent(event)).toBe(event); + }); + + it('passes through command frames without workflows unchanged', () => { + const event = wrappedCommandsEvent(); + event.data.update.availableCommands = [ + { name: 'init', description: 'Initialize' }, + ]; + + expect(redactWorkflowsFromAvailableCommandsEvent(event)).toBe(event); + }); +}); + +describe('redactWorkflowsFromReplayArrays', () => { + it('redacts both replay arrays without mutating them', () => { + const compactedEvent = wrappedCommandsEvent(); + const journalEvent = flatCommandsEvent(); + const session = { + sessionId: 'sess-1', + compactedReplay: [compactedEvent], + liveJournal: [journalEvent], + }; + const shaped = redactWorkflowsFromReplayArrays(session); + + expect( + (shaped.compactedReplay[0] as WrappedEvent).data.update.availableCommands, + ).toEqual([{ name: 'init', description: 'Initialize' }]); + expect((shaped.liveJournal[0] as FlatEvent).data.availableCommands).toEqual( + [{ name: 'init', description: 'Initialize' }], + ); + expect(compactedEvent.data.update.availableCommands).toHaveLength(2); + expect(journalEvent.data.availableCommands).toHaveLength(2); + }); + + it('returns its input unchanged when no replay arrays are present', () => { + const session = { sessionId: 'sess-1', compactedReplay: undefined }; + expect(redactWorkflowsFromReplayArrays(session)).toBe(session); + }); +}); diff --git a/packages/cli/src/serve/workflow-session-gate.ts b/packages/cli/src/serve/workflow-session-gate.ts new file mode 100644 index 00000000000..babccffae9a --- /dev/null +++ b/packages/cli/src/serve/workflow-session-gate.ts @@ -0,0 +1,85 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { BridgeEvent } from '@qwen-code/acp-bridge/eventBus'; +import type { ServeSessionSupportedCommandsStatus } from '@qwen-code/acp-bridge/status'; + +// The daemon's workspace trust verdict never reaches the ACP child's +// workflow gate, so the daemon boundary redacts the surfaces itself with the +// same fail-closed shape the child produces when its own gate denies them. +export function redactWorkflowsFromSupportedCommands( + status: ServeSessionSupportedCommandsStatus, +): ServeSessionSupportedCommandsStatus { + return { + ...status, + availableCommands: status.availableCommands.filter( + (command) => command.name !== 'workflows', + ), + workflowsEnabled: false, + savedWorkflows: [], + }; +} + +export function redactWorkflowsFromAvailableCommandsEvent< + T extends { type: string; data: unknown }, +>(event: T): T { + if (event.type !== 'session_update') return event; + const data = asRecord(event.data); + if (!data) return event; + const wrapped = asRecord(data['update']); + const flat = !wrapped && data['sessionUpdate'] !== undefined; + const candidate = flat ? data : wrapped; + if ( + !candidate || + candidate['sessionUpdate'] !== 'available_commands_update' + ) { + return event; + } + const availableCommands = candidate['availableCommands']; + if (!Array.isArray(availableCommands)) return event; + const filteredCommands = availableCommands.filter( + (command) => asRecord(command)?.['name'] !== 'workflows', + ); + if (filteredCommands.length === availableCommands.length) return event; + const nextCandidate = { + ...candidate, + availableCommands: filteredCommands, + }; + if (flat) return { ...event, data: nextCandidate }; + return { ...event, data: { ...data, update: nextCandidate } }; +} + +export function redactWorkflowsFromReplayArrays< + T extends { + compactedReplay?: BridgeEvent[]; + liveJournal?: BridgeEvent[]; + }, +>(session: T): T { + if (!session.compactedReplay && !session.liveJournal) return session; + return { + ...session, + ...(session.compactedReplay + ? { + compactedReplay: session.compactedReplay.map( + redactWorkflowsFromAvailableCommandsEvent, + ), + } + : {}), + ...(session.liveJournal + ? { + liveJournal: session.liveJournal.map( + redactWorkflowsFromAvailableCommandsEvent, + ), + } + : {}), + }; +} + +function asRecord(value: unknown): Record | undefined { + return typeof value === 'object' && value !== null && !Array.isArray(value) + ? (value as Record) + : undefined; +} diff --git a/packages/cli/src/serve/workspace-registry.ts b/packages/cli/src/serve/workspace-registry.ts index 92de6e20ef9..c3998e73dd7 100644 --- a/packages/cli/src/serve/workspace-registry.ts +++ b/packages/cli/src/serve/workspace-registry.ts @@ -18,6 +18,7 @@ export interface WorkspaceRuntimeEnvMetadata { readonly mode: 'parent-process' | 'runtime-overlay'; readonly overlayKeys: readonly string[]; readonly effectiveEnv?: Readonly; + readonly workflowsEnabledBySettings?: boolean; readonly envFilePaths?: readonly string[]; readonly envFileReadFailed?: boolean; readonly envFileReadFailures?: ReadonlyArray<{ diff --git a/packages/core/src/agents/runtime/workflow-runner.test.ts b/packages/core/src/agents/runtime/workflow-runner.test.ts index 243477cde42..c24fa7a9d82 100644 --- a/packages/core/src/agents/runtime/workflow-runner.test.ts +++ b/packages/core/src/agents/runtime/workflow-runner.test.ts @@ -8,14 +8,18 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { getEventListeners } from 'node:events'; import type { Config } from '../../config/config.js'; import { + getWorkflowTaskMutationKey, isTerminalWorkflowStatus, + tryWithWorkflowTaskMutation, WorkflowRunRegistry, type WorkflowTask, } from '../workflow-run-registry.js'; import { AgentEventEmitter } from './agent-events.js'; +import { WorkflowJournal, type JournalReplay } from './workflow-journal.js'; import { WorkflowRunner, WorkflowScriptNotLaunchedError, + WorkflowStartCancelledError, } from './workflow-runner.js'; import { compileWorkflowScript } from './workflow-sandbox.js'; @@ -23,12 +27,14 @@ const { createProductionDispatchMock, journalWrites, logWorkflowRunMock, + resolveSavedWorkflowScriptMock, writeLineMock, writeWorkflowSnapshotMock, } = vi.hoisted(() => ({ createProductionDispatchMock: vi.fn(), journalWrites: [] as Array<() => void>, logWorkflowRunMock: vi.fn(), + resolveSavedWorkflowScriptMock: vi.fn(), writeLineMock: vi.fn(), writeWorkflowSnapshotMock: vi.fn().mockResolvedValue(undefined), })); @@ -56,6 +62,14 @@ vi.mock('./workflow-orchestrator.js', async (importOriginal) => { }; }); +vi.mock('./workflow-saved.js', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + resolveSavedWorkflowScript: resolveSavedWorkflowScriptMock, + }; +}); + function configWithRegistry(): { config: Config; registry: WorkflowRunRegistry; @@ -95,6 +109,7 @@ describe('WorkflowRunner', () => { createProductionDispatchMock.mockReset(); journalWrites.length = 0; logWorkflowRunMock.mockClear(); + resolveSavedWorkflowScriptMock.mockReset(); writeLineMock.mockReset(); writeLineMock.mockResolvedValue(undefined); writeWorkflowSnapshotMock.mockClear(); @@ -217,7 +232,50 @@ describe('WorkflowRunner', () => { it('records a journal retry as sourced from the same run', async () => { const { config, registry } = configWithRegistry(); const runId = 'wf_1234abcd'; - const handle = await WorkflowRunner.start({ + const attempt = await tryWithWorkflowTaskMutation( + getWorkflowTaskMutationKey(config, runId), + () => + WorkflowRunner.start({ + config, + signal: new AbortController().signal, + script: 'return "retried"', + args: undefined, + resumeFromRunId: runId, + runInBackground: true, + dispatch: async () => 'unused', + }), + ); + expect(attempt.acquired).toBe(true); + if (!attempt.acquired) return; + const handle = attempt.value; + + await handle.completion; + + expect(registry.get(runId)).toMatchObject({ + runId, + sourceRunId: runId, + startMode: 'retry', + }); + }); + + it('cancels a pending background resume before registration', async () => { + const { config, registry } = configWithRegistry(); + const runId = 'wf_1234abcd'; + Object.assign(config, { + storage: { + getWorkflowRunJournalPath: () => 'probe-journal.jsonl', + }, + }); + let resolveLoad: ((replay: JournalReplay) => void) | undefined; + const loadSpy = vi + .spyOn(WorkflowJournal.prototype, 'load') + .mockImplementationOnce( + () => + new Promise((resolve) => { + resolveLoad = resolve; + }), + ); + const start = WorkflowRunner.start({ config, signal: new AbortController().signal, script: 'return "retried"', @@ -227,13 +285,116 @@ describe('WorkflowRunner', () => { dispatch: async () => 'unused', }); - await handle.completion; + try { + await vi.waitFor(() => expect(registry.isStarting(runId)).toBe(true)); + expect(registry.get(runId)).toBeUndefined(); - expect(registry.get(runId)).toMatchObject({ - runId, - sourceRunId: runId, - startMode: 'retry', + registry.abortAll(); + resolveLoad?.({ results: new Map(), started: new Map() }); + + await expect(start).rejects.toThrow('Workflow start was cancelled.'); + expect(registry.isStarting(runId)).toBe(false); + expect(registry.get(runId)).toBeUndefined(); + } finally { + resolveLoad?.({ results: new Map(), started: new Map() }); + await start.catch(() => undefined); + loadSpy.mockRestore(); + } + }); + + it('cancels a pending background script load before registration', async () => { + const { config, registry } = configWithRegistry(); + Object.assign(config, { + storage: { + getWorkflowRunJournalPath: () => 'probe-journal.jsonl', + }, }); + let finishLoad: + | ((saved: { name: string; script: string; scriptPath: string }) => void) + | undefined; + resolveSavedWorkflowScriptMock.mockImplementationOnce( + () => + new Promise((resolve) => { + finishLoad = resolve; + }), + ); + const start = WorkflowRunner.start({ + config, + signal: new AbortController().signal, + scriptPath: '/tmp/review.js', + args: undefined, + runInBackground: true, + dispatch: async () => 'unused', + }); + const saved = { + name: 'review', + script: 'return "done"', + scriptPath: '/tmp/review.js', + }; + + try { + await vi.waitFor(() => + expect(resolveSavedWorkflowScriptMock).toHaveBeenCalledOnce(), + ); + expect(registry.hasRunningEntries()).toBe(true); + expect(registry.list()).toEqual([]); + + registry.abortAll(); + finishLoad?.(saved); + + await expect(start).rejects.toThrow('Workflow start was cancelled.'); + // Typed, not a bare Error: the tool maps this to its "cancelled + // before it could start" result even when the caller's own signal + // is still live. + await expect(start).rejects.toBeInstanceOf(WorkflowStartCancelledError); + expect(registry.hasRunningEntries()).toBe(false); + expect(registry.list()).toEqual([]); + } finally { + finishLoad?.(saved); + await start.catch(() => undefined); + } + }); + + it('rejects a direct resume while history mutation owns the run', async () => { + const { config, registry } = configWithRegistry(); + const runId = 'wf_1234abcd'; + let releaseClaim: (() => void) | undefined; + let claimReady: (() => void) | undefined; + const ready = new Promise((resolve) => { + claimReady = resolve; + }); + const claim = tryWithWorkflowTaskMutation( + getWorkflowTaskMutationKey(config, runId), + async () => { + claimReady?.(); + await new Promise((resolve) => { + releaseClaim = resolve; + }); + }, + ); + await ready; + const loadSpy = vi.spyOn(WorkflowJournal.prototype, 'load'); + + try { + await expect( + WorkflowRunner.start({ + config, + signal: new AbortController().signal, + script: 'return "retried"', + args: undefined, + resumeFromRunId: runId, + runInBackground: true, + dispatch: async () => 'unused', + }), + ).rejects.toThrow(`Workflow run ${runId} is already being modified.`); + expect(loadSpy).not.toHaveBeenCalled(); + expect(registry.isStarting(runId)).toBe(false); + expect(registry.get(runId)).toBeUndefined(); + } finally { + releaseClaim?.(); + await claim; + loadSpy.mockRestore(); + } }); it('keeps one registry-owned handle through exactly-once completion', async () => { @@ -278,6 +439,41 @@ describe('WorkflowRunner', () => { expect(observed.abortCount()).toBe(1); }); + it('notifies the registry when the terminal snapshot is persisted', async () => { + const { config, registry } = configWithRegistry(); + writeWorkflowSnapshotMock.mockResolvedValue(true); + const notify = vi.spyOn(registry, 'notifySnapshotPersisted'); + const handle = await WorkflowRunner.start({ + config, + signal: new AbortController().signal, + script: 'return await agent("work")', + args: undefined, + dispatch: async () => 'done', + }); + await expect(handle.completion).resolves.toMatchObject({ ok: true }); + + expect(writeWorkflowSnapshotMock).toHaveBeenCalledOnce(); + expect(notify).toHaveBeenCalledOnce(); + expect(notify).toHaveBeenCalledWith(handle.runId); + }); + + it('does not notify when the snapshot write fails', async () => { + const { config, registry } = configWithRegistry(); + writeWorkflowSnapshotMock.mockResolvedValue(false); + const notify = vi.spyOn(registry, 'notifySnapshotPersisted'); + const handle = await WorkflowRunner.start({ + config, + signal: new AbortController().signal, + script: 'return await agent("work")', + args: undefined, + dispatch: async () => 'done', + }); + await expect(handle.completion).resolves.toMatchObject({ ok: true }); + + expect(writeWorkflowSnapshotMock).toHaveBeenCalledOnce(); + expect(notify).not.toHaveBeenCalled(); + }); + it('settles failure and caller cancellation through the same owner', async () => { const failed = configWithRegistry(); const failedObserved = observeSettlement(failed.registry); diff --git a/packages/core/src/agents/runtime/workflow-runner.ts b/packages/core/src/agents/runtime/workflow-runner.ts index 7fa318fc566..4b3c87ff3fa 100644 --- a/packages/core/src/agents/runtime/workflow-runner.ts +++ b/packages/core/src/agents/runtime/workflow-runner.ts @@ -13,7 +13,9 @@ import { createChildAbortController, } from '../../utils/abortController.js'; import { + getWorkflowTaskMutationKey, isTerminalWorkflowStatus, + tryWithWorkflowTaskMutation, type WorkflowRunRegistry, type WorkflowTask, } from '../workflow-run-registry.js'; @@ -101,103 +103,155 @@ export class WorkflowScriptNotLaunchedError extends Error { } } +/** + * A start that was cancelled before it registered — a background start by + * the caller's signal, or a start in either mode by + * `WorkflowRunRegistry.cancelStarting` / `abortAll` aborting the run's own + * controller while the caller's signal stayed live. The second source is + * why this is a class and not a bare `Error`: the tool cannot tell it from + * a genuine start failure by looking at the caller's signal, and would + * otherwise surface "cancelled" as an unexplained error. + */ +export class WorkflowStartCancelledError extends Error { + constructor() { + super('Workflow start was cancelled.'); + this.name = 'WorkflowStartCancelledError'; + } +} + export class WorkflowRunner { static async start( options: WorkflowRunnerOptions, ): Promise { - const config = options.config; - const runInBackground = options.runInBackground === true; - const budget = WorkflowBudgetImpl.fromEnv(); - const loaded = - options.scriptPath && options.script === undefined - ? await resolveSavedWorkflowScript( - { scriptPath: options.scriptPath }, - config, - ) - : undefined; - const script = loaded?.script ?? options.script ?? ''; - const scriptPath = loaded?.scriptPath ?? options.scriptPath; - - // Refuse a script that cannot compile before anything exists to clean up. - // Everything below this line has a cost that outlives a failure: a runId is - // minted, a journal file is opened, the run is registered and shows up in - // `/workflows`, and the failure path writes a snapshot and a log entry. A - // single TypeScript annotation used to produce all of that — a phantom - // failed run for a workflow that never started. Compiling first turns it - // into a plain refusal with nothing to explain afterwards. - try { - compileWorkflowScript(script); - } catch (error) { - throw new WorkflowScriptNotLaunchedError( - describeWorkflowCompileError( - error, - script.split(/\r\n|[\n\r\u2028\u2029]/).length, - ), + if (options.resumeFromRunId) { + const attempt = await tryWithWorkflowTaskMutation( + getWorkflowTaskMutationKey(options.config, options.resumeFromRunId), + () => this.startClaimed(options), ); + if (!attempt.acquired) { + throw new Error( + `Workflow run ${options.resumeFromRunId} is already being modified.`, + ); + } + return attempt.value; } + return this.startClaimed(options); + } + private static async startClaimed( + options: WorkflowRunnerOptions, + ): Promise { + const config = options.config; + const runInBackground = options.runInBackground === true; + const budget = WorkflowBudgetImpl.fromEnv(); const runId = options.resumeFromRunId ?? `wf_${randomBytes(8).toString('hex')}`; - const storage = config.storage; - const journal = storage - ? new WorkflowJournal(storage.getWorkflowRunJournalPath(runId)) - : undefined; - const resumeReplay: JournalReplay | undefined = options.resumeFromRunId - ? await journal?.load() - : undefined; - if (runInBackground && options.signal.aborted) { - throw new Error('Background workflow start was cancelled.'); - } - const callerWasAbortedBeforeStart = options.signal.aborted; const registry = config.getWorkflowRunRegistry?.(); let entry: WorkflowTask | undefined; const isCurrentEntry = (): boolean => registry === undefined || (entry !== undefined && registry.get(runId) === entry); - const controller = runInBackground - ? createAbortController() - : createChildAbortController(options.signal); - const dispatch = - options.dispatch ?? - createProductionDispatch( - config, - controller.signal, - (outputTokens) => budget.recordSpent(outputTokens), - registry - ? (emitter, dispatchId) => - isCurrentEntry() - ? registry.bridgeApprovalEvents( - runId, - emitter, - dispatchId, - entry, - ) - : () => undefined - : undefined, - ); - const orchestrator = new WorkflowOrchestrator(dispatch); + const createController = () => + runInBackground + ? createAbortController() + : createChildAbortController(options.signal); + const controller = registry + ? registry.reserveStart(runId, createController) + : createController(); + const storage = config.storage; + const journal = storage + ? new WorkflowJournal(storage.getWorkflowRunJournalPath(runId)) + : undefined; + let script: string; + let scriptPath: string | undefined; + let resumeReplay: JournalReplay | undefined; + let callerWasAbortedBeforeStart: boolean; + let orchestrator: WorkflowOrchestrator; try { - entry = registry?.register({ - runId, - toolUseId: options.toolUseId, - meta: null, - status: 'running', - startTime: Date.now(), - outputFile: '', - abortController: controller, - tokenBudgetTotal: budget.total, - script, - scriptPath, - args: options.args, - ...(options.resumeFromRunId - ? { - sourceRunId: options.resumeFromRunId, - startMode: 'retry' as const, - } - : {}), - isBackgrounded: runInBackground, - }); + const loaded = + options.scriptPath && options.script === undefined + ? await resolveSavedWorkflowScript( + { scriptPath: options.scriptPath }, + config, + ) + : undefined; + script = loaded?.script ?? options.script ?? ''; + scriptPath = loaded?.scriptPath ?? options.scriptPath; + + try { + compileWorkflowScript(script); + } catch (error) { + throw new WorkflowScriptNotLaunchedError( + describeWorkflowCompileError( + error, + script.split(/\r\n|[\n\r\u2028\u2029]/).length, + ), + ); + } + + resumeReplay = options.resumeFromRunId + ? await journal?.load() + : undefined; + // A registry-side cancel (`cancelStarting`, `abortAll`) aborts the + // reserved controller while the caller's signal stays live. It is a + // cancel in either mode: registering anyway would let the settlement + // classifier — which only knows the caller's signal and the entry's + // status — record the run as failed, or completed for a dispatch-free + // script, under a client that was just told `{cancelled: true}`. + if (controller.signal.aborted && !options.signal.aborted) { + throw new WorkflowStartCancelledError(); + } + // The caller's own abort is reported the same way for a background + // start; a foreground start registers and settles `cancelled` so the + // caller's tool result carries the run it asked for. + if (runInBackground && options.signal.aborted) { + throw new WorkflowStartCancelledError(); + } + callerWasAbortedBeforeStart = options.signal.aborted; + const dispatch = + options.dispatch ?? + createProductionDispatch( + config, + controller.signal, + (outputTokens) => budget.recordSpent(outputTokens), + registry + ? (emitter, dispatchId) => + isCurrentEntry() + ? registry.bridgeApprovalEvents( + runId, + emitter, + dispatchId, + entry, + ) + : () => undefined + : undefined, + ); + orchestrator = new WorkflowOrchestrator(dispatch); + entry = registry?.register( + { + runId, + toolUseId: options.toolUseId, + meta: null, + status: 'running', + startTime: Date.now(), + outputFile: '', + abortController: controller, + tokenBudgetTotal: budget.total, + script, + scriptPath, + args: options.args, + ...(options.resumeFromRunId + ? { + sourceRunId: options.resumeFromRunId, + startMode: 'retry' as const, + } + : {}), + isBackgrounded: runInBackground, + }, + controller, + ); } catch (error) { + registry?.releaseStart(runId, controller); controller.abort(); throw error; } @@ -347,7 +401,16 @@ export class WorkflowRunner { tokens_spent: entry.tokensSpent, duration_ms: (entry.endTime ?? entry.startTime) - entry.startTime, }); - await writeWorkflowSnapshot(config, entry); + const snapshotPersisted = await writeWorkflowSnapshot( + config, + entry, + ); + if (snapshotPersisted) { + // Lets the owning session retire its unpersisted history + // cache entry: once the run is safely on disk, a sibling's + // deletion must win over the stale in-memory copy. + registry?.notifySnapshotPersisted(entry.runId); + } await journal?.drain(); try { logWorkflowRun(config, telemetryEvent); diff --git a/packages/core/src/agents/workflow-run-registry.test.ts b/packages/core/src/agents/workflow-run-registry.test.ts index d2bad316663..514c8c00216 100644 --- a/packages/core/src/agents/workflow-run-registry.test.ts +++ b/packages/core/src/agents/workflow-run-registry.test.ts @@ -20,7 +20,9 @@ import { MAX_RETAINED_TERMINAL_WORKFLOWS, isActiveWorkflowStatus, isTerminalWorkflowStatus, + tryWithWorkflowTaskMutation, type WorkflowApprovalRequestCallback, + type WorkflowTaskMutationAttempt, type WorkflowTaskRegistration, type WorkflowStatus, } from './workflow-run-registry.js'; @@ -75,6 +77,47 @@ function approvalEvent( } describe('WorkflowRunRegistry', () => { + it('does not inherit a stale workflow task mutation claim', async () => { + const mutationKey = 'scope\0run\0wf_stale'; + let releaseStale: () => void = () => {}; + let releaseCompeting: () => void = () => {}; + let signalCompeting: () => void = () => {}; + const staleGate = new Promise((resolve) => { + releaseStale = resolve; + }); + const competingStarted = new Promise((resolve) => { + signalCompeting = resolve; + }); + let staleAttempt: Promise> | undefined; + + const original = await tryWithWorkflowTaskMutation( + mutationKey, + async () => { + staleAttempt = staleGate.then(() => + tryWithWorkflowTaskMutation(mutationKey, async () => 'stale'), + ); + return 'original'; + }, + ); + const competing = tryWithWorkflowTaskMutation(mutationKey, async () => { + signalCompeting(); + await new Promise((resolve) => { + releaseCompeting = resolve; + }); + return 'competing'; + }); + + await competingStarted; + releaseStale(); + const staleResult = await staleAttempt; + releaseCompeting(); + const competingResult = await competing; + + expect(original).toEqual({ acquired: true, value: 'original' }); + expect(staleResult).toEqual({ acquired: false }); + expect(competingResult).toEqual({ acquired: true, value: 'competing' }); + }); + it('records rerun lineage and notifies status observers', () => { const r = new WorkflowRunRegistry(); const onStatusChange = vi.fn(); @@ -1030,6 +1073,32 @@ describe('WorkflowRunRegistry', () => { expect(entry.notified).toBe(false); }); + it('removes only terminal entries without live handles', () => { + const r = new WorkflowRunRegistry(); + const running = r.register(reg('wf_running')); + const terminal = r.register(reg('wf_terminal')); + const held = r.register(reg('wf_held')); + const handle = { + runId: held.runId, + abort: vi.fn(), + } as unknown as WorkflowRunHandle; + r.attachHandle(handle); + r.fail(terminal.runId, 'failed', 2_000); + r.fail(held.runId, 'failed', 2_000); + const callback = vi.fn(); + r.setStatusChangeCallback(callback); + + expect(r.removeTerminal(running.runId)).toBe(false); + expect(r.removeTerminal(held.runId)).toBe(false); + expect(r.removeTerminal(terminal.runId)).toBe(true); + + expect(r.get(running.runId)).toBe(running); + expect(r.get(held.runId)).toBe(held); + expect(r.get(terminal.runId)).toBeUndefined(); + expect(callback).toHaveBeenCalledOnce(); + expect(callback).toHaveBeenCalledWith(undefined); + }); + it('rejects a duplicate run id until its owner handle is released', () => { const r = new WorkflowRunRegistry(); const runId = 'wf_collision'; @@ -1049,6 +1118,59 @@ describe('WorkflowRunRegistry', () => { expect(r.register(reg(runId)).status).toBe('running'); }); + it('reserves a run id while a workflow is starting', () => { + const r = new WorkflowRunRegistry(); + const runId = 'wf_starting'; + const owner = new AbortController(); + const competing = new AbortController(); + + r.reserveStart(runId, () => owner); + + expect(r.isStarting(runId)).toBe(true); + expect(r.hasRunningEntries()).toBe(true); + expect(() => r.reserveStart(runId, () => competing)).toThrow( + /already active/, + ); + expect(() => r.register(reg(runId))).toThrow(/already active/); + + const entry = r.register(reg(runId), owner); + expect(entry.runId).toBe(runId); + expect(r.isStarting(runId)).toBe(false); + }); + + it('aborts a workflow that has not registered yet', () => { + const r = new WorkflowRunRegistry(); + const controller = new AbortController(); + + r.reserveStart('wf_starting', () => controller); + r.abortAll(); + + expect(controller.signal.aborted).toBe(true); + expect(r.isStarting('wf_starting')).toBe(true); + r.releaseStart('wf_starting', controller); + expect(r.hasRunningEntries()).toBe(false); + }); + + it('cancelStarting aborts a reserved start and leaves the release to the runner', () => { + const r = new WorkflowRunRegistry(); + const controller = new AbortController(); + + expect(r.cancelStarting('wf_absent')).toBe(false); + + r.reserveStart('wf_starting', () => controller); + expect(r.cancelStarting('wf_starting')).toBe(true); + expect(controller.signal.aborted).toBe(true); + // Same contract as abortAll: the reservation stays until the runner's + // start-failure path releases it, so a competing start cannot slip in + // between the abort and that release. + expect(r.isStarting('wf_starting')).toBe(true); + expect(() => + r.reserveStart('wf_starting', () => new AbortController()), + ).toThrow(/already active/); + r.releaseStart('wf_starting', controller); + expect(r.hasRunningEntries()).toBe(false); + }); + it('register synthesizes description from meta.name when omitted', () => { const r = new WorkflowRunRegistry(); const entry = r.register( @@ -1677,6 +1799,30 @@ describe('WorkflowRunRegistry', () => { expect(ids).not.toContain('wf_0'); }); + it('does not evict a terminal entry until its handle is released', () => { + const r = new WorkflowRunRegistry(); + const held = r.register(reg('wf_held')); + const handle = { + runId: held.runId, + abort: vi.fn(), + } as unknown as WorkflowRunHandle; + r.attachHandle(handle); + r.complete(held.runId, null, 1_000); + + for (let i = 0; i < MAX_RETAINED_TERMINAL_WORKFLOWS; i++) { + r.register(reg(`wf_new_${i}`)); + r.complete(`wf_new_${i}`, null, 2_000 + i); + } + + expect(r.get(held.runId)).toBe(held); + expect(r.list()).toHaveLength(MAX_RETAINED_TERMINAL_WORKFLOWS + 1); + + r.releaseHandle(held.runId, handle); + + expect(r.get(held.runId)).toBeUndefined(); + expect(r.list()).toHaveLength(MAX_RETAINED_TERMINAL_WORKFLOWS); + }); + it('active entries are never evicted', () => { const r = new WorkflowRunRegistry(); r.register(reg('runner')); diff --git a/packages/core/src/agents/workflow-run-registry.ts b/packages/core/src/agents/workflow-run-registry.ts index 45ba5fc0c46..0fc66c1d8e0 100644 --- a/packages/core/src/agents/workflow-run-registry.ts +++ b/packages/core/src/agents/workflow-run-registry.ts @@ -22,6 +22,8 @@ * consumer replacing the other. */ +import { AsyncLocalStorage } from 'node:async_hooks'; +import type { Config } from '../config/config.js'; import type { TaskBase, TaskRegistration } from './tasks/types.js'; import type { WorkflowMeta } from './runtime/workflow-sandbox.js'; import type { WorkflowRunHandle } from './runtime/workflow-runner.js'; @@ -44,6 +46,69 @@ import type { WorkflowDispatchState } from './runtime/workflow-dispatch-schedule const debugLogger = createDebugLogger('WORKFLOW_REGISTRY'); +const mutatingWorkflowTasks = new Map(); +const workflowTaskMutationContext = new AsyncLocalStorage< + ReadonlyMap +>(); +const inMemoryMutationScopeIds = new WeakMap(); +let nextInMemoryMutationScopeId = 1; + +export function getWorkflowTaskMutationKey( + config: Config, + taskId: string, + namespace = 'run', +): string { + const storage = config.storage as + | { getWorkflowRunsDir?: () => string } + | undefined; + const workflowRunsDir = storage?.getWorkflowRunsDir?.(); + if (workflowRunsDir) { + return `${workflowRunsDir}\0${namespace}\0${taskId}`; + } + + const owner = storage ?? config.getWorkflowRunRegistry?.() ?? config; + let scopeId = inMemoryMutationScopeIds.get(owner); + if (scopeId === undefined) { + scopeId = nextInMemoryMutationScopeId++; + inMemoryMutationScopeIds.set(owner, scopeId); + } + return `memory:${scopeId}\0${namespace}\0${taskId}`; +} + +export type WorkflowTaskMutationAttempt = + | { acquired: true; value: T } + | { acquired: false }; + +export async function tryWithWorkflowTaskMutation( + mutationKey: string, + operation: () => Promise, +): Promise> { + const inherited = workflowTaskMutationContext.getStore(); + const inheritedOwner = inherited?.get(mutationKey); + if ( + inheritedOwner !== undefined && + mutatingWorkflowTasks.get(mutationKey) === inheritedOwner + ) { + return { acquired: true, value: await operation() }; + } + if (mutatingWorkflowTasks.has(mutationKey)) return { acquired: false }; + + const owner = Symbol(mutationKey); + mutatingWorkflowTasks.set(mutationKey, owner); + const context = new Map(inherited); + context.set(mutationKey, owner); + try { + return { + acquired: true, + value: await workflowTaskMutationContext.run(context, operation), + }; + } finally { + if (mutatingWorkflowTasks.get(mutationKey) === owner) { + mutatingWorkflowTasks.delete(mutationKey); + } + } +} + /** * Cap on terminal entries retained for dialog history. Picked smaller * than `MAX_RETAINED_TERMINAL_AGENTS` (32) because workflow rows carry @@ -363,6 +428,14 @@ export type WorkflowApprovalRequestCallback = ( signal: AbortSignal, ) => void | Promise; +/** + * Fires when the runner has safely persisted a terminal run's snapshot to + * the shared store. The owning session uses this to retire its + * unpersisted history cache: once the run exists on disk, absence from + * the store means a deletion happened, not "not written yet". + */ +export type WorkflowSnapshotPersistedCallback = (runId: string) => void; + interface WorkflowApprovalRuntime { respond: AgentApprovalRequestEvent['respond']; requestController?: AbortController; @@ -372,6 +445,7 @@ interface WorkflowApprovalRuntime { export class WorkflowRunRegistry { private readonly entries = new Map(); private readonly handles = new Map(); + private readonly starting = new Map(); private registerCallback: WorkflowRunRegisterCallback | undefined; private statusChangeCallback: WorkflowRunStatusChangeCallback | undefined; @@ -379,6 +453,9 @@ export class WorkflowRunRegistry { private completionCallback: WorkflowRunCompletionCallback | undefined; private approvalChangeCallback: WorkflowApprovalChangeCallback | undefined; private approvalRequestCallback: WorkflowApprovalRequestCallback | undefined; + private snapshotPersistedCallback: + | WorkflowSnapshotPersistedCallback + | undefined; private readonly approvalRuntimes = new Map< string, WorkflowApprovalRuntime @@ -448,6 +525,22 @@ export class WorkflowRunRegistry { this.approvalRequestCallback = cb; } + setSnapshotPersistedCallback( + cb: WorkflowSnapshotPersistedCallback | undefined, + ): void { + this.snapshotPersistedCallback = cb; + } + + /** Called by the runner once a terminal run's snapshot is persisted. */ + notifySnapshotPersisted(runId: string): void { + if (!this.snapshotPersistedCallback) return; + try { + this.snapshotPersistedCallback(runId); + } catch (error) { + debugLogger.error('Failed to notify snapshot persistence:', error); + } + } + /** Fire the terminal-completion notification (best-effort). */ private emitNotification(entry: WorkflowTask): void { if (!this.notificationCallback) return; @@ -498,20 +591,90 @@ export class WorkflowRunRegistry { } } + /** + * Hold a run id for a workflow whose start is still in flight — the + * runner reserves before it loads the script and replays the journal, + * and only `register`s once both succeeded. The reservation is what + * makes the id visible to liveness and cancel checks during that + * window; the returned controller is the run's own. + */ + reserveStart( + runId: string, + createController: () => AbortController, + ): AbortController { + const existing = this.entries.get(runId); + if ( + (existing && isActiveWorkflowStatus(existing.status)) || + this.handles.has(runId) || + this.starting.has(runId) + ) { + throw new Error(`Workflow run ${runId} is already active.`); + } + const controller = createController(); + this.starting.set(runId, controller); + return controller; + } + + releaseStart(runId: string, controller: AbortController): void { + if (this.starting.get(runId) === controller) this.starting.delete(runId); + } + + isStarting(runId: string): boolean { + return this.starting.has(runId); + } + + /** + * Run ids reserved by `reserveStart` and not yet registered. A session + * reports these as active-work holds: `list()` has no entry for the + * starting window, and a daemon that judged the session idle from + * `list()` alone would close it and abort the start under the client + * that just asked for it. + */ + listStartingRunIds(): string[] { + return [...this.starting.keys()]; + } + + /** + * Cancel a run that has been reserved but not yet registered. Aborts + * the reserved controller only — the reservation itself is the + * runner's to release, in its start-failure path, exactly as after + * `abortAll`. Returns `false` when nothing is starting under `runId`, + * so a caller can fall through to the registered-entry route. + */ + cancelStarting(runId: string): boolean { + const controller = this.starting.get(runId); + if (!controller) return false; + try { + controller.abort(); + } catch (error) { + debugLogger.error('Failed to abort a starting workflow:', error); + } + return true; + } + /** * Register a new run. Mutates the registration in place to graduate * it to a `WorkflowTask` (sets `id`, `kind`, derived counters), so * callers can keep using their local reference post-register and * observers see updates without an extra `get()`. */ - register(registration: WorkflowTaskRegistration): WorkflowTask { + register( + registration: WorkflowTaskRegistration, + startController?: AbortController, + ): WorkflowTask { const existing = this.entries.get(registration.runId); + const reservedController = this.starting.get(registration.runId); if ( (existing && isActiveWorkflowStatus(existing.status)) || - this.handles.has(registration.runId) + this.handles.has(registration.runId) || + (reservedController !== undefined && + reservedController !== startController) ) { throw new Error(`Workflow run ${registration.runId} is already active.`); } + if (reservedController === startController) { + this.starting.delete(registration.runId); + } const entry = registration as WorkflowTask; entry.id = registration.runId; entry.kind = 'workflow'; @@ -596,7 +759,9 @@ export class WorkflowRunRegistry { } releaseHandle(runId: string, handle: WorkflowRunHandle): void { - if (this.handles.get(runId) === handle) this.handles.delete(runId); + if (this.handles.get(runId) !== handle) return; + this.handles.delete(runId); + this.evictTerminal(); } bridgeApprovalEvents( @@ -1171,6 +1336,21 @@ export class WorkflowRunRegistry { return this.entries.get(runId); } + removeTerminal(runId: string): boolean { + const entry = this.entries.get(runId); + if ( + !entry || + !isTerminalWorkflowStatus(entry.status) || + this.handles.has(runId) + ) { + return false; + } + this.rejectPendingApprovals(runId); + this.entries.delete(runId); + this.emitStatusChange(); + return true; + } + setLineage( runId: string, sourceRunId: string, @@ -1208,6 +1388,7 @@ export class WorkflowRunRegistry { * `reset()` so they settle terminal instead of leaking. */ hasRunningEntries(): boolean { + if (this.starting.size > 0) return true; for (const entry of this.entries.values()) { if (entry.status === 'running' || entry.status === 'pausing') { return true; @@ -1259,6 +1440,9 @@ export class WorkflowRunRegistry { abortAll(): void { const endTime = Date.now(); let lastCancelled: WorkflowTask | undefined; + for (const controller of this.starting.values()) { + controller.abort(); + } for (const entry of Array.from(this.entries.values())) { if (!isActiveWorkflowStatus(entry.status)) continue; this.rejectPendingApprovals(entry.runId, undefined, endTime); @@ -1344,8 +1528,10 @@ export class WorkflowRunRegistry { * (by `endTime`) are evicted first. */ private evictTerminal(): void { - const terminal = this.list().filter((e) => - isTerminalWorkflowStatus(e.status), + const terminal = this.list().filter( + (entry) => + isTerminalWorkflowStatus(entry.status) && + !this.handles.has(entry.runId), ); if (terminal.length <= MAX_RETAINED_TERMINAL_WORKFLOWS) return; terminal.sort((a, b) => (a.endTime ?? 0) - (b.endTime ?? 0)); @@ -1358,7 +1544,7 @@ export class WorkflowRunRegistry { } } - private emitStatusChange(entry: WorkflowTask): void { + private emitStatusChange(entry?: WorkflowTask): void { if (!this.statusChangeCallback) return; try { this.statusChangeCallback(entry); diff --git a/packages/core/src/agents/workflow-snapshot.ts b/packages/core/src/agents/workflow-snapshot.ts index 5f68800e9ac..bf8efb05942 100644 --- a/packages/core/src/agents/workflow-snapshot.ts +++ b/packages/core/src/agents/workflow-snapshot.ts @@ -114,14 +114,15 @@ function safeResult(result: unknown): unknown { * Write a run snapshot to `/workflows/.json`, then prune * the oldest snapshots beyond `MAX_RETAINED_SNAPSHOTS`. Best-effort: a write * failure is logged, not thrown (persistence is a convenience, not a - * correctness requirement). + * correctness requirement). Returns true when the snapshot file was written, + * so the caller can tell persistence apart from a swallowed failure. */ export async function writeWorkflowSnapshot( config: Config, task: WorkflowTask, -): Promise { +): Promise { const storage = config.storage; - if (!storage) return; + if (!storage) return false; try { // Project BEFORE the first await: the caller captures this at // settlement, but in-flight dispatches keep mutating the live @@ -136,8 +137,10 @@ export async function writeWorkflowSnapshot( 'utf8', ); await pruneSnapshots(dir); + return true; } catch (e) { debugLogger.warn(`writeWorkflowSnapshot failed for ${task.runId}: ${e}`); + return false; } } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 6704308c28c..7e85cff2808 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -444,6 +444,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 1a5386f8a55..5e237698fba 100644 --- a/packages/core/src/tools/workflow/workflow.test.ts +++ b/packages/core/src/tools/workflow/workflow.test.ts @@ -663,6 +663,11 @@ await agent('scan package.json') run_in_background: true, }), ).toThrow(/completion channel/i); + expect(() => + new WorkflowTool(interactiveConfig).buildSessionOwnedBackground({ + script: 'return 1', + }), + ).toThrow(/completion channel/i); interactiveRegistry.setCompletionCallback(vi.fn()); const acpConfig = { @@ -678,6 +683,32 @@ await agent('scan package.json') ).toThrow(/interactive TUI/i); }); + it('starts a session-owned background run outside the interactive TUI', async () => { + const registry = new WorkflowRunRegistry(); + registry.setCompletionCallback(vi.fn()); + const config = { + storage: new Storage(path.join(os.tmpdir(), 'workflow-session-owned')), + isInteractive: () => false, + getWorkflowRunRegistry: () => registry, + getSkipWorkflowUsageWarning: () => true, + } as unknown as Config; + + const result = await new WorkflowTool(config, { + dispatch: async () => 'unused', + }) + .buildSessionOwnedBackground({ + script: `phase('Inspect'); return { status: 'ready' };`, + }) + .execute(new AbortController().signal); + + expect(result.workflowRunId).toMatch(/^wf_[0-9a-f]+$/); + const run = registry.get(result.workflowRunId!); + expect(run?.isBackgrounded).toBe(true); + await vi.waitFor(() => + expect(registry.get(result.workflowRunId!)?.status).toBe('completed'), + ); + }); + it('does not register a background run when the caller is already aborted', async () => { const registry = new WorkflowRunRegistry(); registry.setCompletionCallback(vi.fn()); @@ -701,6 +732,92 @@ await agent('scan package.json') expect(dispatch).not.toHaveBeenCalled(); }); + it('reports a registry-side cancel during background preflight as cancelled, not failed', async () => { + // `sessionTaskCancel` on a run that is still loading aborts the run's + // own controller via `cancelStarting`; the caller's signal stays live, + // so the catch cannot recognise the outcome from `signal.aborted`. + const registry = new WorkflowRunRegistry(); + registry.setCompletionCallback(vi.fn()); + const config = { + storage: new Storage(path.join(os.tmpdir(), 'workflow-preflight-test')), + isInteractive: () => true, + getWorkflowRunRegistry: () => registry, + } as unknown as Config; + const caller = new AbortController(); + const dispatch = vi.fn(async () => 'unused'); + const load = vi + .spyOn(WorkflowJournal.prototype, 'load') + .mockImplementation(async () => { + expect(registry.cancelStarting('wf_1234abcd')).toBe(true); + return { results: new Map(), started: new Map() }; + }); + + try { + const result = await new WorkflowTool(config, { dispatch }) + .build({ + script: 'return 1', + resumeFromRunId: 'wf_1234abcd', + run_in_background: true, + }) + .execute(caller.signal); + + expect(caller.signal.aborted).toBe(false); + expect(result).toEqual({ + llmContent: 'Workflow was cancelled before it could start.', + returnDisplay: 'Workflow cancelled.', + }); + expect(registry.list()).toHaveLength(0); + expect(registry.isStarting('wf_1234abcd')).toBe(false); + expect(dispatch).not.toHaveBeenCalled(); + } finally { + load.mockRestore(); + } + }); + + it('reports a registry-side cancel during foreground preflight as cancelled, not failed', async () => { + // The foreground path is the tool's default mode, and the same + // registry-side sources reach it: `sessionTaskCancel` fires + // `cancelStarting` on a resume whose terminal entry was evicted from + // the registry, and `abortAll` on session dispose. Registering anyway + // let the settlement classifier — blind to the run's own controller — + // settle the run `completed` for this dispatch-free script. + const registry = new WorkflowRunRegistry(); + registry.setCompletionCallback(vi.fn()); + const config = { + storage: new Storage(path.join(os.tmpdir(), 'workflow-preflight-test')), + isInteractive: () => true, + getWorkflowRunRegistry: () => registry, + } as unknown as Config; + const caller = new AbortController(); + const dispatch = vi.fn(async () => 'unused'); + const load = vi + .spyOn(WorkflowJournal.prototype, 'load') + .mockImplementation(async () => { + expect(registry.cancelStarting('wf_1234abcd')).toBe(true); + return { results: new Map(), started: new Map() }; + }); + + try { + const result = await new WorkflowTool(config, { dispatch }) + .build({ + script: 'return 1', + resumeFromRunId: 'wf_1234abcd', + }) + .execute(caller.signal); + + expect(caller.signal.aborted).toBe(false); + expect(result).toEqual({ + llmContent: 'Workflow was cancelled before it could start.', + returnDisplay: 'Workflow cancelled.', + }); + expect(registry.list()).toHaveLength(0); + expect(registry.isStarting('wf_1234abcd')).toBe(false); + expect(dispatch).not.toHaveBeenCalled(); + } finally { + load.mockRestore(); + } + }); + it('does not register when cancellation arrives during background preflight', async () => { const registry = new WorkflowRunRegistry(); registry.setCompletionCallback(vi.fn()); diff --git a/packages/core/src/tools/workflow/workflow.ts b/packages/core/src/tools/workflow/workflow.ts index 98e1999809b..00b54aa0468 100644 --- a/packages/core/src/tools/workflow/workflow.ts +++ b/packages/core/src/tools/workflow/workflow.ts @@ -61,6 +61,7 @@ import { import { WorkflowRunner, WorkflowScriptNotLaunchedError, + WorkflowStartCancelledError, type WorkflowRunHandle, } from '../../agents/runtime/workflow-runner.js'; import { isSymlinkedRoot } from '../../agents/runtime/workflow-saved.js'; @@ -361,7 +362,7 @@ class WorkflowToolInvocation extends BaseToolInvocation< ): Promise { const runInBackground = this.params.run_in_background === true; if (runInBackground && signal.aborted) { - return backgroundStartCancelledResult(); + return startCancelledResult(); } let handle: WorkflowRunHandle; try { @@ -381,8 +382,18 @@ class WorkflowToolInvocation extends BaseToolInvocation< : undefined, }); } catch (error) { - if (runInBackground && signal.aborted) { - return backgroundStartCancelledResult(); + // Two cancel sources reach a start before it registers: the caller's + // own signal (background only — a foreground start registers and + // settles `cancelled` instead), and a registry-side cancel + // (`cancelStarting`, `abortAll`) that aborts the run's controller + // while the caller's signal stays live, in either mode. The runner + // reports the latter with a typed error; both are the same outcome + // to the model. + if ( + error instanceof WorkflowStartCancelledError || + (runInBackground && signal.aborted) + ) { + return startCancelledResult(); } // A script that never compiled has no run behind it, so reporting it as // a failed workflow would be wrong twice: it invites the model to go @@ -522,7 +533,7 @@ class WorkflowToolInvocation extends BaseToolInvocation< } } -function backgroundStartCancelledResult(): WorkflowToolResult { +function startCancelledResult(): WorkflowToolResult { return { llmContent: 'Workflow was cancelled before it could start.', returnDisplay: 'Workflow cancelled.', @@ -972,6 +983,21 @@ export class WorkflowTool extends BaseDeclarativeTool< ); } + buildSessionOwnedBackground( + params: Omit, + ): ToolInvocation { + const validationError = this.validateToolParams(params); + if (validationError) { + throw new Error(validationError); + } + if (!this.config.getWorkflowRunRegistry().hasCompletionCallback()) { + throw new Error( + 'WorkflowTool: session-owned background runs require an active workflow completion channel.', + ); + } + return this.createInvocation({ ...params, run_in_background: true }); + } + protected override validateToolParamValues( params: WorkflowParams, ): string | null { diff --git a/packages/sdk-typescript/scripts/build.js b/packages/sdk-typescript/scripts/build.js index 7776114dc38..aa60ba90c8e 100644 --- a/packages/sdk-typescript/scripts/build.js +++ b/packages/sdk-typescript/scripts/build.js @@ -117,6 +117,8 @@ const rootDir = join(__dirname, '..'); // avoid complete Web Shell projection on every streamed text update. // Bumped from 208KB to 215KB for the complete standalone-session lifecycle, // response validation, and outcome-unknown recovery surface. +// Workflow task status and control APIs merge within this budget; keep the +// measured combined bundle bounded here. const MAX_DAEMON_BROWSER_BUNDLE_BYTES = 215 * 1024; // The opt-in `daemon/transports` browser bundle legitimately ships the concrete // ACP transports (AcpHttpTransport/AcpWsTransport/AutoReconnect + negotiate), so diff --git a/packages/sdk-typescript/src/daemon/DaemonClient.ts b/packages/sdk-typescript/src/daemon/DaemonClient.ts index 951526342fb..dac5476eca7 100644 --- a/packages/sdk-typescript/src/daemon/DaemonClient.ts +++ b/packages/sdk-typescript/src/daemon/DaemonClient.ts @@ -59,6 +59,7 @@ import type { DaemonSessionGroupInput, DaemonSessionGroupUpdate, DaemonSessionLspStatus, + DaemonSessionSavedWorkflowStatus, DaemonSessionListPage, DaemonSessionListPageOptions, DaemonWorkspaceSessionInfo, @@ -73,8 +74,10 @@ import type { DaemonUsageRange, DaemonStatusReport, DaemonStatusReportDetail, - DaemonSessionTaskStatus, + DaemonSessionTaskWithWorkflowStatus, DaemonSessionTasksStatus, + DaemonSessionWorkflowTaskStatus, + DaemonSessionWorkflowTasksStatus, DaemonUpdateAgentRequest, DaemonWorkspaceFile, DaemonWorkspaceFileBytes, @@ -3268,6 +3271,42 @@ export class DaemonClient { ); } + async sessionWorkflowTasks( + sessionId: string, + clientId?: string, + ): Promise { + return await this.fetchWithTimeout( + `${this.baseUrl}/session/${urlEncode(sessionId)}/tasks?includeWorkflows=true`, + { headers: this.headers({}, clientId) }, + async (res) => { + if (!res.ok) { + throw await this.failOnError(res, 'GET /session/:id/tasks'); + } + return (await res.json()) as DaemonSessionWorkflowTasksStatus; + }, + ); + } + + 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, @@ -3287,24 +3326,63 @@ export class DaemonClient { async sessionTaskCancel( sessionId: string, taskId: string, - kind: DaemonSessionTaskStatus['kind'], + kind: DaemonSessionTaskWithWorkflowStatus['kind'], clientId?: string, ): Promise<{ cancelled: boolean }> { + return await this.sessionTaskMutation<{ cancelled: boolean }>( + sessionId, + taskId, + 'cancel', + { kind }, + clientId, + ); + } + + async sessionWorkflowTaskAction( + sessionId: string, + taskId: string, + action: + | 'pause' + | 'resume' + | 'retry' + | 'rerun' + | 'delete-history' + | 'run-saved', + clientId?: string, + ): Promise<{ + changed: boolean; + status?: DaemonSessionWorkflowTaskStatus['status']; + taskId?: string; + }> { + return await this.sessionTaskMutation<{ + changed: boolean; + status?: DaemonSessionWorkflowTaskStatus['status']; + taskId?: string; + }>(sessionId, taskId, 'workflow-action', { action }, clientId); + } + + private async sessionTaskMutation( + sessionId: string, + taskId: string, + route: 'cancel' | 'workflow-action', + body: object, + clientId?: string, + ): Promise { return await this.fetchWithTimeout( - `${this.baseUrl}/session/${urlEncode(sessionId)}/tasks/${urlEncode(taskId)}/cancel`, + `${this.baseUrl}/session/${urlEncode(sessionId)}/tasks/${urlEncode(taskId)}/${route}`, { method: 'POST', headers: this.headers({ 'Content-Type': 'application/json' }, clientId), - body: JSON.stringify({ kind }), + body: JSON.stringify(body), }, async (res) => { if (!res.ok) { throw await this.failOnError( res, - 'POST /session/:id/tasks/:taskId/cancel', + `POST /session/:id/tasks/:taskId/${route}`, ); } - return (await res.json()) as { cancelled: boolean }; + return (await res.json()) as T; }, ); } diff --git a/packages/sdk-typescript/src/daemon/DaemonSessionClient.ts b/packages/sdk-typescript/src/daemon/DaemonSessionClient.ts index 5d0bd77f21c..422210e9c28 100644 --- a/packages/sdk-typescript/src/daemon/DaemonSessionClient.ts +++ b/packages/sdk-typescript/src/daemon/DaemonSessionClient.ts @@ -47,8 +47,11 @@ import type { DaemonSession, DaemonSessionStatsStatus, DaemonSessionSupportedCommandsStatus, - DaemonSessionTaskStatus, + DaemonSessionTaskWithWorkflowStatus, DaemonSessionTasksStatus, + DaemonSessionWorkflowTaskStatus, + DaemonSessionWorkflowTasksStatus, + DaemonSessionSavedWorkflowStatus, HeartbeatResult, GoalControlRequest, GoalStateResponse, @@ -984,13 +987,25 @@ export class DaemonSessionClient { return this.client.sessionTasks(this.sessionId, this.clientId); } + workflowTasks(): Promise { + 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); } cancelTask( taskId: string, - kind: DaemonSessionTaskStatus['kind'], + kind: DaemonSessionTaskWithWorkflowStatus['kind'], ): Promise<{ cancelled: boolean }> { return this.client.sessionTaskCancel( this.sessionId, @@ -1000,6 +1015,22 @@ export class DaemonSessionClient { ); } + controlWorkflowTask( + taskId: string, + action: 'pause' | 'resume' | 'retry' | 'rerun' | 'delete-history', + ): Promise<{ + changed: boolean; + status?: DaemonSessionWorkflowTaskStatus['status']; + taskId?: string; + }> { + return this.client.sessionWorkflowTaskAction( + this.sessionId, + taskId, + action, + this.clientId, + ); + } + clearGoal(): Promise<{ cleared: boolean; condition?: string }> { return this.client.sessionGoalClear(this.sessionId, this.clientId); } diff --git a/packages/sdk-typescript/src/daemon/acpRouteTable.ts b/packages/sdk-typescript/src/daemon/acpRouteTable.ts index f29cd5e408a..a2fd3d36c2b 100644 --- a/packages/sdk-typescript/src/daemon/acpRouteTable.ts +++ b/packages/sdk-typescript/src/daemon/acpRouteTable.ts @@ -431,7 +431,36 @@ export const ROUTE_TABLE: readonly RouteEntry[] = [ pattern: /^\/session\/([^/]+)\/tasks$/, mapping: { method: '_qwen/session/tasks', - extractParams: (segs) => ({ sessionId: segs[0] }), + extractParams: (segs, _body, _method, query) => ({ + sessionId: segs[0], + ...boolParam(query, 'includeWorkflows'), + }), + }, + }, + // POST /session/:id/tasks/:taskId/cancel → _qwen/session/tasks/cancel + { + httpMethod: 'POST', + pattern: /^\/session\/([^/]+)\/tasks\/([^/]+)\/cancel$/, + mapping: { + method: '_qwen/session/tasks/cancel', + extractParams: (segs, body) => ({ + ...bodyRecord(body), + sessionId: segs[0], + taskId: segs[1], + }), + }, + }, + // POST /session/:id/tasks/:taskId/workflow-action → _qwen/session/tasks/workflow_action + { + httpMethod: 'POST', + pattern: /^\/session\/([^/]+)\/tasks\/([^/]+)\/workflow-action$/, + mapping: { + method: '_qwen/session/tasks/workflow_action', + extractParams: (segs, body) => ({ + ...bodyRecord(body), + sessionId: segs[0], + taskId: segs[1], + }), }, }, // GET /session/:id/lsp -> _qwen/session/lsp @@ -443,6 +472,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 01143665a48..f8d7bcdfb66 100644 --- a/packages/sdk-typescript/src/daemon/index.ts +++ b/packages/sdk-typescript/src/daemon/index.ts @@ -590,6 +590,12 @@ export type { DaemonSessionLspStatus, DaemonSessionAgentTaskStatus, DaemonSessionMonitorTaskStatus, + DaemonSessionWorkflowTaskStatus, + DaemonWorkflowApprovalStatusEntry, + DaemonWorkflowDispatchStatus, + DaemonWorkflowDispatchStatusEntry, + DaemonWorkflowEvent, + DaemonWorkflowPhaseVisit, DaemonSessionProcessTaskLifecycleStatus, DaemonSessionContextUsage, DaemonSessionContextUsageStatus, @@ -621,7 +627,12 @@ export type { DaemonSessionSupportedCommandsStatus, DaemonSessionTaskLifecycleStatus, DaemonSessionTaskStatus, + 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 d910f04ed76..04ab8b36c31 100644 --- a/packages/sdk-typescript/src/daemon/types.ts +++ b/packages/sdk-typescript/src/daemon/types.ts @@ -111,6 +111,8 @@ export interface DaemonWorkspaceCapability { displayName?: string; primary: boolean; trusted: boolean; + /** Whether new sessions in this workspace can use Workflow. */ + workflowsEnabled?: boolean; /** Whether this runtime can be removed without restarting the daemon. */ removable?: boolean; /** Daemon-owned Live conversation runtime. */ @@ -2510,6 +2512,49 @@ export interface DaemonSessionSupportedCommandsStatus { sessionId: string; availableCommands: DaemonAvailableCommand[]; availableSkills: string[]; + /** Whether Workflow is available for this session. */ + workflowsEnabled?: boolean; + /** Reusable workflow definitions visible to this session. */ + savedWorkflows?: Array<{ + name: string; + source: 'project' | 'user'; + }>; +} + +/** 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 = @@ -2598,11 +2643,128 @@ export interface DaemonSessionMonitorTaskStatus { toolUseId?: string; } +export interface DaemonWorkflowPhaseVisit { + id: string; + index: number; + title: string; + startedAt: number; + endedAt?: number; +} + +export type DaemonWorkflowDispatchStatus = + | 'queued' + | 'running' + | 'completed' + | 'failed' + | 'cancelled' + | 'cached'; + +export interface DaemonWorkflowDispatchStatusEntry { + id: string; + phaseVisitId: string | null; + label: string; + prompt: string; + subagentId?: string; + status: DaemonWorkflowDispatchStatus; + dependsOn: string[]; + queuedAt: number; + startedAt?: number; + endedAt?: number; + error?: string; +} + +export interface DaemonWorkflowApprovalStatusEntry { + approvalId: string; + subagentId: string; + name: string; + description: string; + at: number; +} + +interface DaemonWorkflowEventBase { + id: string; + at: number; +} + +export type DaemonWorkflowEvent = + | (DaemonWorkflowEventBase & { + type: 'phase-started'; + phaseVisitId: string; + title: string; + }) + | (DaemonWorkflowEventBase & { + type: 'phase-completed'; + phaseVisitId: string; + }) + | (DaemonWorkflowEventBase & { + type: + | 'dispatch-queued' + | 'dispatch-started' + | 'dispatch-completed' + | 'dispatch-cancelled' + | 'dispatch-cached'; + dispatchId: string; + }) + | (DaemonWorkflowEventBase & { + type: 'dispatch-failed'; + dispatchId: string; + error: string; + }) + | (DaemonWorkflowEventBase & { type: 'log'; message: string }) + | (DaemonWorkflowEventBase & { + type: 'approval-requested' | 'approval-settled'; + name: string; + dispatchId?: string; + }) + | (DaemonWorkflowEventBase & { + type: 'workflow-completed' | 'workflow-cancelled'; + }) + | (DaemonWorkflowEventBase & { + type: 'workflow-failed'; + error: string; + }); + +export interface DaemonSessionWorkflowTaskStatus { + kind: 'workflow'; + id: string; + /** Tool call in the parent session that launched this workflow. */ + toolUseId?: string; + /** Restored from the project snapshot store; controls are read-only. */ + isHistorical?: boolean; + sourceRunId?: string; + startMode?: 'retry' | 'rerun'; + label: string; + description: string; + status: DaemonSessionTaskLifecycleStatus | 'pausing'; + startTime: number; + endTime?: number; + runtimeMs: number; + outputFile?: string; + isBackgrounded: boolean; + currentPhase: string | null; + phaseVisits: DaemonWorkflowPhaseVisit[]; + dispatches: DaemonWorkflowDispatchStatusEntry[]; + agentsDispatched: number; + agentsCompleted: number; + tokensSpent: number; + tokenBudgetTotal: number | null; + recentLogs: string[]; + /** Ordered runtime facts; absent for snapshots created before event tracing. */ + events?: DaemonWorkflowEvent[]; + pendingApprovalCount: number; + pendingApprovals?: DaemonWorkflowApprovalStatusEntry[]; + error?: string; +} + export type DaemonSessionTaskStatus = | DaemonSessionAgentTaskStatus | DaemonSessionShellTaskStatus | DaemonSessionMonitorTaskStatus; +export type DaemonSessionTaskWithWorkflowStatus = + | DaemonSessionTaskStatus + | DaemonSessionWorkflowTaskStatus; + export interface DaemonSessionTasksStatus { v: 1; sessionId: string; @@ -2610,6 +2772,13 @@ export interface DaemonSessionTasksStatus { tasks: DaemonSessionTaskStatus[]; } +export interface DaemonSessionWorkflowTasksStatus { + v: 1; + sessionId: string; + now: number; + tasks: DaemonSessionTaskWithWorkflowStatus[]; +} + export interface DaemonLspServerStatus { name: string; status: 'NOT_STARTED' | 'IN_PROGRESS' | 'READY' | 'FAILED'; diff --git a/packages/sdk-typescript/src/index.ts b/packages/sdk-typescript/src/index.ts index f3fddd28393..ee0e69f5a78 100644 --- a/packages/sdk-typescript/src/index.ts +++ b/packages/sdk-typescript/src/index.ts @@ -214,6 +214,12 @@ export { type DaemonSessionLspStatus, type DaemonSessionAgentTaskStatus, type DaemonSessionMonitorTaskStatus, + type DaemonSessionWorkflowTaskStatus, + type DaemonWorkflowApprovalStatusEntry, + type DaemonWorkflowDispatchStatus, + type DaemonWorkflowDispatchStatusEntry, + type DaemonWorkflowEvent, + type DaemonWorkflowPhaseVisit, type DaemonSessionProcessTaskLifecycleStatus, type DaemonSessionDiedData, type DaemonSessionDiedEvent, @@ -243,7 +249,12 @@ export { type DaemonSessionSupportedCommandsStatus, type DaemonSessionTaskLifecycleStatus, type DaemonSessionTaskStatus, + 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 283a3a5c23d..b3df862131b 100644 --- a/packages/sdk-typescript/test/unit/DaemonClient.test.ts +++ b/packages/sdk-typescript/test/unit/DaemonClient.test.ts @@ -1382,6 +1382,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, @@ -1437,6 +1472,11 @@ describe('DaemonClient', () => { if (req.url.endsWith('/session/with%2Fslash/tasks')) { return jsonResponse(200, tasks); } + if ( + req.url.endsWith('/session/with%2Fslash/tasks?includeWorkflows=true') + ) { + return jsonResponse(200, tasks); + } if (req.url.endsWith('/session/with%2Fslash/lsp')) { return jsonResponse(200, lsp); } @@ -1453,6 +1493,9 @@ describe('DaemonClient', () => { await expect( client.sessionTasks('with/slash', 'client-1'), ).resolves.toEqual(tasks); + await expect( + client.sessionWorkflowTasks('with/slash', 'client-1'), + ).resolves.toEqual(tasks); await expect( client.sessionLspStatus('with/slash', 'client-1'), ).resolves.toEqual(lsp); @@ -1460,6 +1503,10 @@ describe('DaemonClient', () => { ['GET', 'http://daemon/session/with%2Fslash/context'], ['GET', 'http://daemon/session/with%2Fslash/supported-commands'], ['GET', 'http://daemon/session/with%2Fslash/tasks'], + [ + 'GET', + 'http://daemon/session/with%2Fslash/tasks?includeWorkflows=true', + ], ['GET', 'http://daemon/session/with%2Fslash/lsp'], ]); expect(calls.map((c) => c.headers['x-qwen-client-id'])).toEqual([ @@ -1467,6 +1514,7 @@ describe('DaemonClient', () => { 'client-1', 'client-1', 'client-1', + 'client-1', ]); }); }); diff --git a/packages/sdk-typescript/test/unit/DaemonSessionClient.test.ts b/packages/sdk-typescript/test/unit/DaemonSessionClient.test.ts index 8f0f9c04ee1..0005a4e2db2 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 }), @@ -1749,7 +1779,10 @@ describe('DaemonSessionClient', () => { availableSkills: ['review'], }); } - if (req.url.endsWith('/session/s-1/tasks')) { + if ( + req.url.endsWith('/session/s-1/tasks') || + req.url.endsWith('/session/s-1/tasks?includeWorkflows=true') + ) { return jsonResponse(200, { v: 1, sessionId: 's-1', @@ -1836,6 +1869,12 @@ describe('DaemonSessionClient', () => { now: 1_700_000_000_000, tasks: [], }); + await expect(session.workflowTasks()).resolves.toEqual({ + v: 1, + sessionId: 's-1', + now: 1_700_000_000_000, + tasks: [], + }); await expect(session.lspStatus()).resolves.toEqual({ v: 1, sessionId: 's-1', @@ -1870,6 +1909,7 @@ describe('DaemonSessionClient', () => { 'http://daemon/session/s-1/context', 'http://daemon/session/s-1/supported-commands', 'http://daemon/session/s-1/tasks', + 'http://daemon/session/s-1/tasks?includeWorkflows=true', 'http://daemon/session/s-1/lsp', 'http://daemon/session/s-1/cancel', 'http://daemon/permission/req-1', @@ -1890,6 +1930,7 @@ describe('DaemonSessionClient', () => { 'client-1', 'client-1', 'client-1', + 'client-1', ]); }); diff --git a/packages/sdk-typescript/test/unit/acpRouteTable.test.ts b/packages/sdk-typescript/test/unit/acpRouteTable.test.ts index 5a13fd66204..7a185a8fdf3 100644 --- a/packages/sdk-typescript/test/unit/acpRouteTable.test.ts +++ b/packages/sdk-typescript/test/unit/acpRouteTable.test.ts @@ -460,6 +460,48 @@ describe('acpRouteTable – matchRoute', () => { const result = matchRoute('/session/s17/tasks', 'GET'); expect(result).not.toBeNull(); expect(result!.mapping.method).toBe('_qwen/session/tasks'); + const params = result!.mapping.extractParams( + result!.segments, + undefined, + 'GET', + new URLSearchParams('includeWorkflows=true'), + ); + expect(params).toEqual({ sessionId: 's17', includeWorkflows: true }); + }); + + it('POST /session/:id/tasks/:taskId/cancel maps to _qwen/session/tasks/cancel', () => { + const result = matchRoute('/session/s17/tasks/task%2F1/cancel', 'POST'); + expect(result).not.toBeNull(); + expect(result!.mapping.method).toBe('_qwen/session/tasks/cancel'); + const params = result!.mapping.extractParams( + result!.segments, + { kind: 'workflow' }, + 'POST', + ); + expect(params).toEqual({ + sessionId: 's17', + taskId: 'task/1', + kind: 'workflow', + }); + }); + + it('POST /session/:id/tasks/:taskId/workflow-action maps to _qwen/session/tasks/workflow_action', () => { + const result = matchRoute( + '/session/s17/tasks/workflow%201/workflow-action', + 'POST', + ); + expect(result).not.toBeNull(); + expect(result!.mapping.method).toBe('_qwen/session/tasks/workflow_action'); + const params = result!.mapping.extractParams( + result!.segments, + { action: 'retry' }, + 'POST', + ); + expect(params).toEqual({ + sessionId: 's17', + taskId: 'workflow 1', + action: 'retry', + }); }); it('GET /session/:id/lsp maps to _qwen/session/lsp', () => { @@ -474,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 508fa51b2cd..28a43f05fec 100644 --- a/packages/web-shell/client/App.test.tsx +++ b/packages/web-shell/client/App.test.tsx @@ -54,6 +54,7 @@ type MockConnection = { }>; commands: unknown[]; skills: string[] | undefined; + supportedCommands?: { workflowsEnabled?: boolean }; capabilities: { qwenCodeVersion: string; features: string[] }; loadingTranscript: boolean; catchingUp: boolean; @@ -318,6 +319,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), @@ -505,6 +512,9 @@ const { onCreateGoal?: (condition: string) => Promise; onOpenSession?: (sessionId: string) => void; } | null, + latestWorkflowRunsProps: null as { + onCreateViaChat?: () => void; + } | null, }, rawEnqueuePrompt: vi.fn(() => true), queuedTexts: [] as string[], @@ -1675,6 +1685,17 @@ vi.doMock('./components/dialogs/GoalsDialog', async () => { }, }; }); +vi.doMock('./components/workflows/WorkflowRunsPage', async () => { + const React = await import('react'); + return { + WorkflowRunsPage: (props: { onCreateViaChat?: () => 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 { @@ -1885,7 +1906,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', @@ -1936,12 +1957,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', ); }); @@ -5150,6 +5177,7 @@ beforeEach(() => { mockConnection.missingSession = false; mockConnection.commands = []; mockConnection.skills = []; + mockConnection.supportedCommands = undefined; mockConnection.loadingTranscript = false; mockConnection.catchingUp = false; mockConnection.capabilities = { @@ -5246,6 +5274,7 @@ beforeEach(() => { testState.latestModelManagement = null; testState.latestScheduledTasksProps = null; testState.latestGoalsProps = null; + testState.latestWorkflowRunsProps = null; rawEnqueuePrompt.mockClear(); editorClear.mockClear(); editorCommit.mockClear(); @@ -5336,6 +5365,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(); @@ -23985,6 +24020,138 @@ describe('App /goal command', () => { }); }); +describe('App workflow history entry', () => { + 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('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 acd7d6f3a23..5161a0d98fd 100644 --- a/packages/web-shell/client/App.tsx +++ b/packages/web-shell/client/App.tsx @@ -47,7 +47,7 @@ import type { DaemonTranscriptBlock, DaemonSessionMonitorTaskStatus, DaemonSessionShellTaskStatus, - DaemonSessionTaskStatus, + DaemonSessionTaskWithWorkflowStatus, DaemonSessionArtifact, DaemonSessionSummary, DaemonWorkspaceCapability, @@ -69,7 +69,9 @@ 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 { getTaskActivityKey } from './utils/taskActivity'; import { extractVoiceModels, type VoiceModelOption } from './voice/voiceModels'; import { loadVoiceProviders, @@ -174,6 +176,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'; @@ -285,10 +288,7 @@ import { import { isDefinitelyRejectedPromptAdmission } from './utils/promptAdmission'; import { base64ToBlob } from './utils/base64'; import type { ACPToolCall, Message, PermissionRequest } from './adapters/types'; -import { - backgroundShellTaskId, - isBackgroundSubAgentToolCall, -} from './adapters/toolClassification'; +import { isBackgroundSubAgentToolCall } from './adapters/toolClassification'; import { computeTodoDetails, computeTodoTimeline, @@ -1497,38 +1497,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, @@ -1650,7 +1619,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', @@ -1843,7 +1812,7 @@ function findToolCall( } function mapToWebShellTaskInfo( - task: DaemonSessionTaskStatus, + task: DaemonSessionTaskWithWorkflowStatus, ): WebShellTaskInfo { const base = { id: task.id, @@ -1884,6 +1853,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; } @@ -2608,6 +2588,13 @@ export function App({ ordinaryWorkspaces, ], ); + 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. @@ -4302,6 +4289,7 @@ export function App({ taskActivityKey, connection.status === 'connected', backgroundTasksRefreshTrigger, + workflowsEnabled, ); const terminalBackgroundShellTaskIdsKey = useMemo( () => @@ -5093,12 +5081,11 @@ export function App({ const [gitDialog, setGitDialog] = useState< { workspaceCwd: string; gitCwd?: string; view: GitDialogView } | undefined >(undefined); - // Main content view. The scheduled-tasks page replaces the chat pane inline - // (not a modal overlay), mirroring the reference design; creating or opening - // a chat returns to 'chat'. (Daemon Status is no longer a boolean dialog — it - // is one of the activePanel values below.) + // Main content view. Full-page management views replace the chat pane inline + // (not as modal overlays); creating or opening a chat returns to 'chat'. + // Daemon Status is one of the activePanel values below. const [mainView, setMainView] = useState< - 'chat' | 'scheduledTasks' | 'goals' | 'split' + 'chat' | 'scheduledTasks' | 'workflows' | 'goals' | 'split' >('chat'); const mainViewRef = useRef(mainView); const useFloatingArtifactPanel = @@ -5401,6 +5388,16 @@ export function App({ setActivePanel(null); setMainView('scheduledTasks'); }, []); + const openWorkflows = useCallback(() => { + if (!workflowsEnabled) return; + setActivePanel(null); + setMainView('workflows'); + }, [workflowsEnabled]); + useEffect(() => { + if (mainView === 'workflows' && !workflowsEnabled) { + setMainView('chat'); + } + }, [mainView, workflowsEnabled]); const openGoals = useCallback(() => { setActivePanel(null); setMainView('goals'); @@ -5700,12 +5697,16 @@ export function App({ // need a pending-approval signal plumbed up through SplitView. Escape or // the toolbar exits fullscreen and reveals them. if (artifactPanelFullscreen) setArtifactPanelFullscreen(false); - // The Scheduled Tasks and Goals pages are full-pane overlays + // Scheduled Tasks, Workflows, and Goals are full-pane overlays // (position:absolute) that cover the chat footer too, so dismiss them for // the same reason. The split view is deliberately NOT dismissed: each pane // 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 (mainView === 'scheduledTasks' || mainView === 'goals') { + if ( + mainView === 'scheduledTasks' || + mainView === 'workflows' || + mainView === 'goals' + ) { setMainView('chat'); } }, [ @@ -9257,8 +9258,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 }); @@ -9273,6 +9276,7 @@ export function App({ requireActiveSessionForLocalCommand, sessionActions, sessionOwnerGuard, + workflowsEnabled, ]); const openEnvironmentTasksPanel = useCallback(() => { if (!requireActiveSessionForLocalCommand()) return; @@ -9280,7 +9284,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; @@ -9790,6 +9794,14 @@ export function App({ openEnvironmentTasksPanel(); return true; } + if ( + cmd === 'workflows' && + workflowsEnabled && + text.slice(match[0].length).trim().length === 0 + ) { + openWorkflows(); + return true; + } if (cmd === 'goal') { return handleGoalSlashCommand( text, @@ -10682,6 +10694,8 @@ export function App({ closeMobileDrawer, openPanel, openScheduledTasks, + openWorkflows, + workflowsEnabled, createNewSession, ensureSessionForPrompt, finishPromptPreparation, @@ -12400,6 +12414,10 @@ export function App({ closeMobileDrawer(); openScheduledTasks(); }} + onOpenWorkflows={() => { + closeMobileDrawer(); + openWorkflows(); + }} onOpenGoals={() => { closeMobileDrawer(); openGoals(); @@ -12926,6 +12944,59 @@ export function App({ )} + {mainView === 'workflows' && workflowsEnabled && ( +
+
+ +
+ {t('workflowRuns.title')} +
+
+
+ { + void createNewSession( + connection.workspaceCwd ?? + selectedWorkspaceCwdRef.current, + ).then((created) => { + if (!created) return; + onSessionIdChange?.(undefined); + window.setTimeout(() => { + editorRef.current?.insertText( + '/workflow-creator ', + { mode: 'replace' }, + ); + editorRef.current?.focus(); + }, 0); + }); + }} + /> +
+
+ )} {mainView === 'goals' && (
@@ -13275,11 +13346,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 3733c24cd4e..49f182b77c3 100644 --- a/packages/web-shell/client/adapters/toolClassification.ts +++ b/packages/web-shell/client/adapters/toolClassification.ts @@ -25,6 +25,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.tsx b/packages/web-shell/client/components/ChatPane.tsx index d47ee0f5434..9f1e053a84f 100644 --- a/packages/web-shell/client/components/ChatPane.tsx +++ b/packages/web-shell/client/components/ChatPane.tsx @@ -32,6 +32,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 { @@ -41,6 +42,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'; @@ -61,6 +63,7 @@ import { isExitPlanApprovalRequest, } from '../utils/todos'; import { findMonitorTaskForTool } from '../utils/monitorTasks'; +import { getTaskActivityKey } from '../utils/taskActivity'; import { invokeSlashCommandHandler } from '../utils/slash-command-action'; import { parseWebShellGoalCommand } from '../utils/goalCondition'; import { buildGoalControlRequest } from '../utils/goalControlRequest'; @@ -273,6 +276,17 @@ export function ChatPane({ sessionHasActivePromptRef.current = sessionHasActivePrompt; const { blocks, blockChangeSummary } = useAnimationFrameTranscriptSnapshot(); const messages = useMessagesFromBlocks(t, blocks, blockChangeSummary); + const taskActivityKey = useMemo( + () => getTaskActivityKey(messages), + [messages], + ); + const sessionTasks = useBackgroundTasks( + connection.sessionId, + taskActivityKey, + connection.status === 'connected', + 0, + sessionWorkflowEnabled, + ); const transcriptHistory = useTranscriptHistory(); const store = useTranscriptStore(); const streamingState = useStreamingState(); @@ -1310,49 +1324,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 3a832372c20..1cc5f1a6b24 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/webui/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..de9c94926d7 --- /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/webui/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 681981f1f19..752ce4ebeab 100644 --- a/packages/web-shell/client/components/messages/TasksStatusMessage.test.tsx +++ b/packages/web-shell/client/components/messages/TasksStatusMessage.test.tsx @@ -5,23 +5,31 @@ 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'; +type DaemonSessionTasksStatus = DaemonSessionWorkflowTasksStatus; + // 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(() => ({ - getTasksMock: vi.fn(), - cancelTaskMock: vi.fn(), -})); +const { getTasksMock, cancelTaskMock, controlWorkflowTaskMock } = vi.hoisted( + () => ({ + getTasksMock: vi.fn(), + cancelTaskMock: vi.fn(), + controlWorkflowTaskMock: vi.fn(), + }), +); vi.mock('@qwen-code/webui/daemon-react-sdk', () => ({ useActions: () => ({ getTasks: getTasksMock, + getWorkflowTasks: getTasksMock, cancelTask: cancelTaskMock, + controlWorkflowTask: controlWorkflowTaskMock, }), })); @@ -41,6 +49,8 @@ afterEach(() => { mounted.length = 0; getTasksMock.mockReset(); cancelTaskMock.mockReset(); + controlWorkflowTaskMock.mockReset(); + vi.useRealTimers(); }); function agentTask( @@ -80,19 +90,67 @@ function monitorTask( }; } +function workflowTask( + overrides: Partial = {}, +): DaemonSessionWorkflowTaskStatus { + return { + kind: 'workflow', + id: 'workflow-1', + 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, + recentLogs: [], + pendingApprovalCount: 0, + pendingApprovals: [], + ...overrides, + }; +} + function renderPanel( - tasks: DaemonSessionTaskStatus[], + tasks: DaemonSessionTaskWithWorkflowStatus[], options: { embedded?: boolean; + keyboardShortcuts?: boolean; + syncSnapshot?: boolean; + taskView?: 'all' | 'workflow-active' | 'workflow-history'; + sessionId?: string; + onTasksChange?: (snapshot: DaemonSessionWorkflowTasksStatus) => 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 +164,15 @@ function renderPanel( , ); @@ -154,6 +216,494 @@ 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('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; + }); + getTasksMock.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)); + + 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(); + }); + + it('opens the live graph and stops the workflow through the task API', async () => { + const task = workflowTask(); + cancelTaskMock.mockResolvedValue({ cancelled: true }); + getTasksMock.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', + }); + getTasksMock.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'); + + controlWorkflowTaskMock.mockResolvedValue({ + changed: true, + status: 'running', + }); + getTasksMock.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(); + }); + + it('retries a failed workflow path and refreshes the graph', async () => { + 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', + }); + getTasksMock.mockResolvedValue({ + v: 1, + sessionId: 'session-1', + now: 10_200, + tasks: [workflowTask()], + }); + const container = renderPanel([failed]); + 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(getTasksMock).toHaveBeenCalledOnce(); + }); + + it('reruns a failed workflow from scratch and opens the new run', async () => { + 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, + }); + getTasksMock.mockResolvedValue({ + v: 1, + sessionId: 'session-1', + now: 10_200, + tasks: [failed, rerun, agentTask('newer-agent', { startTime: 3_000 })], + }); + const container = renderPanel([failed]); + 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(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('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 }); + getTasksMock.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(getTasksMock).toHaveBeenCalledOnce(); + 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 d99f28d35f1..ccbba4e07f7 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') return []; + return tasks + .filter( + ( + candidate, + ): candidate is Extract => + candidate.kind === 'workflow' && + candidate.id !== task.id && + candidate.label === task.label, + ) + .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,12 @@ export function TasksStatusMessage({ message, embedded = false, manageActiveEvent = true, + keyboardShortcuts = true, + syncSnapshot = false, + taskView = 'all', + emptyLabel, + onWorkflowRunStarted, + onTasksChange, onClose, planTodos = [], agentTools = [], @@ -252,6 +333,12 @@ export function TasksStatusMessage({ message: SerializedTasksMessage; embedded?: boolean; manageActiveEvent?: boolean; + keyboardShortcuts?: boolean; + syncSnapshot?: boolean; + taskView?: TasksStatusView; + emptyLabel?: string; + onWorkflowRunStarted?: () => void; + onTasksChange?: (snapshot: DaemonSessionTasksStatus) => void; onClose?: () => void; planTodos?: readonly TodoItem[]; agentTools?: readonly ACPToolCall[]; @@ -260,26 +347,53 @@ export function TasksStatusMessage({ }) { const { t } = useI18n(); const actions = useActions(); - const [tasks, setTasks] = useState(() => - arrangeTasks(message.snapshot.tasks), + const includeWorkflows = + taskView !== 'all' || + message.snapshot.tasks.some((task) => task.kind === 'workflow'); + const loadTasks = useCallback( + async (): Promise => + includeWorkflows ? actions.getWorkflowTasks() : actions.getTasks(), + [actions, includeWorkflows], + ); + 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 +406,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 +434,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 +464,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 +478,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 +503,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 +523,133 @@ 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; + } + 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) { + onWorkflowRunStarted?.(); + 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 +694,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 +736,11 @@ export function TasksStatusMessage({ }, [ embedded, + keyboardShortcuts, isOpen, step, tasks.length, + clampedSelectedIndex, selectedTask, handleCancel, onOpenMonitor, @@ -523,13 +762,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 +777,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 +806,15 @@ export function TasksStatusMessage({ task.kind !== 'workflow', + )} onOpenSubagent={onOpenSubagent} />
-
{t('tasks.empty')}
+
+ {emptyLabel ?? t('tasks.empty')} +
{!embedded && (
{t('tasks.shortcut.close')}
@@ -617,7 +854,9 @@ export function TasksStatusMessage({ task.kind !== 'workflow', + )} onOpenSubagent={onOpenSubagent} /> )} @@ -658,6 +897,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={() => setSelectedTaskId(task.id)} onMouseEnter={() => { - if (!embedded) setSelectedIndex(index); + if (!embedded) setSelectedTaskId(task.id); }} > @@ -702,7 +955,11 @@ export function TasksStatusMessage({ {'↳ '} )} - {rowLabel(task, blockingIds.has(task.id))} + {rowLabel( + task, + blockingIds.has(task.id), + taskView !== 'all', + )} {orphanNote && ( {' · '} @@ -718,7 +975,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 +1023,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 +1067,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 +1381,10 @@ function TaskDetail({ busy = false, showCancelConfirm = false, onCancel, + sourceWorkflowTask, + workflowHistoryTasks, + onWorkflowAction, + onDeleteWorkflowHistory, onCancelConfirmDismiss, }: { task: DaemonSessionTaskStatus; @@ -1101,12 +1393,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 +1454,18 @@ function TaskDetail({ }); } + if (task.kind === 'workflow' && task.tokensSpent > 0) { + subtitleParts.push( + t('tasks.detail.tokens', { + count: formatContextTokens(task.tokensSpent), + }), + ); + compactFields.push({ + label: t('tasks.detail.tokenCount'), + value: formatContextTokens(task.tokensSpent), + }); + } + if (task.kind === 'agent' && task.stats?.toolUses !== undefined) { subtitleParts.push( t('tasks.detail.toolCalls', { @@ -1151,7 +1478,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 +1504,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 +1529,51 @@ function TaskDetail({ ) : ( - + <> + {(canPause || canResume) && onWorkflowAction && ( + + )} + {canRetry && onWorkflowAction && ( + + )} + {canRerun && onWorkflowAction && ( + + )} + {canCancel && onCancel && ( + + )} + )}
) : null; @@ -1224,7 +1592,7 @@ function TaskDetail({ {subtitleParts.join(' · ')}
- ) : compactFields.length > 0 ? ( + ) : task.kind === 'workflow' ? null : compactFields.length > 0 ? (
{compactFields .map((field) => `${field.label} ${field.value}`) @@ -1232,14 +1600,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 +1721,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 0e57f1a0618..3ead47fcefc 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 { @@ -135,7 +138,8 @@ function hasDetailView(tool: ACPToolCall): boolean { name === 'read_file' || name === 'readfile' || isSkillToolName(name) || - isAskUserQuestionToolName(tool.toolName) + isAskUserQuestionToolName(tool.toolName) || + name === 'workflow' ); } @@ -392,10 +396,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, @@ -961,6 +995,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; @@ -1060,6 +1095,7 @@ export const ToolLine = memo(function ToolLine({ workspaceCwd, summaryOnly = false, forceExpanded = false, + detailsVisible = true, hideHeader = false, hideCollapsedOutput = false, }: ToolLineProps) { @@ -1079,6 +1115,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 = tool.toolName.toLowerCase() === 'workflow'; useEffect( () => { @@ -1463,39 +1500,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); @@ -1639,6 +1689,9 @@ export const ToolGroup = memo(function ToolGroup({ : undefined; const singleMcpAppResourceUri = singleMcpApp?.resourceUri; const hasMcpApp = tools.some((tool) => getMcpAppDisplay(tool.rawOutput)); + const hasWorkflow = tools.some( + (tool) => tool.toolName.toLowerCase() === 'workflow', + ); const hasForegroundActiveTool = tools.some( (tool) => isActiveToolStatus(tool.status) && !isBackgroundSubAgentToolCall(tool), @@ -1759,10 +1812,20 @@ export const ToolGroup = memo(function ToolGroup({ aria-hidden="true" /> - {(chatExpanded || hasMcpApp) && ( + {(chatExpanded || hasMcpApp || hasWorkflow) && (
@@ -1821,6 +1884,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..055b0817969 --- /dev/null +++ b/packages/web-shell/client/components/messages/WorkflowExecutionView.module.css @@ -0,0 +1,886 @@ +/* + * 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; restate only + the geometry here so they sit flush with the strip. Colour is left to the + owner so the danger/primary variants keep their meaning. */ +.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; + opacity: 1; +} + +.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) + ); + opacity: 1; +} + +.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); +} + +.node[data-workflow-approval] { + --workflow-node-accent: var(--warning-color); + opacity: 1; +} + +.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; +} + +.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..65f560d0bc3 --- /dev/null +++ b/packages/web-shell/client/components/messages/WorkflowExecutionView.test.tsx @@ -0,0 +1,655 @@ +// @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('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('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..12747a3f97b --- /dev/null +++ b/packages/web-shell/client/components/messages/WorkflowExecutionView.tsx @@ -0,0 +1,966 @@ +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 = selectedName === workflow.name; + 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')} + /> + )} +
+ ); + })} +
+ )} +
+ + setTab('active')} + /> + + + setTab('active')} + /> + + + ) + )} + +
+ ); +} + +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 9577cc2048f..c5b22dd7ca9 100644 --- a/packages/web-shell/client/customization.tsx +++ b/packages/web-shell/client/customization.tsx @@ -449,10 +449,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/e2e/utils/mockDaemon.ts b/packages/web-shell/client/e2e/utils/mockDaemon.ts index b72dbbcd24b..dd34a6a2619 100644 --- a/packages/web-shell/client/e2e/utils/mockDaemon.ts +++ b/packages/web-shell/client/e2e/utils/mockDaemon.ts @@ -53,6 +53,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; @@ -378,6 +384,9 @@ export function createWebShellDaemonScenario( events: overrides.events ?? [], state, contextDelayMs: overrides.contextDelayMs, + supportedCommands: overrides.supportedCommands, + workflowTasks: overrides.workflowTasks, + savedWorkflowDetails: overrides.savedWorkflowDetails, artifacts: overrides.artifacts ?? [], workspaceFiles: overrides.workspaceFiles ?? {}, gitStatus: overrides.gitStatus, @@ -694,6 +703,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( @@ -882,8 +893,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)) ); } @@ -1589,6 +1602,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 e7bd5d7d68e..caef6e6c8c4 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(), }, })); @@ -39,7 +44,8 @@ let container: HTMLDivElement | null = null; let sessionId: string | undefined = 'session-a'; let taskActivityKey = 'monitor:running'; let refreshTrigger = 0; -let latestTasks: DaemonSessionTaskStatus[] = []; +let workflowsEnabled = false; +let latestTasks: DaemonSessionTaskWithWorkflowStatus[] = []; function deferred(): Deferred { let resolve: ((value: T) => void) | undefined; @@ -57,6 +63,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, }; } @@ -86,6 +105,7 @@ function Harness() { taskActivityKey, true, refreshTrigger, + workflowsEnabled, ); return null; } @@ -110,8 +130,10 @@ beforeEach(() => { sessionId = 'session-a'; taskActivityKey = 'monitor:running'; 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 +141,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 +191,72 @@ afterEach(async () => { }); describe('useBackgroundTasks', () => { + it('keeps polling while an active workflow is waiting to register', async () => { + taskActivityKey = 'workflow-call:in_progress'; + 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('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('keeps polling after a transient task refresh failure', async () => { const runningMonitor = monitor('monitor-a', 'running'); sdkMock.actions.getTasks diff --git a/packages/web-shell/client/hooks/useBackgroundTasks.ts b/packages/web-shell/client/hooks/useBackgroundTasks.ts index 33485f39f62..991aeed5747 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,9 +10,18 @@ import { isSessionDisconnectedError } from '../utils/sessionErrors'; const TASKS_POLL_INTERVAL_MS = 3000; const MAX_EMPTY_TASK_POLLS = 2; -function hasActiveTask(tasks: readonly DaemonSessionTaskStatus[]): boolean { +function hasActiveTaskActivity(taskActivityKey: string): boolean { + return /:(?:pending|in_progress)(?:\||$)/.test(taskActivityKey); +} + +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', ); } @@ -21,13 +30,14 @@ export function useBackgroundTasks( taskActivityKey: string, 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); @@ -56,8 +66,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 +79,7 @@ export function useBackgroundTasks( return; setTasks(snapshot.tasks); if (snapshot.tasks.length === 0) { + if (hasActiveTaskActivity(taskActivityKey)) return; emptyPollsRef.current += 1; if (emptyPollsRef.current >= MAX_EMPTY_TASK_POLLS) { setPollingActive(false); @@ -99,14 +112,26 @@ export function useBackgroundTasks( disposed = true; clearInterval(id); }; - }, [actions, connected, owner, pollingActive, sessionId, tasksPanelActive]); + }, [ + actions, + connected, + owner, + pollingActive, + sessionId, + 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 +141,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 a49dc795a99..a8f6b11fcd6 100644 --- a/packages/web-shell/client/i18n.tsx +++ b/packages/web-shell/client/i18n.tsx @@ -1139,6 +1139,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', @@ -1386,6 +1427,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', @@ -2594,12 +2636,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', @@ -2615,6 +2715,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', @@ -4241,6 +4343,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', @@ -4471,6 +4609,7 @@ const ZH: Messages = { 'sidebar.settings': '设置', 'sidebar.daemonStatus': 'Daemon 状态', 'sidebar.scheduledTasks': '定时任务', + 'sidebar.workflows': '工作流', 'sidebar.goals': '目标', 'sidebar.themeLight': '切换到浅色主题', 'sidebar.themeDark': '切换到深色主题', @@ -5579,12 +5718,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': '确认停止', @@ -5600,6 +5794,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 870b55ee438..4ecc8d27f82 100644 --- a/packages/web-shell/client/index.tsx +++ b/packages/web-shell/client/index.tsx @@ -223,6 +223,7 @@ export type { WebShellAgentTask, WebShellShellTask, WebShellMonitorTask, + WebShellWorkflowTask, WebShellPreparedSubmit, WebShellSubmitSnapshot, WebShellModelInfo, diff --git a/packages/web-shell/client/utils/composerTasks.test.ts b/packages/web-shell/client/utils/composerTasks.test.ts index 06812c82be2..46629d1ea12 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,6 +52,21 @@ describe('isComposerTask', () => { }, true, ], + [ + { + ...base, + kind: 'workflow', + isBackgrounded: true, + phaseVisits: [], + dispatches: [], + agentsDispatched: 0, + agentsCompleted: 0, + tokensSpent: 0, + recentLogs: [], + pendingApprovalCount: 0, + }, + true, + ], ]; for (const [task, expected] of tasks) { diff --git a/packages/web-shell/client/utils/composerTasks.ts b/packages/web-shell/client/utils/composerTasks.ts index fca49a2e64c..79a91ed2f5c 100644 --- a/packages/web-shell/client/utils/composerTasks.ts +++ b/packages/web-shell/client/utils/composerTasks.ts @@ -1,5 +1,7 @@ -import type { DaemonSessionTaskStatus } from '@qwen-code/sdk/daemon'; +import type { DaemonSessionTaskWithWorkflowStatus } from '@qwen-code/sdk/daemon'; -export function isComposerTask(task: DaemonSessionTaskStatus): boolean { +export function isComposerTask( + task: DaemonSessionTaskWithWorkflowStatus, +): task is Exclude { return task.kind !== 'agent'; } diff --git a/packages/web-shell/client/utils/taskActivity.ts b/packages/web-shell/client/utils/taskActivity.ts new file mode 100644 index 00000000000..5dcd68df90e --- /dev/null +++ b/packages/web-shell/client/utils/taskActivity.ts @@ -0,0 +1,38 @@ +import type { ACPToolCall, Message } from '../adapters/types'; +import { + backgroundShellTaskId, + isBackgroundSubAgentToolCall, +} from '../adapters/toolClassification'; + +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' + ); +} + +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('|'); +} 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..fb69da3a5f2 --- /dev/null +++ b/packages/web-shell/client/utils/workflowTasks.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it } from 'vitest'; +import type { + DaemonSessionTaskStatus, + 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: DaemonSessionTaskStatus[] = [ + 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('does not guess when the tool contains no run identity', () => { + expect( + findWorkflowTaskForTool( + tasks, + workflowTool({ subContent: 'Starting agents' }), + ), + ).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..8b75b61d5b9 --- /dev/null +++ b/packages/web-shell/client/utils/workflowTasks.ts @@ -0,0 +1,47 @@ +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; + return 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, + ); +} 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); +} diff --git a/packages/webui/src/daemon/session/actions.test.ts b/packages/webui/src/daemon/session/actions.test.ts index 4af5cdab8f6..2c90d9cb334 100644 --- a/packages/webui/src/daemon/session/actions.test.ts +++ b/packages/webui/src/daemon/session/actions.test.ts @@ -970,6 +970,23 @@ describe('createDaemonSessionActions', () => { ); }); + it('keeps workflow task loading behind an explicit adapter action', async () => { + const session = createMockSession('session-a'); + const { actions } = createActionsHarness({ session }); + + await expect(actions.getTasks()).resolves.toMatchObject({ + sessionId: 'session-a', + tasks: [], + }); + await expect(actions.getWorkflowTasks()).resolves.toMatchObject({ + sessionId: 'session-a', + tasks: [], + }); + + expect(session.tasks).toHaveBeenCalledOnce(); + expect(session.workflowTasks).toHaveBeenCalledOnce(); + }); + it('reports getTasks failures by default', async () => { const session = createMockSession('session-a'); const addNotice = vi.fn((notice) => notice); @@ -1128,6 +1145,93 @@ describe('createDaemonSessionActions', () => { expect(addNotice).not.toHaveBeenCalled(); }); + it('starts a saved workflow through the session workflow action', async () => { + const session = createMockSession('session-a'); + session.client.sessionWorkflowTaskAction.mockResolvedValueOnce({ + changed: true, + status: 'running', + taskId: 'wf_5678efab', + }); + const { actions } = createActionsHarness({ session }); + + await expect(actions.runSavedWorkflow('deep-review')).resolves.toEqual({ + started: true, + status: 'running', + taskId: 'wf_5678efab', + }); + expect(session.client.sessionWorkflowTaskAction).toHaveBeenCalledWith( + 'session-a', + 'deep-review', + 'run-saved', + 'client-session-a', + ); + }); + + 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'); + const pending = createDeferred(); + sessionA.controlWorkflowTask.mockReturnValueOnce(pending.promise); + const addNotice = vi.fn((notice) => notice); + const { actions, sessionRef } = createActionsHarness({ + addNotice, + session: sessionA, + }); + + const result = actions.controlWorkflowTask('wf-1', 'pause'); + sessionRef.current = sessionB as unknown as DaemonSessionClient; + pending.reject(new Error('old workflow failed')); + + await expect(result).rejects.toThrow('old workflow failed'); + expect(addNotice).not.toHaveBeenCalled(); + }); + + it('suppresses a stale saved-workflow failure after switching sessions', async () => { + const sessionA = createMockSession('session-a'); + const sessionB = createMockSession('session-b'); + const pending = createDeferred(); + sessionA.client.sessionWorkflowTaskAction.mockReturnValueOnce( + pending.promise, + ); + const addNotice = vi.fn((notice) => notice); + const { actions, sessionRef } = createActionsHarness({ + addNotice, + session: sessionA, + }); + + const result = actions.runSavedWorkflow('deep-review'); + sessionRef.current = sessionB as unknown as DaemonSessionClient; + pending.reject(new Error('old saved workflow failed')); + + await expect(result).rejects.toThrow('old saved workflow failed'); + expect(addNotice).not.toHaveBeenCalled(); + }); + it('aborts active prompts and rejects pending session loads when clearing', async () => { const controller = new AbortController(); const session = createMockSession('session-a'); @@ -2711,8 +2815,10 @@ function createMockSession( })), listWorkspaceSessions: vi.fn(), closeSession: vi.fn(), + 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), @@ -2740,6 +2846,12 @@ function createMockSession( supportedCommands: vi.fn(async () => supportedCommandsStatus(sessionId)), stats: vi.fn(), tasks: vi.fn(async () => ({ v: 1 as const, sessionId, tasks: [] })), + workflowTasks: vi.fn(async () => ({ + v: 1 as const, + sessionId, + tasks: [], + })), + controlWorkflowTask: vi.fn(), goal: vi.fn(), controlGoal: vi.fn(), }; diff --git a/packages/webui/src/daemon/session/actions.ts b/packages/webui/src/daemon/session/actions.ts index 3ff439e353f..6fdb0c8e9f2 100644 --- a/packages/webui/src/daemon/session/actions.ts +++ b/packages/webui/src/daemon/session/actions.ts @@ -20,7 +20,7 @@ import type { DaemonRewindResult, DaemonSessionRecapResult, DaemonRewindSnapshotInfo, - DaemonSessionTaskStatus, + DaemonSessionTaskWithWorkflowStatus, DaemonSessionArtifactsEnvelope, DaemonTranscriptStore, DaemonCapabilities, @@ -1980,7 +1980,43 @@ export function createDaemonSessionActions({ } }, - async cancelTask(taskId: string, kind: DaemonSessionTaskStatus['kind']) { + async getWorkflowTasks(opts) { + const session = sessionRef.current; + if (!session) throw new Error('Daemon session is not connected'); + try { + return await withActionTimeout( + session.workflowTasks(), + 'Get tasks timed out', + ); + } catch (error) { + if ( + error instanceof Error && + error.message === 'Daemon session is not connected' + ) { + throw error; + } + if (opts?.silent && isTransientActionError(error)) { + throw error; + } + throw dispatchActionError( + addNotice, + 'Get tasks failed', + error, + 'load_tasks', + opts?.silent + ? { + dispatchedNoticeKeys: silentHardFailureNoticeKeys, + noticeOnceKey: getActionErrorNoticeKey('load_tasks', error), + } + : undefined, + ); + } + }, + + async cancelTask( + taskId: string, + kind: DaemonSessionTaskWithWorkflowStatus['kind'], + ) { const session = requireSessionForAction( addNotice, sessionRef.current, @@ -2002,6 +2038,82 @@ export function createDaemonSessionActions({ } }, + async controlWorkflowTask( + taskId: string, + action: 'pause' | 'resume' | 'retry' | 'rerun' | 'delete-history', + ) { + const session = requireSessionForAction( + addNotice, + sessionRef.current, + 'Control workflow failed', + 'control_workflow', + ); + try { + return await withActionTimeout( + session.controlWorkflowTask(taskId, action), + 'Control workflow timed out', + ); + } catch (error) { + throw dispatchActionError( + noticeForSession(session), + 'Control workflow failed', + error, + 'control_workflow', + ); + } + }, + + async runSavedWorkflow(name: string) { + const session = requireSessionForAction( + addNotice, + sessionRef.current, + 'Run saved workflow failed', + 'run_saved_workflow', + ); + try { + const { changed, ...result } = await withActionTimeout( + session.client.sessionWorkflowTaskAction( + session.sessionId, + name, + 'run-saved', + session.clientId, + ), + 'Run saved workflow timed out', + ); + return { started: changed, ...result }; + } catch (error) { + throw dispatchActionError( + noticeForSession(session), + 'Run saved workflow failed', + error, + 'run_saved_workflow', + ); + } + }, + + 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/webui/src/daemon/session/types.ts b/packages/webui/src/daemon/session/types.ts index 5dbe4f73f90..3c3be5ae39a 100644 --- a/packages/webui/src/daemon/session/types.ts +++ b/packages/webui/src/daemon/session/types.ts @@ -23,6 +23,7 @@ import type { DaemonPendingPromptsResult, DaemonRemovePendingPromptResult, DaemonSessionContextStatus, + DaemonSessionSavedWorkflowDetail, DaemonSessionContextUsageStatus, DaemonSessionRecapResult, DaemonRewindResult, @@ -30,8 +31,10 @@ import type { DaemonSession, DaemonSessionSummary, DaemonSessionSupportedCommandsStatus, - DaemonSessionTaskStatus, + DaemonSessionTaskWithWorkflowStatus, DaemonSessionTasksStatus, + DaemonSessionWorkflowTaskStatus, + DaemonSessionWorkflowTasksStatus, DaemonSessionStatsStatus, DaemonSessionArtifactsEnvelope, DaemonSkillToggleMutation, @@ -229,6 +232,9 @@ export type DaemonNoticeOperation = | 'read_attachment' | 'remove_attachment' | 'cancel_task' + | 'control_workflow' + | 'run_saved_workflow' + | 'read_saved_workflow' | 'load_goal' | 'control_goal' | 'clear_goal' @@ -518,10 +524,33 @@ export interface DaemonSessionActions { ): Promise; sendShellCommand(command: string): Promise; getTasks(opts?: GetTasksActionOptions): Promise; + getWorkflowTasks( + opts?: GetTasksActionOptions, + ): Promise; cancelTask( taskId: string, - kind: DaemonSessionTaskStatus['kind'], + kind: DaemonSessionTaskWithWorkflowStatus['kind'], ): Promise<{ cancelled: boolean }>; + controlWorkflowTask( + taskId: string, + action: 'pause' | 'resume' | 'retry' | 'rerun' | 'delete-history', + ): Promise<{ + changed: boolean; + status?: DaemonSessionWorkflowTaskStatus['status']; + taskId?: string; + }>; + runSavedWorkflow(name: string): Promise<{ + started: boolean; + 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; /**