Compare commits

..

2 Commits

Author SHA1 Message Date
Kit Langton 1803aaf857 fix(tui): render submitted prompts optimistically 2026-07-11 13:25:10 -04:00
Kit Langton 00ab94c44f fix(tui): stabilize compaction completion (#36435) 2026-07-11 13:06:14 -04:00
19 changed files with 250 additions and 162 deletions
+1
View File
@@ -1003,6 +1003,7 @@
"@opencode-ai/client": "workspace:*",
"@opencode-ai/core": "workspace:*",
"@opencode-ai/plugin": "workspace:*",
"@opencode-ai/schema": "workspace:*",
"@opencode-ai/sdk": "workspace:*",
"@opencode-ai/simulation": "workspace:*",
"@opencode-ai/ui": "workspace:*",
-1
View File
@@ -378,7 +378,6 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
get: (input) => runtime.session.get(input.sessionID),
prompt: runtime.session.prompt,
command: runtime.session.command,
rename: runtime.session.rename,
interrupt: (input) => runtime.session.interrupt(input.sessionID),
},
} satisfies Plugin.Context
+2 -12
View File
@@ -70,8 +70,7 @@ export function fromPromise(plugin: Plugin) {
catalog: {
provider: {
list: (input) => run(host.catalog.provider.list(input)),
get: (input) =>
run(host.catalog.provider.get({ ...input, providerID: Provider.ID.make(input.providerID) })),
get: (input) => run(host.catalog.provider.get({ ...input, providerID: Provider.ID.make(input.providerID) })),
},
model: {
list: (input) => run(host.catalog.model.list(input)),
@@ -97,9 +96,7 @@ export function fromPromise(plugin: Plugin) {
),
connect: {
key: (input) =>
run(
host.integration.connect.key({ ...input, integrationID: Integration.ID.make(input.integrationID) }),
),
run(host.integration.connect.key({ ...input, integrationID: Integration.ID.make(input.integrationID) })),
oauth: (input) =>
run(
host.integration.connect.oauth({
@@ -211,13 +208,6 @@ export function fromPromise(plugin: Plugin) {
resume: input.resume ?? undefined,
}),
),
rename: (input) =>
run(
host.session.rename({
...input,
sessionID: Session.ID.make(input.sessionID),
}),
),
interrupt: (input) => run(host.session.interrupt({ sessionID: Session.ID.make(input.sessionID) })),
},
}
+1 -2
View File
@@ -11,7 +11,7 @@ import { SessionV2 } from "../session"
export interface Interface {
readonly session: Pick<
SessionV2.Interface,
"get" | "create" | "messages" | "prompt" | "command" | "rename" | "resume" | "interrupt" | "synthetic"
"get" | "create" | "messages" | "prompt" | "command" | "resume" | "interrupt" | "synthetic"
>
readonly job: Pick<Job.Interface, "start" | "wait" | "block" | "background" | "cancel">
readonly location: {
@@ -51,7 +51,6 @@ export const layerWithCell = (cell: Cell) =>
messages: (input) => require(cell, (runtime) => runtime.session.messages(input)),
prompt: (input) => require(cell, (runtime) => runtime.session.prompt(input)),
command: (input) => require(cell, (runtime) => runtime.session.command(input)),
rename: (input) => require(cell, (runtime) => runtime.session.rename(input)),
resume: (sessionID) => require(cell, (runtime) => runtime.session.resume(sessionID)),
interrupt: (sessionID) => require(cell, (runtime) => runtime.session.interrupt(sessionID)),
synthetic: (input) => require(cell, (runtime) => runtime.session.synthetic(input)),
-1
View File
@@ -82,7 +82,6 @@ export function host(overrides: Overrides = {}): PluginContext {
get: () => Effect.die("unused session.get"),
prompt: () => Effect.die("unused session.prompt"),
command: () => Effect.die("unused session.command"),
rename: () => Effect.die("unused session.rename"),
interrupt: () => Effect.die("unused session.interrupt"),
},
}
-32
View File
@@ -4,7 +4,6 @@ import { AgentV2 } from "@opencode-ai/core/agent"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { PluginHost } from "@opencode-ai/core/plugin/host"
import { PluginPromise } from "@opencode-ai/core/plugin/promise"
import { PluginRuntime } from "@opencode-ai/core/plugin/runtime"
import { SessionV2 } from "@opencode-ai/core/session"
import { SessionMessage } from "@opencode-ai/core/session/message"
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
@@ -44,37 +43,6 @@ describe("fromPromise", () => {
}),
)
it.effect("forwards session rename", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const runtime = yield* PluginRuntime.Service
const renamed: { sessionID: SessionV2.ID; title: string }[] = []
const host = yield* PluginHost.make(plugin).pipe(
Effect.provideService(
PluginRuntime.Service,
PluginRuntime.Service.of({
...runtime,
session: {
...runtime.session,
rename: (input) =>
Effect.sync(() => {
renamed.push(input)
}),
},
}),
),
)
const promisePlugin = Plugin.define({
id: "promise-session-rename",
setup: (ctx) => ctx.session.rename({ sessionID: "ses_plugin_rename", title: "Updated scope" }),
})
yield* PluginPromise.fromPromise(promisePlugin).effect(host)
expect(renamed).toEqual([{ sessionID: SessionV2.ID.make("ses_plugin_rename"), title: "Updated scope" }])
}),
)
it.effect("loads a promise plugin and registers a transform hook", () =>
Effect.gen(function* () {
const agents = yield* AgentV2.Service
+1 -1
View File
@@ -177,7 +177,7 @@ and plugin options.
| `ctx.integration` | `list`, `get`, `connect`, `attempt`, `transform`, `reload`, and connection lookup/resolution |
| `ctx.plugin` | `list` currently active plugin IDs |
| `ctx.reference` | `list`, `transform`, `reload` |
| `ctx.session` | `create`, `get`, `prompt`, `command`, `rename`, `interrupt`, and `hook` |
| `ctx.session` | `create`, `get`, `prompt`, `command`, `interrupt`, and `hook` |
| `ctx.skill` | `list`, `transform`, `reload` |
| `ctx.tool` | `transform` and `hook` |
| `ctx.aisdk` | `hook` |
+1 -1
View File
@@ -1,3 +1,3 @@
import type { SessionApi } from "@opencode-ai/client/effect/api"
export type SessionDomain = Pick<SessionApi<unknown>, "create" | "get" | "prompt" | "command" | "rename" | "interrupt">
export type SessionDomain = Pick<SessionApi<unknown>, "create" | "get" | "prompt" | "command" | "interrupt">
+1 -1
View File
@@ -1,3 +1,3 @@
import type { SessionApi } from "@opencode-ai/client/promise/api"
export type SessionDomain = Pick<SessionApi, "create" | "get" | "prompt" | "command" | "rename" | "interrupt">
export type SessionDomain = Pick<SessionApi, "create" | "get" | "prompt" | "command" | "interrupt">
+1
View File
@@ -55,6 +55,7 @@
"@opencode-ai/client": "workspace:*",
"@opencode-ai/core": "workspace:*",
"@opencode-ai/plugin": "workspace:*",
"@opencode-ai/schema": "workspace:*",
"@opencode-ai/sdk": "workspace:*",
"@opencode-ai/simulation": "workspace:*",
"@opencode-ai/ui": "workspace:*",
+24 -14
View File
@@ -40,6 +40,7 @@ import { useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid"
import type { UserMessage } from "@opencode-ai/sdk/v2"
import { Locale } from "../../util/locale"
import { errorMessage } from "../../util/error"
import { SessionMessage } from "@opencode-ai/schema/session-message"
import { createColors, createFrames } from "../../ui/spinner"
import { useDialog } from "../../ui/dialog"
import { DialogIntegration } from "../dialog-integration"
@@ -57,7 +58,7 @@ import { usePromptMove } from "./move"
import { readLocalAttachment } from "./local-attachment"
import { useData } from "../../context/data"
import { useLocation } from "../../context/location"
import { lastAssistantWithUsage } from "../../util/session"
import { contextUsage } from "../../util/session"
registerOpencodeSpinner()
@@ -280,21 +281,20 @@ export function Prompt(props: PromptProps) {
if (!props.sessionID) return
const session = data.session.get(props.sessionID)
if (!session) return
const last = lastAssistantWithUsage(data.session.message.list(props.sessionID), session.revert?.messageID)
if (!last) return
const tokens =
last.tokens.input + last.tokens.output + last.tokens.reasoning + last.tokens.cache.read + last.tokens.cache.write
if (tokens <= 0) return
const model = data.location.model
.list(session.location)
?.find((model) => model.providerID === last.model.providerID && model.id === last.model.id)
const pct = model?.limit.context ? `${Math.round((tokens / model.limit.context) * 100)}%` : undefined
const cost = data.session.cost(props.sessionID)
const formattedCost = cost > 0 ? money.format(cost) : undefined
const context = contextUsage(
data.session.message.list(props.sessionID),
data.location.model.list(session.location),
session.revert?.messageID,
)
return {
context: pct ? `${Locale.number(tokens)} (${pct})` : Locale.number(tokens),
cost: cost > 0 ? money.format(cost) : undefined,
context: context
? context.percent === undefined
? Locale.number(context.tokens)
: `${Locale.number(context.tokens)} (${context.percent}%)`
: undefined,
cost: formattedCost,
}
})
@@ -1147,9 +1147,18 @@ export function Prompt(props: PromptProps) {
return false
}
}
const messageID = SessionMessage.ID.create()
data.session.input.optimistic(sessionID, {
id: messageID,
type: "user",
text: inputText,
agents: store.prompt.agents,
time: { created: Date.now() },
})
const error = await sdk.api.session
.prompt({
sessionID,
id: messageID,
text: inputText,
files: store.prompt.files,
agents: store.prompt.agents,
@@ -1159,6 +1168,7 @@ export function Prompt(props: PromptProps) {
(error) => error,
)
if (error) {
data.session.input.rollback(sessionID, messageID)
toast.show({ title: "Failed to send prompt", message: errorMessage(error), variant: "error" })
return false
}
+40 -14
View File
@@ -21,6 +21,7 @@ import type {
SessionMessageAssistantText,
SessionMessageAssistantTool,
SessionInfo,
SessionMessageUser,
Shell,
SkillInfo,
} from "@opencode-ai/sdk/v2"
@@ -105,6 +106,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
directory: process.cwd(),
})
const messageIndex = new Map<string, Map<string, number>>()
const optimisticInput = new Map<string, Set<string>>()
let bootstrapping: Promise<void> | undefined
let connected = false
@@ -214,6 +216,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
function removeSession(sessionID: string) {
messageIndex.delete(sessionID)
optimisticInput.delete(sessionID)
setStore(
"session",
produce((draft) => {
@@ -327,32 +330,35 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
)
break
}
case "session.input.admitted":
case "session.input.admitted": {
const inputs = optimisticInput.get(event.data.sessionID)
if (inputs?.delete(event.data.inputID) && inputs.size === 0) optimisticInput.delete(event.data.sessionID)
if (!store.session.input[event.data.sessionID]?.includes(event.data.inputID))
setStore("session", "input", event.data.sessionID, [
...(store.session.input[event.data.sessionID] ?? []),
event.data.inputID,
])
message.update(event.data.sessionID, (draft, index) => {
message.append(
draft,
index,
const item =
event.data.input.type === "user"
? {
id: event.data.inputID,
type: "user",
type: "user" as const,
...event.data.input.data,
time: { created: event.created },
}
: {
id: event.data.inputID,
type: "synthetic",
type: "synthetic" as const,
...event.data.input.data,
time: { created: event.created },
},
)
}
const position = index.get(event.data.inputID)
if (position === undefined) return message.append(draft, index, item)
draft[position] = item
})
break
}
case "session.instructions.updated":
message.update(event.data.sessionID, (draft, index) => {
message.append(draft, index, {
@@ -669,16 +675,12 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
const position = draft.findLastIndex((item) => item.type === "compaction" && item.status === "running")
const current = draft[position]
if (current?.type === "compaction") {
draft[position] = {
id: current.id,
type: "compaction",
Object.assign(current, {
status: "completed",
reason: event.data.reason,
summary: event.data.text,
recent: event.data.recent,
metadata: current.metadata,
time: current.time,
}
})
return
}
message.append(draft, index, {
@@ -818,6 +820,30 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
return store.session.status[sessionID] ?? "idle"
},
input: {
optimistic(sessionID: string, item: SessionMessageUser) {
const inputs = optimisticInput.get(sessionID) ?? new Set<string>()
inputs.add(item.id)
optimisticInput.set(sessionID, inputs)
if (!store.session.input[sessionID]?.includes(item.id))
setStore("session", "input", sessionID, [...(store.session.input[sessionID] ?? []), item.id])
message.update(sessionID, (draft, index) => message.append(draft, index, item))
},
rollback(sessionID: string, inputID: string) {
const inputs = optimisticInput.get(sessionID)
if (!inputs?.delete(inputID)) return
if (inputs.size === 0) optimisticInput.delete(sessionID)
setStore(
"session",
produce((draft) => {
draft.input[sessionID] = (draft.input[sessionID] ?? []).filter((id) => id !== inputID)
const messages = draft.message[sessionID]
const position = messageIndex.get(sessionID)?.get(inputID)
if (!messages || position === undefined) return
messages.splice(position, 1)
messageIndex.set(sessionID, new Map(messages.map((item, index) => [item.id, index])))
}),
)
},
list(sessionID: string) {
return store.session.input[sessionID] ?? []
},
@@ -1,8 +1,8 @@
import type { TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui"
import type { BuiltinTuiPlugin } from "../builtins"
import { createMemo } from "solid-js"
import { createMemo, Show } from "solid-js"
import { useData } from "../../context/data"
import { lastAssistantWithUsage } from "../../util/session"
import { contextUsage } from "../../util/session"
const id = "internal:sidebar-context"
@@ -18,33 +18,23 @@ function View(props: { api: TuiPluginApi; session_id: string }) {
const session = createMemo(() => data.session.get(props.session_id))
const cost = createMemo(() => data.session.cost(props.session_id))
const state = createMemo(() => {
const last = lastAssistantWithUsage(msg(), session()?.revert?.messageID)
if (!last) {
return {
tokens: 0,
percent: null,
}
}
const tokens =
last.tokens.input + last.tokens.output + last.tokens.reasoning + last.tokens.cache.read + last.tokens.cache.write
const model = data.location
.model.list(session()?.location)
?.find((model) => model.providerID === last.model.providerID && model.id === last.model.id)
return {
tokens,
percent: model?.limit.context ? Math.round((tokens / model.limit.context) * 100) : null,
}
})
const state = createMemo(() => contextUsage(msg(), data.location.model.list(session()?.location), session()?.revert?.messageID))
return (
<box>
<text fg={theme().text}>
<b>Context</b>
</text>
<text fg={theme().textMuted}>{state().tokens.toLocaleString()} tokens</text>
<text fg={theme().textMuted}>{state().percent ?? 0}% used</text>
<Show when={state()} fallback={<text fg={theme().textMuted}>Not measured</text>}>
{(value) => (
<>
<text fg={theme().textMuted}>{value().tokens.toLocaleString()} tokens</text>
<Show when={value().percent !== undefined}>
<text fg={theme().textMuted}>{value().percent}% used</text>
</Show>
</>
)}
</Show>
<text fg={theme().textMuted}>{money.format(cost())} spent</text>
</box>
)
+10 -18
View File
@@ -1325,23 +1325,18 @@ function SessionSkillMessage(props: { message: Extract<SessionMessageInfo, { typ
)
}
function CompactionMessage(props: {
message?: Extract<SessionMessageInfo, { type: "compaction" }>
status?: "running"
text?: string
}) {
function CompactionMessage(props: { message: Extract<SessionMessageInfo, { type: "compaction" }> }) {
const ctx = use()
const kv = useKV()
const { theme, syntax } = useTheme()
const status = () => props.message?.status ?? props.status
const text = () =>
props.message?.status === "failed" ? props.message.error.message : (props.message?.summary ?? props.text ?? "")
const color = () => (status() === "failed" ? theme.error : status() === "completed" ? theme.success : theme.textMuted)
const border = color
const status = () => props.message.status
const text = () => (props.message.status === "failed" ? props.message.error.message : props.message.summary)
const content = createMemo(() => text().trim())
const color = () => (status() === "failed" ? theme.error : theme.textMuted)
return (
<box>
<box flexDirection="row" alignItems="center">
<box border={["top"]} borderColor={border()} flexGrow={1} />
<box border={["top"]} borderColor={color()} flexGrow={1} />
<box flexDirection="row" gap={1} paddingLeft={1} paddingRight={1}>
<Switch>
<Match when={status() === "running"}>
@@ -1349,24 +1344,21 @@ function CompactionMessage(props: {
<spinner frames={SPINNER_FRAMES} interval={80} color={color()} />
</Show>
</Match>
<Match when={status() === "completed"}>
<text fg={color()}></text>
</Match>
<Match when={status() === "failed"}>
<text fg={color()}></text>
</Match>
</Switch>
<text fg={color()}>Compaction</text>
</box>
<box border={["top"]} borderColor={border()} flexGrow={1} />
<box border={["top"]} borderColor={color()} flexGrow={1} />
</box>
<Show when={text().trim()}>
<Show when={content()}>
<box paddingTop={1} paddingLeft={3}>
<markdown
syntaxStyle={syntax()}
streaming={status() === "running"}
streaming={true}
internalBlockMode="top-level"
content={text().trim()}
content={content()}
tableOptions={{ style: "grid" }}
conceal={ctx.conceal()}
fg={theme.markdownText}
+3 -5
View File
@@ -88,7 +88,6 @@ export function createSessionRows(sessionID: Accessor<string>) {
{
id: message.id,
created: message.time.created,
input: message.status === "running",
},
]
: [],
@@ -183,7 +182,9 @@ export function createSessionRows(sessionID: Accessor<string>) {
}
const subscriptions = [
data.on("session.input.admitted", input),
data.on("session.compaction.started", message),
data.on("session.compaction.started", (event) => {
if (event.data.sessionID === sessionID()) appendMessage(event.data.inputID ?? event.id.replace(/^evt_/, "msg_"))
}),
data.on("session.instructions.updated", message),
data.on("session.synthetic", (event) => {
if (event.data.sessionID === sessionID() && event.data.description?.trim())
@@ -192,9 +193,6 @@ export function createSessionRows(sessionID: Accessor<string>) {
data.on("session.shell.started", message),
data.on("session.agent.selected", message),
data.on("session.model.selected", message),
data.on("session.compaction.ended", (event) => {
if (event.data.reason !== "manual") message(event)
}),
data.on("session.text.delta", (event) => {
if (event.data.sessionID === sessionID())
appendPart({ messageID: event.data.assistantMessageID, partID: `text:${event.data.ordinal}` })
@@ -6,7 +6,12 @@ import { SplitBorder } from "../../ui/border"
import { Locale } from "../../util/locale"
import { useTerminalDimensions } from "@opentui/solid"
import { useCommandShortcut, useOpencodeKeymap } from "../../keymap"
import { lastAssistantWithUsage } from "../../util/session"
import { contextUsage } from "../../util/session"
const money = new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
})
export function SubagentFooter() {
const route = useRouteData("session")
@@ -23,26 +28,21 @@ export function SubagentFooter() {
const usage = createMemo(() => {
const current = session()
if (!current) return
const last = lastAssistantWithUsage(data.session.message.list(route.sessionID), current.revert?.messageID)
if (!last) return
const tokens =
last.tokens.input + last.tokens.output + last.tokens.reasoning + last.tokens.cache.read + last.tokens.cache.write
if (tokens <= 0) return
const model = data.location
.model.list(current.location)
?.find((model) => model.providerID === last.model.providerID && model.id === last.model.id)
const pct = model?.limit.context ? `${Math.round((tokens / model.limit.context) * 100)}%` : undefined
const cost = data.session.cost(route.sessionID)
const money = new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
})
const cost = current.cost
const formattedCost = cost > 0 ? money.format(cost) : undefined
const context = contextUsage(
data.session.message.list(route.sessionID),
data.location.model.list(current.location),
current.revert?.messageID,
)
return {
context: pct ? `${Locale.number(tokens)} (${pct})` : Locale.number(tokens),
cost: cost > 0 ? money.format(cost) : undefined,
context: context
? context.percent === undefined
? Locale.number(context.tokens)
: `${Locale.number(context.tokens)} (${context.percent}%)`
: undefined,
cost: formattedCost,
}
})
+23 -2
View File
@@ -1,4 +1,4 @@
import type { SessionMessageAssistant, SessionMessageInfo } from "@opencode-ai/sdk/v2"
import type { ModelInfo, SessionMessageAssistant, SessionMessageInfo } from "@opencode-ai/sdk/v2"
export function isDefaultTitle(title: string) {
return /^(New session - |Child session - )\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/.test(title)
@@ -7,8 +7,29 @@ export function isDefaultTitle(title: string) {
export function lastAssistantWithUsage(messages: ReadonlyArray<SessionMessageInfo>, boundary?: string) {
const boundaryIndex = boundary ? messages.findIndex((message) => message.id === boundary) : -1
if (boundary && boundaryIndex === -1) return undefined
const end = boundaryIndex === -1 ? messages.length : boundaryIndex
const compactionIndex = messages.findLastIndex(
(message, index) => message.type === "compaction" && message.status === "completed" && index < end,
)
return messages.findLast(
(message, index): message is SessionMessageAssistant & { tokens: NonNullable<SessionMessageAssistant["tokens"]> } =>
message.type === "assistant" && message.tokens !== undefined && (boundaryIndex === -1 || index < boundaryIndex),
message.type === "assistant" && message.tokens !== undefined && index > compactionIndex && index < end,
)
}
export function contextUsage(
messages: ReadonlyArray<SessionMessageInfo>,
models: ReadonlyArray<ModelInfo> | undefined,
boundary?: string,
) {
const last = lastAssistantWithUsage(messages, boundary)
if (!last) return
const tokens =
last.tokens.input + last.tokens.output + last.tokens.reasoning + last.tokens.cache.read + last.tokens.cache.write
if (tokens <= 0) return
const model = models?.find((model) => model.providerID === last.model.providerID && model.id === last.model.id)
return {
tokens,
percent: model?.limit.context ? Math.round((tokens / model.limit.context) * 100) : undefined,
}
}
+82 -7
View File
@@ -1064,6 +1064,7 @@ test("tracks session status from active sessions and execution events", async ()
const message = data.session.message.get("session-manual", "message-compaction")
return message?.type === "compaction" && message.status === "running" && message.summary === "Streamed summary"
})
const compactionRow = manualRows.find((row) => row.type === "message" && row.messageID === "message-compaction")
emitEvent(events, {
id: "evt_manual_compaction_ended",
created: 3,
@@ -1078,6 +1079,9 @@ test("tracks session status from active sessions and execution events", async ()
expect(manualRows.filter((row) => row.type === "message")).toEqual([
{ type: "message", messageID: "message-compaction" },
])
expect(manualRows.find((row) => row.type === "message" && row.messageID === "message-compaction")).toBe(
compactionRow,
)
emitEvent(events, {
id: "evt_compaction_started",
@@ -1102,6 +1106,7 @@ test("tracks session status from active sessions and execution events", async ()
const message = data.session.message.get("session-live", "msg_compaction_started")
return message?.type === "compaction" && message.status === "running" && message.summary === "Live summary"
})
const autoCompactionRow = rows.find((row) => row.type === "message" && row.messageID === "msg_compaction_started")
emitEvent(events, {
id: "evt_compaction_ended",
@@ -1119,6 +1124,10 @@ test("tracks session status from active sessions and execution events", async ()
status: "completed",
summary: "Live summary",
})
expect(rows.find((row) => row.type === "message" && row.messageID === "msg_compaction_started")).toBe(
autoCompactionRow,
)
expect(rows.some((row) => row.type === "message" && row.messageID === "msg_compaction_ended")).toBeFalse()
} finally {
app.renderer.destroy()
}
@@ -2046,6 +2055,14 @@ test("renders admitted prompts immediately and tracks them until promoted", asyn
await mounted
const received: string[] = []
const unsubscribe = sync.listen((event) => received.push(event.name))
sync.session.input.optimistic(sessionID, {
id: messageID,
type: "user",
text: "optimistic",
time: { created: -1 },
})
expect(sync.session.message.get(sessionID, messageID)).toMatchObject({ text: "optimistic", time: { created: -1 } })
expect(sync.session.input.list(sessionID)).toEqual([messageID])
emitEvent(events, {
id: "evt_admitted_1",
created: 0,
@@ -2057,11 +2074,13 @@ test("renders admitted prompts immediately and tracks them until promoted", asyn
input: { type: "user", data: { text: "hello" }, delivery: "steer" },
},
})
await wait(() => sync.session.message.list(sessionID)?.length === 1)
await wait(() => sync.session.message.get(sessionID, messageID)?.time.created === 0)
const admitted = sync.session.message.list(sessionID)?.[0]
expect(admitted).toMatchObject({ id: messageID, type: "user", text: "hello" })
expect(admitted?.metadata).toBeUndefined()
expect(sync.session.input.list(sessionID)).toEqual([messageID])
sync.session.input.rollback(sessionID, messageID)
expect(sync.session.message.ids(sessionID)).toEqual([messageID])
await sync.session.message.refresh(sessionID)
expect(sync.session.message.list(sessionID)?.[0]?.metadata).toBeUndefined()
@@ -2096,6 +2115,66 @@ test("renders admitted prompts immediately and tracks them until promoted", asyn
}
})
test("rolls back unconfirmed optimistic prompts and accepts later admission", async () => {
const events = createEventStream()
const sessionID = "session-1"
const messageID = "msg_user_rollback"
const calls = createFetch(undefined, events)
let sync!: ReturnType<typeof useData>
let ready!: () => void
const mounted = new Promise<void>((resolve) => {
ready = resolve
})
function Probe() {
sync = useData()
onMount(ready)
return <box />
}
const app = await testRender(() => (
<TestTuiContexts>
<SDKProvider client={createClient(calls.fetch)} api={createApi(calls.fetch)}>
<ProjectProvider>
<DataProvider>
<Probe />
</DataProvider>
</ProjectProvider>
</SDKProvider>
</TestTuiContexts>
))
try {
await mounted
sync.session.input.optimistic(sessionID, {
id: messageID,
type: "user",
text: "optimistic",
time: { created: -1 },
})
sync.session.input.rollback(sessionID, messageID)
expect(sync.session.message.ids(sessionID)).toEqual([])
expect(sync.session.input.list(sessionID)).toEqual([])
emitEvent(events, {
id: "evt_admitted_after_rollback",
created: 1,
type: "session.input.admitted",
durable: durable(sessionID),
data: {
sessionID,
inputID: messageID,
input: { type: "user", data: { text: "accepted" }, delivery: "steer" },
},
})
await wait(() => sync.session.message.ids(sessionID).length === 1)
expect(sync.session.message.get(sessionID, messageID)).toMatchObject({ text: "accepted", time: { created: 1 } })
expect(sync.session.input.list(sessionID)).toEqual([messageID])
} finally {
app.renderer.destroy()
}
})
test("projects live instruction updates with their message ID", async () => {
const events = createEventStream()
const calls = createFetch(undefined, events)
@@ -2167,8 +2246,7 @@ function sessionInfo(id: string, parentID: string | undefined, cost = 0) {
async function mountData(parents: Record<string, string>, costs: Record<string, number> = {}) {
const calls = createFetch((url) => {
const match = url.pathname.match(/^\/api\/session\/([^/]+)$/)
if (match && match[1] !== "active")
return json({ data: sessionInfo(match[1], parents[match[1]], costs[match[1]]) })
if (match && match[1] !== "active") return json({ data: sessionInfo(match[1], parents[match[1]], costs[match[1]]) })
})
let data!: ReturnType<typeof useData>
let ready!: () => void
@@ -2237,10 +2315,7 @@ test("indexes arbitrarily deep nesting under a single root", async () => {
})
test("totals family cost for roots and keeps subagent cost scoped", async () => {
const { data, app } = await mountData(
{ grandchild: "child", child: "root" },
{ root: 1, child: 2, grandchild: 3 },
)
const { data, app } = await mountData({ grandchild: "child", child: "root" }, { root: 1, child: 2, grandchild: 3 })
try {
await data.session.refresh("grandchild")
await data.session.refresh("child")
+28 -9
View File
@@ -2,6 +2,16 @@ import { describe, expect, test } from "bun:test"
import type { SessionMessageInfo } from "@opencode-ai/sdk/v2"
import { isDefaultTitle, lastAssistantWithUsage } from "../../src/util/session"
const assistant = (id: string, input: number): SessionMessageInfo => ({
id,
type: "assistant",
agent: "build",
model: { id: "model", providerID: "provider" },
content: [],
tokens: { input, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 0 },
})
describe("util.session", () => {
test("recognizes generated parent and child titles", () => {
expect(isDefaultTitle("New session - 2026-06-06T12:34:56.789Z")).toBeTrue()
@@ -10,15 +20,6 @@ describe("util.session", () => {
})
test("tracks usage across undo and redo boundaries", () => {
const assistant = (id: string, input: number): SessionMessageInfo => ({
id,
type: "assistant",
agent: "build",
model: { id: "model", providerID: "provider" },
content: [],
tokens: { input, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 0 },
})
const messages = [assistant("msg_z", 10), assistant("msg_a", 30)]
expect(lastAssistantWithUsage(messages)?.tokens.input).toBe(30)
@@ -26,4 +27,22 @@ describe("util.session", () => {
expect(lastAssistantWithUsage(messages, "msg_missing")).toBeUndefined()
expect(lastAssistantWithUsage(messages)?.tokens.input).toBe(30)
})
test("resets usage at completed compaction until the next assistant reports it", () => {
const compaction: SessionMessageInfo = {
id: "msg_compaction",
type: "compaction",
status: "completed",
reason: "manual",
summary: "Current state",
recent: "",
time: { created: 0 },
}
const messages = [assistant("msg_before", 30), compaction]
expect(lastAssistantWithUsage(messages)).toBeUndefined()
messages.push(assistant("msg_after", 5))
expect(lastAssistantWithUsage(messages)?.tokens.input).toBe(5)
})
})