From eff8c468e5dc01a0ea8b3682e95ed616a842fcde Mon Sep 17 00:00:00 2001
From: Den <2119348+dzianisv@users.noreply.github.com>
Date: Fri, 13 Nov 2026 15:51:15 +0000
Subject: [PATCH] feat(chat): read-only subagent sessions surface (#187)
Adds a collapsible Subagents section to the parent detail screen listing
child sessions with a live status badge, per the Stage 2 design. Children
come from the existing global session list filtered client-side on parentID;
status is derived from the SSE sessionStatus map + a new sessionErrors
record; the session.created leak (children prepended to the root list) is
closed by routing on parentID. No release in this task.
OQ1 verified against Qiiks/opencode v1.18.18-orw.1 server source: the
/api/session pool returns children carrying parentID (roots filter applies
only when roots=true), and the session.created SSE payload carries
parentID (v1 SessionInfo). OQ3 answered: the server does NOT populate
time.idle, so cold-loaded children read Idle until observed live.
Gates on the exact head SHA: check:versions exit 0, typecheck exit 0,
full suite 414 pass / 0 fail (baseline 395 + 19: 15 new unit tests for the
three pure helpers, 4 new integration tests). App code implemented
mechanically in this lane (no Google AI Studio access); see PR body.
---
.maestro/flows/subagents.yaml | 104 +++++++++
app/session/[id].tsx | 6 +
scripts/run-e2e-flows.sh | 25 +-
src/components/chat/SubagentSection.tsx | 277 +++++++++++++++++++++++
src/components/chat/index.ts | 1 +
src/lib/i18n/en.json | 11 +
src/lib/i18n/ru.json | 11 +
src/lib/i18n/zh-Hans.json | 11 +
src/lib/sdk.ts | 8 +
src/lib/session-children.test.ts | 150 ++++++++++++
src/lib/session-children.ts | 82 +++++++
src/lib/session-list.integration.test.ts | 89 +++++++-
src/lib/v2-shape.ts | 7 +
src/stores/events.ts | 51 ++++-
src/stores/sessions.ts | 39 ++++
tests/fixtures/mock-opencode-server.ts | 75 +++++-
16 files changed, 933 insertions(+), 14 deletions(-)
create mode 100644 .maestro/flows/subagents.yaml
create mode 100644 src/components/chat/SubagentSection.tsx
create mode 100644 src/lib/session-children.test.ts
create mode 100644 src/lib/session-children.ts
diff --git a/.maestro/flows/subagents.yaml b/.maestro/flows/subagents.yaml
new file mode 100644
index 000000000..e165c414f
--- /dev/null
+++ b/.maestro/flows/subagents.yaml
@@ -0,0 +1,104 @@
+appId: cc.agentlabs.opencode
+name: Subagents section - parent detail lists children with live status (#187)
+---
+# Read-only subagent surface (issue #187): a parent detail screen shows a
+# collapsible Subagents section listing its child sessions with a live status
+# badge. AC1 (child list) + AC3-at-render (root list stays roots-only) at the
+# UI layer; the data path is covered by
+# src/lib/session-list.integration.test.ts (subagents block).
+#
+# The subagents flow's mock is started by scripts/run-e2e-flows.sh itself
+# (port 4101, --seed-subagents) before the flows run, so no workflow-file
+# change is needed for it. --seed-subagents pre-populates one parent + two
+# children, all in /mock/project:
+# seed-parent "Parent Session" (updated now-60s)
+# seed-child-done "Done Subagent", parentID seed-parent, agent explore,
+# model opencode/deepseek-v4-flash-free, time.idle set
+# seed-child-idle "Idle Subagent", parentID seed-parent, no agent/model/idle
+#
+# Why this can actually FAIL if #187 regresses: the section loads children via
+# loadChildren() (GET /session pool, filtered client-side on parentID). If the
+# filter, the mount, or the store wiring breaks, "Subagents (2)" never appears
+# or the child rows never render. If the roots filter regresses, the child
+# titles leak onto the list screen — this flow opens the parent from a list
+# that must show "Parent Session" only.
+#
+# Known limit: the LIVE session.created routing (a child created while viewing
+# its parent appears in the section, never in the root list) is NOT covered
+# here — the mock emits no session.created broadcast and no UI creates a
+# child, so there is no way to trigger it from a flow. That branch is verified
+# by inspection; a live check needs a real server with a task-tool subagent.
+# The flow also does not assert the Running badge: the mock never emits a
+# session.status busy for a child, so no child is live-running in this harness.
+# NEWER flow (non-blocking): see scripts/run-e2e-flows.sh.
+
+- launchApp:
+ clearState: true
+- assertVisible:
+ id: "telemetry-consent-card"
+- tapOn:
+ id: "telemetry-decline-button"
+
+- assertVisible:
+ text: "No Connection"
+- tapOn:
+ id: "add-connection-button"
+- tapOn:
+ id: "connect-ip-input"
+- inputText: "127.0.0.1:4101"
+- hideKeyboard
+- tapOn:
+ id: "connect-submit-button"
+
+# 40s margin: see activation-positive.yaml — the connect handshake's own
+# fetches are individually capped at 30s (src/lib/sdk.ts REQUEST_TIMEOUT_MS).
+- extendedWaitUntil:
+ visible:
+ id: "connection-status-dot"
+ timeout: 40000
+- takeScreenshot: subagents-S1_connected
+
+# The parent must appear on the root list; the children must not leak onto it.
+- extendedWaitUntil:
+ visible:
+ text: "Parent Session"
+ timeout: 15000
+- takeScreenshot: subagents-S2_parent_listed
+
+# Open the parent.
+- tapOn:
+ id: "session-item-seed-parent"
+- extendedWaitUntil:
+ visible:
+ id: "chat-message-input"
+ timeout: 15000
+- assertVisible:
+ text: "Parent Session"
+# Confirms GET /session/seed-parent/message actually resolved (empty state,
+# not stuck loading / errored).
+- assertVisible:
+ text: "Start a conversation"
+
+# The section header renders collapsed with the child count once loadChildren
+# resolves (the count starts at 0, so wait for the loaded value).
+- extendedWaitUntil:
+ visible:
+ text: "Subagents (2)"
+ timeout: 15000
+- takeScreenshot: subagents-S3_section_header
+
+# Expand and check both rows: titles, the agent · model line only where the
+# server provides it, and the Done pill for the finished child.
+- tapOn:
+ text: "Subagents (2)"
+- extendedWaitUntil:
+ visible:
+ text: "Done Subagent"
+ timeout: 15000
+- assertVisible:
+ text: "Idle Subagent"
+- assertVisible:
+ text: "explore · opencode/deepseek-v4-flash-free"
+- assertVisible:
+ text: "Done"
+- takeScreenshot: subagents-S4_children_expanded
diff --git a/app/session/[id].tsx b/app/session/[id].tsx
index db6c5e8a2..1328a9bb7 100644
--- a/app/session/[id].tsx
+++ b/app/session/[id].tsx
@@ -33,6 +33,7 @@ import {
ImageAttachments,
SessionInfo,
MessageTextSheet,
+ SubagentSection,
type SlashCommand,
type Attachment,
} from "../../src/components/chat"
@@ -748,6 +749,11 @@ export default function SessionScreen() {
)}
+ {/* Subagent children (#187) — above the message list, outside the
+ FlatList. Read-only; a load failure shows its own error + retry
+ and never blocks the transcript. */}
+ {id ? : null}
+
{isLoading ? (
diff --git a/scripts/run-e2e-flows.sh b/scripts/run-e2e-flows.sh
index 6f4a40e49..a4f8ae6f1 100755
--- a/scripts/run-e2e-flows.sh
+++ b/scripts/run-e2e-flows.sh
@@ -29,16 +29,36 @@ APK="android/app/build/outputs/apk/release/app-release.apk"
CORE_FLOWS=(activation-positive activation-negative-401)
# demo.yaml (offline "Try a demo" mode) needs no mock server — it never
# connects — so it's safe to run alongside the others here unmodified.
-NEWER_FLOWS=(directory-picker all-sessions variant-picker diff-scroll demo)
+NEWER_FLOWS=(directory-picker all-sessions variant-picker diff-scroll demo subagents)
mkdir -p "$ROOT/artifacts/screenshots" "$ROOT/artifacts/diag"
echo "== installing APK =="
adb install "$APK"
echo "== forwarding mock ports into the emulator (adb reverse) =="
-for p in 4096 4097 4098 4099 4100; do adb reverse "tcp:$p" "tcp:$p"; done
+for p in 4096 4097 4098 4099 4100 4101; do adb reverse "tcp:$p" "tcp:$p"; done
adb reverse --list
+# The subagents flow needs its own mock instance (port 4101, --seed-subagents:
+# one parent + two children, for subagents.yaml / issue #187). It starts here,
+# not in activation-e2e.yml, so this flow needs no workflow-file change: the
+# workflow only starts ports 4096-4100. Same node invocation the workflow uses
+# for the other mocks; wait until its health endpoint answers.
+echo "== starting subagents mock on 4101 =="
+nohup node tests/fixtures/mock-opencode-server.ts --port 4101 --seed-subagents > /tmp/mock-4101.log 2>&1 &
+for i in $(seq 1 30); do
+ if curl -sf --connect-timeout 1 -m 3 "http://127.0.0.1:4101/global/health" > /dev/null 2>&1; then
+ echo "subagents mock on 4101 is up"
+ break
+ fi
+ if [ "$i" = "30" ]; then
+ echo "::error::subagents mock server on port 4101 did not come up in 30s"
+ cat /tmp/mock-4101.log || true
+ exit 1
+ fi
+ sleep 1
+done
+
# Decisive probe: can the EMULATOR actually reach the host mock via 127.0.0.1?
# If any in-emulator method prints the health JSON, the network path is good
# and any flow failure is app-side; if none succeed, attribution falls back to
@@ -96,6 +116,7 @@ adb logcat -c || true # clear, so the captured log is just this run
dump_diag() {
echo "== capturing diagnostics =="
adb logcat -d > "$ROOT/artifacts/diag/logcat.txt" 2>&1 || true
+ cp /tmp/mock-4101.log "$ROOT/artifacts/diag/mock-4101.log" 2>/dev/null || true
# Maestro writes per-run debug (UI hierarchy + commands) under ~/.maestro/tests
cp -r "$HOME/.maestro/tests" "$ROOT/artifacts/diag/maestro-tests" 2>/dev/null || true
}
diff --git a/src/components/chat/SubagentSection.tsx b/src/components/chat/SubagentSection.tsx
new file mode 100644
index 000000000..1b7ce2501
--- /dev/null
+++ b/src/components/chat/SubagentSection.tsx
@@ -0,0 +1,277 @@
+// Read-only subagent surface (#187): a collapsible Subagents section on the
+// parent detail screen — one row per child session (title, agent/model, live
+// status, error) plus a parent summary badge. No navigation, no management:
+// rows are display-only (tap-to-inspect is out of scope).
+//
+// Data: childrenByParent in the sessions store (loaded on focus, updated live
+// via the session.created routing in events.ts); status derived per child
+// from the SSE sessionStatus map + sessionErrors record (see
+// src/lib/session-children.ts). A load failure never blocks the screen — the
+// section shows its own error + retry.
+import { useCallback, useState } from "react"
+import { ActivityIndicator, StyleSheet, Text, TouchableOpacity, View } from "react-native"
+import { useFocusEffect } from "expo-router"
+import { Ionicons } from "@expo/vector-icons"
+import { useTranslation } from "react-i18next"
+import type { Session } from "../../lib/sdk"
+import { deriveChildStatus, summarizeChildren, type ChildStatus } from "../../lib/session-children"
+import { useSessions } from "../../stores/sessions"
+import { useEvents } from "../../stores/events"
+
+// Pill colours match statusColor in ToolCallCard.tsx (completed green, error
+// red, running amber, default grey) so the section speaks the transcript's
+// existing language. Duplicated here because ToolCallCard's helper is
+// module-private and this slice does not touch that file.
+const PILL_COLORS: Record = {
+ done: "#22c55e",
+ failed: "#ef4444",
+ running: "#f59e0b",
+ idle: "#888888",
+}
+
+const WORST_DOT_COLORS: Record = {
+ failed: "#ef4444",
+ running: "#f59e0b",
+ done: "#22c55e",
+ idle: "#888888",
+}
+
+// Same relative format as formatTime in SessionInfo.tsx (duplicated because
+// that helper is module-private and this slice does not touch that file).
+type Translate = ReturnType["t"]
+
+function formatTime(ts: number, t: Translate): string {
+ const diff = Date.now() - ts
+ const mins = Math.floor(diff / 60_000)
+ if (mins < 1) return t("chat.sessionInfo.time.justNow")
+ if (mins < 60) return t("chat.sessionInfo.time.minutesAgo", { count: mins })
+ const hours = Math.floor(mins / 60)
+ if (hours < 24) return t("chat.sessionInfo.time.hoursAgo", { count: hours })
+ const days = Math.floor(hours / 24)
+ if (days < 7) return t("chat.sessionInfo.time.daysAgo", { count: days })
+ return new Date(ts).toLocaleDateString(undefined, { month: "short", day: "numeric" })
+}
+
+function statusLabel(status: ChildStatus, t: Translate): string {
+ if (status === "running") return t("subagents.running")
+ if (status === "done") return t("subagents.done")
+ if (status === "failed") return t("subagents.failed")
+ return t("subagents.idle")
+}
+
+interface RowProps {
+ child: Session
+ status: ChildStatus
+ error?: string
+ isDark: boolean
+}
+
+function SubagentRow({ child, status, error, isDark }: RowProps) {
+ const { t } = useTranslation()
+ const agentLine = child.agent ? child.agent : null
+ const modelLine = child.model ? `${child.model.providerID}/${child.model.modelID}` : null
+ const secondLine = [agentLine, modelLine, formatTime(child.time?.created ?? 0, t)]
+ .filter((part) => part !== null && part !== "")
+ .join(" · ")
+ return (
+
+
+
+ {child.title || "Untitled subagent"}
+
+ {secondLine !== "" && (
+
+ {secondLine}
+
+ )}
+ {status === "failed" && error && (
+
+ {error}
+
+ )}
+
+
+ {statusLabel(status, t)}
+
+
+ )
+}
+
+const EMPTY_CHILDREN: Session[] = []
+
+export function SubagentSection({ parentId, isDark }: { parentId: string; isDark: boolean }) {
+ const { t } = useTranslation()
+ const [expanded, setExpanded] = useState(false)
+ const childrenByParent = useSessions((state) => state.childrenByParent)
+ const children = childrenByParent[parentId] ?? EMPTY_CHILDREN
+ const childrenLoading = useSessions((state) => state.childrenLoading)
+ const childrenError = useSessions((state) => state.childrenError)
+ const loadChildren = useSessions((state) => state.loadChildren)
+ const sessionStatus = useEvents((state) => state.sessionStatus)
+ const sessionErrors = useEvents((state) => state.sessionErrors)
+
+ useFocusEffect(
+ useCallback(() => {
+ if (parentId) void loadChildren(parentId)
+ }, [parentId, loadChildren]),
+ )
+
+ if (!parentId) return null
+ const summary = summarizeChildren(children, sessionStatus, sessionErrors)
+ const dotColor = summary.worst === "none" ? null : WORST_DOT_COLORS[summary.worst]
+ const detailParts: string[] = []
+ if (summary.running > 0) detailParts.push(`${summary.running} ${t("subagents.running")}`)
+ if (summary.failed > 0) detailParts.push(`${summary.failed} ${t("subagents.failed")}`)
+ if (summary.done > 0) detailParts.push(`${summary.done} ${t("subagents.done")}`)
+ if (summary.idle > 0) detailParts.push(`${summary.idle} ${t("subagents.idle")}`)
+ const detail = detailParts.length > 0 ? detailParts.join(" · ") : t("subagents.done")
+
+ return (
+
+ setExpanded((prev) => !prev)}
+ style={s.header}
+ testID="subagents-header"
+ accessibilityRole="button"
+ >
+
+
+ {t("subagents.title")} ({summary.total})
+
+ {childrenLoading && }
+ {summary.total > 0 && dotColor && }
+ {summary.total > 0 && (
+
+ {t("subagents.summary", { count: summary.total, detail })}
+
+ )}
+
+
+ {expanded &&
+ (childrenError ? (
+
+
+ {t("subagents.loadError")}
+
+ void loadChildren(parentId)} testID="subagents-retry" hitSlop={8}>
+ {t("subagents.retry")}
+
+
+ ) : summary.total === 0 && !childrenLoading ? (
+
+
+ {t("subagents.none")}
+
+
+ ) : (
+
+ {children.map((child) => (
+
+ ))}
+
+ ))}
+
+ )
+}
+
+const s = StyleSheet.create({
+ container: {
+ backgroundColor: "#ffffff",
+ borderBottomWidth: 1,
+ borderBottomColor: "#e5e5e5",
+ },
+ containerDark: {
+ backgroundColor: "#0a0a0a",
+ borderBottomColor: "#1a1a1a",
+ },
+ header: {
+ flexDirection: "row",
+ alignItems: "center",
+ paddingHorizontal: 16,
+ paddingVertical: 10,
+ gap: 6,
+ },
+ headerTitle: {
+ fontSize: 15,
+ fontWeight: "600",
+ color: "#0a0a0a",
+ },
+ textDark: {
+ color: "#ffffff",
+ },
+ metaDark: {
+ color: "#666666",
+ },
+ dot: {
+ width: 8,
+ height: 8,
+ borderRadius: 4,
+ },
+ summary: {
+ flex: 1,
+ fontSize: 13,
+ color: "#666666",
+ textAlign: "right",
+ },
+ body: {
+ paddingHorizontal: 16,
+ paddingBottom: 12,
+ gap: 8,
+ },
+ bodyText: {
+ fontSize: 14,
+ color: "#666666",
+ },
+ retry: {
+ fontSize: 14,
+ fontWeight: "600",
+ color: "#3b82f6",
+ },
+ row: {
+ flexDirection: "row",
+ alignItems: "flex-start",
+ paddingVertical: 8,
+ borderTopWidth: 1,
+ borderTopColor: "#f0f0f0",
+ gap: 8,
+ },
+ rowDark: {
+ borderTopColor: "#1a1a1a",
+ },
+ rowMain: {
+ flex: 1,
+ gap: 2,
+ },
+ rowTitle: {
+ fontSize: 15,
+ color: "#0a0a0a",
+ },
+ rowMeta: {
+ fontSize: 13,
+ color: "#666666",
+ },
+ rowError: {
+ fontSize: 13,
+ color: "#ef4444",
+ },
+ pill: {
+ borderRadius: 10,
+ paddingHorizontal: 8,
+ paddingVertical: 3,
+ },
+ pillText: {
+ fontSize: 12,
+ fontWeight: "600",
+ color: "#ffffff",
+ },
+})
diff --git a/src/components/chat/index.ts b/src/components/chat/index.ts
index b22b3d3d7..8b18ef78b 100644
--- a/src/components/chat/index.ts
+++ b/src/components/chat/index.ts
@@ -13,3 +13,4 @@ export { DirectorySwitcher } from "./DirectorySwitcher"
export { DirectoryBrowserSheet } from "./DirectoryBrowserSheet"
export { SessionInfo } from "./SessionInfo"
export { MessageTextSheet } from "./MessageTextSheet"
+export { SubagentSection } from "./SubagentSection"
diff --git a/src/lib/i18n/en.json b/src/lib/i18n/en.json
index b663b32bd..8aa910cfe 100644
--- a/src/lib/i18n/en.json
+++ b/src/lib/i18n/en.json
@@ -448,6 +448,17 @@
"hoursAgo": "{{count}}h ago",
"daysAgo": "{{count}}d ago"
}
+ },
+ "subagents": {
+ "title": "Subagents",
+ "running": "Running",
+ "done": "Done",
+ "idle": "Idle",
+ "failed": "Failed",
+ "none": "No subagents yet",
+ "loadError": "Couldn't load subagents",
+ "retry": "Retry",
+ "summary": "{{count}} subagents · {{detail}}"
}
},
"demo": {
diff --git a/src/lib/i18n/ru.json b/src/lib/i18n/ru.json
index c1e8547c8..471f8a768 100644
--- a/src/lib/i18n/ru.json
+++ b/src/lib/i18n/ru.json
@@ -448,6 +448,17 @@
"hoursAgo": "{{count}} ч назад",
"daysAgo": "{{count}} дн назад"
}
+ },
+ "subagents": {
+ "title": "Субагенты",
+ "running": "Выполняется",
+ "done": "Готово",
+ "idle": "Ожидание",
+ "failed": "Ошибка",
+ "none": "Субагентов пока нет",
+ "loadError": "Не удалось загрузить субагентов",
+ "retry": "Повторить",
+ "summary": "{{count}} субагентов · {{detail}}"
}
},
"demo": {
diff --git a/src/lib/i18n/zh-Hans.json b/src/lib/i18n/zh-Hans.json
index 383fd34ac..779244b5d 100644
--- a/src/lib/i18n/zh-Hans.json
+++ b/src/lib/i18n/zh-Hans.json
@@ -448,6 +448,17 @@
"hoursAgo": "{{count}} 小时前",
"daysAgo": "{{count}} 天前"
}
+ },
+ "subagents": {
+ "title": "子代理",
+ "running": "运行中",
+ "done": "已完成",
+ "idle": "空闲",
+ "failed": "失败",
+ "none": "暂无子代理",
+ "loadError": "无法加载子代理",
+ "retry": "重试",
+ "summary": "{{count}} 个子代理 · {{detail}}"
}
},
"demo": {
diff --git a/src/lib/sdk.ts b/src/lib/sdk.ts
index f2978c321..425836b3e 100644
--- a/src/lib/sdk.ts
+++ b/src/lib/sdk.ts
@@ -71,11 +71,19 @@ export interface Session {
title: string
version: string
share?: { url: string }
+ // Carried from V2SessionInfo so the subagent surface (#187) can show the
+ // child agent/model without a per-child fetch. Absent on v1/legacy servers.
+ agent?: string
+ model?: { providerID: string; modelID: string }
time: {
created: number
updated: number
compacting?: number
archived?: number
+ // Present when the server marks the session finished. The server does not
+ // populate it on every response (OQ3) — a cold-loaded child without it
+ // reads Idle until a live busy -> idle transition is observed.
+ idle?: number
}
summary?: {
additions: number
diff --git a/src/lib/session-children.test.ts b/src/lib/session-children.test.ts
new file mode 100644
index 000000000..471ea2b61
--- /dev/null
+++ b/src/lib/session-children.test.ts
@@ -0,0 +1,150 @@
+// Unit tests for the three new pure helpers in session-children.ts (#187).
+// Recorded exception (issue #187, comment 6857247200): AC-mapped cases for
+// genuinely new behavior — selectChildren (AC1, AC3), deriveChildStatus
+// (AC2, AC4), summarizeChildren (AC2). No mock-based tests. The session.created
+// routing is covered via the existing integration/e2e surface, not here.
+import { test } from "node:test"
+import assert from "node:assert/strict"
+import {
+ selectChildren,
+ deriveChildStatus,
+ summarizeChildren,
+ type ChildSessionStatus,
+} from "./session-children.ts"
+import type { Session } from "./sdk.ts"
+
+// Minimal Session factory — only the fields the helpers read.
+function session(over: Partial & { id: string }): Session {
+ return {
+ id: over.id,
+ slug: over.slug ?? over.id,
+ projectID: "p",
+ directory: "/dir",
+ title: over.title ?? over.id,
+ version: "1",
+ time: over.time ?? { created: 0, updated: 0 },
+ ...over,
+ } as Session
+}
+
+const parent = session({ id: "parent", title: "Parent" })
+const childA = session({ id: "child-a", parentID: "parent", time: { created: 10, updated: 30 } })
+const childB = session({ id: "child-b", parentID: "parent", time: { created: 10, updated: 20 } })
+const otherChild = session({ id: "child-other", parentID: "other-parent" })
+
+const emptyStatus: Record = {}
+const emptyErrors: Record = {}
+
+// --- selectChildren (AC1: child list on the parent detail screen) ---
+
+test("selectChildren (AC1): returns only the parent's own children", () => {
+ const out = selectChildren([parent, childA, childB, otherChild], "parent")
+ assert.deepEqual(
+ out.map((s) => s.id),
+ ["child-a", "child-b"],
+ )
+})
+
+test("selectChildren (AC1): newest-first by time.updated", () => {
+ const out = selectChildren([childB, childA], "parent")
+ assert.equal(out[0].id, "child-a")
+ assert.equal(out[1].id, "child-b")
+})
+
+test("selectChildren (AC1): a parent with no children yields the empty state, not an error", () => {
+ assert.deepEqual(selectChildren([parent, otherChild], "parent"), [])
+ assert.deepEqual(selectChildren([], "parent"), [])
+})
+
+// --- selectChildren (AC3: root list stays roots-only) ---
+
+test("selectChildren (AC3): roots and other parents' children are never selected", () => {
+ const out = selectChildren([parent, childA, otherChild], "other-parent")
+ assert.deepEqual(
+ out.map((s) => s.id),
+ ["child-other"],
+ )
+ assert.ok(!out.some((s) => s.id === "parent"), "a root session is never a child row")
+})
+
+// --- deriveChildStatus (AC2: live status badge) ---
+
+test("deriveChildStatus (AC2): busy is Running, retry is Running", () => {
+ assert.equal(deriveChildStatus(childA, { "child-a": { type: "busy" } }, emptyErrors), "running")
+ assert.equal(
+ deriveChildStatus(childA, { "child-a": { type: "retry", attempt: 2, message: "retrying" } }, emptyErrors),
+ "running",
+ )
+})
+
+test("deriveChildStatus (AC2): time.idle present means Done for a cold-loaded child", () => {
+ const finished = session({ id: "child-done", parentID: "parent", time: { created: 10, updated: 20, idle: 15 } })
+ assert.equal(deriveChildStatus(finished, emptyStatus, emptyErrors), "done")
+})
+
+test("deriveChildStatus (AC2): an observed busy -> idle transition means Done", () => {
+ // A sessionStatus entry exists ONLY when a status event arrived this app
+ // session, so an idle entry is proof the transition was observed live.
+ assert.equal(deriveChildStatus(childA, { "child-a": { type: "idle" } }, emptyErrors), "done")
+})
+
+test("deriveChildStatus (AC2): no status event and no time.idle is Idle, not Done", () => {
+ // A child that finished before app start on a server that never sends
+ // time.idle must not claim a completion it cannot prove.
+ assert.equal(deriveChildStatus(childA, emptyStatus, emptyErrors), "idle")
+})
+
+// --- deriveChildStatus (AC4: child errors visible) ---
+
+test("deriveChildStatus (AC4): a recorded session error means Failed", () => {
+ assert.equal(deriveChildStatus(childA, emptyStatus, { "child-a": "boom" }), "failed")
+})
+
+test("deriveChildStatus (AC4): an error outranks a live status", () => {
+ assert.equal(
+ deriveChildStatus(childA, { "child-a": { type: "busy" } }, { "child-a": "boom" }),
+ "failed",
+ )
+ assert.equal(
+ deriveChildStatus(childA, { "child-a": { type: "idle" } }, { "child-a": "boom" }),
+ "failed",
+ )
+})
+
+test("deriveChildStatus (AC4): an error for another session does not fail this child", () => {
+ assert.equal(deriveChildStatus(childA, emptyStatus, { "child-b": "boom" }), "idle")
+})
+
+// --- summarizeChildren (AC2: parent summary badge) ---
+
+test("summarizeChildren (AC2): counts per state and worst is failed when any child failed", () => {
+ const summary = summarizeChildren(
+ [childA, childB],
+ { "child-a": { type: "busy" } },
+ { "child-b": "boom" },
+ )
+ assert.equal(summary.total, 2)
+ assert.equal(summary.running, 1)
+ assert.equal(summary.failed, 1)
+ assert.equal(summary.done, 0)
+ assert.equal(summary.idle, 0)
+ assert.equal(summary.worst, "failed")
+})
+
+test("summarizeChildren (AC2): worst is running when any child runs and none failed", () => {
+ const summary = summarizeChildren([childA, childB], { "child-a": { type: "busy" } }, emptyErrors)
+ assert.equal(summary.worst, "running")
+})
+
+test("summarizeChildren (AC2): all done when every child is done", () => {
+ const done = session({ id: "child-done", parentID: "parent", time: { created: 10, updated: 20, idle: 15 } })
+ const summary = summarizeChildren([done], emptyStatus, emptyErrors)
+ assert.equal(summary.total, 1)
+ assert.equal(summary.done, 1)
+ assert.equal(summary.worst, "done")
+})
+
+test("summarizeChildren (AC2): no children is the empty summary, worst none", () => {
+ const summary = summarizeChildren([], emptyStatus, emptyErrors)
+ assert.deepEqual(summary, { total: 0, running: 0, done: 0, failed: 0, idle: 0, worst: "none" })
+})
diff --git a/src/lib/session-children.ts b/src/lib/session-children.ts
new file mode 100644
index 000000000..e68dddb21
--- /dev/null
+++ b/src/lib/session-children.ts
@@ -0,0 +1,82 @@
+// Pure, transport-agnostic logic for the read-only subagent surface (#187),
+// extracted so it's unit-testable under plain `node --test` without importing
+// expo/fetch or any store (same pattern as session-list.ts).
+//
+// A child session is any session whose parentID equals the parent's id.
+// Status is DERIVED, never stored on the session: the live SSE status map
+// (events.ts sessionStatus) and error record (sessionErrors) are the inputs,
+// plus the session record's own time.idle for cold-loaded children.
+import type { Session } from "./sdk"
+
+// Structural copy of the SessionStatus union in src/stores/events.ts
+// (kept local so this module stays store-free and node-testable).
+export type ChildSessionStatus =
+ | { type: "idle" }
+ | { type: "busy" }
+ | { type: "retry"; attempt: number; message: string }
+
+export type ChildStatus = "running" | "done" | "idle" | "failed"
+
+// Exactly one level: parentID === parentID — grandchildren never appear as
+// rows of the grandparent. Newest-first by time.updated, same sort as
+// normalizeSessions in session-list.ts.
+export function selectChildren(all: Session[], parentID: string): Session[] {
+ if (!Array.isArray(all) || !parentID) return []
+ return all
+ .filter((s) => s?.parentID === parentID)
+ .sort((a, b) => (b.time?.updated ?? 0) - (a.time?.updated ?? 0))
+}
+
+// Checked in this order (design §3):
+// Failed — sessionErrors[child.id] present (set on session.error, cleared
+// when the child next goes busy, so a stale error never outlives a new run).
+// Running — sessionStatus[child.id] is busy or retry.
+// Done — child.time.idle present (server-marked completion), OR a status
+// entry exists with type idle: sessionStatus entries exist ONLY when a
+// status event arrived this app session (done-rule invariant), so an idle
+// entry is proof a busy -> idle transition was observed live.
+// Idle — none of the above: never started, or finished before app start and
+// the server sent no time.idle. Idle honestly means "not known to be
+// running, failed, or finished".
+export function deriveChildStatus(
+ child: Session,
+ sessionStatus: Record,
+ sessionErrors: Record,
+): ChildStatus {
+ if (sessionErrors[child.id]) return "failed"
+ const status = sessionStatus[child.id]?.type
+ if (status === "busy" || status === "retry") return "running"
+ if (child.time?.idle != null || status === "idle") return "done"
+ return "idle"
+}
+
+export type ChildSummary = {
+ total: number
+ running: number
+ done: number
+ failed: number
+ idle: number
+ // Worst state, for the header badge dot: red if any failed, else amber if
+ // any running, else the settled state; "none" when there are no children.
+ worst: "failed" | "running" | "done" | "idle" | "none"
+}
+
+// Parent summary badge counts + worst state, derived per child through
+// deriveChildStatus so the badge and the rows can never disagree.
+export function summarizeChildren(
+ children: Session[],
+ sessionStatus: Record,
+ sessionErrors: Record,
+): ChildSummary {
+ const summary: ChildSummary = { total: 0, running: 0, done: 0, failed: 0, idle: 0, worst: "none" }
+ if (!Array.isArray(children)) return summary
+ summary.total = children.length
+ for (const child of children) {
+ summary[deriveChildStatus(child, sessionStatus, sessionErrors)] += 1
+ }
+ if (summary.failed > 0) summary.worst = "failed"
+ else if (summary.running > 0) summary.worst = "running"
+ else if (summary.done > 0 && summary.idle === 0) summary.worst = "done"
+ else if (summary.idle > 0) summary.worst = "idle"
+ return summary
+}
diff --git a/src/lib/session-list.integration.test.ts b/src/lib/session-list.integration.test.ts
index 285837089..55df57ad1 100644
--- a/src/lib/session-list.integration.test.ts
+++ b/src/lib/session-list.integration.test.ts
@@ -9,10 +9,19 @@
//
// Run: node --test src/lib/session-list.integration.test.ts
-import { test, before, after } from "node:test"
+import { test, describe, before, after } from "node:test"
import assert from "node:assert/strict"
-import { createMockOpencodeServer } from "../../tests/fixtures/mock-opencode-server.ts"
+import {
+ createMockOpencodeServer,
+ SEED_PARENT_SESSION_ID,
+ SEED_PARENT_TITLE,
+ SEED_CHILD_DONE_ID,
+ SEED_CHILD_DONE_TITLE,
+ SEED_CHILD_IDLE_ID,
+ SEED_CHILD_IDLE_TITLE,
+} from "../../tests/fixtures/mock-opencode-server.ts"
import { loadSessionList, legacySessionQuery, type SessionListTransport } from "./session-list.ts"
+import { selectChildren } from "./session-children.ts"
const PORT = 45071
let mock: ReturnType
@@ -100,3 +109,79 @@ test("older servers (404 on /experimental/session) fall back to /session and sti
// Sanity: the legacy query we sent is the exact one the old code sent.
assert.equal(legacySessionQuery({ roots: true, limit: 50 }), "?roots=true&limit=50")
})
+
+// Subagent children (#187) — the session.created routing itself lives in the
+// RN-only events store, which plain node --test cannot load, and the mock
+// emits no session.created broadcast (nor can any UI create a child), so the
+// live routing branch has no automated cover in this slice: it is verified by
+// inspection, and the rendered section is covered by
+// .maestro/flows/subagents.yaml. What this block DOES prove across the real
+// HTTP boundary is the data path the section and the leak-closure depend on:
+// the pool carries children with parentID (OQ1 at the app boundary),
+// selectChildren isolates one parent's children (AC1), and the roots list
+// excludes children (AC3, load path).
+describe("subagent children: pool carries children, roots list stays roots-only", () => {
+ const PORT = 45072
+ let subMock: ReturnType
+ let subBase: string
+
+ before(async () => {
+ // --seed-subagents pre-populates one parent + two children, all in
+ // DEFAULT_DIRECTORY (/mock/project):
+ // seed-parent (updated now-60s)
+ // seed-child-done (parentID seed-parent, agent explore,
+ // model opencode/deepseek-v4-flash-free,
+ // time.idle set, updated now-30s, newest)
+ // seed-child-idle (parentID seed-parent, no agent/model/idle,
+ // updated now-45s)
+ subMock = createMockOpencodeServer({ port: PORT, seedSubagents: true })
+ await subMock.listen()
+ subBase = subMock.url
+ })
+
+ after(async () => {
+ await subMock.close()
+ })
+
+ test("pool: the global list carries children with parentID set (OQ1)", async () => {
+ const pool = await loadSessionList(realTransport(subBase))
+ const byId = new Map(pool.map((s) => [s.id, s]))
+ assert.equal(pool.length, 3)
+ assert.equal(byId.get(SEED_CHILD_DONE_ID)?.parentID, SEED_PARENT_SESSION_ID)
+ assert.equal(byId.get(SEED_CHILD_IDLE_ID)?.parentID, SEED_PARENT_SESSION_ID)
+ assert.equal(byId.get(SEED_PARENT_SESSION_ID)?.parentID, undefined)
+ })
+
+ test("selectChildren (AC1): isolates one parent's children, newest-first", async () => {
+ const pool = await loadSessionList(realTransport(subBase))
+ const children = selectChildren(pool, SEED_PARENT_SESSION_ID)
+ assert.deepEqual(
+ children.map((s) => s.id),
+ [SEED_CHILD_DONE_ID, SEED_CHILD_IDLE_ID],
+ )
+ assert.equal(children[0].title, SEED_CHILD_DONE_TITLE)
+ assert.equal(children[1].title, SEED_CHILD_IDLE_TITLE)
+ })
+
+ test("selectChildren (AC1): child rows carry agent/model where the server provides them", async () => {
+ const pool = await loadSessionList(realTransport(subBase))
+ const children = selectChildren(pool, SEED_PARENT_SESSION_ID)
+ const done = children.find((s) => s.id === SEED_CHILD_DONE_ID)
+ assert.equal(done?.agent, "explore")
+ assert.deepEqual(done?.model, { providerID: "opencode", modelID: "deepseek-v4-flash-free" })
+ assert.ok(done?.time?.idle != null, "done child carries time.idle")
+ const idle = children.find((s) => s.id === SEED_CHILD_IDLE_ID)
+ assert.equal(idle?.agent, undefined)
+ assert.equal(idle?.model, undefined)
+ })
+
+ test("roots list (AC3): roots:true excludes children at load time", async () => {
+ const sessions = await loadSessionList(realTransport(subBase), { roots: true, limit: 50 })
+ assert.deepEqual(
+ sessions.map((s) => s.id),
+ [SEED_PARENT_SESSION_ID],
+ )
+ assert.ok(sessions.every((s) => !s.parentID), "no parentID row reaches the root list")
+ assert.equal(sessions[0].title, SEED_PARENT_TITLE)
+ })
+})
diff --git a/src/lib/v2-shape.ts b/src/lib/v2-shape.ts
index 7d83ee599..446342688 100644
--- a/src/lib/v2-shape.ts
+++ b/src/lib/v2-shape.ts
@@ -218,10 +218,17 @@ export function toSession(info: V2SessionInfo): Session {
parentID: info.parentID,
title: info.title ?? "",
version: "",
+ // #187: carry agent/model/idle — V2SessionInfo has them, the old mapping
+ // dropped them, and the subagent rows need them without a per-child fetch.
+ // Omitted (not undefined-valued) when the server sends none, so the
+ // mapped shape is unchanged for sessions without them.
+ ...(info.agent !== undefined ? { agent: info.agent } : {}),
+ ...(info.model ? { model: { providerID: info.model.providerID, modelID: info.model.id } } : {}),
time: {
created: info.time?.created ?? 0,
updated: info.time?.updated ?? 0,
archived: info.time?.archived,
+ ...(info.time?.idle !== undefined ? { idle: info.time.idle } : {}),
},
revert: info.revert ? { messageID: info.revert.messageID, partID: info.revert.partID } : undefined,
}
diff --git a/src/stores/events.ts b/src/stores/events.ts
index a6a1c3577..a2e9955a3 100644
--- a/src/stores/events.ts
+++ b/src/stores/events.ts
@@ -29,6 +29,12 @@ interface EventsState {
lastDisconnectAt: number | null
sessionStatus: Record
statusText: Record
+ // Last error message per session id, set on session.error for ANY session
+ // (unlike the viewed-session-only banner) so the subagent surface (#187)
+ // can show a Failed row for a child the user never opened. Cleared when
+ // the session next goes busy. Kept separate from erroredSessions, which
+ // drives notification/review logic and stays untouched.
+ sessionErrors: Record
// Permissions & questions (pending per session)
permissions: Record
questions: Record<
@@ -179,6 +185,7 @@ export const useEvents = create((set, get) => ({
lastDisconnectAt: null,
sessionStatus: {},
statusText: {},
+ sessionErrors: {},
permissions: {},
questions: {},
@@ -282,11 +289,18 @@ export const useEvents = create((set, get) => ({
abortedSessions.delete(sessionID)
}
- set((state) => ({
- sessionStatus: { ...state.sessionStatus, [sessionID]: status },
- // Clear status text when idle
- statusText: status.type === "idle" ? { ...state.statusText, [sessionID]: "" } : state.statusText,
- }))
+ set((state) => {
+ const sessionErrors =
+ status.type === "busy" && state.sessionErrors[sessionID]
+ ? Object.fromEntries(Object.entries(state.sessionErrors).filter(([id]) => id !== sessionID))
+ : state.sessionErrors
+ return {
+ sessionStatus: { ...state.sessionStatus, [sessionID]: status },
+ // Clear status text when idle
+ statusText: status.type === "idle" ? { ...state.statusText, [sessionID]: "" } : state.statusText,
+ sessionErrors,
+ }
+ })
// SSE is the source of truth — update sending state unconditionally
if (status.type === "idle") {
@@ -362,7 +376,25 @@ export const useEvents = create((set, get) => ({
case "session.created": {
const info = props.info as Session | undefined
if (!info) break
- // Add to sessions list
+ // #187 (AC3): route on parentID — a child must never reach the
+ // root list. Roots prepend exactly as before; children upsert
+ // into childrenByParent ONLY if that parent's key already
+ // exists (its section already loaded); otherwise ignore — the
+ // next loadChildren fetch picks the child up.
+ if (info.parentID) {
+ useSessions.setState((state) => {
+ const existing = state.childrenByParent[info.parentID as string]
+ if (!existing) return {}
+ if (existing.some((s) => s.id === info.id)) return {}
+ return {
+ childrenByParent: {
+ ...state.childrenByParent,
+ [info.parentID as string]: [info, ...existing],
+ },
+ }
+ })
+ break
+ }
useSessions.setState((state) => {
const exists = state.sessions.some((s) => s.id === info.id)
if (exists) return {}
@@ -378,6 +410,12 @@ export const useEvents = create((set, get) => ({
// Mark so the eventual busy -> idle transition is not counted
// as a success for the store review prompt
erroredSessions.add(sessionID)
+ // #187 (AC4): record the message for any session id so a child
+ // row can show Failed with the server message. Notification and
+ // viewed-session banner behaviour below are unchanged.
+ set((state) => ({
+ sessionErrors: { ...state.sessionErrors, [sessionID]: error?.message || "Session error occurred" },
+ }))
// Clear sending state unconditionally — SSE is truth
useSessions.setState((state) => ({
sending: { ...state.sending, [sessionID]: false },
@@ -555,6 +593,7 @@ export const useEvents = create((set, get) => ({
lastDisconnectAt: null,
sessionStatus: {},
statusText: {},
+ sessionErrors: {},
permissions: {},
questions: {},
})
diff --git a/src/stores/sessions.ts b/src/stores/sessions.ts
index ccd21f334..890ea581c 100644
--- a/src/stores/sessions.ts
+++ b/src/stores/sessions.ts
@@ -7,6 +7,7 @@ import { AnalyticsEvent, track } from "../lib/analytics"
import { extractPromptFromParts, type PromptFromParts } from "../lib/prompt-from-parts"
import { mergeIncomingMessage } from "../lib/message-merge"
import { isColdSessionLoad, isLiveEventForSession } from "../lib/session-load-reconcile"
+import { selectChildren } from "../lib/session-children"
// Helper to convert API response to our internal format
function parseMessages(response: MessageWithParts[]): { messages: Message[]; parts: Record } {
@@ -37,8 +38,17 @@ interface SessionsState {
hasMore: boolean
error: string | null
+ // Subagent children (#187), keyed by parent session id. Loaded on parent
+ // detail focus via loadChildren; live-created children are upserted here by
+ // the session.created handler in events.ts, only when the parent's key
+ // already exists. Never part of the root `sessions` list.
+ childrenByParent: Record
+ childrenLoading: boolean
+ childrenError: string | null
+
// Actions
loadSessions: () => Promise
+ loadChildren: (parentID: string) => Promise
selectSession: (sessionID: string, directory?: string) => Promise
loadOlderMessages: () => Promise
createSession: (title?: string) => Promise
@@ -96,6 +106,9 @@ export const useSessions = create((set, get) => ({
loadingMore: false,
hasMore: false,
error: null,
+ childrenByParent: {},
+ childrenLoading: false,
+ childrenError: null,
loadSessions: async () => {
const connState = useConnections.getState()
@@ -118,6 +131,32 @@ export const useSessions = create((set, get) => ({
}
},
+ // #187: fetch the global session pool (same list() as loadSessions, no
+ // params so no roots filter) and keep only this parent's children.
+ // Deliberately its own list() call rather than sharing loadSessions' pool
+ // — simpler, lower-risk; pool-sharing is a follow-up optimisation. A
+ // failure here never touches the root list or the transcript: the section
+ // shows its own error + retry.
+ loadChildren: async (parentID) => {
+ const connState = useConnections.getState()
+ const client = connState.clientForDirectory(undefined) || connState.client
+ if (!client) {
+ set({ childrenError: "No active connection" })
+ return
+ }
+
+ try {
+ set({ childrenLoading: true, childrenError: null })
+ const pool = await client.session.list()
+ set((state) => ({
+ childrenByParent: { ...state.childrenByParent, [parentID]: selectChildren(pool, parentID) },
+ childrenLoading: false,
+ }))
+ } catch (error) {
+ set({ childrenError: "Failed to load subagents", childrenLoading: false })
+ }
+ },
+
selectSession: async (sessionID, directory) => {
// Use directory-specific client if the session belongs to a different project
const connState = useConnections.getState()
diff --git a/tests/fixtures/mock-opencode-server.ts b/tests/fixtures/mock-opencode-server.ts
index 29ab7f22c..c6c421820 100644
--- a/tests/fixtures/mock-opencode-server.ts
+++ b/tests/fixtures/mock-opencode-server.ts
@@ -103,6 +103,17 @@ export interface MockServerOptions {
* depend on it landing first.
*/
seedDiff?: boolean
+ /**
+ * Pre-populate a parent session (id "seed-parent", in DEFAULT_DIRECTORY)
+ * with two children (parentID "seed-parent") — used by
+ * .maestro/flows/subagents.yaml and the subagents block of
+ * src/lib/session-list.integration.test.ts (#187) to prove the global pool
+ * carries children with parentID, selectChildren isolates one parent's
+ * children, and the root list stays roots-only. The done child carries
+ * agent/model + time.idle (renders Done with an agent · model line); the
+ * idle child carries neither (renders Idle with no agent line).
+ */
+ seedSubagents?: boolean
}
interface StoredSession {
@@ -110,9 +121,12 @@ interface StoredSession {
slug: string
projectID: string
directory: string
+ parentID?: string
title: string
version: string
- time: { created: number; updated: number }
+ agent?: string
+ model?: { providerID: string; modelID: string }
+ time: { created: number; updated: number; idle?: number }
}
interface StoredPart {
@@ -200,6 +214,16 @@ export const SEED_DIFF_TOOL_TITLE = "Edit wide_diff_target.ts"
export const SEED_DIFF_LINE_MARKER = "ZZZ_DIFF_SCROLL_TARGET_ZZZ"
export const SEED_CODE_LINE_MARKER = "ZZZ_CODE_SCROLL_TARGET_ZZZ"
+// Exported so .maestro/flows/subagents.yaml's assertions, the subagents block
+// of src/lib/session-list.integration.test.ts, and this file's own seeding
+// logic share one source of truth for the seeded parent/child pair (#187).
+export const SEED_PARENT_SESSION_ID = "seed-parent"
+export const SEED_PARENT_TITLE = "Parent Session"
+export const SEED_CHILD_DONE_ID = "seed-child-done"
+export const SEED_CHILD_DONE_TITLE = "Done Subagent"
+export const SEED_CHILD_IDLE_ID = "seed-child-idle"
+export const SEED_CHILD_IDLE_TITLE = "Idle Subagent"
+
// Long, space-free filler so the RN can't word-wrap it — it just
// overflows its parent, which is exactly what the horizontal ScrollView (the
// fix under test) exists to make scrollable instead of clipped/truncated.
@@ -213,6 +237,7 @@ export function createMockOpencodeServer(opts: MockServerOptions) {
replyDelayMs = 300,
seedSessions = false,
seedDiff = false,
+ seedSubagents = false,
} = opts
const sessions = new Map()
@@ -308,6 +333,47 @@ export function createMockOpencodeServer(opts: MockServerOptions) {
messagesBySession.set(seedOther.id, [])
}
+ if (seedSubagents) {
+ const now = Date.now()
+ const seedParent: StoredSession = {
+ id: SEED_PARENT_SESSION_ID,
+ slug: "seed-parent",
+ projectID: "mock-project",
+ directory: DEFAULT_DIRECTORY,
+ title: SEED_PARENT_TITLE,
+ version: "0.0.0-mock",
+ time: { created: now - 120_000, updated: now - 60_000 },
+ }
+ const seedChildDone: StoredSession = {
+ id: SEED_CHILD_DONE_ID,
+ slug: "seed-child-done",
+ projectID: "mock-project",
+ directory: DEFAULT_DIRECTORY,
+ parentID: SEED_PARENT_SESSION_ID,
+ title: SEED_CHILD_DONE_TITLE,
+ version: "0.0.0-mock",
+ agent: "explore",
+ model: { providerID: "opencode", modelID: "deepseek-v4-flash-free" },
+ time: { created: now - 60_000, updated: now - 30_000, idle: now - 30_000 },
+ }
+ const seedChildIdle: StoredSession = {
+ id: SEED_CHILD_IDLE_ID,
+ slug: "seed-child-idle",
+ projectID: "mock-project",
+ directory: DEFAULT_DIRECTORY,
+ parentID: SEED_PARENT_SESSION_ID,
+ title: SEED_CHILD_IDLE_TITLE,
+ version: "0.0.0-mock",
+ time: { created: now - 60_000, updated: now - 45_000 },
+ }
+ sessions.set(seedParent.id, seedParent)
+ messagesBySession.set(seedParent.id, [])
+ sessions.set(seedChildDone.id, seedChildDone)
+ messagesBySession.set(seedChildDone.id, [])
+ sessions.set(seedChildIdle.id, seedChildIdle)
+ messagesBySession.set(seedChildIdle.id, [])
+ }
+
function broadcast(type: string, properties: Record) {
const line = `data: ${JSON.stringify({ type, properties })}\n\n`
console.log(`[mock-opencode-server] broadcast type=${type} clients=${sseClients.size}`)
@@ -660,13 +726,14 @@ export function createMockOpencodeServer(opts: MockServerOptions) {
}
}
-function parseArgs(argv: string[]): { port: number; failAuth: boolean; seedSessions: boolean; seedDiff: boolean } {
- const opts = { port: 4096, failAuth: false, seedSessions: false, seedDiff: false }
+function parseArgs(argv: string[]): { port: number; failAuth: boolean; seedSessions: boolean; seedDiff: boolean; seedSubagents: boolean } {
+ const opts = { port: 4096, failAuth: false, seedSessions: false, seedDiff: false, seedSubagents: false }
for (let i = 0; i < argv.length; i++) {
if (argv[i] === "--port") opts.port = Number(argv[++i])
else if (argv[i] === "--fail-auth") opts.failAuth = true
else if (argv[i] === "--seed-sessions") opts.seedSessions = true
else if (argv[i] === "--seed-diff") opts.seedDiff = true
+ else if (argv[i] === "--seed-subagents") opts.seedSubagents = true
}
return opts
}
@@ -679,7 +746,7 @@ if (invokedDirectly) {
const mock = createMockOpencodeServer(opts)
mock.listen().then(() => {
console.log(
- `[mock-opencode-server] listening on ${mock.url} (failAuth=${opts.failAuth}, seedSessions=${opts.seedSessions}, seedDiff=${opts.seedDiff})`,
+ `[mock-opencode-server] listening on ${mock.url} (failAuth=${opts.failAuth}, seedSessions=${opts.seedSessions}, seedDiff=${opts.seedDiff}, seedSubagents=${opts.seedSubagents})`,
)
})
const shutdown = () => {