conduit/test/features/chat/providers/local_echo_payload_test.dart
cogwheel 4e73c9a0ea
Some checks are pending
L10n / l10n (push) Waiting to run
Fix ten reported issues and vendor Hermes Agent as an API reference (#725)
* Fix ten reported issues and vendor Hermes Agent as an API reference

Closes #693, #695, #699, #700, #701, #703, #708, #712, #716, #723.

- Hermes (#693, #699): load transcripts over REST even when session.resume
  fails, page at 200 messages and shrink on oversized responses, and show
  distinct errors for too-large, not-found, and RPC failures. Add the
  hermes-src submodule (NousResearch/hermes-agent v2026.9.11) as the RPC and
  REST contract reference.
- Server version banner (#695): dismissible, remembered per server and version.
- Model selector (#700): overflow fade and selection tint use the card surface.
- OpenRouter (#701): skip Batch API-only ":batch" model variants.
- Persisted chat content (#703): store plain output text instead of rendered
  <details> markup when structured output exists, matching the web client.
- Direct connections (#708): probes report their real failure instead of a
  generic message, the Open WebUI logout fence no longer blocks Direct setup,
  and 401/403 under the api-key header mode hints at Bearer auth.
- Reasoning effort (#712): per-server, per-model user picks override the
  model's configured default and are sent explicitly in the request.
- Voice calls (#716): stop the record plugin from clearing the Android
  communication device and verify the loudspeaker route before reporting it.
- Voice mode (#723): finalize speech on the responseDone marker.
- Add the five new strings to every locale.

* Address review: keep incomplete-clear block, pass effort on sends, keep all dismissals

- Direct profiles stay hidden and unusable after an incomplete full app-data
  clear, in memory and across restarts, until the logout fence is cleared by a
  completed cleanup or a new authenticated session. Ordinary failed sign-outs
  still do not block Direct setup.
- The primary Open WebUI send path now passes the resolved per-model reasoning
  effort like regeneration and queued completions already did.
- Server version warning dismissals are stored as a bounded set so dismissing
  one server no longer forgets another.

* Address review: release rejected speaker device, speak settled text on responseDone

- When Android accepts the built-in speaker but the read-back disagrees,
  clear that selection before the legacy setSpeakerphoneOn fallback, which is
  ignored while a communication device stays selected.
- Voice mode takes the final text from the message once responseDone is set;
  the streaming content provider can lag behind the flushed message.

* Address review: await the incomplete-clear restart marker before returning

The marker is written through the app-data-clear barrier with a checked write
and awaited by the clear coordinator, so a process death right after an
incomplete clear cannot reload the surviving Direct profiles on restart.

* Address review: arm the restart fence before the wipe and propagate marker failures

The incomplete-clear marker is now armed before any data is wiped, disarmed
only when the clear completes or yields ownership, re-asserted on an
incomplete outcome, and a failed checked write aborts the clear instead of
being swallowed. The in-memory block is unchanged.

* Do not raise an error banner for response.incomplete

Open WebUI forwards the provider's `response.incomplete` event over the
socket but treats it as a no-op: the output is kept and the turn still ends
with a normal `done`. Raising an error on that event flashed "The response
stopped before it was complete" under the answer until `done` cleared it.
`response.failed` still surfaces the provider error.
2026-09-15 00:48:03 +05:30

229 lines
7.8 KiB
Dart

import 'package:checks/checks.dart';
import 'package:conduit/core/models/chat_message.dart';
import 'package:conduit/core/services/direct_replay_output.dart';
import 'package:conduit/features/chat/providers/chat_providers.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
group('localEchoRowForMessage', () {
final message = ChatMessage(
id: 'assistant-1',
role: 'assistant',
content: 'Answer',
timestamp: DateTime.fromMillisecondsSinceEpoch(1700000000000),
model: 'gpt-4o',
isStreaming: false,
metadata: const <String, dynamic>{'modelName': 'GPT-4o'},
output: const <Map<String, dynamic>>[
<String, dynamic>{'type': 'text', 'text': 'Answer'},
],
files: const <Map<String, dynamic>>[
<String, dynamic>{'type': 'image', 'url': 'https://x/img.png'},
],
embeds: const <Map<String, dynamic>>[
<String, dynamic>{'html': '<iframe></iframe>'},
],
usage: const <String, dynamic>{'total_tokens': 42},
sources: const <ChatSourceReference>[
ChatSourceReference(
id: 'src-1',
title: 'Docs',
url: 'https://example.com',
snippet: 'Snippet text',
type: 'web_search',
),
],
statusHistory: const <ChatStatusUpdate>[
ChatStatusUpdate(description: 'Searching', done: true),
],
codeExecutions: const <ChatCodeExecution>[
ChatCodeExecution(
id: 'exec-1',
name: 'run',
language: 'python',
code: 'print(1)',
result: ChatCodeExecutionResult(output: '1'),
),
],
followUps: const <String>['Next?'],
error: const ChatMessageError(content: 'boom'),
);
test('carries every durable field the sync outbox must replay', () {
// The outbox rebuilds the full chat blob from these rows and the server
// replaces each message object wholesale — a key missing here is a key
// wiped from the server copy on the next push.
final payload = localEchoRowForMessage('chat-1', message).payload;
const durableKeys = {
'id',
'parentId',
'childrenIds',
'role',
'content',
'timestamp',
'isStreaming',
'done',
'model',
'metadata',
'output',
'files',
'embeds',
'usage',
'sources',
'statusHistory',
'code_executions',
'followUps',
'error',
};
check(durableKeys.difference(payload.keys.toSet())).isEmpty();
});
test('persists sources and code executions in the server shape', () {
// The OWUI web client reads citation-shaped `sources` and snake_case
// `code_executions`; client-model shapes would break that client for
// any chat synced from Conduit.
final payload = localEchoRowForMessage('chat-1', message).payload;
final source =
(payload['sources'] as List).single as Map<String, dynamic>;
check(source['source']).isA<Map<String, dynamic>>();
check(source['document']).isA<List<dynamic>>();
check(source.containsKey('snippet')).isFalse();
final execution =
(payload['code_executions'] as List).single as Map<String, dynamic>;
check(execution['id']).equals('exec-1');
check((execution['result'] as Map<String, dynamic>)['output'])
.equals('1');
check(payload.containsKey('codeExecutions')).isFalse();
});
group('persisted content (issue #703)', () {
const renderedDetails =
'<details type="tool_calls" done="true" id="call-1" '
'name="get_weather" arguments="{&quot;city&quot;:&quot;Oslo&quot;}" '
'result="&quot;Sunny&quot;">\n'
'<summary>Tool Executed</summary>\n</details>\n';
const structuredOutput = <Map<String, dynamic>>[
<String, dynamic>{
'type': 'function_call',
'id': 'call-1',
'call_id': 'call-1',
'name': 'get_weather',
'arguments': '{"city":"Oslo"}',
'status': 'completed',
},
<String, dynamic>{
'type': 'function_call_output',
'call_id': 'call-1',
'output': '"Sunny"',
},
<String, dynamic>{
'type': 'message',
'id': 'msg-1',
'role': 'assistant',
'status': 'completed',
'content': <Map<String, dynamic>>[
<String, dynamic>{'type': 'output_text', 'text': 'It is sunny.'},
],
},
];
ChatMessage assistant({
required String content,
List<Map<String, dynamic>>? output,
}) => ChatMessage(
id: 'assistant-2',
role: 'assistant',
content: content,
timestamp: DateTime.fromMillisecondsSinceEpoch(1700000000000),
model: 'gpt-4o',
isStreaming: false,
output: output,
);
test('stores the output message text, not the rendered details', () {
// The OWUI web client persists `content = getOutputText(output)` and
// rebuilds the <details> presentation from `output` on load; pushing
// the rendered markup leaks it into shares and exports.
final row = localEchoRowForMessage(
'chat-1',
assistant(
content: '${renderedDetails}It is sunny.',
output: structuredOutput,
),
);
check(row.payload['content']).equals('It is sunny.');
check(row.content).equals('It is sunny.');
check(row.payload['output'])
.isA<List<Object?>>()
.deepEquals(structuredOutput);
});
test('keeps content byte-for-byte when the message has no output', () {
const content = '${renderedDetails}It is sunny.';
final row = localEchoRowForMessage(
'chat-1',
assistant(content: content, output: null),
);
check(row.payload['content']).equals(content);
check(row.content).equals(content);
check(row.payload.containsKey('output')).isFalse();
});
test('leaves user messages untouched', () {
const content =
'<details type="tool_calls"><summary>x</summary>'
'</details>\nPlease keep this literally.';
final user = ChatMessage(
id: 'user-1',
role: 'user',
content: content,
timestamp: DateTime.fromMillisecondsSinceEpoch(1700000000000),
output: structuredOutput,
);
check(localEchoRowForMessage('chat-1', user).payload['content'])
.equals(content);
});
test('direct payload stores the output message text too', () {
final payload = directPersistedMessagePayloadForTest(
assistant(
content: '${renderedDetails}It is sunny.',
output: structuredOutput,
),
);
check(payload['content']).equals('It is sunny.');
check(payload['output'])
.isA<List<Object?>>()
.deepEquals(structuredOutput);
});
test('direct payload keeps the rendered content for a replay mirror', () {
// On load the direct replay mirror short-circuits the structured
// output re-synthesis, so the rendered content is the only copy of
// the reasoning/tool presentation the UI can show.
const content =
'<details type="reasoning" done="true" duration="2">\n'
'<summary>Thought for 2 seconds</summary>\n> hmm\n</details>\n'
'It is sunny.';
final payload = directPersistedMessagePayloadForTest(
assistant(
content: content,
output: buildConduitDirectReplayOutput(
assistantMessageId: 'assistant-2',
rawContent: 'It is sunny.',
),
),
);
check(payload['content']).equals(content);
});
});
});
}