From 782c8191a160bb71e067d1c36394795c66b8daba Mon Sep 17 00:00:00 2001 From: leo Date: Wed, 11 Nov 2026 13:22:44 +0800 Subject: [PATCH] link: tell the phone how full an agent's context is A session update carries its context's window, what is used, the parts it is spent on (by the context panel's keys) and where it is compacted, for the phone's ring under its composer. Co-Authored-By: BaoCode --- lib/chat/panels/context_usage_panel.dart | 3 +-- lib/link/workspace_bridge.dart | 25 +++++++++++++++++++++++ packages/bao_link/lib/src/protocol.dart | 13 ++++++++++++ packages/bao_link/test/link_test.dart | 26 ++++++++++++++++++++++++ 4 files changed, 65 insertions(+), 2 deletions(-) diff --git a/lib/chat/panels/context_usage_panel.dart b/lib/chat/panels/context_usage_panel.dart index 1491066ff..330f25db8 100644 --- a/lib/chat/panels/context_usage_panel.dart +++ b/lib/chat/panels/context_usage_panel.dart @@ -46,8 +46,7 @@ class ContextUsagePanel extends StatefulWidget { /// Claude Code's categories (by their English names, as it reports /// them) as the usage views' parts: an agent's definition is in the /// Agent tool's, the servers' instructions with their tools, the slash - /// commands with the skills. - @visibleForTesting + /// commands with the skills. The phone is told them so too. static String? partOf(String category) => switch (category.toLowerCase().trim()) { 'system prompt' => 'system', diff --git a/lib/link/workspace_bridge.dart b/lib/link/workspace_bridge.dart index aa1a3437e..fe20958b4 100644 --- a/lib/link/workspace_bridge.dart +++ b/lib/link/workspace_bridge.dart @@ -12,6 +12,7 @@ import '../bots/bots.dart'; import '../chat/chat_models.dart'; import '../chat/chat_session.dart'; import '../chat/composer/composer_files.dart' show fileReferenceText; +import '../chat/panels/context_usage_panel.dart' show ContextUsagePanel; import '../customize/customization_store.dart'; import '../customize/customizations.dart'; import '../ide/file_service.dart'; @@ -1268,9 +1269,11 @@ class _Follow { ? null : _from > 0 || session.hasEarlierHistory || session.loadingEarlier; final goal = _goal(session.goal); + final context = _context(session.context); final restText = jsonEncode([ earlier, goal, + context, session.isStreaming, interaction, [for (final choice in choices) choice.toJson()], @@ -1309,9 +1312,31 @@ class _Follow { tasks: tasks, earlier: earlier, goal: goal, + context: context, ); } + /// [usage] as the phone reads it (see [LinkSessionUpdate.context]). + static Map? _context(ContextUsage? usage) { + if (usage == null || usage.window <= 0) return null; + final parts = {}; + for (final segment in usage.segments) { + if (segment.kind != ContextKind.used || segment.tokens <= 0) continue; + final part = ContextUsagePanel.partOf(segment.label) ?? segment.label; + parts[part] = (parts[part] ?? 0) + segment.tokens; + } + return { + 'window': usage.window, + 'used': usage.used, + if (parts.isNotEmpty) + 'parts': [ + for (final MapEntry(:key, :value) in parts.entries) + {'part': key, 'tokens': value}, + ], + 'compactAt': ?usage.detail?.autoCompactAt, + }; + } + /// [goal] as the phone reads it (see [LinkSessionUpdate.goal]). static Map? _goal(KernelGoal? goal) => switch (goal) { null => null, diff --git a/packages/bao_link/lib/src/protocol.dart b/packages/bao_link/lib/src/protocol.dart index f886a52fd..c5c5381fe 100644 --- a/packages/bao_link/lib/src/protocol.dart +++ b/packages/bao_link/lib/src/protocol.dart @@ -736,6 +736,7 @@ class LinkSessionUpdate { this.tasks = const [], this.earlier, this.goal, + this.context, }); factory LinkSessionUpdate.fromJson(Map json) => @@ -766,6 +767,10 @@ class LinkSessionUpdate { final Map map => map, _ => null, }, + context: switch (json['context']) { + final Map map => map, + _ => null, + }, ); final String session; @@ -801,6 +806,13 @@ class LinkSessionUpdate { /// "duration", "tokens"}`, its times in milliseconds. final Map? goal; + /// How full the agent's context is, if its kernel says: `{"window", + /// "used", "parts": [{"part", "tokens"}], "compactAt"}`, a part by the + /// desktop's key for it (`system`, `tools`, `mcp`, `memory`, `skills`, + /// `messages`) or the kernel's own name, `compactAt` where it is + /// compacted, in tokens. + final Map? context; + Map toJson() => { 'session': session, 'start': start, @@ -813,6 +825,7 @@ class LinkSessionUpdate { if (tasks.isNotEmpty) 'tasks': [for (final task in tasks) task.toJson()], 'earlier': ?earlier, 'goal': ?goal, + 'context': ?context, }; } diff --git a/packages/bao_link/test/link_test.dart b/packages/bao_link/test/link_test.dart index b848c115d..35857e16c 100644 --- a/packages/bao_link/test/link_test.dart +++ b/packages/bao_link/test/link_test.dart @@ -55,6 +55,32 @@ void main() { } }); + test('a session update carries how full the context is, when told', () { + const plain = LinkSessionUpdate( + session: 's', + start: 0, + items: [], + count: 0, + ); + expect(plain.toJson().containsKey('context'), isFalse); + const context = { + 'window': 200000, + 'used': 52000, + 'parts': [ + {'part': 'messages', 'tokens': 40000}, + ], + 'compactAt': 167000, + }; + const update = LinkSessionUpdate( + session: 's', + start: 0, + items: [], + count: 0, + context: context, + ); + expect(LinkSessionUpdate.fromJson(update.toJson()).context, context); + }); + test('a bot\'s picture is told from an emoji by its file\'s name', () { expect(isAvatarImage('avatar-0a1b2c3d4e5f.jpg'), isTrue); expect(isAvatarImage('avatar-0a1b2c3d4e5f.PNG'), isFalse);