mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-30 05:26:17 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cbbb83ec1c |
@@ -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
@@ -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",
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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
@@ -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.)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -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
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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",
|
||||
() =>
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,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],
|
||||
|
||||
@@ -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)}`
|
||||
|
||||
@@ -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.
|
||||
@@ -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>
|
||||
|
||||
@@ -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"]> } {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user