mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-28 20:46:14 +00:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
09111e3e9d | ||
|
|
56df77ae34 |
@@ -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
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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 })
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
@@ -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",
|
||||
})
|
||||
}),
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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" } },
|
||||
|
||||
@@ -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))
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user