mirror of
https://github.com/cogwheel0/conduit.git
synced 2026-10-19 01:37:03 +00:00
Some checks are pending
L10n / l10n (push) Waiting to run
* 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.
229 lines
7.8 KiB
Dart
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="{"city":"Oslo"}" '
|
|
'result=""Sunny"">\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);
|
|
});
|
|
});
|
|
});
|
|
}
|