fix(sessions): wire finish, retry and usage signals to OpenCode V2 events (#1033)

## What changes for the user
Four things that silently stopped working with OpenCode V2 work again:

- **"A session finished" notifications** arrive again. A successful or
failed run notifies. A run you stopped yourself does not.
- **Provider retries** show the retrying status and the countdown toast
again.
- **Usage statistics** refresh after each run.
- **The token usage line** under assistant replies (preference,
transcript filter row and hotkey) shows each settled reply's tokens and
cost again.

Per-session transcript reducers are also released again once a run has
settled.

## Why
OpenCode V2 declares `session.idle` and `session.status` in its schema,
but core never publishes them. Runs end with
`session.execution.{succeeded,failed,interrupted}`, and retries arrive
as `session.retry.scheduled`. These features were still keyed only to
the V1 events.

The token usage line had a second problem: it was drawn from V1
`step-finish` parts, which V2 never produces. V2 stores tokens and cost
on each assistant message instead.

A new isolated native fixture,
`scripts/test-session-lifecycle-native.mjs`, confirms this on CLI 2.1.0.
It uses its own storage, port and fake provider, and never the shared
service. In its complete, retry, fail and interrupt scenarios, it sees
no `session.idle` or `session.status` event. Upstream 2.0.27 sources
show the same: these events are declared but never published.

## How
- **Finish notification:** sent from the V2 terminal path after
`session.active` confirms the run ended.
  - Succeeded and failed runs notify.
- User and shutdown interrupts stay silent; idle-timeout interrupts
notify.
  - Subsessions and unknown sessions stay silent.
  - A stray `session.idle` cannot cause a duplicate.
- **Retry:** `session.retry.scheduled` sets the retry state and the
transient toast. The next attempt or the end of the run clears them.
- **Run-end detection:** a shared `lib/native-execution-events.ts`
drives the usage refresh and reducer retirement.
- **Reducer retirement:** it also waits for queued prompts, so prompts
left in the inbox after an interrupt stay listed.
- **Usage line:** moves to `components/message-usage.tsx` with the
existing classes.
- **Removed:**
  - the dead V1 `StepCard`, its step-part types and CSS;
  - the V1 `session.idle`/`session.status` handlers.
- **AGENTS.md:** one bullet so future features wire to V2 lifecycle
events.

Left for the follow-up cleanup PR: the remaining dead `session.idle`
fallbacks in pending discovery, Missions triggers and test fixtures.

## Validation
New tests that fail on unmodified dev and pass here:

| Suite | dev | this PR |
|---|---|---|
| lifecycle unit | 3/4 fail | 4/4 |
| retirement | 8/9 fail | 9/9 |
| browser `usage-metrics` | 3/3 fail | 3/3 |
| browser `service-usage` | 2/3 fail | 3/3 |

- UI typecheck is clean.
- **Passing unit suites:**
- opencode-data-idle 9, runtime-contract 4, session-native-events 8,
message-part-grouping 6
- opencode-transcript-replay 17, opencode-compaction 11,
session-pruning-projection 2, session-pruning-pagination 6
- session-request-authority 77, opencode-data 30, message-timeline-v2 2,
transcript-visibility 8
  - missions 1, instances-pending-compaction 7
- **Passing browser suite:** `system-messages` 10/10.
- The native lifecycle capture passes.

Oversized files touched:

| File | Lines |
|---|---|
| `message-block.tsx` | ~1,760 (down ~240) |
| `message-section.tsx` | ~1,630 |
| `opencode-data.ts` | ~905 |
| `session-events.ts` | ~670 |
| `message-timeline.tsx` | ~645 |
| `message-base.css` | ~675 |
| `session-request-authority.test.ts` | ~2,400 |
| `opencode-data.test.ts` | ~1,215 |
This commit is contained in:
Pascal André 2026-10-20 15:51:48 +02:00 • committed by GitHub
parent debbdce33f
commit 73c0621d0d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
34 changed files with 897 additions and 475 deletions

View file

@ -436,6 +436,9 @@ jobs:
packages/ui/src/stores/opencode-data.test.ts
packages/ui/src/stores/opencode-data-idle.test.ts
packages/ui/src/stores/opencode-data-settlement.test.ts
packages/ui/src/stores/opencode-transcript-replay.test.ts
packages/ui/src/stores/opencode-compaction.test.ts
packages/ui/src/lib/message-part-grouping.test.ts
packages/ui/src/stores/message-v2/empty-content.test.ts
packages/ui/src/stores/permission-lifecycle.test.ts
packages/ui/src/stores/shell-store-reactivity.test.ts
@ -448,6 +451,7 @@ jobs:
packages/ui/src/stores/session-native-events.test.ts
packages/ui/src/stores/session-outline.test.ts
packages/ui/src/stores/runtime-contract.test.ts
packages/ui/src/stores/session-lifecycle-signals.test.ts
packages/ui/src/stores/session-usage-contract.test.ts
packages/ui/src/stores/session-move-restore.test.ts
packages/ui/src/stores/session-request-authority.test.ts

View file

@ -83,7 +83,7 @@
- Floating-panel outside-pointer dismissal must leave ARIA menus, listboxes and dialogs to their own selection/dismissal handlers: their portals are outside the drawer DOM, and closing the drawer on pointerdown destroys a menu before touch pointerup selects its action. Keep this guard shared in the shell, not in individual menu actions; validate the real session Delete confirmation with `tests/browser/session-delete.test.ts`.
- Each side panel has exactly one open/close control: `instance/shell/ShellPanelToggle.tsx` (`PanelLeft`/`PanelRight`, `.icon-toggle` with `aria-expanded`/`aria-controls`), absolutely placed at the shell's outer header corners (`--space-xs` from the window edge) in `.session-shell-frame`, outside the drawer host so modal sibling isolation never hides it, and above floating drawers. The frame is an isolated stacking context: toggles stay above in-frame drawers but below body-portaled dialogs, scrims and utility windows. Both toggles precede the shell content in tab order and use the fixed View-menu labels (`window.menu.leftPanel`/`rightPanel`) with `aria-expanded`, never an Open/Close label flip. They are strictly fixed: no horizontal or vertical movement between docked, floating and closed states in any layout; the sidebar header, right-panel tab bar (both edges when floating) and the conversation header (`data-{left,right}-toggle-reserve`) reserve their footprint instead. Every header's action row (session list header row, right-panel `.tab-strip-shortcuts`, conversation side groups and status indicators, with or without the title row) and the toggles centre on one shared line, `--panel-header-action-center` in `styles/tokens.css`; never measure per-state offsets. The session list header holds a single action row, with no keyboard-shortcut footer below the scrolling session list. Opening one floating drawer closes the other; the outside-pointer handler ignores the toggles. Do not reintroduce arrow-based close/open buttons in panel or conversation headers. Styles live in `styles/panels/shell-panel-toggles.css`; validate with `tests/browser/shell-navigation.test.ts`.
- The session sidebar header ends with Back/Forward (`ArrowLeft`/`ArrowRight`, mirrored in RTL; history wording "Back"/"Forward" with conversation tooltips, distinct from the palette's Previous/Next Session list cycling) over a browser-like in-memory history per project tab and window (`stores/session-navigation-history.ts`, `instance/shell/useSessionNavigationHistory.ts`, at most 50 entries). It records every displayed conversation whatever selected it; Info and the no-session draft are not entries (Back first returns to the cursor entry). Navigating moves the cursor and selects through the ordinary `handleSessionSelect` path without pushing; a new selection truncates the forward stack; unavailable/deleted sessions are skipped and determine the disabled states. No keyboard shortcuts (layout-dependent bracket keys were rejected), no persistence.
- Mission display demand follows the actual right-panel visibility and selected tab, not component mounting. Preserve cached snapshots while hidden, cancel queued trailing display reads, and revalidate on activation. Native `session.status` is an activity invalidation; text/tool-progress deltas are not. Explicit recovery reuses the selected Mission's ▶ and the tree's task recovery actions through `createMissionRecoveryAction`, without a separate administration surface or automatic mutation retry.
- Mission display demand follows the actual right-panel visibility and selected tab, not component mounting. Preserve cached snapshots while hidden, cancel queued trailing display reads, and revalidate on activation. Native lifecycle events (`session.execution.*`, compaction, inbox, Shell, Form and permission events) are activity invalidations; text/tool-progress deltas are not. Explicit recovery reuses the selected Mission's ▶ and the tree's task recovery actions through `createMissionRecoveryAction`, without a separate administration surface or automatic mutation retry.
- Missions preferences live in the Settings/Preferences window's Missions section (`components/settings/missions-settings-section.tsx`); the Missions panel header gear deep-links there via `openSettings("missions")`, with no inline panel disclosure. `stores/settings-screen.ts` is the single Settings route for every entry point: local Electron/Tauri windows always reuse/focus the one native Preferences window with the active instance/Location; only browser and remote windows use the embedded screen. A native rejection (e.g. an older installed host lacking a section, which needs a native rebuild) shows an error notification and never opens a second, embedded Settings surface. Keep the section allowlist identical across renderer, Electron and Tauri (`native-mission-preferences.test.ts`). Project-scoped parts (catalog, Location depth) use the opener's instance/Location and stay disabled without one. `components/mission-preferences.tsx` has no Save/Reload step: each choice is written at once, compare-and-swapped against the value stored at its turn. Edits made during a write stay enabled (disabling a control drops its keyboard focus) and queue behind it, shown immediately, across window/section remounts. Each edit touches one role or the task-session policy of one scope and is refused when that stored value is no longer the one it was made from, so a queued edit never overwrites another window's change to that value or resends a rejected edit of it. (Customizing an inherited role still copies the global values shown at edit time.) A rejected or unconfirmed write is never resent: a read-only reload shows the stored values, with Reload offered only when that read fails or the document is unreadable. The fold's Reset (confirmed) clears only the shown scope's agents/models; resetting an unreadable document is a separate confirmed repair. Catalog/depth demand stays fenced by that section's visibility. Agent/model choices use one `MissionProfileControls` row per role everywhere (coordinator, then the type's roles; no sub-fold, second selector level or Debugging special case). In a mission-type scope, rows show the inherited global values with a muted Global mark; editing a field customizes the role starting from those values, its revert icon inherits again, and an all-native choice remains an explicit override (inherit, native default and a selected profile stay distinct). Creation waits for loaded preferences, shows requested choices and keeps overrides collapsed. Profiles use compact square native selectors in `components/mission-profile-controls.tsx`; preference/profile/library layout lives in `styles/panels/mission-preferences.css`, not wide settings cards. Saved briefs are bounded versioned brief-only records under existing owned UI preferences (`lib/mission-model-library.ts`). Creation shows one "Start from a brief" select at the top of Options that loads on selection (Remove/Reload in its small `…` overflow) and a footer "Save as brief" text button that asks for a name inline (`components/mission-model-library.tsx`, styles in `styles/panels/mission-create.css`); only Preferences keeps the full library manager and invalid-library repair. A selected one-time Mission, including a finished one, offers the same "Save as brief" text action beside its update request (`MissionActionBar` secondary row): `missionRecordedBrief` copies only its recorded objective, verbatim notes, template, frozen profiles and taskMode (the title only prefills the name; briefs have no title field), never IDs, plan, results or lifecycle state. Recurring schedules have no save-as-brief action. Loading copies into ordinary manual creation and is never a launch, delete preserves all executions, and arrays retain config merge-patch replacement semantics. Preserve exact choices in uncertain creation drafts; presets are not historical invocation identity. Defaults never silently reconfigure existing/busy actors. No scheduler or publication authority is implied by saving a brief.
- Delegation is a per-new-mission task-session policy (`taskMode`, omitted means native), not a global subagent prohibition or depth setting. `mission-task-mode-controls.tsx` shares its compact native selector between preferences and creation. Task agents use subagent/all in native mode and primary/all in independent mode; coordinators always use primary/all. Freeze the policy with the creation/defaults snapshot and uncertain request identity, preserve it in explicitly selected saved briefs, and never change existing Missions or OpenCode configuration. Ordinary internal helper calls remain governed by native permissions/depth.
- Delegation's native depth editor (`mission-subagent-depth.tsx`) is separately Location/project scoped and affects all OpenCode conversations there, unlike global new-mission defaults. Demand only while the Preferences window's Missions section is visible, retain dirty drafts and exact file expectations by instance/directory, and never invent an unknown effective depth or label zero as disabled. Enter or leaving the field writes a changed valid value (Inherit writes at once; pressing it keeps focus in the field so the typed value is not saved first; Escape discards an unsaved value) without reload/restart; invalid values are flagged, never written. A write acknowledged after the view was left or hidden is accepted and reread on the next activation, not shown as unconfirmed. Reads and writes make the field read-only rather than disabled, preserving focus. The refresh icon appears only after an error or an unconfirmed write; uncertain writes remain fenced until that explicit refresh, with no automatic replay.
@ -168,6 +168,7 @@
- Follow `dev-docs/CACHE_REFRESH_CONVENTIONS.md` for display snapshots, coalesced trailing refreshes, stale-response fencing and authoritative mutation reads. Check existing feature semantics before adding another cache or refresh policy.
- Listed Workspace directories use bounded non-recursive host watches (`workspaces/file-watches.ts`), never a recursive project scan or a new native daemon dependency. `workspace.filesChanged` feeds the existing SSE stream with browsing-root/directory scope; preserve logical alias paths separately from physical containment so internal junctions retain every listing subscriber. `lib/filesystem-events.ts` coalesces passive cache invalidations (including native filesystem events) to at most one publication every five seconds during continuous activity; no timer runs without pending observations. Explicit mutations/recovery remain immediate. `useWorkspaceTree` rereads only stale visible directories in the selected root; hidden/collapsed and other-worktree snapshots wait for demand. A changed directory device/inode identity drops only its cached subtree and cancels old reads, reloading still-expanded paths; mtime changes never force a subtree scan. Preserve ownership, physical-path/inode and workspace-lifetime fences, and close watches on removal/shutdown.
- The shared native event relay consumes upstream events before slow routing I/O. Preserve per-session/PTY/Shell and per-recipient FIFO, validated full-location ownership, invalidation/connection/workspace fences and bounded-backlog reconnect recovery. A slow recipient must not block another or the shared SDK subscriber. Validate with the relay regressions and isolated native location fixture.
- Session lifecycle features use OpenCode V2's published signals: one `session.execution.{succeeded,failed,interrupted}` per busy period (`lib/native-execution-events.ts`) and `session.retry.scheduled` for provider backoff. The schema-declared `session.idle`/`session.status` are never published; never wire a feature only to them. A terminal event is a hint until `session.active` confirms settlement, which alone announces "A session finished" (root sessions; success, failure or an inactivity stop, not a person's stop or shutdown). Transcript reducers retire at settlement, deferred while prompts stay parked in their inbox or a user shell or unsettled tool call still needs reconciling there. Per-step token/cost lines render from each settled assistant message's native usage (`components/message-usage.tsx`). Capture real streams with `scripts/test-session-lifecycle-native.mjs`.
- Pending discovery admission lives in `workspaces/pending-discovery.ts`. Observe authenticated compaction events before ownership routing; this backend-only state never grants event visibility. Defer legacy full lists and unverified broker reads with non-authoritative 503s before inventory reads and recheck at forwarding. Verified loaded-only capability is connection-scoped. Settlement reconciliation headers require an exact, workspace-bound grant from an authorized mutation attempt and still undergo normal ownership checks. Bounded shared `session.active` probes recover missed ends only when the session becomes inactive; failures retain the hold. UI coalesces deferred refreshes without clearing unscanned queues or replaying mutations. Already-admitted reads, already-loaded Locations and wholly unobserved compactions remain mitigation limits.
- Pending recovery treats idle historical session directories as optional hints, not current workspace authority. Root, catalogue, inventoried worktrees, active sessions and known permission/Form placements remain required. The broker may exclude an optional hint rejected by directory ownership, but still queries valid idle subdirectories and negotiates the actual loaded-only RPC at the owned root for an all-excluded batch. Exclusion never provides empty coverage or clears queues; transport, identity and returned-placement failures remain errors. Recheck required placements when responses arrive so live requests cannot be hidden by stale optional classification. A candidate's ownership/deletion-fence failure or a rejected placement reports only that directory's non-authoritative error (and a rejected placement withholds capability verification); root/origin/connection changes, malformed native coverage and timeouts still fail the batch. Ownership fans must not spawn Git per candidate: common-directory reads use the checkout's administrative files (`workspaces/git-common-directory.ts`), because the shared worker runs two Git processes and each Windows spawn can cost up to a second, which starved recovery across hundreds of worktrees. Each response carries phase-only `Server-Timing`.
- The incomplete-recovery warning names its project, cause, folders and the sessions that required them (`stores/pending-recovery-cause.ts`); the broker reports a per-directory error reason. Never fall back to a generic message.

View file

@ -61,7 +61,7 @@ Yolo state endpoints currently live at `/workspaces/:id/yolo/sessions/:sessionId
The server holds one `client.event.subscribe()` stream. `InstanceEventBridge` maps native location events to CodeNomad `instance.event` records, and `/api/events` multiplexes them with workspace and Yolo events for the browser; heartbeats use `POST /api/client-connections/pong`. The stream is volatile and has no replay guarantee: reconnect must reconcile sessions and pending requests and reread authoritative file/config state. Pending recovery uses the loaded-only broker, not worktree-wide ordinary lists; see [compatibility](OPENCODE_V2_COMPATIBILITY.md). Relay ordering and ownership fences remain specified in [AGENTS.md](../AGENTS.md).
Current native events include session lifecycle/output events (`session.created`, `session.renamed`, `session.moved`, `session.status`, `session.idle`, `session.execution.*`, `session.compaction.*`, `session.text.*`, `session.reasoning.*`, `session.tool.*`), background Shell refresh via `shell.created`, `shell.exited`, `shell.deleted`, file invalidation via `filesystem.changed`, and configuration invalidation via `config.updated`.
Current native events include session lifecycle/output events (`session.created`, `session.renamed`, `session.moved`, `session.execution.*`, `session.retry.scheduled`, `session.step.*`, `session.compaction.*`, `session.text.*`, `session.reasoning.*`, `session.tool.*`), background Shell refresh via `shell.created`, `shell.exited`, `shell.deleted`, file invalidation via `filesystem.changed`, and configuration invalidation via `config.updated`. Each busy period settles with exactly one `session.execution.succeeded`/`failed`/`interrupted`; the schema still declares `session.status` and `session.idle`, but OpenCode V2 never publishes them.
## Feature Ownership

View file

@ -14,7 +14,6 @@ import {
} from "../stores/message-v2/record-display-cache"
import type { MessageRecord } from "../stores/message-v2/types"
import { messageStoreBus } from "../stores/message-v2/bus"
import { formatTokenTotal } from "../lib/formatters"
import { ensureSessionAncestorsExpanded, sessions, setActiveSessionFromList } from "../stores/sessions"
import { selectInstanceTab } from "../stores/app-tabs"
import { useI18n, type I18nContextValue } from "../lib/i18n"
@ -49,11 +48,11 @@ import { showAlertDialog } from "../stores/alerts"
import { resolveFormToolTarget } from "./form-request-tool-target"
import { Markdown } from "./markdown"
import { useTheme } from "../lib/theme"
import { groupTechnicalParts, isTechnicalGroupingVisiblePart, isVisibleStepFinish, segmentExplorationItems, technicalPartKey, type TechnicalCleanupPart, type TranscriptTechnicalGroup } from "../lib/message-part-grouping"
import { groupTechnicalParts, hasVisibleUsage, isTechnicalGroupingVisiblePart, segmentExplorationItems, technicalPartKey, type TechnicalCleanupPart, type TranscriptTechnicalGroup } from "../lib/message-part-grouping"
import { MessageUsage, NO_STEP_BORDER } from "./message-usage"
const USER_BORDER_COLOR = "var(--message-user-border)"
const ASSISTANT_BORDER_COLOR = "var(--message-assistant-border)"
const NO_STEP_BORDER = "none"
const BACKGROUNDABLE_TOOLS = new Set(["bash", "shell", "task", "subagent"])
const LazyToolCall = lazy(() => import("./tool-call"))
@ -449,11 +448,10 @@ function ToolCallItem(props: ToolCallItemProps) {
)
}
interface StepDisplayItem {
type: "step-start" | "step-finish"
interface UsageDisplayItem {
type: "usage"
key: string
part: ClientPart
messageInfo?: MessageInfo
messageInfo: MessageInfo
accentColor?: string
}
@ -496,7 +494,7 @@ type CompactionDisplayItem = {
type SystemDisplayItem = { type: "system"; key: string; part: Extract<ClientPart, { type: "system" }> }
type HiddenPartsDisplayItem = { type: "hidden-parts"; key: string; messageId: string; count: number; start: number }
type MessageBlockItem = ContentDisplayItem | ToolDisplayItem | ExplorationDisplayItem | StepDisplayItem | ReasoningDisplayItem | CompactionDisplayItem | SystemDisplayItem | HiddenPartsDisplayItem
type MessageBlockItem = ContentDisplayItem | ToolDisplayItem | ExplorationDisplayItem | UsageDisplayItem | ReasoningDisplayItem | CompactionDisplayItem | SystemDisplayItem | HiddenPartsDisplayItem
interface MessageDisplayBlock {
messageId: string
@ -674,9 +672,6 @@ export default function MessageBlock(props: MessageBlockProps) {
}
const isDisplayPart = (part: ClientPart) => {
if (part.type === "step-finish") {
return isVisibleStepFinish(part, info, props.usageMetricsVisibility() !== "hidden")
}
if (!isTechnicalGroupingVisiblePart(part)) return false
if (part.type !== "tool" || !props.technicalGroupForPart || props.toolVisibility(part.tool) !== "hidden") return true
return Boolean(
@ -790,22 +785,6 @@ export default function MessageBlock(props: MessageBlockProps) {
return
}
if (part.type === "step-start") {
flushContent()
return
}
if (part.type === "step-finish") {
flushContent()
if (props.usageMetricsVisibility() !== "hidden") {
const key = `${current.id}:${part.id ?? partIndex}:${part.type}`
const accentColor = lastAccentColor || defaultAccentColor
items.push({ type: part.type, key, part, messageInfo: info, accentColor })
lastAccentColor = accentColor
}
return
}
pendingParts.push(part)
})
@ -821,6 +800,11 @@ export default function MessageBlock(props: MessageBlockProps) {
}
}
// A native assistant message is one model step; its settled usage closes it.
if (hasVisibleUsage(info, props.usageMetricsVisibility() !== "hidden")) {
items.push({ type: "usage", key: `${current.id}:usage`, messageInfo: info!, accentColor: lastAccentColor || defaultAccentColor })
}
const resultBlock: MessageDisplayBlock = { messageId: current.id, status: current.status, items, truncated: displayData.hiddenCount > 0 }
sessionCache.messageBlocks.set(current.id, {
signature: cacheSignature,
@ -1022,27 +1006,11 @@ export default function MessageBlock(props: MessageBlockProps) {
/>
</Show>
</Match>
<Match when={item().type === "step-start"}>
<StepCard
kind="start"
part={(item() as StepDisplayItem).part}
messageInfo={(item() as StepDisplayItem).messageInfo}
showAgentMeta
instanceId={props.instanceId}
sessionId={props.sessionId}
messageId={props.messageId}
/>
</Match>
<Match when={item().type === "step-finish"}>
<StepCard
kind="finish"
part={(item() as StepDisplayItem).part}
messageInfo={(item() as StepDisplayItem).messageInfo}
<Match when={item().type === "usage"}>
<MessageUsage
messageInfo={(item() as UsageDisplayItem).messageInfo}
usageVisibility={props.usageMetricsVisibility()}
borderColor={(item() as StepDisplayItem).accentColor}
instanceId={props.instanceId}
sessionId={props.sessionId}
messageId={props.messageId}
borderColor={(item() as UsageDisplayItem).accentColor}
onContentRendered={props.onContentRendered}
/>
</Match>
@ -1316,19 +1284,6 @@ function ExplorationGroup(props: ExplorationGroupProps) {
)
}
interface StepCardProps {
kind: "start" | "finish"
part: ClientPart
messageInfo?: MessageInfo
showAgentMeta?: boolean
usageVisibility?: VisibilityPreference
borderColor?: string
instanceId?: string
sessionId?: string
messageId?: string
onContentRendered?: () => void
}
interface CompactionCardProps {
part: ClientPart
messageInfo?: MessageInfo
@ -1397,171 +1352,6 @@ function CompactionCard(props: CompactionCardProps) {
)
}
function StepCard(props: StepCardProps) {
const { t, locale } = useI18n()
const [usageExpandedOverride, setUsageExpandedOverride] = createSignal<boolean | null>(null)
const usageExpanded = () => usageExpandedOverride() ?? props.usageVisibility === "expanded"
let usagePartId = (props.part as { id?: string }).id
createEffect(() => {
const nextPartId = (props.part as { id?: string }).id
if (nextPartId === usagePartId) return
usagePartId = nextPartId
setUsageExpandedOverride(null)
})
const timestamp = () => {
const value = props.messageInfo?.time?.created ?? (props.part as any)?.time?.start ?? Date.now()
const date = new Date(value)
return date.toLocaleString(locale(), {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
})
}
const agentIdentifier = () => {
if (!props.showAgentMeta) return ""
const info = props.messageInfo
if (!info || info.role !== "assistant") return ""
return info.mode || ""
}
const modelIdentifier = () => {
if (!props.showAgentMeta) return ""
const info = props.messageInfo
if (!info || info.role !== "assistant") return ""
const modelID = info.modelID || ""
const providerID = info.providerID || ""
if (modelID && providerID) return `${providerID}/${modelID}`
return modelID
}
const usageStats = () => {
if (props.kind !== "finish" || props.usageVisibility === "hidden") {
return null
}
const info = props.messageInfo
const part = props.part as any
// step-finish parts have tokens embedded; also check messageInfo
const partTokens = part?.tokens
const infoTokens = info && info.role === "assistant" ? info.tokens : undefined
const tokens = partTokens ?? infoTokens
if (!tokens) {
return null
}
return {
input: tokens.input ?? 0,
output: tokens.output ?? 0,
reasoning: tokens.reasoning ?? 0,
cacheRead: tokens.cache?.read ?? 0,
cacheWrite: tokens.cache?.write ?? 0,
cost: (part?.cost ?? (info && info.role === "assistant" ? info.cost : 0)) ?? 0,
}
}
const finishStyle = () => {
if (props.borderColor === NO_STEP_BORDER) {
return {
"border-inline-start": "none",
}
}
return props.borderColor ? { "border-left-color": props.borderColor } : undefined
}
let didReportUsageStats = false
createEffect(() => {
if (didReportUsageStats) return
if (props.kind !== "finish" || !usageStats()) return
didReportUsageStats = true
props.onContentRendered?.()
})
const usageEntries = (usage: NonNullable<ReturnType<typeof usageStats>>) => [
{ label: t("messageBlock.usage.input"), value: usage.input, formatter: formatTokenTotal },
{ label: t("messageBlock.usage.output"), value: usage.output, formatter: formatTokenTotal },
...(usageExpanded()
? [
{ label: t("messageBlock.usage.reasoning"), value: usage.reasoning, formatter: formatTokenTotal },
{ label: t("messageBlock.usage.cacheRead"), value: usage.cacheRead, formatter: formatTokenTotal },
{ label: t("messageBlock.usage.cacheWrite"), value: usage.cacheWrite, formatter: formatTokenTotal },
]
: []),
{ label: t("messageBlock.usage.cost"), value: usage.cost, formatter: formatCostValue },
]
const renderUsageChips = (usage: NonNullable<ReturnType<typeof usageStats>>) => {
const entries = usageEntries(usage)
return (
<div class="message-step-usage">
<For each={entries}>
{(entry) => (
<span class="message-step-usage-chip" data-label={entry.label}>
{entry.formatter(entry.value)}
</span>
)}
</For>
</div>
)
}
if (props.kind === "finish") {
const usage = usageStats()
if (!usage) {
return null
}
return (
<div
class="delete-hover-scope message-step-card message-step-finish message-step-finish-flush relative"
style={finishStyle()}
>
<button
type="button"
class="message-step-usage-toggle"
aria-expanded={usageExpanded()}
aria-label={`${t(usageExpanded() ? "messageBlock.usage.collapseAriaLabel" : "messageBlock.usage.expandAriaLabel")}. ${usageEntries(usage).map((entry) => `${entry.label}: ${entry.formatter(entry.value)}`).join(", ")}`}
onClick={() => setUsageExpandedOverride((current) => !(current ?? props.usageVisibility === "expanded"))}
>
<ChevronRight class="message-step-usage-disclosure disclosure-chevron" aria-hidden="true" />
{renderUsageChips(usage)}
</button>
</div>
)
}
return (
<div class={`message-step-card message-step-start relative`}>
<div class="message-step-heading">
<div class="message-step-title">
<div class="message-step-title-left">
<Show when={props.showAgentMeta && (agentIdentifier() || modelIdentifier())}>
<span class="message-step-meta-inline">
{[
agentIdentifier(),
modelIdentifier(),
].filter(Boolean).join(" • ")}
</span>
</Show>
</div>
<span class="message-step-right">
<span class="message-step-time">{timestamp()}</span>
</span>
</div>
</div>
</div>
)
}
function formatCostValue(value: number) {
if (!value) return "$0.00"
if (value < 0.01) return `$${value.toPrecision(2)}`
return `$${value.toFixed(2)}`
}
function getReasoningText(part: ClientPart) {
const stringify = (segment: unknown): string => {
if (typeof segment === "string") return segment

View file

@ -44,7 +44,7 @@ import { getOpenCodeInstanceGeneration, getOpenCodeMutationRevision } from "../s
import type { SessionInboxUser } from "@opencode/client"
import { getFormQueue } from "../stores/forms"
import { resolveFormToolTarget } from "./form-request-tool-target"
import { getTechnicalCleanupParts, getTechnicalGroupKind, isTechnicalGroupingVisiblePart, isVisibleStepFinish, projectTranscriptTechnicalGroups, reasoningHasRenderableContent, technicalPartKey, type TechnicalCleanupTranscriptItem } from "../lib/message-part-grouping"
import { getTechnicalCleanupParts, getTechnicalGroupKind, hasVisibleUsage, isTechnicalGroupingVisiblePart, projectTranscriptTechnicalGroups, reasoningHasRenderableContent, technicalPartKey, type TechnicalCleanupTranscriptItem } from "../lib/message-part-grouping"
const QUOTE_SELECTION_MAX_LENGTH = 2000
const STREAMING_TEXT_HOLD_TOP_THRESHOLD_PX = 8
@ -220,9 +220,6 @@ export default function MessageSection(props: MessageSectionProps) {
const messageInfo = resolvedStore.getMessageInfo(messageId)
const display = buildRecordDisplayData(props.instanceId, record)
const projected = display.orderedParts.map((part) => {
if (part.type === "step-finish") {
return isVisibleStepFinish(part, messageInfo, usageMetricsVisibility() !== "hidden") ? [null] : []
}
if (!isTechnicalGroupingVisiblePart(part)) return []
const partId = typeof part.id === "string" ? part.id : ""
const kind = getTechnicalGroupKind(part)
@ -244,6 +241,8 @@ export default function MessageSection(props: MessageSectionProps) {
})
// Technical groups never span omitted parts; the gap renders between them.
if (display.hiddenCount > 0) projected.splice(display.gapIndex, 0, [null])
// Nor a step's usage line, which closes its message.
if (hasVisibleUsage(messageInfo, usageMetricsVisibility() !== "hidden")) projected.push([null])
return projected.flat()
})
return projectTranscriptTechnicalGroups(items)

View file

@ -280,7 +280,7 @@ export function buildTimelineSegments(
continue
}
if (part.type === "step-start" || part.type === "step-finish" || part.type === "system") {
if (part.type === "system") {
continue
}

View file

@ -0,0 +1,82 @@
import { For, createEffect, createSignal } from "solid-js"
import { ChevronRight } from "lucide-solid"
import type { MessageInfo } from "../types/message"
import type { VisibilityPreference } from "../stores/preferences"
import { formatTokenTotal } from "../lib/formatters"
import { useI18n } from "../lib/i18n"
/** Marks a usage line that follows tools, which draw no message border. */
export const NO_STEP_BORDER = "none"
interface MessageUsageProps {
/** A settled native assistant step: one model call with its own usage. */
messageInfo: MessageInfo
usageVisibility: VisibilityPreference
borderColor?: string
onContentRendered?: () => void
}
/** The compact token and cost line closing one assistant step. */
export function MessageUsage(props: MessageUsageProps) {
const { t } = useI18n()
const [expandedOverride, setExpandedOverride] = createSignal<boolean | null>(null)
const expanded = () => expandedOverride() ?? props.usageVisibility === "expanded"
const usage = () => {
const tokens = props.messageInfo.tokens ?? {}
return {
input: tokens.input ?? 0,
output: tokens.output ?? 0,
reasoning: tokens.reasoning ?? 0,
cacheRead: tokens.cache?.read ?? 0,
cacheWrite: tokens.cache?.write ?? 0,
cost: props.messageInfo.cost ?? 0,
}
}
const entries = () => [
{ label: t("messageBlock.usage.input"), value: formatTokenTotal(usage().input) },
{ label: t("messageBlock.usage.output"), value: formatTokenTotal(usage().output) },
...(expanded()
? [
{ label: t("messageBlock.usage.reasoning"), value: formatTokenTotal(usage().reasoning) },
{ label: t("messageBlock.usage.cacheRead"), value: formatTokenTotal(usage().cacheRead) },
{ label: t("messageBlock.usage.cacheWrite"), value: formatTokenTotal(usage().cacheWrite) },
]
: []),
{ label: t("messageBlock.usage.cost"), value: formatCostValue(usage().cost) },
]
const style = () => props.borderColor === NO_STEP_BORDER
? { "border-inline-start": "none" }
: props.borderColor ? { "border-left-color": props.borderColor } : undefined
let reported = false
createEffect(() => {
if (reported) return
reported = true
props.onContentRendered?.()
})
return (
<div class="delete-hover-scope message-step-card message-step-finish message-step-finish-flush relative" style={style()}>
<button
type="button"
class="message-step-usage-toggle"
aria-expanded={expanded()}
aria-label={`${t(expanded() ? "messageBlock.usage.collapseAriaLabel" : "messageBlock.usage.expandAriaLabel")}. ${entries().map((entry) => `${entry.label}: ${entry.value}`).join(", ")}`}
onClick={() => setExpandedOverride(!expanded())}
>
<ChevronRight class="message-step-usage-disclosure disclosure-chevron" aria-hidden="true" />
<div class="message-step-usage">
<For each={entries()}>
{(entry) => <span class="message-step-usage-chip" data-label={entry.label}>{entry.value}</span>}
</For>
</div>
</button>
</div>
)
}
function formatCostValue(value: number) {
if (!value) return "$0.00"
if (value < 0.01) return `$${value.toPrecision(2)}`
return `$${value.toFixed(2)}`
}

View file

@ -17,8 +17,9 @@ export function MissionTracking(props: {
}) {
const observed = () => props.activity?.missions.find(value => value.missionId === props.mission.id)
const target = (kind: MissionReaderTarget["kind"], itemId?: string): MissionReaderTarget => ({ missionId: props.mission.id, kind, itemId })
// Native session.status invalidations regenerate the activity projection: its
// generation paces rereads of the linked conversations' activity.
// Native lifecycle invalidations (session.execution.*, inbox, Shell, Form and
// permission events) regenerate the activity projection: its generation paces
// rereads of the linked conversations' activity.
createEffect(() => { if (props.active) demandMissionTaskSessions(props.instanceId, props.mission, props.activity?.generatedAt) })
return <>
<MissionAttention mission={props.mission} family={observed()?.family} instanceId={props.instanceId} onOpenActor={props.onOpenActor} />

View file

@ -3,6 +3,7 @@ import type { ServiceUsageSnapshot } from "../../../../server/src/api-types"
import { useI18n } from "../../lib/i18n"
import { serverApi } from "../../lib/api-client"
import { serverEvents } from "../../lib/server-events"
import { isExecutionTerminalEvent } from "../../lib/native-execution-events"
import { activeInstanceId } from "../../stores/instances"
import { getCachedServiceUsage, setCachedServiceUsage } from "../../stores/service-usage-cache"
@ -42,7 +43,8 @@ export function UsageSettingsSection(props: { instanceId?: string }) {
// Native stats are durable aggregates: refresh at settled session changes,
// never per token/delta and never by loading transcript messages.
const events = serverEvents.on("instance.event", payload => {
if (payload.type === "instance.event" && payload.instanceId === id && ["session.idle", "session.deleted", "rpc.codenomad.session-pruning.pruned"].includes(payload.event.type)) refresh()
if (payload.type !== "instance.event" || payload.instanceId !== id) return
if (isExecutionTerminalEvent(payload.event) || ["session.deleted", "rpc.codenomad.session-pruning.pruned"].includes(payload.event.type)) refresh()
})
const status = serverEvents.on("instance.eventStatus", payload => {
if (payload.type === "instance.eventStatus" && payload.instanceId === id && payload.status === "connected") refresh()

View file

@ -1,8 +1,8 @@
import assert from "node:assert/strict"
import { describe, it } from "node:test"
import { getTechnicalCleanupParts, groupTechnicalParts, isTechnicalGroupingVisiblePart, isVisibleStepFinish, projectTranscriptTechnicalGroups, segmentExplorationItems, technicalPartKey, type TechnicalCleanupTranscriptItem } from "./message-part-grouping.ts"
import type { ClientPart } from "../types/message.ts"
import { getTechnicalCleanupParts, groupTechnicalParts, hasVisibleUsage, projectTranscriptTechnicalGroups, segmentExplorationItems, technicalPartKey, type TechnicalCleanupTranscriptItem } from "./message-part-grouping.ts"
import type { ClientPart, MessageInfo } from "../types/message.ts"
const part = (id: string, type: ClientPart["type"], tool?: string) => ({ id, type, ...(tool ? { tool } : {}) }) as ClientPart
@ -121,13 +121,12 @@ describe("message technical part grouping", () => {
])
})
it("ignores invisible step lifecycle parts", () => {
const start = part("start", "step-start")
const finish = part("finish", "step-finish")
assert.equal(isTechnicalGroupingVisiblePart(start), false)
assert.equal(isTechnicalGroupingVisiblePart(finish), false)
assert.equal(isVisibleStepFinish(finish, undefined, true), false)
assert.equal(isVisibleStepFinish({ ...finish, tokens: { input: 1 } } as ClientPart, undefined, true), true)
assert.equal(isVisibleStepFinish({ ...finish, tokens: { input: 1 } } as ClientPart, undefined, false), false)
it("shows usage only for settled assistant steps while the preference is visible", () => {
const info = (role: MessageInfo["role"], tokens?: MessageInfo["tokens"]) => ({ id: "m", sessionID: "s", role, time: { created: 1 }, tokens }) as MessageInfo
assert.equal(hasVisibleUsage(undefined, true), false)
assert.equal(hasVisibleUsage(info("assistant"), true), false, "a running step has no usage yet")
assert.equal(hasVisibleUsage(info("assistant", { input: 1 }), true), true)
assert.equal(hasVisibleUsage(info("assistant", { input: 1 }), false), false)
assert.equal(hasVisibleUsage(info("user", { input: 1 }), true), false)
})
})

View file

@ -61,15 +61,14 @@ export function reasoningHasRenderableContent(part: ClientPart): boolean {
export function isTechnicalGroupingVisiblePart(part: ClientPart): boolean {
if ((part as any)?.type === "patch") return false
if (part.type === "step-start" || part.type === "step-finish") return false
if (part.type === "reasoning") return reasoningHasRenderableContent(part)
if (part.type === "text" || part.type === "file") return !isHiddenSyntheticTextPart(part) && partHasRenderableText(part)
return true
}
export function isVisibleStepFinish(part: ClientPart, messageInfo: MessageInfo | undefined, usageVisible: boolean): boolean {
if (part.type !== "step-finish" || !usageVisible) return false
return Boolean((part as any).tokens ?? (messageInfo?.role === "assistant" ? messageInfo.tokens : undefined))
/** Each native assistant message is one model step; its settlement records the step's usage. */
export function hasVisibleUsage(messageInfo: MessageInfo | undefined, usageVisible: boolean): boolean {
return usageVisible && messageInfo?.role === "assistant" && Boolean(messageInfo.tokens)
}
export function technicalPartKey(messageId: string, partId: string) {

View file

@ -0,0 +1,13 @@
const EXECUTION_TERMINAL_EVENT_TYPES = new Set([
"session.execution.succeeded",
"session.execution.failed",
"session.execution.interrupted",
])
/**
* OpenCode V2 settles every busy period of a session with exactly one of these
* events. It never publishes the deprecated session.idle/session.status.
*/
export function isExecutionTerminalEvent(event: { type: string }): boolean {
return EXECUTION_TERMINAL_EVENT_TYPES.has(event.type)
}

View file

@ -6,11 +6,9 @@ import type {
SessionCreated,
SessionDeleted,
SessionExecutionFailed,
SessionIdle,
SessionRevertCleared,
SessionRevertCommitted,
SessionRevertStaged,
SessionStatusUpdated,
TuiToastShow,
V2Event,
} from "@opencode/client"
@ -57,11 +55,9 @@ type SSEEvent =
| SessionDeleted
| SessionCompactionEnded
| SessionExecutionFailed
| SessionIdle
| SessionRevertStaged
| SessionRevertCleared
| SessionRevertCommitted
| SessionStatusUpdated
| PermissionAsked
| PermissionReplied
| TuiToastShow
@ -138,12 +134,6 @@ class SSEManager {
case "tui.toast.show":
this.onTuiToast?.(instanceId, event as TuiToastShow)
break
case "session.idle":
this.onSessionIdle?.(instanceId, event as SessionIdle)
break
case "session.status":
this.onSessionStatus?.(instanceId, event as SessionStatusUpdated)
break
case "permission.asked":
this.onPermissionUpdated?.(instanceId, event as PermissionAsked)
break
@ -186,8 +176,6 @@ class SSEManager {
onSessionCompacted?: (instanceId: string, event: SessionCompactionEnded) => void
onSessionError?: (instanceId: string, event: SessionExecutionFailed) => void
onTuiToast?: (instanceId: string, event: TuiToastShow) => void
onSessionIdle?: (instanceId: string, event: SessionIdle) => void
onSessionStatus?: (instanceId: string, event: SessionStatusUpdated) => void
onPermissionUpdated?: (instanceId: string, event: PermissionAsked) => void
onPermissionReplied?: (instanceId: string, event: PermissionReplied) => void
onNativeSessionEvent?: (instanceId: string, event: NativeSessionEvent) => void

View file

@ -88,15 +88,15 @@ for (const boundary of ["failed", "delete", "reconnect", "prune", "revert", "dis
})
}
test("idle flushes pending compaction before retiring its reducer", ctx => {
test("settlement flushes pending compaction before retiring its reducer", ctx => {
ctx.mock.timers.enable({ apis: ["setTimeout"] })
const f = fixture("compaction-idle")
try {
f.start()
f.delta("last text")
f.emit("session.idle")
f.emit("session.execution.succeeded")
assert.equal(f.summary(), "last text")
finishOpenCodeDataEvent("compaction-idle", { type: "session.idle", data: { sessionID: "s" } } as any)
finishOpenCodeDataEvent("compaction-idle", { type: "session.execution.succeeded", data: { sessionID: "s" } } as any)
ctx.mock.timers.tick(500)
assert.equal(f.projections(), 1)
} finally { f.cleanup() }

View file

@ -8,6 +8,8 @@ import { applyOpenCodeDataEvent, destroyOpenCodeData, finishOpenCodeDataEvent, g
const model = { providerID: "fixture", id: "fixture" }
const sessionId = "session"
// OpenCode V2 settles every busy period with one session.execution.* event.
const SETTLED = "session.execution.succeeded"
function deferred<T>() {
let resolve!: (value: T) => void
@ -27,7 +29,7 @@ function fixture(instanceId: string) {
const event = { id: `event-${created}`, type, created, data: { sessionID: sessionId, ...data } } as OpenCodeEvent
const result = applyOpenCodeDataEvent(instanceId, "/work", event, next => {
project(next)
if (type === "session.idle") idleApplied = true
if (type === SETTLED) idleApplied = true
}, next => {
resynced = next
projectOpenCodeMessages(instanceId, sessionId, next, false)
@ -84,7 +86,7 @@ test("idle drains the queued final step before retiring a rotating reducer", asy
try {
f.fill()
f.finalStep()
const retiring = f.emit("session.idle")
const retiring = f.emit(SETTLED)
assert.equal(f.store.getMessage("final"), undefined, "the final step is still queued")
await waitForReplay(() => f.idleApplied)
assertFinal(f.store)
@ -100,7 +102,7 @@ test("a new execution cancels deferred idle retirement while rotation drains", a
try {
f.fill()
f.finalStep()
const retained = f.emit("session.idle")
const retained = f.emit(SETTLED)
f.emit("session.execution.started")
f.emit("session.step.started", { assistantMessageID: "resumed", agent: "build", model })
f.emit("session.text.started", { assistantMessageID: "resumed" })
@ -145,7 +147,7 @@ for (const phase of ["scheduled", "inflight", "retry"] as const) {
}
if (phase !== "scheduled") await started.promise
f.emit("session.step.ended", { assistantMessageID: "final", finish: "stop" })
f.emit("session.idle")
f.emit(SETTLED)
assert.equal(f.store.getMessage("final"), undefined)
if (phase === "inflight") first.resolve({ data: [], cursor: {} })
if (phase === "retry") first.reject(new Error("temporary recovery failure"))
@ -166,10 +168,58 @@ test("idle payload disposal preserves monotonic message and mutation revisions",
try {
for (let revision = 1; revision <= 3; revision += 1) {
const reducer = f.emit("session.inbox.cancelled", { inboxID: "removed" })
f.emit("session.idle")
f.emit(SETTLED)
assert.equal(getOpenCodeMutationRevision(instanceId, sessionId), revision)
assert.equal(getOpenCodeMessageRevision(instanceId, sessionId), revision)
// The cancellation and the settlement (its native idle record) each change messages.
assert.equal(getOpenCodeMessageRevision(instanceId, sessionId), revision * 2)
assert.notEqual(f.emit("permission.replied", { requestID: "missing" }), reducer)
}
} finally { f.cleanup() }
})
for (const [terminal, data] of [
["session.execution.failed", { error: { type: "fixture", message: "failed" } }],
["session.execution.interrupted", { reason: "user" }],
] as const) {
test(`${terminal} also releases the settled reducer`, () => {
const f = fixture(`settled-${terminal}`)
try {
f.finalStep()
const settled = f.emit(terminal, data)
assert.notEqual(f.emit("permission.replied", { requestID: "missing" }), settled)
assertFinal(f.store)
} finally { f.cleanup() }
})
}
test("a settled session keeps its reducer while prompts stay parked in its inbox", () => {
const f = fixture("settled-parked-prompt")
try {
f.emit("session.inbox.enqueued", { inboxID: "parked", item: { type: "user", payload: { text: "later" }, delivery: "queue" } })
f.finalStep()
// Interruption keeps queued next-turn prompts parked; the composer queue reads them from this reducer.
const settled = f.emit("session.execution.interrupted", { reason: "user" })
assert.deepEqual(settled.session.pending.list(sessionId).map((item) => item.id), ["parked"])
assert.equal(f.emit("permission.replied", { requestID: "missing" }), settled)
f.emit("session.inbox.cancelled", { inboxID: "parked" })
assert.notEqual(f.emit("permission.replied", { requestID: "missing" }), settled, "released once nothing is parked")
} finally { f.cleanup() }
})
test("a settled session keeps its reducer until a user shell started before settlement ends", () => {
const f = fixture("settled-running-shell")
try {
const shell = { id: "shell-1", command: "sleep 5", status: "running", metadata: {} }
f.emit("session.shell.started", { shell })
f.finalStep()
const settled = f.emit(SETTLED)
assert.equal(f.emit("permission.replied", { requestID: "missing" }), settled)
// A fresh reducer ignores session.shell.ended for a shell it never saw.
const ended = f.emit("session.shell.ended", { shell: { ...shell, status: "exited", exit: 0 }, output: { output: "done" } })
assert.equal(ended, settled, "the shell settles in the reducer that created its message")
const row = ended.session.message.list(sessionId).find((message) => message.type === "shell")
assert.equal(row?.type === "shell" ? row.status : undefined, "exited")
assert.equal(f.store.getMessage(row!.id)?.status, "complete", "the projected row leaves the running state")
assert.notEqual(f.emit("permission.replied", { requestID: "missing" }), settled, "released once the shell ended")
} finally { f.cleanup() }
})

View file

@ -1150,9 +1150,9 @@ describe("OpenCode data projection", () => {
applyOpenCodeDataEvent(instanceId, "/work", event("active", "session.step.started", {
agent: "build", model: { providerID: "provider", id: "model" },
}))
const idle = event("idle", "session.idle")
applyOpenCodeDataEvent(instanceId, "/work", idle)
finishOpenCodeDataEvent(instanceId, idle)
const settled = event("settled", "session.execution.succeeded")
applyOpenCodeDataEvent(instanceId, "/work", settled)
finishOpenCodeDataEvent(instanceId, settled)
applyOpenCodeDataEvent(instanceId, "/work", event("active", "session.text.started"))
const data = applyOpenCodeDataEvent(instanceId, "/work", event("active", "session.text.delta", { ordinal: 0, delta: "kept" }))
projectOpenCodeMessages(instanceId, "active", data)

View file

@ -8,6 +8,7 @@ import { MESSAGE_WINDOW_PAGE_SIZE } from "./message-v2/message-window"
import { messageStoreBus } from "./message-v2/bus"
import { sseManager } from "../lib/sse-manager"
import { getLogger } from "../lib/logger"
import { isExecutionTerminalEvent } from "../lib/native-execution-events"
import { createCompactionDeltaBuffer } from "./compaction-delta-buffer"
const log = getLogger("session")
@ -711,7 +712,7 @@ export function applyOpenCodeDataEvent(
const entry = transcript?.entry ?? primary
if (transcript && typeof sessionId === "string") {
if (event.type === "session.compaction.started") unobservedCompactions.delete(messageRevisionKey(instanceId, sessionId))
// A new execution must not inherit a preceding idle event's deferred cleanup.
// A new execution must not inherit a preceding settlement's deferred cleanup.
if (event.type === "session.execution.started" || eventMayAppendMessage(event)) transcript.retireWhenDrained = false
}
if (transcript && typeof sessionId === "string") {
@ -840,19 +841,31 @@ function retireDrainedTranscript(instanceId: string, sessionId: string, transcri
if (!transcript.retireWhenDrained || !isTranscriptCurrent(instanceId, sessionId, transcript)
|| transcript.rotating || transcript.resyncing || transcript.needsAuthoritativeResync
|| transcript.retryTimer || transcript.queue.length) return
// Work that outlives the settlement still needs this reducer: prompts parked in
// its inbox (e.g. after an interruption) are listed from it, and unsettled rows
// settle only in it.
const data = transcript.entry.data
if (data.session.pending.list(sessionId).length || data.session.message.list(sessionId).some(isUnsettledAtSettlement)) return
destroyOpenCodeData(instanceId, sessionId)
}
/** A user shell still running, or tools whose terminal event never arrived: the reducer reconciles those itself. */
function isUnsettledAtSettlement(message: SessionMessageInfo): boolean {
if (message.type === "shell") return message.status === "running"
return message.type === "assistant"
&& message.content.some((part) => part.type === "tool" && (part.state.status === "streaming" || part.state.status === "running"))
}
export function finishOpenCodeDataEvent(instanceId: string, event: OpenCodeEvent): void {
const sessionId = eventSessionId(event)
if (sessionId === undefined) return
if (event.type === "session.deleted") destroyOpenCodeData(instanceId, sessionId)
if (event.type !== "session.idle") return
const transcript = transcriptEntries.get(messageRevisionKey(instanceId, sessionId))
if (!transcript) return
// The caller has projected the synchronous page. Queued terminal updates and
// overflow recovery must publish their final page before releasing its reducer.
transcript.retireWhenDrained = true
if (isExecutionTerminalEvent(event)) transcript.retireWhenDrained = true
// A settlement deferred by parked or running work completes once that clears.
retireDrainedTranscript(instanceId, sessionId, transcript)
}

View file

@ -235,7 +235,7 @@ test("admission metadata keeps synchronous data consumption and buffered compact
} finally { destroyOpenCodeData(instanceId); sdkManager.destroyClientsForInstance(instanceId) }
})
test("FIFO replay retains tool/text/compaction/inbox state across task turns and final idle publication", async ctx => {
test("FIFO replay retains tool/text/compaction/inbox state across task turns and final settlement publication", async ctx => {
ctx.mock.timers.enable({ apis: ["setTimeout"] })
const f = fixture("replay-mixed")
costClock(ctx, f.data())
@ -259,9 +259,12 @@ test("FIFO replay retains tool/text/compaction/inbox state across task turns and
f.apply("session.compaction.delta", { text: "obsolete buffered fragment" }, undefined, publication)
f.apply("session.compaction.ended", { reason: "manual", model, text: "summary final", recent: "", cost: 0, tokens }, undefined, publication)
f.apply("session.step.ended", { ...base, finish: "stop", cost: 0, tokens }, undefined, publication)
const idle = f.make("session.idle")
applyOpenCodeDataEvent(f.instanceId, "/fixture", idle, undefined, undefined, false, { deferred: false, publication })
finishOpenCodeDataEvent(f.instanceId, idle)
const settle = () => {
const settled = f.make("session.execution.succeeded")
applyOpenCodeDataEvent(f.instanceId, "/fixture", settled, undefined, undefined, false, { deferred: false, publication })
finishOpenCodeDataEvent(f.instanceId, settled)
}
settle()
await microtasks()
await finish(ctx, () => Boolean((f.data().session.message.get(sessionId, "msg_live") as any)?.time.completed))
// One trailing task may contain idle itself; run it before testing retirement.
@ -275,7 +278,11 @@ test("FIFO replay retains tool/text/compaction/inbox state across task turns and
assert.ok(published > 1)
const old = f.data()
f.apply("session.execution.started")
assert.notStrictEqual(f.data(), old, "idle retires only after its final queued publication")
assert.strictEqual(f.data(), old, "the composer lists the parked prompt from this reducer")
f.apply("session.inbox.cancelled", { inboxID: "msg_pending" })
settle()
f.apply("session.execution.started")
assert.notStrictEqual(f.data(), old, "settlement retires the reducer once nothing is parked")
} finally { f.cleanup() }
})

View file

@ -5,7 +5,7 @@ import { applyOpenCodeDataEvent, destroyOpenCodeData } from "./opencode-data.ts"
import { sdkManager } from "../lib/sdk-manager.ts"
import { sseManager } from "../lib/sse-manager.ts"
import { addInstance, handleInstanceInvalidation, removeInstance } from "./instances.ts"
import { handleNativeSessionEvent, handleSessionIdle, handleSessionStatus } from "./session-events.ts"
import { handleNativeSessionEvent } from "./session-events.ts"
import { messageStoreBus } from "./message-v2/bus.ts"
import { setActiveSession, setSessions } from "./session-state.ts"
@ -29,7 +29,8 @@ test("the native reducer preserves canonical precise start times and durable ide
}
})
for (const following of ["none", "idle", "status-idle", "new-execution"] as const) {
// OpenCode V2 never publishes the deprecated session.idle; a stray one stays inert.
for (const following of ["none", "legacy-idle", "new-execution"] as const) {
test(`settled tools reach the visible store with following ${following}`, async () => {
const instanceId = `runtime-tool-settlement-${following}`, sessionId = "s"
const statuses = sseManager.getStatuses
@ -68,8 +69,7 @@ test(`settled tools reach the visible store with following ${following}`, async
emit("session.tool.called", { ...base, id: "tool", input: { path: "fixture" } }, 3)
assert.equal(state()?.status, "running")
emit("session.execution.succeeded", { sessionID: sessionId }, 5)
if (following === "idle") handleSessionIdle(instanceId, { type: "session.idle", data: { sessionID: sessionId }, created: 6 } as any)
if (following === "status-idle") handleSessionStatus(instanceId, { type: "session.status", data: { sessionID: sessionId, status: { type: "idle" } }, created: 6 } as any)
if (following === "legacy-idle") emit("session.idle", { sessionID: sessionId }, 6)
if (following === "new-execution") emit("session.execution.started", { sessionID: sessionId }, 6)
release({})
if (following === "new-execution") {

View file

@ -4,12 +4,11 @@ import type {
SessionCompactionEnded,
SessionCreated,
SessionExecutionFailed,
SessionIdle,
SessionMoved,
SessionRetryScheduled,
SessionRevertCleared,
SessionRevertCommitted,
SessionRevertStaged,
SessionStatusUpdated,
TuiToastShow,
} from "@opencode/client"
import { getLogger } from "../lib/logger"
@ -36,14 +35,14 @@ import { showAlertDialog } from "./alerts"
import {
createClientSession,
getIdleSinceForStatusTransition,
mapSdkSessionRetry,
mapSdkSessionStatus,
mapNativeSessionRetry,
type Session,
type SessionRetryState,
type SessionStatus,
} from "../types/session"
import { activeSessionId, getAuthoritativelyDeletedSessionIdsForInstance, invalidateSessionMessageLoad, prependSessionListId, revealWorkingSubsession, sessions, setSessionStatus, setSessions, syncInstanceSessionIndicator, withSession } from "./session-state"
import { mergeFetchedSessionRuntimeState } from "./session-generation-recovery"
import { getRetrySeconds } from "./session-status"
import { tGlobal } from "../lib/i18n"
import { fetchSessions, loadMessages, refreshSessionCatalog, removeSessionRuntimeState } from "./session-api"
@ -63,7 +62,9 @@ const pendingSessionFetches = new Map<string, {
retry?: SessionRetryState | null
}>()
const nativeLifecycleGenerations = new Map<string, number>()
let activeRetryToast: ToastHandle | null = null
// The live backoff countdown of one session; it ends when that session's next
// attempt starts or its run settles.
let activeRetryToast: { key: string; handle: ToastHandle } | null = null
const movedSessionRefreshTimers = new Map<string, ReturnType<typeof setTimeout>>()
function advanceNativeLifecycle(instanceId: string, sessionId: string): number {
@ -77,6 +78,12 @@ function isCurrentNativeLifecycle(instanceId: string, sessionId: string, generat
return nativeLifecycleGenerations.get(`${instanceId}\0${sessionId}`) === generation
}
function dismissRetryToast(instanceId: string, sessionId: string): void {
if (activeRetryToast?.key !== `${instanceId}\0${sessionId}`) return
activeRetryToast.handle.dismiss()
activeRetryToast = null
}
function speakCompletedAssistantText(instanceId: string, sessionId: string): void {
const store = messageStoreBus.getOrCreate(instanceId)
const messageId = store.getLastAssistantMessageId(sessionId)
@ -126,6 +133,7 @@ function handleNativeSessionEvent(instanceId: string, event: NativeSessionEvent)
return
case "session.compaction.started":
advanceNativeLifecycle(instanceId, event.data.sessionID)
dismissRetryToast(instanceId, event.data.sessionID)
ensureSessionStatus(instanceId, event.data.sessionID, "compacting", event.location?.directory)
return
case "session.compaction.failed":
@ -139,15 +147,22 @@ function handleNativeSessionEvent(instanceId: string, event: NativeSessionEvent)
failed: true,
interrupted: true,
refreshMessages: true,
// A person's stop needs no notice and a shutdown resumes the turn later;
// OpenCode stopping an unattended run does.
notify: event.data.reason === "inactivity",
directory: event.location?.directory,
})
return
case "session.execution.succeeded":
void reconcileTerminalNativeSessionStatus(instanceId, event.data.sessionID, {
failed: false,
notify: true,
directory: event.location?.directory,
})
return
case "session.retry.scheduled":
handleRetryScheduled(instanceId, event)
return
}
if (!event.type.startsWith("session.")) return
@ -162,14 +177,21 @@ function handleNativeSessionEvent(instanceId: string, event: NativeSessionEvent)
event.type.startsWith("session.tool.")
) {
advanceNativeLifecycle(instanceId, sessionId)
// Ordinary work replaces the backoff: the next attempt has started.
dismissRetryToast(instanceId, sessionId)
ensureSessionStatus(instanceId, sessionId, "working", event.location?.directory)
}
}
/**
* OpenCode V2 settles each busy period with one session.execution.* event. It is
* a hint until the native active list confirms the session no longer runs; only
* then does the run count as finished (`notify` announces that completion).
*/
async function reconcileTerminalNativeSessionStatus(
instanceId: string,
sessionId: string,
options: { failed: boolean; interrupted?: boolean; refreshMessages?: boolean; directory?: string },
options: { failed: boolean; interrupted?: boolean; refreshMessages?: boolean; notify?: boolean; directory?: string },
): Promise<void> {
const generation = advanceNativeLifecycle(instanceId, sessionId)
let activeSessions: Record<string, unknown> | null = null
@ -193,6 +215,7 @@ async function reconcileTerminalNativeSessionStatus(
}
setTerminalNativeSessionStatus(instanceId, sessionId, options.failed, options.directory)
refreshSettledSessionMessages(instanceId, sessionId, options.refreshMessages)
if (options.notify) notifySessionAttention("sessionDone", instanceId, sessionId)
}
function refreshSettledSessionMessages(instanceId: string, sessionId: string, force = false): void {
@ -217,6 +240,7 @@ function setTerminalNativeSessionStatus(instanceId: string, sessionId: string, f
const existing = sessions().get(instanceId)?.get(sessionId)
if (existing) setSessionStatus(instanceId, sessionId, "idle", { force: true })
else ensureSessionStatus(instanceId, sessionId, "idle", directory)
dismissRetryToast(instanceId, sessionId)
if (failed) messageStoreBus.getOrCreate(instanceId).failPendingSends(sessionId)
speakCompletedAssistantText(instanceId, sessionId)
}
@ -525,53 +549,34 @@ function handleSessionDeleted(instanceId: string, event: EventSessionDeleted): v
log.info(`[SSE] Session deleted: ${sessionId}`)
nativeLifecycleGenerations.delete(`${instanceId}\0${sessionId}`)
dismissRetryToast(instanceId, sessionId)
removeSessionRuntimeState(instanceId, sessionId)
}
function handleSessionIdle(instanceId: string, event: SessionIdle): void {
/** Native provider backoff. The next attempt's step events clear it as ordinary work. */
function handleRetryScheduled(instanceId: string, event: SessionRetryScheduled): void {
const sessionId = event.data.sessionID
if (!sessionId) return
advanceNativeLifecycle(instanceId, sessionId)
notifySessionAttention("sessionDone", instanceId, sessionId)
ensureSessionStatus(instanceId, sessionId, "idle", event.location?.directory)
refreshSettledSessionMessages(instanceId, sessionId)
speakCompletedAssistantText(instanceId, sessionId)
log.info(`[SSE] Session idle: ${sessionId}`)
}
function handleSessionStatus(instanceId: string, event: SessionStatusUpdated): void {
const sessionId = event.data.sessionID
if (!sessionId) return
advanceNativeLifecycle(instanceId, sessionId)
const rawStatus = event.data.status
const status = mapSdkSessionStatus(rawStatus)
const retry = mapSdkSessionRetry(rawStatus)
ensureSessionStatus(instanceId, sessionId, status, event.location?.directory, retry)
if (status === "idle") refreshSettledSessionMessages(instanceId, sessionId)
if (retry) {
const remainingSeconds = Math.max(0, Math.round((retry.next - Date.now()) / 1000))
const countdown =
remainingSeconds > 0
? tGlobal("sessionList.status.retryingIn", { seconds: String(remainingSeconds) })
: tGlobal("sessionList.status.retrying")
const label = getSessionTitle(instanceId, sessionId)
activeRetryToast?.dismiss()
// A live retry status replaced by the next attempt, not an event to keep.
activeRetryToast = showToastNotification({ category: "sessions", transient: true,
title: label || getInstanceDisplayName(instanceId),
message: tGlobal("sessionList.status.retryToast", {
countdown,
message: retry.message,
attempt: String(retry.attempt),
}),
variant: "error",
duration: 7000,
})
}
log.info(`[SSE] Session status updated: ${sessionId}`, { status })
const retry = mapNativeSessionRetry(event.data)
ensureSessionStatus(instanceId, sessionId, "working", event.location?.directory, retry)
const remainingSeconds = getRetrySeconds(retry.next)
const countdown =
remainingSeconds > 0
? tGlobal("sessionList.status.retryingIn", { seconds: String(remainingSeconds) })
: tGlobal("sessionList.status.retrying")
const label = getSessionTitle(instanceId, sessionId)
activeRetryToast?.handle.dismiss()
// A live retry status replaced by the next attempt, not an event to keep.
activeRetryToast = { key: `${instanceId}\0${sessionId}`, handle: showToastNotification({ category: "sessions", transient: true,
title: label || getInstanceDisplayName(instanceId),
message: tGlobal("sessionList.status.retryToast", {
countdown,
message: retry.message,
attempt: String(retry.attempt),
}),
variant: "error",
duration: 7000,
}) }
}
function handleSessionCompacted(instanceId: string, event: SessionCompactionEnded): void {
@ -606,6 +611,8 @@ function handleSessionError(instanceId: string, event: SessionExecutionFailed):
if (sessionId) {
void reconcileTerminalNativeSessionStatus(instanceId, sessionId, {
failed: true,
// The alert stays in this window; the notice reaches history and the system.
notify: true,
directory: event.location?.directory,
})
}
@ -673,8 +680,6 @@ export {
handleSessionCompacted,
handleSessionDeleted,
handleSessionError,
handleSessionIdle,
handleSessionStatus,
handleSessionUpdate,
handleTuiToast,
}

View file

@ -0,0 +1,198 @@
import assert from "node:assert/strict"
import { afterEach, beforeEach, describe, it } from "node:test"
import toast from "solid-toast"
import { sdkManager } from "../lib/sdk-manager.ts"
import { sseManager } from "../lib/sse-manager.ts"
import { tGlobal } from "../lib/i18n/index.tsx"
import { clearToastHistory, getToastHistory } from "../lib/notifications.tsx"
import { addInstance, removeInstance } from "./instances.ts"
import { messageStoreBus } from "./message-v2/bus.ts"
import { destroyOpenCodeData } from "./opencode-data.ts"
import { clearInstanceDeletedSessionAuthority, sessions, setSessions } from "./session-state.ts"
import "./sessions.ts"
// Event sequences recorded from an isolated OpenCode 2.1.0 run
// (scripts/test-session-lifecycle-native.mjs). V2 settles a busy period with
// session.execution.* and reports provider backoff with session.retry.scheduled;
// it never publishes the deprecated session.idle/session.status declarations.
const instanceId = "lifecycle-signals"
const model = { id: "fixture", providerID: "fixture", variant: "default" }
const retryError = { type: "provider.internal", message: "fixture retry", status: 503 }
const failError = { type: "provider.invalid-request", message: "fixture fail", status: 400 }
let sequence = 0
let active: Record<string, unknown> = {}
const toasts: unknown[] = []
const dismissed: string[] = []
const originalToast = toast.custom, originalDismiss = toast.dismiss
function emit(type: string, data: Record<string, unknown>) {
const seq = ++sequence
;(sseManager as any).handleEvent(instanceId, {
id: `evt_${seq}`, type, created: seq, location: { directory: "/work" },
durable: { aggregateID: data.sessionID, seq, version: 1 }, data,
})
}
function run(sessionID: string, assistantMessageID: string, terminal: "succeeded" | "failed" | ["interrupted", string]) {
emit("session.execution.started", { sessionID })
emit("session.step.started", { sessionID, agent: "build", model, assistantMessageID, started: sequence })
if (terminal === "succeeded") {
const tokens = { input: 120, output: 7, reasoning: 0, cache: { read: 0, write: 0 } }
emit("session.step.ended", { sessionID, assistantMessageID, finish: "stop", rawFinish: "stop", cost: 0, tokens })
emit("session.usage.updated", { sessionID, cost: 0, tokens })
emit("session.execution.succeeded", { sessionID })
} else if (terminal === "failed") {
emit("session.step.failed", { sessionID, assistantMessageID, error: failError })
emit("session.execution.failed", { sessionID, error: failError })
} else {
emit("session.step.failed", { sessionID, assistantMessageID, error: { type: "aborted", message: "Step interrupted" } })
emit("session.execution.interrupted", { sessionID, reason: terminal[1] })
}
}
const session = (id: string) => sessions().get(instanceId)?.get(id)
const doneNotifications = () => getToastHistory().filter((item) => item.message === tGlobal("sessionEvents.notify.done", { label: "Root work" }))
async function until(check: () => boolean, label: string) {
for (let attempt = 0; attempt < 200 && !check(); attempt++) await new Promise((resolve) => setTimeout(resolve, 2))
assert.ok(check(), label)
}
/** Waits until reconciliation has settled the session, then for any trailing effects. */
async function settled(id: string) {
await until(() => session(id)?.status === "idle", `${id} settles idle`)
await new Promise((resolve) => setTimeout(resolve, 10))
}
describe("V2 session lifecycle signals", () => {
beforeEach(() => {
sequence = 0
active = {}
toasts.length = 0
dismissed.length = 0
toast.custom = ((render: unknown) => { toasts.push(render); return `toast-${toasts.length}` }) as typeof toast.custom
toast.dismiss = ((id?: string) => { if (id) dismissed.push(id) }) as typeof toast.dismiss
clearToastHistory()
const client = {
session: {
active: async () => active,
get: async () => { throw new Error("fixture sessions are preloaded") },
},
message: { list: async () => ({ data: [], cursor: {} }) },
} as any
;(sdkManager as any).clients.set(`${instanceId}:/workspaces/${instanceId}/instance`, client)
addInstance({ id: instanceId, folder: "/work", port: 0, pid: 0, proxyPath: "", status: "ready", client })
const row = (id: string, parentId: string | null, title: string) => [id, {
id, instanceId, parentId, title, agent: "build", model: { providerId: "fixture", modelId: "fixture" },
status: "idle", retry: null, idleSince: null, generationRecovery: null, runtimeStatusKnown: true,
projectID: "project", location: { directory: "/work" }, time: { created: 1, updated: 1 },
}] as const
setSessions((previous) => new Map(previous).set(instanceId, new Map([
row("root", null, "Root work"), row("child", "root", "Child work"),
] as any)))
})
afterEach(() => {
toast.custom = originalToast
toast.dismiss = originalDismiss
clearToastHistory()
destroyOpenCodeData(instanceId)
if (messageStoreBus.getInstance(instanceId)) messageStoreBus.unregisterInstance(instanceId)
setSessions((previous) => { const next = new Map(previous); next.delete(instanceId); return next })
clearInstanceDeletedSessionAuthority(instanceId)
removeInstance(instanceId, { authoritative: false })
sdkManager.destroyClientsForInstance(instanceId)
})
it("notifies once when a root session's execution succeeds, never while it is still active", async () => {
active = { root: {} }
run("root", "msg_1", "succeeded")
await new Promise((resolve) => setTimeout(resolve, 20))
assert.equal(session("root")?.status, "working", "a terminal event for a still-active session is stale")
assert.equal(doneNotifications().length, 0)
active = {}
run("root", "msg_2", "succeeded")
await settled("root")
assert.equal(doneNotifications().length, 1)
assert.equal(doneNotifications()[0].title, "work")
// A legacy declaration must not produce a second notification for the same run.
emit("session.idle", { sessionID: "root" })
await new Promise((resolve) => setTimeout(resolve, 10))
assert.equal(doneNotifications().length, 1)
})
it("does not notify for subsessions", async () => {
run("child", "msg_child", "succeeded")
await settled("child")
assert.equal(getToastHistory().length, 0)
})
it("notifies when a run fails or is stopped for inactivity, not when a person stops it", async () => {
run("root", "msg_fail", "failed")
await settled("root")
assert.equal(doneNotifications().length, 1, "a failed run has ended without the user")
run("root", "msg_user", ["interrupted", "user"])
await settled("root")
assert.equal(session("root")?.generationRecovery, "interrupted")
assert.equal(doneNotifications().length, 1, "a deliberate stop needs no completion notice")
run("root", "msg_idle", ["interrupted", "inactivity"])
await settled("root")
assert.equal(doneNotifications().length, 2, "OpenCode stopped an unattended run")
// A shutdown keeps the turn's claim: it resumes after restart rather than finishing.
run("root", "msg_shutdown", ["interrupted", "shutdown"])
await settled("root")
assert.equal(doneNotifications().length, 2, "a shutdown interruption is not a completion")
})
it("stays silent for a root session that completes before its metadata is hydrated", async () => {
let release!: () => void
const hydrated = new Promise<void>((resolve) => { release = resolve })
;(sdkManager as any).clients.get(`${instanceId}:/workspaces/${instanceId}/instance`).session.get = async () => {
await hydrated
return { id: "late", title: "Late work", version: "1", projectID: "project", location: { directory: "/work" }, time: { created: 1, updated: 1 } }
}
run("late", "msg_late", "succeeded")
await new Promise((resolve) => setTimeout(resolve, 20))
release()
await settled("late")
assert.equal(session("late")?.parentId, null, "hydration reveals a root session")
assert.equal(getToastHistory().length, 0, "an unhydrated session may be a subagent: never announce it")
})
it("shows native retry backoff as session retry status and a live toast that ends with its attempt", async () => {
emit("session.execution.started", { sessionID: "root" })
emit("session.step.started", { sessionID: "root", agent: "build", model, assistantMessageID: "msg_retry", started: 1 })
const at = Date.now() + 5_000
emit("session.retry.scheduled", { sessionID: "root", assistantMessageID: "msg_retry", attempt: 2, at, error: retryError })
assert.equal(session("root")?.status, "working")
assert.deepEqual(session("root")?.retry, { attempt: 2, message: "fixture retry", next: at })
assert.equal(toasts.length, 1, "one transient retry toast")
// Another session's work leaves this session's countdown alone.
emit("session.step.started", { sessionID: "child", agent: "build", model, assistantMessageID: "msg_other", started: 2 })
assert.deepEqual(dismissed, [])
// The next attempt starts streaming: the backoff and its countdown are over.
emit("session.step.started", { sessionID: "root", agent: "build", model, assistantMessageID: "msg_retry", started: 3 })
assert.equal(session("root")?.retry, null)
assert.equal(session("root")?.status, "working")
assert.deepEqual(dismissed, ["toast-1"])
emit("session.retry.scheduled", { sessionID: "root", assistantMessageID: "msg_retry", attempt: 3, at, error: retryError })
emit("session.retry.scheduled", { sessionID: "root", assistantMessageID: "msg_retry", attempt: 4, at, error: retryError })
assert.equal(session("root")?.retry?.attempt, 4)
assert.equal(toasts.length, 3)
assert.deepEqual(dismissed, ["toast-1", "toast-2"], "a newer attempt replaces the live toast")
emit("session.execution.failed", { sessionID: "root", error: retryError })
await settled("root")
assert.equal(session("root")?.retry, null, "settlement clears retry state")
assert.deepEqual(dismissed, ["toast-1", "toast-2", "toast-3"], "and ends the countdown")
})
})

View file

@ -4,7 +4,7 @@ import { describe, it } from "node:test"
import { sdkManager } from "../lib/sdk-manager.ts"
import { addInstance, handleInstanceInvalidation, removeInstance } from "./instances.ts"
import { messageStoreBus } from "./message-v2/bus.ts"
import { handleNativeSessionEvent, handleSessionStatus, handleSessionUpdate } from "./session-events.ts"
import { handleNativeSessionEvent, handleSessionUpdate } from "./session-events.ts"
import { clearInstanceDeletedSessionAuthority, messagesLoaded, sessions, setActiveSession, setMessagesLoaded, setSessions } from "./session-state.ts"
const delay = (duration: number) => new Promise<void>((resolve) => setTimeout(resolve, duration))
@ -197,6 +197,7 @@ describe("native session event reducer", () => {
const gate = new Promise<void>((resolve) => { release = resolve })
const client = {
session: {
active: async () => ({}),
get: async () => {
await gate
return {
@ -215,9 +216,13 @@ describe("native session event reducer", () => {
durable: { aggregateID: sessionId, seq: 1, version: 1 },
data: { sessionID: sessionId }, location: { directory: "/work" },
})
handleSessionStatus(instanceId, {
type: "session.status", data: { sessionID: sessionId, status: { type: "idle" } },
} as any)
handleNativeSessionEvent(instanceId, {
id: "succeeded", created: 2, type: "session.execution.succeeded",
durable: { aggregateID: sessionId, seq: 2, version: 1 },
data: { sessionID: sessionId }, location: { directory: "/work" },
})
// Settlement is confirmed while the session's metadata is still hydrating.
await delay(10)
release()
await delay(20)

View file

@ -87,7 +87,7 @@ for (const ordering of ["UI-first", "SDK-first"] as const) {
native("session.reasoning.started", { assistantMessageID: "m399" })
native("session.reasoning.delta", { assistantMessageID: "m399", ordinal: 0, delta: "remove" })
const oldProjection = applyOpenCodeDataEvent(instanceId, "/work", {
id: "old", type: "session.idle", created: 4, data: { sessionID: sessionId },
id: "evt_old", type: "session.execution.succeeded", created: 4, data: { sessionID: sessionId },
} as any)
await loadMessages(instanceId, sessionId, { force: true })
assert.deepEqual(store.getSessionMessageIds(sessionId), latestIds)
@ -135,16 +135,25 @@ for (const ordering of ["UI-first", "SDK-first"] as const) {
// A previously captured/throttled callback cannot resurrect deleted parts.
projectOpenCodeMessages(instanceId, sessionId, oldProjection, false)
native("session.idle")
assert.deepEqual(store.getSessionMessageIds(sessionId), latestIds)
// Nothing stays parked (an inbox item would defer retirement), so settlement
// releases the reconciled reducer after projecting its native idle record.
native("session.inbox.cancelled", { inboxID: "resynced" })
const probe = () => applyOpenCodeDataEvent(instanceId, "/work", {
id: `evt_probe_${++eventId}`, type: "permission.replied", created: eventId, data: { sessionID: sessionId, requestID: "missing" },
} as any)
const reconciled = probe()
native("session.execution.succeeded")
const idleRecord = `e${eventId}`
assert.deepEqual(store.getSessionMessageIds(sessionId), [...latestIds, idleRecord].slice(-200))
assert.ok(latestIds.every(id => !hasReasoning(id)))
assert.notEqual(probe(), reconciled, "settlement retires the reconciled reducer")
await loadMoreMessages(instanceId, sessionId)
assert.deepEqual(requests.at(-1), { limit: 200, cursor: "after200" })
assert.deepEqual(store.getSessionMessageIds(sessionId), olderIds)
assert.equal(store.getMessageWindow(sessionId)?.olderCursor, undefined)
assert.deepEqual([...olderIds, ...latestIds], messages.map(message => message.id), "no skipped 180-message gap")
native("session.idle")
native("session.execution.succeeded")
assert.deepEqual(store.getSessionMessageIds(sessionId), olderIds, "live projection does not replace a history page")
await loadNewerMessageWindow(instanceId, sessionId)
assert.deepEqual(store.getSessionMessageIds(sessionId), latestIds)

View file

@ -1,7 +1,7 @@
import assert from "node:assert/strict"
import { test } from "node:test"
import { setTimeout as delay } from "node:timers/promises"
import { applyOpenCodeDataEvent, destroyOpenCodeData, invalidateOpenCodeSessionContent, projectOpenCodeMessages } from "./opencode-data"
import { applyOpenCodeDataEvent, destroyOpenCodeData, finishOpenCodeDataEvent, invalidateOpenCodeSessionContent, projectOpenCodeMessages } from "./opencode-data"
import { getRootClient } from "./opencode-client"
import { messageStoreBus } from "./message-v2/bus"
import { sdkManager } from "../lib/sdk-manager"
@ -20,25 +20,34 @@ test("the SDK projection cannot resurrect deleted parts on the next native event
client.permission.list = async () => []
client.session.form.list = async () => []
client.message.list = async () => ({ data: [current], cursor: {} }) as any
let synced = false
const apply = (type: string, data: object) => applyOpenCodeDataEvent(instanceId, "/work", {
id: type, type, data, created: 1,
} as any, undefined, data => { projectOpenCodeMessages(instanceId, sessionID, data); synced = true })
let synced = false, sequence = 0
const event = (type: string, data: object) => ({ id: `evt_${type}_${++sequence}`, type, data, created: sequence } as any)
const apply = (type: string, data: object) => applyOpenCodeDataEvent(instanceId, "/work", event(type, data),
undefined, data => { projectOpenCodeMessages(instanceId, sessionID, data); synced = true })
const reasoningParts = () => Object.values(messageStoreBus.getOrCreate(instanceId).getMessage("m")!.parts)
.filter(part => part.data.type === "reasoning")
try {
apply("session.step.started", { sessionID, assistantMessageID: "m", agent: "build", model: { providerID: "p", id: "m" } })
apply("session.reasoning.started", { sessionID, assistantMessageID: "m" })
const old = apply("session.reasoning.delta", { sessionID, assistantMessageID: "m", ordinal: 0, delta: "remove" })
assert(old.session.message.get(sessionID, "m")?.type === "assistant")
invalidateOpenCodeSessionContent(instanceId, sessionID)
const pending = apply("session.idle", { sessionID })
// OpenCode V2 settles the run with a terminal execution event while repair is pending.
const pending = apply("session.execution.succeeded", { sessionID })
assert.notEqual(pending, old)
assert.deepEqual(pending.session.message.list(sessionID), [])
for (let i = 0; i < 100 && !synced; i++) await delay(10)
assert(synced, "authoritative resync completed")
const next = apply("session.idle", { sessionID })
// The production order: reduce, project the page, then let settlement retire the reducer.
const settled = event("session.execution.succeeded", { sessionID })
const next = applyOpenCodeDataEvent(instanceId, "/work", settled)
projectOpenCodeMessages(instanceId, sessionID, next)
const parts = Object.values(messageStoreBus.getOrCreate(instanceId).getMessage("m")!.parts)
assert(parts.every(part => part.data.type !== "reasoning"))
finishOpenCodeDataEvent(instanceId, settled)
assert.deepEqual(reasoningParts(), [])
const fresh = apply("session.step.started", { sessionID, assistantMessageID: "m2", agent: "build", model: { providerID: "p", id: "m" } })
assert.notEqual(fresh, next, "settlement retires the reconciled reducer")
projectOpenCodeMessages(instanceId, sessionID, fresh)
assert.deepEqual(reasoningParts(), [], "the next run's reducer cannot resurrect pruned parts")
} finally {
destroyOpenCodeData(instanceId)
sdkManager.destroyClientsForInstance(instanceId)

View file

@ -1495,7 +1495,7 @@ describe("session request authority", () => {
})
for (const mutation of ["cancel", "revert"] as const) {
it(`rejects pre-${mutation} history after idle disposes the reducer`, async () => {
it(`rejects pre-${mutation} history after settlement disposes the reducer`, async () => {
const instanceId = `stale-${mutation}-idle-page`, sessionId = "session"
const { client, cleanup } = setup(instanceId)
const stale = deferred<any>()
@ -1514,10 +1514,10 @@ describe("session request authority", () => {
? { id: "mutation", type: "session.inbox.cancelled", created: 2, data: { sessionID: sessionId, inboxID: "deleted" } } as any
: { id: "mutation", type: "session.revert.committed", created: 2, data: { sessionID: sessionId, to: "deleted" } } as any)
const revision = getOpenCodeMutationRevision(instanceId, sessionId)
const idle = { id: "idle", type: "session.idle", created: 3, data: { sessionID: sessionId } } as any
const data = applyOpenCodeDataEvent(instanceId, "/work", idle)
const settled = { id: "evt_settled", type: "session.execution.succeeded", created: 3, data: { sessionID: sessionId } } as any
const data = applyOpenCodeDataEvent(instanceId, "/work", settled)
projectOpenCodeMessages(instanceId, sessionId, data)
finishOpenCodeDataEvent(instanceId, idle)
finishOpenCodeDataEvent(instanceId, settled)
assert.equal(getOpenCodeMutationRevision(instanceId, sessionId), revision)
const next = applyOpenCodeDataEvent(instanceId, "/work", {
id: "next", type: "permission.replied", created: 4, data: { sessionID: sessionId, requestID: "missing" },
@ -1526,7 +1526,8 @@ describe("session request authority", () => {
stale.resolve({ data: [apiMessage("deleted")], cursor: { next: "newer" } })
assert.equal(await request, false)
const store = messageStoreBus.getOrCreate(instanceId)
assert.deepEqual(store.getSessionMessageIds(sessionId), ["latest"])
// The settlement's native idle record is projected; the pre-mutation page is not.
assert.deepEqual(store.getSessionMessageIds(sessionId), ["latest", "msg_settled"])
assert.equal(store.getMessage("deleted"), undefined)
assert.equal(await loadOldestMessageWindow(instanceId, sessionId), true)
assert.deepEqual(store.getSessionMessageIds(sessionId), ["survivor"])

View file

@ -115,8 +115,6 @@ import {
handleSessionCompacted,
handleSessionDeleted,
handleSessionError,
handleSessionIdle,
handleSessionStatus,
handleSessionUpdate,
handleTuiToast,
} from "./session-events"
@ -126,8 +124,6 @@ sseManager.onSessionUpdate = handleSessionUpdate
sseManager.onSessionCompacted = handleSessionCompacted
sseManager.onSessionDeleted = handleSessionDeleted
sseManager.onSessionError = handleSessionError
sseManager.onSessionIdle = handleSessionIdle
sseManager.onSessionStatus = handleSessionStatus
sseManager.onTuiToast = handleTuiToast
sseManager.onPermissionUpdated = handlePermissionUpdated
sseManager.onPermissionReplied = handlePermissionReplied

View file

@ -220,21 +220,6 @@
margin-bottom: 1px;
}
.message-step-start {
background-color: var(--message-assistant-bg);
border-inline-start: 1px solid var(--message-assistant-border);
margin-top: 0;
}
.message-step-finish {
background-color: var(--message-assistant-bg);
border-inline-start: 1px solid var(--message-assistant-border);
border-block-start: none;
border-block-end: none;
margin: 0;
padding-top: 0;
}
.message-step-finish-flush {
margin-top: -0.125rem;
}
@ -273,11 +258,6 @@
margin-inline-end: 0.35rem;
}
.message-step-heading {
@apply flex flex-wrap items-center gap-2 text-xs;
color: var(--text-muted);
}
.message-queued-badge {
@apply inline-flex items-center font-bold px-3 py-1 text-xs tracking-wide;
align-self: center;
@ -456,11 +436,6 @@
color: var(--color-danger);
}
.message-step-start {
background-color: var(--message-assistant-bg);
border-inline-start: 1px solid var(--message-assistant-border);
}
.message-step-finish {
background-color: var(--message-assistant-bg);
border-inline-start: 1px solid var(--message-assistant-border);
@ -469,60 +444,6 @@
padding-top: 0;
}
.message-step-heading {
@apply flex flex-wrap items-center gap-2 text-xs;
color: var(--text-muted);
}
.message-step-title {
font-weight: var(--font-weight-semibold);
color: var(--text-primary);
@apply flex items-center justify-between w-full;
}
.message-step-title-left {
@apply flex items-center gap-2 text-[var(--text-muted)];
flex: 1 1 auto;
min-width: 0;
}
.message-step-title-left span:last-child {
@apply font-medium text-[11px];
}
.message-step-time {
@apply text-[11px] text-[var(--text-muted)] font-normal ms-auto;
}
.message-step-right {
display: inline-flex;
align-items: center;
gap: 0.25rem;
margin-inline-start: auto;
}
.message-step-right .message-step-time {
margin-inline-start: 0;
}
.message-step-meta-inline {
@apply inline-flex items-center text-[11px] font-medium;
color: var(--message-assistant-border);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.message-step-reason {
@apply text-[11px] font-medium;
color: var(--text-muted);
}
.message-step-finish-spacer {
@apply mt-4;
}
.message-reasoning-card {
--reasoning-border-color: var(--border-strong, var(--border-base));
--message-action-right-inset: 0.75rem;
@ -644,15 +565,6 @@
flex: 0 0 auto;
}
.message-step-meta-inline--measure {
position: fixed;
left: -9999px;
top: -9999px;
visibility: hidden;
pointer-events: none;
white-space: nowrap;
}
.message-reasoning-meta-row {
padding: 0 0.6rem 0.15rem 0.6rem;
}

View file

@ -27,9 +27,6 @@ interface CompactionMessagePart extends PartBase {
model?: ModelRef
providerState?: SessionMessageProviderState
}
interface StepStartMessagePart extends PartBase { type: "step-start" }
interface StepFinishMessagePart extends PartBase { type: "step-finish" }
export type NormalizedMessagePart =
| TextMessagePart
| SystemMessagePart
@ -38,8 +35,6 @@ export type NormalizedMessagePart =
| SkillMessagePart
| ToolMessagePart
| CompactionMessagePart
| StepStartMessagePart
| StepFinishMessagePart
export interface RenderCache {
/** Source the HTML was rendered from, for caches that compare it (Markdown keys on its version instead). */

View file

@ -4,7 +4,7 @@ import type {
ModelInfo as SDKModel,
ProviderInfo as SDKProvider,
SessionInfo as SDKSession,
SessionStatus as SDKSessionStatus,
SessionRetryScheduled,
} from "@opencode/client"
import type { GenerationRecoveryState } from "../stores/session-generation-recovery"
@ -41,25 +41,9 @@ export function getIdleSinceForStatusTransition(
return previousIdleSince ?? null
}
export function mapSdkSessionStatus(status: SDKSessionStatus | null | undefined): SessionStatus {
if (!status || status.type === "idle") {
return "idle"
}
// "busy" and "retry" both mean there's active work.
return "working"
}
export function mapSdkSessionRetry(status: SDKSessionStatus | null | undefined): SessionRetryState | null {
if (!status || status.type !== "retry") {
return null
}
return {
attempt: typeof status.attempt === "number" ? status.attempt : 1,
message: typeof status.message === "string" ? status.message : "",
next: typeof status.next === "number" ? status.next : Date.now(),
}
/** Native provider backoff: the attempt that will run at `at` after `error`. */
export function mapNativeSessionRetry(data: SessionRetryScheduled["data"]): SessionRetryState {
return { attempt: data.attempt, message: data.error.message, next: data.at }
}
// Our client-specific Session interface extending SDK Session

View file

@ -33,4 +33,4 @@ render(() => <ConfigProvider><I18nProvider><ThemeProvider><Show when={visible()}
: <main style={{ width: "min(760px, 100%)" }}><UsageSettingsSection instanceId={instanceId()} /></main>}
</Show></ThemeProvider></I18nProvider></ConfigProvider>, document.getElementById("root")!)
;(window as any).fixture = { queries, setInstanceId, setVisible, fail: () => { fail = true }, defer: () => { defer = true }, release: () => release?.(),
refresh: () => (serverEvents as any).dispatch({ type: "instance.event", instanceId: "usage", event: { type: "session.idle", data: {} } }) }
refresh: (type = "session.execution.succeeded") => (serverEvents as any).dispatch({ type: "instance.event", instanceId: "usage", event: { type, data: { sessionID: "s" } } }) }

View file

@ -0,0 +1,78 @@
import { render } from "solid-js/web"
import SessionView from "../../../src/components/session/session-view"
import TranscriptFilters from "../../../src/components/transcript-filters"
import { ConfigProvider, updatePreferences } from "../../../src/stores/preferences"
import { I18nProvider } from "../../../src/lib/i18n"
import { ThemeProvider } from "../../../src/lib/theme"
import { serverApi } from "../../../src/lib/api-client"
import { sdkManager } from "../../../src/lib/sdk-manager"
import { addInstance } from "../../../src/stores/instances"
import { sessions, setActiveSession, setProviders } from "../../../src/stores/session-state"
import { fetchSessions } from "../../../src/stores/session-api"
import { sseManager } from "../../../src/lib/sse-manager"
import "../../../src/index.css"
// OpenCode V2 records each model step as one assistant message carrying the
// step's own token usage and cost; there are no step-start/step-finish parts.
const instanceId = "usage-instance", sessionId = "usage-session"
const model = { providerID: "fixture", id: "fixture" }
const usage = (input: number, output: number) => ({ input, output, reasoning: 5, cache: { read: 800, write: 16 } })
const nativeMessages: any[] = [
{ id: "msg_01", type: "user", text: "Count the files", time: { created: 1 } },
{ id: "msg_02", type: "assistant", agent: "build", model, finish: "tool-calls", cost: 0.0123, tokens: usage(1200, 34),
content: [{ type: "text", text: "Looking at the tree" }, { type: "tool", id: "call_read", name: "read", time: { created: 2 },
state: { status: "completed", input: { path: "a.ts" }, content: [{ type: "text", text: "file" }] } }],
time: { created: 2, completed: 3 } },
{ id: "msg_03", type: "assistant", agent: "build", model, finish: "stop", cost: 0.5, tokens: usage(1300, 12),
content: [{ type: "text", text: "There are three files" }], time: { created: 4, completed: 5 } },
{ id: "msg_04", type: "idle", outcome: "succeeded", time: { created: 6 } },
]
const info = { id: sessionId, title: "Usage fixture", agent: "build", model, projectID: "fixture", location: { directory: "/fixture" },
cost: 0, tokens: usage(0, 0), time: { created: 1, updated: 6 } }
const client: any = {
session: { active: async () => ({}), inbox: { list: async () => ({ data: [] }) },
list: async () => ({ data: [info], cursor: {} }), get: async () => info },
model: { default: async () => model },
message: { list: async ({ order }: { order?: string }) => ({
data: order === "asc" ? [...nativeMessages] : [...nativeMessages].reverse(), cursor: {},
}) },
}
let config: any = { settings: { locale: "en" } }
serverApi.fetchConfigOwner = async () => config
serverApi.patchConfigOwner = async (_owner, patch: any) => {
config = { ...config, ...patch, settings: { ...config.settings, ...patch.settings } }
return config
}
serverApi.fetchStateOwner = async () => ({} as any)
;(sdkManager as any).clients.set(`${instanceId}:/workspaces/${instanceId}/instance`, client)
addInstance({ id: instanceId, folder: "/fixture", port: 0, pid: 0, proxyPath: "", status: "ready", client })
setProviders(prev => new Map(prev).set(instanceId, [{ id: "fixture", name: "Fixture", models: [{ id: "fixture", name: "Fixture", providerId: "fixture", limit: { context: 10000, output: 1000 }, cost: { input: 0, output: 0 } }] }]))
await fetchSessions(instanceId)
setActiveSession(instanceId, sessionId)
render(() => <ConfigProvider><I18nProvider><ThemeProvider>
<div style={{ display: "flex", "flex-direction": "column", height: "100vh", width: "900px" }}>
<header><TranscriptFilters /></header>
<div style={{ display: "flex", flex: 1, "min-height": 0 }}>
<SessionView sessionId={sessionId} activeSessions={sessions().get(instanceId)!} instanceId={instanceId} instanceFolder="/fixture" escapeInDebounce={false} isActive={true} />
</div>
</div>
</ThemeProvider></I18nProvider></ConfigProvider>, document.getElementById("root")!)
await updatePreferences({ locale: "en" })
let seq = 0
const emit = (type: string, data: Record<string, unknown>) => (sseManager as any).handleEvent(instanceId, {
id: `evt_live_${++seq}`, type, created: 10 + seq, durable: { aggregateID: sessionId, seq, version: 1 },
location: { directory: "/fixture" }, data: { sessionID: sessionId, ...data },
})
;(window as any).fixture = {
// The live V2 step sequence recorded from an isolated OpenCode 2.1.0 run.
startStep: () => {
emit("session.execution.started", {})
emit("session.step.started", { assistantMessageID: "msg_live", agent: "build", model, started: 11 })
emit("session.text.started", { assistantMessageID: "msg_live" })
emit("session.text.delta", { assistantMessageID: "msg_live", ordinal: 0, delta: "Live answer" })
},
endStep: () => {
emit("session.text.ended", { assistantMessageID: "msg_live", text: "Live answer" })
emit("session.step.ended", { assistantMessageID: "msg_live", finish: "stop", rawFinish: "stop", cost: 0, tokens: usage(4200, 99) })
},
}

View file

@ -35,6 +35,27 @@ test("service-wide usage dashboard declares scope, bounds requests, fences stale
} finally { await page.close(); await fixture.close() }
})
test("every settled native execution refreshes the dashboard", async () => {
const fixture = await startProductFixture("service-usage"), page = await fixture.browser.newPage({ viewport: { width: 760, height: 1000 }, locale: "en-US" })
const errors: string[] = []; page.on("pageerror", error => errors.push(error.message))
await page.route("**/api/**", route => route.fulfill({ contentType: "application/json", body: "{}" }))
try {
await page.goto(fixture.url)
await page.locator(".usage-metrics").waitFor()
const queries = () => page.evaluate(() => (window as any).fixture.queries.length as number)
const initial = await queries()
// OpenCode V2 settles runs with session.execution.*; it never publishes session.idle.
for (const [index, type] of ["session.execution.succeeded", "session.execution.failed", "session.execution.interrupted"].entries()) {
await page.evaluate(type => (window as any).fixture.refresh(type), type)
await page.waitForFunction(count => (window as any).fixture.queries.length === count, initial + index + 1)
}
await page.evaluate(() => (window as any).fixture.refresh("session.text.delta"))
await page.waitForTimeout(100)
assert.equal(await queries(), initial + 3, "streaming deltas never refresh durable statistics")
assert.deepEqual(errors, [])
} finally { await page.close(); await fixture.close() }
})
test("Stats is reachable in the real preferences screen", async () => {
const fixture = await startProductFixture("service-usage"), page = await fixture.browser.newPage({ viewport: { width: 1100, height: 900 }, locale: "en-US" })
const errors: string[] = []; page.on("pageerror", error => errors.push(error.message))

View file

@ -0,0 +1,93 @@
import assert from "node:assert/strict"
import { after, before, test } from "node:test"
import { fileURLToPath } from "node:url"
import { chromium, type Browser, type Page } from "playwright"
import { createServer, type ViteDevServer } from "vite"
import solid from "vite-plugin-solid"
import { createFixtureCache } from "./fixture-cache"
import { createFixtureShutdown } from "./fixture-shutdown"
let server: ViteDevServer, browser: Browser, url: string
before(async () => {
const cache = await createFixtureCache(), shutdown = createFixtureShutdown(cache)
try {
server = await createServer({
configFile: false, root: fileURLToPath(new URL("../..", import.meta.url)), logLevel: "error", cacheDir: cache.cacheDir,
plugins: [shutdown.plugin, solid(), { name: "usage-metrics", configureServer(s) {
s.middlewares.use("/fixture", async (_req, res) => {
res.setHeader("Content-Type", "text/html")
res.end(await s.transformIndexHtml("/fixture", '<html><body><div id="root"></div><script type="module" src="/tests/browser/fixtures/usage-metrics.tsx"></script></body></html>'))
})
} }], resolve: { dedupe: ["solid-js"] }, optimizeDeps: { exclude: ["lucide-solid"] },
server: { host: "127.0.0.1", port: 0, hmr: false, watch: null },
})
shutdown.own(server)
await server.listen()
url = `http://127.0.0.1:${(server.httpServer!.address() as { port: number }).port}/fixture`
browser = await chromium.launch({ executablePath: process.env.CODENOMAD_BROWSER_PATH || undefined })
} catch (error) { if (server) await server.close(); else await cache.dispose(); throw error }
})
after(async () => { try { await browser?.close() } finally { await server?.close() } })
async function open(run: (page: Page) => Promise<void>) {
const page = await browser.newPage({ viewport: { width: 1000, height: 800 }, locale: "en-US" })
const errors: string[] = []
page.on("pageerror", error => errors.push(error.message))
try {
await page.route("**/api/**", route => route.fulfill({ contentType: "application/json", body: "{}" }))
await page.goto(url)
await page.waitForFunction(() => Boolean((window as any).fixture))
await page.getByText("There are three files", { exact: true }).waitFor()
await run(page)
assert.deepEqual(errors, [])
} finally { await page.close() }
}
const usageLines = (page: Page) => page.locator(".message-step-usage-toggle")
const chips = (page: Page, messageId: string) => page.locator(`[data-message-id="${messageId}"] .message-step-usage-chip`)
.evaluateAll(nodes => nodes.map(node => `${node.getAttribute("data-label")} ${node.textContent}`))
test("each completed V2 step shows its own compact token and cost line", async () => open(async page => {
await usageLines(page).first().waitFor()
assert.equal(await usageLines(page).count(), 2, "one line per completed assistant step, none for the idle record")
assert.deepEqual(await chips(page, "msg_02"), ["Input 1K", "Output 34", "Cost $0.01"])
assert.deepEqual(await chips(page, "msg_03"), ["Input 1K", "Output 12", "Cost $0.50"])
assert.equal(await page.locator('[data-message-id="msg_02"] .message-step-usage-toggle').getAttribute("aria-expanded"), "false")
// The line keeps the former step-finish geometry: flush under its step, no
// block borders, and a message border only when it follows text, not tools.
const geometry = (messageId: string) => page.locator(`[data-message-id="${messageId}"] .message-step-finish`).evaluate(element => {
const style = getComputedStyle(element)
return [style.marginTop, style.marginBottom, style.paddingTop, style.borderTopWidth, style.borderBottomWidth, style.borderLeftWidth]
})
assert.deepEqual(await geometry("msg_03"), ["-2px", "0px", "0px", "0px", "0px", "1px"])
assert.deepEqual(await geometry("msg_02"), ["-2px", "0px", "0px", "0px", "0px", "0px"])
await page.locator('[data-message-id="msg_03"] .message-step-usage-toggle').click()
assert.deepEqual(await chips(page, "msg_03"), [
"Input 1K", "Output 12", "Reasoning 5", "Cache Read 800", "Cache Write 16", "Cost $0.50",
])
assert.equal(await page.locator('[data-message-id="msg_03"] .message-step-usage-toggle').getAttribute("aria-expanded"), "true")
}))
test("the Tokens filter hides, collapses and expands every usage line", async () => open(async page => {
await usageLines(page).first().waitFor()
await page.getByRole("button", { name: "Message content", exact: true }).click()
const row = page.getByRole("group", { name: "Tokens", exact: true })
await row.getByRole("button", { name: "Expand Tokens", exact: true }).click()
await page.waitForFunction(() => document.querySelectorAll(".message-step-usage-chip").length === 12)
await row.getByRole("button", { name: "Hide Tokens", exact: true }).click()
await page.waitForFunction(() => !document.querySelector(".message-step-usage-toggle"))
await page.getByText("There are three files", { exact: true }).waitFor()
await row.getByRole("button", { name: "Show Tokens", exact: true }).click()
await page.waitForFunction(() => document.querySelectorAll(".message-step-usage-toggle").length === 2)
}))
test("a live step gains its usage line when native step settlement reports it", async () => open(async page => {
await usageLines(page).first().waitFor()
await page.evaluate(() => (window as any).fixture.startStep())
await page.getByText("Live answer", { exact: true }).waitFor()
assert.equal(await page.locator('[data-message-id="msg_live"] .message-step-usage-toggle').count(), 0, "no usage before the step ends")
await page.evaluate(() => (window as any).fixture.endStep())
await page.locator('[data-message-id="msg_live"] .message-step-usage-toggle').waitFor()
assert.deepEqual(await chips(page, "msg_live"), ["Input 4K", "Output 99", "Cost $0.00"])
}))

