Compare commits

...
101 changed files with 18151 additions and 16043 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 -12
View File
@@ -365,7 +365,6 @@
"@opencode-ai/codemode": "workspace:*",
"@opencode-ai/plugin": "workspace:*",
"@opencode-ai/schema": "workspace:*",
"@opencode-ai/shell-scan": "workspace:*",
"@opencode-ai/util": "workspace:*",
"@parcel/watcher": "2.5.1",
"@silvia-odwyer/photon-node": "0.3.4",
@@ -630,6 +629,7 @@
"@tsconfig/bun": "catalog:",
"@types/bun": "catalog:",
"@typescript/native-preview": "catalog:",
"prettier": "3.6.2",
"typescript": "catalog:",
},
},
@@ -734,15 +734,6 @@
"vite": "catalog:",
},
},
"packages/shell-scan": {
"name": "@opencode-ai/shell-scan",
"version": "0.0.0",
"devDependencies": {
"@tsconfig/bun": "catalog:",
"@types/bun": "catalog:",
"@typescript/native-preview": "catalog:",
},
},
"packages/simulation": {
"name": "@opencode-ai/simulation",
"version": "1.17.13",
@@ -2063,8 +2054,6 @@
"@opencode-ai/session-ui": ["@opencode-ai/session-ui@workspace:packages/session-ui"],
"@opencode-ai/shell-scan": ["@opencode-ai/shell-scan@workspace:packages/shell-scan"],
"@opencode-ai/simulation": ["@opencode-ai/simulation@workspace:packages/simulation"],
"@opencode-ai/stats-app": ["@opencode-ai/stats-app@workspace:packages/stats/app"],
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-PatsUdaitHvSUpS5gkC5J2rsUNB5vwJKqHdlOFaKk70=",
"aarch64-linux": "sha256-gTRQMAADH/SpQ8yh+YS2IcnmQxnJcU4FUah2YfrKeP8=",
"aarch64-darwin": "sha256-QTqlwmugYh+iu5Sh/Hxv01NXH/OhzcQ8ObVUbA9A8AM=",
"x86_64-darwin": "sha256-0DPAbNCVw2nUMWkIGEhB6saMdxRRwAJi7wAoWCQc7xQ="
"x86_64-linux": "sha256-xRvq8FkSjn+1q+1wcab+jAmQJdo8lHF8OGnzU+xPLyI=",
"aarch64-linux": "sha256-uAwwtOz81LLimTqiDH6E1W65srSMLGk1QV0cUJYanj0=",
"aarch64-darwin": "sha256-F28kZvtYRb+ri5T8JoIG/VMK6J7ad5R3QivwJDLtmWA=",
"x86_64-darwin": "sha256-NWNoBjwQTTNAESdFWItT4UYc+fTLD1RIe/Ibbqtem7Q="
}
}
+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(
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -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) =>
+65 -16
View File
@@ -263,6 +263,8 @@ export type SessionInboxCompaction = {
export type InstructionEntryInfo = { key: InstructionEntryKey; value: JsonValue }
export type InstructionEntrySnapshot = Array<{ key: InstructionEntryKey; value: JsonValue; removed: boolean }>
export type SessionCreated = {
id: string
created: number
@@ -324,6 +326,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
@@ -334,16 +346,6 @@ export type SessionDeleted = {
data: { sessionID: string }
}
export type SessionForked = {
id: string
created: number
metadata?: { [x: string]: any }
type: "session.forked"
durable: { aggregateID: string; seq: number; version: 2 }
location?: LocationRef
data: { sessionID: string; parentID: string; boundary: SessionForkBoundary; instructions?: { [x: string]: string } }
}
export type SessionInboxDelivered = {
id: string
created: number
@@ -1087,7 +1089,7 @@ export type PtyUpdated = {
data: { info: Pty }
}
export type SessionStatus2 = {
export type SessionStatusUpdated = {
id: string
created: number
metadata?: { [x: string]: any }
@@ -1302,6 +1304,22 @@ export type FormWhen = {
export type ToolContent = ToolTextContent | ToolFileContent
export type SessionForked = {
id: string
created: number
metadata?: { [x: string]: any }
type: "session.forked"
durable: { aggregateID: string; seq: number; version: 2 }
location?: LocationRef
data: {
sessionID: string
parentID: string
boundary: SessionForkBoundary
instructions?: { [x: string]: string }
instructionEntries?: InstructionEntrySnapshot
}
}
export type SessionShellStarted = {
id: string
created: number
@@ -1664,7 +1682,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 +1968,7 @@ export type SessionEventDurable =
| SessionModelSelected
| SessionMoved
| SessionRenamed
| SessionViewed
| SessionDeleted
| SessionForked
| SessionInboxDelivered
@@ -2034,6 +2054,7 @@ export type V2Event =
| SessionModelSelected
| SessionMoved
| SessionRenamed
| SessionViewed
| SessionUsageUpdated
| SessionDeleted
| SessionForked
@@ -2096,7 +2117,7 @@ export type V2Event =
| FormReplied
| FormCancelled
| WebsearchUpdated
| SessionStatus2
| SessionStatusUpdated
| SessionIdle
| TuiPromptAppend
| TuiCommandExecute
@@ -2510,7 +2531,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 +2806,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 +3081,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 +4022,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])
@@ -19,8 +19,8 @@ test("effect entrypoint exposes canonical Schema contracts", () => {
test("generated Effect API names canonical and composed outputs", async () => {
const source = await Bun.file(new URL("../src/effect/api/api.ts", import.meta.url)).text()
expect(source).toContain("export type Endpoint5_5Output = Session.Info")
expect(source).toContain("export type Endpoint19_0Output = OpenCodeEvent")
expect(source).toContain("export type SessionGetOutput = Session.Info")
expect(source).toContain("export type EventSubscribeOutput = OpenCodeEvent")
expect(source).not.toContain("HttpApiClient.ForApi")
})
+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),
-1
View File
@@ -118,7 +118,6 @@
"@opencode-ai/ai": "workspace:*",
"@opencode-ai/schema": "workspace:*",
"@opencode-ai/plugin": "workspace:*",
"@opencode-ai/shell-scan": "workspace:*",
"@opencode-ai/util": "workspace:*",
"@standard-schema/spec": "catalog:",
"@parcel/watcher": "2.5.1",
+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,
+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,
+44 -4
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"
@@ -54,6 +54,7 @@ import { KeyedMutex } from "./effect/keyed-mutex.js"
import { fileURLToPath } from "url"
import { SessionEnvironment } from "./session/environment.js"
import { SessionHistory } from "./session/history.js"
import { InstructionEntry } from "./session/instruction-entry.js"
// get project -> project.locations
//
@@ -156,6 +157,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 +178,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 +225,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
@@ -428,6 +438,14 @@ const layer = Layer.effect(
})
if (!boundary) return yield* new ForkEmptyError({ sessionID: input.sessionID })
const sessionID = SessionSchema.ID.create()
const inherited = yield* db
.transaction(() =>
Effect.all({
instructions: InstructionState.current(db, parent.id),
instructionEntries: InstructionEntry.snapshot(db, parent.id),
}),
)
.pipe(Effect.orDie)
// The fork adopts the parent's newest instruction values rather than the
// values in effect at the boundary; copied history may contain frozen
// instruction-update text the initial baseline already reflects.
@@ -435,7 +453,7 @@ const layer = Layer.effect(
sessionID,
parentID: parent.id,
boundary: { ...input.boundary, messageID: boundary.id },
instructions: yield* InstructionState.current(db, parent.id),
...inherited,
})
return yield* result.get(sessionID).pipe(Effect.orDie)
}),
@@ -449,6 +467,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 +802,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,
},
})
@@ -13,9 +13,59 @@ export const Key = InstructionEntry.Key
export type Key = typeof Key.Type
export const Info = InstructionEntry.Info
export type Info = typeof Info.Type
export const Snapshot = InstructionEntry.Snapshot
export type Snapshot = typeof Snapshot.Type
export const MaxValueBytes = InstructionEntry.MaxValueBytes
export const ValueTooLargeError = InstructionEntry.ValueTooLargeError
type DatabaseService = Database.Interface["db"]
const InsertBatchSize = 10
export const snapshot = Effect.fn("InstructionEntry.snapshot")(function* (
db: DatabaseService,
sessionID: SessionSchema.ID,
) {
return yield* db
.select({
key: InstructionEntryTable.key,
value: InstructionEntryTable.value,
removed: InstructionEntryTable.removed,
})
.from(InstructionEntryTable)
.where(eq(InstructionEntryTable.session_id, sessionID))
.orderBy(asc(InstructionEntryTable.key))
.all()
.pipe(Effect.orDie)
})
export const initialize = Effect.fn("InstructionEntry.initialize")(function* (
db: DatabaseService,
sessionID: SessionSchema.ID,
entries: Snapshot,
created: number,
) {
const batches = Array.from({ length: Math.ceil(entries.length / InsertBatchSize) }, (_, index) =>
entries.slice(index * InsertBatchSize, (index + 1) * InsertBatchSize),
)
yield* Effect.forEach(
batches,
(batch) =>
db
.insert(InstructionEntryTable)
.values(
batch.map((entry) => ({
...entry,
session_id: sessionID,
time_created: created,
time_updated: created,
})),
)
.run()
.pipe(Effect.orDie),
{ discard: true },
)
})
export interface Interface {
readonly list: (sessionID: SessionSchema.ID) => Effect.Effect<ReadonlyArray<Info>>
readonly put: (input: {
@@ -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* () {
+54 -4
View File
@@ -15,6 +15,7 @@ import { SessionInbox } from "./inbox.js"
import { Workspace } from "../workspace.js"
import { InstructionState } from "./instruction-state.js"
import { SessionInboxTable, SessionMessageTable, SessionTable } from "./sql.js"
import { InstructionEntry } from "./instruction-entry.js"
import { Slug } from "../util/slug.js"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Money } from "@opencode-ai/schema/money"
@@ -171,6 +172,9 @@ const projectFork = Effect.fn("SessionProjector.projectFork")(function* (
.pipe(Effect.orDie)
if (!stored) return yield* Effect.die(new SessionAlreadyProjected())
if (event.data.instructionEntries)
yield* InstructionEntry.initialize(db, event.data.sessionID, event.data.instructionEntries, event.created)
let cursor = -1
while (copiedSeq !== undefined) {
const rows = yield* db
@@ -183,6 +187,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 +201,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 +395,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 +547,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 +625,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
@@ -198,7 +198,7 @@ const scanLegacy = Effect.fnUntraced(function* (command: string, shell: string,
})
async function scanPortable(command: string, shell: string, cwd: string) {
const { ShellScan } = await import("@opencode-ai/shell-scan")
const { ShellScan } = await import("./scan.js")
const powershell = ShellSelect.ps(shell)
const result = powershell ? ShellScan.scanPowerShell(command) : ShellScan.scan(command)
if (result.kind === "opaque") return { commands: [{ resource: command, save: command }], directories: [] }
@@ -1,4 +1,4 @@
export * as ShellScan from "./index.js"
export * as ShellScan from "./scan.js"
export type OpaqueReason =
| "command-substitution"
+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")
+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* () {
+307 -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"
@@ -12,6 +13,7 @@ import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Hash } from "@opencode-ai/util/hash"
import { Bus } from "@opencode-ai/core/bus"
import { EventTable } from "@opencode-ai/core/event/sql"
import { Instructions } from "@opencode-ai/core/instructions/index"
import { Location } from "@opencode-ai/core/location"
import { Model } from "@opencode-ai/core/model"
import { Project } from "@opencode-ai/core/project"
@@ -23,6 +25,7 @@ import { SessionMessage } from "@opencode-ai/core/session/message"
import { SessionProjector } from "@opencode-ai/core/session/projector"
import { SessionExecution } from "@opencode-ai/core/session/execution"
import { SessionInbox } from "@opencode-ai/core/session/inbox"
import { InstructionEntry } from "@opencode-ai/core/session/instruction-entry"
import { SessionEvent } from "@opencode-ai/core/session/event"
import { SessionTable } from "@opencode-ai/core/session/sql"
import { SessionStore } from "@opencode-ai/core/session/store"
@@ -41,6 +44,7 @@ const it = testEffect(
SessionStore.node,
Session.node,
SessionTransfer.node,
InstructionEntry.node,
]),
[
[Bus.node, Bus.configured({ persist: true })],
@@ -401,6 +405,95 @@ 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("inherits instruction entries when forking", () =>
Effect.gen(function* () {
const session = yield* Session.Service
const entries = yield* InstructionEntry.Service
const bus = yield* Bus.Service
const { db } = yield* Database.Service
const parent = yield* session.create({ location })
yield* session.prompt({ sessionID: parent.id, text: "Fork context", resume: false })
yield* SessionInbox.promote(db, bus, parent.id, "steer")
yield* entries.put({ sessionID: parent.id, key: "deploy-target", value: "production" })
yield* entries.put({ sessionID: parent.id, key: "retired", value: true })
yield* entries.remove({ sessionID: parent.id, key: "retired" })
yield* Effect.forEach(
Array.from({ length: 20 }, (_, index) => index),
(index) => entries.put({ sessionID: parent.id, key: `entry-${String(index).padStart(2, "0")}`, value: index }),
{ discard: true },
)
const forked = yield* session.fork({ sessionID: parent.id, boundary: { type: "through" } })
const inheritedList = yield* entries.list(forked.id)
const inheritedValues = yield* entries.load(forked.id).pipe(Effect.flatMap(Instructions.read))
expect(inheritedList).toHaveLength(21)
expect(inheritedList).toContainEqual({ key: "deploy-target", value: "production" })
expect(inheritedValues).toContainEqual({
key: Instructions.Key.make("api/retired"),
value: Instructions.removed,
})
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* entries.put({ sessionID: parent.id, key: "deploy-target", value: "staging" })
yield* entries.put({ sessionID: parent.id, key: "new-parent-entry", value: true })
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* entries.list(forked.id)).toEqual(inheritedList)
expect(yield* entries.load(forked.id).pipe(Effect.flatMap(Instructions.read))).toEqual(inheritedValues)
}),
)
it.effect("does not copy a running assistant into a fork", () =>
Effect.gen(function* () {
const session = yield* Session.Service
@@ -451,6 +544,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 +1023,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 +1164,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 +1195,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 +1235,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)))
}),
)
})
@@ -1,7 +1,7 @@
import { describe, expect, test } from "bun:test"
import { ShellScan } from "@opencode-ai/shell-scan"
import { Effect } from "effect"
import { ShellParse } from "../src/shell/parse.js"
import { ShellScan } from "../src/shell/scan.js"
describe("ShellParse portable parity", () => {
test("matches tree-sitter for generated supported syntax", async () => {
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test"
import { ShellScan } from "../src/index.js"
import { ShellScan } from "../../src/shell/scan.js"
describe("ShellScan adversarial corpus", () => {
test.each([
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test"
import { ShellScan } from "../src/index.js"
import { ShellScan } from "../../src/shell/scan.js"
const opaque = ["$COMMAND hidden", "$(printf command) hidden", 'printf "unterminated'] as const
const contexts = [
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test"
import { ShellScan } from "../src/index.js"
import { ShellScan } from "../../src/shell/scan.js"
const staticCommands = [
["git status", ["git", "status"]],
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test"
import { ShellScan } from "../src/index.js"
import { ShellScan } from "../../src/shell/scan.js"
describe("ShellScan structural mutation closure", () => {
test.each([
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test"
import { ShellScan } from "../src/index.js"
import { ShellScan } from "../../src/shell/scan.js"
describe("ShellScan", () => {
test("scans a static command", () => {
+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,
+93 -43
View File
@@ -216,12 +216,17 @@ export function compile<Id extends string, Groups extends HttpApiGroup.Constrain
const modules = new Set(["client", "client-error", "index"])
const groups = Array.from(
Map.groupBy(endpoints, (endpoint) => endpoint.group),
([identifier, endpoints], index) => {
([identifier, endpoints]) => {
if (new Set(endpoints.map((endpoint) => endpoint.sourceGroup)).size > 1) {
throw new GenerationError({ reason: `Client group name collision: ${identifier}` })
}
const base = /^[A-Za-z0-9_-]+$/.test(identifier) ? identifier : `group-${index}`
const module = uniqueModule(base, index, modules)
// Module names derive from the group identifier so unrelated groups never rename.
const sanitized = identifier.replace(/[^A-Za-z0-9_-]+/g, "-").replace(/^-+|-+$/g, "")
const reserved = /^(aux|client|client-error|con|index|nul|prn|com[1-9]|lpt[1-9])$/i.test(sanitized)
const module = sanitized === "" || reserved ? `group-${sanitized}` : sanitized
if (modules.has(module.toLowerCase())) {
throw new GenerationError({ reason: `Client module name collision: ${module}` })
}
modules.add(module.toLowerCase())
return { identifier, sourceIdentifier: endpoints[0].sourceGroup, module, endpoints }
},
@@ -363,9 +368,28 @@ function renderEffectShape(
) {
const references = effectTypeReferences(typeReferences)
const imports = new Set<string>()
const endpointTypes = groups.map((group, groupIndex) => {
const endpoints = group.endpoints.map((endpoint, endpointIndex) => {
const prefix = `Endpoint${groupIndex}_${endpointIndex}`
const externalNames = new Set([
"AppApi",
"Effect",
"Stream",
...typeReferences.flatMap((reference) => reference.name.match(/^[A-Za-z_$][A-Za-z0-9_$]*/) ?? []),
...Object.values(outputTypes ?? {}).flatMap((output) => output.name.match(/^[A-Za-z_$][A-Za-z0-9_$]*/) ?? []),
])
const generatedNames = groups.flatMap((group) => [
groupShapeName(group),
...group.endpoints.flatMap((endpoint) => [
...(endpoint.operation.inputMode === "none" ? [] : [`${endpointTypeName(group, endpoint)}Input`]),
`${endpointTypeName(group, endpoint)}Output`,
groupShapeTypeName(group, endpoint),
]),
])
const collision = generatedNames.find((name) => externalNames.has(name))
if (collision !== undefined) {
throw new GenerationError({ reason: `Generated Effect type collides with imported type: ${collision}` })
}
const endpointTypes = groups.map((group) => {
const endpoints = group.endpoints.map((endpoint) => {
const prefix = endpointTypeName(group, endpoint)
const input = endpoint.input
.map((field) => {
const schema = effectInputSchema(endpoint, field)
@@ -530,8 +554,23 @@ function groupShapeName(group: Group) {
return `${identifierPart(group.identifier)}Api`
}
// Generated symbol names derive from group and endpoint identity, never from traversal
// position, so adding an endpoint or group cannot rename unrelated generated code.
// Uniqueness is validated by compile (groupTypeNames/endpointTypeNames).
function groupTypeName(group: Group) {
return identifierPart(group.identifier)
}
function endpointTypeName(group: Group, endpoint: Endpoint) {
return `${groupTypeName(group)}${endpoint.clientPath.map(identifierPart).join("")}`
}
function endpointAdapterName(group: Group, endpoint: Endpoint) {
return `Endpoint${endpointTypeName(group, endpoint)}`
}
function groupShapeTypeName(group: Group, endpoint: Endpoint) {
return `${identifierPart(group.identifier)}${endpoint.clientPath.map(identifierPart).join("")}Operation`
return `${endpointTypeName(group, endpoint)}Operation`
}
function assertPromiseEndpoint(endpoint: Endpoint) {
@@ -585,7 +624,7 @@ function promiseOperations(groups: ReadonlyArray<Group>) {
function renderEffectFiles(groups: ReadonlyArray<Group>): Output["files"] {
return [
...groups.map((group, index) => ({ path: `${group.module}.ts`, content: renderGroup(group, index) })),
...groups.map((group) => ({ path: `${group.module}.ts`, content: renderGroup(group) })),
{
path: "client-error.ts",
content:
@@ -610,10 +649,11 @@ function renderImportedEffectFiles(
readonly shapeModule?: string
},
): Output["files"] {
const adapters = groups.map((group, groupIndex) => {
const adapters = groups.map((group) => {
const rawGroup = group.endpoints[0]?.topLevel ? "RawClient" : `RawClient[${JSON.stringify(group.sourceIdentifier)}]`
const methods = group.endpoints.map((item, endpointIndex) => {
const prefix = `Endpoint${groupIndex}_${endpointIndex}`
const methods = group.endpoints.map((item) => {
const prefix = endpointTypeName(group, item)
const adapter = endpointAdapterName(group, item)
const schemaBySource = {
params: item.params,
query: item.query,
@@ -660,20 +700,22 @@ function renderImportedEffectFiles(
: isOpaquePayload(item)
? `type ${prefix}Request = Parameters<${rawGroup}[${JSON.stringify(item.endpoint.identifier)}]>[0]\n`
: ""
return `${declarations}const ${prefix} = (raw: ${rawGroup}) => (${argument}) => ${output}`
return `${declarations}const ${adapter} = (raw: ${rawGroup}) => (${argument}) => ${output}`
})
const fields = renderClientTree(
group.endpoints,
(_item, endpointIndex) => `Endpoint${groupIndex}_${endpointIndex}(raw)`,
(item) => `${endpointAdapterName(group, item)}(raw)`,
(name, value) => `${JSON.stringify(name)}: ${value}`,
", ",
)
return `${methods.join("\n\n")}\n\nconst adaptGroup${groupIndex} = (raw: ${rawGroup}) => ({ ${fields} })`
return `${methods.join("\n\n")}\n\nconst adaptGroup${groupTypeName(group)} = (raw: ${rawGroup}) => ({ ${fields} })`
})
const fields = groups.flatMap((group, index) =>
const fields = groups.flatMap((group) =>
group.endpoints[0]?.topLevel
? [`...adaptGroup${index}(raw)`]
: [`${JSON.stringify(group.identifier)}: adaptGroup${index}(raw[${JSON.stringify(group.sourceIdentifier)}])`],
? [`...adaptGroup${groupTypeName(group)}(raw)`]
: [
`${JSON.stringify(group.identifier)}: adaptGroup${groupTypeName(group)}(raw[${JSON.stringify(group.sourceIdentifier)}])`,
],
)
const usesStream = groups.some((group) => group.endpoints.some((item) => item.operation.success === "stream"))
const imported = "api" in options
@@ -683,15 +725,24 @@ function renderImportedEffectFiles(
? renderImportedGroup(options.group)
: renderImportedProjection(groups, options.endpoints)
const api = imported ? options.api : "Api"
const adapterNames = new Set(
groups.flatMap((group) => group.endpoints.map((endpoint) => endpointAdapterName(group, endpoint))),
)
const adapterCollision = (projection?.imports ?? [api]).find((name) => adapterNames.has(name))
if (adapterCollision !== undefined) {
throw new GenerationError({
reason: `Generated Effect adapter collides with imported endpoint: ${adapterCollision}`,
})
}
const imports =
projection === undefined
? `import { ${api} } from ${JSON.stringify(options.module)}`
: `import { HttpApi, HttpApiClient${"endpoints" in options ? ", HttpApiGroup" : ""} } from "effect/unstable/httpapi"\nimport { ${projection.imports.join(", ")} } from ${JSON.stringify(options.module)}`
const httpApiImport = projection === undefined ? 'import { HttpApiClient } from "effect/unstable/httpapi"\n' : ""
const shapeTypes = groups.flatMap((group, groupIndex) =>
group.endpoints.flatMap((endpoint, endpointIndex) => [
...(endpoint.operation.inputMode === "none" ? [] : [`Endpoint${groupIndex}_${endpointIndex}Input`]),
`Endpoint${groupIndex}_${endpointIndex}Output`,
const shapeTypes = groups.flatMap((group) =>
group.endpoints.flatMap((endpoint) => [
...(endpoint.operation.inputMode === "none" ? [] : [`${endpointTypeName(group, endpoint)}Input`]),
`${endpointTypeName(group, endpoint)}Output`,
]),
)
const shapeImport =
@@ -975,11 +1026,12 @@ function renderClientTree(
}
function identifierPart(value: string) {
return value
const identifier = value
.split(/[^A-Za-z0-9]+/)
.filter(Boolean)
.map((part) => `${part[0]?.toUpperCase() ?? ""}${part.slice(1)}`)
.join("")
return /^[A-Za-z_$]/.test(identifier) ? identifier : `_${identifier}`
}
function structuralTypes(schemas: ReadonlyArray<Schema.Top>, mutable: boolean, reservedNames: ReadonlySet<string>) {
@@ -1239,14 +1291,6 @@ function promisePath(path: string, input: ReadonlyArray<InputField>, wildcard?:
return `\`${template}${wildcard === undefined ? "" : `\${encodePath(input.${wildcard.name})}`}\``
}
function uniqueModule(base: string, index: number, modules: ReadonlySet<string>) {
if (!modules.has(base.toLowerCase())) return base
const seed = `${base}-${index}`
let suffix = 0
while (modules.has(`${seed}${suffix === 0 ? "" : `-${suffix}`}`.toLowerCase())) suffix++
return `${seed}${suffix === 0 ? "" : `-${suffix}`}`
}
function normalizeTransport(
schema: Schema.Top | undefined,
source: InputField["source"] | "success" | "error",
@@ -1426,7 +1470,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}` })
@@ -1697,10 +1741,10 @@ function streamEffectPortable(schema: Schema.Top) {
return sameEncoding(schema.events.ast, rebuilt.events.ast)
}
function renderGroup(group: Group, groupIndex: number) {
function renderGroup(group: Group) {
const slots: Array<Slot> = []
const adapters: Array<string> = []
const endpointSources = group.endpoints.map((operation, endpointIndex) => {
const endpointSources = group.endpoints.map((operation) => {
const {
endpoint,
errors,
@@ -1710,7 +1754,7 @@ function renderGroup(group: Group, groupIndex: number) {
query: endpointQuery,
successes,
} = operation
const prefix = `Endpoint${endpointIndex}`
const prefix = `Endpoint${operation.clientPath.map(identifierPart).join("")}`
const params = addSlot(endpointParams, `${prefix}Params`)
const query = addSlot(endpointQuery, `${prefix}Query`)
const headers = addSlot(endpointHeaders, `${prefix}Headers`)
@@ -1805,15 +1849,16 @@ function renderGroup(group: Group, groupIndex: number) {
const usesHttpApiSchema = endpointSources.some((source) => source.includes("HttpApiSchema."))
const methods = renderClientTree(
group.endpoints,
(_item, index) => `Endpoint${index}(raw)`,
(item) => `Endpoint${item.clientPath.map(identifierPart).join("")}(raw)`,
(name, value) => `${JSON.stringify(name)}: ${value}`,
", ",
)
const name = groupTypeName(group)
const rawGroup = group.endpoints[0]?.topLevel
? `HttpApiClient.Client<typeof Group${groupIndex}>`
: `HttpApiClient.Client.Group<typeof Group${groupIndex}, never, never>`
? `HttpApiClient.Client<typeof Group${name}>`
: `HttpApiClient.Client.Group<typeof Group${name}, never, never>`
const usesStream = group.endpoints.some((item) => item.operation.success === "stream")
return `// Generated by @opencode-ai/httpapi-codegen. Do not edit.\nimport { Effect, Schema${usesStream ? ", Stream" : ""} } from "effect"\nimport { Sse } from "effect/unstable/encoding"\nimport { HttpClientError } from "effect/unstable/http"\nimport { HttpApiClient, HttpApiEndpoint, HttpApiGroup${usesHttpApiSchema ? ", HttpApiSchema" : ""} } from "effect/unstable/httpapi"\nimport { ClientError } from "./client-error.js"\n\n${declarations}\n\nexport const Group${groupIndex} = ${groupSource}\n\ntype RawGroup = ${rawGroup}\n\n${adapters.join("\n\n")}\n\nexport const adaptGroup${groupIndex} = (raw: RawGroup) => ({ ${methods} })\n`
return `// Generated by @opencode-ai/httpapi-codegen. Do not edit.\nimport { Effect, Schema${usesStream ? ", Stream" : ""} } from "effect"\nimport { Sse } from "effect/unstable/encoding"\nimport { HttpClientError } from "effect/unstable/http"\nimport { HttpApiClient, HttpApiEndpoint, HttpApiGroup${usesHttpApiSchema ? ", HttpApiSchema" : ""} } from "effect/unstable/httpapi"\nimport { ClientError } from "./client-error.js"\n\n${declarations}\n\nexport const Group${name} = ${groupSource}\n\ntype RawGroup = ${rawGroup}\n\n${adapters.join("\n\n")}\n\nexport const adaptGroup${name} = (raw: RawGroup) => ({ ${methods} })\n`
}
function renderEffectRequestPart(
@@ -1883,15 +1928,20 @@ function renderSchemas(slots: ReadonlyArray<Slot>) {
function renderClient(groups: ReadonlyArray<Group>) {
const imports = groups
.map((group, index) => `import { adaptGroup${index}, Group${index} } from ${JSON.stringify(`./${group.module}`)}`)
.map(
(group) =>
`import { adaptGroup${groupTypeName(group)}, Group${groupTypeName(group)} } from ${JSON.stringify(`./${group.module}`)}`,
)
.join("\n")
const api = `HttpApi.make("generated")${groups.map((_, index) => `.add(Group${index})`).join("")}`
const fields = groups.flatMap((group, index) => {
const api = `HttpApi.make("generated")${groups.map((group) => `.add(Group${groupTypeName(group)})`).join("")}`
const fields = groups.flatMap((group) => {
if (!group.endpoints[0]?.topLevel) {
return [`${JSON.stringify(group.identifier)}: adaptGroup${index}(raw[${JSON.stringify(group.identifier)}])`]
return [
`${JSON.stringify(group.identifier)}: adaptGroup${groupTypeName(group)}(raw[${JSON.stringify(group.identifier)}])`,
]
}
const raw = `{ ${group.endpoints.map((item) => `${JSON.stringify(item.endpoint.identifier)}: raw[${JSON.stringify(item.endpoint.identifier)}]`).join(", ")} }`
return [`...adaptGroup${index}(${raw})`]
return [`...adaptGroup${groupTypeName(group)}(${raw})`]
})
return `// Generated by @opencode-ai/httpapi-codegen. Do not edit.\nimport { Effect } from "effect"\nimport { HttpApi, HttpApiClient } from "effect/unstable/httpapi"\n${imports}\n\nconst Api = ${api}\nconst adaptClient = (raw: HttpApiClient.ForApi<typeof Api>) => ({ ${fields.join(", ")} })\n\nexport const make = (options?: { readonly baseUrl?: URL | string }) =>\n HttpApiClient.make(Api, options).pipe(Effect.map(adaptClient))\n`
}
+79 -26
View File
@@ -120,8 +120,8 @@ describe("HttpApiCodegen.generate", () => {
const source = output.files[0]?.content
expect(source).toContain('import type { Session } from "@example/schema/session"')
expect(source).toContain('export type Endpoint0_0Input = { readonly "id": string }')
expect(source).toContain("export type Endpoint0_0Output = Session.Info")
expect(source).toContain('export type SessionGetInput = { readonly "id": string }')
expect(source).toContain("export type SessionGetOutput = Session.Info")
expect(source).not.toContain("HttpApiClient")
expect(source).not.toContain("@example/api")
})
@@ -141,7 +141,53 @@ describe("HttpApiCodegen.generate", () => {
const source = output.files[0]?.content
expect(source).toContain('import type { OpenCodeEvent } from "@example/protocol/event"')
expect(source).toContain("export type Endpoint0_0Output = OpenCodeEvent")
expect(source).toContain("export type SessionEventsOutput = OpenCodeEvent")
})
test("rejects authoritative Effect types colliding with generated aliases", () => {
expect(() =>
emitEffectShape(compileContract(api(HttpApiEndpoint.get("get", "/session", { success: Schema.String }))), {
outputTypes: {
"session.get": {
name: "SessionGetOutput",
import: 'import type { SessionGetOutput } from "@example/schema/session"',
},
},
}),
).toThrow("Generated Effect type collides with imported type: SessionGetOutput")
})
test("rejects qualified Effect imports colliding with generated interfaces", () => {
const Info = Schema.Struct({ id: Schema.String }).annotate({ identifier: "Session.Info" })
expect(() =>
emitEffectShape(compileContract(api(HttpApiEndpoint.get("get", "/session", { success: Info }))), {
typeReferences: [
{
schema: Info,
name: "SessionApi.Info",
import: 'import type { SessionApi } from "@example/schema/session"',
},
],
}),
).toThrow("Generated Effect type collides with imported type: SessionApi")
})
test("rejects imported endpoints colliding with generated adapter values", () => {
const contract = compileContract(api(HttpApiEndpoint.get("session.get", "/session", { success: Schema.String })))
expect(() =>
emitEffectImported(contract, {
module: "@example/api",
endpoints: { "session.session.get": "EndpointSessionGet" },
}),
).toThrow("Generated Effect adapter collides with imported endpoint: EndpointSessionGet")
expect(() =>
emitEffectImported(contract, {
module: "@example/api",
api: "EndpointSessionGet",
}),
).toThrow("Generated Effect adapter collides with imported endpoint: EndpointSessionGet")
})
test("exposes an imported Effect client through its generated shape", () => {
@@ -151,8 +197,8 @@ describe("HttpApiCodegen.generate", () => {
)
const source = output.files.find((file) => file.path === "client.ts")?.content
expect(source).toContain('import type { Endpoint0_0Output } from "../api"')
expect(source).toContain("preserveEffect<Endpoint0_0Output>()")
expect(source).toContain('import type { SessionGetOutput } from "../api"')
expect(source).toContain("preserveEffect<SessionGetOutput>()")
})
test("projects imported endpoint constants into a generated API", () => {
@@ -271,12 +317,12 @@ describe("HttpApiCodegen.generate", () => {
const effect = emitEffect(contract)
expect(effect.files.find((file) => file.path === "session.ts")?.content).toContain(
'"instructions": { "list": Endpoint0(raw), "put": Endpoint1(raw), "remove": Endpoint2(raw) }',
'"instructions": { "list": EndpointInstructionsList(raw), "put": EndpointInstructionsPut(raw), "remove": EndpointInstructionsRemove(raw) }',
)
const imported = emitEffectImported(contract, { module: "@example/api", api: "Api" })
expect(imported.files.find((file) => file.path === "client.ts")?.content).toContain(
'"instructions": { "list": Endpoint0_0(raw), "put": Endpoint0_1(raw), "remove": Endpoint0_2(raw) }',
'"instructions": { "list": EndpointSessionInstructionsList(raw), "put": EndpointSessionInstructionsPut(raw), "remove": EndpointSessionInstructionsRemove(raw) }',
)
const shape = emitEffectShape(contract)
@@ -396,9 +442,14 @@ describe("HttpApiCodegen.generate", () => {
})
test("rejects normalized group, operation-key, and group prototype collisions", () => {
const normalized = HttpApi.make("test")
const sanitized = HttpApi.make("test")
.add(HttpApiGroup.make("foo-bar").add(HttpApiEndpoint.get("get", "/first", { success: Schema.String })))
.add(HttpApiGroup.make("foo.bar").add(HttpApiEndpoint.get("get", "/second", { success: Schema.String })))
expect(() => compileContract(sanitized)).toThrow("Client module name collision: foo-bar")
const normalized = HttpApi.make("test")
.add(HttpApiGroup.make("foo_bar").add(HttpApiEndpoint.get("get", "/first", { success: Schema.String })))
.add(HttpApiGroup.make("foo.bar").add(HttpApiEndpoint.get("get", "/second", { success: Schema.String })))
expect(() => compileContract(normalized)).toThrow("Client group type collision: FooBar")
const endpointType = HttpApi.make("test")
@@ -499,7 +550,9 @@ describe("HttpApiCodegen.generate", () => {
expect(contract.groups[0]?.endpoints[0]?.operation.name).toBe("get")
expect(promise).toContain('"get": (input: SessionGetInput, requestOptions?: RequestOptions)')
expect(effect).toContain('const adaptGroup0 = (raw: RawClient["session"]) => ({ "get": Endpoint0_0(raw) })')
expect(effect).toContain(
'const adaptGroupSession = (raw: RawClient["session"]) => ({ "get": EndpointSessionGet(raw) })',
)
expect(effect).toContain('raw["session.get"]')
})
@@ -1478,7 +1531,7 @@ describe("HttpApiCodegen.generate", () => {
expect(output.operations[0]).toBeDefined()
expect(output.files.find((file) => file.path === "session.ts")?.content).toContain(
'extends Schema.TaggedError<Endpoint0Error0Class>("Unauthorized")',
'extends Schema.TaggedError<EndpointGetError0Class>("Unauthorized")',
)
})
@@ -1494,7 +1547,7 @@ describe("HttpApiCodegen.generate", () => {
)
expect(output.files.find((file) => file.path === "session.ts")?.content).toContain(
'Endpoint0Error0Class.annotate({ "httpApiStatus": 404 })',
'EndpointGetError0Class.annotate({ "httpApiStatus": 404 })',
)
})
@@ -1504,35 +1557,35 @@ describe("HttpApiCodegen.generate", () => {
expect(output.files.find((file) => file.path === "session.ts")?.content).toContain('HttpApiEndpoint.make("TRACE")')
})
test("uses safe unique module paths without changing public group identifiers", () => {
test("uses safe identity-derived module paths without changing public group identifiers", () => {
const output = compile(
HttpApi.make("test")
.add(HttpApiGroup.make("../session").add(HttpApiEndpoint.get("get", "/session", { success: Schema.String })))
.add(HttpApiGroup.make("GROUP-0").add(HttpApiEndpoint.get("list", "/session", { success: Schema.String }))),
)
expect(output.files.slice(0, 2).map((file) => file.path)).toEqual(["group-0.ts", "GROUP-0-1.ts"])
expect(output.files.slice(0, 2).map((file) => file.path)).toEqual(["session.ts", "GROUP-0.ts"])
expect(output.files[0]?.content).toContain('HttpApiGroup.make("../session"')
})
test("reserves support module names case-insensitively", () => {
test("prefixes group modules that collide with support or Windows-reserved names", () => {
const output = compile(
HttpApi.make("test")
.add(HttpApiGroup.make("client").add(HttpApiEndpoint.get("get", "/client", { success: Schema.String })))
.add(HttpApiGroup.make("INDEX").add(HttpApiEndpoint.get("get", "/index", { success: Schema.String }))),
.add(HttpApiGroup.make("INDEX").add(HttpApiEndpoint.get("get", "/index", { success: Schema.String })))
.add(HttpApiGroup.make("CON").add(HttpApiEndpoint.get("get", "/con", { success: Schema.String }))),
)
expect(output.files.slice(0, 2).map((file) => file.path)).toEqual(["client-0.ts", "INDEX-1.ts"])
expect(output.files.slice(0, 2).map((file) => file.path)).toEqual(["group-INDEX.ts", "group-CON.ts"])
})
test("keeps searching when a reserved-name fallback is also occupied", () => {
const output = compile(
HttpApi.make("test")
.add(HttpApiGroup.make("client-1").add(HttpApiEndpoint.get("first", "/first", { success: Schema.String })))
.add(HttpApiGroup.make("client").add(HttpApiEndpoint.get("second", "/second", { success: Schema.String }))),
)
expect(output.files.slice(0, 2).map((file) => file.path)).toEqual(["client-1.ts", "client-1-1.ts"])
test("rejects module names colliding after normalization", () => {
expect(() =>
compile(
HttpApi.make("test")
.add(HttpApiGroup.make("my.group").add(HttpApiEndpoint.get("first", "/first", { success: Schema.String })))
.add(HttpApiGroup.make("my/group").add(HttpApiEndpoint.get("second", "/second", { success: Schema.String }))),
),
).toThrow("Client module name collision: my-group")
})
test("rejects collisions in the flattened client namespace", () => {
@@ -1558,7 +1611,7 @@ describe("HttpApiCodegen.generate", () => {
),
)
expect(output.files[0]?.content).toContain("type RawGroup = HttpApiClient.Client<typeof Group0")
expect(output.files[0]?.content).toContain("type RawGroup = HttpApiClient.Client<typeof GroupHealth")
})
it.effect("reports compiler failures in the generate Effect", () =>
@@ -1,15 +1,15 @@
// Generated by @opencode-ai/httpapi-codegen. Do not edit.
import { Effect } from "effect"
import { HttpApi, HttpApiClient } from "effect/unstable/httpapi"
import { adaptGroup0, Group0 } from "./session"
import { adaptGroup1, Group1 } from "./event"
import { adaptGroup2, Group2 } from "./system"
import { adaptGroupSession, GroupSession } from "./session"
import { adaptGroupEvent, GroupEvent } from "./event"
import { adaptGroupSystem, GroupSystem } from "./system"
const Api = HttpApi.make("generated").add(Group0).add(Group1).add(Group2)
const Api = HttpApi.make("generated").add(GroupSession).add(GroupEvent).add(GroupSystem)
const adaptClient = (raw: HttpApiClient.ForApi<typeof Api>) => ({
session: adaptGroup0(raw["session"]),
event: adaptGroup1(raw["event"]),
...adaptGroup2({ status: raw["status"] }),
session: adaptGroupSession(raw["session"]),
event: adaptGroupEvent(raw["event"]),
...adaptGroupSystem({ status: raw["status"] }),
})
export const make = (options?: { readonly baseUrl?: URL | string }) =>
@@ -5,35 +5,35 @@ import { HttpClientError } from "effect/unstable/http"
import { HttpApiClient, HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "effect/unstable/httpapi"
import { ClientError } from "./client-error.js"
const Endpoint0SuccessData = Schema.Struct({ type: Schema.String })
const EndpointSubscribeSuccessData = Schema.Struct({ type: Schema.String })
const Endpoint0SuccessError = Schema.Never
const EndpointSubscribeSuccessError = Schema.Never
export const Group1 = HttpApiGroup.make("event", { topLevel: false }).add(
export const GroupEvent = HttpApiGroup.make("event", { topLevel: false }).add(
HttpApiEndpoint.make("GET")("subscribe", "/event", {
success: HttpApiSchema.StreamSse({
data: Endpoint0SuccessData,
error: Endpoint0SuccessError,
data: EndpointSubscribeSuccessData,
error: EndpointSubscribeSuccessError,
contentType: "text/event-stream",
}).pipe(HttpApiSchema.status(202)),
}),
)
type RawGroup = HttpApiClient.Client.Group<typeof Group1, never, never>
type RawGroup = HttpApiClient.Client.Group<typeof GroupEvent, never, never>
const Endpoint0DeclaredError = Schema.Union([Endpoint0SuccessError])
const mapEndpoint0Error = (error: unknown) =>
const EndpointSubscribeDeclaredError = Schema.Union([EndpointSubscribeSuccessError])
const mapEndpointSubscribeError = (error: unknown) =>
HttpClientError.isHttpClientError(error) || Schema.isSchemaError(error) || Sse.Retry.is(error)
? new ClientError({ cause: error })
: Schema.is(Endpoint0DeclaredError)(error)
: Schema.is(EndpointSubscribeDeclaredError)(error)
? error
: new ClientError({ cause: error })
const Endpoint0 = (raw: RawGroup) => () =>
const EndpointSubscribe = (raw: RawGroup) => () =>
Stream.unwrap(
raw["subscribe"]({}).pipe(
Effect.mapError(mapEndpoint0Error),
Effect.map((stream) => stream.pipe(Stream.mapError(mapEndpoint0Error))),
Effect.mapError(mapEndpointSubscribeError),
Effect.map((stream) => stream.pipe(Stream.mapError(mapEndpointSubscribeError))),
),
)
export const adaptGroup1 = (raw: RawGroup) => ({ subscribe: Endpoint0(raw) })
export const adaptGroupEvent = (raw: RawGroup) => ({ subscribe: EndpointSubscribe(raw) })
@@ -5,137 +5,141 @@ import { HttpClientError } from "effect/unstable/http"
import { HttpApiClient, HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi"
import { ClientError } from "./client-error.js"
const Endpoint0Success = Schema.String
const EndpointHealthSuccess = Schema.String
const Endpoint1Query = Schema.Struct({ archived: Schema.optionalKey(Schema.Union([Schema.Boolean, Schema.Undefined])) })
const EndpointListQuery = Schema.Struct({
archived: Schema.optionalKey(Schema.Union([Schema.Boolean, Schema.Undefined])),
})
const Endpoint1Success = Schema.Array(Schema.String)
const EndpointListSuccess = Schema.Array(Schema.String)
const Endpoint2Params = Schema.Struct({ sessionID: Schema.String })
const EndpointGetParams = Schema.Struct({ sessionID: Schema.String })
const Endpoint2Success = Schema.Struct({ data: Schema.String })
const EndpointGetSuccess = Schema.Struct({ data: Schema.String })
class Endpoint2Error0Class extends Schema.TaggedError<Endpoint2Error0Class>("Missing")("Missing", {
class EndpointGetError0Class extends Schema.TaggedError<EndpointGetError0Class>("Missing")("Missing", {
message: Schema.String,
}) {}
const Endpoint2Error0 = Endpoint2Error0Class.annotate({ httpApiStatus: 404 })
const EndpointGetError0 = EndpointGetError0Class.annotate({ httpApiStatus: 404 })
const Endpoint3Params = Schema.Struct({ sessionID: Schema.String })
const EndpointInterruptParams = Schema.Struct({ sessionID: Schema.String })
const Endpoint3Success = Schema.Void.annotate({ httpApiStatus: 204 })
const EndpointInterruptSuccess = Schema.Void.annotate({ httpApiStatus: 204 })
const Endpoint4Params = Schema.Struct({ sessionID: Schema.String })
const EndpointConfigureParams = Schema.Struct({ sessionID: Schema.String })
const Endpoint4Query = Schema.Struct({ dryRun: Schema.optionalKey(Schema.Union([Schema.Boolean, Schema.Undefined])) })
const EndpointConfigureQuery = Schema.Struct({
dryRun: Schema.optionalKey(Schema.Union([Schema.Boolean, Schema.Undefined])),
})
const Endpoint4Headers = Schema.Struct({ traceID: Schema.String })
const EndpointConfigureHeaders = Schema.Struct({ traceID: Schema.String })
const Endpoint4Payload0 = Schema.Union([
const EndpointConfigurePayload0 = Schema.Union([
Schema.Struct({ type: Schema.Literal("local"), command: Schema.Array(Schema.String) }),
Schema.Struct({ type: Schema.Literal("remote"), url: Schema.String }),
])
const Endpoint4Success = Schema.String
const EndpointConfigureSuccess = Schema.String
export const Group0 = HttpApiGroup.make("session", { topLevel: false })
.add(HttpApiEndpoint.make("GET")("health", "/session/health", { success: Endpoint0Success }))
.add(HttpApiEndpoint.make("GET")("list", "/session", { query: Endpoint1Query, success: Endpoint1Success }))
export const GroupSession = HttpApiGroup.make("session", { topLevel: false })
.add(HttpApiEndpoint.make("GET")("health", "/session/health", { success: EndpointHealthSuccess }))
.add(HttpApiEndpoint.make("GET")("list", "/session", { query: EndpointListQuery, success: EndpointListSuccess }))
.add(
HttpApiEndpoint.make("GET")("get", "/session/:sessionID", {
params: Endpoint2Params,
success: Endpoint2Success,
error: Endpoint2Error0,
params: EndpointGetParams,
success: EndpointGetSuccess,
error: EndpointGetError0,
}),
)
.add(
HttpApiEndpoint.make("POST")("interrupt", "/session/:sessionID/interrupt", {
params: Endpoint3Params,
success: Endpoint3Success,
params: EndpointInterruptParams,
success: EndpointInterruptSuccess,
}),
)
.add(
HttpApiEndpoint.make("POST")("configure", "/session/:sessionID/configure", {
params: Endpoint4Params,
query: Endpoint4Query,
headers: Endpoint4Headers,
payload: Endpoint4Payload0,
success: Endpoint4Success,
params: EndpointConfigureParams,
query: EndpointConfigureQuery,
headers: EndpointConfigureHeaders,
payload: EndpointConfigurePayload0,
success: EndpointConfigureSuccess,
}),
)
type RawGroup = HttpApiClient.Client.Group<typeof Group0, never, never>
type RawGroup = HttpApiClient.Client.Group<typeof GroupSession, never, never>
const Endpoint0DeclaredError = Schema.Never
const mapEndpoint0Error = (error: unknown) =>
const EndpointHealthDeclaredError = Schema.Never
const mapEndpointHealthError = (error: unknown) =>
HttpClientError.isHttpClientError(error) || Schema.isSchemaError(error) || Sse.Retry.is(error)
? new ClientError({ cause: error })
: Schema.is(Endpoint0DeclaredError)(error)
: Schema.is(EndpointHealthDeclaredError)(error)
? error
: new ClientError({ cause: error })
const Endpoint0 = (raw: RawGroup) => () => raw["health"]({}).pipe(Effect.mapError(mapEndpoint0Error))
const EndpointHealth = (raw: RawGroup) => () => raw["health"]({}).pipe(Effect.mapError(mapEndpointHealthError))
type Endpoint1Input = { readonly archived?: (typeof Endpoint1Query.Type)["archived"] }
const Endpoint1DeclaredError = Schema.Never
const mapEndpoint1Error = (error: unknown) =>
type EndpointListInput = { readonly archived?: (typeof EndpointListQuery.Type)["archived"] }
const EndpointListDeclaredError = Schema.Never
const mapEndpointListError = (error: unknown) =>
HttpClientError.isHttpClientError(error) || Schema.isSchemaError(error) || Sse.Retry.is(error)
? new ClientError({ cause: error })
: Schema.is(Endpoint1DeclaredError)(error)
: Schema.is(EndpointListDeclaredError)(error)
? error
: new ClientError({ cause: error })
const Endpoint1 = (raw: RawGroup) => (input?: Endpoint1Input) =>
raw["list"]({ query: { archived: input?.["archived"] } }).pipe(Effect.mapError(mapEndpoint1Error))
const EndpointList = (raw: RawGroup) => (input?: EndpointListInput) =>
raw["list"]({ query: { archived: input?.["archived"] } }).pipe(Effect.mapError(mapEndpointListError))
type Endpoint2Input = { readonly sessionID: (typeof Endpoint2Params.Type)["sessionID"] }
const Endpoint2DeclaredError = Schema.Union([Endpoint2Error0])
const mapEndpoint2Error = (error: unknown) =>
type EndpointGetInput = { readonly sessionID: (typeof EndpointGetParams.Type)["sessionID"] }
const EndpointGetDeclaredError = Schema.Union([EndpointGetError0])
const mapEndpointGetError = (error: unknown) =>
HttpClientError.isHttpClientError(error) || Schema.isSchemaError(error) || Sse.Retry.is(error)
? new ClientError({ cause: error })
: Schema.is(Endpoint2DeclaredError)(error)
: Schema.is(EndpointGetDeclaredError)(error)
? error
: new ClientError({ cause: error })
const Endpoint2 = (raw: RawGroup) => (input: Endpoint2Input) =>
const EndpointGet = (raw: RawGroup) => (input: EndpointGetInput) =>
raw["get"]({ params: { sessionID: input["sessionID"] } }).pipe(
Effect.mapError(mapEndpoint2Error),
Effect.mapError(mapEndpointGetError),
Effect.map((value) => value.data),
)
type Endpoint3Input = { readonly sessionID: (typeof Endpoint3Params.Type)["sessionID"] }
const Endpoint3DeclaredError = Schema.Never
const mapEndpoint3Error = (error: unknown) =>
type EndpointInterruptInput = { readonly sessionID: (typeof EndpointInterruptParams.Type)["sessionID"] }
const EndpointInterruptDeclaredError = Schema.Never
const mapEndpointInterruptError = (error: unknown) =>
HttpClientError.isHttpClientError(error) || Schema.isSchemaError(error) || Sse.Retry.is(error)
? new ClientError({ cause: error })
: Schema.is(Endpoint3DeclaredError)(error)
: Schema.is(EndpointInterruptDeclaredError)(error)
? error
: new ClientError({ cause: error })
const Endpoint3 = (raw: RawGroup) => (input: Endpoint3Input) =>
raw["interrupt"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapEndpoint3Error))
const EndpointInterrupt = (raw: RawGroup) => (input: EndpointInterruptInput) =>
raw["interrupt"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapEndpointInterruptError))
type Endpoint4Request = Parameters<RawGroup["configure"]>[0]
type Endpoint4Input = {
readonly sessionID: (typeof Endpoint4Params.Type)["sessionID"]
readonly dryRun?: (typeof Endpoint4Query.Type)["dryRun"]
readonly traceID: (typeof Endpoint4Headers.Type)["traceID"]
readonly payload: typeof Endpoint4Payload0.Type
type EndpointConfigureRequest = Parameters<RawGroup["configure"]>[0]
type EndpointConfigureInput = {
readonly sessionID: (typeof EndpointConfigureParams.Type)["sessionID"]
readonly dryRun?: (typeof EndpointConfigureQuery.Type)["dryRun"]
readonly traceID: (typeof EndpointConfigureHeaders.Type)["traceID"]
readonly payload: typeof EndpointConfigurePayload0.Type
}
const Endpoint4DeclaredError = Schema.Never
const mapEndpoint4Error = (error: unknown) =>
const EndpointConfigureDeclaredError = Schema.Never
const mapEndpointConfigureError = (error: unknown) =>
HttpClientError.isHttpClientError(error) || Schema.isSchemaError(error) || Sse.Retry.is(error)
? new ClientError({ cause: error })
: Schema.is(Endpoint4DeclaredError)(error)
: Schema.is(EndpointConfigureDeclaredError)(error)
? error
: new ClientError({ cause: error })
const Endpoint4 = (raw: RawGroup) => (input: Endpoint4Input) =>
const EndpointConfigure = (raw: RawGroup) => (input: EndpointConfigureInput) =>
raw["configure"]({
params: { sessionID: input["sessionID"] },
query: { dryRun: input["dryRun"] },
headers: { traceID: input["traceID"] },
payload: input["payload"],
} as Endpoint4Request).pipe(Effect.mapError(mapEndpoint4Error))
} as EndpointConfigureRequest).pipe(Effect.mapError(mapEndpointConfigureError))
export const adaptGroup0 = (raw: RawGroup) => ({
health: Endpoint0(raw),
list: Endpoint1(raw),
get: Endpoint2(raw),
interrupt: Endpoint3(raw),
configure: Endpoint4(raw),
export const adaptGroupSession = (raw: RawGroup) => ({
health: EndpointHealth(raw),
list: EndpointList(raw),
get: EndpointGet(raw),
interrupt: EndpointInterrupt(raw),
configure: EndpointConfigure(raw),
})
@@ -5,21 +5,21 @@ import { HttpClientError } from "effect/unstable/http"
import { HttpApiClient, HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi"
import { ClientError } from "./client-error.js"
const Endpoint0Success = Schema.String
const EndpointStatusSuccess = Schema.String
export const Group2 = HttpApiGroup.make("system", { topLevel: true }).add(
HttpApiEndpoint.make("GET")("status", "/status", { success: Endpoint0Success }),
export const GroupSystem = HttpApiGroup.make("system", { topLevel: true }).add(
HttpApiEndpoint.make("GET")("status", "/status", { success: EndpointStatusSuccess }),
)
type RawGroup = HttpApiClient.Client<typeof Group2>
type RawGroup = HttpApiClient.Client<typeof GroupSystem>
const Endpoint0DeclaredError = Schema.Never
const mapEndpoint0Error = (error: unknown) =>
const EndpointStatusDeclaredError = Schema.Never
const mapEndpointStatusError = (error: unknown) =>
HttpClientError.isHttpClientError(error) || Schema.isSchemaError(error) || Sse.Retry.is(error)
? new ClientError({ cause: error })
: Schema.is(Endpoint0DeclaredError)(error)
: Schema.is(EndpointStatusDeclaredError)(error)
? error
: new ClientError({ cause: error })
const Endpoint0 = (raw: RawGroup) => () => raw["status"]({}).pipe(Effect.mapError(mapEndpoint0Error))
const EndpointStatus = (raw: RawGroup) => () => raw["status"]({}).pipe(Effect.mapError(mapEndpointStatusError))
export const adaptGroup2 = (raw: RawGroup) => ({ status: Endpoint0(raw) })
export const adaptGroupSystem = (raw: RawGroup) => ({ status: EndpointStatus(raw) })
File diff suppressed because it is too large Load Diff
+1
View File
@@ -33,6 +33,7 @@
"@tsconfig/bun": "catalog:",
"@types/bun": "catalog:",
"@typescript/native-preview": "catalog:",
"prettier": "3.6.2",
"typescript": "catalog:"
}
}
+6 -1
View File
@@ -1,8 +1,13 @@
import { OpenApi } from "effect/unstable/httpapi"
import { format } from "prettier"
import { fileURLToPath } from "url"
import { ClientApi } from "../src/client.js"
import { stabilizeOpenApi } from "./openapi-stabilize.js"
const document = JSON.stringify(OpenApi.fromApi(ClientApi), null, 2) + "\n"
const document = await format(JSON.stringify(stabilizeOpenApi(OpenApi.fromApi(ClientApi)), null, 2), {
parser: "json",
printWidth: 120,
})
const target = fileURLToPath(new URL("../openapi.json", import.meta.url))
if (process.argv.includes("--check")) {
@@ -0,0 +1,73 @@
// Effect gives shared anonymous schemas encounter-order names (`Union_3`). Replace those names
// with a hash of their canonical shape and sort components so unrelated additions stay local.
export function stabilizeOpenApi(source: object) {
const document = source as {
components: { schemas: Record<string, unknown> }
}
const schemas = document.components.schemas
const reference = (name: string) => `#/components/schemas/${name}`
const families = ["Union", "Objects", "Arrays"].filter((name) => `${name}_` in schemas)
const anonymous = new Map(
Object.keys(schemas)
.filter((name) => families.some((family) => new RegExp(`^${family}_\\d*$`).test(name)))
.map((name) => [name, schemas[name]]),
)
const canonical = (node: unknown, seen = new Set<string>()): unknown => {
if (Array.isArray(node)) return node.map((item) => canonical(item, seen))
if (typeof node !== "object" || node === null) return node
const $ref = "$ref" in node && typeof node.$ref === "string" ? node.$ref : undefined
const name = $ref?.startsWith(reference("")) ? $ref.slice(reference("").length) : undefined
if (name !== undefined && anonymous.has(name)) {
if (seen.has(name)) throw new Error(`Recursive anonymous OpenAPI component: ${name}`)
return Object.fromEntries([
["$ref", canonical(anonymous.get(name), new Set([...seen, name]))],
...Object.entries(node)
.filter(([key]) => key !== "$ref")
.sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0))
.map(([key, value]) => [key, canonical(value, seen)]),
])
}
return Object.fromEntries(
Object.entries(node)
.sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0))
.map(([key, value]) => [key, canonical(value, seen)]),
)
}
const renames = new Map(
[...anonymous].map(([name, schema]) => {
const hash = new Bun.CryptoHasher("sha256")
.update(JSON.stringify(canonical(schema)))
.digest("hex")
.slice(0, 12)
return [name, `${name.replace(/_\d*$/, "")}_${hash}`] as const
}),
)
const rewrite = (node: unknown): unknown => {
if (Array.isArray(node)) return node.map(rewrite)
if (typeof node !== "object" || node === null) return node
return Object.fromEntries(
Object.entries(node).map(([key, value]) => {
if (key !== "$ref" || typeof value !== "string" || !value.startsWith(reference(""))) {
return [key, rewrite(value)]
}
const name = value.slice(reference("").length)
return [key, reference(renames.get(name) ?? name)]
}),
)
}
const result = rewrite(document) as typeof document
const stable = new Map<string, unknown>()
for (const [name, schema] of Object.entries(result.components.schemas)) {
const target = renames.get(name) ?? name
const previous = stable.get(target)
if (previous !== undefined && JSON.stringify(canonical(previous)) !== JSON.stringify(canonical(schema))) {
throw new Error(`Content-addressed OpenAPI component collision: ${target}`)
}
stable.set(target, schema)
}
result.components.schemas = Object.fromEntries(
[...stable].sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0)),
)
return result
}
+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",
@@ -0,0 +1,77 @@
import { expect, test } from "bun:test"
import { stabilizeOpenApi } from "../script/openapi-stabilize.js"
test("content-addresses anonymous components without changing reference siblings", () => {
const result = stabilizeOpenApi({
components: {
schemas: {
Union_: { anyOf: [{ type: "string" }, { type: "null" }] },
Union_2: { type: "number" },
OAuth_2: { type: "string" },
},
},
paths: {
"/test": {
schema: { $ref: "#/components/schemas/Union_", description: "nullable value" },
},
},
}) as {
components: { schemas: Record<string, unknown> }
paths: { "/test": { schema: { $ref: string; description: string } } }
}
expect(result.paths["/test"].schema.description).toBe("nullable value")
expect(result.paths["/test"].schema.$ref).toMatch(/^#\/components\/schemas\/Union_[a-f0-9]{12}$/)
expect(result.components.schemas.OAuth_2).toEqual({ type: "string" })
})
test("keeps anonymous names stable across encounter order and nested ordinals", () => {
const generate = (nested: string, parent: string) =>
stabilizeOpenApi({
components: {
schemas: {
Union_: { type: "boolean" },
Arrays_: { type: "array", items: { type: "boolean" } },
[parent]: { type: "array", items: { $ref: `#/components/schemas/${nested}` } },
[nested]: { type: "string" },
},
},
}) as { components: { schemas: Record<string, unknown> } }
expect(generate("Union_1", "Arrays_2")).toEqual(generate("Union_9", "Arrays_7"))
})
test("merges structurally identical anonymous components", () => {
const result = stabilizeOpenApi({
components: {
schemas: {
Union_: { type: "string" },
Union_2: { type: "string" },
},
},
}) as { components: { schemas: Record<string, unknown> } }
expect(Object.keys(result.components.schemas)).toHaveLength(1)
})
test("includes reference siblings in anonymous component hashes", () => {
const result = stabilizeOpenApi({
components: {
schemas: {
Union_: { type: "string" },
Arrays_: { type: "array", items: { $ref: "#/components/schemas/Union_", description: "first" } },
Arrays_2: { type: "array", items: { $ref: "#/components/schemas/Union_", description: "second" } },
},
},
}) as { components: { schemas: Record<string, unknown> } }
expect(Object.keys(result.components.schemas).filter((name) => name.startsWith("Arrays_"))).toHaveLength(2)
})
test("preserves authored synthetic-looking names without an anonymous family root", () => {
const result = stabilizeOpenApi({
components: { schemas: { Union_2: { type: "string" } } },
}) as { components: { schemas: Record<string, unknown> } }
expect(result.components.schemas.Union_2).toEqual({ type: "string" })
})
+8
View File
@@ -19,6 +19,14 @@ export const Info = Schema.Struct({
}).annotate({ identifier: "InstructionEntry.Info" })
export interface Info extends Schema.Schema.Type<typeof Info> {}
export const Snapshot = Schema.Array(
Schema.Struct({
...Info.fields,
removed: Schema.Boolean,
}),
).annotate({ identifier: "InstructionEntry.Snapshot" })
export type Snapshot = typeof Snapshot.Type
export const MaxValueBytes = 8 * 1024
export class ValueTooLargeError extends Schema.TaggedError<ValueTooLargeError>()(
+14
View File
@@ -15,6 +15,7 @@ import { Revert } from "./session-revert.js"
import { Shell as ShellSchema } from "./shell.js"
import { SessionError } from "./session-error.js"
import { Instruction } from "./instruction.js"
import { InstructionEntry } from "./instruction-entry.js"
import { Agent } from "./agent.js"
import { Skill as SkillSchema } from "./skill.js"
import { Money } from "./money.js"
@@ -105,6 +106,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,
@@ -148,6 +160,7 @@ export const Forked = Event.durable({
parentID: SessionID,
boundary: SessionFork.Boundary,
instructions: Instruction.Values.pipe(optional),
instructionEntries: InstructionEntry.Snapshot.pipe(optional),
},
})
export type Forked = typeof Forked.Type
@@ -585,6 +598,7 @@ export const Definitions = Event.inventory(
ModelSelected,
Moved,
Renamed,
Viewed,
UsageUpdated,
Deleted,
Forked,
@@ -34,6 +34,8 @@ export type Info = Schema.Schema.Type<typeof Info>
export const Status = Event.ephemeral({
type: "session.status",
// The bare SessionStatus identifier belongs to the status union above.
identifier: "SessionStatusUpdated",
schema: {
sessionID: SessionID,
status: Info,
+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).
-31
View File
@@ -1,31 +0,0 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/shell-scan",
"version": "0.0.0",
"type": "module",
"license": "MIT",
"repository": {
"type": "git",
"url": "git+https://github.com/anomalyco/opencode.git",
"directory": "packages/shell-scan"
},
"publishConfig": {
"access": "public"
},
"files": [
"dist"
],
"exports": {
".": "./src/index.ts"
},
"scripts": {
"build": "bun run script/build.ts",
"test": "bun test --only-failures",
"typecheck": "tsgo --noEmit"
},
"devDependencies": {
"@tsconfig/bun": "catalog:",
"@types/bun": "catalog:",
"@typescript/native-preview": "catalog:"
}
}
-10
View File
@@ -1,10 +0,0 @@
#!/usr/bin/env bun
import { $ } from "bun"
import { rm } from "node:fs/promises"
import { fileURLToPath } from "node:url"
process.chdir(fileURLToPath(new URL("..", import.meta.url)))
await rm("dist", { recursive: true, force: true })
await $`bun tsc -p tsconfig.build.json`
-46
View File
@@ -1,46 +0,0 @@
#!/usr/bin/env bun
import { Script } from "@opencode-ai/script"
import { $ } from "bun"
import { rm } from "node:fs/promises"
import { fileURLToPath } from "node:url"
process.chdir(fileURLToPath(new URL("..", import.meta.url)))
const dryRun = Bun.argv.includes("--dry-run")
const originalText = await Bun.file("package.json").text()
const pkg = JSON.parse(originalText) as {
name: string
version: string
exports: Record<string, string | { import: string; types: string }>
}
const tarball = `${pkg.name.replace("@", "").replace("/", "-")}-${pkg.version}.tgz`
if (!dryRun && (await $`npm view ${pkg.name}@${pkg.version} version`.nothrow()).exitCode === 0) {
console.log(`already published ${pkg.name}@${pkg.version}`)
process.exit(0)
}
try {
await $`bun run typecheck`
await $`bun run build`
pkg.exports = Object.fromEntries(
Object.entries(pkg.exports).map(([key, value]) => {
if (typeof value !== "string") return [key, value]
return [
key,
{
import: value.replace("./src/", "./dist/").replace(/\.ts$/, ".js"),
types: value.replace("./src/", "./dist/").replace(/\.ts$/, ".d.ts"),
},
]
}),
)
await Bun.write("package.json", JSON.stringify(pkg, null, 2) + "\n")
await rm(tarball, { force: true })
await $`bun pm pack`
if (!dryRun) await $`npm publish ${tarball} --tag ${Script.channel} --access public`
} finally {
await Bun.write("package.json", originalText)
await rm(tarball, { force: true })
}
-11
View File
@@ -1,11 +0,0 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "./tsconfig.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "dist",
"noEmit": false,
"declaration": true
},
"include": ["src"]
}
-12
View File
@@ -1,12 +0,0 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "@tsconfig/bun/tsconfig.json",
"compilerOptions": {
"module": "NodeNext",
"moduleResolution": "NodeNext",
"allowImportingTsExtensions": false,
"allowJs": false,
"noUncheckedIndexedAccess": false
},
"include": ["src", "test", "bench", "research"]
}
+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()
}
})
@@ -36,12 +36,16 @@
[data-component="text-shimmer"] [data-slot="text-shimmer-char-shimmer"] {
grid-area: 1 / 1;
white-space: pre;
transition: opacity var(--text-shimmer-swap) ease-out;
font: inherit;
letter-spacing: inherit;
line-height: inherit;
}
[data-component="text-shimmer"]:where([data-active="true"]) [data-slot="text-shimmer-char-base"],
[data-component="text-shimmer"]:where([data-active="true"]) [data-slot="text-shimmer-char-shimmer"] {
transition: opacity var(--text-shimmer-swap) ease-out;
}
[data-component="text-shimmer"] [data-slot="text-shimmer-char-base"] {
color: inherit;
opacity: 1;
@@ -112,6 +116,7 @@
color: inherit;
-webkit-text-fill-color: currentColor;
background-image: none;
opacity: 0 !important;
}
[data-component="text-shimmer"] [data-slot="text-shimmer-char-base"] {
+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),
)
})
+4995 -4839
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More