Compare commits

..

4 Commits

Author SHA1 Message Date
Aiden Cline b41fbcf9bc docs(core): clarify subagent session input 2026-08-07 03:39:01 +00:00
Aiden Cline 95b488cc3e docs(core): clarify subagent conversation reuse 2026-08-07 03:35:16 +00:00
Aiden Cline 34c83bbf18 docs(core): align subagent resume guidance 2026-08-07 02:11:05 +00:00
Aiden Cline 5e5888a24f feat(core): continue subagent sessions 2026-08-06 21:20:51 +00:00
48 changed files with 736 additions and 2709 deletions
-1
View File
@@ -178,7 +178,6 @@ export class LanguageModelCompatibility extends Schema.Class<LanguageModelCompat
toolSchema: Schema.optional(LanguageModelToolSchemaCompatibility), toolSchema: Schema.optional(LanguageModelToolSchemaCompatibility),
reasoningField: Schema.optional(Schema.String), reasoningField: Schema.optional(Schema.String),
maxTokensField: Schema.optional(LanguageModelMaxTokensFieldCompatibility), maxTokensField: Schema.optional(LanguageModelMaxTokensFieldCompatibility),
requireFinishReason: Schema.optional(Schema.Boolean),
}) {} }) {}
export namespace LanguageModelCompatibility { export namespace LanguageModelCompatibility {
+2 -2
View File
@@ -102,7 +102,7 @@ describe("llm constructors", () => {
const updated = LanguageModel.update(base, { const updated = LanguageModel.update(base, {
route: responsesRoute, route: responsesRoute,
defaults: { generation: { maxTokens: 20 } }, defaults: { generation: { maxTokens: 20 } },
compatibility: { toolSchema: "gemini", requireFinishReason: false }, compatibility: { toolSchema: "gemini" },
}) })
const updatedInput = LanguageModel.input(updated) const updatedInput = LanguageModel.input(updated)
@@ -110,7 +110,7 @@ describe("llm constructors", () => {
expect(String(updated.id)).toBe("fake-model") expect(String(updated.id)).toBe("fake-model")
expect(updated.route).toBe(responsesRoute) expect(updated.route).toBe(responsesRoute)
expect(updated.defaults?.generation).toEqual({ maxTokens: 20 }) expect(updated.defaults?.generation).toEqual({ maxTokens: 20 })
expect(updated.compatibility).toEqual({ toolSchema: "gemini", requireFinishReason: false }) expect(updated.compatibility).toEqual({ toolSchema: "gemini" })
expect(updatedInput.defaults).toBe(updated.defaults) expect(updatedInput.defaults).toBe(updated.defaults)
expect(updatedInput.compatibility).toBe(updated.compatibility) expect(updatedInput.compatibility).toBe(updated.compatibility)
expect(String(updatedInput.provider)).toBe("fake") expect(String(updatedInput.provider)).toBe("fake")
-26
View File
@@ -140,32 +140,6 @@ export const Commands = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCO
description: "List all available models", description: "List all available models",
params: ServerParams, params: ServerParams,
}), }),
Spec.make("export", {
description: "Export session data as JSON",
params: {
...ServerParams,
session: Flag.string("session").pipe(
Flag.withAlias("s"),
Flag.withDescription("Session ID to export to stdout"),
Flag.optional,
),
sanitize: Flag.boolean("sanitize").pipe(
Flag.withDescription("Redact sensitive transcript and file data"),
Flag.withDefault(false),
),
},
}),
Spec.make("import", {
description: "Import session data from a JSON file or URL",
params: {
...ServerParams,
file: Argument.string("file").pipe(Argument.withDescription("JSON file or URL to import")),
directory: Flag.string("directory").pipe(
Flag.withDescription("Directory in which to import the session"),
Flag.optional,
),
},
}),
Spec.make("mini", { Spec.make("mini", {
description: "Start the minimal interactive interface", description: "Start the minimal interactive interface",
params: { params: {
@@ -1,140 +0,0 @@
import { OpenCode, type SessionInfo } from "@opencode-ai/client"
import { Service } from "@opencode-ai/client/effect/service"
import { Effect, Option } from "effect"
import { EOL, tmpdir } from "node:os"
import path from "node:path"
import { emitKeypressEvents, type Key } from "node:readline"
import { Commands } from "../commands"
import { Runtime } from "../../framework/runtime"
import { ServerConnection } from "../../services/server-connection"
export default Runtime.handler(
Commands.commands.export,
Effect.fn("cli.export")(function* (input) {
const server = yield* ServerConnection.resolve({
server: Option.getOrUndefined(input.server),
standalone: input.standalone,
})
const client = OpenCode.make({
baseUrl: server.endpoint.url,
headers: Service.headers(server.endpoint),
})
const requested = Option.getOrUndefined(input.session)
const selected = requested
? undefined
: yield* Effect.promise(async () => {
const location = await client.location.get({ location: { directory: process.cwd() } })
const page = await client.session.list({
directory: location.directory,
workspace: location.workspaceID,
parentID: null,
order: "desc",
limit: 50,
})
if (page.data.length === 0) {
process.stderr.write(`No sessions found${EOL}`)
return undefined
}
return selectSession(page.data, input.sanitize)
})
const sessionID = requested ?? selected?.session.id
if (!sessionID) return
const data = yield* Effect.promise(() =>
client.session.export({ sessionID, sanitize: selected?.sanitize ?? input.sanitize }),
)
process.stdout.write(yield* Effect.promise(() => writeExport(data, sessionID, requested !== undefined)))
}),
)
type Selection = { session: SessionInfo; sanitize: boolean }
function selectSession(sessions: SessionInfo[], initialSanitize: boolean) {
if (!process.stdin.isTTY) return Promise.reject(new Error("Session ID is required when stdin is not interactive"))
const input = process.stdin
const output = process.stderr
const wasRaw = input.isRaw
const wasPaused = input.isPaused()
const date = new Intl.DateTimeFormat(undefined, {
month: "short",
day: "numeric",
hour: "numeric",
minute: "2-digit",
})
const columns = output.columns ?? 100
const titleWidth = Math.max(8, Math.min(48, columns - 34))
let selected = 0
let offset = 0
let sanitize = initialSanitize
let height = 0
const render = () => {
const visible = sessions.slice(offset, offset + 10)
const lines = [" \x1b[36mExport session\x1b[0m", ""]
lines.push(
...visible.map((session) => {
const index = sessions.indexOf(session)
const title = (session.title ?? "Untitled session").slice(0, titleWidth).padEnd(titleWidth)
const updated = date.format(session.time.updated).slice(0, 18).padEnd(18)
const row = `${index === selected ? ">" : " "} ${title} ${updated} ${session.id.slice(-8)}`
return index === selected ? `\x1b[1m${row}\x1b[0m` : row
}),
"",
` [${sanitize ? "x" : " "}] sanitize sensitive data`,
"",
" navigate \x1b[2mup/down\x1b[0m sanitize \x1b[2mspace\x1b[0m export \x1b[2menter\x1b[0m cancel \x1b[2mesc\x1b[0m",
)
if (height > 0) output.write(`\x1b[${height}F\x1b[J`)
output.write(lines.join(EOL) + EOL)
height = lines.length
}
const clear = () => {
if (height > 0) output.write(`\x1b[${height}F\x1b[J`)
output.write("\x1b[?25h")
input.removeListener("keypress", onKeypress)
input.setRawMode(wasRaw ?? false)
if (wasPaused) input.pause()
}
const onKeypress = (value: string | undefined, key: Key) => {
if (key.name === "up") {
selected = (selected - 1 + sessions.length) % sessions.length
if (selected === sessions.length - 1) offset = Math.max(0, sessions.length - 10)
if (selected < offset) offset = selected
}
if (key.name === "down") {
selected = (selected + 1) % sessions.length
if (selected === 0) offset = 0
if (selected >= offset + 10) offset = selected - 9
}
if (key.name === "space" || value === " ") sanitize = !sanitize
if (key.name === "return") return finish(sessions[selected])
if (key.name === "escape" || (key.ctrl && key.name === "c")) return cancel()
render()
}
const finish = (session: SessionInfo) => {
clear()
resolveSelection?.({ session, sanitize })
}
const cancel = () => {
clear()
resolveSelection?.()
}
let resolveSelection: ((selection?: Selection) => void) | undefined
emitKeypressEvents(input)
input.setRawMode(true)
input.resume()
input.on("keypress", onKeypress)
output.write("\x1b[?25l")
render()
return new Promise<Selection | undefined>((resolve) => {
resolveSelection = resolve
})
}
export async function writeExport(data: unknown, sessionID: string, stdout: boolean) {
const json = JSON.stringify(data, null, 2) + EOL
if (stdout) return json
const file = path.join(tmpdir(), `opencode-session-${sessionID}-${crypto.randomUUID().slice(0, 8)}.json`)
await Bun.write(file, json)
return file + EOL
}
@@ -1,60 +0,0 @@
import { OpenCode } from "@opencode-ai/client"
import { Service } from "@opencode-ai/client/effect/service"
import { Session } from "@opencode-ai/schema/session"
import { SessionTransfer } from "@opencode-ai/schema/session-transfer"
import { Effect, Option, Schema } from "effect"
import { EOL } from "node:os"
import path from "node:path"
import { Commands } from "../commands"
import { Runtime } from "../../framework/runtime"
import { ServerConnection } from "../../services/server-connection"
export default Runtime.handler(
Commands.commands.import,
Effect.fn("cli.import")(function* (input) {
const text = yield* Effect.tryPromise({
try: () =>
input.file.startsWith("http://") || input.file.startsWith("https://")
? fetch(input.file).then((response) => {
if (!response.ok) throw new Error(`Failed to fetch session data: ${response.statusText}`)
return response.text()
})
: Bun.file(input.file).text(),
catch: (cause) => new Error(`Failed to read session data: ${cause instanceof Error ? cause.message : String(cause)}`),
})
const data = yield* Schema.decodeUnknownEffect(Schema.fromJsonString(SessionTransfer.Data))(text)
const encoded = Schema.encodeSync(SessionTransfer.Data)(data)
const server = yield* ServerConnection.resolve({
server: Option.getOrUndefined(input.server),
standalone: input.standalone,
})
const client = OpenCode.make({
baseUrl: server.endpoint.url,
headers: Service.headers(server.endpoint),
})
const location = yield* Effect.promise(() =>
client.location.get({
location: { directory: path.resolve(Option.getOrElse(input.directory, () => process.cwd())) },
}),
)
const response = yield* Effect.promise(() =>
fetch(new URL("/api/session/import", server.endpoint.url), {
method: "POST",
headers: { ...Service.headers(server.endpoint), "content-type": "application/json" },
body: JSON.stringify({
...encoded,
location: { directory: location.directory, workspaceID: location.workspaceID },
}),
}),
)
if (response.status === 409) {
process.stderr.write(`Session already exists${EOL}`)
return
}
if (!response.ok) yield* Effect.fail(new Error(`Failed to import session: ${response.statusText}`))
const imported = yield* Schema.decodeUnknownEffect(
Schema.fromJsonString(Schema.Struct({ data: Session.Info })),
)(yield* Effect.promise(() => response.text()))
process.stdout.write(`Imported session: ${imported.data.id}${EOL}`)
}),
)
-2
View File
@@ -37,8 +37,6 @@ const Handlers = Runtime.handlers(Commands, {
list: () => import("./commands/handlers/plugin/list"), list: () => import("./commands/handlers/plugin/list"),
}, },
models: () => import("./commands/handlers/models"), models: () => import("./commands/handlers/models"),
export: () => import("./commands/handlers/export"),
import: () => import("./commands/handlers/import"),
mini: () => import("./commands/handlers/mini"), mini: () => import("./commands/handlers/mini"),
run: () => import("./commands/handlers/run"), run: () => import("./commands/handlers/run"),
pair: () => import("./commands/handlers/pair"), pair: () => import("./commands/handlers/pair"),
+6 -9
View File
@@ -94,9 +94,11 @@ async function execute(input: RunCommandInput, prepared: Prepared, endpoint: End
prepare: async (next) => { prepare: async (next) => {
const selected = const selected =
next.model ?? next.model ??
(await client.model (options.variant
.default({ location: { directory: next.location.directory, workspace: next.location.workspaceID } }) ? await client.model
.then((result) => result.data)) .default({ location: { directory: next.location.directory, workspace: next.location.workspaceID } })
.then((result) => result.data)
: undefined)
const model = selected const model = selected
? { ? {
providerID: selected.providerID, providerID: selected.providerID,
@@ -106,12 +108,7 @@ async function execute(input: RunCommandInput, prepared: Prepared, endpoint: End
: undefined : undefined
if ((options.variant ?? explicit?.variant) && !model) if ((options.variant ?? explicit?.variant) && !model)
throw new RunTargetError("Cannot select a variant before selecting a model", next.session?.id) throw new RunTargetError("Cannot select a variant before selecting a model", next.session?.id)
const agent = return { model, agent: next.agent }
next.agent ??
(await client.agent
.list({ location: { directory: next.location.directory, workspace: next.location.workspaceID } })
.then((result) => result.data.find((item) => item.mode !== "subagent" && !item.hidden)?.id))
return { model, agent }
}, },
}).catch((error) => { }).catch((error) => {
if (!(error instanceof RunTargetError)) throw error if (!(error instanceof RunTargetError)) throw error
+2 -5
View File
@@ -56,16 +56,13 @@ export async function resolveSessionTarget(input: {
agent: input.agent ?? selected?.agent, agent: input.agent ?? selected?.agent,
signal: input.signal, signal: input.signal,
}) })
if (!selected && (!prepared.agent || !prepared.model)) {
throw new SessionTargetMutationError(new Error("Creating a session requires an agent and model"))
}
const session = const session =
selected ?? selected ??
(await input.client.session (await input.client.session
.create( .create(
{ {
agent: prepared.agent!, agent: prepared.agent,
model: prepared.model!, model: prepared.model,
location: { directory: location.directory, workspaceID: location.workspaceID }, location: { directory: location.directory, workspaceID: location.workspaceID },
}, },
...requestOptions(input.signal), ...requestOptions(input.signal),
-210
View File
@@ -1,210 +0,0 @@
import { expect, test } from "bun:test"
import fs from "node:fs/promises"
import os from "node:os"
import path from "node:path"
import { OPENCODE_VERSION } from "../src/version"
import { writeExport } from "../src/commands/handlers/export"
const info = {
id: "ses_export_test",
projectID: "global",
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 1, updated: 2 },
title: "Exported session",
location: { directory: "/project" },
}
const transfer = {
info,
messages: [
{ id: "msg_first", type: "user", text: "First", time: { created: 1 } },
{ id: "msg_second", type: "user", text: "Second", time: { created: 2 } },
],
}
const sanitizedTransfer = {
info: {
...info,
title: "[redacted:session-title:ses_export_test]",
location: { directory: "/[redacted:session-directory:ses_export_test]" },
},
messages: [
{
id: "msg_first",
type: "user",
text: "[redacted:text:msg_first]",
time: { created: 1 },
},
{
id: "msg_second",
type: "user",
text: "[redacted:text:msg_second]",
time: { created: 2 },
},
],
}
const health = () => Response.json({ healthy: true, version: OPENCODE_VERSION, pid: process.pid })
function run(args: string[], stdin?: string) {
const child = Bun.spawn([process.execPath, "run", "src/index.ts", ...args], {
cwd: path.join(import.meta.dir, ".."),
stdin: stdin === undefined ? undefined : new Blob([stdin]),
stdout: "pipe",
stderr: "pipe",
})
return Promise.all([new Response(child.stdout).text(), new Response(child.stderr).text(), child.exited])
}
test("export is raw by default and supports explicit sanitization", async () => {
const sanitization: string[] = []
const server = Bun.serve({
port: 0,
fetch(request) {
const url = new URL(request.url)
if (url.pathname === "/api/health") return health()
if (url.pathname === `/api/session/${info.id}`) return Response.json({ data: info })
if (url.pathname === `/api/session/${info.id}/export`) {
sanitization.push(url.searchParams.get("sanitize") ?? "")
return Response.json({ data: url.searchParams.get("sanitize") === "true" ? sanitizedTransfer : transfer })
}
return new Response("Not found", { status: 404 })
},
})
try {
const [stdout, , exitCode] = await run(["export", "-s", info.id, "--server", server.url.toString()])
const exported = JSON.parse(stdout)
expect(exitCode).toBe(0)
expect(exported).toEqual(transfer)
const [sanitized, , sanitizedExitCode] = await run([
"export",
"-s",
info.id,
"--sanitize",
"--server",
server.url.toString(),
])
expect(sanitizedExitCode).toBe(0)
expect(JSON.parse(sanitized)).toEqual(sanitizedTransfer)
expect(sanitization).toEqual(["false", "true"])
} finally {
await server.stop(true)
}
}, 15_000)
test("export reports an empty session list without a stack trace", async () => {
const server = Bun.serve({
port: 0,
fetch(request) {
const url = new URL(request.url)
if (url.pathname === "/api/health") return health()
if (url.pathname === "/api/location") {
return Response.json({
directory: "/project",
project: { id: "global", directory: "/project", canonical: "/project" },
})
}
if (url.pathname === "/api/session") return Response.json({ data: [], cursor: {} })
return new Response("Not found", { status: 404 })
},
})
try {
const [stdout, stderr, exitCode] = await run(["export", "--server", server.url.toString()])
expect(exitCode).toBe(0)
expect(stdout).toBe("")
expect(stderr).toBe(`No sessions found${os.EOL}`)
} finally {
await server.stop(true)
}
})
test("interactive export writes a temporary JSON file", async () => {
const output = await writeExport(transfer, info.id, false)
const file = output.trim()
try {
expect(path.dirname(file)).toBe(os.tmpdir())
expect(await Bun.file(file).json()).toEqual(transfer)
} finally {
await fs.rm(file, { force: true })
}
})
test("import validates a file and sends it to the resolved location", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-import-"))
const file = path.join(root, "session.json")
await fs.writeFile(file, JSON.stringify(transfer))
let imported: unknown
const server = Bun.serve({
port: 0,
async fetch(request) {
const url = new URL(request.url)
if (url.pathname === "/api/health") return health()
if (url.pathname === "/api/location") {
return Response.json({
directory: root,
project: { id: "global", directory: root, canonical: root },
})
}
if (url.pathname === "/api/session/import") {
imported = await request.json()
return Response.json({ data: { ...info, location: { directory: root } } })
}
return new Response("Not found", { status: 404 })
},
})
try {
const [stdout, , exitCode] = await run([
"import",
file,
"--directory",
root,
"--server",
server.url.toString(),
])
expect(exitCode).toBe(0)
expect(stdout).toBe(`Imported session: ${info.id}${os.EOL}`)
expect(imported).toEqual({ ...transfer, location: { directory: root } })
} finally {
await server.stop(true)
await fs.rm(root, { recursive: true, force: true })
}
})
test("import reports an existing session without a stack trace", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-import-conflict-"))
const file = path.join(root, "session.json")
await fs.writeFile(file, JSON.stringify(transfer))
const server = Bun.serve({
port: 0,
fetch(request) {
const url = new URL(request.url)
if (url.pathname === "/api/health") return health()
if (url.pathname === "/api/location") {
return Response.json({
directory: root,
project: { id: "global", directory: root, canonical: root },
})
}
if (url.pathname === "/api/session/import") return new Response("Conflict", { status: 409 })
return new Response("Not found", { status: 404 })
},
})
try {
const [stdout, stderr, exitCode] = await run(["import", file, "--server", server.url.toString()])
expect(exitCode).toBe(0)
expect(stdout).toBe("")
expect(stderr).toBe(`Session already exists${os.EOL}`)
} finally {
await server.stop(true)
await fs.rm(root, { recursive: true, force: true })
}
})
+6 -12
View File
@@ -61,11 +61,7 @@ describe("session target resolver", () => {
spyOn(client.location, "get").mockResolvedValue(location("/server", "work_1")) spyOn(client.location, "get").mockResolvedValue(location("/server", "work_1"))
const create = spyOn(client.session, "create").mockImplementation(async (input) => { const create = spyOn(client.session, "create").mockImplementation(async (input) => {
order.push("create") order.push("create")
expect(input).toMatchObject({ expect(input).toMatchObject({ agent: "prepared", location: { directory: "/server", workspaceID: "work_1" } })
agent: "prepared",
model: { providerID: "openai", id: "gpt-5" },
location: { directory: "/server", workspaceID: "work_1" },
})
return session("ses_fresh", "/server", "work_1") return session("ses_fresh", "/server", "work_1")
}) })
@@ -75,22 +71,20 @@ describe("session target resolver", () => {
prepare: async (input) => { prepare: async (input) => {
order.push("prepare") order.push("prepare")
expect(input.location.workspaceID).toBe("work_1") expect(input.location.workspaceID).toBe("work_1")
return { model: { providerID: "openai", id: "gpt-5" }, agent: "prepared" } return { model: input.model, agent: "prepared" }
}, },
}) })
expect(create).toHaveBeenCalledTimes(1) expect(create).toHaveBeenCalledTimes(1)
expect(order).toEqual(["prepare", "create"]) expect(order).toEqual(["prepare", "create"])
}) })
test("requires an explicit agent and model for a fresh Session", async () => { test("uses the agent resolved by the server for a fresh Session", async () => {
const client = OpenCode.make({ baseUrl: "https://opencode.test" }) const client = OpenCode.make({ baseUrl: "https://opencode.test" })
spyOn(client.location, "get").mockResolvedValue(location("/project")) spyOn(client.location, "get").mockResolvedValue(location("/project"))
const create = spyOn(client.session, "create") spyOn(client.session, "create").mockResolvedValue({ ...session("ses_fresh", "/project"), agent: "review" })
await expect(resolveSessionTarget({ client, prepare })).rejects.toThrow( const target = await resolveSessionTarget({ client, prepare })
"Creating a session requires an agent and model", expect(target.agent).toBe("review")
)
expect(create).not.toHaveBeenCalled()
}) })
test("does not retry an ambiguous Session creation", async () => { test("does not retry an ambiguous Session creation", async () => {
+86 -100
View File
@@ -120,61 +120,49 @@ export type SessionListOperation<E = never> = (input?: Endpoint5_0Input) => Effe
export type Endpoint5_1Input = { export type Endpoint5_1Input = {
readonly id?: Session.ID | undefined readonly id?: Session.ID | undefined
readonly title?: string | undefined readonly title?: string | undefined
readonly agent: Agent.ID readonly agent?: Agent.ID | undefined
readonly model: Model.Ref readonly model?: Model.Ref | undefined
readonly location?: Location.Ref | undefined readonly location?: Location.Ref | undefined
} }
export type Endpoint5_1Output = Session.Info export type Endpoint5_1Output = Session.Info
export type SessionCreateOperation<E = never> = (input: Endpoint5_1Input) => Effect.Effect<Endpoint5_1Output, E> export type SessionCreateOperation<E = never> = (input?: Endpoint5_1Input) => Effect.Effect<Endpoint5_1Output, E>
export type Endpoint5_2Input = { export type Endpoint5_2Output = { readonly [x: Session.ID]: { readonly type: "running" } }
readonly info: Session.Info export type SessionActiveOperation<E = never> = () => Effect.Effect<Endpoint5_2Output, E>
readonly messages: ReadonlyArray<SessionMessage.Info>
readonly location?: Location.Ref | undefined
}
export type Endpoint5_2Output = Session.Info
export type SessionImportOperation<E = never> = (input: Endpoint5_2Input) => Effect.Effect<Endpoint5_2Output, E>
export type Endpoint5_3Input = { readonly sessionID: Session.ID; readonly sanitize?: boolean | undefined } export type Endpoint5_3Input = { readonly sessionID: Session.ID }
export type Endpoint5_3Output = { readonly info: Session.Info; readonly messages: ReadonlyArray<SessionMessage.Info> } export type Endpoint5_3Output = Session.Info
export type SessionExportOperation<E = never> = (input: Endpoint5_3Input) => Effect.Effect<Endpoint5_3Output, E> export type SessionGetOperation<E = never> = (input: Endpoint5_3Input) => Effect.Effect<Endpoint5_3Output, E>
export type Endpoint5_4Output = { readonly [x: Session.ID]: { readonly type: "running" } } export type Endpoint5_4Input = { readonly sessionID: Session.ID }
export type SessionActiveOperation<E = never> = () => Effect.Effect<Endpoint5_4Output, E> export type Endpoint5_4Output = void
export type SessionRemoveOperation<E = never> = (input: Endpoint5_4Input) => Effect.Effect<Endpoint5_4Output, E>
export type Endpoint5_5Input = { readonly sessionID: Session.ID } export type Endpoint5_5Input = { readonly sessionID: Session.ID; readonly boundary: Session.ForkRequestBoundary }
export type Endpoint5_5Output = Session.Info export type Endpoint5_5Output = Session.Info
export type SessionGetOperation<E = never> = (input: Endpoint5_5Input) => Effect.Effect<Endpoint5_5Output, E> export type SessionForkOperation<E = never> = (input: Endpoint5_5Input) => Effect.Effect<Endpoint5_5Output, E>
export type Endpoint5_6Input = { readonly sessionID: Session.ID } export type Endpoint5_6Input = { readonly sessionID: Session.ID; readonly agent: Agent.ID }
export type Endpoint5_6Output = void export type Endpoint5_6Output = void
export type SessionRemoveOperation<E = never> = (input: Endpoint5_6Input) => Effect.Effect<Endpoint5_6Output, E> export type SessionSwitchAgentOperation<E = never> = (input: Endpoint5_6Input) => Effect.Effect<Endpoint5_6Output, E>
export type Endpoint5_7Input = { readonly sessionID: Session.ID; readonly boundary: Session.ForkRequestBoundary } export type Endpoint5_7Input = { readonly sessionID: Session.ID; readonly model: Model.Ref }
export type Endpoint5_7Output = Session.Info export type Endpoint5_7Output = void
export type SessionForkOperation<E = never> = (input: Endpoint5_7Input) => Effect.Effect<Endpoint5_7Output, E> export type SessionSwitchModelOperation<E = never> = (input: Endpoint5_7Input) => Effect.Effect<Endpoint5_7Output, E>
export type Endpoint5_8Input = { readonly sessionID: Session.ID; readonly agent: Agent.ID } export type Endpoint5_8Input = { readonly sessionID: Session.ID; readonly title: string }
export type Endpoint5_8Output = void export type Endpoint5_8Output = void
export type SessionSwitchAgentOperation<E = never> = (input: Endpoint5_8Input) => Effect.Effect<Endpoint5_8Output, E> export type SessionRenameOperation<E = never> = (input: Endpoint5_8Input) => Effect.Effect<Endpoint5_8Output, E>
export type Endpoint5_9Input = { readonly sessionID: Session.ID; readonly model: Model.Ref } export type Endpoint5_9Input = {
export type Endpoint5_9Output = void
export type SessionSwitchModelOperation<E = never> = (input: Endpoint5_9Input) => Effect.Effect<Endpoint5_9Output, E>
export type Endpoint5_10Input = { readonly sessionID: Session.ID; readonly title: string }
export type Endpoint5_10Output = void
export type SessionRenameOperation<E = never> = (input: Endpoint5_10Input) => Effect.Effect<Endpoint5_10Output, E>
export type Endpoint5_11Input = {
readonly sessionID: Session.ID readonly sessionID: Session.ID
readonly directory: AbsolutePath readonly directory: AbsolutePath
readonly workspaceID?: Workspace.ID | undefined readonly workspaceID?: Workspace.ID | undefined
} }
export type Endpoint5_11Output = void export type Endpoint5_9Output = void
export type SessionMoveOperation<E = never> = (input: Endpoint5_11Input) => Effect.Effect<Endpoint5_11Output, E> export type SessionMoveOperation<E = never> = (input: Endpoint5_9Input) => Effect.Effect<Endpoint5_9Output, E>
export type Endpoint5_12Input = { export type Endpoint5_10Input = {
readonly sessionID: Session.ID readonly sessionID: Session.ID
readonly id?: SessionMessage.ID | undefined readonly id?: SessionMessage.ID | undefined
readonly text: string readonly text: string
@@ -184,10 +172,10 @@ export type Endpoint5_12Input = {
readonly delivery?: "steer" | "queue" | undefined readonly delivery?: "steer" | "queue" | undefined
readonly resume?: boolean | undefined readonly resume?: boolean | undefined
} }
export type Endpoint5_12Output = SessionPending.User export type Endpoint5_10Output = SessionPending.User
export type SessionPromptOperation<E = never> = (input: Endpoint5_12Input) => Effect.Effect<Endpoint5_12Output, E> export type SessionPromptOperation<E = never> = (input: Endpoint5_10Input) => Effect.Effect<Endpoint5_10Output, E>
export type Endpoint5_13Input = { export type Endpoint5_11Input = {
readonly sessionID: Session.ID readonly sessionID: Session.ID
readonly id?: SessionMessage.ID | undefined readonly id?: SessionMessage.ID | undefined
readonly command: string readonly command: string
@@ -199,19 +187,19 @@ export type Endpoint5_13Input = {
readonly delivery?: "steer" | "queue" | undefined readonly delivery?: "steer" | "queue" | undefined
readonly resume?: boolean | undefined readonly resume?: boolean | undefined
} }
export type Endpoint5_13Output = SessionPending.User export type Endpoint5_11Output = SessionPending.User
export type SessionCommandOperation<E = never> = (input: Endpoint5_13Input) => Effect.Effect<Endpoint5_13Output, E> export type SessionCommandOperation<E = never> = (input: Endpoint5_11Input) => Effect.Effect<Endpoint5_11Output, E>
export type Endpoint5_14Input = { export type Endpoint5_12Input = {
readonly sessionID: Session.ID readonly sessionID: Session.ID
readonly id?: SessionMessage.ID | undefined readonly id?: SessionMessage.ID | undefined
readonly skill: Skill.ID readonly skill: Skill.ID
readonly resume?: boolean | undefined readonly resume?: boolean | undefined
} }
export type Endpoint5_14Output = void export type Endpoint5_12Output = void
export type SessionSkillOperation<E = never> = (input: Endpoint5_14Input) => Effect.Effect<Endpoint5_14Output, E> export type SessionSkillOperation<E = never> = (input: Endpoint5_12Input) => Effect.Effect<Endpoint5_12Output, E>
export type Endpoint5_15Input = { export type Endpoint5_13Input = {
readonly sessionID: Session.ID readonly sessionID: Session.ID
readonly id?: SessionMessage.ID | undefined readonly id?: SessionMessage.ID | undefined
readonly text: string readonly text: string
@@ -220,81 +208,81 @@ export type Endpoint5_15Input = {
readonly delivery?: "steer" | "queue" | undefined readonly delivery?: "steer" | "queue" | undefined
readonly resume?: boolean | undefined readonly resume?: boolean | undefined
} }
export type Endpoint5_15Output = SessionPending.Synthetic export type Endpoint5_13Output = SessionPending.Synthetic
export type SessionSyntheticOperation<E = never> = (input: Endpoint5_15Input) => Effect.Effect<Endpoint5_15Output, E> export type SessionSyntheticOperation<E = never> = (input: Endpoint5_13Input) => Effect.Effect<Endpoint5_13Output, E>
export type Endpoint5_16Input = { export type Endpoint5_14Input = {
readonly sessionID: Session.ID readonly sessionID: Session.ID
readonly id?: Event.ID | undefined readonly id?: Event.ID | undefined
readonly command: string readonly command: string
} }
export type Endpoint5_14Output = void
export type SessionShellOperation<E = never> = (input: Endpoint5_14Input) => Effect.Effect<Endpoint5_14Output, E>
export type Endpoint5_15Input = { readonly sessionID: Session.ID; readonly id?: SessionMessage.ID | undefined }
export type Endpoint5_15Output = SessionPending.Compaction
export type SessionCompactOperation<E = never> = (input: Endpoint5_15Input) => Effect.Effect<Endpoint5_15Output, E>
export type Endpoint5_16Input = { readonly sessionID: Session.ID }
export type Endpoint5_16Output = void export type Endpoint5_16Output = void
export type SessionShellOperation<E = never> = (input: Endpoint5_16Input) => Effect.Effect<Endpoint5_16Output, E> export type SessionWaitOperation<E = never> = (input: Endpoint5_16Input) => Effect.Effect<Endpoint5_16Output, E>
export type Endpoint5_17Input = { readonly sessionID: Session.ID; readonly id?: SessionMessage.ID | undefined } export type Endpoint5_17Input = {
export type Endpoint5_17Output = SessionPending.Compaction
export type SessionCompactOperation<E = never> = (input: Endpoint5_17Input) => Effect.Effect<Endpoint5_17Output, E>
export type Endpoint5_18Input = { readonly sessionID: Session.ID }
export type Endpoint5_18Output = void
export type SessionWaitOperation<E = never> = (input: Endpoint5_18Input) => Effect.Effect<Endpoint5_18Output, E>
export type Endpoint5_19Input = {
readonly sessionID: Session.ID readonly sessionID: Session.ID
readonly messageID: SessionMessage.ID readonly messageID: SessionMessage.ID
readonly files?: boolean | undefined readonly files?: boolean | undefined
} }
export type Endpoint5_19Output = Session.Revert export type Endpoint5_17Output = Session.Revert
export type SessionRevertStageOperation<E = never> = (input: Endpoint5_19Input) => Effect.Effect<Endpoint5_19Output, E> export type SessionRevertStageOperation<E = never> = (input: Endpoint5_17Input) => Effect.Effect<Endpoint5_17Output, E>
export type Endpoint5_18Input = { readonly sessionID: Session.ID }
export type Endpoint5_18Output = void
export type SessionRevertClearOperation<E = never> = (input: Endpoint5_18Input) => Effect.Effect<Endpoint5_18Output, E>
export type Endpoint5_19Input = { readonly sessionID: Session.ID }
export type Endpoint5_19Output = void
export type SessionRevertCommitOperation<E = never> = (input: Endpoint5_19Input) => Effect.Effect<Endpoint5_19Output, E>
export type Endpoint5_20Input = { readonly sessionID: Session.ID } export type Endpoint5_20Input = { readonly sessionID: Session.ID }
export type Endpoint5_20Output = void export type Endpoint5_20Output = ReadonlyArray<SessionMessage.Info>
export type SessionRevertClearOperation<E = never> = (input: Endpoint5_20Input) => Effect.Effect<Endpoint5_20Output, E> export type SessionContextOperation<E = never> = (input: Endpoint5_20Input) => Effect.Effect<Endpoint5_20Output, E>
export type Endpoint5_21Input = { readonly sessionID: Session.ID } export type Endpoint5_21Input = { readonly sessionID: Session.ID }
export type Endpoint5_21Output = void export type Endpoint5_21Output = ReadonlyArray<SessionPending.Info>
export type SessionRevertCommitOperation<E = never> = (input: Endpoint5_21Input) => Effect.Effect<Endpoint5_21Output, E> export type SessionPendingListOperation<E = never> = (input: Endpoint5_21Input) => Effect.Effect<Endpoint5_21Output, E>
export type Endpoint5_22Input = { readonly sessionID: Session.ID } export type Endpoint5_22Input = { readonly sessionID: Session.ID }
export type Endpoint5_22Output = ReadonlyArray<SessionMessage.Info> export type Endpoint5_22Output = ReadonlyArray<InstructionEntry.Info>
export type SessionContextOperation<E = never> = (input: Endpoint5_22Input) => Effect.Effect<Endpoint5_22Output, E>
export type Endpoint5_23Input = { readonly sessionID: Session.ID }
export type Endpoint5_23Output = ReadonlyArray<SessionPending.Info>
export type SessionPendingListOperation<E = never> = (input: Endpoint5_23Input) => Effect.Effect<Endpoint5_23Output, E>
export type Endpoint5_24Input = { readonly sessionID: Session.ID }
export type Endpoint5_24Output = ReadonlyArray<InstructionEntry.Info>
export type SessionInstructionsEntryListOperation<E = never> = ( export type SessionInstructionsEntryListOperation<E = never> = (
input: Endpoint5_24Input, input: Endpoint5_22Input,
) => Effect.Effect<Endpoint5_24Output, E> ) => Effect.Effect<Endpoint5_22Output, E>
export type Endpoint5_25Input = { export type Endpoint5_23Input = {
readonly sessionID: Session.ID readonly sessionID: Session.ID
readonly key: InstructionEntry.Key readonly key: InstructionEntry.Key
readonly value: Schema.Json readonly value: Schema.Json
} }
export type Endpoint5_25Output = void export type Endpoint5_23Output = void
export type SessionInstructionsEntryPutOperation<E = never> = ( export type SessionInstructionsEntryPutOperation<E = never> = (
input: Endpoint5_25Input, input: Endpoint5_23Input,
) => Effect.Effect<Endpoint5_25Output, E> ) => Effect.Effect<Endpoint5_23Output, E>
export type Endpoint5_26Input = { readonly sessionID: Session.ID; readonly key: InstructionEntry.Key } export type Endpoint5_24Input = { readonly sessionID: Session.ID; readonly key: InstructionEntry.Key }
export type Endpoint5_26Output = void export type Endpoint5_24Output = void
export type SessionInstructionsEntryRemoveOperation<E = never> = ( export type SessionInstructionsEntryRemoveOperation<E = never> = (
input: Endpoint5_26Input, input: Endpoint5_24Input,
) => Effect.Effect<Endpoint5_26Output, E> ) => Effect.Effect<Endpoint5_24Output, E>
export type Endpoint5_27Input = { readonly sessionID: Session.ID; readonly prompt: string } export type Endpoint5_25Input = { readonly sessionID: Session.ID; readonly prompt: string }
export type Endpoint5_27Output = { readonly text: string } export type Endpoint5_25Output = { readonly text: string }
export type SessionGenerateOperation<E = never> = (input: Endpoint5_27Input) => Effect.Effect<Endpoint5_27Output, E> export type SessionGenerateOperation<E = never> = (input: Endpoint5_25Input) => Effect.Effect<Endpoint5_25Output, E>
export type Endpoint5_28Input = { export type Endpoint5_26Input = {
readonly sessionID: Session.ID readonly sessionID: Session.ID
readonly after?: Event.Seq | undefined readonly after?: Event.Seq | undefined
readonly follow?: boolean | undefined readonly follow?: boolean | undefined
} }
export type Endpoint5_28Output = export type Endpoint5_26Output =
| ( | (
| { | {
readonly id: Event.ID readonly id: Event.ID
@@ -862,25 +850,23 @@ export type Endpoint5_28Output =
} }
) )
| EventLog.Synced | EventLog.Synced
export type SessionLogOperation<E = never> = (input: Endpoint5_28Input) => Stream.Stream<Endpoint5_28Output, E> export type SessionLogOperation<E = never> = (input: Endpoint5_26Input) => Stream.Stream<Endpoint5_26Output, E>
export type Endpoint5_29Input = { readonly sessionID: Session.ID } export type Endpoint5_27Input = { readonly sessionID: Session.ID }
export type Endpoint5_29Output = void export type Endpoint5_27Output = void
export type SessionInterruptOperation<E = never> = (input: Endpoint5_29Input) => Effect.Effect<Endpoint5_29Output, E> export type SessionInterruptOperation<E = never> = (input: Endpoint5_27Input) => Effect.Effect<Endpoint5_27Output, E>
export type Endpoint5_30Input = { readonly sessionID: Session.ID } export type Endpoint5_28Input = { readonly sessionID: Session.ID }
export type Endpoint5_30Output = void export type Endpoint5_28Output = void
export type SessionBackgroundOperation<E = never> = (input: Endpoint5_30Input) => Effect.Effect<Endpoint5_30Output, E> export type SessionBackgroundOperation<E = never> = (input: Endpoint5_28Input) => Effect.Effect<Endpoint5_28Output, E>
export type Endpoint5_31Input = { readonly sessionID: Session.ID; readonly messageID: SessionMessage.ID } export type Endpoint5_29Input = { readonly sessionID: Session.ID; readonly messageID: SessionMessage.ID }
export type Endpoint5_31Output = SessionMessage.Info export type Endpoint5_29Output = SessionMessage.Info
export type SessionMessageOperation<E = never> = (input: Endpoint5_31Input) => Effect.Effect<Endpoint5_31Output, E> export type SessionMessageOperation<E = never> = (input: Endpoint5_29Input) => Effect.Effect<Endpoint5_29Output, E>
export interface SessionApi<E = never> { export interface SessionApi<E = never> {
readonly list: SessionListOperation<E> readonly list: SessionListOperation<E>
readonly create: SessionCreateOperation<E> readonly create: SessionCreateOperation<E>
readonly import: SessionImportOperation<E>
readonly export: SessionExportOperation<E>
readonly active: SessionActiveOperation<E> readonly active: SessionActiveOperation<E>
readonly get: SessionGetOperation<E> readonly get: SessionGetOperation<E>
readonly remove: SessionRemoveOperation<E> readonly remove: SessionRemoveOperation<E>
+89 -113
View File
@@ -21,10 +21,10 @@ import type {
Endpoint5_0Output, Endpoint5_0Output,
Endpoint5_1Input, Endpoint5_1Input,
Endpoint5_1Output, Endpoint5_1Output,
Endpoint5_2Input,
Endpoint5_2Output, Endpoint5_2Output,
Endpoint5_3Input, Endpoint5_3Input,
Endpoint5_3Output, Endpoint5_3Output,
Endpoint5_4Input,
Endpoint5_4Output, Endpoint5_4Output,
Endpoint5_5Input, Endpoint5_5Input,
Endpoint5_5Output, Endpoint5_5Output,
@@ -76,10 +76,6 @@ import type {
Endpoint5_28Output, Endpoint5_28Output,
Endpoint5_29Input, Endpoint5_29Input,
Endpoint5_29Output, Endpoint5_29Output,
Endpoint5_30Input,
Endpoint5_30Output,
Endpoint5_31Input,
Endpoint5_31Output,
Endpoint6_0Input, Endpoint6_0Input,
Endpoint6_0Output, Endpoint6_0Output,
Endpoint7_0Input, Endpoint7_0Input,
@@ -305,15 +301,15 @@ const Endpoint5_0 = (raw: RawClient["server.session"]) => (input?: Endpoint5_0In
}).pipe(Effect.mapError(mapClientError)), }).pipe(Effect.mapError(mapClientError)),
) )
const Endpoint5_1 = (raw: RawClient["server.session"]) => (input: Endpoint5_1Input) => const Endpoint5_1 = (raw: RawClient["server.session"]) => (input?: Endpoint5_1Input) =>
preserveEffect<Endpoint5_1Output>()( preserveEffect<Endpoint5_1Output>()(
raw["session.create"]({ raw["session.create"]({
payload: { payload: {
id: input["id"], id: input?.["id"],
title: input["title"], title: input?.["title"],
agent: input["agent"], agent: input?.["agent"],
model: input["model"], model: input?.["model"],
location: input["location"], location: input?.["location"],
}, },
}).pipe( }).pipe(
Effect.mapError(mapClientError), Effect.mapError(mapClientError),
@@ -321,11 +317,9 @@ const Endpoint5_1 = (raw: RawClient["server.session"]) => (input: Endpoint5_1Inp
), ),
) )
const Endpoint5_2 = (raw: RawClient["server.session"]) => (input: Endpoint5_2Input) => const Endpoint5_2 = (raw: RawClient["server.session"]) => () =>
preserveEffect<Endpoint5_2Output>()( preserveEffect<Endpoint5_2Output>()(
raw["session.import"]({ raw["session.active"]({}).pipe(
payload: { info: input["info"], messages: input["messages"], location: input["location"] },
}).pipe(
Effect.mapError(mapClientError), Effect.mapError(mapClientError),
Effect.map((value) => value.data), Effect.map((value) => value.data),
), ),
@@ -333,23 +327,20 @@ const Endpoint5_2 = (raw: RawClient["server.session"]) => (input: Endpoint5_2Inp
const Endpoint5_3 = (raw: RawClient["server.session"]) => (input: Endpoint5_3Input) => const Endpoint5_3 = (raw: RawClient["server.session"]) => (input: Endpoint5_3Input) =>
preserveEffect<Endpoint5_3Output>()( preserveEffect<Endpoint5_3Output>()(
raw["session.export"]({ params: { sessionID: input["sessionID"] }, query: { sanitize: input["sanitize"] } }).pipe( raw["session.get"]({ params: { sessionID: input["sessionID"] } }).pipe(
Effect.mapError(mapClientError), Effect.mapError(mapClientError),
Effect.map((value) => value.data), Effect.map((value) => value.data),
), ),
) )
const Endpoint5_4 = (raw: RawClient["server.session"]) => () => const Endpoint5_4 = (raw: RawClient["server.session"]) => (input: Endpoint5_4Input) =>
preserveEffect<Endpoint5_4Output>()( preserveEffect<Endpoint5_4Output>()(
raw["session.active"]({}).pipe( raw["session.remove"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)),
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
),
) )
const Endpoint5_5 = (raw: RawClient["server.session"]) => (input: Endpoint5_5Input) => const Endpoint5_5 = (raw: RawClient["server.session"]) => (input: Endpoint5_5Input) =>
preserveEffect<Endpoint5_5Output>()( preserveEffect<Endpoint5_5Output>()(
raw["session.get"]({ params: { sessionID: input["sessionID"] } }).pipe( raw["session.fork"]({ params: { sessionID: input["sessionID"] }, payload: { boundary: input["boundary"] } }).pipe(
Effect.mapError(mapClientError), Effect.mapError(mapClientError),
Effect.map((value) => value.data), Effect.map((value) => value.data),
), ),
@@ -357,48 +348,35 @@ const Endpoint5_5 = (raw: RawClient["server.session"]) => (input: Endpoint5_5Inp
const Endpoint5_6 = (raw: RawClient["server.session"]) => (input: Endpoint5_6Input) => const Endpoint5_6 = (raw: RawClient["server.session"]) => (input: Endpoint5_6Input) =>
preserveEffect<Endpoint5_6Output>()( preserveEffect<Endpoint5_6Output>()(
raw["session.remove"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)), raw["session.switchAgent"]({ params: { sessionID: input["sessionID"] }, payload: { agent: input["agent"] } }).pipe(
Effect.mapError(mapClientError),
),
) )
const Endpoint5_7 = (raw: RawClient["server.session"]) => (input: Endpoint5_7Input) => const Endpoint5_7 = (raw: RawClient["server.session"]) => (input: Endpoint5_7Input) =>
preserveEffect<Endpoint5_7Output>()( preserveEffect<Endpoint5_7Output>()(
raw["session.fork"]({ params: { sessionID: input["sessionID"] }, payload: { boundary: input["boundary"] } }).pipe( raw["session.switchModel"]({ params: { sessionID: input["sessionID"] }, payload: { model: input["model"] } }).pipe(
Effect.mapError(mapClientError), Effect.mapError(mapClientError),
Effect.map((value) => value.data),
), ),
) )
const Endpoint5_8 = (raw: RawClient["server.session"]) => (input: Endpoint5_8Input) => const Endpoint5_8 = (raw: RawClient["server.session"]) => (input: Endpoint5_8Input) =>
preserveEffect<Endpoint5_8Output>()( preserveEffect<Endpoint5_8Output>()(
raw["session.switchAgent"]({ params: { sessionID: input["sessionID"] }, payload: { agent: input["agent"] } }).pipe( raw["session.rename"]({ params: { sessionID: input["sessionID"] }, payload: { title: input["title"] } }).pipe(
Effect.mapError(mapClientError), Effect.mapError(mapClientError),
), ),
) )
const Endpoint5_9 = (raw: RawClient["server.session"]) => (input: Endpoint5_9Input) => const Endpoint5_9 = (raw: RawClient["server.session"]) => (input: Endpoint5_9Input) =>
preserveEffect<Endpoint5_9Output>()( preserveEffect<Endpoint5_9Output>()(
raw["session.switchModel"]({ params: { sessionID: input["sessionID"] }, payload: { model: input["model"] } }).pipe(
Effect.mapError(mapClientError),
),
)
const Endpoint5_10 = (raw: RawClient["server.session"]) => (input: Endpoint5_10Input) =>
preserveEffect<Endpoint5_10Output>()(
raw["session.rename"]({ params: { sessionID: input["sessionID"] }, payload: { title: input["title"] } }).pipe(
Effect.mapError(mapClientError),
),
)
const Endpoint5_11 = (raw: RawClient["server.session"]) => (input: Endpoint5_11Input) =>
preserveEffect<Endpoint5_11Output>()(
raw["session.move"]({ raw["session.move"]({
params: { sessionID: input["sessionID"] }, params: { sessionID: input["sessionID"] },
payload: { directory: input["directory"], workspaceID: input["workspaceID"] }, payload: { directory: input["directory"], workspaceID: input["workspaceID"] },
}).pipe(Effect.mapError(mapClientError)), }).pipe(Effect.mapError(mapClientError)),
) )
const Endpoint5_12 = (raw: RawClient["server.session"]) => (input: Endpoint5_12Input) => const Endpoint5_10 = (raw: RawClient["server.session"]) => (input: Endpoint5_10Input) =>
preserveEffect<Endpoint5_12Output>()( preserveEffect<Endpoint5_10Output>()(
raw["session.prompt"]({ raw["session.prompt"]({
params: { sessionID: input["sessionID"] }, params: { sessionID: input["sessionID"] },
payload: { payload: {
@@ -416,8 +394,8 @@ const Endpoint5_12 = (raw: RawClient["server.session"]) => (input: Endpoint5_12I
), ),
) )
const Endpoint5_13 = (raw: RawClient["server.session"]) => (input: Endpoint5_13Input) => const Endpoint5_11 = (raw: RawClient["server.session"]) => (input: Endpoint5_11Input) =>
preserveEffect<Endpoint5_13Output>()( preserveEffect<Endpoint5_11Output>()(
raw["session.command"]({ raw["session.command"]({
params: { sessionID: input["sessionID"] }, params: { sessionID: input["sessionID"] },
payload: { payload: {
@@ -437,16 +415,16 @@ const Endpoint5_13 = (raw: RawClient["server.session"]) => (input: Endpoint5_13I
), ),
) )
const Endpoint5_14 = (raw: RawClient["server.session"]) => (input: Endpoint5_14Input) => const Endpoint5_12 = (raw: RawClient["server.session"]) => (input: Endpoint5_12Input) =>
preserveEffect<Endpoint5_14Output>()( preserveEffect<Endpoint5_12Output>()(
raw["session.skill"]({ raw["session.skill"]({
params: { sessionID: input["sessionID"] }, params: { sessionID: input["sessionID"] },
payload: { id: input["id"], skill: input["skill"], resume: input["resume"] }, payload: { id: input["id"], skill: input["skill"], resume: input["resume"] },
}).pipe(Effect.mapError(mapClientError)), }).pipe(Effect.mapError(mapClientError)),
) )
const Endpoint5_15 = (raw: RawClient["server.session"]) => (input: Endpoint5_15Input) => const Endpoint5_13 = (raw: RawClient["server.session"]) => (input: Endpoint5_13Input) =>
preserveEffect<Endpoint5_15Output>()( preserveEffect<Endpoint5_13Output>()(
raw["session.synthetic"]({ raw["session.synthetic"]({
params: { sessionID: input["sessionID"] }, params: { sessionID: input["sessionID"] },
payload: { payload: {
@@ -463,29 +441,29 @@ const Endpoint5_15 = (raw: RawClient["server.session"]) => (input: Endpoint5_15I
), ),
) )
const Endpoint5_16 = (raw: RawClient["server.session"]) => (input: Endpoint5_16Input) => const Endpoint5_14 = (raw: RawClient["server.session"]) => (input: Endpoint5_14Input) =>
preserveEffect<Endpoint5_16Output>()( preserveEffect<Endpoint5_14Output>()(
raw["session.shell"]({ raw["session.shell"]({
params: { sessionID: input["sessionID"] }, params: { sessionID: input["sessionID"] },
payload: { id: input["id"], command: input["command"] }, payload: { id: input["id"], command: input["command"] },
}).pipe(Effect.mapError(mapClientError)), }).pipe(Effect.mapError(mapClientError)),
) )
const Endpoint5_17 = (raw: RawClient["server.session"]) => (input: Endpoint5_17Input) => const Endpoint5_15 = (raw: RawClient["server.session"]) => (input: Endpoint5_15Input) =>
preserveEffect<Endpoint5_17Output>()( preserveEffect<Endpoint5_15Output>()(
raw["session.compact"]({ params: { sessionID: input["sessionID"] }, payload: { id: input["id"] } }).pipe( raw["session.compact"]({ params: { sessionID: input["sessionID"] }, payload: { id: input["id"] } }).pipe(
Effect.mapError(mapClientError), Effect.mapError(mapClientError),
Effect.map((value) => value.data), Effect.map((value) => value.data),
), ),
) )
const Endpoint5_18 = (raw: RawClient["server.session"]) => (input: Endpoint5_18Input) => const Endpoint5_16 = (raw: RawClient["server.session"]) => (input: Endpoint5_16Input) =>
preserveEffect<Endpoint5_18Output>()( preserveEffect<Endpoint5_16Output>()(
raw["session.wait"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)), raw["session.wait"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)),
) )
const Endpoint5_19 = (raw: RawClient["server.session"]) => (input: Endpoint5_19Input) => const Endpoint5_17 = (raw: RawClient["server.session"]) => (input: Endpoint5_17Input) =>
preserveEffect<Endpoint5_19Output>()( preserveEffect<Endpoint5_17Output>()(
raw["session.revert.stage"]({ raw["session.revert.stage"]({
params: { sessionID: input["sessionID"] }, params: { sessionID: input["sessionID"] },
payload: { messageID: input["messageID"], files: input["files"] }, payload: { messageID: input["messageID"], files: input["files"] },
@@ -495,19 +473,35 @@ const Endpoint5_19 = (raw: RawClient["server.session"]) => (input: Endpoint5_19I
), ),
) )
const Endpoint5_18 = (raw: RawClient["server.session"]) => (input: Endpoint5_18Input) =>
preserveEffect<Endpoint5_18Output>()(
raw["session.revert.clear"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)),
)
const Endpoint5_19 = (raw: RawClient["server.session"]) => (input: Endpoint5_19Input) =>
preserveEffect<Endpoint5_19Output>()(
raw["session.revert.commit"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)),
)
const Endpoint5_20 = (raw: RawClient["server.session"]) => (input: Endpoint5_20Input) => const Endpoint5_20 = (raw: RawClient["server.session"]) => (input: Endpoint5_20Input) =>
preserveEffect<Endpoint5_20Output>()( preserveEffect<Endpoint5_20Output>()(
raw["session.revert.clear"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)), raw["session.context"]({ params: { sessionID: input["sessionID"] } }).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
),
) )
const Endpoint5_21 = (raw: RawClient["server.session"]) => (input: Endpoint5_21Input) => const Endpoint5_21 = (raw: RawClient["server.session"]) => (input: Endpoint5_21Input) =>
preserveEffect<Endpoint5_21Output>()( preserveEffect<Endpoint5_21Output>()(
raw["session.revert.commit"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)), raw["session.pending.list"]({ params: { sessionID: input["sessionID"] } }).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
),
) )
const Endpoint5_22 = (raw: RawClient["server.session"]) => (input: Endpoint5_22Input) => const Endpoint5_22 = (raw: RawClient["server.session"]) => (input: Endpoint5_22Input) =>
preserveEffect<Endpoint5_22Output>()( preserveEffect<Endpoint5_22Output>()(
raw["session.context"]({ params: { sessionID: input["sessionID"] } }).pipe( raw["session.instructions.entry.list"]({ params: { sessionID: input["sessionID"] } }).pipe(
Effect.mapError(mapClientError), Effect.mapError(mapClientError),
Effect.map((value) => value.data), Effect.map((value) => value.data),
), ),
@@ -515,45 +509,29 @@ const Endpoint5_22 = (raw: RawClient["server.session"]) => (input: Endpoint5_22I
const Endpoint5_23 = (raw: RawClient["server.session"]) => (input: Endpoint5_23Input) => const Endpoint5_23 = (raw: RawClient["server.session"]) => (input: Endpoint5_23Input) =>
preserveEffect<Endpoint5_23Output>()( preserveEffect<Endpoint5_23Output>()(
raw["session.pending.list"]({ params: { sessionID: input["sessionID"] } }).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
),
)
const Endpoint5_24 = (raw: RawClient["server.session"]) => (input: Endpoint5_24Input) =>
preserveEffect<Endpoint5_24Output>()(
raw["session.instructions.entry.list"]({ params: { sessionID: input["sessionID"] } }).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
),
)
const Endpoint5_25 = (raw: RawClient["server.session"]) => (input: Endpoint5_25Input) =>
preserveEffect<Endpoint5_25Output>()(
raw["session.instructions.entry.put"]({ raw["session.instructions.entry.put"]({
params: { sessionID: input["sessionID"], key: input["key"] }, params: { sessionID: input["sessionID"], key: input["key"] },
payload: { value: input["value"] }, payload: { value: input["value"] },
}).pipe(Effect.mapError(mapClientError)), }).pipe(Effect.mapError(mapClientError)),
) )
const Endpoint5_26 = (raw: RawClient["server.session"]) => (input: Endpoint5_26Input) => const Endpoint5_24 = (raw: RawClient["server.session"]) => (input: Endpoint5_24Input) =>
preserveEffect<Endpoint5_26Output>()( preserveEffect<Endpoint5_24Output>()(
raw["session.instructions.entry.remove"]({ params: { sessionID: input["sessionID"], key: input["key"] } }).pipe( raw["session.instructions.entry.remove"]({ params: { sessionID: input["sessionID"], key: input["key"] } }).pipe(
Effect.mapError(mapClientError), Effect.mapError(mapClientError),
), ),
) )
const Endpoint5_27 = (raw: RawClient["server.session"]) => (input: Endpoint5_27Input) => const Endpoint5_25 = (raw: RawClient["server.session"]) => (input: Endpoint5_25Input) =>
preserveEffect<Endpoint5_27Output>()( preserveEffect<Endpoint5_25Output>()(
raw["session.generate"]({ params: { sessionID: input["sessionID"] }, payload: { prompt: input["prompt"] } }).pipe( raw["session.generate"]({ params: { sessionID: input["sessionID"] }, payload: { prompt: input["prompt"] } }).pipe(
Effect.mapError(mapClientError), Effect.mapError(mapClientError),
Effect.map((value) => value.data), Effect.map((value) => value.data),
), ),
) )
const Endpoint5_28 = (raw: RawClient["server.session"]) => (input: Endpoint5_28Input) => const Endpoint5_26 = (raw: RawClient["server.session"]) => (input: Endpoint5_26Input) =>
preserveStream<Endpoint5_28Output>()( preserveStream<Endpoint5_26Output>()(
Stream.unwrap( Stream.unwrap(
raw["session.log"]({ raw["session.log"]({
params: { sessionID: input["sessionID"] }, params: { sessionID: input["sessionID"] },
@@ -565,18 +543,18 @@ const Endpoint5_28 = (raw: RawClient["server.session"]) => (input: Endpoint5_28I
), ),
) )
const Endpoint5_29 = (raw: RawClient["server.session"]) => (input: Endpoint5_29Input) => const Endpoint5_27 = (raw: RawClient["server.session"]) => (input: Endpoint5_27Input) =>
preserveEffect<Endpoint5_29Output>()( preserveEffect<Endpoint5_27Output>()(
raw["session.interrupt"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)), raw["session.interrupt"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)),
) )
const Endpoint5_30 = (raw: RawClient["server.session"]) => (input: Endpoint5_30Input) => const Endpoint5_28 = (raw: RawClient["server.session"]) => (input: Endpoint5_28Input) =>
preserveEffect<Endpoint5_30Output>()( preserveEffect<Endpoint5_28Output>()(
raw["session.background"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)), raw["session.background"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)),
) )
const Endpoint5_31 = (raw: RawClient["server.session"]) => (input: Endpoint5_31Input) => const Endpoint5_29 = (raw: RawClient["server.session"]) => (input: Endpoint5_29Input) =>
preserveEffect<Endpoint5_31Output>()( preserveEffect<Endpoint5_29Output>()(
raw["session.message"]({ params: { sessionID: input["sessionID"], messageID: input["messageID"] } }).pipe( raw["session.message"]({ params: { sessionID: input["sessionID"], messageID: input["messageID"] } }).pipe(
Effect.mapError(mapClientError), Effect.mapError(mapClientError),
Effect.map((value) => value.data), Effect.map((value) => value.data),
@@ -586,32 +564,30 @@ const Endpoint5_31 = (raw: RawClient["server.session"]) => (input: Endpoint5_31I
const adaptGroup5 = (raw: RawClient["server.session"]) => ({ const adaptGroup5 = (raw: RawClient["server.session"]) => ({
list: Endpoint5_0(raw), list: Endpoint5_0(raw),
create: Endpoint5_1(raw), create: Endpoint5_1(raw),
import: Endpoint5_2(raw), active: Endpoint5_2(raw),
export: Endpoint5_3(raw), get: Endpoint5_3(raw),
active: Endpoint5_4(raw), remove: Endpoint5_4(raw),
get: Endpoint5_5(raw), fork: Endpoint5_5(raw),
remove: Endpoint5_6(raw), switchAgent: Endpoint5_6(raw),
fork: Endpoint5_7(raw), switchModel: Endpoint5_7(raw),
switchAgent: Endpoint5_8(raw), rename: Endpoint5_8(raw),
switchModel: Endpoint5_9(raw), move: Endpoint5_9(raw),
rename: Endpoint5_10(raw), prompt: Endpoint5_10(raw),
move: Endpoint5_11(raw), command: Endpoint5_11(raw),
prompt: Endpoint5_12(raw), skill: Endpoint5_12(raw),
command: Endpoint5_13(raw), synthetic: Endpoint5_13(raw),
skill: Endpoint5_14(raw), shell: Endpoint5_14(raw),
synthetic: Endpoint5_15(raw), compact: Endpoint5_15(raw),
shell: Endpoint5_16(raw), wait: Endpoint5_16(raw),
compact: Endpoint5_17(raw), revert: { stage: Endpoint5_17(raw), clear: Endpoint5_18(raw), commit: Endpoint5_19(raw) },
wait: Endpoint5_18(raw), context: Endpoint5_20(raw),
revert: { stage: Endpoint5_19(raw), clear: Endpoint5_20(raw), commit: Endpoint5_21(raw) }, pending: { list: Endpoint5_21(raw) },
context: Endpoint5_22(raw), instructions: { entry: { list: Endpoint5_22(raw), put: Endpoint5_23(raw), remove: Endpoint5_24(raw) } },
pending: { list: Endpoint5_23(raw) }, generate: Endpoint5_25(raw),
instructions: { entry: { list: Endpoint5_24(raw), put: Endpoint5_25(raw), remove: Endpoint5_26(raw) } }, log: Endpoint5_26(raw),
generate: Endpoint5_27(raw), interrupt: Endpoint5_27(raw),
log: Endpoint5_28(raw), background: Endpoint5_28(raw),
interrupt: Endpoint5_29(raw), message: Endpoint5_29(raw),
background: Endpoint5_30(raw),
message: Endpoint5_31(raw),
}) })
const Endpoint6_0 = (raw: RawClient["server.message"]) => (input: Endpoint6_0Input) => const Endpoint6_0 = (raw: RawClient["server.message"]) => (input: Endpoint6_0Input) =>
@@ -15,10 +15,6 @@ import type {
SessionListOutput, SessionListOutput,
SessionCreateInput, SessionCreateInput,
SessionCreateOutput, SessionCreateOutput,
SessionImportInput,
SessionImportOutput,
SessionExportInput,
SessionExportOutput,
SessionActiveOutput, SessionActiveOutput,
SessionGetInput, SessionGetInput,
SessionGetOutput, SessionGetOutput,
@@ -464,17 +460,17 @@ export function make(options: ClientOptions) {
}, },
requestOptions, requestOptions,
), ),
create: (input: SessionCreateInput, requestOptions?: RequestOptions) => create: (input?: SessionCreateInput, requestOptions?: RequestOptions) =>
request<{ readonly data: SessionCreateOutput }>( request<{ readonly data: SessionCreateOutput }>(
{ {
method: "POST", method: "POST",
path: `/api/session`, path: `/api/session`,
body: { body: {
id: input["id"], id: input?.["id"],
title: input["title"], title: input?.["title"],
agent: input["agent"], agent: input?.["agent"],
model: input["model"], model: input?.["model"],
location: input["location"], location: input?.["location"],
}, },
successStatus: 200, successStatus: 200,
declaredStatuses: [401, 400], declaredStatuses: [401, 400],
@@ -482,30 +478,6 @@ export function make(options: ClientOptions) {
}, },
requestOptions, requestOptions,
).then((value) => value.data), ).then((value) => value.data),
import: (input: SessionImportInput, requestOptions?: RequestOptions) =>
request<{ readonly data: SessionImportOutput }>(
{
method: "POST",
path: `/api/session/import`,
body: { info: input["info"], messages: input["messages"], location: input["location"] },
successStatus: 200,
declaredStatuses: [409, 401, 400],
empty: false,
},
requestOptions,
).then((value) => value.data),
export: (input: SessionExportInput, requestOptions?: RequestOptions) =>
request<{ readonly data: SessionExportOutput }>(
{
method: "GET",
path: `/api/session/${encodeURIComponent(input.sessionID)}/export`,
query: { sanitize: input["sanitize"] },
successStatus: 200,
declaredStatuses: [404, 500, 401, 400],
empty: false,
},
requestOptions,
).then((value) => value.data),
active: (requestOptions?: RequestOptions) => active: (requestOptions?: RequestOptions) =>
request<{ readonly data: SessionActiveOutput }>( request<{ readonly data: SessionActiveOutput }>(
{ {
+57 -812
View File
@@ -35,6 +35,18 @@ export type FileDiffInfo = {
status: "added" | "deleted" | "modified" status: "added" | "deleted" | "modified"
} }
export type SessionActive = { type: "running" }
export type PromptBase64 = string
export type PromptFileSource = { type: "inline" } | { type: "uri"; uri: string }
export type PromptMention = { start: number; end: number; text: string }
export type SessionPendingSyntheticData = { text: string; description?: string; metadata?: { [x: string]: JsonValue } }
export type SessionPendingCompaction = { id: string; sessionID: string; timeCreated: number; type: "compaction" }
export type SessionMessageAgentSelected = { export type SessionMessageAgentSelected = {
id: string id: string
metadata?: { [x: string]: JsonValue } metadata?: { [x: string]: JsonValue }
@@ -43,12 +55,6 @@ export type SessionMessageAgentSelected = {
agent: string agent: string
} }
export type PromptBase64 = string
export type PromptFileSource = { type: "inline" } | { type: "uri"; uri: string }
export type PromptMention = { start: number; end: number; text: string }
export type SessionMessageSynthetic = { export type SessionMessageSynthetic = {
id: string id: string
metadata?: { [x: string]: JsonValue } metadata?: { [x: string]: JsonValue }
@@ -126,12 +132,6 @@ export type SessionMessageCompactionCompleted = {
recent: string recent: string
} }
export type SessionActive = { type: "running" }
export type SessionPendingSyntheticData = { text: string; description?: string; metadata?: { [x: string]: JsonValue } }
export type SessionPendingCompaction = { id: string; sessionID: string; timeCreated: number; type: "compaction" }
export type InstructionEntryKey = string export type InstructionEntryKey = string
export type SessionGenerateResponse = { data: { text: string } } export type SessionGenerateResponse = { data: { text: string } }
@@ -169,8 +169,6 @@ export type EventLogSynced = { type: "log.synced"; aggregateID: string; seq?: nu
export type ModelReasoningField = "reasoning" | "reasoning_content" | "reasoning_text" | (string & {}) export type ModelReasoningField = "reasoning" | "reasoning_content" | "reasoning_text" | (string & {})
export type ModelMaxTokensField = "max_completion_tokens" | "max_tokens"
export type ModelCapabilities = { tools: boolean; input: Array<string>; output: Array<string> } export type ModelCapabilities = { tools: boolean; input: Array<string>; output: Array<string> }
export type ModelVariant = { export type ModelVariant = {
@@ -1050,6 +1048,15 @@ export type PromptFileAttachment = {
export type PromptAgentAttachment = { name: string; mention?: PromptMention } export type PromptAgentAttachment = { name: string; mention?: PromptMention }
export type SessionPendingSynthetic = {
id: string
sessionID: string
timeCreated: number
type: "synthetic"
data: SessionPendingSyntheticData
delivery: "steer" | "queue"
}
export type SessionMessageAssistantText = { type: "text"; text: string; state?: SessionMessageProviderState } export type SessionMessageAssistantText = { type: "text"; text: string; state?: SessionMessageProviderState }
export type SessionMessageAssistantReasoning = { export type SessionMessageAssistantReasoning = {
@@ -1121,15 +1128,6 @@ export type SessionCompactionFailed = {
data: { sessionID: string; reason: "auto" | "manual"; error: SessionStructuredError; inputID?: string } data: { sessionID: string; reason: "auto" | "manual"; error: SessionStructuredError; inputID?: string }
} }
export type SessionPendingSynthetic = {
id: string
sessionID: string
timeCreated: number
type: "synthetic"
data: SessionPendingSyntheticData
delivery: "steer" | "queue"
}
export type InstructionEntryInfo = { key: InstructionEntryKey; value: JsonValue } export type InstructionEntryInfo = { key: InstructionEntryKey; value: JsonValue }
export type SessionPendingSyntheticMessage = { export type SessionPendingSyntheticMessage = {
@@ -1232,11 +1230,7 @@ export type SessionToolCalled = {
export type ToolContent1 = ToolTextContent | ToolFileContent1 export type ToolContent1 = ToolTextContent | ToolFileContent1
export type ModelCompatibility = { export type ModelCompatibility = { reasoningField?: ModelReasoningField }
reasoningField?: ModelReasoningField
maxTokensField?: ModelMaxTokensField
requireFinishReason?: boolean
}
export type ModelCost = { export type ModelCost = {
tier?: { type: "context"; size: number } tier?: { type: "context"; size: number }
@@ -1528,6 +1522,13 @@ export type SessionRevertStaged = {
data: { sessionID: string; revert: SessionRevert } data: { sessionID: string; revert: SessionRevert }
} }
export type SessionPendingUserData = {
text: string
files?: Array<PromptFileAttachment>
agents?: Array<PromptAgentAttachment>
metadata?: { [x: string]: JsonValue }
}
export type SessionMessageUser = { export type SessionMessageUser = {
id: string id: string
metadata?: { [x: string]: JsonValue } metadata?: { [x: string]: JsonValue }
@@ -1538,13 +1539,6 @@ export type SessionMessageUser = {
type: "user" type: "user"
} }
export type SessionPendingUserData = {
text: string
files?: Array<PromptFileAttachment>
agents?: Array<PromptAgentAttachment>
metadata?: { [x: string]: JsonValue }
}
export type SessionPendingUserData1 = { export type SessionPendingUserData1 = {
text: string text: string
files?: Array<PromptFileAttachment> files?: Array<PromptFileAttachment>
@@ -2001,8 +1995,6 @@ export type SessionEventDurable =
| SessionRevertCommitted | SessionRevertCommitted
| SessionUsageRecorded | SessionUsageRecorded
export type SessionTransferData = { info: SessionInfo; messages: Array<SessionMessageInfo> }
export type SessionMessagesResponse = { export type SessionMessagesResponse = {
data: Array<SessionMessageInfo> data: Array<SessionMessageInfo>
cursor: { previous?: string | null; next?: string | null } cursor: { previous?: string | null; next?: string | null }
@@ -2122,14 +2114,6 @@ export type InvalidCursorError = { readonly _tag: "InvalidCursorError"; readonly
export const isInvalidCursorError = (value: unknown): value is InvalidCursorError => export const isInvalidCursorError = (value: unknown): value is InvalidCursorError =>
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "InvalidCursorError" typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "InvalidCursorError"
export type ConflictError = {
readonly _tag: "ConflictError"
readonly message: string
readonly resource?: string | undefined
}
export const isConflictError = (value: unknown): value is ConflictError =>
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "ConflictError"
export type SessionNotFoundError = { export type SessionNotFoundError = {
readonly _tag: "SessionNotFoundError" readonly _tag: "SessionNotFoundError"
readonly sessionID: string readonly sessionID: string
@@ -2138,14 +2122,6 @@ export type SessionNotFoundError = {
export const isSessionNotFoundError = (value: unknown): value is SessionNotFoundError => export const isSessionNotFoundError = (value: unknown): value is SessionNotFoundError =>
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "SessionNotFoundError" typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "SessionNotFoundError"
export type UnknownError = {
readonly _tag: "UnknownError"
readonly message: string
readonly ref?: string | undefined
}
export const isUnknownError = (value: unknown): value is UnknownError =>
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "UnknownError"
export type MessageNotFoundError = { export type MessageNotFoundError = {
readonly _tag: "MessageNotFoundError" readonly _tag: "MessageNotFoundError"
readonly sessionID: string readonly sessionID: string
@@ -2155,6 +2131,14 @@ export type MessageNotFoundError = {
export const isMessageNotFoundError = (value: unknown): value is MessageNotFoundError => export const isMessageNotFoundError = (value: unknown): value is MessageNotFoundError =>
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "MessageNotFoundError" typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "MessageNotFoundError"
export type ConflictError = {
readonly _tag: "ConflictError"
readonly message: string
readonly resource?: string | undefined
}
export const isConflictError = (value: unknown): value is ConflictError =>
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "ConflictError"
export type CommandNotFoundError = { export type CommandNotFoundError = {
readonly _tag: "CommandNotFoundError" readonly _tag: "CommandNotFoundError"
readonly command: string readonly command: string
@@ -2195,6 +2179,14 @@ export type SessionBusyError = {
export const isSessionBusyError = (value: unknown): value is SessionBusyError => export const isSessionBusyError = (value: unknown): value is SessionBusyError =>
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "SessionBusyError" typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "SessionBusyError"
export type UnknownError = {
readonly _tag: "UnknownError"
readonly message: string
readonly ref?: string | undefined
}
export const isUnknownError = (value: unknown): value is UnknownError =>
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "UnknownError"
export type InstructionEntryValueTooLargeError = { export type InstructionEntryValueTooLargeError = {
readonly _tag: "InstructionEntryValueTooLargeError" readonly _tag: "InstructionEntryValueTooLargeError"
readonly actualBytes: number readonly actualBytes: number
@@ -2436,789 +2428,42 @@ export type SessionCreateInput = {
readonly id?: { readonly id?: {
readonly id?: string | null readonly id?: string | null
readonly title?: string | null readonly title?: string | null
readonly agent: string readonly agent?: string | null
readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string } readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null
readonly location?: { readonly directory: string; readonly workspaceID?: string } | null readonly location?: { readonly directory: string; readonly workspaceID?: string } | null
}["id"] }["id"]
readonly title?: { readonly title?: {
readonly id?: string | null readonly id?: string | null
readonly title?: string | null readonly title?: string | null
readonly agent: string readonly agent?: string | null
readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string } readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null
readonly location?: { readonly directory: string; readonly workspaceID?: string } | null readonly location?: { readonly directory: string; readonly workspaceID?: string } | null
}["title"] }["title"]
readonly agent: { readonly agent?: {
readonly id?: string | null readonly id?: string | null
readonly title?: string | null readonly title?: string | null
readonly agent: string readonly agent?: string | null
readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string } readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null
readonly location?: { readonly directory: string; readonly workspaceID?: string } | null readonly location?: { readonly directory: string; readonly workspaceID?: string } | null
}["agent"] }["agent"]
readonly model: { readonly model?: {
readonly id?: string | null readonly id?: string | null
readonly title?: string | null readonly title?: string | null
readonly agent: string readonly agent?: string | null
readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string } readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null
readonly location?: { readonly directory: string; readonly workspaceID?: string } | null readonly location?: { readonly directory: string; readonly workspaceID?: string } | null
}["model"] }["model"]
readonly location?: { readonly location?: {
readonly id?: string | null readonly id?: string | null
readonly title?: string | null readonly title?: string | null
readonly agent: string readonly agent?: string | null
readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string } readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null
readonly location?: { readonly directory: string; readonly workspaceID?: string } | null readonly location?: { readonly directory: string; readonly workspaceID?: string } | null
}["location"] }["location"]
} }
export type SessionCreateOutput = { data: SessionInfo }["data"] export type SessionCreateOutput = { data: SessionInfo }["data"]
export type SessionImportInput = {
readonly info: {
readonly info: {
readonly id: string
readonly parentID?: string
readonly fork?: {
readonly sessionID: string
readonly boundary:
| { readonly type: "before"; readonly messageID: string }
| { readonly type: "through"; readonly messageID: string }
}
readonly projectID: string
readonly agent?: string
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string }
readonly cost: number
readonly tokens: {
readonly input: number
readonly output: number
readonly reasoning: number
readonly cache: { readonly read: number; readonly write: number }
}
readonly time: { readonly created: number; readonly updated: number; readonly archived?: number }
readonly title?: string
readonly location: { readonly directory: string; readonly workspaceID?: string }
readonly subpath?: string
readonly revert?: {
readonly messageID: string
readonly partID?: string
readonly snapshot?: string
readonly files?: ReadonlyArray<{
readonly file: string
readonly patch: string
readonly additions: number
readonly deletions: number
readonly status: "added" | "deleted" | "modified"
}>
}
}
readonly messages: ReadonlyArray<
| {
readonly id: string
readonly metadata?: { readonly [x: string]: JsonValue }
readonly time: { readonly created: number }
readonly type: "agent-switched"
readonly agent: string
}
| {
readonly id: string
readonly metadata?: { readonly [x: string]: JsonValue }
readonly time: { readonly created: number }
readonly type: "model-switched"
readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string }
readonly previous?: { readonly id: string; readonly providerID: string; readonly variant?: string }
}
| {
readonly id: string
readonly metadata?: { readonly [x: string]: JsonValue }
readonly time: { readonly created: number }
readonly text: string
readonly files?: ReadonlyArray<{
readonly data: string
readonly mime: string
readonly source: { readonly type: "inline" } | { readonly type: "uri"; readonly uri: string }
readonly name?: string
readonly description?: string
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
}>
readonly agents?: ReadonlyArray<{
readonly name: string
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
}>
readonly type: "user"
}
| {
readonly id: string
readonly metadata?: { readonly [x: string]: JsonValue }
readonly time: { readonly created: number }
readonly text: string
readonly description?: string
readonly type: "synthetic"
}
| {
readonly id: string
readonly metadata?: { readonly [x: string]: JsonValue }
readonly time: { readonly created: number }
readonly type: "system"
readonly text: string
}
| {
readonly id: string
readonly metadata?: { readonly [x: string]: JsonValue }
readonly time: { readonly created: number }
readonly type: "skill"
readonly skill: string
readonly name: string
readonly text: string
}
| {
readonly id: string
readonly metadata?: { readonly [x: string]: JsonValue }
readonly time: { readonly created: number; readonly completed?: number }
readonly type: "shell"
readonly shellID: string
readonly command: string
readonly status: "running" | "exited" | "timeout" | "killed"
readonly exit?: number | "Infinity" | "-Infinity" | "NaN"
readonly output?: {
readonly output: string
readonly cursor: number
readonly size: number
readonly truncated: boolean
}
}
| {
readonly id: string
readonly metadata?: { readonly [x: string]: JsonValue }
readonly time: { readonly created: number; readonly completed?: number }
readonly type: "assistant"
readonly agent: string
readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string }
readonly content: ReadonlyArray<
| { readonly type: "text"; readonly text: string; readonly state?: { readonly [x: string]: JsonValue } }
| {
readonly type: "reasoning"
readonly text: string
readonly state?: { readonly [x: string]: JsonValue }
readonly time?: { readonly created: number; readonly completed?: number }
}
| {
readonly type: "tool"
readonly id: string
readonly name: string
readonly executed?: boolean
readonly providerState?: { readonly [x: string]: JsonValue }
readonly providerResultState?: { readonly [x: string]: JsonValue }
readonly state:
| { readonly status: "streaming"; readonly input: string }
| {
readonly status: "running"
readonly input: { readonly [x: string]: JsonValue }
readonly metadata: { readonly [x: string]: JsonValue }
}
| {
readonly status: "completed"
readonly input: { readonly [x: string]: JsonValue }
readonly content: readonly [
(
| { readonly type: "text"; readonly text: string }
| {
readonly type: "file"
readonly uri: string
readonly mime: string
readonly name?: string | null
}
),
...Array<
| { readonly type: "text"; readonly text: string }
| {
readonly type: "file"
readonly uri: string
readonly mime: string
readonly name?: string | null
}
>,
]
readonly metadata?: { readonly [x: string]: JsonValue }
}
| {
readonly status: "error"
readonly input: { readonly [x: string]: JsonValue }
readonly error: { readonly type: string; readonly message: string; readonly status?: number }
readonly content?: readonly [
(
| { readonly type: "text"; readonly text: string }
| {
readonly type: "file"
readonly uri: string
readonly mime: string
readonly name?: string | null
}
),
...Array<
| { readonly type: "text"; readonly text: string }
| {
readonly type: "file"
readonly uri: string
readonly mime: string
readonly name?: string | null
}
>,
]
readonly metadata?: { readonly [x: string]: JsonValue }
}
readonly time: { readonly created: number; readonly ran?: number; readonly completed?: number }
}
>
readonly snapshot?: { readonly start?: string; readonly end?: string; readonly files?: ReadonlyArray<string> }
readonly finish?: "stop" | "length" | "tool-calls" | "content-filter" | "error" | "unknown"
readonly cost?: number
readonly tokens?: {
readonly input: number
readonly output: number
readonly reasoning: number
readonly cache: { readonly read: number; readonly write: number }
}
readonly error?: { readonly type: string; readonly message: string; readonly status?: number }
readonly retry?: {
readonly attempt: number
readonly at: number
readonly error: { readonly type: string; readonly message: string; readonly status?: number }
}
}
| (
| {
readonly type: "compaction"
readonly id: string
readonly metadata?: { readonly [x: string]: JsonValue }
readonly time: { readonly created: number }
readonly status: "running"
readonly reason: "auto" | "manual"
readonly summary: string
readonly recent: string
}
| {
readonly type: "compaction"
readonly id: string
readonly metadata?: { readonly [x: string]: JsonValue }
readonly time: { readonly created: number }
readonly status: "completed"
readonly reason: "auto" | "manual"
readonly summary: string
readonly recent: string
}
| {
readonly type: "compaction"
readonly id: string
readonly metadata?: { readonly [x: string]: JsonValue }
readonly time: { readonly created: number }
readonly status: "failed"
readonly reason: "auto" | "manual"
readonly error: { readonly type: string; readonly message: string; readonly status?: number }
}
)
>
readonly location?: { readonly directory: string; readonly workspaceID?: string } | null
}["info"]
readonly messages: {
readonly info: {
readonly id: string
readonly parentID?: string
readonly fork?: {
readonly sessionID: string
readonly boundary:
| { readonly type: "before"; readonly messageID: string }
| { readonly type: "through"; readonly messageID: string }
}
readonly projectID: string
readonly agent?: string
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string }
readonly cost: number
readonly tokens: {
readonly input: number
readonly output: number
readonly reasoning: number
readonly cache: { readonly read: number; readonly write: number }
}
readonly time: { readonly created: number; readonly updated: number; readonly archived?: number }
readonly title?: string
readonly location: { readonly directory: string; readonly workspaceID?: string }
readonly subpath?: string
readonly revert?: {
readonly messageID: string
readonly partID?: string
readonly snapshot?: string
readonly files?: ReadonlyArray<{
readonly file: string
readonly patch: string
readonly additions: number
readonly deletions: number
readonly status: "added" | "deleted" | "modified"
}>
}
}
readonly messages: ReadonlyArray<
| {
readonly id: string
readonly metadata?: { readonly [x: string]: JsonValue }
readonly time: { readonly created: number }
readonly type: "agent-switched"
readonly agent: string
}
| {
readonly id: string
readonly metadata?: { readonly [x: string]: JsonValue }
readonly time: { readonly created: number }
readonly type: "model-switched"
readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string }
readonly previous?: { readonly id: string; readonly providerID: string; readonly variant?: string }
}
| {
readonly id: string
readonly metadata?: { readonly [x: string]: JsonValue }
readonly time: { readonly created: number }
readonly text: string
readonly files?: ReadonlyArray<{
readonly data: string
readonly mime: string
readonly source: { readonly type: "inline" } | { readonly type: "uri"; readonly uri: string }
readonly name?: string
readonly description?: string
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
}>
readonly agents?: ReadonlyArray<{
readonly name: string
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
}>
readonly type: "user"
}
| {
readonly id: string
readonly metadata?: { readonly [x: string]: JsonValue }
readonly time: { readonly created: number }
readonly text: string
readonly description?: string
readonly type: "synthetic"
}
| {
readonly id: string
readonly metadata?: { readonly [x: string]: JsonValue }
readonly time: { readonly created: number }
readonly type: "system"
readonly text: string
}
| {
readonly id: string
readonly metadata?: { readonly [x: string]: JsonValue }
readonly time: { readonly created: number }
readonly type: "skill"
readonly skill: string
readonly name: string
readonly text: string
}
| {
readonly id: string
readonly metadata?: { readonly [x: string]: JsonValue }
readonly time: { readonly created: number; readonly completed?: number }
readonly type: "shell"
readonly shellID: string
readonly command: string
readonly status: "running" | "exited" | "timeout" | "killed"
readonly exit?: number | "Infinity" | "-Infinity" | "NaN"
readonly output?: {
readonly output: string
readonly cursor: number
readonly size: number
readonly truncated: boolean
}
}
| {
readonly id: string
readonly metadata?: { readonly [x: string]: JsonValue }
readonly time: { readonly created: number; readonly completed?: number }
readonly type: "assistant"
readonly agent: string
readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string }
readonly content: ReadonlyArray<
| { readonly type: "text"; readonly text: string; readonly state?: { readonly [x: string]: JsonValue } }
| {
readonly type: "reasoning"
readonly text: string
readonly state?: { readonly [x: string]: JsonValue }
readonly time?: { readonly created: number; readonly completed?: number }
}
| {
readonly type: "tool"
readonly id: string
readonly name: string
readonly executed?: boolean
readonly providerState?: { readonly [x: string]: JsonValue }
readonly providerResultState?: { readonly [x: string]: JsonValue }
readonly state:
| { readonly status: "streaming"; readonly input: string }
| {
readonly status: "running"
readonly input: { readonly [x: string]: JsonValue }
readonly metadata: { readonly [x: string]: JsonValue }
}
| {
readonly status: "completed"
readonly input: { readonly [x: string]: JsonValue }
readonly content: readonly [
(
| { readonly type: "text"; readonly text: string }
| {
readonly type: "file"
readonly uri: string
readonly mime: string
readonly name?: string | null
}
),
...Array<
| { readonly type: "text"; readonly text: string }
| {
readonly type: "file"
readonly uri: string
readonly mime: string
readonly name?: string | null
}
>,
]
readonly metadata?: { readonly [x: string]: JsonValue }
}
| {
readonly status: "error"
readonly input: { readonly [x: string]: JsonValue }
readonly error: { readonly type: string; readonly message: string; readonly status?: number }
readonly content?: readonly [
(
| { readonly type: "text"; readonly text: string }
| {
readonly type: "file"
readonly uri: string
readonly mime: string
readonly name?: string | null
}
),
...Array<
| { readonly type: "text"; readonly text: string }
| {
readonly type: "file"
readonly uri: string
readonly mime: string
readonly name?: string | null
}
>,
]
readonly metadata?: { readonly [x: string]: JsonValue }
}
readonly time: { readonly created: number; readonly ran?: number; readonly completed?: number }
}
>
readonly snapshot?: { readonly start?: string; readonly end?: string; readonly files?: ReadonlyArray<string> }
readonly finish?: "stop" | "length" | "tool-calls" | "content-filter" | "error" | "unknown"
readonly cost?: number
readonly tokens?: {
readonly input: number
readonly output: number
readonly reasoning: number
readonly cache: { readonly read: number; readonly write: number }
}
readonly error?: { readonly type: string; readonly message: string; readonly status?: number }
readonly retry?: {
readonly attempt: number
readonly at: number
readonly error: { readonly type: string; readonly message: string; readonly status?: number }
}
}
| (
| {
readonly type: "compaction"
readonly id: string
readonly metadata?: { readonly [x: string]: JsonValue }
readonly time: { readonly created: number }
readonly status: "running"
readonly reason: "auto" | "manual"
readonly summary: string
readonly recent: string
}
| {
readonly type: "compaction"
readonly id: string
readonly metadata?: { readonly [x: string]: JsonValue }
readonly time: { readonly created: number }
readonly status: "completed"
readonly reason: "auto" | "manual"
readonly summary: string
readonly recent: string
}
| {
readonly type: "compaction"
readonly id: string
readonly metadata?: { readonly [x: string]: JsonValue }
readonly time: { readonly created: number }
readonly status: "failed"
readonly reason: "auto" | "manual"
readonly error: { readonly type: string; readonly message: string; readonly status?: number }
}
)
>
readonly location?: { readonly directory: string; readonly workspaceID?: string } | null
}["messages"]
readonly location?: {
readonly info: {
readonly id: string
readonly parentID?: string
readonly fork?: {
readonly sessionID: string
readonly boundary:
| { readonly type: "before"; readonly messageID: string }
| { readonly type: "through"; readonly messageID: string }
}
readonly projectID: string
readonly agent?: string
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string }
readonly cost: number
readonly tokens: {
readonly input: number
readonly output: number
readonly reasoning: number
readonly cache: { readonly read: number; readonly write: number }
}
readonly time: { readonly created: number; readonly updated: number; readonly archived?: number }
readonly title?: string
readonly location: { readonly directory: string; readonly workspaceID?: string }
readonly subpath?: string
readonly revert?: {
readonly messageID: string
readonly partID?: string
readonly snapshot?: string
readonly files?: ReadonlyArray<{
readonly file: string
readonly patch: string
readonly additions: number
readonly deletions: number
readonly status: "added" | "deleted" | "modified"
}>
}
}
readonly messages: ReadonlyArray<
| {
readonly id: string
readonly metadata?: { readonly [x: string]: JsonValue }
readonly time: { readonly created: number }
readonly type: "agent-switched"
readonly agent: string
}
| {
readonly id: string
readonly metadata?: { readonly [x: string]: JsonValue }
readonly time: { readonly created: number }
readonly type: "model-switched"
readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string }
readonly previous?: { readonly id: string; readonly providerID: string; readonly variant?: string }
}
| {
readonly id: string
readonly metadata?: { readonly [x: string]: JsonValue }
readonly time: { readonly created: number }
readonly text: string
readonly files?: ReadonlyArray<{
readonly data: string
readonly mime: string
readonly source: { readonly type: "inline" } | { readonly type: "uri"; readonly uri: string }
readonly name?: string
readonly description?: string
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
}>
readonly agents?: ReadonlyArray<{
readonly name: string
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
}>
readonly type: "user"
}
| {
readonly id: string
readonly metadata?: { readonly [x: string]: JsonValue }
readonly time: { readonly created: number }
readonly text: string
readonly description?: string
readonly type: "synthetic"
}
| {
readonly id: string
readonly metadata?: { readonly [x: string]: JsonValue }
readonly time: { readonly created: number }
readonly type: "system"
readonly text: string
}
| {
readonly id: string
readonly metadata?: { readonly [x: string]: JsonValue }
readonly time: { readonly created: number }
readonly type: "skill"
readonly skill: string
readonly name: string
readonly text: string
}
| {
readonly id: string
readonly metadata?: { readonly [x: string]: JsonValue }
readonly time: { readonly created: number; readonly completed?: number }
readonly type: "shell"
readonly shellID: string
readonly command: string
readonly status: "running" | "exited" | "timeout" | "killed"
readonly exit?: number | "Infinity" | "-Infinity" | "NaN"
readonly output?: {
readonly output: string
readonly cursor: number
readonly size: number
readonly truncated: boolean
}
}
| {
readonly id: string
readonly metadata?: { readonly [x: string]: JsonValue }
readonly time: { readonly created: number; readonly completed?: number }
readonly type: "assistant"
readonly agent: string
readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string }
readonly content: ReadonlyArray<
| { readonly type: "text"; readonly text: string; readonly state?: { readonly [x: string]: JsonValue } }
| {
readonly type: "reasoning"
readonly text: string
readonly state?: { readonly [x: string]: JsonValue }
readonly time?: { readonly created: number; readonly completed?: number }
}
| {
readonly type: "tool"
readonly id: string
readonly name: string
readonly executed?: boolean
readonly providerState?: { readonly [x: string]: JsonValue }
readonly providerResultState?: { readonly [x: string]: JsonValue }
readonly state:
| { readonly status: "streaming"; readonly input: string }
| {
readonly status: "running"
readonly input: { readonly [x: string]: JsonValue }
readonly metadata: { readonly [x: string]: JsonValue }
}
| {
readonly status: "completed"
readonly input: { readonly [x: string]: JsonValue }
readonly content: readonly [
(
| { readonly type: "text"; readonly text: string }
| {
readonly type: "file"
readonly uri: string
readonly mime: string
readonly name?: string | null
}
),
...Array<
| { readonly type: "text"; readonly text: string }
| {
readonly type: "file"
readonly uri: string
readonly mime: string
readonly name?: string | null
}
>,
]
readonly metadata?: { readonly [x: string]: JsonValue }
}
| {
readonly status: "error"
readonly input: { readonly [x: string]: JsonValue }
readonly error: { readonly type: string; readonly message: string; readonly status?: number }
readonly content?: readonly [
(
| { readonly type: "text"; readonly text: string }
| {
readonly type: "file"
readonly uri: string
readonly mime: string
readonly name?: string | null
}
),
...Array<
| { readonly type: "text"; readonly text: string }
| {
readonly type: "file"
readonly uri: string
readonly mime: string
readonly name?: string | null
}
>,
]
readonly metadata?: { readonly [x: string]: JsonValue }
}
readonly time: { readonly created: number; readonly ran?: number; readonly completed?: number }
}
>
readonly snapshot?: { readonly start?: string; readonly end?: string; readonly files?: ReadonlyArray<string> }
readonly finish?: "stop" | "length" | "tool-calls" | "content-filter" | "error" | "unknown"
readonly cost?: number
readonly tokens?: {
readonly input: number
readonly output: number
readonly reasoning: number
readonly cache: { readonly read: number; readonly write: number }
}
readonly error?: { readonly type: string; readonly message: string; readonly status?: number }
readonly retry?: {
readonly attempt: number
readonly at: number
readonly error: { readonly type: string; readonly message: string; readonly status?: number }
}
}
| (
| {
readonly type: "compaction"
readonly id: string
readonly metadata?: { readonly [x: string]: JsonValue }
readonly time: { readonly created: number }
readonly status: "running"
readonly reason: "auto" | "manual"
readonly summary: string
readonly recent: string
}
| {
readonly type: "compaction"
readonly id: string
readonly metadata?: { readonly [x: string]: JsonValue }
readonly time: { readonly created: number }
readonly status: "completed"
readonly reason: "auto" | "manual"
readonly summary: string
readonly recent: string
}
| {
readonly type: "compaction"
readonly id: string
readonly metadata?: { readonly [x: string]: JsonValue }
readonly time: { readonly created: number }
readonly status: "failed"
readonly reason: "auto" | "manual"
readonly error: { readonly type: string; readonly message: string; readonly status?: number }
}
)
>
readonly location?: { readonly directory: string; readonly workspaceID?: string } | null
}["location"]
}
export type SessionImportOutput = { data: SessionInfo }["data"]
export type SessionExportInput = {
readonly sessionID: { readonly sessionID: string }["sessionID"]
readonly sanitize?: { readonly sanitize?: boolean | undefined }["sanitize"]
}
export type SessionExportOutput = { data: SessionTransferData }["data"]
export type SessionActiveOutput = { data: { [x: string]: SessionActive } }["data"] export type SessionActiveOutput = { data: { [x: string]: SessionActive } }["data"]
export type SessionGetInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] } export type SessionGetInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] }
-2
View File
@@ -181,8 +181,6 @@ test("session methods retain decoded Effect inputs and outputs", async () => {
const page = yield* client.session.list({ limit: 10 }) const page = yield* client.session.list({ limit: 10 })
const active = yield* client.session.active() const active = yield* client.session.active()
const created = yield* client.session.create({ const created = yield* client.session.create({
agent: Agent.ID.make("build"),
model: Model.Ref.make({ id: "claude", providerID: "anthropic" }),
location: Location.Ref.make({ directory: AbsolutePath.make("/tmp/project") }), location: Location.Ref.make({ directory: AbsolutePath.make("/tmp/project") }),
}) })
yield* client.session.switchAgent({ sessionID: Session.ID.make("ses_test"), agent: Agent.ID.make("build") }) yield* client.session.switchAgent({ sessionID: Session.ID.make("ses_test"), agent: Agent.ID.make("build") })
+2 -6
View File
@@ -454,11 +454,7 @@ test("session methods use the public HTTP contract", async () => {
const page = await client.session.list({ limit: 10, order: "desc", parentID: null }) const page = await client.session.list({ limit: 10, order: "desc", parentID: null })
const active = await client.session.active() const active = await client.session.active()
const created = await client.session.create({ const created = await client.session.create({ location: { directory: "/tmp/project" } })
agent: "build",
model: { id: "claude", providerID: "anthropic" },
location: { directory: "/tmp/project" },
})
await client.session.switchAgent({ sessionID: "ses_test", agent: "build" }) await client.session.switchAgent({ sessionID: "ses_test", agent: "build" })
await client.session.switchModel({ await client.session.switchModel({
sessionID: "ses_test", sessionID: "ses_test",
@@ -532,7 +528,7 @@ test("middleware errors remain declared client errors", async () => {
}) })
try { try {
await client.session.create({ agent: "build", model: { id: "claude", providerID: "anthropic" } }) await client.session.create({})
throw new Error("Expected request to fail") throw new Error("Expected request to fail")
} catch (error) { } catch (error) {
expect(isUnauthorizedError(error)).toBe(true) expect(isUnauthorizedError(error)).toBe(true)
+3 -6
View File
@@ -161,14 +161,11 @@ function isPathAction(action: string): action is PathAction {
} }
function expandHome(resource: string, home: string) { function expandHome(resource: string, home: string) {
if (resource.startsWith("~/")) return home + resource.slice(1)
if (resource === "~") return home if (resource === "~") return home
if (resource === "$HOME") return home if (resource === "$HOME") return home
const relative = resource.startsWith("~/") if (resource.startsWith("$HOME/")) return home + resource.slice(5)
? resource.slice(2) if (resource.startsWith("$HOME\\")) return home + resource.slice(5)
: resource.startsWith("$HOME/") || resource.startsWith("$HOME\\")
? resource.slice(6)
: undefined
if (relative !== undefined) return (path.posix.isAbsolute(home) ? path.posix : path.win32).join(home, relative)
return resource return resource
} }
+5 -5
View File
@@ -340,12 +340,12 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: import("../p
hook: (name, callback) => hooks.register("session", name, callback), hook: (name, callback) => hooks.register("session", name, callback),
create: (input) => create: (input) =>
runtime.session.create({ runtime.session.create({
id: input.id, id: input?.id,
title: input.title, title: input?.title,
agent: input.agent, agent: input?.agent,
model: input.model, model: input?.model,
location: location:
input.location ?? Location.Ref.make({ directory: location.directory, workspaceID: location.workspaceID }), input?.location ?? Location.Ref.make({ directory: location.directory, workspaceID: location.workspaceID }),
}), }),
get: (input) => runtime.session.get(input.sessionID), get: (input) => runtime.session.get(input.sessionID),
prompt: runtime.session.prompt, prompt: runtime.session.prompt,
+19 -15
View File
@@ -269,21 +269,25 @@ export function fromPromise(plugin: Plugin) {
register(host.session.hook(name, (event) => Effect.promise(() => Promise.resolve(callback(event))))), register(host.session.hook(name, (event) => Effect.promise(() => Promise.resolve(callback(event))))),
create: (input) => create: (input) =>
run( run(
host.session.create({ host.session.create(
id: input.id == null ? undefined : Session.ID.make(input.id), input === undefined
agent: Agent.ID.make(input.agent), ? undefined
model: model(input.model), : {
location: id: input.id == null ? undefined : Session.ID.make(input.id),
input.location == null agent: input.agent == null ? undefined : Agent.ID.make(input.agent),
? undefined model: input.model == null ? undefined : model(input.model),
: Location.Ref.make({ location:
directory: AbsolutePath.make(input.location.directory), input.location == null
workspaceID: ? undefined
input.location.workspaceID === undefined : Location.Ref.make({
? undefined directory: AbsolutePath.make(input.location.directory),
: Workspace.ID.make(input.location.workspaceID), workspaceID:
}), input.location.workspaceID === undefined
}), ? undefined
: Workspace.ID.make(input.location.workspaceID),
}),
},
),
), ),
get: (input) => run(host.session.get({ sessionID: Session.ID.make(input.sessionID) })), get: (input) => run(host.session.get({ sessionID: Session.ID.make(input.sessionID) })),
prompt: (input) => prompt: (input) =>
+2 -1
View File
@@ -23,7 +23,7 @@ export interface Interface {
| "synthetic" | "synthetic"
| "wait" | "wait"
> >
readonly job: Pick<Job.Interface, "start" | "wait" | "block" | "background" | "cancel"> readonly job: Pick<Job.Interface, "get" | "start" | "wait" | "block" | "background" | "cancel">
readonly location: { readonly location: {
readonly agent: { readonly agent: {
readonly list: ( readonly list: (
@@ -69,6 +69,7 @@ export const layerWithCell = (cell: Cell) =>
wait: (sessionID) => require(cell, (runtime) => runtime.session.wait(sessionID)), wait: (sessionID) => require(cell, (runtime) => runtime.session.wait(sessionID)),
}, },
job: { job: {
get: (id) => require(cell, (runtime) => runtime.job.get(id)),
start: (input) => require(cell, (runtime) => runtime.job.start(input)), start: (input) => require(cell, (runtime) => runtime.job.start(input)),
wait: (input) => require(cell, (runtime) => runtime.job.wait(input)), wait: (input) => require(cell, (runtime) => runtime.job.wait(input)),
block: (input) => require(cell, (runtime) => runtime.job.block(input)), block: (input) => require(cell, (runtime) => runtime.job.block(input)),
-323
View File
@@ -1,323 +0,0 @@
export * as SessionTransfer from "./transfer"
import { SessionTransfer } from "@opencode-ai/schema/session-transfer"
import { Tool } from "@opencode-ai/schema/tool"
import { eq, isNotNull, isNull, ne, or } from "drizzle-orm"
import { Context, DateTime, Effect, Layer, Schema } from "effect"
import path from "path"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import { App } from "../app"
import { Bus } from "../bus"
import { Database } from "../database/database"
import { Location } from "../location"
import { Project } from "../project"
import { ProjectTable } from "../project/sql"
import { AbsolutePath, RelativePath } from "../schema"
import { Session } from "../session"
import { Slug } from "../util/slug"
import { SessionEvent } from "./event"
import { SessionMessage } from "./message"
import { SessionProjector } from "./projector"
import { SessionMessageTable, SessionTable } from "./sql"
export const Data = SessionTransfer.Data
export type Data = SessionTransfer.Data
export class ImportConflictError extends Schema.TaggedErrorClass<ImportConflictError>()(
"SessionTransfer.ImportConflictError",
{ sessionID: Session.ID },
) {}
export interface Interface {
readonly export: (input: {
sessionID: Session.ID
sanitize?: boolean
}) => Effect.Effect<Data, Session.NotFoundError | Session.MessageDecodeError>
readonly import: (input: {
data: Data
location: Location.Ref
}) => Effect.Effect<Session.Info, ImportConflictError>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/SessionTransfer") {}
const layer = Layer.effect(
Service,
Effect.gen(function* () {
const app = yield* App.Metadata
const bus = yield* Bus.Service
const { db } = yield* Database.Service
const projects = yield* Project.Service
const sessions = yield* Session.Service
const encodeMessage = Schema.encodeSync(SessionMessage.Info)
const persistProject = (project: Project.Resolved) => {
const vcs = project.vcs?.type
return db
.insert(ProjectTable)
.values({ id: project.id, worktree: project.canonical, vcs, sandboxes: [] })
.onConflictDoUpdate({
target: ProjectTable.id,
set: { worktree: project.canonical, vcs: vcs ?? null },
setWhere: or(
ne(ProjectTable.worktree, project.canonical),
vcs ? or(isNull(ProjectTable.vcs), ne(ProjectTable.vcs, vcs)) : isNotNull(ProjectTable.vcs),
),
})
.run()
.pipe(Effect.orDie)
}
return Service.of({
export: Effect.fn("SessionTransfer.export")(function* (input) {
const data = {
info: yield* sessions.get(input.sessionID),
messages: yield* sessions.messages({ sessionID: input.sessionID, order: "asc" }),
}
return input.sanitize ? sanitize(data) : data
}),
import: Effect.fn("SessionTransfer.import")(function* (input) {
const sessionID = input.data.info.id
const recorded = yield* db
.select({ id: SessionTable.id })
.from(SessionTable)
.where(eq(SessionTable.id, sessionID))
.get()
.pipe(Effect.orDie)
if (recorded) return yield* new ImportConflictError({ sessionID })
const project = yield* projects.resolve(input.location.directory)
yield* persistProject(project)
const messages = input.data.messages.map((message, index) => {
const encoded = encodeMessage(message)
const { id: _, type, ...data } = encoded
return {
id: message.id,
session_id: sessionID,
type,
seq: index + 1,
time_created: DateTime.toEpochMillis(message.time.created),
data,
}
})
yield* bus
.publish(
SessionEvent.Created,
{
sessionID,
slug: Slug.create(),
version: app.version,
projectID: project.id,
location: input.location,
subpath: RelativePath.make(path.relative(project.directory, input.location.directory).replaceAll("\\", "/")),
title: input.data.info.title,
agent: input.data.info.agent,
model: input.data.info.model,
},
{
location: input.location,
commit: (seq) =>
Effect.gen(function* () {
if (messages.length > 0) {
yield* db.insert(SessionMessageTable).values(messages).run().pipe(Effect.orDie)
yield* Bus.reserveSequence(db, sessionID, seq + messages.length)
}
yield* db
.update(SessionTable)
.set({
cost: input.data.info.cost,
tokens_input: input.data.info.tokens.input,
tokens_output: input.data.info.tokens.output,
tokens_reasoning: input.data.info.tokens.reasoning,
tokens_cache_read: input.data.info.tokens.cache.read,
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_archived: input.data.info.time.archived
? DateTime.toEpochMillis(input.data.info.time.archived)
: null,
})
.where(eq(SessionTable.id, sessionID))
.run()
.pipe(Effect.orDie)
}),
},
)
.pipe(
Effect.catchDefect((defect) =>
defect instanceof SessionProjector.SessionAlreadyProjected
? Effect.fail(new ImportConflictError({ sessionID }))
: Effect.die(defect),
),
)
return yield* sessions.get(sessionID).pipe(Effect.orDie)
}),
})
}),
)
export const node = makeGlobalNode({
service: Service,
layer,
deps: [App.node, Bus.node, Database.node, Project.node, Session.node],
})
function redact(kind: string, id: string, value: string) {
return value.trim() ? `[redacted:${kind}:${id}]` : value
}
function metadata(kind: string, id: string, value: Readonly<Record<string, unknown>> | undefined) {
if (!value) return value
return Object.keys(value).length > 0 ? { redacted: `${kind}:${id}` } : value
}
function sanitize(data: Data): Data {
return {
info: {
...data.info,
title: data.info.title === undefined ? undefined : redact("session-title", data.info.id, data.info.title),
location: {
...data.info.location,
directory: AbsolutePath.make(`/${redact("session-directory", data.info.id, data.info.location.directory)}`),
},
revert: data.info.revert
? {
...data.info.revert,
files: data.info.revert.files?.map((file, index) => ({
...file,
file: redact("revert-file", String(index), file.file),
patch: redact("revert-patch", String(index), file.patch),
})),
}
: undefined,
},
messages: data.messages.map(sanitizeMessage),
}
}
function sanitizeMessage(message: SessionMessage.Info): SessionMessage.Info {
const meta = metadata("message-metadata", message.id, message.metadata)
if (message.type === "user")
return {
...message,
metadata: meta,
text: redact("text", message.id, message.text),
files: message.files?.map((file, index) => ({
...file,
data: "",
source: { type: "inline" },
name: file.name === undefined ? undefined : redact("file-name", String(index), file.name),
description:
file.description === undefined ? undefined : redact("file-description", String(index), file.description),
mention: file.mention
? { ...file.mention, text: redact("file-mention", String(index), file.mention.text) }
: undefined,
})),
agents: message.agents?.map((agent, index) => ({
...agent,
name: redact("agent-name", String(index), agent.name),
mention: agent.mention
? { ...agent.mention, text: redact("agent-mention", String(index), agent.mention.text) }
: undefined,
})),
}
if (message.type === "synthetic")
return {
...message,
metadata: meta,
text: redact("synthetic", message.id, message.text),
description:
message.description === undefined
? undefined
: redact("synthetic-description", message.id, message.description),
}
if (message.type === "system")
return { ...message, metadata: meta, text: redact("system", message.id, message.text) }
if (message.type === "skill") return { ...message, metadata: meta, text: redact("skill", message.id, message.text) }
if (message.type === "shell")
return {
...message,
metadata: meta,
command: redact("shell-command", message.id, message.command),
output: message.output
? { ...message.output, output: redact("shell-output", message.id, message.output.output) }
: undefined,
}
if (message.type === "assistant")
return {
...message,
metadata: meta,
content: message.content.map((content) => {
if (content.type === "text")
return {
...content,
text: redact("text", message.id, content.text),
state: content.state ? { redacted: `text-state:${message.id}` } : undefined,
}
if (content.type === "reasoning")
return {
...content,
text: redact("reasoning", message.id, content.text),
state: content.state ? { redacted: `reasoning-state:${message.id}` } : undefined,
}
return {
...content,
providerState: content.providerState ? { redacted: `tool-provider-state:${message.id}` } : undefined,
providerResultState: content.providerResultState
? { redacted: `tool-provider-result-state:${message.id}` }
: undefined,
state: sanitizeToolState(message.id, content.state),
}
}),
}
if (message.type === "compaction") {
if (message.status === "failed")
return {
...message,
metadata: meta,
}
return {
...message,
metadata: meta,
summary: redact("compaction-summary", message.id, message.summary),
recent: redact("compaction-recent", message.id, message.recent),
}
}
return { ...message, metadata: meta }
}
function sanitizeToolState(id: string, state: SessionMessage.ToolState): SessionMessage.ToolState {
if (state.status === "streaming") return { ...state, input: redact("tool-input", id, state.input) }
if (state.status === "running")
return { ...state, input: { redacted: `tool-input:${id}` }, metadata: { redacted: `tool-metadata:${id}` } }
const meta = state.metadata === undefined ? undefined : { redacted: `tool-metadata:${id}` }
if (state.status === "completed")
return {
...state,
input: { redacted: `tool-input:${id}` },
content: [
sanitizeToolContent(id, state.content[0]),
...state.content.slice(1).map((item) => sanitizeToolContent(id, item)),
],
metadata: meta,
}
return {
...state,
input: { redacted: `tool-input:${id}` },
content: state.content
? [
sanitizeToolContent(id, state.content[0]),
...state.content.slice(1).map((item) => sanitizeToolContent(id, item)),
]
: undefined,
metadata: meta,
}
}
function sanitizeToolContent(id: string, content: Tool.Content): Tool.Content {
if (content.type === "text") return { ...content, text: redact("tool-output", id, content.text) }
return {
...content,
uri: redact("tool-file-uri", id, content.uri),
name: content.name === undefined ? undefined : redact("tool-file-name", id, content.name),
}
}
+58 -18
View File
@@ -23,6 +23,10 @@ export const Input = Schema.Struct({
agent: Schema.String.annotate({ description: "The type of specialized agent to use for this task" }), agent: Schema.String.annotate({ description: "The type of specialized agent to use for this task" }),
description: Schema.String.annotate({ description: "A short 3-5 word label for the task, displayed to the user" }), description: Schema.String.annotate({ description: "A short 3-5 word label for the task, displayed to the user" }),
prompt: Schema.String.annotate({ description: "The task for the subagent to perform" }), prompt: Schema.String.annotate({ description: "The task for the subagent to perform" }),
sessionID: Schema.optionalKey(SessionSchema.ID).annotate({
description:
"Continue a specific previous subagent conversation by passing its sessionID. Calls without a sessionID start a new conversation.",
}),
background: Schema.optionalKey(Schema.Boolean).annotate({ background: Schema.optionalKey(Schema.Boolean).annotate({
description: description:
"Run the subagent in the background and return immediately. You will be notified when it completes. DO NOT sleep, poll, or proactively check on its progress.", "Run the subagent in the background and return immediately. You will be notified when it completes. DO NOT sleep, poll, or proactively check on its progress.",
@@ -36,7 +40,8 @@ export const Output = Schema.Struct({
}) })
export const description = [ export const description = [
"Spawns an agent in a child session to work on the specified task.", "Spawns an agent in a child session to work on the specified task.",
"Include all relevant context and instructions in the prompt because the child starts with fresh context.", "The output includes a sessionID you can pass back later to continue that specific conversation with the subagent.",
"New child sessions start with fresh context, so include all relevant context and instructions when you don't pass a sessionID.",
"Foreground (default) runs the subagent to completion and returns its final response.", "Foreground (default) runs the subagent to completion and returns its final response.",
"Background mode (background=true) launches it asynchronously and returns immediately; you are notified when it finishes.", "Background mode (background=true) launches it asynchronously and returns immediately; you are notified when it finishes.",
"Use background only for independent work that can run while you continue elsewhere.", "Use background only for independent work that can run while you continue elsewhere.",
@@ -77,7 +82,7 @@ export const Plugin = {
) { ) {
yield* runtime.session.synthetic({ yield* runtime.session.synthetic({
sessionID: parentID, sessionID: parentID,
text: `<subagent id="${childID}" state="${state}" description="${description}">\n${text}\n</subagent>`, text: `<subagent sessionID="${childID}" state="${state}" description="${description}">\n${text}\n</subagent>`,
description, description,
metadata: { source: "subagent", childID, agent, state }, metadata: { source: "subagent", childID, agent, state },
}) })
@@ -164,22 +169,51 @@ export const Plugin = {
}) })
.pipe(Effect.mapError((error) => new ToolFailure({ message: `Subagent denied: ${agent.id}`, error }))) .pipe(Effect.mapError((error) => new ToolFailure({ message: `Subagent denied: ${agent.id}`, error })))
if (input.sessionID !== undefined && input.background === true)
return yield* new ToolFailure({
message: "Continuing a subagent in the background is not implemented yet",
})
const existing =
input.sessionID === undefined
? undefined
: yield* runtime.session.get(input.sessionID).pipe(
Effect.mapError(
(error) =>
new ToolFailure({ message: `Subagent session not found: ${input.sessionID}`, error }),
),
)
if (existing !== undefined && existing.parentID !== context.sessionID)
return yield* new ToolFailure({
message: `Session ${existing.id} is not a child of the current session`,
})
if (existing !== undefined && existing.agent !== agent.id)
return yield* new ToolFailure({
message: `Session ${existing.id} belongs to agent ${existing.agent ?? "unknown"}, not ${agent.id}`,
})
if (existing !== undefined && (yield* runtime.job.get(existing.id))?.status === "running")
return yield* new ToolFailure({
message: "Continuing a running subagent is not implemented yet",
})
// Model selection is policy/config/session state, not an LLM-facing tool argument. // Model selection is policy/config/session state, not an LLM-facing tool argument.
const model = agent.model ?? parent.model const model = agent.model ?? parent.model
const child = yield* runtime.session const child =
.create({ existing ??
parentID: context.sessionID, (yield* runtime.session
title: input.description, .create({
agent: Agent.ID.make(input.agent), parentID: context.sessionID,
model, title: input.description,
// TODO(opencode kkdvxn): derive restricted subagent permissions from the parent agent: Agent.ID.make(input.agent),
// session (V1 deriveSubagentSessionPermission). MVP uses the agent's own permissions. model,
}) // TODO(opencode kkdvxn): derive restricted subagent permissions from the parent
.pipe( // session (V1 deriveSubagentSessionPermission). MVP uses the agent's own permissions.
Effect.mapError( })
(error) => new ToolFailure({ message: `Parent session not found: ${context.sessionID}`, error }), .pipe(
), Effect.mapError(
) (error) => new ToolFailure({ message: `Parent session not found: ${context.sessionID}`, error }),
),
))
const background = input.background === true const background = input.background === true
yield* context.progress({ yield* context.progress({
@@ -190,7 +224,10 @@ export const Plugin = {
// The child session owns its agent/model (set at create); prompt only admits input. // The child session owns its agent/model (set at create); prompt only admits input.
yield* runtime.session.prompt({ yield* runtime.session.prompt({
sessionID: child.id, sessionID: child.id,
text: ["You are a subagent spawned by another session.", input.prompt].join("\n"), text:
existing === undefined
? ["You are a subagent spawned by another session.", input.prompt].join("\n")
: input.prompt,
resume: false, resume: false,
}) })
yield* runtime.session.resume(child.id) yield* runtime.session.resume(child.id)
@@ -238,7 +275,10 @@ export const Plugin = {
}).pipe( }).pipe(
Effect.map((output) => ({ Effect.map((output) => ({
output, output,
content: output.output, content:
output.status === "completed"
? `<subagent sessionID="${output.sessionID}" state="completed">\n${output.output}\n</subagent>`
: output.output,
metadata: { sessionID: output.sessionID, status: output.status }, metadata: { sessionID: output.sessionID, status: output.status },
})), })),
), ),
+55 -77
View File
@@ -2,7 +2,7 @@ export * as WebSearchTool from "./websearch"
import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin" import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin"
import { ToolFailure } from "@opencode-ai/ai" import { ToolFailure } from "@opencode-ai/ai"
import { Effect, Schema, Semaphore } from "effect" import { Effect, Schema } from "effect"
import { Form } from "../../form" import { Form } from "../../form"
import { KV } from "../../kv" import { KV } from "../../kv"
import { Permission } from "../../permission" import { Permission } from "../../permission"
@@ -10,7 +10,6 @@ import { WebSearch } from "../../websearch"
export const name = "websearch" export const name = "websearch"
export const NO_RESULTS = "No search results found. Please try a different query." export const NO_RESULTS = "No search results found. Please try a different query."
const providerSelectionLock = Semaphore.makeUnsafe(1)
export const description = `Search the web using the user's selected search integration. Use this for current information beyond knowledge cutoff. export const description = `Search the web using the user's selected search integration. Use this for current information beyond knowledge cutoff.
@@ -30,7 +29,6 @@ export const Plugin = {
const permission = yield* Permission.Service const permission = yield* Permission.Service
const forms = yield* Form.Service const forms = yield* Form.Service
const kv = yield* KV.Service const kv = yield* KV.Service
const websearch = yield* WebSearch.Service
yield* ctx.tool yield* ctx.tool
.transform((draft) => .transform((draft) =>
@@ -51,90 +49,70 @@ export const Plugin = {
agent: context.agent, agent: context.agent,
source: { type: "tool", messageID: context.messageID, id: context.id }, source: { type: "tool", messageID: context.messageID, id: context.id },
}) })
const search = (): Effect.Effect<Effect.Success<ReturnType<typeof ctx.websearch.query>>, unknown> => const result = yield* ctx.websearch.query(input).pipe(
ctx.websearch.query(input).pipe( Effect.catch((error) => {
Effect.catch((error) => { if (!Schema.is(WebSearch.ProviderRequiredError)(error)) return Effect.fail(error)
if (!Schema.is(WebSearch.ProviderRequiredError)(error)) return Effect.fail(error) return Effect.gen(function* () {
return providerSelectionLock const providers = (yield* ctx.websearch.providers()).data
.withPermit( const defaultProvider = providers[0]
Effect.gen(function* () { if (!defaultProvider) return yield* new WebSearch.ProviderRequiredError()
if (yield* websearch.default()) return yield* Effect.void const response = yield* forms.ask({
const providers = (yield* ctx.websearch.providers()).data sessionID: context.sessionID,
const defaultProvider = providers[0] title: "Web Search",
if (!defaultProvider) return yield* new WebSearch.ProviderRequiredError() metadata: { kind: "websearch.provider" },
const response = yield* forms.ask({ fields: [
{
key: "choice",
description: "Allow OpenCode to search the web for up-to-date information?",
type: "string",
required: true,
custom: false,
options: [
{
value: "allow",
label: `Allow web search via ${defaultProvider.name}`,
},
{
value: "choose",
label: "Choose another provider",
},
{ value: "disable", label: "Disable web search" },
],
},
],
})
if (response.status === "cancelled") return yield* Effect.fail(new Error("Web search cancelled"))
if (response.answer.choice === "disable") {
yield* kv.set("websearch:provider", false)
return yield* new WebSearch.DisabledError()
}
const selection =
response.answer.choice === "choose"
? yield* forms.ask({
sessionID: context.sessionID, sessionID: context.sessionID,
title: "Web Search", title: "Choose a web search provider",
metadata: { kind: "websearch.provider" }, metadata: { kind: "websearch.provider" },
fields: [ fields: [
{ {
key: "choice", key: "provider",
description: "Allow OpenCode to search the web for up-to-date information?", description: "Choose a provider for web search.",
type: "string", type: "string",
required: true, required: true,
custom: false, custom: false,
options: [ options: providers.map((provider) => ({ value: provider.id, label: provider.name })),
{
value: "allow",
label: `Allow web search via ${defaultProvider.name}`,
},
{
value: "choose",
label: "Choose another provider",
},
{ value: "disable", label: "Disable web search" },
],
}, },
], ],
}) })
if (response.status === "cancelled") : undefined
return yield* Effect.fail(new Error("Web search cancelled")) if (selection?.status === "cancelled") return yield* Effect.fail(new Error("Web search cancelled"))
if (response.answer.choice === "disable") { const providerID = selection?.answer.provider ?? defaultProvider.id
yield* kv.set("websearch:provider", false) if (typeof providerID !== "string" || !providers.some((provider) => provider.id === providerID))
return yield* new WebSearch.DisabledError() return yield* new WebSearch.ProviderRequiredError()
} yield* kv.set("websearch:provider", providerID)
const selection = return yield* ctx.websearch.query(input)
response.answer.choice === "choose" })
? yield* forms.ask({ }),
sessionID: context.sessionID, )
title: "Choose a web search provider",
metadata: { kind: "websearch.provider" },
fields: [
{
key: "provider",
description: "Choose a provider for web search.",
type: "string",
required: true,
custom: false,
options: providers.map((provider) => ({
value: provider.id,
label: provider.name,
})),
},
],
})
: undefined
if (selection?.status === "cancelled")
return yield* Effect.fail(new Error("Web search cancelled"))
const providerID = selection?.answer.provider ?? defaultProvider.id
if (
typeof providerID !== "string" ||
!providers.some((provider) => provider.id === providerID)
)
return yield* new WebSearch.ProviderRequiredError()
return yield* kv.set("websearch:provider", providerID)
}),
)
.pipe(
Effect.timeoutOrElse({
duration: "1 minute",
orElse: () => Effect.fail(new Error("Web search cancelled")),
}),
Effect.andThen(Effect.suspend(search)),
)
}),
)
const result = yield* search()
const output = { const output = {
provider: result.data.providerID, provider: result.data.providerID,
results: result.data.results, results: result.data.results,
-96
View File
@@ -12,7 +12,6 @@ import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { FSUtil } from "@opencode-ai/util/fs-util" import { FSUtil } from "@opencode-ai/util/fs-util"
import { Global } from "@opencode-ai/util/global" import { Global } from "@opencode-ai/util/global"
import { Permission } from "@opencode-ai/core/permission" import { Permission } from "@opencode-ai/core/permission"
import { AgentPlugin } from "@opencode-ai/core/plugin/agent"
import { AbsolutePath } from "@opencode-ai/core/schema" import { AbsolutePath } from "@opencode-ai/core/schema"
import { ConfigMigrateV1 } from "@opencode-ai/core/v1/config/migrate" import { ConfigMigrateV1 } from "@opencode-ai/core/v1/config/migrate"
import { advance, drain } from "../lib/clock" import { advance, drain } from "../lib/clock"
@@ -51,11 +50,6 @@ describe("ConfigAgentPlugin.Plugin", () => {
it.effect("matches Windows paths against home-relative permissions", () => it.effect("matches Windows paths against home-relative permissions", () =>
Effect.gen(function* () { Effect.gen(function* () {
const permissions = yield* loadHomePermissions("C:\\Users\\test") const permissions = yield* loadHomePermissions("C:\\Users\\test")
expect(permissions).toContainEqual({
action: "external_directory",
resource: "C:\\Users\\test\\p\\**",
effect: "allow",
})
expect( expect(
Permission.evaluate("external_directory", "C:\\Users\\test\\p\\opencode\\src\\*", permissions).effect, Permission.evaluate("external_directory", "C:\\Users\\test\\p\\opencode\\src\\*", permissions).effect,
).toBe("allow") ).toBe("allow")
@@ -65,96 +59,6 @@ describe("ConfigAgentPlugin.Plugin", () => {
}), }),
) )
it.effect("applies remote permission defaults before explicit global and build rules", () =>
Effect.gen(function* () {
const agents = yield* Agent.Service
const global = yield* Global.Service
yield* AgentPlugin.Plugin.effect(host({ agent: agentHost(agents) }))
const entries = [
new Document({
type: "document",
info: decode(
ConfigMigrateV1.migrate({
permission: {
bash: "ask",
edit: "ask",
webfetch: "ask",
read: {
"*": "allow",
"*.env": "deny",
"*.env.*": "deny",
"*.env.example": "allow",
"*.dev.vars": "deny",
"~/.local/share/opencode/mcp-auth.json": "deny",
"$HOME/.local/share/opencode/mcp-auth.json": "deny",
},
external_directory: {
"*": "ask",
"~/.local/share/opencode/*": "deny",
},
},
}),
),
}),
new Document({
type: "document",
info: decode({
permissions: [{ action: "*", resource: "*", effect: "allow" }],
agents: {
build: {
permissions: [
{ action: "external_directory", resource: "*", effect: "allow" },
{
action: "external_directory",
resource: "~/.local/share/opencode/*",
effect: "deny",
},
{ action: "read", resource: "*.env", effect: "deny" },
],
},
},
}),
}),
]
yield* ConfigAgentPlugin.Plugin.effect(host({ agent: agentHost(agents) })).pipe(
Effect.provide(Config.testLayer(entries)),
)
const build = yield* agents.get(Agent.defaultID)
if (!build) throw new Error("expected configured build agent")
const opencodeData = path.join(global.home, ".local", "share", "opencode", "*")
const mcpAuth = path.join(global.home, ".local", "share", "opencode", "mcp-auth.json")
expect(build.permissions).toEqual([
...defaultPermissions(global),
{ action: "question", resource: "*", effect: "allow" },
{ action: "shell", resource: "*", effect: "ask" },
{ action: "edit", resource: "*", effect: "ask" },
{ action: "webfetch", resource: "*", effect: "ask" },
{ action: "read", resource: "*", effect: "allow" },
{ action: "read", resource: "*.env", effect: "deny" },
{ action: "read", resource: "*.env.*", effect: "deny" },
{ action: "read", resource: "*.env.example", effect: "allow" },
{ action: "read", resource: "*.dev.vars", effect: "deny" },
{ action: "read", resource: mcpAuth, effect: "deny" },
{ action: "read", resource: mcpAuth, effect: "deny" },
{ action: "external_directory", resource: "*", effect: "ask" },
{ action: "external_directory", resource: opencodeData, effect: "deny" },
{ action: "*", resource: "*", effect: "allow" },
{ action: "external_directory", resource: "*", effect: "allow" },
{ action: "external_directory", resource: opencodeData, effect: "deny" },
{ action: "read", resource: "*.env", effect: "deny" },
])
expect(Permission.evaluate("shell", "bun test", build.permissions).effect).toBe("allow")
expect(Permission.evaluate("edit", "src/index.ts", build.permissions).effect).toBe("allow")
expect(Permission.evaluate("webfetch", "https://example.com", build.permissions).effect).toBe("allow")
expect(Permission.evaluate("read", ".env", build.permissions).effect).toBe("deny")
expect(Permission.evaluate("external_directory", opencodeData, build.permissions).effect).toBe("deny")
expect(Permission.evaluate("external_directory", "/outside/*", build.permissions).effect).toBe("allow")
}),
)
it.effect("applies all global permissions before agent-specific permissions", () => it.effect("applies all global permissions before agent-specific permissions", () =>
Effect.gen(function* () { Effect.gen(function* () {
const agents = yield* Agent.Service const agents = yield* Agent.Service
+3 -15
View File
@@ -307,7 +307,7 @@ describe("Config", () => {
}), }),
) )
it.live("loads authenticated wellknown config before user configuration", () => it.live("loads authenticated wellknown config below project config", () =>
Effect.acquireUseRelease( Effect.acquireUseRelease(
Effect.promise(() => tmpdir()), Effect.promise(() => tmpdir()),
(tmp) => (tmp) =>
@@ -370,13 +370,7 @@ describe("Config", () => {
return yield* Effect.gen(function* () { return yield* Effect.gen(function* () {
const config = yield* Config.Service const config = yield* Config.Service
const bus = yield* Bus.Service const bus = yield* Bus.Service
const initial = yield* config.entries() expect(Config.latest(yield* config.entries(), "shell")).toBe("project")
expect(Config.latest(initial, "shell")).toBe("project")
expect(
initial.flatMap((entry) =>
entry.type === "document" && entry.info.shell ? [entry.info.shell] : [],
),
).toEqual(["secret", "global", "project"])
const updated = yield* bus const updated = yield* bus
.subscribe(Event.Updated) .subscribe(Event.Updated)
.pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped) .pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
@@ -384,13 +378,7 @@ describe("Config", () => {
key = "next" key = "next"
yield* bus.publish(Integration.Event.ConnectionUpdated, { integrationID }) yield* bus.publish(Integration.Event.ConnectionUpdated, { integrationID })
expect(yield* Fiber.join(updated)).toHaveLength(1) expect(yield* Fiber.join(updated)).toHaveLength(1)
const refreshed = yield* config.entries() expect(Config.latest(yield* config.entries(), "shell")).toBe("project")
expect(Config.latest(refreshed, "shell")).toBe("project")
expect(
refreshed.flatMap((entry) =>
entry.type === "document" && entry.info.shell ? [entry.info.shell] : [],
),
).toEqual(["next", "global", "project"])
}).pipe( }).pipe(
Effect.provide(testLayer(project, global, project, undefined, undefined, credentialNode, wellknownNode)), Effect.provide(testLayer(project, global, project, undefined, undefined, credentialNode, wellknownNode)),
) )
+2 -10
View File
@@ -237,11 +237,7 @@ describe("ConfigProviderPlugin.Plugin", () => {
models: { models: {
chat: { chat: {
name: "First", name: "First",
compatibility: { compatibility: { reasoningField: "vendor_reasoning" },
reasoningField: "vendor_reasoning",
maxTokensField: "max_completion_tokens",
requireFinishReason: false,
},
capabilities: { tools: true, input: ["text"], output: ["text"] }, capabilities: { tools: true, input: ["text"], output: ["text"] },
disabled: true, disabled: true,
limit: { context: 100, output: 50 }, limit: { context: 100, output: 50 },
@@ -322,11 +318,7 @@ describe("ConfigProviderPlugin.Plugin", () => {
expect(model.id).toBe(modelID) expect(model.id).toBe(modelID)
expect(model.modelID).toBe(Model.ID.make("api-chat")) expect(model.modelID).toBe(Model.ID.make("api-chat"))
expect(model.name).toBe("Last") expect(model.name).toBe("Last")
expect(model.compatibility).toEqual({ expect(model.compatibility).toEqual({ reasoningField: "vendor_reasoning" })
reasoningField: "vendor_reasoning",
maxTokensField: "max_completion_tokens",
requireFinishReason: false,
})
expect(model.capabilities).toEqual({ tools: true, input: ["text"], output: ["text"] }) expect(model.capabilities).toEqual({ tools: true, input: ["text"], output: ["text"] })
expect(model.enabled).toBe(false) expect(model.enabled).toBe(false)
expect(model.limit).toEqual({ context: 100, output: 75 }) expect(model.limit).toEqual({ context: 100, output: 75 })
+2 -11
View File
@@ -194,11 +194,7 @@ describe("ModelResolver", () => {
Effect.gen(function* () { Effect.gen(function* () {
const resolved = yield* ModelResolver.fromCatalogModel( const resolved = yield* ModelResolver.fromCatalogModel(
model(Provider.aisdk("@ai-sdk/openai-compatible"), { model(Provider.aisdk("@ai-sdk/openai-compatible"), {
compatibility: { compatibility: { reasoningField: "vendor_reasoning" },
reasoningField: "vendor_reasoning",
maxTokensField: "max_completion_tokens",
requireFinishReason: false,
},
settings: { settings: {
apiKey: "settings-secret", apiKey: "settings-secret",
baseURL: "https://compatible.example/v1", baseURL: "https://compatible.example/v1",
@@ -208,8 +204,7 @@ describe("ModelResolver", () => {
body: {}, body: {},
}), }),
) )
const request = LLM.request({ model: resolved, prompt: "Hello", generation: { maxTokens: 10 } }) const request = LLM.request({ model: resolved, prompt: "Hello" })
const prepared = yield* compileRequest(request)
const headers = yield* resolved.route.auth.apply({ const headers = yield* resolved.route.auth.apply({
request, request,
method: "POST", method: "POST",
@@ -221,10 +216,6 @@ describe("ModelResolver", () => {
expect(headers.authorization).toBe("Bearer settings-secret") expect(headers.authorization).toBe("Bearer settings-secret")
expect(resolved.route.id).toBe("openai-compatible-chat") expect(resolved.route.id).toBe("openai-compatible-chat")
expect(resolved.compatibility?.reasoningField).toBe("vendor_reasoning") expect(resolved.compatibility?.reasoningField).toBe("vendor_reasoning")
expect(resolved.compatibility?.maxTokensField).toBe("max_completion_tokens")
expect(resolved.compatibility?.requireFinishReason).toBe(false)
expect(prepared.body).toMatchObject({ max_completion_tokens: 10 })
expect(prepared.body).not.toHaveProperty("max_tokens")
expect(resolved.route.endpoint.baseURL).toBe("https://compatible.example/v1") expect(resolved.route.endpoint.baseURL).toBe("https://compatible.example/v1")
expect(resolved.route.defaults.http?.body).toEqual({}) expect(resolved.route.defaults.http?.body).toEqual({})
}), }),
+1 -83
View File
@@ -23,7 +23,6 @@ import { SessionPending } from "@opencode-ai/core/session/pending"
import { SessionEvent } from "@opencode-ai/core/session/event" import { SessionEvent } from "@opencode-ai/core/session/event"
import { SessionTable } from "@opencode-ai/core/session/sql" import { SessionTable } from "@opencode-ai/core/session/sql"
import { SessionStore } from "@opencode-ai/core/session/store" import { SessionStore } from "@opencode-ai/core/session/store"
import { SessionTransfer } from "@opencode-ai/core/session/transfer"
import { Workspace } from "@opencode-ai/core/workspace" import { Workspace } from "@opencode-ai/core/workspace"
import { testEffect } from "./lib/effect" import { testEffect } from "./lib/effect"
import { tmpdir } from "./fixture/tmpdir" import { tmpdir } from "./fixture/tmpdir"
@@ -38,14 +37,7 @@ const projects = Layer.succeed(
) )
const it = testEffect( const it = testEffect(
AppNodeBuilder.build( AppNodeBuilder.build(
LayerNode.group([ LayerNode.group([Database.node, Bus.node, SessionProjector.node, SessionStore.node, Session.node]),
Database.node,
Bus.node,
SessionProjector.node,
SessionStore.node,
Session.node,
SessionTransfer.node,
]),
[ [
[Bus.node, Bus.configured({ persist: true })], [Bus.node, Bus.configured({ persist: true })],
[Project.node, projects], [Project.node, projects],
@@ -748,77 +740,3 @@ describe("Session.create", () => {
}), }),
) )
}) })
describe("SessionTransfer", () => {
it.effect("imports projected messages and reserves their aggregate sequence", () =>
Effect.gen(function* () {
const session = yield* Session.Service
const transfer = yield* SessionTransfer.Service
const bus = yield* Bus.Service
const { db } = yield* Database.Service
const template = yield* session.create({ location, title: "Exported" })
const sessionID = Session.ID.create()
const sourceMessageID = SessionMessage.ID.create()
const errorMessageID = SessionMessage.ID.create()
const imported = yield* transfer.import({
data: {
info: { ...template, id: sessionID },
messages: [
{
id: sourceMessageID,
type: "user",
text: "Imported message",
time: { created: DateTime.makeUnsafe(100) },
},
{
id: errorMessageID,
type: "compaction",
status: "failed",
reason: "manual",
error: { type: "test_error", message: "Original error" },
time: { created: DateTime.makeUnsafe(101) },
},
],
},
location,
})
const messages = yield* session.messages({ sessionID, order: "asc" })
expect(imported).toMatchObject({ id: sessionID, title: "Exported", location })
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([
{ id: sourceMessageID, text: `[redacted:text:${sourceMessageID}]` },
{ id: errorMessageID, error: { type: "test_error", message: "Original error" } },
])
yield* session.prompt({ sessionID, text: "Continue", resume: false })
yield* SessionPending.promote(db, bus, sessionID, "steer")
expect((yield* session.messages({ sessionID, order: "asc" })).map((message) => message.type)).toEqual([
"user",
"compaction",
"user",
])
expect(yield* Bus.latestSequence(db, sessionID)).toBe(4)
}),
)
it.effect("rejects an existing session ID without changing its transcript", () =>
Effect.gen(function* () {
const session = yield* Session.Service
const transfer = yield* SessionTransfer.Service
const existing = yield* session.create({ location, title: "Existing" })
const exit = yield* Effect.exit(transfer.import({ data: { info: existing, messages: [] }, location }))
expect(exit._tag).toBe("Failure")
expect((yield* session.get(existing.id)).title).toBe("Existing")
expect(yield* session.messages({ sessionID: existing.id })).toEqual([])
}),
)
})
+148 -4
View File
@@ -240,7 +240,7 @@ describe("SubagentTool", () => {
expect(settled).toMatchObject({ expect(settled).toMatchObject({
status: "completed", status: "completed",
metadata: { status: "completed" }, metadata: { status: "completed" },
content: [{ type: "text", text: childText }], content: [{ type: "text", text: expect.stringContaining(childText) }],
}) })
expect(settled.metadata).toEqual({ expect(settled.metadata).toEqual({
sessionID: outputSessionID(settled.metadata), sessionID: outputSessionID(settled.metadata),
@@ -283,9 +283,15 @@ describe("SubagentTool", () => {
expect(settled).toMatchObject({ expect(settled).toMatchObject({
status: "completed", status: "completed",
metadata: { status: "completed" }, metadata: { status: "completed" },
content: [{ type: "text", text: childText }], content: [{ type: "text", text: expect.stringContaining(childText) }],
}) })
const child = yield* sessions.get(outputSessionID(settled.metadata)) const child = yield* sessions.get(outputSessionID(settled.metadata))
expect(settled.content).toEqual([
{
type: "text",
text: `<subagent sessionID="${child.id}" state="completed">\n${childText}\n</subagent>`,
},
])
expect(settled.metadata).toEqual({ sessionID: child.id, status: "completed" }) expect(settled.metadata).toEqual({ sessionID: child.id, status: "completed" })
expect(progress[0]?.metadata).toEqual({ sessionID: child.id, status: "running" }) expect(progress[0]?.metadata).toEqual({ sessionID: child.id, status: "running" })
expect(child).toMatchObject({ expect(child).toMatchObject({
@@ -315,6 +321,144 @@ describe("SubagentTool", () => {
), ),
) )
it.live("continues an existing child session", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
).pipe(
Effect.flatMap((dir) =>
Effect.gen(function* () {
const location = Location.Ref.make({ directory: AbsolutePath.make(dir.path) })
const sessions = yield* Session.Service
const parent = yield* sessions.create({ location, model: parentModel })
yield* withSubagent(parent.location)
const locations = yield* LocationServiceMap.Service
const registry = yield* Tool.Service.pipe(Effect.provide(locations.get(parent.location)))
yield* waitForTool(registry, SubagentTool.name)
const first = yield* executeTool(registry, {
sessionID: parent.id,
...toolIdentity,
call: {
type: "tool-call",
id: "call-subagent-first",
name: SubagentTool.name,
input: { agent: "reviewer", description: "review", prompt: "review this" },
},
})
const childID = outputSessionID(first.metadata)
const second = yield* executeTool(registry, {
sessionID: parent.id,
...toolIdentity,
call: {
type: "tool-call",
id: "call-subagent-second",
name: SubagentTool.name,
input: {
agent: "reviewer",
description: "follow up",
prompt: "continue this",
sessionID: childID,
},
},
})
expect(outputSessionID(second.metadata)).toBe(childID)
expect((yield* sessions.list({ parentID: parent.id })).data).toHaveLength(1)
expect((yield* sessions.get(childID)).title).toBe("review")
expect(
(yield* sessions.pending(childID)).flatMap((message) =>
message.type === "user" ? [message.data.text] : [],
),
).toEqual(["You are a subagent spawned by another session.\nreview this", "continue this"])
expect(second.content).toEqual([
{
type: "text",
text: `<subagent sessionID="${childID}" state="completed">\n${childText}\n</subagent>`,
},
])
}),
),
),
)
it.live("rejects background continuation", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
).pipe(
Effect.flatMap((dir) =>
Effect.gen(function* () {
const location = Location.Ref.make({ directory: AbsolutePath.make(dir.path) })
const sessions = yield* Session.Service
const parent = yield* sessions.create({ location })
const child = yield* sessions.create({
parentID: parent.id,
title: "review",
agent: Agent.ID.make("reviewer"),
})
yield* withSubagent(parent.location)
const locations = yield* LocationServiceMap.Service
const registry = yield* Tool.Service.pipe(Effect.provide(locations.get(parent.location)))
yield* waitForTool(registry, SubagentTool.name)
expect(
yield* executeTool(registry, {
sessionID: parent.id,
...toolIdentity,
call: {
type: "tool-call",
id: "call-background-continuation",
name: SubagentTool.name,
input: {
agent: "reviewer",
description: "follow up",
prompt: "continue this",
sessionID: child.id,
background: true,
},
},
}),
).toEqual({
status: "error",
error: {
type: "tool.execution",
message: "Continuing a subagent in the background is not implemented yet",
},
})
const jobs = yield* Job.Service
yield* jobs.start({ id: child.id, type: "subagent", run: Effect.never })
yield* jobs.background(child.id)
expect(
yield* executeTool(registry, {
sessionID: parent.id,
...toolIdentity,
call: {
type: "tool-call",
id: "call-running-continuation",
name: SubagentTool.name,
input: {
agent: "reviewer",
description: "follow up",
prompt: "continue this",
sessionID: child.id,
},
},
}),
).toEqual({
status: "error",
error: {
type: "tool.execution",
message: "Continuing a running subagent is not implemented yet",
},
})
yield* jobs.cancel(child.id)
}),
),
),
)
it.live("returns child runner failures as tool errors", () => it.live("returns child runner failures as tool errors", () =>
Effect.acquireRelease( Effect.acquireRelease(
Effect.promise(() => tmpdir()), Effect.promise(() => tmpdir()),
@@ -393,7 +537,7 @@ describe("SubagentTool", () => {
expect(settled.content).toEqual([{ type: "text", text: expect.stringContaining(`id: ${childID}`) }]) expect(settled.content).toEqual([{ type: "text", text: expect.stringContaining(`id: ${childID}`) }])
const admission = Array.from(yield* Fiber.join(admitted))[0] const admission = Array.from(yield* Fiber.join(admitted))[0]
expect(admission?.data.input.data.text).toContain(`<subagent id="${childID}" state="completed"`) expect(admission?.data.input.data.text).toContain(`<subagent sessionID="${childID}" state="completed"`)
expect(admission?.data.input.data).toMatchObject({ expect(admission?.data.input.data).toMatchObject({
description: "background review", description: "background review",
metadata: { metadata: {
@@ -407,7 +551,7 @@ describe("SubagentTool", () => {
yield* SessionPending.promote(database.db, bus, parent.id, "steer") yield* SessionPending.promote(database.db, bus, parent.id, "steer")
const synthetic = (yield* sessions.context(parent.id)).filter((message) => message.type === "synthetic") const synthetic = (yield* sessions.context(parent.id)).filter((message) => message.type === "synthetic")
expect(synthetic).toHaveLength(1) expect(synthetic).toHaveLength(1)
expect(synthetic[0]?.text).toContain(`<subagent id="${childID}" state="completed"`) expect(synthetic[0]?.text).toContain(`<subagent sessionID="${childID}" state="completed"`)
expect(synthetic[0]?.text).toContain(childText) expect(synthetic[0]?.text).toContain(childText)
}), }),
), ),
+2 -45
View File
@@ -1,5 +1,5 @@
import { beforeEach, describe, expect } from "bun:test" import { beforeEach, describe, expect } from "bun:test"
import { Deferred, Effect, Layer } from "effect" import { Effect, Layer } from "effect"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/util/effect/layer-node" import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Permission } from "@opencode-ai/core/permission" import { Permission } from "@opencode-ai/core/permission"
@@ -39,8 +39,6 @@ const providers = [
let providerRequired = false let providerRequired = false
let formResponse: Form.TerminalState = { status: "cancelled" } let formResponse: Form.TerminalState = { status: "cancelled" }
const formResponses: Form.TerminalState[] = [] const formResponses: Form.TerminalState[] = []
let queryBarrier: Deferred.Deferred<void> | undefined
let synchronizedQueries = 0
let result = new WebSearch.Response({ let result = new WebSearch.Response({
providerID: WebSearch.ID.make("exa"), providerID: WebSearch.ID.make("exa"),
results: [{ url: "https://example.com", title: "Search results", content: "search results", time: {} }], results: [{ url: "https://example.com", title: "Search results", content: "search results", time: {} }],
@@ -54,8 +52,6 @@ beforeEach(() => {
providerRequired = false providerRequired = false
formResponse = { status: "cancelled" } formResponse = { status: "cancelled" }
formResponses.length = 0 formResponses.length = 0
queryBarrier = undefined
synchronizedQueries = 0
result = new WebSearch.Response({ result = new WebSearch.Response({
providerID: WebSearch.ID.make("exa"), providerID: WebSearch.ID.make("exa"),
results: [{ url: "https://example.com", title: "Search results", content: "search results", time: {} }], results: [{ url: "https://example.com", title: "Search results", content: "search results", time: {} }],
@@ -79,21 +75,11 @@ const websearch = Layer.succeed(
transform: () => Effect.die("unused"), transform: () => Effect.die("unused"),
reload: () => Effect.die("unused"), reload: () => Effect.die("unused"),
providers: () => Effect.succeed(providers), providers: () => Effect.succeed(providers),
default: () => default: () => Effect.succeed(undefined),
Effect.gen(function* () {
const stored = values.get("websearch:provider")
if (stored === false) return yield* new WebSearch.DisabledError()
return typeof stored === "string" ? providers.find((provider) => provider.id === stored) : undefined
}),
query: (input) => query: (input) =>
Effect.gen(function* () { Effect.gen(function* () {
queries.push(input) queries.push(input)
const stored = values.get("websearch:provider") const stored = values.get("websearch:provider")
if (queryBarrier && synchronizedQueries < 5) {
synchronizedQueries++
if (synchronizedQueries === 5) yield* Deferred.succeed(queryBarrier, undefined)
yield* Deferred.await(queryBarrier)
}
if (providerRequired && typeof stored !== "string") return yield* new WebSearch.ProviderRequiredError() if (providerRequired && typeof stored !== "string") return yield* new WebSearch.ProviderRequiredError()
if (typeof stored === "string") if (typeof stored === "string")
return new WebSearch.Response({ providerID: WebSearch.ID.make(stored), results: result.results }) return new WebSearch.Response({ providerID: WebSearch.ID.make(stored), results: result.results })
@@ -330,35 +316,6 @@ describe("WebSearchTool registration", () => {
}), }),
) )
it.effect("shares provider consent across concurrent searches", () =>
Effect.gen(function* () {
providerRequired = true
formResponse = { status: "answered", answer: { choice: "allow" } }
queryBarrier = yield* Deferred.make<void>()
const registry = yield* Tool.Service
const results = yield* Effect.all(
Array.from({ length: 5 }, (_, index) =>
executeTool(registry, {
sessionID,
...toolIdentity,
call: {
type: "tool-call",
id: `call-concurrent-${index}`,
name: "websearch",
input: { query: `effect ${index}` },
},
}),
),
{ concurrency: "unbounded" },
)
expect(results.every((item) => item.status === "completed")).toBe(true)
expect(formRequests).toHaveLength(1)
expect(values.get("websearch:provider")).toBe("exa")
}),
)
it.effect("persists the choice to disable web search", () => it.effect("persists the choice to disable web search", () =>
Effect.gen(function* () { Effect.gen(function* () {
providerRequired = true providerRequired = true
+3 -34
View File
@@ -1,5 +1,4 @@
import { SessionMessage } from "@opencode-ai/schema/session-message" import { SessionMessage } from "@opencode-ai/schema/session-message"
import { SessionTransfer } from "@opencode-ai/schema/session-transfer"
import { SessionPending } from "@opencode-ai/schema/session-pending" import { SessionPending } from "@opencode-ai/schema/session-pending"
import { PromptInput } from "@opencode-ai/schema/prompt-input" import { PromptInput } from "@opencode-ai/schema/prompt-input"
import { Session } from "@opencode-ai/schema/session" import { Session } from "@opencode-ai/schema/session"
@@ -151,8 +150,8 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo
payload: Schema.Struct({ payload: Schema.Struct({
id: Session.ID.pipe(Schema.optional), id: Session.ID.pipe(Schema.optional),
title: Schema.String.pipe(Schema.optional), title: Schema.String.pipe(Schema.optional),
agent: Agent.ID, agent: Agent.ID.pipe(Schema.optional),
model: Model.Ref, model: Model.Ref.pipe(Schema.optional),
location: Location.Ref.pipe(Schema.optional), location: Location.Ref.pipe(Schema.optional),
}), }),
success: Schema.Struct({ data: Session.Info }), success: Schema.Struct({ data: Session.Info }),
@@ -160,37 +159,7 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo
OpenApi.annotations({ OpenApi.annotations({
identifier: "v2.session.create", identifier: "v2.session.create",
summary: "Create session", summary: "Create session",
description: "Create a session with an explicit agent and model at the requested location.", description: "Create a session at the requested location.",
}),
),
)
.add(
HttpApiEndpoint.post("session.import", "/api/session/import", {
payload: Schema.Struct({
...SessionTransfer.Data.fields,
location: Location.Ref.pipe(Schema.optional),
}),
success: Schema.Struct({ data: Session.Info }),
error: ConflictError,
}).annotateMerge(
OpenApi.annotations({
identifier: "v2.session.import",
summary: "Import session",
description: "Import a projected session transcript at the requested location.",
}),
),
)
.add(
HttpApiEndpoint.get("session.export", "/api/session/:sessionID/export", {
params: { sessionID: Session.ID },
query: Schema.Struct({ sanitize: BooleanFromString.pipe(Schema.optional) }),
success: Schema.Struct({ data: SessionTransfer.Data }),
error: [SessionNotFoundError, UnknownError],
}).annotateMerge(
OpenApi.annotations({
identifier: "v2.session.export",
summary: "Export session",
description: "Export a complete projected session transcript.",
}), }),
), ),
) )
-1
View File
@@ -25,7 +25,6 @@ export { Vcs } from "./vcs.js"
export { SessionPending } from "./session-pending.js" export { SessionPending } from "./session-pending.js"
export { SessionError } from "./session-error.js" export { SessionError } from "./session-error.js"
export { SessionMessage } from "./session-message.js" export { SessionMessage } from "./session-message.js"
export { SessionTransfer } from "./session-transfer.js"
export { Snapshot } from "./snapshot.js" export { Snapshot } from "./snapshot.js"
export { Shell } from "./shell.js" export { Shell } from "./shell.js"
export { Skill } from "./skill.js" export { Skill } from "./skill.js"
-7
View File
@@ -47,16 +47,9 @@ export const ReasoningField: Schema.Codec<ReasoningField> = Schema.Union([
Schema.String, Schema.String,
]).annotate({ identifier: "Model.ReasoningField" }) ]).annotate({ identifier: "Model.ReasoningField" })
export const MaxTokensField = Schema.Literals(["max_completion_tokens", "max_tokens"]).annotate({
identifier: "Model.MaxTokensField",
})
export type MaxTokensField = typeof MaxTokensField.Type
export interface Compatibility extends Schema.Schema.Type<typeof Compatibility> {} export interface Compatibility extends Schema.Schema.Type<typeof Compatibility> {}
export const Compatibility = Schema.Struct({ export const Compatibility = Schema.Struct({
reasoningField: ReasoningField.pipe(optional), reasoningField: ReasoningField.pipe(optional),
maxTokensField: MaxTokensField.pipe(optional),
requireFinishReason: Schema.Boolean.pipe(optional),
}).annotate({ identifier: "Model.Compatibility" }) }).annotate({ identifier: "Model.Compatibility" })
export interface Capabilities extends Schema.Schema.Type<typeof Capabilities> {} export interface Capabilities extends Schema.Schema.Type<typeof Capabilities> {}
-11
View File
@@ -1,11 +0,0 @@
export * as SessionTransfer from "./session-transfer.js"
import { Schema } from "effect"
import { Session } from "./session.js"
import { SessionMessage } from "./session-message.js"
export interface Data extends Schema.Schema.Type<typeof Data> {}
export const Data = Schema.Struct({
info: Session.Info,
messages: Schema.Array(SessionMessage.Info),
}).annotate({ identifier: "SessionTransfer.Data" })
-19
View File
@@ -30,22 +30,3 @@ describe("Model.ReasoningField", () => {
expect(decode(field)).toBe(field) expect(decode(field)).toBe(field)
}) })
}) })
describe("Model.Compatibility", () => {
test("decodes model compatibility overrides", () => {
const decode = Schema.decodeUnknownSync(Model.Compatibility)
expect(decode({})).toEqual({})
expect(
decode({
reasoningField: "vendor_reasoning",
maxTokensField: "max_completion_tokens",
requireFinishReason: false,
}),
).toEqual({
reasoningField: "vendor_reasoning",
maxTokensField: "max_completion_tokens",
requireFinishReason: false,
})
})
})
-52
View File
@@ -1,5 +1,4 @@
import { Session } from "@opencode-ai/core/session" import { Session } from "@opencode-ai/core/session"
import { SessionTransfer } from "@opencode-ai/core/session/transfer"
import { InstructionEntry } from "@opencode-ai/core/session/instruction-entry" import { InstructionEntry } from "@opencode-ai/core/session/instruction-entry"
import { DateTime, Effect, Stream } from "effect" import { DateTime, Effect, Stream } from "effect"
import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi" import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi"
@@ -25,7 +24,6 @@ const DefaultSessionsLimit = 50
export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handlers) => export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handlers) =>
Effect.gen(function* () { Effect.gen(function* () {
const session = yield* Session.Service const session = yield* Session.Service
const transfer = yield* SessionTransfer.Service
return handlers return handlers
.handle( .handle(
@@ -88,56 +86,6 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
} }
}), }),
) )
.handle(
"session.import",
Effect.fn(function* (ctx) {
return {
data: yield* transfer
.import({
data: { info: ctx.payload.info, messages: ctx.payload.messages },
location: ctx.payload.location ?? { directory: AbsolutePath.make(process.cwd()) },
})
.pipe(
Effect.catchTag(
"SessionTransfer.ImportConflictError",
(error) =>
new ConflictError({
message: `Session already exists: ${error.sessionID}`,
resource: error.sessionID,
}),
),
),
}
}),
)
.handle(
"session.export",
Effect.fn(function* (ctx) {
return {
data: yield* transfer.export({ sessionID: ctx.params.sessionID, sanitize: ctx.query.sanitize }).pipe(
Effect.catchTag(
"Session.NotFoundError",
(error) =>
new SessionNotFoundError({
sessionID: error.sessionID,
message: `Session not found: ${error.sessionID}`,
}),
),
Effect.catchTag("Session.MessageDecodeError", (error) => {
const ref = `err_${crypto.randomUUID().slice(0, 8)}`
return Effect.logError("failed to decode session message").pipe(
Effect.annotateLogs({ ref, sessionID: error.sessionID, messageID: error.messageID }),
Effect.andThen(
Effect.fail(
new UnknownError({ message: "Unexpected server error. Check server logs for details.", ref }),
),
),
)
}),
),
}
}),
)
.handle( .handle(
"session.active", "session.active",
Effect.fn(function* () { Effect.fn(function* () {
-2
View File
@@ -16,7 +16,6 @@ import { PtyTicket } from "@opencode-ai/core/pty/ticket"
import { Pty } from "@opencode-ai/core/pty" import { Pty } from "@opencode-ai/core/pty"
import { Project } from "@opencode-ai/core/project" import { Project } from "@opencode-ai/core/project"
import { Session } from "@opencode-ai/core/session" import { Session } from "@opencode-ai/core/session"
import { SessionTransfer } from "@opencode-ai/core/session/transfer"
import { Shell } from "@opencode-ai/core/shell" import { Shell } from "@opencode-ai/core/shell"
import { Job } from "@opencode-ai/core/job" import { Job } from "@opencode-ai/core/job"
import { MCP } from "@opencode-ai/core/mcp/index" import { MCP } from "@opencode-ai/core/mcp/index"
@@ -52,7 +51,6 @@ const applicationServices = LayerNode.group([
Job.node, Job.node,
Project.node, Project.node,
Session.node, Session.node,
SessionTransfer.node,
PluginRuntime.providerNode, PluginRuntime.providerNode,
SdkPlugins.node, SdkPlugins.node,
PermissionSaved.node, PermissionSaved.node,
+1 -1
View File
@@ -512,7 +512,7 @@ function App(props: { pair?: DialogPairCredentials }) {
const terminalTitleEnabled = () => config.data.terminal?.title ?? true const terminalTitleEnabled = () => config.data.terminal?.title ?? true
const copyOnSelectEnabled = () => config.data.terminal?.copy_on_select ?? process.platform !== "win32" const copyOnSelectEnabled = () => config.data.terminal?.copy_on_select ?? process.platform !== "win32"
const pasteSummaryEnabled = () => config.data.prompt?.paste !== "full" const pasteSummaryEnabled = () => config.data.prompt?.paste !== "full"
const tabsVertical = () => config.data.tabs.layout === "vertical" && sessionTabsFitVertically(dimensions().width) const tabsVertical = () => (config.data.tabs?.vertical ?? false) && sessionTabsFitVertically(dimensions().width)
const tabsVisible = () => const tabsVisible = () =>
sessionTabs.enabled() && (sessionTabs.tabs().length > 0 || sessionTabs.newTab()) && route.data.type !== "plugin" sessionTabs.enabled() && (sessionTabs.tabs().length > 0 || sessionTabs.newTab()) && route.data.type !== "plugin"
+7 -6
View File
@@ -88,7 +88,7 @@ export const settings: Setting[] = [
title: "Enabled", title: "Enabled",
category: "Tabs", category: "Tabs",
path: ["tabs", "enabled"], path: ["tabs", "enabled"],
default: true, default: false,
values: [false, true], values: [false, true],
labels: ["off", "on"], labels: ["off", "on"],
}, },
@@ -96,16 +96,17 @@ export const settings: Setting[] = [
title: "Scope", title: "Scope",
category: "Tabs", category: "Tabs",
path: ["tabs", "scope"], path: ["tabs", "scope"],
default: "cwd", default: "global",
values: ["cwd", "global"], values: ["cwd", "global"],
labels: ["current directory", "global"], labels: ["current directory", "global"],
}, },
{ {
title: "Layout", title: "Vertical",
category: "Tabs", category: "Tabs",
path: ["tabs", "layout"], path: ["tabs", "vertical"],
default: "horizontal", default: false,
values: ["horizontal", "vertical"], values: [false, true],
labels: ["off", "on"],
keywords: ["sidebar", "orientation", "left"], keywords: ["sidebar", "orientation", "left"],
}, },
{ {
+2 -6
View File
@@ -8,21 +8,17 @@ import * as fuzzysort from "fuzzysort"
import { useConnected } from "./use-connected" import { useConnected } from "./use-connected"
import { useData } from "../context/data" import { useData } from "../context/data"
import { modelPreferenceKey } from "../model-preference" import { modelPreferenceKey } from "../model-preference"
import { useLocation } from "../context/location"
export function DialogModel(props: { providerID?: string }) { export function DialogModel(props: { providerID?: string }) {
const local = useLocal() const local = useLocal()
const data = useData() const data = useData()
const dialog = useDialog() const dialog = useDialog()
const location = useLocation()
const [query, setQuery] = createSignal("") const [query, setQuery] = createSignal("")
const favoritePriority = new Set(local.model.favorite().map(modelPreferenceKey)) const favoritePriority = new Set(local.model.favorite().map(modelPreferenceKey))
const connected = useConnected() const connected = useConnected()
const providers = createMemo( const providers = createMemo(() => new Map((data.location.provider.list() ?? []).map((item) => [item.id, item])))
() => new Map((data.location.provider.list(location.ref) ?? []).map((item) => [item.id, item])), const models = createMemo(() => data.location.model.list() ?? [])
)
const models = createMemo(() => data.location.model.list(location.ref) ?? [])
const showExtra = createMemo(() => connected() && !props.providerID) const showExtra = createMemo(() => connected() && !props.providerID)
+51 -50
View File
@@ -327,6 +327,10 @@ export function Prompt(props: PromptProps) {
if (!session) return if (!session) return
const agent = session.agent && local.agent.list().find((agent) => agent.id === session.agent) const agent = session.agent && local.agent.list().find((agent) => agent.id === session.agent)
if (agent && !args.agent) local.agent.set(agent.id) if (agent && !args.agent) local.agent.set(agent.id)
if (session.model) {
local.model.set({ providerID: session.model.providerID, modelID: session.model.id })
local.model.variant.set(session.model.variant)
}
syncedSessionID = sessionID syncedSessionID = sessionID
}) })
@@ -939,43 +943,15 @@ export function Prompt(props: PromptProps) {
await slash.command.run(slash.input) await slash.command.run(slash.input)
return true return true
} }
const inputText = expandTrackedPastedText(
store.prompt.text,
input.extmarks.getAllForTypeId(promptPartTypeId).flatMap((extmark) => {
const ref = store.extmarkToPart.get(extmark.id)
if (ref?.type !== "pasted") return []
const part = store.prompt.pasted[ref.index]
if (!part) return []
return [{ start: extmark.start, end: extmark.end, text: part.text }]
}),
)
const slashHead = parseSlashHead(inputText, /\s/)
const isSkill =
slashHead !== undefined &&
(data.location.skill.list(currentLocation.ref) ?? []).some(
(skill) => skill.slash === true && skill.id === slashHead.name,
)
const isCommand =
slashHead !== undefined &&
(data.location.command.list(currentLocation.ref) ?? []).some((command) => command.name === slashHead.name)
const agent = local.agent.current() const agent = local.agent.current()
if (!agent) return false if (!agent) return false
const selection = local.model.selection() const selectedModel = local.model.current()
if (!selection) { if (!selectedModel) {
void promptModelWarning() void promptModelWarning()
return false return false
} }
const usesModel = !props.sessionID || (store.mode !== "shell" && !isSkill)
if (usesModel && !local.model.available(selection)) {
toast.show({
title: "Model unavailable",
message: `${selection.providerID}/${selection.modelID} is not available in this session's location`,
variant: "warning",
})
return false
}
const variant = selection.variant const variant = local.model.variant.current()
let sessionID = props.sessionID let sessionID = props.sessionID
let session = sessionID ? data.session.get(sessionID) : undefined let session = sessionID ? data.session.get(sessionID) : undefined
let finishMoveProgress = false let finishMoveProgress = false
@@ -993,8 +969,8 @@ export function Prompt(props: PromptProps) {
location: directory ? { directory } : location, location: directory ? { directory } : location,
agent: agent.id, agent: agent.id,
model: { model: {
providerID: selection.providerID, providerID: selectedModel.providerID,
id: selection.modelID, id: selectedModel.modelID,
variant, variant,
}, },
}) })
@@ -1014,6 +990,17 @@ export function Prompt(props: PromptProps) {
session = created session = created
} }
const inputText = expandTrackedPastedText(
store.prompt.text,
input.extmarks.getAllForTypeId(promptPartTypeId).flatMap((extmark) => {
const ref = store.extmarkToPart.get(extmark.id)
if (ref?.type !== "pasted") return []
const part = store.prompt.pasted[ref.index]
if (!part) return []
return [{ start: extmark.start, end: extmark.end, text: part.text }]
}),
)
// Capture mode before it gets reset // Capture mode before it gets reset
const currentMode = store.mode const currentMode = store.mode
const editorSelection = editorContext() const editorSelection = editorContext()
@@ -1026,30 +1013,43 @@ export function Prompt(props: PromptProps) {
command: inputText, command: inputText,
}) })
setStore("mode", "normal") setStore("mode", "normal")
} else if (slashHead && isCommand) { } else if (
inputText.startsWith("/") &&
(data.location.command.list(currentLocation.current) ?? []).some(
(command) => command.name === inputText.split("\n")[0].split(" ")[0].slice(1),
)
) {
move.startSubmit() move.startSubmit()
const model = { providerID: selection.providerID, id: selection.modelID, variant } // Parse command from first line, preserve multi-line content in arguments
const cancelCommit = local.model.trackSessionCommit(sessionID, model) const firstLineEnd = inputText.indexOf("\n")
const firstLine = firstLineEnd === -1 ? inputText : inputText.slice(0, firstLineEnd)
const [command, ...firstLineArgs] = firstLine.split(" ")
const restOfInput = firstLineEnd === -1 ? "" : inputText.slice(firstLineEnd + 1)
const args = firstLineArgs.join(" ") + (restOfInput ? "\n" + restOfInput : "")
void client.api.session void client.api.session
.command({ .command({
sessionID, sessionID,
command: slashHead.name, command: command.slice(1),
arguments: slashHead.arguments, arguments: args,
agent: agent.id, agent: agent.id,
model, model: { providerID: selectedModel.providerID, id: selectedModel.modelID, variant },
files: store.prompt.files, files: store.prompt.files,
agents: store.prompt.agents, agents: store.prompt.agents,
}) })
.catch((error) => { .catch((error) => {
cancelCommit()
toast.show({ title: "Failed to run command", message: errorMessage(error), variant: "error" }) toast.show({ title: "Failed to run command", message: errorMessage(error), variant: "error" })
}) })
} else if (isSkill) { } else if (
inputText.startsWith("/") &&
(data.location.skill.list(currentLocation.current) ?? []).some(
(skill) => skill.slash === true && skill.id === inputText.split("\n")[0].split(" ")[0].slice(1),
)
) {
move.startSubmit() move.startSubmit()
void client.api.session.skill({ void client.api.session.skill({
sessionID, sessionID,
skill: slashHead!.name, skill: inputText.split("\n")[0].split(" ")[0].slice(1),
}) })
} else { } else {
move.startSubmit() move.startSubmit()
@@ -1061,15 +1061,13 @@ export function Prompt(props: PromptProps) {
await client.api.session.switchAgent({ sessionID, agent: agent.id }) await client.api.session.switchAgent({ sessionID, agent: agent.id })
} }
if ( if (
session?.model?.providerID !== selection.providerID || session?.model?.providerID !== selectedModel.providerID ||
session.model.id !== selection.modelID || session.model.id !== selectedModel.modelID ||
(session.model.variant ?? "default") !== (variant ?? "default") (session.model.variant ?? "default") !== (variant ?? "default")
) { ) {
const model = { providerID: selection.providerID, id: selection.modelID, variant } await client.api.session.switchModel({
const cancelCommit = local.model.trackSessionCommit(sessionID, model) sessionID,
await client.api.session.switchModel({ sessionID, model }).catch((error) => { model: { providerID: selectedModel.providerID, id: selectedModel.modelID, variant },
cancelCommit()
throw error
}) })
} }
if (session?.revert) { if (session?.revert) {
@@ -1322,7 +1320,10 @@ export function Prompt(props: PromptProps) {
return `Ask anything... "${list()[store.placeholder % list().length]}"` return `Ask anything... "${list()[store.placeholder % list().length]}"`
})() })()
if (!value) return undefined if (!value) return undefined
const width = dimensions().width < 44 ? dimensions().width - 5 : Math.min(75, dimensions().width - 4) - 5 const width =
dimensions().width < 44
? dimensions().width - 5
: Math.min(75, dimensions().width - 4) - 5
return Locale.takeWidth(value, Math.max(1, width)).trimEnd() return Locale.takeWidth(value, Math.max(1, width)).trimEnd()
}) })
const locationLabel = createMemo(() => { const locationLabel = createMemo(() => {
+3 -14
View File
@@ -132,8 +132,8 @@ export const Info = Schema.Struct({
scope: Schema.optional(Schema.Literals(["global", "cwd"])).annotate({ scope: Schema.optional(Schema.Literals(["global", "cwd"])).annotate({
description: "Share tabs globally or keep a separate set for each working directory", description: "Share tabs globally or keep a separate set for each working directory",
}), }),
layout: Schema.optional(Schema.Literals(["horizontal", "vertical"])).annotate({ vertical: Schema.optional(Schema.Boolean).annotate({
description: "Show tabs in a horizontal strip or vertical sidebar", description: "Show tabs in a left sidebar instead of a horizontal strip",
}), }),
}), }),
).annotate({ description: "Tab strip settings" }), ).annotate({ description: "Tab strip settings" }),
@@ -179,7 +179,7 @@ export const Info = Schema.Struct({
}) })
export type Info = Schema.Schema.Type<typeof Info> export type Info = Schema.Schema.Type<typeof Info>
export type Resolved = Omit<Info, "attention" | "keybinds" | "leader" | "mouse" | "tabs"> & { export type Resolved = Omit<Info, "attention" | "keybinds" | "leader" | "mouse"> & {
attention: { attention: {
enabled: boolean enabled: boolean
notifications: boolean notifications: boolean
@@ -191,11 +191,6 @@ export type Resolved = Omit<Info, "attention" | "keybinds" | "leader" | "mouse"
keybinds: TuiKeybind.BindingLookupView keybinds: TuiKeybind.BindingLookupView
leader: { timeout: number } leader: { timeout: number }
mouse: boolean mouse: boolean
tabs: {
enabled: boolean
scope: "global" | "cwd"
layout: "horizontal" | "vertical"
}
} }
export function resolve(input: Info, options: { terminalSuspend: boolean }): Resolved { export function resolve(input: Info, options: { terminalSuspend: boolean }): Resolved {
@@ -226,12 +221,6 @@ export function resolve(input: Info, options: { terminalSuspend: boolean }): Res
}), }),
leader: { timeout: input.leader?.timeout ?? 2000 }, leader: { timeout: input.leader?.timeout ?? 2000 },
mouse: input.mouse ?? true, mouse: input.mouse ?? true,
tabs: {
...input.tabs,
enabled: input.tabs?.enabled ?? true,
scope: input.tabs?.scope ?? "cwd",
layout: input.tabs?.layout ?? "horizontal",
},
} }
} }
+81 -185
View File
@@ -1,7 +1,7 @@
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import { dedupeWith } from "effect/Array" import { dedupeWith } from "effect/Array"
import { createSimpleContext } from "./helper" import { createSimpleContext } from "./helper"
import { batch, createMemo, onCleanup } from "solid-js" import { batch, createMemo } from "solid-js"
import { useEvent } from "./event" import { useEvent } from "./event"
import path from "path" import path from "path"
import { useTuiPaths } from "./runtime" import { useTuiPaths } from "./runtime"
@@ -22,7 +22,6 @@ import { useToast } from "../ui/toast"
import { useRoute } from "./route" import { useRoute } from "./route"
import { useData } from "./data" import { useData } from "./data"
import { usePermission } from "./permission" import { usePermission } from "./permission"
import { useLocation } from "./location"
export function parseModel(model: string) { export function parseModel(model: string) {
const [providerID, ...rest] = model.split("/") const [providerID, ...rest] = model.split("/")
@@ -58,29 +57,26 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
const args = useArgs() const args = useArgs()
const event = useEvent() const event = useEvent()
const permission = usePermission() const permission = usePermission()
const location = useLocation()
const models = () => data.location.model.list(location.ref)
const providers = () => data.location.provider.list(location.ref)
function isModelValid(model: ModelPreferenceModel) { function isModelValid(model: ModelPreferenceModel) {
return !!models()?.some((item) => item.providerID === model.providerID && item.id === model.modelID) return !!data.location.model
.list()
?.some((item) => item.providerID === model.providerID && item.id === model.modelID)
} }
function getFirstValidModel(...modelFns: (() => ModelPreferenceModel | undefined)[]) { function getFirstValidModel(...modelFns: (() => ModelPreferenceModel | undefined)[]) {
for (const modelFn of modelFns) { for (const modelFn of modelFns) {
const model = modelFn() const model = modelFn()
if (model && isModelValid(model)) return model if (!model) continue
if (isModelValid(model)) return model
} }
} }
function createAgent() { function createAgent() {
const agents = createMemo(() => const agents = createMemo(() =>
(data.location.agent.list(location.ref) ?? []).filter((agent) => agent.mode !== "subagent" && !agent.hidden), (data.location.agent.list() ?? []).filter((agent) => agent.mode !== "subagent" && !agent.hidden),
)
const visibleAgents = createMemo(() =>
(data.location.agent.list(location.ref) ?? []).filter((agent) => !agent.hidden),
) )
const visibleAgents = createMemo(() => (data.location.agent.list() ?? []).filter((agent) => !agent.hidden))
const [agentStore, setAgentStore] = createStore({ const [agentStore, setAgentStore] = createStore({
current: undefined as string | undefined, current: undefined as string | undefined,
}) })
@@ -132,40 +128,35 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
const agent = createAgent() const agent = createAgent()
function createModel() { function createModel() {
type ModelSelection = ModelPreferenceModel & { variant?: string } const [modelStore, setModelStore] = createStore<
const [preferences, setPreferences] = createStore<ModelPreference & { ready: boolean }>({ ModelPreference & {
ready: boolean
model: Record<string, ModelPreferenceModel>
}
>({
ready: false, ready: false,
model: {},
recent: [], recent: [],
favorite: [], favorite: [],
variant: {}, variant: {},
}) })
const [selectionState, setSelectionState] = createStore<{
newSessionModelByLocationAgent: Record<string, ModelPreferenceModel | undefined>
draftBySession: Record<string, ModelSelection | undefined>
}>({
newSessionModelByLocationAgent: {},
draftBySession: {},
})
const repository = createModelPreferenceRepository(path.join(paths.state, "model.json")) const repository = createModelPreferenceRepository(path.join(paths.state, "model.json"))
const pendingSelectionCommits = new Map<string, string>() const state = {
const selectionKey = (value: ModelSelection) =>
`${modelPreferenceKey(value)}:${normalizeModelVariant(value.variant) ?? "default"}`
const saveState = {
pending: false, pending: false,
} }
function savePreferences() { function save() {
if (!preferences.ready) { if (!modelStore.ready) {
saveState.pending = true state.pending = true
return return
} }
saveState.pending = false state.pending = false
void repository void repository
.patch({ .patch({
recent: preferences.recent, recent: modelStore.recent,
favorite: preferences.favorite, favorite: modelStore.favorite,
variant: preferences.variant, variant: modelStore.variant,
}) })
.catch(() => undefined) .catch(() => undefined)
} }
@@ -173,14 +164,14 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
repository repository
.load() .load()
.then((value) => { .then((value) => {
setPreferences("recent", value.recent) setModelStore("recent", value.recent)
setPreferences("favorite", value.favorite) setModelStore("favorite", value.favorite)
setPreferences("variant", value.variant) setModelStore("variant", value.variant)
}) })
.catch(() => {}) .catch(() => {})
.finally(() => { .finally(() => {
setPreferences("ready", true) setModelStore("ready", true)
if (saveState.pending) savePreferences() if (state.pending) save()
}) })
const fallbackModel = createMemo(() => { const fallbackModel = createMemo(() => {
@@ -194,13 +185,13 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
} }
} }
for (const item of preferences.recent) { for (const item of modelStore.recent) {
if (isModelValid(item)) { if (isModelValid(item)) {
return item return item
} }
} }
const model = models()?.[0] const model = data.location.model.list()?.[0]
if (!model) return undefined if (!model) return undefined
return { return {
providerID: model.providerID, providerID: model.providerID,
@@ -208,134 +199,30 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
} }
}) })
const newSessionModel = createMemo(() => {
const a = agent.current()
return getFirstValidModel(
() => a && selectionState.newSessionModelByLocationAgent[locationAgentKey(a.id)],
() => a?.model && { providerID: a.model.providerID, modelID: a.model.id },
fallbackModel,
)
})
const currentSelection = createMemo<ModelSelection | undefined>(() => {
if (route.data.type === "session") return sessionSelection(route.data.sessionID)
const model = newSessionModel()
if (!model) return
return { ...model, variant: normalizeModelVariant(preferences.variant[modelPreferenceKey(model)]) }
})
const currentModel = createMemo(() => { const currentModel = createMemo(() => {
const selection = currentSelection() const a = agent.current()
if (!selection) return return (
return { providerID: selection.providerID, modelID: selection.modelID } getFirstValidModel(
}) () => a && modelStore.model[a.id],
() => a?.model && { providerID: a.model.providerID, modelID: a.model.id },
function locationAgentKey(agentID: string) { fallbackModel,
const ref = location.ref ?? data.location.default() ) ?? undefined
return `${JSON.stringify([ref.directory, ref.workspaceID])}:${agentID}`
}
function durableSelection(sessionID: string): ModelSelection | undefined {
const model = data.session.get(sessionID)?.model
if (!model) return
return {
providerID: model.providerID,
modelID: model.id,
variant: normalizeModelVariant(model.variant),
}
}
function sessionSelection(sessionID: string) {
return selectionState.draftBySession[sessionID] ?? durableSelection(sessionID)
}
function setSessionDraft(sessionID: string, selection: ModelSelection) {
const durable = durableSelection(sessionID)
setSelectionState(
"draftBySession",
sessionID,
durable && selectionKey(durable) === selectionKey(selection) ? undefined : selection,
) )
} })
function selectModel(model: ModelPreferenceModel) {
if (route.data.type === "session") {
const sessionID = route.data.sessionID
const current = sessionSelection(sessionID)
const preferred = normalizeModelVariant(
current?.providerID === model.providerID && current.modelID === model.modelID
? current.variant
: preferences.variant[modelPreferenceKey(model)],
)
const info = models()?.find((item) => item.providerID === model.providerID && item.id === model.modelID)
const variant = preferred && info?.variants?.some((item) => item.id === preferred) ? preferred : undefined
setSessionDraft(sessionID, { ...model, variant })
return true
}
const current = agent.current()
if (!current) return false
setSelectionState("newSessionModelByLocationAgent", locationAgentKey(current.id), model)
return true
}
onCleanup(
event.on("session.model.selected", (evt) => {
const expected = pendingSelectionCommits.get(evt.data.sessionID)
if (!expected) return
const committed = selectionKey({
providerID: evt.data.model.providerID,
modelID: evt.data.model.id,
variant: evt.data.model.variant,
})
if (committed !== expected) return
pendingSelectionCommits.delete(evt.data.sessionID)
const draft = selectionState.draftBySession[evt.data.sessionID]
if (draft && selectionKey(draft) === committed)
setSelectionState("draftBySession", evt.data.sessionID, undefined)
}),
)
onCleanup(
event.on("session.deleted", (evt) => {
pendingSelectionCommits.delete(evt.data.sessionID)
setSelectionState("draftBySession", evt.data.sessionID, undefined)
}),
)
return { return {
current: currentModel, current: currentModel,
selection: currentSelection,
available(model = currentModel()) {
return model ? isModelValid(model) : false
},
trackSessionCommit(
sessionID: string,
value: {
providerID: string
id: string
variant?: string
},
) {
const committed = selectionKey({ providerID: value.providerID, modelID: value.id, variant: value.variant })
pendingSelectionCommits.set(sessionID, committed)
return () => {
if (pendingSelectionCommits.get(sessionID) === committed) pendingSelectionCommits.delete(sessionID)
}
},
get ready() { get ready() {
return preferences.ready return modelStore.ready
},
get catalogReady() {
return models() !== undefined
}, },
recent() { recent() {
return preferences.recent return modelStore.recent
}, },
favorite() { favorite() {
return preferences.favorite return modelStore.favorite
}, },
parsed: createMemo(() => { parsed: createMemo(() => {
const value = currentSelection() const value = currentModel()
if (!value) { if (!value) {
return { return {
provider: "Connect a provider", provider: "Connect a provider",
@@ -343,28 +230,33 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
reasoning: false, reasoning: false,
} }
} }
const provider = providers()?.find((item) => item.id === value.providerID) const provider = data.location.provider.list()?.find((item) => item.id === value.providerID)
const info = models()?.find((item) => item.providerID === value.providerID && item.id === value.modelID) const info = data.location.model
.list()
?.find((item) => item.providerID === value.providerID && item.id === value.modelID)
return { return {
provider: provider?.name ?? value.providerID, provider: provider?.name ?? value.providerID,
model: info?.name ?? `${value.modelID} (unavailable)`, model: info?.name ?? value.modelID,
reasoning: (info?.variants?.length ?? 0) !== 0, reasoning: (info?.variants?.length ?? 0) !== 0,
} }
}), }),
cycle(direction: 1 | -1) { cycle(direction: 1 | -1) {
const current = currentSelection() const current = currentModel()
if (!current) return if (!current) return
const recent = recentModels(current, preferences.recent).filter(isModelValid) const recent = modelStore.recent
const index = recent.findIndex((x) => x.providerID === current.providerID && x.modelID === current.modelID) const index = recent.findIndex((x) => x.providerID === current.providerID && x.modelID === current.modelID)
let next = index === -1 ? (direction === 1 ? 0 : recent.length - 1) : index + direction if (index === -1) return
let next = index + direction
if (next < 0) next = recent.length - 1 if (next < 0) next = recent.length - 1
if (next >= recent.length) next = 0 if (next >= recent.length) next = 0
const val = recent[next] const val = recent[next]
if (!val) return if (!val) return
selectModel({ ...val }) const a = agent.current()
if (!a) return
setModelStore("model", a.id, { ...val })
}, },
cycleFavorite(direction: 1 | -1) { cycleFavorite(direction: 1 | -1) {
const favorites = preferences.favorite.filter((item) => isModelValid(item)) const favorites = modelStore.favorite.filter((item) => isModelValid(item))
if (!favorites.length) { if (!favorites.length) {
toast.show({ toast.show({
variant: "info", variant: "info",
@@ -373,7 +265,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
}) })
return return
} }
const current = currentSelection() const current = currentModel()
let index = -1 let index = -1
if (current) { if (current) {
index = favorites.findIndex((x) => x.providerID === current.providerID && x.modelID === current.modelID) index = favorites.findIndex((x) => x.providerID === current.providerID && x.modelID === current.modelID)
@@ -387,39 +279,45 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
} }
const next = favorites[index] const next = favorites[index]
if (!next) return if (!next) return
if (!selectModel({ ...next })) return const a = agent.current()
setPreferences("recent", recentModels(next, preferences.recent)) if (!a) return
savePreferences() setModelStore("model", a.id, { ...next })
setModelStore("recent", recentModels(next, modelStore.recent))
save()
}, },
set(model: { providerID: string; modelID: string }, options?: { recent?: boolean }) { set(model: { providerID: string; modelID: string }, options?: { recent?: boolean }) {
batch(() => { batch(() => {
if (!isModelValid(model)) return if (!isModelValid(model)) return
if (!selectModel(model)) return const a = agent.current()
if (!a) return
setModelStore("model", a.id, model)
if (options?.recent) { if (options?.recent) {
setPreferences("recent", recentModels(model, preferences.recent)) setModelStore("recent", recentModels(model, modelStore.recent))
savePreferences() save()
} }
}) })
}, },
toggleFavorite(model: { providerID: string; modelID: string }) { toggleFavorite(model: { providerID: string; modelID: string }) {
batch(() => { batch(() => {
if (!isModelValid(model)) return if (!isModelValid(model)) return
const exists = preferences.favorite.some( const exists = modelStore.favorite.some(
(x) => x.providerID === model.providerID && x.modelID === model.modelID, (x) => x.providerID === model.providerID && x.modelID === model.modelID,
) )
const next = exists const next = exists
? preferences.favorite.filter((x) => x.providerID !== model.providerID || x.modelID !== model.modelID) ? modelStore.favorite.filter((x) => x.providerID !== model.providerID || x.modelID !== model.modelID)
: [model, ...preferences.favorite] : [model, ...modelStore.favorite]
setPreferences( setModelStore(
"favorite", "favorite",
next.map((x) => ({ providerID: x.providerID, modelID: x.modelID })), next.map((x) => ({ providerID: x.providerID, modelID: x.modelID })),
) )
savePreferences() save()
}) })
}, },
variant: { variant: {
selected() { selected() {
return currentSelection()?.variant const m = currentModel()
if (!m) return undefined
return normalizeModelVariant(modelStore.variant[modelPreferenceKey(m)])
}, },
current() { current() {
const v = this.selected() const v = this.selected()
@@ -427,20 +325,18 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
return undefined return undefined
}, },
list() { list() {
const m = currentSelection() const m = currentModel()
if (!m) return [] if (!m) return []
const info = models()?.find((item) => item.providerID === m.providerID && item.id === m.modelID) const info = data.location.model
.list()
?.find((item) => item.providerID === m.providerID && item.id === m.modelID)
return info?.variants?.map((variant) => variant.id) ?? [] return info?.variants?.map((variant) => variant.id) ?? []
}, },
set(value: string | undefined) { set(value: string | undefined) {
const m = currentSelection() const m = currentModel()
if (!m) return if (!m) return
if (route.data.type === "session") { setModelStore("variant", modelPreferenceKey(m), normalizeModelVariant(value))
setSessionDraft(route.data.sessionID, { ...m, variant: normalizeModelVariant(value) }) save()
return
}
setPreferences("variant", modelPreferenceKey(m), normalizeModelVariant(value))
savePreferences()
}, },
cycle() { cycle() {
const variants = this.list() const variants = this.list()
+3 -3
View File
@@ -49,7 +49,7 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
const event = useEvent() const event = useEvent()
const config = useConfig().data const config = useConfig().data
const paths = useTuiPaths() const paths = useTuiPaths()
const enabled = () => config.tabs.enabled const enabled = () => config.tabs?.enabled ?? false
// Keyed reconcile keeps tab object identity across reorders, so strip rows move instead of // 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. // mutating in place, which per-row animations and drag state depend on.
const [store, updateStore] = useStorage().store<PersistedState>("tabs", { const [store, updateStore] = useStorage().store<PersistedState>("tabs", {
@@ -66,12 +66,12 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
let closedTabs: ClosedSessionTab[] = [] let closedTabs: ClosedSessionTab[] = []
function state() { function state() {
if (config.tabs.scope === "cwd") return store.cwd[paths.cwd] ?? fallback if (config.tabs?.scope === "cwd") return store.cwd[paths.cwd] ?? fallback
return store.global return store.global
} }
function update(mutation: (draft: TabsState) => void) { function update(mutation: (draft: TabsState) => void) {
const scope = config.tabs.scope const scope = config.tabs?.scope ?? "global"
void updateStore((draft) => mutation(scope === "cwd" ? (draft.cwd[paths.cwd] ??= empty()) : draft.global)).catch( void updateStore((draft) => mutation(scope === "cwd" ? (draft.cwd[paths.cwd] ??= empty()) : draft.global)).catch(
// Failed writes lose only tab layout, but silence would hide tabs resetting every launch. // Failed writes lose only tab layout, but silence would hide tabs resetting every launch.
(error) => console.error("Failed to persist session tabs", error), (error) => console.error("Failed to persist session tabs", error),
+18 -9
View File
@@ -204,7 +204,7 @@ export function Session() {
const availableWidth = createMemo( const availableWidth = createMemo(
() => () =>
dimensions().width - dimensions().width -
(config.tabs?.enabled && config.tabs.layout === "vertical" && sessionTabsFitVertically(dimensions().width) (config.tabs?.enabled && config.tabs.vertical && sessionTabsFitVertically(dimensions().width)
? SESSION_SIDEBAR_WIDTH ? SESSION_SIDEBAR_WIDTH
: 0), : 0),
) )
@@ -361,7 +361,7 @@ export function Session() {
createEffect(() => { createEffect(() => {
const current = prompt() const current = prompt()
if (sent || !current || !synced() || !local.model.ready || !local.model.catalogReady) return if (sent || !current || !synced() || !local.model.ready) return
if (!local.agent.current() || !local.model.current()) return if (!local.agent.current() || !local.model.current()) return
if (!args.prompt || route.prompt?.text !== args.prompt || current.current.text !== args.prompt) return if (!args.prompt || route.prompt?.text !== args.prompt || current.current.text !== args.prompt) return
sent = true sent = true
@@ -824,13 +824,22 @@ export function Session() {
if (options === null) return if (options === null) return
const content = const content =
options.format === "markdown" options.format === "markdown"
? formatSessionTranscript(sessionData, messages(), options.thinking) ? formatSessionTranscript(sessionData, messages(), options.thinking)
: JSON.stringify( : await (async () => {
await client.api.session.export({ sessionID: sessionData.id, sanitize: options.sanitize }), const messages: unknown[] = []
null, let cursor: string | undefined
2, do {
) + EOL const page = await client.api.message.list(
cursor
? { sessionID: sessionData.id, limit: 200, cursor }
: { sessionID: sessionData.id, limit: 200, order: "asc" },
)
messages.push(...page.data)
cursor = page.data.length ? (page.cursor.next ?? undefined) : undefined
} while (cursor)
return JSON.stringify({ info: sessionData, messages }, null, 2) + EOL
})()
if (options.action === "copy") { if (options.action === "copy") {
await clipboard.write?.(content) await clipboard.write?.(content)
+3 -47
View File
@@ -9,11 +9,11 @@ export type ExportFormat = "markdown" | "json"
export type DialogExportOptionsProps = { export type DialogExportOptionsProps = {
defaultThinking: boolean defaultThinking: boolean
onConfirm?: (options: { action: "copy" | "export"; format: ExportFormat; thinking: boolean; sanitize: boolean }) => void onConfirm?: (options: { action: "copy" | "export"; format: ExportFormat; thinking: boolean }) => void
onCancel?: () => void onCancel?: () => void
} }
type Active = ExportFormat | "thinking" | "sanitize" | "copy" | "export" type Active = ExportFormat | "thinking" | "copy" | "export"
export function DialogExportOptions(props: DialogExportOptionsProps) { export function DialogExportOptions(props: DialogExportOptionsProps) {
const dialog = useDialog() const dialog = useDialog()
@@ -22,7 +22,6 @@ export function DialogExportOptions(props: DialogExportOptionsProps) {
const [store, setStore] = createStore({ const [store, setStore] = createStore({
format: "markdown" as ExportFormat, format: "markdown" as ExportFormat,
thinking: props.defaultThinking, thinking: props.defaultThinking,
sanitize: false,
active: "markdown" as Active, active: "markdown" as Active,
}) })
@@ -31,7 +30,6 @@ export function DialogExportOptions(props: DialogExportOptionsProps) {
action, action,
format: store.format, format: store.format,
thinking: store.thinking, thinking: store.thinking,
sanitize: store.sanitize,
}) })
const activate = () => { const activate = () => {
@@ -40,7 +38,6 @@ export function DialogExportOptions(props: DialogExportOptionsProps) {
return return
} }
if (store.active === "thinking") setStore("thinking", !store.thinking) if (store.active === "thinking") setStore("thinking", !store.thinking)
if (store.active === "sanitize") setStore("sanitize", !store.sanitize)
if (store.active === "copy" || store.active === "export") confirm(store.active) if (store.active === "copy" || store.active === "export") confirm(store.active)
} }
@@ -55,7 +52,7 @@ export function DialogExportOptions(props: DialogExportOptionsProps) {
const order: Active[] = const order: Active[] =
store.format === "markdown" store.format === "markdown"
? ["markdown", "json", "thinking", "copy", "export"] ? ["markdown", "json", "thinking", "copy", "export"]
: ["markdown", "json", "sanitize", "copy", "export"] : ["markdown", "json", "copy", "export"]
setStore("active", order[(order.indexOf(store.active) + 1) % order.length]) setStore("active", order[(order.indexOf(store.active) + 1) % order.length])
}, },
}, },
@@ -156,46 +153,6 @@ export function DialogExportOptions(props: DialogExportOptionsProps) {
</text> </text>
</box> </box>
</Show> </Show>
<Show when={store.format === "json"}>
<box
flexDirection="row"
gap={1}
backgroundColor={
store.active === "sanitize"
? theme.background.formfield.focused
: store.sanitize
? theme.background.formfield.selected
: theme.background.formfield.default
}
onMouseUp={() => {
setStore("active", "sanitize")
setStore("sanitize", !store.sanitize)
}}
>
<text
fg={
store.active === "sanitize"
? theme.text.formfield.focused
: store.sanitize
? theme.text.formfield.selected
: theme.text.formfield.default
}
>
{store.sanitize ? "[x]" : "[ ]"}
</text>
<text
fg={
store.active === "sanitize"
? theme.text.formfield.focused
: store.sanitize
? theme.text.formfield.selected
: theme.text.formfield.default
}
>
Sanitize sensitive data
</text>
</box>
</Show>
<box flexDirection="row" justifyContent="flex-end" gap={1} paddingBottom={1}> <box flexDirection="row" justifyContent="flex-end" gap={1} paddingBottom={1}>
<box <box
paddingLeft={4} paddingLeft={4}
@@ -229,7 +186,6 @@ DialogExportOptions.show = (dialog: DialogContext, defaultThinking: boolean) =>
action: "copy" | "export" action: "copy" | "export"
format: ExportFormat format: ExportFormat
thinking: boolean thinking: boolean
sanitize: boolean
} | null>((resolve) => { } | null>((resolve) => {
dialog.replace( dialog.replace(
() => ( () => (
+1 -12
View File
@@ -3,7 +3,6 @@ import { testRender } from "@opentui/solid"
import { expect, test } from "bun:test" import { expect, test } from "bun:test"
import { Schema } from "effect" import { Schema } from "effect"
import { resolve, ConfigProvider, Info, useConfig, type Interface } from "../src/config" import { resolve, ConfigProvider, Info, useConfig, type Interface } from "../src/config"
import { settings } from "../src/component/dialog-config"
test("validates mini replay settings", () => { test("validates mini replay settings", () => {
const decode = Schema.decodeUnknownSync(Info) const decode = Schema.decodeUnknownSync(Info)
@@ -18,10 +17,7 @@ test("validates mini replay settings", () => {
test("validates the session tabs setting", () => { test("validates the session tabs setting", () => {
const decode = Schema.decodeUnknownSync(Info) const decode = Schema.decodeUnknownSync(Info)
expect(decode({ tabs: { enabled: true, layout: "vertical" } })).toEqual({ expect(decode({ tabs: { enabled: true, vertical: true } })).toEqual({ tabs: { enabled: true, vertical: true } })
tabs: { enabled: true, layout: "vertical" },
})
expect(() => decode({ tabs: { layout: true } })).toThrow()
expect(() => decode({ tabs: { enabled: "on" } })).toThrow() expect(() => decode({ tabs: { enabled: "on" } })).toThrow()
}) })
@@ -42,13 +38,6 @@ test("resolves nested config and keybind defaults", () => {
expect(config.scroll).toEqual({ speed: 2, acceleration: true }) expect(config.scroll).toEqual({ speed: 2, acceleration: true })
expect(config.diffs).toEqual({ view: "split" }) expect(config.diffs).toEqual({ view: "split" })
expect(config.debug).toEqual({ devtools: true }) expect(config.debug).toEqual({ devtools: true })
expect(config.tabs).toEqual({ enabled: true, scope: "cwd", layout: "horizontal" })
})
test("shows resolved tab defaults in settings", () => {
expect(settings.find((setting) => setting.path.join(".") === "tabs.enabled")?.default).toBe(true)
expect(settings.find((setting) => setting.path.join(".") === "tabs.scope")?.default).toBe("cwd")
expect(settings.find((setting) => setting.path.join(".") === "tabs.layout")?.default).toBe("horizontal")
}) })
test("provides config and its host interface", async () => { test("provides config and its host interface", async () => {
@@ -60,8 +60,8 @@ async function renderSessionTabs(
await Bun.write( await Bun.write(
file, file,
JSON.stringify({ JSON.stringify({
global: { tabs: [], unread: {} }, global: { tabs: options.persisted.map((sessionID) => ({ sessionID })), unread: {} },
cwd: { [directory]: { tabs: options.persisted.map((sessionID) => ({ sessionID })), unread: {} } }, cwd: {},
}), }),
) )
} }
@@ -153,15 +153,15 @@ test("loads persisted tab metadata concurrently on connect", async () => {
} }
}) })
test("stores session tabs for the current working directory by default", async () => { test("stores session tabs globally by default", async () => {
const setup = await renderSessionTabs("first") const setup = await renderSessionTabs("first")
try { try {
const file = path.join(setup.state, "test", "tui", "tabs.json") const file = path.join(setup.state, "test", "tui", "tabs.json")
await wait(() => Bun.file(file).size > 0) await wait(() => Bun.file(file).size > 0)
expect(await Bun.file(file).json()).toEqual({ expect(await Bun.file(file).json()).toEqual({
global: { tabs: [], unread: {} }, global: { tabs: [{ sessionID: "first" }], unread: {} },
cwd: { [directory]: { tabs: [{ sessionID: "first" }], unread: {} } }, cwd: {},
}) })
} finally { } finally {
setup.destroy() setup.destroy()
@@ -180,7 +180,7 @@ test("concurrent TUIs do not alternate shared tab titles from divergent session
await titled.data.session.sync("shared") await titled.data.session.sync("shared")
await wait(async () => { await wait(async () => {
if (!(await Bun.file(file).exists())) return false if (!(await Bun.file(file).exists())) return false
return (await Bun.file(file).json()).cwd[directory]?.tabs[0]?.title === "Generated title" return (await Bun.file(file).json()).global.tabs[0]?.title === "Generated title"
}) })
const observed = ["Generated title"] const observed = ["Generated title"]
const pending = new Set<Promise<void>>() const pending = new Set<Promise<void>>()
@@ -189,7 +189,7 @@ test("concurrent TUIs do not alternate shared tab titles from divergent session
const read = Bun.file(file) const read = Bun.file(file)
.json() .json()
.then((value) => { .then((value) => {
const title = value.cwd[directory]?.tabs[0]?.title const title = value.global.tabs[0]?.title
if (title && observed.at(-1) !== title) observed.push(title) if (title && observed.at(-1) !== title) observed.push(title)
}) })
.catch(() => undefined) .catch(() => undefined)