View file

@ -0,0 +1,168 @@
// Explicit isolated native capture of the session lifecycle signals CodeNomad
// consumes: completion, provider retry, failure and interruption. Records the
// real event stream and asserts that terminal/retry state arrives through
// session.execution.* and session.retry.scheduled, never the deprecated V1
// session.idle/session.status declarations. No service discovery or real provider.
import assert from "node:assert/strict"
import { spawn } from "node:child_process"
import { randomUUID } from "node:crypto"
import { createServer } from "node:http"
import { mkdtemp, mkdir, writeFile } from "node:fs/promises"
import os from "node:os"
import path from "node:path"
import { setTimeout as delay } from "node:timers/promises"
import { OpenCode } from "@opencode/client"
import { stopFixtureChild } from "./native-fixture-guards.mjs"
const cli = process.argv[2]
if (!cli || !path.isAbsolute(cli)) throw new Error("Pass an absolute CLI path; shared service discovery is forbidden")
const temporary = path.join(os.tmpdir(), "opencode")
await mkdir(temporary, { recursive: true })
const root = await mkdtemp(path.join(temporary, "lifecycle-native-"))
const env = Object.fromEntries(Object.entries(process.env).filter(([key]) => !/^(OPENCODE_|XDG_|npm_config_)/i.test(key)))
for (const key of ["XDG_DATA_HOME", "XDG_CONFIG_HOME", "XDG_STATE_HOME", "XDG_CACHE_HOME"]) env[key] = path.join(root, key)
if (process.platform === "win32") {
for (const key of Object.keys(env)) if (key.toLowerCase() === "programdata") delete env[key]
env.ProgramData = path.join(root, "ProgramData")
}
const password = randomUUID()
Object.assign(env, {
HOME: root, USERPROFILE: root, OPENCODE_TEST_HOME: root,
OPENCODE_CONFIG_DIR: path.join(root, "config"), OPENCODE_DB: path.join(root, "fixture.db"),
OPENCODE_SERVER_PASSWORD: password, OPENCODE_CONFIG_PROJECT_DISABLE: "1",
OPENCODE_DISABLE_MODELS_FETCH: "1", OPENCODE_DISABLE_FFF: "1",
})
await mkdir(env.OPENCODE_CONFIG_DIR)
await mkdir(path.join(root, "plugin"))
await writeFile(path.join(root, "plugin", "index.ts"), `export default { id: 'lifecycle-fixture', async setup(ctx) {
await ctx.session.hook('http.request', event => {
event.request.headers.set('x-fixture-kind', event.kind)
event.request.headers.set('x-fixture-session', event.sessionID)
})
} }`)
// Per-session provider behaviour, keyed by the session ID the plugin forwards.
const behaviours = new Map()
const requests = new Map()
let providerError
const provider = createServer(async (request, response) => {
try {
for await (const _chunk of request) { /* drain */ }
if (request.headers["x-fixture-kind"] !== "primary") {
response.setHeader("Content-Type", "application/json")
response.end(JSON.stringify({ id: "aux", choices: [{ message: { role: "assistant", content: "Fixture" }, finish_reason: "stop" }] }))
return
}
const sessionID = request.headers["x-fixture-session"]
const count = (requests.get(sessionID) ?? 0) + 1
requests.set(sessionID, count)
const behaviour = behaviours.get(sessionID) ?? "complete"
if ((behaviour === "retry" && count === 1) || behaviour === "fail") {
// 503 is retryable (ProviderInternal); 400 is a deterministic request failure.
response.statusCode = behaviour === "fail" ? 400 : 503
response.setHeader("Content-Type", "application/json")
response.end(JSON.stringify({ error: { message: `fixture ${behaviour}`, type: "fixture_error" } }))
return
}
response.setHeader("Content-Type", "text/event-stream")
const chunk = (delta, finish_reason = null, usage) => response.write(`data: ${JSON.stringify({
id: "fixture", object: "chat.completion.chunk", model: "fixture",
choices: [{ index: 0, delta, finish_reason }], ...(usage ? { usage } : {}),
})}\n\n`)
chunk({ role: "assistant", content: "FIXTURE_ANSWER" })
if (behaviour === "hold") return // Stays open until native interruption.
chunk({}, "stop", { prompt_tokens: 120, completion_tokens: 7, total_tokens: 127 })
response.end("data: [DONE]\n\n")
} catch (error) { providerError = error; response.destroy(error) }
})
await new Promise(resolve => provider.listen(0, "127.0.0.1", resolve))
env.OPENCODE_CONFIG_CONTENT = JSON.stringify({
model: "fixture/fixture", plugins: [path.join(root, "plugin")],
providers: { fixture: { package: "@opencode/ai/providers/openai-compatible", settings: {
baseURL: `http://127.0.0.1:${provider.address().port}/v1`, apiKey: "fixture-only",
}, models: { fixture: {} } } },
})
let output = ""
const child = spawn(cli, ["serve", "--hostname", "127.0.0.1", "--port", "0", "--print-logs"], { cwd: root, env, windowsHide: true })
const stopped = new Promise(resolve => child.once("close", resolve))
child.stdout.on("data", data => { output += data })
child.stderr.on("data", data => { output += data })
async function until(predicate, label) {
for (let attempt = 0; attempt < 1500; attempt++) {
if (providerError) throw providerError
if (await predicate()) return
if (child.exitCode !== null) throw new Error("Isolated native server exited")
await delay(20)
}
throw new Error(`Isolated condition timed out: ${label}`)
}
const events = []
const streams = new AbortController()
const TERMINAL = new Set(["session.execution.succeeded", "session.execution.failed", "session.execution.interrupted"])
const LIFECYCLE = /^session\.(execution\.|retry\.|step\.(started|ended|failed)$|usage\.updated$|status$|idle$)/
const forSession = (sessionID) => events.filter(event => event.data?.sessionID === sessionID)
const terminalOf = (sessionID) => forSession(sessionID).find(event => TERMINAL.has(event.type))
try {
await until(() => /http:\/\/127\.0\.0\.1:\d+/.test(output), "server start")
const client = OpenCode.make({ baseUrl: output.match(/http:\/\/127\.0\.0\.1:\d+/)[0], headers: {
Authorization: `Basic ${Buffer.from(`opencode:${password}`).toString("base64")}`,
} })
const runtime = (await client.server.info()).version
void (async () => {
try { for await (const event of client.event.subscribe({ signal: streams.signal })) events.push(event) } catch {}
})()
await until(() => events.some(event => event.type === "server.connected"), "event stream")
const capture = {}
for (const behaviour of ["complete", "retry", "fail", "hold"]) {
const session = await client.session.create({ location: { directory: root }, model: { providerID: "fixture", id: "fixture" } })
behaviours.set(session.id, behaviour)
await client.session.prompt({ sessionID: session.id, text: `Lifecycle fixture: ${behaviour}` })
if (behaviour === "hold") {
await until(() => forSession(session.id).some(event => event.type === "session.text.delta"), "held stream")
await client.session.interrupt({ sessionID: session.id, resume: false })
}
await until(() => terminalOf(session.id), `${behaviour} terminal`)
await delay(300) // Let trailing ephemeral events (usage, legacy status) arrive.
capture[behaviour] = forSession(session.id).filter(event => LIFECYCLE.test(event.type))
.map(event => ({ type: event.type, data: event.data }))
}
const all = events.map(event => event.type)
await writeFile(path.join(root, "lifecycle-events.json"), JSON.stringify({ runtime, capture, types: [...new Set(all)] }, null, 2))
console.log("Runtime", runtime)
for (const [behaviour, list] of Object.entries(capture)) {
console.log(`\n[${behaviour}]`)
for (const event of list) console.log(" ", event.type, JSON.stringify(event.data))
}
// The V1 declarations are never published by the V2 runtime.
assert(!all.includes("session.idle"), "session.idle must not be published")
assert(!all.includes("session.status"), "session.status must not be published")
assert.equal(terminalOf([...behaviours].find(([, value]) => value === "complete")[0]).type, "session.execution.succeeded")
assert.equal(terminalOf([...behaviours].find(([, value]) => value === "fail")[0]).type, "session.execution.failed")
const interrupted = terminalOf([...behaviours].find(([, value]) => value === "hold")[0])
assert.equal(interrupted.type, "session.execution.interrupted")
assert.equal(interrupted.data.reason, "user")
const retry = capture.retry.find(event => event.type === "session.retry.scheduled")
assert(retry, "a retryable provider failure schedules a native retry")
assert.equal(retry.data.attempt, 2)
assert.equal(typeof retry.data.at, "number")
assert.equal(typeof retry.data.error?.message, "string")
assert.equal(capture.retry.at(-1).type, "session.execution.succeeded")
const ended = capture.complete.find(event => event.type === "session.step.ended")
assert(ended && typeof ended.data.cost === "number" && ended.data.tokens?.input >= 0, "step end carries usage")
console.log(`\nPASS: V2 lifecycle captured (${runtime}); evidence ${path.join(root, "lifecycle-events.json")}`)
} catch (error) {
console.error("Evidence:", root)
throw error
} finally {
streams.abort()
await writeFile(path.join(root, "native.log"), output)
await stopFixtureChild(child, stopped)
provider.closeAllConnections()
await new Promise(resolve => provider.close(resolve))
}