diff --git a/README.md b/README.md index 3c077849..4b29fac4 100644 --- a/README.md +++ b/README.md @@ -76,5 +76,6 @@ All download buttons currently point to the latest GitHub release page. - [Changelog](/Users/shenlan/workspaces/cloud-neutral-toolkit/XWorkmate.svc.plus/docs/releases/xworkmate-changelog.md) - [Feature Matrix](/Users/shenlan/workspaces/cloud-neutral-toolkit/XWorkmate.svc.plus/docs/plans/xworkmate-ui-feature-matrix.md) - [Roadmap](/Users/shenlan/workspaces/cloud-neutral-toolkit/XWorkmate.svc.plus/docs/plans/xworkmate-ui-feature-roadmap.md) +- [Cross-Repo Task State Workflow](docs/architecture/cross-repo-task-state-workflow.md) - [Gateway Dev Runbook](/Users/shenlan/workspaces/cloud-neutral-toolkit/XWorkmate.svc.plus/docs/runbooks/gateway-dev-runbook.md) - [Security Rules](/Users/shenlan/workspaces/cloud-neutral-toolkit/XWorkmate.svc.plus/docs/security/secure-development-rules.md) diff --git a/docs/architecture/cross-repo-task-state-workflow.md b/docs/architecture/cross-repo-task-state-workflow.md new file mode 100644 index 00000000..0caace65 --- /dev/null +++ b/docs/architecture/cross-repo-task-state-workflow.md @@ -0,0 +1,196 @@ +# Cross-Repo Task State Workflow + +This document records the task-state workflow across: + +- `xworkmate-app` +- `xworkmate-bridge` +- `openclaw-multi-session-plugins` + +The core ownership split 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. + +## Overall 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 or /gateway/openclaw"] + 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"] +``` + +## App TaskThread State Machine + +`TaskThread.lifecycleState.status` is intentionally small. Most terminal outcomes return to `ready`; the specific terminal result is stored in `lastResultCode`. + +```mermaid +stateDiagram-v2 + [*] --> Ready: create task / restore persisted task + + Ready --> Queued: OpenClaw gateway active slot is full + Queued --> Running: drainOpenClawGatewayQueue + Queued --> Ready: abortRun\nlastResultCode=aborted + Queued --> Ready: queue full\nlastResultCode=OPENCLAW_GATEWAY_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 + + 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_* + 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 /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 compatibility layers. `/gateway/openclaw` is a narrow OpenClaw task-submit lane, not a general ACP base endpoint. + +```mermaid +flowchart TD + REQ["APP request"] --> EP{"HTTP / WS entry"} + EP -->|/acp or /acp/rpc| RPC["General JSON-RPC"] + EP -->|/gateway/openclaw| GW["OpenClaw task-submit endpoint"] + + 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"] + + GW --> GUARD{"method is session.start / session.message ?"} + GUARD -->|no| REJECT["Reject: not a global ACP base"] + GUARD -->|yes| FORCE["Force routing=gateway/openclaw
Reject multiAgent"] + FORCE --> START + + START --> ORCH["session_orchestrator"] + MSG --> ORCH + ORCH --> PROVIDER{"provider compat"} + PROVIDER -->|single-agent| SA["codex / opencode / gemini / hermes"] + PROVIDER -->|gateway| OCG["OpenClaw runtime"] + PROVIDER -->|multi-agent via /acp/rpc| MA["multi-agent orchestration"] + + SA --> NORM["normalized result"] + OCG --> NORM + MA --> 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 + +```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"] +``` + +## Boundary Rules + +- The app does not store OpenClaw URLs. It only consumes bridge capabilities where `gatewayProviders` includes `openclaw`. +- `/gateway/openclaw` is only for OpenClaw `session.start` and `session.message`; it is not a global ACP 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. diff --git a/lib/app/app_controller_desktop_thread_actions.dart b/lib/app/app_controller_desktop_thread_actions.dart index b3dd476a..6f37f2f6 100644 --- a/lib/app/app_controller_desktop_thread_actions.dart +++ b/lib/app/app_controller_desktop_thread_actions.dart @@ -67,6 +67,8 @@ extension AppControllerDesktopThreadActions on AppController { bool assistantSessionHasPendingRun(String sessionKey) { final normalized = normalizedAssistantSessionKeyInternal(sessionKey); return aiGatewayPendingSessionKeysInternal.contains(normalized) || + (openClawGatewayQueuedTurnsBySessionInternal[normalized]?.isNotEmpty ?? + false) || (multiAgentRunPendingInternal && matchesSessionKey( normalized, @@ -417,11 +419,21 @@ extension AppControllerDesktopThreadActions on AppController { required String agentId, required Map metadata, required bool resumeSessionHint, + bool appendUserTurn = true, }) async { final resumeSession = resumeSessionHint || - shouldResumeGatewaySessionForNextSendInternal(sessionKey); - appendGatewayUserTurnInternal(sessionKey, message); + (appendUserTurn && + shouldResumeGatewaySessionForNextSendInternal(sessionKey)); + final taskPrompt = taskWorkspaceContextPromptInternal( + sessionKey: sessionKey, + userPrompt: message, + workingDirectory: workingDirectory, + remoteWorkingDirectoryHint: remoteWorkingDirectoryHint, + ); + if (appendUserTurn) { + appendGatewayUserTurnInternal(sessionKey, message); + } markGatewayChatRunInternal(sessionKey); try { final result = await goTaskServiceClientInternal.executeTask( @@ -430,7 +442,7 @@ extension AppControllerDesktopThreadActions on AppController { threadId: sessionKey, target: target, provider: provider, - prompt: message, + prompt: taskPrompt, workingDirectory: workingDirectory, remoteWorkingDirectoryHint: remoteWorkingDirectoryHint, model: model, @@ -482,6 +494,53 @@ extension AppControllerDesktopThreadActions on AppController { } } + String taskWorkspaceContextPromptInternal({ + required String sessionKey, + required String userPrompt, + required String workingDirectory, + required String remoteWorkingDirectoryHint, + }) { + final requestText = userPrompt.trim().isEmpty + ? 'See attached.' + : userPrompt.trim(); + final buffer = StringBuffer() + ..writeln('TaskThread workspace context:') + ..writeln('- sessionKey: $sessionKey') + ..writeln('- localWorkspace: ${workingDirectory.trim()}'); + final remoteHint = remoteWorkingDirectoryHint.trim(); + if (remoteHint.isNotEmpty) { + buffer.writeln('- remoteWorkspaceHint: $remoteHint'); + } + buffer.writeln( + '- currentTaskWorkspace: ${remoteHint.isNotEmpty ? remoteHint : workingDirectory.trim()}', + ); + buffer + ..writeln() + ..writeln('Workspace isolation rules:') + ..writeln( + '1. Treat currentTaskWorkspace as the only writable workspace for this TaskThread execution.', + ) + ..writeln( + '2. Create, modify, and export task files inside currentTaskWorkspace or its task artifact scope.', + ) + ..writeln( + '3. Do not use arbitrary global directories, OpenClaw media cache, Downloads, Desktop, or /tmp as final deliverable locations.', + ) + ..writeln( + '4. If a tool creates output outside currentTaskWorkspace, copy or export the final deliverables into currentTaskWorkspace before claiming completion.', + ) + ..writeln( + '5. When reporting files, prefer paths inside currentTaskWorkspace or paths relative to currentTaskWorkspace.', + ) + ..writeln( + '6. The app syncs final artifacts from currentTaskWorkspace back into localWorkspace.', + ) + ..writeln() + ..writeln('User request:') + ..write(requestText); + return buffer.toString(); + } + bool usesOpenClawGatewayQueueInternal( AssistantExecutionTarget target, SingleAgentProvider provider, @@ -493,6 +552,7 @@ extension AppControllerDesktopThreadActions on AppController { Future enqueueOpenClawGatewayTurnInternal( OpenClawGatewayQueuedTurnInternal turn, ) async { + appendGatewayUserTurnInternal(turn.sessionKey, turn.message); if (openClawGatewayActiveTasksInternal >= openClawGatewayMaxActiveTasksInternal && openClawGatewayQueuedTurnsInternal.length >= @@ -632,6 +692,7 @@ extension AppControllerDesktopThreadActions on AppController { agentId: turn.agentId, metadata: turn.metadata, resumeSessionHint: turn.resumeSessionHint, + appendUserTurn: false, ), ); } catch (error) { diff --git a/lib/app/app_controller_desktop_thread_sessions_collaboration_impl.dart b/lib/app/app_controller_desktop_thread_sessions_collaboration_impl.dart index 8999e21b..95241e21 100644 --- a/lib/app/app_controller_desktop_thread_sessions_collaboration_impl.dart +++ b/lib/app/app_controller_desktop_thread_sessions_collaboration_impl.dart @@ -160,12 +160,18 @@ Future runMultiAgentCollaborationThreadSessionInternal( ); controller.recomputeTasksInternal(); try { + final taskPrompt = controller.taskWorkspaceContextPromptInternal( + sessionKey: sessionKey, + userPrompt: composedPrompt, + workingDirectory: workingDirectory, + remoteWorkingDirectoryHint: remoteWorkingDirectoryHint?.trim() ?? '', + ); final result = await controller.goTaskServiceClientInternal.executeTask( GoTaskServiceRequest( sessionId: sessionKey, threadId: sessionKey, target: controller.assistantExecutionTargetForSession(sessionKey), - prompt: composedPrompt, + prompt: taskPrompt, workingDirectory: workingDirectory, remoteWorkingDirectoryHint: remoteWorkingDirectoryHint?.trim() ?? '', model: controller.assistantModelForSession(sessionKey), diff --git a/lib/features/assistant/assistant_page_task_models.dart b/lib/features/assistant/assistant_page_task_models.dart index 896528a5..ee87dbf4 100644 --- a/lib/features/assistant/assistant_page_task_models.dart +++ b/lib/features/assistant/assistant_page_task_models.dart @@ -344,6 +344,9 @@ String sessionStatusInternal( if (session.abortedLastRun == true) { return 'failed'; } + if (normalizedLifecycle == 'queued') { + return 'queued'; + } if (sessionPending) { return 'running'; } diff --git a/lib/widgets/assistant_task_progress_bar.dart b/lib/widgets/assistant_task_progress_bar.dart index d2fa09c6..8b814e5e 100644 --- a/lib/widgets/assistant_task_progress_bar.dart +++ b/lib/widgets/assistant_task_progress_bar.dart @@ -2,7 +2,13 @@ import 'package:flutter/material.dart'; import '../i18n/app_language.dart'; -enum AssistantTaskProgressPhase { idle, running, syncingArtifacts, interrupted } +enum AssistantTaskProgressPhase { + idle, + queued, + running, + syncingArtifacts, + interrupted, +} class AssistantTaskProgressState { const AssistantTaskProgressState({ @@ -26,6 +32,7 @@ class AssistantTaskProgressState { bool get visible => phase != AssistantTaskProgressPhase.idle; bool get interrupted => phase == AssistantTaskProgressPhase.interrupted; bool get running => + phase == AssistantTaskProgressPhase.queued || phase == AssistantTaskProgressPhase.running || phase == AssistantTaskProgressPhase.syncingArtifacts; } @@ -146,6 +153,15 @@ AssistantTaskProgressState assistantTaskProgressState({ final budget = runtimeBudgetMinutes == null || runtimeBudgetMinutes <= 0 ? null : runtimeBudgetMinutes; + final result = lastResultCode.trim().toUpperCase(); + if (status == 'queued' || syncStatus == 'queued' || result == 'QUEUED') { + return AssistantTaskProgressState( + phase: AssistantTaskProgressPhase.queued, + label: appText('任务已排队,等待执行...', 'Task queued, waiting to run...'), + value: 0.18, + runtimeBudgetMinutes: budget, + ); + } if (pending && syncStatus == 'syncing') { return AssistantTaskProgressState( phase: AssistantTaskProgressPhase.syncingArtifacts, @@ -161,7 +177,6 @@ AssistantTaskProgressState assistantTaskProgressState({ runtimeBudgetMinutes: budget, ); } - final result = lastResultCode.trim().toUpperCase(); if (status == 'interrupted' || syncStatus == 'interrupted') { return AssistantTaskProgressState( phase: AssistantTaskProgressPhase.interrupted, diff --git a/pubspec.yaml b/pubspec.yaml index 2961a1c5..3973dc4c 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -2,9 +2,9 @@ name: xworkmate description: "XWorkmate desktop-first AI workspace shell." publish_to: 'none' -version: 1.0.0-beta.2+5 -build-date: 2026-05-18 -build-id: e7e1135 +version: 1.0.0-beta.2+6 +build-date: 2026-05-19 +build-id: 0f5440d environment: sdk: ^3.11.0 diff --git a/test/features/assistant/assistant_task_progress_bar_test.dart b/test/features/assistant/assistant_task_progress_bar_test.dart index 10b1afd2..c482d9f5 100644 --- a/test/features/assistant/assistant_task_progress_bar_test.dart +++ b/test/features/assistant/assistant_task_progress_bar_test.dart @@ -57,6 +57,32 @@ void main() { expect(indicator.value, 0.82); }); + testWidgets('shows queued progress while a task waits to run', ( + tester, + ) async { + await tester.pumpWidget( + _buildTestApp( + assistantTaskProgressState( + pending: true, + lifecycleStatus: 'queued', + lastResultCode: 'queued', + artifactSyncStatus: 'queued', + ), + onStop: () {}, + ), + ); + + expect(find.text('任务已排队,等待执行...'), findsOneWidget); + expect( + find.byKey(const Key('assistant-task-progress-stop-button')), + findsOneWidget, + ); + final indicator = tester.widget( + find.byKey(const Key('assistant-task-progress-indicator')), + ); + expect(indicator.value, 0.18); + }); + testWidgets('shows interrupted state after ACP connection closes', ( tester, ) async { diff --git a/test/runtime/assistant_execution_target_test.dart b/test/runtime/assistant_execution_target_test.dart index d0a19873..1058c504 100644 --- a/test/runtime/assistant_execution_target_test.dart +++ b/test/runtime/assistant_execution_target_test.dart @@ -898,7 +898,21 @@ void main() { await controller.sendChatMessage('first turn'); expect(fakeGoTaskService.requests, hasLength(1)); - expect(fakeGoTaskService.requests.single.resumeSession, isFalse); + final request = fakeGoTaskService.requests.single; + expect(request.resumeSession, isFalse); + expect(request.prompt, contains('TaskThread workspace context:')); + expect(request.prompt, contains('- sessionKey: unit-fixture-task-a')); + expect(request.prompt, contains(request.workingDirectory)); + expect(request.prompt, contains(request.remoteWorkingDirectoryHint)); + expect(request.prompt, contains('User request:\nfirst turn')); + expect( + controller.chatMessages.map((message) => message.text), + contains('first turn'), + ); + expect( + controller.chatMessages.map((message) => message.text).join('\n'), + isNot(contains('TaskThread workspace context:')), + ); }); test( @@ -1632,11 +1646,15 @@ void main() { final taskBRequest = fakeGoTaskService.requests[1]; expect(taskARequest.sessionId, sessionA); expect(taskBRequest.sessionId, sessionB); - expect(taskARequest.prompt, taskBRequest.prompt); + expect(taskARequest.prompt, isNot(taskBRequest.prompt)); + expect(taskARequest.prompt, contains(prompt)); + expect(taskBRequest.prompt, contains(prompt)); expect(taskARequest.resumeSession, isFalse); expect(taskBRequest.resumeSession, isFalse); expect(taskARequest.workingDirectory, endsWith('/$sessionA')); expect(taskBRequest.workingDirectory, endsWith('/$sessionB')); + expect(taskARequest.prompt, contains(taskARequest.workingDirectory)); + expect(taskBRequest.prompt, contains(taskBRequest.workingDirectory)); expect( taskARequest.workingDirectory, isNot(taskBRequest.workingDirectory), @@ -2192,6 +2210,26 @@ void main() { .status, 'queued', ); + expect( + controller.assistantSessionHasPendingRun('queue-task-b'), + isTrue, + ); + expect( + controller.assistantSessionHasPendingRun('queue-task-c'), + isTrue, + ); + expect( + controller.localSessionMessagesInternal['queue-task-b']!.map( + (message) => message.text, + ), + contains('same prompt'), + ); + expect( + controller.localSessionMessagesInternal['queue-task-c']!.map( + (message) => message.text, + ), + contains('different prompt'), + ); fakeGoTaskService.complete( 'queue-task-a', @@ -2214,9 +2252,12 @@ void main() { final taskBRequest = fakeGoTaskService.requests[1]; expect(taskBRequest.sessionId, 'queue-task-b'); - expect(taskBRequest.prompt, 'same prompt'); + expect(taskBRequest.prompt, contains('TaskThread workspace context:')); + expect(taskBRequest.prompt, contains('- sessionKey: queue-task-b')); + expect(taskBRequest.prompt, contains('User request:\nsame prompt')); expect(taskBRequest.resumeSession, isFalse); expect(taskBRequest.workingDirectory, endsWith('/queue-task-b')); + expect(taskBRequest.prompt, contains(taskBRequest.workingDirectory)); expect( taskBRequest.remoteWorkingDirectoryHint, endsWith('/threads/queue-task-b'), @@ -2239,12 +2280,25 @@ void main() { 'queue-task-b', 'ready', ); + expect( + controller.localSessionMessagesInternal['queue-task-b']! + .where( + (message) => + message.role == 'user' && message.text == 'same prompt', + ) + .length, + 1, + ); await fakeGoTaskService.waitForRequestCount(3); final taskCRequest = fakeGoTaskService.requests[2]; expect(taskCRequest.sessionId, 'queue-task-c'); - expect(taskCRequest.prompt, 'different prompt'); + expect( + taskCRequest.prompt, + contains('User request:\ndifferent prompt'), + ); expect(taskCRequest.workingDirectory, endsWith('/queue-task-c')); + expect(taskCRequest.prompt, contains(taskCRequest.workingDirectory)); fakeGoTaskService.complete( 'queue-task-c', const GoTaskServiceResult(