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..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 @@ -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' || @@ -25,11 +25,16 @@ 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 === '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/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/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..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 @@ -1,168 +1,333 @@ -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 { 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_PREVIEW_DEVICE_KEY, + SwarmBeatScript, + SwarmLedger, + SwarmScript, + SWARM_WIDE_WORKERS, + SwarmWideScript, + wideSpawnId +} 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; +/** + * 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: {} }; + +/** + * 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; + } -interface PreviewReadInput { - path: string; + subscribeSession(sessionId: string, callbacks: HostSessionCallbacks): HostSessionStream { + return this.fixture.openStream(sessionId, callbacks); + } + + 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. + * + * 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 reducer: DurableSessionReducer = new DurableSessionReducer(); + private fixture: SwarmLedger = new SwarmLedger(); + 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 taskCallId: string = 'task_call_preview'; - private childSessionId: string = 'child-session-preview'; + 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 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; + @Local followCursor: number = 0; - aboutToAppear(): void { - this.timeline.reset('preview'); - this.reducer.apply(this.taskRecord(1, 'running', 'inprogress')); - this.publish(); + 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, 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); + } + + 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; + this.nextBeat(); } - 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 }; + /** + * 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 { + 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(); } /** - * 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. + * 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 summarize(answer?: ChatMessage): string { + 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 + * 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(); + } + + /** + * 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. + * + * 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(','); + 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[] { + 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 }) { 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.nextBeat(); }) + 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(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'; }) } - 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++; } }) - }.width('100%').height('100%').backgroundColor(PAGE_BG) + // 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, + 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).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 new file mode 100644 index 0000000000..cff5dd838d --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/preview/SubagentSwarmFixture.ets @@ -0,0 +1,822 @@ +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'; +/** 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 { + 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}`, 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. */ + 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; + } + + /** + * 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 (!turnOnly && 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(); + private muted: Set = new Set(); + + /** 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; + } + + /** + * 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); + 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 && !this.muted.has(sessionId) ? 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 implements SwarmBeatScript { + 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(); }, + // 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. */ + 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: '/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 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'); +} + +/** + * 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 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' +].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/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 a0803dc740..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,6 +2,7 @@ import { InteractionMailboxStore } from './InteractionMailboxStore'; import { InteractionMailboxAction, InteractionMailboxState } from '../model/InteractionMailbox'; import { DurableSessionReducer } from './DurableSessionReducer'; import { HostSessionStream, HostSessionEvent } from './HostSessionStream'; +import { SubagentBranchCache, SubagentFollowState, SubagentStreamCoordinator } from './SubagentStreamCoordinator'; import { ChatMessage, RemoteModelCatalog @@ -47,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; } @@ -61,6 +69,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; @@ -74,9 +83,16 @@ 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 + // 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); }, + options?.branchCache); } start(sessionId: string, cursor: ChatSessionCursor, _activeTurn?: ChatMessage): void { if (!this.stopped && this.sessionId === sessionId && this.stream && !this.stream.isClosed()) { @@ -87,6 +103,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 +114,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 +160,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 +177,44 @@ 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(); + } + /** + * 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.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/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..e4fa56520b --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/services/SubagentStreamCoordinator.ets @@ -0,0 +1,906 @@ +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. + * + * `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; +} + +/** + * 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; + 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; + /** 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; + /** 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; +} + +/** + * 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. + * + * 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 = ''; + 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(); + /** 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, + branchCache?: SubagentBranchCache) { + this.source = source; + this.branchCache = branchCache; + 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; + 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 + }; + } + + /** + * 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); + return SubagentStreamCoordinator.ownerItem(item, branch, index, items); + }); + 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 + }; + } + + /** + * 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. + * + * 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 + * 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 + * 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}`, + branch.status, + 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 { + // 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, + streamFailed: false, + cached: [], + cachedStatus: '', + items: [], + 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(branch.sessionId, { + onEvent: async (event: HostSessionEvent): Promise => { + this.applyChildEvent(branch.sessionId, event); + }, + onCaughtUp: async (): Promise => { + 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(branch.sessionId); + if (!current) return; + current.reducer = new DurableSessionReducer(); + current.caughtUp = false; + }, + 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.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. + * + * 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; + 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 wasSettled = SubagentStreamCoordinator.isSettled(branch); + const items = SubagentStreamCoordinator.mergeBranchItems( + branch.cached, SubagentStreamCoordinator.branchItems(branch.reducer.messages())); + const signature = SubagentStreamCoordinator.branchSignature(items); + 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)}`); + }); + } + + /** + * 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(messages: ChatMessage[]): ChatMessageItemResponse[] { + const items: ChatMessageItemResponse[] = []; + 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[] = []; + 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 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/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: ['/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..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 @@ -8,6 +8,13 @@ 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() {} + observeParentMessages() {} + 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-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 e78af739f3..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; @@ -30,207 +34,776 @@ 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) - ]; +/** + * 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 + } + } + }; } -async function timelineHarness() { +/** 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(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 }; + } + }, () => { 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(); +} + +/** 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('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('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('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 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 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 marked record that arrives before its Task is not lost', () => { +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 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, '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'); + + // 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 +833,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 +900,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 +911,17 @@ 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\(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\(\)/, + 'and closing the session closes the streams it opened'); +}); 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( 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 {