mirror of
https://github.com/NeuralNomadsAI/CodeNomad.git
synced 2026-10-21 05:06:37 +00:00
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:
parent
debbdce33f
commit
73c0621d0d
34 changed files with 897 additions and 475 deletions
4
.github/workflows/pr-build.yml
vendored
4
.github/workflows/pr-build.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
82
packages/ui/src/components/message-usage.tsx
Normal file
82
packages/ui/src/components/message-usage.tsx
Normal 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)}`
|
||||
}
|
||||
|
|
@ -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} />
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
13
packages/ui/src/lib/native-execution-events.ts
Normal file
13
packages/ui/src/lib/native-execution-events.ts
Normal 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)
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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() }
|
||||
|
|
|
|||
|
|
@ -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() }
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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() }
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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") {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
198
packages/ui/src/stores/session-lifecycle-signals.test.ts
Normal file
198
packages/ui/src/stores/session-lifecycle-signals.test.ts
Normal 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")
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"])
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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). */
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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" } } }) }
|
||||
|
|
|
|||
78
packages/ui/tests/browser/fixtures/usage-metrics.tsx
Normal file
78
packages/ui/tests/browser/fixtures/usage-metrics.tsx
Normal 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) })
|
||||
},
|
||||
}
|
||||
|
|
@ -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))
|
||||
|
|
|
|||
93
packages/ui/tests/browser/usage-metrics.test.ts
Normal file
93
packages/ui/tests/browser/usage-metrics.test.ts
Normal 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"])
|
||||
}))
|
||||
168
scripts/test-session-lifecycle-native.mjs
Normal file
168
scripts/test-session-lifecycle-native.mjs
Normal 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))
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue