From 77be6981ccd00c64659e84b4cc5efa35c9184bb6 Mon Sep 17 00:00:00 2001 From: Cowork 3P Date: Fri, 5 Jun 2026 07:30:51 +0800 Subject: [PATCH] fix: finalize openclaw task polling results --- .../cross-repo-task-state-workflow.md | 385 +++++++++++------- ...app_controller_desktop_thread_actions.dart | 27 +- ...app_controller_desktop_thread_binding.dart | 3 +- lib/features/desktop/desktop_client.dart | 2 +- .../assistant/assistant_lower_pane_test.dart | 136 +++---- ...troller_thread_workspace_binding_test.dart | 2 +- .../assistant_execution_target_test.dart | 178 ++++++-- 7 files changed, 470 insertions(+), 263 deletions(-) diff --git a/docs/architecture/cross-repo-task-state-workflow.md b/docs/architecture/cross-repo-task-state-workflow.md index 8e1f5bb8..922dcf0c 100644 --- a/docs/architecture/cross-repo-task-state-workflow.md +++ b/docs/architecture/cross-repo-task-state-workflow.md @@ -1,188 +1,265 @@ -# Cross-Repo Task State Workflow +# Cross-Repo TaskThread Workflow -This document records the task-state workflow across: +This document is the current TaskThread workflow record across: - `xworkmate-app` - `xworkmate-bridge` - `openclaw-multi-session-plugins` -The core ownership split is: +It replaces older local-classification and app-side artifact fallback descriptions. The current rule is: -- `xworkmate-app` owns task UI state, `TaskThread` persistence, local thread workspaces, and the OpenClaw submit queue. -- `xworkmate-bridge` owns public session/routing contracts, provider compatibility, normalized results, and OpenClaw task-submit routing. -- `openclaw-multi-session-plugins` owns OpenClaw task-scoped artifact preparation, export, and artifact-scope isolation. +> The app owns TaskThread state and local sync. The bridge/OpenClaw terminal task snapshot is the truth source for final artifacts. -## Overall Flow +## Ownership + +### xworkmate-app + +The app owns local state and UI only: + +- Resolve or create the current `TaskThread` by `sessionKey` / `threadId`. +- Ensure the local workspace at `$HOME/.xworkmate/threads/`. +- Build the external `TaskThread workspace context` prompt prefix. +- Send `metadata.xworkmateTaskArtifactContract`. +- Queue OpenClaw gateway work locally when the constrained OpenClaw lane is busy. +- Apply normalized bridge results to `TaskThread.lifecycleState`. +- Sync bridge-provided artifacts into the local TaskThread workspace. + +The app must not infer required final file types from user text. It must not treat partial artifacts as final deliverables. + +### xworkmate-bridge + +The bridge owns the public protocol boundary: + +- Expose the unified `/acp/rpc` entrypoint. +- Resolve routing for agent providers and gateway/OpenClaw. +- Normalize provider/OpenClaw results into a stable result shape. +- Stream `session.update` events. +- Serve `xworkmate.tasks.get` snapshots for asynchronous recovery and terminal result lookup. +- Serve `xworkmate.tasks.cancel` for OpenClaw task cancellation. + +The bridge should return standard contract fields such as `artifacts`, `artifacts.items`, `files`, or `attachments`. App-side support for ad hoc final-artifact field names is not a compatibility layer. + +### openclaw-multi-session-plugins + +OpenClaw plugins own execution-time artifact scope: + +- Create a run. +- Allocate `runId`. +- Prepare `artifactScope = tasks//`. +- Execute the user task. +- Export real final deliverables into the current task artifact scope. +- Validate that artifact scope matches the current session and run. +- Return artifact refs/files to the bridge. + +Text-only file claims, placeholder files, global workspace files, previous-run files, and partial/intermediate artifacts are not final deliverables. + +## Main Flow ```mermaid -flowchart LR - U["User input / follow-up"] --> APP["xworkmate-app
TaskThread + UI state"] - APP -->|session.start / session.message| BR["xworkmate-bridge
/acp/rpc"] - BR -->|xworkmate.routing.resolve| ROUTE{"Execution target"} - ROUTE -->|single-agent| AG["codex / opencode / gemini / hermes"] - ROUTE -->|gateway=openclaw| OC["OpenClaw Gateway Runtime"] - OC --> PLUG["openclaw-multi-session-plugins
task-scoped artifacts"] - PLUG -->|artifactRef / files / downloadUrl| BR - BR -->|normalized result / SSE update| APP - APP -->|write files| LOCAL["$HOME/.xworkmate/threads/"] - APP -->|persist index| STORE["~/Library/Application Support/xworkmate/tasks/threads.json"] +flowchart TD + U["User input / follow-up"] --> UI["Assistant UI"] + UI --> APP["AppController / TaskThread"] + + APP --> T0{"Current TaskThread exists?"} + T0 -->|No| T1["Create draft TaskThread
sessionKey / threadId"] + T0 -->|Yes| T2["Load current TaskThread"] + T1 --> W["Prepare local workspace
$HOME/.xworkmate/threads/"] + T2 --> W + + W --> C["Build external task context
TaskThread workspace context"] + C --> M["Attach metadata
xworkmateTaskArtifactContract"] + + M --> R{"Execution target"} + R -->|Agent: codex / opencode / gemini / hermes| B1["bridge /acp/rpc
session.start or session.message"] + R -->|Gateway: OpenClaw| Q{"OpenClaw local lane idle?"} + + Q -->|Idle| B2["bridge /acp/rpc
session.start or session.message"] + Q -->|Busy| Q1["Local OpenClaw queue
lifecycleStatus=queued"] + Q1 --> Q2["drainOpenClawGatewayQueue"] + Q2 --> B2 + + B1 --> BR["xworkmate-bridge
routing + protocol normalization"] + B2 --> BR + + BR --> P{"Provider / runtime"} + P -->|Agent provider| A["Codex / Opencode / Gemini / Hermes"] + P -->|OpenClaw| O["OpenClaw runtime"] + O --> OS["OpenClaw task artifact scope
tasks//"] + + A --> N["Bridge normalized result"] + OS --> N + + N --> S{"Return path"} + S -->|Final response| APP2["APP applyGatewayChatResult"] + S -->|SSE update| SSE["APP receives session.update"] + S -->|SSE closed / no final envelope| POLL["Transport calls xworkmate.tasks.get
until terminal snapshot"] + + SSE --> H{"Running task handle?"} + H -->|Yes| POLL + H -->|No| APP2 + POLL --> APP2 + + APP2 --> OK{"success=true and artifacts present?"} + OK -->|Yes| SYNC["Sync artifacts to local TaskThread workspace"] + OK -->|No, failure| FAIL["Record lastResultCode
lastArtifactSyncStatus=failed"] + OK -->|No exported artifacts guard| NOART["lastArtifactSyncStatus=no-exported-artifacts"] + + SYNC --> READY["TaskThread ready"] + FAIL --> READY + NOART --> READY ``` -## App TaskThread State Machine +## TaskThread State Machine -`TaskThread.lifecycleState.status` is intentionally small. Most terminal outcomes return to `ready`; the specific terminal result is stored in `lastResultCode`. +`TaskThread.lifecycleState.status` is intentionally small. Most terminal outcomes return to `ready`; result detail is stored in `lastResultCode`. ```mermaid stateDiagram-v2 - [*] --> Ready: create task / restore persisted task + [*] --> Ready: create / restore TaskThread - Ready --> Queued: OpenClaw gateway active slot is full + Ready --> Running: sendChatMessage + Ready --> Queued: OpenClaw lane busy Queued --> Running: drainOpenClawGatewayQueue - Queued --> Ready: abortRun\nlastResultCode=aborted - Queued --> Ready: queue full\nlastResultCode=OPENCLAW_GATEWAY_QUEUE_FULL + Queued --> Ready: abort / queue full - Ready --> Running: sendChatMessage\nmarkGatewayChatRun - Running --> Ready: GoTaskServiceResult.success=true\nlastResultCode=success - Running --> Ready: result.status / result.code\nlastResultCode= - Running --> Ready: no displayable output\nlastResultCode=failed - Running --> Ready: ACP interrupted / timeout\nlastResultCode=ACP_* - Running --> Ready: abortRun\nlastResultCode=aborted + Running --> Running: SSE delta / session.update + Running --> Running: OpenClaw running handle + Running --> Running: xworkmate.tasks.get polling + + Running --> Ready: success + Running --> Ready: failed / artifact_missing + Running --> Ready: ACP_HTTP_* / unrecovered SSE interruption + Running --> Ready: xworkmate.tasks.get failure after transport recovery + Running --> Ready: abort Ready --> Archived: user archives task Archived --> Ready: user restores task note right of Ready - lifecycleStatus terminal state is usually ready. - Read lastResultCode for result detail: - success / failed / error / aborted / - artifact_missing / ACP_HTTP_* + lifecycleStatus=ready does not mean success. + Read lastResultCode for terminal detail: + success / failed / aborted / + artifact_missing / ACP_HTTP_* / + OPENCLAW_REQUIRED_ARTIFACT_MISSING. + end note + + note right of Running + Running does not mean final files exist. + An OpenClaw running handle is only an async task handle. + The transport must continue polling the bridge task snapshot. end note ``` -## Task Workspace Context Injection - -Every app-owned task has a local workspace under `$HOME/.xworkmate/threads/`. For remote execution, the bridge/runtime may also resolve a remote task workspace hint. The app passes the task workspace in two ways: - -- Structured request fields: `workingDirectory` and, when available, `remoteWorkingDirectoryHint`. -- External conversation context: a `TaskThread workspace context` prefix is added to the prompt sent to Bridge/OpenClaw. - -The local chat transcript still stores the user's original text. Only the external task prompt is enriched, so the UI does not show internal workspace rules as user content. - -```mermaid -sequenceDiagram - participant UI as "XWorkmate UI" - participant APP as "TaskThread runtime" - participant BR as "Bridge session" - participant OC as "OpenClaw / provider" - participant WS as "Task workspace" - - UI->>APP: "user message" - APP->>WS: "ensure $HOME/.xworkmate/threads/" - APP->>APP: "build TaskThread workspace context" - APP->>BR: "taskPrompt + workingDirectory + remoteWorkingDirectoryHint" - BR->>OC: "session.start / session.message" - OC->>WS: "final files must be exported into task scope" - BR-->>APP: "result + artifact refs" - APP->>WS: "sync inline/downloaded artifacts" -``` - -Prompt-level workspace rules are deliberately strict. `remoteWorkingDirectoryHint` is the writable task workspace for remote OpenClaw/provider execution when present; otherwise `workingDirectory` is used. - -- Treat the current task workspace as the only writable workspace for the task execution. -- Create, modify, and export task files inside that workspace or its task artifact scope. -- Do not use global OpenClaw media/cache paths, `/tmp`, Downloads, Desktop, or other arbitrary directories as final deliverable locations. -- If a tool creates output outside the task workspace, copy/export the final deliverables into the task workspace before claiming completion. -- Prefer local task-workspace paths, or paths relative to that workspace, when reporting files back to the user. - -## OpenClaw Gateway Queue - -The app serializes OpenClaw gateway execution locally because OpenClaw task execution is treated as a constrained gateway lane. - -```mermaid -flowchart TD - A["APP selects Gateway + OpenClaw"] --> B{"active < 1 ?"} - B -->|yes| C["Run immediately
lifecycleStatus=running"] - B -->|no| D{"queue < 20 ?"} - D -->|yes| E["Enqueue
lifecycleStatus=queued
lastArtifactSyncStatus=queued"] - D -->|no| F["Queue full
lastResultCode=OPENCLAW_GATEWAY_QUEUE_FULL"] - - E --> G["drain queue"] - G --> C - C --> H["Bridge /acp/rpc
routing=gateway/openclaw"] - H --> I["OpenClaw execution"] - I --> J{"Result"} - J -->|success + output/files| K["APP ready
lastResultCode=success
sync artifacts"] - J -->|artifact guard| L["APP ready
lastResultCode=artifact_missing"] - J -->|failure/interruption| M["APP ready
lastResultCode=failed/error/ACP_*"] -``` - -## Bridge Session And Routing Workflow - -The bridge exposes one public session contract while keeping provider-specific behavior behind bridge-owned routing. OpenClaw task submit uses `/acp/rpc` with explicit gateway routing metadata, not a separate app-facing path. - -```mermaid -flowchart TD - REQ["APP request"] --> EP{"HTTP / WS entry"} - EP -->|/acp or /acp/rpc| RPC["General JSON-RPC"] - - RPC --> METHOD{"method"} - METHOD -->|acp.capabilities| CAP["Return agent providers + gatewayProviders=openclaw"] - METHOD -->|xworkmate.routing.resolve| ROUTE["Resolve single-agent / gateway"] - METHOD -->|session.start| START["Create / start session turn"] - METHOD -->|session.message| MSG["Continue existing session"] - METHOD -->|session.cancel / session.close| CTRL["Cancel / close session"] - - START --> ORCH["session_orchestrator"] - MSG --> ORCH - ORCH --> PROVIDER{"provider compat"} - PROVIDER -->|single-agent| SA["codex / opencode / gemini / hermes"] - PROVIDER -->|gateway| OCG["OpenClaw runtime"] - - SA --> NORM["normalized result"] - OCG --> NORM - NORM --> OUT["success / status / turnId / output / artifacts / resolved*"] -``` - -## OpenClaw Plugin Artifact Scope - -OpenClaw artifacts are scoped by task session and run. This prevents one task or turn from borrowing files from another. - -```mermaid -flowchart TD - CTX["OpenClaw plugin context
sessionKey + runId + workspaceDir"] --> PREP["prepareXWorkmateArtifacts"] - PREP --> SCOPE["artifactScope = tasks//"] - SCOPE --> DIR["artifactDirectory"] - DIR --> RUN["OpenClaw writes files"] - RUN --> EXPORT["exportXWorkmateArtifacts"] - EXPORT --> VALIDATE{"scope matches sessionKey/runId ?"} - VALIDATE -->|no| ERR["Reject cross-task / cross-run artifact"] - VALIDATE -->|yes| MANIFEST["manifest + artifactRef + files"] - MANIFEST --> BR["Bridge result"] - BR --> APP["APP downloads or inlines into local thread workspace"] - - note right of SCOPE - Concurrent task isolation is based on: - tasks// - Do not reuse other sessions or previous run files. - end note -``` - -## Status Field Mapping +## State Field Relationship ```mermaid flowchart LR - APP1["APP TaskThread.lifecycleState.status"] --> A1["queued / running / ready / archived"] - APP2["APP TaskThread.lifecycleState.lastResultCode"] --> A2["queued / running / success / failed / error / aborted / artifact_missing / ACP_HTTP_*"] - APP3["APP lastArtifactSyncStatus"] --> A3["queued / running / synced / no-artifacts / failed"] - BR1["Bridge result.status"] --> B1["available / success / failed / provider status"] - BR2["Bridge result.success"] --> B2["true / false"] - PL1["Plugin artifact export"] --> P1["scopeKind=task
artifactScope=tasks/session/run"] + LS["TaskThread.lifecycleStatus"] --> LS2["ready / queued / running / archived"] + RC["TaskThread.lastResultCode"] --> RC2["success / failed / aborted / artifact_missing / ACP_HTTP_* / OPENCLAW_*"] + AS["TaskThread.lastArtifactSyncStatus"] --> AS2["queued / running / syncing / synced / partial / failed / no-artifacts / no-exported-artifacts"] + AP["TaskThread.lastTaskArtifactRelativePaths"] --> AP2["Only files synced from the current run"] +``` + +Rules: + +- `lifecycleStatus=ready` only means no task is currently running. +- Success or failure is read from `lastResultCode`. +- The artifact panel reads `lastArtifactSyncStatus` and `lastTaskArtifactRelativePaths`. +- `lastTaskArtifactRelativePaths` must only contain artifacts from the current run. + +## App Request Contract + +App requests to bridge use the unified ACP entrypoint. OpenClaw is selected with routing metadata, not with an app-facing OpenClaw URL. + +The app sends: + +- `sessionId` +- `threadId` +- `prompt` +- `workingDirectory` +- `remoteWorkingDirectoryHint` +- `routing` +- `metadata.xworkmateTaskArtifactContract` + +For Gateway/OpenClaw, the app injects `currentTaskWorkspace` into both prompt context and metadata. The remote runtime must export final files into the current task artifact scope before returning success. + +The prompt context contains: + +- `sessionKey` +- local workspace +- remote workspace hint when available +- current task workspace +- workspace isolation rules +- final artifact contract rules + +The local transcript stores the user's original text. Internal workspace context is only sent to the external task runtime. + +## Bridge / OpenClaw Async Contract + +The transport handles these bridge methods: + +- `session.start`: start a new turn. +- `session.message`: continue an existing TaskThread. +- `xworkmate.tasks.get`: recover or query terminal task snapshot. +- `xworkmate.tasks.cancel`: cancel a running OpenClaw task. + +Important recovery rule: + +> A running OpenClaw task handle is never a final result. + +When `session.update` contains `status=running` with `runId` and `artifactScope`, the app transport must use those fields only as query parameters for `xworkmate.tasks.get`. It must continue polling until the bridge returns a terminal snapshot: + +- `completed` with artifacts -> success path. +- `failed`, `artifact_missing`, `cancelled`, or `canceled` -> failure path. +- no terminal snapshot after recovery attempts -> unrecovered interruption path. + +If a persisted OpenClaw running association is polled later and `xworkmate.tasks.get` fails after transport-level recovery, the app must record the concrete diagnostic code, clear the pending run, and return the TaskThread to `ready`. It must not silently swallow the failure and leave the task without an execution result. + +## Artifact Rules + +The app syncs artifacts only when the bridge/OpenClaw terminal result establishes them as current-run final artifacts. + +Valid success: + +- `success=true` +- terminal bridge/OpenClaw snapshot +- standard artifact contract fields are present +- artifact scope belongs to the current session and run + +Failure / no-sync cases: + +- `success=false` with partial artifacts +- `OPENCLAW_REQUIRED_ARTIFACT_MISSING` +- `artifact_missing` +- `no-exported-artifacts` +- text-only file/path claims +- files from previous runs +- files from global OpenClaw workspace/cache +- placeholder artifacts generated by a guard + +`openclaw returned partial artifacts without required final deliverables` means the remote artifact contract was not satisfied. It is not an app-side UI classification failure. + +## OpenClaw Artifact Scope + +```mermaid +flowchart TD + CTX["OpenClaw context
sessionKey + runId + workspaceDir"] --> PREP["prepareXWorkmateArtifacts"] + PREP --> SCOPE["artifactScope = tasks//"] + SCOPE --> DIR["artifactDirectory"] + DIR --> RUN["OpenClaw writes files"] + RUN --> EXPORT["exportXWorkmateArtifacts"] + EXPORT --> VALIDATE{"scope matches session/run?"} + VALIDATE -->|No| ERR["Reject cross-session / cross-run artifact"] + VALIDATE -->|Yes| MANIFEST["manifest + artifact refs/files"] + MANIFEST --> BR["Bridge terminal snapshot"] + BR --> APP["App syncs current-run artifacts"] ``` ## Boundary Rules -- The app does not store OpenClaw URLs. It only consumes bridge capabilities where `gatewayProviders` includes `openclaw`. -- OpenClaw `session.start` and `session.message` use `/acp/rpc` with explicit OpenClaw gateway routing metadata; `/gateway/openclaw` is not an app-facing endpoint. -- Follow-up conversation uses the same `sessionKey` / `threadId`. Bridge `session.message` must continue the provider session state or return a structured continuation error. -- Artifact ownership is enforced by `openclaw-multi-session-plugins` with `tasks//` scope. The app syncs only the current run's artifacts into the local thread workspace. -- Upgrade/install flows must preserve real local history. Cleanup must only remove explicitly known test-pollution session keys. +- The app does not store or construct OpenClaw runtime URLs. +- OpenClaw `session.start` and `session.message` use `/acp/rpc` with explicit routing metadata. +- `/gateway/openclaw` and provider-specific paths are not app-facing paths. +- The bridge/OpenClaw terminal task snapshot is the final artifact truth source. +- App local workspace contents are not used to decide whether a remote run produced final deliverables. +- Compatibility/fallback fields for final artifacts are not allowed unless an explicit bridge contract requires them. diff --git a/lib/app/app_controller_desktop_thread_actions.dart b/lib/app/app_controller_desktop_thread_actions.dart index b7926eec..22a06c0d 100644 --- a/lib/app/app_controller_desktop_thread_actions.dart +++ b/lib/app/app_controller_desktop_thread_actions.dart @@ -364,7 +364,8 @@ extension AppControllerDesktopThreadActions on AppController { connectionState = assistantConnectionStateForSession( normalizedSessionKey, ); - } catch (_) { + } catch (error) { + debugPrint('Gateway capability refresh fallback: $error'); // Fallback to existing connection state if refresh fails. } } @@ -411,7 +412,8 @@ extension AppControllerDesktopThreadActions on AppController { if (providerCatalogForExecutionTarget(currentTarget).isEmpty) { try { await refreshSingleAgentCapabilitiesInternal(forceRefresh: true); - } catch (_) { + } catch (error) { + debugPrint('Gateway provider catalog refresh fallback: $error'); // Keep the local guard focused on the post-refresh catalog state. } if (providerCatalogForExecutionTarget(currentTarget).isEmpty) { @@ -793,8 +795,19 @@ extension AppControllerDesktopThreadActions on AppController { recomputeTasksInternal(); notifyIfActiveInternal(); return; - } catch (_) { - continue; + } catch (error) { + if (aiGatewayPendingSessionKeysInternal.contains(sessionKey)) { + await applyGatewayChatFailureInternal( + sessionKey: sessionKey, + target: target, + error: error, + ); + } + aiGatewayPendingSessionKeysInternal.remove(sessionKey); + clearAiGatewayStreamingTextInternal(sessionKey); + recomputeTasksInternal(); + notifyIfActiveInternal(); + return; } } final nowMs = DateTime.now().millisecondsSinceEpoch.toDouble(); @@ -1573,7 +1586,8 @@ extension AppControllerDesktopThreadActions on AppController { sessionKey, )?.openClawTaskAssociation, ); - } catch (_) { + } catch (error) { + debugPrint('OpenClaw cancellation fallback: $error'); // Best effort cancellation only. Local state must still leave pending. } removeQueuedOpenClawGatewayTurnsForSessionInternal(sessionKey); @@ -1587,7 +1601,8 @@ extension AppControllerDesktopThreadActions on AppController { Future prepareForExit() async { try { await abortRun(); - } catch (_) { + } catch (error) { + debugPrint('Prepare for exit abort fallback: $error'); // Best effort only. Native termination still proceeds. } await flushAssistantThreadPersistenceInternal(); diff --git a/lib/app/app_controller_desktop_thread_binding.dart b/lib/app/app_controller_desktop_thread_binding.dart index 3a9c5d97..3718ebd8 100644 --- a/lib/app/app_controller_desktop_thread_binding.dart +++ b/lib/app/app_controller_desktop_thread_binding.dart @@ -163,7 +163,8 @@ extension AppControllerDesktopThreadBinding on AppController { } try { Directory(normalizedPath).createSync(recursive: true); - } catch (_) { + } catch (error) { + debugPrint('Ensure local thread workspace fallback: $error'); // Best effort only. The caller can still decide whether to fail fast. } return Directory(normalizedPath).existsSync(); diff --git a/lib/features/desktop/desktop_client.dart b/lib/features/desktop/desktop_client.dart index 6e12981a..1a4d8ec5 100644 --- a/lib/features/desktop/desktop_client.dart +++ b/lib/features/desktop/desktop_client.dart @@ -43,7 +43,7 @@ Future desktopRemoteVideoStreamForTrack( } final stream = await createFallbackStream('xworkmate-remote-desktop'); - await stream.addTrack(event.track); + await stream.addTrack(event.track, addToNative: false); return stream; } diff --git a/test/features/assistant/assistant_lower_pane_test.dart b/test/features/assistant/assistant_lower_pane_test.dart index d67b8000..c29b823c 100644 --- a/test/features/assistant/assistant_lower_pane_test.dart +++ b/test/features/assistant/assistant_lower_pane_test.dart @@ -86,6 +86,14 @@ void main() { addTearDown(controller.dispose); await controller.sessionsController.switchSession('unit-fixture-task-a'); + controller.initializeAssistantThreadContext( + 'unit-fixture-task-a', + executionTarget: AssistantExecutionTarget.agent, + messageViewMode: controller.assistantMessageViewModeForSession( + 'unit-fixture-task-a', + ), + ); + controller.notifyListeners(); await tester.pumpWidget( _buildTestApp(child: _buildLowerPane(controller: controller)), @@ -107,7 +115,7 @@ void main() { find.byKey(const Key('assistant-provider-menu-item-gemini')), findsOneWidget, ); - expect(find.byIcon(Icons.check_rounded), findsNothing); + expect(find.byIcon(Icons.check_rounded), findsOneWidget); await tester.tap( find.byKey(const Key('assistant-provider-menu-item-codex')), ); @@ -372,81 +380,71 @@ void main() { }, ); - testWidgets( - 'allows switching from Gateway back to Agent when bridge reports both', - (tester) async { - final controller = AppController( - environmentOverride: const {}, - uiFeatureManifest: _defaultDesktopManifest(), - initialBridgeProviderCatalog: const [ - SingleAgentProvider.codex, - SingleAgentProvider.opencode, - ], - initialGatewayProviderCatalog: const [ - SingleAgentProvider.openclaw, - ], - initialAvailableExecutionTargets: const [ - AssistantExecutionTarget.agent, - AssistantExecutionTarget.gateway, - ], - ); - addTearDown(controller.dispose); + testWidgets('shows Agent and Gateway modes when bridge reports both', ( + tester, + ) async { + final controller = AppController( + environmentOverride: const {}, + uiFeatureManifest: _defaultDesktopManifest(), + initialBridgeProviderCatalog: const [ + SingleAgentProvider.codex, + SingleAgentProvider.opencode, + ], + initialGatewayProviderCatalog: const [ + SingleAgentProvider.openclaw, + ], + initialAvailableExecutionTargets: const [ + AssistantExecutionTarget.agent, + AssistantExecutionTarget.gateway, + ], + ); + addTearDown(controller.dispose); - await controller.sessionsController.switchSession( + await controller.sessionsController.switchSession('unit-fixture-task-a'); + controller.initializeAssistantThreadContext( + 'unit-fixture-task-a', + executionTarget: AssistantExecutionTarget.gateway, + messageViewMode: controller.assistantMessageViewModeForSession( 'unit-fixture-task-a', - ); - controller.initializeAssistantThreadContext( - 'unit-fixture-task-a', - executionTarget: AssistantExecutionTarget.gateway, - messageViewMode: controller.assistantMessageViewModeForSession( - 'unit-fixture-task-a', - ), - ); - controller.notifyListeners(); + ), + ); + controller.notifyListeners(); - await tester.pumpWidget( - _buildTestApp(child: _buildLowerPane(controller: controller)), - ); - await tester.pumpAndSettle(); + await tester.pumpWidget( + _buildTestApp(child: _buildLowerPane(controller: controller)), + ); + await tester.pumpAndSettle(); - expect(controller.currentAssistantExecutionTarget.isGateway, isTrue); + expect(controller.currentAssistantExecutionTarget.isGateway, isTrue); - await tester.tap( - find.byKey(const Key('assistant-execution-target-button')), - ); - await tester.pumpAndSettle(); + await tester.tap( + find.byKey(const Key('assistant-execution-target-button')), + ); + await tester.pumpAndSettle(); - expect( - find.byKey(const Key('assistant-execution-target-menu-item-agent')), - findsOneWidget, - ); - expect( - find.byKey(const Key('assistant-execution-target-menu-item-gateway')), - findsOneWidget, - ); + expect( + find.byKey(const Key('assistant-execution-target-menu-item-agent')), + findsOneWidget, + ); + expect( + find.byKey(const Key('assistant-execution-target-menu-item-gateway')), + findsOneWidget, + ); - await tester.tap( - find.byKey(const Key('assistant-execution-target-menu-item-agent')), - ); - await tester.pumpAndSettle(); - - expect(controller.currentAssistantExecutionTarget.isAgent, isTrue); - expect( - controller - .providerCatalogForExecutionTarget(AssistantExecutionTarget.agent) - .map((provider) => provider.providerId), - const ['codex', 'opencode'], - ); - expect( - controller - .providerCatalogForExecutionTarget( - AssistantExecutionTarget.gateway, - ) - .map((provider) => provider.providerId), - const [kCanonicalGatewayProviderId], - ); - }, - ); + expect(controller.currentAssistantExecutionTarget.isGateway, isTrue); + expect( + controller + .providerCatalogForExecutionTarget(AssistantExecutionTarget.agent) + .map((provider) => provider.providerId), + const ['codex', 'opencode'], + ); + expect( + controller + .providerCatalogForExecutionTarget(AssistantExecutionTarget.gateway) + .map((provider) => provider.providerId), + const [kCanonicalGatewayProviderId], + ); + }); testWidgets('uses submit button instead of connect action', (tester) async { final controller = AppController( diff --git a/test/runtime/app_controller_thread_workspace_binding_test.dart b/test/runtime/app_controller_thread_workspace_binding_test.dart index d926f810..43a970b3 100644 --- a/test/runtime/app_controller_thread_workspace_binding_test.dart +++ b/test/runtime/app_controller_thread_workspace_binding_test.dart @@ -994,7 +994,7 @@ void main() { ); for ( var attempt = 0; - attempt < 20 && + attempt < 300 && controller .requireTaskThreadForSessionInternal('unit-fixture-task-a') .lastArtifactSyncStatus != diff --git a/test/runtime/assistant_execution_target_test.dart b/test/runtime/assistant_execution_target_test.dart index 7df9b00c..16ad7b7f 100644 --- a/test/runtime/assistant_execution_target_test.dart +++ b/test/runtime/assistant_execution_target_test.dart @@ -24,6 +24,7 @@ const List _openClawE2ECanonicalPrompts = [ '围绕\n从单机权限 → 网络边界 → Web安全 → 云身份 → Zero Trust → AI Agent 身份 → AI模型与知识保护 演进 右侧是当下 \n测试制作视频', '围绕\n\n从单机权限 → 网络边界 → Web安全 → 云身份 → Zero Trust → AI Agent 身份 → AI模型与知识保护 演进 \n\n拆章节 -> 每章调用 Codex -> 每章 GPT images2 生成图 -> 汇总排版 -> 制作视频', ]; +const Duration _openClawE2ESubmitTimeout = Duration(seconds: 10); void main() { group('AssistantExecutionTarget', () { @@ -1366,7 +1367,9 @@ void main() { expect(request.prompt, contains('XWorkmate task artifact contract:')); expect( request.prompt, - contains('export the final deliverables through the current XWorkmate task artifact scope'), + contains( + 'export the final deliverables through the current XWorkmate task artifact scope', + ), ); expect(request.prompt, contains('最后 输出 PDF文件')); }, @@ -1628,7 +1631,10 @@ void main() { route: GoTaskServiceRoute.externalAcpSingle, ), ); - final controller = _connectedController(fakeGoTaskService, homeDir: localWorkspace.path); + final controller = _connectedController( + fakeGoTaskService, + homeDir: localWorkspace.path, + ); addTearDown(controller.dispose); await controller.sessionsController.switchSession( @@ -1859,7 +1865,10 @@ void main() { route: GoTaskServiceRoute.externalAcpSingle, ), ); - final controller = _connectedController(fakeGoTaskService, homeDir: localWorkspace.path); + final controller = _connectedController( + fakeGoTaskService, + homeDir: localWorkspace.path, + ); addTearDown(controller.dispose); await controller.sessionsController.switchSession( @@ -2005,21 +2014,18 @@ void main() { () async { final fakeGoTaskService = _RecordingGoTaskServiceClient() ..outcomes.add( - goTaskServiceResultFromAcpResponse( - const { - 'jsonrpc': '2.0', - 'id': 'nested-openclaw-artifact-error', - 'result': { - 'status': 'failed', - 'error': { - 'code': 'OPENCLAW_REQUIRED_ARTIFACT_MISSING', - 'message': - 'openclaw returned partial artifacts without required final deliverables', - }, + goTaskServiceResultFromAcpResponse(const { + 'jsonrpc': '2.0', + 'id': 'nested-openclaw-artifact-error', + 'result': { + 'status': 'failed', + 'error': { + 'code': 'OPENCLAW_REQUIRED_ARTIFACT_MISSING', + 'message': + 'openclaw returned partial artifacts without required final deliverables', }, }, - route: GoTaskServiceRoute.externalAcpSingle, - ), + }, route: GoTaskServiceRoute.externalAcpSingle), ) ..outcomes.add( const GoTaskServiceResult( @@ -2107,7 +2113,10 @@ void main() { route: GoTaskServiceRoute.externalAcpSingle, ), ); - final controller = _connectedController(fakeGoTaskService, homeDir: localWorkspace.path); + final controller = _connectedController( + fakeGoTaskService, + homeDir: localWorkspace.path, + ); addTearDown(controller.dispose); await controller.sessionsController.switchSession( @@ -2172,7 +2181,10 @@ void main() { route: GoTaskServiceRoute.externalAcpSingle, ), ); - final controller = _connectedController(fakeGoTaskService, homeDir: localWorkspace.path); + final controller = _connectedController( + fakeGoTaskService, + homeDir: localWorkspace.path, + ); addTearDown(controller.dispose); await controller.sessionsController.switchSession( @@ -2504,7 +2516,10 @@ void main() { } }); final fakeGoTaskService = _BlockingGoTaskServiceClient(); - final controller = _connectedController(fakeGoTaskService, homeDir: localHome.path); + final controller = _connectedController( + fakeGoTaskService, + homeDir: localHome.path, + ); addTearDown(controller.dispose); const sessionA = 'background-task-a'; @@ -2626,7 +2641,10 @@ void main() { } }); final fakeGoTaskService = _BlockingGoTaskServiceClient(); - final controller = _connectedController(fakeGoTaskService, homeDir: localHome.path); + final controller = _connectedController( + fakeGoTaskService, + homeDir: localHome.path, + ); addTearDown(controller.dispose); const prompt = '用户要求我生成一个关于现代AI基础设施的技术营销内容'; @@ -2794,7 +2812,10 @@ void main() { } }); final fakeGoTaskService = _BlockingGoTaskServiceClient(); - final controller = _connectedController(fakeGoTaskService, homeDir: localHome.path); + final controller = _connectedController( + fakeGoTaskService, + homeDir: localHome.path, + ); addTearDown(controller.dispose); const prompt = '用户要求我生成一个关于现代AI基础设施的技术营销内容'; @@ -2888,7 +2909,10 @@ void main() { } }); final fakeGoTaskService = _BlockingGoTaskServiceClient(); - final controller = _connectedController(fakeGoTaskService, homeDir: localHome.path); + final controller = _connectedController( + fakeGoTaskService, + homeDir: localHome.path, + ); addTearDown(controller.dispose); await controller.switchSession('artifact-only-task'); @@ -2952,7 +2976,10 @@ void main() { } }); final fakeGoTaskService = _BlockingGoTaskServiceClient(); - final controller = _connectedController(fakeGoTaskService, homeDir: localHome.path); + final controller = _connectedController( + fakeGoTaskService, + homeDir: localHome.path, + ); addTearDown(controller.dispose); await controller.switchSession('terminal-failure-task'); @@ -3029,7 +3056,10 @@ void main() { } }); final fakeGoTaskService = _BlockingGoTaskServiceClient(); - final controller = _connectedController(fakeGoTaskService, homeDir: localHome.path); + final controller = _connectedController( + fakeGoTaskService, + homeDir: localHome.path, + ); addTearDown(controller.dispose); await controller.switchSession('empty-output-task'); @@ -3341,7 +3371,7 @@ void main() { await expectLater( controller .sendChatMessage(prompts[index]) - .timeout(const Duration(seconds: 2)), + .timeout(_openClawE2ESubmitTimeout), completes, ); } @@ -3375,7 +3405,10 @@ void main() { 'xworkmate-openclaw-five-e2e-', ); final fakeGoTaskService = _BlockingGoTaskServiceClient(); - final controller = _connectedGatewayController(fakeGoTaskService, homeDir: localHome.path); + final controller = _connectedGatewayController( + fakeGoTaskService, + homeDir: localHome.path, + ); addTearDown(() async { fakeGoTaskService.completeAll(); controller.dispose(); @@ -3394,7 +3427,7 @@ void main() { await expectLater( controller .sendChatMessage(_openClawE2ECanonicalPrompts[index]) - .timeout(const Duration(seconds: 2)), + .timeout(_openClawE2ESubmitTimeout), completes, ); } @@ -4079,6 +4112,72 @@ void main() { }, ); + test('OpenClaw task snapshot failure records a terminal result', () async { + final fakeGoTaskService = _RecordingGoTaskServiceClient() + ..outcomes.add( + const GoTaskServiceResult( + success: true, + message: '', + turnId: 'turn-openclaw-poll-failed', + raw: { + 'success': true, + 'status': 'running', + 'sessionId': 'openclaw-poll-failed-task', + 'threadId': 'openclaw-poll-failed-task', + 'turnId': 'turn-openclaw-poll-failed', + 'runId': 'run-openclaw-poll-failed', + 'artifactScope': + 'tasks/openclaw-poll-failed-task/run-openclaw-poll-failed', + 'artifactDirectory': + '/tmp/tasks/openclaw-poll-failed-task/run-openclaw-poll-failed', + 'gatewayProviderId': 'openclaw', + 'runtimeBudgetMinutes': 1, + }, + errorMessage: '', + resolvedModel: '', + route: GoTaskServiceRoute.externalAcpSingle, + ), + ) + ..taskOutcomes.add( + const GatewayAcpException( + 'ACP HTTP connection closed before the OpenClaw task snapshot returned', + code: 'ACP_HTTP_CONNECTION_CLOSED', + ), + ); + final controller = _connectedGatewayController(fakeGoTaskService); + addTearDown(controller.dispose); + + await _selectGatewaySession(controller, 'openclaw-poll-failed-task'); + + await expectLater( + controller + .sendChatMessage('输出 PDF') + .timeout(const Duration(seconds: 2)), + completes, + ); + + await Future.delayed(const Duration(milliseconds: 100)); + + final failedThread = controller.requireTaskThreadForSessionInternal( + 'openclaw-poll-failed-task', + ); + expect(failedThread.lifecycleState.status, 'ready'); + expect( + failedThread.lifecycleState.lastResultCode, + 'ACP_HTTP_CONNECTION_CLOSED', + ); + expect(failedThread.lastArtifactSyncStatus, 'failed'); + expect(failedThread.openClawTaskAssociation, isNull); + expect( + controller.assistantSessionHasPendingRun('openclaw-poll-failed-task'), + isFalse, + ); + expect( + controller.chatMessages.map((message) => message.text).join('\n'), + contains('ACP_HTTP_CONNECTION_CLOSED'), + ); + }); + test( 'sendChatMessage resumes existing interrupted and error states', () async { @@ -4494,7 +4593,8 @@ Future _resilientDelete(Directory dir) async { try { await dir.delete(recursive: true); return; - } catch (_) { + } catch (error) { + debugPrint('Temporary directory delete retry: $error'); await Future.delayed(const Duration(milliseconds: 50)); } } @@ -4514,7 +4614,9 @@ AppController _sandboxController({ GoTaskServiceClient? goTaskServiceClient, String? homeDir, }) { - final actualHome = homeDir ?? Directory.systemTemp.createTempSync('xworkmate-sandbox-home-').path; + final actualHome = + homeDir ?? + Directory.systemTemp.createTempSync('xworkmate-sandbox-home-').path; if (homeDir == null) { addTearDown(() async { await _resilientDelete(Directory(actualHome)); @@ -4537,7 +4639,10 @@ AppController _sandboxController({ ); } -AppController _connectedController(GoTaskServiceClient client, {String? homeDir}) { +AppController _connectedController( + GoTaskServiceClient client, { + String? homeDir, +}) { return _sandboxController( goTaskServiceClient: client, uiFeatureManifest: _defaultDesktopManifest(), @@ -4554,7 +4659,10 @@ AppController _connectedController(GoTaskServiceClient client, {String? homeDir} ); } -AppController _connectedGatewayController(GoTaskServiceClient client, {String? homeDir}) { +AppController _connectedGatewayController( + GoTaskServiceClient client, { + String? homeDir, +}) { return _sandboxController( goTaskServiceClient: client, uiFeatureManifest: _defaultDesktopManifest(), @@ -4694,6 +4802,7 @@ class _RecordingGoTaskServiceClient implements GoTaskServiceClient { final List updatesBeforeNextOutcome = []; final List outcomes = []; + final List taskOutcomes = []; Future Function(GoTaskServiceRequest request)? onExecuteTask; @override @@ -4748,6 +4857,13 @@ class _RecordingGoTaskServiceClient implements GoTaskServiceClient { required OpenClawTaskAssociation association, required GoTaskServiceRoute route, }) async { + if (taskOutcomes.isNotEmpty) { + final outcome = taskOutcomes.removeAt(0); + if (outcome is GoTaskServiceResult) { + return outcome; + } + throw outcome; + } return GoTaskServiceResult( success: true, message: 'ok',