Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

166 changes: 166 additions & 0 deletions apps/flutter/integration_test/history_recovery_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
// Live desktop regression: real bridge -> relay -> app-server -> transcript.
// Use an isolated CODEX_HOME copy: resuming a thread can update its metadata.
// Run with --dart-define-from-file containing PCX_RELAY, PCX_KEY, PCX_SERVICE,
// PCX_HISTORY_THREAD (paginated, >100 items) and PCX_SECOND_THREAD (legacy).
// Both threads must be visible in thread/list; publish the matching meta
// service too, as the screen restores its per-thread configuration from it.
// This test never starts a model turn.

import 'dart:io';

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import 'package:pocket_codex/l10n/gen/app_localizations.dart';
import 'package:pocket_codex/src/bridge_api.dart';
import 'package:pocket_codex/src/bridge_api_rust.dart';
import 'package:pocket_codex/src/providers.dart';
import 'package:pocket_codex/src/rust/api/bridge.dart' as frb;
import 'package:pocket_codex/src/rust/frb_generated.dart';
import 'package:pocket_codex/src/screens/app_session_screen.dart';
import 'package:pocket_codex/src/ui_prefs.dart';
import 'package:super_sliver_list/super_sliver_list.dart';
import 'package:window_manager/window_manager.dart';

const _relay = String.fromEnvironment('PCX_RELAY');
const _key = String.fromEnvironment('PCX_KEY');
const _service = String.fromEnvironment('PCX_SERVICE');
const _paginated = String.fromEnvironment('PCX_HISTORY_THREAD');
const _legacy = String.fromEnvironment('PCX_SECOND_THREAD');

class _MemoryPrefs extends UiPrefsStore {
@override
Future<UiPrefs> build() async => const UiPrefs();
}

class _ObservedBridge extends RustBridgeApi {
final histories = <String, ThreadHistory>{};
final olderPages = <OlderPage>[];

@override
Future<ThreadHistory> appThreadRead(
String serviceKey,
String threadId,
) async {
final history = await super.appThreadRead(serviceKey, threadId);
histories[threadId] = history;
return history;
}

@override
Future<OlderPage> appThreadOlderPage(
String serviceKey,
String threadId,
) async {
final page = await super.appThreadOlderPage(serviceKey, threadId);
olderPages.add(page);
return page;
}
}

Future<void> _until(WidgetTester tester, bool Function() ready) async {
final deadline = DateTime.now().add(const Duration(seconds: 90));
while (!ready() && DateTime.now().isBefore(deadline)) {
await Future<void>.delayed(const Duration(milliseconds: 100));
}
expect(ready(), isTrue, reason: 'live history operation did not complete');
await Future<void>.delayed(const Duration(milliseconds: 100));
}

