fix: recover openclaw interrupted results

This commit is contained in:
Haitao Pan 2026-05-22 11:20:46 +08:00
parent 68a1eeb1a0
commit cbd8e478c2
3 changed files with 144 additions and 7 deletions

View File

@ -220,7 +220,7 @@ class ExternalCodeAgentAcpDesktopTransport
.toString()
.trim()
.toLowerCase();
final result = _castMap(snapshot['result']);
final result = _recoveredResultFromSessionSnapshot(snapshot);
if (result.isNotEmpty &&
(status == 'completed' ||
status == 'failed' ||
@ -307,7 +307,7 @@ class ExternalCodeAgentAcpDesktopTransport
snapshot.remove('result');
snapshot['turnId'] = update.turnId;
snapshot['success'] = !update.error;
final text = _firstNonEmptyString(snapshot, const <String>[
final text = _firstNonEmptyDisplayText(snapshot, const <String>[
'output',
'message',
'summary',
@ -322,9 +322,58 @@ class ExternalCodeAgentAcpDesktopTransport
return snapshot;
}
String _firstNonEmptyString(Map<String, dynamic> values, List<String> keys) {
Map<String, dynamic> _recoveredResultFromSessionSnapshot(
Map<String, dynamic> snapshot,
) {
final result = <String, dynamic>{..._castMap(snapshot['result'])};
final artifactRecord = _castMap(snapshot['artifacts']);
final artifactItems = _listValue(artifactRecord['items']);
if (artifactItems.isNotEmpty && !_hasArtifactList(result)) {
result['artifacts'] = artifactItems;
}
for (final entry in <String, String>{
'remoteWorkingDirectory': 'remoteWorkingDirectory',
'remoteWorkspaceRefKind': 'remoteWorkspaceRefKind',
'resultSummary': 'summary',
}.entries) {
final value = artifactRecord[entry.key]?.toString().trim() ?? '';
if (value.isNotEmpty &&
(result[entry.value]?.toString().trim().isEmpty ?? true)) {
result[entry.value] = value;
}
}
return result;
}
bool _hasArtifactList(Map<String, dynamic> result) {
for (final key in const <String>['artifacts', 'files', 'attachments']) {
if (_listValue(result[key]).isNotEmpty) {
return true;
}
final recordItems = _listValue(_castMap(result[key])['items']);
if (recordItems.isNotEmpty) {
return true;
}
}
for (final key in const <String>['payload', 'result', 'data']) {
final nested = _castMap(result[key]);
if (nested.isNotEmpty && _hasArtifactList(nested)) {
return true;
}
}
return false;
}
List<Object?> _listValue(Object? value) {
return value is List ? value : const <Object?>[];
}
String _firstNonEmptyDisplayText(
Map<String, dynamic> values,
List<String> keys,
) {
for (final key in keys) {
final value = values[key]?.toString().trim() ?? '';
final value = _displayText(values[key]).trim();
if (value.isNotEmpty) {
return value;
}
@ -332,6 +381,55 @@ class ExternalCodeAgentAcpDesktopTransport
return '';
}
String _displayText(Object? value, [Set<Object>? visited]) {
final seen = visited ?? <Object>{};
if (value == null) {
return '';
}
if (value is String) {
return value.trim();
}
if (value is Map) {
if (!seen.add(value)) {
return '';
}
final map = value.cast<String, dynamic>();
for (final key in const <String>[
'output',
'summary',
'resultSummary',
'message',
'content',
'text',
'delta',
'output_text',
]) {
final extracted = _displayText(map[key], seen);
if (extracted.isNotEmpty) {
return extracted;
}
}
for (final key in const <String>['result', 'payload', 'data']) {
final extracted = _displayText(map[key], seen);
if (extracted.isNotEmpty) {
return extracted;
}
}
return '';
}
if (value is List) {
if (!seen.add(value)) {
return '';
}
return value
.map((item) => _displayText(item, seen))
.where((item) => item.isNotEmpty)
.join('\n')
.trim();
}
return value.toString().trim();
}
Map<String, dynamic> _castMap(Object? value) {
if (value is Map<String, dynamic>) {
return value;

View File

@ -589,6 +589,11 @@ Object? _firstGoTaskArtifactList(Map<String, dynamic> result) {
]) {
if (candidate is List) {
artifacts.addAll(candidate);
} else if (candidate is Map) {
final items = _castList(candidate.cast<String, dynamic>()['items']);
if (items.isNotEmpty) {
artifacts.addAll(items);
}
}
}
return artifacts.isEmpty ? null : artifacts;
@ -723,7 +728,7 @@ GoTaskServiceUpdate? goTaskServiceUpdateFromAcpNotification(
payload['text']?.toString() ??
_castMap(payload['message'])['content']?.toString() ??
'',
message: payload['message']?.toString() ?? '',
message: _extractGoTaskDisplayText(payload['message']),
pending: _boolValue(payload['pending']) ?? false,
error: _boolValue(payload['error']) ?? false,
route:

View File

@ -184,6 +184,31 @@ void main() {
expect(result.artifacts.single.content, 'nested artifact body');
});
test('uses bridge artifact record items as artifacts', () {
final result = goTaskServiceResultFromAcpResponse(<String, dynamic>{
'jsonrpc': '2.0',
'id': 'request-id',
'result': <String, dynamic>{
'success': true,
'message': 'created from snapshot record',
'artifacts': <String, dynamic>{
'items': <Map<String, dynamic>>[
<String, dynamic>{
'relativePath': 'exports/report.md',
'content': 'snapshot artifact body',
'contentType': 'text/markdown',
},
],
},
},
}, route: GoTaskServiceRoute.externalAcpSingle);
expect(result.message, 'created from snapshot record');
expect(result.artifacts, hasLength(1));
expect(result.artifacts.single.relativePath, 'exports/report.md');
expect(result.artifacts.single.content, 'snapshot artifact body');
});
test('uses bridge files and attachments aliases as artifacts', () {
final result = goTaskServiceResultFromAcpResponse(<String, dynamic>{
'jsonrpc': '2.0',
@ -582,7 +607,9 @@ void main() {
'event': 'completed',
'pending': false,
'error': false,
'message': 'stable completed output',
'message': <String, dynamic>{
'content': 'stable completed output',
},
'result': <String, dynamic>{
'success': true,
'output': 'stable completed output',
@ -692,7 +719,9 @@ void main() {
'success': true,
'output': 'recovered from bridge session snapshot',
'turnId': 'turn-recovered',
'artifacts': <Map<String, dynamic>>[
},
'artifacts': <String, dynamic>{
'items': <Map<String, dynamic>>[
<String, dynamic>{
'relativePath': 'exports/snapshot.md',
'downloadUrl':
@ -702,6 +731,9 @@ void main() {
'sizeBytes': 64,
},
],
'remoteWorkingDirectory': '/remote/openclaw/workspace',
'remoteWorkspaceRefKind': 'remotePath',
'resultSummary': 'recovered from top-level artifacts',
},
},
}),
@ -743,6 +775,8 @@ void main() {
expect(result.success, isTrue);
expect(result.message, 'recovered from bridge session snapshot');
expect(result.artifacts.single.relativePath, 'exports/snapshot.md');
expect(result.remoteWorkingDirectory, '/remote/openclaw/workspace');
expect(result.remoteWorkspaceRefKind, WorkspaceRefKind.remotePath);
expect(
requestPaths,
containsAll(<String>['/gateway/openclaw', '/acp/rpc']),