mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-15 17:08:21 -04:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1803aaf857 | |||
| 00ab94c44f |
@@ -1003,6 +1003,7 @@
|
|||||||
"@opencode-ai/client": "workspace:*",
|
"@opencode-ai/client": "workspace:*",
|
||||||
"@opencode-ai/core": "workspace:*",
|
"@opencode-ai/core": "workspace:*",
|
||||||
"@opencode-ai/plugin": "workspace:*",
|
"@opencode-ai/plugin": "workspace:*",
|
||||||
|
"@opencode-ai/schema": "workspace:*",
|
||||||
"@opencode-ai/sdk": "workspace:*",
|
"@opencode-ai/sdk": "workspace:*",
|
||||||
"@opencode-ai/simulation": "workspace:*",
|
"@opencode-ai/simulation": "workspace:*",
|
||||||
"@opencode-ai/ui": "workspace:*",
|
"@opencode-ai/ui": "workspace:*",
|
||||||
|
|||||||
@@ -378,7 +378,6 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
|
|||||||
get: (input) => runtime.session.get(input.sessionID),
|
get: (input) => runtime.session.get(input.sessionID),
|
||||||
prompt: runtime.session.prompt,
|
prompt: runtime.session.prompt,
|
||||||
command: runtime.session.command,
|
command: runtime.session.command,
|
||||||
rename: runtime.session.rename,
|
|
||||||
interrupt: (input) => runtime.session.interrupt(input.sessionID),
|
interrupt: (input) => runtime.session.interrupt(input.sessionID),
|
||||||
},
|
},
|
||||||
} satisfies Plugin.Context
|
} satisfies Plugin.Context
|
||||||
|
|||||||
@@ -70,8 +70,7 @@ export function fromPromise(plugin: Plugin) {
|
|||||||
catalog: {
|
catalog: {
|
||||||
provider: {
|
provider: {
|
||||||
list: (input) => run(host.catalog.provider.list(input)),
|
list: (input) => run(host.catalog.provider.list(input)),
|
||||||
get: (input) =>
|
get: (input) => run(host.catalog.provider.get({ ...input, providerID: Provider.ID.make(input.providerID) })),
|
||||||
run(host.catalog.provider.get({ ...input, providerID: Provider.ID.make(input.providerID) })),
|
|
||||||
},
|
},
|
||||||
model: {
|
model: {
|
||||||
list: (input) => run(host.catalog.model.list(input)),
|
list: (input) => run(host.catalog.model.list(input)),
|
||||||
@@ -97,9 +96,7 @@ export function fromPromise(plugin: Plugin) {
|
|||||||
),
|
),
|
||||||
connect: {
|
connect: {
|
||||||
key: (input) =>
|
key: (input) =>
|
||||||
run(
|
run(host.integration.connect.key({ ...input, integrationID: Integration.ID.make(input.integrationID) })),
|
||||||
host.integration.connect.key({ ...input, integrationID: Integration.ID.make(input.integrationID) }),
|
|
||||||
),
|
|
||||||
oauth: (input) =>
|
oauth: (input) =>
|
||||||
run(
|
run(
|
||||||
host.integration.connect.oauth({
|
host.integration.connect.oauth({
|
||||||
@@ -211,13 +208,6 @@ export function fromPromise(plugin: Plugin) {
|
|||||||
resume: input.resume ?? undefined,
|
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) })),
|
interrupt: (input) => run(host.session.interrupt({ sessionID: Session.ID.make(input.sessionID) })),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { SessionV2 } from "../session"
|
|||||||
export interface Interface {
|
export interface Interface {
|
||||||
readonly session: Pick<
|
readonly session: Pick<
|
||||||
SessionV2.Interface,
|
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 job: Pick<Job.Interface, "start" | "wait" | "block" | "background" | "cancel">
|
||||||
readonly location: {
|
readonly location: {
|
||||||
@@ -51,7 +51,6 @@ export const layerWithCell = (cell: Cell) =>
|
|||||||
messages: (input) => require(cell, (runtime) => runtime.session.messages(input)),
|
messages: (input) => require(cell, (runtime) => runtime.session.messages(input)),
|
||||||
prompt: (input) => require(cell, (runtime) => runtime.session.prompt(input)),
|
prompt: (input) => require(cell, (runtime) => runtime.session.prompt(input)),
|
||||||
command: (input) => require(cell, (runtime) => runtime.session.command(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)),
|
resume: (sessionID) => require(cell, (runtime) => runtime.session.resume(sessionID)),
|
||||||
interrupt: (sessionID) => require(cell, (runtime) => runtime.session.interrupt(sessionID)),
|
interrupt: (sessionID) => require(cell, (runtime) => runtime.session.interrupt(sessionID)),
|
||||||
synthetic: (input) => require(cell, (runtime) => runtime.session.synthetic(input)),
|
synthetic: (input) => require(cell, (runtime) => runtime.session.synthetic(input)),
|
||||||
|
|||||||
@@ -82,7 +82,6 @@ export function host(overrides: Overrides = {}): PluginContext {
|
|||||||
get: () => Effect.die("unused session.get"),
|
get: () => Effect.die("unused session.get"),
|
||||||
prompt: () => Effect.die("unused session.prompt"),
|
prompt: () => Effect.die("unused session.prompt"),
|
||||||
command: () => Effect.die("unused session.command"),
|
command: () => Effect.die("unused session.command"),
|
||||||
rename: () => Effect.die("unused session.rename"),
|
|
||||||
interrupt: () => Effect.die("unused session.interrupt"),
|
interrupt: () => Effect.die("unused session.interrupt"),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { AgentV2 } from "@opencode-ai/core/agent"
|
|||||||
import { PluginV2 } from "@opencode-ai/core/plugin"
|
import { PluginV2 } from "@opencode-ai/core/plugin"
|
||||||
import { PluginHost } from "@opencode-ai/core/plugin/host"
|
import { PluginHost } from "@opencode-ai/core/plugin/host"
|
||||||
import { PluginPromise } from "@opencode-ai/core/plugin/promise"
|
import { PluginPromise } from "@opencode-ai/core/plugin/promise"
|
||||||
import { PluginRuntime } from "@opencode-ai/core/plugin/runtime"
|
|
||||||
import { SessionV2 } from "@opencode-ai/core/session"
|
import { SessionV2 } from "@opencode-ai/core/session"
|
||||||
import { SessionMessage } from "@opencode-ai/core/session/message"
|
import { SessionMessage } from "@opencode-ai/core/session/message"
|
||||||
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
|
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", () =>
|
it.effect("loads a promise plugin and registers a transform hook", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const agents = yield* AgentV2.Service
|
const agents = yield* AgentV2.Service
|
||||||
|
|||||||
Vendored
+1
-1
@@ -177,7 +177,7 @@ and plugin options.
|
|||||||
| `ctx.integration` | `list`, `get`, `connect`, `attempt`, `transform`, `reload`, and connection lookup/resolution |
|
| `ctx.integration` | `list`, `get`, `connect`, `attempt`, `transform`, `reload`, and connection lookup/resolution |
|
||||||
| `ctx.plugin` | `list` currently active plugin IDs |
|
| `ctx.plugin` | `list` currently active plugin IDs |
|
||||||
| `ctx.reference` | `list`, `transform`, `reload` |
|
| `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.skill` | `list`, `transform`, `reload` |
|
||||||
| `ctx.tool` | `transform` and `hook` |
|
| `ctx.tool` | `transform` and `hook` |
|
||||||
| `ctx.aisdk` | `hook` |
|
| `ctx.aisdk` | `hook` |
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
import type { SessionApi } from "@opencode-ai/client/effect/api"
|
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,3 +1,3 @@
|
|||||||
import type { SessionApi } from "@opencode-ai/client/promise/api"
|
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">
|
||||||
|
|||||||
@@ -55,6 +55,7 @@
|
|||||||
"@opencode-ai/client": "workspace:*",
|
"@opencode-ai/client": "workspace:*",
|
||||||
"@opencode-ai/core": "workspace:*",
|
"@opencode-ai/core": "workspace:*",
|
||||||
"@opencode-ai/plugin": "workspace:*",
|
"@opencode-ai/plugin": "workspace:*",
|
||||||
|
"@opencode-ai/schema": "workspace:*",
|
||||||
"@opencode-ai/sdk": "workspace:*",
|
"@opencode-ai/sdk": "workspace:*",
|
||||||
"@opencode-ai/simulation": "workspace:*",
|
"@opencode-ai/simulation": "workspace:*",
|
||||||
"@opencode-ai/ui": "workspace:*",
|
"@opencode-ai/ui": "workspace:*",
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ import { useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid"
|
|||||||
import type { UserMessage } from "@opencode-ai/sdk/v2"
|
import type { UserMessage } from "@opencode-ai/sdk/v2"
|
||||||
import { Locale } from "../../util/locale"
|
import { Locale } from "../../util/locale"
|
||||||
import { errorMessage } from "../../util/error"
|
import { errorMessage } from "../../util/error"
|
||||||
|
import { SessionMessage } from "@opencode-ai/schema/session-message"
|
||||||
import { createColors, createFrames } from "../../ui/spinner"
|
import { createColors, createFrames } from "../../ui/spinner"
|
||||||
import { useDialog } from "../../ui/dialog"
|
import { useDialog } from "../../ui/dialog"
|
||||||
import { DialogIntegration } from "../dialog-integration"
|
import { DialogIntegration } from "../dialog-integration"
|
||||||
@@ -57,7 +58,7 @@ import { usePromptMove } from "./move"
|
|||||||
import { readLocalAttachment } from "./local-attachment"
|
import { readLocalAttachment } from "./local-attachment"
|
||||||
import { useData } from "../../context/data"
|
import { useData } from "../../context/data"
|
||||||
import { useLocation } from "../../context/location"
|
import { useLocation } from "../../context/location"
|
||||||
import { lastAssistantWithUsage } from "../../util/session"
|
import { contextUsage } from "../../util/session"
|
||||||
|
|
||||||
registerOpencodeSpinner()
|
registerOpencodeSpinner()
|
||||||
|
|
||||||
@@ -280,21 +281,20 @@ export function Prompt(props: PromptProps) {
|
|||||||
if (!props.sessionID) return
|
if (!props.sessionID) return
|
||||||
const session = data.session.get(props.sessionID)
|
const session = data.session.get(props.sessionID)
|
||||||
if (!session) return
|
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 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 {
|
return {
|
||||||
context: pct ? `${Locale.number(tokens)} (${pct})` : Locale.number(tokens),
|
context: context
|
||||||
cost: cost > 0 ? money.format(cost) : undefined,
|
? 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
|
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
|
const error = await sdk.api.session
|
||||||
.prompt({
|
.prompt({
|
||||||
sessionID,
|
sessionID,
|
||||||
|
id: messageID,
|
||||||
text: inputText,
|
text: inputText,
|
||||||
files: store.prompt.files,
|
files: store.prompt.files,
|
||||||
agents: store.prompt.agents,
|
agents: store.prompt.agents,
|
||||||
@@ -1159,6 +1168,7 @@ export function Prompt(props: PromptProps) {
|
|||||||
(error) => error,
|
(error) => error,
|
||||||
)
|
)
|
||||||
if (error) {
|
if (error) {
|
||||||
|
data.session.input.rollback(sessionID, messageID)
|
||||||
toast.show({ title: "Failed to send prompt", message: errorMessage(error), variant: "error" })
|
toast.show({ title: "Failed to send prompt", message: errorMessage(error), variant: "error" })
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import type {
|
|||||||
SessionMessageAssistantText,
|
SessionMessageAssistantText,
|
||||||
SessionMessageAssistantTool,
|
SessionMessageAssistantTool,
|
||||||
SessionInfo,
|
SessionInfo,
|
||||||
|
SessionMessageUser,
|
||||||
Shell,
|
Shell,
|
||||||
SkillInfo,
|
SkillInfo,
|
||||||
} from "@opencode-ai/sdk/v2"
|
} from "@opencode-ai/sdk/v2"
|
||||||
@@ -105,6 +106,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
|||||||
directory: process.cwd(),
|
directory: process.cwd(),
|
||||||
})
|
})
|
||||||
const messageIndex = new Map<string, Map<string, number>>()
|
const messageIndex = new Map<string, Map<string, number>>()
|
||||||
|
const optimisticInput = new Map<string, Set<string>>()
|
||||||
let bootstrapping: Promise<void> | undefined
|
let bootstrapping: Promise<void> | undefined
|
||||||
let connected = false
|
let connected = false
|
||||||
|
|
||||||
@@ -214,6 +216,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
|||||||
|
|
||||||
function removeSession(sessionID: string) {
|
function removeSession(sessionID: string) {
|
||||||
messageIndex.delete(sessionID)
|
messageIndex.delete(sessionID)
|
||||||
|
optimisticInput.delete(sessionID)
|
||||||
setStore(
|
setStore(
|
||||||
"session",
|
"session",
|
||||||
produce((draft) => {
|
produce((draft) => {
|
||||||
@@ -327,32 +330,35 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
|||||||
)
|
)
|
||||||
break
|
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))
|
if (!store.session.input[event.data.sessionID]?.includes(event.data.inputID))
|
||||||
setStore("session", "input", event.data.sessionID, [
|
setStore("session", "input", event.data.sessionID, [
|
||||||
...(store.session.input[event.data.sessionID] ?? []),
|
...(store.session.input[event.data.sessionID] ?? []),
|
||||||
event.data.inputID,
|
event.data.inputID,
|
||||||
])
|
])
|
||||||
message.update(event.data.sessionID, (draft, index) => {
|
message.update(event.data.sessionID, (draft, index) => {
|
||||||
message.append(
|
const item =
|
||||||
draft,
|
|
||||||
index,
|
|
||||||
event.data.input.type === "user"
|
event.data.input.type === "user"
|
||||||
? {
|
? {
|
||||||
id: event.data.inputID,
|
id: event.data.inputID,
|
||||||
type: "user",
|
type: "user" as const,
|
||||||
...event.data.input.data,
|
...event.data.input.data,
|
||||||
time: { created: event.created },
|
time: { created: event.created },
|
||||||
}
|
}
|
||||||
: {
|
: {
|
||||||
id: event.data.inputID,
|
id: event.data.inputID,
|
||||||
type: "synthetic",
|
type: "synthetic" as const,
|
||||||
...event.data.input.data,
|
...event.data.input.data,
|
||||||
time: { created: event.created },
|
time: { created: event.created },
|
||||||
},
|
}
|
||||||
)
|
const position = index.get(event.data.inputID)
|
||||||
|
if (position === undefined) return message.append(draft, index, item)
|
||||||
|
draft[position] = item
|
||||||
})
|
})
|
||||||
break
|
break
|
||||||
|
}
|
||||||
case "session.instructions.updated":
|
case "session.instructions.updated":
|
||||||
message.update(event.data.sessionID, (draft, index) => {
|
message.update(event.data.sessionID, (draft, index) => {
|
||||||
message.append(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 position = draft.findLastIndex((item) => item.type === "compaction" && item.status === "running")
|
||||||
const current = draft[position]
|
const current = draft[position]
|
||||||
if (current?.type === "compaction") {
|
if (current?.type === "compaction") {
|
||||||
draft[position] = {
|
Object.assign(current, {
|
||||||
id: current.id,
|
|
||||||
type: "compaction",
|
|
||||||
status: "completed",
|
status: "completed",
|
||||||
reason: event.data.reason,
|
reason: event.data.reason,
|
||||||
summary: event.data.text,
|
summary: event.data.text,
|
||||||
recent: event.data.recent,
|
recent: event.data.recent,
|
||||||
metadata: current.metadata,
|
})
|
||||||
time: current.time,
|
|
||||||
}
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
message.append(draft, index, {
|
message.append(draft, index, {
|
||||||
@@ -818,6 +820,30 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
|||||||
return store.session.status[sessionID] ?? "idle"
|
return store.session.status[sessionID] ?? "idle"
|
||||||
},
|
},
|
||||||
input: {
|
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) {
|
list(sessionID: string) {
|
||||||
return store.session.input[sessionID] ?? []
|
return store.session.input[sessionID] ?? []
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import type { TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui"
|
import type { TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui"
|
||||||
import type { BuiltinTuiPlugin } from "../builtins"
|
import type { BuiltinTuiPlugin } from "../builtins"
|
||||||
import { createMemo } from "solid-js"
|
import { createMemo, Show } from "solid-js"
|
||||||
import { useData } from "../../context/data"
|
import { useData } from "../../context/data"
|
||||||
import { lastAssistantWithUsage } from "../../util/session"
|
import { contextUsage } from "../../util/session"
|
||||||
|
|
||||||
const id = "internal:sidebar-context"
|
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 session = createMemo(() => data.session.get(props.session_id))
|
||||||
const cost = createMemo(() => data.session.cost(props.session_id))
|
const cost = createMemo(() => data.session.cost(props.session_id))
|
||||||
|
|
||||||
const state = createMemo(() => {
|
const state = createMemo(() => contextUsage(msg(), data.location.model.list(session()?.location), session()?.revert?.messageID))
|
||||||
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,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<box>
|
<box>
|
||||||
<text fg={theme().text}>
|
<text fg={theme().text}>
|
||||||
<b>Context</b>
|
<b>Context</b>
|
||||||
</text>
|
</text>
|
||||||
<text fg={theme().textMuted}>{state().tokens.toLocaleString()} tokens</text>
|
<Show when={state()} fallback={<text fg={theme().textMuted}>Not measured</text>}>
|
||||||
<text fg={theme().textMuted}>{state().percent ?? 0}% used</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>
|
<text fg={theme().textMuted}>{money.format(cost())} spent</text>
|
||||||
</box>
|
</box>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1325,23 +1325,18 @@ function SessionSkillMessage(props: { message: Extract<SessionMessageInfo, { typ
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function CompactionMessage(props: {
|
function CompactionMessage(props: { message: Extract<SessionMessageInfo, { type: "compaction" }> }) {
|
||||||
message?: Extract<SessionMessageInfo, { type: "compaction" }>
|
|
||||||
status?: "running"
|
|
||||||
text?: string
|
|
||||||
}) {
|
|
||||||
const ctx = use()
|
const ctx = use()
|
||||||
const kv = useKV()
|
const kv = useKV()
|
||||||
const { theme, syntax } = useTheme()
|
const { theme, syntax } = useTheme()
|
||||||
const status = () => props.message?.status ?? props.status
|
const status = () => props.message.status
|
||||||
const text = () =>
|
const text = () => (props.message.status === "failed" ? props.message.error.message : props.message.summary)
|
||||||
props.message?.status === "failed" ? props.message.error.message : (props.message?.summary ?? props.text ?? "")
|
const content = createMemo(() => text().trim())
|
||||||
const color = () => (status() === "failed" ? theme.error : status() === "completed" ? theme.success : theme.textMuted)
|
const color = () => (status() === "failed" ? theme.error : theme.textMuted)
|
||||||
const border = color
|
|
||||||
return (
|
return (
|
||||||
<box>
|
<box>
|
||||||
<box flexDirection="row" alignItems="center">
|
<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}>
|
<box flexDirection="row" gap={1} paddingLeft={1} paddingRight={1}>
|
||||||
<Switch>
|
<Switch>
|
||||||
<Match when={status() === "running"}>
|
<Match when={status() === "running"}>
|
||||||
@@ -1349,24 +1344,21 @@ function CompactionMessage(props: {
|
|||||||
<spinner frames={SPINNER_FRAMES} interval={80} color={color()} />
|
<spinner frames={SPINNER_FRAMES} interval={80} color={color()} />
|
||||||
</Show>
|
</Show>
|
||||||
</Match>
|
</Match>
|
||||||
<Match when={status() === "completed"}>
|
|
||||||
<text fg={color()}>✓</text>
|
|
||||||
</Match>
|
|
||||||
<Match when={status() === "failed"}>
|
<Match when={status() === "failed"}>
|
||||||
<text fg={color()}>✗</text>
|
<text fg={color()}>✗</text>
|
||||||
</Match>
|
</Match>
|
||||||
</Switch>
|
</Switch>
|
||||||
<text fg={color()}>Compaction</text>
|
<text fg={color()}>Compaction</text>
|
||||||
</box>
|
</box>
|
||||||
<box border={["top"]} borderColor={border()} flexGrow={1} />
|
<box border={["top"]} borderColor={color()} flexGrow={1} />
|
||||||
</box>
|
</box>
|
||||||
<Show when={text().trim()}>
|
<Show when={content()}>
|
||||||
<box paddingTop={1} paddingLeft={3}>
|
<box paddingTop={1} paddingLeft={3}>
|
||||||
<markdown
|
<markdown
|
||||||
syntaxStyle={syntax()}
|
syntaxStyle={syntax()}
|
||||||
streaming={status() === "running"}
|
streaming={true}
|
||||||
internalBlockMode="top-level"
|
internalBlockMode="top-level"
|
||||||
content={text().trim()}
|
content={content()}
|
||||||
tableOptions={{ style: "grid" }}
|
tableOptions={{ style: "grid" }}
|
||||||
conceal={ctx.conceal()}
|
conceal={ctx.conceal()}
|
||||||
fg={theme.markdownText}
|
fg={theme.markdownText}
|
||||||
|
|||||||
@@ -88,7 +88,6 @@ export function createSessionRows(sessionID: Accessor<string>) {
|
|||||||
{
|
{
|
||||||
id: message.id,
|
id: message.id,
|
||||||
created: message.time.created,
|
created: message.time.created,
|
||||||
input: message.status === "running",
|
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
: [],
|
: [],
|
||||||
@@ -183,7 +182,9 @@ export function createSessionRows(sessionID: Accessor<string>) {
|
|||||||
}
|
}
|
||||||
const subscriptions = [
|
const subscriptions = [
|
||||||
data.on("session.input.admitted", input),
|
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.instructions.updated", message),
|
||||||
data.on("session.synthetic", (event) => {
|
data.on("session.synthetic", (event) => {
|
||||||
if (event.data.sessionID === sessionID() && event.data.description?.trim())
|
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.shell.started", message),
|
||||||
data.on("session.agent.selected", message),
|
data.on("session.agent.selected", message),
|
||||||
data.on("session.model.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) => {
|
data.on("session.text.delta", (event) => {
|
||||||
if (event.data.sessionID === sessionID())
|
if (event.data.sessionID === sessionID())
|
||||||
appendPart({ messageID: event.data.assistantMessageID, partID: `text:${event.data.ordinal}` })
|
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 { Locale } from "../../util/locale"
|
||||||
import { useTerminalDimensions } from "@opentui/solid"
|
import { useTerminalDimensions } from "@opentui/solid"
|
||||||
import { useCommandShortcut, useOpencodeKeymap } from "../../keymap"
|
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() {
|
export function SubagentFooter() {
|
||||||
const route = useRouteData("session")
|
const route = useRouteData("session")
|
||||||
@@ -23,26 +28,21 @@ export function SubagentFooter() {
|
|||||||
const usage = createMemo(() => {
|
const usage = createMemo(() => {
|
||||||
const current = session()
|
const current = session()
|
||||||
if (!current) return
|
if (!current) return
|
||||||
const last = lastAssistantWithUsage(data.session.message.list(route.sessionID), current.revert?.messageID)
|
const cost = current.cost
|
||||||
if (!last) return
|
const formattedCost = cost > 0 ? money.format(cost) : undefined
|
||||||
const tokens =
|
const context = contextUsage(
|
||||||
last.tokens.input + last.tokens.output + last.tokens.reasoning + last.tokens.cache.read + last.tokens.cache.write
|
data.session.message.list(route.sessionID),
|
||||||
if (tokens <= 0) return
|
data.location.model.list(current.location),
|
||||||
|
current.revert?.messageID,
|
||||||
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",
|
|
||||||
})
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
context: pct ? `${Locale.number(tokens)} (${pct})` : Locale.number(tokens),
|
context: context
|
||||||
cost: cost > 0 ? money.format(cost) : undefined,
|
? context.percent === undefined
|
||||||
|
? Locale.number(context.tokens)
|
||||||
|
: `${Locale.number(context.tokens)} (${context.percent}%)`
|
||||||
|
: undefined,
|
||||||
|
cost: formattedCost,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -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) {
|
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)
|
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) {
|
export function lastAssistantWithUsage(messages: ReadonlyArray<SessionMessageInfo>, boundary?: string) {
|
||||||
const boundaryIndex = boundary ? messages.findIndex((message) => message.id === boundary) : -1
|
const boundaryIndex = boundary ? messages.findIndex((message) => message.id === boundary) : -1
|
||||||
if (boundary && boundaryIndex === -1) return undefined
|
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(
|
return messages.findLast(
|
||||||
(message, index): message is SessionMessageAssistant & { tokens: NonNullable<SessionMessageAssistant["tokens"]> } =>
|
(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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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")
|
const message = data.session.message.get("session-manual", "message-compaction")
|
||||||
return message?.type === "compaction" && message.status === "running" && message.summary === "Streamed summary"
|
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, {
|
emitEvent(events, {
|
||||||
id: "evt_manual_compaction_ended",
|
id: "evt_manual_compaction_ended",
|
||||||
created: 3,
|
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([
|
expect(manualRows.filter((row) => row.type === "message")).toEqual([
|
||||||
{ type: "message", messageID: "message-compaction" },
|
{ type: "message", messageID: "message-compaction" },
|
||||||
])
|
])
|
||||||
|
expect(manualRows.find((row) => row.type === "message" && row.messageID === "message-compaction")).toBe(
|
||||||
|
compactionRow,
|
||||||
|
)
|
||||||
|
|
||||||
emitEvent(events, {
|
emitEvent(events, {
|
||||||
id: "evt_compaction_started",
|
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")
|
const message = data.session.message.get("session-live", "msg_compaction_started")
|
||||||
return message?.type === "compaction" && message.status === "running" && message.summary === "Live summary"
|
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, {
|
emitEvent(events, {
|
||||||
id: "evt_compaction_ended",
|
id: "evt_compaction_ended",
|
||||||
@@ -1119,6 +1124,10 @@ test("tracks session status from active sessions and execution events", async ()
|
|||||||
status: "completed",
|
status: "completed",
|
||||||
summary: "Live summary",
|
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 {
|
} finally {
|
||||||
app.renderer.destroy()
|
app.renderer.destroy()
|
||||||
}
|
}
|
||||||
@@ -2046,6 +2055,14 @@ test("renders admitted prompts immediately and tracks them until promoted", asyn
|
|||||||
await mounted
|
await mounted
|
||||||
const received: string[] = []
|
const received: string[] = []
|
||||||
const unsubscribe = sync.listen((event) => received.push(event.name))
|
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, {
|
emitEvent(events, {
|
||||||
id: "evt_admitted_1",
|
id: "evt_admitted_1",
|
||||||
created: 0,
|
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" },
|
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]
|
const admitted = sync.session.message.list(sessionID)?.[0]
|
||||||
expect(admitted).toMatchObject({ id: messageID, type: "user", text: "hello" })
|
expect(admitted).toMatchObject({ id: messageID, type: "user", text: "hello" })
|
||||||
expect(admitted?.metadata).toBeUndefined()
|
expect(admitted?.metadata).toBeUndefined()
|
||||||
expect(sync.session.input.list(sessionID)).toEqual([messageID])
|
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)
|
await sync.session.message.refresh(sessionID)
|
||||||
expect(sync.session.message.list(sessionID)?.[0]?.metadata).toBeUndefined()
|
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 () => {
|
test("projects live instruction updates with their message ID", async () => {
|
||||||
const events = createEventStream()
|
const events = createEventStream()
|
||||||
const calls = createFetch(undefined, events)
|
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> = {}) {
|
async function mountData(parents: Record<string, string>, costs: Record<string, number> = {}) {
|
||||||
const calls = createFetch((url) => {
|
const calls = createFetch((url) => {
|
||||||
const match = url.pathname.match(/^\/api\/session\/([^/]+)$/)
|
const match = url.pathname.match(/^\/api\/session\/([^/]+)$/)
|
||||||
if (match && match[1] !== "active")
|
if (match && match[1] !== "active") return json({ data: sessionInfo(match[1], parents[match[1]], costs[match[1]]) })
|
||||||
return json({ data: sessionInfo(match[1], parents[match[1]], costs[match[1]]) })
|
|
||||||
})
|
})
|
||||||
let data!: ReturnType<typeof useData>
|
let data!: ReturnType<typeof useData>
|
||||||
let ready!: () => void
|
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 () => {
|
test("totals family cost for roots and keeps subagent cost scoped", async () => {
|
||||||
const { data, app } = await mountData(
|
const { data, app } = await mountData({ grandchild: "child", child: "root" }, { root: 1, child: 2, grandchild: 3 })
|
||||||
{ grandchild: "child", child: "root" },
|
|
||||||
{ root: 1, child: 2, grandchild: 3 },
|
|
||||||
)
|
|
||||||
try {
|
try {
|
||||||
await data.session.refresh("grandchild")
|
await data.session.refresh("grandchild")
|
||||||
await data.session.refresh("child")
|
await data.session.refresh("child")
|
||||||
|
|||||||
@@ -2,6 +2,16 @@ import { describe, expect, test } from "bun:test"
|
|||||||
import type { SessionMessageInfo } from "@opencode-ai/sdk/v2"
|
import type { SessionMessageInfo } from "@opencode-ai/sdk/v2"
|
||||||
import { isDefaultTitle, lastAssistantWithUsage } from "../../src/util/session"
|
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", () => {
|
describe("util.session", () => {
|
||||||
test("recognizes generated parent and child titles", () => {
|
test("recognizes generated parent and child titles", () => {
|
||||||
expect(isDefaultTitle("New session - 2026-06-06T12:34:56.789Z")).toBeTrue()
|
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", () => {
|
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)]
|
const messages = [assistant("msg_z", 10), assistant("msg_a", 30)]
|
||||||
|
|
||||||
expect(lastAssistantWithUsage(messages)?.tokens.input).toBe(30)
|
expect(lastAssistantWithUsage(messages)?.tokens.input).toBe(30)
|
||||||
@@ -26,4 +27,22 @@ describe("util.session", () => {
|
|||||||
expect(lastAssistantWithUsage(messages, "msg_missing")).toBeUndefined()
|
expect(lastAssistantWithUsage(messages, "msg_missing")).toBeUndefined()
|
||||||
expect(lastAssistantWithUsage(messages)?.tokens.input).toBe(30)
|
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)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user