void main() {
final binding = IntegrationTestWidgetsFlutterBinding.ensureInitialized();
binding.framePolicy = LiveTestWidgetsFlutterBindingFramePolicy.fullyLive;
testWidgets(
'repeated thread opens and older history through the real relay',
(tester) async {
if ([_relay, _key, _service, _paginated, _legacy].any((v) => v.isEmpty)) {
markTestSkipped('isolated live-history configuration not provided');
return;
}
await RustLib.init();
await windowManager.ensureInitialized();
await windowManager.show();
await windowManager.focus();
final dir = await Directory.systemTemp.createTemp('pcx-history-it-');
await frb.initBridge(supportDir: dir.path);
await frb.setRelay(relay: _relay);
await frb.setKey(key: _key);
final api = _ObservedBridge();
await api.appConnect(_service, 0);
addTearDown(() async {
await tester.pumpWidget(const SizedBox.shrink());
await api.appDisconnect(_service);
// The native logger keeps its file open until process exit on Windows.
});

for (var round = 0; round < 3; round++) {
final threads = await api.appThreadList(_service);
expect(threads.any((thread) => thread.id == _paginated), isTrue);
expect(threads.any((thread) => thread.id == _legacy), isTrue);
}

for (final threadId in [_paginated, _legacy, _paginated]) {
api.histories.remove(threadId);
await tester.pumpWidget(
ProviderScope(
overrides: [
bridgeApiProvider.overrideWithValue(api),
uiPrefsProvider.overrideWith(_MemoryPrefs.new),
],
child: MaterialApp(
locale: const Locale('zh'),
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: AppSessionScreen(
key: ValueKey(threadId),
serviceKey: _service,
threadId: threadId,
home: true,
),
),
),
);
await _until(tester, () => api.histories.containsKey(threadId));
await _until(
tester,
() => find.byType(SuperListView).evaluate().isNotEmpty,
);
expect(api.histories[threadId]!.items, isNotEmpty);
expect(api.appIsConnected(_service), isTrue);
expect(tester.takeException(), isNull);
debugPrint(
'Live history rendered: ${api.histories[threadId]!.items.length} items',
);
}

expect(api.histories[_paginated]!.turns, isNotEmpty);
expect(api.histories[_paginated]!.hasOlder, isTrue);
// Opening settles the transcript at its end over several layout frames.
await Future<void>.delayed(const Duration(milliseconds: 500));
final pagesBeforeScroll = api.olderPages.length;
final transcript = tester.widget<SuperListView>(
find.byType(SuperListView),
);
transcript.controller!.jumpTo(0);
final olderButton = find.byKey(const Key('chat-older-history-load'));
await _until(
tester,
() =>
api.olderPages.length > pagesBeforeScroll ||
olderButton.hitTestable().evaluate().isNotEmpty,
);
if (api.olderPages.length == pagesBeforeScroll) {
await tester.tap(olderButton);
}
await _until(tester, () => api.olderPages.length > pagesBeforeScroll);
expect(api.olderPages.last.items, isNotEmpty);
expect(api.appIsConnected(_service), isTrue);
expect(tester.takeException(), isNull);
debugPrint(
'Live older history loaded: ${api.olderPages.last.items.length} items',
);
},
timeout: const Timeout(Duration(minutes: 8)),
);
}
4 changes: 4 additions & 0 deletions apps/flutter/lib/l10n/app_en.arb
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,10 @@
"interrupt": "Interrupt",
"thinking": "Thinking…",
"emptyConversation": "Send a message to start the conversation",
"olderHistoryHint": "Scroll up to load earlier messages",
"@olderHistoryHint": {
"description": "Leading row of a long conversation whose earlier history hasn't been loaded yet; scrolling to it fetches the previous page."
},
"turnFailed": "Turn didn't finish — the connection dropped or the remote codex failed. Retry, or check codex on the host machine (it may need to be logged in again).",
"sandboxHelperUnavailable": "This built-in (自带) session can't start its command sandbox, so the agent can't run commands or read files here. Switch to \"Full access\" mode to run without a sandbox, or connect to an external/remote codex host.",
"@sandboxHelperUnavailable": {
Expand Down
1 change: 1 addition & 0 deletions apps/flutter/lib/l10n/app_zh.arb
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@
"interrupt": "打断",
"thinking": "思考中…",
"emptyConversation": "发送消息开始对话",
"olderHistoryHint": "向上滚动加载更早的消息",
"turnFailed": "本轮未完成 —— 连接中断或远程 codex 异常。请重试,或检查主机上的 codex(可能需重新登录)。",
"sandboxHelperUnavailable": "此自带会话无法启动命令沙箱,因此智能体在这里无法执行命令或读取文件。请切换到「完全放行」模式(不使用沙箱运行),或连接到外部/远程 codex 主机。",
"disconnect": "断开连接",
Expand Down
6 changes: 6 additions & 0 deletions apps/flutter/lib/l10n/gen/app_localizations.dart
Original file line number Diff line number Diff line change
Expand Up @@ -578,6 +578,12 @@ abstract class AppLocalizations {
/// **'Send a message to start the conversation'**
String get emptyConversation;

/// Leading row of a long conversation whose earlier history hasn't been loaded yet; scrolling to it fetches the previous page.
///
/// In en, this message translates to:
/// **'Scroll up to load earlier messages'**
String get olderHistoryHint;

/// No description provided for @turnFailed.
///
/// In en, this message translates to:
Expand Down
3 changes: 3 additions & 0 deletions apps/flutter/lib/l10n/gen/app_localizations_en.dart
Original file line number Diff line number Diff line change
Expand Up @@ -279,6 +279,9 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get emptyConversation => 'Send a message to start the conversation';

@override
String get olderHistoryHint => 'Scroll up to load earlier messages';

@override
String get turnFailed =>
'Turn didn\'t finish — the connection dropped or the remote codex failed. Retry, or check codex on the host machine (it may need to be logged in again).';
Expand Down
3 changes: 3 additions & 0 deletions apps/flutter/lib/l10n/gen/app_localizations_zh.dart
Original file line number Diff line number Diff line change
Expand Up @@ -275,6 +275,9 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get emptyConversation => '发送消息开始对话';

@override
String get olderHistoryHint => '向上滚动加载更早的消息';

@override
String get turnFailed =>
'本轮未完成 —— 连接中断或远程 codex 异常。请重试,或检查主机上的 codex(可能需重新登录)。';
Expand Down
61 changes: 61 additions & 0 deletions apps/flutter/lib/src/bridge_api.dart
Original file line number Diff line number Diff line change
Expand Up @@ -459,6 +459,8 @@ class ThreadHistory {
this.approvalPolicy,
this.sandboxMode,
this.configConfirmed = false,
this.hasOlder = false,
this.turns = const [],
});

/// Conversation items, oldest first.
Expand Down Expand Up @@ -507,6 +509,50 @@ class ThreadHistory {
/// Whether a live `thread/settings/updated` notification has confirmed this
/// config (vs only a start/resume snapshot).
final bool configConfirmed;

/// Whether earlier items remain unread — [BridgeApi.appThreadOlderPage]
/// fetches them. False for a thread whose history arrives whole.
final bool hasOlder;

/// One entry per turn in the WHOLE thread, oldest first, including turns
/// whose items aren't loaded yet. The turn rail shows a conversation's shape,
/// so it needs every turn even before their bodies are read.
final List<TurnSummary> turns;
}

/// A turn reduced to what the rail shows: the question, and how it was answered.
class TurnSummary {
/// Creates a turn summary.
const TurnSummary({
required this.turnId,
this.userText = '',
this.assistantText = '',
this.loaded = false,
});

/// Id of the turn, for fetching its items on demand.
final String turnId;

/// The user's message that opened the turn; empty when it had none.
final String userText;

/// The turn's final agent message; empty when it produced no prose.
final String assistantText;

/// Whether this turn's items are already in the transcript.
final bool loaded;
}

/// One page of older items, and whether history continues before them.
class OlderPage {
/// Creates an older page.
const OlderPage({required this.items, required this.hasOlder});

/// Older items, oldest first, to prepend to the transcript.
final List<ThreadItem> items;

/// Whether older items still remain.
final bool hasOlder;
}

/// The server-reported runtime configuration of a thread — what its turns
Expand Down Expand Up @@ -1222,8 +1268,23 @@ abstract interface class BridgeApi {

/// Read a thread's history (items oldest first) and whether a turn is still
/// running, so re-opening an in-flight thread restores its live state.
///
/// A paginated thread returns only its newest turns' items, with
/// [ThreadHistory.hasOlder] set and [ThreadHistory.turns] naming every turn.
Future<ThreadHistory> appThreadRead(String serviceKey, String threadId);

/// One page further back through a paginated thread's history. Returns an
/// empty page when the thread reads whole or is already at its start.
Future<OlderPage> appThreadOlderPage(String serviceKey, String threadId);

/// Every item of one turn, oldest first — for jumping to a turn the
/// transcript hasn't scrolled back to yet.
Future<List<ThreadItem>> appThreadTurnItems(
String serviceKey,
String threadId,
String turnId,
);

/// The latest server-reported runtime config for a thread (from its
/// start/resume response, kept fresh by `thread/settings/updated`
/// notifications), or null when the server hasn't reported any. Reads the
Expand Down
66 changes: 52 additions & 14 deletions apps/flutter/lib/src/bridge_api_rust.dart
Original file line number Diff line number Diff line change
Expand Up @@ -432,20 +432,7 @@ class RustBridgeApi implements BridgeApi {
threadId: threadId,
);
return ThreadHistory(
items: h.items
.map(
(i) => ThreadItem(
id: i.id,
itemType: i.itemType,
title: i.title,
text: i.text,
images: i.images,
turnId: i.turnId,
turnCompletedAt: i.turnCompletedAt?.toInt(),
turnDurationMs: i.turnDurationMs?.toInt(),
),
)
.toList(),
items: h.items.map(_item).toList(),
running: h.running,
branch: h.branch,
cwd: h.cwd,
Expand All @@ -458,7 +445,58 @@ class RustBridgeApi implements BridgeApi {
approvalPolicy: h.approvalPolicy,
sandboxMode: h.sandboxMode,
configConfirmed: h.configConfirmed,
hasOlder: h.hasOlder,
turns: h.turns
.map(
(t) => TurnSummary(
turnId: t.turnId,
userText: t.userText,
assistantText: t.assistantText,
loaded: t.loaded,
),
)
.toList(),
);
}

static ThreadItem _item(frb.ThreadItemDto i) => ThreadItem(
id: i.id,
itemType: i.itemType,
title: i.title,
text: i.text,
images: i.images,
turnId: i.turnId,
turnCompletedAt: i.turnCompletedAt?.toInt(),
turnDurationMs: i.turnDurationMs?.toInt(),
);

@override
Future<OlderPage> appThreadOlderPage(
String serviceKey,
String threadId,
) async {
final page = await frb.appThreadOlderPage(
serviceKey: serviceKey,
threadId: threadId,
);
return OlderPage(
items: page.items.map(_item).toList(),
hasOlder: page.hasOlder,
);
}

@override
Future<List<ThreadItem>> appThreadTurnItems(
String serviceKey,
String threadId,
String turnId,
) async {
final items = await frb.appThreadTurnItems(
serviceKey: serviceKey,
threadId: threadId,
turnId: turnId,
);
return items.map(_item).toList();
}

@override
Expand Down
Loading
Loading