fix: finalize openclaw task polling results

This commit is contained in:
Cowork 3P 2026-06-05 07:30:51 +08:00
parent c4191fa5c9
commit 77be6981cc
7 changed files with 470 additions and 263 deletions

View File

@ -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/<session>`.
- 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/<session>/<run>`.
- 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<br/>TaskThread + UI state"]
APP -->|session.start / session.message| BR["xworkmate-bridge<br/>/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<br/>task-scoped artifacts"]
PLUG -->|artifactRef / files / downloadUrl| BR
BR -->|normalized result / SSE update| APP
APP -->|write files| LOCAL["$HOME/.xworkmate/threads/<session>"]
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<br/>sessionKey / threadId"]
T0 -->|Yes| T2["Load current TaskThread"]
T1 --> W["Prepare local workspace<br/>$HOME/.xworkmate/threads/<session>"]
T2 --> W
W --> C["Build external task context<br/>TaskThread workspace context"]
C --> M["Attach metadata<br/>xworkmateTaskArtifactContract"]
M --> R{"Execution target"}
R -->|Agent: codex / opencode / gemini / hermes| B1["bridge /acp/rpc<br/>session.start or session.message"]
R -->|Gateway: OpenClaw| Q{"OpenClaw local lane idle?"}
Q -->|Idle| B2["bridge /acp/rpc<br/>session.start or session.message"]
Q -->|Busy| Q1["Local OpenClaw queue<br/>lifecycleStatus=queued"]
Q1 --> Q2["drainOpenClawGatewayQueue"]
Q2 --> B2
B1 --> BR["xworkmate-bridge<br/>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<br/>tasks/<session>/<run>"]
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<br/>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<br/>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=<status|code>
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/<session>`. 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/<session>"
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<br/>lifecycleStatus=running"]
B -->|no| D{"queue < 20 ?"}
D -->|yes| E["Enqueue<br/>lifecycleStatus=queued<br/>lastArtifactSyncStatus=queued"]
D -->|no| F["Queue full<br/>lastResultCode=OPENCLAW_GATEWAY_QUEUE_FULL"]
E --> G["drain queue"]
G --> C
C --> H["Bridge /acp/rpc<br/>routing=gateway/openclaw"]
H --> I["OpenClaw execution"]
I --> J{"Result"}
J -->|success + output/files| K["APP ready<br/>lastResultCode=success<br/>sync artifacts"]
J -->|artifact guard| L["APP ready<br/>lastResultCode=artifact_missing"]
J -->|failure/interruption| M["APP ready<br/>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<br/>sessionKey + runId + workspaceDir"] --> PREP["prepareXWorkmateArtifacts"]
PREP --> SCOPE["artifactScope = tasks/<sessionKey>/<runId>"]
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/<session>/<run>
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<br/>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<br/>sessionKey + runId + workspaceDir"] --> PREP["prepareXWorkmateArtifacts"]
PREP --> SCOPE["artifactScope = tasks/<session>/<run>"]
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/<session>/<run>` 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.

View File

@ -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<void> prepareForExit() async {
try {
await abortRun();
} catch (_) {
} catch (error) {
debugPrint('Prepare for exit abort fallback: $error');
// Best effort only. Native termination still proceeds.
}
await flushAssistantThreadPersistenceInternal();

View File

@ -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();

View File

@ -43,7 +43,7 @@ Future<MediaStream?> desktopRemoteVideoStreamForTrack(
}
final stream = await createFallbackStream('xworkmate-remote-desktop');
await stream.addTrack(event.track);
await stream.addTrack(event.track, addToNative: false);
return stream;
}

View File

@ -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 <String, String>{},
uiFeatureManifest: _defaultDesktopManifest(),
initialBridgeProviderCatalog: const <SingleAgentProvider>[
SingleAgentProvider.codex,
SingleAgentProvider.opencode,
],
initialGatewayProviderCatalog: const <SingleAgentProvider>[
SingleAgentProvider.openclaw,
],
initialAvailableExecutionTargets: const <AssistantExecutionTarget>[
AssistantExecutionTarget.agent,
AssistantExecutionTarget.gateway,
],
);
addTearDown(controller.dispose);
testWidgets('shows Agent and Gateway modes when bridge reports both', (
tester,
) async {
final controller = AppController(
environmentOverride: const <String, String>{},
uiFeatureManifest: _defaultDesktopManifest(),
initialBridgeProviderCatalog: const <SingleAgentProvider>[
SingleAgentProvider.codex,
SingleAgentProvider.opencode,
],
initialGatewayProviderCatalog: const <SingleAgentProvider>[
SingleAgentProvider.openclaw,
],
initialAvailableExecutionTargets: const <AssistantExecutionTarget>[
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 <String>['codex', 'opencode'],
);
expect(
controller
.providerCatalogForExecutionTarget(
AssistantExecutionTarget.gateway,
)
.map((provider) => provider.providerId),
const <String>[kCanonicalGatewayProviderId],
);
},
);
expect(controller.currentAssistantExecutionTarget.isGateway, isTrue);
expect(
controller
.providerCatalogForExecutionTarget(AssistantExecutionTarget.agent)
.map((provider) => provider.providerId),
const <String>['codex', 'opencode'],
);
expect(
controller
.providerCatalogForExecutionTarget(AssistantExecutionTarget.gateway)
.map((provider) => provider.providerId),
const <String>[kCanonicalGatewayProviderId],
);
});
testWidgets('uses submit button instead of connect action', (tester) async {
final controller = AppController(

View File

@ -994,7 +994,7 @@ void main() {
);
for (
var attempt = 0;
attempt < 20 &&
attempt < 300 &&
controller
.requireTaskThreadForSessionInternal('unit-fixture-task-a')
.lastArtifactSyncStatus !=

View File

@ -24,6 +24,7 @@ const List<String> _openClawE2ECanonicalPrompts = <String>[
'围绕\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 <String, dynamic>{
'jsonrpc': '2.0',
'id': 'nested-openclaw-artifact-error',
'result': <String, dynamic>{
'status': 'failed',
'error': <String, dynamic>{
'code': 'OPENCLAW_REQUIRED_ARTIFACT_MISSING',
'message':
'openclaw returned partial artifacts without required final deliverables',
},
goTaskServiceResultFromAcpResponse(const <String, dynamic>{
'jsonrpc': '2.0',
'id': 'nested-openclaw-artifact-error',
'result': <String, dynamic>{
'status': 'failed',
'error': <String, dynamic>{
'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: <String, dynamic>{
'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<void>.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<void> _resilientDelete(Directory dir) async {
try {
await dir.delete(recursive: true);
return;
} catch (_) {
} catch (error) {
debugPrint('Temporary directory delete retry: $error');
await Future<void>.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<GoTaskServiceUpdate> updatesBeforeNextOutcome =
<GoTaskServiceUpdate>[];
final List<Object> outcomes = <Object>[];
final List<Object> taskOutcomes = <Object>[];
Future<void> 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',