diff --git a/bun.lock b/bun.lock index 5ddeac231f..e9c7917910 100644 --- a/bun.lock +++ b/bun.lock @@ -859,6 +859,20 @@ "vite": "catalog:", }, }, + "packages/theme": { + "name": "@opencode-ai/theme", + "version": "0.0.0", + "dependencies": { + "@opentui/core": "catalog:", + "effect": "catalog:", + }, + "devDependencies": { + "@tsconfig/bun": "catalog:", + "@types/bun": "catalog:", + "@typescript/native-preview": "catalog:", + "typescript": "catalog:", + }, + }, "packages/tui": { "name": "@opencode-ai/tui", "version": "1.18.4", @@ -868,6 +882,7 @@ "@opencode-ai/plugin": "workspace:*", "@opencode-ai/schema": "workspace:*", "@opencode-ai/simulation": "workspace:*", + "@opencode-ai/theme": "workspace:*", "@opencode-ai/ui": "workspace:*", "@opencode-ai/util": "workspace:*", "@opentui/core": "catalog:", @@ -1030,6 +1045,7 @@ }, "devDependencies": { "@astrojs/cloudflare": "14.1.4", + "@opencode-ai/theme": "workspace:*", "@types/bun": "catalog:", "astro": "7.1.3", "effect": "catalog:", @@ -2075,6 +2091,8 @@ "@opencode-ai/storybook": ["@opencode-ai/storybook@workspace:packages/storybook"], + "@opencode-ai/theme": ["@opencode-ai/theme@workspace:packages/theme"], + "@opencode-ai/tui": ["@opencode-ai/tui@workspace:packages/tui"], "@opencode-ai/ui": ["@opencode-ai/ui@workspace:packages/ui"], diff --git a/packages/ai/package.json b/packages/ai/package.json index f83d89430f..c8a16bbf7c 100644 --- a/packages/ai/package.json +++ b/packages/ai/package.json @@ -15,6 +15,7 @@ ], "exports": { ".": "./src/index.ts", + "./testing": "./src/testing.ts", "./*": "./src/*.ts" }, "devDependencies": { diff --git a/packages/ai/src/testing.ts b/packages/ai/src/testing.ts new file mode 100644 index 0000000000..69619b96e5 --- /dev/null +++ b/packages/ai/src/testing.ts @@ -0,0 +1,157 @@ +export * as TestLLM from "./testing" + +import { LLMClient, type Interface as LLMClientShape } from "./route/client" +import { + LLMEvent, + LLMResponse, + type FinishReasonDetails, + type LLMError, + type LLMRequest, + type UsageInput, +} from "./schema" +import { Context, Deferred, Effect, Latch, Layer, Queue, Scope, Stream } from "effect" + +export type Response = readonly LLMEvent[] | Stream.Stream + +export type Gate = Readonly<{ started: Effect.Effect; release: Effect.Effect }> + +export interface Interface { + readonly requests: LLMRequest[] + readonly push: (...responses: readonly Response[]) => Effect.Effect + readonly always: (response: Response) => Effect.Effect + readonly wait: (count: number) => Effect.Effect + readonly gate: Effect.Effect + readonly client: LLMClientShape +} + +export interface LayerOptions { + readonly transformRequest?: (request: LLMRequest) => LLMRequest + /** Used after the one-shot response queue is exhausted. Omit to defect on unexpected requests. */ + readonly fallback?: Response +} + +export class Service extends Context.Service()("@opencode/ai/TestLLM") {} + +export const complete = ( + options: { readonly reason: FinishReasonDetails; readonly usage?: UsageInput }, + ...events: readonly LLMEvent[] +) => [ + LLMEvent.stepStart({ index: 0 }), + ...events, + LLMEvent.stepFinish({ index: 0, reason: options.reason, usage: options.usage }), + LLMEvent.finish({ reason: options.reason }), +] + +export const stop = (...events: readonly LLMEvent[]) => complete({ reason: { normalized: "stop" } }, ...events) + +export const toolCalls = (...events: readonly LLMEvent[]) => + complete({ reason: { normalized: "tool-calls" } }, ...events) + +const textEvents = (value: string, id: string) => [ + LLMEvent.textStart({ id }), + LLMEvent.textDelta({ id, text: value }), + LLMEvent.textEnd({ id }), +] + +export const text = (value: string, id: string) => stop(...textEvents(value, id)) + +export const textWithUsage = (value: string, id: string, inputTokens: number) => + complete( + { reason: { normalized: "stop" }, usage: { inputTokens, nonCachedInputTokens: inputTokens } }, + ...textEvents(value, id), + ) + +export const tool = (id: string, name: string, input: unknown) => toolCalls(LLMEvent.toolCall({ id, name, input })) + +export const failAfter = (error: LLMError, ...events: readonly LLMEvent[]) => + Stream.fromIterable(events).pipe(Stream.concat(Stream.fail(error))) + +export const hangAfter = (...events: readonly LLMEvent[]) => Stream.concat(Stream.fromIterable(events), Stream.never) + +const toStream = (response: Response) => (Stream.isStream(response) ? response : Stream.fromIterable(response)) + +export const layer = (options: LayerOptions = {}) => + Layer.effect( + Service, + Effect.gen(function* () { + const requests: LLMRequest[] = [] + const responses: Response[] = [] + let started = Deferred.makeUnsafe() + let fallback = options.fallback + let activeGate: { readonly started: Queue.Queue; readonly release: Latch.Latch } | undefined + const wait = (count: number): Effect.Effect => + Effect.suspend(() => + requests.length >= count ? Effect.void : Deferred.await(started).pipe(Effect.andThen(wait(count))), + ) + + const stream = ((request: LLMRequest) => { + requests.push(options.transformRequest?.(request) ?? request) + const waiting = started + started = Deferred.makeUnsafe() + Deferred.doneUnsafe(waiting, Effect.void) + const response = responses.shift() ?? fallback + if (!response) return Stream.die(new Error(`TestLLM has no response for request ${requests.length}`)) + const streamed = toStream(response) + const gate = activeGate + if (!gate) return streamed + return Stream.unwrap( + Queue.offer(gate.started, undefined).pipe(Effect.andThen(gate.release.await), Effect.as(streamed)), + ) + }) as LLMClientShape["stream"] + const client = LLMClient.Service.of({ + prepare: () => Effect.die("TestLLM does not prepare provider-native requests"), + stream, + generate: (request) => + stream(request).pipe( + Stream.runFold(LLMResponse.empty, LLMResponse.reduce), + Effect.flatMap((state) => { + const response = LLMResponse.complete(state) + if (response) return Effect.succeed(response) + return Effect.die("TestLLM response ended without a terminal finish event") + }), + ), + }) + + return Service.of({ + requests, + push: (...input) => + Effect.sync(() => { + responses.push(...input) + }), + always: (response) => + Effect.sync(() => { + fallback = response + }), + wait, + gate: Effect.gen(function* () { + const gate = { + started: yield* Effect.acquireRelease(Queue.unbounded(), Queue.shutdown), + release: yield* Latch.make(), + } + activeGate = gate + const release = Effect.sync(() => { + if (activeGate === gate) activeGate = undefined + }).pipe(Effect.andThen(gate.release.open), Effect.asVoid) + yield* Effect.addFinalizer(() => release) + return { + started: Queue.take(gate.started), + release, + } + }), + client, + }) + }), + ) + +export const clientLayer = Layer.effect( + LLMClient.Service, + Effect.map(Service, (service) => service.client), +) + +export const push = (...responses: readonly Response[]) => Service.use((service) => service.push(...responses)) + +export const always = (response: Response) => Service.use((service) => service.always(response)) + +export const wait = (count: number) => Service.use((service) => service.wait(count)) + +export const gate = Service.use((service) => service.gate) diff --git a/packages/ai/test/exports.test.ts b/packages/ai/test/exports.test.ts index bea36c4c3b..cb32528634 100644 --- a/packages/ai/test/exports.test.ts +++ b/packages/ai/test/exports.test.ts @@ -19,6 +19,7 @@ import { OpenResponses, } from "@opencode-ai/ai/protocols" import * as AnthropicMessages from "@opencode-ai/ai/protocols/anthropic-messages" +import { TestLLM } from "@opencode-ai/ai/testing" describe("public exports", () => { test("root exposes app-facing runtime APIs", () => { @@ -28,6 +29,7 @@ describe("public exports", () => { expect(ImageInput.bytes).toBeFunction() expect(Provider.make).toBeFunction() expect(ProviderSubpath.make).toBe(Provider.make) + expect(TestLLM.layer).toBeFunction() }) test("route barrel exposes route-authoring APIs", () => { diff --git a/packages/app/src/components/dialog-select-server.tsx b/packages/app/src/components/dialog-select-server.tsx index aa16976228..23102204c6 100644 --- a/packages/app/src/components/dialog-select-server.tsx +++ b/packages/app/src/components/dialog-select-server.tsx @@ -562,6 +562,7 @@ export function useServerManagementController(options: { onSelect?: () => void; startEdit, resetForm, submitForm, + canRemove: server.canRemove, handleRemove, handleFormChange: () => (isAddMode() ? handleAddChange : handleEditChange), handleFormNameChange: () => (isAddMode() ? handleAddNameChange : handleEditNameChange), @@ -649,13 +650,15 @@ export function ServerConnectionList(props: { controller: ReturnType - - props.controller.handleRemove(ServerConnection.key(i))} - class="text-text-on-critical-base hover:bg-surface-critical-weak" - > - {language.t("dialog.server.menu.delete")} - + + + props.controller.handleRemove(ServerConnection.key(i))} + class="text-text-on-critical-base hover:bg-surface-critical-weak" + > + {language.t("dialog.server.menu.delete")} + + diff --git a/packages/app/src/components/server/server-row-menu.tsx b/packages/app/src/components/server/server-row-menu.tsx index 0a2920dec7..ee4a46db07 100644 --- a/packages/app/src/components/server/server-row-menu.tsx +++ b/packages/app/src/components/server/server-row-menu.tsx @@ -21,6 +21,7 @@ export const ServerRowMenu: Component<{ labels={serverMenuLabels(language)} canDefault={props.controller.canDefault()} isDefault={props.controller.defaultKey() === key} + canRemove={props.controller.canRemove(key)} onEdit={props.onEdit} onSetDefault={() => props.controller.setDefault(key)} onRemoveDefault={() => props.controller.setDefault(null)} @@ -47,6 +48,7 @@ export const ServerRowMenuView: Component<{ labels: ReturnType canDefault: boolean isDefault: boolean + canRemove: boolean onEdit: (server: ServerConnection.Http) => void onSetDefault: () => void onRemoveDefault: () => void @@ -84,10 +86,10 @@ export const ServerRowMenuView: Component<{ {props.labels.defaultRemove} - - - {props.labels.delete} - + + + {props.labels.delete} + diff --git a/packages/app/src/components/titlebar.tsx b/packages/app/src/components/titlebar.tsx index 32d7e80b2f..649a8f5777 100644 --- a/packages/app/src/components/titlebar.tsx +++ b/packages/app/src/components/titlebar.tsx @@ -395,27 +395,25 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl }} onReorder={(keys) => tabsStoreActions.reorder(keys)} /> - - - {language.t("command.session.new")} - - - } - > - } - onClick={openNewTab} - aria-label={language.t("command.session.new")} - /> - - + + {language.t("command.session.new")} + + + } + > + } + onClick={openNewTab} + aria-label={language.t("command.session.new")} + /> +
diff --git a/packages/app/src/context/server.tsx b/packages/app/src/context/server.tsx index 450129f458..818bdf69b5 100644 --- a/packages/app/src/context/server.tsx +++ b/packages/app/src/context/server.tsx @@ -178,6 +178,17 @@ export function resolveServerList(input: { return [...deduped.values()] } +export function canRemoveServer(input: { + key: ServerConnection.Key + provided?: Array + stored: StoredServer[] +}) { + if (input.provided?.some((server) => ServerConnection.key(server) === input.key)) return false + return input.stored.some((server) => + typeof server === "string" ? server === input.key : ("type" in server ? server.http.url : server.url) === input.key, + ) +} + export namespace ServerConnection { type Base = { displayName?: string; label?: string } @@ -312,6 +323,10 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext( }) } + function canRemove(key: ServerConnection.Key) { + return canRemoveServer({ key, provided: props.servers, stored: store.list }) + } + const isReady = Object.assign( createMemo(() => ready() && !!state.active), { promise: ready.promise }, @@ -350,6 +365,7 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext( setActive, add, remove, + canRemove, scope, projects: { ...projects, diff --git a/packages/app/src/pages/home/home-projects-controller.tsx b/packages/app/src/pages/home/home-projects-controller.tsx index 3e6b6d306b..888f4ede72 100644 --- a/packages/app/src/pages/home/home-projects-controller.tsx +++ b/packages/app/src/pages/home/home-projects-controller.tsx @@ -60,6 +60,7 @@ export function createHomeProjectsController(home: HomeController) { defaultKey: serverManagement.defaultKey, setDefault: (conn: ServerConnection.Any | undefined) => serverManagement.setDefault(conn ? ServerConnection.key(conn) : null), + canRemove: (conn: ServerConnection.Any) => serverManagement.canRemove(ServerConnection.key(conn)), remove: (conn: ServerConnection.Any) => serverManagement.handleRemove(ServerConnection.key(conn)), edit: (conn: ServerConnection.Http) => dialog.show(() => ), focus: home.selection.focusServer, diff --git a/packages/app/src/pages/home/home-projects-view.tsx b/packages/app/src/pages/home/home-projects-view.tsx index 7ec01bd75f..d8cd64aa18 100644 --- a/packages/app/src/pages/home/home-projects-view.tsx +++ b/packages/app/src/pages/home/home-projects-view.tsx @@ -47,6 +47,7 @@ export type HomeProjectsViewProps = { onToggleCollapsed: (server: ServerConnection.Any) => void onEditServer: (server: ServerConnection.Http) => void onSetDefaultServer: (server: ServerConnection.Any | undefined) => void + canRemoveServer: (server: ServerConnection.Any) => boolean onRemoveServer: (server: ServerConnection.Any) => void onMoveProject: (server: ServerConnection.Any, worktree: string, index: number) => void onSelectProject: (server: ServerConnection.Any, directory: string) => void @@ -192,6 +193,7 @@ function HomeServerRow(props: { onToggleCollapsed: HomeProjectsViewProps["onToggleCollapsed"] onEditServer: HomeProjectsViewProps["onEditServer"] onSetDefaultServer: HomeProjectsViewProps["onSetDefaultServer"] + canRemoveServer: HomeProjectsViewProps["canRemoveServer"] onRemoveServer: HomeProjectsViewProps["onRemoveServer"] onSetContextMenuOpen: HomeProjectsContextMenuProps["onSetContextMenuOpen"] onChooseProject: HomeProjectsViewProps["onChooseProject"] @@ -277,6 +279,7 @@ function HomeServerRow(props: { labels={serverMenuLabels(props.language)} canDefault={props.canDefaultServer()} isDefault={props.defaultServerKey() === ServerConnection.key(props.server)} + canRemove={props.canRemoveServer(props.server)} onEdit={props.onEditServer} onSetDefault={() => props.onSetDefaultServer(props.server)} onRemoveDefault={() => props.onSetDefaultServer(undefined)} diff --git a/packages/app/src/pages/home/home-projects.tsx b/packages/app/src/pages/home/home-projects.tsx index ff2abf7c16..32495403bc 100644 --- a/packages/app/src/pages/home/home-projects.tsx +++ b/packages/app/src/pages/home/home-projects.tsx @@ -24,6 +24,7 @@ export function HomeProjects(props: { projects: HomeProjectsController; scroll: onToggleCollapsed={props.projects.server.toggleCollapsed} onEditServer={props.projects.server.edit} onSetDefaultServer={props.projects.server.setDefault} + canRemoveServer={props.projects.server.canRemove} onRemoveServer={props.projects.server.remove} onMoveProject={props.projects.project.move} onSelectProject={props.projects.project.select} diff --git a/packages/cli/src/commands/commands.ts b/packages/cli/src/commands/commands.ts index a3ae15113f..f08884c15a 100644 --- a/packages/cli/src/commands/commands.ts +++ b/packages/cli/src/commands/commands.ts @@ -14,10 +14,23 @@ const ServerParams = { ), } +const PermissionParams = { + auto: Flag.boolean("auto").pipe( + Flag.withDescription("Auto-approve permissions that are not explicitly denied"), + Flag.withDefault(false), + ), + yolo: Flag.boolean("yolo").pipe(Flag.withDefault(false), Flag.withHidden), + dangerouslySkipPermissions: Flag.boolean("dangerously-skip-permissions").pipe( + Flag.withDefault(false), + Flag.withHidden, + ), +} + export const Commands = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME : "opencode", { description: "OpenCode 2.0 preview command line interface", params: { ...ServerParams, + ...PermissionParams, directory: Argument.string("directory").pipe( Argument.withDescription("Directory to start OpenCode in"), Argument.optional, @@ -196,11 +209,7 @@ export const Commands = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCO ), title: Flag.string("title").pipe(Flag.withDescription("Session title"), Flag.optional), thinking: Flag.boolean("thinking").pipe(Flag.withDescription("Show thinking blocks"), Flag.withDefault(false)), - auto: Flag.boolean("auto").pipe( - Flag.withDescription("Auto-approve permissions that are not explicitly denied"), - Flag.withDefault(false), - ), - yolo: Flag.boolean("yolo").pipe(Flag.withDefault(false), Flag.withHidden), + ...PermissionParams, }, }), Spec.make("service", { diff --git a/packages/cli/src/commands/handlers/default.ts b/packages/cli/src/commands/handlers/default.ts index f83a0ef7cb..e071949460 100644 --- a/packages/cli/src/commands/handlers/default.ts +++ b/packages/cli/src/commands/handlers/default.ts @@ -59,6 +59,7 @@ export default Runtime.handler(Commands, (input) => continue: input.continue, sessionID: Option.getOrUndefined(input.session), prompt: Option.getOrUndefined(input.prompt), + auto: input.auto || input.yolo || input.dangerouslySkipPermissions, }, config: { path: config.path, diff --git a/packages/cli/src/commands/handlers/run.ts b/packages/cli/src/commands/handlers/run.ts index 74a88e8e72..b86e1bfc05 100644 --- a/packages/cli/src/commands/handlers/run.ts +++ b/packages/cli/src/commands/handlers/run.ts @@ -24,7 +24,7 @@ export default Runtime.handler(Commands.commands.run, (input) => file: [...input.file], title: Option.getOrUndefined(input.title), thinking: input.thinking, - auto: input.auto || input.yolo, + auto: input.auto || input.yolo || input.dangerouslySkipPermissions, }), ) }), diff --git a/packages/core/src/plugin/supervisor.ts b/packages/core/src/plugin/supervisor.ts index 5f44006b56..39bb868678 100644 --- a/packages/core/src/plugin/supervisor.ts +++ b/packages/core/src/plugin/supervisor.ts @@ -2,7 +2,7 @@ export * as PluginSupervisor from "./supervisor" import type { Plugin as PluginDefinition } from "@opencode-ai/plugin/effect/plugin" import { Event } from "@opencode-ai/schema/config" -import { Context, Deferred, Effect, Layer, Option, PubSub, Schema, Semaphore, Stream } from "effect" +import { Context, Deferred, Effect, Layer, Option, PubSub, Schema, Stream } from "effect" import path from "path" import { fileURLToPath, pathToFileURL } from "url" import { Agent } from "../agent" @@ -230,10 +230,8 @@ const layer = Layer.effect( const bus = yield* Bus.Service const watcher = yield* Watcher.Service const fs = yield* FSUtil.Service - const lock = Semaphore.makeUnsafe(1) const ready = yield* Deferred.make() let observed = 0 - let applied = -1 // Configured local plugin files can live outside config roots, where the // config change feed cannot see them; watch those entrypoints directly. @@ -265,61 +263,40 @@ const layer = Layer.effect( } }) - const activate = Effect.fn("PluginSupervisor.activate")(function* (target: number) { - yield* lock.withPermit( - Effect.gen(function* () { - if (applied >= target) return - // Resolve OpenCode's internal plugins with their privileged Location services. - const internal = yield* PluginInternal.list() - // Combine internal plugins with host-contributed SDK plugins in boot order. - const pre = [...internal.pre.map((plugin) => ({ ...plugin, version: "internal" })), ...sdk.all()] - const post = internal.post.map((plugin) => ({ ...plugin, version: "internal" })) - const entries = yield* config.entries() - const operations = yield* scan(entries) - yield* watchConfiguredSources(entries, operations) - // Apply config operations and load enabled package plugins into one ordered generation. - const plugins = yield* resolve(pre, post, operations) - // Replace the active generation in one scoped, batched activation. - yield* registry.activate(plugins) - applied = target - }), - ) + const activate = Effect.fn("PluginSupervisor.activate")(function* () { + // Resolve OpenCode's internal plugins with their privileged Location services. + const internal = yield* PluginInternal.list() + // Combine internal plugins with host-contributed SDK plugins in boot order. + const pre = [...internal.pre.map((plugin) => ({ ...plugin, version: "internal" })), ...sdk.all()] + const post = internal.post.map((plugin) => ({ ...plugin, version: "internal" })) + const entries = yield* config.entries() + const operations = yield* scan(entries) + yield* watchConfiguredSources(entries, operations) + // Apply config operations and load enabled package plugins into one ordered generation. + const plugins = yield* resolve(pre, post, operations) + // Replace the active generation in one scoped, batched activation. + yield* registry.activate(plugins) }) - const sourceChanges = config.changes().pipe( - Stream.filterEffect((update) => Effect.map(config.entries(), (entries) => isPluginSource(entries, update.path))), - Stream.merge(Stream.fromPubSub(configuredChanges)), - // Make accepted filesystem work visible to flush before coalescing the burst. - Stream.mapEffect(() => Effect.sync(() => ++observed)), - Stream.debounce("100 millis"), - ) - const busUpdates = bus - .subscribe([Event.Updated, SdkPlugins.Updated]) - .pipe(Stream.mapEffect(() => Effect.sync(() => ++observed))) - const updates = yield* Stream.merge(busUpdates, sourceChanges).pipe( - Stream.toQueue({ capacity: 1, strategy: "sliding" }), - ) - const signals = yield* Stream.concat(Stream.succeed(0), Stream.fromQueue(updates)).pipe( - Stream.broadcast({ capacity: 1, strategy: "sliding", replay: 1 }), - ) - const attempt = (target: number) => - activate(target).pipe( - Effect.map(() => observed === target), - Effect.catchCause((cause) => Effect.logError("failed to reload plugins", { cause }).pipe(Effect.as(false))), - ) - - yield* signals.pipe( - Stream.runForEach((target) => - activate(target).pipe(Effect.catchCause((cause) => Effect.logError("failed to reload plugins", { cause }))), + const updates = Stream.merge( + config.changes().pipe( + Stream.filterEffect((update) => Effect.map(config.entries(), (entries) => isPluginSource(entries, update.path))), + Stream.merge(Stream.fromPubSub(configuredChanges)), ), - Effect.forkScoped({ startImmediately: true }), + bus.subscribe([Event.Updated, SdkPlugins.Updated]), + ).pipe( + // Make accepted work visible to flush before coalescing the burst. + Stream.mapEffect(() => Effect.sync(() => ++observed)), ) - yield* signals.pipe( + yield* Stream.concat(Stream.succeed(0), updates).pipe( + // Keep observing updates while activation runs, retaining only the latest generation request. + Stream.buffer({ capacity: 1, strategy: "sliding" }), Stream.debounce("100 millis"), - Stream.mapEffect(attempt), - Stream.filter((settled) => settled), - Stream.take(1), - Stream.runDrain, - Effect.andThen(Deferred.succeed(ready, undefined)), + Stream.runForEach((target) => + Effect.gen(function* () { + yield* activate() + if (observed === target) yield* Deferred.succeed(ready, undefined) + }).pipe(Effect.catchCause((cause) => Effect.logError("failed to reload plugins", { cause }))), + ), Effect.forkScoped({ startImmediately: true }), ) return Service.of({ flush: Deferred.await(ready) }) diff --git a/packages/core/src/tool/plugin/websearch.ts b/packages/core/src/tool/plugin/websearch.ts index 3269e5dccb..b605d33daa 100644 --- a/packages/core/src/tool/plugin/websearch.ts +++ b/packages/core/src/tool/plugin/websearch.ts @@ -32,85 +32,109 @@ export const Plugin = { yield* ctx.tool .transform((draft) => - draft.add( - { - name, - options: { codemode: false }, - description, - input: Input, - output: Output, - execute: (input, context) => - Effect.gen(function* () { - yield* permission.assert({ - action: name, - resources: [input.query], - save: ["*"], - metadata: input, - sessionID: context.sessionID, - agent: context.agent, - source: { type: "tool", messageID: context.messageID, callID: context.callID }, - }) - const result = yield* ctx.websearch.query(input).pipe( - Effect.catch((error) => { - if (!Schema.is(WebSearch.ProviderRequiredError)(error)) return Effect.fail(error) - return Effect.gen(function* () { - const providers = (yield* ctx.websearch.providers()).data - if (providers.length === 0) return yield* new WebSearch.ProviderRequiredError() - const response = yield* forms.ask({ - sessionID: context.sessionID, - title: "Choose a provider so the agent can search the web", - metadata: { kind: "websearch.provider" }, - fields: [ - { - key: "provider", - title: "Provider", - description: "OpenCode will use your choice for future searches.", - type: "string", - required: true, - custom: false, - options: [ - ...providers.map((provider) => ({ value: provider.id, label: provider.name })), - { value: "__disable__", label: "Disable web search" }, - ], - }, - ], - }) - if (response.status === "cancelled") return yield* Effect.fail(new Error("Web search cancelled")) - const answer = response.answer.provider - if (answer === "__disable__") { - yield* kv.set("websearch:provider", false) - return yield* new WebSearch.DisabledError() - } - if (typeof answer !== "string" || !providers.some((provider) => provider.id === answer)) - return yield* new WebSearch.ProviderRequiredError() - yield* kv.set("websearch:provider", answer) - return yield* ctx.websearch.query(input) + draft.add({ + name, + options: { codemode: false }, + description, + input: Input, + output: Output, + execute: (input, context) => + Effect.gen(function* () { + yield* permission.assert({ + action: name, + resources: [input.query], + save: ["*"], + metadata: input, + sessionID: context.sessionID, + agent: context.agent, + source: { type: "tool", messageID: context.messageID, callID: context.callID }, + }) + const result = yield* ctx.websearch.query(input).pipe( + Effect.catch((error) => { + if (!Schema.is(WebSearch.ProviderRequiredError)(error)) return Effect.fail(error) + return Effect.gen(function* () { + const providers = (yield* ctx.websearch.providers()).data + const defaultProvider = providers[0] + if (!defaultProvider) return yield* new WebSearch.ProviderRequiredError() + const response = yield* forms.ask({ + sessionID: context.sessionID, + title: "Web Search", + metadata: { kind: "websearch.provider" }, + fields: [ + { + key: "choice", + description: "Allow OpenCode to search the web for up-to-date information?", + type: "string", + required: true, + custom: false, + options: [ + { + value: "allow", + label: `Allow web search via ${defaultProvider.name}`, + }, + { + value: "choose", + label: "Choose another provider", + }, + { value: "disable", label: "Disable web search" }, + ], + }, + ], }) - }), - ) - const output = { - provider: result.data.providerID, - results: result.data.results, - } - const content = output.results.length - ? output.results - .map((result) => { - const title = result.title ?? result.url - const published = result.time.published - ? `\nPublished: ${new Date(result.time.published).toISOString()}` - : "" - return `## [${title}](${result.url})${published}${result.content ? `\n\n${result.content}` : ""}` - }) - .join("\n\n") - : NO_RESULTS - return { output, content, metadata: { provider: output.provider } } - }).pipe( - Effect.mapError( - (error) => new ToolFailure({ message: `Unable to search the web for ${input.query}`, error }), - ), + if (response.status === "cancelled") return yield* Effect.fail(new Error("Web search cancelled")) + if (response.answer.choice === "disable") { + yield* kv.set("websearch:provider", false) + return yield* new WebSearch.DisabledError() + } + const selection = + response.answer.choice === "choose" + ? yield* forms.ask({ + sessionID: context.sessionID, + title: "Choose a web search provider", + metadata: { kind: "websearch.provider" }, + fields: [ + { + key: "provider", + description: "Choose a provider for web search.", + type: "string", + required: true, + custom: false, + options: providers.map((provider) => ({ value: provider.id, label: provider.name })), + }, + ], + }) + : undefined + if (selection?.status === "cancelled") return yield* Effect.fail(new Error("Web search cancelled")) + const providerID = selection?.answer.provider ?? defaultProvider.id + if (typeof providerID !== "string" || !providers.some((provider) => provider.id === providerID)) + return yield* new WebSearch.ProviderRequiredError() + yield* kv.set("websearch:provider", providerID) + return yield* ctx.websearch.query(input) + }) + }), + ) + const output = { + provider: result.data.providerID, + results: result.data.results, + } + const content = output.results.length + ? output.results + .map((result) => { + const title = result.title ?? result.url + const published = result.time.published + ? `\nPublished: ${new Date(result.time.published).toISOString()}` + : "" + return `## [${title}](${result.url})${published}${result.content ? `\n\n${result.content}` : ""}` + }) + .join("\n\n") + : NO_RESULTS + return { output, content, metadata: { provider: output.provider } } + }).pipe( + Effect.mapError( + (error) => new ToolFailure({ message: `Unable to search the web for ${input.query}`, error }), ), - }, - ), + ), + }), ) .pipe(Effect.orDie) diff --git a/packages/core/test/generate.test.ts b/packages/core/test/generate.test.ts index b669e60f48..f90d83ed1b 100644 --- a/packages/core/test/generate.test.ts +++ b/packages/core/test/generate.test.ts @@ -1,6 +1,7 @@ import { expect } from "bun:test" -import { LLMClient, LLMEvent, LLMResponse, Model } from "@opencode-ai/ai" +import { Model } from "@opencode-ai/ai" import { OpenAIChat } from "@opencode-ai/ai/protocols" +import { TestLLM } from "@opencode-ai/ai/testing" import { AISDK } from "@opencode-ai/core/aisdk" import { Catalog } from "@opencode-ai/core/catalog" import { Generate } from "@opencode-ai/core/generate" @@ -9,7 +10,7 @@ import { ModelResolver } from "@opencode-ai/core/model-resolver" import { ID, Info, Ref } from "@opencode-ai/core/model" import { Provider } from "@opencode-ai/core/provider" import { Npm } from "@opencode-ai/util/npm" -import { Effect, Layer, Stream } from "effect" +import { Effect, Layer } from "effect" import { testEffect } from "./lib/effect" const selected = Info.make({ @@ -64,21 +65,7 @@ const aisdk = Layer.mock(AISDK.Service, { }, model: () => Effect.succeed(runtime), }) -const client = Layer.mock(LLMClient.Service)({ - prepare: () => Effect.die("unused"), - stream: () => Stream.die("unused"), - generate: () => - Effect.sync(() => { - const response = LLMResponse.fromEvents([ - LLMEvent.textStart({ id: "generate" }), - LLMEvent.textDelta({ id: "generate", text: "OK" }), - LLMEvent.textEnd({ id: "generate" }), - LLMEvent.finish({ reason: { normalized: "stop" } }), - ]) - if (!response) throw new Error("Incomplete generate response") - return response - }), -}) +const client = TestLLM.clientLayer.pipe(Layer.provide(TestLLM.layer({ fallback: TestLLM.text("OK", "generate") }))) const resolver = ModelResolver.layer.pipe(Layer.provide(Layer.mergeAll(catalog, integrations, npm, aisdk))) const it = testEffect(Generate.layer.pipe(Layer.provide(Layer.merge(resolver, client)))) diff --git a/packages/core/test/session-runner.test.ts b/packages/core/test/session-runner.test.ts index 13f1ed5c3b..3fc569c2f9 100644 --- a/packages/core/test/session-runner.test.ts +++ b/packages/core/test/session-runner.test.ts @@ -1,8 +1,8 @@ import { describe, expect, test } from "bun:test" import { - LLMClient, LLMError, LLMEvent, + LLMRequest, Message, Model, SystemPart, @@ -11,10 +11,9 @@ import { InvalidProviderOutputReason, InvalidRequestReason, RateLimitReason, - type LLMClientShape, - type LLMRequest, } from "@opencode-ai/ai" import * as OpenAIChat from "@opencode-ai/ai/protocols/openai-chat" +import { TestLLM } from "@opencode-ai/ai/testing" import { Catalog } from "@opencode-ai/core/catalog" import { Database } from "@opencode-ai/core/database/database" import { makeLocationNode } from "@opencode-ai/util/effect/app-node" @@ -78,77 +77,59 @@ import { agentHost, catalogHost, host } from "./plugin/host" import PROMPT_DEFAULT from "../src/session/runner/prompt/base.txt" import { CodeModeInstructions } from "@opencode-ai/core/codemode/instructions" -const requests: LLMRequest[] = [] +let requests: LLMRequest[] = [] const emptyCodeMode = `\n\n${CodeModeInstructions.render({ total: 0, shown: 0, namespaces: [] })}` -let response: LLMEvent[] = [] -let responses: LLMEvent[][] | undefined -let responseStream: Stream.Stream | undefined -let responseStreams: Stream.Stream[] | undefined -let streamGate: Deferred.Deferred | undefined -let streamStarted: Deferred.Deferred | undefined -let streamFailure: LLMError | undefined -let toolExecutionGate: Deferred.Deferred | undefined -let toolExecutionsStarted: Deferred.Deferred | undefined -let toolExecutionsReady = 5 -let activeToolExecutions = 0 -let maxActiveToolExecutions = 0 -const client = Layer.succeed( - LLMClient.Service, - LLMClient.Service.of({ - prepare: () => Effect.die("unused"), - stream: ((request: LLMRequest) => { - requests.push({ - ...request, - system: request.system.map((part) => ({ - ...part, - text: part.text.replace(emptyCodeMode, ""), - })), - tools: request.tools.filter((tool) => tool.name !== "execute"), - }) - if (responseStreams) return responseStreams.shift() ?? Stream.empty - if (responseStream) { - const stream = responseStream - responseStream = undefined - return stream - } - const bus = streamFailure - ? Stream.fail(streamFailure) - : Stream.fromIterable(responses === undefined ? response : (responses.shift() ?? [])) - if (!streamGate) return bus - return Stream.unwrap( - (streamStarted ? Deferred.succeed(streamStarted, undefined) : Effect.void).pipe( - Effect.andThen(Deferred.await(streamGate)), - Effect.as(bus), - ), - ) - }) as unknown as LLMClientShape["stream"], - generate: () => Effect.die("unused"), - }), -) -const reply = { - stop: () => [ - LLMEvent.stepStart({ index: 0 }), - LLMEvent.stepFinish({ index: 0, reason: { normalized: "stop" } }), - LLMEvent.finish({ reason: { normalized: "stop" } }), - ], - text: (text: string, id: string) => fragmentFixture("text", id, [text]).completeEvents, - textWithUsage: (text: string, id: string, inputTokens: number) => - fragmentFixture("text", id, [text]).completeEvents.map((event) => - LLMEvent.is.stepFinish(event) - ? LLMEvent.stepFinish({ - index: event.index, - reason: event.reason, - usage: { inputTokens, nonCachedInputTokens: inputTokens }, - }) - : event, - ), - tool: (id: string, name: string, input: unknown) => [ - LLMEvent.stepStart({ index: 0 }), - LLMEvent.toolCall({ id, name, input }), - LLMEvent.stepFinish({ index: 0, reason: { normalized: "tool-calls" } }), - LLMEvent.finish({ reason: { normalized: "tool-calls" } }), - ], +type ToolBarrier = { + readonly count: number + readonly started: Deferred.Deferred + readonly release: Deferred.Deferred + active: number + maxActive: number } +let toolBarrier: ToolBarrier | undefined +const releaseTools = (barrier: ToolBarrier) => + Effect.sync(() => { + if (toolBarrier === barrier) toolBarrier = undefined + }).pipe(Effect.andThen(Deferred.succeed(barrier.release, undefined)), Effect.asVoid) +const blockTools = (count = 1) => + Effect.acquireRelease( + Effect.all({ started: Deferred.make(), release: Deferred.make() }).pipe( + Effect.map((deferreds) => { + const barrier = { count, ...deferreds, active: 0, maxActive: 0 } + toolBarrier = barrier + return barrier + }), + ), + releaseTools, + ).pipe( + Effect.map((barrier) => ({ + started: Deferred.await(barrier.started), + release: releaseTools(barrier), + maxActive: Effect.sync(() => barrier.maxActive), + })), + ) +const awaitToolBarrier = Effect.suspend(() => { + const barrier = toolBarrier + if (!barrier) return Effect.void + barrier.active++ + barrier.maxActive = Math.max(barrier.maxActive, barrier.active) + return (barrier.active === barrier.count ? Deferred.succeed(barrier.started, undefined) : Effect.void).pipe( + Effect.andThen(Deferred.await(barrier.release)), + Effect.ensuring(Effect.sync(() => barrier.active--)), + ) +}) +const testLLM = TestLLM.layer({ + fallback: [], + transformRequest: (request) => + LLMRequest.update(request, { + system: request.system.map((part) => ({ + ...part, + text: part.text.replace(emptyCodeMode, ""), + })), + tools: request.tools.filter((tool) => tool.name !== "execute"), + }), +}) +const client = TestLLM.clientLayer const model = Model.make({ id: "fake-model", provider: "fake", route: OpenAIChat.route }) const defaultSystem = PROMPT_DEFAULT const replacementModel = Model.make({ id: "replacement", provider: "fake", route: OpenAIChat.route }) @@ -221,7 +202,7 @@ test("does not apply an ineligible tier without base pricing", () => { const authorizations: Tool.Context[] = [] const executions: string[] = [] -const permissionFail = ({ +const permissionFail = { name: "permission_fail", description: "Reject a permission", input: Schema.Struct({}), @@ -235,7 +216,7 @@ const permissionFail = ({ resources: ["src/index.ts"], }), }), -}) +} const permission = Layer.succeed( Permission.Service, Permission.Service.of({ @@ -247,11 +228,7 @@ const permission = Layer.succeed( list: () => Effect.die("unused"), }), ) -const transformTools = ( - registry: Tool.Interface, - tools: Readonly>, - options?: Tool.Options, -) => +const transformTools = (registry: Tool.Interface, tools: Readonly>, options?: Tool.Options) => registry.transform((draft) => Object.entries(tools).forEach(([name, tool]) => draft.add({ ...tool, name, options: { ...tool.options, ...options } }), @@ -259,9 +236,10 @@ const transformTools = ( ) const echo = Layer.effectDiscard( Tool.Service.use((registry) => - transformTools(registry, + transformTools( + registry, { - echo: ({ + echo: { name: "echo", description: "Echo text", input: Schema.Struct({ text: Schema.String }), @@ -270,32 +248,24 @@ const echo = Layer.effectDiscard( Effect.gen(function* () { authorizations.push(context) executions.push(text) - activeToolExecutions++ - maxActiveToolExecutions = Math.max(maxActiveToolExecutions, activeToolExecutions) - if (activeToolExecutions === toolExecutionsReady && toolExecutionsStarted) { - yield* Deferred.succeed(toolExecutionsStarted, undefined) - } - if (toolExecutionGate) yield* Deferred.await(toolExecutionGate) + yield* awaitToolBarrier return { output: { text }, content: text } - }).pipe(Effect.ensuring(Effect.sync(() => activeToolExecutions--))), - }), - defect: ({ + }), + }, + defect: { name: "defect", description: "Fail unexpectedly", input: Schema.Struct({}), output: Schema.Struct({}), - execute: () => - (toolExecutionGate ? Deferred.await(toolExecutionGate) : Effect.void).pipe( - Effect.andThen(Effect.die("unexpected tool defect")), - ), - }), - storefail: ({ + execute: () => awaitToolBarrier.pipe(Effect.andThen(Effect.die("unexpected tool defect"))), + }, + storefail: { name: "storefail", description: "Produce output that cannot be persisted", input: Schema.Struct({}), output: Schema.Struct({}), execute: () => Effect.succeed({ output: {} }), - }), + }, }, { codemode: false }, ), @@ -469,11 +439,16 @@ const it = testEffect( [Config.node, config], [PluginSupervisor.node, pluginSupervisor], ], - ), + ).pipe(Layer.provideMerge(testLLM)), ) const sessionID = Session.ID.make("ses_runner_test") const otherSessionID = Session.ID.make("ses_runner_other") const admit = (session: Session.Interface, text: string) => session.prompt({ sessionID, text, resume: false }) +const runPrompt = Effect.fnUntraced(function* (session: Session.Interface, text: string) { + const message = yield* admit(session, text) + yield* session.resume(sessionID) + return message +}) const insertSession = (id: Session.ID) => Effect.gen(function* () { @@ -506,10 +481,9 @@ const setup = Effect.gen(function* () { yield* Effect.forEach(SystemPromptPlugin.Plugins, (plugin) => plugin.effect(pluginHost), { discard: true, }) - requests.length = 0 + requests = (yield* TestLLM.Service).requests authorizations.length = 0 executions.length = 0 - response = [] systemBaseline = "Initial context" systemRemoved = false systemUnavailable = false @@ -518,17 +492,7 @@ const setup = Effect.gen(function* () { pluginFlushHook = Effect.void currentModel = model skillBaselines.clear() - responses = undefined - streamFailure = undefined - responseStream = undefined - responseStreams = undefined - streamGate = undefined - streamStarted = undefined - toolExecutionGate = undefined - toolExecutionsStarted = undefined - toolExecutionsReady = 5 - activeToolExecutions = 0 - maxActiveToolExecutions = 0 + toolBarrier = undefined yield* agents.transform((draft) => draft.update(Agent.ID.make("build"), (agent) => { agent.mode = "primary" @@ -567,9 +531,8 @@ const rateLimited = (retryAfterMs?: number) => const setupOverflowRecovery = Effect.gen(function* () { const session = yield* setup - response = reply.text("Earlier answer", "text-earlier") - yield* admit(session, "Earlier question ".repeat(700)) - yield* session.resume(sessionID) + yield* TestLLM.push(TestLLM.text("Earlier answer", "text-earlier")) + yield* runPrompt(session, "Earlier question ".repeat(700)) currentModel = recoveryModel requests.length = 0 return session @@ -581,6 +544,7 @@ const messageTexts = (request: LLMRequest, role: "user" | "system") => ) const userTexts = (request: LLMRequest) => messageTexts(request, "user") const systemTexts = (request: LLMRequest) => messageTexts(request, "system") +const messageRoles = (request: LLMRequest | undefined) => request?.messages.map((message) => message.role) const recordedEventTypes = (id: Session.ID) => Effect.gen(function* () { @@ -619,6 +583,9 @@ const recordedStepSettlementEvents = (id: Session.ID, assistantMessageID: Sessio ) }) +const recordedStepSettlementTypes = (id: Session.ID, assistantMessageID: SessionMessage.ID) => + recordedStepSettlementEvents(id, assistantMessageID).pipe(Effect.map((events) => events.map((event) => event.type))) + const hostedCall = (id: string, query: string) => LLMEvent.toolCall({ id, name: "web_search", input: { query }, providerExecuted: true }) @@ -742,7 +709,7 @@ const verifyEphemeralDeltas = (kind: FragmentKind) => const bus = yield* Bus.Service const live = yield* bus.subscribe(fixture.delta).pipe(Stream.take(32), Stream.runCollect, Effect.forkScoped) yield* Effect.yieldNow - response = fixture.completeEvents + yield* TestLLM.push(fixture.completeEvents) yield* session.resume(sessionID) @@ -769,7 +736,7 @@ const verifyPartialFlushOnFailure = (kind: FragmentKind) => const fixture = fragmentFixture(kind, fragmentID(kind, "partial"), ["Partial"]) const failure = providerUnavailable() yield* admit(session, prompt) - responseStream = Stream.concat(Stream.fromIterable(fixture.partialEvents), Stream.fail(failure)) + yield* TestLLM.push(TestLLM.failAfter(failure, ...fixture.partialEvents)) expect(yield* session.resume(sessionID).pipe(Effect.flip)).toBe(failure) expect(yield* session.context(sessionID)).toMatchObject([ @@ -802,9 +769,11 @@ const verifyPartialFlushOnInterruption = (kind: FragmentKind) => const fixture = fragmentFixture(kind, fragmentID(kind, "interrupted"), ["Partial"]) const streamed = yield* Deferred.make() yield* admit(session, prompt) - responseStream = Stream.concat( - Stream.fromIterable(fixture.partialEvents), - Stream.fromEffect(Deferred.succeed(streamed, undefined)).pipe(Stream.flatMap(() => Stream.never)), + yield* TestLLM.push( + Stream.concat( + Stream.fromIterable(fixture.partialEvents), + Stream.fromEffect(Deferred.succeed(streamed, undefined)).pipe(Stream.flatMap(() => Stream.never)), + ), ) const runner = yield* SessionRunner.Service @@ -840,7 +809,7 @@ describe("SessionRunnerLLM", () => { }), ) yield* admit(session, "Original message") - responses = [reply.tool("call-removed", "echo", { text: "blocked" })] + yield* TestLLM.push(TestLLM.tool("call-removed", "echo", { text: "blocked" })) yield* session.resume(sessionID) @@ -872,9 +841,10 @@ describe("SessionRunnerLLM", () => { const session = yield* setup const registry = yield* Tool.Service const contexts: Tool.Context[] = [] - yield* transformTools(registry, + yield* transformTools( + registry, { - location_context: ({ + location_context: { name: "location_context", description: "Read application context", input: Schema.Struct({ query: Schema.String }), @@ -885,12 +855,12 @@ describe("SessionRunnerLLM", () => { yield* context.progress({ phase: "reading" }) return { output: { answer: query.toUpperCase() } } }), - }), + }, }, { codemode: false }, ) yield* admit(session, "Use application context") - responses = [reply.tool("call-location", "location_context", { query: "hello" }), []] + yield* TestLLM.push(TestLLM.tool("call-location", "location_context", { query: "hello" }), []) const bus = yield* Bus.Service const progressFiber = yield* bus.subscribe(SessionEvent.Tool.Progress).pipe( Stream.filter((event) => event.data.sessionID === sessionID && event.data.callID === "call-location"), @@ -934,50 +904,42 @@ describe("SessionRunnerLLM", () => { const registry = yield* Tool.Service const scope = yield* Scope.make() const executions: string[] = [] - yield* transformTools(registry, - { - reloaded: ({ - name: "reloaded", - description: "Record the advertised tool", - input: Schema.Struct({}), - output: Schema.Struct({ value: Schema.String }), - execute: () => - Effect.sync(() => executions.push("advertised")).pipe(Effect.as({ output: { value: "advertised" } })), - }), + yield* transformTools( + registry, + { + reloaded: { + name: "reloaded", + description: "Record the advertised tool", + input: Schema.Struct({}), + output: Schema.Struct({ value: Schema.String }), + execute: () => + Effect.sync(() => executions.push("advertised")).pipe(Effect.as({ output: { value: "advertised" } })), }, - { codemode: false }, - ) - .pipe(Scope.provide(scope)) + }, + { codemode: false }, + ).pipe(Scope.provide(scope)) yield* admit(session, "Use the reloaded tool") - responses = [ - [ - LLMEvent.stepStart({ index: 0 }), - LLMEvent.toolCall({ id: "call-reloaded", name: "reloaded", input: {} }), - LLMEvent.stepFinish({ index: 0, reason: { normalized: "tool-calls" } }), - LLMEvent.finish({ reason: { normalized: "tool-calls" } }), - ], - [], - ] - streamGate = yield* Deferred.make() - streamStarted = yield* Deferred.make() + yield* TestLLM.push(TestLLM.tool("call-reloaded", "reloaded", {}), []) + const stream = yield* TestLLM.gate const run = yield* session.resume(sessionID).pipe(Effect.forkChild) - yield* Deferred.await(streamStarted) + yield* stream.started yield* Scope.close(scope, Exit.void) - yield* transformTools(registry, + yield* transformTools( + registry, { - reloaded: ({ + reloaded: { name: "reloaded", description: "Record the replacement tool", input: Schema.Struct({}), output: Schema.Struct({ value: Schema.String }), execute: () => Effect.sync(() => executions.push("replacement")).pipe(Effect.as({ output: { value: "replacement" } })), - }), + }, }, { codemode: false }, ) - yield* Deferred.succeed(streamGate, undefined) + yield* stream.release yield* Fiber.join(run) expect(executions).toEqual(["advertised"]) @@ -1019,16 +981,16 @@ describe("SessionRunnerLLM", () => { const session = yield* setup const secondStarted = yield* Deferred.make() const releaseSecond = yield* Deferred.make() - responseStreams = [ - Stream.fromIterable(reply.tool("call-echo", "echo", { text: "background started" })), + yield* TestLLM.push( + Stream.fromIterable(TestLLM.tool("call-echo", "echo", { text: "background started" })), Stream.unwrap( Deferred.succeed(secondStarted, undefined).pipe( Effect.andThen(Deferred.await(releaseSecond)), - Effect.as(Stream.fromIterable(reply.stop())), + Effect.as(Stream.fromIterable(TestLLM.stop())), ), ), - Stream.fromIterable(reply.text("Handled completion", "text-completion")), - ] + Stream.fromIterable(TestLLM.text("Handled completion", "text-completion")), + ) yield* admit(session, "Start background work") const running = yield* session.resume(sessionID).pipe(Effect.forkChild({ startImmediately: true })) yield* Deferred.await(secondStarted) @@ -1038,7 +1000,7 @@ describe("SessionRunnerLLM", () => { yield* Fiber.join(running) expect(requests).toHaveLength(3) - expect(userTexts(requests[2]!)).toContain("Background work completed") + expect(userTexts(requests[2])).toContain("Background work completed") }), ) @@ -1046,9 +1008,7 @@ describe("SessionRunnerLLM", () => { Effect.gen(function* () { const session = yield* setup yield* admit(session, "First") - yield* admit(session, "Second") - - yield* session.resume(sessionID) + yield* runPrompt(session, "Second") expect(requests).toHaveLength(1) expect(requests[0]?.model).toBe(model) @@ -1071,12 +1031,9 @@ describe("SessionRunnerLLM", () => { if (event.type === "session.instructions.updated") instructionEvents.push(event) }), ) - yield* admit(session, "First") - - yield* session.resume(sessionID) + yield* runPrompt(session, "First") systemBaseline = "Changed context" - yield* admit(session, "Second") - yield* session.resume(sessionID) + yield* runPrompt(session, "Second") yield* unsubscribe expect(instructionEvents).toHaveLength(2) @@ -1113,7 +1070,7 @@ describe("SessionRunnerLLM", () => { yield* session.wait(sessionID) expect(requests).toHaveLength(1) - expect(requests[0]?.messages.map((message) => message.role)).toEqual(["user"]) + expect(messageRoles(requests[0])).toEqual(["user"]) }), ) @@ -1122,8 +1079,7 @@ describe("SessionRunnerLLM", () => { const session = yield* setup const bus = yield* Bus.Service const { db } = yield* Database.Service - yield* admit(session, "First") - yield* session.resume(sessionID) + yield* runPrompt(session, "First") yield* bus.publish(SessionEvent.Moved, { sessionID, @@ -1145,14 +1101,11 @@ describe("SessionRunnerLLM", () => { it.effect("forks instruction values at the selected message instead of the parent's latest state", () => Effect.gen(function* () { const session = yield* setup - const first = yield* admit(session, "First") - yield* session.resume(sessionID) + yield* runPrompt(session, "First") systemBaseline = "Changed context" - const second = yield* admit(session, "Second") - yield* session.resume(sessionID) + const second = yield* runPrompt(session, "Second") systemBaseline = "Latest context" - yield* admit(session, "Third") - yield* session.resume(sessionID) + yield* runPrompt(session, "Third") const forked = yield* session.fork({ sessionID, messageID: second.id }) expect( @@ -1201,11 +1154,9 @@ describe("SessionRunnerLLM", () => { it.effect("caps nested fork instruction ancestry at the selected message", () => Effect.gen(function* () { const session = yield* setup - yield* admit(session, "First") - yield* session.resume(sessionID) + yield* runPrompt(session, "First") systemBaseline = "Changed context" - const second = yield* admit(session, "Second") - yield* session.resume(sessionID) + const second = yield* runPrompt(session, "Second") const child = yield* session.fork({ sessionID, messageID: second.id }) const inheritedFirst = (yield* session.messages({ sessionID: child.id })).find( @@ -1224,6 +1175,7 @@ describe("SessionRunnerLLM", () => { initial_values: { "test/context": Instructions.hash("Initial context") }, current_values: { "test/context": Instructions.hash("Initial context") }, }) + return undefined }), ) @@ -1231,8 +1183,7 @@ describe("SessionRunnerLLM", () => { Effect.gen(function* () { const session = yield* setup const { db } = yield* Database.Service - yield* admit(session, "First") - yield* session.resume(sessionID) + yield* runPrompt(session, "First") yield* db.delete(InstructionStateTable).where(eq(InstructionStateTable.session_id, sessionID)).run() yield* admit(session, "Second") requests.length = 0 @@ -1241,7 +1192,7 @@ describe("SessionRunnerLLM", () => { expect(requests).toHaveLength(1) expect(requests[0]?.system.map((part) => part.text)).toEqual([defaultSystem, "Initial context"]) - expect(requests[0]?.messages.map((message) => message.role)).toEqual(["user", "user"]) + expect(messageRoles(requests[0])).toEqual(["user", "user"]) expect( yield* db .select({ id: EventTable.id }) @@ -1259,24 +1210,21 @@ describe("SessionRunnerLLM", () => { it.effect("keeps the initial instructions stable and derives a chronological update from values", () => Effect.gen(function* () { const session = yield* setup - yield* admit(session, "First") - - yield* session.resume(sessionID) + yield* runPrompt(session, "First") systemBaseline = "Changed context" - yield* admit(session, "Second") - yield* session.resume(sessionID) + yield* runPrompt(session, "Second") expect( PromptCacheDiagnostics.compare( - PromptCacheDiagnostics.snapshot(requests[0]!), - PromptCacheDiagnostics.snapshot(requests[1]!), + PromptCacheDiagnostics.snapshot(requests[0]), + PromptCacheDiagnostics.snapshot(requests[1]), ), ).toEqual({ status: "append-only", previousMessages: 1, currentMessages: 3 }) expect(requests.map((request) => request.system.map((part) => part.text))).toEqual([ [defaultSystem, "Initial context"], [defaultSystem, "Initial context"], ]) - expect(requests[1]?.messages.map((message) => message.role)).toEqual(["user", "system", "user"]) + expect(messageRoles(requests[1])).toEqual(["user", "system", "user"]) expect(requests[1]?.messages.at(1)?.content).toEqual([{ type: "text", text: "Changed context" }]) expect(yield* session.messages({ sessionID })).toHaveLength(2) const { db } = yield* Database.Service @@ -1307,7 +1255,7 @@ describe("SessionRunnerLLM", () => { currentModel = Model.make({ id: "gpt-5", provider: "openai", route: OpenAIChat.route }) yield* admit(session, "First") - response = reply.text("Done", "text-provider-prompt") + yield* TestLLM.push(TestLLM.text("Done", "text-provider-prompt")) yield* session.resume(sessionID) expect(requests.at(-1)?.system.map((part) => part.text)).toEqual([ @@ -1330,7 +1278,7 @@ describe("SessionRunnerLLM", () => { ) yield* admit(session, "First") - response = reply.text("Done", "text-empty-agent-system") + yield* TestLLM.push(TestLLM.text("Done", "text-empty-agent-system")) yield* session.resume(sessionID) expect(requests.at(-1)?.system.map((part) => part.text)).toEqual([ @@ -1352,7 +1300,7 @@ describe("SessionRunnerLLM", () => { ) yield* admit(session, "First") - response = reply.text("Done", "text-build") + yield* TestLLM.push(TestLLM.text("Done", "text-build")) yield* session.resume(sessionID) expect(requests.at(-1)?.system.map((part) => part.text)).toEqual(["Build agent instructions", "Initial context"]) @@ -1376,7 +1324,7 @@ describe("SessionRunnerLLM", () => { }) yield* admit(session, "First") - response = reply.text("Done", "text-reviewer") + yield* TestLLM.push(TestLLM.text("Done", "text-reviewer")) yield* session.resume(sessionID) expect(requests.at(-1)?.system.map((part) => part.text)).toEqual(["Reviewer instructions", "Initial context"]) @@ -1396,7 +1344,7 @@ describe("SessionRunnerLLM", () => { ) yield* admit(session, "First") - response = reply.text("Done", "text-no-system") + yield* TestLLM.push(TestLLM.text("Done", "text-no-system")) yield* session.resume(sessionID) expect(requests.at(-1)?.system.map((part) => part.text)).toEqual(["Build agent instructions", "Initial context"]) @@ -1422,7 +1370,7 @@ describe("SessionRunnerLLM", () => { .pipe(Effect.orDie) yield* admit(session, "First") - response = reply.text("Done", "text-selected") + yield* TestLLM.push(TestLLM.text("Done", "text-selected")) yield* session.resume(sessionID) expect(requests.at(-1)?.system.map((part) => part.text)).toEqual(["Reviewer instructions", "Initial context"]) @@ -1444,7 +1392,7 @@ describe("SessionRunnerLLM", () => { yield* session.prompt({ sessionID, text: "Inspect files", resume: false }) requests.length = 0 - response = [] + yield* TestLLM.push([]) const failure = yield* session.resume(sessionID).pipe(Effect.flip) expect(failure).toMatchObject({ @@ -1465,7 +1413,7 @@ describe("SessionRunnerLLM", () => { yield* session.prompt({ sessionID, text: "Wait for plugins", resume: false }) requests.length = 0 - response = [] + yield* TestLLM.push([]) const running = yield* session.resume(sessionID).pipe(Effect.forkChild({ startImmediately: true })) yield* Effect.yieldNow @@ -1489,22 +1437,19 @@ describe("SessionRunnerLLM", () => { }), ) skillBaselines.set(Agent.ID.make("build"), "Build skills") - yield* admit(session, "First") - - yield* session.resume(sessionID) + yield* runPrompt(session, "First") skillBaselines.set(Agent.ID.make("reviewer"), "Reviewer skills") yield* bus.publish(SessionEvent.AgentSelected, { sessionID, agent: Agent.ID.make("reviewer"), }) - yield* admit(session, "Second") - yield* session.resume(sessionID) + yield* runPrompt(session, "Second") expect(requests.map((request) => request.system.map((part) => part.text))).toEqual([ [defaultSystem, "Initial context\n\nBuild skills"], [defaultSystem, "Initial context\n\nBuild skills"], ]) - expect(systemTexts(requests[1]!)).toContainEqual(expect.stringContaining("Reviewer skills")) + expect(systemTexts(requests[1])).toContainEqual(expect.stringContaining("Reviewer skills")) }), ) @@ -1525,9 +1470,7 @@ describe("SessionRunnerLLM", () => { }) .pipe(Effect.asVoid) }) - yield* admit(session, "First") - - yield* session.resume(sessionID) + yield* runPrompt(session, "First") expect(requests.map((request) => request.system.map((part) => part.text))).toEqual([ [defaultSystem, "Initial context\n\nBuild skills"], @@ -1550,9 +1493,7 @@ describe("SessionRunnerLLM", () => { }) .pipe(Effect.asVoid) }) - yield* admit(session, "First") - - yield* session.resume(sessionID) + yield* runPrompt(session, "First") expect(requests.map((request) => request.model)).toEqual([model]) expect(requests.map((request) => request.system.map((part) => part.text))).toEqual([ [defaultSystem, "Initial context"], @@ -1563,14 +1504,11 @@ describe("SessionRunnerLLM", () => { it.effect("admits removed context as a chronological System message", () => Effect.gen(function* () { const session = yield* setup - yield* admit(session, "First") - - yield* session.resume(sessionID) + yield* runPrompt(session, "First") systemRemoved = true - yield* admit(session, "Second") - yield* session.resume(sessionID) + yield* runPrompt(session, "Second") - expect(requests[1]?.messages.map((message) => message.role)).toEqual(["user", "system", "user"]) + expect(messageRoles(requests[1])).toEqual(["user", "system", "user"]) expect(requests[1]?.messages.at(1)?.content).toEqual([ { type: "text", text: "System context source removed: test/context" }, ]) @@ -1583,9 +1521,7 @@ describe("SessionRunnerLLM", () => { const session = yield* setup const contextEntries = yield* InstructionEntry.Service yield* contextEntries.put({ sessionID, key: "deploy-target", value: "production" }) - yield* admit(session, "First") - - yield* session.resume(sessionID) + yield* runPrompt(session, "First") // String values render verbatim inside the initial tagged block. expect(requests[0]?.system.map((part) => part.text)).toEqual([ @@ -1595,10 +1531,9 @@ describe("SessionRunnerLLM", () => { // Non-string JSON pretty-prints; the change narrates as a System update. yield* contextEntries.put({ sessionID, key: "deploy-target", value: { region: "us-east-1" } }) - yield* admit(session, "Second") - yield* session.resume(sessionID) + yield* runPrompt(session, "Second") - expect(requests[1]?.messages.map((message) => message.role)).toEqual(["user", "system", "user"]) + expect(messageRoles(requests[1])).toEqual(["user", "system", "user"]) expect(requests[1]?.messages.at(1)?.content).toEqual([ { type: "text", @@ -1616,10 +1551,9 @@ describe("SessionRunnerLLM", () => { // Deleting the row announces removal through the stored removal text. yield* contextEntries.remove({ sessionID, key: "deploy-target" }) - yield* admit(session, "Third") - yield* session.resume(sessionID) + yield* runPrompt(session, "Third") - expect(requests[2]?.messages.map((message) => message.role)).toEqual(["user", "system", "user", "system", "user"]) + expect(messageRoles(requests[2])).toEqual(["user", "system", "user", "system", "user"]) expect(requests[2]?.messages.at(-2)?.content).toEqual([ { type: "text", text: 'The context under "deploy-target" no longer applies. Disregard it.' }, ]) @@ -1632,12 +1566,10 @@ describe("SessionRunnerLLM", () => { const session = yield* setup const entries = yield* InstructionEntry.Service yield* entries.put({ sessionID, key: "nullable", value: "present" }) - yield* admit(session, "First") - yield* session.resume(sessionID) + yield* runPrompt(session, "First") yield* entries.put({ sessionID, key: "nullable", value: null }) - yield* admit(session, "Second") - yield* session.resume(sessionID) + yield* runPrompt(session, "Second") expect(requests[1]?.messages.at(1)?.content).toEqual([ { @@ -1673,26 +1605,22 @@ describe("SessionRunnerLLM", () => { Effect.gen(function* () { const session = yield* setup const bus = yield* Bus.Service - yield* admit(session, "First") - - yield* session.resume(sessionID) + yield* runPrompt(session, "First") systemBaseline = "Changed context" - yield* admit(session, "Second") - yield* session.resume(sessionID) + yield* runPrompt(session, "Second") yield* bus.publish(SessionEvent.ModelSelected, { sessionID, model: { id: ID.make("replacement"), providerID: Provider.ID.make("fake") }, }) systemBaseline = "Replacement context" - yield* admit(session, "Third") - yield* session.resume(sessionID) + yield* runPrompt(session, "Third") expect(requests.map((request) => request.system.map((part) => part.text))).toEqual([ [defaultSystem, "Initial context"], [defaultSystem, "Initial context"], [defaultSystem, "Initial context"], ]) - expect(requests[1]?.messages.map((message) => message.role)).toEqual(["user", "system", "user"]) + expect(messageRoles(requests[1])).toEqual(["user", "system", "user"]) expect(requests[2]?.messages.filter((message) => message.role === "system")).toHaveLength(2) expect((yield* session.context(sessionID)).map((message) => message.type)).toEqual([ "user", @@ -1702,8 +1630,7 @@ describe("SessionRunnerLLM", () => { ]) yield* replaySessionProjection(sessionID) expect(yield* session.messages({ sessionID })).toHaveLength(4) - yield* admit(session, "Fourth") - yield* session.resume(sessionID) + yield* runPrompt(session, "Fourth") }), ) @@ -1711,20 +1638,16 @@ describe("SessionRunnerLLM", () => { Effect.gen(function* () { const session = yield* setup const bus = yield* Bus.Service - yield* admit(session, "First") - - yield* session.resume(sessionID) + yield* runPrompt(session, "First") yield* bus.publish(SessionEvent.ModelSelected, { sessionID, model: { id: ID.make("replacement"), providerID: Provider.ID.make("fake") }, }) systemUnavailable = true - yield* admit(session, "Second") - yield* session.resume(sessionID) + yield* runPrompt(session, "Second") systemUnavailable = false systemBaseline = "Replacement context" - yield* admit(session, "Third") - yield* session.resume(sessionID) + yield* runPrompt(session, "Third") expect(requests.map((request) => request.system.map((part) => part.text))).toEqual([ [defaultSystem, "Initial context"], @@ -1738,9 +1661,7 @@ describe("SessionRunnerLLM", () => { Effect.gen(function* () { const session = yield* setup const bus = yield* Bus.Service - yield* admit(session, "First") - - yield* session.resume(sessionID) + yield* runPrompt(session, "First") yield* bus.publish(SessionEvent.Compaction.Started, { sessionID, reason: "manual", @@ -1753,18 +1674,16 @@ describe("SessionRunnerLLM", () => { recent: "", }) systemBaseline = "Replacement context" - yield* admit(session, "Second") - yield* session.resume(sessionID) + yield* runPrompt(session, "Second") expect(requests.map((request) => request.system.map((part) => part.text))).toEqual([ [defaultSystem, "Initial context"], [defaultSystem, "Initial context"], ]) - expect(requests[1]?.messages.map((message) => message.role)).toEqual(["user", "system", "user"]) + expect(messageRoles(requests[1])).toEqual(["user", "system", "user"]) expect(requests[1]?.messages.at(1)?.content).toEqual([{ type: "text", text: "Replacement context" }]) yield* replaySessionProjection(sessionID) - yield* admit(session, "Third") - yield* session.resume(sessionID) + yield* runPrompt(session, "Third") }), ) @@ -1772,17 +1691,16 @@ describe("SessionRunnerLLM", () => { Effect.gen(function* () { const session = yield* setup currentModel = recoveryModel - streamGate = yield* Deferred.make() - streamStarted = yield* Deferred.make() - responses = [ - reply.tool("call-active", "echo", { text: "active" }), + const stream = yield* TestLLM.gate + yield* TestLLM.push( + TestLLM.tool("call-active", "echo", { text: "active" }), [LLMEvent.textDelta({ id: "summary", text: "durable summary" })], - reply.text("Steer complete", "text-steer"), - reply.text("Queue complete", "text-queue"), - ] + TestLLM.text("Steer complete", "text-steer"), + TestLLM.text("Queue complete", "text-queue"), + ) yield* admit(session, "Active work") const active = yield* session.resume(sessionID).pipe(Effect.forkChild) - yield* Deferred.await(streamStarted) + yield* stream.started const first = yield* session.compact({ sessionID }) const second = yield* session.compact({ sessionID }) @@ -1802,7 +1720,7 @@ describe("SessionRunnerLLM", () => { }) expect(yield* SessionPending.has((yield* Database.Service).db, sessionID, "steer")).toBe(false) - yield* Deferred.succeed(streamGate, undefined) + yield* stream.release yield* Fiber.join(active) expect(requests).toHaveLength(4) @@ -1823,16 +1741,15 @@ describe("SessionRunnerLLM", () => { Effect.gen(function* () { const session = yield* setup currentModel = recoveryModel - streamGate = yield* Deferred.make() - streamStarted = yield* Deferred.make() - responses = [ - reply.text("Active complete", "text-active-failure"), + const stream = yield* TestLLM.gate + yield* TestLLM.push( + TestLLM.text("Active complete", "text-active-failure"), [], - reply.text("Continued", "text-after-failure"), - ] + TestLLM.text("Continued", "text-after-failure"), + ) yield* admit(session, "Active work") const active = yield* session.resume(sessionID).pipe(Effect.forkChild) - yield* Deferred.await(streamStarted) + yield* stream.started const compaction = yield* session.compact({ sessionID }) yield* session.prompt({ @@ -1841,7 +1758,7 @@ describe("SessionRunnerLLM", () => { delivery: "queue", resume: false, }) - yield* Deferred.succeed(streamGate, undefined) + yield* stream.release yield* Fiber.join(active) expect(requests).toHaveLength(3) @@ -1886,12 +1803,11 @@ describe("SessionRunnerLLM", () => { it.effect("manually compacts when the model has no context limit", () => Effect.gen(function* () { const session = yield* setup - response = reply.text("Earlier answer", "text-manual-unknown-history") - yield* admit(session, "Earlier question") - yield* session.resume(sessionID) + yield* TestLLM.push(TestLLM.text("Earlier answer", "text-manual-unknown-history")) + yield* runPrompt(session, "Earlier question") requests.length = 0 - response = reply.text("Manual summary", "text-manual-unknown-summary") + yield* TestLLM.push(TestLLM.text("Manual summary", "text-manual-unknown-summary")) const compaction = yield* session.compact({ sessionID }) yield* session.resume(sessionID) @@ -1908,11 +1824,10 @@ describe("SessionRunnerLLM", () => { it.effect("preserves provider errors from manual compaction", () => Effect.gen(function* () { const session = yield* setup - response = reply.text("Earlier answer", "text-manual-provider-history") - yield* admit(session, "Earlier question") - yield* session.resume(sessionID) + yield* TestLLM.push(TestLLM.text("Earlier answer", "text-manual-provider-history")) + yield* runPrompt(session, "Earlier question") - response = [LLMEvent.providerError({ message: "summary unavailable" })] + yield* TestLLM.push([LLMEvent.providerError({ message: "summary unavailable" })]) const compaction = yield* session.compact({ sessionID }) yield* session.resume(sessionID) @@ -1927,11 +1842,10 @@ describe("SessionRunnerLLM", () => { it.effect("preserves typed provider failures from manual compaction", () => Effect.gen(function* () { const session = yield* setup - response = reply.text("Earlier answer", "text-manual-failure-history") - yield* admit(session, "Earlier question") - yield* session.resume(sessionID) + yield* TestLLM.push(TestLLM.text("Earlier answer", "text-manual-failure-history")) + yield* runPrompt(session, "Earlier question") - responseStream = Stream.fail(providerUnavailable()) + yield* TestLLM.push(Stream.fail(providerUnavailable())) const compaction = yield* session.compact({ sessionID }) yield* session.resume(sessionID) @@ -1946,15 +1860,16 @@ describe("SessionRunnerLLM", () => { it.effect("records cancelled manual compaction without surfacing an internal failure", () => Effect.gen(function* () { const session = yield* setup - response = reply.text("Earlier answer", "text-manual-interrupt-history") - yield* admit(session, "Earlier question") - yield* session.resume(sessionID) + yield* TestLLM.push(TestLLM.text("Earlier answer", "text-manual-interrupt-history")) + yield* runPrompt(session, "Earlier question") const streamed = yield* Deferred.make() const partial = fragmentFixture("text", "text-manual-interrupt-summary", ["Partial summary"]) - responseStream = Stream.concat( - Stream.fromIterable(partial.partialEvents), - Stream.fromEffect(Deferred.succeed(streamed, undefined)).pipe(Stream.flatMap(() => Stream.never)), + yield* TestLLM.push( + Stream.concat( + Stream.fromIterable(partial.partialEvents), + Stream.fromEffect(Deferred.succeed(streamed, undefined)).pipe(Stream.flatMap(() => Stream.never)), + ), ) const compaction = yield* session.compact({ sessionID }) const run = yield* session.resume(sessionID).pipe(Effect.forkChild) @@ -1975,9 +1890,8 @@ describe("SessionRunnerLLM", () => { it.effect("settles an admitted manual compaction when pre-start resolution throws", () => Effect.gen(function* () { const session = yield* setup - response = reply.text("Earlier answer", "text-manual-resolution-history") - yield* admit(session, "Earlier question") - yield* session.resume(sessionID) + yield* TestLLM.push(TestLLM.text("Earlier answer", "text-manual-resolution-history")) + yield* runPrompt(session, "Earlier question") const compaction = yield* session.compact({ sessionID }) modelResolveHook = Effect.die("model resolution failed") @@ -2001,18 +1915,16 @@ describe("SessionRunnerLLM", () => { it.effect("automatically compacts into a completed summary and retained recent turn", () => Effect.gen(function* () { const session = yield* setup - response = reply.textWithUsage("Earlier answer", "text-first", 3_950) - yield* admit(session, "Earlier question ".repeat(180)) - yield* session.resume(sessionID) + yield* TestLLM.push(TestLLM.textWithUsage("Earlier answer", "text-first", 3_950)) + yield* runPrompt(session, "Earlier question ".repeat(180)) currentModel = compactModel requests.length = 0 - responses = [ - reply.text("## Objective\n- Preserve the task", "text-summary"), - reply.textWithUsage("Continued", "text-final", 3_950), - ] - yield* admit(session, "Recent exact request ".repeat(180)) - yield* session.resume(sessionID) + yield* TestLLM.push( + TestLLM.text("## Objective\n- Preserve the task", "text-summary"), + TestLLM.textWithUsage("Continued", "text-final", 3_950), + ) + yield* runPrompt(session, "Recent exact request ".repeat(180)) expect(requests).toHaveLength(2) expect(userTexts(requests[0])[0]).toContain("## Objective") @@ -2029,12 +1941,11 @@ describe("SessionRunnerLLM", () => { requests.length = 0 executions.length = 0 - responses = [ - reply.text("## Objective\n- Preserve the updated task", "text-summary-2"), - reply.text("Continued again", "text-final-2"), - ] - yield* admit(session, "Newest exact request ".repeat(180)) - yield* session.resume(sessionID) + yield* TestLLM.push( + TestLLM.text("## Objective\n- Preserve the updated task", "text-summary-2"), + TestLLM.text("Continued again", "text-final-2"), + ) + yield* runPrompt(session, "Newest exact request ".repeat(180)) expect(requests).toHaveLength(2) expect(userTexts(requests[0])[0]).toContain( @@ -2052,14 +1963,12 @@ describe("SessionRunnerLLM", () => { Effect.gen(function* () { const session = yield* setup currentModel = fullOutputModel - response = reply.textWithUsage("Earlier answer", "text-full-output-first", 9_500) - yield* admit(session, "Earlier question") - yield* session.resume(sessionID) + yield* TestLLM.push(TestLLM.textWithUsage("Earlier answer", "text-full-output-first", 9_500)) + yield* runPrompt(session, "Earlier question") requests.length = 0 - response = reply.text("Continued", "text-full-output-final") - yield* admit(session, "Continue") - yield* session.resume(sessionID) + yield* TestLLM.push(TestLLM.text("Continued", "text-full-output-final")) + yield* runPrompt(session, "Continue") expect(requests).toHaveLength(1) expect(userTexts(requests[0])).toContain("Continue") @@ -2070,16 +1979,15 @@ describe("SessionRunnerLLM", () => { it.effect("stops after required automatic compaction fails", () => Effect.gen(function* () { const session = yield* setup - response = reply.textWithUsage("Earlier answer", "text-before-failed-compaction", 3_950) - yield* admit(session, "Earlier question ".repeat(180)) - yield* session.resume(sessionID) + yield* TestLLM.push(TestLLM.textWithUsage("Earlier answer", "text-before-failed-compaction", 3_950)) + yield* runPrompt(session, "Earlier question ".repeat(180)) currentModel = compactModel requests.length = 0 - responses = [ + yield* TestLLM.push( [LLMEvent.providerError({ message: "Unsupported parameter: max_output_tokens" })], - reply.text("Must not run", "text-after-failed-compaction"), - ] + TestLLM.text("Must not run", "text-after-failed-compaction"), + ) yield* admit(session, "Recent exact request ".repeat(180)) expect(yield* Effect.exit(session.resume(sessionID))).toMatchObject({ _tag: "Failure" }) @@ -2099,16 +2007,15 @@ describe("SessionRunnerLLM", () => { it.effect("forces one compaction and retries after provider context overflow", () => Effect.gen(function* () { const session = yield* setupOverflowRecovery - responses = [ + yield* TestLLM.push( [ LLMEvent.stepStart({ index: 0 }), LLMEvent.providerError({ message: "prompt too long", classification: "context-overflow" }), ], - reply.text("## Objective\n- Recover overflow", "text-summary"), - reply.text("Recovered", "text-final"), - ] - yield* admit(session, "Continue") - yield* session.resume(sessionID) + TestLLM.text("## Objective\n- Recover overflow", "text-summary"), + TestLLM.text("Recovered", "text-final"), + ) + yield* runPrompt(session, "Continue") expect(requests).toHaveLength(3) expect(userTexts(requests[1])[0]).toContain("## Objective") @@ -2129,13 +2036,12 @@ describe("SessionRunnerLLM", () => { Effect.gen(function* () { const session = yield* setupOverflowRecovery currentModel = model - responses = [ + yield* TestLLM.push( [LLMEvent.providerError({ message: "prompt too long", classification: "context-overflow" })], - reply.text("## Objective\n- Recover unknown limit", "text-summary-unknown-limit"), - reply.text("Recovered", "text-final-unknown-limit"), - ] - yield* admit(session, "Continue") - yield* session.resume(sessionID) + TestLLM.text("## Objective\n- Recover unknown limit", "text-summary-unknown-limit"), + TestLLM.text("Recovered", "text-final-unknown-limit"), + ) + yield* runPrompt(session, "Continue") expect(requests).toHaveLength(3) expect(yield* session.context(sessionID)).toMatchObject([ @@ -2149,13 +2055,12 @@ describe("SessionRunnerLLM", () => { Effect.gen(function* () { const session = yield* setupOverflowRecovery currentModel = undersizedContextModel - responses = [ + yield* TestLLM.push( [LLMEvent.providerError({ message: "prompt too long", classification: "context-overflow" })], - reply.text("## Objective\n- Recover undersized limit", "text-summary-undersized-limit"), - reply.text("Recovered", "text-final-undersized-limit"), - ] - yield* admit(session, "Continue") - yield* session.resume(sessionID) + TestLLM.text("## Objective\n- Recover undersized limit", "text-summary-undersized-limit"), + TestLLM.text("Recovered", "text-final-undersized-limit"), + ) + yield* runPrompt(session, "Continue") expect(requests).toHaveLength(3) expect(yield* session.context(sessionID)).toMatchObject([ @@ -2172,7 +2077,7 @@ describe("SessionRunnerLLM", () => { LLMEvent.stepStart({ index: 0 }), LLMEvent.providerError({ message: "prompt too long", classification: "context-overflow" }), ] - responses = [overflow(), reply.text("## Objective\n- Recover once", "text-summary"), overflow()] + yield* TestLLM.push(overflow(), TestLLM.text("## Objective\n- Recover once", "text-summary"), overflow()) yield* admit(session, "Continue") expect((yield* session.resume(sessionID).pipe(Effect.flip)).message).toBe("prompt too long") @@ -2187,22 +2092,23 @@ describe("SessionRunnerLLM", () => { it.effect("recovers once from a raw context overflow failure", () => Effect.gen(function* () { const session = yield* setupOverflowRecovery - responseStream = Stream.fail( - new LLMError({ - module: "test", - method: "stream", - reason: new InvalidRequestReason({ - message: "prompt too long", - classification: "context-overflow", + yield* TestLLM.push( + Stream.fail( + new LLMError({ + module: "test", + method: "stream", + reason: new InvalidRequestReason({ + message: "prompt too long", + classification: "context-overflow", + }), }), - }), + ), ) - responses = [ - reply.text("## Objective\n- Recover raw overflow", "text-summary"), - reply.text("Recovered", "text-final"), - ] - yield* admit(session, "Continue") - yield* session.resume(sessionID) + yield* TestLLM.push( + TestLLM.text("## Objective\n- Recover raw overflow", "text-summary"), + TestLLM.text("Recovered", "text-final"), + ) + yield* runPrompt(session, "Continue") expect(requests).toHaveLength(3) expect(yield* session.context(sessionID)).toMatchObject([ @@ -2215,10 +2121,10 @@ describe("SessionRunnerLLM", () => { it.effect("publishes the original overflow when recovery summarization fails", () => Effect.gen(function* () { const session = yield* setupOverflowRecovery - responses = [ + yield* TestLLM.push( [LLMEvent.providerError({ message: "prompt too long", classification: "context-overflow" })], [LLMEvent.providerError({ message: "summary unavailable" })], - ] + ) yield* admit(session, "Continue") expect((yield* session.resume(sessionID).pipe(Effect.flip)).message).toBe("prompt too long") @@ -2243,26 +2149,29 @@ describe("SessionRunnerLLM", () => { it.effect("interrupts overflow recovery while the summary provider is running", () => Effect.gen(function* () { const session = yield* setupOverflowRecovery - responses = [ + yield* TestLLM.push( [LLMEvent.providerError({ message: "prompt too long", classification: "context-overflow" })], - reply.text("## Objective\n- Interrupted", "text-summary"), - ] - const firstGate = yield* Deferred.make() - const summaryGate = yield* Deferred.make() - streamGate = firstGate + TestLLM.text("## Objective\n- Interrupted", "text-summary"), + ) + const first = yield* TestLLM.gate yield* admit(session, "Continue") const run = yield* session.resume(sessionID).pipe(Effect.forkChild) - while (requests.length < 1) yield* Effect.yieldNow - streamGate = summaryGate - yield* Deferred.succeed(firstGate, undefined) - while (requests.length < 2) yield* Effect.yieldNow + yield* first.started + + const summary = yield* TestLLM.gate + yield* first.release + yield* summary.started yield* session.interrupt(sessionID) - expect(yield* Fiber.await(run)).toMatchObject({ _tag: "Failure" }) - streamGate = undefined - expect(requests).toHaveLength(2) + const exit = yield* Fiber.await(run) + expect(Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause)).toBeTrue() expect(yield* session.context(sessionID)).toContainEqual( - expect.objectContaining({ type: "compaction", status: "failed", reason: "auto" }), + expect.objectContaining({ + type: "compaction", + status: "failed", + reason: "auto", + error: { type: "compaction.interrupted", message: "Compaction was interrupted" }, + }), ) }), ) @@ -2271,12 +2180,9 @@ describe("SessionRunnerLLM", () => { Effect.gen(function* () { const session = yield* setup const bus = yield* Bus.Service - yield* admit(session, "First") - - yield* session.resume(sessionID) + yield* runPrompt(session, "First") systemBaseline = "Changed context" - yield* admit(session, "Second") - yield* session.resume(sessionID) + yield* runPrompt(session, "Second") yield* bus.publish(SessionEvent.Compaction.Started, { sessionID, reason: "manual", @@ -2289,8 +2195,7 @@ describe("SessionRunnerLLM", () => { recent: "", }) systemUnavailable = true - yield* admit(session, "Third") - yield* session.resume(sessionID) + yield* runPrompt(session, "Third") // Compaction already moved current values into the new epoch before the unavailable read. expect(requests.at(-1)?.system.map((part) => part.text)).toEqual([defaultSystem, "Changed context"]) @@ -2303,50 +2208,49 @@ describe("SessionRunnerLLM", () => { const session = yield* setup yield* admit(session, "Use tools") - response = [ - LLMEvent.stepStart({ index: 0 }), - LLMEvent.reasoningStart({ id: "reasoning-1" }), - LLMEvent.reasoningDelta({ id: "reasoning-1", text: "Think" }), - LLMEvent.reasoningEnd({ id: "reasoning-1" }), - LLMEvent.toolInputStart({ id: "call-error", name: "write" }), - LLMEvent.toolInputDelta({ id: "call-error", name: "write", text: '{"path":"README.md"}' }), - LLMEvent.toolInputEnd({ id: "call-error", name: "write" }), - LLMEvent.toolCall({ id: "call-error", name: "write", input: { path: "README.md" }, providerExecuted: true }), - LLMEvent.toolError({ id: "call-error", name: "write", message: "Denied" }), - LLMEvent.toolResult({ id: "call-error", name: "write", result: { type: "error", value: "Denied" } }), - LLMEvent.toolCall({ - id: "call-provider", - name: "web_search", - input: { query: "hello" }, - providerExecuted: true, - providerMetadata: { openai: { source: "provider" } }, - }), - LLMEvent.toolResult({ - id: "call-provider", - name: "web_search", - result: { - type: "content", - value: [ - { type: "text", text: "Hello" }, - { type: "file", uri: "data:image/png;base64,aGVsbG8=", mime: "image/png", name: "hello.png" }, - ], + yield* TestLLM.push( + TestLLM.complete( + { + reason: { normalized: "tool-calls" }, + usage: { + inputTokens: 10, + nonCachedInputTokens: 8, + outputTokens: 4, + reasoningTokens: 1, + cacheReadInputTokens: 2, + }, }, - providerExecuted: true, - providerMetadata: { openai: { source: "provider" } }, - }), - LLMEvent.stepFinish({ - index: 0, - reason: { normalized: "tool-calls" }, - usage: { - inputTokens: 10, - nonCachedInputTokens: 8, - outputTokens: 4, - reasoningTokens: 1, - cacheReadInputTokens: 2, - }, - }), - LLMEvent.finish({ reason: { normalized: "tool-calls" } }), - ] + LLMEvent.reasoningStart({ id: "reasoning-1" }), + LLMEvent.reasoningDelta({ id: "reasoning-1", text: "Think" }), + LLMEvent.reasoningEnd({ id: "reasoning-1" }), + LLMEvent.toolInputStart({ id: "call-error", name: "write" }), + LLMEvent.toolInputDelta({ id: "call-error", name: "write", text: '{"path":"README.md"}' }), + LLMEvent.toolInputEnd({ id: "call-error", name: "write" }), + LLMEvent.toolCall({ id: "call-error", name: "write", input: { path: "README.md" }, providerExecuted: true }), + LLMEvent.toolError({ id: "call-error", name: "write", message: "Denied" }), + LLMEvent.toolResult({ id: "call-error", name: "write", result: { type: "error", value: "Denied" } }), + LLMEvent.toolCall({ + id: "call-provider", + name: "web_search", + input: { query: "hello" }, + providerExecuted: true, + providerMetadata: { openai: { source: "provider" } }, + }), + LLMEvent.toolResult({ + id: "call-provider", + name: "web_search", + result: { + type: "content", + value: [ + { type: "text", text: "Hello" }, + { type: "file", uri: "data:image/png;base64,aGVsbG8=", mime: "image/png", name: "hello.png" }, + ], + }, + providerExecuted: true, + providerMetadata: { openai: { source: "provider" } }, + }), + ), + ) yield* session.resume(sessionID) @@ -2398,12 +2302,12 @@ describe("SessionRunnerLLM", () => { const session = yield* setup yield* admit(session, "Echo this") - responses = [reply.tool("call-echo", "echo", { text: "hello" }), reply.text("Done", "text-final")] + yield* TestLLM.push(TestLLM.tool("call-echo", "echo", { text: "hello" }), TestLLM.text("Done", "text-final")) yield* session.resume(sessionID) expect(requests).toHaveLength(2) - expect(requests[1]?.messages.map((message) => message.role)).toEqual(["user", "assistant", "tool"]) + expect(messageRoles(requests[1])).toEqual(["user", "assistant", "tool"]) expect(authorizations).toMatchObject([{ sessionID, callID: "call-echo" }]) expect(executions).toEqual(["hello"]) const context = yield* session.context(sessionID) @@ -2428,7 +2332,7 @@ describe("SessionRunnerLLM", () => { { type: "assistant", finish: "stop", content: [{ type: "text", text: "Done" }] }, ]) const assistant = requireAssistant(context) - expect((yield* recordedStepSettlementEvents(sessionID, assistant.id)).map((event) => event.type)).toEqual([ + expect(yield* recordedStepSettlementTypes(sessionID, assistant.id)).toEqual([ "session.step.started.1", "session.tool.called.1", "session.tool.success.2", @@ -2443,18 +2347,16 @@ describe("SessionRunnerLLM", () => { const bus = yield* Bus.Service yield* admit(session, "Echo this") - responses = [reply.tool("call-echo", "echo", { text: "hello" }), reply.stop()] - toolExecutionGate = yield* Deferred.make() - toolExecutionsStarted = yield* Deferred.make() - toolExecutionsReady = 1 + yield* TestLLM.push(TestLLM.tool("call-echo", "echo", { text: "hello" }), TestLLM.stop()) + const tools = yield* blockTools() const run = yield* Effect.forkChild(session.resume(sessionID)) - yield* Deferred.await(toolExecutionsStarted) + yield* tools.started yield* bus.publish(SessionEvent.ModelSelected, { sessionID, model: { id: ID.make("replacement"), providerID: Provider.ID.make("fake") }, }) systemBaseline = "Replacement context" - yield* Deferred.succeed(toolExecutionGate, undefined) + yield* tools.release yield* Fiber.join(run) expect(requests.map((request) => request.model)).toEqual([model, replacementModel]) @@ -2462,7 +2364,7 @@ describe("SessionRunnerLLM", () => { [defaultSystem, "Initial context"], [defaultSystem, "Initial context"], ]) - expect(systemTexts(requests[1]!)).toContain("Replacement context") + expect(systemTexts(requests[1])).toContain("Replacement context") }), ) @@ -2471,32 +2373,31 @@ describe("SessionRunnerLLM", () => { const session = yield* setup yield* admit(session, "Think first") - response = [ - LLMEvent.stepStart({ index: 0 }), - LLMEvent.reasoningStart({ id: "reasoning-anthropic" }), - LLMEvent.reasoningDelta({ id: "reasoning-anthropic", text: "Signed thought" }), - LLMEvent.reasoningEnd({ - id: "reasoning-anthropic", - providerMetadata: { openai: { signature: "sig_1" }, anthropic: { ignored: true } }, - }), - LLMEvent.reasoningStart({ - id: "reasoning-openai", - providerMetadata: { - openai: { itemId: "rs_1", reasoningEncryptedContent: null }, - anthropic: { ignored: true }, - }, - }), - LLMEvent.reasoningDelta({ id: "reasoning-openai", text: "Encrypted thought" }), - LLMEvent.reasoningEnd({ - id: "reasoning-openai", - providerMetadata: { - openai: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" }, - anthropic: { ignored: true }, - }, - }), - LLMEvent.stepFinish({ index: 0, reason: { normalized: "stop" } }), - LLMEvent.finish({ reason: { normalized: "stop" } }), - ] + yield* TestLLM.push( + TestLLM.stop( + LLMEvent.reasoningStart({ id: "reasoning-anthropic" }), + LLMEvent.reasoningDelta({ id: "reasoning-anthropic", text: "Signed thought" }), + LLMEvent.reasoningEnd({ + id: "reasoning-anthropic", + providerMetadata: { openai: { signature: "sig_1" }, anthropic: { ignored: true } }, + }), + LLMEvent.reasoningStart({ + id: "reasoning-openai", + providerMetadata: { + openai: { itemId: "rs_1", reasoningEncryptedContent: null }, + anthropic: { ignored: true }, + }, + }), + LLMEvent.reasoningDelta({ id: "reasoning-openai", text: "Encrypted thought" }), + LLMEvent.reasoningEnd({ + id: "reasoning-openai", + providerMetadata: { + openai: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" }, + anthropic: { ignored: true }, + }, + }), + ), + ) yield* session.resume(sessionID) yield* replaySessionProjection(sessionID) @@ -2520,7 +2421,7 @@ describe("SessionRunnerLLM", () => { ]) yield* admit(session, "Continue") - response = [] + yield* TestLLM.push([]) yield* session.resume(sessionID) expect(requests[1]?.messages[1]?.content).toEqual([ @@ -2543,17 +2444,16 @@ describe("SessionRunnerLLM", () => { const session = yield* setup yield* admit(session, "Check first") - response = [ - LLMEvent.stepStart({ index: 0 }), - LLMEvent.textStart({ id: "commentary", providerMetadata: { openai: { phase: "commentary" } } }), - LLMEvent.textDelta({ id: "commentary", text: "Checking." }), - LLMEvent.textEnd({ - id: "commentary", - providerMetadata: { openai: { phase: "commentary" }, anthropic: { ignored: true } }, - }), - LLMEvent.stepFinish({ index: 0, reason: { normalized: "stop" } }), - LLMEvent.finish({ reason: { normalized: "stop" } }), - ] + yield* TestLLM.push( + TestLLM.stop( + LLMEvent.textStart({ id: "commentary", providerMetadata: { openai: { phase: "commentary" } } }), + LLMEvent.textDelta({ id: "commentary", text: "Checking." }), + LLMEvent.textEnd({ + id: "commentary", + providerMetadata: { openai: { phase: "commentary" }, anthropic: { ignored: true } }, + }), + ), + ) yield* session.resume(sessionID) yield* replaySessionProjection(sessionID) @@ -2566,7 +2466,7 @@ describe("SessionRunnerLLM", () => { ]) yield* admit(session, "Continue") - response = [] + yield* TestLLM.push([]) yield* session.resume(sessionID) expect(requests[1]?.messages[1]?.content).toEqual([ @@ -2584,33 +2484,32 @@ describe("SessionRunnerLLM", () => { const session = yield* setup yield* admit(session, "Search first") - response = [ - LLMEvent.stepStart({ index: 0 }), - LLMEvent.toolCall({ - id: "hosted-search", - name: "web_search", - input: { query: "Effect" }, - providerExecuted: true, - providerMetadata: { openai: { itemId: "hosted-search" }, fake: { ignored: true } }, - }), - LLMEvent.toolResult({ - id: "hosted-search", - name: "web_search", - result: { type: "json", value: [{ title: "Effect" }] }, - providerExecuted: true, - providerMetadata: { openai: { blockType: "web_search_tool_result" }, anthropic: { ignored: true } }, - }), - LLMEvent.stepFinish({ index: 0, reason: { normalized: "stop" } }), - LLMEvent.finish({ reason: { normalized: "stop" } }), - ] + yield* TestLLM.push( + TestLLM.stop( + LLMEvent.toolCall({ + id: "hosted-search", + name: "web_search", + input: { query: "Effect" }, + providerExecuted: true, + providerMetadata: { openai: { itemId: "hosted-search" }, fake: { ignored: true } }, + }), + LLMEvent.toolResult({ + id: "hosted-search", + name: "web_search", + result: { type: "json", value: [{ title: "Effect" }] }, + providerExecuted: true, + providerMetadata: { openai: { blockType: "web_search_tool_result" }, anthropic: { ignored: true } }, + }), + ), + ) yield* session.resume(sessionID) yield* replaySessionProjection(sessionID) yield* admit(session, "Continue") - response = [] + yield* TestLLM.push([]) yield* session.resume(sessionID) - expect(requests[1]?.messages.map((message) => message.role)).toEqual(["user", "assistant", "user"]) + expect(messageRoles(requests[1])).toEqual(["user", "assistant", "user"]) expect(requests[1]?.messages[1]?.content).toMatchObject([ { type: "tool-call", @@ -2638,8 +2537,7 @@ describe("SessionRunnerLLM", () => { const session = yield* setup yield* admit(session, "Echo five times") - toolExecutionGate = yield* Deferred.make() - toolExecutionsStarted = yield* Deferred.make() + const tools = yield* blockTools(5) const providerGate = yield* Deferred.make() const initial = Stream.fromIterable([ LLMEvent.stepStart({ index: 0 }), @@ -2651,16 +2549,15 @@ describe("SessionRunnerLLM", () => { LLMEvent.stepFinish({ index: 0, reason: { normalized: "tool-calls" } }), LLMEvent.finish({ reason: { normalized: "tool-calls" } }), ]) - responseStream = Stream.concat( - initial, - Stream.fromEffect(Deferred.await(providerGate)).pipe(Stream.flatMap(() => final)), + yield* TestLLM.push( + Stream.concat(initial, Stream.fromEffect(Deferred.await(providerGate)).pipe(Stream.flatMap(() => final))), ) const run = yield* session.resume(sessionID).pipe(Effect.forkChild) - yield* Deferred.await(toolExecutionsStarted) + yield* tools.started expect(executions).toHaveLength(5) - expect(maxActiveToolExecutions).toBe(5) + expect(yield* tools.maxActive).toBe(5) expect(yield* session.context(sessionID)).toMatchObject([ { type: "user", text: "Echo five times" }, { @@ -2677,13 +2574,11 @@ describe("SessionRunnerLLM", () => { yield* Effect.yieldNow expect(requests).toHaveLength(1) - yield* Deferred.succeed(toolExecutionGate, undefined) + yield* tools.release yield* Fiber.join(run) - toolExecutionGate = undefined - toolExecutionsStarted = undefined expect(executions).toHaveLength(5) - expect(maxActiveToolExecutions).toBe(5) + expect(yield* tools.maxActive).toBe(5) expect(requests).toHaveLength(2) }), ) @@ -2693,17 +2588,15 @@ describe("SessionRunnerLLM", () => { const session = yield* setup yield* admit(session, "Echo twice") - responses = [ - reply.tool("tool_0", "echo", { text: "first" }), - reply.tool("tool_0", "echo", { text: "second" }), + yield* TestLLM.push( + TestLLM.tool("tool_0", "echo", { text: "first" }), + TestLLM.tool("tool_0", "echo", { text: "second" }), [], - ] + ) yield* session.resume(sessionID) - expect(executions).toEqual(["first", "second"]) - expect(requests).toHaveLength(3) - expect(yield* session.context(sessionID)).toMatchObject([ + const expected = [ { type: "user", text: "Echo twice" }, { type: "assistant", @@ -2725,33 +2618,14 @@ describe("SessionRunnerLLM", () => { }, ], }, - ]) + ] + expect(executions).toEqual(["first", "second"]) + expect(requests).toHaveLength(3) + expect(yield* session.context(sessionID)).toMatchObject(expected) yield* replaySessionProjection(sessionID) - expect(yield* session.context(sessionID)).toMatchObject([ - { type: "user", text: "Echo twice" }, - { - type: "assistant", - content: [ - { - type: "tool", - id: "tool_0", - state: { status: "completed", content: [{ type: "text", text: "first" }] }, - }, - ], - }, - { - type: "assistant", - content: [ - { - type: "tool", - id: "tool_0", - state: { status: "completed", content: [{ type: "text", text: "second" }] }, - }, - ], - }, - ]) + expect(yield* session.context(sessionID)).toMatchObject(expected) }), ) @@ -2760,21 +2634,18 @@ describe("SessionRunnerLLM", () => { const session = yield* setup yield* admit(session, "Run once") - response = reply.text("Once", "text-once") - streamGate = yield* Deferred.make() - streamStarted = yield* Deferred.make() + yield* TestLLM.push(TestLLM.text("Once", "text-once")) + const stream = yield* TestLLM.gate const first = yield* session.resume(sessionID).pipe(Effect.forkChild) - yield* Deferred.await(streamStarted) + yield* stream.started const second = yield* session.resume(sessionID).pipe(Effect.forkChild) yield* Effect.yieldNow expect(requests).toHaveLength(1) - yield* Deferred.succeed(streamGate, undefined) + yield* stream.release yield* Fiber.join(first) yield* Fiber.join(second) - streamGate = undefined - streamStarted = undefined expect(requests).toHaveLength(1) expect(yield* session.context(sessionID)).toMatchObject([ @@ -2789,22 +2660,19 @@ describe("SessionRunnerLLM", () => { const session = yield* setup yield* admit(session, "Start working") - responses = [reply.stop(), reply.stop()] - streamGate = yield* Deferred.make() - streamStarted = yield* Deferred.make() + yield* TestLLM.push(TestLLM.stop(), TestLLM.stop()) + const stream = yield* TestLLM.gate const first = yield* session.resume(sessionID).pipe(Effect.forkChild) - yield* Deferred.await(streamStarted) + yield* stream.started yield* session.prompt({ sessionID, text: "Change direction" }) - yield* Deferred.succeed(streamGate, undefined) + yield* stream.release yield* Fiber.join(first) - streamGate = undefined - streamStarted = undefined yield* Effect.yieldNow expect(requests).toHaveLength(2) - expect(userTexts(requests[0]!)).toEqual(["Start working"]) - expect(userTexts(requests[1]!)).toEqual(["Start working", "Change direction"]) + expect(userTexts(requests[0])).toEqual(["Start working"]) + expect(userTexts(requests[1])).toEqual(["Start working", "Change direction"]) expect((yield* session.context(sessionID)).map((message) => message.type)).toEqual([ "user", "assistant", @@ -2819,26 +2687,23 @@ describe("SessionRunnerLLM", () => { const session = yield* setup yield* admit(session, "Start working") - responses = [reply.tool("call-echo", "echo", { text: "hello" }), reply.stop(), reply.stop()] - streamGate = yield* Deferred.make() - streamStarted = yield* Deferred.make() + yield* TestLLM.push(TestLLM.tool("call-echo", "echo", { text: "hello" }), TestLLM.stop(), TestLLM.stop()) + const stream = yield* TestLLM.gate const first = yield* session.resume(sessionID).pipe(Effect.forkChild) - yield* Deferred.await(streamStarted) + yield* stream.started yield* session.prompt({ sessionID, text: "Wait until continuation ends", delivery: "queue", }) - yield* Deferred.succeed(streamGate, undefined) + yield* stream.release yield* Fiber.join(first) - streamGate = undefined - streamStarted = undefined expect(requests).toHaveLength(3) - expect(userTexts(requests[0]!)).toEqual(["Start working"]) - expect(userTexts(requests[1]!)).toEqual(["Start working"]) - expect(userTexts(requests[2]!)).toEqual(["Start working", "Wait until continuation ends"]) + expect(userTexts(requests[0])).toEqual(["Start working"]) + expect(userTexts(requests[1])).toEqual(["Start working"]) + expect(userTexts(requests[2])).toEqual(["Start working", "Wait until continuation ends"]) }), ) @@ -2848,12 +2713,11 @@ describe("SessionRunnerLLM", () => { const { db } = yield* Database.Service yield* admit(session, "Interrupt current work") - responses = [[], reply.stop()] - streamGate = yield* Deferred.make() - streamStarted = yield* Deferred.make() + yield* TestLLM.push([], TestLLM.stop()) + const stream = yield* TestLLM.gate const run = yield* session.resume(sessionID).pipe(Effect.forkChild) - yield* Deferred.await(streamStarted) + yield* stream.started yield* session.prompt({ sessionID, text: "Run after interrupt", @@ -2864,15 +2728,13 @@ describe("SessionRunnerLLM", () => { expect(requests).toHaveLength(1) expect(yield* SessionPending.has(db, sessionID, "queue")).toBe(true) const resumed = yield* session.resume(sessionID).pipe(Effect.forkChild) - while (requests.length < 2) yield* Effect.yieldNow - yield* Deferred.succeed(streamGate, undefined) + yield* stream.started + yield* stream.release yield* Fiber.join(resumed) - streamGate = undefined - streamStarted = undefined expect(requests).toHaveLength(2) - expect(userTexts(requests[0]!)).toEqual(["Interrupt current work"]) - expect(userTexts(requests[1]!)).toEqual(["Interrupt current work", "Run after interrupt"]) + expect(userTexts(requests[0])).toEqual(["Interrupt current work"]) + expect(userTexts(requests[1])).toEqual(["Interrupt current work", "Run after interrupt"]) }), ) @@ -2882,12 +2744,11 @@ describe("SessionRunnerLLM", () => { const { db } = yield* Database.Service yield* admit(session, "Interrupt current work") - responses = [[], reply.stop()] - streamGate = yield* Deferred.make() - streamStarted = yield* Deferred.make() + yield* TestLLM.push([], TestLLM.stop()) + const stream = yield* TestLLM.gate const run = yield* session.resume(sessionID).pipe(Effect.forkChild) - yield* Deferred.await(streamStarted) + yield* stream.started yield* session.prompt({ sessionID, text: "Steer after interrupt", @@ -2898,15 +2759,13 @@ describe("SessionRunnerLLM", () => { expect(yield* SessionPending.has(db, sessionID, "steer")).toBe(true) const resumed = yield* session.resume(sessionID).pipe(Effect.forkChild) - while (requests.length < 2) yield* Effect.yieldNow - yield* Deferred.succeed(streamGate, undefined) + yield* stream.started + yield* stream.release yield* Fiber.join(resumed) - streamGate = undefined - streamStarted = undefined expect(requests).toHaveLength(2) - expect(userTexts(requests[0]!)).toEqual(["Interrupt current work"]) - expect(userTexts(requests[1]!)).toEqual(["Interrupt current work", "Steer after interrupt"]) + expect(userTexts(requests[0])).toEqual(["Interrupt current work"]) + expect(userTexts(requests[1])).toEqual(["Interrupt current work", "Steer after interrupt"]) }), ) @@ -2915,23 +2774,20 @@ describe("SessionRunnerLLM", () => { const session = yield* setup yield* admit(session, "Start working") - responses = [reply.stop(), reply.stop(), reply.stop()] - streamGate = yield* Deferred.make() - streamStarted = yield* Deferred.make() + yield* TestLLM.push(TestLLM.stop(), TestLLM.stop(), TestLLM.stop()) + const stream = yield* TestLLM.gate const first = yield* session.resume(sessionID).pipe(Effect.forkChild) - yield* Deferred.await(streamStarted) + yield* stream.started yield* session.prompt({ sessionID, text: "Queue first", delivery: "queue" }) yield* session.prompt({ sessionID, text: "Queue second", delivery: "queue" }) - yield* Deferred.succeed(streamGate, undefined) + yield* stream.release yield* Fiber.join(first) - streamGate = undefined - streamStarted = undefined expect(requests).toHaveLength(3) - expect(userTexts(requests[0]!)).toEqual(["Start working"]) - expect(userTexts(requests[1]!)).toEqual(["Start working", "Queue first"]) - expect(userTexts(requests[2]!)).toEqual(["Start working", "Queue first", "Queue second"]) + expect(userTexts(requests[0])).toEqual(["Start working"]) + expect(userTexts(requests[1])).toEqual(["Start working", "Queue first"]) + expect(userTexts(requests[2])).toEqual(["Start working", "Queue first", "Queue second"]) }), ) @@ -2946,13 +2802,13 @@ describe("SessionRunnerLLM", () => { resume: false, }) - responses = [reply.stop(), reply.stop()] + yield* TestLLM.push(TestLLM.stop(), TestLLM.stop()) yield* session.resume(sessionID) expect(requests).toHaveLength(2) - expect(userTexts(requests[0]!)).toEqual(["Start steering"]) - expect(userTexts(requests[1]!)).toEqual(["Start steering", "Queue for later"]) + expect(userTexts(requests[0])).toEqual(["Start steering"]) + expect(userTexts(requests[1])).toEqual(["Start steering", "Queue for later"]) }), ) @@ -2961,39 +2817,36 @@ describe("SessionRunnerLLM", () => { const session = yield* setup yield* admit(session, "Start working") - responses = [reply.stop(), reply.stop(), reply.stop(), reply.stop()] - const firstGate = yield* Deferred.make() - const secondGate = yield* Deferred.make() - streamGate = firstGate + yield* TestLLM.push(TestLLM.stop(), TestLLM.stop(), TestLLM.stop(), TestLLM.stop()) + const firstStream = yield* TestLLM.gate const first = yield* session.resume(sessionID).pipe(Effect.forkChild) - while (requests.length < 1) yield* Effect.yieldNow + yield* firstStream.started yield* session.prompt({ sessionID, text: "Queue first", delivery: "queue" }) yield* session.prompt({ sessionID, text: "Queue second", delivery: "queue" }) - streamGate = secondGate - yield* Deferred.succeed(firstGate, undefined) - while (requests.length < 2) yield* Effect.yieldNow + const secondStream = yield* TestLLM.gate + yield* firstStream.release + yield* secondStream.started yield* session.prompt({ sessionID, text: "Steer before next queued input" }) yield* session.prompt({ sessionID, text: "Also steer before next queued input", }) yield* session.synthetic({ sessionID, text: "Background completion before next queued input" }) - yield* Deferred.succeed(secondGate, undefined) + yield* secondStream.release yield* Fiber.join(first) - streamGate = undefined expect(requests).toHaveLength(4) - expect(userTexts(requests[0]!)).toEqual(["Start working"]) - expect(userTexts(requests[1]!)).toEqual(["Start working", "Queue first"]) - expect(userTexts(requests[2]!)).toEqual([ + expect(userTexts(requests[0])).toEqual(["Start working"]) + expect(userTexts(requests[1])).toEqual(["Start working", "Queue first"]) + expect(userTexts(requests[2])).toEqual([ "Start working", "Queue first", "Steer before next queued input", "Also steer before next queued input", "Background completion before next queued input", ]) - expect(userTexts(requests[3]!)).toEqual([ + expect(userTexts(requests[3])).toEqual([ "Start working", "Queue first", "Steer before next queued input", @@ -3009,22 +2862,19 @@ describe("SessionRunnerLLM", () => { const session = yield* setup yield* admit(session, "Start working") - responses = [reply.stop(), reply.stop()] - streamGate = yield* Deferred.make() - streamStarted = yield* Deferred.make() + yield* TestLLM.push(TestLLM.stop(), TestLLM.stop()) + const stream = yield* TestLLM.gate const first = yield* session.resume(sessionID).pipe(Effect.forkChild) - yield* Deferred.await(streamStarted) + yield* stream.started yield* session.prompt({ sessionID, text: "First steer" }) yield* session.prompt({ sessionID, text: "Second steer" }) - yield* Deferred.succeed(streamGate, undefined) + yield* stream.release yield* Fiber.join(first) - streamGate = undefined - streamStarted = undefined yield* Effect.yieldNow expect(requests).toHaveLength(2) - expect(userTexts(requests[1]!)).toEqual(["Start working", "First steer", "Second steer"]) + expect(userTexts(requests[1])).toEqual(["Start working", "First steer", "Second steer"]) yield* (yield* SessionExecution.Service).wake(sessionID) yield* Effect.yieldNow expect(requests).toHaveLength(2) @@ -3036,23 +2886,21 @@ describe("SessionRunnerLLM", () => { const session = yield* setup yield* admit(session, "Start working") - streamFailure = invalidRequest() - streamGate = yield* Deferred.make() - streamStarted = yield* Deferred.make() + const failure = invalidRequest() + yield* TestLLM.push(Stream.fail(failure)) + const stream = yield* TestLLM.gate const first = yield* session.resume(sessionID).pipe(Effect.forkChild) - yield* Deferred.await(streamStarted) + yield* stream.started yield* session.prompt({ sessionID, text: "Recover with this" }) - yield* Deferred.succeed(streamGate, undefined) - expect(yield* Fiber.join(first).pipe(Effect.flip)).toBe(streamFailure) + yield* stream.release + expect(yield* Fiber.join(first).pipe(Effect.flip)).toBe(failure) - streamFailure = undefined - streamGate = undefined - streamStarted = undefined + yield* TestLLM.push([]) yield* session.wait(sessionID) expect(requests).toHaveLength(2) - expect(userTexts(requests[1]!)).toEqual(["Start working", "Recover with this"]) + expect(userTexts(requests[1])).toEqual(["Start working", "Recover with this"]) }), ) @@ -3089,11 +2937,11 @@ describe("SessionRunnerLLM", () => { executed: false, }) requests.length = 0 - response = [] + yield* TestLLM.push([]) yield* session.resume(sessionID) expect(requests).toHaveLength(1) - expect(requests[0]?.messages.map((message) => message.role)).toEqual(["user", "assistant", "tool"]) + expect(messageRoles(requests[0])).toEqual(["user", "assistant", "tool"]) expect(yield* session.context(sessionID)).toMatchObject([ { type: "user", text: "Recover interrupted tool" }, { @@ -3147,11 +2995,11 @@ describe("SessionRunnerLLM", () => { state: { itemId: "call-hosted-interrupted" }, }) requests.length = 0 - response = [] + yield* TestLLM.push([]) yield* session.resume(sessionID) expect(requests).toHaveLength(1) - expect(requests[0]?.messages.map((message) => message.role)).toEqual(["user", "assistant"]) + expect(messageRoles(requests[0])).toEqual(["user", "assistant"]) expect(requests[0]?.messages[1]?.content).toMatchObject([ { type: "tool-call", @@ -3184,11 +3032,11 @@ describe("SessionRunnerLLM", () => { name: "echo", }) requests.length = 0 - response = [] + yield* TestLLM.push([]) yield* session.resume(sessionID) expect(requests).toHaveLength(1) - expect(requests[0]?.messages.map((message) => message.role)).toEqual(["user", "assistant", "tool"]) + expect(messageRoles(requests[0])).toEqual(["user", "assistant", "tool"]) expect(yield* session.context(sessionID)).toMatchObject([ { type: "user", text: "Recover interrupted tool input" }, { type: "assistant", content: [{ type: "tool", id: "call-pending-interrupted", state: { status: "error" } }] }, @@ -3206,11 +3054,13 @@ describe("SessionRunnerLLM", () => { resume: false, }) + const stream = yield* TestLLM.gate yield* (yield* SessionExecution.Service).wake(sessionID) - while (requests.length === 0) yield* Effect.yieldNow + yield* stream.started + yield* stream.release expect(requests).toHaveLength(1) - expect(userTexts(requests[0]!)).toEqual(["Wait in queue"]) + expect(userTexts(requests[0])).toEqual(["Wait in queue"]) }), ) @@ -3226,12 +3076,14 @@ describe("SessionRunnerLLM", () => { expect(yield* session.resume(sessionID).pipe(Effect.catchDefect(Effect.succeed))).toBe(defect) fail = false requests.length = 0 - response = reply.stop() + yield* TestLLM.push(TestLLM.stop()) + const stream = yield* TestLLM.gate yield* (yield* SessionExecution.Service).wake(sessionID) - while (requests.length === 0) yield* Effect.yieldNow + yield* stream.started + yield* stream.release - expect(userTexts(requests[0]!)).toEqual(["Recover promoted input"]) + expect(userTexts(requests[0])).toEqual(["Recover promoted input"]) }), ) @@ -3244,21 +3096,17 @@ describe("SessionRunnerLLM", () => { ? Effect.die("fail after prompt promotion commits") : Effect.void, ) - yield* admit(session, "Run committed promotion") - - yield* session.resume(sessionID) + yield* runPrompt(session, "Run committed promotion") expect(requests).toHaveLength(1) - expect(userTexts(requests[0]!)).toEqual(["Run committed promotion"]) + expect(userTexts(requests[0])).toEqual(["Run committed promotion"]) }), ) it.effect("adds session correlation headers to model requests", () => Effect.gen(function* () { const session = yield* setup - yield* admit(session, "Run correlated request") - - yield* session.resume(sessionID) + yield* runPrompt(session, "Run correlated request") expect(requests[0]?.http?.headers).toEqual({ "x-session-affinity": sessionID, @@ -3282,9 +3130,7 @@ describe("SessionRunnerLLM", () => { .where(eq(SessionTable.id, sessionID)) .run() .pipe(Effect.orDie) - yield* admit(session, "Run child request") - - yield* session.resume(sessionID) + yield* runPrompt(session, "Run child request") expect(requests[0]?.http?.headers?.["x-parent-session-id"]).toBe(parentID) }), @@ -3301,25 +3147,21 @@ describe("SessionRunnerLLM", () => { resume: false, }) - streamGate = yield* Deferred.make() - streamStarted = yield* Deferred.make() + const stream = yield* TestLLM.gate const first = yield* session.resume(sessionID).pipe(Effect.forkChild) - yield* Deferred.await(streamStarted) - streamStarted = yield* Deferred.make() + yield* stream.started const second = yield* session.resume(otherSessionID).pipe(Effect.forkChild) - yield* Deferred.await(streamStarted) + yield* stream.started expect(requests).toHaveLength(2) expect(requests.map((request) => request.providerOptions?.openai?.promptCacheKey)).toEqual([ sessionID, otherSessionID, ]) - yield* Deferred.succeed(streamGate, undefined) + yield* stream.release yield* Fiber.join(first) yield* Fiber.join(second) - streamGate = undefined - streamStarted = undefined }), ) @@ -3356,23 +3198,20 @@ describe("SessionRunnerLLM", () => { const session = yield* setup yield* admit(session, "Retry after failure") - streamFailure = invalidRequest() - streamGate = yield* Deferred.make() - streamStarted = yield* Deferred.make() + yield* TestLLM.push(Stream.fail(invalidRequest())) + const stream = yield* TestLLM.gate const first = yield* session.resume(sessionID).pipe(Effect.forkChild) - yield* Deferred.await(streamStarted) + yield* stream.started const second = yield* session.resume(sessionID).pipe(Effect.forkChild) yield* Effect.yieldNow expect(requests).toHaveLength(1) - yield* Deferred.succeed(streamGate, undefined) + yield* stream.release const [firstExit, secondExit] = yield* Effect.all([Fiber.await(first), Fiber.await(second)]) expect(secondExit).toEqual(firstExit) - streamFailure = undefined - streamGate = undefined - streamStarted = undefined + yield* TestLLM.push([]) yield* session.resume(sessionID) expect(requests).toHaveLength(2) }), @@ -3383,7 +3222,7 @@ describe("SessionRunnerLLM", () => { const session = yield* setup yield* admit(session, "Call missing") - responses = [reply.tool("call-missing", "missing", {}), reply.text("Recovered", "text-after-error")] + yield* TestLLM.push(TestLLM.tool("call-missing", "missing", {}), TestLLM.text("Recovered", "text-after-error")) yield* session.resume(sessionID) expect(requests).toHaveLength(2) @@ -3412,12 +3251,12 @@ describe("SessionRunnerLLM", () => { const session = yield* setup yield* admit(session, "Call defect") - responses = [reply.tool("call-defect", "defect", {}), reply.text("Recovered", "text-after-defect")] + yield* TestLLM.push(TestLLM.tool("call-defect", "defect", {}), TestLLM.text("Recovered", "text-after-defect")) yield* session.resume(sessionID) expect(requests).toHaveLength(2) - expect(requests[1]?.messages.map((message) => message.role)).toEqual(["user", "assistant", "tool"]) + expect(messageRoles(requests[1])).toEqual(["user", "assistant", "tool"]) const context = yield* session.context(sessionID) expect(context).toMatchObject([ { type: "user", text: "Call defect" }, @@ -3437,7 +3276,7 @@ describe("SessionRunnerLLM", () => { { type: "assistant", finish: "stop", content: [{ type: "text", text: "Recovered" }] }, ]) const assistant = requireAssistant(context) - expect((yield* recordedStepSettlementEvents(sessionID, assistant.id)).map((event) => event.type)).toEqual([ + expect(yield* recordedStepSettlementTypes(sessionID, assistant.id)).toEqual([ "session.step.started.1", "session.tool.called.1", "session.tool.failed.2", @@ -3450,9 +3289,10 @@ describe("SessionRunnerLLM", () => { Effect.gen(function* () { const session = yield* setup const registry = yield* Tool.Service - yield* transformTools(registry, + yield* transformTools( + registry, { - blocked: ({ + blocked: { name: "blocked", description: "Fail because policy blocked execution", input: Schema.Struct({}), @@ -3461,13 +3301,13 @@ describe("SessionRunnerLLM", () => { Effect.fail(new Permission.BlockedError({ rules: [], permission: "blocked", resources: ["*"] })).pipe( Effect.mapError(() => new Tool.Error({ message: "Permission blocked" })), ), - }), + }, }, { codemode: false }, ) yield* admit(session, "Call blocked") - responses = [reply.tool("call-blocked", "blocked", {}), reply.stop()] + yield* TestLLM.push(TestLLM.tool("call-blocked", "blocked", {}), TestLLM.stop()) yield* session.resume(sessionID) @@ -3489,21 +3329,22 @@ describe("SessionRunnerLLM", () => { Effect.gen(function* () { const session = yield* setup const registry = yield* Tool.Service - yield* transformTools(registry, + yield* transformTools( + registry, { - declined: ({ + declined: { name: "declined", description: "Fail because the user declined approval", input: Schema.Struct({}), output: Schema.Struct({}), execute: () => Effect.die(new Permission.DeclinedError()), - }), + }, }, { codemode: false }, ) yield* admit(session, "Call declined") - response = reply.tool("call-declined", "declined", {}) + yield* TestLLM.push(TestLLM.tool("call-declined", "declined", {})) const exit = yield* session.resume(sessionID).pipe(Effect.exit) @@ -3530,9 +3371,10 @@ describe("SessionRunnerLLM", () => { Effect.gen(function* () { const session = yield* setup const registry = yield* Tool.Service - yield* transformTools(registry, + yield* transformTools( + registry, { - corrected: ({ + corrected: { name: "corrected", description: "Fail with user correction feedback", input: Schema.Struct({}), @@ -3541,13 +3383,13 @@ describe("SessionRunnerLLM", () => { Effect.fail(new Permission.CorrectedError({ feedback: "Use another tool" })).pipe( Effect.mapError(() => new Tool.Error({ message: "Use another tool" })), ), - }), + }, }, { codemode: false }, ) yield* admit(session, "Call corrected") - responses = [reply.tool("call-corrected", "corrected", {}), reply.stop()] + yield* TestLLM.push(TestLLM.tool("call-corrected", "corrected", {}), TestLLM.stop()) yield* session.resume(sessionID) @@ -3571,10 +3413,10 @@ describe("SessionRunnerLLM", () => { const registry = yield* Tool.Service yield* transformTools(registry, { permissionfail: permissionFail }, { codemode: false }) yield* admit(session, "Reject permission") - responses = [ - reply.tool("call-permission", "permissionfail", {}), - [LLMEvent.stepStart({ index: 0 }), LLMEvent.stepFinish({ index: 0, reason: { normalized: "stop" } })], - ] + yield* TestLLM.push(TestLLM.tool("call-permission", "permissionfail", {}), [ + LLMEvent.stepStart({ index: 0 }), + LLMEvent.stepFinish({ index: 0, reason: { normalized: "stop" } }), + ]) yield* session.resume(sessionID) @@ -3607,21 +3449,22 @@ describe("SessionRunnerLLM", () => { Effect.gen(function* () { const session = yield* setup const registry = yield* Tool.Service - yield* transformTools(registry, + yield* transformTools( + registry, { - question: ({ + question: { name: "question", description: "Ask the user", input: Schema.Struct({}), output: Schema.Struct({}), execute: () => Effect.die(new QuestionTool.CancelledError()), - }), + }, }, { codemode: false }, ) yield* admit(session, "Ask then stop") - responses = [reply.tool("call-question", "question", {}), []] + yield* TestLLM.push(TestLLM.tool("call-question", "question", {}), []) const run = yield* session.resume(sessionID).pipe(Effect.exit, Effect.forkChild) const exit = yield* Fiber.join(run) @@ -3650,21 +3493,19 @@ describe("SessionRunnerLLM", () => { const session = yield* setup yield* admit(session, "Settle before failing") const failure = providerUnavailable() - toolExecutionGate = yield* Deferred.make() - responseStream = Stream.concat( - Stream.fromIterable([ + const tools = yield* blockTools() + yield* TestLLM.push( + TestLLM.failAfter( + failure, LLMEvent.stepStart({ index: 0 }), LLMEvent.toolCall({ id: "call-before-failure", name: "echo", input: { text: "settle" } }), - ]), - Stream.fail(failure), + ), ) const run = yield* session.resume(sessionID).pipe(Effect.forkChild) - while (executions.length === 0) yield* Effect.yieldNow - yield* Effect.yieldNow - yield* Deferred.succeed(toolExecutionGate, undefined) + yield* tools.started + yield* tools.release expect(yield* Fiber.join(run).pipe(Effect.flip)).toBe(failure) - toolExecutionGate = undefined const context = yield* session.context(sessionID) expect(context).toMatchObject([ @@ -3681,7 +3522,7 @@ describe("SessionRunnerLLM", () => { }, ]) const assistant = requireAssistant(context) - expect((yield* recordedStepSettlementEvents(sessionID, assistant.id)).map((event) => event.type)).toEqual([ + expect(yield* recordedStepSettlementTypes(sessionID, assistant.id)).toEqual([ "session.step.started.1", "session.tool.called.1", "session.tool.success.2", @@ -3694,19 +3535,17 @@ describe("SessionRunnerLLM", () => { Effect.gen(function* () { const session = yield* setup yield* admit(session, "Interrupt blocked tool") - toolExecutionGate = yield* Deferred.make() - responseStream = Stream.concat( - Stream.fromIterable([ + const tools = yield* blockTools() + yield* TestLLM.push( + TestLLM.hangAfter( LLMEvent.stepStart({ index: 0 }), LLMEvent.toolCall({ id: "call-before-interrupt", name: "echo", input: { text: "blocked" } }), - ]), - Stream.never, + ), ) const run = yield* session.resume(sessionID).pipe(Effect.forkChild) - while (executions.length === 0) yield* Effect.yieldNow + yield* tools.started yield* session.interrupt(sessionID) - toolExecutionGate = undefined expect(yield* Fiber.await(run)).toMatchObject({ _tag: "Failure" }) yield* session.interrupt(sessionID) @@ -3725,7 +3564,7 @@ describe("SessionRunnerLLM", () => { }, ]) const assistant = requireAssistant(context) - expect((yield* recordedStepSettlementEvents(sessionID, assistant.id)).map((event) => event.type)).toEqual([ + expect(yield* recordedStepSettlementTypes(sessionID, assistant.id)).toEqual([ "session.step.started.1", "session.tool.called.1", "session.tool.failed.2", @@ -3739,10 +3578,9 @@ describe("SessionRunnerLLM", () => { { type: "assistant", content: [{ type: "tool", id: "call-before-interrupt", state: { status: "error" } }] }, ]) requests.length = 0 - responseStream = undefined - response = [] + yield* TestLLM.push([]) yield* session.resume(sessionID) - expect(requests[0]?.messages.map((message) => message.role)).toEqual(["user", "assistant", "tool"]) + expect(messageRoles(requests[0])).toEqual(["user", "assistant", "tool"]) }), ) @@ -3750,15 +3588,12 @@ describe("SessionRunnerLLM", () => { Effect.gen(function* () { const session = yield* setup yield* admit(session, "Interrupt provider") - streamGate = yield* Deferred.make() - streamStarted = yield* Deferred.make() + const stream = yield* TestLLM.gate const run = yield* session.resume(sessionID).pipe(Effect.forkChild) - yield* Deferred.await(streamStarted) + yield* stream.started yield* session.interrupt(sessionID) const exit = yield* Fiber.await(run) - streamGate = undefined - streamStarted = undefined expect(Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause)).toBeTrue() expect(requests).toHaveLength(1) @@ -3775,16 +3610,13 @@ describe("SessionRunnerLLM", () => { Effect.gen(function* () { const session = yield* setup yield* admit(session, "Interrupt tool settlement") - toolExecutionGate = yield* Deferred.make() - toolExecutionsStarted = yield* Deferred.make() - toolExecutionsReady = 1 - response = reply.tool("call-await-interrupt", "echo", { text: "blocked" }) + const tools = yield* blockTools() + yield* TestLLM.push(TestLLM.tool("call-await-interrupt", "echo", { text: "blocked" })) const runner = yield* SessionRunner.Service const run = yield* runner.drain({ sessionID, force: true }).pipe(Effect.forkChild) - yield* Deferred.await(toolExecutionsStarted) + yield* tools.started yield* Fiber.interrupt(run) - toolExecutionGate = undefined expect(yield* Fiber.await(run)).toMatchObject({ _tag: "Failure" }) expect(yield* session.context(sessionID)).toMatchObject([ @@ -3819,10 +3651,10 @@ describe("SessionRunnerLLM", () => { ) yield* admit(session, "Finish at the limit") - responses = [ - reply.tool("call-terminal", "echo", { text: "done" }), - reply.tool("call-forbidden", "echo", { text: "forbidden" }), - ] + yield* TestLLM.push( + TestLLM.tool("call-terminal", "echo", { text: "done" }), + TestLLM.tool("call-forbidden", "echo", { text: "forbidden" }), + ) yield* session.resume(sessionID) @@ -3855,21 +3687,18 @@ describe("SessionRunnerLLM", () => { ) yield* admit(session, "Start work") - responses = [ - reply.tool("call-before-steer", "echo", { text: "before" }), - reply.tool("call-after-steer", "echo", { text: "after" }), - reply.stop(), - ] - streamGate = yield* Deferred.make() - streamStarted = yield* Deferred.make() + yield* TestLLM.push( + TestLLM.tool("call-before-steer", "echo", { text: "before" }), + TestLLM.tool("call-after-steer", "echo", { text: "after" }), + TestLLM.stop(), + ) + const stream = yield* TestLLM.gate const run = yield* session.resume(sessionID).pipe(Effect.forkChild) - yield* Deferred.await(streamStarted) + yield* stream.started yield* session.prompt({ sessionID, text: "Change direction" }) - yield* Deferred.succeed(streamGate, undefined) + yield* stream.release yield* Fiber.join(run) - streamGate = undefined - streamStarted = undefined expect(requests).toHaveLength(3) expect(requests[1]?.toolChoice).toBeUndefined() @@ -3882,11 +3711,12 @@ describe("SessionRunnerLLM", () => { it.effect("projects provider errors as terminal assistant step failures", () => Effect.gen(function* () { const session = yield* setup - yield* admit(session, "Fail durably") + yield* TestLLM.push([ + LLMEvent.stepStart({ index: 0 }), + LLMEvent.providerError({ message: "Provider unavailable" }), + ]) - response = [LLMEvent.stepStart({ index: 0 }), LLMEvent.providerError({ message: "Provider unavailable" })] - - expect((yield* session.resume(sessionID).pipe(Effect.flip)).message).toBe("Provider unavailable") + expect((yield* runPrompt(session, "Fail durably").pipe(Effect.flip)).message).toBe("Provider unavailable") expect(requests).toHaveLength(1) expect(yield* session.context(sessionID)).toMatchObject([ @@ -3899,11 +3729,9 @@ describe("SessionRunnerLLM", () => { it.effect("projects provider errors emitted before assistant step start", () => Effect.gen(function* () { const session = yield* setup - yield* admit(session, "Fail before step") + yield* TestLLM.push([LLMEvent.providerError({ message: "Provider unavailable" })]) - response = [LLMEvent.providerError({ message: "Provider unavailable" })] - - expect((yield* session.resume(sessionID).pipe(Effect.flip)).message).toBe("Provider unavailable") + expect((yield* runPrompt(session, "Fail before step").pipe(Effect.flip)).message).toBe("Provider unavailable") expect(requests).toHaveLength(1) expect(yield* session.context(sessionID)).toMatchObject([ @@ -3916,20 +3744,20 @@ describe("SessionRunnerLLM", () => { it.effect("projects content-filter finishes as visible terminal failures", () => Effect.gen(function* () { const session = yield* setup - yield* admit(session, "Blocked response") - response = [ - LLMEvent.stepStart({ index: 0 }), - LLMEvent.textStart({ id: "partial" }), - LLMEvent.textDelta({ id: "partial", text: "Partial" }), - LLMEvent.stepFinish({ - index: 0, - reason: { normalized: "content-filter" }, - usage: { nonCachedInputTokens: 8, outputTokens: 3, reasoningTokens: 1 }, - }), - LLMEvent.finish({ reason: { normalized: "content-filter" } }), - ] + yield* TestLLM.push( + TestLLM.complete( + { + reason: { normalized: "content-filter" }, + usage: { nonCachedInputTokens: 8, outputTokens: 3, reasoningTokens: 1 }, + }, + LLMEvent.textStart({ id: "partial" }), + LLMEvent.textDelta({ id: "partial", text: "Partial" }), + ), + ) - expect((yield* session.resume(sessionID).pipe(Effect.flip)).message).toBe("Provider blocked the response") + expect((yield* runPrompt(session, "Blocked response").pipe(Effect.flip)).message).toBe( + "Provider blocked the response", + ) expect(yield* session.context(sessionID)).toMatchObject([ { type: "user" }, { @@ -3953,22 +3781,18 @@ describe("SessionRunnerLLM", () => { Effect.gen(function* () { const session = yield* setup yield* admit(session, "Tool before blocked response") - toolExecutionGate = yield* Deferred.make() - toolExecutionsStarted = yield* Deferred.make() - toolExecutionsReady = 1 - response = [ - LLMEvent.stepStart({ index: 0 }), - LLMEvent.toolCall({ id: "call-before-content-filter", name: "echo", input: { text: "settled" } }), - LLMEvent.stepFinish({ index: 0, reason: { normalized: "content-filter" } }), - LLMEvent.finish({ reason: { normalized: "content-filter" } }), - ] + const tools = yield* blockTools() + yield* TestLLM.push( + TestLLM.complete( + { reason: { normalized: "content-filter" } }, + LLMEvent.toolCall({ id: "call-before-content-filter", name: "echo", input: { text: "settled" } }), + ), + ) const run = yield* session.resume(sessionID).pipe(Effect.forkChild) - yield* Deferred.await(toolExecutionsStarted) - yield* Deferred.succeed(toolExecutionGate, undefined) + yield* tools.started + yield* tools.release expect((yield* Fiber.join(run).pipe(Effect.flip)).message).toBe("Provider blocked the response") - toolExecutionGate = undefined - toolExecutionsStarted = undefined const assistant = requireAssistant(yield* session.context(sessionID)) const bus = yield* recordedStepSettlementEvents(sessionID, assistant.id) @@ -3987,16 +3811,14 @@ describe("SessionRunnerLLM", () => { it.effect("does not recover context overflow after durable assistant output", () => Effect.gen(function* () { const session = yield* setup - yield* admit(session, "Fail after output") - - response = [ + yield* TestLLM.push([ LLMEvent.stepStart({ index: 0 }), LLMEvent.textStart({ id: "text-partial" }), LLMEvent.textDelta({ id: "text-partial", text: "Partial" }), LLMEvent.textEnd({ id: "text-partial" }), LLMEvent.providerError({ message: "prompt too long", classification: "context-overflow" }), - ] - expect((yield* session.resume(sessionID).pipe(Effect.flip)).message).toBe("prompt too long") + ]) + expect((yield* runPrompt(session, "Fail after output").pipe(Effect.flip)).message).toBe("prompt too long") expect(requests).toHaveLength(1) expect(yield* session.context(sessionID)).toMatchObject([ @@ -4014,11 +3836,10 @@ describe("SessionRunnerLLM", () => { it.effect("projects raw provider stream failures as terminal assistant step failures", () => Effect.gen(function* () { const session = yield* setup - yield* admit(session, "Fail raw stream durably") const failure = invalidRequest() - responseStream = Stream.fail(failure) + yield* TestLLM.push(Stream.fail(failure)) - expect(yield* session.resume(sessionID).pipe(Effect.flip)).toBe(failure) + expect(yield* runPrompt(session, "Fail raw stream durably").pipe(Effect.flip)).toBe(failure) yield* replaySessionProjection(sessionID) expect(yield* session.context(sessionID)).toMatchObject([ { type: "user", text: "Fail raw stream durably" }, @@ -4031,11 +3852,11 @@ describe("SessionRunnerLLM", () => { Effect.gen(function* () { const session = yield* setup yield* admit(session, "Retry transport") - responseStream = Stream.fail(providerUnavailable()) - response = reply.text("Recovered", "retry-success") + yield* TestLLM.push(Stream.fail(providerUnavailable())) + yield* TestLLM.push(TestLLM.text("Recovered", "retry-success")) const run = yield* session.resume(sessionID).pipe(Effect.forkChild) - while (requests.length < 1) yield* Effect.yieldNow + yield* TestLLM.wait(1) yield* TestClock.adjust("1999 millis") expect(requests).toHaveLength(1) yield* TestClock.adjust("1 millis") @@ -4058,11 +3879,11 @@ describe("SessionRunnerLLM", () => { Effect.gen(function* () { const session = yield* setup yield* admit(session, "Retry rate limit") - responseStream = Stream.fail(rateLimited(5_000)) - response = reply.text("Recovered", "retry-after-success") + yield* TestLLM.push(Stream.fail(rateLimited(5_000))) + yield* TestLLM.push(TestLLM.text("Recovered", "retry-after-success")) const run = yield* session.resume(sessionID).pipe(Effect.forkChild) - while (requests.length < 1) yield* Effect.yieldNow + yield* TestLLM.wait(1) yield* TestClock.adjust("4999 millis") expect(requests).toHaveLength(1) yield* TestClock.adjust("1 millis") @@ -4074,15 +3895,17 @@ describe("SessionRunnerLLM", () => { it.effect("does not retry eligible failures after observable output", () => Effect.gen(function* () { const session = yield* setup - yield* admit(session, "Do not replay partial output") const failure = rateLimited() - responseStream = Stream.fromIterable([ - LLMEvent.stepStart({ index: 0 }), - LLMEvent.textStart({ id: "partial-rate-limit" }), - LLMEvent.textDelta({ id: "partial-rate-limit", text: "Partial" }), - ]).pipe(Stream.concat(Stream.fail(failure))) + yield* TestLLM.push( + TestLLM.failAfter( + failure, + LLMEvent.stepStart({ index: 0 }), + LLMEvent.textStart({ id: "partial-rate-limit" }), + LLMEvent.textDelta({ id: "partial-rate-limit", text: "Partial" }), + ), + ) - expect(yield* session.resume(sessionID).pipe(Effect.flip)).toBe(failure) + expect(yield* runPrompt(session, "Do not replay partial output").pipe(Effect.flip)).toBe(failure) expect(requests).toHaveLength(1) expect(yield* recordedEventTypes(sessionID)).not.toContain("session.retry.scheduled.1") expect(yield* session.context(sessionID)).toMatchObject([ @@ -4101,15 +3924,16 @@ describe("SessionRunnerLLM", () => { Effect.gen(function* () { const session = yield* setup yield* admit(session, "Exhaust retries") - streamFailure = providerUnavailable() + const failure = providerUnavailable() + yield* TestLLM.always(Stream.fail(failure)) const run = yield* session.resume(sessionID).pipe(Effect.forkChild) - while (requests.length < 1) yield* Effect.yieldNow + yield* TestLLM.wait(1) for (const [index, delay] of [2_000, 4_000, 8_000, 16_000].entries()) { yield* TestClock.adjust(delay) - while (requests.length < index + 2) yield* Effect.yieldNow + yield* TestLLM.wait(index + 2) } - expect(yield* Fiber.join(run).pipe(Effect.flip)).toBe(streamFailure) + expect(yield* Fiber.join(run).pipe(Effect.flip)).toBe(failure) expect(requests).toHaveLength(5) const database = (yield* Database.Service).db @@ -4150,11 +3974,11 @@ describe("SessionRunnerLLM", () => { ) yield* admit(session, "Retry without consuming a step") const failure = providerUnavailable() - responseStream = Stream.fail(failure) - responses = [reply.tool("call-after-retry", "echo", { text: "recovered" }), reply.stop()] + yield* TestLLM.push(Stream.fail(failure)) + yield* TestLLM.push(TestLLM.tool("call-after-retry", "echo", { text: "recovered" }), TestLLM.stop()) const run = yield* session.resume(sessionID).pipe(Effect.forkChild) - while (requests.length < 1) yield* Effect.yieldNow + yield* TestLLM.wait(1) yield* TestClock.adjust("2 seconds") yield* Fiber.join(run) @@ -4185,11 +4009,10 @@ describe("SessionRunnerLLM", () => { it.effect("does not retry non-eligible provider failures", () => Effect.gen(function* () { const session = yield* setup - yield* admit(session, "Do not retry") const failure = invalidRequest() - streamFailure = failure + yield* TestLLM.push(Stream.fail(failure)) - expect(yield* session.resume(sessionID).pipe(Effect.flip)).toBe(failure) + expect(yield* runPrompt(session, "Do not retry").pipe(Effect.flip)).toBe(failure) expect(requests).toHaveLength(1) expect(yield* recordedEventTypes(sessionID)).not.toContain("session.retry.scheduled.1") }), @@ -4198,24 +4021,25 @@ describe("SessionRunnerLLM", () => { it.effect("settles malformed streamed tool input before the provider failure", () => Effect.gen(function* () { const session = yield* setup - yield* admit(session, "Call a malformed tool") const failure = new LLMError({ module: "test", method: "stream", reason: new InvalidProviderOutputReason({ message: "Invalid JSON input for tool call echo" }), }) - responseStream = Stream.fromIterable([ - LLMEvent.stepStart({ index: 0 }), - LLMEvent.toolInputStart({ id: "call-malformed", name: "echo" }), - LLMEvent.toolInputDelta({ id: "call-malformed", name: "echo", text: '{"text":"partial' }), - ]).pipe(Stream.concat(Stream.fail(failure))) + yield* TestLLM.push( + TestLLM.failAfter( + failure, + LLMEvent.stepStart({ index: 0 }), + LLMEvent.toolInputStart({ id: "call-malformed", name: "echo" }), + LLMEvent.toolInputDelta({ id: "call-malformed", name: "echo", text: '{"text":"partial' }), + ), + ) - expect(yield* session.resume(sessionID).pipe(Effect.flip)).toBe(failure) + expect(yield* runPrompt(session, "Call a malformed tool").pipe(Effect.flip)).toBe(failure) const assistant = requireAssistant(yield* session.context(sessionID)) - response = reply.stop() - yield* admit(session, "Continue") - yield* session.resume(sessionID) + yield* TestLLM.push(TestLLM.stop()) + yield* runPrompt(session, "Continue") expect(yield* recordedStepSettlementEvents(sessionID, assistant.id)).toMatchObject([ { type: "session.step.started.1" }, @@ -4237,12 +4061,10 @@ describe("SessionRunnerLLM", () => { it.effect("continues after malformed local tool input without exposing raw arguments", () => Effect.gen(function* () { const session = yield* setup - yield* admit(session, "Recover malformed tool input") const marker = "raw-malformed-marker" const raw = `{"text":"${marker}` - responses = [ - [ - LLMEvent.stepStart({ index: 0 }), + yield* TestLLM.push( + TestLLM.toolCalls( LLMEvent.toolInputStart({ id: "call-malformed", name: "echo" }), LLMEvent.toolInputDelta({ id: "call-malformed", name: "echo", text: raw }), LLMEvent.toolInputEnd({ id: "call-malformed", name: "echo" }), @@ -4251,13 +4073,11 @@ describe("SessionRunnerLLM", () => { name: "echo", raw, }), - LLMEvent.stepFinish({ index: 0, reason: { normalized: "tool-calls" } }), - LLMEvent.finish({ reason: { normalized: "tool-calls" } }), - ], - reply.stop(), - ] + ), + TestLLM.stop(), + ) - yield* session.resume(sessionID) + yield* runPrompt(session, "Recover malformed tool input") expect(requests).toHaveLength(2) expect(executions).toEqual([]) @@ -4313,7 +4133,7 @@ describe("SessionRunnerLLM", () => { }) if (!failed) throw new Error("Malformed tool assistant missing") expect(failed.error).toBeUndefined() - expect((yield* recordedStepSettlementEvents(sessionID, failed.id)).map((event) => event.type)).toEqual([ + expect(yield* recordedStepSettlementTypes(sessionID, failed.id)).toEqual([ "session.step.started.1", "session.tool.failed.2", "session.step.ended.1", @@ -4336,31 +4156,24 @@ describe("SessionRunnerLLM", () => { Effect.gen(function* () { const session = yield* setup yield* admit(session, "Run parallel tools") - toolExecutionGate = yield* Deferred.make() - toolExecutionsStarted = yield* Deferred.make() - toolExecutionsReady = 1 - responses = [ - [ - LLMEvent.stepStart({ index: 0 }), + const tools = yield* blockTools() + yield* TestLLM.push( + TestLLM.toolCalls( LLMEvent.toolCall({ id: "call-valid", name: "echo", input: { text: "valid" } }), LLMEvent.toolInputError({ id: "call-malformed", name: "echo", raw: '{"text":"partial', }), - LLMEvent.stepFinish({ index: 0, reason: { normalized: "tool-calls" } }), - LLMEvent.finish({ reason: { normalized: "tool-calls" } }), - ], - reply.stop(), - ] + ), + TestLLM.stop(), + ) const run = yield* session.resume(sessionID).pipe(Effect.forkChild) - yield* Deferred.await(toolExecutionsStarted) + yield* tools.started expect(requests).toHaveLength(1) - yield* Deferred.succeed(toolExecutionGate, undefined) + yield* tools.release yield* Fiber.join(run) - toolExecutionGate = undefined - toolExecutionsStarted = undefined expect(requests).toHaveLength(2) expect(executions).toEqual(["valid"]) @@ -4379,23 +4192,20 @@ describe("SessionRunnerLLM", () => { Effect.gen(function* () { const session = yield* setup yield* admit(session, "Interrupt malformed recovery") - toolExecutionGate = yield* Deferred.make() - toolExecutionsStarted = yield* Deferred.make() - toolExecutionsReady = 1 - response = [ - LLMEvent.stepStart({ index: 0 }), - LLMEvent.toolCall({ id: "call-valid", name: "echo", input: { text: "blocked" } }), - LLMEvent.toolInputError({ - id: "call-malformed", - name: "echo", - raw: '{"text":"partial', - }), - LLMEvent.stepFinish({ index: 0, reason: { normalized: "tool-calls" } }), - LLMEvent.finish({ reason: { normalized: "tool-calls" } }), - ] + const tools = yield* blockTools() + yield* TestLLM.push( + TestLLM.toolCalls( + LLMEvent.toolCall({ id: "call-valid", name: "echo", input: { text: "blocked" } }), + LLMEvent.toolInputError({ + id: "call-malformed", + name: "echo", + raw: '{"text":"partial', + }), + ), + ) const run = yield* session.resume(sessionID).pipe(Effect.forkChild) - yield* Deferred.await(toolExecutionsStarted) + yield* tools.started while ( !(yield* session.context(sessionID)).some( (message) => @@ -4405,8 +4215,6 @@ describe("SessionRunnerLLM", () => { ) yield* Effect.yieldNow yield* session.interrupt(sessionID) - toolExecutionGate = undefined - toolExecutionsStarted = undefined expect(yield* Fiber.await(run)).toMatchObject({ _tag: "Failure" }) expect(requests).toHaveLength(1) @@ -4427,19 +4235,21 @@ describe("SessionRunnerLLM", () => { it.effect("records malformed provider-executed input as executed", () => Effect.gen(function* () { const session = yield* setup - yield* admit(session, "Fail malformed hosted input") const failure = new LLMError({ module: "test", method: "stream", reason: new InvalidProviderOutputReason({ message: "Invalid hosted tool input" }), }) - responseStream = Stream.fromIterable([ - LLMEvent.stepStart({ index: 0 }), - LLMEvent.toolInputStart({ id: "call-hosted", name: "web_search", providerExecuted: true }), - LLMEvent.toolInputDelta({ id: "call-hosted", name: "web_search", text: '{"query":"partial' }), - ]).pipe(Stream.concat(Stream.fail(failure))) + yield* TestLLM.push( + TestLLM.failAfter( + failure, + LLMEvent.stepStart({ index: 0 }), + LLMEvent.toolInputStart({ id: "call-hosted", name: "web_search", providerExecuted: true }), + LLMEvent.toolInputDelta({ id: "call-hosted", name: "web_search", text: '{"query":"partial' }), + ), + ) - expect(yield* session.resume(sessionID).pipe(Effect.flip)).toBe(failure) + expect(yield* runPrompt(session, "Fail malformed hosted input").pipe(Effect.flip)).toBe(failure) expect(requireAssistant(yield* session.context(sessionID))).toMatchObject({ error: { type: "provider.invalid-output", message: "Invalid hosted tool input" }, content: [ @@ -4457,22 +4267,24 @@ describe("SessionRunnerLLM", () => { it.effect("records a provider failure after malformed input", () => Effect.gen(function* () { const session = yield* setup - yield* admit(session, "Fail after malformed input") const failure = new LLMError({ module: "test", method: "stream", reason: new InvalidProviderOutputReason({ message: "Provider failed after malformed input" }), }) - responseStream = Stream.fromIterable([ - LLMEvent.stepStart({ index: 0 }), - LLMEvent.toolInputError({ - id: "call-malformed", - name: "echo", - raw: '{"text":"partial', - }), - ]).pipe(Stream.concat(Stream.fail(failure))) + yield* TestLLM.push( + TestLLM.failAfter( + failure, + LLMEvent.stepStart({ index: 0 }), + LLMEvent.toolInputError({ + id: "call-malformed", + name: "echo", + raw: '{"text":"partial', + }), + ), + ) - expect(yield* session.resume(sessionID).pipe(Effect.flip)).toBe(failure) + expect(yield* runPrompt(session, "Fail after malformed input").pipe(Effect.flip)).toBe(failure) expect(requireAssistant(yield* session.context(sessionID))).toMatchObject({ error: { type: "provider.invalid-output", message: "Provider failed after malformed input" }, content: [ @@ -4491,25 +4303,22 @@ describe("SessionRunnerLLM", () => { it.effect("continues after repeated malformed tool input", () => Effect.gen(function* () { const session = yield* setup - yield* admit(session, "Keep producing malformed tools") - const malformed = (id: string) => [ - LLMEvent.stepStart({ index: 0 }), - LLMEvent.toolInputError({ - id, - name: "echo", - raw: '{"text":"partial', - }), - LLMEvent.stepFinish({ index: 0, reason: { normalized: "tool-calls" } }), - LLMEvent.finish({ reason: { normalized: "tool-calls" } }), - ] - responses = [ + const malformed = (id: string) => + TestLLM.toolCalls( + LLMEvent.toolInputError({ + id, + name: "echo", + raw: '{"text":"partial', + }), + ) + yield* TestLLM.push( malformed("call-first"), - reply.tool("call-valid-between", "echo", { text: "valid" }), + TestLLM.tool("call-valid-between", "echo", { text: "valid" }), malformed("call-second"), - reply.stop(), - ] + TestLLM.stop(), + ) - yield* session.resume(sessionID) + yield* runPrompt(session, "Keep producing malformed tools") expect(requests).toHaveLength(4) expect(executions).toEqual(["valid"]) @@ -4526,20 +4335,17 @@ describe("SessionRunnerLLM", () => { agent.steps = 2 }), ) - yield* admit(session, "Stop malformed tools at the step limit") - const malformed = (id: string) => [ - LLMEvent.stepStart({ index: 0 }), - LLMEvent.toolInputError({ - id, - name: "echo", - raw: '{"text":"partial', - }), - LLMEvent.stepFinish({ index: 0, reason: { normalized: "tool-calls" } }), - LLMEvent.finish({ reason: { normalized: "tool-calls" } }), - ] - responses = [malformed("call-first"), malformed("call-at-limit")] + const malformed = (id: string) => + TestLLM.toolCalls( + LLMEvent.toolInputError({ + id, + name: "echo", + raw: '{"text":"partial', + }), + ) + yield* TestLLM.push(malformed("call-first"), malformed("call-at-limit")) - yield* session.resume(sessionID) + yield* runPrompt(session, "Stop malformed tools at the step limit") expect(requests).toHaveLength(2) expect(requests[0]?.toolChoice).toBeUndefined() @@ -4552,28 +4358,23 @@ describe("SessionRunnerLLM", () => { Effect.gen(function* () { const session = yield* setup yield* admit(session, "Do not continue failed provider") - - toolExecutionGate = yield* Deferred.make() - toolExecutionsStarted = yield* Deferred.make() - toolExecutionsReady = 1 - response = [ + const tools = yield* blockTools() + yield* TestLLM.push([ LLMEvent.stepStart({ index: 0 }), LLMEvent.toolCall({ id: "call-before-provider-error", name: "echo", input: { text: "settled" } }), LLMEvent.providerError({ message: "Provider unavailable" }), - ] + ]) const run = yield* session.resume(sessionID).pipe(Effect.forkChild) - yield* Deferred.await(toolExecutionsStarted) - yield* Deferred.succeed(toolExecutionGate, undefined) + yield* tools.started + yield* tools.release expect((yield* Fiber.join(run).pipe(Effect.flip)).message).toBe("Provider unavailable") - toolExecutionGate = undefined - toolExecutionsStarted = undefined expect(requests).toHaveLength(1) expect(executions).toEqual(["settled"]) const context = yield* session.context(sessionID) const assistant = requireAssistant(context) - expect((yield* recordedStepSettlementEvents(sessionID, assistant.id)).map((event) => event.type)).toEqual([ + expect(yield* recordedStepSettlementTypes(sessionID, assistant.id)).toEqual([ "session.step.started.1", "session.tool.called.1", "session.tool.success.2", @@ -4585,15 +4386,15 @@ describe("SessionRunnerLLM", () => { it.effect("durably fails a hosted tool when its provider errors before returning a result", () => Effect.gen(function* () { const session = yield* setup - yield* admit(session, "Fail hosted tool durably") - - response = [ + yield* TestLLM.push([ LLMEvent.stepStart({ index: 0 }), hostedCall("call-hosted-provider-error", "effect"), LLMEvent.providerError({ message: "Provider unavailable" }), - ] + ]) - expect((yield* session.resume(sessionID).pipe(Effect.flip)).message).toBe("Provider unavailable") + expect((yield* runPrompt(session, "Fail hosted tool durably").pipe(Effect.flip)).message).toBe( + "Provider unavailable", + ) expect(requests).toHaveLength(1) const context = yield* session.context(sessionID) @@ -4605,7 +4406,7 @@ describe("SessionRunnerLLM", () => { }, ]) const assistant = requireAssistant(context) - expect((yield* recordedStepSettlementEvents(sessionID, assistant.id)).map((event) => event.type)).toEqual([ + expect(yield* recordedStepSettlementTypes(sessionID, assistant.id)).toEqual([ "session.step.started.1", "session.tool.called.1", "session.tool.failed.2", @@ -4617,14 +4418,15 @@ describe("SessionRunnerLLM", () => { it.effect("preserves a tool defect before provider failure settlement", () => Effect.gen(function* () { const session = yield* setup - yield* admit(session, "Defect while provider fails") - response = [ + yield* TestLLM.push([ LLMEvent.stepStart({ index: 0 }), LLMEvent.toolCall({ id: "call-defect-provider-error", name: "defect", input: {} }), LLMEvent.providerError({ message: "Provider unavailable" }), - ] + ]) - expect((yield* session.resume(sessionID).pipe(Effect.flip)).message).toBe("Provider unavailable") + expect((yield* runPrompt(session, "Defect while provider fails").pipe(Effect.flip)).message).toBe( + "Provider unavailable", + ) const context = yield* session.context(sessionID) const assistant = requireAssistant(context) @@ -4643,13 +4445,15 @@ describe("SessionRunnerLLM", () => { Effect.gen(function* () { const session = yield* setup yield* admit(session, "Storage fails while provider fails") - response = [ + yield* TestLLM.push([ LLMEvent.stepStart({ index: 0 }), LLMEvent.toolCall({ id: "call-store-provider-error", name: "storefail", input: {} }), LLMEvent.providerError({ message: "Provider unavailable" }), - ] + ]) - expect(yield* session.resume(sessionID).pipe(Effect.exit)).toMatchObject({ _tag: "Failure" }) + expect(yield* session.resume(sessionID).pipe(Effect.exit)).toMatchObject({ + _tag: "Failure", + }) expect(requireAssistant(yield* session.context(sessionID))).toMatchObject({ error: { type: "provider.unknown", message: "Provider unavailable" }, @@ -4660,10 +4464,11 @@ describe("SessionRunnerLLM", () => { it.effect("durably fails a hosted tool left unresolved at normal provider EOF", () => Effect.gen(function* () { const session = yield* setup - yield* admit(session, "Fail hosted tool at EOF") - response = [LLMEvent.stepStart({ index: 0 }), hostedCall("call-hosted-eof", "effect")] + yield* TestLLM.push([LLMEvent.stepStart({ index: 0 }), hostedCall("call-hosted-eof", "effect")]) - expect((yield* session.resume(sessionID).pipe(Effect.flip)).message).toBe("Provider did not return a tool result") + expect((yield* runPrompt(session, "Fail hosted tool at EOF").pipe(Effect.flip)).message).toBe( + "Provider did not return a tool result", + ) const assistant = requireAssistant(yield* session.context(sessionID)) const bus = yield* recordedStepSettlementEvents(sessionID, assistant.id) expect(bus.map((event) => event.type)).toEqual([ @@ -4692,15 +4497,9 @@ describe("SessionRunnerLLM", () => { it.effect("fails an unresolved hosted tool before one clean step end", () => Effect.gen(function* () { const session = yield* setup - yield* admit(session, "Settle hosted tool before ending") - response = [ - LLMEvent.stepStart({ index: 0 }), - hostedCall("call-hosted-clean-end", "effect"), - LLMEvent.stepFinish({ index: 0, reason: { normalized: "stop" } }), - LLMEvent.finish({ reason: { normalized: "stop" } }), - ] + yield* TestLLM.push(TestLLM.stop(hostedCall("call-hosted-clean-end", "effect"))) - yield* session.resume(sessionID) + yield* runPrompt(session, "Settle hosted tool before ending") const assistant = requireAssistant(yield* session.context(sessionID)) const bus = yield* recordedStepSettlementEvents(sessionID, assistant.id) @@ -4722,21 +4521,24 @@ describe("SessionRunnerLLM", () => { yield* admit(session, "Fail unresolved tools") const failure = invalidRequest() const providerFailed = yield* Deferred.make() - toolExecutionGate = yield* Deferred.make() - responseStream = Stream.concat( - Stream.fromIterable([ - LLMEvent.stepStart({ index: 0 }), - LLMEvent.toolCall({ id: "call-local-raw-failure", name: "defect", input: {} }), - hostedCall("call-hosted-raw-failure-pair", "effect"), - ]), - Stream.fromEffect(Deferred.succeed(providerFailed, undefined)).pipe(Stream.flatMap(() => Stream.fail(failure))), + const tools = yield* blockTools() + yield* TestLLM.push( + Stream.concat( + Stream.fromIterable([ + LLMEvent.stepStart({ index: 0 }), + LLMEvent.toolCall({ id: "call-local-raw-failure", name: "defect", input: {} }), + hostedCall("call-hosted-raw-failure-pair", "effect"), + ]), + Stream.fromEffect(Deferred.succeed(providerFailed, undefined)).pipe( + Stream.flatMap(() => Stream.fail(failure)), + ), + ), ) const run = yield* session.resume(sessionID).pipe(Effect.forkChild) yield* Deferred.await(providerFailed) - yield* Deferred.succeed(toolExecutionGate, undefined) + yield* tools.release expect(yield* Fiber.join(run).pipe(Effect.flip)).toBe(failure) - toolExecutionGate = undefined const assistant = requireAssistant(yield* session.context(sessionID)) const bus = yield* recordedStepSettlementEvents(sessionID, assistant.id) @@ -4757,14 +4559,15 @@ describe("SessionRunnerLLM", () => { it.effect("durably fails a hosted tool left unresolved by a raw provider stream failure", () => Effect.gen(function* () { const session = yield* setup - yield* admit(session, "Fail hosted tool on raw failure") const failure = providerUnavailable() - responseStream = Stream.concat( - Stream.fromIterable([LLMEvent.stepStart({ index: 0 }), hostedCall("call-hosted-raw-failure", "effect")]), - Stream.fail(failure), + yield* TestLLM.push( + Stream.concat( + Stream.fromIterable([LLMEvent.stepStart({ index: 0 }), hostedCall("call-hosted-raw-failure", "effect")]), + Stream.fail(failure), + ), ) - expect(yield* session.resume(sessionID).pipe(Effect.flip)).toBe(failure) + expect(yield* runPrompt(session, "Fail hosted tool on raw failure").pipe(Effect.flip)).toBe(failure) expect(requests).toHaveLength(1) const assistant = requireAssistant(yield* session.context(sessionID)) const bus = yield* recordedStepSettlementEvents(sessionID, assistant.id) @@ -4793,15 +4596,13 @@ describe("SessionRunnerLLM", () => { it.effect("rejects a second text start before the open fragment ends", () => Effect.gen(function* () { const session = yield* setup - yield* admit(session, "Two blocks") - - response = [ + yield* TestLLM.push([ LLMEvent.stepStart({ index: 0 }), LLMEvent.textStart({ id: "text-1" }), LLMEvent.textStart({ id: "text-2" }), - ] + ]) - const defect = yield* session.resume(sessionID).pipe(Effect.catchDefect(Effect.succeed)) + const defect = yield* runPrompt(session, "Two blocks").pipe(Effect.catchDefect(Effect.succeed)) expect(defect).toBeInstanceOf(Error) if (!(defect instanceof Error)) return expect(defect.message).toBe("text start before end: text-2") @@ -4811,21 +4612,18 @@ describe("SessionRunnerLLM", () => { it.effect("projects sequential text fragments as separate content parts", () => Effect.gen(function* () { const session = yield* setup - yield* admit(session, "Two blocks") + yield* TestLLM.push( + TestLLM.stop( + LLMEvent.textStart({ id: "text-1" }), + LLMEvent.textDelta({ id: "text-1", text: "First" }), + LLMEvent.textEnd({ id: "text-1" }), + LLMEvent.textStart({ id: "text-2" }), + LLMEvent.textDelta({ id: "text-2", text: "Second" }), + LLMEvent.textEnd({ id: "text-2" }), + ), + ) - response = [ - LLMEvent.stepStart({ index: 0 }), - LLMEvent.textStart({ id: "text-1" }), - LLMEvent.textDelta({ id: "text-1", text: "First" }), - LLMEvent.textEnd({ id: "text-1" }), - LLMEvent.textStart({ id: "text-2" }), - LLMEvent.textDelta({ id: "text-2", text: "Second" }), - LLMEvent.textEnd({ id: "text-2" }), - LLMEvent.stepFinish({ index: 0, reason: { normalized: "stop" } }), - LLMEvent.finish({ reason: { normalized: "stop" } }), - ] - - yield* session.resume(sessionID) + yield* runPrompt(session, "Two blocks") expect(yield* session.context(sessionID)).toMatchObject([ { type: "user", text: "Two blocks" }, @@ -4855,7 +4653,7 @@ describe("SessionRunnerLLM", () => { it.effect("rejects duplicate streamed text starts", () => Effect.gen(function* () { const session = yield* setup - response = [LLMEvent.textStart({ id: "text-1" }), LLMEvent.textStart({ id: "text-1" })] + yield* TestLLM.push([LLMEvent.textStart({ id: "text-1" }), LLMEvent.textStart({ id: "text-1" })]) const defect = yield* session.resume(sessionID).pipe(Effect.catchDefect(Effect.succeed)) expect(defect).toBeInstanceOf(Error) @@ -4867,19 +4665,16 @@ describe("SessionRunnerLLM", () => { it.effect("transitions streamed raw tool input to parsed called input", () => Effect.gen(function* () { const session = yield* setup - yield* admit(session, "Call provider tool") + yield* TestLLM.push( + TestLLM.stop( + LLMEvent.toolInputStart({ id: "call-parsed", name: "web_search" }), + LLMEvent.toolInputDelta({ id: "call-parsed", name: "web_search", text: '{"query":"hello"}' }), + LLMEvent.toolInputEnd({ id: "call-parsed", name: "web_search" }), + hostedCall("call-parsed", "hello"), + ), + ) - response = [ - LLMEvent.stepStart({ index: 0 }), - LLMEvent.toolInputStart({ id: "call-parsed", name: "web_search" }), - LLMEvent.toolInputDelta({ id: "call-parsed", name: "web_search", text: '{"query":"hello"}' }), - LLMEvent.toolInputEnd({ id: "call-parsed", name: "web_search" }), - hostedCall("call-parsed", "hello"), - LLMEvent.stepFinish({ index: 0, reason: { normalized: "stop" } }), - LLMEvent.finish({ reason: { normalized: "stop" } }), - ] - - yield* session.resume(sessionID) + yield* runPrompt(session, "Call provider tool") expect(yield* session.context(sessionID)).toMatchObject([ { type: "user", text: "Call provider tool" }, @@ -4894,7 +4689,7 @@ describe("SessionRunnerLLM", () => { it.effect("rejects malformed streamed tool input ordering", () => Effect.gen(function* () { const session = yield* setup - response = [LLMEvent.toolInputDelta({ id: "call-1", name: "read", text: "{}" })] + yield* TestLLM.push([LLMEvent.toolInputDelta({ id: "call-1", name: "read", text: "{}" })]) const defect = yield* session.resume(sessionID).pipe(Effect.catchDefect(Effect.succeed)) expect(defect).toBeInstanceOf(Error) diff --git a/packages/core/test/tool-websearch.test.ts b/packages/core/test/tool-websearch.test.ts index 9bd6815f22..1f1ec9cb5d 100644 --- a/packages/core/test/tool-websearch.test.ts +++ b/packages/core/test/tool-websearch.test.ts @@ -30,6 +30,15 @@ const webSearchToolNode = makeLocationNode({ const sessionID = Session.ID.make("ses_websearch_test") const assertions: Permission.AssertInput[] = [] const queries: WebSearch.Input[] = [] +const formRequests: Form.CreateInput[] = [] +const values = new Map() +const providers = [ + { id: WebSearch.ID.make("exa"), name: "Exa" }, + { id: WebSearch.ID.make("parallel"), name: "Parallel" }, +] +let providerRequired = false +let formResponse: Form.TerminalState = { status: "cancelled" } +const formResponses: Form.TerminalState[] = [] let result = new WebSearch.Response({ providerID: WebSearch.ID.make("exa"), results: [{ url: "https://example.com", title: "Search results", content: "search results", time: {} }], @@ -38,6 +47,11 @@ let result = new WebSearch.Response({ beforeEach(() => { assertions.length = 0 queries.length = 0 + formRequests.length = 0 + values.clear() + providerRequired = false + formResponse = { status: "cancelled" } + formResponses.length = 0 result = new WebSearch.Response({ providerID: WebSearch.ID.make("exa"), results: [{ url: "https://example.com", title: "Search results", content: "search results", time: {} }], @@ -60,11 +74,15 @@ const websearch = Layer.succeed( WebSearch.Service.of({ transform: () => Effect.die("unused"), reload: () => Effect.die("unused"), - providers: () => Effect.succeed([]), + providers: () => Effect.succeed(providers), default: () => Effect.succeed(undefined), query: (input) => - Effect.sync(() => { + Effect.gen(function* () { queries.push(input) + const stored = values.get("websearch:provider") + if (providerRequired && typeof stored !== "string") return yield* new WebSearch.ProviderRequiredError() + if (typeof stored === "string") + return new WebSearch.Response({ providerID: WebSearch.ID.make(stored), results: result.results }) return result }), }), @@ -73,7 +91,11 @@ const form = Layer.succeed( Form.Service, Form.Service.of({ create: () => Effect.die("unused"), - ask: () => Effect.die("unused"), + ask: (input) => + Effect.sync(() => { + formRequests.push(input) + return formResponses.shift() ?? formResponse + }), get: () => Effect.die("unused"), list: () => Effect.die("unused"), state: () => Effect.die("unused"), @@ -84,22 +106,19 @@ const form = Layer.succeed( const kv = Layer.succeed( KV.Service, KV.Service.of({ - get: () => Effect.succeed(undefined), - set: () => Effect.void, - remove: () => Effect.void, + get: (key) => Effect.succeed(values.get(key)), + set: (key, value) => Effect.sync(() => values.set(key, value)).pipe(Effect.asVoid), + remove: (key) => Effect.sync(() => values.delete(key)).pipe(Effect.asVoid), }), ) const it = testEffect( - AppNodeBuilder.build( - LayerNode.group([Tool.node, WebSearch.node, webSearchToolNode]), - [ - [Permission.node, permission], - [WebSearch.node, websearch], - [Form.node, form], - [KV.node, kv], - [Image.node, imagePassthrough], - ], - ), + AppNodeBuilder.build(LayerNode.group([Tool.node, WebSearch.node, webSearchToolNode]), [ + [Permission.node, permission], + [WebSearch.node, websearch], + [Form.node, form], + [KV.node, kv], + [Image.node, imagePassthrough], + ]), ) describe("WebSearchTool registration", () => { @@ -202,4 +221,116 @@ describe("WebSearchTool registration", () => { }) }), ) + + it.effect("asks once and uses the default provider when web search is first enabled", () => + Effect.gen(function* () { + providerRequired = true + formResponse = { status: "answered", answer: { choice: "allow" } } + const registry = yield* Tool.Service + + expect( + yield* executeTool(registry, { + sessionID, + ...toolIdentity, + call: { type: "tool-call", id: "call-enable", name: "websearch", input: { query: "effect" } }, + }), + ).toMatchObject({ status: "completed", metadata: { provider: "exa" } }) + expect(values.get("websearch:provider")).toBe("exa") + expect(queries).toHaveLength(2) + expect(formRequests).toEqual([ + { + sessionID, + title: "Web Search", + metadata: { kind: "websearch.provider" }, + fields: [ + { + key: "choice", + description: "Allow OpenCode to search the web for up-to-date information?", + type: "string", + required: true, + custom: false, + options: [ + { + value: "allow", + label: "Allow web search via Exa", + }, + { + value: "choose", + label: "Choose another provider", + }, + { value: "disable", label: "Disable web search" }, + ], + }, + ], + }, + ]) + + expect( + yield* executeTool(registry, { + sessionID, + ...toolIdentity, + call: { type: "tool-call", id: "call-enabled", name: "websearch", input: { query: "effect schema" } }, + }), + ).toMatchObject({ status: "completed", metadata: { provider: "exa" } }) + expect(formRequests).toHaveLength(1) + expect(queries).toHaveLength(3) + }), + ) + + it.effect("asks a second form when choosing another provider", () => + Effect.gen(function* () { + providerRequired = true + formResponses.push( + { status: "answered", answer: { choice: "choose" } }, + { status: "answered", answer: { provider: "parallel" } }, + ) + const registry = yield* Tool.Service + + expect( + yield* executeTool(registry, { + sessionID, + ...toolIdentity, + call: { type: "tool-call", id: "call-choose", name: "websearch", input: { query: "effect" } }, + }), + ).toMatchObject({ status: "completed", metadata: { provider: "parallel" } }) + expect(values.get("websearch:provider")).toBe("parallel") + expect(queries).toHaveLength(2) + expect(formRequests[1]).toEqual({ + sessionID, + title: "Choose a web search provider", + metadata: { kind: "websearch.provider" }, + fields: [ + { + key: "provider", + description: "Choose a provider for web search.", + type: "string", + required: true, + custom: false, + options: [ + { value: "exa", label: "Exa" }, + { value: "parallel", label: "Parallel" }, + ], + }, + ], + }) + }), + ) + + it.effect("persists the choice to disable web search", () => + Effect.gen(function* () { + providerRequired = true + formResponse = { status: "answered", answer: { choice: "disable" } } + const registry = yield* Tool.Service + + expect( + yield* executeTool(registry, { + sessionID, + ...toolIdentity, + call: { type: "tool-call", id: "call-disable", name: "websearch", input: { query: "effect" } }, + }), + ).toMatchObject({ status: "error" }) + expect(values.get("websearch:provider")).toBe(false) + expect(queries).toHaveLength(1) + }), + ) }) diff --git a/packages/plugin/src/tui/context.ts b/packages/plugin/src/tui/context.ts index 34cb863536..0cde1200ec 100644 --- a/packages/plugin/src/tui/context.ts +++ b/packages/plugin/src/tui/context.ts @@ -19,7 +19,7 @@ import type { ShellInfo, SkillInfo, } from "@opencode-ai/client" -import type { KeyEvent, Renderable } from "@opentui/core" +import type { CliRenderer, KeyEvent, Renderable } from "@opentui/core" import type { JSX } from "@opentui/solid" interface LocationCollection { @@ -113,7 +113,134 @@ export interface Page { readonly render: (input: { readonly data?: Record }) => JSX.Element } -export type Slot = (props: Record) => JSX.Element +export interface SlotMap { + readonly app: Readonly> + readonly "home.footer": Readonly> + readonly "sidebar.content": { + readonly sessionID: string + } + readonly "sidebar.footer": Readonly> +} + +export type SlotName = keyof SlotMap +export type Slot = (props: SlotMap[Name]) => JSX.Element + +export interface App { + readonly version: string + readonly channel: string +} + +export type ToastVariant = "info" | "success" | "warning" | "error" + +export interface ToastOptions { + readonly title?: string + readonly message: string + readonly variant?: ToastVariant + readonly duration?: number +} + +export interface Toast { + show(options: ToastOptions): void +} + +export type AttentionWhen = "always" | "focused" | "blurred" +export type AttentionSoundName = "default" | "question" | "permission" | "error" | "done" | "subagent_done" + +export type AttentionNotification = + | boolean + | { + readonly when?: AttentionWhen + } + +export type AttentionSound = + | boolean + | { + readonly name?: AttentionSoundName + readonly volume?: number + readonly when?: AttentionWhen + } + +export interface AttentionNotifyOptions { + readonly title?: string + readonly message: string + readonly notification?: AttentionNotification + readonly sound?: AttentionSound +} + +export type AttentionNotifySkipReason = + | "attention_disabled" + | "empty_message" + | "blurred" + | "focused" + | "focus_unknown" + | "renderer_destroyed" + +export interface AttentionNotifyResult { + readonly ok: boolean + readonly notification: boolean + readonly sound: boolean + readonly skipped?: AttentionNotifySkipReason +} + +export interface Attention { + notify(options: AttentionNotifyOptions): Promise +} + +export type DialogSize = "medium" | "large" | "xlarge" + +export interface DialogOptions { + readonly size?: DialogSize + readonly centered?: boolean +} + +export interface DialogAlertOptions { + readonly title: string + readonly message: string +} + +export interface DialogConfirmOptions { + readonly title: string + readonly message: string + readonly label?: { + readonly confirm?: string + readonly cancel?: string + } +} + +export interface DialogPromptOptions { + readonly title: string + readonly description?: string + readonly placeholder?: string + readonly value?: string +} + +export interface DialogSelectOption { + readonly title: string + readonly value: Value + readonly description?: string + readonly category?: string + readonly disabled?: boolean +} + +export interface DialogSelectOptions { + readonly title: string + readonly placeholder?: string + readonly options: readonly DialogSelectOption[] + readonly current?: Value +} + +export interface Dialog { + /** Shows a dialog and returns a function that closes it. */ + show(render: () => JSX.Element, onClose?: () => void): () => void + /** Sets the presentation options for this plugin's active dialog. */ + set(options: DialogOptions): void + /** Closes this plugin's active dialog. */ + clear(): void + alert(options: DialogAlertOptions): Promise + confirm(options: DialogConfirmOptions): Promise + prompt(options: DialogPromptOptions): Promise + select(options: DialogSelectOptions): Promise +} export interface KeymapCommand { /** Stable command and config keybind identifier. Omit for an inline command. */ @@ -158,13 +285,32 @@ export interface KeymapLayer { readonly bindings?: readonly string[] } +export interface KeymapPending { + readonly key: string + readonly token?: string +} + +export interface KeymapActive { + readonly key: string + readonly title?: string + readonly description?: string + readonly group?: string + readonly continues: boolean +} + export interface Keymap { /** Creates a reactive keymap layer owned by the calling component. */ layer(input: () => KeymapLayer): void /** Dispatches a reachable command by ID. */ dispatch(id: string, input?: string): void - /** Returns the formatted shortcut for a registered command. */ - shortcut(id: string): string | undefined + /** Returns every formatted shortcut for a registered command. */ + shortcuts(id: string): readonly string[] + /** Returns the currently reachable commands. Reactive when read in a Solid computation. */ + commands(): readonly KeymapCommand[] + /** Returns the pending key sequence. Reactive when read in a Solid computation. */ + pending(): readonly KeymapPending[] + /** Returns bindings reachable from the pending key sequence. Reactive when read in a Solid computation. */ + active(): readonly KeymapActive[] /** Controls mutually exclusive OpenCode input modes. */ readonly mode: { /** Returns the active mode. */ @@ -175,19 +321,28 @@ export interface Keymap { } export interface UI { + readonly dialog: Dialog + readonly toast: Toast + readonly format: { + path(value: string): string + } readonly router: { register(page: Page): () => void navigate(destination: Destination): void current(): Route } - readonly slot: (name: string, render: Slot) => () => void + readonly slot: (name: Name, render: Slot) => () => void } export interface Context { readonly options: Readonly> readonly location: LocationRef | undefined + readonly app: App + readonly renderer: CliRenderer readonly client: OpenCodeClient readonly data: Data + readonly attention: Attention + readonly theme: any readonly keymap: Keymap readonly ui: UI } diff --git a/packages/theme/package.json b/packages/theme/package.json new file mode 100644 index 0000000000..aa54b64232 --- /dev/null +++ b/packages/theme/package.json @@ -0,0 +1,36 @@ +{ + "$schema": "https://json.schemastore.org/package.json", + "name": "@opencode-ai/theme", + "version": "0.0.0", + "type": "module", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/anomalyco/opencode.git", + "directory": "packages/theme" + }, + "publishConfig": { + "access": "public" + }, + "files": [ + "dist" + ], + "exports": { + "./tui": "./src/tui/index.ts", + "./tui/v1": "./src/tui/v1.ts" + }, + "scripts": { + "build": "bun run script/build.ts", + "typecheck": "tsgo --noEmit" + }, + "dependencies": { + "@opentui/core": "catalog:", + "effect": "catalog:" + }, + "devDependencies": { + "@tsconfig/bun": "catalog:", + "@types/bun": "catalog:", + "@typescript/native-preview": "catalog:", + "typescript": "catalog:" + } +} diff --git a/packages/theme/script/build.ts b/packages/theme/script/build.ts new file mode 100644 index 0000000000..323a63ddf9 --- /dev/null +++ b/packages/theme/script/build.ts @@ -0,0 +1,9 @@ +#!/usr/bin/env bun + +import { $ } from "bun" +import { fileURLToPath } from "node:url" + +process.chdir(fileURLToPath(new URL("..", import.meta.url))) + +await $`rm -rf dist` +await $`bun tsc -p tsconfig.build.json` diff --git a/packages/theme/script/publish.ts b/packages/theme/script/publish.ts new file mode 100644 index 0000000000..8e37674b64 --- /dev/null +++ b/packages/theme/script/publish.ts @@ -0,0 +1,45 @@ +#!/usr/bin/env bun + +import { Script } from "@opencode-ai/script" +import { $ } from "bun" +import { rm } from "node:fs/promises" +import { fileURLToPath } from "node:url" + +process.chdir(fileURLToPath(new URL("..", import.meta.url))) + +const originalText = await Bun.file("package.json").text() +const pkg = JSON.parse(originalText) as { + name: string + version: string + exports: Record +} +const tarball = `${pkg.name.replace("@", "").replace("/", "-")}-${pkg.version}.tgz` + +if ((await $`npm view ${pkg.name}@${pkg.version} version`.nothrow()).exitCode === 0) { + console.log(`already published ${pkg.name}@${pkg.version}`) + process.exit(0) +} + +try { + await $`bun run typecheck` + await $`bun run build` + pkg.exports = Object.fromEntries( + Object.entries(pkg.exports).map(([key, value]) => { + if (typeof value !== "string") return [key, value] + return [ + key, + { + import: value.replace("./src/", "./dist/").replace(/\.ts$/, ".js"), + types: value.replace("./src/", "./dist/").replace(/\.ts$/, ".d.ts"), + }, + ] + }), + ) + await Bun.write("package.json", JSON.stringify(pkg, null, 2) + "\n") + await rm(tarball, { force: true }) + await $`bun pm pack` + await $`npm publish ${tarball} --tag ${Script.channel} --access public` +} finally { + await Bun.write("package.json", originalText) + await rm(tarball, { force: true }) +} diff --git a/packages/theme/src/tui/color.ts b/packages/theme/src/tui/color.ts new file mode 100644 index 0000000000..7f7387db0e --- /dev/null +++ b/packages/theme/src/tui/color.ts @@ -0,0 +1,77 @@ +type OklchColor = { + l: number + c: number + h: number +} + +function clamp(value: number, min: number, max: number) { + return Math.max(min, Math.min(max, value)) +} + +function hue(value: number) { + return ((value % 360) + 360) % 360 +} + +function linearToSrgb(value: number) { + if (value <= 0.0031308) return value * 12.92 + return 1.055 * Math.pow(value, 1 / 2.4) - 0.055 +} + +function srgbToLinear(value: number) { + if (value <= 0.04045) return value / 12.92 + return Math.pow((value + 0.055) / 1.055, 2.4) +} + +export function rgbToOklch(red: number, green: number, blue: number): OklchColor { + const linearRed = srgbToLinear(red) + const linearGreen = srgbToLinear(green) + const linearBlue = srgbToLinear(blue) + const lRoot = Math.cbrt(0.4122214708 * linearRed + 0.5363325363 * linearGreen + 0.0514459929 * linearBlue) + const mRoot = Math.cbrt(0.2119034982 * linearRed + 0.6806995451 * linearGreen + 0.1073969566 * linearBlue) + const sRoot = Math.cbrt(0.0883024619 * linearRed + 0.2817188376 * linearGreen + 0.6299787005 * linearBlue) + const lightness = 0.2104542553 * lRoot + 0.793617785 * mRoot - 0.0040720468 * sRoot + const a = 1.9779984951 * lRoot - 2.428592205 * mRoot + 0.4505937099 * sRoot + const b = 0.0259040371 * lRoot + 0.7827717662 * mRoot - 0.808675766 * sRoot + const chroma = Math.sqrt(a * a + b * b) + const angle = Math.atan2(b, a) * (180 / Math.PI) + return { l: lightness, c: chroma, h: angle < 0 ? angle + 360 : angle } +} + +function oklchToRgb(color: OklchColor) { + const a = color.c * Math.cos((color.h * Math.PI) / 180) + const b = color.c * Math.sin((color.h * Math.PI) / 180) + const lRoot = color.l + 0.3963377774 * a + 0.2158037573 * b + const mRoot = color.l - 0.1055613458 * a - 0.0638541728 * b + const sRoot = color.l - 0.0894841775 * a - 1.291485548 * b + const l = lRoot * lRoot * lRoot + const m = mRoot * mRoot * mRoot + const s = sRoot * sRoot * sRoot + return { + r: linearToSrgb(4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s), + g: linearToSrgb(-1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s), + b: linearToSrgb(-0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s), + } +} + +function fitOklch(color: OklchColor): OklchColor { + const base = { l: clamp(color.l, 0, 1), c: Math.max(0, color.c), h: hue(color.h) } + const rgb = oklchToRgb(base) + if (rgb.r >= 0 && rgb.r <= 1 && rgb.g >= 0 && rgb.g <= 1 && rgb.b >= 0 && rgb.b <= 1) return base + + const fitted = Array.from({ length: 24 }).reduce((result, _, index) => { + if (result) return result + const next = { ...base, c: base.c * Math.pow(0.9, index + 1) } + const output = oklchToRgb(next) + if (output.r >= 0 && output.r <= 1 && output.g >= 0 && output.g <= 1 && output.b >= 0 && output.b <= 1) return next + }, undefined) + return fitted ?? { ...base, c: 0 } +} + +export function oklchToHex(color: OklchColor) { + const rgb = oklchToRgb(fitOklch(color)) + const toHex = (value: number) => + Math.round(clamp(value, 0, 1) * 255) + .toString(16) + .padStart(2, "0") + return `#${toHex(rgb.r)}${toHex(rgb.g)}${toHex(rgb.b)}` +} diff --git a/packages/tui/src/theme/v2/defaults.ts b/packages/theme/src/tui/defaults.ts similarity index 99% rename from packages/tui/src/theme/v2/defaults.ts rename to packages/theme/src/tui/defaults.ts index e9eb352a07..847eb9cb69 100644 --- a/packages/tui/src/theme/v2/defaults.ts +++ b/packages/theme/src/tui/defaults.ts @@ -1,4 +1,4 @@ -import type { HueName, ThemeDocument } from "./schema" +import type { HueName, ThemeDocument } from "./schema.js" export const DEFAULT_CATEGORICAL = [ "blue", diff --git a/packages/tui/src/theme/v2/expand.ts b/packages/theme/src/tui/expand.ts similarity index 98% rename from packages/tui/src/theme/v2/expand.ts rename to packages/theme/src/tui/expand.ts index 6601c476a3..fb39a8d7e2 100644 --- a/packages/tui/src/theme/v2/expand.ts +++ b/packages/theme/src/tui/expand.ts @@ -4,8 +4,8 @@ import type { StatefulColorDefinition, TextDefinition, ThemeTokensDefinition, -} from "./index" -import { ActionState } from "./schema" +} from "./index.js" +import { ActionState } from "./schema.js" export function expandTheme(definition: Definition): Definition { return { diff --git a/packages/theme/src/tui/fallback.ts b/packages/theme/src/tui/fallback.ts new file mode 100644 index 0000000000..20325a72e4 --- /dev/null +++ b/packages/theme/src/tui/fallback.ts @@ -0,0 +1,57 @@ +import type { ThemeTokensDefinition } from "./index.js" +import { ActionVariant, FeedbackKind } from "./schema.js" + +export function fallback(): ThemeTokensDefinition { + const red = "#ff0000" + + return { + text: { + default: red, + action: Object.fromEntries(ActionVariant.literals.map((variant) => [variant, { default: red }])), + formfield: { default: red }, + feedback: Object.fromEntries(FeedbackKind.literals.map((kind) => [kind, { default: red }])), + }, + background: { + default: red, + surface: { offset: red, overlay: red }, + action: Object.fromEntries(ActionVariant.literals.map((variant) => [variant, { default: red }])), + formfield: { default: red }, + feedback: Object.fromEntries(FeedbackKind.literals.map((kind) => [kind, { default: red }])), + }, + border: { default: red }, + scrollbar: { default: red }, + diff: { + text: { added: red, removed: red, context: red, hunkHeader: red }, + background: { added: red, removed: red, context: red }, + highlight: { added: red, removed: red }, + lineNumber: { text: red, background: { added: red, removed: red } }, + }, + syntax: { + comment: red, + keyword: red, + function: red, + variable: red, + string: red, + number: red, + type: red, + operator: red, + punctuation: red, + }, + markdown: { + text: red, + heading: red, + link: red, + linkText: red, + code: red, + blockQuote: red, + emphasis: red, + strong: red, + horizontalRule: red, + listItem: red, + listEnumeration: red, + image: red, + imageText: red, + codeBlock: red, + }, + } +} diff --git a/packages/tui/src/theme/v2/index.ts b/packages/theme/src/tui/index.ts similarity index 74% rename from packages/tui/src/theme/v2/index.ts rename to packages/theme/src/tui/index.ts index b6cfbb98f5..0ad7eab5ce 100644 --- a/packages/tui/src/theme/v2/index.ts +++ b/packages/theme/src/tui/index.ts @@ -29,7 +29,7 @@ export { type ContextKey, type TextDefinition, type ThemeTokensDefinition, -} from "./schema" +} from "./schema.js" export type { Categorical, @@ -42,7 +42,9 @@ export type { ResolvedTheme, ResolvedThemeView, StatefulColor, -} from "./types" -export { DEFAULT_CATEGORICAL } from "./defaults" -export { migrateV1 } from "./v1-migrate" -export { selectTheme, selectThemeMode, supportsThemeMode, themeModes } from "./select" +} from "./types.js" +export { DEFAULT_CATEGORICAL, DEFAULT_THEME } from "./defaults.js" +export { migrateV1 } from "./v1-migrate.js" +export { resolveTheme, resolveThemeDocument, themeDecodeError } from "./resolve.js" +export { selectTheme, selectThemeMode, supportsThemeMode, themeModes } from "./select.js" +export { generateSyntax } from "./syntax.js" diff --git a/packages/tui/src/theme/v2/resolve.ts b/packages/theme/src/tui/resolve.ts similarity index 97% rename from packages/tui/src/theme/v2/resolve.ts rename to packages/theme/src/tui/resolve.ts index 500f853dbd..4443e88e5d 100644 --- a/packages/tui/src/theme/v2/resolve.ts +++ b/packages/theme/src/tui/resolve.ts @@ -1,8 +1,8 @@ import { RGBA } from "@opentui/core" import { Schema } from "effect" -import { DEFAULT_CATEGORICAL, DEFAULT_THEME } from "./defaults" -import { expandTheme, expandTokens, mergeTheme } from "./expand" -import { fallback } from "./fallback" +import { DEFAULT_CATEGORICAL, DEFAULT_THEME } from "./defaults.js" +import { expandTheme, expandTokens, mergeTheme } from "./expand.js" +import { fallback } from "./fallback.js" import { ActionState, ActionVariant, @@ -12,7 +12,7 @@ import { HueStep, ThemeDefinition, ThemeDocument, -} from "./schema" +} from "./schema.js" import type { ActionStateKey, HueDefinition, @@ -22,8 +22,8 @@ import type { ResolvedThemeView, StatefulColorDefinition, ThemeTokensDefinition, -} from "./index" -import { selectTheme, selectThemeMode } from "./select" +} from "./index.js" +import { selectTheme, selectThemeMode } from "./select.js" const decodeThemeDefinitionSchema = Schema.decodeUnknownSync(ThemeDefinition) diff --git a/packages/tui/src/theme/v2/schema.ts b/packages/theme/src/tui/schema.ts similarity index 100% rename from packages/tui/src/theme/v2/schema.ts rename to packages/theme/src/tui/schema.ts diff --git a/packages/tui/src/theme/v2/select.ts b/packages/theme/src/tui/select.ts similarity index 96% rename from packages/tui/src/theme/v2/select.ts rename to packages/theme/src/tui/select.ts index 17d47488fa..261e6c3a6d 100644 --- a/packages/tui/src/theme/v2/select.ts +++ b/packages/theme/src/tui/select.ts @@ -1,4 +1,4 @@ -import { expandTheme, mergeTheme } from "./expand" +import { expandTheme, mergeTheme } from "./expand.js" import type { FileThemeDefinition, MergeModeDefinition, @@ -6,7 +6,7 @@ import type { ModeDefinition, ThemeDefinition, ThemeDocument, -} from "./index" +} from "./index.js" export function selectTheme( document: ThemeDocument & { light: ThemeDefinition; dark: ThemeDefinition }, diff --git a/packages/tui/src/theme/v2/syntax.ts b/packages/theme/src/tui/syntax.ts similarity index 98% rename from packages/tui/src/theme/v2/syntax.ts rename to packages/theme/src/tui/syntax.ts index 4a8917f089..6330c9913a 100644 --- a/packages/tui/src/theme/v2/syntax.ts +++ b/packages/theme/src/tui/syntax.ts @@ -1,5 +1,5 @@ import { SyntaxStyle, type RGBA, type ThemeTokenStyle } from "@opentui/core" -import type { Mode, ResolvedThemeView } from "./index" +import type { Mode, ResolvedThemeView } from "./index.js" export function generateSyntax(theme: ResolvedThemeView, mode: Mode) { const step = mode === "light" ? 800 : 200 diff --git a/packages/tui/src/theme/v2/types.ts b/packages/theme/src/tui/types.ts similarity index 99% rename from packages/tui/src/theme/v2/types.ts rename to packages/theme/src/tui/types.ts index ff21ccfa60..66e9333a9a 100644 --- a/packages/tui/src/theme/v2/types.ts +++ b/packages/theme/src/tui/types.ts @@ -9,7 +9,7 @@ import type { MarkdownToken, ContextKey, SyntaxToken, -} from "./schema" +} from "./schema.js" export type ResolvedActionState = "default" | ActionState export type ResolvedFormfieldState = ResolvedActionState diff --git a/packages/tui/src/theme/v2/v1-migrate.ts b/packages/theme/src/tui/v1-migrate.ts similarity index 98% rename from packages/tui/src/theme/v2/v1-migrate.ts rename to packages/theme/src/tui/v1-migrate.ts index ac81ee4f5c..1269f63145 100644 --- a/packages/tui/src/theme/v2/v1-migrate.ts +++ b/packages/theme/src/tui/v1-migrate.ts @@ -1,9 +1,9 @@ import { RGBA } from "@opentui/core" -import { oklchToHex, rgbToOklch } from "@opencode-ai/ui/theme/color" -import type { Theme, ThemeV1Json } from "../v1" -import { DEFAULT_CATEGORICAL, DEFAULT_THEME } from "./defaults" -import type { FileThemeDefinition, Mode, ThemeDocument } from "./index" -import { HueStep } from "./schema" +import { oklchToHex, rgbToOklch } from "./color.js" +import { DEFAULT_CATEGORICAL, DEFAULT_THEME } from "./defaults.js" +import type { FileThemeDefinition, Mode, ThemeDocument } from "./index.js" +import { HueStep } from "./schema.js" +import type { Theme, ThemeV1Json } from "./v1.js" type ThemeColor = Exclude type ChromaticHue = "red" | "orange" | "yellow" | "green" | "cyan" | "blue" | "purple" diff --git a/packages/theme/src/tui/v1.ts b/packages/theme/src/tui/v1.ts new file mode 100644 index 0000000000..4765630edf --- /dev/null +++ b/packages/theme/src/tui/v1.ts @@ -0,0 +1,76 @@ +import type { RGBA } from "@opentui/core" + +export type Theme = { + readonly primary: RGBA + readonly secondary: RGBA + readonly accent: RGBA + readonly error: RGBA + readonly warning: RGBA + readonly success: RGBA + readonly info: RGBA + readonly text: RGBA + readonly textMuted: RGBA + readonly selectedListItemText: RGBA + readonly background: RGBA + readonly backgroundPanel: RGBA + readonly backgroundElement: RGBA + readonly backgroundMenu: RGBA + readonly border: RGBA + readonly borderActive: RGBA + readonly borderSubtle: RGBA + readonly diffAdded: RGBA + readonly diffRemoved: RGBA + readonly diffContext: RGBA + readonly diffHunkHeader: RGBA + readonly diffHighlightAdded: RGBA + readonly diffHighlightRemoved: RGBA + readonly diffAddedBg: RGBA + readonly diffRemovedBg: RGBA + readonly diffContextBg: RGBA + readonly diffLineNumber: RGBA + readonly diffAddedLineNumberBg: RGBA + readonly diffRemovedLineNumberBg: RGBA + readonly markdownText: RGBA + readonly markdownHeading: RGBA + readonly markdownLink: RGBA + readonly markdownLinkText: RGBA + readonly markdownCode: RGBA + readonly markdownBlockQuote: RGBA + readonly markdownEmph: RGBA + readonly markdownStrong: RGBA + readonly markdownHorizontalRule: RGBA + readonly markdownListItem: RGBA + readonly markdownListEnumeration: RGBA + readonly markdownImage: RGBA + readonly markdownImageText: RGBA + readonly markdownCodeBlock: RGBA + readonly syntaxComment: RGBA + readonly syntaxKeyword: RGBA + readonly syntaxFunction: RGBA + readonly syntaxVariable: RGBA + readonly syntaxString: RGBA + readonly syntaxNumber: RGBA + readonly syntaxType: RGBA + readonly syntaxOperator: RGBA + readonly syntaxPunctuation: RGBA + readonly thinkingOpacity: number + _hasSelectedListItemText: boolean +} + +export type ThemeColor = Exclude +export type HexColor = `#${string}` +export type RefName = string +export type Variant = { + dark: HexColor | RefName + light: HexColor | RefName +} +export type ColorValue = HexColor | RefName | Variant | RGBA | number +export type ThemeV1Json = { + $schema?: string + defs?: Record + theme: Omit, "selectedListItemText" | "backgroundMenu"> & { + selectedListItemText?: ColorValue + backgroundMenu?: ColorValue + thinkingOpacity?: number + } +} diff --git a/packages/theme/sst-env.d.ts b/packages/theme/sst-env.d.ts new file mode 100644 index 0000000000..f25b971455 --- /dev/null +++ b/packages/theme/sst-env.d.ts @@ -0,0 +1,10 @@ +/* This file is auto-generated by SST. Do not edit. */ +/* tslint:disable */ +/* eslint-disable */ +/* deno-fmt-ignore-file */ +/* biome-ignore-all lint: auto-generated */ + +/// + +import "sst" +export {} diff --git a/packages/theme/tsconfig.build.json b/packages/theme/tsconfig.build.json new file mode 100644 index 0000000000..e235ae78cf --- /dev/null +++ b/packages/theme/tsconfig.build.json @@ -0,0 +1,11 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "extends": "./tsconfig.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "dist", + "noEmit": false, + "declaration": true + }, + "include": ["src"] +} diff --git a/packages/theme/tsconfig.json b/packages/theme/tsconfig.json new file mode 100644 index 0000000000..189db6f3e9 --- /dev/null +++ b/packages/theme/tsconfig.json @@ -0,0 +1,12 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "extends": "@tsconfig/bun/tsconfig.json", + "compilerOptions": { + "lib": ["ESNext", "DOM", "DOM.Iterable"], + "module": "NodeNext", + "moduleResolution": "NodeNext", + "allowImportingTsExtensions": false, + "allowJs": false, + "noUncheckedIndexedAccess": false + } +} diff --git a/packages/tui/package.json b/packages/tui/package.json index 0fb182f8d3..95945eb5da 100644 --- a/packages/tui/package.json +++ b/packages/tui/package.json @@ -83,6 +83,7 @@ "@opencode-ai/plugin": "workspace:*", "@opencode-ai/schema": "workspace:*", "@opencode-ai/simulation": "workspace:*", + "@opencode-ai/theme": "workspace:*", "@opencode-ai/ui": "workspace:*", "@opencode-ai/util": "workspace:*", "@opentui/core": "catalog:", diff --git a/packages/tui/src/app.tsx b/packages/tui/src/app.tsx index 91b286e798..73a44854d1 100644 --- a/packages/tui/src/app.tsx +++ b/packages/tui/src/app.tsx @@ -88,6 +88,7 @@ import { DialogVariant } from "./component/dialog-variant" import { win32DisableProcessedInput, win32FlushInputBuffer } from "./terminal-win32" import { destroyRenderer } from "./util/renderer" import { cliErrorMessage, errorFormat } from "./util/error" +import { AttentionProvider } from "./context/attention" registerOpencodeSpinner() @@ -346,18 +347,20 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) { - - - + + + + + @@ -1124,10 +1127,7 @@ function App(props: { pair?: DialogPairCredentials }) { - - - - + diff --git a/packages/tui/src/context/attention.tsx b/packages/tui/src/context/attention.tsx new file mode 100644 index 0000000000..525d620517 --- /dev/null +++ b/packages/tui/src/context/attention.tsx @@ -0,0 +1,24 @@ +import type { Attention } from "@opencode-ai/plugin/tui/context" +import { useRenderer } from "@opentui/solid" +import { createContext, onCleanup, useContext, type ParentProps } from "solid-js" +import { createTuiAttention } from "../attention" +import { useConfig } from "../config" + +const AttentionContext = createContext() + +export function AttentionProvider(props: ParentProps) { + const config = useConfig() + const attention = createTuiAttention({ + renderer: useRenderer(), + config: config.data, + update: config.update, + }) + onCleanup(() => attention.dispose()) + return {props.children} +} + +export function useAttention() { + const attention = useContext(AttentionContext) + if (!attention) throw new Error("AttentionProvider is missing") + return attention +} diff --git a/packages/tui/src/context/keymap.tsx b/packages/tui/src/context/keymap.tsx index e29c6fa2f3..a7a28efa12 100644 --- a/packages/tui/src/context/keymap.tsx +++ b/packages/tui/src/context/keymap.tsx @@ -1,4 +1,4 @@ -import type { KeymapCommand, KeymapLayer } from "@opencode-ai/plugin/tui/context" +import type { KeymapActive, KeymapCommand, KeymapLayer, KeymapPending } from "@opencode-ai/plugin/tui/context" import { InputRenderable, TextareaRenderable, type KeyEvent, type Renderable } from "@opentui/core" import { stringifyKeyStroke, type Binding, type CommandContext } from "@opentui/keymap" import { @@ -255,13 +255,19 @@ function useShortcuts() { const commands = keymap.getCommands({ visibility: "registered" }).map((command) => command.name) const bindings = keymap.getCommandBindings({ visibility: "registered", commands }) return new Map( - commands.map((id) => [ - id, - { - first: formatKeySequence(bindings.get(id)?.[0]?.sequence, formatOptions(value.config)), - all: formatCommandBindings(bindings.get(id) ?? [], formatOptions(value.config)), - }, - ]), + commands.map((id) => { + const current = bindings.get(id) ?? [] + return [ + id, + { + first: formatKeySequence(current[0]?.sequence, formatOptions(value.config)), + all: formatCommandBindings(current, formatOptions(value.config)), + list: current + .map((binding) => formatKeySequence(binding.sequence, formatOptions(value.config))) + .filter((shortcut): shortcut is string => shortcut !== undefined), + }, + ] + }), ) }) return { @@ -271,6 +277,9 @@ function useShortcuts() { all(id: string) { return shortcuts().get(id)?.all }, + list(id: string) { + return shortcuts().get(id)?.list ?? [] + }, } } @@ -328,6 +337,41 @@ function useActiveKeys() { return useKeymapSelector((keymap) => keymap.getActiveKeys({ includeMetadata: true })) } +function useState() { + const value = useValue() + const commands = useCommands() + const pending = usePendingSequence() + const active = useActiveKeys() + return { + commands, + pending: (): readonly KeymapPending[] => + pending().map((item) => ({ + key: formatKeySequence([item], formatOptions(value.config)) ?? "", + ...(item.tokenName ? { token: item.tokenName } : {}), + })), + active: (): readonly KeymapActive[] => + active().map((item) => ({ + key: + formatKeySequence( + [{ stroke: item.stroke, display: item.display, tokenName: item.tokenName }], + formatOptions(value.config), + ) ?? "", + ...(typeof item.commandAttrs?.title === "string" ? { title: item.commandAttrs.title } : {}), + ...(typeof item.bindingAttrs?.desc === "string" + ? { description: item.bindingAttrs.desc } + : typeof item.commandAttrs?.desc === "string" + ? { description: item.commandAttrs.desc } + : {}), + ...(typeof item.commandAttrs?.category === "string" + ? { group: item.commandAttrs.category } + : typeof item.bindingAttrs?.group === "string" + ? { group: item.bindingAttrs.group } + : {}), + continues: item.continues, + })), + } +} + function useValue() { const value = useContext(Context) if (!value) throw new Error("Keymap.Provider is missing") @@ -344,6 +388,7 @@ export const Keymap = { useCommands, usePendingSequence, useActiveKeys, + useState, } as const function createMode(keymap: OpenTuiKeymap) { diff --git a/packages/tui/src/context/theme.tsx b/packages/tui/src/context/theme.tsx index 9b53e8d07a..699653a51a 100644 --- a/packages/tui/src/context/theme.tsx +++ b/packages/tui/src/context/theme.tsx @@ -1,5 +1,6 @@ import { CliRenderEvents, SyntaxStyle, type TerminalColors } from "@opentui/core" import { useRenderer } from "@opentui/solid" +import { generateSyntax, resolveThemeDocument, themeModes } from "@opencode-ai/theme/tui" import { DEFAULT_THEMES, addTheme, @@ -14,12 +15,9 @@ import { type Theme, type ThemeDocumentSource, } from "../theme" -import { generateSyntax } from "../theme/v2/syntax" import { generateSystem, terminalMode } from "../theme/system" import { discoverThemes, themeDirectories } from "../theme/discovery" -import { createComponentTheme, type ComponentTheme } from "../theme/v2/component" -import { resolveThemeDocument } from "../theme/v2/resolve" -import { themeModes } from "../theme/v2/select" +import { createComponentTheme, type ComponentTheme } from "../theme/component" import { createEffect, createMemo, onCleanup, onMount, type Accessor, type ParentProps } from "solid-js" import { createStore, produce } from "solid-js/store" import { createSimpleContext } from "./helper" diff --git a/packages/tui/src/feature-plugins/builtins.ts b/packages/tui/src/feature-plugins/builtins.ts index e3c1783f58..0a5af4cd54 100644 --- a/packages/tui/src/feature-plugins/builtins.ts +++ b/packages/tui/src/feature-plugins/builtins.ts @@ -1,6 +1,5 @@ import type { TuiPlugin, TuiPluginApi, TuiPluginModule } from "@opencode-ai/plugin/v1/tui" import type { PluginRuntime } from "../plugin/runtime" -import Notifications from "./system/notifications" import PluginManager from "./system/plugins" import WhichKey from "./system/which-key" @@ -11,7 +10,7 @@ export type BuiltinTuiPlugin = Omit & { } export function createBuiltinPlugins(): BuiltinTuiPlugin[] { - return [Notifications, PluginManager, WhichKey] + return [PluginManager, WhichKey] } export async function loadBuiltinPlugins(api: TuiPluginApi, runtime: PluginRuntime) { diff --git a/packages/tui/src/feature-plugins/home/footer.tsx b/packages/tui/src/feature-plugins/home/footer.tsx index 60623ef61b..6042e6e087 100644 --- a/packages/tui/src/feature-plugins/home/footer.tsx +++ b/packages/tui/src/feature-plugins/home/footer.tsx @@ -1,28 +1,22 @@ import { Plugin } from "@opencode-ai/plugin/tui" import { createMemo, Match, Show, Switch } from "solid-js" import { useTerminalDimensions } from "@opentui/solid" -import { useTuiApp, useTuiPaths } from "../../context/runtime" -import { useTheme } from "../../context/theme" -import { abbreviateHome } from "../../runtime" import { FilePath } from "../../ui/file-path" import { stringWidth } from "../../util/string-width" function Directory(props: { context: Plugin.Context; maxWidth: number }) { - const { themeV2 } = useTheme() - const paths = useTuiPaths() const directory = createMemo(() => - props.context.location ? abbreviateHome(props.context.location.directory, paths.home) : undefined, + props.context.location ? props.context.ui.format.path(props.context.location.directory) : undefined, ) return ( - {(value) => } + {(value) => } ) } function Mcp(props: { context: Plugin.Context }) { - const { themeV2 } = useTheme() const list = createMemo(() => props.context.data.location.mcp.server.list(props.context.location) ?? []) const failed = createMemo(() => list().some((item) => item.status.status === "failed")) const count = createMemo(() => list().filter((item) => item.status.status === "connected").length) @@ -30,26 +24,33 @@ function Mcp(props: { context: Plugin.Context }) { return ( - + - + - 0 ? themeV2.text.feedback.success.default : themeV2.text.subdued }}>⊙ + 0 + ? props.context.theme.themeV2.text.feedback.success.default + : props.context.theme.themeV2.text.subdued, + }} + > + ⊙{" "} + {count()} MCP - /status + /status ) } function View(props: { context: Plugin.Context }) { - const { themeV2 } = useTheme() - const app = useTuiApp() const dimensions = useTerminalDimensions() const mcpWidth = createMemo(() => { const list = props.context.data.location.mcp.server.list(props.context.location) ?? [] @@ -71,12 +72,12 @@ function View(props: { context: Plugin.Context }) { > - {app.version} + {props.context.app.version} ) diff --git a/packages/tui/src/feature-plugins/sidebar/footer.tsx b/packages/tui/src/feature-plugins/sidebar/footer.tsx index adde20e115..65ff8fecdc 100644 --- a/packages/tui/src/feature-plugins/sidebar/footer.tsx +++ b/packages/tui/src/feature-plugins/sidebar/footer.tsx @@ -1,18 +1,15 @@ import { Plugin } from "@opencode-ai/plugin/tui" import { createMemo, Show } from "solid-js" -import { useTuiPaths } from "../../context/runtime" -import { useTheme } from "../../context/theme" -import { abbreviateHome } from "../../runtime" import { FilePath } from "../../ui/file-path" function View(props: { context: Plugin.Context }) { - const { themeV2 } = useTheme() - const paths = useTuiPaths() const directory = createMemo(() => - props.context.location ? abbreviateHome(props.context.location.directory, paths.home) : undefined, + props.context.location ? props.context.ui.format.path(props.context.location.directory) : undefined, ) return ( - {(value) => } + + {(value) => } + ) } diff --git a/packages/tui/src/feature-plugins/system/diff-viewer.tsx b/packages/tui/src/feature-plugins/system/diff-viewer.tsx index d7dbbd957e..15cc5860be 100644 --- a/packages/tui/src/feature-plugins/system/diff-viewer.tsx +++ b/packages/tui/src/feature-plugins/system/diff-viewer.tsx @@ -16,7 +16,6 @@ import path from "path" import { createEffect, createMemo, createResource, createSignal, For, Match, onCleanup, Show, Switch } from "solid-js" import { DiffViewerFileTree } from "./diff-viewer-file-tree" import { Panel, PanelGroup, Separator } from "./diff-viewer-ui" -import { useDialog } from "../../ui/dialog" import { DialogSelect } from "../../ui/dialog-select" import { getScrollAcceleration } from "../../util/scroll" import { useConfig } from "../../config" @@ -83,7 +82,7 @@ function diffSourceLabel(mode: DiffMode) { function DiffViewer(props: { context: Plugin.Context }) { const dimensions = useTerminalDimensions() const config = useConfig() - const dialog = useDialog() + const dialog = props.context.ui.dialog const themeState = useTheme() const themeV2 = themeState.themeV2 const params = () => { @@ -141,7 +140,7 @@ function DiffViewer(props: { context: Plugin.Context }) { const fileRows = createMemo(() => flattenFileTree(fileTree(), expandedFileNodes())) const patchFileIndexes = createMemo(() => orderedPatchFileIndexes(flattenFileTree(fileTree()))) const focusRunner = (input: Record void>) => () => input[focus()]() - const shortcut = (id: string) => () => props.context.keymap.shortcut(id) + const shortcut = (id: string) => () => props.context.keymap.shortcuts(id)[0] const switchFocusShortcut = shortcut("diff.switch_focus") const nextHunkShortcut = shortcut("diff.next_hunk") const previousHunkShortcut = shortcut("diff.previous_hunk") @@ -703,7 +702,7 @@ function DiffViewer(props: { context: Plugin.Context }) { }) const openSwitchDiffDialog = () => { - dialog.replace(() => ( + dialog.show(() => ( ({ ...option, - onSelect(dialog) { + onSelect() { dialog.clear() props.context.ui.router.navigate({ type: "plugin", @@ -729,8 +728,8 @@ function DiffViewer(props: { context: Plugin.Context }) { } const openHelpDialog = () => { - dialog.replace(() => ) - dialog.setSize("large") + dialog.show(() => ) + dialog.set({ size: "large" }) } props.context.keymap.layer(() => ({ @@ -952,7 +951,7 @@ function DiffViewer(props: { context: Plugin.Context }) { function DiffViewerHelpDialog(props: { context: Plugin.Context }) { const { themeV2 } = useTheme().contextual("elevated") - const shortcut = (id: string) => () => props.context.keymap.shortcut(id) + const shortcut = (id: string) => () => props.context.keymap.shortcuts(id)[0] const rows = [ { shortcut: () => "q", @@ -1051,7 +1050,6 @@ function DiffViewerHelpDialog(props: { context: Plugin.Context }) { } function Commands(props: { context: Plugin.Context }) { - const dialog = useDialog() props.context.keymap.layer(() => ({ mode: "global", commands: [ @@ -1083,7 +1081,7 @@ function Commands(props: { context: Plugin.Context }) { returnRoute, }, }) - dialog.clear() + props.context.ui.dialog.clear() }, }, ], diff --git a/packages/tui/src/feature-plugins/system/notifications.ts b/packages/tui/src/feature-plugins/system/notifications.ts index 6592b843b9..fd5d35b9b1 100644 --- a/packages/tui/src/feature-plugins/system/notifications.ts +++ b/packages/tui/src/feature-plugins/system/notifications.ts @@ -1,21 +1,19 @@ +import { Plugin } from "@opencode-ai/plugin/tui" +import type { AttentionSoundName } from "@opencode-ai/plugin/tui/context" import type { OpenCodeEvent } from "@opencode-ai/client" -import type { TuiAttentionSoundName, TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/v1/tui" -import type { BuiltinTuiPlugin } from "../builtins" - -const id = "internal:notifications" type SessionError = Extract["data"]["error"] function notify( - api: TuiPluginApi, + context: Plugin.Context, sessionID: string | undefined, message: string, - sound: TuiAttentionSoundName, + sound: AttentionSoundName, title?: string, ) { - const session = sessionID ? api.state.session.get(sessionID) : undefined + const session = sessionID ? context.data.session.get(sessionID) : undefined const isSubagent = session?.parentID !== undefined - void api.attention.notify({ + void context.attention.notify({ title: title ?? session?.title, message, notification: isSubagent ? false : { when: "blurred" }, @@ -32,101 +30,74 @@ function sessionErrorMessage(error: SessionError) { return "Session error" } -const tui: TuiPlugin = async (api) => { - const errored = new Set() - const terminal = new Set() - const forms = new Set() - const questions = new Set() - const permissions = new Set() +export default Plugin.define({ + id: "opencode.notifications", + setup(context) { + const errored = new Set() + const terminal = new Set() + const forms = new Set() + const questions = new Set() + const permissions = new Set() - api.event.on("form.created", (event) => { - if (forms.has(event.data.form.id)) return - forms.add(event.data.form.id) - notify( - api, - event.data.form.sessionID, - "Input needs response", - "question", - event.data.form.title, - ) - }) - - api.event.on("form.replied", (event) => { - forms.delete(event.data.id) - }) - - api.event.on("form.cancelled", (event) => { - forms.delete(event.data.id) - }) - - api.event.on("question.asked", (event) => { - if (questions.has(event.data.id)) return - questions.add(event.data.id) - notify(api, event.data.sessionID, "Question needs input", "question") - }) - - api.event.on("question.replied", (event) => { - questions.delete(event.data.requestID) - }) - - api.event.on("question.rejected", (event) => { - questions.delete(event.data.requestID) - }) - - api.event.on("permission.asked", (event) => { - if (permissions.has(event.data.id)) return - permissions.add(event.data.id) - notify(api, event.data.sessionID, "Permission needs input", "permission") - }) - - api.event.on("permission.replied", (event) => { - permissions.delete(event.data.requestID) - }) - - const started = (sessionID: string) => { - errored.delete(sessionID) - terminal.delete(sessionID) - } - - const ended = (sessionID: string) => { - if (terminal.has(sessionID)) return - terminal.add(sessionID) - if (errored.has(sessionID)) { + const started = (sessionID: string) => { errored.delete(sessionID) - return + terminal.delete(sessionID) + } + const ended = (sessionID: string) => { + if (terminal.has(sessionID)) return + terminal.add(sessionID) + if (errored.has(sessionID)) { + errored.delete(sessionID) + return + } + const session = context.data.session.get(sessionID) + notify(context, sessionID, "Session done", session?.parentID ? "subagent_done" : "done") } - const session = api.state.session.get(sessionID) - notify(api, sessionID, "Session done", session?.parentID ? "subagent_done" : "done") - } + const dispose = [ + context.data.on("form.created", (event) => { + if (forms.has(event.data.form.id)) return + forms.add(event.data.form.id) + notify(context, event.data.form.sessionID, "Input needs response", "question", event.data.form.title) + }), + context.data.on("form.replied", (event) => forms.delete(event.data.id)), + context.data.on("form.cancelled", (event) => forms.delete(event.data.id)), + context.data.on("question.asked", (event) => { + if (questions.has(event.data.id)) return + questions.add(event.data.id) + notify(context, event.data.sessionID, "Question needs input", "question") + }), + context.data.on("question.replied", (event) => questions.delete(event.data.requestID)), + context.data.on("question.rejected", (event) => questions.delete(event.data.requestID)), + context.data.on("permission.asked", (event) => { + if (permissions.has(event.data.id)) return + permissions.add(event.data.id) + notify(context, event.data.sessionID, "Permission needs input", "permission") + }), + context.data.on("permission.replied", (event) => permissions.delete(event.data.requestID)), + context.data.on("session.execution.started", (event) => started(event.data.sessionID)), + context.data.on("session.execution.succeeded", (event) => ended(event.data.sessionID)), + context.data.on("session.execution.interrupted", (event) => ended(event.data.sessionID)), + context.data.on("session.execution.failed", (event) => { + const sessionID = event.data.sessionID + if (errored.has(sessionID)) { + ended(sessionID) + return + } + errored.add(sessionID) + notify(context, sessionID, event.data.error.message, "error") + ended(sessionID) + }), + context.data.on("session.error", (event) => { + const sessionID = event.data.sessionID + if (!sessionID) return + if (context.data.session.status(sessionID) !== "running") return + if (errored.has(sessionID)) return + errored.add(sessionID) + notify(context, sessionID, sessionErrorMessage(event.data.error), "error") + }), + ] - api.event.on("session.execution.started", (event) => started(event.data.sessionID)) - api.event.on("session.execution.succeeded", (event) => ended(event.data.sessionID)) - api.event.on("session.execution.interrupted", (event) => ended(event.data.sessionID)) - api.event.on("session.execution.failed", (event) => { - const sessionID = event.data.sessionID - if (errored.has(sessionID)) { - ended(sessionID) - return - } - errored.add(sessionID) - notify(api, sessionID, event.data.error.message, "error") - ended(sessionID) - }) - - api.event.on("session.error", (event) => { - const sessionID = event.data.sessionID - if (!sessionID) return - if (api.state.session.status(sessionID)?.type !== "busy") return - if (errored.has(sessionID)) return - errored.add(sessionID) - notify(api, sessionID, sessionErrorMessage(event.data.error), "error") - }) -} - -const plugin: BuiltinTuiPlugin = { - id, - tui, -} - -export default plugin + return () => dispose.reverse().forEach((cleanup) => cleanup()) + }, +}) diff --git a/packages/tui/src/feature-plugins/system/scrap.tsx b/packages/tui/src/feature-plugins/system/scrap.tsx index 1a21a4ca51..9c2921e77d 100644 --- a/packages/tui/src/feature-plugins/system/scrap.tsx +++ b/packages/tui/src/feature-plugins/system/scrap.tsx @@ -1,12 +1,9 @@ import { Plugin } from "@opencode-ai/plugin/tui" import { useTerminalDimensions } from "@opentui/solid" -import { Keymap } from "../../context/keymap" import { useTheme } from "../../context/theme" -import { useDialog } from "../../ui/dialog" function Commands(props: { context: Plugin.Context }) { - const dialog = useDialog() - Keymap.createLayer(() => ({ + props.context.keymap.layer(() => ({ mode: "global", commands: [ { @@ -16,7 +13,7 @@ function Commands(props: { context: Plugin.Context }) { palette: true, run() { props.context.ui.router.navigate({ type: "plugin", name: "scrap" }) - dialog.clear() + props.context.ui.dialog.clear() }, }, ], @@ -29,7 +26,7 @@ function Scrap(props: { context: Plugin.Context }) { const { themeV2 } = useTheme() const { themeV2: elevatedTheme } = useTheme().contextual("elevated") - Keymap.createLayer(() => ({ + props.context.keymap.layer(() => ({ commands: [ { bind: "escape", diff --git a/packages/tui/src/plugin/builtins.ts b/packages/tui/src/plugin/builtins.ts index 4e9040ce6e..5c2cb7cbc3 100644 --- a/packages/tui/src/plugin/builtins.ts +++ b/packages/tui/src/plugin/builtins.ts @@ -4,6 +4,7 @@ import SidebarFooter from "../feature-plugins/sidebar/footer" import SidebarLsp from "../feature-plugins/sidebar/lsp" import SidebarMcp from "../feature-plugins/sidebar/mcp" import DiffViewer from "../feature-plugins/system/diff-viewer" +import Notifications from "../feature-plugins/system/notifications" import Scrap from "../feature-plugins/system/scrap" export const builtins = [ @@ -12,6 +13,7 @@ export const builtins = [ SidebarMcp, SidebarLsp, SidebarFooter, + Notifications, Scrap, DiffViewer, ] diff --git a/packages/tui/src/plugin/context.tsx b/packages/tui/src/plugin/context.tsx index a42434b5d6..4034348d50 100644 --- a/packages/tui/src/plugin/context.tsx +++ b/packages/tui/src/plugin/context.tsx @@ -13,15 +13,25 @@ import { import path from "path" import { stat } from "fs/promises" import { fileURLToPath, pathToFileURL } from "url" -import type { Context, Page, Slot } from "@opencode-ai/plugin/tui/context" +import type { Context, Dialog, Page, Slot, SlotMap, SlotName, Toast } from "@opencode-ai/plugin/tui/context" import { createStore, produce, reconcile as reconcileStore } from "solid-js/store" +import { useRenderer } from "@opentui/solid" import { useConfig } from "../config" import { useClient } from "../context/client" import { useData } from "../context/data" import { Keymap } from "../context/keymap" import { useRoute } from "../context/route" -import { useTuiLifecycle } from "../context/runtime" +import { useTuiApp, useTuiLifecycle, useTuiPaths } from "../context/runtime" import { useLocation } from "../context/location" +import { useTheme } from "../context/theme" +import { DialogAlert } from "../ui/dialog-alert" +import { DialogConfirm } from "../ui/dialog-confirm" +import { DialogPrompt } from "../ui/dialog-prompt" +import { DialogSelect } from "../ui/dialog-select" +import { useDialog } from "../ui/dialog" +import { useToast } from "../ui/toast" +import { useAttention } from "../context/attention" +import { abbreviateHome } from "../util/path-format" import { builtins } from "./builtins" export interface PackageResolver { @@ -38,7 +48,7 @@ type Value = { readonly ready: () => boolean readonly list: () => ReadonlyArray readonly route: (id: string, name: string) => Page["render"] | undefined - readonly slot: (name: string) => ReadonlyArray + readonly slot: (name: Name) => ReadonlyArray> readonly activate: (id: string) => Promise readonly deactivate: (id: string) => Promise } @@ -57,14 +67,22 @@ type Registration = { const PluginContext = createContext() export function PluginProvider(props: ParentProps<{ packages: PackageResolver }>) { + const renderer = useRenderer() const client = useClient() const data = useData() const route = useRoute() const config = useConfig() const keymap = Keymap.use() const shortcuts = Keymap.useShortcuts() + const keymapState = Keymap.useState() const lifecycle = useTuiLifecycle() + const app = useTuiApp() + const paths = useTuiPaths() const location = useLocation() + const theme = useTheme() + const dialog = useDialog() + const toast = useToast() + const attention = useAttention() const directory = config.path ? path.dirname(config.path) : process.cwd() const [store, setStore] = createStore({ ready: false, @@ -82,20 +100,147 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver }> setStore("registrations", id, "cleanups", []) }) const owned: Dispose[] = [] + let activeDialog: symbol | undefined + const dialogApi: Dialog = { + show(render, onClose) { + const token = Symbol() + let closed = false + activeDialog = token + dialog.replace(render, () => { + if (closed) return + closed = true + if (activeDialog === token) activeDialog = undefined + onClose?.() + }) + return () => { + if (closed || activeDialog !== token) return + dialog.clear() + } + }, + set(options) { + if (!activeDialog) return + dialog.setSize(options.size ?? "medium") + dialog.setCentered(options.centered ?? false) + }, + clear() { + dialog.clear() + }, + alert(options) { + return new Promise((resolve) => { + let settled = false + const done = () => { + if (settled) return + settled = true + resolve() + } + dialogApi.show(() => , done) + }) + }, + confirm(options) { + return new Promise((resolve) => { + let settled = false + const done = (result: boolean | undefined) => { + if (settled) return + settled = true + resolve(result) + } + dialogApi.show( + () => ( + done(true)} + onCancel={() => done(false)} + /> + ), + () => done(undefined), + ) + }) + }, + prompt(options) { + return new Promise((resolve) => { + let settled = false + const done = (result: string | undefined) => { + if (settled) return + settled = true + resolve(result) + } + dialogApi.show( + () => ( + {options.description} : undefined} + placeholder={options.placeholder} + value={options.value} + onConfirm={(value) => { + done(value) + dialogApi.clear() + }} + /> + ), + () => done(undefined), + ) + }) + }, + select(options) { + return new Promise((resolve) => { + let settled = false + const done = (result: (typeof options.options)[number]["value"] | undefined) => { + if (settled) return + settled = true + resolve(result) + } + dialogApi.show( + () => ( + ({ ...option }))} + current={options.current} + onSelect={(option) => { + done(option.value) + dialogApi.clear() + }} + /> + ), + () => done(undefined), + ) + }) + }, + } + const toastApi: Toast = { + show(options) { + toast.show({ ...options, variant: options.variant ?? "info" }) + }, + } + owned.push(async () => dialogApi.clear()) const context: Context = { options: item.options ?? {}, get location() { return location.current }, + app: { version: app.version, channel: app.channel }, + renderer, client: client.api, data, + attention, + theme, keymap: { layer: Keymap.createLayer, dispatch: keymap.dispatch, - shortcut: shortcuts.get, + shortcuts: shortcuts.list, + commands: keymapState.commands, + pending: keymapState.pending, + active: keymapState.active, mode: keymap.mode, }, ui: { + dialog: dialogApi, + toast: toastApi, + format: { + path: (value) => abbreviateHome(value, paths.home), + }, router: { register(page) { if (store.registrations[item.plugin.id]?.routes[page.name]) @@ -391,7 +536,16 @@ export function PluginRoute(props: { readonly fallback: (id: string, name: strin return <>{content()} } -export function PluginSlot(props: { readonly name: string; readonly input?: Record }) { +export function PluginSlot(props: { + readonly name: Name + readonly input: SlotMap[Name] + readonly mode: "all" | "replace" +}) { const plugins = usePlugin() - return {(render) => render(props.input ?? {})} + const renderers = createMemo(() => { + const items = plugins.slot(props.name) + if (props.mode === "replace") return items.slice(-1) + return items + }) + return {(render) => render(props.input)} } diff --git a/packages/tui/src/routes/home.tsx b/packages/tui/src/routes/home.tsx index 92640c56b3..a489c9e43b 100644 --- a/packages/tui/src/routes/home.tsx +++ b/packages/tui/src/routes/home.tsx @@ -85,11 +85,10 @@ export function Home() { /> - - + {(_) => { diff --git a/packages/tui/src/routes/session/footer.tsx b/packages/tui/src/routes/session/footer.tsx index 106cbda253..401db265c9 100644 --- a/packages/tui/src/routes/session/footer.tsx +++ b/packages/tui/src/routes/session/footer.tsx @@ -5,11 +5,13 @@ import { useDirectory } from "../../context/directory" import { useConnected } from "../../component/use-connected" import { createStore } from "solid-js/store" import { useRoute } from "../../context/route" +import { usePermission } from "../../context/permission" export function Footer() { const { themeV2 } = useTheme() const data = useData() const route = useRoute() + const permission = usePermission() const mcp = createMemo( () => (data.location.mcp.server.list() ?? []).filter((x) => x.status.status === "connected").length, ) @@ -61,7 +63,7 @@ export function Footer() { - 0}> + 0}> {permissions().length} Permission {permissions().length > 1 ? "s" : ""} diff --git a/packages/tui/src/routes/session/form.tsx b/packages/tui/src/routes/session/form.tsx index 510bc7b632..5cad287f61 100644 --- a/packages/tui/src/routes/session/form.tsx +++ b/packages/tui/src/routes/session/form.tsx @@ -746,11 +746,7 @@ export function FormPrompt(props: { form: FormWithLocation }) { - - {answerField()!.description ?? formLabel(answerField()!)} - {answerField()!.required ? " (required)" : ""} - {multi() ? " (select all that apply)" : ""} - + {answerField()!.description ?? formLabel(answerField()!)} diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index 917e3a4e83..13683ff08e 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -77,7 +77,6 @@ import { nextThinkingMode, reasoningSummary, type ThinkingMode } from "../../con import { getScrollAcceleration } from "../../util/scroll" import { collapseToolOutput } from "../../util/collapse-tool-output" import { usePluginRuntime } from "../../plugin/runtime" -import { PluginSlot } from "../../plugin/context" import { Keymap, type KeymapCommand } from "../../context/keymap" import { usePathFormatter } from "../../context/path-format" import { useLocation } from "../../context/location" @@ -158,6 +157,7 @@ export function Session() { (sessionID) => data.session.permission.list(sessionID) ?? [], ) }) + const promptedPermissions = createMemo(() => (local.permission.mode === "auto" ? [] : permissions())) const forms = createMemo(() => { const global = data.session.form.list("global", location()) ?? [] if (session()?.parentID) return global @@ -169,7 +169,7 @@ export function Session() { open: false, tab: undefined as string | undefined, }) - const disabled = createMemo(() => permissions().length > 0 || forms().length > 0) + const disabled = createMemo(() => promptedPermissions().length > 0 || forms().length > 0) const pending = createMemo(() => { const completed = messages().findLast((x) => x.type === "assistant" && x.time.completed)?.id @@ -915,7 +915,6 @@ export function Session() { > - (scroll = r)} @@ -960,7 +959,6 @@ export function Session() { - {null} - 0}> - + 0}> + {(_) => { - const request = permissions()[0] + const request = promptedPermissions()[0] return request ? ( ) : null @@ -2327,6 +2325,17 @@ function GenericTool(props: ToolProps) { ) } +function useToolPermission(part: () => SessionMessageAssistantTool | undefined) { + const ctx = use() + const data = useData() + const local = useLocal() + return createMemo(() => { + if (local.permission.mode === "auto") return false + const request = data.session.permission.list(ctx.sessionID)?.[0] + return request?.source?.type === "tool" && request.source.callID === part()?.id + }) +} + function InlineTool(props: { icon: string iconColor?: RGBA @@ -2341,16 +2350,10 @@ function InlineTool(props: { onClick?: () => void }) { const { themeV2 } = useTheme() - const ctx = use() - const data = useData() const renderer = useRenderer() const [hover, setHover] = createSignal(false) const [errorExpanded, setErrorExpanded] = createSignal(false) - - const permission = createMemo(() => { - const request = data.session.permission.list(ctx.sessionID)?.[0] - return request?.source?.type === "tool" && request.source.callID === props.part.id - }) + const permission = useToolPermission(() => props.part) const error = createMemo(() => (props.part.state.status === "error" ? props.part.state.error.message : undefined)) @@ -2529,15 +2532,10 @@ function BlockTool(props: BlockToolProps) { function BlockToolContent(props: BlockToolProps & { borderColor: RGBA }) { const { themeV2 } = useTheme() const ctx = use() - const data = useData() const renderer = useRenderer() const [hover, setHover] = createSignal(false) const error = createMemo(() => (props.part?.state.status === "error" ? props.part.state.error.message : undefined)) - const permission = createMemo(() => { - if (!props.part) return false - const request = data.session.permission.list(ctx.sessionID)?.[0] - return request?.source?.type === "tool" && request.source.callID === props.part.id - }) + const permission = useToolPermission(() => props.part) return ( { - const request = data.session.permission.list(ctx.sessionID)?.[0] - return request?.source?.type === "tool" && request.source.callID === props.part.id - }) + const permission = useToolPermission(() => props.part) const color = createMemo(() => (permission() ? themeV2.text.feedback.warning.default : themeV2.text.default)) const shellID = createMemo(() => stringValue(props.metadata.shellID)) const background = createMemo(() => Boolean(shellID()) && props.part.state.status !== "running") diff --git a/packages/tui/src/routes/session/sidebar.tsx b/packages/tui/src/routes/session/sidebar.tsx index 80a9b209fa..9cdd57c242 100644 --- a/packages/tui/src/routes/session/sidebar.tsx +++ b/packages/tui/src/routes/session/sidebar.tsx @@ -53,12 +53,12 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) { - + - + diff --git a/packages/tui/src/theme/v2/component.ts b/packages/tui/src/theme/component.ts similarity index 94% rename from packages/tui/src/theme/v2/component.ts rename to packages/tui/src/theme/component.ts index 797d4b8b73..28e52658d7 100644 --- a/packages/tui/src/theme/v2/component.ts +++ b/packages/tui/src/theme/component.ts @@ -1,6 +1,6 @@ import type { RGBA } from "@opentui/core" import type { Accessor } from "solid-js" -import type { Mode, ResolvedThemeView } from "./index" +import type { Mode, ResolvedThemeView } from "@opencode-ai/theme/tui" export function createComponentTheme(current: Accessor, mode: Accessor) { return { diff --git a/packages/tui/src/theme/index.ts b/packages/tui/src/theme/index.ts index d128a75539..320b894137 100644 --- a/packages/tui/src/theme/index.ts +++ b/packages/tui/src/theme/index.ts @@ -1,9 +1,7 @@ import { Schema } from "effect" +import { migrateV1, resolveThemeDocument, ThemeDocument, themeDecodeError } from "@opencode-ai/theme/tui" import { resolveThemeColors } from "./resolve" import { DEFAULT_THEMES, type Theme, type ThemeV1Json } from "./v1" -import { resolveThemeDocument, themeDecodeError } from "./v2/resolve" -import { ThemeDocument } from "./v2/schema" -import { migrateV1 } from "./v2/v1-migrate" export { DEFAULT_THEMES, generateSyntax, selectedForeground, type Theme, type ThemeV1Json } from "./v1" export { resolveThemeDocument, type ThemeDocument } diff --git a/packages/tui/src/theme/v1.ts b/packages/tui/src/theme/v1.ts index 5cf1df1b9f..6946f9cf72 100644 --- a/packages/tui/src/theme/v1.ts +++ b/packages/tui/src/theme/v1.ts @@ -1,4 +1,5 @@ import { RGBA, SyntaxStyle } from "@opentui/core" +import type { Theme, ThemeV1Json } from "@opencode-ai/theme/tui/v1" import aura from "./assets/aura.json" with { type: "json" } import ayu from "./assets/ayu.json" with { type: "json" } import carbonfox from "./assets/carbonfox.json" with { type: "json" } @@ -33,80 +34,7 @@ import vercel from "./assets/vercel.json" with { type: "json" } import vesper from "./assets/vesper.json" with { type: "json" } import zenburn from "./assets/zenburn.json" with { type: "json" } -export type Theme = { - readonly primary: RGBA - readonly secondary: RGBA - readonly accent: RGBA - readonly error: RGBA - readonly warning: RGBA - readonly success: RGBA - readonly info: RGBA - readonly text: RGBA - readonly textMuted: RGBA - readonly selectedListItemText: RGBA - readonly background: RGBA - readonly backgroundPanel: RGBA - readonly backgroundElement: RGBA - readonly backgroundMenu: RGBA - readonly border: RGBA - readonly borderActive: RGBA - readonly borderSubtle: RGBA - readonly diffAdded: RGBA - readonly diffRemoved: RGBA - readonly diffContext: RGBA - readonly diffHunkHeader: RGBA - readonly diffHighlightAdded: RGBA - readonly diffHighlightRemoved: RGBA - readonly diffAddedBg: RGBA - readonly diffRemovedBg: RGBA - readonly diffContextBg: RGBA - readonly diffLineNumber: RGBA - readonly diffAddedLineNumberBg: RGBA - readonly diffRemovedLineNumberBg: RGBA - readonly markdownText: RGBA - readonly markdownHeading: RGBA - readonly markdownLink: RGBA - readonly markdownLinkText: RGBA - readonly markdownCode: RGBA - readonly markdownBlockQuote: RGBA - readonly markdownEmph: RGBA - readonly markdownStrong: RGBA - readonly markdownHorizontalRule: RGBA - readonly markdownListItem: RGBA - readonly markdownListEnumeration: RGBA - readonly markdownImage: RGBA - readonly markdownImageText: RGBA - readonly markdownCodeBlock: RGBA - readonly syntaxComment: RGBA - readonly syntaxKeyword: RGBA - readonly syntaxFunction: RGBA - readonly syntaxVariable: RGBA - readonly syntaxString: RGBA - readonly syntaxNumber: RGBA - readonly syntaxType: RGBA - readonly syntaxOperator: RGBA - readonly syntaxPunctuation: RGBA - readonly thinkingOpacity: number - _hasSelectedListItemText: boolean -} - -export type ThemeColor = Exclude -export type HexColor = `#${string}` -export type RefName = string -export type Variant = { - dark: HexColor | RefName - light: HexColor | RefName -} -export type ColorValue = HexColor | RefName | Variant | RGBA | number -export type ThemeV1Json = { - $schema?: string - defs?: Record - theme: Omit, "selectedListItemText" | "backgroundMenu"> & { - selectedListItemText?: ColorValue - backgroundMenu?: ColorValue - thinkingOpacity?: number - } -} +export type { ColorValue, HexColor, RefName, Theme, ThemeColor, ThemeV1Json, Variant } from "@opencode-ai/theme/tui/v1" export const DEFAULT_THEMES: Record = { aura, diff --git a/packages/tui/src/theme/v2/fallback.ts b/packages/tui/src/theme/v2/fallback.ts deleted file mode 100644 index ed4137bc0f..0000000000 --- a/packages/tui/src/theme/v2/fallback.ts +++ /dev/null @@ -1,57 +0,0 @@ -import type { ThemeTokensDefinition } from "./index" -import { ActionVariant, FeedbackKind } from "./schema" - -export function fallback(): ThemeTokensDefinition { - const red = "#ff0000" - - return { - text: { - default: red, - action: Object.fromEntries(ActionVariant.literals.map((variant) => [variant, { default: red }])), - formfield: { default: red }, - feedback: Object.fromEntries(FeedbackKind.literals.map((kind) => [kind, { default: red }])), - }, - background: { - default: red, - surface: { offset: red, overlay: red }, - action: Object.fromEntries(ActionVariant.literals.map((variant) => [variant, { default: red }])), - formfield: { default: red }, - feedback: Object.fromEntries(FeedbackKind.literals.map((kind) => [kind, { default: red }])), - }, - border: { default: red }, - scrollbar: { default: red }, - diff: { - text: { added: red, removed: red, context: red, hunkHeader: red }, - background: { added: red, removed: red, context: red }, - highlight: { added: red, removed: red }, - lineNumber: { text: red, background: { added: red, removed: red } }, - }, - syntax: { - comment: red, - keyword: red, - function: red, - variable: red, - string: red, - number: red, - type: red, - operator: red, - punctuation: red, - }, - markdown: { - text: red, - heading: red, - link: red, - linkText: red, - code: red, - blockQuote: red, - emphasis: red, - strong: red, - horizontalRule: red, - listItem: red, - listEnumeration: red, - image: red, - imageText: red, - codeBlock: red, - }, - } -} diff --git a/packages/tui/src/theme/v2/solid.ts b/packages/tui/src/theme/v2/solid.ts deleted file mode 100644 index d7088d208b..0000000000 --- a/packages/tui/src/theme/v2/solid.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { createComponent, createContext, useContext, type Accessor, type ParentProps } from "solid-js" -import { createComponentTheme, type ComponentTheme } from "./component" -import type { ContextKey, Mode, ResolvedTheme } from "./index" - -type ThemeRuntime = { - readonly resolved: Accessor - readonly mode: Accessor - readonly component: ComponentTheme -} - -const ThemeContext = createContext() - -export function ThemeProvider(props: ParentProps<{ theme: ResolvedTheme; mode?: Mode }>) { - const resolved = () => props.theme - const mode = () => props.mode ?? "light" - return createComponent(ThemeContext.Provider, { - value: { resolved, mode, component: createComponentTheme(resolved, mode) }, - get children() { - return props.children - }, - }) -} - -export function ContextProvider(props: ParentProps<{ context: ContextKey }>) { - const parent = runtime() - const context = () => { - const value = parent.resolved().contexts[props.context] - if (!value) throw new Error(`Theme context is not defined: ${props.context}`) - return value - } - context() - return createComponent(ThemeContext.Provider, { - value: { resolved: parent.resolved, mode: parent.mode, component: createComponentTheme(context, parent.mode) }, - get children() { - return props.children - }, - }) -} - -export function useTheme() { - return runtime().component -} - -export function useResolvedTheme() { - return runtime().resolved -} - -function runtime() { - const context = useContext(ThemeContext) - if (!context) throw new Error("Theme context must be used within a ThemeProvider") - return context -} diff --git a/packages/tui/src/ui/dialog-confirm.tsx b/packages/tui/src/ui/dialog-confirm.tsx index 6abbc98dd6..1541c4c992 100644 --- a/packages/tui/src/ui/dialog-confirm.tsx +++ b/packages/tui/src/ui/dialog-confirm.tsx @@ -11,7 +11,10 @@ export type DialogConfirmProps = { message: string onConfirm?: () => void onCancel?: () => void - label?: string + label?: { + confirm?: string + cancel?: string + } } export type DialogConfirmResult = boolean | undefined @@ -81,7 +84,7 @@ export function DialogConfirm(props: DialogConfirmProps) { }} > - {Locale.titlecase(key === "cancel" ? (props.label ?? key) : key)} + {Locale.titlecase(props.label?.[key] ?? key)} )} @@ -91,7 +94,7 @@ export function DialogConfirm(props: DialogConfirmProps) { ) } -DialogConfirm.show = (dialog: DialogContext, title: string, message: string, label?: string) => { +DialogConfirm.show = (dialog: DialogContext, title: string, message: string, label?: DialogConfirmProps["label"]) => { return new Promise((resolve) => { dialog.replace( () => ( diff --git a/packages/tui/test/cli/cmd/tui/notifications.test.ts b/packages/tui/test/cli/cmd/tui/notifications.test.ts index f717496def..6574874c8a 100644 --- a/packages/tui/test/cli/cmd/tui/notifications.test.ts +++ b/packages/tui/test/cli/cmd/tui/notifications.test.ts @@ -1,27 +1,17 @@ import { describe, expect, test } from "bun:test" import Notifications from "../../../../src/feature-plugins/system/notifications" import type { OpenCodeEvent, PermissionAsked, QuestionAsked } from "@opencode-ai/client" -import type { TuiAttentionNotifyInput, TuiPluginApi } from "@opencode-ai/plugin/v1/tui" -import { createTuiPluginApi } from "../../../fixture/tui-plugin" +import type { AttentionNotifyOptions, Context } from "@opencode-ai/plugin/tui/context" -type Session = NonNullable> +type Session = { id: string; title: string; parentID?: string } async function setup() { - const notifications: TuiAttentionNotifyInput[] = [] + const notifications: AttentionNotifyOptions[] = [] const handlers = new Map void)[]>() - const session = ( - id: string, - title: string, - parentID?: string, - ): Session => ({ + const session = (id: string, title: string, parentID?: string): Session => ({ id, title, - slug: id, - projectID: "project", - directory: "/workspace", ...(parentID && { parentID }), - version: "0.0.0-test", - time: { created: 0, updated: 0 }, }) const sessions: Record = { session: session("session", "Demo session"), @@ -30,41 +20,35 @@ async function setup() { timeout: session("timeout", "Timeout session"), } - await Notifications.tui( - createTuiPluginApi({ - attention: { - async notify(input) { - notifications.push(input) - return { ok: true, notification: true, sound: true } - }, + await Notifications.setup({ + attention: { + async notify(input: AttentionNotifyOptions) { + notifications.push(input) + return { ok: true, notification: true, sound: true } }, - event: { - on: ( - type: Type, - handler: (event: Extract) => void, - ) => { - const list = handlers.get(type) ?? [] - const wrapped = handler as (event: OpenCodeEvent) => void - list.push(wrapped) - handlers.set(type, list) - return () => { - handlers.set( - type, - (handlers.get(type) ?? []).filter((item) => item !== wrapped), - ) - } - }, + }, + data: { + on: ( + type: Type, + handler: (event: Extract) => void, + ) => { + const list = handlers.get(type) ?? [] + const wrapped = handler as (event: OpenCodeEvent) => void + list.push(wrapped) + handlers.set(type, list) + return () => { + handlers.set( + type, + (handlers.get(type) ?? []).filter((item) => item !== wrapped), + ) + } }, - state: { - session: { - get: (sessionID: string) => sessions[sessionID], - status: () => ({ type: "busy" }), - }, + session: { + get: (sessionID: string) => sessions[sessionID], + status: () => "running" as const, }, - }), - undefined, - {} as never, - ) + }, + } as unknown as Context) return { notifications, @@ -139,31 +123,31 @@ function executionFailed(id: string, sessionID = "session"): OpenCodeEvent { } } -const questionNotification: TuiAttentionNotifyInput = { +const questionNotification: AttentionNotifyOptions = { title: "Demo session", message: "Question needs input", notification: { when: "blurred" }, sound: { name: "question", when: "always" }, } -const formNotification: TuiAttentionNotifyInput = { +const formNotification: AttentionNotifyOptions = { title: "Input requested", message: "Input needs response", notification: { when: "blurred" }, sound: { name: "question", when: "always" }, } -const titledFormNotification: TuiAttentionNotifyInput = { +const titledFormNotification: AttentionNotifyOptions = { ...formNotification, title: "Confirm deployment", } -const globalFormNotification: TuiAttentionNotifyInput = { +const globalFormNotification: AttentionNotifyOptions = { ...formNotification, title: "demo-mcp is requesting input", } -const permissionNotification: TuiAttentionNotifyInput = { +const permissionNotification: AttentionNotifyOptions = { title: "Demo session", message: "Permission needs input", notification: { when: "blurred" }, diff --git a/packages/tui/test/cli/tui/diff-viewer.test.tsx b/packages/tui/test/cli/tui/diff-viewer.test.tsx index c54df1f229..524e1c2b84 100644 --- a/packages/tui/test/cli/tui/diff-viewer.test.tsx +++ b/packages/tui/test/cli/tui/diff-viewer.test.tsx @@ -171,19 +171,25 @@ async function renderDiffViewer(vcsDiff: unknown[], height = 20, initialRoute?: }) }, dispatch() {}, - shortcut: () => undefined, + shortcuts: () => [], mode: { current: () => "base", push: () => () => {} }, }, ui: { + dialog: { + show: () => () => {}, + set() {}, + clear() {}, + }, router: { register(page: Page) { if (page.name === "diff") renderDiff = page.render - return () => {} + return () => {} }, navigate(destination: Destination) { - current = destination.type === "plugin" && !("id" in destination) - ? { ...destination, id: "diff-viewer" } - : destination + current = + destination.type === "plugin" && !("id" in destination) + ? { ...destination, id: "diff-viewer" } + : destination }, current: () => current, }, diff --git a/packages/tui/test/cli/tui/theme-mode.test.tsx b/packages/tui/test/cli/tui/theme-mode.test.tsx index 0b8123f05c..34aa59ffd7 100644 --- a/packages/tui/test/cli/tui/theme-mode.test.tsx +++ b/packages/tui/test/cli/tui/theme-mode.test.tsx @@ -2,10 +2,9 @@ import { testRender } from "@opentui/solid" import { expect, test } from "bun:test" import { RGBA } from "@opentui/core" +import { DEFAULT_THEME, selectTheme } from "@opencode-ai/theme/tui" import { createTuiResolvedConfig } from "../../fixture/tui-runtime" import { DEFAULT_THEMES } from "../../../src/theme" -import { DEFAULT_THEME } from "../../../src/theme/v2/defaults" -import { selectTheme } from "../../../src/theme/v2/select" import { ConfigProvider } from "../../../src/config" import { ThemeProvider, useTheme, type ThemeError } from "../../../src/context/theme" diff --git a/packages/tui/test/keymap.test.tsx b/packages/tui/test/keymap.test.tsx index 3885914ebc..e6f2c3fc5c 100644 --- a/packages/tui/test/keymap.test.tsx +++ b/packages/tui/test/keymap.test.tsx @@ -76,6 +76,32 @@ test("formats navigation keys as arrows", async () => { } }) +test("returns every formatted command shortcut", async () => { + let read = () => [] as readonly string[] + + function Harness() { + const shortcuts = Keymap.useShortcuts() + Keymap.createLayer(() => ({ + commands: [{ id: "demo.command", bind: "x,y", run() {} }], + })) + read = () => shortcuts.list("demo.command") + return + } + + const app = await testRender(() => ( + + + + + + )) + try { + expect(read()).toEqual(["x", "y"]) + } finally { + app.renderer.destroy() + } +}) + test("global commands stay reachable when the mode changes", async () => { const calls: string[] = [] let exercise = () => {} diff --git a/packages/tui/test/theme/v2/component.test.ts b/packages/tui/test/theme/v2/component.test.ts index 469624f8ba..87c3265777 100644 --- a/packages/tui/test/theme/v2/component.test.ts +++ b/packages/tui/test/theme/v2/component.test.ts @@ -1,11 +1,8 @@ import { expect, test } from "bun:test" import { createSignal } from "solid-js" import { RGBA } from "@opentui/core" -import { createComponentTheme } from "../../../src/theme/v2/component" -import { DEFAULT_THEME } from "../../../src/theme/v2/defaults" -import { resolveTheme } from "../../../src/theme/v2/resolve" -import { selectTheme } from "../../../src/theme/v2/select" -import type { ContextKey } from "../../../src/theme/v2" +import { DEFAULT_THEME, resolveTheme, selectTheme, type ContextKey } from "@opencode-ai/theme/tui" +import { createComponentTheme } from "../../../src/theme/component" test("provides reactive properties, states, contexts, and color operations", () => { const [resolved, setResolved] = createSignal(resolveTheme(selectTheme(DEFAULT_THEME, "light"))) diff --git a/packages/tui/test/theme/v2/resolve.test.ts b/packages/tui/test/theme/v2/resolve.test.ts index 9d42b40341..f3e3837de6 100644 --- a/packages/tui/test/theme/v2/resolve.test.ts +++ b/packages/tui/test/theme/v2/resolve.test.ts @@ -1,10 +1,14 @@ import { expect, test } from "bun:test" import { RGBA } from "@opentui/core" +import { + DEFAULT_THEME, + resolveTheme, + resolveThemeDocument, + selectTheme, + type Mode, + type ThemeDefinition, +} from "@opencode-ai/theme/tui" import { parseTheme, type ThemeDocumentSource } from "../../../src/theme" -import { DEFAULT_THEME } from "../../../src/theme/v2/defaults" -import type { Mode, ThemeDefinition } from "../../../src/theme/v2" -import { resolveTheme, resolveThemeDocument } from "../../../src/theme/v2/resolve" -import { selectTheme } from "../../../src/theme/v2/select" const light = selectTheme(DEFAULT_THEME, "light") const dark = selectTheme(DEFAULT_THEME, "dark") diff --git a/packages/tui/test/theme/v2/select.test.ts b/packages/tui/test/theme/v2/select.test.ts index 00f0bd437d..9da37b2ac3 100644 --- a/packages/tui/test/theme/v2/select.test.ts +++ b/packages/tui/test/theme/v2/select.test.ts @@ -1,6 +1,13 @@ import { expect, test } from "bun:test" -import type { HueDefinition, ThemeDefinition, ThemeDocument } from "../../../src/theme/v2" -import { selectTheme, selectThemeMode, supportsThemeMode, themeModes } from "../../../src/theme/v2/select" +import { + selectTheme, + selectThemeMode, + supportsThemeMode, + themeModes, + type HueDefinition, + type ThemeDefinition, + type ThemeDocument, +} from "@opencode-ai/theme/tui" const hue = {} as HueDefinition const light = { hue, categorical: ["blue"], text: { default: "#111111", subdued: "#222222" } } satisfies ThemeDefinition diff --git a/packages/tui/test/theme/v2/types.test.ts b/packages/tui/test/theme/v2/types.test.ts index f98a013f5e..09f6dce6ba 100644 --- a/packages/tui/test/theme/v2/types.test.ts +++ b/packages/tui/test/theme/v2/types.test.ts @@ -1,5 +1,5 @@ import { expect, test } from "bun:test" -import type { BackgroundDefinition, TextDefinition, ThemeDefinition, ThemeDocument } from "../../../src/theme/v2" +import type { BackgroundDefinition, TextDefinition, ThemeDefinition, ThemeDocument } from "@opencode-ai/theme/tui" const text = { default: "$hue.neutral.900", diff --git a/packages/tui/test/theme/v2/v1-migrate.test.ts b/packages/tui/test/theme/v2/v1-migrate.test.ts index fed2c9ba79..31b5b25e5d 100644 --- a/packages/tui/test/theme/v2/v1-migrate.test.ts +++ b/packages/tui/test/theme/v2/v1-migrate.test.ts @@ -1,9 +1,13 @@ import { expect, test } from "bun:test" +import { + DEFAULT_CATEGORICAL, + DEFAULT_THEME, + migrateV1, + resolveThemeDocument, + selectThemeMode, + themeModes, +} from "@opencode-ai/theme/tui" import { DEFAULT_THEMES, resolveTheme as resolveV1 } from "../../../src/theme" -import { resolveThemeDocument } from "../../../src/theme/v2/resolve" -import { selectThemeMode, themeModes } from "../../../src/theme/v2/select" -import { migrateV1 } from "../../../src/theme/v2/v1-migrate" -import { DEFAULT_CATEGORICAL, DEFAULT_THEME } from "../../../src/theme/v2/defaults" test("migrates resolved V1 modes into V2 tokens", () => { const migrated = migrateV1(DEFAULT_THEMES.opencode) diff --git a/packages/www/package.json b/packages/www/package.json index 1774df1035..d687bd8ea0 100644 --- a/packages/www/package.json +++ b/packages/www/package.json @@ -18,6 +18,7 @@ }, "devDependencies": { "@astrojs/cloudflare": "14.1.4", + "@opencode-ai/theme": "workspace:*", "@types/bun": "catalog:", "astro": "7.1.3", "effect": "catalog:", diff --git a/packages/www/script/generate-theme-tokens.ts b/packages/www/script/generate-theme-tokens.ts index 0c84937714..fc76c57d8c 100644 --- a/packages/www/script/generate-theme-tokens.ts +++ b/packages/www/script/generate-theme-tokens.ts @@ -2,7 +2,7 @@ import { Schema, SchemaAST } from "effect" import { format } from "prettier" -import { ThemeDefinition, ThemeDocument } from "../../tui/src/theme/v2/schema" +import { ThemeDefinition, ThemeDocument } from "@opencode-ai/theme/tui" const target = import.meta.dir + "/../snippets/generated/theme-tokens.mdx" const root = requireObject(ThemeDefinition.ast) @@ -64,7 +64,7 @@ ${JSON.stringify(example, null, 2)} ## Token reference This reference is generated from the Effect schema in -\`packages/tui/src/theme/v2/schema.ts\`. Changes to the runtime schema update +\`@opencode-ai/theme/tui\`. Changes to the runtime schema update this section through \`bun run generate\`. ### Hue tokens diff --git a/packages/www/snippets/generated/theme-tokens.mdx b/packages/www/snippets/generated/theme-tokens.mdx index 39873c3571..03ca51c7a8 100644 --- a/packages/www/snippets/generated/theme-tokens.mdx +++ b/packages/www/snippets/generated/theme-tokens.mdx @@ -30,7 +30,7 @@ ## Token reference This reference is generated from the Effect schema in -`packages/tui/src/theme/v2/schema.ts`. Changes to the runtime schema update +`@opencode-ai/theme/tui`. Changes to the runtime schema update this section through `bun run generate`. ### Hue tokens diff --git a/script/publish.ts b/script/publish.ts index 3623eeb777..4baf6b03b7 100755 --- a/script/publish.ts +++ b/script/publish.ts @@ -38,6 +38,9 @@ await prepareReleaseFiles() console.log("\n=== schema ===\n") await $`bun ./packages/schema/script/publish.ts` +console.log("\n=== theme ===\n") +await $`bun ./packages/theme/script/publish.ts` + console.log("\n=== ai ===\n") await $`bun ./packages/ai/script/publish.ts`