Compare commits

..
16 changed files with 987 additions and 817 deletions
+7 -19
View File
@@ -215,10 +215,8 @@ export function createData(config: CreateDataInput) {
)
const messageIndex = new Map<string, Map<string, number>>()
const sync = createSync()
let activeUpdates: Map<string, DataSessionStatus | undefined> | undefined
function setSessionActive(sessionID: string, status: DataSessionStatus) {
activeUpdates?.set(sessionID, status)
setStore("session", "active", sessionID, status)
}
@@ -475,7 +473,6 @@ export function createData(config: CreateDataInput) {
}
function removeSession(sessionID: string) {
activeUpdates?.set(sessionID, undefined)
store.session.pending[sessionID]?.forEach((item) => outbox.delete(item.id))
messageIndex.delete(sessionID)
sync.invalidate(`session:${sessionID}`)
@@ -507,25 +504,17 @@ export function createData(config: CreateDataInput) {
function handleEvent(event: OpenCodeEvent) {
switch (event.type) {
case "server.connected": {
const updates = new Map<string, DataSessionStatus | undefined>()
activeUpdates = updates
case "server.connected":
void api()
.session.active()
.then((active) => {
if (activeUpdates !== updates) return
// Lifecycle events received during hydration supersede the snapshot.
const snapshot = new Map<string, DataSessionStatus>(Object.keys(active).map((id) => [id, "running"]))
updates.forEach((status, id) => {
if (status === undefined) return snapshot.delete(id)
snapshot.set(id, status)
})
activeUpdates = undefined
setStore("session", "active", reconcile(Object.fromEntries(snapshot)))
})
.catch(() => {
if (activeUpdates === updates) activeUpdates = undefined
setStore(
"session",
"active",
reconcile(Object.fromEntries(Object.keys(active).map((sessionID) => [sessionID, "running" as const]))),
)
})
.catch(() => undefined)
void api()
.location.get({ location: locationQuery(defaultLocation()) })
.then((location) => {
@@ -536,7 +525,6 @@ export function createData(config: CreateDataInput) {
void result.location.vcs.sync().catch((error) => console.error("Failed to preload VCS info", error))
void result.project.sync().catch((error) => console.error("Failed to preload projects", error))
return
}
case "project.updated":
setStore("project", "info", event.data.id, reconcile(event.data))
return
-96
View File
@@ -485,102 +485,6 @@ test("preserves assistant content replacement events across an active message re
}
})
test.each([
"session.execution.succeeded",
"session.execution.failed",
"session.execution.interrupted",
"session.execution.started",
"session.deleted",
] as const)("preserves %s activity when an older snapshot arrives", async (type) => {
const release = Promise.withResolvers<void>()
const requested = Promise.withResolvers<void>()
const setup = activityFixture(async () => {
requested.resolve()
await release.promise
return Response.json({
data: {
...(type === "session.execution.started" ? {} : { ses_refresh: { type: "running" } }),
ses_hydrated: { type: "running" },
},
})
})
try {
if (type !== "session.execution.started") setup.data.session.setStatus("ses_refresh", "running")
setup.emit({ type: "server.connected", data: {} })
await requested.promise
setup.emit({
id: "evt_activity",
created: 2,
type,
durable: { aggregateID: "ses_refresh", seq: 2, version: 1 },
data: { sessionID: "ses_refresh", reason: "user" },
})
expect(setup.data.session.status("ses_refresh")).toBe(type === "session.execution.started" ? "running" : "idle")
release.resolve()
await wait(() => setup.data.session.status("ses_hydrated") === "running")
expect(setup.data.session.status("ses_refresh")).toBe(type === "session.execution.started" ? "running" : "idle")
} finally {
release.resolve()
setup.dispose()
}
})
test("ignores activity snapshots from an older connection", async () => {
const reads: ReturnType<typeof Promise.withResolvers<Response>>[] = []
const setup = activityFixture(() => {
const read = Promise.withResolvers<Response>()
reads.push(read)
return read.promise
})
try {
setup.emit({ type: "server.connected", data: {} })
await wait(() => reads.length === 1)
setup.emit({ type: "server.connected", data: {} })
await wait(() => reads.length === 2)
reads[1]?.resolve(Response.json({ data: { ses_new: { type: "running" } } }))
await wait(() => setup.data.session.status("ses_new") === "running")
reads[0]?.resolve(Response.json({ data: { ses_old: { type: "running" } } }))
await Bun.sleep(20)
expect(setup.data.session.status("ses_new")).toBe("running")
expect(setup.data.session.status("ses_old")).toBe("idle")
} finally {
reads.forEach((read) => read.resolve(Response.json({ data: {} })))
setup.dispose()
}
})
function activityFixture(read: () => Response | Promise<Response>) {
const listeners = new Set<Parameters<CreateDataInput["event"]["listen"]>[0]>()
const api = OpenCode.make({
baseUrl: "http://opencode.local",
fetch: async (input, init) => {
const request = input instanceof Request ? input : new Request(input, init)
const path = new URL(request.url).pathname
if (path === "/api/session/active") return read()
if (path === "/api/project") return Response.json([])
if (path === "/api/location") return Response.json({ directory: "/project" })
return Response.json({ location: { directory: "/project" }, data: { branch: "main" } })
},
})
return createRoot((dispose) => ({
data: createData({
api: () => api,
directory: "/project",
event: {
on: () => () => {},
listen(handler) {
listeners.add(handler)
return () => listeners.delete(handler)
},
},
}),
emit: (details: OpenCodeEvent) => listeners.forEach((listener) => listener({ name: details.type, details })),
dispose,
}))
}
async function wait(check: () => boolean) {
const started = Date.now()
while (!check()) {
-107
View File
@@ -1,107 +0,0 @@
export * as CommandInvocation from "./invocation.js"
import type { Plugin } from "@opencode-ai/plugin/effect"
import { Agent } from "@opencode-ai/schema/agent"
import type { ConfigCommand } from "@opencode-ai/schema/config/command"
import { Model } from "@opencode-ai/schema/model"
import { Provider } from "@opencode-ai/schema/provider"
import { AppProcess } from "@opencode-ai/util/process"
import { Effect } from "effect"
import { ChildProcess } from "effect/unstable/process"
import type { Command } from "../command.js"
import { Location } from "../location.js"
import { ShellSelect } from "../shell/select.js"
// Invocation for configured template commands; source loading and registration stay with the caller.
export const make = Effect.fnUntraced(function* (ctx: Pick<Plugin.Context, "agent" | "session">) {
const location = yield* Location.Service
const processes = yield* AppProcess.Service
const shell = yield* ShellSelect.Service
return Effect.fn("CommandInvocation.invoke")(function* (command: ConfigCommand.Info, input: Command.Invocation) {
const agent = command.agent === undefined ? undefined : Agent.ID.make(command.agent)
const commandAgent = yield* Effect.gen(function* () {
if (agent === undefined) return
const session = yield* ctx.session.get({ sessionID: input.sessionID })
if (session.agent !== agent) yield* ctx.session.switchAgent({ sessionID: input.sessionID, agent })
return (yield* ctx.agent.get({ agentID: agent })).data
})
const model =
command.model === undefined
? commandAgent?.model
: {
id: Model.ID.make(command.model.model),
providerID: Provider.ID.make(command.model.providerID),
...(command.model.variant === undefined ? {} : { variant: Model.VariantID.make(command.model.variant) }),
}
if (model !== undefined) yield* ctx.session.switchModel({ sessionID: input.sessionID, model })
yield* ctx.session.prompt({
...input.prompt,
sessionID: input.sessionID,
text: yield* evaluateTemplate(command.template, input.prompt.text, { location, processes, shell }),
delivery: input.delivery,
})
})
})
function evaluateTemplate(
template: string,
input: string,
services: {
readonly location: Location.Info
readonly processes: AppProcess.Interface
readonly shell: ShellSelect.Interface
},
) {
return Effect.gen(function* () {
const args = parseArguments(input)
const placeholders = template.match(placeholderRegex) ?? []
const last = Math.max(0, ...placeholders.map((item) => Number(item.slice(1))))
const expanded = template.replaceAll(placeholderRegex, (_, index) => {
const position = Number(index)
const argIndex = position - 1
if (argIndex >= args.length) return ""
if (position === last) return args.slice(argIndex).join(" ")
return args[argIndex]
})
const withArguments = expanded.replaceAll("$ARGUMENTS", input)
const text =
placeholders.length === 0 && !template.includes("$ARGUMENTS") && input.trim()
? `${withArguments}\n\n${input}`.trim()
: withArguments.trim()
const matches = Array.from(text.matchAll(shellRegex))
if (matches.length === 0) return text
const shell = yield* services.shell.resolve({ priority: "config" })
const outputs = yield* Effect.forEach(
matches,
(match) => {
const source = match[1] ?? ""
return services.processes
.run(
ChildProcess.make(shell, ShellSelect.args(shell, source), {
cwd: services.location.directory,
stdin: "ignore",
}),
{ combineOutput: true },
)
.pipe(
Effect.map((result) => (result.output ?? Buffer.concat([result.stdout, result.stderr])).toString("utf8")),
Effect.mapError(
(error) => new Error(`Shell interpolation failed for ${JSON.stringify(source)}: ${error.message}`),
),
)
},
{ concurrency: 2 },
)
const iterator = outputs[Symbol.iterator]()
return text.replace(shellRegex, () => iterator.next().value ?? "")
})
}
function parseArguments(input: string) {
return (input.match(argsRegex) ?? []).map((arg) => arg.replace(quoteTrimRegex, ""))
}
const argsRegex = /(?:\[Image\s+\d+\]|"[^"]*"|'[^']*'|[^\s"']+)/gi
const placeholderRegex = /\$(\d+)/g
const quoteTrimRegex = /^["']|["']$/g
const shellRegex = /!`([^`]+)`/g
+106 -3
View File
@@ -1,12 +1,18 @@
export * as ConfigCommandPlugin from "./command.js"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { Agent } from "@opencode-ai/schema/agent"
import { Info, type Entry } from "@opencode-ai/schema/config"
import { ConfigCommand } from "@opencode-ai/schema/config/command"
import { Model } from "@opencode-ai/schema/model"
import { Provider } from "@opencode-ai/schema/provider"
import { AppProcess } from "@opencode-ai/util/process"
import path from "path"
import { Effect, Option, Schema, Stream } from "effect"
import { CommandInvocation } from "../../command/invocation.js"
import { ChildProcess } from "effect/unstable/process"
import { Config } from "../../config.js"
import { Location } from "../../location.js"
import { ShellSelect } from "../../shell/select.js"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { ConfigMarkdown } from "../markdown.js"
@@ -23,7 +29,9 @@ export const Plugin = define({
const commands = yield* loadDirectory(fs, entry.path)
return [{ commands: Object.fromEntries(commands.map((command) => [command.name, command.info])) }]
})
const invoke = yield* CommandInvocation.make(ctx)
const location = yield* Location.Service
const processes = yield* AppProcess.Service
const shell = yield* ShellSelect.Service
const load = Effect.fn("ConfigCommandPlugin.load")(function* () {
return yield* Effect.forEach(yield* config.entries(), loadEntry).pipe(Effect.map((documents) => documents.flat()))
})
@@ -55,7 +63,38 @@ export const Plugin = define({
draft.add({
name,
description: command.description,
execute: (input) => invoke(command, input),
execute: (input) =>
Effect.gen(function* () {
const agent = command.agent === undefined ? undefined : Agent.ID.make(command.agent)
const commandAgent = yield* Effect.gen(function* () {
if (agent === undefined) return
const session = yield* ctx.session.get({ sessionID: input.sessionID })
if (session.agent !== agent) yield* ctx.session.switchAgent({ sessionID: input.sessionID, agent })
return (yield* ctx.agent.get({ agentID: agent })).data
})
const model =
command.model === undefined
? commandAgent?.model
: {
id: Model.ID.make(command.model.model),
providerID: Provider.ID.make(command.model.providerID),
...(command.model.variant === undefined
? {}
: { variant: Model.VariantID.make(command.model.variant) }),
}
if (model !== undefined) yield* ctx.session.switchModel({ sessionID: input.sessionID, model })
yield* ctx.session.prompt({
...input.prompt,
sessionID: input.sessionID,
text: yield* evaluateTemplate(command.template, input.prompt.text, {
config,
location,
processes,
shell,
}),
delivery: input.delivery,
})
}).pipe(Effect.asVoid),
})
}
}
@@ -108,3 +147,67 @@ function decode(directory: string, filepath: string, content: string) {
info,
}
}
function evaluateTemplate(
template: string,
input: string,
services: {
readonly config: Config.Interface
readonly location: Location.Info
readonly processes: AppProcess.Interface
readonly shell: ShellSelect.Interface
},
) {
return Effect.gen(function* () {
const args = parseArguments(input)
const placeholders = template.match(placeholderRegex) ?? []
const last = Math.max(0, ...placeholders.map((item) => Number(item.slice(1))))
const expanded = template.replaceAll(placeholderRegex, (_, index) => {
const position = Number(index)
const argIndex = position - 1
if (argIndex >= args.length) return ""
if (position === last) return args.slice(argIndex).join(" ")
return args[argIndex]
})
const withArguments = expanded.replaceAll("$ARGUMENTS", input)
const text =
placeholders.length === 0 && !template.includes("$ARGUMENTS") && input.trim()
? `${withArguments}\n\n${input}`.trim()
: withArguments.trim()
const matches = Array.from(text.matchAll(shellRegex))
if (matches.length === 0) return text
const shell = yield* services.shell.resolve({ priority: "config" })
const outputs = yield* Effect.forEach(
matches,
(match) => {
const source = match[1] ?? ""
return services.processes
.run(
ChildProcess.make(shell, ShellSelect.args(shell, source), {
cwd: services.location.directory,
stdin: "ignore",
}),
{ combineOutput: true },
)
.pipe(
Effect.map((result) => (result.output ?? Buffer.concat([result.stdout, result.stderr])).toString("utf8")),
Effect.mapError((error) =>
new Error(`Shell interpolation failed for ${JSON.stringify(source)}: ${error.message}`),
),
)
},
{ concurrency: 2 },
)
const iterator = outputs[Symbol.iterator]()
return text.replace(shellRegex, () => iterator.next().value ?? "")
})
}
function parseArguments(input: string) {
return (input.match(argsRegex) ?? []).map((arg) => arg.replace(quoteTrimRegex, ""))
}
const argsRegex = /(?:\[Image\s+\d+\]|"[^"]*"|'[^']*'|[^\s"']+)/gi
const placeholderRegex = /\$(\d+)/g
const quoteTrimRegex = /^["']|["']$/g
const shellRegex = /!`([^`]+)`/g
+193 -186
View File
@@ -1,6 +1,7 @@
export * as SessionCompaction from "./compaction.js"
import { LLMClient, LLMEvent, Message } from "@opencode-ai/ai"
import { LLMClient, AIError, LLMEvent, Message, type LLMRequest } from "@opencode-ai/ai"
import type { StreamOptions } from "@opencode-ai/ai/route"
import { SessionError } from "@opencode-ai/schema/session-error"
import { Context, Effect, Layer, Stream } from "effect"
import { Bus } from "../bus.js"
@@ -64,6 +65,13 @@ export type Draft = {
configure: (settings: Partial<Settings>) => void
}
type Dependencies = {
readonly bus: Bus.Interface
readonly llm: {
readonly stream: (request: LLMRequest, options?: StreamOptions) => Stream.Stream<LLMEvent, AIError>
}
}
export type AutoInput = {
readonly session: SessionSchema.Info
readonly messages: readonly SessionMessage.Info[]
@@ -232,196 +240,195 @@ const planContent = (messages: readonly SessionMessage.Info[], tokens: number) =
}
}
const make = (dependencies: Dependencies) => {
const state = State.create<Settings, Draft>({
name: "session-compaction",
initial: () => ({ auto: true, buffer: DEFAULT_BUFFER, tokens: DEFAULT_KEEP_TOKENS }),
draft: (draft) => ({
configure: (settings) => {
if (settings.auto !== undefined) draft.auto = settings.auto
if (settings.buffer !== undefined) draft.buffer = settings.buffer
if (settings.tokens !== undefined) draft.tokens = settings.tokens
},
}),
})
const failed = Effect.fnUntraced(function* (input: {
readonly sessionID: SessionSchema.ID
readonly reason: SessionMessage.Compaction["reason"]
readonly error: SessionError.Error
readonly inputID?: SessionMessage.ID
}) {
yield* dependencies.bus.publish(SessionEvent.Compaction.Failed, input)
return { status: "failed" as const, error: input.error }
})
const execute = Effect.fn("SessionCompaction.execute")(function* (plan: Plan) {
if (!plan.started)
yield* dependencies.bus.publish(SessionEvent.Compaction.Started, {
sessionID: plan.session.id,
reason: plan.reason,
recent: plan.recent,
inputID: plan.inputID,
})
const chunks: string[] = []
let failure: SessionError.Error | undefined
let usage: SessionUsage.Recorded | undefined
const recordUsage = Effect.suspend(() =>
usage
? dependencies.bus.publish(SessionEvent.UsageRecorded, {
sessionID: plan.session.id,
source: "compaction",
...usage,
})
: Effect.void,
)
const prepared = yield* plan.prepare({
scope: { session: plan.session, agentID: Agent.ID.make("compaction"), model: plan.resolved },
transcript: { system: [], messages: [Message.user(plan.prompt)] },
contextHooks: false,
})
yield* dependencies.llm.stream(prepared.request, prepared.options).pipe(
Stream.runForEach((event) => {
if (LLMEvent.is.providerError(event))
failure = {
type: event.classification === "context-overflow" ? "provider.invalid-request" : "provider.error",
message: event.message,
}
if (LLMEvent.is.textDelta(event)) {
chunks.push(event.text)
return dependencies.bus.publish(SessionEvent.Compaction.Delta, {
sessionID: plan.session.id,
text: event.text,
})
}
if (LLMEvent.is.stepFinish(event)) {
const step = SessionUsage.record(event.usage, plan.resolved.cost)
usage = usage ? SessionUsage.add(usage, step) : step
}
return Effect.void
}),
Effect.catchTag("AI.Error", (error) =>
Effect.sync(() => {
failure = toSessionError(error)
}),
),
Effect.onInterrupt(() =>
recordUsage.pipe(
Effect.andThen(
plan.reason === "auto"
? failed({
sessionID: plan.session.id,
reason: plan.reason,
error: { type: "compaction.interrupted", message: "Compaction was interrupted" },
inputID: plan.inputID,
}).pipe(Effect.asVoid)
: Effect.void,
),
),
),
)
yield* recordUsage
const summary = chunks.join("")
if (failure || !summary.trim()) {
const error = failure ?? { type: "compaction.failed" as const, message: "Compaction produced no summary" }
return yield* failed({
sessionID: plan.session.id,
reason: plan.reason,
error,
inputID: plan.inputID,
})
}
yield* dependencies.bus.publish(SessionEvent.Compaction.Ended, {
sessionID: plan.session.id,
reason: plan.reason,
text: summary,
recent: plan.recent,
})
return { status: "completed" as const }
})
const compact = Effect.fn("SessionCompaction.compact")(function* (input: AutoInput) {
const content = planContent(input.messages, state.get().tokens)
if (content)
return yield* execute({
session: input.session,
resolved: input.resolved,
prepare: input.prepare,
reason: "auto",
...content,
})
return yield* failed({
sessionID: input.session.id,
reason: "auto",
error: { type: "compaction.unavailable", message: "Nothing to compact yet" },
})
})
const required = (input: RequiredInput) => {
const config = state.get()
if (!config.auto) return false
const limit = input.resolved.limit
const context = limit.context
if (context <= 0) return false
const last = input.messages.findLast(
(message): message is SessionMessage.Assistant & { tokens: NonNullable<SessionMessage.Assistant["tokens"]> } =>
message.type === "assistant" && message.tokens !== undefined,
)
if (!last) return false
const output = Math.min(limit.output, OUTPUT_TOKEN_MAX)
const promptCeiling = Math.min(
limit.input === undefined ? Number.POSITIVE_INFINITY : limit.input - config.buffer,
context - Math.max(output, config.buffer),
)
const used =
last.tokens.input + last.tokens.output + last.tokens.reasoning + last.tokens.cache.read + last.tokens.cache.write
if (used <= 0) return false
return used >= promptCeiling
}
const compactManual = Effect.fn("SessionCompaction.compactManual")(function* (input: ManualInput) {
const content = planContent(input.messages, state.get().tokens)
if (!content)
return yield* failed({
sessionID: input.session.id,
reason: "manual",
error: { type: "compaction.unavailable", message: "Nothing to compact yet" },
inputID: input.inputID,
})
const resolved = yield* input.resolveModel(input.session).pipe(
Effect.catch((cause) =>
failed({
sessionID: input.session.id,
reason: "manual",
error: toSessionError(cause),
inputID: input.inputID,
}),
),
)
if ("status" in resolved) return resolved
return yield* execute({
session: input.session,
resolved,
prepare: input.prepare,
reason: "manual",
inputID: input.inputID,
started: input.started,
...content,
})
})
return Service.of({
transform: state.transform,
reload: state.reload,
enabled: () => state.get().auto,
required,
compact,
compactManual,
})
}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const bus = yield* Bus.Service
const llm = yield* LLMClient.Service
const state = State.create<Settings, Draft>({
name: "session-compaction",
initial: () => ({ auto: true, buffer: DEFAULT_BUFFER, tokens: DEFAULT_KEEP_TOKENS }),
draft: (draft) => ({
configure: (settings) => {
if (settings.auto !== undefined) draft.auto = settings.auto
if (settings.buffer !== undefined) draft.buffer = settings.buffer
if (settings.tokens !== undefined) draft.tokens = settings.tokens
},
}),
})
const failed = Effect.fnUntraced(function* (input: {
readonly sessionID: SessionSchema.ID
readonly reason: SessionMessage.Compaction["reason"]
readonly error: SessionError.Error
readonly inputID?: SessionMessage.ID
}) {
yield* bus.publish(SessionEvent.Compaction.Failed, input)
return { status: "failed" as const, error: input.error }
})
const execute = Effect.fn("SessionCompaction.execute")(function* (plan: Plan) {
if (!plan.started)
yield* bus.publish(SessionEvent.Compaction.Started, {
sessionID: plan.session.id,
reason: plan.reason,
recent: plan.recent,
inputID: plan.inputID,
})
const chunks: string[] = []
let failure: SessionError.Error | undefined
let usage: SessionUsage.Recorded | undefined
const recordUsage = Effect.suspend(() =>
usage
? bus.publish(SessionEvent.UsageRecorded, {
sessionID: plan.session.id,
source: "compaction",
...usage,
})
: Effect.void,
)
const prepared = yield* plan.prepare({
scope: { session: plan.session, agentID: Agent.ID.make("compaction"), model: plan.resolved },
transcript: { system: [], messages: [Message.user(plan.prompt)] },
contextHooks: false,
})
yield* llm.stream(prepared.request, prepared.options).pipe(
Stream.runForEach((event) => {
if (LLMEvent.is.providerError(event))
failure = {
type: event.classification === "context-overflow" ? "provider.invalid-request" : "provider.error",
message: event.message,
}
if (LLMEvent.is.textDelta(event)) {
chunks.push(event.text)
return bus.publish(SessionEvent.Compaction.Delta, {
sessionID: plan.session.id,
text: event.text,
})
}
if (LLMEvent.is.stepFinish(event)) {
const step = SessionUsage.record(event.usage, plan.resolved.cost)
usage = usage ? SessionUsage.add(usage, step) : step
}
return Effect.void
}),
Effect.catchTag("AI.Error", (error) =>
Effect.sync(() => {
failure = toSessionError(error)
}),
),
Effect.onInterrupt(() =>
recordUsage.pipe(
Effect.andThen(
plan.reason === "auto"
? failed({
sessionID: plan.session.id,
reason: plan.reason,
error: { type: "compaction.interrupted", message: "Compaction was interrupted" },
inputID: plan.inputID,
}).pipe(Effect.asVoid)
: Effect.void,
),
),
),
)
yield* recordUsage
const summary = chunks.join("")
if (failure || !summary.trim()) {
const error = failure ?? { type: "compaction.failed" as const, message: "Compaction produced no summary" }
return yield* failed({
sessionID: plan.session.id,
reason: plan.reason,
error,
inputID: plan.inputID,
})
}
yield* bus.publish(SessionEvent.Compaction.Ended, {
sessionID: plan.session.id,
reason: plan.reason,
text: summary,
recent: plan.recent,
})
return { status: "completed" as const }
})
const compact = Effect.fn("SessionCompaction.compact")(function* (input: AutoInput) {
const content = planContent(input.messages, state.get().tokens)
if (content)
return yield* execute({
session: input.session,
resolved: input.resolved,
prepare: input.prepare,
reason: "auto",
...content,
})
return yield* failed({
sessionID: input.session.id,
reason: "auto",
error: { type: "compaction.unavailable", message: "Nothing to compact yet" },
})
})
const required = (input: RequiredInput) => {
const config = state.get()
if (!config.auto) return false
const limit = input.resolved.limit
const context = limit.context
if (context <= 0) return false
const last = input.messages.findLast(
(message): message is SessionMessage.Assistant & { tokens: NonNullable<SessionMessage.Assistant["tokens"]> } =>
message.type === "assistant" && message.tokens !== undefined,
)
if (!last) return false
const output = Math.min(limit.output, OUTPUT_TOKEN_MAX)
const promptCeiling = Math.min(
limit.input === undefined ? Number.POSITIVE_INFINITY : limit.input - config.buffer,
context - Math.max(output, config.buffer),
)
const used =
last.tokens.input +
last.tokens.output +
last.tokens.reasoning +
last.tokens.cache.read +
last.tokens.cache.write
if (used <= 0) return false
return used >= promptCeiling
}
const compactManual = Effect.fn("SessionCompaction.compactManual")(function* (input: ManualInput) {
const content = planContent(input.messages, state.get().tokens)
if (!content)
return yield* failed({
sessionID: input.session.id,
reason: "manual",
error: { type: "compaction.unavailable", message: "Nothing to compact yet" },
inputID: input.inputID,
})
const resolved = yield* input.resolveModel(input.session).pipe(
Effect.catch((cause) =>
failed({
sessionID: input.session.id,
reason: "manual",
error: toSessionError(cause),
inputID: input.inputID,
}),
),
)
if ("status" in resolved) return resolved
return yield* execute({
session: input.session,
resolved,
prepare: input.prepare,
reason: "manual",
inputID: input.inputID,
started: input.started,
...content,
})
})
return Service.of({
transform: state.transform,
reload: state.reload,
enabled: () => state.get().auto,
required,
compact,
compactManual,
})
return make({ bus, llm })
}),
)
+1
View File
@@ -222,6 +222,7 @@ const layer = Layer.effect(
agent: loaded.agent.id,
model: loaded.model,
prepared,
toolsDisabled: stepLimitReached,
recoverContinuation,
recoverOverflow: Effect.suspend(() =>
recoverOverflow && compaction.enabled()
+22 -11
View File
@@ -44,6 +44,7 @@ interface Input {
readonly agent: Agent.ID
readonly model: SessionRunnerModel.Resolved
readonly prepared: SessionModelRequest.Prepared
readonly toolsDisabled: boolean
readonly recoverContinuation: boolean
/** The runner owns compaction policy; the attempt invokes it only before durable output. */
readonly recoverOverflow: Effect.Effect<boolean>
@@ -72,12 +73,11 @@ export const make = Effect.gen(function* () {
})
const toolRuns: Array<{
readonly call: ToolCall
readonly fiber: Fiber.Fiber<void, Permission.DeclinedError | QuestionTool.CancelledError>
readonly fiber: Fiber.Fiber<void, SessionModelRequest.ExecuteError>
}> = []
const interruptTools = Effect.suspend(() => Fiber.interruptAll(toolRuns.map((run) => run.fiber)))
const executeTool = (call: ToolCall) => {
if (input.prepared.request.toolChoice?.type === "none")
return new Tool.Error({ message: "Tools are disabled after the maximum agent steps" })
if (input.toolsDisabled) return new Tool.Error({ message: "Tools are disabled after the maximum agent steps" })
return input.prepared.executeTool({
sessionID: input.sessionID,
agent: input.agent,
@@ -132,7 +132,10 @@ export const make = Effect.gen(function* () {
if (streamInterrupted) yield* interruptTools
const joined = yield* restore(Fiber.awaitAll(toolRuns.map((run) => run.fiber))).pipe(Effect.exit)
if (Exit.isFailure(joined)) yield* interruptTools
const tools = classifyToolExits(joined, toolRuns)
const tools = classifyToolExits(
joined,
toolRuns.map((run) => run.call),
)
if (
!publisher.record().outputStarted &&
@@ -237,7 +240,7 @@ export const make = Effect.gen(function* () {
if (tools.interrupted && Exit.isFailure(joined)) return yield* Effect.failCause(joined.cause)
if (record.failure) return yield* new StepFailedError({ error: record.failure })
return Outcome.Completed({
needsContinuation: input.prepared.request.toolChoice?.type !== "none" && record.needsContinuation,
needsContinuation: !input.toolsDisabled && record.needsContinuation,
})
}),
)
@@ -246,22 +249,27 @@ export const make = Effect.gen(function* () {
return { attempt }
})
const isDecline = (
error: SessionModelRequest.ExecuteError,
): error is Permission.DeclinedError | QuestionTool.CancelledError =>
error._tag === "Permission.DeclinedError" || error._tag === "QuestionTool.CancelledError"
const isInterruptedStream = (failure: AIError) => {
if (failure.reason._tag === "InvalidProviderOutput") return failure.reason.classification === "incomplete-stream"
if (failure.reason._tag === "Transport") return failure.reason.operation === "read"
return false
}
/** Tool.Error settles in each fiber; only user declines remain in the typed error channel. */
/** Keep every joined exit associated with its call; a decline is not an infrastructure failure. */
const classifyToolExits = (
settled: Exit.Exit<Array<Exit.Exit<void, Permission.DeclinedError | QuestionTool.CancelledError>>>,
runs: ReadonlyArray<{ readonly call: ToolCall }>,
settled: Exit.Exit<Array<Exit.Exit<void, SessionModelRequest.ExecuteError>>>,
calls: ReadonlyArray<ToolCall>,
) => {
const exits = Exit.isSuccess(settled) ? settled.value : []
const declines = exits.flatMap((exit, index) =>
Exit.isFailure(exit)
? exit.cause.reasons.flatMap((reason) =>
Cause.isFailReason(reason) ? [{ call: runs[index].call, reason: reason.error }] : [],
Cause.isFailReason(reason) && isDecline(reason.error) ? [{ call: calls[index], reason: reason.error }] : [],
)
: [],
)
@@ -271,8 +279,11 @@ const classifyToolExits = (
const failure = causes
.flatMap((cause) => {
if (Cause.hasInterrupts(cause)) return []
const reasons = cause.reasons.filter(Cause.isDieReason)
return reasons.length > 0 ? [Cause.fromReasons<never>(reasons)] : []
const reasons = cause.reasons.flatMap(
(reason): Array<Cause.Reason<never>> =>
Cause.isFailReason(reason) ? (isDecline(reason.error) ? [] : [Cause.makeDieReason(reason.error)]) : [reason],
)
return reasons.length > 0 ? [Cause.fromReasons(reasons)] : []
})
.at(0)
return { interrupted: causes.some(Cause.hasInterrupts), declines, failure }
+126 -104
View File
@@ -1,7 +1,8 @@
export * as SessionTitle from "./title.js"
import { isDeepStrictEqual } from "node:util"
import { LLMClient, LLMEvent, Message, SystemPart } from "@opencode-ai/ai"
import { LLMClient, AIError, LLMEvent, Message, SystemPart, type LLMRequest } from "@opencode-ai/ai"
import type { StreamOptions } from "@opencode-ai/ai/route"
import { Context, DateTime, Effect, Layer, Stream } from "effect"
import type { Agent } from "../agent.js"
import { Database } from "../database/database.js"
@@ -22,6 +23,15 @@ const MAX_CONTEXT_LENGTH = 8_000
const MAX_FIRST_MESSAGE_LENGTH = 2_000
const titleChanged = Symbol("Session title changed")
type Dependencies = {
readonly bus: Bus.Interface
readonly llm: {
readonly stream: (request: LLMRequest, options?: StreamOptions) => Stream.Stream<LLMEvent, AIError>
}
readonly context: SessionContext.Interface
readonly store: SessionStore.Interface
}
export interface Interface {
/** Generates an initial title or regenerates one from bounded conversation history. */
readonly generate: (sessionID: SessionSchema.ID) => Effect.Effect<void>
@@ -36,6 +46,117 @@ export const isUntitled = (session: SessionSchema.Info) =>
time: { created: DateTime.toEpochMillis(session.time.created) },
})
const attempt = Effect.fn("SessionTitle.attempt")(function* (
dependencies: Dependencies,
input: {
readonly session: SessionSchema.Info
readonly agent: Agent.Info
readonly text: string
readonly model: SessionRunnerModel.Resolved
},
) {
const chunks: string[] = []
let failed = false
let usage: SessionUsage.Recorded | undefined
const recordUsage = Effect.suspend(() =>
usage
? dependencies.bus.publish(SessionEvent.UsageRecorded, {
sessionID: input.session.id,
source: "title",
...usage,
})
: Effect.void,
)
const prepared = yield* dependencies.context.prepare({
scope: { session: input.session, agentID: input.agent.id, model: input.model },
transcript: {
system: input.agent.system ? [SystemPart.make(input.agent.system)] : [],
messages: [Message.user(input.text)],
},
contextHooks: false,
})
yield* dependencies.llm.stream(prepared.request, prepared.options).pipe(
Stream.runForEach((event) => {
if (LLMEvent.is.providerError(event)) failed = true
if (LLMEvent.is.textDelta(event)) chunks.push(event.text)
if (LLMEvent.is.stepFinish(event)) {
const step = SessionUsage.record(event.usage, input.model.cost)
usage = usage ? SessionUsage.add(usage, step) : step
}
return Effect.void
}),
Effect.catchTag("AI.Error", () =>
Effect.sync(() => {
failed = true
}),
),
Effect.onInterrupt(() => recordUsage.pipe(Effect.asVoid)),
)
yield* recordUsage
if (failed) return
return chunks
.join("")
.split("\n")
.map((line) => line.trim())
.find((line) => line.length > 0)
})
const make = (dependencies: Dependencies) => {
const generate = Effect.fn("SessionTitle.generate")(function* (
db: Database.Interface["db"],
sessionID: SessionSchema.ID,
) {
const session = yield* dependencies.store.get(sessionID)
if (!session) return
const firstUser = yield* SessionHistory.firstUserMessage(db, session.id)
if (!firstUser) return
const text = !isUntitled(session)
? yield* dependencies.store.context(session.id).pipe(
Effect.map((messages) => {
const original = `Original request:\n${firstUser.text.slice(0, MAX_FIRST_MESSAGE_LENGTH)}`
const recent = messages
.flatMap((message) => {
if (message.type === "user" && message.id !== firstUser.id) return [`User: ${message.text.trim()}`]
if (message.type !== "assistant") return []
const text = message.content
.flatMap((part) => (part.type === "text" ? [part.text.trim()] : []))
.filter(Boolean)
.join("\n")
return text ? [`Assistant: ${text}`] : []
})
.join("\n\n")
if (!recent) return original
const prefix = `${original}\n\nRecent conversation:\n`
return `${prefix}${recent.slice(-(MAX_CONTEXT_LENGTH - prefix.length))}`
}),
Effect.orElseSucceed(() => firstUser.text),
)
: firstUser.text
const selection = yield* dependencies.context.selectTitle(session)
if (!selection) return
const title =
(yield* attempt(dependencies, { session, agent: selection.agent, text, model: selection.selected })) ??
(selection.primary && !isDeepStrictEqual(selection.selected.ref, selection.primary.ref)
? yield* attempt(dependencies, { session, agent: selection.agent, text, model: selection.primary })
: undefined)
if (!title) return
const expectedSequence = (yield* Bus.latestSequence(db, sessionID)) + 1
const current = yield* dependencies.store.get(sessionID)
if (!current || current.title !== session.title || current.title === truncate(title)) return
yield* dependencies.bus
.publish(
SessionEvent.Renamed,
{
sessionID: session.id,
title: truncate(title),
},
{ commit: (sequence) => (sequence === expectedSequence ? Effect.void : Effect.die(titleChanged)) },
)
.pipe(Effect.catchDefect((defect) => (defect === titleChanged ? Effect.void : Effect.die(defect))))
})
return { generate }
}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
@@ -43,110 +164,11 @@ export const layer = Layer.effect(
const llm = yield* LLMClient.Service
const context = yield* SessionContext.Service
const store = yield* SessionStore.Service
const db = (yield* Database.Service).db
const attempt = Effect.fn("SessionTitle.attempt")(function* (input: {
readonly session: SessionSchema.Info
readonly agent: Agent.Info
readonly text: string
readonly model: SessionRunnerModel.Resolved
}) {
const chunks: string[] = []
let failed = false
let usage: SessionUsage.Recorded | undefined
const recordUsage = Effect.suspend(() =>
usage
? bus.publish(SessionEvent.UsageRecorded, {
sessionID: input.session.id,
source: "title",
...usage,
})
: Effect.void,
)
const prepared = yield* context.prepare({
scope: { session: input.session, agentID: input.agent.id, model: input.model },
transcript: {
system: input.agent.system ? [SystemPart.make(input.agent.system)] : [],
messages: [Message.user(input.text)],
},
contextHooks: false,
})
yield* llm.stream(prepared.request, prepared.options).pipe(
Stream.runForEach((event) => {
if (LLMEvent.is.providerError(event)) failed = true
if (LLMEvent.is.textDelta(event)) chunks.push(event.text)
if (LLMEvent.is.stepFinish(event)) {
const step = SessionUsage.record(event.usage, input.model.cost)
usage = usage ? SessionUsage.add(usage, step) : step
}
return Effect.void
}),
Effect.catchTag("AI.Error", () =>
Effect.sync(() => {
failed = true
}),
),
Effect.onInterrupt(() => recordUsage.pipe(Effect.asVoid)),
)
yield* recordUsage
if (failed) return
return chunks
.join("")
.split("\n")
.map((line) => line.trim())
.find((line) => line.length > 0)
const database = yield* Database.Service
const title = make({ bus, llm, context, store })
return Service.of({
generate: (sessionID) => title.generate(database.db, sessionID),
})
const generate = Effect.fn("SessionTitle.generate")(function* (sessionID: SessionSchema.ID) {
const session = yield* store.get(sessionID)
if (!session) return
const firstUser = yield* SessionHistory.firstUserMessage(db, session.id)
if (!firstUser) return
const text = !isUntitled(session)
? yield* store.context(session.id).pipe(
Effect.map((messages) => {
const original = `Original request:\n${firstUser.text.slice(0, MAX_FIRST_MESSAGE_LENGTH)}`
const recent = messages
.flatMap((message) => {
if (message.type === "user" && message.id !== firstUser.id) return [`User: ${message.text.trim()}`]
if (message.type !== "assistant") return []
const text = message.content
.flatMap((part) => (part.type === "text" ? [part.text.trim()] : []))
.filter(Boolean)
.join("\n")
return text ? [`Assistant: ${text}`] : []
})
.join("\n\n")
if (!recent) return original
const prefix = `${original}\n\nRecent conversation:\n`
return `${prefix}${recent.slice(-(MAX_CONTEXT_LENGTH - prefix.length))}`
}),
Effect.orElseSucceed(() => firstUser.text),
)
: firstUser.text
const selection = yield* context.selectTitle(session)
if (!selection) return
const title =
(yield* attempt({ session, agent: selection.agent, text, model: selection.selected })) ??
(selection.primary && !isDeepStrictEqual(selection.selected.ref, selection.primary.ref)
? yield* attempt({ session, agent: selection.agent, text, model: selection.primary })
: undefined)
if (!title) return
const expectedSequence = (yield* Bus.latestSequence(db, sessionID)) + 1
const current = yield* store.get(sessionID)
if (!current || current.title !== session.title || current.title === truncate(title)) return
yield* bus
.publish(
SessionEvent.Renamed,
{
sessionID: session.id,
title: truncate(title),
},
{ commit: (sequence) => (sequence === expectedSequence ? Effect.void : Effect.die(titleChanged)) },
)
.pipe(Effect.catchDefect((defect) => (defect === titleChanged ? Effect.void : Effect.die(defect))))
})
return Service.of({ generate })
}),
)
@@ -1,215 +0,0 @@
import { describe, expect } from "bun:test"
import path from "path"
import { DateTime, Effect, Layer } from "effect"
import { CommandInvocation } from "@opencode-ai/core/command/invocation"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { Location } from "@opencode-ai/core/location"
import { ShellSelect } from "@opencode-ai/core/shell/select"
import { Agent } from "@opencode-ai/schema/agent"
import { ConfigCommand } from "@opencode-ai/schema/config/command"
import { Model } from "@opencode-ai/schema/model"
import { Money } from "@opencode-ai/schema/money"
import { Provider } from "@opencode-ai/schema/provider"
import { Session } from "@opencode-ai/schema/session"
import { SessionInbox } from "@opencode-ai/schema/session-inbox"
import { SessionMessage } from "@opencode-ai/schema/session-message"
import { AppProcess } from "@opencode-ai/util/process"
import { tempLocationLayer } from "../fixture/location"
import { testEffect } from "../lib/effect"
import { host } from "../plugin/host"
const shell = ShellSelect.Service.of({
resolve: (input) =>
Effect.sync(() => {
expect(input).toEqual({ priority: "config" })
return "sh"
}),
transform: () => Effect.die("unused shell.transform"),
reload: () => Effect.die("unused shell.reload"),
})
const it = testEffect(
Layer.mergeAll(AppNodeBuilder.build(AppProcess.node), tempLocationLayer, Layer.succeed(ShellSelect.Service, shell)),
)
const sessionID = Session.ID.make("ses_command_invocation")
describe("CommandInvocation", () => {
it.effect("expands arguments without changing unconfigured session defaults or prompt attachments", () =>
Effect.gen(function* () {
const prompts: unknown[] = []
const invoke = yield* CommandInvocation.make(promptHost(prompts))
const files = [{ uri: "file:///context.md", name: "context" }]
for (const [template, text, expected] of [
[
"$2 / $1 / $2",
`"alpha beta" 'gamma delta' [Image 3] tail`,
"gamma delta [Image 3] tail / alpha beta / gamma delta [Image 3] tail",
],
["[$1][$3]", "one two", "[one][]"],
["raw [$ARGUMENTS]", `"alpha beta" 'gamma delta'`, `raw ["alpha beta" 'gamma delta']`],
[" Review ", " details ", "Review \n\n details"],
[" Review ", " ", "Review"],
]) {
expect(
yield* invoke(new ConfigCommand.Info({ template }), {
sessionID,
prompt: { text, files },
delivery: "queue",
}),
).toBeUndefined()
expect(prompts.at(-1)).toEqual({ sessionID, text: expected, files, delivery: "queue" })
}
}),
)
it.effect("switches agents before applying command or agent model defaults and admitting the prompt", () =>
Effect.gen(function* () {
const calls: unknown[] = []
const ctx = promptHost(calls)
const location = yield* Location.Service
const reviewer = Agent.ID.make("reviewer")
const agentModel = { id: Model.ID.make("agent-model"), providerID: Provider.ID.make("example") }
const commandModel = {
model: Model.ID.make("command-model"),
providerID: Provider.ID.make("example"),
variant: Model.VariantID.make("careful"),
}
const session = Session.Info.make({
id: sessionID,
projectID: location.project.id,
agent: Agent.ID.make("build"),
cost: Money.USD.zero,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: DateTime.makeUnsafe(0), updated: DateTime.makeUnsafe(0) },
location: { directory: location.directory },
})
for (const testCase of [
{
currentAgent: session.agent,
agentModel,
command: new ConfigCommand.Info({ template: "Review", agent: reviewer, model: commandModel }),
expected: [
["session.get", { sessionID }],
["switchAgent", { sessionID, agent: reviewer }],
["agent.get", { agentID: reviewer }],
["switchModel", { sessionID, model: { id: "command-model", providerID: "example", variant: "careful" } }],
],
},
{
currentAgent: reviewer,
agentModel,
command: new ConfigCommand.Info({ template: "Review", agent: reviewer }),
expected: [
["session.get", { sessionID }],
["agent.get", { agentID: reviewer }],
["switchModel", { sessionID, model: agentModel }],
],
},
{
currentAgent: session.agent,
agentModel: undefined,
command: new ConfigCommand.Info({ template: "Review", agent: reviewer }),
expected: [
["session.get", { sessionID }],
["switchAgent", { sessionID, agent: reviewer }],
["agent.get", { agentID: reviewer }],
],
},
{
currentAgent: session.agent,
agentModel,
command: new ConfigCommand.Info({
template: "Review",
model: { model: commandModel.model, providerID: commandModel.providerID },
}),
expected: [["switchModel", { sessionID, model: { id: "command-model", providerID: "example" } }]],
},
]) {
calls.length = 0
const invoke = yield* CommandInvocation.make(
host({
agent: {
...ctx.agent,
get: (input) =>
Effect.sync(() => {
calls.push(["agent.get", input])
return { location, data: { ...Agent.Info.default(reviewer), model: testCase.agentModel } }
}),
},
session: {
...ctx.session,
get: (input) =>
Effect.sync(() => {
calls.push(["session.get", input])
return { ...session, agent: testCase.currentAgent }
}),
switchAgent: (input) => Effect.sync(() => calls.push(["switchAgent", input])),
switchModel: (input) => Effect.sync(() => calls.push(["switchModel", input])),
},
}),
)
yield* invoke(testCase.command, {
sessionID,
prompt: { text: "" },
delivery: "steer",
})
expect(calls).toEqual([...testCase.expected, { sessionID, text: "Review", delivery: "steer" }])
}
}),
)
it.live("interpolates in source order using the location, closed stdin and nonzero-exit output", () =>
Effect.gen(function* () {
const prompts: unknown[] = []
const location = yield* Location.Service
yield* Effect.promise(() => Bun.write(path.join(location.directory, "context.txt"), "context"))
const invoke = yield* CommandInvocation.make(promptHost(prompts))
yield* invoke(
new ConfigCommand.Info({
template:
'first=!`read value || printf closed-; cat context.txt; sleep 0.05; printf "%s" "-stderr" >&2; exit 7`; second=!`printf "%s" "$1"`',
}),
{ sessionID, prompt: { text: "argument" }, delivery: "steer" },
)
expect(prompts).toEqual([{ sessionID, text: "first=closed-context-stderr; second=argument", delivery: "steer" }])
}),
)
it.live("wraps process failures with the shell source and does not admit a prompt", () =>
Effect.gen(function* () {
const prompts: unknown[] = []
const location = yield* Location.Service
const missing = path.join(location.directory, "missing-shell")
const invoke = yield* CommandInvocation.make(promptHost(prompts)).pipe(
Effect.provideService(ShellSelect.Service, { ...shell, resolve: () => Effect.succeed(missing) }),
)
const error = yield* invoke(new ConfigCommand.Info({ template: '!`printf "hello"`' }), {
sessionID,
prompt: { text: "" },
delivery: "steer",
}).pipe(Effect.flip)
expect(error).toBeInstanceOf(Error)
expect(String(error)).toContain('Shell interpolation failed for "printf \\"hello\\"": Command failed:')
expect(String(error)).toContain(missing)
expect(prompts).toEqual([])
}),
)
})
function promptHost(prompts: unknown[]) {
return host({
session: {
prompt: (input) =>
Effect.sync(() => {
prompts.push(input)
return SessionInbox.User.make({
id: SessionMessage.ID.make("msg_command_invocation"),
sessionID: input.sessionID,
timeCreated: DateTime.makeUnsafe(0),
type: "user",
payload: { text: input.text },
delivery: input.delivery ?? "steer",
})
}),
},
})
}
+2 -15
View File
@@ -3811,18 +3811,11 @@ describe("SessionRunnerLLM", () => {
])
})
scenario("interrupts runner continuation on a decline after settling an ordinary tool error", function* (s) {
scenario("interrupts runner continuation when permission approval is declined", function* (s) {
const registry = yield* Tool.Service
yield* transformTools(
registry,
{
failed: {
name: "failed",
description: "Fail normally before the declined call",
input: Schema.Struct({}),
output: Schema.Struct({}),
execute: () => Effect.fail(new Tool.Error({ message: "Ordinary tool failure" })),
},
declined: {
name: "declined",
description: "Fail because the user declined approval",
@@ -3835,12 +3828,7 @@ describe("SessionRunnerLLM", () => {
)
yield* s.admit("Call declined")
yield* s.llm.push(
TestLLM.toolCalls(
LLMEvent.toolCall({ id: "call-failed", name: "failed", input: {} }),
LLMEvent.toolCall({ id: "call-declined", name: "declined", input: {} }),
),
)
yield* s.llm.push(TestLLM.tool("call-declined", "declined", {}))
const exit = yield* s.resume.pipe(Effect.exit)
@@ -3850,7 +3838,6 @@ describe("SessionRunnerLLM", () => {
expect(yield* s.context).toMatchObject([
Expected.user("Call declined"),
Expected.assistant({}, [
Expected.failedTool({ id: "call-failed" }, { error: { message: "Ordinary tool failure" } }),
Expected.failedTool(
{ id: "call-declined" },
{ error: { type: "aborted", message: "The user declined this tool call" } },
+11 -26
View File
@@ -33,12 +33,8 @@ const it = testEffect(
),
)
for (const fixture of [
{ finish: "stop", toolChoice: undefined },
{ finish: "content-filter", toolChoice: undefined },
{ finish: "stop", toolChoice: "none" },
] as const) {
it.effect(`settles ${fixture.finish} with tool choice ${fixture.toolChoice ?? "default"}`, () =>
for (const finish of ["stop", "content-filter"] as const) {
it.effect(`settles ${finish} with snapshot files and nonzero usage after its tool`, () =>
Effect.gen(function* () {
const db = (yield* Database.Service).db
const llm = yield* TestLLM.Test
@@ -48,7 +44,6 @@ for (const fixture of [
const end = Snapshot.ID.make("after")
const files = [RelativePath.make("changed.ts")]
let captures = 0
let executions = 0
const steps = yield* SessionStep.make.pipe(
Effect.provide(
Layer.mock(Snapshot.Service)({
@@ -85,7 +80,7 @@ for (const fixture of [
yield* llm.push(
TestLLM.complete(
{
reason: { normalized: fixture.finish },
reason: { normalized: finish },
usage: {
inputTokens: 15,
outputTokens: 6,
@@ -105,24 +100,16 @@ for (const fixture of [
agent: Agent.defaultID,
model,
prepared: {
request: LLM.request({ model: model.model, prompt: "Run one tool", toolChoice: fixture.toolChoice }),
request: LLM.request({ model: model.model, prompt: "Run one tool" }),
options: {},
executeTool: () =>
Effect.sync(() => {
executions++
return { content: "Completed tool" }
}),
executeTool: () => Effect.succeed({ content: "Completed tool" }),
},
toolsDisabled: false,
recoverContinuation: true,
recoverOverflow: Effect.succeed(false),
})
.pipe(Effect.exit)
expect(Exit.isSuccess(result)).toBe(fixture.finish === "stop")
expect(executions).toBe(fixture.toolChoice === "none" ? 0 : 1)
if (Exit.isSuccess(result))
expect(result.value).toEqual(
SessionStep.Outcome.Completed({ needsContinuation: fixture.toolChoice !== "none" }),
)
expect(Exit.isSuccess(result)).toBe(finish === "stop")
expect(yield* llm.requests()).toHaveLength(1)
expect(captures).toBe(2)
const message = yield* db
@@ -131,10 +118,10 @@ for (const fixture of [
.where(eq(SessionMessageTable.id, assistantMessageID))
.get()
expect(message?.data).toMatchObject({
finish: fixture.finish,
finish,
tokens: { input: 10, output: 4, reasoning: 2, cache: { read: 3, write: 2 } },
snapshot: { start, end, files },
content: [{ type: "tool", state: { status: fixture.toolChoice === "none" ? "error" : "completed" } }],
content: [{ type: "tool", state: { status: "completed" } }],
})
expect(message?.data).toHaveProperty("cost", expect.closeTo(0.0000233, 10))
const events = yield* db
@@ -144,11 +131,9 @@ for (const fixture of [
.orderBy(asc(EventTable.seq))
.all()
const types = events.map((event) => event.type)
const terminal = fixture.finish === "stop" ? "session.step.ended.1" : "session.step.failed.1"
const terminal = finish === "stop" ? "session.step.ended.1" : "session.step.failed.1"
expect(types.filter((type) => type === terminal)).toHaveLength(1)
expect(
types.indexOf(fixture.toolChoice === "none" ? "session.tool.failed.2" : "session.tool.success.2"),
).toBeLessThan(types.indexOf(terminal))
expect(types.indexOf("session.tool.success.2")).toBeLessThan(types.indexOf(terminal))
}),
)
}
+39 -5
View File
@@ -57,6 +57,7 @@ export function FormPrompt(props: { form: FormWithLocation }) {
const [tabHover, setTabHover] = createSignal<number | "confirm" | null>(null)
const [reviewHeight, setReviewHeight] = createSignal(1)
const [reviewScrollable, setReviewScrollable] = createSignal(false)
const [submitting, setSubmitting] = createSignal<"reply" | "cancel">()
const [store, setStore] = createStore({
tab: 0,
answers: initial.answers,
@@ -219,6 +220,10 @@ export function FormPrompt(props: { form: FormWithLocation }) {
onCleanup(
keymap.intercept("key", ({ event, consume }) => {
if (keymap.mode.current() !== FORM_MODE) return
if (submitting()) {
consume()
return
}
if (textual() || !other() || (store.editing && renderer.currentFocusedEditor === textarea)) return
if (event.ctrl || event.meta || event.option || event.super || event.hyper) return
if ((!store.editing && event.sequence === " ") || !/^[^\p{C}\p{Zl}\p{Zp}]$/u.test(event.sequence)) return
@@ -243,6 +248,9 @@ export function FormPrompt(props: { form: FormWithLocation }) {
}
function reply(answer: FormAnswer) {
if (submitting()) return
setStore("error", "")
setSubmitting("reply")
void data.session.form
.reply({ sessionID: props.form.sessionID, formID: props.form.id, answer }, props.form.location)
.catch(showError)
@@ -329,6 +337,10 @@ export function FormPrompt(props: { form: FormWithLocation }) {
usePaste((event) => {
if (keymap.mode.current() !== FORM_MODE) return
if (submitting()) {
event.preventDefault()
return
}
const value = stripAnsiSequences(decodePasteBytes(event.bytes)).replace(/\r\n?/g, "\n")
if (store.editing && renderer.currentFocusedEditor === textarea) {
textarea.insertText(value)
@@ -343,6 +355,7 @@ export function FormPrompt(props: { form: FormWithLocation }) {
return clipboard
.read()
.then((content) => {
if (submitting()) return
if (content?.mime !== "text/plain") return
const value = stripAnsiSequences(content.data).replace(/\r\n?/g, "\n")
if (store.editing || textual()) {
@@ -441,13 +454,22 @@ export function FormPrompt(props: { form: FormWithLocation }) {
}
function cancel() {
if (submitting()) return
const editor = textarea?.focused ? textarea : undefined
editor?.blur()
setStore("error", "")
setSubmitting("cancel")
void data.session.form
.cancel({ sessionID: props.form.sessionID, formID: props.form.id }, props.form.location)
.catch(showError)
.catch((error: unknown) => {
showError(error)
if (editor && !editor.isDestroyed) editor.focus()
})
}
function showError(error: unknown) {
setStore("error", errorMessage(error))
setSubmitting(undefined)
}
function openExternal() {
@@ -747,7 +769,16 @@ export function FormPrompt(props: { form: FormWithLocation }) {
borderColor={theme.hue.interactive[themeMode() === "light" ? 800 : 200]}
customBorderChars={SplitBorder.customBorderChars}
>
<box gap={1} paddingLeft={1} paddingRight={3} paddingTop={1} paddingBottom={1}>
<Show when={submitting()}>
<box padding={1} paddingLeft={2} gap={1}>
<text fg={theme.text.subdued}>{props.form.title}</text>
<text fg={theme.text.feedback.info.default}>
{submitting() === "reply" ? "Sending answers..." : "Dismissing form..."}
</text>
</box>
</Show>
{/* Keep the controls mounted so a failed request retains uncommitted editor text. */}
<box visible={!submitting()} gap={1} paddingLeft={1} paddingRight={3} paddingTop={1} paddingBottom={1}>
<box paddingLeft={1}>
<text fg={theme.text.subdued}>{props.form.title}</text>
</box>
@@ -1102,6 +1133,7 @@ export function FormPrompt(props: { form: FormWithLocation }) {
</Show>
</box>
<box
visible={!submitting()}
flexDirection="row"
flexShrink={0}
gap={1}
@@ -1145,10 +1177,12 @@ export function FormPrompt(props: { form: FormWithLocation }) {
esc <span style={{ fg: theme.text.subdued }}>{store.editing && !textual() ? "close" : "dismiss"}</span>
</text>
</box>
<Show when={store.error}>
<text fg={theme.text.feedback.error.default}>{store.error}</text>
</Show>
</box>
<Show when={store.error}>
<box paddingLeft={2} paddingRight={3} paddingBottom={1}>
<text fg={theme.text.feedback.error.default}>{store.error}</text>
</box>
</Show>
</box>
)
}
+96 -22
View File
@@ -14,7 +14,7 @@ import { Keymap } from "../../context/keymap"
import { usePathFormatter } from "../../context/path-format"
import { SimulationSemantics } from "../../simulation/semantics"
import { PatchDiff } from "../../component/patch-diff"
import { useToast } from "../../ui/toast"
import { errorMessage } from "../../util/error"
type PermissionStage = "permission" | "always" | "reject"
@@ -111,9 +111,10 @@ function EditBody(props: { file?: string; diff?: string; patch?: string }) {
export function PermissionPrompt(props: { request: PermissionRequest; directory?: string }) {
const data = useData()
const toast = useToast()
const [store, setStore] = createStore({
stage: "permission" as PermissionStage,
submitting: undefined as PermissionReply | undefined,
error: "",
})
const pathFormatter = usePathFormatter()
const session = createMemo(() => data.session.get(props.request.sessionID))
@@ -131,11 +132,23 @@ export function PermissionPrompt(props: { request: PermissionRequest; directory?
})
const theme = useTheme()
const submitting = createMemo(() => {
if (store.submitting === "once") return "Sending approval..."
if (store.submitting === "always") return "Sending always-allow approval..."
if (store.submitting === "reject") return "Sending rejection..."
return undefined
})
function reply(value: PermissionReply, message?: string) {
if (store.submitting) return
setStore("error", "")
setStore("submitting", value)
void data.session.permission
.reply({ sessionID: props.request.sessionID, requestID: props.request.id, reply: value, message })
.catch((error: unknown) => toast.error(error))
.catch((error: unknown) => {
setStore("error", errorMessage(error))
setStore("submitting", undefined)
})
}
return (
@@ -154,9 +167,14 @@ export function PermissionPrompt(props: { request: PermissionRequest; directory?
}
options={{ confirm: permissionOptionLabel("confirm"), cancel: permissionOptionLabel("cancel") }}
escapeKey="cancel"
submitting={submitting()}
error={store.error}
onSelect={(option) => {
setStore("stage", "permission")
if (option === "cancel") return
if (store.submitting) return
if (option === "cancel") {
setStore("stage", "permission")
return
}
reply("always")
}}
/>
@@ -165,10 +183,13 @@ export function PermissionPrompt(props: { request: PermissionRequest; directory?
<RejectPrompt
action={props.request.action}
instance={props.request.id}
submitting={submitting()}
error={store.error}
onConfirm={(message) => {
reply("reject", message || undefined)
}}
onCancel={() => {
if (store.submitting) return
setStore("stage", "permission")
}}
/>
@@ -252,7 +273,10 @@ export function PermissionPrompt(props: { request: PermissionRequest; directory?
}
escapeKey="reject"
fullscreen
submitting={submitting()}
error={store.error}
onSelect={(option) => {
if (store.submitting) return
if (option === "always") {
setStore("stage", "always")
return
@@ -284,6 +308,8 @@ export function permissionSemanticLabel(action: string, title?: string) {
function RejectPrompt(props: {
action: string
instance: string
submitting?: string
error?: string
onConfirm: (message: string) => void
onCancel: () => void
}) {
@@ -300,6 +326,7 @@ function RejectPrompt(props: {
title: "Cancel permission rejection",
group: "Permission",
run(_input, event) {
if (props.submitting) return
if (event?.ctrl && event.name === "c" && input.plainText) {
input.setText("")
return
@@ -307,12 +334,21 @@ function RejectPrompt(props: {
props.onCancel()
},
},
{ bind: "escape", title: "Cancel permission rejection", group: "Permission", run: () => props.onCancel() },
{
bind: "escape",
title: "Cancel permission rejection",
group: "Permission",
run: () => {
if (!props.submitting) props.onCancel()
},
},
{
bind: "return",
title: "Confirm permission rejection",
group: "Permission",
run: () => props.onConfirm(input.plainText),
run: () => {
if (!props.submitting) props.onConfirm(input.plainText)
},
},
],
}))
@@ -360,11 +396,12 @@ function RejectPrompt(props: {
role: "textbox",
label: "Rejection reason",
focused: val.focused,
disabled: false,
disabled: !!props.submitting,
}))(val)
val.traits = { status: "REJECT" }
}}
focused
visible={!props.submitting}
focused={!props.submitting}
textColor={theme.text.default}
focusedTextColor={theme.text.default}
cursorColor={theme.text.default}
@@ -372,6 +409,7 @@ function RejectPrompt(props: {
/>
<box
id="session.permission.reject.actions"
visible={!props.submitting}
ref={SimulationSemantics.bind(() => ({
instance: props.instance,
role: "group",
@@ -387,7 +425,7 @@ function RejectPrompt(props: {
instance: props.instance,
role: "button",
label: "Confirm rejection",
disabled: false,
disabled: !!props.submitting,
}))}
onMouseUp={() => props.onConfirm(input.plainText)}
>
@@ -401,7 +439,7 @@ function RejectPrompt(props: {
instance: props.instance,
role: "button",
label: "Cancel rejection",
disabled: false,
disabled: !!props.submitting,
}))}
onMouseUp={props.onCancel}
>
@@ -410,7 +448,15 @@ function RejectPrompt(props: {
</text>
</box>
</box>
<Show when={props.submitting}>
<text fg={theme.text.feedback.info.default}>{props.submitting}</text>
</Show>
</box>
<Show when={props.error}>
<box paddingLeft={2} paddingRight={3} paddingBottom={1}>
<text fg={theme.text.feedback.error.default}>{props.error}</text>
</box>
</Show>
</box>
)
}
@@ -427,6 +473,8 @@ export function SessionQuestion<const T extends Record<string, string>>(props: {
options: T
escapeKey?: keyof T
fullscreen?: boolean
submitting?: string
error?: string
onSelect: (option: keyof T) => void
}) {
const theme = useTheme("elevated")
@@ -436,6 +484,7 @@ export function SessionQuestion<const T extends Record<string, string>>(props: {
selected: keys[0],
expanded: false,
})
const expanded = createMemo(() => store.expanded && !props.submitting)
const narrow = createMemo(() => dimensions().width < 80)
const shortcuts = Keymap.useShortcuts()
const id = () => props.id ?? "session.permission"
@@ -502,28 +551,41 @@ export function SessionQuestion<const T extends Record<string, string>>(props: {
run: () => props.onSelect(store.selected),
},
...(props.escapeKey ? [{ bind: "escape", title: "Reject permission", group: group(), run: dismiss }] : []),
],
].map((command) => ({
...command,
run: () => {
if (!props.submitting) command.run()
},
})),
bindings: [...(props.escapeKey ? ["app.exit"] : []), ...(props.fullscreen ? ["permission.prompt.fullscreen"] : [])],
}))
const hint = createMemo(() => (store.expanded ? "minimize" : "fullscreen"))
useRenderer()
const content = () => (
// Reparent one dialog tree so leaving fullscreen does not recreate retained controls.
const content = (
<box
id={id()}
ref={SimulationSemantics.bind(() => ({
instance: props.instance,
role: "dialog",
label: props.semanticLabel ?? props.title,
expanded: store.expanded,
expanded: expanded(),
}))}
backgroundColor={theme.background.default}
border={["left"]}
borderColor={theme.background.action.primary.focused}
customBorderChars={SplitBorder.customBorderChars}
{...(store.expanded
? { top: dimensions().height * -1 + 1, bottom: 1, left: 2, right: 2, position: "absolute" }
{...(expanded()
? {
top: dimensions().height * -1 + 1,
maxHeight: undefined,
bottom: 1,
left: 2,
right: 2,
position: "absolute",
}
: {
top: 0,
maxHeight: 15,
@@ -547,7 +609,7 @@ export function SessionQuestion<const T extends Record<string, string>>(props: {
{props.header}
</box>
</Show>
{props.body}
<box visible={!props.submitting}>{props.body}</box>
</box>
<box
flexDirection={narrow() ? "column" : "row"}
@@ -563,6 +625,7 @@ export function SessionQuestion<const T extends Record<string, string>>(props: {
>
<box
id={`${id()}.actions`}
visible={!props.submitting}
ref={SimulationSemantics.bind(() => ({
instance: props.instance,
role: "listbox",
@@ -582,7 +645,7 @@ export function SessionQuestion<const T extends Record<string, string>>(props: {
label: props.options[option],
focused: option === store.selected,
selected: option === store.selected,
disabled: false,
disabled: !!props.submitting,
}))}
paddingLeft={1}
paddingRight={1}
@@ -591,8 +654,11 @@ export function SessionQuestion<const T extends Record<string, string>>(props: {
? theme.background.action.primary.focused
: theme.background.action.primary.default
}
onMouseOver={() => setStore("selected", option)}
onMouseOver={() => {
if (!props.submitting) setStore("selected", option)
}}
onMouseUp={() => {
if (props.submitting) return
setStore("selected", option)
props.onSelect(option)
}}
@@ -606,7 +672,7 @@ export function SessionQuestion<const T extends Record<string, string>>(props: {
)}
</For>
</box>
<box flexDirection="row" gap={2} flexShrink={0}>
<box visible={!props.submitting} flexDirection="row" gap={2} flexShrink={0}>
<Show when={props.fullscreen}>
<text fg={theme.text.default}>
{shortcuts.get("permission.prompt.fullscreen")} <span style={{ fg: theme.text.subdued }}>{hint()}</span>
@@ -621,13 +687,21 @@ export function SessionQuestion<const T extends Record<string, string>>(props: {
enter <span style={{ fg: theme.text.subdued }}>confirm</span>
</text>
</box>
<Show when={props.submitting}>
<text fg={theme.text.feedback.info.default}>{props.submitting}</text>
</Show>
</box>
<Show when={props.error}>
<box paddingLeft={2} paddingRight={3} paddingBottom={1}>
<text fg={theme.text.feedback.error.default}>{props.error}</text>
</box>
</Show>
</box>
)
return (
<Show when={!store.expanded} fallback={<Portal>{content()}</Portal>}>
{content()}
<Show when={!expanded()} fallback={<Portal>{content}</Portal>}>
{content}
</Show>
)
}
+156 -1
View File
@@ -21,7 +21,11 @@ async function mountForm(
fields?: FormWithLocation["fields"],
height = 20,
clipboardText?: string,
response?: { reply?: 404 | 409; cancel?: 404 | 409; syncFailure?: boolean },
response?: {
reply?: 404 | 409 | (() => Promise<Response>)
cancel?: 404 | 409 | (() => Promise<Response>)
syncFailure?: boolean
},
) {
const state = path.join(root, "state")
await mkdir(state, { recursive: true })
@@ -65,12 +69,23 @@ async function mountForm(
if (url.pathname === "/api/session/ses_test/form/frm_test/reply")
return request.json().then((answer) => {
replies.push(answer)
if (typeof response?.reply === "function")
return response.reply().then((result) => {
terminal = result.ok
return result
})
return response?.reply ? failure(response.reply) : new Response(null, { status: 204 })
})
if (url.pathname === "/api/session/ses_test/form/frm_test/cancel") {
cancellations.push(true)
if (typeof response?.cancel === "function")
return response.cancel().then((result) => {
terminal = result.ok
return result
})
return response?.cancel ? failure(response.cancel) : new Response(null, { status: 204 })
}
return undefined
}, events)
const { FormPrompt } = await import("../../../src/routes/session/form")
@@ -135,6 +150,146 @@ function mountRecoveringForm(root: string, response: { reply?: 404 | 409; cancel
)
}
for (const width of [48, 120]) {
test(`acknowledges a form reply before HTTP completes and retains its answer on failure at ${width} columns`, async () => {
await using tmp = await tmpdir()
const pending = Promise.withResolvers<Response>()
const prompt = await mountForm(
tmp.path,
width,
[{ key: "target", type: "string", options: [{ value: "staging", label: "Staging" }], custom: true }],
20,
undefined,
{ reply: () => pending.promise },
)
try {
await prompt.app.mockInput.pasteBracketedText("production west")
await prompt.app.waitFor(() => prompt.app.renderer.currentFocusedEditor?.plainText === "production west")
prompt.app.mockInput.pressEnter()
await prompt.app.waitForFrame((frame) => frame.includes("Sending answers..."))
expect(prompt.app.captureCharFrame()).not.toContain("Composer ready")
expect(prompt.app.captureCharFrame()).not.toContain("enter submit")
prompt.app.mockInput.pressEnter()
prompt.app.mockInput.pressEscape()
prompt.app.mockInput.pressKey("c", { ctrl: true })
await prompt.app.mockInput.pasteBracketedText("do not replace my answer")
await prompt.app.waitFor(() => prompt.replies.length === 1)
expect(prompt.cancellations).toEqual([])
pending.resolve(
json({ _tag: "FormInvalidAnswerError", id: "frm_test", message: "Reply failed" }, { status: 400 }),
)
await prompt.app.waitForFrame((frame) => frame.includes("Reply failed") && frame.includes("production west"))
expect(prompt.replies).toEqual([{ answer: { target: "production west" } }])
expect(prompt.app.captureCharFrame()).toContain("enter submit")
prompt.app.mockInput.pressEnter()
await prompt.app.waitFor(() => prompt.app.renderer.currentFocusedEditor?.plainText === "production west")
prompt.app.mockInput.pressEnter()
await prompt.app.waitFor(() => prompt.replies.length === 2)
expect(prompt.replies[1]).toEqual(prompt.replies[0])
} finally {
pending.resolve(new Response(null, { status: 204 }))
prompt.app.renderer.destroy()
}
})
}
test("a failed form cancellation restores its uncommitted text and focus", async () => {
await using tmp = await tmpdir()
const pending = Promise.withResolvers<Response>()
const prompt = await mountForm(tmp.path, 80, [{ key: "notes", type: "string" }], 20, undefined, {
cancel: () => pending.promise,
})
try {
await prompt.app.mockInput.typeText(" unfinished draft ")
const editor = prompt.app.renderer.currentFocusedEditor
prompt.app.mockInput.pressEscape()
await prompt.app.waitForFrame((frame) => frame.includes("Dismissing form..."))
expect(prompt.app.renderer.currentFocusedEditor).toBeNull()
prompt.app.mockInput.pressEscape()
prompt.app.mockInput.pressEnter()
prompt.app.mockInput.pressKey("c", { ctrl: true })
await prompt.app.mockInput.typeText("must not edit")
await prompt.app.waitFor(() => prompt.cancellations.length === 1)
expect(prompt.replies).toEqual([])
pending.resolve(json({ message: "Cancel failed" }, { status: 500 }))
await prompt.app.waitForFrame((frame) => frame.includes("UnexpectedStatus"))
expect(prompt.app.renderer.currentFocusedEditor).toBe(editor)
expect(editor?.plainText).toBe(" unfinished draft ")
await prompt.app.mockInput.typeText("!")
expect(editor?.plainText).toBe(" unfinished draft !")
prompt.app.mockInput.pressEscape()
await prompt.app.waitFor(() => prompt.cancellations.length === 2)
} finally {
pending.resolve(new Response(null, { status: 204 }))
prompt.app.renderer.destroy()
}
})
test("only restores the composer after the submitted form is acknowledged", async () => {
await using tmp = await tmpdir()
const pending = Promise.withResolvers<Response>()
const prompt = await mountForm(tmp.path, 80, [{ key: "target", type: "boolean" }], 20, undefined, {
reply: () => pending.promise,
})
try {
prompt.app.mockInput.pressEnter()
await prompt.app.waitForFrame((frame) => frame.includes("Sending answers..."))
expect(prompt.app.captureCharFrame()).not.toContain("Composer ready")
pending.resolve(new Response(null, { status: 204 }))
await prompt.app.waitForFrame((frame) => frame.includes("Composer ready"))
expect(prompt.replies).toHaveLength(1)
} finally {
pending.resolve(new Response(null, { status: 204 }))
prompt.app.renderer.destroy()
}
})
test("a failed review submission retains every answer and ignores competing mouse actions", async () => {
await using tmp = await tmpdir()
const pending = Promise.withResolvers<Response>()
const prompt = await mountForm(
tmp.path,
80,
[
{ key: "targets", type: "multiselect", options: [{ value: "staging", label: "Staging" }], default: ["staging"] },
{ key: "notes", type: "string", default: "Keep the existing config" },
],
25,
undefined,
{ reply: () => pending.promise },
)
try {
prompt.app.mockInput.pressArrow("right")
prompt.app.mockInput.pressEnter()
await prompt.app.waitForFrame((frame) => frame.includes("enter submit"))
const lines = prompt.app.captureCharFrame().split("\n")
const row = lines.findIndex((line) => line.includes("enter submit"))
await prompt.app.mockMouse.click(lines[row].indexOf("enter submit"), row)
await prompt.app.waitForFrame((frame) => frame.includes("Sending answers..."))
await prompt.app.mockMouse.click(lines[row].indexOf("enter submit"), row)
await prompt.app.mockMouse.click(lines[row].indexOf("esc dismiss"), row)
prompt.app.mockInput.pressEnter()
prompt.app.mockInput.pressEscape()
await prompt.app.waitFor(() => prompt.replies.length === 1)
expect(prompt.cancellations).toEqual([])
expect(prompt.replies).toEqual([{ answer: { targets: ["staging"], notes: "Keep the existing config" } }])
pending.resolve(json({ _tag: "FormInvalidAnswerError", id: "frm_test", message: "Reply failed" }, { status: 400 }))
await prompt.app.waitForFrame((frame) => frame.includes("Reply failed"))
expect(prompt.app.captureCharFrame()).toContain("targets: Staging")
expect(prompt.app.captureCharFrame()).toContain("notes: Keep the existing config")
prompt.app.mockInput.pressArrow("left")
await prompt.app.waitFor(() => prompt.app.renderer.currentFocusedEditor?.plainText === "Keep the existing config")
} finally {
pending.resolve(new Response(null, { status: 204 }))
prompt.app.renderer.destroy()
}
})
test("restores the composer when terminal-form revalidation fails", async () => {
await using tmp = await tmpdir()
const prompt = await mountRecoveringForm(tmp.path, { reply: 404, syncFailure: true })
@@ -1,7 +0,0 @@
import { expect, test } from "bun:test"
import { permissionSemanticLabel } from "../../../src/routes/session/permission"
test("uses the permission action when a surface has no display title", () => {
expect(permissionSemanticLabel("shell")).toBe("Permission required: shell")
expect(permissionSemanticLabel("edit", "Edit fixture.txt")).toBe("Permission required: Edit fixture.txt")
})
@@ -0,0 +1,228 @@
/** @jsxImportSource @opentui/solid */
import { testRender } from "@opentui/solid"
import { expect, test } from "bun:test"
import { onMount, Show } from "solid-js"
import type { PermissionRequest } from "@opencode-ai/client"
import { PermissionPrompt, permissionSemanticLabel } from "../../../src/routes/session/permission"
import { ConfigProvider } from "../../../src/config"
import { ClientProvider } from "../../../src/context/client"
import { DataProvider, useData } from "../../../src/context/data"
import { LocationProvider } from "../../../src/context/location"
import { Keymap } from "../../../src/context/keymap"
import { ThemeProvider } from "../../../src/context/theme"
import { emptyThemeSource } from "../../fixture/fixture"
import { TestTuiContexts } from "../../fixture/tui-environment"
import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
import { createApi, createEventStream, createFetch, json } from "../../fixture/tui-client"
test("uses the permission action when a surface has no display title", () => {
expect(permissionSemanticLabel("shell")).toBe("Permission required: shell")
expect(permissionSemanticLabel("edit", "Edit fixture.txt")).toBe("Permission required: Edit fixture.txt")
})
async function mountPermission(width: number, child = false) {
const pending = Promise.withResolvers<Response>()
const replies: unknown[] = []
const request = {
id: "per_test",
sessionID: "ses_test",
action: "read",
resources: ["README.md"],
save: ["*.md"],
} satisfies PermissionRequest
const events = createEventStream()
const transport = createFetch(async (url, init) => {
if (url.pathname === "/api/session/ses_test/permission") return json({ data: [request] })
if (url.pathname === "/api/session/ses_test/permission/per_test/reply") {
replies.push(await init.json())
return replies.length === 1 ? pending.promise : new Response(null, { status: 204 })
}
if (url.pathname === "/api/session/ses_test")
return json({
data: {
id: "ses_test",
parentID: child ? "ses_parent" : undefined,
projectID: "proj_test",
title: "Permission demo",
location: { directory: process.cwd() },
time: { created: 0, updated: 0 },
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
},
})
return undefined
}, events)
function CurrentPermission() {
const data = useData()
onMount(async () => {
await data.session.sync(request.sessionID)
await data.session.permission.sync(request.sessionID)
})
return (
<Show when={data.session.permission.list(request.sessionID)?.[0]} keyed fallback={<text>Composer ready</text>}>
{(current) => <PermissionPrompt request={current} />}
</Show>
)
}
const app = await testRender(
() => (
<TestTuiContexts>
<ConfigProvider config={createTuiResolvedConfig()}>
<Keymap.Provider>
<ClientProvider api={createApi(transport.fetch)}>
<DataProvider directory={process.cwd()}>
<LocationProvider>
<ThemeProvider mode="dark" source={emptyThemeSource}>
<box height="100%">
<box paddingTop={3}>
<text>Transcript stays visible</text>
</box>
<box flexGrow={1} />
<CurrentPermission />
</box>
</ThemeProvider>
</LocationProvider>
</DataProvider>
</ClientProvider>
</Keymap.Provider>
</ConfigProvider>
</TestTuiContexts>
),
{ width, height: 25, kittyKeyboard: true },
)
app.renderer.start()
await app.waitForFrame((frame) => frame.includes("Permission required"))
return { app, pending, replies }
}
for (const width of [48, 120]) {
test(`an expanded permission submits inline and restores its retained fullscreen controls on failure at ${width} columns`, async () => {
const prompt = await mountPermission(width)
try {
prompt.app.mockInput.pressKey("f", { ctrl: true })
await prompt.app.waitForFrame((frame) => frame.includes("minimize"))
expect(prompt.app.captureCharFrame()).not.toContain("Transcript stays visible")
const dialog = prompt.app.renderer.root.findDescendantById("session.permission")
const choice = prompt.app.renderer.root.findDescendantById("session.permission.action.once")
expect(dialog).toBeDefined()
expect(choice).toBeDefined()
expect(dialog?.height).toBeGreaterThan(15)
prompt.app.mockInput.pressEnter()
await prompt.app.waitForFrame((frame) => frame.includes("Sending approval..."))
expect(prompt.app.captureCharFrame()).toContain("Transcript stays visible")
expect(dialog?.height).toBeLessThan(15)
expect(prompt.app.renderer.root.findDescendantById("session.permission") === dialog).toBe(true)
expect(prompt.app.renderer.root.findDescendantById("session.permission.action.once") === choice).toBe(true)
prompt.app.mockInput.pressEscape()
prompt.app.mockInput.pressKey("f", { ctrl: true })
prompt.app.mockInput.pressEnter()
await prompt.app.waitFor(() => prompt.replies.length === 1)
expect(prompt.replies).toEqual([{ reply: "once" }])
prompt.pending.resolve(json({}, { status: 500 }))
await prompt.app.waitForFrame((frame) => frame.includes("UnexpectedStatus") && frame.includes("minimize"))
expect(prompt.app.captureCharFrame()).not.toContain("Transcript stays visible")
expect(prompt.app.captureCharFrame()).toContain("README.md")
expect(dialog?.height).toBeGreaterThan(15)
expect(prompt.app.renderer.root.findDescendantById("session.permission") === dialog).toBe(true)
expect(prompt.app.renderer.root.findDescendantById("session.permission.action.once") === choice).toBe(true)
expect(choice?.isDestroyed).toBe(false)
prompt.app.mockInput.pressEscape()
await prompt.app.waitForFrame((frame) => frame.includes("Transcript stays visible"))
prompt.app.mockInput.pressEnter()
await prompt.app.waitForFrame((frame) => frame.includes("Composer ready"))
expect(prompt.replies).toEqual([{ reply: "once" }, { reply: "once" }])
} finally {
prompt.pending.resolve(new Response(null, { status: 204 }))
prompt.app.renderer.destroy()
}
})
for (const reply of ["once", "always", "reject"] as const) {
test(`acknowledges ${reply} before HTTP completes and restores permission interaction on failure at ${width} columns`, async () => {
const prompt = await mountPermission(width)
try {
if (reply === "always") {
prompt.app.mockInput.pressArrow("right")
prompt.app.mockInput.pressEnter()
await prompt.app.waitForFrame((frame) => frame.includes("Always allow"))
}
if (reply === "reject") prompt.app.mockInput.pressEscape()
if (reply !== "reject") prompt.app.mockInput.pressEnter()
await prompt.app.waitForFrame((frame) => frame.includes("Sending"))
expect(prompt.app.captureCharFrame()).not.toContain("enter confirm")
expect(prompt.app.captureCharFrame()).not.toContain("Composer ready")
prompt.app.mockInput.pressEnter()
prompt.app.mockInput.pressEscape()
prompt.app.mockInput.pressArrow("right")
prompt.app.mockInput.pressKey("c", { ctrl: true })
await prompt.app.waitFor(() => prompt.replies.length === 1)
expect(prompt.replies).toEqual([{ reply }])
prompt.pending.resolve(json({}, { status: 500 }))
await prompt.app.waitForFrame((frame) => frame.includes("UnexpectedStatus"))
expect(prompt.app.captureCharFrame()).toContain("enter confirm")
if (reply === "always") expect(prompt.app.captureCharFrame()).toContain("Always allow")
if (reply === "reject") prompt.app.mockInput.pressEscape()
if (reply !== "reject") prompt.app.mockInput.pressEnter()
await prompt.app.waitForFrame((frame) => frame.includes("Composer ready"))
expect(prompt.replies).toEqual([{ reply }, { reply }])
} finally {
prompt.pending.resolve(new Response(null, { status: 204 }))
prompt.app.renderer.destroy()
}
})
}
}
test("a failed permission rejection retains the reason and restores its editor", async () => {
const prompt = await mountPermission(48, true)
try {
prompt.app.mockInput.pressEscape()
await prompt.app.waitFor(() => prompt.app.renderer.currentFocusedEditor !== null)
await prompt.app.mockInput.typeText("Keep the file unchanged")
const editor = prompt.app.renderer.currentFocusedEditor
prompt.app.mockInput.pressEnter()
await prompt.app.waitForFrame((frame) => frame.includes("Sending rejection..."))
expect(prompt.app.renderer.currentFocusedEditor).toBeNull()
prompt.app.mockInput.pressEnter()
prompt.app.mockInput.pressEscape()
prompt.app.mockInput.pressKey("c", { ctrl: true })
await prompt.app.mockInput.pasteBracketedText("must not replace the reason")
await prompt.app.waitFor(() => prompt.replies.length === 1)
expect(prompt.replies).toEqual([{ reply: "reject", message: "Keep the file unchanged" }])
prompt.pending.resolve(json({}, { status: 500 }))
await prompt.app.waitForFrame((frame) => frame.includes("UnexpectedStatus"))
expect(prompt.app.renderer.currentFocusedEditor).toBe(editor)
expect(editor?.plainText).toBe("Keep the file unchanged")
await prompt.app.mockInput.typeText("!")
prompt.app.mockInput.pressEnter()
await prompt.app.waitForFrame((frame) => frame.includes("Composer ready"))
expect(prompt.replies[1]).toEqual({ reply: "reject", message: "Keep the file unchanged!" })
} finally {
prompt.pending.resolve(new Response(null, { status: 204 }))
prompt.app.renderer.destroy()
}
})
test("only restores the composer after the permission reply is acknowledged", async () => {
const prompt = await mountPermission(120)
try {
prompt.app.mockInput.pressEnter()
await prompt.app.waitForFrame((frame) => frame.includes("Sending approval..."))
expect(prompt.app.captureCharFrame()).not.toContain("Composer ready")
prompt.pending.resolve(new Response(null, { status: 204 }))
await prompt.app.waitForFrame((frame) => frame.includes("Composer ready"))
expect(prompt.replies).toHaveLength(1)
} finally {
prompt.pending.resolve(new Response(null, { status: 204 }))
prompt.app.renderer.destroy()
}
})