mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-22 09:36:17 +00:00
Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4a70e6c8ba | ||
|
|
e33d688428 | ||
|
|
f69f78ec6b | ||
|
|
e2758b9945 | ||
|
|
7c6ecaaca8 | ||
|
|
c29a7c152d | ||
|
|
fa4c5b26dc | ||
|
|
fb703ede73 | ||
|
|
2937f0e635 | ||
|
|
3694149135 | ||
|
|
c4eeefe0f1 | ||
|
|
667c274c7f | ||
|
|
2c8e2a2b28 |
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@opencode-ai/core": patch
|
||||
---
|
||||
|
||||
Fix OpenCode Console device authorization URLs when the server returns an origin-rooted verification path.
|
||||
@@ -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
@@ -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="
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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?: {
|
||||
|
||||
@@ -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")
|
||||
})
|
||||
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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* () {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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* () {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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). */
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()),
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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* () {
|
||||
|
||||
@@ -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")
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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()),
|
||||
|
||||
@@ -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>) =>
|
||||
|
||||
@@ -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, [
|
||||
|
||||
@@ -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
-1
@@ -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
-1
@@ -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
-1
@@ -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
-1
@@ -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", () => {
|
||||
@@ -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,
|
||||
|
||||
@@ -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`
|
||||
}
|
||||
|
||||
@@ -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) })
|
||||
|
||||
+4995
-4839
File diff suppressed because it is too large
Load Diff
@@ -33,6 +33,7 @@
|
||||
"@tsconfig/bun": "catalog:",
|
||||
"@types/bun": "catalog:",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"prettier": "3.6.2",
|
||||
"typescript": "catalog:"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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" })
|
||||
})
|
||||
@@ -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>()(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
}),
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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:"
|
||||
}
|
||||
}
|
||||
@@ -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`
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "dist",
|
||||
"noEmit": false,
|
||||
"declaration": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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")
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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"] {
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
+4995
-4839
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
Reference in New Issue
Block a user