From bfe626e636ad59b3cc39131e057b8dfca89ac681 Mon Sep 17 00:00:00 2001 From: Tant Date: Fri, 9 Oct 2026 13:03:21 +0800 Subject: [PATCH 1/5] feat(core): stamp the subagent session link on async agent spawns An asynchronous AgentSpawn returns a started result while the child Session runs on, so the parent Turn's completion writer never saw the link that synchronous delegation stamps in memory. Remote record-stream consumers read persisted Turns only, which left the spawning tool item without any child Session fact. Link the child Session on the parent Turn's persisted tool item before the spawn start returns, materializing the running generation exactly like a relay read so the stamped item keeps the round and item identity the later completion merge preserves. The existing tool-completion record resync then publishes the link mid-Turn, with no protocol or client change. --- .../src/agentic/coordination/coordinator.rs | 200 ++++++++++++++++++ .../src/agentic/session/session_manager.rs | 89 ++++++++ 2 files changed, 289 insertions(+) diff --git a/src/crates/assembly/core/src/agentic/coordination/coordinator.rs b/src/crates/assembly/core/src/agentic/coordination/coordinator.rs index 116080384a..34644aa5d9 100644 --- a/src/crates/assembly/core/src/agentic/coordination/coordinator.rs +++ b/src/crates/assembly/core/src/agentic/coordination/coordinator.rs @@ -13035,6 +13035,12 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet subagent_session_id.clone(), BackgroundSubagentCancelTarget::Scheduler(cancel_handle.clone()), ); + self.stamp_subagent_link_on_parent_tool_item( + &subagent_parent_info, + &subagent_session_id, + &subagent_dialog_turn_id, + ) + .await; let background_subagent_tasks = self.background_subagent_tasks.clone(); let background_subagent_outcomes = self.background_subagent_outcomes.clone(); @@ -13143,6 +13149,12 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet subagent_session_id.clone(), BackgroundSubagentCancelTarget::Direct(background_cancel_token), ); + self.stamp_subagent_link_on_parent_tool_item( + &subagent_parent_info, + &subagent_session_id, + &subagent_dialog_turn_id, + ) + .await; let background_subagent_tasks = self.background_subagent_tasks.clone(); let background_subagent_outcomes = self.background_subagent_outcomes.clone(); @@ -13176,6 +13188,51 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet }) } + /// Persist the child-Session link of a spawned subagent on the parent Turn's + /// tool item. + /// + /// A synchronous delegated command is stamped in memory before its synthetic + /// Turn is written. An asynchronous spawn instead returns a `started` result + /// while the child Session runs on, so the parent Turn needs the link on its + /// persisted tool item. It is written before this start returns, which is + /// before the spawning tool completion republishes the parent Turn, so + /// record-stream consumers see the link in the same resync they already get. + /// + /// The link is an annotation: a host that cannot resolve the parent Turn + /// keeps the spawn result rather than failing the tool call. + async fn stamp_subagent_link_on_parent_tool_item( + &self, + parent_info: &SubagentParentInfo, + subagent_session_id: &str, + subagent_dialog_turn_id: &str, + ) { + let subagent_model_id = self + .session_manager + .get_session(subagent_session_id) + .and_then(|session| session.config.model_id); + if let Err(error) = self + .session_manager + .link_subagent_session_on_parent_tool_item( + &parent_info.session_id, + &parent_info.dialog_turn_id, + &parent_info.tool_call_id, + subagent_session_id, + subagent_dialog_turn_id, + subagent_model_id, + ) + .await + { + warn!( + "Failed to link spawned subagent Session on the parent tool item: parent_session_id={}, parent_dialog_turn_id={}, parent_tool_call_id={}, subagent_session_id={}, error={}", + parent_info.session_id, + parent_info.dialog_turn_id, + parent_info.tool_call_id, + subagent_session_id, + error + ); + } + } + /// Clean up runtime-only subagent resources. /// /// Durable and reusable Subagent sessions remain available for follow-up. @@ -17789,6 +17846,149 @@ mod tests { .expect("clean up persisted test session"); } + #[tokio::test] + async fn async_agent_spawn_links_the_child_session_on_the_parent_tool_item() { + let workspace = tempfile::tempdir().expect("workspace"); + crate::service::workspace::legacy_compat::register_local_fixture_blocking(workspace.path()); + let (coordinator, session_manager) = test_persistent_coordinator(); + let parent = session_manager + .create_session( + "Async delegation parent".to_string(), + "Ultimate".to_string(), + SessionConfig { + workspace_path: Some(workspace.path().to_string_lossy().into_owned()), + ..Default::default() + }, + ) + .await + .expect("create parent session"); + let child = session_manager + .create_session( + "Async delegation child".to_string(), + "GeneralPurpose".to_string(), + SessionConfig { + workspace_path: Some(workspace.path().to_string_lossy().into_owned()), + model_id: Some("child-subagent-model".to_string()), + ..Default::default() + }, + ) + .await + .expect("create spawned child session"); + let parent_turn_id = session_manager + .start_dialog_turn( + &parent.session_id, + "Ultimate".to_string(), + "delegate the audit".to_string(), + Some("turn-async-agent-spawn".to_string()), + None, + None, + ) + .await + .expect("start parent turn"); + let tool_call_id = "tool-call-agent-spawn".to_string(); + let spawning_round = Message::assistant_with_tools( + String::new(), + vec![ToolCall { + tool_id: tool_call_id.clone(), + tool_name: "AgentSpawn".to_string(), + arguments: serde_json::json!({ + "prompt": "audit the widget", + "agent_type": "GeneralPurpose", + "run_in_background": true, + }), + raw_arguments: None, + is_error: false, + parse_error: None, + recovered_from_truncation: false, + repair_kind: Default::default(), + }], + ) + .with_turn_id(parent_turn_id.clone()) + .with_round_id("round-async-agent-spawn".to_string()); + let started_result = Message::tool_result(crate::agentic::core::ToolResult { + tool_id: tool_call_id.clone(), + tool_name: "AgentSpawn".to_string(), + effective_tool_name: None, + result: serde_json::json!({ + "context_mode": "isolated", + "status": "started", + "run_in_background": true, + "bg_task_id": "bg-task-1", + "agent_id": "agent-1", + }), + result_for_assistant: Some("Background agent started".to_string()), + is_error: false, + duration_ms: Some(4), + image_attachments: None, + }) + .with_turn_id(parent_turn_id.clone()) + .with_round_id("round-async-agent-spawn".to_string()); + session_manager + .add_messages(&parent.session_id, vec![spawning_round, started_result]) + .await + .expect("record the spawning round and its started result"); + + // An asynchronous spawn returns while the parent Turn is still running, + // so the link has to reach the parent Turn's persisted tool item. + coordinator + .stamp_subagent_link_on_parent_tool_item( + &SubagentParentInfo { + tool_call_id: tool_call_id.clone(), + session_id: parent.session_id.clone(), + dialog_turn_id: parent_turn_id.clone(), + }, + &child.session_id, + "turn-child-subagent", + ) + .await; + + let storage = session_manager + .effective_session_storage_path(&parent.session_id) + .await + .expect("parent session storage path"); + let turns = coordinator + .load_relay_session_turns(&storage, &parent.session_id, Some(&parent_turn_id)) + .await + .expect("relay read of the running parent turn"); + let item = turns + .iter() + .flat_map(|turn| turn.model_rounds.iter()) + .flat_map(|round| round.tool_items.iter()) + .find(|item| item.id == tool_call_id) + .expect("the spawning tool item survives the relay read"); + assert_eq!( + item.tool_name, "AgentSpawn", + "the link belongs to the spawning tool item" + ); + assert_eq!( + item.subagent_session_id.as_deref(), + Some(child.session_id.as_str()) + ); + assert_eq!( + item.subagent_dialog_turn_id.as_deref(), + Some("turn-child-subagent") + ); + assert_eq!( + item.subagent_model_id.as_deref(), + Some("child-subagent-model") + ); + // The record stream serializes the persisted item as it is, so a remote + // consumer reads the link under its camelCase wire name. + assert_eq!( + serde_json::to_value(item).expect("item serializes")["subagentSessionId"], + serde_json::json!(child.session_id) + ); + + session_manager + .delete_session_by_id(&parent.session_id) + .await + .expect("clean up persisted parent session"); + session_manager + .delete_session_by_id(&child.session_id) + .await + .expect("clean up persisted child session"); + } + #[tokio::test] async fn transient_turns_keep_authoritative_terminal_results() { let workspace = tempfile::tempdir().expect("workspace"); diff --git a/src/crates/assembly/core/src/agentic/session/session_manager.rs b/src/crates/assembly/core/src/agentic/session/session_manager.rs index d63d616934..f2fc16e06e 100644 --- a/src/crates/assembly/core/src/agentic/session/session_manager.rs +++ b/src/crates/assembly/core/src/agentic/session/session_manager.rs @@ -8247,6 +8247,95 @@ impl SessionManager { generated_count } + /// Record a spawned subagent's child Session on the parent Turn's tool item. + /// + /// A synchronous delegated command is stamped in memory before its synthetic + /// Turn is written. An asynchronous spawn returns to the parent Turn long + /// before the child Turn finishes, so the link has to become durable on the + /// already persisted parent Turn for hosts that only read persisted Turns. + /// Rounds are materialized from the live generation exactly like a relay + /// read, so the stamped item keeps the round and item identity that the + /// later completion merge preserves. + /// + /// This is a best-effort annotation of an in-progress Turn: an unknown Turn, + /// an already terminal Turn, or a missing tool item leaves the Turn as is. + pub(crate) async fn link_subagent_session_on_parent_tool_item( + &self, + parent_session_id: &str, + parent_dialog_turn_id: &str, + parent_tool_call_id: &str, + subagent_session_id: &str, + subagent_dialog_turn_id: &str, + subagent_model_id: Option, + ) -> OpenBitFunResult<()> { + if !self.should_persist_session_id(parent_session_id) + && !self.transient_turns.contains_key(parent_session_id) + { + return Ok(()); + } + let Some(workspace_path) = self.effective_session_storage_path(parent_session_id).await + else { + return Ok(()); + }; + let _mutation_guard = self.acquire_session_mutation(parent_session_id).await?; + let Some(turn_index) = self.sessions.get(parent_session_id).and_then(|session| { + session + .dialog_turn_ids + .iter() + .position(|turn_id| turn_id == parent_dialog_turn_id) + }) else { + return Ok(()); + }; + let Some(mut turn) = self + .load_runtime_dialog_turn(&workspace_path, parent_session_id, turn_index) + .await? + else { + return Ok(()); + }; + // A terminal Turn is owned by its completion writer, which merges the + // same link from the in-memory rounds instead. + if turn.status != TurnStatus::InProgress { + return Ok(()); + } + // Rounds materialize on the Turn's own start time, so a mid-Turn + // checkpoint does not reorder the running generation. + let materialized_at = turn.start_time; + let messages: Vec = self + .context_store + .get_context_messages(parent_session_id) + .into_iter() + .filter(|message| message.metadata.turn_id.as_deref() == Some(parent_dialog_turn_id)) + .collect(); + Self::append_generation_rounds( + &mut turn, + parent_dialog_turn_id, + &messages, + materialized_at, + ); + let Some(tool_item) = turn + .model_rounds + .iter_mut() + .flat_map(|round| round.tool_items.iter_mut()) + .find(|item| { + item.id == parent_tool_call_id || item.tool_call.id == parent_tool_call_id + }) + else { + return Ok(()); + }; + tool_item.subagent_session_id = Some(subagent_session_id.to_string()); + tool_item.subagent_dialog_turn_id = Some(subagent_dialog_turn_id.to_string()); + if subagent_model_id.is_some() { + tool_item.subagent_model_id = subagent_model_id; + } + self.save_runtime_dialog_turn(&workspace_path, &turn) + .await?; + debug!( + "Linked spawned subagent Session on the parent tool item: parent_session_id={}, parent_turn_id={}, parent_tool_call_id={}, subagent_session_id={}", + parent_session_id, parent_dialog_turn_id, parent_tool_call_id, subagent_session_id + ); + Ok(()) + } + /// Complete a reopened interrupted turn by appending only the messages /// produced by the current execution generation. pub async fn complete_recovered_dialog_turn( From 51345327614d3670c15dca087185b04c800287ec Mon Sep 17 00:00:00 2001 From: Tant Date: Fri, 9 Oct 2026 13:03:26 +0800 Subject: [PATCH 2/5] test(services-integrations): assert the subagent session link rides relay item records The record stream serializes the persisted tool item as it is, so a stamped parent AgentSpawn item must publish subagentSessionId, subagentDialogTurnId and subagentModelId under their camelCase wire names. --- .../tests/remote_connect_contracts.rs | 70 +++++++++++++++++++ 1 file changed, 70 insertions(+) diff --git a/src/crates/services/services-integrations/tests/remote_connect_contracts.rs b/src/crates/services/services-integrations/tests/remote_connect_contracts.rs index ffb4e9ce5c..c46c57bd1c 100644 --- a/src/crates/services/services-integrations/tests/remote_connect_contracts.rs +++ b/src/crates/services/services-integrations/tests/remote_connect_contracts.rs @@ -496,6 +496,76 @@ fn relay_records_carry_attachment_pixels_on_the_turn_record_alone() { } } +#[test] +fn relay_item_records_carry_the_subagent_session_link() { + let turn: openbitfun_services_core::session::DialogTurnData = serde_json::from_value( + serde_json::json!({ + "turnId": "turn-1", + "turnIndex": 0, + "sessionId": "session-1", + "timestamp": 1_700_000_000_u64, + "startTime": 1_700_000_000_u64, + "status": "inprogress", + "userMessage": { + "id": "message-user-1", + "content": "delegate the audit", + "timestamp": 1_700_000_000_u64 + }, + "modelRounds": [{ + "id": "round-1", + "turnId": "turn-1", + "roundIndex": 0, + "timestamp": 1_700_000_000_u64, + "startTime": 1_700_000_000_u64, + "status": "completed", + "textItems": [], + "thinkingItems": [], + "toolItems": [{ + "id": "tool-call-agent-spawn", + "toolName": "AgentSpawn", + "toolCall": {"id": "tool-call-agent-spawn", "input": {"run_in_background": true}}, + "toolResult": {"result": {"status": "started"}, "success": true}, + "startTime": 1_700_000_000_u64, + "endTime": 1_700_000_001_u64, + "orderIndex": 0, + "status": "completed", + "subagentSessionId": "child-session", + "subagentDialogTurnId": "child-turn", + "subagentModelId": "child-model" + }] + }] + }), + ) + .expect("turn fixture deserializes"); + + let records = + openbitfun_services_integrations::remote_connect::session_records::records_from_turns( + std::slice::from_ref(&turn), + &|_| None, + ) + .expect("records build"); + let item_record = records + .iter() + .find(|record| record["id"] == "item/tool-call-agent-spawn") + .expect("the spawning tool item is published as its own record"); + + // The record keeps the persisted item shape, so the child Session link rides + // the wire without a protocol change and without a client-side join. + assert_eq!(item_record["item"]["type"], "tool"); + assert_eq!( + item_record["item"]["data"]["subagentSessionId"], + "child-session" + ); + assert_eq!( + item_record["item"]["data"]["subagentDialogTurnId"], + "child-turn" + ); + assert_eq!( + item_record["item"]["data"]["subagentModelId"], + "child-model" + ); +} + #[test] fn remote_image_context_to_agent_attachment_preserves_metadata_contract() { let attachment = agent_input_attachment_from_remote_image_context(RemoteImageContext { From a952bdcc062f032c697ea7872b202e4a9d82d972 Mon Sep 17 00:00:00 2001 From: Tant Date: Fri, 9 Oct 2026 13:10:43 +0800 Subject: [PATCH 3/5] feat(mobile): stream subagent sessions into task cards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An Ultimate session delegates work to child agents, and the host keeps each worker in its own hidden session: the parent stream only carries the launch item, a child's steps exist on the child's own stream and carry no marker of their own. The client already knew how to draw a delegation card's branch; the records it drew came from a contract the host never published. The launching item now names its child session (`subagentSessionId`, the field the host stamps on the item data, camelCase as the record stream serializes it). A small coordinator follows the parent's records, opens that child session through the same `subscribeSession` call the session on screen uses, reduces its records with the same durable reducer, and attaches them as the branch of the item that launched it. Grouping comes from the subscription a record arrived on and never from a marker on the record, so a worker's steps cannot be mistaken for the parent's own rows. The coordinator publishes only through the controller's existing path, so the child's records reach the screen through the reducer, timeline store, projection, grouping policy and card that a parent record already uses. A launch whose item carries no child session id opens nothing: an older host keeps the plain tool rows it has always sent. A card is keyed by what changed in its message, and a launch item settles as soon as its worker starts — a background worker's launch settles while its stream says nothing new. The version this coordinator publishes therefore counts the launch's own record as well as its branch, or exactly that card would keep drawing a settled worker as running. --- .../main/ets/entryability/EntryAbility.ets | 6 +- .../policy/ChatMessageStructurePolicy.ets | 78 ++- .../ets/pages/preview/MobileDesignGallery.ets | 3 +- .../pages/preview/SubagentProcessPreview.ets | 242 ++++--- .../pages/preview/SubagentSwarmFixture.ets | 639 ++++++++++++++++++ .../ets/services/ChatSessionController.ets | 28 +- .../ets/services/RemoteSessionManager.ets | 3 +- .../services/SubagentStreamCoordinator.ets | 345 ++++++++++ 8 files changed, 1209 insertions(+), 135 deletions(-) create mode 100644 src/apps/mobile/harmonyos/entry/src/main/ets/pages/preview/SubagentSwarmFixture.ets create mode 100644 src/apps/mobile/harmonyos/entry/src/main/ets/services/SubagentStreamCoordinator.ets diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/entryability/EntryAbility.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/entryability/EntryAbility.ets index a5e11d4441..baef2ffcf1 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/entryability/EntryAbility.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/entryability/EntryAbility.ets @@ -13,7 +13,7 @@ export default class EntryAbility extends UIAbility { onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): void { const scenarioId = want.parameters?.['openbitfunDesignPreview']; - if (scenarioId === 'history-scroll' || scenarioId === 'durable-timeline' || scenarioId === 'composer-submit' || scenarioId === 'catalog-refresh' || scenarioId === 'catalog-refresh-dark' || scenarioId === 'model-role-selector' || scenarioId === 'model-role-selector-dark' || scenarioId === 'model-role-selector-wide' || scenarioId === 'subagent-process' || scenarioId === 'subagent-process-dark' || scenarioId === 'file-visibility' || scenarioId === 'file-visibility-dark' || scenarioId === 'immersive-bottom' || scenarioId === 'immersive-bottom-dark' || scenarioId === 'immersive-bottom-wide' || scenarioId === 'immersive-bottom-drawer' || scenarioId === 'immersive-bottom-drawer-dark' || scenarioId === 'immersive-bottom-settings' || scenarioId === 'immersive-bottom-settings-dark' || scenarioId === 'immersive-bottom-cover' || scenarioId === 'immersive-bottom-miniapps' || scenarioId === 'immersive-bottom-wide-shell' || scenarioId === 'immersive-bottom-tools' || scenarioId === 'immersive-bottom-tools-editor' || scenarioId === 'immersive-bottom-tools-file-action' || scenarioId === 'immersive-bottom-view-settings' || scenarioId === 'immersive-bottom-connect' || scenarioId === 'composer-overflow' || scenarioId === 'composer-overflow-empty' || scenarioId === 'composer-overflow-dark' || scenarioId === 'composer-overflow-wide' || scenarioId === 'composer-overflow-expanded' || scenarioId === 'composer-overflow-expanded-dark' || scenarioId === 'device-selector' || scenarioId === 'device-selector-dark' || scenarioId === 'welcome-home' || scenarioId === 'connected-conversation' || + if (scenarioId === 'history-scroll' || scenarioId === 'durable-timeline' || scenarioId === 'composer-submit' || scenarioId === 'catalog-refresh' || scenarioId === 'catalog-refresh-dark' || scenarioId === 'model-role-selector' || scenarioId === 'model-role-selector-dark' || scenarioId === 'model-role-selector-wide' || scenarioId === 'subagent-process' || scenarioId === 'subagent-process-dark' || scenarioId === 'subagent-swarm' || scenarioId === 'subagent-swarm-dark' || scenarioId === 'file-visibility' || scenarioId === 'file-visibility-dark' || scenarioId === 'immersive-bottom' || scenarioId === 'immersive-bottom-dark' || scenarioId === 'immersive-bottom-wide' || scenarioId === 'immersive-bottom-drawer' || scenarioId === 'immersive-bottom-drawer-dark' || scenarioId === 'immersive-bottom-settings' || scenarioId === 'immersive-bottom-settings-dark' || scenarioId === 'immersive-bottom-cover' || scenarioId === 'immersive-bottom-miniapps' || scenarioId === 'immersive-bottom-wide-shell' || scenarioId === 'immersive-bottom-tools' || scenarioId === 'immersive-bottom-tools-editor' || scenarioId === 'immersive-bottom-tools-file-action' || scenarioId === 'immersive-bottom-view-settings' || scenarioId === 'immersive-bottom-connect' || scenarioId === 'composer-overflow' || scenarioId === 'composer-overflow-empty' || scenarioId === 'composer-overflow-dark' || scenarioId === 'composer-overflow-wide' || scenarioId === 'composer-overflow-expanded' || scenarioId === 'composer-overflow-expanded-dark' || scenarioId === 'device-selector' || scenarioId === 'device-selector-dark' || scenarioId === 'welcome-home' || scenarioId === 'connected-conversation' || scenarioId === 'streaming-dark' || scenarioId === 'reconnecting-wide' || scenarioId === 'narrow-multiline' || scenarioId === 'fold-context' || scenarioId === 'long-reading' || @@ -27,9 +27,9 @@ export default class EntryAbility extends UIAbility { AppStorage.setOrCreate('scenarioId', scenarioId); } try { - const colorMode = scenarioId === 'cold-start-home-dark' || scenarioId === 'catalog-refresh-dark' || scenarioId === 'model-role-selector-dark' || scenarioId === 'subagent-process-dark' || scenarioId === 'file-visibility-dark' || scenarioId === 'immersive-bottom-dark' || scenarioId === 'immersive-bottom-drawer-dark' || scenarioId === 'immersive-bottom-settings-dark' || scenarioId === 'composer-overflow-dark' || scenarioId === 'composer-overflow-expanded-dark' || scenarioId === 'device-selector-dark' || scenarioId === 'streaming-dark' || scenarioId === 'interaction-mailbox-dark' || scenarioId === 'copy-selection-dark' + const colorMode = scenarioId === 'cold-start-home-dark' || scenarioId === 'catalog-refresh-dark' || scenarioId === 'model-role-selector-dark' || scenarioId === 'subagent-process-dark' || scenarioId === 'subagent-swarm-dark' || scenarioId === 'file-visibility-dark' || scenarioId === 'immersive-bottom-dark' || scenarioId === 'immersive-bottom-drawer-dark' || scenarioId === 'immersive-bottom-settings-dark' || scenarioId === 'composer-overflow-dark' || scenarioId === 'composer-overflow-expanded-dark' || scenarioId === 'device-selector-dark' || scenarioId === 'streaming-dark' || scenarioId === 'interaction-mailbox-dark' || scenarioId === 'copy-selection-dark' ? ConfigurationConstant.ColorMode.COLOR_MODE_DARK - : scenarioId === 'cold-start-home' || scenarioId === 'catalog-refresh' || scenarioId === 'model-role-selector' || scenarioId === 'model-role-selector-wide' || scenarioId === 'subagent-process' || scenarioId === 'file-visibility' || scenarioId === 'immersive-bottom' || scenarioId === 'immersive-bottom-wide' || scenarioId === 'immersive-bottom-drawer' || scenarioId === 'immersive-bottom-settings' || scenarioId === 'immersive-bottom-cover' || scenarioId === 'immersive-bottom-miniapps' || scenarioId === 'immersive-bottom-wide-shell' || scenarioId === 'immersive-bottom-tools' || scenarioId === 'immersive-bottom-tools-editor' || scenarioId === 'immersive-bottom-tools-file-action' || scenarioId === 'immersive-bottom-view-settings' || scenarioId === 'immersive-bottom-connect' || scenarioId === 'composer-overflow' || scenarioId === 'composer-overflow-empty' || scenarioId === 'composer-overflow-wide' || scenarioId === 'composer-overflow-expanded' || scenarioId === 'device-selector' || scenarioId === 'connected-conversation' || scenarioId === 'reconnecting-wide' || scenarioId === 'copy-selection' || scenarioId === 'copy-selection-nav' || scenarioId === 'copy-selection-wide' + : scenarioId === 'cold-start-home' || scenarioId === 'catalog-refresh' || scenarioId === 'model-role-selector' || scenarioId === 'model-role-selector-wide' || scenarioId === 'subagent-process' || scenarioId === 'subagent-swarm' || scenarioId === 'file-visibility' || scenarioId === 'immersive-bottom' || scenarioId === 'immersive-bottom-wide' || scenarioId === 'immersive-bottom-drawer' || scenarioId === 'immersive-bottom-settings' || scenarioId === 'immersive-bottom-cover' || scenarioId === 'immersive-bottom-miniapps' || scenarioId === 'immersive-bottom-wide-shell' || scenarioId === 'immersive-bottom-tools' || scenarioId === 'immersive-bottom-tools-editor' || scenarioId === 'immersive-bottom-tools-file-action' || scenarioId === 'immersive-bottom-view-settings' || scenarioId === 'immersive-bottom-connect' || scenarioId === 'composer-overflow' || scenarioId === 'composer-overflow-empty' || scenarioId === 'composer-overflow-wide' || scenarioId === 'composer-overflow-expanded' || scenarioId === 'device-selector' || scenarioId === 'connected-conversation' || scenarioId === 'reconnecting-wide' || scenarioId === 'copy-selection' || scenarioId === 'copy-selection-nav' || scenarioId === 'copy-selection-wide' ? ConfigurationConstant.ColorMode.COLOR_MODE_LIGHT : ConfigurationConstant.ColorMode.COLOR_MODE_NOT_SET; this.context.getApplicationContext().setColorMode(colorMode); diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/policy/ChatMessageStructurePolicy.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/policy/ChatMessageStructurePolicy.ets index cdc4e02c74..4bae245e58 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/policy/ChatMessageStructurePolicy.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/policy/ChatMessageStructurePolicy.ets @@ -188,19 +188,22 @@ export class ChatMessageStructurePolicy { * Rebuilds the branch hierarchy carried by the remote wire format. * * Live snapshots are intentionally flat and mark child-agent records with - * `is_subagent`. The parent UI must attach those records to the Task that - * owns them before grouping ordinary transcript activity. Older desktop - * builds did not emit the marker, so while a Task is running we use the same - * conservative fallback as mobile web and scope its following process items. + * `is_subagent`. The parent UI must attach those records to the tool entry + * that launched the child before grouping ordinary transcript activity. + * Older desktop builds did not emit the marker, so while a legacy `Task` is + * running we use the same conservative fallback as mobile web and scope its + * following process items. * - * A Task's children can arrive nested in its own items and again flat behind - * it, so folding the restatement would draw every child twice. A counted - * restatement is skipped, and a child that legitimately repeats survives. + * A branch owner can arrive with its children nested in its own items — + * which is how this client hands a child stream's records to the card — and + * the same child can be restated flat behind it, so folding the restatement + * would draw every child twice. A counted restatement is skipped, and a child + * that legitimately repeats survives. * - * A marked record with no owning Task in this list — a partial snapshot, or a - * child that arrived before its Task — keeps its own collapsed branch rather - * than being dropped, because the process it carries is exactly what the - * reader opened the branch to see. + * A marked record with no owning owner entry in this list — a partial + * snapshot, or a child that arrived before its launch — keeps its own + * collapsed branch rather than being dropped, because the process it carries + * is exactly what the reader opened the branch to see. */ static scopeSubagentItems(items: ConversationUiMessageItem[]): ConversationUiMessageItem[] { const result: ConversationUiMessageItem[] = []; @@ -225,6 +228,7 @@ export class ChatMessageStructurePolicy { return; } if (!hasSubagentMarkers && currentTask && + ChatMessageStructurePolicy.isLegacyUnmarkedTaskOwner(currentTask) && ChatMessageStructurePolicy.isRunningTaskEntry(currentTask) && ChatMessageStructurePolicy.isLegacySubagentProcessEntry(entry)) { ChatMessageStructurePolicy.appendScopedChild(currentTask, carried, entry); @@ -464,15 +468,44 @@ export class ChatMessageStructurePolicy { } /** - * The Task tool entry that owns a subagent branch. + * Normalized names of the tool calls that launch a child agent. * - * A Task entry may carry the subagent marker as well: the host records the - * child Session id on the Task tool item, and the remote wire maps that field - * onto `is_subagent`. The marker therefore means "this Task owns a subagent", - * not "this entry is subagent output", so it must not disqualify the owner — - * otherwise no Task ever opens a branch and every flat child record is lost. + * `Task` is the delegation tool older desktops used; the current delegation + * tool is `AgentSpawn`. Both own a branch, and mobile web accepts both names + * for the same reason. A plain tool row must never be mistaken for one of + * these, so the list stays explicit instead of matching a prefix or a + * contains-rule. + */ + private static isOwnerTool(tool: ConversationUiToolStatus): boolean { + const name = ToolStatusPresentationPolicy.normalizedName(tool); + return name === 'task' || name === 'agentspawn'; + } + + /** + * The tool entry that owns a subagent branch. + * + * An owner entry may carry the subagent marker as well: the host records the + * child Session id on the launching tool item, and the remote wire maps that + * field onto `is_subagent`. The marker therefore means "this item owns a + * subagent", not "this entry is subagent output", so it must not disqualify + * the owner — otherwise no launch ever opens a branch and every record of its + * child is lost or drawn as an orphan branch beside it. */ private static isParentTaskEntry(entry: ConversationUiMessageItem): boolean { + return !!entry.tool && ChatMessageStructurePolicy.isOwnerTool(entry.tool); + } + + /** + * Whether an unmarked owner may claim the process records that follow it. + * + * This is the compatibility path for older desktops, which ran a `Task` + * subagent inside the parent's own stream and published its process records + * flat and unmarked. Only `Task` ever used it. `AgentSpawn` was introduced + * with hidden child sessions, so the records that follow a launch are the + * parent's own work; folding them into the launch item would indent the + * parent's transcript under it. + */ + private static isLegacyUnmarkedTaskOwner(entry: ConversationUiMessageItem): boolean { return !!entry.tool && ToolStatusPresentationPolicy.normalizedName(entry.tool) === 'task'; } @@ -542,12 +575,15 @@ export class ChatMessageStructurePolicy { // A marked record that already carries children is a branch of its own, not // an internal tool of some other branch. if (!!entry.subItems && entry.subItems.length > 0) return false; - return entry.is_subagent === true && !!entry.tool && - ToolStatusPresentationPolicy.normalizedName(entry.tool) !== 'task'; + // A launch item is a branch too, even before its child has published + // anything. Dropping it while its branch is empty would make the card the + // reader just opened disappear and reappear as the child's first step lands. + if (!!entry.tool && ChatMessageStructurePolicy.isOwnerTool(entry.tool)) return false; + return entry.is_subagent === true && !!entry.tool; } static subagentTitle(entry: ConversationUiMessageItem): string { - const taskTool = !!entry.tool && ToolStatusPresentationPolicy.normalizedName(entry.tool) === 'task'; + const taskTool = !!entry.tool && ChatMessageStructurePolicy.isOwnerTool(entry.tool); if (entry.tool && taskTool) { const title = ChatMessageStructurePolicy.subagentTaskTitle(entry.tool); if (title.length > 0) return title; @@ -561,7 +597,7 @@ export class ChatMessageStructurePolicy { } static subagentBody(entry: ConversationUiMessageItem): string { - if (entry.tool && ToolStatusPresentationPolicy.normalizedName(entry.tool) === 'task') return ''; + if (entry.tool && ChatMessageStructurePolicy.isOwnerTool(entry.tool)) return ''; const content = ChatMessageStructurePolicy.plainSubagentLabel(entry.content || ''); if (content.length === 0 || content === ChatMessageStructurePolicy.subagentTitle(entry)) return ''; return content; diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/preview/MobileDesignGallery.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/preview/MobileDesignGallery.ets index 8da4cd02c6..fe0098ba0d 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/preview/MobileDesignGallery.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/preview/MobileDesignGallery.ets @@ -57,7 +57,8 @@ struct MobileDesignGallery { CatalogRefreshPreview() } else if ((AppStorage.get('scenarioId') || '').startsWith('model-role-selector')) { ModelRoleSelectorPreview() - } else if ((AppStorage.get('scenarioId') || '').startsWith('subagent-process')) { + } else if ((AppStorage.get('scenarioId') || '').startsWith('subagent-process') || + (AppStorage.get('scenarioId') || '').startsWith('subagent-swarm')) { SubagentProcessPreview() } else if ((AppStorage.get('scenarioId') || '').startsWith('file-visibility')) { FileVisibilityPreview() diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/preview/SubagentProcessPreview.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/preview/SubagentProcessPreview.ets index 129cb1e4fc..c2e32deaaa 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/preview/SubagentProcessPreview.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/preview/SubagentProcessPreview.ets @@ -1,8 +1,9 @@ -import { ChatMessage } from '../../model/RemoteModels'; +import { ChatMessage, RemoteModelCatalog } from '../../model/RemoteModels'; import { ChatTimelineRowStore, ObservableChatTimelineItem } from '../../model/ChatTimelineModels'; -import { DurableSessionReducer, SessionRecord } from '../../services/DurableSessionReducer'; +import { ChatSessionController, ChatSessionSnapshot } from '../../services/ChatSessionController'; import { ChatTimelineStore } from '../../services/ChatTimelineStore'; -import { HostSessionEvent } from '../../services/HostSessionStream'; +import { HostSessionCallbacks, HostSessionStream } from '../../services/HostSessionStream'; +import { RemoteSessionManager } from '../../services/RemoteSessionManager'; import { ChatMessageStructurePolicy, StructuredRenderGroup } from '../policy/ChatMessageStructurePolicy'; import { ConversationUiMessage, @@ -12,157 +13,182 @@ import { import { ChatTimeline } from '../components/ChatTimeline'; import { ComposerBar } from '../components/ComposerBar'; import { INK, PAGE_BG } from '../components/Theme'; +import { SWARM_PARENT_SESSION, SwarmLedger, SwarmScript } from './SubagentSwarmFixture'; -/** Typed fixtures for the tool payloads a Task branch is titled and read from. */ -interface PreviewTaskInput { - description: string; - subagent_type: string; -} +/** How long one beat of the swarm waits before it publishes; device capture is manual too. */ +const SWARM_BEAT_INTERVAL_MS: number = 900; +const SWARM_PREVIEW_CATALOG: RemoteModelCatalog = { version: 1, models: [], default_models: {} }; + +/** + * The runtime connection, over the fixture's pages. + * + * Only stream opening and the model catalog are answered: the controller reads + * every session — the one on screen and every spawned worker — through the same + * call production makes, so the fixture exercises the production stream reader, + * reducer, timeline store, projection and card instead of a preview-only path. + */ +class SwarmSessionSource extends RemoteSessionManager { + private fixture: SwarmLedger; + + constructor(fixture: SwarmLedger) { + super(); + this.fixture = fixture; + } + + subscribeSession(sessionId: string, callbacks: HostSessionCallbacks): HostSessionStream { + return this.fixture.openStream(sessionId, callbacks); + } -interface PreviewReadInput { - path: string; + async getModelCatalog(_sessionId?: string): Promise { + return SWARM_PREVIEW_CATALOG; + } } /** - * A running Task whose subagent process is published flat behind it. + * A swarm session rendered by the production transcript. * - * The fixture is the host's own durable record shape: the owner Task tool item - * carries the child Session id, and every process record the child produces - * repeats it, so the branch owner and its children both arrive marked. The - * preview mounts the production reducer, timeline store and rows, so what it - * draws is what a live session draws. + * The fixture publishes a parent session whose delegation items name the child + * sessions they started, and those children publish their own records on their + * own streams. Nothing here hands the timeline a prepared branch: the records go + * through the controller, the reducer and the store, and what the card draws is + * what a live session draws. */ @ComponentV2 export struct SubagentProcessPreview { - private reducer: DurableSessionReducer = new DurableSessionReducer(); + private fixture: SwarmLedger = new SwarmLedger(); + private script: SwarmScript = new SwarmScript(this.fixture); + private source: SwarmSessionSource = new SwarmSessionSource(this.fixture); private timeline: ChatTimelineStore = new ChatTimelineStore(); private rowStore: ChatTimelineRowStore = new ChatTimelineRowStore(); - private taskCallId: string = 'task_call_preview'; - private childSessionId: string = 'child-session-preview'; + private controller?: ChatSessionController; + private timer: number = -1; @Local rows: ObservableChatTimelineItem[] = []; @Local revision: number = 0; - @Local step: number = 0; @Local summary: string = ''; @Local connectionState: string = 'connected'; @Local active: boolean = true; + @Local paused: boolean = false; + @Local failure: string = ''; @Local stopRequests: number = 0; aboutToAppear(): void { - this.timeline.reset('preview'); - this.reducer.apply(this.taskRecord(1, 'running', 'inprogress')); - this.publish(); + this.timeline.reset(SWARM_PARENT_SESSION); + // The turn's own opening records are published before the session is read, + // like a history page; every later beat arrives as a live publication. + this.script.advance(); + this.controller = new ChatSessionController(this.source, { + onSnapshot: (snapshot: ChatSessionSnapshot): void => { this.applySnapshot(snapshot); }, + canPoll: (_sessionId: string): boolean => true, + onError: (error: Object): void => { this.report(error); } + }, { initialPollDelayMs: 0 }); + this.controller.start(SWARM_PARENT_SESSION, { + pollVersion: 0, knownMessageCount: 0, knownModelCatalogVersion: 0 + }); + this.timer = setInterval((): void => { this.tick(); }, SWARM_BEAT_INTERVAL_MS); + } + + aboutToDisappear(): void { + if (this.timer !== -1) { + clearInterval(this.timer); + this.timer = -1; + } + this.controller?.stop(); } - private taskRecord(revision: number, status: string, turnStatus: string): HostSessionEvent { - const input: PreviewTaskInput = { description: 'Audit the payment module', subagent_type: 'Explore' }; - const payload: SessionRecord = { - sessionId: 'preview', id: `item/${this.taskCallId}`, revision, - turn: { turnId: 'turn', turnIndex: 0, sessionId: 'preview', timestamp: 1, - userMessage: { id: 'user', content: 'Audit the payment module', timestamp: 1 }, status: turnStatus }, - round: { id: 'round', turnId: 'turn', roundIndex: 0, timestamp: 2, status: turnStatus }, - item: { type: 'tool', data: { id: this.taskCallId, orderIndex: 0, timestamp: 3, - toolName: 'Task', startTime: 3, status, - subagentSessionId: this.childSessionId, - toolCall: { id: this.taskCallId, input } } } - }; - return { session_id: 'preview', event: 'session-record', payload }; + private tick(): void { + if (this.paused) return; + if (!this.script.advance()) { + this.pause(); + return; + } } - private childRecord(id: string, type: string, order: number, revision: number, - content: string, toolName: string, status: string): HostSessionEvent { - const input: PreviewReadInput = { path: 'src/payments.ts' }; - const payload: SessionRecord = { - sessionId: 'preview', id: `item/${id}`, revision, - turn: { turnId: 'turn', turnIndex: 0, sessionId: 'preview', timestamp: 1, - userMessage: { id: 'user', content: 'Audit the payment module', timestamp: 1 }, status: 'inprogress' }, - round: { id: 'round', turnId: 'turn', roundIndex: 0, timestamp: 2, status: 'inprogress' }, - item: { type, data: { id, orderIndex: order, timestamp: 3, content, - subagentSessionId: this.childSessionId, - toolName, toolCall: { id, input }, status } } - }; - return { session_id: 'preview', event: 'session-record', payload }; + private pause(): void { + this.paused = true; + if (this.timer !== -1) { + clearInterval(this.timer); + this.timer = -1; + } + this.refreshSummary(); } - private publish(): void { - const messages = this.reducer.messages(); - const active = messages.find((message: ChatMessage) => message.role === 'assistant' && message.status === 'active'); - this.timeline.applySnapshot({ sessionId: 'preview', cursor: { - pollVersion: 0, knownMessageCount: messages.length, knownModelCatalogVersion: 0 - }, changed: true, title: '', sessionState: active ? 'active' : 'idle', newMessages: [], - messageSnapshot: messages.filter((message: ChatMessage) => message !== active), activeTurn: active, - shouldSyncAfterTurnEnded: false, historyRewritten: false, completedTurnId: '' }); + private applySnapshot(snapshot: ChatSessionSnapshot): void { + this.timeline.applySnapshot(snapshot); this.rows = this.rowStore.reconcile(this.timeline.project(false)); this.revision++; const state = this.timeline.snapshot(); this.active = state.activeTurn !== undefined; - this.summary = this.summarize(state.activeTurn || state.persistedMessages - .filter((message: ChatMessage) => message.role === 'assistant') - .pop()); + this.refreshSummary(); + } + + /** + * A stream failure stays visible instead of stopping the fixture: the band + * states it, and the swarm keeps publishing, so a broken child stream cannot + * be mistaken for a worker that had nothing to say. + */ + private report(error: Object): void { + this.failure = (error as Error).message || 'stream failed'; + this.refreshSummary(); } /** - * The branch the bubble would draw, read through the production grouping - * policy so the line states what is on screen rather than what was published. + * What the transcript currently holds, read back through the production + * grouping policy: how many cards the turn draws and how many steps each of + * them has. A worker's step count only rises while its stream is live, which + * is the evidence a screenshot alone cannot state. */ - private summarize(answer?: ChatMessage): string { + private refreshSummary(): void { + const cards = this.branchCards(); + const steps = cards.map((card: ConversationUiMessageItem) => (card.subItems || []).length).join(','); + this.summary = `swarm step=${this.script.position()}/${this.script.beatCount()} paused=${this.paused ? 1 : 0} ` + + `rows=${this.rows.length} cards=${cards.length} steps=[${steps}]` + + (this.failure.length > 0 ? ` failure=${this.failure}` : ''); + } + + private branchCards(): ConversationUiMessageItem[] { + const state = this.timeline.snapshot(); + let answer = state.activeTurn; if (!answer) { - return 'step=0 children=0 phase=none'; + state.persistedMessages.forEach((message: ChatMessage) => { + if (message.role === 'assistant') answer = message; + }); } + if (!answer) return []; const message: ConversationUiMessage = toConversationUiMessage(answer); const groups: StructuredRenderGroup[] = ChatMessageStructurePolicy.structuredGroups( message.items || [], 'item', (message.status || '') === 'active', message.status || 'done'); - const card = groups + return groups .filter((group: StructuredRenderGroup) => group.type === 'item' && group.items.length === 1) .map((group: StructuredRenderGroup) => group.items[0]) - .find((entry: ConversationUiMessageItem) => !!entry.tool && entry.tool.name === 'Task'); - const children = card && card.subItems ? card.subItems.length : 0; - return `step=${this.step} children=${children} phase=${message.status || 'done'}`; - } - - private next(): void { - this.step++; - if (this.step === 1) { - this.reducer.apply(this.childRecord('child-think', 'thinking', 1, 2, - 'Reading the payment module', 'Read', 'running')); - } - if (this.step === 2) { - this.reducer.apply(this.childRecord('child-read', 'tool', 2, 3, - '', 'Read', 'completed')); - } - if (this.step === 3) { - this.reducer.apply(this.childRecord('child-text', 'text', 3, 4, - 'Found no hard-coded keys', 'Read', 'completed')); - } - if (this.step === 4) { - this.reducer.apply(this.childRecord('child-grep', 'tool', 4, 5, - '', 'Grep', 'running')); - } - if (this.step === 5) { - this.reducer.apply(this.childRecord('child-grep', 'tool', 4, 6, - '', 'Grep', 'completed')); - } - if (this.step === 6) { - this.reducer.apply(this.taskRecord(7, 'completed', 'completed')); - } - this.publish(); + .filter((entry: ConversationUiMessageItem) => ChatMessageStructurePolicy.isSubagentEntry(entry)); } build() { - Column({ space: 8 }) { - Button('Next step').id('subagent-next').onClick(() => { this.next(); }) - Text(this.summary).id('subagent-summary').fontColor(INK) + Column({ space: 6 }) { + Text(this.summary).id('subagent-summary').fontSize(10).fontColor(INK).width('100%') Row({ space: 8 }) { - Button('Host offline').id('subagent-offline').onClick(() => { this.connectionState = 'reconnecting'; }) - Button('Host returned').id('subagent-returned').onClick(() => { - this.connectionState = 'connected'; - this.publish(); + Button('Next beat').id('subagent-next').onClick(() => { this.tick(); }) + Button(this.paused ? 'Resume' : 'Pause').id('subagent-pause').onClick(() => { + if (this.paused) { + this.paused = false; + if (this.timer === -1) this.timer = setInterval((): void => { this.tick(); }, SWARM_BEAT_INTERVAL_MS); + this.refreshSummary(); + } else { + this.pause(); + } }) + Button('Host offline').id('subagent-offline').onClick(() => { this.connectionState = 'reconnecting'; }) + Button('Host returned').id('subagent-returned').onClick(() => { this.connectionState = 'connected'; }) } - Text(`${this.connectionState} stops=${this.stopRequests}`).id('subagent-host-state').fontColor(INK) - ChatTimeline({ timelineItems: this.rows, timelineRevision: this.revision, - connectionState: this.connectionState, isBusy: this.active }).layoutWeight(1) - ComposerBar({ canStop: this.active, connectionState: this.connectionState, - onStop: () => { this.stopRequests++; } }) + Text(`${this.connectionState} stops=${this.stopRequests}`).id('subagent-host-state').fontSize(10).fontColor(INK) + ChatTimeline({ + timelineItems: this.rows, timelineRevision: this.revision, + connectionState: this.connectionState, isBusy: this.active + }).layoutWeight(1) + ComposerBar({ + canStop: this.active, connectionState: this.connectionState, + onStop: () => { this.stopRequests++; } + }) }.width('100%').height('100%').backgroundColor(PAGE_BG) } } diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/preview/SubagentSwarmFixture.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/preview/SubagentSwarmFixture.ets new file mode 100644 index 0000000000..a717d559de --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/preview/SubagentSwarmFixture.ets @@ -0,0 +1,639 @@ +import { + HostSessionCallbacks, + HostSessionSource, + HostSessionStream, + HostStreamEventWire, + HostStreamHint, + HostStreamPage, + HostStreamReadRequest +} from '../../services/HostSessionStream'; + +/** + * A swarm session, published the way a live host publishes one. + * + * Two facts this fixture exists to keep straight, both read off a real + * `Ultimate` session's record stream: + * + * 1. The parent stream carries the launch and nothing of the child. An + * `AgentSpawn` tool item names the child Session id it started + * (`subagentSessionId`, camelCase, exactly as `records_from_turns` serializes + * `ToolItemData`), holds the full task brief in `toolCall.input.prompt`, and + * answers with a text table of `agent_id / bg_task_id / status`. The rest of + * the parent's items are ordinary tool rows. + * 2. A child's records are an ordinary session's records on the child's own + * stream id: thinking, tool and text items with the same key shapes and no + * subagent marker anywhere. A worker that ran to completion holds hundreds of + * records, so the fixture publishes its history in one page before the + * branch that reads it is opened. + * + * Nothing here fabricates a child item inside the parent stream. The records a + * client reads are either the parent's own or a child's own, grouped by which + * stream they arrived on. + */ + +/** Host stream epoch for every fixture stream. */ +const SWARM_PREVIEW_EPOCH: number = 1; +/** Fixture-local clock origin; the host stamps real epoch milliseconds. */ +const SWARM_PREVIEW_CLOCK: number = 1767225600000; +const SWARM_PREVIEW_TOOL_MS: number = 640; + +export const SWARM_PREVIEW_HOST: string = 'swarm-preview-host'; +export const SWARM_PARENT_SESSION: string = 'swarm-preview-parent'; +/** First worker: finished before its branch was opened, so its history backfills. */ +export const SWARM_PAYMENTS_SESSION: string = 'swarm-preview-child-payments'; +/** Second worker: still running while the parent's turn is on screen. */ +export const SWARM_DOCS_SESSION: string = 'swarm-preview-child-docs'; +/** Third worker: its own tools failed and it reported the failure. */ +export const SWARM_FLAKY_SESSION: string = 'swarm-preview-child-flaky'; + +interface SwarmUserMessage { id: string; content: string; timestamp: number; } +interface SwarmTurnHeader { + turnId: string; turnIndex: number; sessionId: string; timestamp: number; + userMessage: SwarmUserMessage; status: string; +} +interface SwarmRoundHeader { id: string; turnId: string; roundIndex: number; timestamp: number; status: string; } +/** A tool call's own input: one flat object, holding only the keys that tool takes. */ +export interface SwarmToolInput { + pattern?: string; + path?: string; + glob?: string; + output_mode?: string; + file_path?: string; + offset?: number; + limit?: number; + cmd?: string; + workdir?: string; + yield_time_ms?: number; + agent_id?: string; + agent_type?: string; + prompt?: string; + bg_task_ids?: string[]; + timeout_seconds?: number; +} +interface SwarmToolCall { id: string; input: SwarmToolInput; } +interface SwarmToolResult { + result: string; + success: boolean; + resultForAssistant?: string; + error?: string; + durationMs?: number; +} +interface SwarmThinkingData { + id: string; content: string; isStreaming: boolean; isCollapsed: boolean; + timestamp: number; orderIndex: number; status: string; +} +interface SwarmTextData { + id: string; content: string; isStreaming: boolean; timestamp: number; + isMarkdown: boolean; orderIndex: number; status: string; +} +interface SwarmToolData { + id: string; toolName: string; toolCall: SwarmToolCall; startTime: number; + orderIndex: number; status: string; toolResult?: SwarmToolResult; + endTime?: number; durationMs?: number; subagentSessionId?: string; +} +interface SwarmItem { type: string; data: Object; } +interface SwarmRecord { + sessionId: string; id: string; revision: number; turn: SwarmTurnHeader; + round?: SwarmRoundHeader; item?: SwarmItem; +} + +/** Optional facts a published tool call can carry. */ +export interface SwarmToolOptions { + subagentSessionId?: string; + success?: boolean; + status?: string; + error?: string; + resultForAssistant?: string; +} + +/** + * One session's record log, published in the host's own order. + * + * Records are stamped with a per-session revision that rises with every + * publication, which is what lets a client deduplicate a replayed page and + * accept an updated status for an item it already has. + */ +export class SwarmSessionLog { + readonly sessionId: string; + private wake: (sessionId: string) => void; + private turnId: string; + private userText: string; + private turnIndex: number; + private published: HostStreamEventWire[] = []; + private seq: number = 0; + private revision: number = 0; + private itemSeq: number = 0; + private roundCount: number = -1; + private roundId: string = ''; + private order: number = 0; + private clock: number = SWARM_PREVIEW_CLOCK; + private turnStatus: string = 'inprogress'; + + constructor(sessionId: string, turnId: string, userText: string, turnIndex: number, + wake: (sessionId: string) => void) { + this.sessionId = sessionId; + this.turnId = turnId; + this.userText = userText; + this.turnIndex = turnIndex; + this.wake = wake; + } + + records(): HostStreamEventWire[] { return this.published.slice(); } + newestSeq(): number { return this.seq; } + + /** Publishes the turn record, which every later record of this log repeats. */ + openTurn(): void { + this.publish(this.record(`turn/${this.turnId}`)); + } + + /** Opens the next model round and publishes it. */ + beginRound(status: string = 'inprogress'): void { + this.roundCount++; + this.roundId = `${this.turnId}-round-${this.roundCount}`; + this.order = 0; + const round: SwarmRoundHeader = { + id: this.roundId, turnId: this.turnId, roundIndex: this.roundCount, + timestamp: this.clock, status + }; + this.publish(this.record(`round/${this.roundId}`, undefined, round)); + } + + thinking(content: string): void { + const id = this.nextItemId('thinking'); + const data: SwarmThinkingData = { + id, content, isStreaming: false, isCollapsed: false, + timestamp: this.now(), orderIndex: this.order++, status: 'completed' + }; + this.publishItem('thinking', id, data); + } + + text(content: string): void { + const id = this.nextItemId('text'); + const data: SwarmTextData = { + id, content, isStreaming: false, + timestamp: this.now(), isMarkdown: true, orderIndex: this.order++, status: 'completed' + }; + this.publishItem('text', id, data); + } + + /** A tool call the host has recorded but not finished: status running, no result. */ + toolStarted(id: string, name: string, input: SwarmToolInput, options?: SwarmToolOptions): void { + this.publishTool(id, name, input, undefined, 'running', options); + } + + /** The same tool call once its result is known, in the host's status vocabulary. */ + toolFinished(id: string, name: string, input: SwarmToolInput, output: string, options?: SwarmToolOptions): void { + const status = options?.status ?? ((options?.success === false) ? 'failed' : 'completed'); + this.publishTool(id, name, input, output, status, options); + } + + private publishTool(id: string, name: string, input: SwarmToolInput, output: string | undefined, + status: string, options?: SwarmToolOptions): void { + const start = this.now(); + const data: SwarmToolData = { + id, toolName: name, toolCall: { id, input }, startTime: start, + orderIndex: this.order++, status, subagentSessionId: options?.subagentSessionId + }; + if (output !== undefined) { + data.endTime = start + SWARM_PREVIEW_TOOL_MS; + data.durationMs = SWARM_PREVIEW_TOOL_MS; + data.toolResult = { + result: output, success: options?.success !== false, + resultForAssistant: options?.resultForAssistant, error: options?.error, + durationMs: SWARM_PREVIEW_TOOL_MS + }; + } + this.publishItem('tool', id, data); + } + + private publishItem(type: string, id: string, data: Object): void { + this.publish(this.record(`item/${id}`, { type, data })); + } + + private nextItemId(kind: string): string { + this.itemSeq++; + return `${this.turnId}-${kind}-${this.itemSeq}`; + } + + private now(): number { + this.clock += SWARM_PREVIEW_TOOL_MS; + return this.clock; + } + + private record(id: string, item?: SwarmItem, round?: SwarmRoundHeader): SwarmRecord { + this.revision++; + const record: SwarmRecord = { + sessionId: this.sessionId, id, revision: this.revision, turn: this.turnHeader() + }; + if (round) { + record.round = round; + } else if (this.roundId.length > 0) { + record.round = { + id: this.roundId, turnId: this.turnId, roundIndex: this.roundCount, + timestamp: this.clock, status: this.turnStatus + }; + } + if (item) record.item = item; + return record; + } + + private turnHeader(): SwarmTurnHeader { + return { + turnId: this.turnId, turnIndex: this.turnIndex, sessionId: this.sessionId, timestamp: this.clock, + userMessage: { id: `${this.turnId}-user`, content: this.userText, timestamp: this.clock }, + status: this.turnStatus + }; + } + + private publish(payload: Object): void { + this.seq++; + this.published.push({ seq: this.seq, event: 'session-record', payload }); + this.wake(this.sessionId); + } +} + +/** Serves every fixture stream out of the one record log of its session. */ +class SwarmStreamSource implements HostSessionSource { + target: string = SWARM_PREVIEW_HOST; + private ledger: SwarmLedger; + private sessionId: string; + + constructor(ledger: SwarmLedger, sessionId: string) { + this.ledger = ledger; + this.sessionId = sessionId; + } + + async read(request: HostStreamReadRequest): Promise { + return this.ledger.page(this.sessionId, request); + } + + async unsubscribe(): Promise {} + + /** The fixture never pushes hints; a publication wakes the stream instead. */ + onHint(_listener: (hint: HostStreamHint) => void): () => void { + return (): void => {}; + } + + onReconnect(_listener: () => void): () => void { + return (): void => {}; + } +} + +/** + * The fixture's stream transport: one log per session id, opened on demand. + * + * `openStream` is the same call the runtime connection makes for a session on + * screen, so a child stream is read by the production stream reader — paging, + * epoch, cursor and dedup included — and not by a fixture-shaped shortcut. + */ +export class SwarmLedger { + private logs: Map = new Map(); + private streams: Map = new Map(); + + /** Creates (once) the log a session publishes into. */ + open(sessionId: string, turnId: string, userText: string, turnIndex: number = 0): SwarmSessionLog { + const existing = this.logs.get(sessionId); + if (existing) return existing; + const log = new SwarmSessionLog(sessionId, turnId, userText, turnIndex, + (woken: string): void => { this.wake(woken); }); + this.logs.set(sessionId, log); + return log; + } + + openStream(sessionId: string, callbacks: HostSessionCallbacks): HostSessionStream { + const stream = new HostSessionStream(sessionId, new SwarmStreamSource(this, sessionId), callbacks); + this.streams.set(sessionId, stream); + return stream; + } + + /** A read page for one session: everything after the caller's cursor. */ + page(sessionId: string, request: HostStreamReadRequest): HostStreamPage { + const log = this.logs.get(sessionId); + const records: HostStreamEventWire[] = log ? log.records() : []; + const cursor = log ? log.newestSeq() : 0; + const after = request.after ?? 0; + const page: HostStreamPage = { + resp: 'stream_page', stream_id: sessionId, epoch: SWARM_PREVIEW_EPOCH, cursor, + events: records.filter((entry: HostStreamEventWire) => entry.seq > after), + has_more: false, oldest_seq: records.length > 0 ? records[0].seq : 1, truncated: false + }; + return page; + } + + private wake(sessionId: string): void { + this.streams.get(sessionId)?.wake(); + } +} + +/** + * The swarm the preview plays out, one beat at a time. + * + * Each beat publishes the records a live host would publish at that moment. + * Beats exist so the fixture can be captured mid-flight — a worker's steps + * arriving one page at a time — instead of only in its finished state. + */ +export class SwarmScript { + private ledger: SwarmLedger; + private parent: SwarmSessionLog; + private payments: SwarmSessionLog; + private docs: SwarmSessionLog; + private flaky: SwarmSessionLog; + private beats: (() => void)[] = []; + private cursor: number = 0; + + constructor(ledger: SwarmLedger) { + this.ledger = ledger; + this.parent = ledger.open(SWARM_PARENT_SESSION, 'swarm-parent-turn-1', + 'Audit the payments module with a swarm and report what each worker found.', 0); + this.payments = ledger.open(SWARM_PAYMENTS_SESSION, 'swarm-payments-turn-1', SWARM_PAYMENTS_BRIEF, 0); + this.docs = ledger.open(SWARM_DOCS_SESSION, 'swarm-docs-turn-1', SWARM_DOCS_BRIEF, 0); + this.flaky = ledger.open(SWARM_FLAKY_SESSION, 'swarm-flaky-turn-1', SWARM_FLAKY_BRIEF, 0); + this.beats = [ + (): void => { this.beatOpening(); }, + (): void => { this.beatSpawnPayments(); }, + (): void => { this.beatSpawnFlaky(); }, + (): void => { this.beatSpawnDocs(); }, + (): void => { this.beatDocsThinking(); }, + (): void => { this.beatDocsSearchStarted(); }, + (): void => { this.beatDocsSearchFinished(); }, + (): void => { this.beatWait(); }, + (): void => { this.beatParentRows(); }, + (): void => { this.beatDocsReadPlan(); }, + (): void => { this.beatDocsGrepStarted(); }, + (): void => { this.beatDocsGrepFinished(); }, + (): void => { this.beatDocsReadStarted(); }, + (): void => { this.beatDocsSpawnSettles(); } + ]; + } + + /** How many beats the swarm has; a driver stops when `advance` answers false. */ + beatCount(): number { return this.beats.length; } + /** The beat that will be published next; equals `beatCount` when finished. */ + position(): number { return this.cursor; } + + /** Publishes the next beat; false when the swarm script has nothing left. */ + advance(): boolean { + if (this.cursor >= this.beats.length) return false; + const beat = this.beats[this.cursor]; + this.cursor++; + beat(); + return true; + } + + /** Publishes every remaining beat at once. */ + drain(): void { + while (this.advance()) { + } + } + + // ── Beats ──────────────────────────────────────────────────────────────── + + /** The parent's turn opens and it states what it is about to hand out. */ + private beatOpening(): void { + const parent = this.parent; + parent.openTurn(); + parent.beginRound(); + parent.thinking(PARENT_PLAN_REASONING); + parent.text(PARENT_PLAN_TEXT); + } + + /** + * First worker launched, and it had already finished by the time the parent + * published the launch: its history arrives as the child page the branch + * reads, not as records inside the parent stream. + */ + private beatSpawnPayments(): void { + const payments = this.payments; + payments.openTurn(); + payments.beginRound(); + payments.thinking('Locate every payment entry point before reading any of them.'); + payments.toolFinished('swarm-payments-search', 'Grep', + { pattern: 'charge|refund|invoice', path: 'src/payments', output_mode: 'files_with_matches' }, + PAYMENTS_GREP_OUTPUT); + payments.beginRound(); + payments.toolFinished('swarm-payments-read-a', 'Read', + { file_path: 'src/payments/charge.ts', limit: 120 }, PAYMENTS_READ_A_OUTPUT); + payments.thinking('The retry path re-enters the charge guard without the idempotency key.'); + payments.toolFinished('swarm-payments-read-b', 'Read', + { file_path: 'src/payments/retry.ts', limit: 80 }, PAYMENTS_READ_B_OUTPUT); + payments.beginRound(); + payments.text(PAYMENTS_REPORT); + this.parent.toolFinished('swarm-payments-spawn', 'AgentSpawn', { + agent_id: 'swarm-payments', agent_type: 'Explore', prompt: SWARM_PAYMENTS_BRIEF + }, spawnTable('swarm-payments', 'bg-7f21c9'), { + subagentSessionId: SWARM_PAYMENTS_SESSION, resultForAssistant: PAYMENTS_REPORT + }); + } + + /** Third worker: its own tools failed, and it reported that back. */ + private beatSpawnFlaky(): void { + const flaky = this.flaky; + flaky.openTurn(); + flaky.beginRound(); + flaky.thinking('Run the migration check first; it is the only way to know the schema state.'); + flaky.toolFinished('swarm-flaky-exec', 'ExecCommand', { + cmd: 'pnpm run migrate:check --json', workdir: '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/workspace/app', yield_time_ms: 20000 + }, FLAKY_EXEC_OUTPUT, { + success: false, error: 'script "migrate:check" does not exist in this workspace' + }); + flaky.beginRound(); + flaky.text(FLAKY_REPORT); + this.parent.toolFinished('swarm-flaky-spawn', 'AgentSpawn', { + agent_id: 'swarm-flaky', agent_type: 'Explore', prompt: SWARM_FLAKY_BRIEF + }, 'agent_id bg_task_id status\nswarm-flaky bg-3c81ab failed', { + subagentSessionId: SWARM_FLAKY_SESSION, success: false, status: 'error', + error: 'the worker could not run its workspace check' + }); + } + + /** Second worker launched into the background: the call returns, the worker keeps going. */ + private beatSpawnDocs(): void { + this.parent.beginRound(); + this.parent.toolStarted('swarm-docs-spawn', 'AgentSpawn', { + agent_id: 'swarm-docs', agent_type: 'Explore', prompt: SWARM_DOCS_BRIEF + }, { subagentSessionId: SWARM_DOCS_SESSION }); + } + + private beatDocsThinking(): void { + const docs = this.docs; + docs.openTurn(); + docs.beginRound(); + docs.thinking('Start from the documentation index, then follow every link into the guides.'); + } + + private beatDocsSearchStarted(): void { + this.docs.toolStarted('swarm-docs-search', 'Grep', { + pattern: 'quickstart|getting-started', path: 'docs', glob: '*.md' + }); + } + + private beatDocsSearchFinished(): void { + this.docs.toolFinished('swarm-docs-search', 'Grep', { + pattern: 'quickstart|getting-started', path: 'docs', glob: '*.md' + }, DOCS_GREP_OUTPUT); + } + + /** The parent collects the background workers it handed out. */ + private beatWait(): void { + this.parent.beginRound(); + this.parent.toolFinished('swarm-wait', 'AgentWait', { + bg_task_ids: ['bg-7f21c9', 'bg-3c81ab', 'bg-9d40e2'], timeout_seconds: 600 + }, SWARM_WAIT_REPORT); + } + + /** + * Ordinary parent work, published while the last worker is still streaming. + * + * These rows are the parent's own: nothing may fold them into a card just + * because a launch precedes them. + */ + private beatParentRows(): void { + const parent = this.parent; + parent.beginRound(); + parent.toolFinished('swarm-parent-grep', 'Grep', { + pattern: 'swarm', path: 'src/agents', output_mode: 'files_with_matches' + }, PARENT_GREP_OUTPUT); + parent.toolFinished('swarm-parent-read', 'Read', { + file_path: 'src/agents/swarm.ts', limit: 60 + }, PARENT_READ_OUTPUT); + } + + private beatDocsReadPlan(): void { + const docs = this.docs; + docs.beginRound(); + docs.thinking('Two guides disagree about the install step; read both before writing the summary.'); + } + + private beatDocsGrepStarted(): void { + this.docs.toolStarted('swarm-docs-links', 'Grep', { + pattern: 'npm install|pnpm install', path: 'docs/guides' + }); + } + + private beatDocsGrepFinished(): void { + this.docs.toolFinished('swarm-docs-links', 'Grep', { + pattern: 'npm install|pnpm install', path: 'docs/guides' + }, DOCS_LINKS_OUTPUT); + } + + private beatDocsReadStarted(): void { + this.docs.toolStarted('swarm-docs-read', 'Read', { + file_path: 'docs/guides/install.md', offset: 1, limit: 60 + }); + } + + /** The launch settles while its worker is still running. */ + private beatDocsSpawnSettles(): void { + this.parent.toolFinished('swarm-docs-spawn', 'AgentSpawn', { + agent_id: 'swarm-docs', agent_type: 'Explore', prompt: SWARM_DOCS_BRIEF + }, spawnTable('swarm-docs', 'bg-9d40e2'), { subagentSessionId: SWARM_DOCS_SESSION }); + } +} + +/** The text table an `AgentSpawn` answers with while its worker runs. */ +function spawnTable(agentId: string, bgTaskId: string): string { + return ['agent_id bg_task_id status', `${agentId} ${bgTaskId} started`].join('\n'); +} + +/** + * The brief a worker is spawned with: the full task text, mixed Markdown, long + * enough that the card's one-line title is the part a reader can see. + */ +const SWARM_PAYMENTS_BRIEF: string = [ + 'Audit the payments module and report only what you can prove from the code.', + '', + 'Steps:', + '1. find every entry point that can charge or refund a customer;', + '2. read the retry, idempotency and ledger paths end to end;', + '3. name each defect with file, symbol and the input that triggers it;', + '4. state explicitly what you could not verify.', + '', + 'Do not refactor anything. Do not run the test suite; this workspace has no', + 'payment fixtures. Your report is read by another agent, so keep it factual.' +].join('\n'); + +const SWARM_DOCS_BRIEF: string = [ + 'Check the documentation for drift against the shipped CLI.', + '', + 'Walk docs/ from the index, follow every internal link, and record each place', + 'where a command, flag or default differs from what the binary does today.', + 'Report the drift with the file and the line that has to change.' +].join('\n'); + +const SWARM_FLAKY_BRIEF: string = [ + 'Verify the database migrations can be replayed from an empty schema.', + 'Run the workspace check script and report its exit status verbatim.' +].join('\n'); + +const PARENT_PLAN_REASONING: string = + 'The audit splits into independent areas, so hand each one to a worker and collect their reports.'; +const PARENT_PLAN_TEXT: string = + 'I will run three workers in parallel: payment entry points, documentation drift, and a migration replay.'; + +const PAYMENTS_GREP_OUTPUT: string = [ + 'src/payments/charge.ts', 'src/payments/refund.ts', 'src/payments/retry.ts', + 'src/payments/invoice.ts', 'src/payments/ledger.ts' +].join('\n'); + +const PAYMENTS_READ_A_OUTPUT: string = [ + 'export async function charge(request: ChargeRequest): Promise {', + ' const key = request.idempotencyKey ?? randomKey();', + ' return ledger.append(await gateway.charge(request, key));', + '}' +].join('\n'); + +const PAYMENTS_READ_B_OUTPUT: string = [ + 'export async function retry(captured: Captured): Promise {', + ' // The guard runs again before the key is restored from the capture.', + ' if (captured.attempts > MAX_ATTEMPTS) throw new RetryExhausted(captured);', + ' return charge(captured.request);', + '}' +].join('\n'); + +const PAYMENTS_REPORT: string = [ + 'Reported two defects and one gap.', + '', + '- retry.ts re-enters charge() without restoring the captured idempotency key,', + ' so a retried charge is recorded twice in the ledger (charge.ts:12, retry.ts:4).', + '- invoice.ts rounds the taxable amount before summing line items, which drifts', + ' by one cent on invoices with more than four lines.', + '- I could not verify the refund path: it needs a signed ledger entry this', + ' workspace cannot produce.' +].join('\n'); + +const FLAKY_EXEC_OUTPUT: string = + 'ERR_PNPM_NO_SCRIPT Missing script: migrate:check\nCommand failed with exit code 1.'; + +const FLAKY_REPORT: string = [ + 'The migration replay could not be attempted.', + '', + 'The workspace has no migrate:check script, so the check I was asked to run does', + 'not exist here. Everything else in this brief depends on it, so this worker', + 'stopped rather than reporting a result it could not prove.' +].join('\n'); + +const DOCS_GREP_OUTPUT: string = [ + 'docs/index.md', 'docs/guides/quickstart.md', 'docs/guides/install.md', 'docs/reference/cli.md' +].join('\n'); + +const DOCS_LINKS_OUTPUT: string = [ + 'docs/guides/install.md:18: pnpm install', + 'docs/guides/install.md:31: pnpm add -g openbitfun', + 'docs/guides/quickstart.md:12: npm install -g openbitfun' +].join('\n'); + +const SWARM_WAIT_REPORT: string = [ + 'Two workers reported, one failed.', + '', + 'payments: double ledger entry on retry, invoice rounding drift.', + 'docs: quickstart.md still installs with npm while the CLI ships as pnpm.', + 'flaky: could not run its check; the script it was told to use does not exist.' +].join('\n'); + +const PARENT_GREP_OUTPUT: string = 'src/agents/swarm.ts\nsrc/agents/swarm/plan.ts'; + +const PARENT_READ_OUTPUT: string = [ + 'export function planWorkers(areas: AuditArea[]): WorkerPlan {', + ' return areas.map((area) => ({ area, agentType: area.preferredAgent }));', + '}' +].join('\n'); diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/services/ChatSessionController.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/services/ChatSessionController.ets index a0803dc740..0897df1c3d 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/services/ChatSessionController.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/services/ChatSessionController.ets @@ -2,6 +2,7 @@ import { InteractionMailboxStore } from './InteractionMailboxStore'; import { InteractionMailboxAction, InteractionMailboxState } from '../model/InteractionMailbox'; import { DurableSessionReducer } from './DurableSessionReducer'; import { HostSessionStream, HostSessionEvent } from './HostSessionStream'; +import { SubagentStreamCoordinator } from './SubagentStreamCoordinator'; import { ChatMessage, RemoteModelCatalog @@ -61,6 +62,7 @@ export class ChatSessionController { private stream?: HostSessionStream; private reducer: DurableSessionReducer = new DurableSessionReducer(); private mailbox: InteractionMailboxStore; + private subagents: SubagentStreamCoordinator; private generation: number = 0; private stopped: boolean = true; private caughtUp: boolean = false; @@ -77,6 +79,12 @@ export class ChatSessionController { _options?: ChatSessionControllerOptions) { this.sessionManager = sessionManager; this.callbacks = callbacks; this.mailbox = new InteractionMailboxStore(sessionManager, (state: InteractionMailboxState): void => { this.callbacks.onMailbox?.(state); }); + // A delegation tool's child sessions are streams of their own; they are read + // while this session is open and their records land in the branch of the + // item that launched them. + this.subagents = new SubagentStreamCoordinator(sessionManager, + (): void => { this.publishChildBranch(); }, + (error: Error): void => { if (!this.stopped) this.callbacks.onError(error); }); } start(sessionId: string, cursor: ChatSessionCursor, _activeTurn?: ChatMessage): void { if (!this.stopped && this.sessionId === sessionId && this.stream && !this.stream.isClosed()) { @@ -87,6 +95,7 @@ export class ChatSessionController { this.sessionState = 'idle'; this.completedTurnId = ''; this.hasMoreMessages = false; this.historyLoading = false; this.historyReplaying = false; this.historyFailed = false; this.pendingModelCatalog = undefined; + this.subagents.beginSession(sessionId); const epoch = this.generation; void this.loadInitialModelCatalog(sessionId, epoch); try { @@ -97,6 +106,9 @@ export class ChatSessionController { // must not delay the serial transcript stream or its durable cursor. if (event.event === 'session-interaction-changed') { void this.mailbox.refresh(); return; } if (!this.reducer.apply(event)) return; + // The launch of a child agent is a parent record: it is the only place + // the host names the session its process lives in. + this.subagents.observeParentEvent(event); if (this.caughtUp && !this.historyReplaying) this.publish(); }, onResumed: async (): Promise => { if (this.current(epoch)) void this.mailbox.refresh(); }, @@ -140,6 +152,7 @@ export class ChatSessionController { } stop(_clearActiveTurn: boolean = true): void { this.generation++; this.stopped = true; this.mailbox.select(''); this.stream?.close(); this.stream = undefined; + this.subagents.endSession(); } loadOlder(): void { this.stream?.loadOlder(); } nudge(): void { this.stream?.wake(); } @@ -156,12 +169,25 @@ export class ChatSessionController { } private current(epoch: number): boolean { return epoch === this.generation && !this.stopped; } async pollNow(): Promise { this.stream?.wake(); } + /** + * A child branch grew while the parent's own records did not. + * + * The transcript is republished through the same path a parent record takes, + * so an arriving subagent step and an arriving parent record reach the screen + * the same way. It waits for the parent's own page like any other publish: + * while a history replay is landing, the transcript on screen is a partial + * one and every record of it would repaint the conversation. + */ + private publishChildBranch(): void { + if (!this.caughtUp || this.historyReplaying) return; + this.publish(); + } private publish(): void { if (this.stopped || !this.callbacks.canPoll(this.sessionId)) return; const persisted: ChatMessage[] = []; let active: ChatMessage | undefined = undefined; this.completedTurnId = ''; - this.reducer.messages().forEach((message: ChatMessage) => { + this.subagents.decorate(this.reducer.messages()).forEach((message: ChatMessage) => { if (message.role === 'assistant' && (message.status === 'active' || message.status === 'running')) active = message; else { persisted.push(message); diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteSessionManager.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteSessionManager.ets index 2b11f882e1..a8c57baf0a 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteSessionManager.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteSessionManager.ets @@ -20,6 +20,7 @@ import { RemoteResponseMapper } from './RemoteResponseMapper'; import { stampRemoteSessionsWorkspace } from './RemoteSessionIdentity'; import { TranscriptIntegrityPolicy } from './TranscriptIntegrityPolicy'; import { RemoteSessionClient } from './RemoteSessionController'; +import { SubagentStreamSource } from './SubagentStreamCoordinator'; import { RemoteToolActionClient } from './RemoteToolActionController'; /** @@ -38,7 +39,7 @@ const ACCOUNT_HANDSHAKE_TIMEOUT_MS: number = 15000; interface PermissionReplyArgs { request: PermissionReplyRequest; } interface PermissionReplyRequest { requestId: string; reply: string; feedback?: string; updatedInput?: Object; } -export class RemoteSessionManager implements RemoteChatCommandClient, RemoteFileDownloadClient, RemoteModelClient, RemoteSessionClient, RemoteToolActionClient { +export class RemoteSessionManager implements RemoteChatCommandClient, RemoteFileDownloadClient, RemoteModelClient, RemoteSessionClient, RemoteToolActionClient, SubagentStreamSource { private transport?: RemoteCommandTransport; private transportGeneration: number = 0; private workspace?: WorkspaceInfo; diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/services/SubagentStreamCoordinator.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/services/SubagentStreamCoordinator.ets new file mode 100644 index 0000000000..39a75f8a0f --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/services/SubagentStreamCoordinator.ets @@ -0,0 +1,345 @@ +import { ChatMessage, ChatMessageItemResponse, RemoteToolStatusResponse } from '../model/RemoteModels'; +import { DurableSessionReducer } from './DurableSessionReducer'; +import { HostSessionCallbacks, HostSessionEvent, HostSessionStream } from './HostSessionStream'; + +/** + * The one call the coordinator needs from the runtime connection. + * + * `RemoteSessionManager` implements it, so the production path opens child + * streams exactly the way it opens the session on screen; a preview fixture can + * implement it over its own pages. + */ +export interface SubagentStreamSource { + subscribeSession(sessionId: string, callbacks: HostSessionCallbacks): HostSessionStream; +} + +/** Tool-item fields a launching record carries; the child Session id names its stream. */ +interface SubagentOwnerItemData { + toolName?: string; + subagentSessionId?: string; + toolCall?: SubagentOwnerToolCall; +} +interface SubagentOwnerToolCall { id?: string; } +interface SubagentOwnerItem { type?: string; data?: SubagentOwnerItemData; } +interface SubagentOwnerRecord { item?: SubagentOwnerItem; } + +/** One child session whose records are drawn inside the branch that launched it. */ +interface SubagentBranch { + sessionId: string; + ownerToolCallId: string; + reducer: DurableSessionReducer; + stream?: HostSessionStream; + /** False until the child's first page settled, so its backfill is one publish. */ + caughtUp: boolean; + /** How many times this branch's content changed. Monotonic per branch. */ + changes: number; + items: ChatMessageItemResponse[]; + signature: string; +} + +/** + * Streams a delegation tool's child sessions into the branch that launched them. + * + * A host keeps a spawned child agent in its own hidden session: the parent + * record names that session on the launching tool item, and nothing the child + * does appears in the parent's own stream. The only place the child's steps + * exist is its stream, so the branch content is grouped by the subscription the + * records arrived on — the child session id the host named — and never by a + * marker on a record. + * + * The parent session's reducer stays the single author of the parent + * transcript: this coordinator only attaches each child's own projection as the + * branch items of the item that owns it, and the transcript grouping policy + * then draws that item as the card it already knew how to draw. A host that does + * not name a child session produces no branch here at all, so an older desktop + * keeps the plain tool rows it has always sent. + */ +export class SubagentStreamCoordinator { + private source: SubagentStreamSource; + private notify: () => void; + private onError: (error: Error) => void; + private parentSessionId: string = ''; + private branches: Map = new Map(); + /** Owner tool call id -> child session id, i.e. which item a child belongs to. */ + private owners: Map = new Map(); + /** Owner tool call id -> the digest of the last launch state this coordinator published. */ + private ownerDigests: Map = new Map(); + /** Owner tool call id -> the render version this coordinator has published for it. */ + private ownerVersions: Map = new Map(); + + constructor(source: SubagentStreamSource, notify: () => void, onError: (error: Error) => void) { + this.source = source; + this.notify = notify; + this.onError = onError; + } + + /** + * Binds the coordinator to the parent session that is being opened. + * + * Nothing survives a session switch: another session's children are not this + * session's branches, and their streams would keep pushing records into a + * transcript that is no longer on screen. + */ + beginSession(parentSessionId: string): void { + this.detach(); + this.parentSessionId = parentSessionId; + } + + /** Closes every child stream; the parent session's own transcript is untouched. */ + endSession(): void { + this.detach(); + } + + /** + * Follows the parent's own records, which are the only place a child session + * id is named. + * + * Nothing else is inferred: a launch item without that field is an ordinary + * tool call, and no amount of tool ordering is treated as a subagent. + */ + observeParentEvent(event: HostSessionEvent): void { + if (this.parentSessionId.length === 0) return; + const owner = SubagentStreamCoordinator.ownerData(event); + if (!owner) return; + const childSessionId = owner.subagentSessionId || ''; + const ownerToolCallId = owner.toolCall ? (owner.toolCall.id || '') : ''; + if (childSessionId.length === 0 || ownerToolCallId.length === 0 || this.parentSessionId === childSessionId) return; + if (this.branches.has(childSessionId)) return; + this.openBranch(childSessionId, ownerToolCallId); + } + + /** + * The child branch items for every launching item the parent has published. + * + * Messages are returned unchanged when no branch applies, so a session without + * spawned children pays nothing beyond one map lookup per tool item. An + * affected message is copied with its owner's render version advanced by the + * branch's own change count, because ArkUI keys the row by what changed in + * the message and a branch grows without any parent record changing. + */ + decorate(messages: ChatMessage[]): ChatMessage[] { + if (this.branches.size === 0) return messages; + return messages.map((message: ChatMessage) => this.decorateMessage(message)); + } + + private decorateMessage(message: ChatMessage): ChatMessage { + const items = message.items; + if (!items || items.length === 0) return message; + let attached = 0; + const decorated: ChatMessageItemResponse[] = items.map((item: ChatMessageItemResponse, index: number) => { + const toolId = item.tool ? (item.tool.id || '') : ''; + if (toolId.length === 0) return item; + const childSessionId = this.owners.get(toolId); + if (!childSessionId) return item; + const branch = this.branches.get(childSessionId); + if (!branch) return item; + attached += this.ownerVersion(toolId, branch, item.tool); + if (branch.items.length === 0) return item; + // A host that publishes the process inside its own stream keeps this item + // on the path it has always used, and needs no branch attached here. + if (SubagentStreamCoordinator.parentCarriesBranchItems(items, index)) return item; + return { + type: item.type, + steering_id: item.steering_id, + round_index: item.round_index, + content: item.content, + tool: item.tool, + is_subagent: item.is_subagent, + subItems: branch.items, + images: item.images + }; + }); + if (attached === 0) return message; + return { + id: message.id, + role: message.role, + text: message.text, + status: message.status, + renderVersion: (message.renderVersion || 0) + attached, + turnId: message.turnId, + detail: message.detail, + error: message.error, + timestamp: message.timestamp, + thinking: message.thinking, + tools: message.tools, + items: decorated, + images: message.images + }; + } + + /** + * The render version this coordinator publishes for one launch item. + * + * Two independent things alter a card: the worker's own process grew, and the + * launch item's own record was updated. A launch settles as soon as the worker + * starts, and a background worker's launch settles while its stream has + * nothing new to say, so a version that only counted branch growth would leave + * the header of exactly that card behind: the row is keyed by what changed in + * the message, and a tool item's own status is not part of that key. Folding + * the launch's own digest in here means a settled launch repaints the card it + * belongs to even when its worker publishes no further step. + * + * The value is cumulative rather than a per-publication delta: the transcript + * the controller publishes must be identical when nothing changed, or a row + * would be rebuilt from a message that no longer carries its branch. + */ + private ownerVersion(ownerToolCallId: string, branch: SubagentBranch, + tool?: RemoteToolStatusResponse): number { + const digest = [ + `${branch.changes}`, + tool ? (tool.name || '') : '', + tool ? (tool.status || '') : '' + ].join('\u0000'); + if (this.ownerDigests.get(ownerToolCallId) !== digest) { + this.ownerDigests.set(ownerToolCallId, digest); + this.ownerVersions.set(ownerToolCallId, (this.ownerVersions.get(ownerToolCallId) || 0) + 1); + } + return this.ownerVersions.get(ownerToolCallId) || 0; + } + + /** + * Whether the parent stream itself already carries this owner's process. + * + * Older hosts published a subagent's records inside the parent stream and + * marked them, and the transcript grouping already attaches those records to + * the item that launched them. Attaching the child session's records here as + * well would draw every step twice, so a marked sibling record — up to the + * next launch item, which owns its own records — keeps that host on the path + * it has always used. + */ + private static parentCarriesBranchItems(items: ChatMessageItemResponse[], ownerIndex: number): boolean { + for (let index = ownerIndex + 1; index < items.length; index++) { + const entry = items[index]; + if (entry.is_subagent !== true) continue; + if (entry.tool && SubagentStreamCoordinator.isOwnerToolName(entry.tool.name || '')) return false; + return true; + } + return false; + } + + private static isOwnerToolName(rawName: string): boolean { + const name = rawName.trim().replace(/[\s-]/g, '_').toLowerCase(); + return name === 'task' || name === 'agentspawn'; + } + + private static ownerData(event: HostSessionEvent): SubagentOwnerItemData | undefined { + if (event.event !== 'session-record') return undefined; + const record = event.payload as SubagentOwnerRecord; + const item = record.item; + if (!item || item.type !== 'tool' || !item.data) return undefined; + return item.data.subagentSessionId ? item.data : undefined; + } + + private openBranch(childSessionId: string, ownerToolCallId: string): void { + const branch: SubagentBranch = { + sessionId: childSessionId, + ownerToolCallId: ownerToolCallId, + reducer: new DurableSessionReducer(), + caughtUp: false, + changes: 0, + items: [], + signature: '' + }; + this.branches.set(childSessionId, branch); + this.owners.set(ownerToolCallId, childSessionId); + try { + branch.stream = this.source.subscribeSession(childSessionId, { + onEvent: async (event: HostSessionEvent): Promise => { + this.applyChildEvent(childSessionId, event); + }, + onCaughtUp: async (): Promise => { + const current = this.branches.get(childSessionId); + if (!current) return; + current.caughtUp = true; + this.refresh(current); + }, + onGap: async (): Promise => { + // The host restarted this child's stream: everything reduced so far + // came from its previous epoch, so it is dropped before the replay. + const current = this.branches.get(childSessionId); + if (!current) return; + current.reducer = new DurableSessionReducer(); + current.caughtUp = false; + }, + onError: (error: Error): void => { this.onError(error); } + }); + } catch (_error) { + // A child stream that cannot be opened is not a reason to fail the + // session on screen: the launch item keeps its own row and result, and its + // branch stays empty instead of showing fabricated steps. + this.branches.delete(childSessionId); + this.owners.delete(ownerToolCallId); + } + } + + /** + * One record of one child session. + * + * Records are deduplicated by identity and revision inside the child's own + * reducer, which is the same reducer the session on screen uses, so a + * replayed page cannot publish a step twice. Until the child's first page has + * settled the parent is not republished at all: a child that finished before + * its branch was opened backfills hundreds of records, and each of them + * republishing the parent transcript would repaint the whole conversation per + * record for a branch nobody has seen yet. + */ + private applyChildEvent(childSessionId: string, event: HostSessionEvent): void { + const branch = this.branches.get(childSessionId); + if (!branch || this.parentSessionId.length === 0) return; + if (!branch.reducer.apply(event)) return; + if (!branch.caughtUp) return; + this.refresh(branch); + } + + private refresh(branch: SubagentBranch): void { + const items = SubagentStreamCoordinator.branchItems(branch.reducer); + const signature = SubagentStreamCoordinator.branchSignature(items); + if (signature === branch.signature) return; + branch.signature = signature; + branch.items = items; + branch.changes++; + this.notify(); + } + + /** + * The process a child session publishes, in its own order. + * + * A child session is an ordinary session: it has its own turns and its own + * user message (the brief it was spawned with), which the card already shows + * as the branch title, so only the assistant's items are branch content. + */ + private static branchItems(reducer: DurableSessionReducer): ChatMessageItemResponse[] { + const items: ChatMessageItemResponse[] = []; + reducer.messages().forEach((message: ChatMessage) => { + if (message.role !== 'assistant') return; + (message.items || []).forEach((item: ChatMessageItemResponse) => { items.push(item); }); + }); + return items; + } + + /** Everything about a branch that can change what the card draws. */ + private static branchSignature(items: ChatMessageItemResponse[]): string { + const parts: string[] = []; + items.forEach((item: ChatMessageItemResponse) => { + const tool = item.tool; + parts.push([ + item.type || '', + `${(item.content || '').length}`, + tool ? `${tool.id || ''}:${tool.name || ''}:${tool.status || ''}` : '' + ].join('\u0000')); + }); + return `${items.length}\u0001${parts.join('\u0002')}`; + } + + private detach(): void { + this.branches.forEach((branch: SubagentBranch) => { + branch.stream?.close(); + branch.stream = undefined; + }); + this.branches.clear(); + this.owners.clear(); + this.ownerDigests.clear(); + this.ownerVersions.clear(); + this.parentSessionId = ''; + } +} From 675023c44ecde9a97bed586258eb263555d641d3 Mon Sep 17 00:00:00 2001 From: Tant Date: Fri, 9 Oct 2026 13:10:50 +0800 Subject: [PATCH 4/5] test(mobile): pin the real swarm record contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The visibility suite pinned a contract no host produces: child records carrying the child session id inside the parent stream, a nested shape the record stream has never sent. It is rewritten around what a live Ultimate session publishes. Pinned now: the launching tool item (`AgentSpawn`/`Task`) is the only place a child session id appears, a child stream's own records are unmarked and are grouped by the subscription they arrived on, a launch without that field opens no stream and stays a plain tool row, a settled launch repaints its card even when its worker says nothing new, and a host that still inlines a marked child in the parent stream is not drawn twice. The fixture drives the production chain — stream reader, controller, durable reducer, timeline store, keyed rows, projection, grouping policy — so the assertions cover the path the app runs rather than a preview-only seam. The three suites that load `ChatSessionController` from source now resolve the coordinator the controller constructs. --- .../tools/tests/interaction-mailbox.test.cjs | 4 +- .../tests/session-model-hydration.test.cjs | 6 + .../tools/tests/session-record.test.cjs | 21 +- .../subagent-process-visibility.test.cjs | 693 ++++++++++++++---- 4 files changed, 565 insertions(+), 159 deletions(-) diff --git a/src/apps/mobile/harmonyos/tools/tests/interaction-mailbox.test.cjs b/src/apps/mobile/harmonyos/tools/tests/interaction-mailbox.test.cjs index 497d08fe83..42771e355c 100644 --- a/src/apps/mobile/harmonyos/tools/tests/interaction-mailbox.test.cjs +++ b/src/apps/mobile/harmonyos/tools/tests/interaction-mailbox.test.cjs @@ -13,8 +13,10 @@ function load(name, dependencies = {}) { } const models = load('../model/InteractionMailbox'); const { InteractionMailboxStore } = load('InteractionMailboxStore', { '../model/InteractionMailbox': models }); +const reducer = load('DurableSessionReducer'); const { ChatSessionController } = load('ChatSessionController', { - './InteractionMailboxStore': { InteractionMailboxStore }, './DurableSessionReducer': load('DurableSessionReducer'), + './InteractionMailboxStore': { InteractionMailboxStore }, './DurableSessionReducer': reducer, + './SubagentStreamCoordinator': load('SubagentStreamCoordinator', { './DurableSessionReducer': reducer }), }); const request = (id = 'approval', sessionId = 's') => ({ requestId: id, sessionId, toolCallId: 'tool', action: 'write', resources: ['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/remote/file'], source: { identity: 'Agent' } }); diff --git a/src/apps/mobile/harmonyos/tools/tests/session-model-hydration.test.cjs b/src/apps/mobile/harmonyos/tools/tests/session-model-hydration.test.cjs index 25c80356b2..9c06cfb60a 100644 --- a/src/apps/mobile/harmonyos/tools/tests/session-model-hydration.test.cjs +++ b/src/apps/mobile/harmonyos/tools/tests/session-model-hydration.test.cjs @@ -8,6 +8,12 @@ const exported = {}; new Function('require', 'exports', ts.transpileModule(source, {compilerOptions: {target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS}}).outputText)(name => ({ './InteractionMailboxStore': { InteractionMailboxStore: class { select() {} } }, './DurableSessionReducer': { DurableSessionReducer: class { messages() { return []; } } }, + './SubagentStreamCoordinator': { SubagentStreamCoordinator: class { + beginSession() {} + endSession() {} + observeParentEvent() {} + decorate(messages) { return messages; } + } }, }[name] || {}), exported); const flush = () => new Promise(resolve => setImmediate(resolve)); function fixture() { diff --git a/src/apps/mobile/harmonyos/tools/tests/session-record.test.cjs b/src/apps/mobile/harmonyos/tools/tests/session-record.test.cjs index 0db2a974f0..d971fb8991 100644 --- a/src/apps/mobile/harmonyos/tools/tests/session-record.test.cjs +++ b/src/apps/mobile/harmonyos/tools/tests/session-record.test.cjs @@ -13,6 +13,15 @@ function load(name, dependencies = {}) { const mailboxModule = load('InteractionMailboxStore', { '../model/InteractionMailbox': load('../model/InteractionMailbox') }); const reducerModule = load('DurableSessionReducer'); const { DurableSessionReducer } = reducerModule; +// The controller opens a delegation tool's child sessions itself; the +// coordinator is loaded from source with the reducer it reduces them with. +const subagentModule = load('SubagentStreamCoordinator', { './DurableSessionReducer': reducerModule }); +function loadController() { + return load('ChatSessionController', { + './DurableSessionReducer': reducerModule, './InteractionMailboxStore': mailboxModule, + './SubagentStreamCoordinator': subagentModule + }); +} function record(revision, content, status = 'inprogress', itemId = 'text') { return { session_id: 'session', event: 'session-record', payload: { sessionId: 'session', id: `item/${itemId}`, revision, @@ -41,7 +50,7 @@ test('late older child cannot regress completed parent, while its own unseen con assert.equal(reducer.messages()[1].renderVersion, 10); }); test('controller hydrates from the same record log without transcript RPC', async () => { - const { ChatSessionController } = load('ChatSessionController', { './DurableSessionReducer': reducerModule, './InteractionMailboxStore': mailboxModule }); + const { ChatSessionController } = loadController(); let callbacks, snapshots = []; const manager = { getModelCatalog: async () => ({ version: 1, models: [], default_models: {} }), subscribeSession: (_id, next) => { callbacks = next; return { wake() {}, close() {} }; }, getSessionMessages: () => { throw new Error('Snapshot RPC is forbidden'); } }; const controller = new ChatSessionController(manager, { onSnapshot: value => snapshots.push(value), canPoll: () => true, onError: error => { throw error; } }); @@ -87,7 +96,7 @@ test('command history entrypoints delegate to the same durable collection with n }); test('initial and resumed mailbox restores questions independently of transcript lifetime', async () => { - const { ChatSessionController } = load('ChatSessionController', { './DurableSessionReducer': reducerModule, './InteractionMailboxStore': mailboxModule }); + const { ChatSessionController } = loadController(); let callbacks, snapshot, mailbox, mailboxReads = 0; const question = { questions: [{question:'Proceed?',options:[{label:'Yes'}]}] }; const manager = { @@ -232,7 +241,7 @@ test('a dropped turn stops being cached', () => { test('history publishes user and assistant together after reduction, including a concurrent model read', async () => { - const { ChatSessionController } = load('ChatSessionController', { './DurableSessionReducer': reducerModule, './InteractionMailboxStore': mailboxModule }); + const { ChatSessionController } = loadController(); let callbacks, resolveCatalog; const snapshots = []; const manager = { @@ -272,9 +281,7 @@ async function durableTimelineHarness() { const timeline = new ChatTimelineStore(); timeline.reset('session'); let stream; - const { ChatSessionController } = load('ChatSessionController', { - './DurableSessionReducer': reducerModule, './InteractionMailboxStore': mailboxModule - }); + const { ChatSessionController } = loadController(); const controller = new ChatSessionController({ getModelCatalog: async () => ({ version: 1, models: [], default_models: {} }), subscribeSession: (_id, callbacks) => { stream = callbacks; return { wake() {}, close() {} }; } @@ -372,7 +379,7 @@ test('legacy partial snapshots still preserve omitted active content', () => { assert.deepEqual(timeline.snapshot().activeTurn.items, active.items); }); test('host restart terminal record settles active turn without reporting successful completion', async () => { - const { ChatSessionController } = load('ChatSessionController', { './DurableSessionReducer': reducerModule, './InteractionMailboxStore': mailboxModule }); + const { ChatSessionController } = loadController(); let stream, snapshot; const timeline = new ChatTimelineStore(); timeline.reset('session'); const controller = new ChatSessionController({ getModelCatalog: async () => ({version:1,models:[],default_models:{}}), diff --git a/src/apps/mobile/harmonyos/tools/tests/subagent-process-visibility.test.cjs b/src/apps/mobile/harmonyos/tools/tests/subagent-process-visibility.test.cjs index e78af739f3..06a3ec50de 100644 --- a/src/apps/mobile/harmonyos/tools/tests/subagent-process-visibility.test.cjs +++ b/src/apps/mobile/harmonyos/tools/tests/subagent-process-visibility.test.cjs @@ -30,207 +30,586 @@ function load(relative) { const { DurableSessionReducer } = load('services/DurableSessionReducer'); const { ChatTimelineStore } = load('services/ChatTimelineStore'); const { ChatSessionController } = load('services/ChatSessionController'); +const { SubagentStreamCoordinator } = load('services/SubagentStreamCoordinator'); const { ChatTimelineRowStore } = load('model/ChatTimelineModels'); const ui = load('pages/state/ConversationUiModels'); const { ChatMessageStructurePolicy: Policy } = load('pages/policy/ChatMessageStructurePolicy'); +const fixture = load('pages/preview/SubagentSwarmFixture'); -const SESSION = 'session'; -const TURN = 'turn'; -const CHILD_SESSION = 'child-session'; +const SESSION = fixture.SWARM_PARENT_SESSION; +const CHILD_SESSION = fixture.SWARM_PAYMENTS_SESSION; const TASK_CALL_ID = 'task_call_1'; +const SWARM_CALL_ID = 'swarm_call_1'; -/** One durable session-record event exactly as the host publishes it. */ -function record(revision, id, type, order, data) { +/** + * One durable session-record event exactly as the host publishes it. + * + * The shapes are the ones `records_from_turns` serializes: a turn header every + * record repeats, an optional round header, and the item's own data under + * `item.data` with camelCase keys. + */ +function record(revision, id, type, order, data, sessionId = SESSION) { return { - session_id: SESSION, + session_id: sessionId, event: 'session-record', payload: { - sessionId: SESSION, + sessionId, id: `item/${id}`, revision, turn: { - turnId: TURN, turnIndex: 0, sessionId: SESSION, timestamp: 1, + turnId: 'turn', turnIndex: 0, sessionId, timestamp: 1, userMessage: { id: 'user', content: 'run the audit', timestamp: 1 }, status: 'inprogress' }, - round: { id: 'round', turnId: TURN, roundIndex: 0, timestamp: 2, status: 'inprogress' }, + round: { id: 'round', turnId: 'turn', roundIndex: 0, timestamp: 2, status: 'inprogress' }, item: { type, data: Object.assign({ id, orderIndex: order, timestamp: 3 }, data) } } }; } /** - * The owner Task tool record. + * The owner tool record of a delegation. * - * The host records the child Session id on the Task tool item — the durable - * record contract is `is_subagent_item` / `subagent_session_id` on the item - * data in `src/crates/services/services-core/src/session/types.rs`, published - * as `isSubagentItem` / `subagentSessionId` — and every mobile client maps that - * field onto the subagent marker (`DurableSessionReducer.ets:157`), so the - * owner Task arrives marked exactly like the records it owns. + * The host records the child Session id on the launching tool item — the + * durable contract is `subagent_session_id` on the item data in + * `src/crates/services/services-core/src/session/types.rs`, published as + * `subagentSessionId` — and the launching tool is `AgentSpawn` in an Ultimate + * session. Nothing else on the parent item says a child exists, and the child's + * own records never say which launch they belong to. */ -function ownerTaskRecord(revision) { - return record(revision, TASK_CALL_ID, 'tool', 0, { - toolName: 'Task', - toolCall: { id: TASK_CALL_ID, input: { description: 'Audit the payment module', subagent_type: 'Explore' } }, +function ownerRecord(revision, sessionId = CHILD_SESSION, toolName = 'AgentSpawn', toolId = SWARM_CALL_ID) { + return record(revision, toolId, 'tool', 0, { + toolName, + toolCall: { id: toolId, input: { agent_id: 'swarm-audit', agent_type: 'Explore', prompt: 'Audit it' } }, + toolResult: { result: 'agent_id bg_task_id status\nswarm-audit bg-7f21c9 started', success: true }, startTime: 3, - status: 'running', - subagentSessionId: CHILD_SESSION + status: 'completed', + subagentSessionId: sessionId }); } -function childThinkingRecord(revision) { - return record(revision, 'child-think', 'thinking', 1, { - content: 'Reading the payment module', subagentSessionId: CHILD_SESSION - }); +/** Unmarked process records of a child session, as that session's own stream publishes them. */ +function childThinkingRecord(revision, sessionId = CHILD_SESSION) { + return record(revision, 'child-think', 'thinking', 1, { content: 'Reading the payment module' }, sessionId); } -function childToolRecord(revision) { - return record(revision, 'child-tool', 'tool', 2, { +function childToolRecord(revision, sessionId = CHILD_SESSION) { + return record(revision, 'child-read', 'tool', 2, { toolName: 'Read', - toolCall: { id: 'child-read', input: { path: 'src/payments.ts' } }, + toolCall: { id: 'child-read', input: { file_path: 'src/payments.ts' } }, + toolResult: { result: 'export const charge = 1', success: true }, startTime: 4, - status: 'completed', + status: 'completed' + }, sessionId); +} + +function childTextRecord(revision, sessionId = CHILD_SESSION) { + return record(revision, 'child-text', 'text', 3, { content: 'Found no hard-coded keys' }, sessionId); +} + +/** A launch item as the host records it, at the status that revision carried. */ +function launchRecord(revision, status) { + return record(revision, SWARM_CALL_ID, 'tool', 0, { + toolName: 'AgentSpawn', + toolCall: { id: SWARM_CALL_ID, input: { agent_id: 'swarm-audit', agent_type: 'Explore', prompt: 'Audit it' } }, + startTime: 3, + status, subagentSessionId: CHILD_SESSION }); } -function childTextRecord(revision) { - return record(revision, 'child-text', 'text', 3, { - content: 'Found no hard-coded keys', subagentSessionId: CHILD_SESSION - }); +/** The child's own records, in the order its stream publishes them. */ +function childRecords() { + return [childThinkingRecord(1), childToolRecord(2), childTextRecord(3)]; } -/** Every record a running Task publishes, in host order. */ -function runningTaskRecords() { - return [ - ownerTaskRecord(1), - childThinkingRecord(2), - childToolRecord(3), - childTextRecord(4) - ]; +/** + * A coordinator over a stub stream per child session, plus the callbacks it + * registered, so a test can deliver child records as the host would. + */ +function coordinatorHarness() { + const opened = new Map(); + const closed = []; + const errors = []; + let notifications = 0; + const coordinator = new SubagentStreamCoordinator({ + subscribeSession: (sessionId, callbacks) => { + opened.set(sessionId, callbacks); + return { close: () => { closed.push(sessionId); }, wake: () => {}, loadOlder: () => {}, isClosed: () => false }; + } + }, () => { notifications++; }, error => { errors.push(error); }); + return { + coordinator, opened, closed, errors, + notifications: () => notifications + }; +} + +/** A parent transcript holding one delegation item, as the reducer projects it. */ +function parentMessages(records) { + const reducer = new DurableSessionReducer(); + records.forEach(event => reducer.apply(event)); + return reducer.messages(); } -async function timelineHarness() { +/** The assistant row's items of a transcript built from `records`. */ +function parentItems(records) { + const assistant = parentMessages(records).find(message => message.role === 'assistant'); + assert.ok(assistant, 'the transcript holds an assistant row'); + return ui.toConversationUiMessage(assistant).items || []; +} + +async function until(predicate, label, attempts = 400) { + for (let index = 0; index < attempts; index++) { + if (predicate()) return; + await new Promise(resolve => setTimeout(resolve, 1)); + } + assert.fail(`timed out waiting for ${label}`); +} + +/** + * The production chain the live assistant row walks: the session's records -> + * controller -> durable reducer -> timeline store -> projector -> keyed V2 rows + * -> conversation UI DTO -> grouped render model. Returns the Task cards the + * bubble would draw, or undefined while the session has not published yet. + */ +function projectCards(timeline) { + const rows = new ChatTimelineRowStore().reconcile(timeline.project(false)); + const liveRow = rows.find(row => row.type === 'assistant_live_turn'); + if (!liveRow) return undefined; + const message = ui.toConversationUiMessage(liveRow.message); + const groups = Policy.structuredGroups(message.items || [], 'item', (message.status || '') === 'active', + message.status || 'active'); + const items = groups.flatMap(group => group.type === 'item' ? group.items : []); + const tools = groups.flatMap(group => group.tools || []); + const cards = items.filter(entry => Policy.isSubagentEntry(entry)); + return { message, groups, items, tools, cards }; +} + +function cardsFromTimeline(timeline) { + const projected = projectCards(timeline); + assert.ok(projected, 'the running turn is projected as a live assistant row'); + return projected; +} + +function cardCount(timeline) { + const projected = projectCards(timeline); + return projected ? projected.cards.length : -1; +} + +/** The live swarm fixture read through the production controller. */ +async function swarmHarness() { + const ledger = new fixture.SwarmLedger(); + const script = new fixture.SwarmScript(ledger); const timeline = new ChatTimelineStore(); timeline.reset(SESSION); - let stream; + const errors = []; + const subscribed = []; const controller = new ChatSessionController({ getModelCatalog: async () => ({ version: 1, models: [], default_models: {} }), - subscribeSession: (_id, callbacks) => { - stream = callbacks; - return { wake() {}, close() {} }; + subscribeSession: (sessionId, callbacks) => { + subscribed.push(sessionId); + return ledger.openStream(sessionId, callbacks); } }, { onSnapshot: snapshot => timeline.applySnapshot(snapshot), canPoll: () => true, - onError: error => { throw error; } - }); + onError: error => { errors.push(error); } + }, { initialPollDelayMs: 0 }); + // The turn's opening records exist before the session is read, the way a + // history page already holds everything the reader has produced so far. + script.advance(); controller.start(SESSION, { pollVersion: 0, knownMessageCount: 0, knownModelCatalogVersion: 0 }); - await stream.onCaughtUp(); - for (const event of runningTaskRecords()) await stream.onEvent(event); - return { timeline, stream, controller }; + await until(() => cardCount(timeline) === 0, 'the parent session to be read'); + return { ledger, script, timeline, controller, errors, subscribed }; } -/** - * The production chain the live assistant row walks: durable records -> timeline - * store -> projector -> keyed V2 rows -> conversation UI DTO -> grouped render - * model. Returns the Task card the bubble would draw. - */ -function taskCardFromActiveTurn(timeline) { - const rows = new ChatTimelineRowStore().reconcile(timeline.project(false)); - const liveRow = rows.find(row => row.type === 'assistant_live_turn'); - assert.ok(liveRow, 'the running turn is projected as a live assistant row'); - const message = ui.toConversationUiMessage(liveRow.message); - const groups = Policy.structuredGroups(message.items || [], 'item', true, message.status || 'active'); - const cards = groups - .filter(group => group.type === 'item' && group.items.length === 1) - .map(group => group.items[0]) - .filter(entry => entry.tool && entry.tool.name === 'Task'); - assert.equal(cards.length, 1, 'exactly one Task card is drawn for one Task tool call'); - return { message, card: cards[0], groups }; +/** Publishes every beat of the fixture up to and including `lastBeat`. */ +function beatThrough(script, lastBeat) { + while (script.position() <= lastBeat) { + assert.equal(script.advance(), true, `the fixture has beat ${lastBeat}`); + } } -test('a running Task card receives the subagent process published flat behind it', async () => { - const { timeline, controller } = await timelineHarness(); - const { card } = taskCardFromActiveTurn(timeline); +test('a launch item that names a child session receives that child stream as its branch', async () => { + const harness = await swarmHarness(); + const { script, timeline, subscribed, controller } = harness; + try { + script.advance(); // launch the payments worker, which had already finished + await until(() => (projectCards(timeline)?.cards[0]?.subItems || []).length === 6, + "the child's own page to land in the branch"); + + const { cards, message } = cardsFromTimeline(timeline); + const card = cards[0]; + assert.equal(card.tool.name, 'AgentSpawn', 'the launching item is the card'); + assert.deepEqual((card.subItems || []).map(child => child.type), + ['thinking', 'tool', 'tool', 'thinking', 'tool', 'text'], + "the branch holds the child's own items in the child's own order"); + assert.equal(card.subItems[0].content, 'Locate every payment entry point before reading any of them.'); + assert.equal(card.subItems[1].tool.name, 'Grep'); + assert.equal(card.subItems[5].content.indexOf('Reported two defects'), 0); + assert.equal(message.text.indexOf('I will run three workers'), 0, + "the parent's own text is the answer, not the worker's report"); + + assert.ok(subscribed.includes(CHILD_SESSION), 'the child session was read as its own stream'); + assert.deepEqual(harness.errors, [], 'no stream failed'); + } finally { + controller.stop(); + } +}); - assert.equal((card.subItems || []).length, 3, 'thinking, tool and text are Task children'); - assert.deepEqual((card.subItems || []).map(child => child.type), ['thinking', 'tool', 'text']); - assert.equal(card.subItems[0].content, 'Reading the payment module'); - assert.equal(card.subItems[1].tool.name, 'Read'); - assert.equal(card.subItems[2].content, 'Found no hard-coded keys'); - controller.stop(); +test('the branch is grouped by the subscription it arrived on, not by a marker on the record', async () => { + const harness = await swarmHarness(); + const { script, timeline, controller } = harness; + try { + script.advance(); + await until(() => (projectCards(timeline)?.cards[0]?.subItems || []).length === 6, 'the branch'); + const card = cardsFromTimeline(timeline).cards[0]; + assert.ok(card.subItems.length > 0, 'the branch has the child process'); + assert.ok(card.subItems.every(child => child.is_subagent !== true), + 'and it does: the child records are unmarked, exactly as the host publishes them'); + assert.ok(card.subItems.every(child => !child.subItems || child.subItems.length === 0), + 'no child item is nested in another'); + } finally { + controller.stop(); + } }); -test('the subagent process never leaks into the parent transcript as sibling rows', async () => { - const { timeline, controller } = await timelineHarness(); - const { message, groups } = taskCardFromActiveTurn(timeline); - - const siblingTypes = groups.flatMap(group => group.items.concat(group.tools.map(tool => ({ type: 'tool', tool })))) - .filter(entry => entry.type !== 'tool' || !entry.tool || entry.tool.name !== 'Task') - .map(entry => entry.type); - assert.deepEqual(siblingTypes, [], 'only the Task card represents the running subagent'); - assert.equal(message.text, '', 'the subagent answer is not the parent answer'); - assert.equal(message.thinking, '', 'the subagent reasoning is not the parent reasoning'); - controller.stop(); +test("the parent's own records never become a card's process", async () => { + const harness = await swarmHarness(); + const { script, timeline, controller } = harness; + try { + beatThrough(script, 8); // launches, AgentWait and ordinary parent rows + await until(() => cardCount(timeline) === 3, 'three launches to be read'); + + const { items, tools, cards } = cardsFromTimeline(timeline); + const parentTools = tools.concat(items.filter(entry => entry.tool).map(entry => entry.tool)) + .filter(tool => tool.name !== 'AgentSpawn' && tool.name !== 'Task'); + assert.deepEqual(parentTools.map(tool => tool.name), ['AgentWait', 'Grep', 'Read'], + "the parent's own rows stay sibling rows"); + cards.forEach(card => { + const ids = (card.subItems || []).map(child => child.tool?.id || child.type); + assert.ok(ids.indexOf('swarm-wait') < 0 && ids.indexOf('swarm-parent-grep') < 0, + "the parent's own rows are not drawn inside a branch"); + }); + } finally { + controller.stop(); + } }); -test('the subagent process stays visible after the turn completes', async () => { - const { timeline, stream, controller } = await timelineHarness(); - const finished = ownerTaskRecord(5); - finished.payload.item.data.status = 'completed'; - finished.payload.turn.status = 'completed'; - await stream.onEvent(finished); - - const assistant = timeline.snapshot().persistedMessages.find(row => row.role === 'assistant'); - assert.ok(assistant, 'the completed turn is persisted'); - const message = ui.toConversationUiMessage(assistant); - const groups = Policy.structuredGroups(message.items || [], 'item', false, message.status || 'done'); - const card = groups.map(group => group.items[0]).find(entry => entry.tool && entry.tool.name === 'Task'); - assert.ok(card, 'the Task card survives completion'); - assert.equal((card.subItems || []).length, 3, 'its process stays under it'); - controller.stop(); +test('a worker that is still running keeps publishing into its own card', async () => { + const harness = await swarmHarness(); + const { script, timeline, controller } = harness; + try { + beatThrough(script, 3); // the second worker is launched, still silent + await until(() => cardCount(timeline) === 3, 'three launches'); + const docsCard = () => cardsFromTimeline(timeline).cards.find(card => card.tool.id === 'swarm-docs-spawn'); + assert.equal((docsCard().subItems || []).length, 0, 'a launch is drawn before its worker says anything'); + + beatThrough(script, 4); + await until(() => (docsCard().subItems || []).length === 1, "the worker's first step"); + const firstKey = cardsFromTimeline(timeline).groups.map(group => group.key).join('|'); + + beatThrough(script, 6); + await until(() => (docsCard().subItems || []).length === 2, 'the worker to publish more steps'); + assert.deepEqual((docsCard().subItems || []).map(child => child.type), ['thinking', 'tool']); + assert.equal(cardsFromTimeline(timeline).groups.map(group => group.key).join('|'), firstKey, + 'an arriving step must not recreate the card node that is already open'); + + beatThrough(script, 12); + await until(() => (docsCard().subItems || []).length === 5, 'the worker to keep streaming'); + assert.deepEqual((docsCard().subItems || []).map(child => child.tool?.id || child.type), + ['thinking', 'swarm-docs-search', 'thinking', 'swarm-docs-links', 'swarm-docs-read'], + 'and its card still holds one row per step it published'); + assert.equal(harness.errors.length, 0, 'no stream failed while its card streamed'); + } finally { + controller.stop(); + } }); -test('an owner Task marked as a subagent is still the branch owner', () => { - const task = { - type: 'tool', - is_subagent: true, - tool: { id: TASK_CALL_ID, name: 'Task', status: 'running', tool_input: { description: 'Audit' } } - }; - const children = [ - { type: 'thinking', is_subagent: true, content: 'child reasoning' }, - { type: 'tool', is_subagent: true, tool: { id: 'child-read', name: 'Read', status: 'running' } }, - { type: 'text', is_subagent: true, content: 'child output' } - ]; - const scoped = Policy.scopeSubagentItems([task].concat(children)); - assert.equal(scoped.length, 1, 'the marked children fold into the marked owner Task'); - assert.equal((scoped[0].subItems || []).length, 3); +test('a launch item without a child session id is an ordinary tool row', () => { + const bare = ownerRecord(1); + delete bare.payload.item.data.subagentSessionId; + const items = parentItems([bare]); + const groups = Policy.structuredGroups(items, 'item', true, 'active'); + + assert.deepEqual(groups.map(group => group.type), ['tool_group'], + 'nothing claims it, so it is drawn as a tool row'); + assert.equal(groups[0].tools[0].name, 'AgentSpawn'); + assert.equal(groups[0].tools[0].tool_input.prompt, 'Audit it', + 'with the brief it was given, exactly as an older host draws it'); + assert.equal(Policy.scopeSubagentItems(items).length, 1, 'and no branch is opened for it'); +}); + +test('a launch item without a child session id opens no branch and no stream', () => { + const harness = coordinatorHarness(); + const { coordinator, opened } = harness; + coordinator.beginSession(SESSION); + const bare = ownerRecord(1); + delete bare.payload.item.data.subagentSessionId; + coordinator.observeParentEvent(bare); + assert.equal(opened.size, 0, 'an older host names no child session, so nothing is followed'); + + const decorated = coordinator.decorate(parentMessages([bare])); + const groups = Policy.structuredGroups(ui.toConversationUiMessage(decorated[1]).items || [], + 'item', true, 'active'); + assert.deepEqual(groups.map(group => group.type), ['tool_group'], + 'and the launch stays an ordinary tool row'); + assert.equal(groups[0].tools[0].name, 'AgentSpawn'); + coordinator.endSession(); +}); + +test('a marked launch item still opens its branch and is never swallowed by another', () => { + const owner = { type: 'tool', is_subagent: true, tool: { id: SWARM_CALL_ID, name: 'AgentSpawn', status: 'completed' } }; + const groups = Policy.structuredGroups([owner], 'item', true, 'active'); + assert.equal(groups.length, 1, 'the launch is drawn'); + assert.equal(groups[0].items[0].tool.name, 'AgentSpawn', + 'as a branch owner rather than as an internal tool of another branch'); + assert.equal(Policy.isSubagentEntry(groups[0].items[0]), true); }); -test('every Task name variant the host can send opens the branch', () => { - for (const name of ['Task', 'task', 'TASK', ' Task ', 'Task']) { - const task = { type: 'tool', tool: { id: 'call', name, status: 'running' } }; +test('an unmarked launch item keeps the parent rows that follow it out of its branch', () => { + // Older desktops ran a Task subagent inline and marked nothing; AgentSpawn + // never worked that way, so the records after it are the parent's own work. + const swarm = { type: 'tool', tool: { id: 'swarm', name: 'AgentSpawn', status: 'running' } }; + const read = { type: 'tool', tool: { id: 'parent-read', name: 'Read', status: 'completed' } }; + const scoped = Policy.scopeSubagentItems([swarm, read]); + assert.equal(scoped.length, 2, 'the parent row stays a sibling row'); + assert.equal((scoped[0].subItems || []).length, 0); + + const task = { type: 'tool', tool: { id: 'task', name: 'Task', status: 'running' } }; + const legacy = Policy.scopeSubagentItems([task, read]); + assert.equal(legacy.length, 1, 'the legacy Task fallback is unchanged'); + assert.equal((legacy[0].subItems || []).length, 1); +}); + +test('every owner name the host can send opens the branch', () => { + for (const name of ['Task', 'task', 'TASK', ' Task ', 'AgentSpawn', 'agentspawn', 'AgentSpawn']) { + const owner = { type: 'tool', tool: { id: 'call', name, status: 'running' } }; const child = { type: 'text', is_subagent: true, content: 'child output' }; - const scoped = Policy.scopeSubagentItems([task, child]); - assert.equal((scoped[0].subItems || []).length, 1, `Task name ${JSON.stringify(name)} owns the child`); + const scoped = Policy.scopeSubagentItems([owner, child]); + assert.equal((scoped[0].subItems || []).length, 1, `owner name ${JSON.stringify(name)} owns the child`); } }); -test('a marked record that arrives before its Task is not lost', () => { +test('a card read from a decorated parent message carries the child branch', () => { + const harness = coordinatorHarness(); + const { coordinator, opened } = harness; + coordinator.beginSession(SESSION); + coordinator.observeParentEvent(ownerRecord(1)); + assert.ok(opened.has(CHILD_SESSION), 'the child session is followed once its launch is read'); + + const callbacks = opened.get(CHILD_SESSION); + childRecords().forEach(event => callbacks.onEvent(event)); + callbacks.onCaughtUp(); + + const decorated = coordinator.decorate(parentMessages([ownerRecord(1)])); + const card = Policy.subagentBranchAt(ui.toConversationUiMessage(decorated[1]).items || [], 0, SWARM_CALL_ID); + assert.ok(card, 'the branch is found by the launch tool call'); + assert.deepEqual((card.subItems || []).map(child => child.type), ['thinking', 'tool', 'text']); + assert.ok(harness.notifications() > 0, 'the parent is republished when the branch changes'); + coordinator.endSession(); + assert.ok(harness.closed.includes(CHILD_SESSION), 'and the child stream is closed with the session'); +}); + +test('the same child record published twice adds one step', () => { + const harness = coordinatorHarness(); + const { coordinator, opened } = harness; + coordinator.beginSession(SESSION); + coordinator.observeParentEvent(ownerRecord(1)); + const callbacks = opened.get(CHILD_SESSION); + const [thinking, tool, text] = childRecords(); + + callbacks.onEvent(thinking); + callbacks.onCaughtUp(); + callbacks.onEvent(thinking); // the host replayed a page the reader already had + callbacks.onEvent(tool); + callbacks.onEvent(text); + + const decorated = coordinator.decorate(parentMessages([ownerRecord(1)])); + const card = Policy.subagentBranchAt(ui.toConversationUiMessage(decorated[1]).items || [], 0, SWARM_CALL_ID); + assert.deepEqual((card.subItems || []).map(child => child.type), ['thinking', 'tool', 'text'], + 'identity and revision decide, so a replayed record is not a second step'); + + // A newer revision of an existing step updates it in place. + const updated = childToolRecord(9); + updated.payload.item.data.status = 'failed'; + updated.payload.item.data.toolResult = { result: '', success: false, error: 'permission denied' }; + callbacks.onEvent(updated); + const after = coordinator.decorate(parentMessages([ownerRecord(1)])); + const updatedCard = Policy.subagentBranchAt(ui.toConversationUiMessage(after[1]).items || [], 0, SWARM_CALL_ID); + assert.equal((updatedCard.subItems || []).length, 3, 'an updated step replaces the one it updates'); + assert.equal(updatedCard.subItems[1].tool.status, 'failed'); + coordinator.endSession(); +}); + +test('a settled launch repaints its card even when its worker says nothing new', () => { + const harness = coordinatorHarness(); + const { coordinator, opened } = harness; + coordinator.beginSession(SESSION); + coordinator.observeParentEvent(launchRecord(1, 'running')); + const callbacks = opened.get(CHILD_SESSION); + callbacks.onEvent(childThinkingRecord(1)); + callbacks.onCaughtUp(); + + const running = coordinator.decorate(parentMessages([launchRecord(1, 'running')])); + const runningCard = Policy.subagentBranchAt(ui.toConversationUiMessage(running[1]).items || [], 0, SWARM_CALL_ID); + assert.equal(runningCard.tool.status, 'running', 'the launch is drawn at the status it was recorded with'); + assert.equal((runningCard.subItems || []).length, 1, 'with the one step its worker has published'); + + // The launch settles. Its worker publishes no further step, so the launching + // item's own record is the only thing that changed. + const settled = coordinator.decorate(parentMessages([launchRecord(1, 'running'), launchRecord(2, 'completed')])); + const settledCard = Policy.subagentBranchAt(ui.toConversationUiMessage(settled[1]).items || [], 0, SWARM_CALL_ID); + assert.equal(settledCard.tool.status, 'completed'); + assert.equal((settledCard.subItems || []).length, 1, 'and its worker keeps the branch it already had'); + assert.ok(settled[1].renderVersion > running[1].renderVersion, + 'the card is repainted: a tool item status alone is not part of the row key'); + + // A publication that changes nothing is stable: the same version and the same + // attached branch, or the row would be rebuilt from a message without it. + const repeated = coordinator.decorate(parentMessages([launchRecord(1, 'running'), launchRecord(2, 'completed')])); + assert.equal(repeated[1].renderVersion, settled[1].renderVersion); + assert.equal(((ui.toConversationUiMessage(repeated[1]).items || [])[0].subItems || []).length, 1); + coordinator.endSession(); +}); + +test('a host that publishes a child inside the parent stream is not drawn twice', () => { + const harness = coordinatorHarness(); + const { coordinator, opened } = harness; + coordinator.beginSession(SESSION); + coordinator.observeParentEvent(ownerRecord(1)); + const callbacks = opened.get(CHILD_SESSION); + childRecords().forEach(event => callbacks.onEvent(event)); + callbacks.onCaughtUp(); + + // The same worker's records, restated flat behind the launch and marked, as + // hosts that inline a subagent's process publish them. + const inline = record(4, 'child-read', 'tool', 2, { + toolName: 'Read', toolCall: { id: 'child-read', input: { file_path: 'src/payments.ts' } }, + startTime: 4, status: 'completed', isSubagentItem: true, subagentSessionId: CHILD_SESSION + }); + const decorated = coordinator.decorate(parentMessages([ownerRecord(1), inline])); + const items = ui.toConversationUiMessage(decorated[1]).items || []; + assert.equal((items[0].subItems || []).length, 0, + 'the branch the parent stream already carries is not restated from the child stream'); + + const scoped = Policy.scopeSubagentItems(items); + assert.equal(scoped.length, 1, 'and the parent stream folds that record into the same card'); + assert.equal((scoped[0].subItems || []).length, 1); + assert.equal(scoped[0].subItems[0].tool.id, 'child-read', 'the one record the parent stream sent'); + coordinator.endSession(); +}); + +test('switching sessions follows the new one and drops the old', () => { + const harness = coordinatorHarness(); + const { coordinator, opened } = harness; + coordinator.beginSession(SESSION); + coordinator.observeParentEvent(ownerRecord(1)); + assert.equal(opened.size, 1); + + coordinator.beginSession('another-session'); + assert.ok(harness.closed.includes(CHILD_SESSION), 'the previous session\'s child stream is closed'); + const other = ownerRecord(1, 'other-child', 'AgentSpawn', 'other-call'); + coordinator.observeParentEvent(other); + assert.ok(opened.has('other-child'), 'and the new session\'s children are followed'); + assert.equal(opened.has(CHILD_SESSION), true, 'only the stream itself was closed'); + coordinator.endSession(); + assert.ok(harness.closed.includes('other-child')); +}); + +test('the swarm fixture publishes the live record shapes, not a nested invention', () => { + const ledger = new fixture.SwarmLedger(); + const script = new fixture.SwarmScript(ledger); + script.drain(); + + const parent = ledger.page(fixture.SWARM_PARENT_SESSION, {}).events.map(entry => entry.payload); + // A launch item is republished as its call settles, so read its latest revision. + const byCall = new Map(); + parent.filter(entry => entry.item && entry.item.data.toolName === 'AgentSpawn') + .forEach(entry => byCall.set(entry.item.data.id, entry)); + const launches = Array.from(byCall.values()); + assert.equal(launches.length, 3, 'the parent launches three workers'); + const childSessions = launches.map(entry => entry.item.data.subagentSessionId); + assert.deepEqual(childSessions, + [fixture.SWARM_PAYMENTS_SESSION, fixture.SWARM_FLAKY_SESSION, fixture.SWARM_DOCS_SESSION], + 'each launch names the session its worker runs in'); + launches.forEach(launch => { + assert.deepEqual(Object.keys(launch.item.data.toolCall.input).sort(), ['agent_id', 'agent_type', 'prompt'], + 'with the live input shape, brief and all'); + const table = launch.item.data.toolResult.result; + assert.match(table, /^agent_id\s+bg_task_id\s+status/, + 'and a result table of the columns the host answers with'); + assert.ok(table.includes(launch.item.data.toolCall.input.agent_id), + 'naming the worker it started'); + }); + const settled = launches.filter(launch => launch.item.data.status === 'completed'); + assert.equal(settled.length, 2, 'two launches settled with a running worker behind them'); + settled.forEach(launch => { + assert.ok(launch.item.data.toolResult.result.includes('started'), + 'and report the status the host reports for a background worker'); + }); + const failed = launches.filter(launch => launch.item.data.status === 'error'); + assert.equal(failed.length, 1, 'one launch failed, which is what the card colours for'); + assert.equal(failed[0].item.data.toolResult.success, false); + + const parentText = JSON.stringify(parent); + assert.equal(parentText.includes('"isSubagentItem"'), false, + 'no parent record marks a child: the child is not in this stream at all'); + assert.equal(parentText.includes('"subItems"'), false, 'and nothing is nested inside a parent item'); + + childSessions.forEach(sessionId => { + const events = ledger.page(sessionId, {}).events; + assert.ok(events.length > 0, `the child session ${sessionId} publishes its own records`); + const payloads = events.map(entry => entry.payload); + assert.ok(payloads.every(entry => entry.sessionId === sessionId), 'on its own session id'); + assert.equal(JSON.stringify(payloads).includes('subagentSessionId'), false, + 'with no subagent marker on any child record'); + assert.ok(payloads.some(entry => entry.item && entry.item.data.subagentSessionId === sessionId) === false, + 'and no child record claims a child of its own'); + const names = payloads.filter(entry => entry.item && entry.item.type === 'tool') + .map(entry => entry.item.data.toolName); + assert.ok(names.every(name => ['Grep', 'Read', 'ExecCommand'].includes(name)), + 'the child uses the live tool names'); + }); +}); + +test('the swarm fixture streams one worker a page at a time', () => { + const ledger = new fixture.SwarmLedger(); + const script = new fixture.SwarmScript(ledger); + const docsItems = () => ledger.page(fixture.SWARM_DOCS_SESSION, {}).events + .filter(entry => entry.payload.item).length; + + script.advance(); // opening + script.advance(); // first worker + its history + script.advance(); // second worker + script.advance(); // its launch + const before = docsItems(); + script.advance(); + const after = docsItems(); + assert.ok(after > before, 'a running worker publishes its steps after its launch, not with it'); + + const running = ledger.page(fixture.SWARM_DOCS_SESSION, {}) + .events.filter(entry => entry.payload.item && entry.payload.item.data.status === 'running'); + assert.equal(running.length, 0, 'and the fixture has not finished every step it will publish'); + script.drain(); + const settled = ledger.page(fixture.SWARM_DOCS_SESSION, {}) + .events.filter(entry => entry.payload.item && entry.payload.item.data.status === 'running'); + assert.ok(settled.length > 0, 'the last step a live worker published is still in progress'); +}); + +test('a marked record that arrives before its launch is not lost', () => { const orphan = { type: 'thinking', is_subagent: true, content: 'first child reasoning' }; - const task = { type: 'tool', is_subagent: true, tool: { id: TASK_CALL_ID, name: 'Task', status: 'running' } }; + const owner = { type: 'tool', is_subagent: true, tool: { id: SWARM_CALL_ID, name: 'AgentSpawn', status: 'running' } }; const later = { type: 'text', is_subagent: true, content: 'later child output' }; - const scoped = Policy.scopeSubagentItems([orphan, task, later]); + const scoped = Policy.scopeSubagentItems([orphan, owner, later]); assert.equal(scoped.length, 2, 'the early record keeps its own branch'); const orphanBranch = scoped[0]; assert.equal(orphanBranch.type, 'thinking'); assert.equal((orphanBranch.subItems || []).length, 1, 'its content survives as a child, not as parent output'); assert.equal(orphanBranch.subItems[0].is_subagent, false, 'the inner copy renders as subagent process'); - assert.equal(scoped[1].tool.name, 'Task'); - assert.equal((scoped[1].subItems || []).length, 1, 'the later record still joins its Task'); + assert.equal(scoped[1].tool.name, 'AgentSpawn'); + assert.equal((scoped[1].subItems || []).length, 1, 'the later record still joins its launch'); }); test('an orphaned subagent tool still renders inside its own branch', () => { @@ -260,61 +639,61 @@ test('an orphaned subagent record labels its branch without repeating its own te assert.equal(toolBranch.subItems[0].tool.name, 'Read', 'and its step is the tool row'); }); -test('an unmarked Task with flat marked children is unchanged', () => { - const task = { type: 'tool', tool: { id: TASK_CALL_ID, name: 'Task', status: 'running' } }; +test('an unmarked launch with flat marked children is unchanged', () => { + const owner = { type: 'tool', tool: { id: SWARM_CALL_ID, name: 'AgentSpawn', status: 'running' } }; const children = [ { type: 'thinking', is_subagent: true, content: 'child reasoning' }, { type: 'tool', is_subagent: true, tool: { id: 'child-read', name: 'Read', status: 'completed' } } ]; - const scoped = Policy.scopeSubagentItems([task].concat(children)); + const scoped = Policy.scopeSubagentItems([owner].concat(children)); assert.equal(scoped.length, 1); assert.equal((scoped[0].subItems || []).length, 2); }); /** - * The branch view key is what ArkUI keys the Task card node by, and an unchanged - * key never re-runs the item builder. The card therefore cannot rely on a - * rebuild to receive a step that arrives later: it re-reads its branch from the - * observed message on every render. These tests pin that contract, so a future - * "fix" that instead churns the key (and so destroys an open branch on every - * streamed step) fails here. + * The branch view key is what ArkUI keys the card node by, and an unchanged key + * never re-runs the item builder. The card therefore cannot rely on a rebuild to + * receive a step that arrives later: it re-reads its branch from the observed + * message on every render. These tests pin that contract, so a future "fix" that + * instead churns the key (and so destroys an open branch on every streamed step) + * fails here. */ -test('the Task branch view key stays stable while its subagent publishes steps', () => { - const task = { type: 'tool', tool: { id: TASK_CALL_ID, name: 'Task', status: 'running' } }; +test('the branch view key stays stable while its child publishes steps', () => { + const owner = { type: 'tool', is_subagent: true, tool: { id: SWARM_CALL_ID, name: 'AgentSpawn', status: 'running' } }; const child = { type: 'thinking', is_subagent: true, content: 'child reasoning' }; const branchKeys = items => Policy.structuredGroups(Policy.scopeSubagentItems(items), 'item', true, 'active') .map(group => group.key).join('|'); - assert.equal(branchKeys([task, child]), branchKeys([task]), + assert.equal(branchKeys([owner, child]), branchKeys([owner]), 'an arriving step must not recreate the branch node that is already open'); }); test('a card re-reads its branch, so a later step reaches the open card', () => { - const owner = { type: 'tool', is_subagent: true, tool: { id: TASK_CALL_ID, name: 'Task', status: 'running' } }; + const owner = { type: 'tool', is_subagent: true, tool: { id: SWARM_CALL_ID, name: 'AgentSpawn', status: 'running' } }; const firstStep = { type: 'thinking', is_subagent: true, content: 'step one' }; const secondStep = { type: 'tool', is_subagent: true, tool: { id: 'child-read', name: 'Read', status: 'running' } }; - // What the frozen item builder captured when the Task card was first drawn. + // What the frozen item builder captured when the card was first drawn. const captured = Policy.structuredGroups([owner], 'item', true, 'active')[0].items[0]; - assert.equal((captured.subItems || []).length, 0, 'a Task card is born with no process items'); + assert.equal((captured.subItems || []).length, 0, 'a card is born with no process items'); // What the card reads on every later render, from the transcript as it is now. - const first = Policy.subagentBranchAt([owner, firstStep], 0, TASK_CALL_ID); + const first = Policy.subagentBranchAt([owner, firstStep], 0, SWARM_CALL_ID); assert.equal((first.subItems || []).length, 1, 'the first step is visible'); - const second = Policy.subagentBranchAt([owner, firstStep, secondStep], 0, TASK_CALL_ID); + const second = Policy.subagentBranchAt([owner, firstStep, secondStep], 0, SWARM_CALL_ID); assert.equal((second.subItems || []).length, 2, 'a step published later is visible too'); assert.equal(second.subItems[1].tool.name, 'Read'); assert.notEqual(second, captured, 'the stale captured snapshot is not what the card draws'); }); -test('a branch is found by its Task tool call and by its render ordinal', () => { - const owner = { type: 'tool', is_subagent: true, tool: { id: TASK_CALL_ID, name: 'Task', status: 'running' } }; +test('a branch is found by its launch tool call and by its render ordinal', () => { + const owner = { type: 'tool', is_subagent: true, tool: { id: SWARM_CALL_ID, name: 'AgentSpawn', status: 'running' } }; const child = { type: 'text', is_subagent: true, content: 'child output' }; - assert.equal(Policy.subagentBranchAt([owner, child], 0, TASK_CALL_ID).tool.id, TASK_CALL_ID); - assert.equal(Policy.subagentBranchAt([owner, child], 0, '').tool.id, TASK_CALL_ID, + assert.equal(Policy.subagentBranchAt([owner, child], 0, SWARM_CALL_ID).tool.id, SWARM_CALL_ID); + assert.equal(Policy.subagentBranchAt([owner, child], 0, '').tool.id, SWARM_CALL_ID, 'the ordinal identifies the branch when the host sent no tool call id'); - assert.equal(Policy.subagentBranchAt([owner, child], 1, TASK_CALL_ID).tool.id, TASK_CALL_ID, + assert.equal(Policy.subagentBranchAt([owner, child], 1, SWARM_CALL_ID).tool.id, SWARM_CALL_ID, 'an unknown ordinal still resolves through the owning tool call'); assert.equal(Policy.subagentBranchAt([owner, child], 3, ''), undefined, 'an ordinal with no branch resolves to nothing, so the card keeps its snapshot'); @@ -327,7 +706,7 @@ test('a branch path reports the ordinal the render path numbered it with', () => assert.equal(Policy.subagentOrdinal('item-tool-0'), 0); }); -test('the Task card is fed the branch re-read from the observed message', () => { +test('the card is fed the branch re-read from the observed message', () => { const card = fs.readFileSync(path.join(ROOT, 'pages/components/SubagentTaskCard.ets'), 'utf8'); assert.match(card, /@Param items: ConversationUiMessageItem\[\]/, 'the card takes the branch process items as its param'); @@ -338,3 +717,15 @@ test('the Task card is fed the branch re-read from the observed message', () => assert.match(bubble, /ChatMessageStructurePolicy\.subagentBranchAt\(/, 'the re-resolution goes through the shared policy'); }); + +test('the controller reads the launch record for the child session it names', () => { + const controller = fs.readFileSync(path.join(ROOT, 'services/ChatSessionController.ets'), 'utf8'); + assert.match(controller, /this\.subagents\.observeParentEvent\(event\)/, + 'the parent stream is where a child session is discovered'); + assert.match(controller, /this\.subagents\.decorate\(this\.reducer\.messages\(\)\)/, + 'and the branch is attached on the one path that publishes the transcript'); + assert.match(controller, /this\.subagents\.beginSession\(sessionId\)/, + 'a session switch rebinds the follow set'); + assert.match(controller, /this\.subagents\.endSession\(\)/, + 'and closing the session closes the streams it opened'); +}); From d90a450b4980cb071c3d2c0ea92aada403665373 Mon Sep 17 00:00:00 2001 From: Tant Date: Fri, 9 Oct 2026 15:51:52 +0800 Subject: [PATCH 5/5] fix(mobile): subagent card expansion, live status and stream durability - keep branch expansion across row rebuilds via a keyed observed disclosure state instead of the card's local boolean - derive the card status from the child stream (worker lifecycle), not the launch record the host completes at spawn time; fall back to the launch status when the subscription failed before data - cap concurrent child streams at 8 with steal-oldest/waiters and lazy re-attach on expand; persist child branch transcripts through the phone transcript cache so a cold start restores the steps, and re-attach children named again by a later launch --- .../main/ets/entryability/EntryAbility.ets | 5 + .../entry/src/main/ets/model/RemoteModels.ets | 9 + .../ets/pages/actions/ConversationIntent.ets | 9 + .../actions/ConversationIntentDispatcher.ets | 2 + .../pages/components/ChatMessageBubble.ets | 12 + .../ets/pages/components/ChatTimeline.ets | 28 +- .../ets/pages/components/ConversationView.ets | 4 + .../ets/pages/components/SubagentTaskCard.ets | 68 +- .../pages/preview/SubagentProcessPreview.ets | 163 ++++- .../pages/preview/SubagentSwarmFixture.ets | 195 +++++- .../runtime/AppRootRuntimeComposition.ets | 11 +- .../ets/pages/state/ConversationCoreState.ets | 7 + .../ets/pages/state/ConversationViewState.ets | 7 + .../state/SubagentBranchExpansionState.ets | 83 +++ .../ets/services/ChatSessionController.ets | 35 +- .../ets/services/DurableSessionReducer.ets | 6 +- .../ets/services/RemoteChatBranchCache.ets | 36 + .../RemoteChatPollingLifecycleController.ets | 11 +- .../services/SubagentStreamCoordinator.ets | 637 ++++++++++++++++-- .../tools/tests/composer-submit.test.cjs | 2 + .../tools/tests/file-visibility.test.cjs | 1 + .../tests/session-model-hydration.test.cjs | 1 + .../tests/subagent-branch-lifecycle.test.cjs | 583 ++++++++++++++++ .../subagent-process-visibility.test.cjs | 204 +++++- 24 files changed, 2041 insertions(+), 78 deletions(-) create mode 100644 src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/SubagentBranchExpansionState.ets create mode 100644 src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteChatBranchCache.ets create mode 100644 src/apps/mobile/harmonyos/tools/tests/subagent-branch-lifecycle.test.cjs diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/entryability/EntryAbility.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/entryability/EntryAbility.ets index baef2ffcf1..3758d2817c 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/entryability/EntryAbility.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/entryability/EntryAbility.ets @@ -25,6 +25,11 @@ export default class EntryAbility extends UIAbility { this.previewStorage = new LocalStorage(); this.previewStorage.setOrCreate('scenarioId', scenarioId); AppStorage.setOrCreate('scenarioId', scenarioId); + // A preview can ask for a variant of its scenario without a scenario id of + // its own, because the gallery routes by the id alone. Anything not asked + // for is the scenario's own default. + const option = want.parameters?.['openbitfunDesignOption'] as string | undefined; + AppStorage.setOrCreate('previewOption', option ?? ''); } try { const colorMode = scenarioId === 'cold-start-home-dark' || scenarioId === 'catalog-refresh-dark' || scenarioId === 'model-role-selector-dark' || scenarioId === 'subagent-process-dark' || scenarioId === 'subagent-swarm-dark' || scenarioId === 'file-visibility-dark' || scenarioId === 'immersive-bottom-dark' || scenarioId === 'immersive-bottom-drawer-dark' || scenarioId === 'immersive-bottom-settings-dark' || scenarioId === 'composer-overflow-dark' || scenarioId === 'composer-overflow-expanded-dark' || scenarioId === 'device-selector-dark' || scenarioId === 'streaming-dark' || scenarioId === 'interaction-mailbox-dark' || scenarioId === 'copy-selection-dark' diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/model/RemoteModels.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/model/RemoteModels.ets index 6ed5dbbe03..098801fe09 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/model/RemoteModels.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/model/RemoteModels.ets @@ -439,6 +439,15 @@ export interface RemoteToolStatusResponse { error_preview?: string; exit_code?: number; plan?: RemotePlanToolResponse; + /** + * Child Session a launching tool started, when this record is a delegation. + * + * The durable record names it (`subagent_session_id` on the item data), and + * this projection keeps the name instead of dropping it: a transcript restored + * from the phone's cache is the only thing a cold or offline session view has, + * and the link is what maps a stored child back to the branch that owns it. + */ + subagent_session_id?: string; } export interface RemotePlanToolResponse { diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/actions/ConversationIntent.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/actions/ConversationIntent.ets index e073742131..ea39485876 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/actions/ConversationIntent.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/actions/ConversationIntent.ets @@ -26,6 +26,7 @@ export enum ConversationIntentType { OpenFilePreview = 'open_file_preview', DownloadFile = 'download_file', BuildPlan = 'build_plan', + BranchExpanded = 'branch_expanded', Send = 'send', VoiceInput = 'voice_input', ChatInputChanged = 'chat_input_changed' @@ -154,4 +155,12 @@ export class ConversationIntents { new PlanBuildRequest(path, name) ); } + + /** + * The reader opened a delegation card. The branch id is the tool call that + * launched the worker, which is what its records are found by. + */ + static branchExpanded(branchId: string): ConversationIntent { + return new ConversationIntent(ConversationIntentType.BranchExpanded, '', branchId); + } } diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/actions/ConversationIntentDispatcher.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/actions/ConversationIntentDispatcher.ets index 3d285970ea..c7cceb8482 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/actions/ConversationIntentDispatcher.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/actions/ConversationIntentDispatcher.ets @@ -26,6 +26,7 @@ export interface ConversationIntentDispatcherHooks { readonly openFilePreview: (route: AppRoute, request: FilePreviewRequest) => void; readonly downloadFile: (path: string) => void; readonly buildPlan: (path: string, name: string) => Promise; + readonly branchExpanded: (branchId: string) => void; readonly send: () => Promise; readonly voiceInput: () => Promise; readonly inputChanged: (route: AppRoute, value: string) => void; @@ -70,6 +71,7 @@ export class ConversationIntentDispatcher { void this.hooks.buildPlan(intent.planBuildRequest.path, intent.planBuildRequest.name); } return; + case ConversationIntentType.BranchExpanded: this.hooks.branchExpanded(intent.toolId); return; case ConversationIntentType.Send: void this.hooks.send(); return; case ConversationIntentType.VoiceInput: void this.hooks.voiceInput(); return; case ConversationIntentType.ChatInputChanged: this.hooks.inputChanged(route, intent.value); return; diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ChatMessageBubble.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ChatMessageBubble.ets index 9a1a3d1c81..a26c577058 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ChatMessageBubble.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ChatMessageBubble.ets @@ -8,6 +8,7 @@ import { ChatMessageStructurePolicy, StructuredRenderGroup } from '../policy/Cha import { AssistantProcessLayoutPolicy } from '../policy/AssistantProcessLayoutPolicy'; import { ConversationMessageRenderPolicy } from '../policy/ConversationMessageRenderPolicy'; import { ThinkingPresentationPolicy } from '../policy/ThinkingPresentationPolicy'; +import { SubagentBranchExpansionState } from '../state/SubagentBranchExpansionState'; import { ACCENT, INK, LINE, STATUS_DANGER } from './Theme'; import { MessageBodyMarkdown, MessageFileCards, MessageImageGallery, MessageMarkdown } from './ChatMessageContent'; import { @@ -89,6 +90,14 @@ export struct ChatMessageBubble { @Event onOpenFilePreview: (path: string, label: string) => void = (_path: string, _label: string) => {}; @Event onBuildPlan: (path: string, name: string) => void = (_path: string, _name: string) => {}; @Event onDownloadFile: (path: string) => void = (_path: string) => {}; + /** + * Disclosure of the delegation cards in this message, owned by the + * conversation: the row and the structured subtree under it are rebuilt + * whenever the turn is republished, and disclosure has to outlive that. + */ + @Param subagentBranches: SubagentBranchExpansionState = new SubagentBranchExpansionState(); + /** Raised when the reader opens a delegation card, so its worker is followed. */ + @Event onBranchExpanded: (branchId: string) => void = (_branchId: string) => {}; private readonly fileReferenceCache: MessageFileReferenceProjectionCache = new MessageFileReferenceProjectionCache(); @@ -415,10 +424,13 @@ export struct ChatMessageBubble { interactionMailbox: this.interactionMailbox, title: this.subagentTitle(this.currentSubagentBranch(entry, path)), path: path, + branchId: entry.tool?.id || '', status: this.currentSubagentBranch(entry, path).tool?.status || (activeScope ? 'running' : 'completed'), items: this.currentSubagentBranch(entry, path).subItems || [], + expansion: this.subagentBranches, renderRevision: this.renderRevision, + onBranchExpanded: this.onBranchExpanded, onApproveTool: this.onApproveTool, onRejectTool: this.onRejectTool, onCancelTool: this.onCancelTool, diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ChatTimeline.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ChatTimeline.ets index adae8d6c8f..5106413b49 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ChatTimeline.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ChatTimeline.ets @@ -14,6 +14,7 @@ import { ObservableChatTimelineItem } from '../../model/ChatTimelineModels'; import { ChatSurface } from '../state/ChatSurface'; +import { SubagentBranchExpansionState } from '../state/SubagentBranchExpansionState'; import { MessageCopyScope } from '../actions/ConversationIntent'; import { CARD, @@ -65,6 +66,10 @@ struct ChatAssistantTimelineRow { @Event onOpenFilePreview: (path: string, label: string) => void = (_path: string, _label: string) => {}; @Event onBuildPlan: (path: string, name: string) => void = (_path: string, _name: string) => {}; @Event onDownloadFile: (path: string) => void = (_path: string) => {}; + /** Disclosure of this row's delegation cards, owned above the row. */ + @Param subagentBranches: SubagentBranchExpansionState = new SubagentBranchExpansionState(); + /** Raised when the reader opens a delegation card, so its worker is followed. */ + @Event onBranchExpanded: (branchId: string) => void = (_branchId: string) => {}; aboutToAppear(): void { this.synchronizeMessage(); @@ -97,6 +102,7 @@ struct ChatAssistantTimelineRow { fileDownloadStatus: this.fileDownloadStatus, activeFilePreviewPath: this.activeFilePreviewPath, activeFilePreviewLoading: this.activeFilePreviewLoading, + subagentBranches: this.subagentBranches, onApproveTool: this.onApproveTool, onRejectTool: this.onRejectTool, onCancelTool: this.onCancelTool, @@ -107,7 +113,8 @@ struct ChatAssistantTimelineRow { onRetryMessage: this.onRetryMessage, onOpenFilePreview: this.onOpenFilePreview, onBuildPlan: this.onBuildPlan, - onDownloadFile: this.onDownloadFile + onDownloadFile: this.onDownloadFile, + onBranchExpanded: this.onBranchExpanded }) } @@ -154,6 +161,15 @@ export struct ChatTimeline { @Param activeFilePreviewPath: string = ''; @Param activeFilePreviewLoading: boolean = false; @Param maxContentWidth: number = 0; + /** + * Disclosure of the delegation cards in this transcript. + * + * A conversation projection passes the instance its state owns, so a card the + * reader opened stays open while the rows around it are rebuilt. A host that + * passes nothing — the previews — gets one per timeline, which is still wider + * than the row and the card it outlives. + */ + @Param subagentBranches: SubagentBranchExpansionState = new SubagentBranchExpansionState(); /** * How much of the pane the floating header and composer cover. The list runs * the full height behind them, so without these the first and last messages @@ -176,6 +192,8 @@ export struct ChatTimeline { @Event onOpenFilePreview: (path: string, label: string) => void = (_path: string, _label: string) => {}; @Event onBuildPlan: (path: string, name: string) => void = (_path: string, _name: string) => {}; @Event onDownloadFile: (path: string) => void = (_path: string) => {}; + /** Raised when the reader opens a delegation card, so its worker is followed. */ + @Event onBranchExpanded: (branchId: string) => void = (_branchId: string) => {}; // Says whether an open reuses this subtree or rebuilds it. A rebuild means // every bubble is constructed from nothing, and the cost of that is the @@ -272,6 +290,7 @@ export struct ChatTimeline { fileDownloadStatus: this.fileDownloadStatus, activeFilePreviewPath: this.activeFilePreviewPath, activeFilePreviewLoading: this.activeFilePreviewLoading, + subagentBranches: this.subagentBranches, onApproveTool: (toolId: string, updatedInput?: Object) => { this.onApproveTool(toolId, updatedInput); }, @@ -304,6 +323,9 @@ export struct ChatTimeline { }, onDownloadFile: (path: string) => { this.onDownloadFile(path); + }, + onBranchExpanded: (branchId: string) => { + this.onBranchExpanded(branchId); } }) .reuse({ reuseId: () => 'chat_assistant' }) @@ -333,6 +355,7 @@ export struct ChatTimeline { fileDownloadStatus: this.fileDownloadStatus, activeFilePreviewPath: this.activeFilePreviewPath, activeFilePreviewLoading: this.activeFilePreviewLoading, + subagentBranches: this.subagentBranches, onApproveTool: this.onApproveTool, onRejectTool: this.onRejectTool, onCancelTool: this.onCancelTool, @@ -343,7 +366,8 @@ export struct ChatTimeline { onRetryMessage: this.onRetryMessage, onOpenFilePreview: this.onOpenFilePreview, onBuildPlan: this.onBuildPlan, - onDownloadFile: this.onDownloadFile + onDownloadFile: this.onDownloadFile, + onBranchExpanded: this.onBranchExpanded }) } .onAppear(() => { diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationView.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationView.ets index eff105257a..53dbbc5ebd 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationView.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationView.ets @@ -420,6 +420,7 @@ export struct ConversationView { activeFilePreviewPath: this.activeFilePreviewPath, activeFilePreviewLoading: this.activeFilePreviewLoading, maxContentWidth: this.composerPresentation === ComposerPresentation.Floating ? 760 : 0, + subagentBranches: this.state.subagentBranches, topInset: this.hoverOperateMode ? 0 : this.topInset, bottomInset: this.hoverOperateMode ? 0 : this.bottomInset, onLoadOlder: () => { @@ -457,6 +458,9 @@ export struct ConversationView { }, onDownloadFile: (path: string) => { this.onDownloadFile(path); + }, + onBranchExpanded: (branchId: string) => { + this.onIntent(ConversationIntents.branchExpanded(branchId)); } }) } diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SubagentTaskCard.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SubagentTaskCard.ets index 3b46f8e57a..46c380706b 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SubagentTaskCard.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SubagentTaskCard.ets @@ -2,6 +2,7 @@ import { InteractionMailboxState } from '../../model/InteractionMailbox'; import { MobileDesignTypography } from '../../generated/MobileDesignTokens'; import { RemoteI18n } from '../../i18n/RemoteI18n'; import { ConversationUiMessageItem, ConversationUiQuestionAnswer } from '../state/ConversationUiModels'; +import { SubagentBranchExpansionState } from '../state/SubagentBranchExpansionState'; import { ChatMessageStructurePolicy } from '../policy/ChatMessageStructurePolicy'; import { CARD, @@ -31,6 +32,24 @@ export struct SubagentTaskCard { */ @Param items: ConversationUiMessageItem[] = []; @Param renderRevision: number = 0; + /** + * The tool call that launched this worker; the key its disclosure is kept under. + * + * A branch is identified by what launched it. The render path is only a + * fallback for a host that sent no tool call id, and it is stable for as long + * as the branch is, which is all a key has to be. + */ + @Param branchId: string = ''; + /** + * Disclosure owned by the conversation, not by this card. + * + * The row this card sits in is rebuilt whenever the turn is republished, and + * with it every component below the row's keyed subtree. State kept here would + * be what the reader loses at that moment — which is what happened when a + * launch settled under an open card — so the boolean lives in owner state that + * outlives the row and this card only reads and writes it. + */ + @Param expansion: SubagentBranchExpansionState = new SubagentBranchExpansionState(); @Event onApproveTool: (toolId: string, updatedInput?: Object) => void = (_toolId: string, _updatedInput?: Object) => {}; @Event onRejectTool: (toolId: string) => void = (_toolId: string) => {}; @@ -40,7 +59,11 @@ export struct SubagentTaskCard { @Event onOpenFilePreview: (path: string, label: string) => void = (_path: string, _label: string) => {}; @Event onBuildPlan: (path: string, name: string) => void = (_path: string, _name: string) => {}; - @Local expanded: boolean = false; + /** + * Raised when the reader opens this branch, so the owner of its records can + * make sure the worker is actually being followed. + */ + @Event onBranchExpanded: (branchId: string) => void = (_branchId: string) => {}; build() { Column() { @@ -80,7 +103,7 @@ export struct SubagentTaskCard { .fontColor(this.isError() ? STATUS_DANGER : MUTED) .maxLines(1) if (this.hasProcessItems()) { - SymbolGlyph(this.expanded ? $r('sys.symbol.chevron_up') : $r('sys.symbol.chevron_down')) + SymbolGlyph(this.isExpanded() ? $r('sys.symbol.chevron_up') : $r('sys.symbol.chevron_down')) .fontSize(13) .fontColor([MUTED]) .width(16) @@ -91,11 +114,9 @@ export struct SubagentTaskCard { .height(28) .alignItems(VerticalAlign.Center) .onClick(() => { - if (this.hasProcessItems()) { - this.expanded = !this.expanded; - } + this.toggleExpanded(); }) - if (this.expanded && this.hasProcessItems()) { + if (this.isExpanded() && this.hasProcessItems()) { this.ProcessItems() } } @@ -106,9 +127,9 @@ export struct SubagentTaskCard { top: this.isEmphasized() ? 6 : 0, bottom: this.isEmphasized() ? 6 : 0 }) - .backgroundColor(this.isEmphasized() || this.expanded ? SOFT : TRANSPARENT) + .backgroundColor(this.isEmphasized() || this.isExpanded() ? SOFT : TRANSPARENT) .borderRadius(this.isEmphasized() ? 14 : 8) - .border({ width: this.isEmphasized() || this.expanded ? 1 : 0, color: LINE }) + .border({ width: this.isEmphasized() || this.isExpanded() ? 1 : 0, color: LINE }) .accessibilityText(`${this.title}, ${this.statusText()}`) } @@ -167,6 +188,37 @@ export struct SubagentTaskCard { * Counting each kind separately keeps a child's key while siblings of other * kinds arrive, so a running Task cannot renumber what is already drawn. */ + /** + * The reader's disclosure of this branch, read from the conversation that owns it. + * + * Reading it in `build` is what subscribes this card to it: the disclosure is + * `@Trace`, so opening or closing the branch repaints exactly this card. + */ + private isExpanded(): boolean { + return this.expansion.isExpanded(this.branchKey()); + } + + /** + * Opens or closes the branch, and tells the owner of its records when it opened. + * + * An open branch is a request to see the worker: its stream may have been + * released (the cap releases a settled child, and a wide delegation holds only + * so many at once), so the same gesture both flips the disclosure and asks for + * the worker to be followed again. Re-attaching is idempotent, and does nothing + * for a branch that is already being followed. + */ + private toggleExpanded(): void { + if (!this.hasProcessItems()) return; + if (this.expansion.toggle(this.branchKey())) { + this.onBranchExpanded(this.branchId); + } + } + + /** What this branch's disclosure is remembered under. */ + private branchKey(): string { + return this.branchId.length > 0 ? this.branchId : this.path; + } + private processItemKey(index: number): string { const items = this.visibleProcessItems(); const ordinals: Map = new Map(); diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/preview/SubagentProcessPreview.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/preview/SubagentProcessPreview.ets index c2e32deaaa..49bafe8c3d 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/preview/SubagentProcessPreview.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/preview/SubagentProcessPreview.ets @@ -3,20 +3,44 @@ import { ChatTimelineRowStore, ObservableChatTimelineItem } from '../../model/Ch import { ChatSessionController, ChatSessionSnapshot } from '../../services/ChatSessionController'; import { ChatTimelineStore } from '../../services/ChatTimelineStore'; import { HostSessionCallbacks, HostSessionStream } from '../../services/HostSessionStream'; +import { RemoteChatBranchCache } from '../../services/RemoteChatBranchCache'; +import { RemoteChatCache } from '../../services/RemoteChatCache'; +import { RemoteChatLocalRdbStore } from '../../services/RemoteChatLocalRdbStore'; +import { RemoteLogger } from '../../services/RemoteLogger'; import { RemoteSessionManager } from '../../services/RemoteSessionManager'; +import { SubagentFollowState } from '../../services/SubagentStreamCoordinator'; import { ChatMessageStructurePolicy, StructuredRenderGroup } from '../policy/ChatMessageStructurePolicy'; import { ConversationUiMessage, ConversationUiMessageItem, toConversationUiMessage } from '../state/ConversationUiModels'; +import { SubagentBranchExpansionState } from '../state/SubagentBranchExpansionState'; import { ChatTimeline } from '../components/ChatTimeline'; import { ComposerBar } from '../components/ComposerBar'; import { INK, PAGE_BG } from '../components/Theme'; -import { SWARM_PARENT_SESSION, SwarmLedger, SwarmScript } from './SubagentSwarmFixture'; +import { + SWARM_PREVIEW_DEVICE_KEY, + SwarmBeatScript, + SwarmLedger, + SwarmScript, + SWARM_WIDE_WORKERS, + SwarmWideScript, + wideSpawnId +} from './SubagentSwarmFixture'; /** How long one beat of the swarm waits before it publishes; device capture is manual too. */ const SWARM_BEAT_INTERVAL_MS: number = 900; +/** + * How far the preview's own controls sit below the top of the screen. + * + * The page draws under the system status bar, and this band is the fixture's + * controls plus the reading a capture has to carry: starting them at the very + * top puts both under the clock and the battery indicator, where half a line is + * unreadable and a control half covered by an overlay is a control that cannot + * be pressed. + */ +const PREVIEW_TOP_INSET: number = 148; const SWARM_PREVIEW_CATALOG: RemoteModelCatalog = { version: 1, models: [], default_models: {} }; /** @@ -52,16 +76,34 @@ class SwarmSessionSource extends RemoteSessionManager { * own streams. Nothing here hands the timeline a prepared branch: the records go * through the controller, the reducer and the store, and what the card draws is * what a live session draws. + * + * Three variants of it are launchable, chosen by the `previewOption` ability + * parameter rather than by a scenario id of their own: + * + * - nothing (`subagent-swarm`): the three-worker swarm, one beat at a time, which + * is the walk through a launch, a running worker and a settled launch; + * - `wide`: the ten-worker delegation, published at once, which is what the + * per-session follow cap is for; + * - `restart`: the same swarm with every worker's own stream answering nothing, + * the way a phone that just started finds them, so every step a card still + * shows came out of the transcript cache. */ @ComponentV2 export struct SubagentProcessPreview { private fixture: SwarmLedger = new SwarmLedger(); - private script: SwarmScript = new SwarmScript(this.fixture); + private script: SwarmBeatScript = new SwarmScript(this.fixture); private source: SwarmSessionSource = new SwarmSessionSource(this.fixture); private timeline: ChatTimelineStore = new ChatTimelineStore(); private rowStore: ChatTimelineRowStore = new ChatTimelineRowStore(); + private transcriptCache: RemoteChatCache = + new RemoteChatCache(new RemoteChatLocalRdbStore(), (): string => SWARM_PREVIEW_DEVICE_KEY); + private branchCache: RemoteChatBranchCache = new RemoteChatBranchCache(this.transcriptCache); private controller?: ChatSessionController; private timer: number = -1; + private option: string = ''; + private sessionId: string = ''; + /** The reader's disclosures, kept by this preview exactly as the conversation keeps them. */ + @Local expanded: SubagentBranchExpansionState = new SubagentBranchExpansionState(); @Local rows: ObservableChatTimelineItem[] = []; @Local revision: number = 0; @Local summary: string = ''; @@ -70,20 +112,36 @@ export struct SubagentProcessPreview { @Local paused: boolean = false; @Local failure: string = ''; @Local stopRequests: number = 0; + @Local followCursor: number = 0; - aboutToAppear(): void { - this.timeline.reset(SWARM_PARENT_SESSION); + async aboutToAppear(): Promise { + this.option = AppStorage.get('previewOption') ?? ''; + if (this.option === 'wide') this.script = new SwarmWideScript(this.fixture); + this.sessionId = this.script.parentSession(); + this.timeline.reset(this.sessionId); // The turn's own opening records are published before the session is read, // like a history page; every later beat arrives as a live publication. this.script.advance(); + if (this.option === 'restart') { + this.fixture.muteSessions(this.script.childSessions()); + } + const hostContext = this.getUIContext().getHostContext(); + if (hostContext) await this.transcriptCache.init(hostContext); this.controller = new ChatSessionController(this.source, { onSnapshot: (snapshot: ChatSessionSnapshot): void => { this.applySnapshot(snapshot); }, canPoll: (_sessionId: string): boolean => true, onError: (error: Object): void => { this.report(error); } - }, { initialPollDelayMs: 0 }); - this.controller.start(SWARM_PARENT_SESSION, { + }, { initialPollDelayMs: 0, branchCache: this.branchCache }); + this.controller.start(this.sessionId, { pollVersion: 0, knownMessageCount: 0, knownModelCatalogVersion: 0 }); + if (this.option === 'wide' || this.option === 'restart') { + // Nothing is left to play beat by beat: the whole delegation is published, + // and what the cards then hold is what the preview is here to state. + this.script.drain(); + this.pause(); + return; + } this.timer = setInterval((): void => { this.tick(); }, SWARM_BEAT_INTERVAL_MS); } @@ -97,10 +155,25 @@ export struct SubagentProcessPreview { private tick(): void { if (this.paused) return; + this.nextBeat(); + } + + /** + * Publishes the next beat, whether the automatic beats are running or held. + * + * Stepping by hand is what a capture needs: a card can only be opened while its + * worker is mid-turn if the beats stand still while the reader does it, and a + * held fixture that could not be stepped would make every capture start again. + */ + private nextBeat(): void { if (!this.script.advance()) { this.pause(); return; } + // The reading follows the script, not only the snapshots: a beat that moves a + // worker without moving the parent transcript publishes no snapshot, and the + // line a capture carries has to keep up with the beats that were played. + this.refreshSummary(); } private pause(): void { @@ -121,6 +194,24 @@ export struct SubagentProcessPreview { this.refreshSummary(); } + /** + * Asks for one card's worker to be followed, the way opening a card does. + * + * The follow cap releases the oldest working child when a wider delegation + * arrives, so a card can be drawn from a branch whose stream is not open. The + * request is the same one the card raises when the reader opens it, and it is + * idempotent for a worker already being followed. + */ + private followOneCard(): void { + const children = this.script.childSessions(); + if (children.length === 0) return; + const index = this.followCursor % children.length; + this.followCursor++; + const branchId = this.option === 'wide' ? wideSpawnId(index) : 'swarm-docs-spawn'; + this.controller?.ensureBranchSubscribed(branchId); + this.refreshSummary(); + } + /** * A stream failure stays visible instead of stopping the fixture: the band * states it, and the swarm keeps publishing, so a broken child stream cannot @@ -136,13 +227,48 @@ export struct SubagentProcessPreview { * grouping policy: how many cards the turn draws and how many steps each of * them has. A worker's step count only rises while its stream is live, which * is the evidence a screenshot alone cannot state. + * + * The follow reading is the other half of that evidence: how many branches the + * session has, how many of their child streams are open, how many are waiting + * for a slot, and how many steps a card is drawing out of the transcript cache + * rather than off a stream. + */ + /** + * Opens or closes one card's disclosure, exactly as the card's own header does. + * + * The preview carries this because the emulator's own input tool cannot deliver + * a tap to a row inside this list: every coordinate inside a card header is + * reported as a clickable node and still reaches nothing, while the controls + * above the list work. The gesture is the card's — the same keyed disclosure, + * the same request to follow the worker — so what a capture then shows is the + * card reading state that outlived its row. */ + private toggleCard(index: number): void { + const cards = this.branchCards(); + if (cards.length === 0) return; + const card = cards[(index + cards.length) % cards.length]; + const branchId = card.tool?.id || ''; + if (branchId.length === 0) return; + if (this.expanded.toggle(branchId)) this.controller?.ensureBranchSubscribed(branchId); + this.refreshSummary(); + } + private refreshSummary(): void { const cards = this.branchCards(); const steps = cards.map((card: ConversationUiMessageItem) => (card.subItems || []).length).join(','); - this.summary = `swarm step=${this.script.position()}/${this.script.beatCount()} paused=${this.paused ? 1 : 0} ` + - `rows=${this.rows.length} cards=${cards.length} steps=[${steps}]` + + const statuses = cards.map((card: ConversationUiMessageItem) => card.tool?.status || '').join(','); + const follow: SubagentFollowState | undefined = this.controller?.subagentFollowState(); + const followText = follow === undefined ? 'streams=?' : + `streams=${follow.subscribed}/${follow.branches} waiting=${follow.waiting} restored=${follow.restored}`; + const open = this.expanded.expandedKeys().join(','); + this.summary = `swarm option=${this.option.length > 0 ? this.option : 'live'} ` + + `step=${this.script.position()}/${this.script.beatCount()} paused=${this.paused ? 1 : 0} ` + + `rows=${this.rows.length} cards=${cards.length} steps=[${steps}] status=[${statuses}] ` + + `open=[${open}] ${followText}` + (this.failure.length > 0 ? ` failure=${this.failure}` : ''); + // The same reading into the device log, so a run can be checked as text and + // not only as pixels: what a capture shows is one frame of this sequence. + RemoteLogger.info(this.summary); } private branchCards(): ConversationUiMessageItem[] { @@ -165,9 +291,8 @@ export struct SubagentProcessPreview { build() { Column({ space: 6 }) { - Text(this.summary).id('subagent-summary').fontSize(10).fontColor(INK).width('100%') Row({ space: 8 }) { - Button('Next beat').id('subagent-next').onClick(() => { this.tick(); }) + Button('Next beat').id('subagent-next').onClick(() => { this.nextBeat(); }) Button(this.paused ? 'Resume' : 'Pause').id('subagent-pause').onClick(() => { if (this.paused) { this.paused = false; @@ -177,18 +302,32 @@ export struct SubagentProcessPreview { this.pause(); } }) + Button(this.option === 'wide' ? `Follow card ${this.followCursor % SWARM_WIDE_WORKERS}` : 'Follow docs card') + .id('subagent-follow-card').onClick(() => { this.followOneCard(); }) + } + Row({ space: 8 }) { + Button('Refresh state').id('subagent-refresh').onClick(() => { this.refreshSummary(); }) + Button('Toggle card').id('subagent-toggle-card').onClick(() => { this.toggleCard(-1); }) Button('Host offline').id('subagent-offline').onClick(() => { this.connectionState = 'reconnecting'; }) Button('Host returned').id('subagent-returned').onClick(() => { this.connectionState = 'connected'; }) } + // Below the controls rather than above them: the reading is the evidence a + // capture has to carry, and the line above the buttons sits under the + // system status bar where half of it cannot be read. + Text(this.summary).id('subagent-summary').fontSize(10).fontColor(INK).width('100%') Text(`${this.connectionState} stops=${this.stopRequests}`).id('subagent-host-state').fontSize(10).fontColor(INK) ChatTimeline({ timelineItems: this.rows, timelineRevision: this.revision, - connectionState: this.connectionState, isBusy: this.active + connectionState: this.connectionState, isBusy: this.active, + subagentBranches: this.expanded, + onBranchExpanded: (branchId: string): void => { + this.controller?.ensureBranchSubscribed(branchId); + } }).layoutWeight(1) ComposerBar({ canStop: this.active, connectionState: this.connectionState, onStop: () => { this.stopRequests++; } }) - }.width('100%').height('100%').backgroundColor(PAGE_BG) + }.width('100%').height('100%').backgroundColor(PAGE_BG).padding({ top: PREVIEW_TOP_INSET }) } } diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/preview/SubagentSwarmFixture.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/preview/SubagentSwarmFixture.ets index a717d559de..cff5dd838d 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/preview/SubagentSwarmFixture.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/preview/SubagentSwarmFixture.ets @@ -45,6 +45,30 @@ export const SWARM_PAYMENTS_SESSION: string = 'swarm-preview-child-payments'; export const SWARM_DOCS_SESSION: string = 'swarm-preview-child-docs'; /** Third worker: its own tools failed and it reported the failure. */ export const SWARM_FLAKY_SESSION: string = 'swarm-preview-child-flaky'; +/** The wide delegation: ten workers launched at once, which is what the cap is for. */ +export const SWARM_WIDE_PARENT_SESSION: string = 'swarm-preview-parent-wide'; +/** Session id a wide worker runs in; the index is appended. */ +export const SWARM_WIDE_CHILD_PREFIX: string = 'swarm-preview-wide-child-'; +/** How many workers the wide delegation hands out at once. */ +export const SWARM_WIDE_WORKERS: number = 10; +/** The dev key the preview's cold start stores its transcripts under. */ +export const SWARM_PREVIEW_DEVICE_KEY: string = 'swarm-preview-device'; + +/** + * The beats a preview can play, whichever delegation it is playing. + * + * The parent session id is part of the script because it is the session a + * preview opens, and the children are the sessions a cold-start preview has to + * mute so that only the transcript cache can answer for them. + */ +export interface SwarmBeatScript { + parentSession(): string; + childSessions(): string[]; + beatCount(): number; + position(): number; + advance(): boolean; + drain(): void; +} interface SwarmUserMessage { id: string; content: string; timestamp: number; } interface SwarmTurnHeader { @@ -143,7 +167,21 @@ export class SwarmSessionLog { /** Publishes the turn record, which every later record of this log repeats. */ openTurn(): void { - this.publish(this.record(`turn/${this.turnId}`)); + this.publish(this.record(`turn/${this.turnId}`, undefined, undefined, true)); + } + + /** + * Ends the turn this log is publishing, without publishing another item. + * + * A host writes a turn's own terminal status when the turn ends, and that + * status is the only place a finished worker says it is finished: its step + * count does not change at that moment, so nothing else in the log moves. It is + * the turn's own record, not a round's: a turn record carries the header the + * rounds repeat, which is what makes it the state of the turn. + */ + settleTurn(status: string): void { + this.turnStatus = status; + this.publish(this.record(`turn/${this.turnId}`, undefined, undefined, true)); } /** Opens the next model round and publishes it. */ @@ -220,14 +258,22 @@ export class SwarmSessionLog { return this.clock; } - private record(id: string, item?: SwarmItem, round?: SwarmRoundHeader): SwarmRecord { + /** + * One record of this session's log. + * + * A record of a turn's own state carries no round: `turnOnly` is what a turn + * header published on its own looks like, and a record stamped with the round + * the log happens to be in is a round's record, not the turn's. + */ + private record(id: string, item?: SwarmItem, round?: SwarmRoundHeader, + turnOnly: boolean = false): SwarmRecord { this.revision++; const record: SwarmRecord = { sessionId: this.sessionId, id, revision: this.revision, turn: this.turnHeader() }; if (round) { record.round = round; - } else if (this.roundId.length > 0) { + } else if (!turnOnly && this.roundId.length > 0) { record.round = { id: this.roundId, turnId: this.turnId, roundIndex: this.roundCount, timestamp: this.clock, status: this.turnStatus @@ -289,6 +335,7 @@ class SwarmStreamSource implements HostSessionSource { export class SwarmLedger { private logs: Map = new Map(); private streams: Map = new Map(); + private muted: Set = new Set(); /** Creates (once) the log a session publishes into. */ open(sessionId: string, turnId: string, userText: string, turnIndex: number = 0): SwarmSessionLog { @@ -300,6 +347,18 @@ export class SwarmLedger { return log; } + /** + * Stops these sessions from answering, the way a host that is gone answers. + * + * A cold-start preview reads the session on screen from the record log it + * publishes, and the workers it launched from nowhere at all: whatever a card + * still shows at that point came from the transcript cache, which is exactly + * what a phone that just restarted has. + */ + muteSessions(sessionIds: string[]): void { + sessionIds.forEach((sessionId: string): void => { this.muted.add(sessionId); }); + } + openStream(sessionId: string, callbacks: HostSessionCallbacks): HostSessionStream { const stream = new HostSessionStream(sessionId, new SwarmStreamSource(this, sessionId), callbacks); this.streams.set(sessionId, stream); @@ -309,7 +368,7 @@ export class SwarmLedger { /** A read page for one session: everything after the caller's cursor. */ page(sessionId: string, request: HostStreamReadRequest): HostStreamPage { const log = this.logs.get(sessionId); - const records: HostStreamEventWire[] = log ? log.records() : []; + const records: HostStreamEventWire[] = log && !this.muted.has(sessionId) ? log.records() : []; const cursor = log ? log.newestSeq() : 0; const after = request.after ?? 0; const page: HostStreamPage = { @@ -332,7 +391,7 @@ export class SwarmLedger { * Beats exist so the fixture can be captured mid-flight — a worker's steps * arriving one page at a time — instead of only in its finished state. */ -export class SwarmScript { +export class SwarmScript implements SwarmBeatScript { private ledger: SwarmLedger; private parent: SwarmSessionLog; private payments: SwarmSessionLog; @@ -362,10 +421,19 @@ export class SwarmScript { (): void => { this.beatDocsGrepStarted(); }, (): void => { this.beatDocsGrepFinished(); }, (): void => { this.beatDocsReadStarted(); }, - (): void => { this.beatDocsSpawnSettles(); } + (): void => { this.beatDocsSpawnSettles(); }, + // The workers answer, one turn at a time, after the parent has collected + // them: a worker's turn can end without publishing another step, so these + // beats are the only thing that moves a card's status. + (): void => { this.payments.settleTurn('completed'); }, + (): void => { this.flaky.settleTurn('error'); }, + (): void => { this.docs.settleTurn('completed'); } ]; } + parentSession(): string { return SWARM_PARENT_SESSION; } + childSessions(): string[] { return [SWARM_PAYMENTS_SESSION, SWARM_FLAKY_SESSION, SWARM_DOCS_SESSION]; } + /** How many beats the swarm has; a driver stops when `advance` answers false. */ beatCount(): number { return this.beats.length; } /** The beat that will be published next; equals `beatCount` when finished. */ @@ -530,6 +598,108 @@ export class SwarmScript { } } +/** + * The wide delegation: ten workers handed out at once. + * + * This is the delegation the follow cap exists for. Every launch is answered with + * the status the host answers a background worker with, so every card's own + * launch record says the call is done while its worker has only just started; + * each worker then publishes one step of its own. What the preview reads back is + * ten cards holding ten workers' process while only the cap's worth of child + * streams are open, and which of those streams is open follows what the reader + * opened and what finished. + */ +export class SwarmWideScript implements SwarmBeatScript { + private parent: SwarmSessionLog; + private children: SwarmSessionLog[] = []; + private beats: (() => void)[] = []; + private cursor: number = 0; + + constructor(ledger: SwarmLedger) { + this.parent = ledger.open(SWARM_WIDE_PARENT_SESSION, 'swarm-wide-turn-1', SWARM_WIDE_PROMPT, 0); + for (let index = 0; index < SWARM_WIDE_WORKERS; index++) { + this.children.push(ledger.open(wideChildSession(index), `swarm-wide-turn-${index}`, + SWARM_WIDE_BRIEF, 0)); + } + this.beats.push((): void => { this.beatOpening(); }); + for (let index = 0; index < SWARM_WIDE_WORKERS; index++) { + this.beats.push((): void => { this.beatSpawn(index); }); + } + for (let index = 0; index < SWARM_WIDE_WORKERS; index++) { + this.beats.push((): void => { this.beatWorkerStep(index); }); + } + } + + parentSession(): string { return SWARM_WIDE_PARENT_SESSION; } + + childSessions(): string[] { + const sessions: string[] = []; + for (let index = 0; index < this.children.length; index++) sessions.push(wideChildSession(index)); + return sessions; + } + + beatCount(): number { return this.beats.length; } + position(): number { return this.cursor; } + + advance(): boolean { + if (this.cursor >= this.beats.length) return false; + const beat = this.beats[this.cursor]; + this.cursor++; + beat(); + return true; + } + + drain(): void { + while (this.advance()) { + } + } + + private beatOpening(): void { + const parent = this.parent; + parent.openTurn(); + parent.beginRound(); + parent.thinking(SWARM_WIDE_PLAN_REASONING); + parent.text(SWARM_WIDE_PLAN_TEXT); + } + + /** One launch, answered at once: the worker behind it has not reported yet. */ + private beatSpawn(index: number): void { + this.parent.beginRound(); + this.parent.toolFinished(wideSpawnId(index), 'AgentSpawn', { + agent_id: wideAgentId(index), agent_type: 'Explore', prompt: SWARM_WIDE_BRIEF + }, spawnTable(wideAgentId(index), `bg-${index}4c0a1`), { subagentSessionId: wideChildSession(index) }); + } + + /** The worker's own first step, published on the worker's own stream. */ + private beatWorkerStep(index: number): void { + const child = this.children[index]; + child.openTurn(); + child.beginRound(); + child.thinking(SWARM_WIDE_WORKER_THINKING); + child.toolFinished(wideStepId(index), 'Grep', { + pattern: 'audit', path: `src/area-${index}`, output_mode: 'files_with_matches' + }, `src/area-${index}/index.ts`); + } +} + +/** The session one wide worker runs in. */ +export function wideChildSession(index: number): string { + return `${SWARM_WIDE_CHILD_PREFIX}${index}`; +} + +/** The tool call that launched one wide worker; what its card is keyed by. */ +export function wideSpawnId(index: number): string { + return `swarm-wide-spawn-${index}`; +} + +function wideAgentId(index: number): string { + return `audit-area-${index}`; +} + +function wideStepId(index: number): string { + return `swarm-wide-grep-${index}`; +} + /** The text table an `AgentSpawn` answers with while its worker runs. */ function spawnTable(agentId: string, bgTaskId: string): string { return ['agent_id bg_task_id status', `${agentId} ${bgTaskId} started`].join('\n'); @@ -570,6 +740,19 @@ const PARENT_PLAN_REASONING: string = const PARENT_PLAN_TEXT: string = 'I will run three workers in parallel: payment entry points, documentation drift, and a migration replay.'; +const SWARM_WIDE_PROMPT: string = + 'Audit every area of the workspace at once and report which ones a reader should look at first.'; +const SWARM_WIDE_BRIEF: string = [ + 'Audit one area of the workspace and report the entry points it exposes.', + 'Name each file you read and state what the area is responsible for.' +].join('\n'); +const SWARM_WIDE_PLAN_REASONING: string = + 'Ten areas, one worker each: the widest delegation this phone has had to draw.'; +const SWARM_WIDE_PLAN_TEXT: string = + 'I will hand the audit to ten workers and follow every one of their streams.'; +const SWARM_WIDE_WORKER_THINKING: string = + 'Read the area index first, then name what it covers.'; + const PAYMENTS_GREP_OUTPUT: string = [ 'src/payments/charge.ts', 'src/payments/refund.ts', 'src/payments/retry.ts', 'src/payments/invoice.ts', 'src/payments/ledger.ts' diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/runtime/AppRootRuntimeComposition.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/runtime/AppRootRuntimeComposition.ets index 6d13f2fd08..2dba036af3 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/runtime/AppRootRuntimeComposition.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/runtime/AppRootRuntimeComposition.ets @@ -30,6 +30,7 @@ import { GitHubAccountProfileClient } from '../../services/GitHubAccountProfile' import { CloudAccountSessionStore } from '../../services/CloudAccountSessionStore'; import { RemoteActivityLifecycleController } from '../../services/RemoteActivityLifecycleController'; import { RemoteChatCache } from '../../services/RemoteChatCache'; +import { RemoteChatBranchCache } from '../../services/RemoteChatBranchCache'; import { RemoteChatCommandController } from '../../services/RemoteChatCommandController'; import { RemoteChatLocalRdbStore } from '../../services/RemoteChatLocalRdbStore'; import { RemoteChatPollingLifecycleController, RemoteChatPollingSnapshot } from '../../services/RemoteChatPollingLifecycleController'; @@ -45,6 +46,7 @@ import { RemoteSessionController } from '../../services/RemoteSessionController' import { RemoteSessionListCache } from '../../services/RemoteSessionListCache'; import { RemoteSessionListRdbStore } from '../../services/RemoteSessionListRdbStore'; import { RemoteSessionManager } from '../../services/RemoteSessionManager'; +import { SubagentBranchCache } from '../../services/SubagentStreamCoordinator'; import { RemoteWorkspaceRepository } from '../../services/RemoteWorkspaceRepository'; import { RemoteWorkspaceCoordinator } from '../../services/RemoteWorkspaceCoordinator'; import { RemoteConnectionCoordinator } from '../../services/RemoteConnectionCoordinator'; @@ -511,6 +513,7 @@ export abstract class AppRootRuntimeComposition { this.remoteChatPollingLifecycleController.nudge(); } ); + readonly remoteChatBranchCache: SubagentBranchCache = new RemoteChatBranchCache(this.remoteChatCache); readonly remoteChatPollingLifecycleController: RemoteChatPollingLifecycleController = new RemoteChatPollingLifecycleController( this.sessionManager, @@ -562,7 +565,8 @@ export abstract class AppRootRuntimeComposition { // not prove disconnection, and the probe is generation-fenced. void this.remoteActivityViewModel.checkConnectionHealth(); } - } + }, + this.remoteChatBranchCache ); readonly remoteModelController: RemoteModelController = new RemoteModelController( @@ -792,6 +796,11 @@ export abstract class AppRootRuntimeComposition { buildPlan: async (path: string, name: string): Promise => { await this.conversationController.buildRemotePlan(path, name); }, + // Opening a delegation card is a request to watch that worker: the branch + // may have lost its stream to the follow cap, and the card has to fill in. + branchExpanded: (branchId: string): void => { + this.remoteChatPollingLifecycleController.ensureBranchSubscribed(branchId); + }, send: async (): Promise => { await this.conversationController.sendVisibleMessage(); }, voiceInput: async (): Promise => { await this.toggleVoiceInput(); }, inputChanged: (route: AppRoute, value: string): void => diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/ConversationCoreState.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/ConversationCoreState.ets index 1702f038cc..ccb38b3943 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/ConversationCoreState.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/ConversationCoreState.ets @@ -14,6 +14,7 @@ import { ChatTimelineRowStore } from '../../model/ChatTimelineModels'; import { RemoteUiState } from '../../services/RemoteUiState'; +import { SubagentBranchExpansionState } from './SubagentBranchExpansionState'; /** Shared observable state for General Chat and Remote Chat conversations. */ @ObservedV2 @@ -35,6 +36,12 @@ export class ConversationCoreState { @Trace chatInput: string = ''; @Trace selectedImages: SelectedImageAttachment[] = []; @Trace isVoiceListening: boolean = false; + /** + * Which delegation cards are open. Disclosures outlive the rows that draw + * them, so they are owned by the conversation rather than by a card, and they + * are shared by every surface that projects this conversation. + */ + readonly subagentBranches: SubagentBranchExpansionState = new SubagentBranchExpansionState(); private composerRevision: number = 0; private readonly defaultAgentType: string; private readonly timelineRevisionTracker: ChatTimelineRevisionTracker = new ChatTimelineRevisionTracker(); diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/ConversationViewState.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/ConversationViewState.ets index 7452089d09..4577484f01 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/ConversationViewState.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/ConversationViewState.ets @@ -22,6 +22,7 @@ import { AppRoute } from '../navigation/AppRouteContract'; import { ConversationCoreState } from './ConversationCoreState'; import { GeneralChatPageState } from './GeneralChatPageState'; import { RemotePageState } from './RemotePageState'; +import { SubagentBranchExpansionState } from './SubagentBranchExpansionState'; /** Immutable presentation state consumed by ConversationView. */ export class ConversationViewState { @@ -53,6 +54,11 @@ export class ConversationViewState { selectedImages: ConversationUiSelectedImage[] = []; isVoiceListening: boolean = false; chatInput: string = ''; + /** + * Disclosure of the conversation's delegation cards; the projection carries the + * owner's instance so a card the reader opened stays open across repaints. + */ + subagentBranches: SubagentBranchExpansionState = new SubagentBranchExpansionState(); static project( route: AppRoute, @@ -114,6 +120,7 @@ export class ConversationViewState { state.selectedImages = core.selectedImages.map((image) => toConversationUiSelectedImage(image)); state.isVoiceListening = core.isVoiceListening; state.chatInput = core.chatInput; + state.subagentBranches = core.subagentBranches; return state; } diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/SubagentBranchExpansionState.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/SubagentBranchExpansionState.ets new file mode 100644 index 0000000000..8e1153d6ae --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/SubagentBranchExpansionState.ets @@ -0,0 +1,83 @@ +/** + * One delegation card's disclosure, keyed by the branch it belongs to. + * + * `@Trace` is what the card reads: a component that renders `expanded` is + * repainted when it changes, wherever the change came from. + */ +@ObservedV2 +export class SubagentBranchDisclosure { + readonly key: string; + @Trace expanded: boolean = false; + + constructor(key: string) { + this.key = key; + } +} + +/** + * Which delegation cards the reader has opened, keyed by branch identity. + * + * The card cannot own this itself. Its row is rebuilt whenever the turn it sits + * in is republished — a launch settling reorders the structured groups of the + * turn, and the row's keyed subtree is rebuilt with it — and a card that kept + * its disclosure in its own `@Local` state collapsed under the reader at exactly + * that moment. Hoisting the boolean to owner state that outlives the row keeps + * what the reader opened open, which is the same reason the sidebar's workspace + * disclosure lives in `DeviceDirectoryState` rather than in its row — and it is + * why this container is observed the same way that one is: `@Trace` on the + * collection as well as on the entry, so a card that reads a key it has not + * looked up before is still repainted when that key's entry arrives. + * + * The key is the branch, not the view path or the row: a card keeps its + * disclosure while its worker streams, while the transcript around it changes, + * and while the list recycles its row — until the session's state itself is + * replaced. Disclosure deliberately does not survive an app restart; that is a + * separate promise nothing here makes. + */ +@ObservedV2 +export class SubagentBranchExpansionState { + @Trace disclosures: SubagentBranchDisclosure[] = []; + + isExpanded(key: string): boolean { + const found = this.find(key); + return found !== undefined && found.expanded; + } + + /** + * Which branches the reader has open, in the order they were opened. + * + * Disclosure is the reader's, and this is the only reading of it that outlives + * a row: it is what a preview states and what a test asserts, in place of + * looking for the pixels a card happens to draw. + */ + expandedKeys(): string[] { + return this.disclosures + .filter((entry: SubagentBranchDisclosure): boolean => entry.expanded) + .map((entry: SubagentBranchDisclosure): string => entry.key); + } + + setExpanded(key: string, expanded: boolean): void { + if (key.length === 0) return; + this.ensure(key).expanded = expanded; + } + + /** Flips one card and answers what it now is, so the caller can act on opening. */ + toggle(key: string): boolean { + if (key.length === 0) return false; + const disclosure = this.ensure(key); + disclosure.expanded = !disclosure.expanded; + return disclosure.expanded; + } + + private find(key: string): SubagentBranchDisclosure | undefined { + return this.disclosures.find((entry: SubagentBranchDisclosure): boolean => entry.key === key); + } + + private ensure(key: string): SubagentBranchDisclosure { + const found = this.find(key); + if (found !== undefined) return found; + const created = new SubagentBranchDisclosure(key); + this.disclosures.push(created); + return created; + } +} diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/services/ChatSessionController.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/services/ChatSessionController.ets index 0897df1c3d..bafe738fee 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/services/ChatSessionController.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/services/ChatSessionController.ets @@ -2,7 +2,7 @@ import { InteractionMailboxStore } from './InteractionMailboxStore'; import { InteractionMailboxAction, InteractionMailboxState } from '../model/InteractionMailbox'; import { DurableSessionReducer } from './DurableSessionReducer'; import { HostSessionStream, HostSessionEvent } from './HostSessionStream'; -import { SubagentStreamCoordinator } from './SubagentStreamCoordinator'; +import { SubagentBranchCache, SubagentFollowState, SubagentStreamCoordinator } from './SubagentStreamCoordinator'; import { ChatMessage, RemoteModelCatalog @@ -48,6 +48,13 @@ export interface ChatSessionControllerCallbacks { export interface ChatSessionControllerOptions { /** Stabilization window before the first idle poll after selecting a session. */ initialPollDelayMs: number; + /** + * Where a delegation's child transcripts are kept between app launches. + * + * Absent for a host that has no phone transcript cache to write to: the + * branches then live for as long as the session is open, as they always did. + */ + branchCache?: SubagentBranchCache; } @@ -76,7 +83,7 @@ export class ChatSessionController { private pendingModelCatalog?: RemoteModelCatalog; constructor(sessionManager: RemoteSessionManager, callbacks: ChatSessionControllerCallbacks, - _options?: ChatSessionControllerOptions) { + options?: ChatSessionControllerOptions) { this.sessionManager = sessionManager; this.callbacks = callbacks; this.mailbox = new InteractionMailboxStore(sessionManager, (state: InteractionMailboxState): void => { this.callbacks.onMailbox?.(state); }); // A delegation tool's child sessions are streams of their own; they are read @@ -84,7 +91,8 @@ export class ChatSessionController { // item that launched them. this.subagents = new SubagentStreamCoordinator(sessionManager, (): void => { this.publishChildBranch(); }, - (error: Error): void => { if (!this.stopped) this.callbacks.onError(error); }); + (error: Error): void => { if (!this.stopped) this.callbacks.onError(error); }, + options?.branchCache); } start(sessionId: string, cursor: ChatSessionCursor, _activeTurn?: ChatMessage): void { if (!this.stopped && this.sessionId === sessionId && this.stream && !this.stream.isClosed()) { @@ -182,12 +190,31 @@ export class ChatSessionController { if (!this.caughtUp || this.historyReplaying) return; this.publish(); } + /** + * Makes sure one delegation's worker is being followed. + * + * The card asks for this when the reader opens it: the branch may have lost + * its stream to the cap, and an open card has to show the worker's own steps. + */ + ensureBranchSubscribed(ownerToolCallId: string): void { + this.subagents.ensureSubscribed(ownerToolCallId); + } + /** How many of this session's children are followed, for previews and focused tests. */ + subagentFollowState(): SubagentFollowState { + return this.subagents.followState(); + } private publish(): void { if (this.stopped || !this.callbacks.canPoll(this.sessionId)) return; + const transcript: ChatMessage[] = this.reducer.messages(); + // The transcript names every child this session launched. Reading the names + // out of it is what keeps a delegation's worker reachable when no parent + // record arrives — a phone that just restarted, or an offline host — because + // the cached launch item still carries the child Session id. + this.subagents.observeParentMessages(transcript); const persisted: ChatMessage[] = []; let active: ChatMessage | undefined = undefined; this.completedTurnId = ''; - this.subagents.decorate(this.reducer.messages()).forEach((message: ChatMessage) => { + this.subagents.decorate(transcript).forEach((message: ChatMessage) => { if (message.role === 'assistant' && (message.status === 'active' || message.status === 'running')) active = message; else { persisted.push(message); diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/services/DurableSessionReducer.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/services/DurableSessionReducer.ets index c32811d4ae..48cb67d2c3 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/services/DurableSessionReducer.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/services/DurableSessionReducer.ets @@ -158,7 +158,11 @@ export class DurableSessionReducer { if (entry.data.type === 'tool') { const tool: RemoteToolStatusResponse = { id: data.toolCall?.id || data.id, name: data.toolName, status: data.status || (data.toolResult ? (data.toolResult.success ? 'completed' : 'failed') : 'running'), tool_input: data.toolCall?.input, tool_output: data.toolResult?.result, - duration_ms: data.durationMs ?? data.toolResult?.durationMs, error_preview: data.toolResult?.error, start_ms: data.startTime }; + duration_ms: data.durationMs ?? data.toolResult?.durationMs, error_preview: data.toolResult?.error, start_ms: data.startTime, + // The child Session this launch started, kept on the projection so a + // transcript that only exists on this phone still says which session a + // delegation drew its worker from. + subagent_session_id: data.subagentSessionId }; item.tool = tool; tools.push(tool); } items.push(item); diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteChatBranchCache.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteChatBranchCache.ets new file mode 100644 index 0000000000..209ba1963b --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteChatBranchCache.ets @@ -0,0 +1,36 @@ +import { ChatMessage } from '../model/RemoteModels'; +import { RemoteChatCache } from './RemoteChatCache'; +import { SubagentBranchCache } from './SubagentStreamCoordinator'; + +/** + * Child-branch transcripts in the phone's own transcript cache. + * + * This is the storage the parent session's transcript already uses: transcripts + * scoped per controlled desktop and session id, every failure swallowed because + * a cache is reconstructible from the desktop, and one clear on sign-out. A + * child session is an ordinary session on its own stream id, so it is stored + * under its own id — which is also the id the parent's launching item names, and + * therefore the key that maps a stored child back to the parent and the branch + * that owns it. No second store, no second schema, and no second lifecycle to + * keep in step with the parent's. + */ +export class RemoteChatBranchCache implements SubagentBranchCache { + private readonly cache: RemoteChatCache; + + constructor(cache: RemoteChatCache) { + this.cache = cache; + } + + async loadBranch(sessionId: string): Promise { + return await this.cache.load(sessionId); + } + + /** + * A child's transcript is authoritative for the child, the same way the parent + * transcript is: the reducer's projection is complete at every change, so it + * replaces the stored rows rather than appending to them. + */ + async saveBranch(sessionId: string, messages: ChatMessage[]): Promise { + await this.cache.replaceSnapshot(sessionId, messages); + } +} diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteChatPollingLifecycleController.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteChatPollingLifecycleController.ets index 35462bdc80..40ec7630f5 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteChatPollingLifecycleController.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteChatPollingLifecycleController.ets @@ -6,6 +6,7 @@ import { ChatSessionCursor, ChatSessionSnapshot } from './ChatSessionController'; +import { SubagentBranchCache } from './SubagentStreamCoordinator'; import { RemoteSessionManager } from './RemoteSessionManager'; export type RemoteChatPollingCursor = ChatSessionCursor; @@ -22,9 +23,11 @@ export type RemoteChatPollingLifecycleCallbacks = ChatSessionControllerCallbacks export class RemoteChatPollingLifecycleController { private readonly controller: ChatSessionController; - constructor(sessionManager: RemoteSessionManager, callbacks: ChatSessionControllerCallbacks) { + constructor(sessionManager: RemoteSessionManager, callbacks: ChatSessionControllerCallbacks, + branchCache?: SubagentBranchCache) { this.controller = new ChatSessionController(sessionManager, callbacks, { - initialPollDelayMs: 400 + initialPollDelayMs: 400, + branchCache: branchCache }); } @@ -45,6 +48,10 @@ export class RemoteChatPollingLifecycleController { loadOlder(): void { this.controller.loadOlder(); } + ensureBranchSubscribed(ownerToolCallId: string): void { + this.controller.ensureBranchSubscribed(ownerToolCallId); + } + nudge(): void { this.controller.nudge(); } diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/services/SubagentStreamCoordinator.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/services/SubagentStreamCoordinator.ets index 39a75f8a0f..e4fa56520b 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/services/SubagentStreamCoordinator.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/services/SubagentStreamCoordinator.ets @@ -1,6 +1,7 @@ import { ChatMessage, ChatMessageItemResponse, RemoteToolStatusResponse } from '../model/RemoteModels'; import { DurableSessionReducer } from './DurableSessionReducer'; import { HostSessionCallbacks, HostSessionEvent, HostSessionStream } from './HostSessionStream'; +import { RemoteLogger } from './RemoteLogger'; /** * The one call the coordinator needs from the runtime connection. @@ -13,6 +14,51 @@ export interface SubagentStreamSource { subscribeSession(sessionId: string, callbacks: HostSessionCallbacks): HostSessionStream; } +/** + * Where a child branch's records are kept between app launches. + * + * This is the phone's own transcript cache, keyed by the child Session id — + * `RemoteChatBranchCache` adapts `RemoteChatCache`, which is the storage the + * parent session's transcript already uses. A child is an ordinary session on + * its own stream id, so its transcript caches the same way; and because the + * child Session id is what the parent's launching item names, the key is also + * what maps a stored child back to the parent and the branch that owns it. + */ +export interface SubagentBranchCache { + loadBranch(sessionId: string): Promise; + saveBranch(sessionId: string, messages: ChatMessage[]): Promise; +} + +/** How many of a session's children are followed, and how many are waiting for a slot. */ +export interface SubagentFollowState { + branches: number; + subscribed: number; + waiting: number; + /** Steps currently drawn from the transcript cache, i.e. what a cold start had. */ + restored: number; +} + +/** + * How many child sessions one parent session follows at once. + * + * Every child is another paged stream read over the same device RPC lane as the + * parent session, and a delegation wider than this is past what a phone screen + * presents at once. The cap never costs a card its content: a child that holds + * no slot keeps the branch it already published (or what the transcript cache + * holds for it), and takes a slot as soon as one frees or the reader opens its + * card. + */ +const MAX_CONCURRENT_CHILD_STREAMS: number = 8; + +/** + * Longest a branch's transcript write is held back, in milliseconds. + * + * The same interval the parent transcript's own cache write uses, for the same + * reason: a worker publishing a step at a time would otherwise rewrite (and + * re-encrypt) the child's whole transcript on the thread that draws the screen. + */ +const BRANCH_CACHE_FLUSH_MS: number = 400; + /** Tool-item fields a launching record carries; the child Session id names its stream. */ interface SubagentOwnerItemData { toolName?: string; @@ -29,12 +75,53 @@ interface SubagentBranch { ownerToolCallId: string; reducer: DurableSessionReducer; stream?: HostSessionStream; + /** Whether this child's own stream is open right now. */ + subscribed: boolean; /** False until the child's first page settled, so its backfill is one publish. */ caughtUp: boolean; - /** How many times this branch's content changed. Monotonic per branch. */ - changes: number; + /** True when the child's stream failed before it published anything. */ + streamFailed: boolean; + /** + * Steps read back from the transcript cache, i.e. what this branch published + * in an earlier launch of the app. They are merged with whatever the child's + * stream publishes now, never replaced by it: a child stream can start at its + * newest page, so the cached prefix is the only copy of its earlier steps. + */ + cached: ChatMessageItemResponse[]; + /** + * The outcome the cached transcript ended on: running, completed or failed. + * + * A worker this phone watched before a restart is the one case where no stream + * will ever say how it ended — the records it published are on the host, and a + * host that is gone answers nothing — so its stored outcome is the only + * reading of it left. + */ + cachedStatus: string; items: ChatMessageItemResponse[]; + /** How many times this branch's content or status changed. Monotonic per branch. */ + changes: number; signature: string; + /** + * The worker's own lifecycle, derived from its stream: running, completed or + * failed. Empty means this coordinator has nothing to say about the worker, + * e.g. its stream failed before it published anything. + */ + status: string; + /** + * Whether that reading came from the worker's own stream. + * + * A branch restored from the transcript cache answers with the outcome it was + * stored with, and that is not a worker this session has seen finish: a phone + * that just started holds a stored "completed" for a worker that may be + * running its next turn right now. Until this child's stream has read a turn of + * its own, nothing here is treated as settled — in particular its stream is + * kept, because releasing it is what would make the stored reading permanent. + */ + liveStatus: boolean; + /** Monotonic stamp of when this child was last seen working; decides release order. */ + lastActivity: number; + /** Pending transcript write, or -1 when none is scheduled. */ + flushTimer: number; } /** @@ -53,9 +140,19 @@ interface SubagentBranch { * then draws that item as the card it already knew how to draw. A host that does * not name a child session produces no branch here at all, so an older desktop * keeps the plain tool rows it has always sent. + * + * Two facts about a branch are read from two different places, and are kept + * apart on purpose: + * + * - The launching item's own status is the spawn call's outcome. A host answers + * an asynchronous `AgentSpawn` as soon as the worker starts, so that status + * says nothing about whether the worker is still working. + * - The card's lifecycle is the worker's, so it is derived from the child's own + * stream here, and only that reading is what the card is fed. */ export class SubagentStreamCoordinator { private source: SubagentStreamSource; + private branchCache?: SubagentBranchCache; private notify: () => void; private onError: (error: Error) => void; private parentSessionId: string = ''; @@ -66,9 +163,15 @@ export class SubagentStreamCoordinator { private ownerDigests: Map = new Map(); /** Owner tool call id -> the render version this coordinator has published for it. */ private ownerVersions: Map = new Map(); + /** Child session ids holding no slot, oldest first; newest is served first. */ + private waiters: string[] = []; + /** Monotonic stamp backing `lastActivity`, so release order needs no clock. */ + private activitySeq: number = 0; - constructor(source: SubagentStreamSource, notify: () => void, onError: (error: Error) => void) { + constructor(source: SubagentStreamSource, notify: () => void, onError: (error: Error) => void, + branchCache?: SubagentBranchCache) { this.source = source; + this.branchCache = branchCache; this.notify = notify; this.onError = onError; } @@ -101,11 +204,58 @@ export class SubagentStreamCoordinator { if (this.parentSessionId.length === 0) return; const owner = SubagentStreamCoordinator.ownerData(event); if (!owner) return; - const childSessionId = owner.subagentSessionId || ''; - const ownerToolCallId = owner.toolCall ? (owner.toolCall.id || '') : ''; - if (childSessionId.length === 0 || ownerToolCallId.length === 0 || this.parentSessionId === childSessionId) return; - if (this.branches.has(childSessionId)) return; - this.openBranch(childSessionId, ownerToolCallId); + this.openBranch(owner.subagentSessionId || '', + owner.toolCall ? (owner.toolCall.id || '') : ''); + } + + /** + * Follows the children the transcript itself names. + * + * A session that is read from its cached transcript — a phone that just + * restarted, or a host that is not answering — delivers no parent records at + * all, and the durable link is still there: the launching item keeps the child + * Session id it was recorded with. Discovering children here is what lets a + * cold or offline view open the same branches, whose steps then come from the + * branch cache until the child's stream catches up. + */ + observeParentMessages(messages: ChatMessage[]): void { + if (this.parentSessionId.length === 0) return; + messages.forEach((message: ChatMessage) => { + (message.items || []).forEach((item: ChatMessageItemResponse) => { + const tool = item.tool; + if (!tool) return; + this.openBranch(tool.subagent_session_id || '', tool.id || ''); + }); + }); + } + + /** + * Makes sure the child behind one launching item is being followed. + * + * This is what a reader opening a card asks for. The branch may have lost its + * slot to the cap, or never had one; either way an open card has to show the + * worker's own steps rather than a prefix that stopped where its slot did. + */ + ensureSubscribed(ownerToolCallId: string): void { + if (this.parentSessionId.length === 0) return; + const childSessionId = this.owners.get(ownerToolCallId); + if (childSessionId === undefined) return; + const branch = this.branches.get(childSessionId); + if (!branch || branch.subscribed) return; + RemoteLogger.info(`subagent branch re-attached session=${SubagentStreamCoordinator.shortId(childSessionId)}`); + this.acquireSubscriber(branch); + } + + /** What this coordinator currently follows, for previews and focused tests. */ + followState(): SubagentFollowState { + let restored = 0; + this.branches.forEach((branch: SubagentBranch) => { restored += branch.cached.length; }); + return { + branches: this.branches.size, + subscribed: this.subscribedCount(), + waiting: this.waiters.length, + restored: restored + }; } /** @@ -134,20 +284,7 @@ export class SubagentStreamCoordinator { const branch = this.branches.get(childSessionId); if (!branch) return item; attached += this.ownerVersion(toolId, branch, item.tool); - if (branch.items.length === 0) return item; - // A host that publishes the process inside its own stream keeps this item - // on the path it has always used, and needs no branch attached here. - if (SubagentStreamCoordinator.parentCarriesBranchItems(items, index)) return item; - return { - type: item.type, - steering_id: item.steering_id, - round_index: item.round_index, - content: item.content, - tool: item.tool, - is_subagent: item.is_subagent, - subItems: branch.items, - images: item.images - }; + return SubagentStreamCoordinator.ownerItem(item, branch, index, items); }); if (attached === 0) return message; return { @@ -167,17 +304,71 @@ export class SubagentStreamCoordinator { }; } + /** + * One launching item, as the card reads it. + * + * Two things are replaced and nothing else: the status the card draws is the + * worker's own lifecycle, and the branch's items are the worker's own process. + * The launching record's status stays where it belongs — in the digest below, + * which is what decides whether the card repaints. + * + * A host that publishes the process inside its own stream keeps this item on + * the path it has always used, and needs no branch attached here. + */ + private static ownerItem(item: ChatMessageItemResponse, branch: SubagentBranch, ownerIndex: number, + items: ChatMessageItemResponse[]): ChatMessageItemResponse { + const tool = branch.status.length > 0 ? SubagentStreamCoordinator.toolWithStatus(item.tool, branch.status) : item.tool; + const subItems = branch.items.length === 0 || + SubagentStreamCoordinator.parentCarriesBranchItems(items, ownerIndex) ? item.subItems : branch.items; + if (tool === item.tool && subItems === item.subItems) return item; + return { + type: item.type, + steering_id: item.steering_id, + round_index: item.round_index, + content: item.content, + tool: tool, + is_subagent: item.is_subagent, + subItems: subItems, + images: item.images + }; + } + + /** The same tool record, with the worker's lifecycle as its status. */ + private static toolWithStatus(tool: RemoteToolStatusResponse | undefined, + status: string): RemoteToolStatusResponse | undefined { + if (!tool) return tool; + return { + permission_request_id: tool.permission_request_id, + id: tool.id, + name: tool.name, + status: status, + duration_ms: tool.duration_ms, + start_ms: tool.start_ms, + input_preview: tool.input_preview, + tool_input: tool.tool_input, + stdout: tool.stdout, + stderr: tool.stderr, + tool_output: tool.tool_output, + result_preview: tool.result_preview, + error_preview: tool.error_preview, + exit_code: tool.exit_code, + plan: tool.plan, + subagent_session_id: tool.subagent_session_id + }; + } + /** * The render version this coordinator publishes for one launch item. * - * Two independent things alter a card: the worker's own process grew, and the + * Three independent things alter a card: the worker's own process grew, the + * worker's own lifecycle changed (a turn settling adds no step), and the * launch item's own record was updated. A launch settles as soon as the worker * starts, and a background worker's launch settles while its stream has * nothing new to say, so a version that only counted branch growth would leave * the header of exactly that card behind: the row is keyed by what changed in * the message, and a tool item's own status is not part of that key. Folding - * the launch's own digest in here means a settled launch repaints the card it - * belongs to even when its worker publishes no further step. + * every one of those readings in here means a settled launch repaints the card + * it belongs to even when its worker publishes no further step. * * The value is cumulative rather than a per-publication delta: the transcript * the controller publishes must be identical when nothing changed, or a row @@ -187,6 +378,7 @@ export class SubagentStreamCoordinator { tool?: RemoteToolStatusResponse): number { const digest = [ `${branch.changes}`, + branch.status, tool ? (tool.name || '') : '', tool ? (tool.status || '') : '' ].join('\u0000'); @@ -231,47 +423,239 @@ export class SubagentStreamCoordinator { } private openBranch(childSessionId: string, ownerToolCallId: string): void { + // A host that names no child session, or names this very session, has no + // branch to open: the launch item stays the plain tool row it always was. + if (childSessionId.length === 0 || ownerToolCallId.length === 0) return; + if (this.parentSessionId === childSessionId) return; + if (this.owners.has(ownerToolCallId)) return; + const resumed = this.branches.get(childSessionId); + if (resumed !== undefined) { + // The same worker, named by a launch this session has not seen before: a + // reusable child that was continued. It is one branch — its steps are the + // worker's, not the launch's — but the launch happening now is what its + // records have to land under, and a branch the cap released has to be + // followed again, or the turn it is taking would never arrive. Only a new + // launch does this: the same launch republished by every poll is not a + // reason to take a slot back from a worker that is using one. + this.owners.set(ownerToolCallId, childSessionId); + resumed.ownerToolCallId = ownerToolCallId; + resumed.lastActivity = ++this.activitySeq; + if (!resumed.subscribed) this.acquireSubscriber(resumed); + return; + } const branch: SubagentBranch = { sessionId: childSessionId, ownerToolCallId: ownerToolCallId, reducer: new DurableSessionReducer(), + subscribed: false, caughtUp: false, - changes: 0, + streamFailed: false, + cached: [], + cachedStatus: '', items: [], - signature: '' + changes: 0, + signature: '', + status: '', + liveStatus: false, + lastActivity: ++this.activitySeq, + flushTimer: -1 }; this.branches.set(childSessionId, branch); this.owners.set(ownerToolCallId, childSessionId); + this.hydrate(branch); + this.acquireSubscriber(branch); + } + + /** + * Reads back what this child published in an earlier launch of the app. + * + * The steps land as the branch's cached prefix, and the child's stream is + * merged into them as it arrives, so a cold start shows a worker's process + * before (and without) its stream — and a worker whose stream only starts at + * its newest page keeps the earlier steps it had. + */ + private hydrate(branch: SubagentBranch): void { + const cache = this.branchCache; + if (!cache) return; + cache.loadBranch(branch.sessionId).then((messages: ChatMessage[]): void => { + const current = this.branches.get(branch.sessionId); + // A branch replaced by a session switch belongs to nobody now. + if (!current || current !== branch) return; + const cached = SubagentStreamCoordinator.branchItems(messages); + if (cached.length === 0) return; + branch.cached = cached; + branch.cachedStatus = SubagentStreamCoordinator.turnStatus(messages); + RemoteLogger.info( + `subagent branch restored session=${SubagentStreamCoordinator.shortId(branch.sessionId)} ` + + `steps=${cached.length} status=${branch.cachedStatus}` + ); + this.refresh(branch); + }).catch((error: Object): void => { + RemoteLogger.error(`subagent branch restore failed: ${SubagentStreamCoordinator.reason(error)}`); + }); + } + + /** + * Opens a child's stream if this session can follow one more child. + * + * Past the cap a slot is taken from another child, oldest activity first and + * a settled child before a working one: a worker that already reported is the + * one whose stream can be released for nothing, and a working one that loses + * its slot keeps the branch it published and waits to be re-attached. + */ + private acquireSubscriber(branch: SubagentBranch): void { + if (branch.subscribed) return; + if (this.subscribedCount() >= MAX_CONCURRENT_CHILD_STREAMS) { + const victim = this.releaseCandidate(branch); + if (!victim) { + this.waitForSlot(branch); + return; + } + this.releaseSubscription(victim, true); + } + this.forgetWaiter(branch); + this.subscribe(branch); + // Subscribing decides what can be said about the worker: a branch opened + // over the cap has no reading until this point. + this.refresh(branch); + } + + private subscribe(branch: SubagentBranch): void { try { - branch.stream = this.source.subscribeSession(childSessionId, { + branch.stream = this.source.subscribeSession(branch.sessionId, { onEvent: async (event: HostSessionEvent): Promise => { - this.applyChildEvent(childSessionId, event); + this.applyChildEvent(branch.sessionId, event); }, onCaughtUp: async (): Promise => { - const current = this.branches.get(childSessionId); + const current = this.branches.get(branch.sessionId); if (!current) return; current.caughtUp = true; + current.streamFailed = false; this.refresh(current); }, onGap: async (): Promise => { // The host restarted this child's stream: everything reduced so far // came from its previous epoch, so it is dropped before the replay. - const current = this.branches.get(childSessionId); + const current = this.branches.get(branch.sessionId); if (!current) return; current.reducer = new DurableSessionReducer(); current.caughtUp = false; }, - onError: (error: Error): void => { this.onError(error); } + onError: (error: Error): void => { + const current = this.branches.get(branch.sessionId); + // A child stream that failed before it published anything says nothing + // about the worker, so no lifecycle is invented for it: the card keeps + // the launching item's own status. The stream keeps retrying by itself. + if (current && current.items.length === 0 && current.cached.length === 0) { + current.streamFailed = true; + this.refresh(current); + } + this.onError(error); + } }); + branch.subscribed = true; + RemoteLogger.info( + `subagent branch followed session=${SubagentStreamCoordinator.shortId(branch.sessionId)} ` + + `streams=${this.subscribedCount()}/${this.branches.size}` + ); } catch (_error) { // A child stream that cannot be opened is not a reason to fail the // session on screen: the launch item keeps its own row and result, and its // branch stays empty instead of showing fabricated steps. - this.branches.delete(childSessionId); - this.owners.delete(ownerToolCallId); + this.releaseSubscription(branch, false); + if (branch.items.length === 0 && branch.cached.length === 0 && !branch.caughtUp) { + this.branches.delete(branch.sessionId); + this.owners.delete(branch.ownerToolCallId); + } + } + } + + /** Closes one child's stream, keeping the branch and everything it published. */ + private releaseSubscription(branch: SubagentBranch, wait: boolean): void { + const wasSubscribed = branch.subscribed; + const before = this.subscribedCount(); + branch.stream?.close(); + branch.stream = undefined; + branch.subscribed = false; + if (wasSubscribed) { + RemoteLogger.info( + `subagent branch released session=${SubagentStreamCoordinator.shortId(branch.sessionId)} ` + + `streams=${before - 1}/${this.branches.size}` + ); + } + // Whatever the child has published so far is written now: this branch may + // not be the one that is subscribed when its stream would have said more. + if (branch.changes > 0) this.flushBranch(branch); + if (wait) this.waitForSlot(branch); + } + + /** + * Which child gives up its slot, or undefined when every other child is + * already waiting. + * + * The most recently active children stay followed — a card the reader is + * watching is the one that just changed — and a settled child goes before a + * working one, because releasing it cannot lose a step. + */ + private releaseCandidate(except: SubagentBranch): SubagentBranch | undefined { + let settled: SubagentBranch | undefined = undefined; + let oldest: SubagentBranch | undefined = undefined; + this.branches.forEach((branch: SubagentBranch) => { + if (branch === except || !branch.subscribed) return; + if (oldest === undefined || branch.lastActivity < oldest.lastActivity) oldest = branch; + if (SubagentStreamCoordinator.isSettled(branch) && + (settled === undefined || branch.lastActivity < settled.lastActivity)) settled = branch; + }); + return settled || oldest; + } + + private waitForSlot(branch: SubagentBranch): void { + if (this.waiters.indexOf(branch.sessionId) < 0) this.waiters.push(branch.sessionId); + } + + private forgetWaiter(branch: SubagentBranch): void { + this.waiters = this.waiters.filter((sessionId: string): boolean => sessionId !== branch.sessionId); + } + + /** + * Follows waiting children as slots free, newest first. + * + * Newest first because a launch the reader just watched appear is the card + * most likely to be on screen; the older ones keep their branch and are served + * when their turn comes or when their card is opened. + */ + private drainWaiters(): void { + let budget = this.waiters.length; + while (budget > 0 && this.subscribedCount() < MAX_CONCURRENT_CHILD_STREAMS) { + budget--; + const sessionId = this.waiters.pop(); + if (sessionId === undefined) return; + const branch = this.branches.get(sessionId); + if (!branch || branch.subscribed) continue; + this.acquireSubscriber(branch); } } + private subscribedCount(): number { + let count = 0; + this.branches.forEach((branch: SubagentBranch) => { + if (branch.subscribed) count++; + }); + return count; + } + + /** + * A child that has settled: its own stream read a turn of it and no turn of it + * is in progress. + * + * The stream is what makes a branch settled. A reading that came out of the + * transcript cache is a reading of an earlier launch of the app, and releasing + * on it would keep a worker that is working right now reported as finished. + */ + private static isSettled(branch: SubagentBranch): boolean { + return branch.caughtUp && branch.liveStatus && branch.status.length > 0 && branch.status !== 'running'; + } + /** * One record of one child session. * @@ -287,18 +671,129 @@ export class SubagentStreamCoordinator { const branch = this.branches.get(childSessionId); if (!branch || this.parentSessionId.length === 0) return; if (!branch.reducer.apply(event)) return; + branch.streamFailed = false; + branch.lastActivity = ++this.activitySeq; if (!branch.caughtUp) return; this.refresh(branch); } + /** + * Republishes a branch that changed in any way the card can show. + * + * The items are what the card draws; the status is what its header says, and a + * child's turn can settle without publishing another item, so both are + * compared before the transcript is republished. + */ private refresh(branch: SubagentBranch): void { - const items = SubagentStreamCoordinator.branchItems(branch.reducer); + const wasSettled = SubagentStreamCoordinator.isSettled(branch); + const items = SubagentStreamCoordinator.mergeBranchItems( + branch.cached, SubagentStreamCoordinator.branchItems(branch.reducer.messages())); const signature = SubagentStreamCoordinator.branchSignature(items); - if (signature === branch.signature) return; + const live = SubagentStreamCoordinator.turnStatus(branch.reducer.messages()); + const status = SubagentStreamCoordinator.childStatus(branch, live); + branch.liveStatus = live.length > 0; + if (signature === branch.signature && status === branch.status) return; branch.signature = signature; branch.items = items; + branch.status = status; branch.changes++; this.notify(); + this.scheduleFlush(branch); + // A settled worker will not publish again, so it costs nothing to stop + // following it — unless it is the child that just held a slot, in which case + // its stream has already been closed above. It is the transition that + // releases: a worker that settled, was continued and then settled again keeps + // its stream until the cap wants the slot back, which is where a settled + // branch is the first one to be taken. + if (!wasSettled && SubagentStreamCoordinator.isSettled(branch) && branch.subscribed) { + this.releaseSubscription(branch, false); + this.drainWaiters(); + } + } + + /** + * The worker's own lifecycle, as its stream describes it. + * + * A child session is an ordinary session with its own turns, so the reading is + * the child's own: a turn of it still in progress means the worker is working, + * and the last turn's outcome is what a finished worker reported. Multi-turn + * children — a worker continued after it answered once — are running again the + * moment any of their turns is. + * + * Nothing is invented from what is missing. Before the child has said anything + * at all the worker was started and is not finished, so it reads as running; + * a stream that failed before publishing anything says nothing, and the + * launching item's own status stands. The one reading that survives a restart + * is the branch itself: a worker whose stored transcript is all this phone has + * left is reported at the outcome it was stored with, because its stream will + * never say more. + */ + private static childStatus(branch: SubagentBranch, live: string): string { + if (live.length > 0) return live; + if (branch.streamFailed && branch.cached.length === 0) return ''; + return branch.caughtUp && branch.cachedStatus.length > 0 ? branch.cachedStatus : 'running'; + } + + /** A child transcript's lifecycle reading, or '' when it holds no turn yet. */ + private static turnStatus(messages: ChatMessage[]): string { + let sawTurn = false; + let running = false; + let lastOutcome = ''; + messages.forEach((message: ChatMessage) => { + if (message.role !== 'assistant') return; + sawTurn = true; + const status = (message.status || '').toLowerCase(); + if (SubagentStreamCoordinator.isRunningStatus(status)) { + running = true; + return; + } + lastOutcome = SubagentStreamCoordinator.isFailedStatus(status) ? 'failed' : 'completed'; + }); + if (!sawTurn) return ''; + return running ? 'running' : lastOutcome; + } + + private static isRunningStatus(status: string): boolean { + return status === 'running' || status === 'active' || status === 'inprogress' || + status === 'preparing' || status === 'pending' || status === 'queued'; + } + + private static isFailedStatus(status: string): boolean { + return status === 'failed' || status === 'error' || status === 'timeout' || + status === 'cancelled' || status === 'canceled' || status === 'rejected'; + } + + /** + * Writes the child's transcript to the phone cache, at most once per interval. + * + * A child that is still publishing is not a reason to hold the write back + * either: the flush is what a later cold start renders from, so the newest + * state of a worker in progress is worth having. + * + * A stored child is one more session in the same per-device store the sessions + * the reader opens live in, and the store keeps its own bounded number of them. + * That is the right shelf for it: a child transcript is reconstructible by + * asking the host for the child's stream again, so being evicted costs a read, + * not a step. + */ + private scheduleFlush(branch: SubagentBranch): void { + if (!this.branchCache || branch.flushTimer !== -1) return; + branch.flushTimer = setTimeout((): void => { + branch.flushTimer = -1; + this.flushBranch(branch); + }, BRANCH_CACHE_FLUSH_MS); + } + + private flushBranch(branch: SubagentBranch): void { + const cache = this.branchCache; + if (!cache) return; + const messages = branch.reducer.messages(); + // Nothing of this child's own stream has arrived, so there is nothing to + // store: writing an empty transcript would erase what the cache holds. + if (messages.length === 0) return; + cache.saveBranch(branch.sessionId, messages).catch((error: Object): void => { + RemoteLogger.error(`subagent branch cache write failed: ${SubagentStreamCoordinator.reason(error)}`); + }); } /** @@ -308,15 +803,63 @@ export class SubagentStreamCoordinator { * user message (the brief it was spawned with), which the card already shows * as the branch title, so only the assistant's items are branch content. */ - private static branchItems(reducer: DurableSessionReducer): ChatMessageItemResponse[] { + private static branchItems(messages: ChatMessage[]): ChatMessageItemResponse[] { const items: ChatMessageItemResponse[] = []; - reducer.messages().forEach((message: ChatMessage) => { + messages.forEach((message: ChatMessage) => { if (message.role !== 'assistant') return; (message.items || []).forEach((item: ChatMessageItemResponse) => { items.push(item); }); }); return items; } + /** + * What the cache restored, with everything the child's stream published since. + * + * Neither list can replace the other. The stream replays a child session from + * its own start, so it restates steps the cache already holds — those are + * updated in place, which is how a step that finished while the app was closed + * shows its newer state without a second copy. And a stream can also start + * only at its newest page, so a cached step the stream never mentions is the + * only copy of it: it stays where it was. + */ + private static mergeBranchItems(cached: ChatMessageItemResponse[], + live: ChatMessageItemResponse[]): ChatMessageItemResponse[] { + if (cached.length === 0) return live; + if (live.length === 0) return cached; + const merged = cached.slice(); + const claimed: boolean[] = merged.map((_item: ChatMessageItemResponse): boolean => false); + live.forEach((item: ChatMessageItemResponse) => { + const index = merged.findIndex((candidate: ChatMessageItemResponse, position: number): boolean => + !claimed[position] && SubagentStreamCoordinator.sameStep(candidate, item)); + if (index < 0) { + merged.push(item); + return; + } + claimed[index] = true; + merged[index] = item; + }); + return merged; + } + + /** + * What makes two branch steps the same piece of work. + * + * A tool call is identified by its id, exactly as the transcript grouping + * identifies it; reasoning and text carry no id, so their kind and content are + * all there is to tell one from the other. Status and output are deliberately + * left out: a restated step carries the newer ones for the same step. + */ + private static sameStep(a: ChatMessageItemResponse, b: ChatMessageItemResponse): boolean { + const aId = a.tool ? (a.tool.id || '') : ''; + const bId = b.tool ? (b.tool.id || '') : ''; + if (aId.length > 0 || bId.length > 0) return aId.length > 0 && aId === bId; + return SubagentStreamCoordinator.stepFingerprint(a) === SubagentStreamCoordinator.stepFingerprint(b); + } + + private static stepFingerprint(item: ChatMessageItemResponse): string { + return [(item.type || '').toLowerCase(), item.content || ''].join('\u0000'); + } + /** Everything about a branch that can change what the card draws. */ private static branchSignature(items: ChatMessageItemResponse[]): string { const parts: string[] = []; @@ -331,15 +874,33 @@ export class SubagentStreamCoordinator { return `${items.length}\u0001${parts.join('\u0002')}`; } + private static shortId(value: string): string { + if (value.length <= 10) return value; + return `${value.slice(0, 6)}...${value.slice(-4)}`; + } + + private static reason(error: Object): string { + return error instanceof Error ? error.message : `${error}`; + } + private detach(): void { this.branches.forEach((branch: SubagentBranch) => { + if (branch.flushTimer !== -1) { + clearTimeout(branch.flushTimer); + branch.flushTimer = -1; + } + // The session is closing, so what the child published is written now: + // the timer that would have written it belongs to a session that is gone. + if (branch.changes > 0) this.flushBranch(branch); branch.stream?.close(); branch.stream = undefined; + branch.subscribed = false; }); this.branches.clear(); this.owners.clear(); this.ownerDigests.clear(); this.ownerVersions.clear(); + this.waiters = []; this.parentSessionId = ''; } } diff --git a/src/apps/mobile/harmonyos/tools/tests/composer-submit.test.cjs b/src/apps/mobile/harmonyos/tools/tests/composer-submit.test.cjs index b35a18e86e..9711345d9d 100644 --- a/src/apps/mobile/harmonyos/tools/tests/composer-submit.test.cjs +++ b/src/apps/mobile/harmonyos/tools/tests/composer-submit.test.cjs @@ -52,6 +52,8 @@ const coreSource = fs.readFileSync(path.join(__dirname, '../../entry/src/main/et const coreJs = ts.transpileModule(coreSource, {compilerOptions:{target:ts.ScriptTarget.ES2022,module:ts.ModuleKind.CommonJS}}).outputText; const coreExports = {}; new Function('require','exports',coreJs)(name => name.endsWith('RemoteUiState') ? {RemoteUiState:{emptyActiveTurn:()=>({}),emptyModelCatalog:()=>({})}} : + name.endsWith('SubagentBranchExpansionState') ? + {SubagentBranchExpansionState:class{isExpanded(){return false;}toggle(){return false;}}} : {ChatTimelineRevisionTracker:class{reset(){return 0;}},ChatTimelineRowStore:class{clear(){}}},coreExports); function submitComposer(core, session, text, images) { const submission=core.prepareComposerSubmission(session,text,images); submission.commit(); return () => submission.rollback(); diff --git a/src/apps/mobile/harmonyos/tools/tests/file-visibility.test.cjs b/src/apps/mobile/harmonyos/tools/tests/file-visibility.test.cjs index 3441073bd4..228fba23b3 100644 --- a/src/apps/mobile/harmonyos/tools/tests/file-visibility.test.cjs +++ b/src/apps/mobile/harmonyos/tools/tests/file-visibility.test.cjs @@ -492,6 +492,7 @@ function appChain() { openFilePreview: (route, request) => controller.open(route, request), downloadFile: () => {}, buildPlan: async () => {}, + branchExpanded: () => {}, send: async () => {}, voiceInput: async () => {}, inputChanged: () => {} diff --git a/src/apps/mobile/harmonyos/tools/tests/session-model-hydration.test.cjs b/src/apps/mobile/harmonyos/tools/tests/session-model-hydration.test.cjs index 9c06cfb60a..eb8f005389 100644 --- a/src/apps/mobile/harmonyos/tools/tests/session-model-hydration.test.cjs +++ b/src/apps/mobile/harmonyos/tools/tests/session-model-hydration.test.cjs @@ -12,6 +12,7 @@ new Function('require', 'exports', ts.transpileModule(source, {compilerOptions: beginSession() {} endSession() {} observeParentEvent() {} + observeParentMessages() {} decorate(messages) { return messages; } } }, }[name] || {}), exported); diff --git a/src/apps/mobile/harmonyos/tools/tests/subagent-branch-lifecycle.test.cjs b/src/apps/mobile/harmonyos/tools/tests/subagent-branch-lifecycle.test.cjs new file mode 100644 index 0000000000..cc54908d5d --- /dev/null +++ b/src/apps/mobile/harmonyos/tools/tests/subagent-branch-lifecycle.test.cjs @@ -0,0 +1,583 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const ts = require('typescript'); + +const ROOT = path.join(__dirname, '../../entry/src/main/ets'); +const COMPONENTS = path.join(ROOT, 'pages/components'); +const STATE = path.join(ROOT, 'pages/state'); +const cache = new Map(); + +/** The session-scoped logger, whose own platform kit is not reachable here. */ +const LOGGER = { RemoteLogger: { info() {}, warn() {}, error() {}, debug() {} } }; + +/** Loads one .ets module and resolves its relative imports from the source tree. */ +function load(relative) { + if (cache.has(relative)) return cache.get(relative); + const source = fs.readFileSync(path.join(ROOT, relative + '.ets'), 'utf8') + .replace(/@ObservedV2\s*/g, '').replace(/@Trace\s*/g, ''); + const js = ts.transpileModule(source, { + compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS } + }).outputText; + const exported = {}; + cache.set(relative, exported); + new Function('require', 'exports', js)(name => { + if (!name.startsWith('.')) return {}; + const target = path.relative(ROOT, path.resolve(path.join(ROOT, relative), '..', name)) + .split(path.sep).join('/'); + if (target === 'services/RemoteLogger') return LOGGER; + return fs.existsSync(path.join(ROOT, `${target}.ets`)) ? load(target) : {}; + }, exported); + return exported; +} + +function source(relative) { + return fs.readFileSync(path.join(ROOT, relative), 'utf8'); +} + +const { DurableSessionReducer } = load('services/DurableSessionReducer'); +const { SubagentStreamCoordinator } = load('services/SubagentStreamCoordinator'); +const { SubagentBranchExpansionState } = load('pages/state/SubagentBranchExpansionState'); +const ui = load('pages/state/ConversationUiModels'); +const { ChatMessageStructurePolicy: Policy } = load('pages/policy/ChatMessageStructurePolicy'); +const fixture = load('pages/preview/SubagentSwarmFixture'); + +const SESSION = fixture.SWARM_PARENT_SESSION; +const CHILD_SESSION = fixture.SWARM_DOCS_SESSION; + +/** The follow cap the coordinator enforces, read from the constant that states it. */ +const CAP = Number(/MAX_CONCURRENT_CHILD_STREAMS: number = (\d+)/.exec( + source('services/SubagentStreamCoordinator.ets'))[1]); +/** How long the coordinator holds a branch's transcript write back, in milliseconds. */ +const FLUSH_MS = Number(/BRANCH_CACHE_FLUSH_MS: number = (\d+)/.exec( + source('services/SubagentStreamCoordinator.ets'))[1]); + +function record(revision, id, type, order, data, sessionId = SESSION) { + return { + session_id: sessionId, + event: 'session-record', + payload: { + sessionId, + id: `item/${id}`, + revision, + turn: { + turnId: 'turn', turnIndex: 0, sessionId, timestamp: 1, + userMessage: { id: 'user', content: 'run the audit', timestamp: 1 }, + status: 'inprogress' + }, + round: { id: 'round', turnId: 'turn', roundIndex: 0, timestamp: 2, status: 'inprogress' }, + item: { type, data: Object.assign({ id, orderIndex: order, timestamp: 3 }, data) } + } + }; +} + +/** A launch item as the host records it: the child Session id is the whole link. */ +function launchRecord(revision, status, toolId = 'swarm-docs-spawn', sessionId = CHILD_SESSION, roundIndex = 0) { + const parent = record(revision, toolId, 'tool', roundIndex, { + toolName: 'AgentSpawn', + toolCall: { id: toolId, input: { agent_id: 'swarm-docs', agent_type: 'Explore', prompt: 'Audit it' } }, + startTime: 3, + status, + subagentSessionId: sessionId + }); + // A launch is republished as its call settles, and what it is republished in is + // the round the host is in when it settles — which is how a settled launch ends + // up among the parent rows that follow it. + parent.payload.round = { + id: `round-${roundIndex}`, turnId: 'turn', roundIndex, timestamp: 2, status: 'inprogress' + }; + parent.payload.item.data.orderIndex = roundIndex; + return parent; +} + +/** An ordinary row of the parent's own work, in the round the host published it. */ +function parentToolRecord(revision, toolId, name, order, roundIndex) { + const parent = record(revision, toolId, 'tool', order, { + toolName: name, + toolCall: { id: toolId, input: { pattern: 'swarm', path: 'src/agents' } }, + toolResult: { result: 'src/agents/swarm.ts', success: true }, + startTime: 3, + status: 'completed' + }); + parent.payload.round = { + id: `round-${roundIndex}`, turnId: 'turn', roundIndex, timestamp: 2, status: 'inprogress' + }; + return parent; +} + +/** One worker the wide delegation launched, all of them running at once. */ +function wideLaunchRecord(index) { + return launchRecord(1, 'completed', fixture.wideSpawnId(index), fixture.wideChildSession(index)); +} + +function childThinkingRecord(revision, sessionId = CHILD_SESSION) { + return record(revision, 'child-think', 'thinking', 1, { content: 'Reading the module' }, sessionId); +} + +function childToolRecord(revision, sessionId = CHILD_SESSION) { + return record(revision, 'child-read', 'tool', 2, { + toolName: 'Read', + toolCall: { id: 'child-read', input: { file_path: 'src/payments.ts' } }, + toolResult: { result: 'export const charge = 1', success: true }, + startTime: 4, + status: 'completed' + }, sessionId); +} + +function childTextRecord(revision, sessionId = CHILD_SESSION) { + return record(revision, 'child-text', 'text', 3, { content: 'Found no hard-coded keys' }, sessionId); +} + +/** The child's own turn record at a new revision: where a turn's outcome lands. */ +function childTurnSettle(revision, status, sessionId = CHILD_SESSION) { + return { + session_id: sessionId, + event: 'session-record', + payload: { + sessionId, id: 'turn/turn', revision, + turn: { + turnId: 'turn', turnIndex: 0, sessionId, timestamp: 1, + userMessage: { id: 'user', content: 'the brief', timestamp: 1 }, + status + } + } + }; +} + +const childRecords = () => [childThinkingRecord(1), childToolRecord(2), childTextRecord(3)]; + +/** A parent transcript holding the records, as the reducer projects it. */ +function parentMessages(records) { + const reducer = new DurableSessionReducer(); + records.forEach(event => reducer.apply(event)); + return reducer.messages(); +} + +/** + * A coordinator over stub streams, keeping the callbacks it registered. + * + * Nothing here is a fixture-shaped shortcut: the coordinator opens a stream per + * child exactly as it does in the app, and the callbacks it is handed are the + * ones a host's pages would drive. + */ +function coordinatorHarness(options = {}) { + const opened = new Map(); + const live = new Set(); + const closed = []; + const errors = []; + let notifications = 0; + const coordinator = new SubagentStreamCoordinator({ + subscribeSession: (sessionId, callbacks) => { + if (options.refuses) throw new Error(`no stream for ${sessionId}`); + opened.set(sessionId, callbacks); + live.add(sessionId); + return { + close: () => { closed.push(sessionId); live.delete(sessionId); }, + wake: () => {}, loadOlder: () => {}, isClosed: () => false + }; + } + }, () => { notifications++; }, error => errors.push(error), options.branchCache); + return { + coordinator, opened, live, closed, errors, + open: () => Array.from(live.keys()), + notifications: () => notifications + }; +} + +const flush = () => new Promise(resolve => setTimeout(resolve, 0)); + +async function until(predicate, label, attempts = 400) { + for (let index = 0; index < attempts; index++) { + if (predicate()) return; + await new Promise(resolve => setTimeout(resolve, 1)); + } + assert.fail(`timed out waiting for ${label}`); +} + +/** The card the bubble would draw for one launch, read through the shared policy. */ +function cardFor(coordinator, records, toolId) { + const decorated = coordinator.decorate(parentMessages(records)); + const items = ui.toConversationUiMessage(decorated[1]).items || []; + const card = Policy.subagentBranchAt(items, 0, toolId); + assert.ok(card, `the branch ${toolId} is found by the launch tool call`); + return card; +} + +/** The ordered group keys of one assistant frame: what a structured rebuild keys on. */ +function frameGroups(coordinator, records) { + const decorated = coordinator.decorate(parentMessages(records)); + const items = ui.toConversationUiMessage(decorated[1]).items || []; + return Policy.structuredGroups(items, 'item', true, 'active').map(group => group.key).join('|'); +} + +/** The phone's transcript store, in memory: what a restart reads from. */ +class FakeBranchCache { + constructor() { + this.stored = new Map(); + this.writes = 0; + } + + async loadBranch(sessionId) { + const stored = this.stored.get(sessionId); + return stored === undefined ? [] : JSON.parse(JSON.stringify(stored)); + } + + async saveBranch(sessionId, messages) { + this.writes++; + // Stored as plain rows, which is what the real store does: a branch that + // only survives in memory is exactly what this cache exists to replace. + this.stored.set(sessionId, JSON.parse(JSON.stringify(messages))); + } +} + +// ── The disclosure outlives the row it was opened in ──────────────────────── + +test('a branch disclosure is kept per branch, not per render pass', () => { + const expansion = new SubagentBranchExpansionState(); + assert.equal(expansion.isExpanded('swarm-docs-spawn'), false, 'a branch starts closed'); + + assert.equal(expansion.toggle('swarm-docs-spawn'), true, 'opening answers the new state'); + assert.equal(expansion.isExpanded('swarm-docs-spawn'), true); + assert.equal(expansion.isExpanded('swarm-payments-spawn'), false, + 'another branch is not opened by it'); + + assert.equal(expansion.toggle('swarm-docs-spawn'), false, 'closing answers the new state'); + assert.equal(expansion.isExpanded('swarm-docs-spawn'), false); + + expansion.setExpanded('swarm-docs-spawn', true); + assert.equal(expansion.isExpanded('swarm-docs-spawn'), true, 'the owner can set it directly'); + assert.equal(expansion.toggle(''), false, 'a branch with no identity has no disclosure'); + assert.equal(expansion.isExpanded(''), false); +}); + +test('the disclosure survives the row being rebuilt around it', () => { + const harness = coordinatorHarness(); + const { coordinator, opened } = harness; + coordinator.beginSession(SESSION); + coordinator.observeParentEvent(launchRecord(1, 'running')); + const callbacks = opened.get(CHILD_SESSION); + callbacks.onEvent(childThinkingRecord(1)); + callbacks.onCaughtUp(); + + // The parent's own rows, published while its worker runs. + const parentRows = [ + parentToolRecord(1, 'swarm-parent-grep', 'Grep', 0, 5), + parentToolRecord(1, 'swarm-parent-read', 'Read', 1, 5) + ]; + const before = [launchRecord(1, 'running', 'swarm-docs-spawn', CHILD_SESSION, 0), ...parentRows]; + const card = cardFor(coordinator, before, 'swarm-docs-spawn'); + // The reader opens the card: the same key the card reads and writes. + const expansion = new SubagentBranchExpansionState(); + expansion.toggle(card.tool.id); + assert.equal(expansion.isExpanded(card.tool.id), true); + + // The launch settles in a later round. The launch item is republished in the + // round the host is in when its call returns, so it lands after the parent rows + // that followed it: the structured groups of the frame reorder, and the whole + // keyed subtree below the row — the card included — is rebuilt with them. + const settled = [launchRecord(2, 'completed', 'swarm-docs-spawn', CHILD_SESSION, 5), ...parentRows]; + assert.notEqual(frameGroups(coordinator, settled), frameGroups(coordinator, before), + 'the frame is rebuilt at that moment'); + assert.ok(frameGroups(coordinator, before).indexOf('subagent') < + frameGroups(coordinator, before).indexOf('tool'), + 'the card was drawn above the parent rows before the settle'); + assert.ok(frameGroups(coordinator, settled).indexOf('subagent') > + frameGroups(coordinator, settled).indexOf('tool'), + 'and below them after it'); + + const settledCard = cardFor(coordinator, settled, 'swarm-docs-spawn'); + assert.equal(settledCard.tool.id, card.tool.id, + 'the branch is the same branch: its identity is the launch, not the render path'); + assert.equal(expansion.isExpanded(settledCard.tool.id), true, + 'and the card the rebuild draws is still the one the reader opened'); + assert.equal(harness.notifications() > 0, true, 'the settle republished the parent transcript'); + coordinator.endSession(); +}); + +test('the card reads and writes the conversation\'s disclosure, and owns none', () => { + const card = source('pages/components/SubagentTaskCard.ets'); + assert.match(card, /@Param expansion: SubagentBranchExpansionState/, + 'the disclosure is a param, so the conversation owns it'); + assert.doesNotMatch(card, /@Local expanded/, + 'a disclosure kept in the card is lost the moment the row is rebuilt'); + assert.match(card, /this\.expansion\.isExpanded\(this\.branchKey\(\)\)/, + 'the card reads it through the branch key'); + assert.match(card, /this\.expansion\.toggle\(this\.branchKey\(\)\)/, + 'and writes it back through the same key'); + assert.match(source('pages/state/SubagentBranchExpansionState.ets'), /@Trace expanded: boolean/, + 'the disclosure is traced, so opening it repaints the card that reads it'); + + const timeline = source('pages/components/ChatTimeline.ets'); + assert.equal((timeline.match(/subagentBranches: this\.subagentBranches/g) || []).length, 3, + 'the pane hands the disclosure to the list, and the list to both row call sites'); + + const bubble = source('pages/components/ChatMessageBubble.ets'); + assert.match(bubble, /branchId: entry\.tool\?\.id \|\| ''/, + 'the bubble keys the card by the tool call that launched it'); + assert.match(bubble, /expansion: this\.subagentBranches/); + + const view = source('pages/components/ConversationView.ets'); + assert.match(view, /subagentBranches: this\.state\.subagentBranches/, + 'the conversation view passes the state it projects'); + assert.match(source('pages/state/ConversationViewState.ets'), + /state\.subagentBranches = core\.subagentBranches/, + 'and the projection carries the owner\'s instance rather than a fresh one'); + assert.match(source('pages/state/ConversationCoreState.ets'), + /readonly subagentBranches: SubagentBranchExpansionState/, + 'which is owned by the conversation itself'); +}); + +// ── A session follows a bounded number of its children ────────────────────── + +test('a session follows no more of its children than the cap allows', () => { + const harness = coordinatorHarness(); + const { coordinator, opened } = harness; + coordinator.beginSession(SESSION); + for (let index = 0; index < CAP + 2; index++) coordinator.observeParentEvent(wideLaunchRecord(index)); + + const state = coordinator.followState(); + assert.equal(state.branches, CAP + 2, 'every worker keeps its own branch'); + assert.equal(state.subscribed, CAP, 'but only the cap of them hold a stream'); + assert.equal(state.waiting, 2, 'and the two that lost a slot are waiting for one'); + + const followed = harness.open(); + assert.deepEqual(followed, Array.from({ length: CAP }, (_entry, index) => fixture.wideChildSession(index + 2)), + 'the workers that just started stay followed, and the oldest ones wait'); + assert.deepEqual(harness.closed, [fixture.wideChildSession(0), fixture.wideChildSession(1)], + 'nothing is released that has not already published what it published'); + coordinator.endSession(); +}); + +test('a card the reader opens takes a slot back for its worker', () => { + const harness = coordinatorHarness(); + const { coordinator, opened } = harness; + coordinator.beginSession(SESSION); + for (let index = 0; index < CAP + 2; index++) coordinator.observeParentEvent(wideLaunchRecord(index)); + const before = coordinator.followState(); + + // The reader opens the card of the first worker, whose stream was released + // when the delegation grew past the cap. + coordinator.ensureSubscribed(fixture.wideSpawnId(0)); + + const after = coordinator.followState(); + assert.equal(opened.has(fixture.wideChildSession(0)), true, 'the opened worker is followed again'); + assert.equal(after.subscribed, before.subscribed, 'still inside the cap'); + assert.equal(after.waiting, before.waiting, 'and still the same number of workers want a slot'); + assert.equal(harness.closed.filter(id => id === fixture.wideChildSession(0)).length, 1, + 'its earlier stream was closed, not duplicated'); + + // Following a worker that already holds a stream is a no-op. + const streams = Array.from(opened.keys()).length; + coordinator.ensureSubscribed(fixture.wideSpawnId(0)); + assert.equal(Array.from(opened.keys()).length, streams, 're-attaching twice opens nothing twice'); + coordinator.endSession(); +}); + +test('a worker that reported hands its slot to one that is still working', () => { + const harness = coordinatorHarness(); + const { coordinator, opened } = harness; + coordinator.beginSession(SESSION); + for (let index = 0; index < CAP + 2; index++) coordinator.observeParentEvent(wideLaunchRecord(index)); + const working = fixture.wideChildSession(2); + assert.equal(opened.has(working), true, 'the third worker is followed'); + + // It answers: its turn settles, which is the one change that needs no step. + // The child's first page has to have landed for its turn to be readable. + const callbacks = opened.get(working); + callbacks.onCaughtUp(); + callbacks.onEvent(childTurnSettle(9, 'completed', working)); + + const state = coordinator.followState(); + assert.equal(state.subscribed, CAP, 'the slot it held is taken again immediately'); + assert.equal(state.waiting, 1, 'and the worker that was waiting longest is no longer waiting'); + assert.ok(harness.open().includes(fixture.wideChildSession(1)), 'it is the one that was served'); + coordinator.endSession(); +}); + +// ── What a worker published outlives the launch of the app ────────────────── + +test('a worker already read is still there after a restart', async () => { + const branchCache = new FakeBranchCache(); + const first = coordinatorHarness({ branchCache }); + first.coordinator.beginSession(SESSION); + first.coordinator.observeParentEvent(launchRecord(1, 'completed')); + const callbacks = first.opened.get(CHILD_SESSION); + childRecords().forEach(event => callbacks.onEvent(event)); + callbacks.onCaughtUp(); + first.coordinator.endSession(); // the session closes: what the child published is written now + await until(() => branchCache.stored.has(CHILD_SESSION), 'the child transcript to be stored'); + + const stored = branchCache.stored.get(CHILD_SESSION); + assert.deepEqual(stored.filter(message => message.role === 'assistant').length, 1, + 'the stored rows are the worker\'s own transcript'); + assert.equal(branchCache.writes, 1, 'and it was written once, under the child Session id'); + assert.equal(stored[0].id, 'user', 'the brief the worker was spawned with is part of it'); + + // A phone that just started: the same session, and a child whose stream has + // not answered yet. Everything that is drawn now came out of the cache above. + const cold = coordinatorHarness({ branchCache }); + cold.coordinator.beginSession(SESSION); + cold.coordinator.observeParentEvent(launchRecord(1, 'completed')); + assert.equal(cold.coordinator.followState().restored, 0, 'nothing is drawn before the read lands'); + await until(() => cold.coordinator.followState().restored === 3, 'the branch to be restored'); + + const card = cardFor(cold.coordinator, [launchRecord(1, 'completed')], 'swarm-docs-spawn'); + assert.deepEqual((card.subItems || []).map(child => child.type), ['thinking', 'tool', 'text'], + 'the worker\'s steps are drawn from the transcript the phone stored'); + assert.equal(card.subItems[1].tool.name, 'Read', 'with the shapes a live branch draws'); + cold.coordinator.endSession(); +}); + +test('a worker that published before a restart is not drawn twice after one', async () => { + const branchCache = new FakeBranchCache(); + const first = coordinatorHarness({ branchCache }); + first.coordinator.beginSession(SESSION); + first.coordinator.observeParentEvent(launchRecord(1, 'completed')); + childRecords().forEach(event => first.opened.get(CHILD_SESSION).onEvent(event)); + first.coordinator.endSession(); + await until(() => branchCache.stored.has(CHILD_SESSION), 'the child transcript to be stored'); + + const cold = coordinatorHarness({ branchCache }); + cold.coordinator.beginSession(SESSION); + cold.coordinator.observeParentEvent(launchRecord(1, 'completed')); + await until(() => cold.coordinator.followState().restored === 3, 'the branch to be restored'); + + // The host replays the child from its own start, which restates every step the + // cache already holds, and only then publishes what happened while the phone + // was closed. + const replayed = cold.opened.get(CHILD_SESSION); + replayed.onCaughtUp(); + childRecords().forEach(event => replayed.onEvent(event)); + const settled = childToolRecord(4); + settled.payload.item.data.status = 'failed'; + replayed.onEvent(settled); + replayed.onEvent(childTurnSettle(9, 'completed')); + + const card = cardFor(cold.coordinator, [launchRecord(1, 'completed')], 'swarm-docs-spawn'); + assert.deepEqual((card.subItems || []).map(child => child.type), ['thinking', 'tool', 'text'], + 'the replay restates steps instead of adding them'); + assert.equal(card.subItems[1].tool.status, 'failed', + 'and a step that changed while the app was closed shows its newer state'); + assert.equal(cold.coordinator.followState().restored, 3, 'the restored prefix is what it was'); + assert.equal(card.tool.status, 'completed', + 'the worker reported before the restart, and its card says so without a stream'); + cold.coordinator.endSession(); +}); + +test('a worker still running before a restart is not reported as finished after one', async () => { + const branchCache = new FakeBranchCache(); + const first = coordinatorHarness({ branchCache }); + first.coordinator.beginSession(SESSION); + first.coordinator.observeParentEvent(launchRecord(1, 'completed')); + childRecords().forEach(event => first.opened.get(CHILD_SESSION).onEvent(event)); + first.coordinator.endSession(); + await until(() => branchCache.stored.has(CHILD_SESSION), 'the child transcript to be stored'); + + const cold = coordinatorHarness({ branchCache }); + cold.coordinator.beginSession(SESSION); + cold.coordinator.observeParentEvent(launchRecord(1, 'completed')); + await until(() => cold.coordinator.followState().restored === 3, 'the branch to be restored'); + // The child's stream answers nothing: this is the whole reading there is. + cold.opened.get(CHILD_SESSION).onCaughtUp(); + assert.equal(cardFor(cold.coordinator, [launchRecord(1, 'completed')], 'swarm-docs-spawn').tool.status, + 'running', 'a turn that never ended is still running, cache or no cache'); + cold.coordinator.endSession(); +}); + +test('a stored outcome does not settle a worker whose stream has not answered', async () => { + const branchCache = new FakeBranchCache(); + const first = coordinatorHarness({ branchCache }); + first.coordinator.beginSession(SESSION); + first.coordinator.observeParentEvent(launchRecord(1, 'completed')); + const settled = first.opened.get(CHILD_SESSION); + childRecords().forEach(event => settled.onEvent(event)); + settled.onCaughtUp(); + settled.onEvent(childTurnSettle(9, 'completed')); + first.coordinator.endSession(); + await until(() => branchCache.stored.has(CHILD_SESSION), 'the child transcript to be stored'); + + // A phone that just started: the stored transcript says this worker finished, + // and the worker has been handed another turn since. + const cold = coordinatorHarness({ branchCache }); + cold.coordinator.beginSession(SESSION); + cold.coordinator.observeParentEvent(launchRecord(1, 'completed')); + await until(() => cold.coordinator.followState().restored === 3, 'the branch to be restored'); + assert.equal(cold.coordinator.followState().subscribed, 1, + 'the stored outcome does not release the stream: only this child can say what it is doing'); + const cardStatus = () => cardFor(cold.coordinator, [launchRecord(1, 'completed')], 'swarm-docs-spawn').tool.status; + assert.equal(cardStatus(), 'running', + 'a worker whose stream has not answered yet is not reported as finished'); + + // The stream catches up and says nothing: the host holds no more of this + // child, so the outcome the phone stored is the reading that stands. + const live = cold.opened.get(CHILD_SESSION); + live.onCaughtUp(); + assert.equal(cardStatus(), 'completed', 'and a stored outcome is what the card falls back to'); + assert.equal(cold.coordinator.followState().subscribed, 1, 'still followed, so it can still correct it'); + + // The host does answer after all, with the turn the worker is running now. + live.onEvent(childTurnSettle(10, 'inprogress')); + assert.equal(cardStatus(), 'running', + 'and a worker that is working right now is reported as working, not as it was stored'); + cold.coordinator.endSession(); +}); + +test('the branch cache is written at most once per interval while a worker streams', async () => { + const branchCache = new FakeBranchCache(); + const harness = coordinatorHarness({ branchCache }); + harness.coordinator.beginSession(SESSION); + harness.coordinator.observeParentEvent(launchRecord(1, 'completed')); + const callbacks = harness.opened.get(CHILD_SESSION); + childRecords().forEach(event => callbacks.onEvent(event)); + callbacks.onCaughtUp(); + assert.equal(branchCache.writes, 0, 'a streaming worker does not rewrite its transcript per step'); + + await until(() => branchCache.writes === 1, 'the first write', Math.ceil(FLUSH_MS / 5) + 200); + assert.equal(branchCache.writes, 1, 'and the write holds the rest of the interval back'); + harness.coordinator.endSession(); + await flush(); + assert.equal(branchCache.writes, 2, 'closing the session writes what is there, once more'); +}); + +test('a worker that is launched again after it settled is followed again', () => { + const harness = coordinatorHarness(); + const { coordinator, opened } = harness; + coordinator.beginSession(SESSION); + coordinator.observeParentEvent(launchRecord(1, 'completed')); + const first = opened.get(CHILD_SESSION); + childRecords().forEach(event => first.onEvent(event)); + first.onCaughtUp(); + first.onEvent(childTurnSettle(4, 'completed')); + + assert.equal(coordinator.followState().subscribed, 0, + 'the worker reported, so its stream was released and its branch kept'); + + // The same worker is launched again: a new tool call, the same child Session. + coordinator.observeParentEvent(launchRecord(5, 'completed', 'swarm-docs-resume')); + assert.equal(coordinator.followState().branches, 1, 'it is one branch, not two'); + assert.equal(coordinator.followState().subscribed, 1, 'and it is followed again'); + const resumed = opened.get(CHILD_SESSION); + assert.equal(resumed === first, false, 'which is a fresh stream, not the released one'); + + // The turn it is taking now arrives as a running turn of the same child. + resumed.onCaughtUp(); + resumed.onEvent(childRecords()[2]); + resumed.onEvent(childTurnSettle(6, 'inprogress')); + const live = cardFor(coordinator, [launchRecord(5, 'completed', 'swarm-docs-resume')], 'swarm-docs-resume'); + assert.equal(live.tool.status, 'running', + 'the card of the launch that is happening now reports the worker, not the launch'); + assert.equal((live.subItems || []).length > 0, true, 'and it draws the worker it launched'); + + // The same launch republished by every poll is not a reason to take a slot. + const streams = coordinator.followState().subscribed; + coordinator.observeParentEvent(launchRecord(5, 'completed', 'swarm-docs-resume')); + assert.equal(coordinator.followState().subscribed, streams, 'the same launch changes nothing'); + assert.equal(opened.get(CHILD_SESSION) === resumed, true, 'and opens no second stream'); + + // It reports again: the branch settles at the newer turn's outcome. + resumed.onEvent(childTurnSettle(7, 'completed')); + const settled = cardFor(coordinator, [launchRecord(5, 'completed', 'swarm-docs-resume')], 'swarm-docs-resume'); + assert.equal(settled.tool.status, 'completed', 'and a finished worker reads as finished'); + coordinator.endSession(); +}); diff --git a/src/apps/mobile/harmonyos/tools/tests/subagent-process-visibility.test.cjs b/src/apps/mobile/harmonyos/tools/tests/subagent-process-visibility.test.cjs index 06a3ec50de..e3cbdf66db 100644 --- a/src/apps/mobile/harmonyos/tools/tests/subagent-process-visibility.test.cjs +++ b/src/apps/mobile/harmonyos/tools/tests/subagent-process-visibility.test.cjs @@ -7,6 +7,9 @@ const ts = require('typescript'); const ROOT = path.join(__dirname, '../../entry/src/main/ets'); const cache = new Map(); +/** The session-scoped logger, whose own platform kit is not reachable here. */ +const LOGGER = { RemoteLogger: { info() {}, warn() {}, error() {}, debug() {} } }; + /** Loads one .ets module and resolves its relative imports from the source tree. */ function load(relative) { if (cache.has(relative)) return cache.get(relative); @@ -22,6 +25,7 @@ function load(relative) { if (!name.startsWith('.')) return {}; const target = path.relative(ROOT, path.resolve(path.join(ROOT, relative), '..', name)) .split(path.sep).join('/'); + if (target === 'services/RemoteLogger') return LOGGER; return fs.existsSync(path.join(ROOT, `${target}.ets`)) ? load(target) : {}; }, exported); return exported; @@ -123,17 +127,80 @@ function childRecords() { return [childThinkingRecord(1), childToolRecord(2), childTextRecord(3)]; } +/** + * The child's own turn record at a new revision. + * + * A worker's turn ending publishes no item: the turn's own status is the only + * place a finished worker says it is finished, and it is what a card's header + * has to be read from. + */ +function childTurnSettle(revision, status, sessionId = CHILD_SESSION) { + return { + session_id: sessionId, + event: 'session-record', + payload: { + sessionId, id: 'turn/turn', revision, + turn: { + turnId: 'turn', turnIndex: 0, sessionId, timestamp: 1, + userMessage: { id: 'user', content: 'the brief', timestamp: 1 }, + status + } + } + }; +} + +/** One record of a child's own turn, on a turn of its choosing. */ +function childTurnRecord(turnId, id, type, order, data) { + return { + session_id: CHILD_SESSION, + event: 'session-record', + payload: { + sessionId: CHILD_SESSION, id: `item/${id}`, revision: 1, + turn: { + turnId, turnIndex: 1, sessionId: CHILD_SESSION, timestamp: 9, + userMessage: { id: `${turnId}-user`, content: 'carry on', timestamp: 9 }, status: 'inprogress' + }, + round: { id: `${turnId}-round`, turnId, roundIndex: 0, timestamp: 10, status: 'inprogress' }, + item: { type, data: Object.assign({ id, orderIndex: order, timestamp: 11 }, data) } + } + }; +} + +/** A turn's own record at a new revision, for a turn other than the first. */ +function childTurnHeaderSettle(turnId, revision, status) { + return { + session_id: CHILD_SESSION, + event: 'session-record', + payload: { + sessionId: CHILD_SESSION, id: `turn/${turnId}`, revision, + turn: { + turnId, turnIndex: 1, sessionId: CHILD_SESSION, timestamp: 9, + userMessage: { id: `${turnId}-user`, content: 'carry on', timestamp: 9 }, status + } + } + }; +} + +/** The card a decorated parent transcript draws for one launch. */ +function cardOf(coordinator, records, toolId = SWARM_CALL_ID) { + const decorated = coordinator.decorate(parentMessages(records)); + const card = Policy.subagentBranchAt(ui.toConversationUiMessage(decorated[1]).items || [], 0, toolId); + assert.ok(card, 'the launch is drawn as a card'); + return card; +} + /** * A coordinator over a stub stream per child session, plus the callbacks it * registered, so a test can deliver child records as the host would. */ -function coordinatorHarness() { +function coordinatorHarness(options = {}) { const opened = new Map(); const closed = []; const errors = []; let notifications = 0; const coordinator = new SubagentStreamCoordinator({ subscribeSession: (sessionId, callbacks) => { + if (options.refuses) throw new Error(`no stream for ${sessionId}`); opened.set(sessionId, callbacks); return { close: () => { closed.push(sessionId); }, wake: () => {}, loadOlder: () => {}, isClosed: () => false }; } @@ -442,6 +509,132 @@ test('the same child record published twice adds one step', () => { coordinator.endSession(); }); +test('a launch record that settled does not settle the worker it started', () => { + const harness = coordinatorHarness(); + const { coordinator, opened } = harness; + coordinator.beginSession(SESSION); + // The host answers an asynchronous `AgentSpawn` as soon as the worker starts, + // so the launching item is completed while its worker has barely begun. + coordinator.observeParentEvent(launchRecord(1, 'completed')); + const callbacks = opened.get(CHILD_SESSION); + callbacks.onEvent(childThinkingRecord(1)); + callbacks.onCaughtUp(); + + const card = cardOf(coordinator, [launchRecord(1, 'completed')]); + assert.equal(card.tool.status, 'running', + 'the header reports the turn its worker is in, not the call that started it'); + assert.equal((card.subItems || []).length, 1, 'with the step its worker has published'); + coordinator.endSession(); +}); + +test("a worker's turn settling is what settles its card", () => { + const harness = coordinatorHarness(); + const { coordinator, opened } = harness; + coordinator.beginSession(SESSION); + coordinator.observeParentEvent(launchRecord(1, 'completed')); + const callbacks = opened.get(CHILD_SESSION); + childRecords().forEach(event => callbacks.onEvent(event)); + callbacks.onCaughtUp(); + assert.equal(cardOf(coordinator, [launchRecord(1, 'completed')]).tool.status, 'running'); + + // The worker's turn ends and it publishes no further step: the turn's own + // status is the only thing that moved. + callbacks.onEvent(childTurnSettle(9, 'completed')); + const done = cardOf(coordinator, [launchRecord(1, 'completed')]); + assert.equal(done.tool.status, 'completed', 'the card is settled by its worker reporting'); + assert.equal((done.subItems || []).length, 3, 'and keeps every step the worker published'); + + // The same reading covers a worker that ended badly. + const failed = coordinatorHarness(); + failed.coordinator.beginSession(SESSION); + failed.coordinator.observeParentEvent(launchRecord(1, 'completed')); + const failing = failed.opened.get(CHILD_SESSION); + childRecords().forEach(event => failing.onEvent(event)); + failing.onCaughtUp(); + failing.onEvent(childTurnSettle(9, 'error')); + assert.equal(cardOf(failed.coordinator, [launchRecord(1, 'completed')]).tool.status, 'failed', + 'a worker whose turn ended in error is drawn as failed'); + failed.coordinator.endSession(); + coordinator.endSession(); +}); + +test('a child stream that said nothing leaves the launch record to speak for its worker', () => { + // The child's stream fails before it publishes anything: nothing is known about + // the worker, so nothing is invented for it. + const broken = coordinatorHarness(); + broken.coordinator.beginSession(SESSION); + broken.coordinator.observeParentEvent(launchRecord(1, 'completed')); + broken.opened.get(CHILD_SESSION).onError(new Error('stream failed')); + assert.equal(cardOf(broken.coordinator, [launchRecord(1, 'completed')]).tool.status, 'completed', + 'the launching item keeps the status it was recorded with'); + assert.equal(broken.errors.length, 1, 'and the failure is still reported'); + broken.coordinator.endSession(); + + // A child whose stream cannot even be opened is the same reading. + const refused = coordinatorHarness({ refuses: true }); + refused.coordinator.beginSession(SESSION); + refused.coordinator.observeParentEvent(launchRecord(1, 'completed')); + assert.equal(cardOf(refused.coordinator, [launchRecord(1, 'completed')]).tool.status, 'completed', + 'a worker that cannot be followed at all is drawn as its launch was recorded'); + refused.coordinator.endSession(); +}); + +test('a worker continued after it answered reports as running again', () => { + const harness = coordinatorHarness(); + const { coordinator, opened } = harness; + coordinator.beginSession(SESSION); + coordinator.observeParentEvent(launchRecord(1, 'completed')); + const callbacks = opened.get(CHILD_SESSION); + childRecords().forEach(event => callbacks.onEvent(event)); + callbacks.onCaughtUp(); + callbacks.onEvent(childTurnSettle(9, 'completed')); + assert.equal(cardOf(coordinator, [launchRecord(1, 'completed')]).tool.status, 'completed'); + + // The same worker is handed a second turn: it is working again. + callbacks.onEvent(childTurnRecord('turn-2', 'child-think-2', 'thinking', 0, { content: 'Second pass' })); + assert.equal(cardOf(coordinator, [launchRecord(1, 'completed')]).tool.status, 'running', + 'any turn of a worker still in progress is the worker still working'); + callbacks.onEvent(childTurnHeaderSettle('turn-2', 2, 'completed')); + assert.equal(cardOf(coordinator, [launchRecord(1, 'completed')]).tool.status, 'completed', + 'and its last turn is what it reports when none is'); + coordinator.endSession(); +}); + +test('the fixture keeps a settled launch and a working worker apart, beat by beat', async () => { + const harness = await swarmHarness(); + const { script, timeline, controller } = harness; + const cardById = id => cardsFromTimeline(timeline).cards.find(card => card.tool.id === id); + const statusOf = id => (cardById(id) || { tool: {} }).tool.status; + const launches = ['swarm-payments-spawn', 'swarm-flaky-spawn', 'swarm-docs-spawn']; + try { + beatThrough(script, 13); // every launch has settled; no worker has reported yet + await until(() => cardCount(timeline) === 3, 'three launches'); + await until(() => launches.every(id => (cardById(id)?.subItems || []).length > 0), + 'the workers to publish their steps'); + + assert.equal(statusOf('swarm-docs-spawn'), 'running', + 'a launch that settled while its worker is still working draws a running card'); + assert.equal(statusOf('swarm-payments-spawn'), 'running', + 'a worker that finished before its branch was opened is read from its own turn'); + assert.equal(statusOf('swarm-flaky-spawn'), 'running', + 'and a worker whose own tools failed is still working until its turn ends'); + const docsSteps = (cardById('swarm-docs-spawn')?.subItems || []).length; + + beatThrough(script, 14); // the first worker's turn ends + await until(() => statusOf('swarm-payments-spawn') === 'completed', 'the first worker to report'); + beatThrough(script, 15); + await until(() => statusOf('swarm-flaky-spawn') === 'failed', 'the second worker to report'); + beatThrough(script, 16); + await until(() => statusOf('swarm-docs-spawn') === 'completed', 'the last worker to report'); + + assert.equal((cardById('swarm-docs-spawn')?.subItems || []).length, docsSteps, + 'a worker settling adds no step: only its status moved'); + assert.deepEqual(harness.errors, [], 'no worker stream failed'); + } finally { + controller.stop(); + } +}); + test('a settled launch repaints its card even when its worker says nothing new', () => { const harness = coordinatorHarness(); const { coordinator, opened } = harness; @@ -453,14 +646,15 @@ test('a settled launch repaints its card even when its worker says nothing new', const running = coordinator.decorate(parentMessages([launchRecord(1, 'running')])); const runningCard = Policy.subagentBranchAt(ui.toConversationUiMessage(running[1]).items || [], 0, SWARM_CALL_ID); - assert.equal(runningCard.tool.status, 'running', 'the launch is drawn at the status it was recorded with'); + assert.equal(runningCard.tool.status, 'running', 'the card is drawn at the worker status it was recorded with'); assert.equal((runningCard.subItems || []).length, 1, 'with the one step its worker has published'); // The launch settles. Its worker publishes no further step, so the launching // item's own record is the only thing that changed. const settled = coordinator.decorate(parentMessages([launchRecord(1, 'running'), launchRecord(2, 'completed')])); const settledCard = Policy.subagentBranchAt(ui.toConversationUiMessage(settled[1]).items || [], 0, SWARM_CALL_ID); - assert.equal(settledCard.tool.status, 'completed'); + assert.equal(settledCard.tool.status, 'running', + "the card still reports the worker's own lifecycle, not the settled launch record"); assert.equal((settledCard.subItems || []).length, 1, 'and its worker keeps the branch it already had'); assert.ok(settled[1].renderVersion > running[1].renderVersion, 'the card is repainted: a tool item status alone is not part of the row key'); @@ -722,8 +916,10 @@ test('the controller reads the launch record for the child session it names', () const controller = fs.readFileSync(path.join(ROOT, 'services/ChatSessionController.ets'), 'utf8'); assert.match(controller, /this\.subagents\.observeParentEvent\(event\)/, 'the parent stream is where a child session is discovered'); - assert.match(controller, /this\.subagents\.decorate\(this\.reducer\.messages\(\)\)/, + assert.match(controller, /this\.subagents\.decorate\(transcript\)/, 'and the branch is attached on the one path that publishes the transcript'); + assert.match(controller, /this\.subagents\.observeParentMessages\(transcript\)/, + 'which also reads the children a cached transcript names, so a cold view has them'); assert.match(controller, /this\.subagents\.beginSession\(sessionId\)/, 'a session switch rebinds the follow set'); assert.match(controller, /this\.subagents\.endSession\(\)/,