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 a94029d09..e450a72df 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" @@ -783,6 +784,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 b00f6f3c2..fd8d2850e 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 4bea187cf..d2bcec550 100644 --- a/src/lib/session-list.integration.test.ts +++ b/src/lib/session-list.integration.test.ts @@ -15,11 +15,20 @@ // typecheck + review — these tests prove the /api/event endpoint the store // reads from actually streams, heartbeats, and broadcasts over real HTTP. -import { test, before, after } from "node:test" +import { test, describe, before, after } from "node:test" import assert from "node:assert/strict" import { register } from "node:module" -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" import { SSEParser, SSE_LIVENESS_EVENT_TYPE } from "./sse.ts" import type { Client } from "./sdk" @@ -38,6 +47,7 @@ register( "data:text/javascript,export async function resolve(specifier, context, nextResolve) { if (specifier === 'expo/fetch') { return { url: 'data:text/javascript,export const fetch = globalThis.fetch;', shortCircuit: true }; } try { return await nextResolve(specifier, context); } catch (e) { if ((specifier.startsWith('./') || specifier.startsWith('../')) && !/\\.[a-z]+$/i.test(specifier)) { return nextResolve(specifier + '.ts', context); } throw e; } }", ) + const PORT = 45071 let mock: ReturnType let base: string @@ -125,6 +135,81 @@ test("older servers (404 on /experimental/session) fall back to /session and sti 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) + }) +}) // --- v2 SSE stream (/api/event) — liveness transport coverage (#189/#186) --- // // Transport-level only: subscribes over real HTTP, parses frames with the 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 301078fe1..a0d750a2b 100644 --- a/src/stores/events.ts +++ b/src/stores/events.ts @@ -44,6 +44,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< @@ -209,6 +215,7 @@ export const useEvents = create((set, get) => ({ lastDisconnectAt: null, sessionStatus: {}, statusText: {}, + sessionErrors: {}, permissions: {}, questions: {}, @@ -341,11 +348,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") { @@ -421,7 +435,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 {} @@ -437,6 +469,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 }, @@ -641,6 +679,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 7e1d14ece..4034fd8d7 100644 --- a/tests/fixtures/mock-opencode-server.ts +++ b/tests/fixtures/mock-opencode-server.ts @@ -111,6 +111,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 { @@ -118,9 +129,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 { @@ -208,6 +222,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. @@ -221,6 +245,7 @@ export function createMockOpencodeServer(opts: MockServerOptions) { replyDelayMs = 300, seedSessions = false, seedDiff = false, + seedSubagents = false, } = opts const sessions = new Map() @@ -317,6 +342,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}`) @@ -706,13 +772,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 } @@ -725,7 +792,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 = () => {