mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-29 13:06:13 +00:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3e506cfbde | ||
|
|
1f77f4a4ed | ||
|
|
e8fa7daed5 |
@@ -215,8 +215,10 @@ 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)
|
||||
}
|
||||
|
||||
@@ -473,6 +475,7 @@ 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}`)
|
||||
@@ -504,17 +507,25 @@ export function createData(config: CreateDataInput) {
|
||||
|
||||
function handleEvent(event: OpenCodeEvent) {
|
||||
switch (event.type) {
|
||||
case "server.connected":
|
||||
case "server.connected": {
|
||||
const updates = new Map<string, DataSessionStatus | undefined>()
|
||||
activeUpdates = updates
|
||||
void api()
|
||||
.session.active()
|
||||
.then((active) => {
|
||||
setStore(
|
||||
"session",
|
||||
"active",
|
||||
reconcile(Object.fromEntries(Object.keys(active).map((sessionID) => [sessionID, "running" as const]))),
|
||||
)
|
||||
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
|
||||
})
|
||||
.catch(() => undefined)
|
||||
void api()
|
||||
.location.get({ location: locationQuery(defaultLocation()) })
|
||||
.then((location) => {
|
||||
@@ -525,6 +536,7 @@ 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,6 +485,102 @@ 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()) {
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
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,18 +1,12 @@
|
||||
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 { ChildProcess } from "effect/unstable/process"
|
||||
import { CommandInvocation } from "../../command/invocation.js"
|
||||
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"
|
||||
|
||||
@@ -29,9 +23,7 @@ export const Plugin = define({
|
||||
const commands = yield* loadDirectory(fs, entry.path)
|
||||
return [{ commands: Object.fromEntries(commands.map((command) => [command.name, command.info])) }]
|
||||
})
|
||||
const location = yield* Location.Service
|
||||
const processes = yield* AppProcess.Service
|
||||
const shell = yield* ShellSelect.Service
|
||||
const invoke = yield* CommandInvocation.make(ctx)
|
||||
const load = Effect.fn("ConfigCommandPlugin.load")(function* () {
|
||||
return yield* Effect.forEach(yield* config.entries(), loadEntry).pipe(Effect.map((documents) => documents.flat()))
|
||||
})
|
||||
@@ -63,38 +55,7 @@ export const Plugin = define({
|
||||
draft.add({
|
||||
name,
|
||||
description: command.description,
|
||||
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),
|
||||
execute: (input) => invoke(command, input),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -147,67 +108,3 @@ 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,7 +1,6 @@
|
||||
export * as SessionCompaction from "./compaction.js"
|
||||
|
||||
import { LLMClient, AIError, LLMEvent, Message, type LLMRequest } from "@opencode-ai/ai"
|
||||
import type { StreamOptions } from "@opencode-ai/ai/route"
|
||||
import { LLMClient, LLMEvent, Message } from "@opencode-ai/ai"
|
||||
import { SessionError } from "@opencode-ai/schema/session-error"
|
||||
import { Context, Effect, Layer, Stream } from "effect"
|
||||
import { Bus } from "../bus.js"
|
||||
@@ -65,13 +64,6 @@ 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[]
|
||||
@@ -240,195 +232,196 @@ 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
|
||||
return make({ bus, llm })
|
||||
|
||||
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,
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
+104
-126
@@ -1,8 +1,7 @@
|
||||
export * as SessionTitle from "./title.js"
|
||||
|
||||
import { isDeepStrictEqual } from "node:util"
|
||||
import { LLMClient, AIError, LLMEvent, Message, SystemPart, type LLMRequest } from "@opencode-ai/ai"
|
||||
import type { StreamOptions } from "@opencode-ai/ai/route"
|
||||
import { LLMClient, LLMEvent, Message, SystemPart } from "@opencode-ai/ai"
|
||||
import { Context, DateTime, Effect, Layer, Stream } from "effect"
|
||||
import type { Agent } from "../agent.js"
|
||||
import { Database } from "../database/database.js"
|
||||
@@ -23,15 +22,6 @@ 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>
|
||||
@@ -46,117 +36,6 @@ 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* () {
|
||||
@@ -164,11 +43,110 @@ export const layer = Layer.effect(
|
||||
const llm = yield* LLMClient.Service
|
||||
const context = yield* SessionContext.Service
|
||||
const store = yield* SessionStore.Service
|
||||
const database = yield* Database.Service
|
||||
const title = make({ bus, llm, context, store })
|
||||
return Service.of({
|
||||
generate: (sessionID) => title.generate(database.db, sessionID),
|
||||
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 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 })
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
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",
|
||||
})
|
||||
}),
|
||||
},
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user