diff --git a/lib/app/app_controller_desktop_thread_actions.dart b/lib/app/app_controller_desktop_thread_actions.dart index eae81a95..d06f93f1 100644 --- a/lib/app/app_controller_desktop_thread_actions.dart +++ b/lib/app/app_controller_desktop_thread_actions.dart @@ -792,7 +792,11 @@ extension AppControllerDesktopThreadActions on AppController { : result.status.trim(), ); current = nextAssociation; - if (result.isOpenClawRunningTaskHandle) { + // A tasks.get response may temporarily omit association decoration + // while the plugin/runtime is reconnecting. The explicit status is + // authoritative; never finalize a running task as an empty success. + if (result.status.trim().toLowerCase() == 'running' || + result.isOpenClawRunningTaskHandle) { // T3: 给 running 轮询加兜底截止,避免 gateway 始终回 running 时无限轮询、永远卡「任务运行中...」。 final nowMs = DateTime.now().millisecondsSinceEpoch.toDouble(); runningPollFirstAtMs ??= nowMs; diff --git a/test/runtime/assistant_execution_target_test.dart b/test/runtime/assistant_execution_target_test.dart index 972a7309..9276a09a 100644 --- a/test/runtime/assistant_execution_target_test.dart +++ b/test/runtime/assistant_execution_target_test.dart @@ -4161,6 +4161,86 @@ void main() { ); }); + test( + 'OpenClaw running snapshot without decoration keeps polling', + () async { + final fakeGoTaskService = _RecordingGoTaskServiceClient() + ..taskOutcomes.add( + const GoTaskServiceResult( + success: true, + message: '', + turnId: 'turn-running-undecorated', + raw: { + 'success': true, + 'status': 'running', + 'runId': 'run-running-undecorated', + }, + errorMessage: '', + resolvedModel: '', + route: GoTaskServiceRoute.externalAcpSingle, + ), + ) + ..taskOutcomes.add( + const GoTaskServiceResult( + success: true, + message: 'terminal output', + turnId: 'turn-running-undecorated', + raw: { + 'success': true, + 'status': 'completed', + 'runId': 'run-running-undecorated', + 'output': 'terminal output', + }, + errorMessage: '', + resolvedModel: '', + route: GoTaskServiceRoute.externalAcpSingle, + ), + ); + final controller = _connectedGatewayController(fakeGoTaskService); + addTearDown(controller.dispose); + const association = OpenClawTaskAssociation( + sessionId: 'running-undecorated', + threadId: 'running-undecorated', + turnId: 'turn-running-undecorated', + runId: 'run-running-undecorated', + artifactScope: 'tasks/running-undecorated/run-running-undecorated', + artifactDirectory: + '/tmp/tasks/running-undecorated/run-running-undecorated', + gatewayProviderId: 'openclaw', + startedAtMs: 0, + status: 'running', + appThreadKey: 'running-undecorated', + openclawSessionKey: 'agent:main:running-undecorated', + ); + controller.upsertTaskThreadInternal( + association.sessionId, + executionTarget: AssistantExecutionTarget.gateway, + selectedProvider: SingleAgentProvider.openclaw, + lifecycleStatus: 'running', + lastResultCode: 'running', + openClawTaskAssociation: association, + ); + controller.aiGatewayPendingSessionKeysInternal.add( + association.sessionId, + ); + + await controller.pollOpenClawTaskAssociationInternal( + sessionKey: association.sessionId, + target: AssistantExecutionTarget.gateway, + association: association, + pollInterval: Duration.zero, + ); + + expect(fakeGoTaskService.getTaskCount, 2); + expect( + controller.localSessionMessagesInternal[association.sessionId]!.map( + (message) => message.text, + ), + contains('terminal output'), + ); + }, + ); + test('OpenClaw task snapshot retry exhaustion is terminal', () async { final fakeGoTaskService = _RecordingGoTaskServiceClient(); for (