Compare commits

..
Author SHA1 Message Date
Kit Langton cbbb83ec1c refactor(core): reuse Markdown chunk byte counts 2026-08-28 23:51:02 -04:00
23 changed files with 177 additions and 1197 deletions
+1 -3
View File
@@ -5,7 +5,6 @@ on:
branches:
- dev
- production
- beta
workflow_dispatch:
concurrency: ${{ github.workflow }}-${{ github.ref }}
@@ -16,7 +15,7 @@ permissions:
jobs:
deploy:
if: github.repository == 'anomalyco/opencode' && (github.ref_name == 'dev' || github.ref_name == 'production' || github.ref_name == 'beta')
if: github.repository == 'anomalyco/opencode' && (github.ref_name == 'dev' || github.ref_name == 'production')
runs-on: ubuntu-latest
environment: ${{ github.ref_name }}
steps:
@@ -29,7 +28,6 @@ jobs:
node-version: "24"
- uses: aws-actions/configure-aws-credentials@7474bc4690e29a8392af63c5b98e7449536d5c3a # v4.3.1
if: github.ref_name != 'beta'
with:
role-to-assume: ${{ vars.AWS_DEPLOY_ROLE_ARN }}
role-session-name: opencode-${{ github.run_id }}
+9 -2
View File
@@ -1,5 +1,4 @@
import { domain } from "./stage"
import { createWebApp } from "./webapp"
const GITHUB_APP_ID = new sst.Secret("GITHUB_APP_ID")
const GITHUB_APP_PRIVATE_KEY = new sst.Secret("GITHUB_APP_PRIVATE_KEY")
@@ -60,4 +59,12 @@ new sst.cloudflare.x.Astro("Web", {
},
})
createWebApp("app." + domain)
new sst.cloudflare.StaticSite("WebApp", {
domain: "app." + domain,
path: "packages/app",
build: {
// Preserve Sentry credentials and run source-map uploads on every deployment.
command: "bun run build",
output: "./dist",
},
})
-18
View File
@@ -1,18 +0,0 @@
export function createWebApp(domain: string) {
return new sst.cloudflare.StaticSite("WebApp", {
domain,
path: "packages/app",
environment:
$app.stage === "beta"
? {
OPENCODE_CHANNEL: "beta",
VITE_SENTRY_ENVIRONMENT: "beta",
}
: undefined,
build: {
// Preserve Sentry credentials and run source-map uploads on every deployment.
command: "bun run build",
output: "./dist",
},
})
}
+1 -22
View File
@@ -71,25 +71,4 @@ Environment options:
## Deployment
The `deploy` GitHub Actions workflow uses SST to deploy the web app from these branches in `anomalyco/opencode`:
| Branch | Site |
| ------------ | --------------------- |
| `dev` | `app.dev.opencode.ai` |
| `production` | `app.opencode.ai` |
| `beta` | `beta.opencode.ai` |
Changes merged into `v2` reach the beta site when they are promoted to `beta`. The beta SST stage deploys
only the web app, using the same `WebApp` StaticSite definition as production. It sets the build channel
and Sentry environment to `beta` without deploying the API, console, database, or billing infrastructure.
The hosted app defaults to `http://localhost:49374`, matching the managed V2 service. Saved server selections
override this default. Connecting still requires the service's credentials.
The workflow reuses the repository's `CLOUDFLARE_API_TOKEN` and web Sentry settings. The Cloudflare token
must cover SST's R2 state storage, KV assets, Workers, and custom-domain management in the account that
owns `opencode.ai`. The beta GitHub environment must allow deployments from the `beta` branch; it does not
need AWS credentials.
SST manages the beta site's custom domain. The first deployment creates its DNS record and TLS certificate.
Do not create a CNAME for `beta.opencode.ai` first, because it would conflict with the Workers custom domain.
You can deploy the `dist` folder to any static host provider (netlify, surge, now, etc.)
+1 -1
View File
@@ -53,7 +53,7 @@ export function createWebPlatform(version: string) {
}
function getCurrentServerUrl() {
if (location.hostname.includes("opencode.ai")) return "http://localhost:49374"
if (location.hostname.includes("opencode.ai")) return "http://localhost:4096"
if (import.meta.env.DEV)
return `http://${import.meta.env.VITE_OPENCODE_SERVER_HOST ?? "localhost"}:${import.meta.env.VITE_OPENCODE_SERVER_PORT ?? "4096"}`
return location.origin
+2 -2
View File
@@ -43,12 +43,12 @@ export default function Layout(props: ParentProps) {
style={{
"padding-top": "env(safe-area-inset-top, 0px)",
"padding-bottom": "env(safe-area-inset-bottom, 0px)",
// Native Windows chrome supplies the gap; retain paint clearance for the panels' outer outlines.
// The native Windows titlebar already includes the gap above the content panels.
"--shell-top-inset":
platform.platform === "desktop" &&
platform.os === "windows" &&
!(mobile() && preferences.general.mobileTitlebarPosition() === "bottom")
? "1px"
? "0px"
: "8px",
}}
>
+2 -2
View File
@@ -46,12 +46,12 @@ export const register = Effect.fn("ConfigMCPPlugin.register")(function* (
const servers = new Map<string, ServerConfig>()
for (const document of documents) {
for (const [name, server] of Object.entries(document.info.mcp?.servers ?? {})) {
servers.set(name, server)
servers.set(name, { ...server, timeout: { ...timeout, ...server.timeout } })
}
}
for (const [name, server] of servers) {
if (draft.get(name)) continue
draft.set(name, { ...server, timeout: { ...timeout, ...server.timeout } })
draft.set(name, server)
}
})
})
+113 -11
View File
@@ -3,8 +3,9 @@ export * from "./session/schema.js"
import { Cause, Effect, Layer, Schema, Context, RcMap, Stream, Scope } from "effect"
import { ListAnchor } from "@opencode-ai/schema/session"
import { and, desc, eq } from "drizzle-orm"
import { and, asc, desc, eq, gt, isNull, like, lt, or, type SQL } from "drizzle-orm"
import { Project } from "./project.js"
import { Workspace } from "@opencode-ai/schema/workspace"
import { Model } from "@opencode-ai/schema/model"
import { Location } from "./location.js"
import { SessionMessage } from "./session/message.js"
@@ -12,13 +13,14 @@ import { PromptInput } from "@opencode-ai/schema/prompt-input"
import { Bus } from "./bus.js"
import { Database } from "./database/database.js"
import { SessionProjector } from "./session/projector.js"
import { SessionMessageTable } from "./session/sql.js"
import { SessionMessageTable, SessionTable } from "./session/sql.js"
import { SessionSchema } from "./session/schema.js"
import { AbsolutePath, RelativePath } from "./schema.js"
import { AbsolutePath, PositiveInt, RelativePath } from "./schema.js"
import { Agent } from "@opencode-ai/schema/agent"
import { App } from "./app.js"
import { Slug } from "./util/slug.js"
import path from "path"
import { fromRow } from "./session/info.js"
import { SessionRunner } from "./session/runner/index.js"
import { SessionStore } from "./session/store.js"
import { SessionExecution } from "./session/execution.js"
@@ -56,6 +58,7 @@ import { Job } from "./job.js"
import { Command } from "./command.js"
import { Global } from "@opencode-ai/util/global"
import { SessionEnvironment } from "./session/environment.js"
import { SessionHistory } from "./session/history.js"
import { InstructionEntry } from "./session/instruction-entry.js"
// get project -> project.locations
@@ -69,8 +72,30 @@ import { InstructionEntry } from "./session/instruction-entry.js"
export { ListAnchor }
export const ListInput = SessionStore.ListInput
export type ListInput = SessionStore.ListInput
const ListInputBase = {
workspaceID: Workspace.ID.pipe(Schema.optional),
search: Schema.String.pipe(Schema.optional),
limit: PositiveInt.pipe(Schema.optional),
order: Schema.Literals(["asc", "desc"]).pipe(Schema.optional),
parentID: Schema.NullOr(SessionSchema.ID).pipe(Schema.optional),
anchor: ListAnchor.pipe(Schema.optional),
}
const ListDirectoryInput = Schema.Struct({
...ListInputBase,
directory: AbsolutePath,
})
const ListProjectInput = Schema.Struct({
...ListInputBase,
project: Project.ID,
subpath: RelativePath.pipe(Schema.optional),
})
const ListAllInput = Schema.Struct(ListInputBase)
export const ListInput = Schema.Union([ListDirectoryInput, ListProjectInput, ListAllInput])
export type ListInput = typeof ListInput.Type
type CreateBaseInput = {
id?: SessionSchema.ID
@@ -136,9 +161,15 @@ export interface Interface {
}) => Effect.Effect<SessionEnvironment.Variables | undefined, NotFoundError>
readonly view: (input: { sessionID: SessionSchema.ID; idle: number }) => Effect.Effect<void, NotFoundError>
readonly remove: (sessionID: SessionSchema.ID) => Effect.Effect<void, NotFoundError>
readonly messages: (
input: SessionStore.MessagesInput,
) => Effect.Effect<SessionMessage.Info[], NotFoundError | MessageDecodeError>
readonly messages: (input: {
sessionID: SessionSchema.ID
limit?: number
order?: "asc" | "desc"
cursor?: {
id: SessionMessage.ID
direction: "previous" | "next"
}
}) => Effect.Effect<SessionMessage.Info[], NotFoundError | MessageDecodeError>
readonly message: (input: {
sessionID: SessionSchema.ID
messageID: SessionMessage.ID
@@ -378,12 +409,83 @@ const layer = Layer.effect(
yield* bus.publish(SessionEvent.Deleted, { sessionID })
yield* bus.remove(sessionID)
}),
list: Effect.fn("Session.list")(function* (input) {
return { data: yield* store.list(input) }
list: Effect.fn("Session.list")(function* (input = {}) {
const direction = input.anchor?.direction ?? "next"
const requestedOrder = input.order ?? "desc"
const order = direction === "previous" ? (requestedOrder === "asc" ? "desc" : "asc") : requestedOrder
const sortColumn = SessionTable.time_updated
const conditions: SQL[] = []
if ("directory" in input) conditions.push(eq(SessionTable.directory, input.directory))
if (input.workspaceID) conditions.push(eq(SessionTable.workspace_id, input.workspaceID))
if ("project" in input) conditions.push(eq(SessionTable.project_id, input.project))
if ("project" in input && input.subpath !== undefined) conditions.push(eq(SessionTable.path, input.subpath))
if (input.search) conditions.push(like(SessionTable.title, `%${input.search}%`))
if (input.parentID !== undefined)
conditions.push(
input.parentID === null ? isNull(SessionTable.parent_id) : eq(SessionTable.parent_id, input.parentID),
)
if (input.anchor) {
conditions.push(
order === "asc"
? or(
gt(sortColumn, input.anchor.time),
and(eq(sortColumn, input.anchor.time), gt(SessionTable.id, input.anchor.id)),
)!
: or(
lt(sortColumn, input.anchor.time),
and(eq(sortColumn, input.anchor.time), lt(SessionTable.id, input.anchor.id)),
)!,
)
}
const query = db
.select()
.from(SessionTable)
.where(conditions.length > 0 ? and(...conditions) : undefined)
.orderBy(
order === "asc" ? asc(sortColumn) : desc(sortColumn),
order === "asc" ? asc(SessionTable.id) : desc(SessionTable.id),
)
const rows = yield* (input.limit === undefined ? query.all() : query.limit(input.limit).all()).pipe(
Effect.orDie,
)
return { data: (direction === "previous" ? rows.toReversed() : rows).map((row) => fromRow(row)) }
}),
messages: Effect.fn("Session.messages")(function* (input) {
yield* result.get(input.sessionID)
return yield* store.messages(input)
const direction = input.cursor?.direction ?? "next"
const requestedOrder = input.order ?? "desc"
const order = direction === "previous" ? (requestedOrder === "asc" ? "desc" : "asc") : requestedOrder
const anchor = input.cursor
? yield* db
.select({ seq: SessionMessageTable.seq })
.from(SessionMessageTable)
.where(
and(eq(SessionMessageTable.session_id, input.sessionID), eq(SessionMessageTable.id, input.cursor.id)),
)
.get()
.pipe(Effect.orDie)
: undefined
if (input.cursor && !anchor) return []
const boundary = anchor
? order === "asc"
? gt(SessionMessageTable.seq, anchor.seq)
: lt(SessionMessageTable.seq, anchor.seq)
: undefined
const where = boundary
? and(eq(SessionMessageTable.session_id, input.sessionID), boundary)
: eq(SessionMessageTable.session_id, input.sessionID)
const query = db
.select()
.from(SessionMessageTable)
.where(where)
.orderBy(order === "asc" ? asc(SessionMessageTable.seq) : desc(SessionMessageTable.seq))
const rows = yield* (input.limit === undefined ? query.all() : query.limit(input.limit).all()).pipe(
Effect.orDie,
)
return yield* Effect.forEach(
direction === "previous" ? rows.toReversed() : rows,
SessionHistory.decodeMessageRow,
)
}),
message: (input) => sessions.forSession(input.sessionID).message(input.messageID),
updateMessage: (input) => sessions.forSession(input.sessionID).updateMessage(input),
+2 -119
View File
@@ -1,10 +1,7 @@
export * as SessionStore from "./store.js"
import { and, asc, desc, eq, gt, isNotNull, isNull, like, lt, notInArray, or, sql, type SQL } from "drizzle-orm"
import { Context, Effect, Layer, Schema } from "effect"
import { Project } from "@opencode-ai/schema/project"
import { Workspace } from "@opencode-ai/schema/workspace"
import { AbsolutePath, PositiveInt, RelativePath } from "@opencode-ai/schema/schema"
import { and, eq, isNotNull, isNull, notInArray, sql } from "drizzle-orm"
import { Context, Effect, Layer } from "effect"
import { Database } from "../database/database.js"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import { SessionHistory } from "./history.js"
@@ -14,45 +11,8 @@ import { Session } from "@opencode-ai/schema/session"
import { SessionMessageTable, SessionTable } from "./sql.js"
import { fromRow } from "./info.js"
const ListInputBase = {
workspaceID: Workspace.ID.pipe(Schema.optional),
search: Schema.String.pipe(Schema.optional),
limit: PositiveInt.pipe(Schema.optional),
order: Schema.Literals(["asc", "desc"]).pipe(Schema.optional),
parentID: Schema.NullOr(Session.ID).pipe(Schema.optional),
anchor: Session.ListAnchor.pipe(Schema.optional),
}
const ListDirectoryInput = Schema.Struct({
...ListInputBase,
directory: AbsolutePath,
})
const ListProjectInput = Schema.Struct({
...ListInputBase,
project: Project.ID,
subpath: RelativePath.pipe(Schema.optional),
})
const ListAllInput = Schema.Struct(ListInputBase)
export const ListInput = Schema.Union([ListDirectoryInput, ListProjectInput, ListAllInput])
export type ListInput = typeof ListInput.Type
export type MessagesInput = {
sessionID: Session.ID
limit?: number
order?: "asc" | "desc"
cursor?: {
id: SessionMessage.ID
direction: "previous" | "next"
}
}
export interface Interface {
readonly get: (sessionID: Session.ID) => Effect.Effect<Session.Info | undefined>
readonly list: (input?: ListInput) => Effect.Effect<Session.Info[]>
readonly messages: (input: MessagesInput) => Effect.Effect<SessionMessage.Info[], MessageDecodeError>
readonly context: (sessionID: Session.ID) => Effect.Effect<SessionMessage.Info[], MessageDecodeError>
readonly message: (
messageID: SessionMessage.ID,
@@ -95,83 +55,6 @@ const layer = Layer.effect(
const row = yield* db.select().from(SessionTable).where(eq(SessionTable.id, sessionID)).get().pipe(Effect.orDie)
return row ? fromRow(row) : undefined
}),
list: Effect.fn("SessionStore.list")(function* (input = {}) {
const direction = input.anchor?.direction ?? "next"
const requestedOrder = input.order ?? "desc"
const order = direction === "previous" ? (requestedOrder === "asc" ? "desc" : "asc") : requestedOrder
const sortColumn = SessionTable.time_updated
const conditions: SQL[] = []
if ("directory" in input) conditions.push(eq(SessionTable.directory, input.directory))
if (input.workspaceID) conditions.push(eq(SessionTable.workspace_id, input.workspaceID))
if ("project" in input) conditions.push(eq(SessionTable.project_id, input.project))
if ("project" in input && input.subpath !== undefined) conditions.push(eq(SessionTable.path, input.subpath))
if (input.search) conditions.push(like(SessionTable.title, `%${input.search}%`))
if (input.parentID !== undefined)
conditions.push(
input.parentID === null ? isNull(SessionTable.parent_id) : eq(SessionTable.parent_id, input.parentID),
)
if (input.anchor) {
conditions.push(
order === "asc"
? or(
gt(sortColumn, input.anchor.time),
and(eq(sortColumn, input.anchor.time), gt(SessionTable.id, input.anchor.id)),
)!
: or(
lt(sortColumn, input.anchor.time),
and(eq(sortColumn, input.anchor.time), lt(SessionTable.id, input.anchor.id)),
)!,
)
}
const query = db
.select()
.from(SessionTable)
.where(conditions.length > 0 ? and(...conditions) : undefined)
.orderBy(
order === "asc" ? asc(sortColumn) : desc(sortColumn),
order === "asc" ? asc(SessionTable.id) : desc(SessionTable.id),
)
const rows = yield* (input.limit === undefined ? query.all() : query.limit(input.limit).all()).pipe(
Effect.orDie,
)
return (direction === "previous" ? rows.toReversed() : rows).map((row) => fromRow(row))
}),
messages: Effect.fn("SessionStore.messages")(function* (input) {
const direction = input.cursor?.direction ?? "next"
const requestedOrder = input.order ?? "desc"
const order = direction === "previous" ? (requestedOrder === "asc" ? "desc" : "asc") : requestedOrder
const anchor = input.cursor
? yield* db
.select({ seq: SessionMessageTable.seq })
.from(SessionMessageTable)
.where(
and(eq(SessionMessageTable.session_id, input.sessionID), eq(SessionMessageTable.id, input.cursor.id)),
)
.get()
.pipe(Effect.orDie)
: undefined
if (input.cursor && !anchor) return []
const boundary = anchor
? order === "asc"
? gt(SessionMessageTable.seq, anchor.seq)
: lt(SessionMessageTable.seq, anchor.seq)
: undefined
const where = boundary
? and(eq(SessionMessageTable.session_id, input.sessionID), boundary)
: eq(SessionMessageTable.session_id, input.sessionID)
const query = db
.select()
.from(SessionMessageTable)
.where(where)
.orderBy(order === "asc" ? asc(SessionMessageTable.seq) : desc(SessionMessageTable.seq))
const rows = yield* (input.limit === undefined ? query.all() : query.limit(input.limit).all()).pipe(
Effect.orDie,
)
return yield* Effect.forEach(
direction === "previous" ? rows.toReversed() : rows,
SessionHistory.decodeMessageRow,
)
}),
context: Effect.fn("SessionStore.context")((sessionID) => SessionHistory.load(db, sessionID)),
message: Effect.fn("SessionStore.message")(function* (messageID) {
const row = yield* db
+1 -1
View File
@@ -95,7 +95,7 @@ export function convertHTMLToMarkdown(html: string) {
const remaining = limit - outputBytes
const next = bytes.byteLength <= remaining ? value : sliceBytes(value, remaining)
output.push(next)
outputBytes += encoder.encode(next).byteLength
outputBytes += bytes.byteLength <= remaining ? bytes.byteLength : encoder.encode(next).byteLength
last = next.at(-1) ?? last
}
const appendRaw = (value: string) => {
-62
View File
@@ -1207,68 +1207,6 @@ test("adds, disconnects, and reconnects MCP servers at runtime", async () => {
)
})
testEffect(Layer.empty).live(
"merges MCP defaults into the winning configured server without changing runtime overrides",
() =>
Effect.gen(function* () {
const entries = [
new Document({
type: "document",
info: new Info({
mcp: new ConfigMCP.Info({
timeout: { startup: 10, catalog: 20, execution: 30 },
servers: {
resources: { type: "local", command: ["earlier"], disabled: true, timeout: { execution: 90 } },
},
}),
}),
}),
new Document({
type: "document",
info: new Info({
mcp: new ConfigMCP.Info({
timeout: { catalog: 40 },
servers: {
resources: { type: "local", command: ["later"], disabled: true, timeout: { startup: 50 } },
},
}),
}),
}),
]
const original = JSON.stringify(entries)
yield* Effect.gen(function* () {
const service = yield* Mcp.Service
const check = yield* service.transform((draft) => {
expect(draft.get("resources")).toEqual({
type: "local",
command: ["later"],
disabled: true,
timeout: { startup: 50, catalog: 40, execution: 30 },
})
})
yield* check.dispose
const runtime = {
type: "local",
command: ["runtime"],
disabled: true,
timeout: { catalog: 60 },
} satisfies ConfigMCP.Local
yield* service.add("resources", runtime)
yield* service.reload()
yield* service.transform((draft) => {
expect(draft.get("resources")).toEqual(runtime)
})
}).pipe(
Effect.provide(
resourceMcpLayer("https://unused.example", undefined, undefined, {
entries: () => Effect.succeed(entries),
}),
),
)
expect(JSON.stringify(entries)).toBe(original)
}),
)
testEffect(resourceMcpLayer(new ConfigMCP.Local({ type: "local", command: ["unused"], disabled: true }))).live(
"manages live MCP servers entirely through scoped transforms",
() =>
+3 -22
View File
@@ -21,7 +21,6 @@ import { SessionProjector } from "@opencode-ai/core/session/projector"
import { SessionExecution } from "@opencode-ai/core/session/execution"
import { fromRow } from "@opencode-ai/core/session/info"
import { SessionInbox } from "@opencode-ai/core/session/inbox"
import { SessionStore } from "@opencode-ai/core/session/store"
import { Shell } from "@opencode-ai/schema/shell"
import {
InstructionStateTable,
@@ -33,10 +32,9 @@ import { testEffect } from "./lib/effect"
import { Snapshot } from "@opencode-ai/core/snapshot"
const it = testEffect(
AppNodeBuilder.build(
LayerNode.group([Database.node, Bus.node, SessionProjector.node, SessionInbox.node, SessionStore.node]),
[[Bus.node, Bus.configured({ persist: true })]],
),
AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, SessionProjector.node, SessionInbox.node]), [
[Bus.node, Bus.configured({ persist: true })],
]),
)
const sessionsLayer = AppNodeBuilder.build(Session.node, [[SessionExecution.node, SessionExecution.noopLayer]])
const sessionID = Session.ID.make("ses_projector_test")
@@ -280,9 +278,7 @@ describe("SessionProjector", () => {
yield* db.run(sql`update session_message set data = '{"time":{"created":0}}' where id = ${messageID}`)
const sessions = yield* Session.Service
const store = yield* SessionStore.Service
const expected = { _tag: "Session.MessageDecodeError", sessionID, messageID }
expect(yield* store.messages({ sessionID }).pipe(Effect.flip)).toMatchObject(expected)
expect(yield* sessions.messages({ sessionID }).pipe(Effect.flip)).toMatchObject(expected)
expect(yield* sessions.context(sessionID).pipe(Effect.flip)).toMatchObject(expected)
expect(yield* sessions.message({ sessionID, messageID }).pipe(Effect.catchDefect(Effect.succeed))).toMatchObject(
@@ -291,21 +287,6 @@ describe("SessionProjector", () => {
}).pipe(Effect.provide(sessionsLayer)),
)
it.effect("checks session existence before resolving a missing message cursor", () =>
Effect.gen(function* () {
const sessions = yield* Session.Service
const missing = Session.ID.make("ses_missing")
expect(
yield* sessions
.messages({
sessionID: missing,
cursor: { id: SessionMessage.ID.make("msg_missing"), direction: "next" },
})
.pipe(Effect.flip),
).toEqual(new Session.NotFoundError({ sessionID: missing }))
}).pipe(Effect.provide(sessionsLayer)),
)
it.effect("consumes the pending row and projects the message at promotion", () =>
Effect.gen(function* () {
const db = yield* seedSession()
-190
View File
@@ -1,190 +0,0 @@
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { Bus } from "@opencode-ai/core/bus"
import { Database } from "@opencode-ai/core/database/database"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { ProjectTable } from "@opencode-ai/core/project/sql"
import { SessionProjector } from "@opencode-ai/core/session/projector"
import { SessionStore } from "@opencode-ai/core/session/store"
import { Event } from "@opencode-ai/schema/event"
import { Project } from "@opencode-ai/schema/project"
import { AbsolutePath } from "@opencode-ai/schema/schema"
import { Session } from "@opencode-ai/schema/session"
import { SessionEvent } from "@opencode-ai/schema/session-event"
import { SessionMessage } from "@opencode-ai/schema/session-message"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { testEffect } from "./lib/effect"
const it = testEffect(
AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, SessionProjector.node, SessionStore.node]), [
[Bus.node, Bus.configured({ persist: true })],
]),
)
const seedSessions = (rows: { id: string; updated: number }[]) =>
Effect.gen(function* () {
const database = yield* Database.Service
const bus = yield* Bus.Service
const directory = AbsolutePath.make("/project")
yield* database.db.insert(ProjectTable).values({ id: Project.ID.global, worktree: directory, sandboxes: [] }).run()
yield* Effect.forEach(rows, (row) =>
Effect.gen(function* () {
const sessionID = Session.ID.make(row.id)
yield* bus.publish(SessionEvent.Created, {
sessionID,
projectID: Project.ID.global,
location: { directory },
slug: "store-test",
version: "test",
})
yield* bus.replay({
id: Event.ID.create(),
created: row.updated,
aggregateID: sessionID,
seq: 1,
type: Bus.versionedType(SessionEvent.Renamed.type, 1),
data: { sessionID, title: row.id },
})
}),
)
return bus
})
describe("SessionStore", () => {
it.effect("lists by updated time and ID with exclusive two-item pages in either direction", () =>
Effect.gen(function* () {
yield* seedSessions([
{ id: "ses_d", updated: 20 },
{ id: "ses_z", updated: 10 },
{ id: "ses_a", updated: 30 },
{ id: "ses_c", updated: 20 },
{ id: "ses_y", updated: 10 },
{ id: "ses_e", updated: 30 },
{ id: "ses_b", updated: 20 },
])
const store = yield* SessionStore.Service
expect((yield* store.list()).map((session) => String(session.id))).toEqual([
"ses_e",
"ses_a",
"ses_d",
"ses_c",
"ses_b",
"ses_z",
"ses_y",
])
expect((yield* store.list({ order: "asc" })).map((session) => String(session.id))).toEqual([
"ses_y",
"ses_z",
"ses_b",
"ses_c",
"ses_d",
"ses_a",
"ses_e",
])
const pages: { order: "asc" | "desc"; direction: "next" | "previous"; ids: string[] }[] = [
{ order: "asc", direction: "next", ids: ["ses_d", "ses_a"] },
{ order: "asc", direction: "previous", ids: ["ses_z", "ses_b"] },
{ order: "desc", direction: "next", ids: ["ses_b", "ses_z"] },
{ order: "desc", direction: "previous", ids: ["ses_a", "ses_d"] },
]
yield* Effect.forEach(pages, (page) =>
Effect.gen(function* () {
const sessions = yield* store.list({
order: page.order,
limit: 2,
anchor: { id: Session.ID.make("ses_c"), time: 20, direction: page.direction },
})
expect(sessions.map((session) => String(session.id))).toEqual(page.ids)
}),
)
}),
)
it.effect("pages messages by durable sequence, not timestamp or ID, and scopes cursor lookup", () =>
Effect.gen(function* () {
const sessionID = Session.ID.make("ses_messages")
const foreignID = Session.ID.make("ses_foreign")
const bus = yield* seedSessions([
{ id: sessionID, updated: 0 },
{ id: foreignID, updated: 0 },
])
const store = yield* SessionStore.Service
yield* Effect.forEach(
[
{ id: "evt_z", created: 300 },
{ id: "evt_b", created: 700 },
{ id: "evt_x", created: 100 },
{ id: "evt_c", created: 400 },
{ id: "evt_w", created: 200 },
{ id: "evt_a", created: 600 },
{ id: "evt_y", created: 500 },
],
(event, index) =>
bus.replay({
id: Event.ID.make(event.id),
created: event.created,
aggregateID: sessionID,
seq: index + 2,
type: Bus.versionedType(SessionEvent.Synthetic.type, 1),
data: { sessionID, text: event.id },
}),
)
yield* bus.publish(
SessionEvent.Synthetic,
{ sessionID: foreignID, text: "foreign" },
{
id: Event.ID.make("evt_foreign"),
},
)
expect((yield* store.messages({ sessionID })).map((message) => String(message.id))).toEqual([
"msg_y",
"msg_a",
"msg_w",
"msg_c",
"msg_x",
"msg_b",
"msg_z",
])
expect((yield* store.messages({ sessionID, order: "asc" })).map((message) => String(message.id))).toEqual([
"msg_z",
"msg_b",
"msg_x",
"msg_c",
"msg_w",
"msg_a",
"msg_y",
])
const pages: { order: "asc" | "desc"; direction: "next" | "previous"; ids: string[] }[] = [
{ order: "asc", direction: "next", ids: ["msg_w", "msg_a"] },
{ order: "asc", direction: "previous", ids: ["msg_b", "msg_x"] },
{ order: "desc", direction: "next", ids: ["msg_x", "msg_b"] },
{ order: "desc", direction: "previous", ids: ["msg_a", "msg_w"] },
]
yield* Effect.forEach(pages, (page) =>
Effect.gen(function* () {
const messages = yield* store.messages({
sessionID,
order: page.order,
limit: 2,
cursor: { id: SessionMessage.ID.make("msg_c"), direction: page.direction },
})
expect(messages.map((message) => String(message.id))).toEqual(page.ids)
}),
)
expect(yield* store.messages({ sessionID: Session.ID.make("ses_missing") })).toEqual([])
expect(
yield* store.messages({
sessionID,
cursor: { id: SessionMessage.ID.make("msg_missing"), direction: "next" },
}),
).toEqual([])
expect(
yield* store.messages({
sessionID,
order: "asc",
cursor: { id: SessionMessage.ID.make("msg_foreign"), direction: "next" },
}),
).toEqual([])
}),
)
})
-2
View File
@@ -2,7 +2,6 @@ import { expect, test } from "bun:test"
import { Schema } from "effect"
import { Agent } from "@opencode-ai/core/agent"
import { Session } from "@opencode-ai/core/session"
import { SessionStore } from "@opencode-ai/core/session/store"
import { Location } from "@opencode-ai/schema/location"
import { Model } from "@opencode-ai/schema/model"
import { Provider } from "@opencode-ai/schema/provider"
@@ -127,7 +126,6 @@ test("Core reuses the canonical shared schemas", async () => {
[Session.ID, schemaSession.Session.ID],
[Session.Info, schemaSession.Session.Info],
[Session.ListAnchor, schemaSession.Session.ListAnchor],
[Session.ListInput, SessionStore.ListInput],
[coreSessionInbox.Delivery, SessionInbox.Delivery],
[coreSessionInbox.Item, SessionInbox.Item],
[coreSessionInbox.User, SessionInbox.User],
+9
View File
@@ -128,6 +128,15 @@ describe("WebFetchTool helpers", () => {
expect(output).toHaveLength(WebFetchTool.MAX_RESPONSE_BYTES - 64 * 1024)
})
test.each(["x", "\u00e9", "\u{1f600}"])("preserves UTF-8 boundaries at the content limit for %s", (character) => {
const budget = WebFetchTool.MAX_RESPONSE_BYTES - 64 * 1024
const fitting = "aa" + character.repeat(Math.floor((budget - 2) / Buffer.byteLength(character)))
expect(WebFetchTool.convertHTMLToMarkdown(fitting)).toBe(fitting)
const truncated = WebFetchTool.convertHTMLToMarkdown(fitting + character)
expect(truncated).toBe(fitting)
expect(Buffer.byteLength(truncated)).toBe(Buffer.byteLength(fitting))
})
test("bounds deeply nested list output and fragmented code fences", () => {
const lists = `${"<ul><li>item".repeat(2_000)}${"</li></ul>".repeat(2_000)}`
const quotes = `${"<blockquote><p>item".repeat(2_000)}${"</p></blockquote>".repeat(2_000)}`
-106
View File
@@ -1,106 +0,0 @@
# Session Tab Switching
## Run
Install OpenCode Drive, then run from the repository root:
```sh
PERF_RUN=before \
OPENCODE_DRIVE_MEDIA_DIR="$PWD/.cache/tui-switch/media" \
opencode-drive run script/bench-tui-tabs.ts
```
Use a new `PERF_RUN` for each run; existing result directories are not overwritten.
`PERF_TARGET` selects another source worktree and defaults to the working directory.
`PERF_OUTPUT` overrides the result root, which defaults to `<target>/.cache/tui-switch`.
`PERF_CONTENT=prose` replaces the Markdown with equal-byte-size plain text.
Drive checks the script, creates an isolated server/home/project, imports synthetic
sessions through the real API, and launches the real TUI components. It never
connects to the elected background service. Only the final streaming correctness
check prompts a model, and that model is simulated.
Run benchmarks serially, without simultaneous tests or builds. The script records
the target revision and its production TUI/Client diff, every completed action in
`samples.jsonl`, summary statistics, terminal frames, and Drive artifact metadata.
It retains failed runs' completed samples. Result and media directories are local
artifacts, not files to commit.
## Workload
- SHORT: 20 messages, 256 text bytes per assistant.
- LONG: 2,000 messages with the same text sizes and a comparable latest page.
- LARGE: 20 messages, 32 KiB per assistant.
- Every fifth assistant includes a completed synthetic read-tool result.
- Historical fixtures carry creation/completion times but omit stream-end/token
accounting. Correctness tests exercise complete timing and token metadata.
- Markdown deliberately repeats small fenced blocks, about 170 per LARGE
assistant. It is a stress fixture, not a typical-response latency claim.
The TUI starts after import. It opens sessions through the picker, switches with
the real keybindings, loads all LONG history, and returns to both its tail and a
saved head anchor. Each warm category has one explicitly retained warm-up sample
(`sample: 0`) and eight measured observations. Initial opens and first/last-message
navigation are single observations and are not included in warm medians.
Location caveat: this runner supplies `info.location` but omits the import API's
top-level `location`. Imported sessions therefore use the isolated server's
working directory, not the fixture `files` directory. The results describe warm
cross-Location sessions, not same-Location restoration or cold project loading.
Both directories are private synthetic fixtures; no live user Location is used.
`actionMs` is Drive's action RPC duration, including its forced render and UI-tree
inspection. `visibleMs` additionally waits for a destination marker, with 20 ms
polling. Neither is physical-terminal input-to-paint latency or a styled-content
completion guarantee. Fixed inter-action pacing occurs outside the measured
interval and is not used instead of readiness checks.
The final check streams an incomplete ordinary fence, completes it, verifies its
final displayed text, and checks the server's completed assistant projection.
Normal tests separately cover custom Markdown and footer correctness.
## Initial Experiments
Base: `849824efd2`, Bun 1.3.14, OpenTUI 0.5.9, Apple M2 Max, 120x40, source builds,
DevTools disabled. All results below are local action-RPC medians in milliseconds,
eight measured observations per cell. They are not release-binary guarantees.
| Scenario | Base repeat | Markdown ordering | Ordering + footer index | Footer confirmation |
| ------------------------------ | ----------: | ----------------: | ----------------------: | ------------------: |
| Latest 20 of LONG retained | 28.6 | 28.8 | 26.6 | 26.2 |
| All 2,000 retained, tail | 107.7 | 107.9 | 66.9 | 60.5 |
| All 2,000 retained, saved head | 115.3 | 118.7 | 71.9 | 69.3 |
| Dense Markdown | 1,615.5 | 832.1 | 830.4 | 820.6 |
These are the `before-02`, `markdown-02`, `footer-01`, and `footer-02` runs. The earlier
base/Markdown pair independently measured 1,717.3 -> 842.6 ms for dense Markdown;
that earlier runner did not yet include the saved-head category, so its results
are not pooled into the table. Ordinary short/latest-page differences are near
the noise floor and are not claimed as improvements.
### Kept
- Configure Markdown's custom renderer before content. OpenTUI's `renderNode`
setter otherwise clears populated parse/block state and repeats preparation.
Keep content before `streaming` so the completion update retains final tokens.
- Share a reactive message-position index across a Session view's footers. Scan
only from each position to its preceding user/synthetic input instead of
searching and slicing entire history prefixes. This improves both tail and
historical positions rather than shifting work to the newer suffix.
Captured content, geometry, and styling matched across the base, Markdown-only,
and both footer trials after excluding the isolated-project path footer.
The helper dependency test deliberately supplies a known position. It verifies
that the bounded calculation does not read an unrelated prefix, not that the
whole Session ignores structural history changes. A real-App regression checks
the reactive index through prepend/reconcile and a same-length truncate/append.
### Deferred
- The second row reduction after a cache-hit sync still exists. Removing it
cleanly needs an explicit cache-hit/synchronization contract; this pass does
not change the public Client data API or infer freshness from array identity.
- Parsed Markdown caches, mounted-view retention, history eviction, and initial
window changes were not mixed into these experiments.
- No memory-leak or retained-heap improvement is claimed by these latency runs.
+5 -10
View File
@@ -141,7 +141,6 @@ const context = createContext<{
groupExploration: () => boolean
diffWrapMode: () => "word" | "none"
models: () => ModelInfo[]
messageIndex: (messageID: string) => number | undefined
config: ReturnType<typeof useConfig>["data"]
mutatePending: (action: PendingAction, inboxID: string) => Promise<boolean>
pendingDelivery: (inboxID: string) => SessionInbox.Delivery | undefined
@@ -181,7 +180,6 @@ export function Session(props: {
const promptRef = usePromptRef()
const session = createMemo(() => data.session.get(route.sessionID))
const messages = () => data.session.message.list(route.sessionID)
const messageIndexes = createMemo(() => new Map(messages().map((message, index) => [message.id, index])))
const messagesBeforeRevert = () => {
const messageID = session()?.revert?.messageID
if (!messageID) return messages()
@@ -1351,7 +1349,6 @@ export function Session(props: {
groupExploration,
diffWrapMode,
models,
messageIndex: (messageID) => messageIndexes().get(messageID),
config,
mutatePending,
pendingDelivery: (inboxID) => pendingDeliveries().get(inboxID),
@@ -2034,10 +2031,8 @@ function AssistantFooter(props: { message: SessionMessageAssistant }) {
?.name ?? `${props.message.model.providerID}/${props.message.model.id}`,
)
const messages = createMemo(() => data.session.message.list(ctx.sessionID))
const duration = createMemo(() => turnDuration(props.message, messages(), ctx.messageIndex(props.message.id)))
const tokensPerSecond = createMemo(() =>
turnTokensPerSecond(props.message, messages(), ctx.messageIndex(props.message.id)),
)
const duration = createMemo(() => turnDuration(props.message, messages()))
const tokensPerSecond = createMemo(() => turnTokensPerSecond(props.message, messages()))
const interrupted = createMemo(() => props.message.error?.message === "Step interrupted")
return (
<>
@@ -2216,7 +2211,6 @@ function CompactionMessage(props: { message: Extract<SessionMessageInfo, { type:
<box paddingTop={1} paddingLeft={3}>
<markdown
syntaxStyle={syntax()}
renderNode={plugins.markdown()}
streaming={true}
internalBlockMode="top-level"
content={content()}
@@ -2224,6 +2218,7 @@ function CompactionMessage(props: { message: Extract<SessionMessageInfo, { type:
conceal={ctx.markdownMode() === "rendered"}
fg={theme.markdown.text}
bg={theme.background.default}
renderNode={plugins.markdown()}
/>
</box>
</Show>
@@ -2663,10 +2658,9 @@ function TextPart(props: { last: boolean; part: SessionMessageAssistantText; mes
return (
<Show when={props.part.text.trim()}>
<box paddingLeft={3} flexShrink={0}>
{/* Configure custom nodes before parsing; apply content before streaming so completion keeps the final tokens. */}
{/* Apply content before streaming so completion does not freeze the previous Markdown tokens. */}
<markdown
syntaxStyle={syntax()}
renderNode={plugins.markdown()}
content={props.part.text.trim()}
streaming={props.message.time.completed === undefined}
internalBlockMode="top-level"
@@ -2674,6 +2668,7 @@ function TextPart(props: { last: boolean; part: SessionMessageAssistantText; mes
conceal={ctx.markdownMode() === "rendered"}
fg={theme.markdown.text}
bg={theme.background.default}
renderNode={plugins.markdown()}
/>
</box>
</Show>
+10 -19
View File
@@ -346,21 +346,21 @@ export function cacheReuseDrop(previous: CacheUsage | undefined, current: CacheU
return drop > 0 ? drop : undefined
}
export function turnDuration(message: SessionMessageAssistant, messages: SessionMessageInfo[], position?: number) {
export function turnDuration(message: SessionMessageAssistant, messages: SessionMessageInfo[]) {
if (message.time.completed === undefined) return 0
const index = position ?? messages.findIndex((item) => item.id === message.id)
const input = messages[inputIndex(messages, index === -1 ? messages.length : index)]
const index = messages.findIndex((item) => item.id === message.id)
const input = messages
.slice(0, index === -1 ? messages.length : index)
.findLast((item) => item.type === "user" || item.type === "synthetic")
return Math.max(0, message.time.completed - (input?.time.created ?? message.time.created))
}
export function turnTokensPerSecond(
message: SessionMessageAssistant,
messages: SessionMessageInfo[],
position?: number,
) {
const index = position ?? messages.findIndex((item) => item.id === message.id)
export function turnTokensPerSecond(message: SessionMessageAssistant, messages: SessionMessageInfo[]) {
const index = messages.findIndex((item) => item.id === message.id)
const end = index === -1 ? messages.length : index + 1
const start = inputIndex(messages, end)
const start = messages
.slice(0, end)
.findLastIndex((item) => item.type === "user" || item.type === "synthetic")
const steps = messages
.slice(start + 1, end)
.filter((item): item is SessionMessageAssistant => item.type === "assistant")
@@ -374,15 +374,6 @@ export function turnTokensPerSecond(
return output / (duration / 1_000)
}
function inputIndex(messages: SessionMessageInfo[], end: number) {
// Reading a sliced prefix subscribes every footer to unrelated historical messages.
for (let index = end - 1; index >= 0; index--) {
const message = messages[index]
if (message.type === "user" || message.type === "synthetic") return index
}
return -1
}
function hasTokenUsage(
message: SessionMessageAssistant,
): message is SessionMessageAssistant & { tokens: NonNullable<SessionMessageAssistant["tokens"]> } {
-247
View File
@@ -490,253 +490,6 @@ test("automatic rename refreshes the displayed title before settling, even witho
}
})
test.each([80, 120])("completes custom Markdown and ordinary fences in a session at width %s", async (width) => {
await using state = await tmpdir()
const session = {
id: "ses_markdown",
title: "Markdown fixture",
projectID: "project",
location: { directory },
agent: "build",
model: { providerID: "fixture", id: "model" },
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 1, updated: 2 },
}
const initial =
"```mermaid\ngraph LR\n A[DiagramStart] --> B[DiagramEnd]\n```\n\n```latex\nx^2\n```\n\n```text\ninitial"
await using setup = await createAppFixture({
width,
height: 55,
state: state.path,
config: { animations: false, tabs: { enabled: false }, session: { sidebar: "hide" } },
args: { sessionID: session.id },
fetch: (url) => {
if (url.pathname === `/api/session/${session.id}`) return json({ data: session })
if (url.pathname === `/api/session/${session.id}/message`)
return json({
data: [
{
id: "msg_markdown",
type: "assistant",
agent: session.agent,
model: session.model,
time: { created: 2 },
content: [{ type: "text", text: initial }],
},
{
id: "msg_compaction",
type: "compaction",
time: { created: 1 },
status: "completed",
reason: "manual",
summary: "```latex\ny^2\n```",
recent: "msg_markdown",
},
],
cursor: {},
})
if (
url.pathname === `/api/session/${session.id}/inbox` ||
url.pathname === `/api/session/${session.id}/permission`
)
return json({ data: [] })
return undefined
},
})
const streaming = await setup.waitForFrame(
(frame) =>
frame.includes("initial") &&
frame.includes("DiagramStart") &&
frame.includes("DiagramEnd") &&
frame.includes("x\u00b2") &&
frame.includes("y\u00b2"),
)
expect(streaming).toContain("Compaction")
expect(streaming).not.toContain("initial final")
expect(streaming).not.toContain("MARKDOWN_END")
// Queue final text and completion together to exercise TextPart's reactive property order.
setup.events.emit({
id: "evt_markdown_text_ended",
created: 3,
type: "session.text.ended",
durable: { aggregateID: session.id, seq: 1, version: 1 },
data: {
sessionID: session.id,
assistantMessageID: "msg_markdown",
ordinal: 0,
text: `${initial} final\n\`\`\`\n\nMARKDOWN_END`,
},
})
setup.events.emit({
id: "evt_markdown_step_ended",
created: 4,
type: "session.step.ended",
durable: { aggregateID: session.id, seq: 2, version: 1 },
data: {
sessionID: session.id,
assistantMessageID: "msg_markdown",
finish: "stop",
cost: 0,
tokens: session.tokens,
},
})
const frame = await setup.waitForFrame(
(frame) => frame.includes("MARKDOWN_END") && frame.includes("initial final") && frame.includes("2ms"),
)
expect(frame).toContain("DiagramStart")
expect(frame).toContain("DiagramEnd")
expect(frame).toContain("x\u00b2")
expect(frame).toContain("y\u00b2")
expect(frame).toContain("initial final")
expect(frame).not.toContain("graph LR")
expect(frame).not.toContain("x^2")
expect(frame).not.toContain("y^2")
expect(frame).not.toContain("```")
})
test("keeps assistant footer metrics current after history prepend and same-length replacement", async () => {
await using state = await tmpdir()
const session = {
id: "ses_footer",
title: "Footer fixture",
projectID: "project",
location: { directory },
agent: "build",
model: { providerID: "fixture", id: "model" },
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 100, updated: 5000 },
}
await using setup = await createAppFixture({
width: 100,
height: 40,
state: state.path,
config: { animations: false, tabs: { enabled: false }, session: { sidebar: "hide", tps: true } },
args: { sessionID: session.id },
fetch: (url) => {
if (url.pathname === `/api/session/${session.id}`) return json({ data: session })
if (url.pathname === `/api/session/${session.id}/message`) {
if (url.searchParams.get("cursor") === "older")
return json({
data: [
{
id: "msg_0001",
type: "system",
text: "Earlier instructions",
description: "Prepended instructions",
time: { created: 200 },
},
{ id: "msg_0000", type: "user", text: "Prepended input", time: { created: 100 } },
],
cursor: {},
})
return json({
data: [
{
id: "msg_0003",
type: "assistant",
agent: session.agent,
model: session.model,
time: { created: 2000, streamed: 3000, completed: 5000 },
finish: "stop",
cost: 0,
tokens: { ...session.tokens, output: 20 },
content: [{ type: "text", text: "Original answer" }],
},
{ id: "msg_0002", type: "user", text: "Current input", time: { created: 1000 } },
],
cursor: { next: "older" },
})
}
if (
url.pathname === `/api/session/${session.id}/inbox` ||
url.pathname === `/api/session/${session.id}/permission`
)
return json({ data: [] })
return undefined
},
})
const initial = await setup.waitForFrame((frame) => frame.includes("Original answer") && frame.includes("20.0 tok/s"))
expect(initial).toContain("Current input")
expect(initial).toContain("4.0s \u00b7 20.0 tok/s")
expect(initial).not.toContain("Prepended input")
setup.mockInput.pressKey("g", { ctrl: true })
const prepended = await setup.waitForFrame(
(frame) =>
frame.includes("Prepended input") &&
frame.includes("Prepended instructions") &&
frame.includes("Original answer") &&
frame.includes("20.0 tok/s") &&
!frame.includes("Loading session history..."),
)
expect(prepended).toContain("Current input")
expect(prepended).toContain("Original answer")
expect(prepended).toContain("4.0s \u00b7 20.0 tok/s")
// Remove and append in one queue batch: array length is unchanged, but the message ID is new.
setup.events.emit({
id: "evt_footer_reverted",
created: 5500,
type: "session.revert.committed",
durable: { aggregateID: session.id, seq: 1, version: 1 },
data: { sessionID: session.id, to: "msg_0003" },
})
setup.events.emit({
id: "evt_footer_step_started",
created: 6000,
type: "session.step.started",
durable: { aggregateID: session.id, seq: 2, version: 1 },
data: { sessionID: session.id, assistantMessageID: "msg_0004", agent: session.agent, model: session.model },
})
setup.events.emit({
id: "evt_footer_text_started",
created: 6500,
type: "session.text.started",
durable: { aggregateID: session.id, seq: 3, version: 1 },
data: { sessionID: session.id, assistantMessageID: "msg_0004", ordinal: 0 },
})
setup.events.emit({
id: "evt_footer_text_ended",
created: 7500,
type: "session.text.ended",
durable: { aggregateID: session.id, seq: 4, version: 1 },
data: { sessionID: session.id, assistantMessageID: "msg_0004", ordinal: 0, text: "Replacement answer" },
})
setup.events.emit({
id: "evt_footer_step_streamed",
created: 8000,
type: "session.step.streamed",
durable: { aggregateID: session.id, seq: 5, version: 1 },
data: { sessionID: session.id, assistantMessageID: "msg_0004" },
})
setup.events.emit({
id: "evt_footer_step_ended",
created: 9000,
type: "session.step.ended",
durable: { aggregateID: session.id, seq: 6, version: 1 },
data: {
sessionID: session.id,
assistantMessageID: "msg_0004",
finish: "stop",
cost: 0,
tokens: { ...session.tokens, output: 50 },
},
})
const replaced = await setup.waitForFrame(
(frame) => frame.includes("Replacement answer") && frame.includes("25.0 tok/s"),
)
expect(replaced).toContain("Prepended input")
expect(replaced).toContain("Prepended instructions")
expect(replaced).toContain("Current input")
expect(replaced).toContain("8.0s \u00b7 25.0 tok/s")
expect(replaced).not.toContain("Original answer")
expect(replaced).not.toContain("20.0 tok/s")
})
test("session startup prompt is submitted exactly once", async () => {
const setup = await createTestRenderer({ width: 80, height: 24, useThread: false })
const events = createEventStream()
@@ -1,14 +1,11 @@
/** @jsxImportSource @opentui/solid */
import { afterAll, expect, test } from "bun:test"
import { once } from "node:events"
import { readdir } from "node:fs/promises"
import path from "node:path"
import {
BoxRenderable,
CliRenderEvents,
DiffRenderable,
ImageRenderable,
InputRenderable,
MouseButton,
type Renderable,
ScrollBoxRenderable,
@@ -318,9 +315,6 @@ test.each(["branch", "committed", "working"] as const)(
expect(viewer.app.captureCharFrame()).toMatch(/●\s+v2/)
expect(viewer.branchesRequests[0].searchParams.get("location[directory]")).toBe("/repo/session")
expect(viewer.branchesRequests[0].searchParams.get("limit")).toBe("100")
// The picker can paint before its deferred input focus.
if (!viewer.app.renderer.currentFocusedEditor) await once(viewer.app.renderer, CliRenderEvents.FOCUSED_EDITOR)
expect(viewer.app.renderer.currentFocusedEditor).toBeInstanceOf(InputRenderable)
await viewer.app.mockInput.typeText("origin/release")
await Bun.sleep(160)
await viewer.app.waitFor(() => viewer.branchesRequests.at(-1)?.searchParams.get("search") === "origin/release")
@@ -1,7 +1,5 @@
import { expect, test } from "bun:test"
import type { SessionMessageAssistant, SessionMessageAssistantTool, SessionMessageInfo } from "@opencode-ai/client"
import { createMemo, createRoot } from "solid-js"
import { createStore } from "solid-js/store"
import {
backgroundToolRowIndex,
cacheReuseDrop,
@@ -48,99 +46,6 @@ test("omits turn throughput when a stream boundary is unavailable", () => {
expect(turnTokensPerSecond(final, [final])).toBeUndefined()
})
test.each([false, true])(
"measures historical footers without later inputs or incomplete steps (indexed: %s)",
(indexed) => {
const step = (id: string, created: number, streamed: number, completed: number, output: number) => ({
...assistant(id, []),
time: { created, streamed, completed },
tokens: { input: 1, output, reasoning: 2, cache: { read: 0, write: 0 } },
})
const messages: SessionMessageInfo[] = [
step("before-input", 0, 1_000, 2_000, 5),
{ type: "user", id: "input", text: "Question", time: { created: 3_000 } },
step("first-step", 4_000, 5_000, 6_000, 10),
{ type: "system", id: "system", text: "Instructions", time: { created: 6_500 } },
step("second-step", 7_000, 8_000, 9_000, 20),
{ type: "synthetic", id: "synthetic", text: "Update", time: { created: 10_000 } },
step("after-synthetic", 11_000, 13_000, 14_000, 12),
{ type: "user", id: "later-input", text: "Next question", time: { created: 15_000 } },
assistant("incomplete", []),
]
expect(
messages.flatMap((message, index) =>
message.type === "assistant"
? [
[
turnDuration(message, messages, indexed ? index : undefined),
turnTokensPerSecond(message, messages, indexed ? index : undefined),
],
]
: [],
),
).toEqual([
[2_000, 5],
[3_000, 10],
[6_000, 15],
[4_000, 6],
[0, undefined],
])
},
)
test("preserves missing-anchor footer fallbacks without including the absent assistant's tokens", () => {
const absent = assistant("absent", [])
absent.time = { created: 8_000, streamed: 9_000, completed: 10_000 }
absent.tokens = { input: 1, output: 900, reasoning: 0, cache: { read: 0, write: 0 } }
const stored = assistant("stored", [])
stored.time = { created: 6_000, streamed: 8_000, completed: 9_000 }
stored.tokens = { input: 1, output: 20, reasoning: 0, cache: { read: 0, write: 0 } }
const input: SessionMessageInfo = { type: "user", id: "input", text: "Question", time: { created: 5_000 } }
expect(turnDuration(absent, [input, stored])).toBe(5_000)
expect(turnTokensPerSecond(absent, [input, stored])).toBe(10)
expect(turnDuration(absent, [stored])).toBe(2_000)
expect(turnTokensPerSecond(absent, [stored])).toBe(10)
expect(turnDuration(absent, [])).toBe(2_000)
expect(turnTokensPerSecond(absent, [])).toBeUndefined()
})
test("indexed tail footer calculations do not subscribe to an unrelated history prefix", () => {
createRoot((dispose) => {
try {
const final = assistant("final", [])
final.time = { created: 2_000, streamed: 3_000, completed: 5_000 }
final.tokens = { input: 1, output: 20, reasoning: 0, cache: { read: 0, write: 0 } }
const [messages, setMessages] = createStore<SessionMessageInfo[]>([
{ type: "user", id: "old-input", text: "Old question", time: { created: 0 } },
assistant("old-step", []),
{ type: "user", id: "input", text: "Current question", time: { created: 1_000 } },
final,
])
let runs = 0
const footer = createMemo(() => {
runs++
const current = messages[3]
if (current.type !== "assistant") throw new Error("Expected an assistant")
return [turnDuration(current, messages, 3), turnTokensPerSecond(current, messages, 3)]
})
expect(footer()).toEqual([4_000, 20])
setMessages(0, { type: "user", id: "replaced-prefix", text: "Older question", time: { created: 50 } })
expect(footer()).toEqual([4_000, 20])
expect(runs).toBe(1)
setMessages(2, "time", "created", 1_500)
expect(footer()).toEqual([3_500, 20])
setMessages(3, { ...final, time: { ...final.time, streamed: 4_000 }, tokens: { ...final.tokens, output: 60 } })
expect(footer()).toEqual([3_500, 30])
expect(runs).toBe(3)
} finally {
dispose()
}
})
})
test("filters OpenAI cache quantization from cache reuse drops", () => {
const openai = { id: "gpt", providerID: "openai" }
expect(cacheReuseDrop(undefined, { read: 10_000, model: openai })).toBeUndefined()
-231
View File
@@ -1,231 +0,0 @@
import { $ } from "bun"
import { appendFileSync } from "node:fs"
import { mkdir } from "node:fs/promises"
import path from "node:path"
import { Effect, Schema } from "effect"
import { Llm, OpenCodeDriver, type Ui } from "opencode-drive"
import { Session } from "../packages/schema/src/session"
import { SessionMessage } from "../packages/schema/src/session-message"
const run = process.env.PERF_RUN
if (!run) throw new Error("PERF_RUN must identify a new experiment")
const target = process.env.PERF_TARGET ?? process.cwd()
const output = path.join(process.env.PERF_OUTPUT ?? path.join(target, ".cache", "tui-switch"), run)
await mkdir(path.dirname(output), { recursive: true })
await mkdir(output, { recursive: false })
const revision = (await $`git -C ${target} rev-parse HEAD`.quiet().text()).trim()
await Bun.write(
path.join(output, "changes.patch"),
await $`git -C ${target} diff HEAD -- packages/tui/src packages/client/src/solid`.quiet().text(),
)
const samples: { name: string; sample: number; actionMs: number; visibleMs: number }[] = []
const fixtures = [
{ name: "SHORT", count: 20, bytes: 256 },
{ name: "LONG", count: 2000, bytes: 256 },
{ name: "LARGE", count: 20, bytes: 32768 },
]
const measure = (ui: Ui, name: string, sample: number, action: Effect.Effect<unknown, unknown>, marker: string) =>
Effect.gen(function* () {
const start = performance.now()
yield* action
const actionMs = performance.now() - start
yield* ui.waitFor(marker, { timeout: 30_000, interval: 20 })
const result = { name, sample, actionMs, visibleMs: performance.now() - start }
samples.push(result)
appendFileSync(path.join(output, "samples.jsonl"), JSON.stringify(result) + "\n")
console.error(JSON.stringify(result))
// Fixed pacing is outside the timing window; the marker above determines readiness.
yield* Effect.sleep(150)
})
export default OpenCodeDriver.useReport(
{
keepArtifacts: true,
project: {
git: true,
files: {
"README.md": "# Synthetic tab-switch benchmark\n",
".opencode/cli.json": JSON.stringify({ debug: { devtools: false } }),
},
},
config: { autoupdate: false },
tui: { viewport: { cols: 120, rows: 40 } },
opencode: { dev: target },
},
(driver) =>
Effect.gen(function* () {
yield* driver.tui.close()
const template = yield* driver.opencode.session.create({ title: "Template" })
const model = (yield* driver.opencode.model.default({ location: template.location })).data
if (!model) return yield* Effect.fail(new Error("Simulated model unavailable"))
const agent = (yield* driver.opencode.agent.list({ location: template.location })).data.find(
(item) => item.id === "build",
)
if (!agent) return yield* Effect.fail(new Error("Build agent unavailable"))
const seeded = yield* Effect.forEach(fixtures, (fixture) =>
Effect.gen(function* () {
const messages = Array.from({ length: fixture.count }, (_, index) => {
const created = 1_780_000_000_000 + index * 10_000
const marker =
index === 0
? `FIRST_${fixture.name}`
: index === fixture.count - 1
? `END_${fixture.name}`
: `ROW_${index}`
const id = SessionMessage.ID.create()
if (index % 2 === 0)
return Schema.decodeUnknownSync(SessionMessage.Info)({
id,
type: "user",
time: { created },
text: `${marker} Please inspect the parser and explain the next small implementation step with a test.`,
})
const block =
process.env.PERF_CONTENT === "prose"
? "The parser validates input before constructing the result. Keep this boundary explicit and add a focused regression test. "
: "The parser validates input before constructing the result. Keep this boundary explicit and add a focused regression test.\n\n```ts\nconst value = parse(source)\nexpect(value.ok).toBe(true)\n```\n\n"
const tail = `\n\n${marker}`
return Schema.decodeUnknownSync(SessionMessage.Info)({
id,
type: "assistant",
agent: agent.id,
model: { providerID: model.providerID, id: model.id },
finish: "stop",
time: { created, completed: created + 400 },
content: [
...(index % 10 === 5
? [
{
type: "tool",
id: `call_${index}`,
name: "read",
state: {
status: "completed",
input: { path: "src/parser.ts" },
content: [
{
type: "text",
text: "1: export const parse = (source: string) => ({ ok: true, source })",
},
],
},
time: { created, completed: created + 100 },
},
]
: []),
{
type: "text",
text:
block.repeat(Math.ceil(fixture.bytes / block.length)).slice(0, fixture.bytes - tail.length) + tail,
},
],
})
})
return yield* driver.opencode.session.import({
info: {
...template,
id: Session.ID.create(),
title: `Perf ${fixture.name}`,
agent: agent.id,
model: { providerID: model.providerID, id: model.id },
},
messages,
})
}),
)
yield* driver.opencode.session.remove({ sessionID: template.id })
const tui = yield* driver.tuis.launch("measured", { viewport: { cols: 120, rows: 40 } })
const ui = tui.ui
yield* Effect.forEach(seeded, (session, index) =>
Effect.gen(function* () {
yield* ui.press("o", { ctrl: true })
yield* ui.waitFor("Search sessions and")
yield* ui.type(session.title ?? "")
yield* ui.waitFor(session.title ?? "")
yield* measure(ui, `cold.${fixtures[index].name}`, 0, ui.enter(), `END_${fixtures[index].name}`)
}),
)
const select = (index: number) => ui.press(String(index + 1), { ctrl: true })
yield* Effect.forEach(["latest20", "retained2000", "head2000", "large"], (phase) =>
Effect.gen(function* () {
if (phase === "retained2000") {
yield* select(1)
yield* measure(ui, "history.first", 0, ui.press("g", { ctrl: true }), "FIRST_LONG")
yield* measure(ui, "history.latest", 0, ui.press("g", { ctrl: true, meta: true }), "END_LONG")
}
if (phase === "head2000") {
yield* select(1)
yield* ui.press("g", { ctrl: true })
yield* ui.waitFor("FIRST_LONG")
}
const index = phase === "large" ? 2 : 1
yield* Effect.forEach(
Array.from({ length: 9 }, (_, index) => index),
(sample) =>
Effect.gen(function* () {
yield* measure(ui, `${phase}.SHORT`, sample, select(0), "END_SHORT")
yield* measure(
ui,
`${phase}.${fixtures[index].name}`,
sample,
select(index),
phase === "head2000" ? "FIRST_LONG" : `END_${fixtures[index].name}`,
)
}),
)
const frame = yield* ui.capture()
yield* Effect.promise(() => Bun.write(path.join(output, `${phase}.frame.json`), JSON.stringify(frame)))
if (phase === "head2000") {
yield* ui.press("g", { ctrl: true, meta: true })
yield* ui.waitFor("END_LONG")
}
}),
)
yield* ui.screenshot("measured-large")
yield* select(0)
yield* driver.llm.queue(
Llm.text("```text\ninitial", { delay: 10, chunkSize: 5 }),
Llm.text(" final\n```\n\nSTREAM_DONE", { delay: 10, chunkSize: 5 }),
)
yield* ui.submit("Synthetic streaming completion check")
yield* ui.waitFor("STREAM_DONE", { timeout: 30_000 })
yield* ui.waitFor("initial final")
yield* driver.opencode.session.wait({ sessionID: seeded[0].id })
const final = yield* driver.opencode.message.list({ sessionID: seeded[0].id, limit: 1, order: "desc" })
if (final.data[0]?.type !== "assistant" || !final.data[0].time.completed)
return yield* Effect.fail(new Error("Streaming completion was not projected"))
yield* ui.screenshot("streaming-completed")
const summary = [...new Set(samples.filter((sample) => sample.sample > 0).map((sample) => sample.name))].map(
(name) => {
const values = samples
.filter((sample) => sample.name === name && sample.sample > 0)
.map((sample) => sample.actionMs)
.toSorted((a, b) => a - b)
return {
name,
n: values.length,
medianMs: (values[3] + values[4]) / 2,
minMs: values[0],
maxMs: values.at(-1),
}
},
)
yield* Effect.promise(() =>
Bun.write(
path.join(output, "results.json"),
JSON.stringify(
{ run, target, revision, fixtures, content: process.env.PERF_CONTENT ?? "markdown", samples, summary },
null,
2,
),
),
)
console.table(summary)
return summary
}),
).pipe(
Effect.tap((report) =>
Effect.promise(() => Bun.write(path.join(output, "report.json"), JSON.stringify(report, null, 2))),
),
)
+18 -26
View File
@@ -7,35 +7,27 @@ export default $config({
removal: input?.stage === "production" ? "retain" : "remove",
protect: ["production"].includes(input?.stage),
home: "cloudflare",
providers:
input.stage === "beta"
? {}
: {
aws: {
version: "7.30.0",
region: "us-east-1",
profile: process.env.GITHUB_ACTIONS
? undefined
: input.stage === "production"
? "opencode-production"
: "opencode-dev",
},
stripe: {
version: "0.0.28",
apiKey: process.env.STRIPE_SECRET_KEY!,
},
random: "4.19.2",
planetscale: "0.4.1",
honeycomb: "0.49.0",
},
providers: {
aws: {
version: "7.30.0",
region: "us-east-1",
profile: process.env.GITHUB_ACTIONS
? undefined
: input.stage === "production"
? "opencode-production"
: "opencode-dev",
},
stripe: {
version: "0.0.28",
apiKey: process.env.STRIPE_SECRET_KEY!,
},
random: "4.19.2",
planetscale: "0.4.1",
honeycomb: "0.49.0",
},
}
},
async run() {
if ($app.stage === "beta") {
const { createWebApp } = await import("./infra/webapp.js")
return { WebAppUrl: createWebApp("beta.opencode.ai").url }
}
const stage = await import("./infra/stage.js")
await import("./infra/app.js")
const lake = stage.deployAws ? await import("./infra/lake.js") : undefined