Compare commits

..
82 changed files with 2159 additions and 328 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"@opencode-ai/core": patch
---
Fix OpenCode Console device authorization URLs when the server returns an origin-rooted verification path.
+1
View File
@@ -630,6 +630,7 @@
"@tsconfig/bun": "catalog:",
"@types/bun": "catalog:",
"@typescript/native-preview": "catalog:",
"prettier": "3.6.2",
"typescript": "catalog:",
},
},
+1 -1
View File
@@ -110,7 +110,7 @@ export const model = (input: ModelInput) => {
const multipartImages = yield* Effect.forEach(sourceImages, (image) => {
if (image.type === "bytes") return Effect.succeed({ data: image.data, mediaType: image.mediaType })
if (image.type === "url") return ImageInputs.decodeDataUrl(image.url, ADAPTER)
return Effect.succeed(undefined)
return Effect.undefined
})
const multipartMask =
mask === undefined
@@ -16,7 +16,7 @@ export const decodeDataUrl = (
url: string,
module: string,
): Effect.Effect<{ readonly mediaType: string; readonly data: Uint8Array } | undefined, AIError> => {
if (!url.startsWith("data:")) return Effect.succeed(undefined)
if (!url.startsWith("data:")) return Effect.undefined
const match = /^data:([^;,]+);base64,(.*)$/s.exec(url)
if (!match) return Effect.fail(invalid(module, "Image data URLs must contain a MIME type and base64 data"))
return Effect.fromResult(Encoding.decodeBase64(match[2])).pipe(
@@ -1,6 +1,5 @@
import { base64Encode } from "@opencode-ai/util/encode"
import { getDirectory } from "@opencode-ai/util/path"
import { startTransition } from "solid-js"
import type { NewSessionComposerAdapter } from "@/composer/adapter"
import { useComposerState } from "@/composer/persistence"
import { createComposerControls, createComposerModelSelection } from "@/composer/selection"
@@ -72,24 +71,21 @@ export function createNewSessionComposerAdapter(props: {
if (!created) return
data.session.remember(created)
await startTransition(() => {
tabs.updateDraft(props.draftID, { worktree: undefined })
if (permission.isAutoAcceptingDirectory(projectDirectory)) {
permission.enableAutoAccept(created.id, sessionDirectory)
}
local.session.promote(sessionDirectory, created.id, {
agent: selection.agent,
model: selection.model,
variant: selection.variant ?? null,
})
tabs.promoteDraft(props.draftID, { server: server.key, sessionId: created.id })
submission.retarget(
prompt.capture(
{ dir: base64Encode(sessionDirectory), id: created.id },
{ server: server.key, scope: serverSDK.scope },
),
)
if (permission.isAutoAcceptingDirectory(projectDirectory)) {
permission.enableAutoAccept(created.id, sessionDirectory)
}
local.session.promote(sessionDirectory, created.id, {
agent: selection.agent,
model: selection.model,
variant: selection.variant ?? null,
})
await tabs.promoteDraft(props.draftID, { server: server.key, sessionId: created.id })
submission.retarget(
prompt.capture(
{ dir: base64Encode(sessionDirectory), id: created.id },
{ server: server.key, scope: serverSDK.scope },
),
)
return {
id: created.id,
+8 -6
View File
@@ -1,4 +1,4 @@
import { Route, useParams } from "@solidjs/router"
import { Navigate, Route, useParams } from "@solidjs/router"
import { createMemo, lazy, Show, type ParentProps } from "solid-js"
import { Home } from "@/home/route"
import { ServerProvider } from "@/runtime/server/current"
@@ -6,7 +6,7 @@ import { useGlobal } from "@/runtime/server/runtime"
import { ServerConnection } from "@/runtime/server/registry"
import { LayoutProvider } from "@/shell/state/layout"
import Shell from "@/shell/shell"
import { requireServerKey } from "./session"
import { parseServerKey } from "./session"
export const File = lazy(() => import("@opencode-ai/session-ui/file").then((module) => ({ default: module.File })))
const loadDraftRoute = () => Promise.all([import("@/new-session/route"), File.preload()]).then(([module]) => module)
@@ -44,12 +44,14 @@ export function AppRoutes() {
function TargetServerRoute(props: ParentProps) {
const params = useParams<{ serverKey: string }>()
const global = useGlobal()
const connection = createMemo(() =>
global.servers.list().find((item) => ServerConnection.key(item) === requireServerKey(params.serverKey)),
)
const connection = createMemo(() => {
const key = parseServerKey(params.serverKey)
if (!key) return
return global.servers.list().find((item) => ServerConnection.key(item) === key)
})
return (
<Show when={connection()} keyed>
<Show when={connection()} keyed fallback={<Navigate href="/" />}>
{(connection) => <ServerProvider conn={connection}>{props.children}</ServerProvider>}
</Show>
)
@@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test"
import { ServerConnection } from "@/runtime/server/registry"
import { requireServerKey, rootSession, sessionHref } from "./session"
import { parseServerKey, requireServerKey, rootSession, sessionHref } from "./session"
describe("session routes", () => {
test("builds and decodes a server-keyed session route", () => {
@@ -12,6 +12,8 @@ describe("session routes", () => {
})
test("rejects malformed server keys", () => {
expect(parseServerKey(undefined)).toBeUndefined()
expect(parseServerKey("not-base64")).toBeUndefined()
expect(() => requireServerKey("not-base64")).toThrow("Invalid server route")
})
+7 -1
View File
@@ -7,8 +7,14 @@ export function sessionHref(server: ServerConnection.Key, sessionID: string) {
}
export function requireServerKey(segment: string | undefined) {
const key = parseServerKey(segment)
if (!key) throw new Error("Invalid server route")
return key
}
export function parseServerKey(segment: string | undefined) {
const key = decode64(segment)
if (!key || base64Encode(key) !== segment) throw new Error("Invalid server route")
if (!key || base64Encode(key) !== segment) return
return ServerConnection.Key.make(key)
}
+2 -2
View File
@@ -229,12 +229,12 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
)
})
},
promoteDraft(draftID: string, session: Omit<SessionTab, "type">) {
async promoteDraft(draftID: string, session: Omit<SessionTab, "type">) {
// Keep the replacement and navigation atomic so /new-session never renders
// after its backing draft tab has been removed from the store.
const active = location.pathname === "/new-session" && location.query.draftId === draftID
const next = { type: "session" as const, ...session }
void startTransition(() => {
await startTransition(() => {
setStore(
produce((tabs) => {
const index = tabs.findIndex((tab) => tab.type === "draft" && tab.draftID === draftID)
+14
View File
@@ -378,6 +378,15 @@ export type Endpoint5_31Output =
readonly location?: Location.Ref | undefined
readonly data: { readonly sessionID: Session.ID; readonly title: string }
}
| {
readonly id: Event.ID
readonly created: number
readonly metadata?: { readonly [x: string]: unknown } | undefined
readonly type: "session.viewed"
readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
readonly location?: Location.Ref | undefined
readonly data: { readonly sessionID: Session.ID; readonly idle: number }
}
| {
readonly id: Event.ID
readonly created: number
@@ -919,6 +928,10 @@ export type Endpoint5_35Input = { readonly sessionID: Session.ID; readonly varia
export type Endpoint5_35Output = void
export type SessionEnvironmentOperation<E = never> = (input: Endpoint5_35Input) => Effect.Effect<Endpoint5_35Output, E>
export type Endpoint5_36Input = { readonly sessionID: Session.ID; readonly idle: number }
export type Endpoint5_36Output = void
export type SessionViewOperation<E = never> = (input: Endpoint5_36Input) => Effect.Effect<Endpoint5_36Output, E>
export interface SessionApi<E = never> {
readonly list: SessionListOperation<E>
readonly create: SessionCreateOperation<E>
@@ -964,6 +977,7 @@ export interface SessionApi<E = never> {
readonly background: SessionBackgroundOperation<E>
readonly message: SessionMessageOperation<E>
readonly environment: SessionEnvironmentOperation<E>
readonly view: SessionViewOperation<E>
}
export type Endpoint6_0Input = {
@@ -86,6 +86,8 @@ import type {
Endpoint5_34Output,
Endpoint5_35Input,
Endpoint5_35Output,
Endpoint5_36Input,
Endpoint5_36Output,
Endpoint6_0Input,
Endpoint6_0Output,
Endpoint7_0Input,
@@ -615,6 +617,13 @@ const Endpoint5_35 = (raw: RawClient["server.session"]) => (input: Endpoint5_35I
}).pipe(Effect.mapError(mapClientError)),
)
const Endpoint5_36 = (raw: RawClient["server.session"]) => (input: Endpoint5_36Input) =>
preserveEffect<Endpoint5_36Output>()(
raw["session.view"]({ params: { sessionID: input["sessionID"] }, payload: { idle: input["idle"] } }).pipe(
Effect.mapError(mapClientError),
),
)
const adaptGroup5 = (raw: RawClient["server.session"]) => ({
list: Endpoint5_0(raw),
create: Endpoint5_1(raw),
@@ -645,6 +654,7 @@ const adaptGroup5 = (raw: RawClient["server.session"]) => ({
background: Endpoint5_33(raw),
message: Endpoint5_34(raw),
environment: Endpoint5_35(raw),
view: Endpoint5_36(raw),
})
const Endpoint6_0 = (raw: RawClient["server.message"]) => (input: Endpoint6_0Input) =>
@@ -80,6 +80,8 @@ import type {
SessionMessageOutput,
SessionEnvironmentInput,
SessionEnvironmentOutput,
SessionViewInput,
SessionViewOutput,
MessageListInput,
MessageListOutput,
ModelListInput,
@@ -898,6 +900,18 @@ export function make(options: ClientOptions) {
},
requestOptions,
),
view: (input: SessionViewInput, requestOptions?: RequestOptions) =>
request<SessionViewOutput>(
{
method: "POST",
path: `/api/session/${encodeURIComponent(input.sessionID)}/view`,
body: { idle: input["idle"] },
successStatus: 204,
declaredStatuses: [404, 401, 400],
empty: true,
},
requestOptions,
),
},
message: {
list: (input: MessageListInput, requestOptions?: RequestOptions) =>
+45 -4
View File
@@ -324,6 +324,16 @@ export type SessionRenamed = {
data: { sessionID: string; title: string }
}
export type SessionViewed = {
id: string
created: number
metadata?: { [x: string]: any }
type: "session.viewed"
durable: { aggregateID: string; seq: number; version: 1 }
location?: LocationRef
data: { sessionID: string; idle: number }
}
export type SessionDeleted = {
id: string
created: number
@@ -1664,7 +1674,8 @@ export type SessionInfo = {
model?: ModelRef
cost: MoneyUSD
tokens: TokenUsageInfo
time: { created: number; updated: number; archived?: number }
outcome?: "succeeded" | "failed" | "interrupted"
time: { created: number; updated: number; idle?: number; viewed?: number; archived?: number }
title?: string
location: LocationRef
subpath?: string
@@ -1949,6 +1960,7 @@ export type SessionEventDurable =
| SessionModelSelected
| SessionMoved
| SessionRenamed
| SessionViewed
| SessionDeleted
| SessionForked
| SessionInboxDelivered
@@ -2034,6 +2046,7 @@ export type V2Event =
| SessionModelSelected
| SessionMoved
| SessionRenamed
| SessionViewed
| SessionUsageUpdated
| SessionDeleted
| SessionForked
@@ -2510,7 +2523,14 @@ export type SessionImportInput = {
readonly reasoning: number
readonly cache: { readonly read: number; readonly write: number }
}
readonly time: { readonly created: number; readonly updated: number; readonly archived?: number }
readonly outcome?: "succeeded" | "failed" | "interrupted"
readonly time: {
readonly created: number
readonly updated: number
readonly idle?: number
readonly viewed?: number
readonly archived?: number
}
readonly title?: string
readonly location: { readonly directory: string; readonly workspaceID?: string }
readonly subpath?: string
@@ -2778,7 +2798,14 @@ export type SessionImportInput = {
readonly reasoning: number
readonly cache: { readonly read: number; readonly write: number }
}
readonly time: { readonly created: number; readonly updated: number; readonly archived?: number }
readonly outcome?: "succeeded" | "failed" | "interrupted"
readonly time: {
readonly created: number
readonly updated: number
readonly idle?: number
readonly viewed?: number
readonly archived?: number
}
readonly title?: string
readonly location: { readonly directory: string; readonly workspaceID?: string }
readonly subpath?: string
@@ -3046,7 +3073,14 @@ export type SessionImportInput = {
readonly reasoning: number
readonly cache: { readonly read: number; readonly write: number }
}
readonly time: { readonly created: number; readonly updated: number; readonly archived?: number }
readonly outcome?: "succeeded" | "failed" | "interrupted"
readonly time: {
readonly created: number
readonly updated: number
readonly idle?: number
readonly viewed?: number
readonly archived?: number
}
readonly title?: string
readonly location: { readonly directory: string; readonly workspaceID?: string }
readonly subpath?: string
@@ -3980,6 +4014,13 @@ export type SessionEnvironmentInput = {
export type SessionEnvironmentOutput = void
export type SessionViewInput = {
readonly sessionID: { readonly sessionID: string }["sessionID"]
readonly idle: { readonly idle: number }["idle"]
}
export type SessionViewOutput = void
export type MessageListInput = {
readonly sessionID: { readonly sessionID: string }["sessionID"]
readonly limit?: {
+40 -16
View File
@@ -1,7 +1,7 @@
// Client data layer: apply server events and cache API reads into a Solid store.
// Prefer straightforward projection. Do not add generation counters, stale-response
// merges, live/history overlays, or other race machinery here—last write wins.
// Reconnect invalidates cached reads; active UI owners decide what to sync again.
// Prefer straightforward projection. Invalidated reads revalidate serially so an older
// response cannot commit after its replacement. Reconnect invalidates cached reads;
// active UI owners decide what to sync again.
import type {
AgentInfo,
@@ -120,32 +120,46 @@ function locationQuery(ref?: LocationRef) {
}
function createSync() {
const state = new Map<string, true | Promise<void>>()
type Pending = { promise: Promise<void>; invalidated: boolean }
const state = new Map<string, true | Pending>()
const start = (key: string, load: () => Promise<void>, wait?: Promise<void>) => {
const entry: Pending = { promise: Promise.resolve(), invalidated: false }
state.set(key, entry)
entry.promise = (wait ? wait.catch(() => undefined).then(load) : load())
.then(() => {
if (state.get(key) === entry) state.set(key, true)
})
.finally(() => {
if (state.get(key) === entry) state.delete(key)
})
return entry.promise
}
return {
run(key: string, load: () => Promise<void>) {
const active = state.get(key)
if (active === true) return Promise.resolve()
if (active) return active
const pending = load()
.then(() => {
if (state.get(key) === pending) state.set(key, true)
})
.finally(() => {
if (state.get(key) === pending) state.delete(key)
})
state.set(key, pending)
return pending
if (!active) return start(key, load)
if (!active.invalidated) return active.promise
return start(key, load, active.promise)
},
complete(key: string) {
if (state.has(key)) return
state.set(key, true)
},
has(key: string) {
return state.has(key)
},
invalidate(key?: string) {
if (key) {
state.delete(key)
const active = state.get(key)
if (active === true) state.delete(key)
if (active !== undefined && active !== true) active.invalidated = true
return
}
state.clear()
state.forEach((active, current) => {
if (active === true) state.delete(current)
if (active !== true) active.invalidated = true
})
},
}
}
@@ -882,6 +896,16 @@ export function createData(config: CreateDataInput) {
const currentAssistant = message.activeAssistant(draft)
if (currentAssistant) currentAssistant.retry = undefined
})
if (event.type === "session.execution.interrupted" && event.data.reason === "shutdown") return
// An event can overtake the first read; queue a revalidation when that read is still active.
if (!store.session.info[event.data.sessionID] && !sync.has(`session:${event.data.sessionID}`)) return
result.session.invalidate(event.data.sessionID)
void result.session.sync(event.data.sessionID)
return
case "session.viewed":
if (!store.session.info[event.data.sessionID] && !sync.has(`session:${event.data.sessionID}`)) return
result.session.invalidate(event.data.sessionID)
void result.session.sync(event.data.sessionID)
return
case "session.revert.staged":
if (store.session.info[event.data.sessionID])
+11 -1
View File
@@ -136,8 +136,10 @@ test("event.subscribe terminates on Effect protocol decode failures", async () =
test("session methods retain decoded Effect inputs and outputs", async () => {
const logQueries: Array<Record<string, string>> = []
const requests: Array<{ method: string; url: string }> = []
const httpClient = HttpClient.make((request) => {
const url = request.url
requests.push({ method: request.method, url })
if (url.includes("/log")) {
logQueries.push(Object.fromEntries(request.urlParams.params))
return Effect.succeed(
@@ -183,6 +185,7 @@ test("session methods retain decoded Effect inputs and outputs", async () => {
const created = yield* client.session.create({
location: Location.Ref.make({ directory: AbsolutePath.make("/tmp/project") }),
})
yield* client.session.view({ sessionID: Session.ID.make("ses_test"), idle: session.data.time.idle })
yield* client.session.switchAgent({ sessionID: Session.ID.make("ses_test"), agent: Agent.ID.make("build") })
yield* client.session.switchModel({
sessionID: Session.ID.make("ses_test"),
@@ -207,7 +210,11 @@ test("session methods retain decoded Effect inputs and outputs", async () => {
return { page, active, created, admitted, context, log, message }
}).pipe(Effect.provideService(HttpClient.HttpClient, httpClient), Effect.runPromise)
expect(DateTime.toEpochMillis(result.page.data[0].time.created)).toBe(1_717_171_717_000)
const listed = result.page.data[0]
if (!listed?.time.idle || !listed.time.viewed) throw new Error("Expected attention times")
expect(DateTime.toEpochMillis(listed.time.created)).toBe(1_717_171_717_000)
expect(DateTime.toEpochMillis(listed.time.idle)).toBe(1_717_171_717_002)
expect(DateTime.toEpochMillis(listed.time.viewed)).toBe(1_717_171_717_001)
expect(result.active).toEqual({ ses_test: { type: "running" } })
expect(Object.getPrototypeOf(result.page.data[0])).toBe(Object.prototype)
expect(Object.getPrototypeOf(result.created)).toBe(Object.prototype)
@@ -217,6 +224,7 @@ test("session methods retain decoded Effect inputs and outputs", async () => {
expect(DateTime.toEpochMillis(result.admitted.timeCreated)).toBe(1_717_171_717_000)
expect(result.context).toEqual([])
expect(logQueries[0]).toEqual({ after: "0" })
expect(requests).toContainEqual({ method: "POST", url: "http://localhost:3000/api/session/ses_test/view" })
const logged = Array.from(result.log)
expect(logged.map((item) => item.type)).toEqual(["session.model.selected", "log.synced"])
expect(logged[0]?.type === "session.model.selected" && logged[0].created).toBe(1_717_171_717_000)
@@ -258,6 +266,8 @@ const session = {
time: {
created: 1_717_171_717_000,
updated: 1_717_171_717_000,
idle: 1_717_171_717_002,
viewed: 1_717_171_717_001,
},
title: "Test",
location: { directory: "/tmp/project" },
+8
View File
@@ -524,6 +524,7 @@ test("session methods use the public HTTP contract", async () => {
const page = await client.session.list({ limit: 10, order: "desc", parentID: null })
const active = await client.session.active()
const created = await client.session.create({ location: { directory: "/tmp/project" } })
await client.session.view({ sessionID: "ses_test", idle: session.data.time.idle })
await client.session.switchAgent({ sessionID: "ses_test", agent: "build" })
await client.session.switchModel({
sessionID: "ses_test",
@@ -550,6 +551,7 @@ test("session methods use the public HTTP contract", async () => {
const message = await client.session.message({ sessionID: "ses_test", messageID: "msg_model" })
expect(page.cursor.next).toBe("next")
expect(page.data[0].time).toMatchObject({ idle: 1_717_171_717_002, viewed: 1_717_171_717_001 })
expect(active).toEqual({ ses_test: { type: "running" } })
expect(created.id).toBe("ses_test")
expect(admitted.id).toBe("msg_test")
@@ -562,6 +564,7 @@ test("session methods use the public HTTP contract", async () => {
["GET", "http://localhost:3000/api/session?limit=10&order=desc&parentID=null"],
["GET", "http://localhost:3000/api/session/active"],
["POST", "http://localhost:3000/api/session"],
["POST", "http://localhost:3000/api/session/ses_test/view"],
["POST", "http://localhost:3000/api/session/ses_test/agent"],
["POST", "http://localhost:3000/api/session/ses_test/model"],
["POST", "http://localhost:3000/api/session/ses_test/prompt"],
@@ -574,6 +577,9 @@ test("session methods use the public HTTP contract", async () => {
["POST", "http://localhost:3000/api/session/ses_test/interrupt?continue=true"],
["GET", "http://localhost:3000/api/session/ses_test/message/msg_model"],
])
const viewBody = requests.find((request) => request.url.endsWith("/api/session/ses_test/view"))?.init?.body
if (typeof viewBody !== "string") throw new Error("Expected JSON view request body")
expect(JSON.parse(viewBody)).toEqual({ idle: session.data.time.idle })
const body = requests.find((request) => request.url.endsWith("/api/session/ses_test/prompt"))?.init?.body
if (typeof body !== "string") throw new Error("Expected JSON request body")
expect(JSON.parse(body)).toEqual({
@@ -636,6 +642,8 @@ const session = {
time: {
created: 1_717_171_717_000,
updated: 1_717_171_717_000,
idle: 1_717_171_717_002,
viewed: 1_717_171_717_001,
},
title: "Test",
location: { directory: "/tmp/project" },
+81
View File
@@ -0,0 +1,81 @@
import { test } from "bun:test"
import { createRoot } from "solid-js"
import { createData, type CreateDataInput } from "../src/solid"
import { OpenCode, type OpenCodeEvent, type SessionInfo } from "../src/promise"
const session = (viewed: number): SessionInfo => ({
id: "ses_refresh",
projectID: "project",
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
outcome: "succeeded",
time: { created: 0, updated: 0, idle: 2, viewed },
location: { directory: "/project" },
})
test("revalidates after an event overtakes an active session read", async () => {
let release!: () => void
const gate = new Promise<void>((resolve) => (release = resolve))
const listeners = new Set<Parameters<CreateDataInput["event"]["listen"]>[0]>()
let requests = 0
const api = OpenCode.make({
baseUrl: "http://opencode.local",
fetch: async (input, init) => {
const request = input instanceof Request ? input : new Request(input, init)
if (!request.url.endsWith("/api/session/ses_refresh")) throw new Error(`Unexpected request: ${request.url}`)
requests++
if (requests === 1) {
await gate
return Response.json({ data: session(1) })
}
return Response.json({ data: session(2) })
},
})
const event: CreateDataInput["event"] = {
on:
<Type extends OpenCodeEvent["type"]>(
_type: Type,
_handler: (event: Extract<OpenCodeEvent, { type: Type }>) => void,
) =>
() => {},
listen(handler) {
listeners.add(handler)
return () => listeners.delete(handler)
},
}
const setup = createRoot((dispose) => ({
data: createData({ api: () => api, directory: "/project", event, connection: { status: () => "connected" } }),
dispose,
}))
try {
setup.data.session.remember(session(1))
setup.data.session.invalidate("ses_refresh")
const initial = setup.data.session.sync("ses_refresh")
await wait(() => requests === 1)
const viewed: OpenCodeEvent = {
id: "evt_viewed",
created: 2,
type: "session.viewed",
durable: { aggregateID: "ses_refresh", seq: 1, version: 1 },
data: { sessionID: "ses_refresh", idle: 2 },
}
listeners.forEach((listener) => listener({ name: viewed.type, details: viewed }))
await Bun.sleep(20)
release()
await initial
await wait(() => requests === 2 && setup.data.session.get("ses_refresh")?.time.viewed === 2)
} finally {
setup.dispose()
}
})
async function wait(check: () => boolean) {
const started = Date.now()
while (!check()) {
if (Date.now() - started > 2_000) throw new Error("Timed out waiting for condition")
await Bun.sleep(10)
}
}
+3 -3
View File
@@ -841,17 +841,17 @@ export class Interpreter<R> {
private customIterator(value: unknown, node: AstNode, allowAsync = true) {
if (value instanceof CodeModeGenerator) {
if (value.asynchronous && !allowAsync) return Effect.succeed(undefined)
if (value.asynchronous && !allowAsync) return Effect.undefined
return Effect.succeed({
iterator: value,
next: new GeneratorMethodReference(value, "next"),
asynchronous: value.asynchronous,
})
}
if (!isRecord(value) || isRuntimeReference(value)) return Effect.succeed(undefined)
if (!isRecord(value) || isRuntimeReference(value)) return Effect.undefined
const asyncMethod = allowAsync ? Reflect.get(value, AsyncIteratorSymbol) : undefined
const method = asyncMethod ?? Reflect.get(value, IteratorSymbol)
if (method === undefined || method === null) return Effect.succeed(undefined)
if (method === undefined || method === null) return Effect.undefined
const self = this
return Effect.map(
this.invokeCallable(this.requireIteratorMethod(method, "Iterator method", node), [], node),
+32 -2
View File
@@ -1,8 +1,8 @@
{
"version": "7",
"dialect": "sqlite",
"id": "dcde8e6b-4bf4-4f6b-b2be-4030c2c3e936",
"prevIds": ["5c1aa56b-c3ee-4283-9a84-c0bf626dc604"],
"id": "3fb67508-0196-4bae-b2bd-c08ece7583fd",
"prevIds": ["dcde8e6b-4bf4-4f6b-b2be-4030c2c3e936"],
"ddl": [
{
"name": "account_state",
@@ -1350,6 +1350,36 @@
"entityType": "columns",
"table": "session_v2"
},
{
"type": "integer",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "time_idle",
"entityType": "columns",
"table": "session_v2"
},
{
"type": "integer",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "time_viewed",
"entityType": "columns",
"table": "session_v2"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "idle_outcome",
"entityType": "columns",
"table": "session_v2"
},
{
"type": "integer",
"notNull": false,
+1 -1
View File
@@ -46,7 +46,7 @@ export const Plugin = define({
function firstMissing(target: string): Effect.Effect<string | undefined> {
const parent = path.dirname(target)
if (parent === target) return Effect.succeed(undefined)
if (parent === target) return Effect.undefined
return fs.isDir(parent).pipe(Effect.flatMap((exists) => (exists ? Effect.succeed(target) : firstMissing(parent))))
}
+2
View File
@@ -43,6 +43,7 @@ import m40 from "./migration/20260808023530_workspace_domain.js"
import m41 from "./migration/20260811161259_execution_claim_attempts.js"
import m42 from "./migration/20260812181746_session_inbox.js"
import m43 from "./migration/20260812213948_worktree.js"
import m44 from "./migration/20260819222447_session_viewed_state.js"
export const migrations = [
m00,
@@ -89,4 +90,5 @@ export const migrations = [
m41,
m42,
m43,
m44,
] satisfies DatabaseMigration.Migration[]
@@ -0,0 +1,15 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration.js"
const migration: DatabaseMigration.Migration = {
id: "20260819222447_session_viewed_state",
up(tx) {
return Effect.gen(function* () {
yield* tx.run(`ALTER TABLE \`session_v2\` ADD \`time_idle\` integer;`)
yield* tx.run(`ALTER TABLE \`session_v2\` ADD \`time_viewed\` integer;`)
yield* tx.run(`ALTER TABLE \`session_v2\` ADD \`idle_outcome\` text;`)
})
},
}
export default migration
+3
View File
@@ -209,6 +209,9 @@ const schema: Omit<DatabaseMigration.Migration, "id"> = {
\`model\` text,
\`time_created\` integer NOT NULL,
\`time_updated\` integer NOT NULL,
\`time_idle\` integer,
\`time_viewed\` integer,
\`idle_outcome\` text,
\`time_compacting\` integer,
\`time_archived\` integer,
\`time_suspended\` integer,
+1 -1
View File
@@ -109,7 +109,7 @@ const layer = Layer.effect(
const next = Bom.split(input.content)
const current = yield* environment.files.read(input.target.absolute, { offset: 0, length: 3 }).pipe(
Effect.map((result) => result.bytes),
Effect.catchTag("Environment.NotFound", () => Effect.succeed(undefined)),
Effect.catchTag("Environment.NotFound", () => Effect.undefined),
)
yield* environment.files.write(
input.target.absolute,
+7 -12
View File
@@ -9,7 +9,6 @@ import { AppProcess } from "@opencode-ai/util/process"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import { File } from "./file.js"
import { KeyedMutex } from "./effect/keyed-mutex.js"
import { gitExecutable } from "./util/git-executable.js"
export class Repository extends Schema.Class<Repository>("Git.Repository")({
worktree: AbsolutePath,
@@ -315,7 +314,7 @@ const layer = Layer.effect(
) {
const result = yield* proc
.run(
ChildProcess.make(gitExecutable, repositoryArgs(repository, args), {
ChildProcess.make("git", repositoryArgs(repository, args), {
cwd: repository.worktree,
env: options?.env,
extendEnv: true,
@@ -450,14 +449,10 @@ const layer = Layer.effect(
if (!input.paths.length) return new Set<RelativePath>()
const result = yield* proc
.run(
ChildProcess.make(
gitExecutable,
repositoryArgs(input.repository, ["check-ignore", "--no-index", "--stdin", "-z"]),
{
cwd: input.repository.worktree,
extendEnv: true,
},
),
ChildProcess.make("git", repositoryArgs(input.repository, ["check-ignore", "--no-index", "--stdin", "-z"]), {
cwd: input.repository.worktree,
extendEnv: true,
}),
{ stdin: input.paths.join("\0") + "\0" },
)
.pipe(
@@ -630,7 +625,7 @@ const layer = Layer.effect(
cwd = repository.worktree,
) {
const result = yield* proc
.run(ChildProcess.make(gitExecutable, args, { cwd, extendEnv: true, stdin: "ignore" }))
.run(ChildProcess.make("git", args, { cwd, extendEnv: true, stdin: "ignore" }))
.pipe(
Effect.mapError(
(cause) => new WorktreeError({ operation, directory: worktreeDirectory, message: cause.message, cause }),
@@ -727,7 +722,7 @@ function execute(cwd: string, proc: AppProcess.Interface) {
return (args: string[]) =>
proc
.run(
ChildProcess.make(gitExecutable, args, {
ChildProcess.make("git", args, {
cwd,
extendEnv: true,
stdin: "ignore",
+2 -3
View File
@@ -78,9 +78,8 @@ const layer = Layer.effect(
? "Directory"
: input.kind === "file"
? "File"
: (yield* fs
.stat(absolute)
.pipe(Effect.catchReason("PlatformError", "NotFound", () => Effect.succeed(undefined))))?.type
: (yield* fs.stat(absolute).pipe(Effect.catchReason("PlatformError", "NotFound", () => Effect.undefined)))
?.type
const externalDirectory = type === "Directory" ? absolute : path.dirname(absolute)
const externalResource = slash(path.join(externalDirectory, "*"))
return {
+2 -1
View File
@@ -1,4 +1,5 @@
import { Effect, Layer, LayerMap } from "effect"
import { existsSync } from "fs"
import path from "path"
import { Agent } from "./agent.js"
import { AISDK } from "./aisdk.js"
@@ -146,7 +147,7 @@ export function buildLocationServiceMap(
Layer.provide(LayerNode.compile(location.hoisted)),
)
},
{ idleTimeToLive: "60 minutes" },
{ idleTimeToLive: (ref) => (existsSync(ref.directory) ? "60 minutes" : 0) },
),
(inner) => ({
...inner,
+2 -2
View File
@@ -624,11 +624,11 @@ export const layer = (options?: Options) =>
Effect.map((input) => input as Record<string, SourceProvider>),
Effect.orElseSucceed(() => undefined),
)
: Effect.succeed(undefined)
: Effect.undefined
// The bundled snapshot is the boot-time floor for the catalog; the
// periodic fetch below still refreshes on top.
const loadSnapshot = options?.snapshot === false ? Effect.succeed(undefined) : bundledSnapshot
const loadSnapshot = options?.snapshot === false ? Effect.undefined : bundledSnapshot
const fetchAndWrite = Effect.fn("ModelsDev.fetchAndWrite")(function* () {
const text = yield* fetchApi()
+1 -1
View File
@@ -302,7 +302,7 @@ const layer = Layer.effect(
const rememberedRules = yield* savedRules()
for (const [id, item] of pending) {
const rules = yield* configured(item.request.sessionID, item.agent).pipe(
Effect.catchTag("Session.NotFoundError", () => Effect.succeed(undefined)),
Effect.catchTag("Session.NotFoundError", () => Effect.undefined),
)
if (!rules) continue
if (denied(item.request, rules)) continue
+11 -8
View File
@@ -46,15 +46,18 @@ function oauth(http: HttpClient.HttpClient) {
Effect.gen(function* () {
const server = yield* normalizeServer(answer.server ?? defaultServer)
const device = yield* post(http, `${server}/auth/device/code`, { client_id: clientID }, Device)
const verification = URL.canParse(device.verification_uri_complete)
? new URL(device.verification_uri_complete)
: undefined
if (verification && verification.protocol !== "http:" && verification.protocol !== "https:") {
return yield* Effect.fail(new Error("Invalid device verification URL: expected HTTP(S)"))
}
const verification = yield* Effect.try({
try: () => {
const url = new URL(device.verification_uri_complete, `${server}/`)
if (url.protocol !== "http:" && url.protocol !== "https:") throw new Error("expected HTTP(S)")
return url
},
catch: (cause) =>
new Error(`Invalid device verification URL: ${cause instanceof Error ? cause.message : String(cause)}`),
})
return {
mode: "auto" as const,
url: verification?.href ?? `${server}/${device.verification_uri_complete.replace(/^\/+/, "")}`,
url: verification.href,
instructions: `Enter code: ${device.user_code}`,
callback: poll(http, server, device.device_code, Duration.seconds(device.interval)),
}
@@ -213,7 +216,7 @@ function fetchProviders(http: HttpClient.HttpClient, value: Credential.Value) {
)
.pipe(
Effect.flatMap((response) => {
if (response.status === 404) return Effect.succeed(undefined)
if (response.status === 404) return Effect.undefined
return HttpClientResponse.filterStatusOk(response).pipe(
Effect.flatMap(HttpClientResponse.schemaBodyJson(RemoteResponse)),
Effect.map((remote) => remote.config.provider),
+1 -1
View File
@@ -10,7 +10,7 @@ export const parseResponse = <F extends Schema.Struct.Fields>(body: string, resu
const decode = Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Struct({ result })))
const parse = (payload: string) => {
const trimmed = payload.trim()
if (!trimmed.startsWith("{")) return Effect.succeed(undefined)
if (!trimmed.startsWith("{")) return Effect.undefined
return decode(trimmed).pipe(Effect.map((response) => response.result))
}
return Effect.gen(function* () {
+1 -1
View File
@@ -235,7 +235,7 @@ const layer = Layer.effect(
Effect.mapError((cause) => failure("Invalid ripgrep JSON output", cause)),
Effect.flatMap((json) => {
if (!json || typeof json !== "object" || !("type" in json) || json.type !== "match")
return Effect.succeed(undefined)
return Effect.undefined
return Schema.decodeUnknownEffect(RawMatch)(json).pipe(
Effect.map((match) => ({
...match.data,
+34 -3
View File
@@ -1,7 +1,7 @@
export * as Session from "./session.js"
export * from "./session/schema.js"
import { Effect, Layer, Schema, Context, RcMap, Stream, Scope } from "effect"
import { Cause, Effect, Layer, Schema, Context, RcMap, Stream, Scope } from "effect"
import { ListAnchor } from "@opencode-ai/schema/session"
import { and, asc, desc, eq, gt, isNull, like, lt, or, type SQL } from "drizzle-orm"
import { Project } from "./project.js"
@@ -156,6 +156,11 @@ export class DestinationNotDirectoryError extends Schema.TaggedError<Destination
"Session.DestinationNotDirectoryError",
{ directory: AbsolutePath },
) {}
export class DestinationUnavailableError extends Schema.TaggedError<DestinationUnavailableError>()(
"Session.DestinationUnavailableError",
{ directory: AbsolutePath },
) {}
export const MessageNotFoundError = SessionRevert.MessageNotFoundError
export type MessageNotFoundError = SessionRevert.MessageNotFoundError
@@ -172,6 +177,7 @@ export interface Interface {
readonly sessionID: SessionSchema.ID
readonly variables?: SessionEnvironment.Variables
}) => 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: {
sessionID: SessionSchema.ID
@@ -218,7 +224,10 @@ export interface Interface {
directory: AbsolutePath
workspaceID?: Location.Ref["workspaceID"]
delivery?: SessionInbox.Delivery
}) => Effect.Effect<void, NotFoundError | DestinationNotFoundError | DestinationNotDirectoryError>
}) => Effect.Effect<
void,
NotFoundError | DestinationNotFoundError | DestinationNotDirectoryError | DestinationUnavailableError
>
readonly prompt: (input: {
id?: SessionMessage.ID
sessionID: SessionSchema.ID
@@ -449,6 +458,18 @@ const layer = Layer.effect(
if (input.variables !== undefined) yield* environments.set(input.sessionID, input.variables)
return yield* environments.get(input.sessionID)
}),
view: Effect.fn("Session.view")(function* (input) {
const row = yield* db
.select({ idle: SessionTable.time_idle, viewed: SessionTable.time_viewed })
.from(SessionTable)
.where(eq(SessionTable.id, input.sessionID))
.get()
.pipe(Effect.orDie)
if (!row) return yield* new NotFoundError({ sessionID: input.sessionID })
if (row.idle === null || input.idle > row.idle || (row.viewed !== null && row.viewed >= input.idle))
return yield* Effect.void
yield* bus.publish(SessionEvent.Viewed, { sessionID: input.sessionID, idle: input.idle })
}),
remove: Effect.fn("Session.remove")(function* (sessionID) {
const session = yield* result.get(sessionID)
yield* execution.interrupt(sessionID)
@@ -772,12 +793,22 @@ const layer = Layer.effect(
if (!info) return yield* new DestinationNotFoundError({ directory })
if (info.type !== "Directory") return yield* new DestinationNotDirectoryError({ directory })
const project = yield* projects.resolve(directory)
yield* persistProject(project)
const payload: SessionInbox.MovePayload = {
location: Location.Ref.make({ directory, workspaceID: input.workspaceID }),
projectID: project.id,
subpath: RelativePath.make(path.relative(project.directory, directory).replaceAll("\\", "/")),
}
yield* Location.Service.pipe(
Effect.provide(locations.get(payload.location)),
Effect.scoped,
Effect.catchCause((cause) => {
if (Cause.hasInterruptsOnly(cause)) return Effect.failCause(cause)
return Effect.logWarning("session move destination unavailable", { directory, cause }).pipe(
Effect.andThen(Effect.fail(new DestinationUnavailableError({ directory }))),
)
}),
)
yield* persistProject(project)
const item = SessionInbox.Item.make({
type: "move",
payload,
+3
View File
@@ -50,9 +50,12 @@ export function fromRow(row: typeof SessionTable.$inferSelect): SessionSchema.In
}),
subpath: row.path ? RelativePath.make(row.path) : undefined,
revert: row.revert ? decodeRevert(row.revert) : undefined,
outcome: row.idle_outcome ?? undefined,
time: {
created: DateTime.makeUnsafe(row.time_created),
updated: DateTime.makeUnsafe(row.time_updated),
idle: row.time_idle === null ? undefined : DateTime.makeUnsafe(row.time_idle),
viewed: row.time_viewed === null ? undefined : DateTime.makeUnsafe(row.time_viewed),
archived: row.time_archived ? DateTime.makeUnsafe(row.time_archived) : undefined,
},
})
@@ -70,6 +70,7 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
Match.type<SessionEvent.DurableEvent>(),
Match.discriminatorsExhaustive("type")({
"session.created": () => Effect.void,
"session.viewed": () => Effect.void,
"session.usage.recorded": () => Effect.void,
"session.agent.selected": (event) =>
Effect.gen(function* () {
+50 -4
View File
@@ -183,6 +183,7 @@ const projectFork = Effect.fn("SessionProjector.projectFork")(function* (
lt(SessionMessageTable.seq, copiedSeq + 1),
// Terminal events for active projections stay on the parent, so forks copy only settled history.
sql`${SessionMessageTable.type} != 'assistant' or json_extract(${SessionMessageTable.data}, '$.time.completed') is not null`,
sql`${SessionMessageTable.type} != 'shell' or json_extract(${SessionMessageTable.data}, '$.status') != 'running'`,
sql`${SessionMessageTable.type} != 'compaction' or json_extract(${SessionMessageTable.data}, '$.status') != 'running'`,
),
)
@@ -196,7 +197,7 @@ const projectFork = Effect.fn("SessionProjector.projectFork")(function* (
.insert(SessionMessageTable)
.values(
rows.map((row) => ({
id: SessionMessage.ID.create(),
id: SessionMessage.ID.make(`${SessionMessage.ID.fromEvent(event.id)}_${row.seq}`),
session_id: event.data.sessionID,
type: row.type,
seq: row.seq,
@@ -390,6 +391,37 @@ function insertMessage(db: DatabaseService, event: SessionEvent.DurableEvent, me
.pipe(Effect.orDie)
}
function projectIdle(
db: DatabaseService,
event:
| typeof SessionEvent.Execution.Succeeded.Type
| typeof SessionEvent.Execution.Failed.Type
| typeof SessionEvent.Execution.Interrupted.Type,
) {
return Effect.gen(function* () {
yield* run(db, event)
if (event.type === SessionEvent.Execution.Interrupted.type && event.data.reason === "shutdown") return
const time = event.created
const outcome =
event.type === SessionEvent.Execution.Succeeded.type
? "succeeded"
: event.type === SessionEvent.Execution.Failed.type
? "failed"
: "interrupted"
yield* db
.update(SessionTable)
.set({
// Unread uses a strict timestamp comparison, so every terminal must advance even within one millisecond.
time_idle: sql`max(${time}, coalesce(${SessionTable.time_idle} + 1, ${time}))`,
idle_outcome: outcome,
time_updated: sql`${SessionTable.time_updated}`,
})
.where(eq(SessionTable.id, event.data.sessionID))
.run()
.pipe(Effect.orDie)
})
}
const layer = Layer.effectDiscard(
Effect.gen(function* () {
const bus = yield* Bus.Service
@@ -511,6 +543,20 @@ const layer = Layer.effectDiscard(
.run()
.pipe(Effect.orDie),
)
yield* bus.project(SessionEvent.Viewed, (event) => {
const idle = event.data.idle
return db
.update(SessionTable)
.set({
// Monotone watermark: a duplicate or stale view never regresses, and a terminal event
// committing after the viewer's observation keeps the newer idle transition unread.
time_viewed: sql`max(${idle}, coalesce(${SessionTable.time_viewed}, ${idle}))`,
time_updated: sql`${SessionTable.time_updated}`,
})
.where(eq(SessionTable.id, event.data.sessionID))
.run()
.pipe(Effect.orDie)
})
yield* bus.project(SessionEvent.UsageRecorded, (event) => applyUsage(db, event.data.sessionID, event.data))
yield* bus.project(SessionEvent.Forked, (event) => projectFork(db, event))
yield* bus.project(SessionEvent.InboxDelivered, (event) =>
@@ -575,9 +621,9 @@ const layer = Layer.effectDiscard(
delivery: event.data.delivery,
}),
)
yield* bus.project(SessionEvent.Execution.Succeeded, (event) => run(db, event))
yield* bus.project(SessionEvent.Execution.Failed, (event) => run(db, event))
yield* bus.project(SessionEvent.Execution.Interrupted, (event) => run(db, event))
yield* bus.project(SessionEvent.Execution.Succeeded, (event) => projectIdle(db, event))
yield* bus.project(SessionEvent.Execution.Failed, (event) => projectIdle(db, event))
yield* bus.project(SessionEvent.Execution.Interrupted, (event) => projectIdle(db, event))
yield* bus.project(SessionEvent.InstructionsUpdated, (event) =>
Effect.gen(function* () {
yield* run(db, event)
+3
View File
@@ -57,6 +57,9 @@ export const SessionTable = sqliteTable(
variant?: string
}>(),
...Timestamps,
time_idle: integer(),
time_viewed: integer(),
idle_outcome: text().$type<NonNullable<Session.Info["outcome"]>>(),
time_compacting: integer(),
time_archived: integer(),
/** The execution claim timestamp (historical column name; see SessionStore.claim). */
+17 -2
View File
@@ -53,7 +53,7 @@ const layer = Layer.effect(
export: Effect.fn("SessionTransfer.export")(function* (input) {
const data = {
info: yield* sessions.get(input.sessionID),
messages: yield* sessions.messages({ sessionID: input.sessionID, order: "asc" }),
messages: (yield* sessions.messages({ sessionID: input.sessionID, order: "asc" })).filter(isSettled),
}
return input.sanitize ? sanitize(data) : data
}),
@@ -68,7 +68,7 @@ const layer = Layer.effect(
if (recorded) return yield* new ImportConflictError({ sessionID })
const project = yield* projects.resolve(input.location.directory)
yield* upsertProject(db, project).pipe(Effect.orDie)
const messages = input.data.messages.map((message, index) => {
const messages = input.data.messages.filter(isSettled).map((message, index) => {
const encoded = encodeMessage(message)
const { id: _, type, ...data } = encoded
return {
@@ -115,6 +115,15 @@ const layer = Layer.effect(
tokens_cache_write: input.data.info.tokens.cache.write,
time_created: DateTime.toEpochMillis(input.data.info.time.created),
time_updated: DateTime.toEpochMillis(input.data.info.time.updated),
time_idle: input.data.info.time.idle ? DateTime.toEpochMillis(input.data.info.time.idle) : null,
time_viewed:
input.data.info.time.idle && input.data.info.time.viewed
? Math.min(
DateTime.toEpochMillis(input.data.info.time.idle),
DateTime.toEpochMillis(input.data.info.time.viewed),
)
: null,
idle_outcome: input.data.info.time.idle ? (input.data.info.outcome ?? null) : null,
time_archived: input.data.info.time.archived
? DateTime.toEpochMillis(input.data.info.time.archived)
: null,
@@ -144,6 +153,12 @@ export const node = makeGlobalNode({
deps: [App.node, Bus.node, Database.node, Project.node, Session.node],
})
function isSettled(message: SessionMessage.Info) {
if (message.type === "assistant") return message.time.completed !== undefined
if (message.type === "shell" || message.type === "compaction") return message.status !== "running"
return true
}
function redact(kind: string, id: string, value: string) {
return value.trim() ? `[redacted:${kind}:${id}]` : value
}
+1 -1
View File
@@ -201,7 +201,7 @@ export const noopLayer = Layer.succeed(
Service.of({
transform: () => Effect.succeed({ dispose: Effect.void }),
reload: () => Effect.void,
capture: () => Effect.succeed(undefined),
capture: () => Effect.undefined,
files: () => Effect.succeed([]),
diff: () => Effect.succeed([]),
restore: () => Effect.void,
+1 -1
View File
@@ -78,7 +78,7 @@ export const Plugin = {
source,
})
const current = yield* FileMutation.readText(environment.files, target.absolute).pipe(
Effect.catchTag("Environment.NotFound", () => Effect.succeed(undefined)),
Effect.catchTag("Environment.NotFound", () => Effect.undefined),
)
const next = Bom.split(input.content)
const preview = fileDiff(target.resource, current?.text ?? "", next.text, current ? "modified" : "added")
-6
View File
@@ -1,6 +0,0 @@
import path from "path"
import { which } from "./which.js"
const resolved = process.platform === "win32" ? which("git") : undefined
export const gitExecutable = resolved ? path.resolve(resolved) : "git"
+1 -2
View File
@@ -8,7 +8,6 @@ import { AppProcess } from "@opencode-ai/util/process"
import type { DiffOptions, Interface } from "../vcs.js"
import { chunksByFile, emptyPatch, MAX_PATCH_BYTES, MAX_TOTAL_PATCH_BYTES, PATCH_CONTEXT_LINES } from "./patch.js"
import type { Patch } from "./patch.js"
import { gitExecutable } from "../util/git-executable.js"
/**
* Git adapter for the Vcs service. Ported from the V1 pipeline: patches are
@@ -129,7 +128,7 @@ function makeGit(proc: AppProcess.Interface) {
const run = Effect.fnUntraced(
function* (args: string[], opts: { cwd: string; maxOutputBytes?: number }) {
const result = yield* proc.run(
ChildProcess.make(gitExecutable, [...cfg, ...args], {
ChildProcess.make("git", [...cfg, ...args], {
cwd: opts.cwd,
extendEnv: true,
stdin: "ignore",
+1 -1
View File
@@ -31,7 +31,7 @@ export const make = Effect.gen(function* () {
return yield* Effect.forEach(entries, (entry) =>
canonical(fs, entry.directory).pipe(
Effect.map((directory) => ({ directory, type: entry.kind === "main" ? "root" : "worktree" }) as const),
Effect.catchTag("Worktree.DirectoryUnavailableError", () => Effect.succeed(undefined)),
Effect.catchTag("Worktree.DirectoryUnavailableError", () => Effect.undefined),
),
).pipe(Effect.map((items) => items.filter((item): item is ListEntry => item !== undefined)))
}),
@@ -17,6 +17,7 @@ import previousV2Migration from "@opencode-ai/core/database/migration/2026080423
import workspaceMigration from "@opencode-ai/core/database/migration/20260808023530_workspace_domain"
import executionClaimsMigration from "@opencode-ai/core/database/migration/20260811161259_execution_claim_attempts"
import sessionInboxMigration from "@opencode-ai/core/database/migration/20260812181746_session_inbox"
import sessionViewedStateMigration from "@opencode-ai/core/database/migration/20260819222447_session_viewed_state"
import { Global } from "@opencode-ai/util/global"
const run = <A, E>(
@@ -77,6 +78,28 @@ describe("DatabaseMigration", () => {
)
})
test("adds nullable attention state to existing sessions", async () => {
await run(
Effect.gen(function* () {
const db = yield* makeDb
yield* db.run(sql`CREATE TABLE session_v2 (id text PRIMARY KEY, title text)`)
yield* db.run(sql`INSERT INTO session_v2 (id, title) VALUES ('ses_existing', 'Existing')`)
yield* DatabaseMigration.applyOnly(db, [sessionViewedStateMigration])
yield* DatabaseMigration.applyOnly(db, [sessionViewedStateMigration])
expect(yield* db.get(sql`SELECT id, title, time_idle, time_viewed, idle_outcome FROM session_v2`)).toEqual({
id: "ses_existing",
title: "Existing",
time_idle: null,
time_viewed: null,
idle_outcome: null,
})
expect(yield* db.get(sql`SELECT count(*) AS count FROM migration`)).toEqual({ count: 1 })
}),
)
})
test("rejects a non-empty database without a session table", async () => {
await expect(
run(
+1 -1
View File
@@ -10,7 +10,7 @@ export const emptyCredentialNode = makeGlobalNode({
Credential.Service.of({
all: () => Effect.succeed([]),
list: () => Effect.succeed([]),
get: () => Effect.succeed(undefined),
get: () => Effect.undefined,
create: () => Effect.die("unused Credential.create"),
update: () => Effect.die("unused Credential.update"),
remove: () => Effect.die("unused Credential.remove"),
+2 -2
View File
@@ -19,9 +19,9 @@ export const emptyMcpLayer = Layer.succeed(
callTool: () => Effect.die("unused mcp.callTool"),
instructions: () => Effect.succeed([]),
prompts: () => Effect.succeed([]),
prompt: () => Effect.succeed(undefined),
prompt: () => Effect.undefined,
resourceCatalog: () => Effect.succeed(MCP.ResourceCatalog.make({ resources: [], templates: [] })),
readResource: () => Effect.succeed(undefined),
readResource: () => Effect.undefined,
}),
)
+2 -2
View File
@@ -21,7 +21,7 @@ const runtime = LanguageModel.make({ id: "gemini", provider: "test-provider", ro
const catalog = Layer.mock(Catalog.Service, {
provider: {
get: () => Effect.succeed(undefined),
get: () => Effect.undefined,
all: () => Effect.die("unused"),
available: () => Effect.die("unused"),
},
@@ -35,7 +35,7 @@ const catalog = Layer.mock(Catalog.Service, {
})
const integrations = Layer.mock(Integration.Service, {
connection: {
active: () => Effect.succeed(undefined),
active: () => Effect.undefined,
resolve: () => Effect.die("unused"),
key: () => Effect.die("unused"),
update: () => Effect.die("unused"),
@@ -405,7 +405,7 @@ describe("ConfigInstructionPlugin.Plugin", () => {
FSUtil.Service.of({
...fs,
up: () => Effect.succeed([discovered]),
readFileStringSafe: () => Effect.succeed(undefined),
readFileStringSafe: () => Effect.undefined,
}),
),
),
+1 -1
View File
@@ -17,7 +17,7 @@ const failingCredentialNode = makeGlobalNode({
Credential.Service.of({
all: () => Effect.succeed([]),
list: () => Effect.succeed([]),
get: () => Effect.succeed(undefined),
get: () => Effect.undefined,
create: () => Effect.die(new Error("credential persistence failed")),
update: () => Effect.void,
remove: () => Effect.void,
+22
View File
@@ -43,6 +43,28 @@ const itWithSdk = testEffect(
)
describe("LocationServiceMap", () => {
it.live("retries a location after its missing directory is recreated", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
).pipe(
Effect.flatMap((dir) =>
Effect.gen(function* () {
const locations = yield* LocationServiceMap.Service
const directory = path.join(dir.path, "recreated")
const ref = Location.Ref.make({ directory: AbsolutePath.make(directory) })
const first = yield* Location.Service.pipe(Effect.provide(locations.get(ref)), Effect.scoped, Effect.exit)
expect(first._tag).toBe("Failure")
yield* Effect.promise(() => fs.mkdir(directory))
const location = yield* Location.Service.pipe(Effect.provide(locations.get(ref)), Effect.scoped)
expect(location.directory).toBe(ref.directory)
}),
),
),
)
itWithSdk.live("preserves embedded SDK plugins after Location eviction", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
+1 -1
View File
@@ -309,7 +309,7 @@ describe("ModelResolver", () => {
})
const integrations = Layer.mock(Integration.Service, {
connection: {
active: () => Effect.succeed(undefined),
active: () => Effect.undefined,
resolve: () => Effect.die("unused"),
key: () => Effect.die("unused"),
update: () => Effect.die("unused"),
+1 -1
View File
@@ -33,7 +33,7 @@ const npmLayer = Layer.succeed(
Npm.Service.of({
add: () => Effect.succeed({ directory: "", entrypoint: undefined }),
resolve: () => Effect.succeed({ directory: "", entrypoint: undefined }),
which: () => Effect.succeed(undefined),
which: () => Effect.undefined,
}),
)
@@ -24,7 +24,7 @@ function npmEntrypoint(entrypoint?: string) {
return Npm.Service.of({
add: () => Effect.succeed({ directory: "", entrypoint }),
resolve: () => Effect.succeed({ directory: "", entrypoint }),
which: () => Effect.succeed(undefined),
which: () => Effect.undefined,
})
}
@@ -105,7 +105,7 @@ describe("OpencodePlugin", () => {
return Response.json({
device_code: "device",
user_code: "user",
verification_uri_complete: `${url.origin}/verify`,
verification_uri_complete: "/console/device?user_code=user&client_id=opencode-cli",
expires_in: 60,
interval: 0,
})
@@ -130,7 +130,7 @@ describe("OpencodePlugin", () => {
methodID: Integration.MethodID.make("device"),
answer: { server: `${server.url.origin}/console///?ignored=true#ignored` },
})
expect(attempt.url).toBe(`${server.url.origin}/verify`)
expect(attempt.url).toBe(`${server.url.origin}/console/device?user_code=user&client_id=opencode-cli`)
yield* eventually(
integrations.oauth.status({ integrationID, attemptID: attempt.attemptID }),
(status) => status.status === "complete",
@@ -148,6 +148,38 @@ describe("OpencodePlugin", () => {
),
)
it.live("rejects malformed device verification URLs", () =>
Effect.acquireUseRelease(
Effect.sync(() =>
Bun.serve({
port: 0,
fetch: () =>
Response.json({
device_code: "device",
user_code: "user",
verification_uri_complete: "http://[::1",
expires_in: 60,
interval: 0,
}),
}),
),
(server) =>
Effect.gen(function* () {
yield* addPlugin()
const error = yield* (yield* Integration.Service).oauth
.connect({
integrationID: Integration.ID.make("opencode"),
methodID: Integration.MethodID.make("device"),
answer: { server: server.url.origin },
})
.pipe(Effect.flip)
expect(error).toBeInstanceOf(Integration.AuthorizationError)
expect(String(error.cause)).toContain("Invalid device verification URL")
}),
(server) => Effect.promise(() => server.stop(true)),
),
)
it.effect("rejects non-HTTP OpenCode servers", () =>
Effect.gen(function* () {
yield* addPlugin()
@@ -15,7 +15,7 @@ const it = testEffect(PluginTestLayer)
const npm = Npm.Service.of({
add: () => Effect.succeed({ directory: "", entrypoint: undefined }),
resolve: () => Effect.succeed({ directory: "", entrypoint: undefined }),
which: () => Effect.succeed(undefined),
which: () => Effect.undefined,
})
const addPlugin = Effect.fn(function* () {
+250 -3
View File
@@ -4,6 +4,7 @@ import fs from "fs/promises"
import path from "path"
import { DateTime, Effect, Layer, Stream } from "effect"
import { Money } from "@opencode-ai/schema/money"
import { Shell } from "@opencode-ai/schema/shell"
import { Agent } from "@opencode-ai/core/agent"
import { asc, eq } from "drizzle-orm"
import { Database } from "@opencode-ai/core/database/database"
@@ -401,6 +402,41 @@ describe("Session.create", () => {
}),
)
it.effect("replays a fork with stable projected identities", () =>
Effect.gen(function* () {
const session = yield* Session.Service
const bus = yield* Bus.Service
const { db } = yield* Database.Service
const parent = yield* session.create({ location, title: "Parent" })
yield* session.prompt({ sessionID: parent.id, text: "First", resume: false })
yield* SessionInbox.promote(db, bus, parent.id, "steer")
yield* session.synthetic({ sessionID: parent.id, text: "Second", resume: false })
yield* SessionInbox.promote(db, bus, parent.id, "steer")
const forked = yield* session.fork({ sessionID: parent.id, boundary: { type: "through" } })
const original = (yield* session.context(forked.id)).map((message) => message.id)
const recorded = yield* db
.select()
.from(EventTable)
.where(eq(EventTable.aggregate_id, forked.id))
.get()
.pipe(Effect.orDie)
if (!recorded) return yield* Effect.die(new Error("Fork event not found"))
yield* bus.remove(forked.id)
yield* db.delete(SessionTable).where(eq(SessionTable.id, forked.id)).run().pipe(Effect.orDie)
yield* bus.replay({
id: recorded.id,
created: recorded.created,
aggregateID: recorded.aggregate_id,
seq: recorded.seq,
type: recorded.type,
data: recorded.data,
})
expect((yield* session.context(forked.id)).map((message) => message.id)).toEqual(original)
}),
)
it.effect("does not copy a running assistant into a fork", () =>
Effect.gen(function* () {
const session = yield* Session.Service
@@ -451,6 +487,49 @@ describe("Session.create", () => {
}),
)
it.effect("copies only settled shell messages into forks", () =>
Effect.gen(function* () {
const session = yield* Session.Service
const bus = yield* Bus.Service
const { db } = yield* Database.Service
const parent = yield* session.create({ location })
yield* session.prompt({ sessionID: parent.id, text: "Run a shell", resume: false })
yield* SessionInbox.promote(db, bus, parent.id, "steer")
const shell = Shell.Info.make({
id: Shell.ID.make("sh_fork_running"),
status: "running",
command: "sleep 10",
cwd: location.directory,
shell: "/bin/sh",
file: "/tmp/sh_fork_running.out",
metadata: {},
time: { started: 0 },
})
yield* bus.publish(SessionEvent.Shell.Started, { sessionID: parent.id, shell })
const running = yield* session.fork({ sessionID: parent.id, boundary: { type: "through" } })
expect(yield* session.context(parent.id)).toMatchObject([
{ type: "user", text: "Run a shell" },
{ type: "shell", command: "sleep 10", status: "running" },
])
expect(yield* session.context(running.id)).toMatchObject([{ type: "user", text: "Run a shell" }])
yield* bus.publish(SessionEvent.Shell.Ended, {
sessionID: parent.id,
shell: { ...shell, status: "exited", exit: 0, time: { started: 0, completed: 1 } },
output: { output: "complete", cursor: 8, size: 8, truncated: false },
})
const completed = yield* session.fork({ sessionID: parent.id, boundary: { type: "through" } })
expect(yield* session.context(running.id)).toMatchObject([{ type: "user", text: "Run a shell" }])
expect(yield* session.context(completed.id)).toMatchObject([
{ type: "user", text: "Run a shell" },
{ type: "shell", command: "sleep 10", status: "exited", output: { output: "complete" } },
])
}),
)
it.effect("rejects forking an empty session", () =>
Effect.gen(function* () {
const session = yield* Session.Service
@@ -887,6 +966,134 @@ describe("Session.create", () => {
})
describe("SessionTransfer", () => {
it.effect("exports only settled projected messages", () =>
Effect.gen(function* () {
const session = yield* Session.Service
const transfer = yield* SessionTransfer.Service
const bus = yield* Bus.Service
const { db } = yield* Database.Service
const source = yield* session.create({ location })
yield* session.prompt({ sessionID: source.id, text: "Settled", resume: false })
yield* SessionInbox.promote(db, bus, source.id, "steer")
yield* bus.publish(SessionEvent.Step.Started, {
sessionID: source.id,
assistantMessageID: SessionMessage.ID.create(),
agent: Agent.ID.make("build"),
model: Model.Ref.make({ id: Model.ID.make("model"), providerID: Provider.ID.make("provider") }),
})
yield* bus.publish(SessionEvent.Shell.Started, {
sessionID: source.id,
shell: Shell.Info.make({
id: Shell.ID.make("sh_transfer_export"),
status: "running",
command: "sleep 10",
cwd: location.directory,
shell: "/bin/sh",
file: "/tmp/sh_transfer_export.out",
metadata: {},
time: { started: 0 },
}),
})
yield* bus.publish(SessionEvent.Compaction.Started, {
sessionID: source.id,
reason: "manual",
recent: "pending",
})
expect((yield* transfer.export({ sessionID: source.id })).messages).toMatchObject([
{ type: "user", text: "Settled" },
])
}),
)
it.effect("imports only settled projected messages", () =>
Effect.gen(function* () {
const session = yield* Session.Service
const transfer = yield* SessionTransfer.Service
const { db } = yield* Database.Service
const template = yield* session.create({ location, title: "Transfer source" })
const sessionID = Session.ID.create()
const userID = SessionMessage.ID.create()
const runningAssistantID = SessionMessage.ID.create()
const completedAssistantID = SessionMessage.ID.create()
const runningShellID = SessionMessage.ID.create()
const completedShellID = SessionMessage.ID.create()
const runningCompactionID = SessionMessage.ID.create()
const completedCompactionID = SessionMessage.ID.create()
const model = Model.Ref.make({ id: Model.ID.make("model"), providerID: Provider.ID.make("provider") })
yield* transfer.import({
data: {
info: { ...template, id: sessionID },
messages: [
{ id: userID, type: "user", text: "Settled", time: { created: DateTime.makeUnsafe(1) } },
{
id: runningAssistantID,
type: "assistant",
agent: Agent.ID.make("build"),
model,
content: [],
time: { created: DateTime.makeUnsafe(2) },
},
{
id: completedAssistantID,
type: "assistant",
agent: Agent.ID.make("build"),
model,
content: [],
time: { created: DateTime.makeUnsafe(3), completed: DateTime.makeUnsafe(4) },
},
{
id: runningShellID,
type: "shell",
shellID: Shell.ID.make("sh_transfer_running"),
command: "sleep 10",
status: "running",
time: { created: DateTime.makeUnsafe(5) },
},
{
id: completedShellID,
type: "shell",
shellID: Shell.ID.make("sh_transfer_completed"),
command: "pwd",
status: "exited",
exit: 0,
output: { output: "/project", cursor: 8, size: 8, truncated: false },
time: { created: DateTime.makeUnsafe(6), completed: DateTime.makeUnsafe(7) },
},
{
id: runningCompactionID,
type: "compaction",
status: "running",
reason: "manual",
summary: "pending",
recent: "pending",
time: { created: DateTime.makeUnsafe(8) },
},
{
id: completedCompactionID,
type: "compaction",
status: "completed",
reason: "manual",
summary: "summary",
recent: "recent",
time: { created: DateTime.makeUnsafe(9) },
},
],
},
location,
})
expect((yield* session.messages({ sessionID, order: "asc" })).map((message) => message.id)).toEqual([
userID,
completedAssistantID,
completedShellID,
completedCompactionID,
])
expect(yield* Bus.latestSequence(db, sessionID)).toBe(4)
}),
)
it.effect("imports projected messages and reserves their aggregate sequence", () =>
Effect.gen(function* () {
const session = yield* Session.Service
@@ -900,7 +1107,15 @@ describe("SessionTransfer", () => {
const imported = yield* transfer.import({
data: {
info: { ...template, id: sessionID },
info: {
...template,
id: sessionID,
time: {
...template.time,
idle: DateTime.makeUnsafe(200),
viewed: DateTime.makeUnsafe(150),
},
},
messages: [
{
id: sourceMessageID,
@@ -923,13 +1138,18 @@ describe("SessionTransfer", () => {
const messages = yield* session.messages({ sessionID, order: "asc" })
expect(imported).toMatchObject({ id: sessionID, title: "Exported", location })
expect(imported.time).toMatchObject({ idle: DateTime.makeUnsafe(200), viewed: DateTime.makeUnsafe(150) })
expect(messages).toMatchObject([
{ id: sourceMessageID, type: "user", text: "Imported message" },
{ id: errorMessageID, type: "compaction", error: { type: "test_error", message: "Original error" } },
])
expect(yield* Bus.latestSequence(db, sessionID)).toBe(2)
expect((yield* transfer.export({ sessionID })).messages).toEqual(messages)
expect((yield* transfer.export({ sessionID, sanitize: true })).messages).toMatchObject([
const exported = yield* transfer.export({ sessionID })
expect(exported.info.time).toMatchObject({ idle: DateTime.makeUnsafe(200), viewed: DateTime.makeUnsafe(150) })
expect(exported.messages).toEqual(messages)
const sanitized = yield* transfer.export({ sessionID, sanitize: true })
expect(sanitized.info.time).toMatchObject({ idle: DateTime.makeUnsafe(200), viewed: DateTime.makeUnsafe(150) })
expect(sanitized.messages).toMatchObject([
{ id: sourceMessageID, text: `[redacted:text:${sourceMessageID}]` },
{ id: errorMessageID, error: { type: "test_error", message: "Original error" } },
])
@@ -958,4 +1178,31 @@ describe("SessionTransfer", () => {
expect(yield* session.messages({ sessionID: existing.id })).toEqual([])
}),
)
it.effect("clamps an imported viewed watermark to its idle transition", () =>
Effect.gen(function* () {
const session = yield* Session.Service
const transfer = yield* SessionTransfer.Service
const template = yield* session.create({ location })
const imported = yield* transfer.import({
data: {
info: {
...template,
id: Session.ID.create(),
outcome: "succeeded",
time: {
...template.time,
idle: DateTime.makeUnsafe(200),
viewed: DateTime.makeUnsafe(250),
},
},
messages: [],
},
location,
})
expect(imported.time).toMatchObject({ idle: DateTime.makeUnsafe(200), viewed: DateTime.makeUnsafe(200) })
expect(imported.outcome).toBe("succeeded")
}),
)
})
+42 -1
View File
@@ -1,12 +1,14 @@
import { describe, expect } from "bun:test"
import path from "path"
import { mkdir, rm } from "fs/promises"
import { Effect } from "effect"
import { Effect, Layer, LayerMap } from "effect"
import { Worktree } from "@opencode-ai/schema/worktree"
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 { Location } from "@opencode-ai/core/location"
import { LocationServiceMap } from "@opencode-ai/core/location-service-map"
import type { LocationServices } from "@opencode-ai/core/location-services"
import { Project } from "@opencode-ai/core/project"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Session } from "@opencode-ai/core/session"
@@ -28,8 +30,47 @@ const it = testEffect(
],
),
)
const unavailableLocations = Layer.effect(
LocationServiceMap.Service,
LayerMap.make(
() => Layer.effectDiscard(Effect.fail(new Error("broken location"))) as unknown as Layer.Layer<LocationServices>,
),
)
const itWithUnavailableDestination = testEffect(
AppNodeBuilder.build(
LayerNode.group([Database.node, Bus.node, SessionProjector.node, SessionStore.node, Session.node]),
[
[Project.node, globalProjectLayer],
[SessionExecution.node, SessionExecution.noopLayer],
[LocationServiceMap.node, unavailableLocations],
],
),
)
describe("Session.move", () => {
itWithUnavailableDestination.effect("rejects an unavailable destination before admitting the move", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.flatMap((tmp) =>
Effect.gen(function* () {
const session = yield* Session.Service
const source = AbsolutePath.make(path.join(tmp.path, "source"))
const destination = AbsolutePath.make(path.join(tmp.path, "destination"))
yield* Effect.promise(() => Promise.all([mkdir(source), mkdir(destination)]))
const created = yield* session.create({ location: Location.Ref.make({ directory: source }) })
const error = yield* session.move({ sessionID: created.id, directory: destination }).pipe(Effect.flip)
expect(error).toEqual(new Session.DestinationUnavailableError({ directory: destination }))
expect((yield* session.get(created.id)).location.directory).toBe(source)
expect(yield* session.inbox(created.id)).toEqual([])
}),
),
),
)
it.effect("applies a move immediately when the source directory no longer exists", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
+1 -1
View File
@@ -74,7 +74,7 @@ const locations = Layer.effect(
}),
Layer.mock(Snapshot.Service, {
capture: () =>
ready ? Effect.succeed(undefined) : Effect.die(new Error("Snapshot used before plugins were ready")),
ready ? Effect.undefined : Effect.die(new Error("Snapshot used before plugins were ready")),
restore: () =>
ready ? Effect.void : Effect.die(new Error("Snapshot used before plugins were ready")),
}),
@@ -88,16 +88,16 @@ const config = Config.testLayer()
const pluginSupervisor = Layer.succeed(PluginSupervisor.Service, PluginSupervisor.Service.of({ flush: Effect.void }))
const promptCatalog = Layer.mock(Catalog.Service, {
provider: {
get: () => Effect.succeed(undefined),
get: () => Effect.undefined,
all: () => Effect.succeed([]),
available: () => Effect.succeed([]),
},
model: {
get: () => Effect.succeed(undefined),
get: () => Effect.undefined,
all: () => Effect.succeed([]),
available: () => Effect.succeed([]),
default: () => Effect.succeed(undefined),
small: () => Effect.succeed(undefined),
default: () => Effect.undefined,
small: () => Effect.undefined,
},
})
const runnerLayer = (llmClient: Layer.Layer<typeof LLMClient.Service>) =>
+4 -4
View File
@@ -378,16 +378,16 @@ const pluginSupervisor = Layer.succeed(
)
const promptCatalog = Layer.mock(Catalog.Service, {
provider: {
get: () => Effect.succeed(undefined),
get: () => Effect.undefined,
all: () => Effect.succeed([]),
available: () => Effect.succeed([]),
},
model: {
get: () => Effect.succeed(undefined),
get: () => Effect.undefined,
all: () => Effect.succeed([]),
available: () => Effect.succeed([]),
default: () => Effect.succeed(undefined),
small: () => Effect.succeed(undefined),
default: () => Effect.undefined,
small: () => Effect.undefined,
},
})
const runnerLayer = AppNodeBuilder.build(SessionRunnerLLM.node, [
+216
View File
@@ -0,0 +1,216 @@
import { describe, expect } from "bun:test"
import path from "path"
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 { EventTable } from "@opencode-ai/core/event/sql"
import { Location } from "@opencode-ai/core/location"
import { Project } from "@opencode-ai/core/project"
import { ProjectTable } from "@opencode-ai/core/project/sql"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Session } from "@opencode-ai/core/session"
import { SessionEvent } from "@opencode-ai/core/session/event"
import { SessionExecution } from "@opencode-ai/core/session/execution"
import { SessionProjector } from "@opencode-ai/core/session/projector"
import { SessionTable } from "@opencode-ai/core/session/sql"
import { SessionStore } from "@opencode-ai/core/session/store"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { DateTime, Effect, Layer } from "effect"
import { asc, eq } from "drizzle-orm"
import { tmpdir } from "./fixture/tmpdir"
import { testEffect } from "./lib/effect"
import { globalProjectLayer } from "./lib/project"
const it = testEffect(
AppNodeBuilder.build(
LayerNode.group([Database.node, Bus.node, SessionProjector.node, SessionStore.node, Session.node]),
[
[Bus.node, Bus.configured({ persist: true })],
[Project.node, globalProjectLayer],
[SessionExecution.node, SessionExecution.noopLayer],
],
),
)
const location = Location.Ref.make({ directory: AbsolutePath.make("/project") })
describe("Session.view", () => {
it.effect("copies the latest idle time without changing session recency", () =>
Effect.gen(function* () {
const session = yield* Session.Service
const bus = yield* Bus.Service
const { db } = yield* Database.Service
const created = yield* session.create({ location })
expect(created.time.idle).toBeUndefined()
expect(created.time.viewed).toBeUndefined()
expect(created.outcome).toBeUndefined()
yield* session.view({ sessionID: created.id, idle: 0 })
expect((yield* session.get(created.id)).time.viewed).toBeUndefined()
yield* bus.publish(SessionEvent.Execution.Succeeded, { sessionID: created.id })
const idle = yield* session.get(created.id)
expect(idle.time.idle).toBeDefined()
expect(idle.time.viewed).toBeUndefined()
expect(idle.time.updated).toEqual(created.time.updated)
expect(idle.outcome).toBe("succeeded")
if (!idle.time.idle) return yield* Effect.die(new Error("Expected idle time"))
yield* session.view({ sessionID: created.id, idle: DateTime.toEpochMillis(idle.time.idle) })
const viewed = yield* session.get(created.id)
if (!viewed.time.idle || !viewed.time.viewed) return yield* Effect.die(new Error("Expected attention times"))
expect(viewed.time.viewed).toEqual(viewed.time.idle)
expect(viewed.time.updated).toEqual(created.time.updated)
expect(
yield* db
.select({ idle: SessionTable.time_idle, viewed: SessionTable.time_viewed })
.from(SessionTable)
.where(eq(SessionTable.id, created.id))
.get(),
).toEqual({
idle: DateTime.toEpochMillis(viewed.time.idle),
viewed: DateTime.toEpochMillis(viewed.time.viewed),
})
expect((yield* session.list()).data.find((item) => item.id === created.id)?.time).toEqual(viewed.time)
yield* session.view({ sessionID: created.id, idle: DateTime.toEpochMillis(viewed.time.idle) })
expect((yield* session.get(created.id)).time).toEqual(viewed.time)
yield* bus.publish(SessionEvent.Execution.Failed, {
sessionID: created.id,
error: { type: "unknown", message: "failed" },
})
const unread = yield* session.get(created.id)
if (!unread.time.idle || !unread.time.viewed) return yield* Effect.die(new Error("Expected attention times"))
expect(DateTime.toEpochMillis(unread.time.idle)).toBeGreaterThan(DateTime.toEpochMillis(unread.time.viewed))
expect(unread.outcome).toBe("failed")
yield* session.view({ sessionID: created.id, idle: DateTime.toEpochMillis(unread.time.idle) })
expect((yield* session.get(created.id)).time.viewed).toEqual(unread.time.idle)
yield* bus.publish(SessionEvent.Execution.Interrupted, { sessionID: created.id, reason: "shutdown" })
expect((yield* session.get(created.id)).time.idle).toEqual(unread.time.idle)
expect((yield* session.get(created.id)).outcome).toBe("failed")
yield* bus.publish(SessionEvent.Execution.Interrupted, { sessionID: created.id, reason: "user" })
const interrupted = yield* session.get(created.id)
if (!interrupted.time.idle || !interrupted.time.viewed)
return yield* Effect.die(new Error("Expected attention times"))
expect(DateTime.toEpochMillis(interrupted.time.idle)).toBeGreaterThan(
DateTime.toEpochMillis(interrupted.time.viewed),
)
expect(interrupted.outcome).toBe("interrupted")
expect(
(yield* db
.select({ type: EventTable.type })
.from(EventTable)
.where(eq(EventTable.aggregate_id, created.id))
.all()).filter((event) => event.type === Bus.versionedType(SessionEvent.Viewed.type, 1)),
).toHaveLength(2)
}),
)
it.effect("keeps a newer completion unread when the viewed watermark is stale", () =>
Effect.gen(function* () {
const session = yield* Session.Service
const bus = yield* Bus.Service
const created = yield* session.create({ location })
yield* bus.publish(SessionEvent.Execution.Succeeded, { sessionID: created.id })
const observed = (yield* session.get(created.id)).time.idle
if (!observed) return yield* Effect.die(new Error("Expected idle time"))
// A failure commits between the viewer's observation and the viewed event.
yield* bus.publish(SessionEvent.Execution.Failed, {
sessionID: created.id,
error: { type: "unknown", message: "failed" },
})
yield* session.view({ sessionID: created.id, idle: DateTime.toEpochMillis(observed) })
const stale = yield* session.get(created.id)
if (!stale.time.idle || !stale.time.viewed) return yield* Effect.die(new Error("Expected attention times"))
expect(stale.time.viewed).toEqual(observed)
expect(DateTime.toEpochMillis(stale.time.idle)).toBeGreaterThan(DateTime.toEpochMillis(stale.time.viewed))
yield* session.view({ sessionID: created.id, idle: DateTime.toEpochMillis(stale.time.idle) + 1 })
expect((yield* session.get(created.id)).time.viewed).toEqual(observed)
// A duplicate stale watermark never regresses a newer acknowledgement.
yield* session.view({ sessionID: created.id, idle: DateTime.toEpochMillis(stale.time.idle) })
const acked = yield* session.get(created.id)
expect(acked.time.viewed).toEqual(acked.time.idle)
yield* bus.publish(SessionEvent.Viewed, { sessionID: created.id, idle: DateTime.toEpochMillis(observed) })
expect((yield* session.get(created.id)).time.viewed).toEqual(acked.time.viewed)
}),
)
it.effect("rejects an unknown session", () =>
Effect.gen(function* () {
const session = yield* Session.Service
const sessionID = Session.ID.make("ses_missing_view")
expect(yield* Effect.flip(session.view({ sessionID, idle: 0 }))).toEqual(new Session.NotFoundError({ sessionID }))
}),
)
it.effect("replays viewed state into a fresh database", () =>
Effect.gen(function* () {
const session = yield* Session.Service
const bus = yield* Bus.Service
const sourceDb = (yield* Database.Service).db
const created = yield* session.create({ id: Session.ID.make("ses_view_replay"), location })
yield* bus.publish(SessionEvent.Execution.Succeeded, { sessionID: created.id })
const idle = (yield* session.get(created.id)).time.idle
if (!idle) return yield* Effect.die(new Error("Expected idle time"))
yield* session.view({ sessionID: created.id, idle: DateTime.toEpochMillis(idle) })
yield* bus.publish(SessionEvent.Execution.Failed, {
sessionID: created.id,
error: { type: "unknown", message: "failed" },
})
const expected = yield* session.get(created.id)
if (!expected.time.idle || !expected.time.viewed) return yield* Effect.die(new Error("Expected attention times"))
const expectedIdle = DateTime.toEpochMillis(expected.time.idle)
const expectedViewed = DateTime.toEpochMillis(expected.time.viewed)
const serialized = (yield* sourceDb
.select()
.from(EventTable)
.where(eq(EventTable.aggregate_id, created.id))
.orderBy(asc(EventTable.seq))
.all()
.pipe(Effect.orDie)).map((event) => ({
id: event.id,
created: event.created,
aggregateID: event.aggregate_id,
seq: event.seq,
type: event.type,
data: event.data,
}))
const tmp = yield* Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
)
const targetLayer = AppNodeBuilder.build(
LayerNode.group([Database.node, Bus.node, SessionProjector.node, SessionStore.node]),
[
[Database.node, Database.configured({ path: path.join(tmp.path, "target.sqlite") })],
[Bus.node, Bus.configured({ persist: true })],
],
)
yield* Effect.gen(function* () {
const db = (yield* Database.Service).db
const targetBus = yield* Bus.Service
const store = yield* SessionStore.Service
yield* db
.insert(ProjectTable)
.values({ id: Project.ID.global, worktree: location.directory, sandboxes: [] })
.run()
.pipe(Effect.orDie)
yield* Effect.forEach(serialized, (event) => targetBus.replay(event), { discard: true })
const replayed = yield* store.get(created.id)
expect(replayed?.time).toEqual(expected.time)
expect(replayed?.outcome).toBe("failed")
expect(expected.time.updated).toEqual(created.time.updated)
expect(expectedIdle).toBeGreaterThan(expectedViewed)
}).pipe(Effect.provide(Layer.fresh(targetLayer)))
}),
)
})
+3
View File
@@ -60,6 +60,9 @@ const session = (
model: null,
time_created: 1,
time_updated: 2,
time_idle: null,
time_viewed: null,
idle_outcome: null,
time_compacting: 3,
time_archived: null,
time_suspended: null,
+1 -1
View File
@@ -1426,7 +1426,7 @@ export function write(
output.files,
(file) =>
fs.exists(join(directory, file.path)).pipe(
Effect.flatMap((exists) => (exists ? fs.stat(join(directory, file.path)) : Effect.succeed(undefined))),
Effect.flatMap((exists) => (exists ? fs.stat(join(directory, file.path)) : Effect.undefined)),
Effect.flatMap((info) =>
info?.type === "SymbolicLink"
? new GenerationError({ reason: `Unsafe output path: ${file.path}` })
+214 -15
View File
@@ -362,7 +362,7 @@
}
}
},
"description": "Retrieve currently loaded plugins.",
"description": "Retrieve enabled server plugins and their current status.",
"summary": "List plugins"
}
},
@@ -2158,7 +2158,7 @@
}
}
},
"description": "Queue a durable session compaction request.",
"description": "Durably admit a session compaction request. Steers by default: it runs at the next step boundary instead of waiting behind queued prompts.",
"summary": "Compact session",
"requestBody": {
"content": {
@@ -3547,7 +3547,7 @@
}
}
},
"description": "Interrupt active execution owned by this OpenCode process. Idle interruption is a no-op. When continue=true, execution resumes pending steering input while queued work remains parked.",
"description": "Interrupt active execution owned by this OpenCode process. Idle interruption is a no-op. When continue=true, execution resumes pending steering input and next-in-line control items (manual compaction, moves) while queued prompts remain parked.",
"summary": "Interrupt session execution"
}
},
@@ -3789,6 +3789,87 @@
}
}
},
"/api/session/{sessionID}/view": {
"post": {
"tags": ["session"],
"operationId": "v2.session.view",
"parameters": [
{
"name": "sessionID",
"in": "path",
"schema": {
"type": "string",
"allOf": [
{
"pattern": "^ses"
}
]
},
"required": true
}
],
"security": [],
"responses": {
"204": {
"description": "<No Content>"
},
"400": {
"description": "InvalidRequestError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
}
}
}
},
"401": {
"description": "UnauthorizedError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UnauthorizedErrorEncoded"
}
}
}
},
"404": {
"description": "SessionNotFoundError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SessionNotFoundErrorEncoded"
}
}
}
}
},
"description": "Mark the idle transition observed by the viewer as viewed.",
"summary": "View session",
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"idle": {
"type": "integer",
"allOf": [
{
"minimum": 0
}
]
}
},
"required": ["idle"],
"additionalProperties": false
}
}
},
"required": true
}
}
},
"/api/session/{sessionID}/message": {
"get": {
"tags": ["session"],
@@ -7971,6 +8052,14 @@
},
"required": true
},
{
"name": "x-opencode-ticket",
"in": "header",
"schema": {
"$ref": "#/components/schemas/Union_"
},
"required": false
},
{
"name": "location",
"in": "query",
@@ -9914,15 +10003,106 @@
"required": ["_tag", "agentID", "message"],
"additionalProperties": false
},
"Plugin.Info": {
"type": "object",
"properties": {
"id": {
"type": "string"
"Plugin.Source": {
"anyOf": [
{
"type": "object",
"properties": {
"type": {
"type": "string",
"enum": ["builtin"]
}
},
"required": ["type"],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"type": {
"type": "string",
"enum": ["package"]
},
"package": {
"type": "string"
}
},
"required": ["type", "package"],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"type": {
"type": "string",
"enum": ["local"]
},
"path": {
"type": "string"
}
},
"required": ["type", "path"],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"type": {
"type": "string",
"enum": ["sdk"]
}
},
"required": ["type"],
"additionalProperties": false
}
},
"required": ["id"],
"additionalProperties": false
]
},
"Plugin.Info": {
"anyOf": [
{
"type": "object",
"properties": {
"id": {
"type": "string"
},
"source": {
"$ref": "#/components/schemas/Plugin.Source"
},
"status": {
"type": "string",
"enum": ["active"]
},
"tui": {
"type": "boolean"
}
},
"required": ["id", "source", "status", "tui"],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"id": {
"type": "string"
},
"source": {
"$ref": "#/components/schemas/Plugin.Source"
},
"status": {
"type": "string",
"enum": ["failed"]
},
"error": {
"type": "string"
},
"tui": {
"type": "boolean"
}
},
"required": ["source", "status", "error", "tui"],
"additionalProperties": false
}
]
},
"Session.ForkBoundary": {
"anyOf": [
@@ -10128,6 +10308,10 @@
"tokens": {
"$ref": "#/components/schemas/TokenUsage.Info"
},
"outcome": {
"type": "string",
"enum": ["succeeded", "failed", "interrupted"]
},
"time": {
"type": "object",
"properties": {
@@ -10137,6 +10321,12 @@
"updated": {
"type": "number"
},
"idle": {
"type": "number"
},
"viewed": {
"type": "number"
},
"archived": {
"type": "number"
}
@@ -10480,14 +10670,11 @@
"name": {
"type": "string"
},
"text": {
"type": "string"
},
"mention": {
"$ref": "#/components/schemas/Prompt.Mention"
}
},
"required": ["id", "name", "text"],
"required": ["id", "name"],
"additionalProperties": false
},
"Arrays_3": {
@@ -11003,6 +11190,9 @@
"required": ["type", "id", "name", "state", "time"],
"additionalProperties": false
},
"Session.Message.ProviderState_4": {
"type": "object"
},
"Session.Message.Assistant.Retry": {
"type": "object",
"properties": {
@@ -11099,6 +11289,12 @@
"type": "string",
"enum": ["stop", "length", "tool-calls", "content-filter", "error", "unknown"]
},
"rawFinish": {
"type": "string"
},
"providerState": {
"$ref": "#/components/schemas/Session.Message.ProviderState_4"
},
"cost": {
"$ref": "#/components/schemas/Money.USD"
},
@@ -11970,6 +12166,9 @@
"items": {
"type": "string"
}
},
"responsesWebsockets": {
"type": "boolean"
}
},
"required": ["tools", "input", "output"],
+1
View File
@@ -33,6 +33,7 @@
"@tsconfig/bun": "catalog:",
"@types/bun": "catalog:",
"@typescript/native-preview": "catalog:",
"prettier": "3.6.2",
"typescript": "catalog:"
}
}
+2 -1
View File
@@ -1,8 +1,9 @@
import { OpenApi } from "effect/unstable/httpapi"
import { format } from "prettier"
import { fileURLToPath } from "url"
import { ClientApi } from "../src/client.js"
const document = JSON.stringify(OpenApi.fromApi(ClientApi), null, 2) + "\n"
const document = await format(JSON.stringify(OpenApi.fromApi(ClientApi), null, 2), { parser: "json", printWidth: 120 })
const target = fileURLToPath(new URL("../openapi.json", import.meta.url))
if (process.argv.includes("--check")) {
+15 -1
View File
@@ -5,7 +5,7 @@ import { PromptInput } from "@opencode-ai/schema/prompt-input"
import { Session } from "@opencode-ai/schema/session"
import { InstructionEntry } from "@opencode-ai/schema/instruction-entry"
import { Project } from "@opencode-ai/schema/project"
import { AbsolutePath, PositiveInt, RelativePath, statics } from "@opencode-ai/schema/schema"
import { AbsolutePath, NonNegativeInt, PositiveInt, RelativePath, statics } from "@opencode-ai/schema/schema"
import { Event } from "@opencode-ai/schema/event"
import { Workspace } from "@opencode-ai/schema/workspace"
import { Context, Effect, Encoding, Result, Schema, SchemaGetter, Struct } from "effect"
@@ -708,6 +708,20 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo
}),
),
)
.add(
HttpApiEndpoint.post("session.view", "/api/session/:sessionID/view", {
params: { sessionID: Session.ID },
payload: Schema.Struct({ idle: NonNegativeInt }),
success: HttpApiSchema.NoContent,
error: SessionNotFoundError,
}).annotateMerge(
OpenApi.annotations({
identifier: "v2.session.view",
summary: "View session",
description: "Mark the idle transition observed by the viewer as viewed.",
}),
),
)
.annotateMerge(
OpenApi.annotations({
title: "session",
+12
View File
@@ -105,6 +105,17 @@ export const Renamed = Event.durable({
})
export type Renamed = typeof Renamed.Type
export const Viewed = Event.durable({
type: "session.viewed",
...options,
schema: {
...Base,
/** Epoch-millisecond idle watermark the viewer observed; projection never marks a newer idle transition viewed. */
idle: Schema.Finite,
},
})
export type Viewed = typeof Viewed.Type
export const UsageRecorded = Event.durable({
type: "session.usage.recorded",
...options,
@@ -585,6 +596,7 @@ export const Definitions = Event.inventory(
ModelSelected,
Moved,
Renamed,
Viewed,
UsageUpdated,
Deleted,
Forked,
+4
View File
@@ -37,9 +37,13 @@ export const Info = Schema.Struct({
model: Model.Ref.pipe(optional),
cost: Money.USD,
tokens: TokenUsage.Info,
/** Outcome of the last completed execution, recorded at `time.idle`. Absent until a run reaches a terminal transition. */
outcome: Schema.Literals(["succeeded", "failed", "interrupted"]).pipe(optional),
time: Schema.Struct({
created: DateTimeUtcFromMillis,
updated: DateTimeUtcFromMillis,
idle: DateTimeUtcFromMillis.pipe(optional),
viewed: DateTimeUtcFromMillis.pipe(optional),
archived: DateTimeUtcFromMillis.pipe(optional),
}),
title: Schema.String.pipe(optional),
+21 -9
View File
@@ -54,17 +54,29 @@ describe("contract hygiene", () => {
}),
).toEqual({ text: "completed" })
const info = Session.Info.make({
id: Session.ID.make("ses_untitled"),
projectID: Project.ID.make("global"),
cost: Money.USD.zero,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: {
created: DateTime.makeUnsafe(0),
updated: DateTime.makeUnsafe(0),
idle: undefined,
viewed: undefined,
},
title: undefined,
location: { directory: AbsolutePath.make("/project") },
})
const encoded = Schema.encodeSync(Session.Info)(info)
expect(encoded).not.toHaveProperty("title")
expect(encoded.time).toEqual({ created: 0, updated: 0 })
expect(
Schema.encodeSync(Session.Info)({
id: Session.ID.make("ses_untitled"),
projectID: Project.ID.make("global"),
cost: Money.USD.zero,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: DateTime.makeUnsafe(0), updated: DateTime.makeUnsafe(0) },
title: undefined,
location: { directory: AbsolutePath.make("/project") },
}),
).not.toHaveProperty("title")
...info,
time: { ...info.time, idle: DateTime.makeUnsafe(2), viewed: DateTime.makeUnsafe(1) },
}).time,
).toEqual({ created: 0, updated: 0, idle: 2, viewed: 1 })
})
test("session inbox items omit the internal enqueue sequence", () => {
@@ -83,6 +83,7 @@ describe("public event manifest", () => {
"session.model.selected.1",
"session.moved.1",
"session.renamed.1",
"session.viewed.1",
"session.usage.recorded.1",
"session.forked.2",
"session.inbox.delivered.1",
+12
View File
@@ -156,6 +156,15 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
}
}),
)
.handle(
"session.view",
Effect.fn(function* (ctx) {
yield* session
.view({ sessionID: ctx.params.sessionID, idle: ctx.payload.idle })
.pipe(Effect.catchTag("Session.NotFoundError", missingSession))
return HttpApiSchema.NoContent.make()
}),
)
.handle(
"session.remove",
Effect.fn(function* (ctx) {
@@ -240,6 +249,9 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
Effect.catchTag("Session.DestinationNotDirectoryError", (error) =>
Effect.fail(new InvalidRequestError({ message: `Not a directory: ${error.directory}` })),
),
Effect.catchTag("Session.DestinationUnavailableError", (error) =>
Effect.fail(new InvalidRequestError({ message: `Directory is unavailable: ${error.directory}` })),
),
)
return HttpApiSchema.NoContent.make()
}),
+47
View File
@@ -52,6 +52,53 @@ it.live("serves unauthenticated and answers CORS preflight when no password is c
}).pipe(Effect.scoped),
)
it.live("serves the session view operation and missing-session error", () =>
Effect.gen(function* () {
const handler = yield* ServerFetch.make(options)
const created = yield* Effect.promise(() =>
handler(
new Request("http://opencode.local/api/session", {
method: "POST",
headers: { "content-type": "application/json" },
body: "{}",
}),
).then((response) => response.json()),
)
if (typeof created !== "object" || created === null || !("data" in created))
return yield* Effect.die(new Error("Expected a session response"))
const data = created.data
if (typeof data !== "object" || data === null || !("id" in data) || typeof data.id !== "string")
return yield* Effect.die(new Error("Expected a session ID"))
const viewed = yield* Effect.promise(() =>
handler(
new Request(`http://opencode.local/api/session/${data.id}/view`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ idle: 0 }),
}),
),
)
expect(viewed.status).toBe(204)
const invalid = yield* Effect.promise(() =>
handler(new Request(`http://opencode.local/api/session/${data.id}/view`, { method: "POST" })),
)
expect(invalid.status).toBe(400)
const missing = yield* Effect.promise(() =>
handler(
new Request("http://opencode.local/api/session/ses_missing_view/view", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ idle: 0 }),
}),
),
)
expect(missing.status).toBe(404)
}).pipe(Effect.scoped),
)
// Pins the eager-boot guarantee: the application layer is built before the handler returns, so
// an aborted first request cannot interrupt layer construction and wedge every later request
// (the Effect-TS/effect#6319 failure class that lazy first-request builds are prone to).
+13 -11
View File
@@ -33,14 +33,23 @@ function Status(props: { status: McpServer["status"]; loading: boolean }) {
return <>Disabled </>
}
export function DialogMcp() {
export function DialogMcp(props: { initialServer?: string; details?: boolean } = {}) {
const data = useData()
const dialog = useDialog()
const client = useClient()
const toast = useToast()
const theme = useTheme("elevated")
const [focused, setFocused] = createSignal<string>()
const [detail, setDetail] = createSignal<McpServer>()
const servers = createMemo(() =>
pipe(
data.location.mcp.server.list() ?? [],
sortBy((server) => server.name),
),
)
const initial = props.initialServer ? servers().find((server) => server.name === props.initialServer) : undefined
const [focused, setFocused] = createSignal<string | undefined>(props.initialServer)
const [detail, setDetail] = createSignal<McpServer | undefined>(
props.details && initial?.status.status === "failed" ? initial : undefined,
)
const [loading, setLoading] = createSignal<string | null>(null)
const statusColor = (status: McpServer["status"]) => {
@@ -50,13 +59,6 @@ export function DialogMcp() {
return theme.text.subdued
}
const servers = createMemo(() =>
pipe(
data.location.mcp.server.list() ?? [],
sortBy((server) => server.name),
),
)
createEffect(() => {
if (focused()) return
const first = servers()[0]
@@ -153,7 +155,7 @@ export function DialogMcp() {
title={`MCP server: ${server().name}`}
error={statusError(server().status) ?? "Unknown MCP connection error"}
onBack={() => {
setDetail()
setDetail(undefined)
dialog.setSize("medium")
}}
/>
+61 -39
View File
@@ -1,5 +1,5 @@
import { createEffect, createMemo, createSignal, onCleanup } from "solid-js"
import { useRenderer } from "@opentui/solid"
import { useKeyboard, useRenderer } from "@opentui/solid"
import { isDeepEqual } from "remeda"
import { createSimpleContext } from "./helper"
import { useClient } from "./client"
@@ -25,12 +25,12 @@ import {
type ClosedSessionTab,
type SessionTab,
type SessionTabHistory,
type SessionTabUnread,
} from "./session-tabs-model"
type TabsState = {
tabs: SessionTab[]
unread: Record<string, SessionTabUnread>
// Read only long enough to remove the former client-owned state from persisted tab files.
unread?: Record<string, unknown>
}
type PersistedState = {
@@ -43,10 +43,12 @@ type ScrollAnchor = {
screenY: number
}
const empty = (): TabsState => ({ tabs: [], unread: {} })
const empty = (): TabsState => ({ tabs: [] })
// Deliberately after connect settles: the visible session's mount syncs win the first slots.
const TAB_PREFETCH_DELAY = 300
const VIEW_RETRY_DELAY = 250
const VIEW_RETRY_MAX_DELAY = 5_000
export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimpleContext({
name: "SessionTabs",
@@ -60,8 +62,7 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
const paths = useTuiPaths()
const renderer = useRenderer()
const enabled = () => config.tabs.enabled
// Focus reporting emits transitions, so an interactive launch owns unread state until its first blur.
const [focused, setFocused] = createSignal(true)
const [focused, setFocused] = createSignal<boolean>()
// Keyed reconcile keeps tab object identity across reorders, so strip rows move instead of
// mutating in place, which per-row animations and drag state depend on.
const [store, updateStore] = useStorage().store<PersistedState>("tabs", {
@@ -87,6 +88,7 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
const onFocus = () => setFocused(true)
const onBlur = () => setFocused(false)
useKeyboard(onFocus)
renderer.on("focus", onFocus)
renderer.on("blur", onBlur)
onCleanup(() => {
@@ -112,16 +114,20 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
const session = data.session.get(sessionID)
return session?.title ?? persisted ?? fallback ?? (session ? withTimestampedFallback(session) : undefined)
}
const isUnread = (sessionID: string) => {
const info = data.session.get(sessionID)
return info?.time.idle !== undefined && (info.time.viewed === undefined || info.time.idle > info.time.viewed)
}
const family = (sessionID: string) => {
const session = root(sessionID)
const members = data.session.family(session)
return members.length > 0 ? members : [session]
}
const normalize = (value: TabsState) => ({
tabs: value.tabs.reduce<SessionTab[]>((tabs, tab) => {
const sessionID = root(tab.sessionID)
return openSessionTab(tabs, { sessionID, title: title(sessionID, tab.title) })
}, []),
unread: Object.entries(value.unread).reduce<Record<string, SessionTabUnread>>((result, entry) => {
const sessionID = root(entry[0])
result[sessionID] = result[sessionID] === "error" ? "error" : entry[1]
return result
}, {}),
})
const current = () => (route.data.type === "session" ? root(route.data.sessionID) : undefined)
const newTab = createMemo((open = false) => {
@@ -132,29 +138,23 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
}, false)
const status = (sessionID: string) => {
const session = root(sessionID)
const members = data.session.family(session)
const family = members.length > 0 ? members : [session]
const members = family(session)
return {
unread: state().unread[session],
// Unread reads the root session only: background subagent completions wake the parent,
// whose own idle transition then carries the signal.
unread: !isUnread(session)
? undefined
: data.session.get(session)?.outcome === "failed"
? ("error" as const)
: ("activity" as const),
promptPulse: promptPulses()[session] ?? 0,
attention: family.some(
attention: members.some(
(id) => (data.session.permission.list(id)?.length ?? 0) > 0 || (data.session.form.list(id)?.length ?? 0) > 0,
),
busy: family.some((id) => data.session.status(id) === "running" || data.session.pending.list(id).length > 0),
busy: members.some((id) => data.session.status(id) === "running" || data.session.pending.list(id).length > 0),
}
}
function markUnread(sessionID: string, unread: SessionTabUnread) {
if (!enabled() || !focused()) return
const session = root(sessionID)
if (current() === session || !state().tabs.some((tab) => tab.sessionID === session)) return
if (state().unread[session] === unread) return
update((draft) => {
if (!draft.tabs.some((tab) => tab.sessionID === session)) return
draft.unread[session] = unread
})
}
createEffect(() => {
if (!enabled()) return
if (route.data.type !== "session" || route.data.sessionID === "dummy") return
@@ -176,14 +176,40 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
})
})
// Viewed state is server-global, so acknowledgement runs even with tabs disabled: other
// clients rely on this client reporting what its user has seen.
const acknowledged = new Map<string, number>()
const [viewRetry, setViewRetry] = createSignal(0)
let viewRetryTimer: ReturnType<typeof setTimeout> | undefined
let viewRetryAttempt = 0
onCleanup(() => clearTimeout(viewRetryTimer))
createEffect(() => {
if (!enabled() || !focused()) return
viewRetry()
if (focused() !== true) return
if (route.data.type !== "session" || route.data.sessionID === "dummy") return
const sessionID = root(route.data.sessionID)
if (!state().unread[sessionID]) return
update((draft) => {
delete draft.unread[sessionID]
})
const idle = data.session.get(sessionID)?.time.idle
if (idle === undefined || !isUnread(sessionID) || acknowledged.get(sessionID) === idle) return
// Record before the request so event-driven re-runs don't re-post the same watermark.
acknowledged.set(sessionID, idle)
void client.api.session.view({ sessionID, idle }).then(
() => {
clearTimeout(viewRetryTimer)
viewRetryTimer = undefined
viewRetryAttempt = 0
},
() => {
if (acknowledged.get(sessionID) !== idle) return
acknowledged.delete(sessionID)
if (viewRetryTimer) return
const delay = Math.min(VIEW_RETRY_DELAY * 2 ** viewRetryAttempt, VIEW_RETRY_MAX_DELAY)
viewRetryAttempt++
viewRetryTimer = setTimeout(() => {
viewRetryTimer = undefined
setViewRetry((value) => value + 1)
}, delay)
},
)
})
createEffect(() => {
@@ -193,7 +219,7 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
update((draft) => {
const next = normalize(draft)
draft.tabs = next.tabs
draft.unread = next.unread
delete draft.unread
})
})
@@ -214,7 +240,7 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
const sessionIDs = signature.split("\n")
let stale = false
void (async () => {
await Promise.allSettled(sessionIDs.map((sessionID) => data.session.sync(sessionID)))
await Promise.allSettled(sessionIDs.map((sessionID) => data.session.sync(sessionID, { children: true })))
if (stale) return
const locations = new Map(
sessionIDs
@@ -248,9 +274,6 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
})
})
onCleanup(event.on("session.execution.succeeded", (evt) => markUnread(evt.data.sessionID, "activity")))
onCleanup(event.on("session.execution.interrupted", (evt) => markUnread(evt.data.sessionID, "activity")))
onCleanup(event.on("session.execution.failed", (evt) => markUnread(evt.data.sessionID, "error")))
onCleanup(
event.on("session.moved", (evt) => {
if (!enabled() || !state().tabs.some((tab) => tab.sessionID === root(evt.data.sessionID))) return
@@ -287,7 +310,6 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
history = previous.history
update((draft) => {
draft.tabs = closeSessionTab(draft.tabs, target).tabs
delete draft.unread[target]
})
setPromptPulses((pulses) => {
if (pulses[target] === undefined) return pulses
@@ -384,7 +406,7 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
cycleUnread(direction: 1 | -1) {
if (!enabled()) return
const tab = cycleSessionTab(state().tabs, current(), direction, (tab) =>
Boolean(state().unread[tab.sessionID] || status(tab.sessionID).attention),
Boolean(status(tab.sessionID).unread || status(tab.sessionID).attention),
)
if (tab) route.navigate({ type: "session", sessionID: tab.sessionID })
},
@@ -1,5 +1,6 @@
import { Plugin } from "@opencode-ai/plugin/tui"
import { createMemo, For, Match, Show, Switch, createSignal } from "solid-js"
import { DialogMcp } from "../../component/dialog-mcp"
function View(props: { context: Plugin.Context; sessionID: string }) {
const [open, setOpen] = createSignal(true)
@@ -39,7 +40,16 @@ function View(props: { context: Plugin.Context; sessionID: string }) {
<Show when={list().length <= 2 || open()}>
<For each={list()}>
{(item) => (
<box flexDirection="row" gap={1}>
<box
flexDirection="row"
gap={1}
minWidth={0}
onMouseUp={() =>
props.context.ui.dialog.show(() => (
<DialogMcp initialServer={item.name} details={item.status.status === "failed"} />
))
}
>
<text
flexShrink={0}
style={{
@@ -48,18 +58,21 @@ function View(props: { context: Plugin.Context; sessionID: string }) {
>
</text>
<text fg={theme.text.default} wrapMode="word">
{item.name}{" "}
<span style={{ fg: theme.text.subdued }}>
<Switch fallback={item.status.status}>
<Match when={item.status.status === "connected"}>Connected</Match>
<Match when={item.status.status === "failed"}>
<i>{item.status.status === "failed" ? item.status.error : undefined}</i>
</Match>
<Match when={item.status.status === "disabled"}>Disabled</Match>
<Match when={item.status.status === "needs_auth"}>Needs auth</Match>
</Switch>
</span>
<text fg={theme.text.default} wrapMode="none" truncate flexGrow={1} flexShrink={1} minWidth={0}>
<b>{item.name}</b>
</text>
<text
fg={item.status.status === "failed" ? theme.text.feedback.error.default : theme.text.subdued}
wrapMode="none"
flexShrink={0}
>
<Switch fallback={item.status.status}>
<Match when={item.status.status === "connected"}>Connected</Match>
<Match when={item.status.status === "pending"}>Connecting</Match>
<Match when={item.status.status === "failed"}>Error</Match>
<Match when={item.status.status === "disabled"}>Disabled</Match>
<Match when={item.status.status === "needs_auth"}>Sign in</Match>
</Switch>
</text>
</box>
)}
@@ -34,6 +34,9 @@ test("scopes sessions to the active session location", async () => {
return json({ directory, project: { id: project, directory, canonical: directory } })
}
if (url.pathname !== "/api/session") return undefined
// Family syncs list children by parentID; only project-scoped list requests matter here.
const parentID = url.searchParams.get("parentID")
if (parentID && parentID !== "null") return json({ data: [], cursor: {} })
const project = url.searchParams.get("project") ?? ""
requestedProjects.push(project)
return json({
+224 -64
View File
@@ -35,7 +35,12 @@ async function renderSessionTabs(
persisted?: string[]
sessionGate?: Promise<void>
sessionDirectories?: Record<string, string>
sessionParents?: Record<string, string>
sessionTimes?: Record<string, { idle?: number; viewed?: number }>
sessionOutcomes?: Record<string, "succeeded" | "failed" | "interrupted">
newLocation?: "launch" | "inherit"
tabsEnabled?: boolean
viewFailures?: number
},
) {
const temporary = options?.state ? undefined : await tmpdir()
@@ -46,16 +51,26 @@ async function renderSessionTabs(
await Bun.write(
file,
JSON.stringify({
global: { tabs: [], unread: {} },
cwd: { [directory]: { tabs: options.persisted.map((sessionID) => ({ sessionID })), unread: {} } },
global: { tabs: [], unread: { ses_legacy: "error" } },
cwd: {
[directory]: {
tabs: options.persisted.map((sessionID) => ({ sessionID })),
unread: { ses_legacy: "activity" },
},
},
}),
)
}
const events = createEventStream()
const sessions: string[] = []
const views: string[] = []
const viewWatermarks: number[] = []
const locations: string[] = []
const vcsLocations: string[] = []
const calls = createFetch(async (url) => {
const sessionTimes = Object.fromEntries(
Object.entries(options?.sessionTimes ?? {}).map(([sessionID, time]) => [sessionID, { ...time }]),
)
const calls = createFetch(async (url, request) => {
if (url.pathname === "/api/location") {
const requested = url.searchParams.get("location[directory]") ?? directory
locations.push(requested)
@@ -72,22 +87,45 @@ async function renderSessionTabs(
data: { branch: { current: "main", default: "main" } },
})
}
if (url.pathname === "/api/session" && url.searchParams.has("parentID")) {
const parentID = url.searchParams.get("parentID")
const children = Object.entries(options?.sessionParents ?? {})
.filter(([, parent]) => parent === parentID)
.map(([sessionID]) => sessionInfo(sessionID))
return json({ data: children, cursor: {} })
}
const viewed = url.pathname.match(/^\/api\/session\/([^/]+)\/view$/)?.[1]
if (viewed && request.method === "POST") {
views.push(viewed)
const payload: unknown = await request.json()
if (typeof payload !== "object" || payload === null || !("idle" in payload) || typeof payload.idle !== "number")
throw new Error("Expected an idle watermark")
viewWatermarks.push(payload.idle)
if (views.length <= (options?.viewFailures ?? 0)) return new Response(null, { status: 503 })
const time = (sessionTimes[viewed] ??= {})
time.viewed = Math.min(payload.idle, time.idle ?? payload.idle)
return new Response(null, { status: 204 })
}
const sessionID = url.pathname.match(/^\/api\/session\/([^/]+)$/)?.[1]
if (!sessionID) return undefined
sessions.push(sessionID)
await options?.sessionGate
return json({
data: {
id: sessionID,
title: sessionID === initialSessionID ? options?.title : undefined,
projectID: "project",
location: { directory: options?.sessionDirectories?.[sessionID] ?? directory },
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 0, updated: 0 },
},
})
return json({ data: sessionInfo(sessionID) })
}, events)
function sessionInfo(sessionID: string) {
return {
id: sessionID,
parentID: options?.sessionParents?.[sessionID],
title: sessionID === initialSessionID ? options?.title : undefined,
projectID: "project",
location: { directory: options?.sessionDirectories?.[sessionID] ?? directory },
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
outcome: options?.sessionOutcomes?.[sessionID],
time: { created: 0, updated: 0, ...sessionTimes[sessionID] },
}
}
let tabs!: ReturnType<typeof useSessionTabs>
let route!: ReturnType<typeof useRoute>
let client!: ReturnType<typeof useClient>
@@ -109,7 +147,7 @@ async function renderSessionTabs(
<StorageProvider>
<ConfigProvider
config={createTuiResolvedConfig({
tabs: { enabled: true },
tabs: { enabled: options?.tabsEnabled ?? true },
session: { new_location: options?.newLocation ?? "launch" },
})}
>
@@ -138,9 +176,14 @@ async function renderSessionTabs(
route,
data,
sessions,
views,
viewWatermarks,
locations,
vcsLocations,
state,
setSessionTime(sessionID: string, time: { idle?: number; viewed?: number }) {
sessionTimes[sessionID] = time
},
emit: (event: OpenCodeEvent) => events.emit({ ...event, location: { directory } }),
focus: () => app.renderer.emit("focus"),
blur: () => app.renderer.emit("blur"),
@@ -153,14 +196,6 @@ async function renderSessionTabs(
}
}
const executionSucceeded = (sessionID: string): OpenCodeEvent => ({
id: `evt_done_${sessionID}`,
created: Date.now(),
type: "session.execution.succeeded",
durable: { aggregateID: sessionID, seq: 1, version: 1 },
data: { sessionID },
})
test("loads persisted tab metadata concurrently on connect", async () => {
let release!: () => void
const sessionGate = new Promise<void>((resolve) => (release = resolve))
@@ -230,10 +265,10 @@ test("stores session tabs for the current working directory by default", async (
const file = path.join(setup.state, "test", "tui", "tabs.json")
await wait(() => Bun.file(file).size > 0)
const stored = await Bun.file(file).json()
expect(stored.global).toEqual({ tabs: [], unread: {} })
expect(stored.global).toEqual({ tabs: [] })
expect(Object.keys(stored.cwd)).toEqual([directory])
expect(stored.cwd[directory].tabs.map((tab: { sessionID: string }) => tab.sessionID)).toEqual(["first"])
expect(stored.cwd[directory].unread).toEqual({})
expect(stored.cwd[directory]).not.toHaveProperty("unread")
} finally {
await setup.destroy()
}
@@ -257,47 +292,172 @@ test("keeps scroll anchors for open session tabs", async () => {
}
})
test("only the foreground TUI mutates unread state", async () => {
await using temporary = await tmpdir()
let foreground: Awaited<ReturnType<typeof renderSessionTabs>> | undefined
let background: Awaited<ReturnType<typeof renderSessionTabs>> | undefined
test("derives unread state from server session times", async () => {
const setup = await renderSessionTabs("first", {
home: true,
persisted: ["first", "second"],
sessionTimes: { second: { idle: 2 } },
})
try {
foreground = await renderSessionTabs("first", { state: temporary.path, persisted: ["first", "second"] })
background = await renderSessionTabs("second", { state: temporary.path })
foreground.focus()
background.blur()
await wait(() => foreground?.tabs.tabs().length === 2 && background?.tabs.tabs().length === 2, 2_000, "shared tabs")
const firstDone = executionSucceeded("first")
foreground.emit(firstDone)
background.emit(firstDone)
await Promise.all([foreground.flush(), background.flush()])
expect(foreground.tabs.status("first").unread).toBeUndefined()
expect(background.tabs.status("first").unread).toBeUndefined()
const secondDone = executionSucceeded("second")
foreground.emit(secondDone)
background.emit(secondDone)
await wait(
() =>
foreground?.tabs.status("second").unread === "activity" &&
background?.tabs.status("second").unread === "activity",
10_000,
"shared unread activity",
)
foreground.tabs.select("second")
await wait(
() =>
foreground?.tabs.status("second").unread === undefined &&
background?.tabs.status("second").unread === undefined,
10_000,
"shared unread clearing",
)
await wait(() => setup.tabs.status("second").unread === "activity")
expect(setup.tabs.status("first").unread).toBeUndefined()
} finally {
if (foreground) await foreground.destroy()
if (background) await background.destroy()
await setup.destroy()
}
})
test("marks unread failed sessions with error styling", async () => {
const setup = await renderSessionTabs("first", {
home: true,
persisted: ["first", "second"],
sessionTimes: { first: { idle: 2 }, second: { idle: 2 } },
sessionOutcomes: { second: "failed" },
})
try {
await wait(() => setup.tabs.status("second").unread === "error")
expect(setup.tabs.status("first").unread).toBe("activity")
} finally {
await setup.destroy()
}
})
test("acknowledges viewed sessions even when tabs are disabled", async () => {
const setup = await renderSessionTabs("first", {
tabsEnabled: false,
sessionTimes: { first: { idle: 2 } },
})
try {
setup.focus()
await setup.data.session.sync("first")
await wait(() => setup.views.includes("first"))
expect(setup.tabs.tabs()).toEqual([])
} finally {
await setup.destroy()
}
})
test("purges legacy persisted unread records", async () => {
const setup = await renderSessionTabs("first", { persisted: ["first"] })
try {
const file = path.join(setup.state, "test", "tui", "tabs.json")
// Normalize rewrites the active scope; the legacy record must not survive it.
await wait(async () => {
const stored = await Bun.file(file).json()
return !("unread" in stored.cwd[directory])
})
} finally {
await setup.destroy()
}
})
test("refreshes server session times after terminal events", async () => {
const setup = await renderSessionTabs("first", { home: true, persisted: ["first"] })
try {
// Terminal events refresh only already-loaded sessions, so ensure the initial sync landed.
await wait(() => setup.data.session.get("first") !== undefined)
setup.setSessionTime("first", { idle: 2 })
setup.emit({
id: "evt_done_first",
created: 2,
type: "session.execution.succeeded",
durable: { aggregateID: "first", seq: 1, version: 1 },
data: { sessionID: "first" },
})
await wait(() => setup.tabs.status("first").unread === "activity")
} finally {
await setup.destroy()
}
})
test("views a selected unread session only while focused", async () => {
const setup = await renderSessionTabs("first", {
home: true,
persisted: ["first"],
sessionTimes: { first: { idle: 2 } },
})
try {
setup.blur()
setup.route.navigate({ type: "session", sessionID: "first" })
await wait(() => setup.tabs.current() === "first" && setup.tabs.status("first").unread === "activity")
await Bun.sleep(20)
expect(setup.views).toEqual([])
setup.focus()
await wait(() => setup.views.includes("first"))
setup.emit({
id: "evt_viewed_first",
created: 3,
type: "session.viewed",
durable: { aggregateID: "first", seq: 2, version: 1 },
data: { sessionID: "first", idle: 2 },
})
await wait(() => setup.tabs.status("first").unread === undefined)
expect(setup.views).toEqual(["first"])
expect(setup.viewWatermarks).toEqual([2])
} finally {
await setup.destroy()
}
})
test("does not acknowledge an unread session until focus is confirmed", async () => {
const setup = await renderSessionTabs("first", { sessionTimes: { first: { idle: 2 } } })
try {
await wait(() => setup.tabs.status("first").unread === "activity")
await Bun.sleep(20)
expect(setup.views).toEqual([])
setup.focus()
await wait(() => setup.views.includes("first"))
} finally {
await setup.destroy()
}
})
test("retries a failed view acknowledgement", async () => {
const setup = await renderSessionTabs("first", {
sessionTimes: { first: { idle: 2 } },
viewFailures: 1,
})
try {
setup.focus()
await wait(() => setup.views.length === 2)
expect(setup.views).toEqual(["first", "first"])
expect(setup.viewWatermarks).toEqual([2, 2])
} finally {
await setup.destroy()
}
})
test("ignores subagent unread state on the root tab", async () => {
const setup = await renderSessionTabs("root", {
home: true,
persisted: ["root"],
sessionParents: { child: "root" },
sessionTimes: { child: { idle: 2 } },
})
try {
await wait(() => setup.data.session.get("child") !== undefined)
expect(setup.tabs.status("root").unread).toBeUndefined()
setup.route.navigate({ type: "session", sessionID: "root" })
await Bun.sleep(20)
expect(setup.views).toEqual([])
// A background subagent completion wakes the parent; the parent's own idle transition
// then carries the unread signal and is the only state acknowledged.
setup.focus()
setup.setSessionTime("root", { idle: 3 })
setup.emit({
id: "evt_done_root",
created: 3,
type: "session.execution.succeeded",
durable: { aggregateID: "root", seq: 1, version: 1 },
data: { sessionID: "root" },
})
await wait(() => setup.views.includes("root"))
expect(setup.views).toEqual(["root"])
} finally {
await setup.destroy()
}
})
+1 -9
View File
@@ -22,7 +22,6 @@ import { makeGlobalNode } from "./effect/app-node.js"
import { filesystem, path } from "./effect/app-node-platform.js"
const toError = (err: unknown): Error => (err instanceof globalThis.Error ? err : new globalThis.Error(String(err)))
const nativeWindowsExtensions = new Set([".com", ".exe"])
const toTag = (err: NodeJS.ErrnoException): PlatformError.SystemErrorTag => {
switch (err.code) {
@@ -262,14 +261,7 @@ const makeCrossSpawnSpawner = Effect.gen(function* () {
const launchProcess = (command: ChildProcess.StandardCommand, opts: NodeChildProcess.SpawnOptions) =>
Effect.callback<readonly [NodeChildProcess.ChildProcess, ExitSignal], PlatformError.PlatformError>((resume) => {
const signal = Deferred.makeUnsafe<readonly [code: number | null, signal: NodeJS.Signals | null]>()
const native =
process.platform === "win32" &&
!opts.shell &&
path.isAbsolute(command.command) &&
nativeWindowsExtensions.has(path.extname(command.command).toLowerCase())
const proc = native
? NodeChildProcess.spawn(command.command, command.args, opts)
: launch(command.command, command.args, opts)
const proc = launch(command.command, command.args, opts)
let end = false
let exit: readonly [code: number | null, signal: NodeJS.Signals | null] | undefined
proc.on("error", (err) => {
+2 -2
View File
@@ -70,8 +70,8 @@ export namespace FSUtil {
const readFileStringSafe = Effect.fn("FileSystem.readFileStringSafe")(function* (path: string) {
return yield* fs.readFileString(path).pipe(
Effect.catchReason("PlatformError", "NotFound", () => Effect.succeed(undefined)),
Effect.catchReason("PlatformError", "PermissionDenied", () => Effect.succeed(undefined)),
Effect.catchReason("PlatformError", "NotFound", () => Effect.undefined),
Effect.catchReason("PlatformError", "PermissionDenied", () => Effect.undefined),
)
})
+214 -15
View File
@@ -362,7 +362,7 @@
}
}
},
"description": "Retrieve currently loaded plugins.",
"description": "Retrieve enabled server plugins and their current status.",
"summary": "List plugins"
}
},
@@ -2158,7 +2158,7 @@
}
}
},
"description": "Queue a durable session compaction request.",
"description": "Durably admit a session compaction request. Steers by default: it runs at the next step boundary instead of waiting behind queued prompts.",
"summary": "Compact session",
"requestBody": {
"content": {
@@ -3547,7 +3547,7 @@
}
}
},
"description": "Interrupt active execution owned by this OpenCode process. Idle interruption is a no-op. When continue=true, execution resumes pending steering input while queued work remains parked.",
"description": "Interrupt active execution owned by this OpenCode process. Idle interruption is a no-op. When continue=true, execution resumes pending steering input and next-in-line control items (manual compaction, moves) while queued prompts remain parked.",
"summary": "Interrupt session execution"
}
},
@@ -3789,6 +3789,87 @@
}
}
},
"/api/session/{sessionID}/view": {
"post": {
"tags": ["session"],
"operationId": "v2.session.view",
"parameters": [
{
"name": "sessionID",
"in": "path",
"schema": {
"type": "string",
"allOf": [
{
"pattern": "^ses"
}
]
},
"required": true
}
],
"security": [],
"responses": {
"204": {
"description": "<No Content>"
},
"400": {
"description": "InvalidRequestError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
}
}
}
},
"401": {
"description": "UnauthorizedError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UnauthorizedErrorEncoded"
}
}
}
},
"404": {
"description": "SessionNotFoundError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SessionNotFoundErrorEncoded"
}
}
}
}
},
"description": "Mark the idle transition observed by the viewer as viewed.",
"summary": "View session",
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"idle": {
"type": "integer",
"allOf": [
{
"minimum": 0
}
]
}
},
"required": ["idle"],
"additionalProperties": false
}
}
},
"required": true
}
}
},
"/api/session/{sessionID}/message": {
"get": {
"tags": ["session"],
@@ -7971,6 +8052,14 @@
},
"required": true
},
{
"name": "x-opencode-ticket",
"in": "header",
"schema": {
"$ref": "#/components/schemas/Union_"
},
"required": false
},
{
"name": "location",
"in": "query",
@@ -9914,15 +10003,106 @@
"required": ["_tag", "agentID", "message"],
"additionalProperties": false
},
"Plugin.Info": {
"type": "object",
"properties": {
"id": {
"type": "string"
"Plugin.Source": {
"anyOf": [
{
"type": "object",
"properties": {
"type": {
"type": "string",
"enum": ["builtin"]
}
},
"required": ["type"],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"type": {
"type": "string",
"enum": ["package"]
},
"package": {
"type": "string"
}
},
"required": ["type", "package"],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"type": {
"type": "string",
"enum": ["local"]
},
"path": {
"type": "string"
}
},
"required": ["type", "path"],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"type": {
"type": "string",
"enum": ["sdk"]
}
},
"required": ["type"],
"additionalProperties": false
}
},
"required": ["id"],
"additionalProperties": false
]
},
"Plugin.Info": {
"anyOf": [
{
"type": "object",
"properties": {
"id": {
"type": "string"
},
"source": {
"$ref": "#/components/schemas/Plugin.Source"
},
"status": {
"type": "string",
"enum": ["active"]
},
"tui": {
"type": "boolean"
}
},
"required": ["id", "source", "status", "tui"],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"id": {
"type": "string"
},
"source": {
"$ref": "#/components/schemas/Plugin.Source"
},
"status": {
"type": "string",
"enum": ["failed"]
},
"error": {
"type": "string"
},
"tui": {
"type": "boolean"
}
},
"required": ["source", "status", "error", "tui"],
"additionalProperties": false
}
]
},
"Session.ForkBoundary": {
"anyOf": [
@@ -10128,6 +10308,10 @@
"tokens": {
"$ref": "#/components/schemas/TokenUsage.Info"
},
"outcome": {
"type": "string",
"enum": ["succeeded", "failed", "interrupted"]
},
"time": {
"type": "object",
"properties": {
@@ -10137,6 +10321,12 @@
"updated": {
"type": "number"
},
"idle": {
"type": "number"
},
"viewed": {
"type": "number"
},
"archived": {
"type": "number"
}
@@ -10480,14 +10670,11 @@
"name": {
"type": "string"
},
"text": {
"type": "string"
},
"mention": {
"$ref": "#/components/schemas/Prompt.Mention"
}
},
"required": ["id", "name", "text"],
"required": ["id", "name"],
"additionalProperties": false
},
"Arrays_3": {
@@ -11003,6 +11190,9 @@
"required": ["type", "id", "name", "state", "time"],
"additionalProperties": false
},
"Session.Message.ProviderState_4": {
"type": "object"
},
"Session.Message.Assistant.Retry": {
"type": "object",
"properties": {
@@ -11099,6 +11289,12 @@
"type": "string",
"enum": ["stop", "length", "tool-calls", "content-filter", "error", "unknown"]
},
"rawFinish": {
"type": "string"
},
"providerState": {
"$ref": "#/components/schemas/Session.Message.ProviderState_4"
},
"cost": {
"$ref": "#/components/schemas/Money.USD"
},
@@ -11970,6 +12166,9 @@
"items": {
"type": "string"
}
},
"responsesWebsockets": {
"type": "boolean"
}
},
"required": ["tools", "input", "output"],
+214 -15
View File
@@ -362,7 +362,7 @@
}
}
},
"description": "Retrieve currently loaded plugins.",
"description": "Retrieve enabled server plugins and their current status.",
"summary": "List plugins"
}
},
@@ -2158,7 +2158,7 @@
}
}
},
"description": "Queue a durable session compaction request.",
"description": "Durably admit a session compaction request. Steers by default: it runs at the next step boundary instead of waiting behind queued prompts.",
"summary": "Compact session",
"requestBody": {
"content": {
@@ -3547,7 +3547,7 @@
}
}
},
"description": "Interrupt active execution owned by this OpenCode process. Idle interruption is a no-op. When continue=true, execution resumes pending steering input while queued work remains parked.",
"description": "Interrupt active execution owned by this OpenCode process. Idle interruption is a no-op. When continue=true, execution resumes pending steering input and next-in-line control items (manual compaction, moves) while queued prompts remain parked.",
"summary": "Interrupt session execution"
}
},
@@ -3789,6 +3789,87 @@
}
}
},
"/api/session/{sessionID}/view": {
"post": {
"tags": ["session"],
"operationId": "v2.session.view",
"parameters": [
{
"name": "sessionID",
"in": "path",
"schema": {
"type": "string",
"allOf": [
{
"pattern": "^ses"
}
]
},
"required": true
}
],
"security": [],
"responses": {
"204": {
"description": "<No Content>"
},
"400": {
"description": "InvalidRequestError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
}
}
}
},
"401": {
"description": "UnauthorizedError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UnauthorizedErrorEncoded"
}
}
}
},
"404": {
"description": "SessionNotFoundError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SessionNotFoundErrorEncoded"
}
}
}
}
},
"description": "Mark the idle transition observed by the viewer as viewed.",
"summary": "View session",
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"idle": {
"type": "integer",
"allOf": [
{
"minimum": 0
}
]
}
},
"required": ["idle"],
"additionalProperties": false
}
}
},
"required": true
}
}
},
"/api/session/{sessionID}/message": {
"get": {
"tags": ["session"],
@@ -7971,6 +8052,14 @@
},
"required": true
},
{
"name": "x-opencode-ticket",
"in": "header",
"schema": {
"$ref": "#/components/schemas/Union_"
},
"required": false
},
{
"name": "location",
"in": "query",
@@ -9914,15 +10003,106 @@
"required": ["_tag", "agentID", "message"],
"additionalProperties": false
},
"Plugin.Info": {
"type": "object",
"properties": {
"id": {
"type": "string"
"Plugin.Source": {
"anyOf": [
{
"type": "object",
"properties": {
"type": {
"type": "string",
"enum": ["builtin"]
}
},
"required": ["type"],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"type": {
"type": "string",
"enum": ["package"]
},
"package": {
"type": "string"
}
},
"required": ["type", "package"],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"type": {
"type": "string",
"enum": ["local"]
},
"path": {
"type": "string"
}
},
"required": ["type", "path"],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"type": {
"type": "string",
"enum": ["sdk"]
}
},
"required": ["type"],
"additionalProperties": false
}
},
"required": ["id"],
"additionalProperties": false
]
},
"Plugin.Info": {
"anyOf": [
{
"type": "object",
"properties": {
"id": {
"type": "string"
},
"source": {
"$ref": "#/components/schemas/Plugin.Source"
},
"status": {
"type": "string",
"enum": ["active"]
},
"tui": {
"type": "boolean"
}
},
"required": ["id", "source", "status", "tui"],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"id": {
"type": "string"
},
"source": {
"$ref": "#/components/schemas/Plugin.Source"
},
"status": {
"type": "string",
"enum": ["failed"]
},
"error": {
"type": "string"
},
"tui": {
"type": "boolean"
}
},
"required": ["source", "status", "error", "tui"],
"additionalProperties": false
}
]
},
"Session.ForkBoundary": {
"anyOf": [
@@ -10128,6 +10308,10 @@
"tokens": {
"$ref": "#/components/schemas/TokenUsage.Info"
},
"outcome": {
"type": "string",
"enum": ["succeeded", "failed", "interrupted"]
},
"time": {
"type": "object",
"properties": {
@@ -10137,6 +10321,12 @@
"updated": {
"type": "number"
},
"idle": {
"type": "number"
},
"viewed": {
"type": "number"
},
"archived": {
"type": "number"
}
@@ -10480,14 +10670,11 @@
"name": {
"type": "string"
},
"text": {
"type": "string"
},
"mention": {
"$ref": "#/components/schemas/Prompt.Mention"
}
},
"required": ["id", "name", "text"],
"required": ["id", "name"],
"additionalProperties": false
},
"Arrays_3": {
@@ -11003,6 +11190,9 @@
"required": ["type", "id", "name", "state", "time"],
"additionalProperties": false
},
"Session.Message.ProviderState_4": {
"type": "object"
},
"Session.Message.Assistant.Retry": {
"type": "object",
"properties": {
@@ -11099,6 +11289,12 @@
"type": "string",
"enum": ["stop", "length", "tool-calls", "content-filter", "error", "unknown"]
},
"rawFinish": {
"type": "string"
},
"providerState": {
"$ref": "#/components/schemas/Session.Message.ProviderState_4"
},
"cost": {
"$ref": "#/components/schemas/Money.USD"
},
@@ -11970,6 +12166,9 @@
"items": {
"type": "string"
}
},
"responsesWebsockets": {
"type": "boolean"
}
},
"required": ["tools", "input", "output"],