diff --git a/packages/app/src/components/dialog-fork.tsx b/packages/app/src/components/dialog-fork.tsx index 5187d980ea26..8f307b6cb928 100644 --- a/packages/app/src/components/dialog-fork.tsx +++ b/packages/app/src/components/dialog-fork.tsx @@ -11,6 +11,7 @@ import { extractPromptFromParts } from "@/utils/prompt" import type { TextPart as SDKTextPart } from "@opencode-ai/sdk/v2/client" import { base64Encode } from "@opencode-ai/core/util/encode" import { useLanguage } from "@/context/language" +import { isHumanUserMessage } from "@opencode-ai/session-ui/closure-record" interface ForkableMessage { id: string @@ -39,9 +40,8 @@ export const DialogFork: Component = () => { const result: ForkableMessage[] = [] for (const message of msgs) { - if (message.role !== "user") continue - const parts = sync().data.part[message.id] ?? [] + if (!isHumanUserMessage(message, parts)) continue const textPart = parts.find((x): x is SDKTextPart => x.type === "text" && !x.synthetic && !x.ignored) if (!textPart) continue diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index 41cf7d30ca34..5c31f012409f 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -28,6 +28,7 @@ import { import { useLayout } from "@/context/layout" import { useSDK } from "@/context/sdk" import { useSync } from "@/context/sync" +import { selectHumanUserMessages } from "@opencode-ai/session-ui/closure-record" import { useComments } from "@/context/comments" import { Button } from "@opencode-ai/ui/button" import { DockShellForm, DockTray } from "@opencode-ai/ui/dock-surface" @@ -313,7 +314,7 @@ export const PromptInput: Component = (props) => { if (!sessionID) return false const messages = sync().data.message[sessionID] if (!messages) return false - return messages.some((m) => m.role === "user") + return selectHumanUserMessages(messages, (messageID) => sync().data.part[messageID] ?? []).length > 0 }) const history = props.history ?? createPersistedPromptInputHistory() diff --git a/packages/app/src/components/session/session-context-tab.tsx b/packages/app/src/components/session/session-context-tab.tsx index a0758f3eacae..4a669b25b472 100644 --- a/packages/app/src/components/session/session-context-tab.tsx +++ b/packages/app/src/components/session/session-context-tab.tsx @@ -18,6 +18,7 @@ import { useLanguage } from "@/context/language" import { useProviders } from "@/hooks/use-providers" import { useSDK } from "@/context/sdk" import { useSessionLayout } from "@/pages/session/session-layout" +import { selectHumanUserMessages } from "@opencode-ai/session-ui/closure-record" import { getSessionContext } from "./session-context-metrics" import { estimateSessionContextBreakdown, type SessionContextBreakdownKey } from "./session-context-breakdown" import { createSessionContextFormatter } from "./session-context-format" @@ -114,7 +115,7 @@ export function SessionContextTab() { ) const userMessages = createMemo( - () => messages().filter((m) => m.role === "user") as UserMessage[], + () => selectHumanUserMessages(messages(), (messageID) => sync().data.part[messageID] ?? []), emptyUserMessages, { equals: same }, ) @@ -147,11 +148,10 @@ export function SessionContextTab() { const counts = createMemo(() => { const all = messages() - const user = all.reduce((count, x) => count + (x.role === "user" ? 1 : 0), 0) const assistant = all.reduce((count, x) => count + (x.role === "assistant" ? 1 : 0), 0) return { all: all.length, - user, + user: userMessages().length, assistant, } }) diff --git a/packages/app/src/components/session/session-header.tsx b/packages/app/src/components/session/session-header.tsx index dc6cdaa6f3fd..642e0234fd30 100644 --- a/packages/app/src/components/session/session-header.tsx +++ b/packages/app/src/components/session/session-header.tsx @@ -33,6 +33,7 @@ import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2" import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2" import { reviewTooltipKeybind } from "../command-tooltip-keybind" import { useTitlebarRightMount } from "../titlebar" +import { selectHumanUserMessages } from "@opencode-ai/session-ui/closure-record" const OPEN_APPS = [ "vscode", @@ -231,9 +232,12 @@ export function SessionHeader() { ({ id: "finder", label: fileManager().label, icon: fileManager().icon } as const), ) const opening = createMemo(() => openRequest.app !== undefined) - const tint = createMemo(() => - messageAgentColor(params.id ? sync().data.message[params.id] : undefined, sync().data.agent), - ) + const tint = createMemo(() => { + if (!params.id) return + const messages = sync().data.message[params.id] ?? [] + const human = selectHumanUserMessages(messages, (messageID) => sync().data.part[messageID] ?? []) + return messageAgentColor(human, sync().data.agent) + }) const v2ActionsState = createMemo(() => ({ statusVisible: status(), statusLabel: language.t("status.popover.trigger"), diff --git a/packages/app/src/pages/layout/sidebar-items.tsx b/packages/app/src/pages/layout/sidebar-items.tsx index 776b81ae5a83..57935e57dc00 100644 --- a/packages/app/src/pages/layout/sidebar-items.tsx +++ b/packages/app/src/pages/layout/sidebar-items.tsx @@ -17,6 +17,7 @@ import { messageAgentColor } from "@/utils/agent" import { sessionTitle } from "@/utils/session-title" import { sessionPermissionRequest } from "../session/composer/session-request-tree" import { childSessionOnPath, getProjectAvatarSource, hasProjectPermissions } from "./helpers" +import { selectHumanUserMessages } from "@opencode-ai/session-ui/closure-record" export const ProjectIcon = (props: { project: LocalProject @@ -168,9 +169,14 @@ export const SessionItem = (props: SessionItemProps): JSX.Element => { return serverSync().session.data.session_working(props.session.id) }) - const tint = createMemo(() => - messageAgentColor(serverSync().session.data.message[props.session.id], sessionStore.agent), - ) + const tint = createMemo(() => { + const messages = serverSync().session.data.message[props.session.id] ?? [] + const human = selectHumanUserMessages( + messages, + (messageID) => serverSync().session.data.part[messageID] ?? [], + ) + return messageAgentColor(human, sessionStore.agent) + }) const tooltip = createMemo(() => props.showTooltip ?? (props.mobile || !props.sidebarExpanded())) const currentChild = createMemo(() => { if (!props.showChild) return diff --git a/packages/app/src/pages/session/timeline/closure.ts b/packages/app/src/pages/session/timeline/closure.ts new file mode 100644 index 000000000000..8afc9d549cfe --- /dev/null +++ b/packages/app/src/pages/session/timeline/closure.ts @@ -0,0 +1,35 @@ +import { Binary } from "@opencode-ai/core/util/binary" +import type { AssistantMessage, Message, Part, SessionStatus, UserMessage } from "@opencode-ai/sdk/v2" +import { closureEvidencePart } from "@opencode-ai/session-ui/closure-record" + +export function closureTimelineMessageID(message: UserMessage, parts: Part[]) { + if (!closureEvidencePart(message, parts)) return + return message.id +} + +export function selectActiveTimelineMessageID(messages: Message[], userMessages: UserMessage[], status: SessionStatus) { + const parentID = messages.findLast( + (message): message is AssistantMessage => + message.role === "assistant" && typeof message.time.completed !== "number", + )?.parentID + if (parentID) { + const result = Binary.search(userMessages, parentID, (message) => message.id) + const message = result.found ? userMessages[result.index] : userMessages.find((item) => item.id === parentID) + if (message) return message.id + } + + if (status.type === "idle") return + return userMessages.at(-1)?.id +} + +export function selectTimelineUserMessages( + messages: Message[], + userMessages: UserMessage[], + parts: (messageID: string) => Part[], +) { + const human = new Set(userMessages.map((message) => message.id)) + return messages.filter((message): message is UserMessage => { + if (message.role !== "user") return false + return human.has(message.id) || !!closureEvidencePart(message, parts(message.id)) + }) +} diff --git a/packages/app/src/pages/session/timeline/message-timeline.tsx b/packages/app/src/pages/session/timeline/message-timeline.tsx index 18a153d29971..35786b710ba5 100644 --- a/packages/app/src/pages/session/timeline/message-timeline.tsx +++ b/packages/app/src/pages/session/timeline/message-timeline.tsx @@ -84,7 +84,7 @@ const emptyTools: ToolPart[] = [] const emptyAssistantMessages: AssistantMessage[] = [] const idle = { type: "idle" as const } -type FramedTimelineRow = Exclude +type FramedTimelineRow = Exclude type TimelineRowByTag = Extract const timelineFallbackItemSize = 60 @@ -1072,6 +1072,27 @@ export function MessageTimeline(props: { switch (row()._tag) { case "TurnGap": return + ) +} + export function AssistantMessageDisplay(props: { message: AssistantMessage parts: PartType[] @@ -1994,9 +2007,7 @@ ToolRegistry.register({ typeof props.input.description === "string" && props.input.description ? props.input.description : childSessionId() - if (!value) return value - if (props.metadata.background === true) return `${value} (background)` - return value + return formatTaskSubtitle(value, props.metadata.background === true) }) const running = createMemo(() => props.status === "pending" || props.status === "running") diff --git a/packages/session-ui/src/components/session-turn.tsx b/packages/session-ui/src/components/session-turn.tsx index ce5f0b545ee8..8593cd953b8f 100644 --- a/packages/session-ui/src/components/session-turn.tsx +++ b/packages/session-ui/src/components/session-turn.tsx @@ -15,6 +15,7 @@ import { createEffect, createMemo, createSignal, For, on, ParentProps, Show } fr import { createStore } from "solid-js/store" import { Dynamic } from "solid-js/web" import { AssistantParts, Message, MessageDivider, PART_MAPPING, type UserActions } from "./message-part" +import { isHumanUserMessage } from "./closure-record" import { Card } from "@opencode-ai/ui/card" import { Accordion } from "@opencode-ai/ui/accordion" import { StickyAccordionHeader } from "@opencode-ai/ui/sticky-accordion-header" @@ -189,7 +190,7 @@ export function SessionTurn( if (index < 0) return -1 const msg = messages[index] - if (!msg || msg.role !== "user") return -1 + if (!msg || !isHumanUserMessage(msg, list(data.store.part?.[msg.id], emptyParts))) return -1 return index }) @@ -219,7 +220,7 @@ export function SessionTurn( const messages = allMessages() ?? emptyMessages const result = Binary.search(messages, item.parentID, (m) => m.id) const msg = result.found ? messages[result.index] : messages.find((m) => m.id === item.parentID) - if (!msg || msg.role !== "user") return + if (!msg || !isHumanUserMessage(msg, list(data.store.part?.[msg.id], emptyParts))) return return msg }) diff --git a/packages/tui/src/component/prompt/index.tsx b/packages/tui/src/component/prompt/index.tsx index fe7f4a22f75f..e1231f7d1523 100644 --- a/packages/tui/src/component/prompt/index.tsx +++ b/packages/tui/src/component/prompt/index.tsx @@ -41,6 +41,9 @@ import type { AssistantMessage, FilePart, UserMessage } from "@opencode-ai/sdk/v import { Locale } from "../../util/locale" import { errorMessage } from "../../util/error" import { formatDuration } from "../../util/format" +import { abortSessionBranch } from "../../util/session-abort" +import { decideInterrupt, INTERRUPT_WINDOW_MS } from "../../util/session-interrupt" +import { isHumanUserMessage } from "../../util/closure-record" import { createColors, createFrames } from "../../ui/spinner" import { useDialog } from "../../ui/dialog" import { DialogProvider as DialogProviderConnect } from "../dialog-provider" @@ -57,6 +60,7 @@ import { usePromptWorkspace } from "./workspace" import { usePromptMove } from "./move" import { readLocalAttachment } from "./local-attachment" import { useLocation } from "../../context/location" +import { countActiveDescendants } from "../../util/session-tree" registerOpencodeSpinner() @@ -112,6 +116,15 @@ function fadeColor(color: RGBA, alpha: number) { return RGBA.fromValues(color.r, color.g, color.b, color.a * alpha) } +function ActiveIndicator(props: { count: number }) { + const theme = useTheme().theme + return ( + + {props.count} active task{props.count === 1 ? "" : "s"} + + ) +} + function hasEditorRangeSelection(selection: EditorSelection["ranges"][number]) { return ( selection.selection.start.line !== selection.selection.end.line || @@ -161,6 +174,9 @@ export function Prompt(props: PromptProps) { const dialog = useDialog() const toast = useToast() const status = createMemo(() => sync.data.session_status?.[props.sessionID ?? ""] ?? { type: "idle" }) + const activeDescendants = createMemo(() => + countActiveDescendants(sync.data.session, sync.data.session_status, props.sessionID ?? ""), + ) const history = usePromptHistory() const stash = usePromptStash() const keymap = useOpencodeKeymap() @@ -258,7 +274,7 @@ export function Prompt(props: PromptProps) { if (!props.sessionID) return undefined const messages = sync.data.message[props.sessionID] if (!messages) return undefined - return messages.findLast((m): m is UserMessage => m.role === "user") + return messages.findLast((m): m is UserMessage => isHumanUserMessage(m, sync.data.part[m.id] ?? [])) }) const usage = createMemo(() => { @@ -286,6 +302,7 @@ export function Prompt(props: PromptProps) { mode: "normal" | "shell" extmarkToPartIndex: Map interrupt: number + interruptAt: number placeholder: number }>({ placeholder: randomIndex(list().length), @@ -296,6 +313,7 @@ export function Prompt(props: PromptProps) { mode: "normal", extmarkToPartIndex: new Map(), interrupt: 0, + interruptAt: 0, }) createEffect( @@ -394,29 +412,54 @@ export function Prompt(props: PromptProps) { name: "session.interrupt", category: "Session", hidden: true, - enabled: status().type !== "idle", + // Not keyed to this session's own status: a session reads idle while delegated work + // still runs beneath it, and that case is the defect, not a fast path. Scanning the + // subtree instead would not fix it - `session_status` is an event-fed projection, so + // it lags the work it would have to detect and carries nothing at all for a job whose + // child session does not exist yet. Ask rather than predict: `abort` answers success + // when a session has no work, so a request with nothing to close costs one round trip. + enabled: Boolean(props.sessionID), run: () => { - if (auto()?.visible) return - if (!input.focused) return - // TODO: this should be its own command - if (store.mode === "shell") { + const outcome = decideInterrupt({ + sessionID: props.sessionID, + autocompleteVisible: Boolean(auto()?.visible), + focused: input.focused, + mode: store.mode, + armed: store.interrupt, + armedAt: store.interruptAt, + now: Date.now(), + }) + + if (outcome.kind === "blocked") return + if (outcome.kind === "exit_shell") { setStore("mode", "normal") return } - if (!props.sessionID) return - - setStore("interrupt", store.interrupt + 1) - - setTimeout(() => { - setStore("interrupt", 0) - }, 5000) - - if (store.interrupt >= 2) { - void sdk.client.session.abort({ - sessionID: props.sessionID, - }) - setStore("interrupt", 0) + if (outcome.kind === "arm") { + setStore("interrupt", outcome.armed) + setStore("interruptAt", outcome.armedAt) + // The timer clears only the sequence that scheduled it. The decision itself + // expires the window from `armedAt`, so a stale timer cannot cancel a later + // sequence and a delayed timer cannot keep an expired press alive. + setTimeout(() => { + if (store.interruptAt !== outcome.armedAt) return + setStore("interrupt", 0) + }, INTERRUPT_WINDOW_MS) + dialog.clear() + return } + + setStore("interrupt", 0) + void abortSessionBranch({ + client: sdk.client, + sessionID: outcome.sessionID, + onFailure: (error) => + toast.show({ + title: "Interrupt failed", + message: errorMessage(error), + variant: "error", + }), + }) dialog.clear() }, }, @@ -1058,15 +1101,20 @@ export function Prompt(props: PromptProps) { if (store.mode === "shell") { move.startSubmit() - void sdk.client.session.shell({ - sessionID, - agent: agent.name, - model: { - providerID: selectedModel.providerID, - modelID: selectedModel.modelID, - }, - command: inputText, - }) + void sdk.client.session + .shell( + { + sessionID, + agent: agent.name, + model: { + providerID: selectedModel.providerID, + modelID: selectedModel.modelID, + }, + command: inputText, + }, + { throwOnError: true }, + ) + .catch((error) => toast.show({ message: errorMessage(error), variant: "error" })) setStore("mode", "normal") } else if ( inputText.startsWith("/") && @@ -1080,19 +1128,24 @@ export function Prompt(props: PromptProps) { const restOfInput = firstLineEnd === -1 ? "" : inputText.slice(firstLineEnd + 1) const args = firstLineArgs.join(" ") + (restOfInput ? "\n" + restOfInput : "") - void sdk.client.session.command({ - sessionID, - command: command.slice(1), - arguments: args, - agent: agent.name, - model: `${selectedModel.providerID}/${selectedModel.modelID}`, - variant, - parts: nonTextParts.filter((x) => x.type === "file"), - }) + void sdk.client.session + .command( + { + sessionID, + command: command.slice(1), + arguments: args, + agent: agent.name, + model: `${selectedModel.providerID}/${selectedModel.modelID}`, + variant, + parts: nonTextParts.filter((x) => x.type === "file"), + }, + { throwOnError: true }, + ) + .catch((error) => toast.show({ message: errorMessage(error), variant: "error" })) } else { move.startSubmit() - sdk.client.session - .prompt( + try { + await sdk.client.session.promptAsync( { sessionID, ...selectedModel, @@ -1110,13 +1163,15 @@ export function Prompt(props: PromptProps) { }, { throwOnError: true }, ) - .catch((error) => { - toast.show({ - title: "Failed to send prompt", - message: errorMessage(error), - variant: "error", - }) + } catch (error) { + toast.show({ + title: "Failed to send prompt", + message: errorMessage(error), + variant: "error", }) + if (finishMoveProgress) move.finishSubmit() + return false + } if (editorParts.length > 0) editor.markSelectionSent() } history.append({ @@ -1590,6 +1645,9 @@ export function Prompt(props: PromptProps) { {store.interrupt > 0 ? "again to interrupt" : "interrupt"} + 0}> + + @@ -1642,6 +1700,11 @@ export function Prompt(props: PromptProps) { (new working copy) + 0}> + + + + {props.hint ?? ( diff --git a/packages/tui/src/config/keybind.ts b/packages/tui/src/config/keybind.ts index 5dd7e4b5aafe..31af74b50fbd 100644 --- a/packages/tui/src/config/keybind.ts +++ b/packages/tui/src/config/keybind.ts @@ -95,7 +95,7 @@ export const Definitions = { session_share: keybind("none", "Share current session"), session_unshare: keybind("none", "Unshare current session"), session_interrupt: keybind("escape", "Interrupt current session"), - session_background: keybind("ctrl+b", "Background synchronous subagents"), + session_background: keybind("ctrl+b", "Make subagents async"), session_compact: keybind("c", "Compact the session"), session_toggle_timestamps: keybind("none", "Toggle message timestamps"), session_toggle_generic_tool_output: keybind("none", "Toggle generic tool output"), diff --git a/packages/tui/src/context/sync.tsx b/packages/tui/src/context/sync.tsx index 71e050d11e68..b2127a1f50b9 100644 --- a/packages/tui/src/context/sync.tsx +++ b/packages/tui/src/context/sync.tsx @@ -32,6 +32,7 @@ import { batch, onMount } from "solid-js" import path from "path" import { useKV } from "./kv" import { usePermission } from "./permission" +import { transcriptStatus } from "../util/closure-record" const emptyConsoleState: ConsoleState = { consoleManagedProviders: [], @@ -149,6 +150,7 @@ export const { const fullSyncedSessions = new Set() const syncingSessions = new Map>() + const treeSyncedRoots = new Set() const hydratingSessions = new Map; parts: Set }>() const touchMessage = (sessionID: string, messageID: string) => { hydratingSessions.get(sessionID)?.messages.add(messageID) @@ -587,9 +589,7 @@ export const { if (session.time.compacting) return "compacting" const messages = store.message[sessionID] ?? [] const last = messages.at(-1) - if (!last) return "idle" - if (last.role === "user") return "working" - return last.time.completed ? "idle" : "working" + return transcriptStatus(last, last ? (store.part[last.id] ?? []) : []) }, async sync(sessionID: string) { if (fullSyncedSessions.has(sessionID)) return @@ -665,6 +665,57 @@ export const { syncingSessions.set(sessionID, task) return task }, + async syncTree(sessionID: string) { + // Walk up to the root, fetching any parent not yet in the store. + let rootID = sessionID + const visited = new Set() + while (true) { + if (visited.has(rootID)) break + visited.add(rootID) + const match = search(store.session, rootID, (s) => s.id) + if (!match.found) break + const parentID = store.session[match.index].parentID + if (!parentID) break + const parentMatch = search(store.session, parentID, (s) => s.id) + if (parentMatch.found) { + rootID = parentID + continue + } + const res = await sdk.client.session.get({ sessionID: parentID }).catch(() => undefined) + if (!res?.data) break + setStore( + produce((draft) => { + const idx = search(draft.session, res.data!.id, (s) => s.id) + if (!idx.found) draft.session.splice(idx.index, 0, res.data!) + }), + ) + rootID = parentID + } + + if (treeSyncedRoots.has(rootID)) return + treeSyncedRoots.add(rootID) + + // BFS from the root, loading every descendant level by level via session.children. + const queue = [rootID] + while (queue.length > 0) { + const level = [...queue] + queue.length = 0 + const results = await Promise.all( + level.map((id) => sdk.client.session.children({ sessionID: id }).catch(() => undefined)), + ) + const all = results.flatMap((r) => r?.data ?? []) + if (all.length === 0) break + setStore( + produce((draft) => { + for (const child of all) { + const idx = search(draft.session, child.id, (s) => s.id) + if (!idx.found) draft.session.splice(idx.index, 0, child) + } + }), + ) + for (const child of all) queue.push(child.id) + } + }, }, bootstrap, } diff --git a/packages/tui/src/routes/session/dialog-fork-from-timeline.tsx b/packages/tui/src/routes/session/dialog-fork-from-timeline.tsx index 4cc9b59c00a9..ac81893605f6 100644 --- a/packages/tui/src/routes/session/dialog-fork-from-timeline.tsx +++ b/packages/tui/src/routes/session/dialog-fork-from-timeline.tsx @@ -8,6 +8,7 @@ import { useRoute } from "../../context/route" import { useDialog, type DialogContext } from "../../ui/dialog" import type { PromptInfo } from "../../component/prompt/history" import { stripPromptPartIDs as strip } from "../../prompt/part" +import { isHumanUserMessage } from "../../util/closure-record" export function DialogForkFromTimeline(props: { sessionID: string; onMove: (messageID?: string) => void }) { const sync = useSync() @@ -35,8 +36,9 @@ export function DialogForkFromTimeline(props: { sessionID: string; onMove: (mess } satisfies DialogSelectOption const result = [] as DialogSelectOption[] for (const message of messages) { - if (message.role !== "user") continue - const part = (sync.data.part[message.id] ?? []).find( + const parts = sync.data.part[message.id] ?? [] + if (!isHumanUserMessage(message, parts)) continue + const part = parts.find( (x) => x.type === "text" && !x.synthetic && !x.ignored, ) as TextPart if (!part) continue @@ -49,7 +51,6 @@ export function DialogForkFromTimeline(props: { sessionID: string; onMove: (mess sessionID: props.sessionID, messageID: message.id, }) - const parts = sync.data.part[message.id] ?? [] const prompt = parts.reduce( (agg, part) => { if (part.type === "text") { diff --git a/packages/tui/src/routes/session/dialog-message.tsx b/packages/tui/src/routes/session/dialog-message.tsx index b7d01842060b..81d157fc575d 100644 --- a/packages/tui/src/routes/session/dialog-message.tsx +++ b/packages/tui/src/routes/session/dialog-message.tsx @@ -1,11 +1,12 @@ import { createMemo } from "solid-js" import { useSync } from "../../context/sync" -import { DialogSelect } from "../../ui/dialog-select" +import { DialogSelect, type DialogSelectOption } from "../../ui/dialog-select" import { useSDK } from "../../context/sdk" import { useRoute } from "../../context/route" import { useClipboard } from "../../context/clipboard" import type { PromptInfo } from "../../component/prompt/history" import { stripPromptPartIDs as strip } from "../../prompt/part" +import { isHumanUserMessage } from "../../util/closure-record" export function DialogMessage(props: { messageID: string @@ -15,13 +16,18 @@ export function DialogMessage(props: { const sync = useSync() const sdk = useSDK() const message = createMemo(() => sync.data.message[props.sessionID]?.find((x) => x.id === props.messageID)) + const actionable = createMemo(() => { + const value = message() + if (!value) return false + return isHumanUserMessage(value, sync.data.part[value.id] ?? []) + }) const route = useRoute() const clipboard = useClipboard() return ( []).filter(() => actionable())} /> ) } diff --git a/packages/tui/src/routes/session/dialog-timeline.tsx b/packages/tui/src/routes/session/dialog-timeline.tsx index bda87119d4b5..5e62692a4bb2 100644 --- a/packages/tui/src/routes/session/dialog-timeline.tsx +++ b/packages/tui/src/routes/session/dialog-timeline.tsx @@ -6,6 +6,7 @@ import { Locale } from "../../util/locale" import { DialogMessage } from "./dialog-message" import { useDialog } from "../../ui/dialog" import type { PromptInfo } from "../../component/prompt/history" +import { isHumanUserMessage } from "../../util/closure-record" export function DialogTimeline(props: { sessionID: string @@ -23,8 +24,9 @@ export function DialogTimeline(props: { const messages = sync.data.message[props.sessionID] ?? [] const result = [] as DialogSelectOption[] for (const message of messages) { - if (message.role !== "user") continue - const part = (sync.data.part[message.id] ?? []).find( + const parts = sync.data.part[message.id] ?? [] + if (!isHumanUserMessage(message, parts)) continue + const part = parts.find( (x) => x.type === "text" && !x.synthetic && !x.ignored, ) as TextPart if (!part) continue diff --git a/packages/tui/src/routes/session/footer.tsx b/packages/tui/src/routes/session/footer.tsx index c3a96254e98b..607fcd759ab1 100644 --- a/packages/tui/src/routes/session/footer.tsx +++ b/packages/tui/src/routes/session/footer.tsx @@ -5,6 +5,7 @@ import { useDirectory } from "../../context/directory" import { useConnected } from "../../component/use-connected" import { createStore } from "solid-js/store" import { useRoute } from "../../context/route" +import { collectSubtree } from "../../util/session-tree" export function Footer() { const { theme } = useTheme() @@ -15,7 +16,7 @@ export function Footer() { const lsp = createMemo(() => Object.keys(sync.data.lsp)) const permissions = createMemo(() => { if (route.data.type !== "session") return [] - return sync.data.permission[route.data.sessionID] ?? [] + return collectSubtree(sync.data.session, route.data.sessionID).flatMap((id) => sync.data.permission[id] ?? []) }) const directory = useDirectory() const connected = useConnected() diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index cbdaf0cfa0c7..45b59b8f6df9 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -82,6 +82,14 @@ import { getRevertDiffFiles } from "../../util/revert-diff" import { OPENCODE_BASE_MODE, useBindings, useCommandShortcut, useOpencodeKeymap } from "../../keymap" import { usePathFormatter } from "../../context/path-format" import { LocationProvider } from "../../context/location" +import { runAfterSessionBranchAbort } from "../../util/session-abort" +import { + closureEvidencePart, + isHumanUserMessage, + isMessageNavigationStop, + taskSpinnerRunning, +} from "../../util/closure-record" +import { collectSubtree } from "../../util/session-tree" addDefaultParsers(parsers.parsers) @@ -206,9 +214,12 @@ export function Session() { onCleanup(() => setEpilogue()) const children = createMemo(() => { const parentID = session()?.parentID ?? session()?.id + // Newest first. Session ids are descending, so ascending-id order used to express that; + // after the 2026-08-14T11:19:55.136Z wrap new sessions get high ids and sorted last, + // rendering as the oldest. Order by creation time, keeping raw id as a stable tie-break. return sync.data.session .filter((x) => x.parentID === parentID || x.id === parentID) - .toSorted((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)) + .toSorted((a, b) => b.time.created - a.time.created || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)) }) const messages = createMemo(() => sync.data.message[route.sessionID] ?? []) const messagesBeforeRevert = () => { @@ -230,14 +241,14 @@ export function Session() { ) : [], ) - const permissions = createMemo(() => { - if (session()?.parentID) return [] - return children().flatMap((x) => sync.data.permission[x.id] ?? []) - }) - const questions = createMemo(() => { - if (session()?.parentID) return [] - return children().flatMap((x) => sync.data.question[x.id] ?? []) + // Full descendant tree (any depth) so depth-2+ subagent prompts surface - not just direct children. + const descendants = createMemo(() => { + const rootID = session()?.id + if (!rootID || session()?.parentID) return [] + return collectSubtree(sync.data.session, rootID) }) + const permissions = createMemo(() => descendants().flatMap((id) => sync.data.permission[id] ?? [])) + const questions = createMemo(() => descendants().flatMap((id) => sync.data.question[id] ?? [])) const visible = createMemo(() => !session()?.parentID && permissions().length === 0 && questions().length === 0) const disabled = createMemo(() => permissions().length > 0 || questions().length > 0) @@ -312,6 +323,7 @@ export function Session() { } editor.reconnect(result.data.directory) await sync.session.sync(sessionID) + void sync.session.syncTree(sessionID).catch(() => {}) if (route.sessionID === sessionID && scroll) scroll.scrollBy(100_000) })().catch((error) => { if (route.sessionID !== sessionID) return @@ -392,7 +404,7 @@ export function Session() { const parts = sync.data.part[message.id] if (!parts || !Array.isArray(parts)) return false - return parts.some((part) => part && part.type === "text" && !part.synthetic && !part.ignored) + return isMessageNavigationStop(message, parts) }) .sort((a, b) => a.y - b.y) @@ -616,32 +628,44 @@ export function Session() { name: "undo", }, run: async () => { - const status = sync.data.session_status?.[route.sessionID] - if (status?.type !== "idle") await sdk.client.session.abort({ sessionID: route.sessionID }).catch(() => {}) - const message = messagesBeforeRevert().findLast((item) => item.role === "user") - if (!message) return - void sdk.client.session - .revert({ - sessionID: route.sessionID, - messageID: message.id, - }) - .then(() => { - toBottom() - }) - const parts = sync.data.part[message.id] - prompt?.set( - parts.reduce( - (agg, part) => { - if (part.type === "text") { - if (!part.synthetic) agg.input += part.text - } - if (part.type === "file") agg.parts.push(part) - return agg - }, - { input: "", parts: [] as PromptInfo["parts"] }, - ), - ) - dialog.clear() + await runAfterSessionBranchAbort({ + client: sdk.client, + sessionID: route.sessionID, + onFailure: (error) => + toast.show({ + title: "Undo failed", + message: errorMessage(error), + variant: "error", + }), + action: async () => { + const message = messagesBeforeRevert().findLast((item) => + isHumanUserMessage(item, sync.data.part[item.id] ?? []), + ) + if (!message) return + void sdk.client.session + .revert({ + sessionID: route.sessionID, + messageID: message.id, + }) + .then(() => { + toBottom() + }) + const parts = sync.data.part[message.id] + prompt?.set( + parts.reduce( + (agg, part) => { + if (part.type === "text") { + if (!part.synthetic) agg.input += part.text + } + if (part.type === "file") agg.parts.push(part) + return agg + }, + { input: "", parts: [] as PromptInfo["parts"] }, + ), + ) + dialog.clear() + }, + }) }, }, { @@ -652,21 +676,36 @@ export function Session() { slash: { name: "redo", }, - run: () => { + run: async () => { dialog.clear() const messageID = session()?.revert?.messageID if (!messageID) return - const message = messages().find((x) => x.role === "user" && x.id > messageID) - if (!message) { - void sdk.client.session.unrevert({ - sessionID: route.sessionID, - }) - prompt?.set({ input: "", parts: [] }) - return - } - void sdk.client.session.revert({ + await runAfterSessionBranchAbort({ + client: sdk.client, sessionID: route.sessionID, - messageID: message.id, + onFailure: (error) => + toast.show({ + title: "Redo failed", + message: errorMessage(error), + variant: "error", + }), + action: async () => { + const boundaryIndex = messages().findIndex((x) => x.id === messageID) + const message = messages().find( + (x, i) => boundaryIndex >= 0 && i > boundaryIndex && isHumanUserMessage(x, sync.data.part[x.id] ?? []), + ) + if (!message) { + void sdk.client.session.unrevert({ + sessionID: route.sessionID, + }) + prompt?.set({ input: "", parts: [] }) + return + } + void sdk.client.session.revert({ + sessionID: route.sessionID, + messageID: message.id, + }) + }, }) }, }, @@ -841,14 +880,12 @@ export function Session() { // Find the most recent user message with non-ignored, non-synthetic text parts for (let i = messages.length - 1; i >= 0; i--) { const message = messages[i] - if (!message || message.role !== "user") continue + if (!message || !isHumanUserMessage(message, sync.data.part[message.id] ?? [])) continue const parts = sync.data.part[message.id] if (!parts || !Array.isArray(parts)) continue - const hasValidTextPart = parts.some( - (part) => part && part.type === "text" && !part.synthetic && !part.ignored, - ) + const hasValidTextPart = isMessageNavigationStop(message, parts) if (hasValidTextPart) { const child = scroll.getChildren().find((child) => { @@ -1020,7 +1057,7 @@ export function Session() { }, }, { - title: "Background subagents", + title: "Make subagents async", value: "session.background", category: "Session", hidden: true, @@ -1137,7 +1174,7 @@ export function Session() { if (index === -1) return [] return messages() .slice(index) - .filter((message) => message.role === "user") + .filter((message) => isHumanUserMessage(message, sync.data.part[message.id] ?? [])) }) const revert = createMemo(() => { @@ -1200,6 +1237,9 @@ export function Session() { {(message, index) => ( + + {(part) => } + {(function () { const redoShortcut = useCommandShortcut("session.redo") @@ -1362,6 +1402,19 @@ export function Session() { ) } +function BranchClosureMessage(props: { message: { id: string }; part: TextPart; index: number }) { + const { theme } = useTheme() + return ( + + + Branch closure + {"\n"} + {props.part.text} + + + ) +} + function UserMessage(props: { message: UserMessage parts: Part[] @@ -1526,7 +1579,7 @@ function AssistantMessage(props: { message: AssistantMessage; parts: Part[]; las > · {backgroundShortcut()} - background + async @@ -2246,11 +2299,7 @@ function Task(props: ToolProps) { const status = createMemo(() => sync.data.session_status[sessionID() ?? ""]) const isRunning = createMemo(() => { - const value = status() - return ( - props.part.state.status === "running" || - (props.metadata.background === true && value !== undefined && value.type !== "idle") - ) + return taskSpinnerRunning(props.part.state.status, props.metadata.background === true, status()) }) const retry = createMemo(() => { const value = status() @@ -2321,7 +2370,7 @@ export function formatSubagentToolcalls(count: number) { } export function formatSubagentTitle(agent: string, description: string, background: boolean) { - return `${agent} Task${background ? " (background)" : ""} — ${description}` + return `${agent} Task${background ? " (async)" : ""} — ${description}` } export function formatSubagentRetry(attempt: number, message: string) { diff --git a/packages/tui/src/util/closure-record.ts b/packages/tui/src/util/closure-record.ts new file mode 100644 index 000000000000..6d1306bb5cea --- /dev/null +++ b/packages/tui/src/util/closure-record.ts @@ -0,0 +1,32 @@ +import { isCompleteClosurePair } from "@opencode-ai/core/session/closure-record" +import type { Message, Part, TextPart, UserMessage } from "@opencode-ai/sdk/v2" + +export function closureEvidencePart(message: Message, parts: Part[]): TextPart | undefined { + if (!isCompleteClosurePair({ info: message, parts })) return + const part = parts[0] + if (part?.type !== "text") return + return part +} + +export function isHumanUserMessage(message: Message, parts: Part[]): message is UserMessage { + return message.role === "user" && !isCompleteClosurePair({ info: message, parts }) +} + +export function isMessageNavigationStop(message: Message, parts: Part[]) { + if (isCompleteClosurePair({ info: message, parts })) return false + return parts.some((part) => part.type === "text" && !part.synthetic && !part.ignored) +} + +export function transcriptStatus(message: Message | undefined, parts: Part[]): "idle" | "working" { + if (!message || isCompleteClosurePair({ info: message, parts })) return "idle" + if (message.role === "user") return "working" + return message.time.completed ? "idle" : "working" +} + +export function taskSpinnerRunning( + partStatus: string, + background: boolean, + sessionStatus: { type: string } | undefined, +) { + return partStatus === "running" || (background && sessionStatus !== undefined && sessionStatus.type !== "idle") +} diff --git a/packages/tui/src/util/session-abort.ts b/packages/tui/src/util/session-abort.ts new file mode 100644 index 000000000000..4ef027eb0555 --- /dev/null +++ b/packages/tui/src/util/session-abort.ts @@ -0,0 +1,60 @@ +/** + * Structural view of the generated client, narrowed to the one call this module + * makes so a test fake satisfies the same shape as the production client. + */ +export type SessionAbortClient = { + session: { + abort: (parameters: { sessionID: string }, options: { throwOnError: true }) => Promise + } +} + +export type SessionAbortInput = { + client: SessionAbortClient + sessionID: string + /** + * Called exactly once when the branch did not close. The value is whatever the + * client rejected with; for a typed 500 the SDK's error interceptor has already + * wrapped the failure into an `Error` carrying its message. + */ + onFailure: (error: unknown) => void +} + +export type SessionAbortActionInput = SessionAbortInput & { + /** Work whose safety depends on the branch actually being closed. */ + action: () => Promise +} + +/** + * Requests branch closure and observes the outcome. + * + * `throwOnError: true` is required, not stylistic: the generated client defaults + * to `ThrowOnError = false`, so a bare `session.abort(...)` resolves with an + * `{ error }` object on a typed 500 and a `void`-ed call discards it entirely. + * Throwing collapses HTTP failure and transport failure into one rejection + * channel, which is what makes a single `onFailure` sufficient. + * + * Never rejects: failure is reported through `onFailure` and answered as `false`, + * so a caller cannot mistake an unobserved rejection for success. + */ +export async function abortSessionBranch(input: SessionAbortInput): Promise { + return input.client.session + .abort({ sessionID: input.sessionID }, { throwOnError: true }) + .then(() => true) + .catch((error: unknown) => { + input.onFailure(error) + return false + }) +} + +/** + * Runs a dependent mutation only after branch closure succeeds. + * + * Kept beside `abortSessionBranch` so the ordering property stays directly + * testable: observing the abort is not enough if a caller can still revert or + * unrevert after the endpoint rejects. + */ +export async function runAfterSessionBranchAbort(input: SessionAbortActionInput): Promise { + const closed = await abortSessionBranch(input) + if (!closed) return false + return input.action() +} diff --git a/packages/tui/src/util/session-interrupt.ts b/packages/tui/src/util/session-interrupt.ts new file mode 100644 index 000000000000..3225d4f021db --- /dev/null +++ b/packages/tui/src/util/session-interrupt.ts @@ -0,0 +1,69 @@ +/** + * CP-023 §13.3 - the selected-Session interrupt (double-Escape) decision. + * + * The whole decision lives here so the production key handler and the K41 + * matrix exercise the same code rather than a restatement of it. + * + * `status` is deliberately absent from `InterruptInput`. §13.3 clause 5 makes + * the command available whenever a Session is selected, "even if that Session's + * projected status is idle", and §13.5 forbids status/count as authority + * because both can lag or omit hidden Task work below the selected Session. A + * status field cannot gate a decision that cannot see one. + * + * The five-second window is carried by `armedAt` rather than by the caller's + * reset timer. A timer can be stale - it is scheduled against one arming and + * can fire after a later one - so a decision that trusted it would let clause + * 4's reset cancel a sequence it does not belong to. The caller still schedules + * a timer, but only to clear the visual hint. + */ + +/** Why a qualifying key press did not reach the interrupt sequence. */ +export type InterruptBlock = "autocomplete" | "unfocused" | "no_session" + +export type InterruptInput = { + /** The currently selected Session, if any. */ + sessionID: string | undefined + /** §13.3 clause 1 - autocomplete precedence. */ + autocompleteVisible: boolean + /** §13.3 clause 1 - focus precedence. A dialog blurs the prompt input, so an open dialog arrives here as `false`. */ + focused: boolean + /** §13.3 clause 1 - shell precedence. */ + mode: "normal" | "shell" + /** How many qualifying presses the current sequence has already taken. */ + armed: number + /** When the current sequence was armed, in the caller's clock. */ + armedAt: number + /** The caller's clock now. */ + now: number +} + +export type InterruptOutcome = + | { readonly kind: "blocked"; readonly by: InterruptBlock } + | { readonly kind: "exit_shell" } + | { readonly kind: "arm"; readonly armed: number; readonly armedAt: number } + | { readonly kind: "abort"; readonly sessionID: string } + +/** §13.3 clause 2 - the window the first qualifying Escape arms. */ +export const INTERRUPT_WINDOW_MS = 5000 + +/** + * Returns true while `armedAt` is still inside the window. Exactly + * `INTERRUPT_WINDOW_MS` later the window has elapsed, which keeps the boundary + * off the reset timer's own firing instant instead of racing it. + */ +export function interruptWindowOpen(armedAt: number, now: number): boolean { + return now - armedAt < INTERRUPT_WINDOW_MS +} + +export function decideInterrupt(input: InterruptInput): InterruptOutcome { + // Precedence, in the order §13.3 clause 1 preserves from current behaviour. + if (input.autocompleteVisible) return { kind: "blocked", by: "autocomplete" } + if (!input.focused) return { kind: "blocked", by: "unfocused" } + if (input.mode === "shell") return { kind: "exit_shell" } + if (!input.sessionID) return { kind: "blocked", by: "no_session" } + + const carried = interruptWindowOpen(input.armedAt, input.now) ? input.armed : 0 + const armed = carried + 1 + if (armed >= 2) return { kind: "abort", sessionID: input.sessionID } + return { kind: "arm", armed, armedAt: input.now } +} diff --git a/packages/tui/src/util/session-tree.ts b/packages/tui/src/util/session-tree.ts new file mode 100644 index 000000000000..406c4ad63f49 --- /dev/null +++ b/packages/tui/src/util/session-tree.ts @@ -0,0 +1,36 @@ +import type { Session, SessionStatus } from "@opencode-ai/sdk/v2" + +// Breadth-first collection of `rootID` and every descendant (any depth), reading the +// flat `parentID` lineage. `seen` makes it cycle-safe (session trees are acyclic by +// construction, but the guard costs nothing and removes an infinite-loop foot-gun). +export function collectSubtree(sessions: readonly Session[], rootID: string): string[] { + const ids = [rootID] + const seen = new Set([rootID]) + const queue = [rootID] + while (queue.length > 0) { + const parentID = queue.pop()! + for (const s of sessions) { + if (s.parentID !== parentID) continue + if (seen.has(s.id)) continue + seen.add(s.id) + ids.push(s.id) + queue.push(s.id) + } + } + return ids +} + +export function isActiveSessionStatus(status: SessionStatus | undefined): boolean { + return status !== undefined && status.type !== "idle" +} + +export function countActiveDescendants( + sessions: readonly Session[], + status: Readonly> | undefined, + currentID: string, +): number { + return collectSubtree(sessions, currentID).filter((id) => { + if (id === currentID) return false + return isActiveSessionStatus(status?.[id]) + }).length +} diff --git a/packages/tui/src/util/transcript.ts b/packages/tui/src/util/transcript.ts index c27e58d8e171..83d6053927b5 100644 --- a/packages/tui/src/util/transcript.ts +++ b/packages/tui/src/util/transcript.ts @@ -1,6 +1,7 @@ import type { AssistantMessage, Part, Provider, UserMessage } from "@opencode-ai/sdk/v2" import { Locale } from "./locale" import * as Model from "./model" +import { isCompleteClosurePair } from "@opencode-ai/core/session/closure-record" export type TranscriptOptions = { thinking: boolean @@ -38,7 +39,9 @@ export function formatTranscript( for (const msg of messages.toSorted( (a, b) => a.info.time.created - b.info.time.created || a.info.id.localeCompare(b.info.id), )) { - transcript += formatMessage(msg.info, msg.parts, options, providers) + const block = formatMessage(msg.info, msg.parts, options, providers) + if (!block) continue + transcript += block transcript += `---\n\n` } @@ -51,14 +54,19 @@ export function formatMessage( options: TranscriptOptions, providers?: Provider[] | ReadonlyMap, ): string { - let result = "" + if (isCompleteClosurePair({ info: msg, parts })) { + const part = parts[0] + if (part?.type !== "text") return "" + return `## Branch closure\n\n${part.text}\n\n` + } if (msg.role === "user") { - result += `## User\n\n` - } else { - result += formatAssistantHeader(msg, options.assistantMetadata, providers ?? options.providers) + const body = parts.map((part) => formatPart(part, options)).join("") + if (!body) return "" + return `## User\n\n${body}` } + let result = formatAssistantHeader(msg, options.assistantMetadata, providers ?? options.providers) for (const part of parts) { result += formatPart(part, options) } diff --git a/packages/tui/test/cli/tui/inline-tool-wrap-snapshot.test.tsx b/packages/tui/test/cli/tui/inline-tool-wrap-snapshot.test.tsx index 8ba730906ae0..ed7233b86863 100644 --- a/packages/tui/test/cli/tui/inline-tool-wrap-snapshot.test.tsx +++ b/packages/tui/test/cli/tui/inline-tool-wrap-snapshot.test.tsx @@ -280,11 +280,9 @@ describe("TUI inline tool wrapping", () => { expect(formatSubagentToolcalls(0)).toBe("0 toolcalls") }) - test("keeps background state attached to the subagent identity", () => { + test("renders async vocabulary from retained background state", () => { expect(formatSubagentTitle("Explore", "Inspect renderer", false)).toBe("Explore Task — Inspect renderer") - expect(formatSubagentTitle("Explore", "Inspect renderer", true)).toBe( - "Explore Task (background) — Inspect renderer", - ) + expect(formatSubagentTitle("Explore", "Inspect renderer", true)).toBe("Explore Task (async) — Inspect renderer") }) test("keeps retry status ahead of wrapping messages", () => { diff --git a/packages/tui/test/config.test.tsx b/packages/tui/test/config.test.tsx index 37fc0033e561..87ae375d2977 100644 --- a/packages/tui/test/config.test.tsx +++ b/packages/tui/test/config.test.tsx @@ -12,6 +12,7 @@ import { type Info as TuiConfigInfo, useTuiConfig, } from "../src/config" +import { TuiKeybind } from "../src/config/keybind" const decodeInfo = Schema.decodeUnknownSync(Info) const decodePlugin = Schema.decodeUnknownSync(PluginSpec) @@ -101,6 +102,14 @@ test("resolves a session move keybind", () => { expect(config.keybinds.get("session.move")).toMatchObject([{ key: "ctrl+o" }]) }) +test("keeps async promotion copy on the stable background keybind", () => { + expect(TuiKeybind.Definitions.session_background).toEqual({ + default: "ctrl+b", + description: "Make subagents async", + }) + expect(TuiKeybind.CommandMap.session_background).toBe("session.background") +}) + test("disables suspend and assigns ctrl+z to undo when unsupported", () => { const config = resolve({}, { terminalSuspend: false }) diff --git a/packages/tui/test/util/session-interrupt.test.ts b/packages/tui/test/util/session-interrupt.test.ts new file mode 100644 index 000000000000..5e806475fc98 --- /dev/null +++ b/packages/tui/test/util/session-interrupt.test.ts @@ -0,0 +1,253 @@ +import { describe, expect, test } from "bun:test" +import { readFileSync } from "fs" +import { fileURLToPath } from "url" +import { + decideInterrupt, + interruptWindowOpen, + INTERRUPT_WINDOW_MS, + type InterruptInput, +} from "../../src/util/session-interrupt" +import { abortSessionBranch, type SessionAbortClient } from "../../src/util/session-abort" +import { errorMessage } from "../../src/util/error" + +// CP-023 K41 - "TUI matrix: selected idle Session, active Session, autocomplete open, +// dialog open, shell active, focus variants, first Escape, second Escape, >5s timeout, +// abort rejection. Preserve precedence and surface error." (§13.3) +// +// The decision under test is the production decision: `prompt/index.tsx`'s +// `session.interrupt` command holds no branch of its own, it supplies inputs and applies +// the outcome. There is no mirror of the handler here. + +/** A press that would abort if nothing blocked it: focused, normal mode, session, armed. */ +function armedPress(overrides: Partial = {}): InterruptInput { + return { + sessionID: "ses_target", + autocompleteVisible: false, + focused: true, + mode: "normal", + armed: 1, + armedAt: 1_000, + now: 1_100, + ...overrides, + } +} + +/** The first press of a sequence. */ +function firstPress(overrides: Partial = {}): InterruptInput { + return armedPress({ armed: 0, armedAt: 0, ...overrides }) +} + +describe("CP-023 K41 - selected-Session interrupt decision", () => { + describe("precedence (§13.3 clause 1)", () => { + test("autocomplete open blocks, and the same press aborts once it closes", () => { + expect(decideInterrupt(armedPress({ autocompleteVisible: true }))).toEqual({ + kind: "blocked", + by: "autocomplete", + }) + // Positive control: autocomplete is the only reason the press was blocked. + expect(decideInterrupt(armedPress({ autocompleteVisible: false }))).toEqual({ + kind: "abort", + sessionID: "ses_target", + }) + }) + + test("unfocused blocks, and the same press aborts once focused", () => { + expect(decideInterrupt(armedPress({ focused: false }))).toEqual({ kind: "blocked", by: "unfocused" }) + expect(decideInterrupt(armedPress({ focused: true }))).toEqual({ kind: "abort", sessionID: "ses_target" }) + }) + + test("an open dialog reaches the decision as unfocused", () => { + // `Dialog` pushes the `modal` mode and calls `focus?.blur()` on the previously + // focused renderable (`ui/dialog.tsx`, `Dialog`), so the prompt input is not + // focused while a dialog is open. K41's dialog row is discharged through the same + // focus gate as the focus row rather than by an independent branch - there is no + // independent branch, and inventing one would assert a mechanism that does not + // exist. + expect(decideInterrupt(armedPress({ focused: false }))).toEqual({ kind: "blocked", by: "unfocused" }) + }) + + test("shell mode exits shell and never arms or aborts, even mid-sequence", () => { + expect(decideInterrupt(armedPress({ mode: "shell" }))).toEqual({ kind: "exit_shell" }) + expect(decideInterrupt(firstPress({ mode: "shell" }))).toEqual({ kind: "exit_shell" }) + // Positive control: mode is the only reason neither press reached the sequence. + expect(decideInterrupt(armedPress({ mode: "normal" })).kind).toBe("abort") + expect(decideInterrupt(firstPress({ mode: "normal" })).kind).toBe("arm") + }) + + test("no selected Session blocks", () => { + expect(decideInterrupt(armedPress({ sessionID: undefined }))).toEqual({ kind: "blocked", by: "no_session" }) + expect(decideInterrupt(firstPress({ sessionID: undefined }))).toEqual({ kind: "blocked", by: "no_session" }) + }) + + test("precedence order is autocomplete, then focus, then shell, then session", () => { + const all = armedPress({ autocompleteVisible: true, focused: false, mode: "shell", sessionID: undefined }) + expect(decideInterrupt(all)).toEqual({ kind: "blocked", by: "autocomplete" }) + expect(decideInterrupt({ ...all, autocompleteVisible: false })).toEqual({ kind: "blocked", by: "unfocused" }) + expect(decideInterrupt({ ...all, autocompleteVisible: false, focused: true })).toEqual({ kind: "exit_shell" }) + expect(decideInterrupt({ ...all, autocompleteVisible: false, focused: true, mode: "normal" })).toEqual({ + kind: "blocked", + by: "no_session", + }) + }) + }) + + describe("sequence (§13.3 clauses 2-4)", () => { + test("the first qualifying Escape arms and stamps the window", () => { + expect(decideInterrupt(firstPress({ now: 7_777 }))).toEqual({ kind: "arm", armed: 1, armedAt: 7_777 }) + }) + + test("the second qualifying Escape inside the window aborts the selected Session", () => { + expect(decideInterrupt(armedPress({ armedAt: 1_000, now: 1_000 + INTERRUPT_WINDOW_MS - 1 }))).toEqual({ + kind: "abort", + sessionID: "ses_target", + }) + }) + + test("a press after the window re-arms instead of aborting, at and beyond the boundary", () => { + expect(decideInterrupt(armedPress({ armedAt: 1_000, now: 1_000 + INTERRUPT_WINDOW_MS }))).toEqual({ + kind: "arm", + armed: 1, + armedAt: 1_000 + INTERRUPT_WINDOW_MS, + }) + expect(decideInterrupt(armedPress({ armedAt: 1_000, now: 1_000 + INTERRUPT_WINDOW_MS + 1 }))).toEqual({ + kind: "arm", + armed: 1, + armedAt: 1_000 + INTERRUPT_WINDOW_MS + 1, + }) + // Positive control for the boundary: one millisecond earlier still aborts. + expect(decideInterrupt(armedPress({ armedAt: 1_000, now: 1_000 + INTERRUPT_WINDOW_MS - 1 })).kind).toBe("abort") + }) + + test("the window predicate closes exactly at INTERRUPT_WINDOW_MS", () => { + expect(interruptWindowOpen(0, INTERRUPT_WINDOW_MS - 1)).toBe(true) + expect(interruptWindowOpen(0, INTERRUPT_WINDOW_MS)).toBe(false) + }) + + test("an expired sequence does not accumulate - a stale arm cannot become an abort", () => { + // Three presses spaced beyond the window each arm; none reaches abort. + let armed = 0 + let armedAt = 0 + for (const now of [0, 10_000, 20_000]) { + const outcome = decideInterrupt(armedPress({ armed, armedAt, now })) + expect(outcome).toEqual({ kind: "arm", armed: 1, armedAt: now }) + if (outcome.kind !== "arm") throw new Error("unreachable") + armed = outcome.armed + armedAt = outcome.armedAt + } + }) + }) + + describe("status is not authority (§13.3 clause 5, §13.5)", () => { + test("the decision has no status input at all", () => { + const input = armedPress() + // Exhaustive: `armedPress` supplies every member of `InterruptInput`, so this is + // the whole input surface rather than a sample of it. + expect(Object.keys(input).sort()).toEqual( + ["armed", "armedAt", "autocompleteVisible", "focused", "mode", "now", "sessionID"].sort(), + ) + }) + + test("an idle Session aborts on the second Escape exactly as an active one does", () => { + // There is no projected-status variant to vary: the only Session-derived input is + // its ID. Both K41 rows - "selected idle Session" and "active Session" - are the + // same call, which is the property the row wants. + expect(decideInterrupt(armedPress({ sessionID: "ses_idle" }))).toEqual({ + kind: "abort", + sessionID: "ses_idle", + }) + expect(decideInterrupt(armedPress({ sessionID: "ses_active" }))).toEqual({ + kind: "abort", + sessionID: "ses_active", + }) + }) + + test("the production command's availability does not consult session status", () => { + // The only thing that can reinstate §13.4's "status-gated" defect is the command's + // own `enabled`/`run`, and no runtime assertion reaches it without mounting the + // TUI. Anchored on the command name rather than a line number. + const source = readFileSync( + fileURLToPath(new URL("../../src/component/prompt/index.tsx", import.meta.url)), + "utf8", + ) + const start = source.indexOf('name: "session.interrupt"') + expect(start).toBeGreaterThan(-1) + const end = source.indexOf('title: "Open editor"', start) + expect(end).toBeGreaterThan(start) + const command = source.slice(start, end) + + expect(command).not.toContain("status(") + + // Positive control: the extractor can see `status(` when it is present, so the + // assertion above is a real absence and not an empty or misplaced slice. + const active = source.slice(source.indexOf("const status ="), source.length) + expect(active).toContain("status(") + expect(command).toContain("decideInterrupt(") + expect(command).toContain("abortSessionBranch(") + }) + }) +}) + +describe("CP-023 K92 - the TUI abort caller observes its result", () => { + function fakeClient(behaviour: () => Promise) { + const calls: Array<{ parameters: { sessionID: string }; options: { throwOnError: true } }> = [] + const client: SessionAbortClient = { + session: { + abort: (parameters, options) => { + calls.push({ parameters, options }) + return behaviour() + }, + }, + } + return { client, calls } + } + + test("success answers true, calls the endpoint once, and reports no failure", async () => { + const { client, calls } = fakeClient(async () => ({ data: true })) + const failures: unknown[] = [] + const closed = await abortSessionBranch({ + client, + sessionID: "ses_target", + onFailure: (error) => failures.push(error), + }) + expect(closed).toBe(true) + expect(failures).toEqual([]) + expect(calls).toEqual([{ parameters: { sessionID: "ses_target" }, options: { throwOnError: true } }]) + }) + + test("a typed 500 answers false, reports exactly once, and never rejects", async () => { + // The shape the SDK's error interceptor produces for a thrown SessionClosureError: + // a real Error carrying the domain message, with the parsed body under `cause`. + const rejection = new Error("closure record could not be written", { + cause: { + body: { _tag: "SessionClosureError", kind: "record_failed", message: "closure record could not" }, + status: 500, + }, + }) + const { client, calls } = fakeClient(async () => { + throw rejection + }) + const failures: unknown[] = [] + const closed = await abortSessionBranch({ + client, + sessionID: "ses_target", + onFailure: (error) => failures.push(error), + }) + expect(closed).toBe(false) + expect(failures).toEqual([rejection]) + expect(calls).toHaveLength(1) + // The toast carries the closure kind's own message rather than a generic fallback. + expect(errorMessage(failures[0])).toBe("closure record could not be written") + }) + + test("a transport rejection is observed on the same channel", async () => { + const rejection = new Error("network error (no response)") + const { client } = fakeClient(async () => { + throw rejection + }) + const failures: unknown[] = [] + expect( + await abortSessionBranch({ client, sessionID: "ses_target", onFailure: (error) => failures.push(error) }), + ).toBe(false) + expect(failures).toEqual([rejection]) + }) +}) diff --git a/packages/tui/test/util/session-tree.test.ts b/packages/tui/test/util/session-tree.test.ts new file mode 100644 index 000000000000..414e247139a3 --- /dev/null +++ b/packages/tui/test/util/session-tree.test.ts @@ -0,0 +1,208 @@ +import { describe, expect, test } from "bun:test" +import type { Session, SessionStatus } from "@opencode-ai/sdk/v2" +import { collectSubtree, countActiveDescendants, isActiveSessionStatus } from "../../src/util/session-tree" + +function session(items: { id: string; parentID?: string }[]) { + return items as unknown as Session[] +} + +describe("util.session-tree", () => { + test("classifies busy and retry as active session statuses", () => { + expect(isActiveSessionStatus({ type: "busy" })).toBe(true) + expect(isActiveSessionStatus({ type: "retry", attempt: 1, message: "retrying", next: Date.now() + 1000 })).toBe( + true, + ) + expect(isActiveSessionStatus({ type: "idle" })).toBe(false) + expect(isActiveSessionStatus(undefined)).toBe(false) + }) + + test("returns the root for a single root session", () => { + expect(collectSubtree(session([{ id: "root" }]), "root")).toEqual(["root"]) + }) + + test("collects depth-1 fan-out", () => { + expect( + collectSubtree( + session([ + { id: "root" }, + { id: "child-a", parentID: "root" }, + { id: "child-b", parentID: "root" }, + { id: "child-c", parentID: "root" }, + ]), + "root", + ), + ).toEqual(["root", "child-a", "child-b", "child-c"]) + }) + + test("collects a depth-2 chain", () => { + expect( + collectSubtree( + session([{ id: "root" }, { id: "child", parentID: "root" }, { id: "grandchild", parentID: "child" }]), + "root", + ), + ).toEqual(["root", "child", "grandchild"]) + }) + + test("collects a depth-3 chain", () => { + expect( + collectSubtree( + session([ + { id: "root" }, + { id: "child", parentID: "root" }, + { id: "grandchild", parentID: "child" }, + { id: "great-grandchild", parentID: "grandchild" }, + ]), + "root", + ), + ).toEqual(["root", "child", "grandchild", "great-grandchild"]) + }) + + test("collects multiple branches", () => { + expect( + collectSubtree( + session([ + { id: "root" }, + { id: "child-a", parentID: "root" }, + { id: "child-b", parentID: "root" }, + { id: "grandchild-a1", parentID: "child-a" }, + { id: "grandchild-a2", parentID: "child-a" }, + { id: "grandchild-b1", parentID: "child-b" }, + ]), + "root", + ), + ).toEqual(["root", "child-a", "child-b", "grandchild-b1", "grandchild-a1", "grandchild-a2"]) + }) + + test("excludes unrelated sessions", () => { + expect( + collectSubtree( + session([ + { id: "root" }, + { id: "child", parentID: "root" }, + { id: "unrelated-root" }, + { id: "unrelated-child", parentID: "unrelated-root" }, + ]), + "root", + ), + ).toEqual(["root", "child"]) + }) + + test("terminates on a synthetic parentID cycle", () => { + expect( + collectSubtree( + session([ + { id: "root", parentID: "child" }, + { id: "child", parentID: "root" }, + ]), + "root", + ), + ).toEqual(["root", "child"]) + }) + + test("counts a busy descendant", () => { + expect( + countActiveDescendants( + session([{ id: "root" }, { id: "child", parentID: "root" }]), + { child: { type: "busy" } }, + "root", + ), + ).toBe(1) + }) + + test("counts a retrying descendant", () => { + expect( + countActiveDescendants( + session([{ id: "root" }, { id: "child", parentID: "root" }]), + { + child: { + type: "retry", + attempt: 1, + message: "retrying", + next: Date.now() + 1000, + }, + }, + "root", + ), + ).toBe(1) + }) + + test("does not count an idle descendant", () => { + expect( + countActiveDescendants( + session([{ id: "root" }, { id: "child", parentID: "root" }]), + { child: { type: "idle" } }, + "root", + ), + ).toBe(0) + }) + + test("treats a missing status as idle", () => { + const sessions = session([{ id: "root" }, { id: "child", parentID: "root" }]) + expect(countActiveDescendants(sessions, {}, "root")).toBe(0) + expect(countActiveDescendants(sessions, undefined, "root")).toBe(0) + }) + + test("counts an active grandchild below an idle child", () => { + expect( + countActiveDescendants( + session([{ id: "root" }, { id: "child", parentID: "root" }, { id: "grandchild", parentID: "child" }]), + { child: { type: "idle" }, grandchild: { type: "busy" } }, + "root", + ), + ).toBe(1) + }) + + test("excludes the current session even when active", () => { + expect(countActiveDescendants(session([{ id: "root" }]), { root: { type: "busy" } }, "root")).toBe(0) + }) + + test("excludes active sessions outside the current subtree", () => { + expect( + countActiveDescendants( + session([ + { id: "root" }, + { id: "child", parentID: "root" }, + { id: "other" }, + { id: "other-child", parentID: "other" }, + ]), + { + child: { type: "idle" }, + other: { type: "busy" }, + "other-child": { type: "retry", attempt: 1, message: "retrying", next: 1 }, + }, + "root", + ), + ).toBe(0) + }) + + test("counts multiple active descendants", () => { + expect( + countActiveDescendants( + session([ + { id: "root" }, + { id: "child-a", parentID: "root" }, + { id: "child-b", parentID: "root" }, + { id: "grandchild", parentID: "child-a" }, + ]), + { + "child-a": { type: "busy" }, + "child-b": { type: "retry", attempt: 2, message: "retrying", next: 1 }, + grandchild: { type: "busy" }, + }, + "root", + ), + ).toBe(3) + }) + + test("counts a large active subtree within a bounded time", () => { + const children = Array.from({ length: 1000 }, (_, index) => ({ + id: `child-${index}`, + parentID: index === 0 ? "root" : `child-${index - 1}`, + })) + const status = Object.fromEntries(children.map((item) => [item.id, { type: "busy" } satisfies SessionStatus])) + const start = performance.now() + + expect(countActiveDescendants(session([{ id: "root" }, ...children]), status, "root")).toBe(children.length) + expect(performance.now() - start).toBeLessThan(1000) + }) +}) diff --git a/packages/tui/test/util/transcript.test.ts b/packages/tui/test/util/transcript.test.ts index fd1e1824db9d..b5e6e34b6a34 100644 --- a/packages/tui/test/util/transcript.test.ts +++ b/packages/tui/test/util/transcript.test.ts @@ -1,6 +1,16 @@ import { describe, expect, test } from "bun:test" import { formatAssistantHeader, formatMessage, formatPart, formatTranscript } from "../../src/util/transcript" import type { AssistantMessage, Part, Provider, UserMessage } from "@opencode-ai/sdk/v2" +import { CLOSURE_RECORD_METADATA_KEY } from "@opencode-ai/core/session/closure-record" +import { readFileSync } from "node:fs" +import { fileURLToPath } from "node:url" +import { + closureEvidencePart, + isHumanUserMessage, + isMessageNavigationStop, + taskSpinnerRunning, + transcriptStatus, +} from "../../src/util/closure-record" const providers: Provider[] = [ { @@ -61,6 +71,77 @@ const providers: Provider[] = [ }, ] +function closureRows(): { info: UserMessage; parts: Part[] }[] { + const sessionID = "ses_closure_tui" + const message = (id: string) => + ({ + id, + sessionID, + role: "user", + agent: "build", + model: { providerID: "anthropic", modelID: "claude-sonnet-4-20250514" }, + time: { created: 1 }, + }) as UserMessage + const text = (messageID: string, value: string, synthetic = false, metadata?: Record): Part => ({ + id: `part_${messageID}`, + sessionID, + messageID, + type: "text", + text: value, + ...(synthetic ? { synthetic: true } : {}), + ...(metadata ? { metadata } : {}), + }) + const closure = message("msg_1_closure") + const sentence = "[Branch closure] This Session's prior Task execution: Cancellation won physical closure." + const payload = { + version: 1, + freeze_owner_operation_id: "op_tui", + generation: 1, + fact_key: "self:ses_closure_tui", + identity_source: "session_identity", + record_kind: "self", + subject_session_id: sessionID, + terminal_outcome: "cancelled", + } + const human = message("msg_2_human") + const synthetic = message("msg_3_synthetic") + const malformed = message("msg_4_malformed") + const partial = message("msg_5_partial") + return [ + { + info: closure, + parts: [text(closure.id, sentence, true, { [CLOSURE_RECORD_METADATA_KEY]: payload })], + }, + { info: human, parts: [text(human.id, "ordinary human row")] }, + { info: synthetic, parts: [text(synthetic.id, "ordinary synthetic row", true)] }, + { + info: malformed, + parts: [ + text(malformed.id, "malformed lookalike has distinct text", true, { + [CLOSURE_RECORD_METADATA_KEY]: payload, + }), + ], + }, + { + info: partial, + parts: [ + text(partial.id, "multipart lookalike first distinct text", true, { + [CLOSURE_RECORD_METADATA_KEY]: payload, + }), + { ...text(partial.id, "multipart lookalike second distinct text", true), id: "part_partial_second" }, + ], + }, + ] +} + +function region(source: string, start: string, end: string) { + const from = source.indexOf(start) + expect(from).toBeGreaterThan(-1) + const to = source.indexOf(end, from + start.length) + expect(to).toBeGreaterThan(from) + return source.slice(from, to) +} + describe("transcript", () => { describe("formatAssistantHeader", () => { const baseMsg: AssistantMessage = { @@ -295,6 +376,27 @@ describe("transcript", () => { }) describe("formatTranscript", () => { + test("renders only a complete closure pair as Branch closure and omits empty synthetic User turns", () => { + const rows = closureRows() + expect(rows.map((row) => !!closureEvidencePart(row.info, row.parts))).toEqual([true, false, false, false, false]) + + const result = formatTranscript( + { id: "ses_closure_tui", title: "Closure transcript", time: { created: 1, updated: 2 } }, + rows, + { thinking: false, toolDetails: false, assistantMetadata: false }, + ) + + expect(result).toContain( + "## Branch closure\n\n[Branch closure] This Session's prior Task execution: Cancellation won physical closure.\n\n", + ) + expect(result.match(/## User/g)).toHaveLength(1) + expect(result).toContain("## User\n\nordinary human row\n\n") + expect(result).not.toContain("ordinary synthetic row") + expect(result).not.toContain("malformed lookalike has distinct text") + expect(result).not.toContain("multipart lookalike") + expect(result.match(/---/g)).toHaveLength(3) + }) + test("formats complete transcript", () => { const session = { id: "ses_abc123", @@ -446,4 +548,106 @@ describe("transcript", () => { expect(result).not.toContain("claude-sonnet-4-20250514") }) }) + + describe("closure evidence consumers", () => { + test("keeps closure evidence out of human actions and navigation without relaxing generic filters", () => { + const rows = closureRows() + expect(rows.map((row) => isHumanUserMessage(row.info, row.parts))).toEqual([false, true, true, true, true]) + expect(rows.map((row) => isMessageNavigationStop(row.info, row.parts))).toEqual([ + false, + true, + false, + false, + false, + ]) + expect(rows.map((row) => transcriptStatus(row.info, row.parts))).toEqual([ + "idle", + "working", + "working", + "working", + "working", + ]) + }) + + test("a completed async receipt cannot keep an idle child spinner live", () => { + expect(taskSpinnerRunning("completed", true, { type: "busy" })).toBe(true) + expect(taskSpinnerRunning("completed", true, { type: "idle" })).toBe(false) + expect(taskSpinnerRunning("completed", false, { type: "busy" })).toBe(false) + expect(taskSpinnerRunning("running", false, { type: "idle" })).toBe(true) + }) + + test("production render, action, boundary, status, fork, and copy/export paths call the tested decisions", () => { + const index = readFileSync(fileURLToPath(new URL("../../src/routes/session/index.tsx", import.meta.url)), "utf8") + const transcript = region(index, "", "function UserMessage") + const closure = region(transcript, " actionable())")).toBeGreaterThan(dialog.indexOf('title: "Fork"')) + expect(dialog).toContain('title: "Fork"') + + const fork = readFileSync( + fileURLToPath(new URL("../../src/routes/session/dialog-fork-from-timeline.tsx", import.meta.url)), + "utf8", + ) + expect(fork).toContain("if (!isHumanUserMessage(message, parts)) continue") + expect(fork).toContain("!x.synthetic && !x.ignored") + + const timeline = readFileSync( + fileURLToPath(new URL("../../src/routes/session/dialog-timeline.tsx", import.meta.url)), + "utf8", + ) + const options = region(timeline, "const options = createMemo", "return {(msg, msgIndex) => { - const filteredParts = createMemo(() => - msg.parts.filter((x, index) => { - if (x.type === "step-start" && index > 0) return false - if (x.type === "snapshot") return false - if (x.type === "patch") return false - if (x.type === "step-finish") return false - if (x.type === "text" && x.synthetic === true) return false - if (x.type === "text" && !x.text) return false - if (x.type === "tool" && (x.state.status === "pending" || x.state.status === "running")) - return false - return true - }), - ) + const closure = createMemo(() => isClosureShareMessage(msg)) + const filteredParts = createMemo(() => visibleShareParts(msg)) return ( @@ -380,7 +371,9 @@ export default function Share(props: { } }) - return + return ( + + ) }} @@ -599,47 +592,7 @@ export function fromV1(v1: Message.Info): MessageWithParts { } if (v1.role === "user") { - return { - id: v1.id, - sessionID: v1.metadata.sessionID, - role: "user", - agent: "user", - model: { - providerID: "", - modelID: "", - }, - time: { - created: v1.metadata.time.created, - }, - parts: v1.parts.flatMap((part, index): MessageV2.Part[] => { - const base = { - id: index.toString(), - messageID: v1.id, - sessionID: v1.metadata.sessionID, - } - if (part.type === "text") { - return [ - { - ...base, - type: "text", - text: part.text, - }, - ] - } - if (part.type === "file") { - return [ - { - ...base, - type: "file", - mime: part.mediaType, - filename: part.filename, - url: part.url, - }, - ] - } - return [] - }), - } + return fromLegacyUserMessage(v1) as MessageWithParts } throw new Error("unknown message type") diff --git a/packages/web/src/components/share/closure-record.ts b/packages/web/src/components/share/closure-record.ts new file mode 100644 index 000000000000..e618bec618ec --- /dev/null +++ b/packages/web/src/components/share/closure-record.ts @@ -0,0 +1,150 @@ +// Browser-local parity copy: packages/web deliberately has no runtime dependency on @opencode-ai/core. +/** Reserved flat metadata key carried by a complete branch-closure TextPart. */ +export const CLOSURE_RECORD_METADATA_KEY = "opencode.branch_closure" as const + +/** + * The minimal transcript shape needed to classify a complete closure evidence pair. + * + * This is deliberately structural: both the domain `SessionV1.WithParts` shape and + * SDK `{ info, parts }` responses satisfy it without importing either representation. + */ +export type ClosureRecordCandidate = { + readonly info: { + readonly role: string + readonly id: string + readonly sessionID: string + } + readonly parts: readonly { + readonly type: string + readonly synthetic?: boolean + readonly sessionID: string + readonly messageID: string + readonly text?: string + readonly metadata?: unknown + }[] +} + +type Metadata = Record +type Terminal = "cancelled" | "completed" | "error" | "unknown" + +const own = (value: Metadata, key: string) => Object.hasOwn(value, key) +const object = (value: unknown): value is Metadata => + typeof value === "object" && value !== null && !Array.isArray(value) +const nonempty = (value: unknown): value is string => typeof value === "string" && value.length > 0 +const exactKeys = (value: Metadata, required: readonly string[], optional: readonly string[] = []) => { + const allowed = new Set([...required, ...optional]) + return required.every((key) => own(value, key)) && Object.keys(value).every((key) => allowed.has(key)) +} +const terminal = (value: unknown): value is Terminal => + value === "cancelled" || value === "completed" || value === "error" || value === "unknown" + +const closureSentence = (outcome: Terminal, yielded: boolean) => { + const state = yielded ? "The Task had yielded with attached work outstanding at the fence. " : "" + if (outcome === "cancelled") return `${state}Cancellation won physical closure.` + if (outcome === "completed") return `${state}The tracked execution completed before cancellation took effect.` + if (outcome === "error") return `${state}The tracked execution ended with an error before cancellation took effect.` + return `${state}The terminal outcome could not be established.` +} + +/** + * Classifies the canonical complete Message/TextPart closure-evidence pair. + * + * This gate is intentionally strict because only a complete pair receives closure-record + * semantics. A partial or malformed lookalike remains ordinary synthetic transcript data. + */ +export function isCompleteClosurePair(message: ClosureRecordCandidate): boolean { + if (message.info.role !== "user" || message.parts.length !== 1) return false + const part = message.parts[0] + if ( + !part || + part.type !== "text" || + part.synthetic !== true || + part.sessionID !== message.info.sessionID || + part.messageID !== message.info.id + ) + return false + if (!object(part.metadata) || !exactKeys(part.metadata, [CLOSURE_RECORD_METADATA_KEY])) return false + const data = part.metadata[CLOSURE_RECORD_METADATA_KEY] + if (!object(data)) return false + + const source = data.identity_source + if (source !== "prior_user_message" && source !== "session_identity" && source !== "resume_admission") return false + const sourceKeys = source === "prior_user_message" ? ["source_user_message_id"] : [] + const common = [ + "version", + "freeze_owner_operation_id", + "generation", + "fact_key", + "identity_source", + ...sourceKeys, + "record_kind", + "subject_session_id", + ] + if ( + data.version !== 1 || + !nonempty(data.freeze_owner_operation_id) || + typeof data.generation !== "number" || + !Number.isInteger(data.generation) || + data.generation <= 0 || + !nonempty(data.fact_key) || + !nonempty(data.subject_session_id) || + (source === "prior_user_message" && !nonempty(data.source_user_message_id)) + ) + return false + + const yielded = own(data, "state_at_fence") + if (yielded && data.state_at_fence !== "yielded_with_outstanding_work") return false + if (data.record_kind === "self") { + if (!exactKeys(data, [...common, "terminal_outcome"], ["state_at_fence"])) return false + if (data.subject_session_id !== message.info.sessionID || !terminal(data.terminal_outcome)) return false + return ( + part.text === + `[Branch closure] This Session's prior Task execution: ${closureSentence(data.terminal_outcome, yielded)}` + ) + } + if (data.record_kind === "edge") { + if ( + !exactKeys( + data, + [...common, "owner_session_id", "child_session_id", "terminal_outcome"], + ["task_part_id", "state_at_fence"], + ) + ) + return false + if ( + data.owner_session_id !== message.info.sessionID || + data.subject_session_id !== data.child_session_id || + !nonempty(data.child_session_id) || + (own(data, "task_part_id") && !nonempty(data.task_part_id)) || + !terminal(data.terminal_outcome) + ) + return false + return ( + part.text === + `[Branch closure] Child Session ${data.child_session_id}: ${closureSentence(data.terminal_outcome, yielded)} Owner Session: ${data.owner_session_id}.` + ) + } + if (data.record_kind !== "root") return false + if ( + !exactKeys(data, [...common, "requested_root_session_id", "branch_outcome"], ["terminal_outcome", "state_at_fence"]) + ) + return false + if ( + data.requested_root_session_id !== message.info.sessionID || + data.subject_session_id !== data.requested_root_session_id || + data.branch_outcome !== "quiesced" || + (own(data, "terminal_outcome") && !terminal(data.terminal_outcome)) || + (yielded && !own(data, "terminal_outcome")) + ) + return false + if (!own(data, "terminal_outcome")) + return ( + part.text === + `[Branch closure] Requested Session ${data.requested_root_session_id}: Its in-scope Task branch reached conversational quiescence.` + ) + if (!terminal(data.terminal_outcome)) return false + return ( + part.text === + `[Branch closure] Requested Session ${data.requested_root_session_id}: ${closureSentence(data.terminal_outcome, yielded)} Its in-scope Task branch reached conversational quiescence.` + ) +} diff --git a/packages/web/src/components/share/legacy-user-message.ts b/packages/web/src/components/share/legacy-user-message.ts new file mode 100644 index 000000000000..d92f0a93a0fa --- /dev/null +++ b/packages/web/src/components/share/legacy-user-message.ts @@ -0,0 +1,77 @@ +export type LegacyUserMessage = { + id: string + role: "user" + metadata: { + sessionID: string + time: { created: number } + } + parts: readonly { + type: string + text?: string + mediaType?: string + filename?: string + url?: string + }[] +} + +type ConvertedPart = + | { + id: string + messageID: string + sessionID: string + type: "text" + text: string + } + | { + id: string + messageID: string + sessionID: string + type: "file" + mime: string + filename?: string + url: string + } + +export function fromLegacyUserMessage(v1: LegacyUserMessage) { + return { + id: v1.id, + sessionID: v1.metadata.sessionID, + role: "user", + agent: "user", + model: { + providerID: "", + modelID: "", + }, + time: { + created: v1.metadata.time.created, + }, + parts: v1.parts.flatMap((part, index): ConvertedPart[] => { + const base = { + id: index.toString(), + messageID: v1.id, + sessionID: v1.metadata.sessionID, + } + if (part.type === "text" && part.text !== undefined) { + return [ + { + ...base, + type: "text", + text: part.text, + }, + ] + } + if (part.type === "file" && part.mediaType !== undefined && part.url !== undefined) { + return [ + { + ...base, + type: "file", + mime: part.mediaType, + filename: part.filename, + url: part.url, + }, + ] + } + return [] + }), + } +} diff --git a/packages/web/src/components/share/part.module.css b/packages/web/src/components/share/part.module.css index b1269445f666..43477fd216a6 100644 --- a/packages/web/src/components/share/part.module.css +++ b/packages/web/src/components/share/part.module.css @@ -129,6 +129,22 @@ position: relative; } + [data-component="branch-closure"] { + min-width: 0; + max-width: var(--md-tool-width); + padding: 0.5rem calc(0.5rem + 3px); + border-left: 1px solid var(--sl-color-gray-5); + color: var(--sl-color-text-secondary); + font-size: 0.875rem; + + [data-slot="branch-closure-label"] { + margin-bottom: 0.25rem; + color: var(--sl-color-text-dimmed); + font-size: 0.75rem; + font-weight: 600; + } + } + [data-component="assistant-reasoning"] { min-width: 0; display: flex; diff --git a/packages/web/src/components/share/part.tsx b/packages/web/src/components/share/part.tsx index 67099196a07a..2297027caded 100644 --- a/packages/web/src/components/share/part.tsx +++ b/packages/web/src/components/share/part.tsx @@ -39,6 +39,7 @@ export interface PartProps { message: MessageV2.Info part: MessageV2.Part last: boolean + closure?: boolean } export function Part(props: PartProps) { @@ -73,6 +74,9 @@ export function Part(props: PartProps) { }} > + + + @@ -129,7 +133,13 @@ export function Part(props: PartProps) {
- {props.message.role === "user" && props.part.type === "text" && ( + {props.closure && props.part.type === "text" && ( +
+
Branch closure
+ +
+ )} + {!props.closure && props.message.role === "user" && props.part.type === "text" && (
diff --git a/packages/web/src/components/share/share-message.ts b/packages/web/src/components/share/share-message.ts new file mode 100644 index 000000000000..f692dc562c6f --- /dev/null +++ b/packages/web/src/components/share/share-message.ts @@ -0,0 +1,32 @@ +import { isCompleteClosurePair, type ClosureRecordCandidate } from "./closure-record" + +type SharePart = ClosureRecordCandidate["parts"][number] & { + readonly id: string + readonly state?: { readonly status?: string } +} + +type ShareMessage = ClosureRecordCandidate["info"] & { + readonly parts: T[] +} + +export function isLegacyShareMessage(value: unknown): value is { metadata: unknown } { + return typeof value === "object" && value !== null && "metadata" in value +} + +export function visibleShareParts(message: ShareMessage): T[] { + const closure = isCompleteClosurePair({ info: message, parts: message.parts }) + return message.parts.filter((part, index) => { + if (part.type === "step-start" && index > 0) return false + if (part.type === "snapshot") return false + if (part.type === "patch") return false + if (part.type === "step-finish") return false + if (part.type === "text" && part.synthetic === true && !closure) return false + if (part.type === "text" && !part.text) return false + if (part.type === "tool" && (part.state?.status === "pending" || part.state?.status === "running")) return false + return true + }) +} + +export function isClosureShareMessage(message: ShareMessage) { + return isCompleteClosurePair({ info: message, parts: message.parts }) +} diff --git a/packages/web/src/content/docs/cli.mdx b/packages/web/src/content/docs/cli.mdx index 94d9ba3c75d4..3ccf021722f8 100644 --- a/packages/web/src/content/docs/cli.mdx +++ b/packages/web/src/content/docs/cli.mdx @@ -725,7 +725,7 @@ These environment variables enable experimental features that may change or be r | `OPENCODE_EXPERIMENTAL_EXA` | boolean | Enable experimental Exa features | | `OPENCODE_EXPERIMENTAL_LSP_TY` | boolean | Enable TY LSP for python files | | `OPENCODE_EXPERIMENTAL_PLAN_MODE` | boolean | Enable plan mode | -| `OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS` | boolean | Enable background subagent tasks | +| `OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS` | boolean | Enable async subagent tasks | | `OPENCODE_EXPERIMENTAL_EVENT_SYSTEM` | boolean | Enable experimental event system | | `OPENCODE_EXPERIMENTAL_NATIVE_LLM` | boolean | Enable native LLM request path | | `OPENCODE_EXPERIMENTAL_PARALLEL` | boolean | Enable parallel web search execution |