Compare commits

..

2 Commits

Author SHA1 Message Date
Kit Langton 182494f885 test(app): restore archive compatibility 2026-07-28 11:20:23 -04:00
Kit Langton 2b767d8cb1 feat(session): add durable session archival 2026-07-28 11:18:37 -04:00
88 changed files with 1759 additions and 1939 deletions
-18
View File
@@ -859,20 +859,6 @@
"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",
@@ -882,7 +868,6 @@
"@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:",
@@ -1045,7 +1030,6 @@
},
"devDependencies": {
"@astrojs/cloudflare": "14.1.4",
"@opencode-ai/theme": "workspace:*",
"@types/bun": "catalog:",
"astro": "7.1.3",
"effect": "catalog:",
@@ -2091,8 +2075,6 @@
"@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"],
-1
View File
@@ -15,7 +15,6 @@
],
"exports": {
".": "./src/index.ts",
"./testing": "./src/testing.ts",
"./*": "./src/*.ts"
},
"devDependencies": {
+2 -1
View File
@@ -135,7 +135,7 @@ export function classifyProviderFailure(input: ProviderFailure): LLMError["reaso
rateLimit: input.rateLimit,
})
}
if (input.status === 408 || input.status === 409 || (input.status !== undefined && input.status >= 500))
if (input.status !== undefined && input.status >= 500)
return new ProviderInternalReason({
...common,
status: input.status,
@@ -145,6 +145,7 @@ export function classifyProviderFailure(input: ProviderFailure): LLMError["reaso
if (
input.status === 400 ||
input.status === 404 ||
input.status === 409 ||
input.status === 413 ||
input.status === 422
)
-157
View File
@@ -1,157 +0,0 @@
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<LLMEvent, LLMError>
export type Gate = Readonly<{ started: Effect.Effect<void>; release: Effect.Effect<void> }>
export interface Interface {
readonly requests: LLMRequest[]
readonly push: (...responses: readonly Response[]) => Effect.Effect<void>
readonly always: (response: Response) => Effect.Effect<void>
readonly wait: (count: number) => Effect.Effect<void>
readonly gate: Effect.Effect<Gate, never, Scope.Scope>
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<Service, Interface>()("@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<void>()
let fallback = options.fallback
let activeGate: { readonly started: Queue.Queue<void>; readonly release: Latch.Latch } | undefined
const wait = (count: number): Effect.Effect<void> =>
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<void>(), 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)
-2
View File
@@ -19,7 +19,6 @@ 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", () => {
@@ -29,7 +28,6 @@ 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", () => {
-6
View File
@@ -58,12 +58,6 @@ describe("provider error classification", () => {
).toEqual(["ProviderInternal", "ProviderInternal"])
})
test("classifies transient client statuses as provider internal", () => {
expect(
[408, 409].map((status) => classifyProviderFailure({ message: `HTTP ${status}`, status })._tag),
).toEqual(["ProviderInternal", "ProviderInternal"])
})
test("classifies nested provider codes when a top-level code is also present", () => {
expect(
[
@@ -562,7 +562,6 @@ export function useServerManagementController(options: { onSelect?: () => void;
startEdit,
resetForm,
submitForm,
canRemove: server.canRemove,
handleRemove,
handleFormChange: () => (isAddMode() ? handleAddChange : handleEditChange),
handleFormNameChange: () => (isAddMode() ? handleAddNameChange : handleEditNameChange),
@@ -650,15 +649,13 @@ export function ServerConnectionList(props: { controller: ReturnType<typeof useS
</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</Show>
<Show when={props.controller.canRemove(key)}>
<DropdownMenu.Separator />
<DropdownMenu.Item
onSelect={() => props.controller.handleRemove(ServerConnection.key(i))}
class="text-text-on-critical-base hover:bg-surface-critical-weak"
>
<DropdownMenu.ItemLabel>{language.t("dialog.server.menu.delete")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</Show>
<DropdownMenu.Separator />
<DropdownMenu.Item
onSelect={() => props.controller.handleRemove(ServerConnection.key(i))}
class="text-text-on-critical-base hover:bg-surface-critical-weak"
>
<DropdownMenu.ItemLabel>{language.t("dialog.server.menu.delete")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu>
@@ -21,7 +21,6 @@ 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)}
@@ -48,7 +47,6 @@ export const ServerRowMenuView: Component<{
labels: ReturnType<typeof serverMenuLabels>
canDefault: boolean
isDefault: boolean
canRemove: boolean
onEdit: (server: ServerConnection.Http) => void
onSetDefault: () => void
onRemoveDefault: () => void
@@ -86,10 +84,10 @@ export const ServerRowMenuView: Component<{
<Show when={props.canDefault && props.isDefault}>
<MenuV2.Item onSelect={props.onRemoveDefault}>{props.labels.defaultRemove}</MenuV2.Item>
</Show>
<Show when={props.canRemove}>
<MenuV2.Separator />
<MenuV2.Item onSelect={props.onRemove}>{props.labels.delete}</MenuV2.Item>
</Show>
<MenuV2.Separator />
<MenuV2.Item disabled={builtin()} onSelect={props.onRemove}>
{props.labels.delete}
</MenuV2.Item>
</MenuV2.Group>
</MenuV2.Content>
</MenuV2.Portal>
+21 -19
View File
@@ -395,25 +395,27 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl
}}
onReorder={(keys) => tabsStoreActions.reorder(keys)}
/>
<TooltipV2
placement="bottom"
value={
<>
{language.t("command.session.new")}
<KeybindV2 keys={newTabTooltipKeybind(command)} variant="neutral" />
</>
}
>
<IconButtonV2
type="button"
variant="ghost-muted"
size="large"
class="shrink-0"
icon={<IconV2 name="plus" />}
onClick={openNewTab}
aria-label={language.t("command.session.new")}
/>
</TooltipV2>
<Show when={!creating()}>
<TooltipV2
placement="bottom"
value={
<>
{language.t("command.session.new")}
<KeybindV2 keys={newTabTooltipKeybind(command)} variant="neutral" />
</>
}
>
<IconButtonV2
type="button"
variant="ghost-muted"
size="large"
class="shrink-0"
icon={<IconV2 name="plus" />}
onClick={openNewTab}
aria-label={language.t("command.session.new")}
/>
</TooltipV2>
</Show>
<div class="flex-1" />
<TitlebarV2Right state={v2RightState()} />
</div>
-16
View File
@@ -178,17 +178,6 @@ export function resolveServerList(input: {
return [...deduped.values()]
}
export function canRemoveServer(input: {
key: ServerConnection.Key
provided?: Array<ServerConnection.Any>
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 }
@@ -323,10 +312,6 @@ 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 },
@@ -365,7 +350,6 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
setActive,
add,
remove,
canRemove,
scope,
projects: {
...projects,
@@ -60,7 +60,6 @@ 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(() => <DialogServerV2 mode="edit" server={conn} />),
focus: home.selection.focusServer,
@@ -47,7 +47,6 @@ 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
@@ -193,7 +192,6 @@ function HomeServerRow(props: {
onToggleCollapsed: HomeProjectsViewProps["onToggleCollapsed"]
onEditServer: HomeProjectsViewProps["onEditServer"]
onSetDefaultServer: HomeProjectsViewProps["onSetDefaultServer"]
canRemoveServer: HomeProjectsViewProps["canRemoveServer"]
onRemoveServer: HomeProjectsViewProps["onRemoveServer"]
onSetContextMenuOpen: HomeProjectsContextMenuProps["onSetContextMenuOpen"]
onChooseProject: HomeProjectsViewProps["onChooseProject"]
@@ -279,7 +277,6 @@ 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)}
@@ -24,7 +24,6 @@ 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}
@@ -53,7 +53,6 @@ function setup(
}
describe("createCompatibleApi", () => {
/*
test("routes V1 archive through the legacy session update", async () => {
const { api, requests } = setup("v1")
await api.session.archive({ sessionID: "ses_1", directory: "/repo" })
@@ -64,7 +63,6 @@ describe("createCompatibleApi", () => {
expect(requests[0]!.method).toBe("PATCH")
expect(await requests[0]!.json()).toMatchObject({ time: { archived: expect.any(Number) } })
})
*/
test("converts current prompts to the V1 prompt contract", async () => {
const { api, requests } = setup("v1")
@@ -147,7 +145,6 @@ describe("createCompatibleApi", () => {
expect(detections).toBe(1)
})
/*
test("keeps V2 session actions on the current API", async () => {
const { api, requests } = setup("v2")
await api.session.archive({ sessionID: "ses_1" })
@@ -155,7 +152,6 @@ describe("createCompatibleApi", () => {
expect(new URL(requests[0]!.url).pathname).toBe("/api/session/ses_1/archive")
expect(requests[0]!.method).toBe("POST")
})
*/
test("uses the global V1 session search endpoint", async () => {
const { api, requests } = setup("v1")
+4 -4
View File
@@ -27,7 +27,7 @@ type CompatibleSessionApi = Omit<
shell: (input: SessionShellInput & LegacyPrompt) => Promise<SessionShellOutput>
compact: (input: SessionCompactInput & { model?: LegacyPrompt["model"] }) => Promise<SessionCompactOutput>
rename: (input: Parameters<SessionApi["rename"]>[0] & LegacyLocation) => ReturnType<SessionApi["rename"]>
// archive: (input: Parameters<SessionApi["archive"]>[0] & LegacyLocation) => ReturnType<SessionApi["archive"]>
archive: (input: Parameters<SessionApi["archive"]>[0] & LegacyLocation) => ReturnType<SessionApi["archive"]>
remove: (input: Parameters<SessionApi["remove"]>[0] & LegacyLocation) => ReturnType<SessionApi["remove"]>
}
type CompatiblePermissionApi = Omit<ServerApi["permission"], "reply"> & {
@@ -183,9 +183,9 @@ function createV1Api(input: CompatibleInput): CompatibleApi {
async rename(value: Parameters<ServerApi["session"]["rename"]>[0] & LegacyLocation) {
await legacy(value).session.update({ sessionID: value.sessionID, title: value.title })
},
// async archive(value: Parameters<ServerApi["session"]["archive"]>[0] & LegacyLocation) {
// await legacy(value).session.update({ sessionID: value.sessionID, time: { archived: Date.now() } })
// },
async archive(value: Parameters<ServerApi["session"]["archive"]>[0] & LegacyLocation) {
await legacy(value).session.update({ sessionID: value.sessionID, time: { archived: Date.now() } })
},
async remove(value: Parameters<ServerApi["session"]["remove"]>[0] & LegacyLocation) {
await legacy(value).session.delete(value)
},
+5 -14
View File
@@ -14,23 +14,10 @@ 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,
@@ -209,7 +196,11 @@ 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)),
...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),
},
}),
Spec.make("service", {
@@ -59,7 +59,6 @@ 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,
+1 -1
View File
@@ -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 || input.dangerouslySkipPermissions,
auto: input.auto || input.yolo,
}),
)
}),
+76 -62
View File
@@ -152,15 +152,19 @@ export type Endpoint5_8Input = { readonly sessionID: Session.ID; readonly title:
export type Endpoint5_8Output = void
export type SessionRenameOperation<E = never> = (input: Endpoint5_8Input) => Effect.Effect<Endpoint5_8Output, E>
export type Endpoint5_9Input = {
export type Endpoint5_9Input = { readonly sessionID: Session.ID }
export type Endpoint5_9Output = void
export type SessionArchiveOperation<E = never> = (input: Endpoint5_9Input) => Effect.Effect<Endpoint5_9Output, E>
export type Endpoint5_10Input = {
readonly sessionID: Session.ID
readonly directory: AbsolutePath
readonly workspaceID?: Workspace.ID | undefined
}
export type Endpoint5_9Output = void
export type SessionMoveOperation<E = never> = (input: Endpoint5_9Input) => Effect.Effect<Endpoint5_9Output, E>
export type Endpoint5_10Output = void
export type SessionMoveOperation<E = never> = (input: Endpoint5_10Input) => Effect.Effect<Endpoint5_10Output, E>
export type Endpoint5_10Input = {
export type Endpoint5_11Input = {
readonly sessionID: Session.ID
readonly id?: SessionMessage.ID | undefined
readonly text: string
@@ -170,10 +174,10 @@ export type Endpoint5_10Input = {
readonly delivery?: "steer" | "queue" | undefined
readonly resume?: boolean | undefined
}
export type Endpoint5_10Output = SessionPending.User
export type SessionPromptOperation<E = never> = (input: Endpoint5_10Input) => Effect.Effect<Endpoint5_10Output, E>
export type Endpoint5_11Output = SessionPending.User
export type SessionPromptOperation<E = never> = (input: Endpoint5_11Input) => Effect.Effect<Endpoint5_11Output, E>
export type Endpoint5_11Input = {
export type Endpoint5_12Input = {
readonly sessionID: Session.ID
readonly id?: SessionMessage.ID | undefined
readonly command: string
@@ -185,19 +189,19 @@ export type Endpoint5_11Input = {
readonly delivery?: "steer" | "queue" | undefined
readonly resume?: boolean | undefined
}
export type Endpoint5_11Output = SessionPending.User
export type SessionCommandOperation<E = never> = (input: Endpoint5_11Input) => Effect.Effect<Endpoint5_11Output, E>
export type Endpoint5_12Output = SessionPending.User
export type SessionCommandOperation<E = never> = (input: Endpoint5_12Input) => Effect.Effect<Endpoint5_12Output, E>
export type Endpoint5_12Input = {
export type Endpoint5_13Input = {
readonly sessionID: Session.ID
readonly id?: SessionMessage.ID | undefined
readonly skill: Skill.ID
readonly resume?: boolean | undefined
}
export type Endpoint5_12Output = void
export type SessionSkillOperation<E = never> = (input: Endpoint5_12Input) => Effect.Effect<Endpoint5_12Output, E>
export type Endpoint5_13Output = void
export type SessionSkillOperation<E = never> = (input: Endpoint5_13Input) => Effect.Effect<Endpoint5_13Output, E>
export type Endpoint5_13Input = {
export type Endpoint5_14Input = {
readonly sessionID: Session.ID
readonly id?: SessionMessage.ID | undefined
readonly text: string
@@ -206,81 +210,81 @@ export type Endpoint5_13Input = {
readonly delivery?: "steer" | "queue" | undefined
readonly resume?: boolean | undefined
}
export type Endpoint5_13Output = SessionPending.Synthetic
export type SessionSyntheticOperation<E = never> = (input: Endpoint5_13Input) => Effect.Effect<Endpoint5_13Output, E>
export type Endpoint5_14Output = SessionPending.Synthetic
export type SessionSyntheticOperation<E = never> = (input: Endpoint5_14Input) => Effect.Effect<Endpoint5_14Output, E>
export type Endpoint5_14Input = {
export type Endpoint5_15Input = {
readonly sessionID: Session.ID
readonly id?: Event.ID | undefined
readonly command: string
}
export type Endpoint5_14Output = void
export type SessionShellOperation<E = never> = (input: Endpoint5_14Input) => Effect.Effect<Endpoint5_14Output, E>
export type Endpoint5_15Output = void
export type SessionShellOperation<E = never> = (input: Endpoint5_15Input) => Effect.Effect<Endpoint5_15Output, E>
export type Endpoint5_15Input = { readonly sessionID: Session.ID; readonly id?: SessionMessage.ID | undefined }
export type Endpoint5_15Output = SessionPending.Compaction
export type SessionCompactOperation<E = never> = (input: Endpoint5_15Input) => Effect.Effect<Endpoint5_15Output, E>
export type Endpoint5_16Input = { readonly sessionID: Session.ID; readonly id?: SessionMessage.ID | undefined }
export type Endpoint5_16Output = SessionPending.Compaction
export type SessionCompactOperation<E = never> = (input: Endpoint5_16Input) => Effect.Effect<Endpoint5_16Output, E>
export type Endpoint5_16Input = { readonly sessionID: Session.ID }
export type Endpoint5_16Output = void
export type SessionWaitOperation<E = never> = (input: Endpoint5_16Input) => Effect.Effect<Endpoint5_16Output, E>
export type Endpoint5_17Input = { readonly sessionID: Session.ID }
export type Endpoint5_17Output = void
export type SessionWaitOperation<E = never> = (input: Endpoint5_17Input) => Effect.Effect<Endpoint5_17Output, E>
export type Endpoint5_17Input = {
export type Endpoint5_18Input = {
readonly sessionID: Session.ID
readonly messageID: SessionMessage.ID
readonly files?: boolean | undefined
}
export type Endpoint5_17Output = Session.Revert
export type SessionRevertStageOperation<E = never> = (input: Endpoint5_17Input) => Effect.Effect<Endpoint5_17Output, E>
export type Endpoint5_18Input = { readonly sessionID: Session.ID }
export type Endpoint5_18Output = void
export type SessionRevertClearOperation<E = never> = (input: Endpoint5_18Input) => Effect.Effect<Endpoint5_18Output, E>
export type Endpoint5_18Output = Session.Revert
export type SessionRevertStageOperation<E = never> = (input: Endpoint5_18Input) => Effect.Effect<Endpoint5_18Output, E>
export type Endpoint5_19Input = { readonly sessionID: Session.ID }
export type Endpoint5_19Output = void
export type SessionRevertCommitOperation<E = never> = (input: Endpoint5_19Input) => Effect.Effect<Endpoint5_19Output, E>
export type SessionRevertClearOperation<E = never> = (input: Endpoint5_19Input) => Effect.Effect<Endpoint5_19Output, E>
export type Endpoint5_20Input = { readonly sessionID: Session.ID }
export type Endpoint5_20Output = ReadonlyArray<SessionMessage.Info>
export type SessionContextOperation<E = never> = (input: Endpoint5_20Input) => Effect.Effect<Endpoint5_20Output, E>
export type Endpoint5_20Output = void
export type SessionRevertCommitOperation<E = never> = (input: Endpoint5_20Input) => Effect.Effect<Endpoint5_20Output, E>
export type Endpoint5_21Input = { readonly sessionID: Session.ID }
export type Endpoint5_21Output = ReadonlyArray<SessionPending.Info>
export type SessionPendingListOperation<E = never> = (input: Endpoint5_21Input) => Effect.Effect<Endpoint5_21Output, E>
export type Endpoint5_21Output = ReadonlyArray<SessionMessage.Info>
export type SessionContextOperation<E = never> = (input: Endpoint5_21Input) => Effect.Effect<Endpoint5_21Output, E>
export type Endpoint5_22Input = { readonly sessionID: Session.ID }
export type Endpoint5_22Output = ReadonlyArray<InstructionEntry.Info>
export type SessionInstructionsEntryListOperation<E = never> = (
input: Endpoint5_22Input,
) => Effect.Effect<Endpoint5_22Output, E>
export type Endpoint5_22Output = ReadonlyArray<SessionPending.Info>
export type SessionPendingListOperation<E = never> = (input: Endpoint5_22Input) => Effect.Effect<Endpoint5_22Output, E>
export type Endpoint5_23Input = {
export type Endpoint5_23Input = { readonly sessionID: Session.ID }
export type Endpoint5_23Output = ReadonlyArray<InstructionEntry.Info>
export type SessionInstructionsEntryListOperation<E = never> = (
input: Endpoint5_23Input,
) => Effect.Effect<Endpoint5_23Output, E>
export type Endpoint5_24Input = {
readonly sessionID: Session.ID
readonly key: InstructionEntry.Key
readonly value: Schema.Json
}
export type Endpoint5_23Output = void
export type SessionInstructionsEntryPutOperation<E = never> = (
input: Endpoint5_23Input,
) => Effect.Effect<Endpoint5_23Output, E>
export type Endpoint5_24Input = { readonly sessionID: Session.ID; readonly key: InstructionEntry.Key }
export type Endpoint5_24Output = void
export type SessionInstructionsEntryRemoveOperation<E = never> = (
export type SessionInstructionsEntryPutOperation<E = never> = (
input: Endpoint5_24Input,
) => Effect.Effect<Endpoint5_24Output, E>
export type Endpoint5_25Input = { readonly sessionID: Session.ID; readonly prompt: string }
export type Endpoint5_25Output = { readonly text: string }
export type SessionGenerateOperation<E = never> = (input: Endpoint5_25Input) => Effect.Effect<Endpoint5_25Output, E>
export type Endpoint5_25Input = { readonly sessionID: Session.ID; readonly key: InstructionEntry.Key }
export type Endpoint5_25Output = void
export type SessionInstructionsEntryRemoveOperation<E = never> = (
input: Endpoint5_25Input,
) => Effect.Effect<Endpoint5_25Output, E>
export type Endpoint5_26Input = {
export type Endpoint5_26Input = { readonly sessionID: Session.ID; readonly prompt: string }
export type Endpoint5_26Output = { readonly text: string }
export type SessionGenerateOperation<E = never> = (input: Endpoint5_26Input) => Effect.Effect<Endpoint5_26Output, E>
export type Endpoint5_27Input = {
readonly sessionID: Session.ID
readonly after?: Event.Seq | undefined
readonly follow?: boolean | undefined
}
export type Endpoint5_26Output =
export type Endpoint5_27Output =
| (
| {
readonly id: Event.ID
@@ -323,6 +327,15 @@ export type Endpoint5_26Output =
readonly location?: Location.Ref | undefined
readonly data: { readonly sessionID: Session.ID; readonly title: string }
}
| {
readonly id: Event.ID
readonly created: DateTime.Utc
readonly metadata?: { readonly [x: string]: unknown } | undefined
readonly type: "session.archived"
readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
readonly location?: Location.Ref | undefined
readonly data: { readonly sessionID: Session.ID }
}
| {
readonly id: Event.ID
readonly created: DateTime.Utc
@@ -825,19 +838,19 @@ export type Endpoint5_26Output =
}
)
| EventLog.Synced
export type SessionLogOperation<E = never> = (input: Endpoint5_26Input) => Stream.Stream<Endpoint5_26Output, E>
export type Endpoint5_27Input = { readonly sessionID: Session.ID }
export type Endpoint5_27Output = void
export type SessionInterruptOperation<E = never> = (input: Endpoint5_27Input) => Effect.Effect<Endpoint5_27Output, E>
export type SessionLogOperation<E = never> = (input: Endpoint5_27Input) => Stream.Stream<Endpoint5_27Output, E>
export type Endpoint5_28Input = { readonly sessionID: Session.ID }
export type Endpoint5_28Output = void
export type SessionBackgroundOperation<E = never> = (input: Endpoint5_28Input) => Effect.Effect<Endpoint5_28Output, E>
export type SessionInterruptOperation<E = never> = (input: Endpoint5_28Input) => Effect.Effect<Endpoint5_28Output, E>
export type Endpoint5_29Input = { readonly sessionID: Session.ID; readonly messageID: SessionMessage.ID }
export type Endpoint5_29Output = SessionMessage.Info
export type SessionMessageOperation<E = never> = (input: Endpoint5_29Input) => Effect.Effect<Endpoint5_29Output, E>
export type Endpoint5_29Input = { readonly sessionID: Session.ID }
export type Endpoint5_29Output = void
export type SessionBackgroundOperation<E = never> = (input: Endpoint5_29Input) => Effect.Effect<Endpoint5_29Output, E>
export type Endpoint5_30Input = { readonly sessionID: Session.ID; readonly messageID: SessionMessage.ID }
export type Endpoint5_30Output = SessionMessage.Info
export type SessionMessageOperation<E = never> = (input: Endpoint5_30Input) => Effect.Effect<Endpoint5_30Output, E>
export interface SessionApi<E = never> {
readonly list: SessionListOperation<E>
@@ -849,6 +862,7 @@ export interface SessionApi<E = never> {
readonly switchAgent: SessionSwitchAgentOperation<E>
readonly switchModel: SessionSwitchModelOperation<E>
readonly rename: SessionRenameOperation<E>
readonly archive: SessionArchiveOperation<E>
readonly move: SessionMoveOperation<E>
readonly prompt: SessionPromptOperation<E>
readonly command: SessionCommandOperation<E>
+65 -57
View File
@@ -76,6 +76,8 @@ import type {
Endpoint5_28Output,
Endpoint5_29Input,
Endpoint5_29Output,
Endpoint5_30Input,
Endpoint5_30Output,
Endpoint6_0Input,
Endpoint6_0Output,
Endpoint7_0Input,
@@ -358,14 +360,19 @@ const Endpoint5_8 = (raw: RawClient["server.session"]) => (input: Endpoint5_8Inp
const Endpoint5_9 = (raw: RawClient["server.session"]) => (input: Endpoint5_9Input) =>
preserveEffect<Endpoint5_9Output>()(
raw["session.archive"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)),
)
const Endpoint5_10 = (raw: RawClient["server.session"]) => (input: Endpoint5_10Input) =>
preserveEffect<Endpoint5_10Output>()(
raw["session.move"]({
params: { sessionID: input["sessionID"] },
payload: { directory: input["directory"], workspaceID: input["workspaceID"] },
}).pipe(Effect.mapError(mapClientError)),
)
const Endpoint5_10 = (raw: RawClient["server.session"]) => (input: Endpoint5_10Input) =>
preserveEffect<Endpoint5_10Output>()(
const Endpoint5_11 = (raw: RawClient["server.session"]) => (input: Endpoint5_11Input) =>
preserveEffect<Endpoint5_11Output>()(
raw["session.prompt"]({
params: { sessionID: input["sessionID"] },
payload: {
@@ -383,8 +390,8 @@ const Endpoint5_10 = (raw: RawClient["server.session"]) => (input: Endpoint5_10I
),
)
const Endpoint5_11 = (raw: RawClient["server.session"]) => (input: Endpoint5_11Input) =>
preserveEffect<Endpoint5_11Output>()(
const Endpoint5_12 = (raw: RawClient["server.session"]) => (input: Endpoint5_12Input) =>
preserveEffect<Endpoint5_12Output>()(
raw["session.command"]({
params: { sessionID: input["sessionID"] },
payload: {
@@ -404,16 +411,16 @@ const Endpoint5_11 = (raw: RawClient["server.session"]) => (input: Endpoint5_11I
),
)
const Endpoint5_12 = (raw: RawClient["server.session"]) => (input: Endpoint5_12Input) =>
preserveEffect<Endpoint5_12Output>()(
const Endpoint5_13 = (raw: RawClient["server.session"]) => (input: Endpoint5_13Input) =>
preserveEffect<Endpoint5_13Output>()(
raw["session.skill"]({
params: { sessionID: input["sessionID"] },
payload: { id: input["id"], skill: input["skill"], resume: input["resume"] },
}).pipe(Effect.mapError(mapClientError)),
)
const Endpoint5_13 = (raw: RawClient["server.session"]) => (input: Endpoint5_13Input) =>
preserveEffect<Endpoint5_13Output>()(
const Endpoint5_14 = (raw: RawClient["server.session"]) => (input: Endpoint5_14Input) =>
preserveEffect<Endpoint5_14Output>()(
raw["session.synthetic"]({
params: { sessionID: input["sessionID"] },
payload: {
@@ -430,29 +437,29 @@ const Endpoint5_13 = (raw: RawClient["server.session"]) => (input: Endpoint5_13I
),
)
const Endpoint5_14 = (raw: RawClient["server.session"]) => (input: Endpoint5_14Input) =>
preserveEffect<Endpoint5_14Output>()(
const Endpoint5_15 = (raw: RawClient["server.session"]) => (input: Endpoint5_15Input) =>
preserveEffect<Endpoint5_15Output>()(
raw["session.shell"]({
params: { sessionID: input["sessionID"] },
payload: { id: input["id"], command: input["command"] },
}).pipe(Effect.mapError(mapClientError)),
)
const Endpoint5_15 = (raw: RawClient["server.session"]) => (input: Endpoint5_15Input) =>
preserveEffect<Endpoint5_15Output>()(
const Endpoint5_16 = (raw: RawClient["server.session"]) => (input: Endpoint5_16Input) =>
preserveEffect<Endpoint5_16Output>()(
raw["session.compact"]({ params: { sessionID: input["sessionID"] }, payload: { id: input["id"] } }).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
),
)
const Endpoint5_16 = (raw: RawClient["server.session"]) => (input: Endpoint5_16Input) =>
preserveEffect<Endpoint5_16Output>()(
const Endpoint5_17 = (raw: RawClient["server.session"]) => (input: Endpoint5_17Input) =>
preserveEffect<Endpoint5_17Output>()(
raw["session.wait"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)),
)
const Endpoint5_17 = (raw: RawClient["server.session"]) => (input: Endpoint5_17Input) =>
preserveEffect<Endpoint5_17Output>()(
const Endpoint5_18 = (raw: RawClient["server.session"]) => (input: Endpoint5_18Input) =>
preserveEffect<Endpoint5_18Output>()(
raw["session.revert.stage"]({
params: { sessionID: input["sessionID"] },
payload: { messageID: input["messageID"], files: input["files"] },
@@ -462,27 +469,19 @@ const Endpoint5_17 = (raw: RawClient["server.session"]) => (input: Endpoint5_17I
),
)
const Endpoint5_18 = (raw: RawClient["server.session"]) => (input: Endpoint5_18Input) =>
preserveEffect<Endpoint5_18Output>()(
raw["session.revert.clear"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)),
)
const Endpoint5_19 = (raw: RawClient["server.session"]) => (input: Endpoint5_19Input) =>
preserveEffect<Endpoint5_19Output>()(
raw["session.revert.commit"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)),
raw["session.revert.clear"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)),
)
const Endpoint5_20 = (raw: RawClient["server.session"]) => (input: Endpoint5_20Input) =>
preserveEffect<Endpoint5_20Output>()(
raw["session.context"]({ params: { sessionID: input["sessionID"] } }).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
),
raw["session.revert.commit"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)),
)
const Endpoint5_21 = (raw: RawClient["server.session"]) => (input: Endpoint5_21Input) =>
preserveEffect<Endpoint5_21Output>()(
raw["session.pending.list"]({ params: { sessionID: input["sessionID"] } }).pipe(
raw["session.context"]({ params: { sessionID: input["sessionID"] } }).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
),
@@ -490,7 +489,7 @@ const Endpoint5_21 = (raw: RawClient["server.session"]) => (input: Endpoint5_21I
const Endpoint5_22 = (raw: RawClient["server.session"]) => (input: Endpoint5_22Input) =>
preserveEffect<Endpoint5_22Output>()(
raw["session.instructions.entry.list"]({ params: { sessionID: input["sessionID"] } }).pipe(
raw["session.pending.list"]({ params: { sessionID: input["sessionID"] } }).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
),
@@ -498,29 +497,37 @@ const Endpoint5_22 = (raw: RawClient["server.session"]) => (input: Endpoint5_22I
const Endpoint5_23 = (raw: RawClient["server.session"]) => (input: Endpoint5_23Input) =>
preserveEffect<Endpoint5_23Output>()(
raw["session.instructions.entry.list"]({ params: { sessionID: input["sessionID"] } }).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
),
)
const Endpoint5_24 = (raw: RawClient["server.session"]) => (input: Endpoint5_24Input) =>
preserveEffect<Endpoint5_24Output>()(
raw["session.instructions.entry.put"]({
params: { sessionID: input["sessionID"], key: input["key"] },
payload: { value: input["value"] },
}).pipe(Effect.mapError(mapClientError)),
)
const Endpoint5_24 = (raw: RawClient["server.session"]) => (input: Endpoint5_24Input) =>
preserveEffect<Endpoint5_24Output>()(
const Endpoint5_25 = (raw: RawClient["server.session"]) => (input: Endpoint5_25Input) =>
preserveEffect<Endpoint5_25Output>()(
raw["session.instructions.entry.remove"]({ params: { sessionID: input["sessionID"], key: input["key"] } }).pipe(
Effect.mapError(mapClientError),
),
)
const Endpoint5_25 = (raw: RawClient["server.session"]) => (input: Endpoint5_25Input) =>
preserveEffect<Endpoint5_25Output>()(
const Endpoint5_26 = (raw: RawClient["server.session"]) => (input: Endpoint5_26Input) =>
preserveEffect<Endpoint5_26Output>()(
raw["session.generate"]({ params: { sessionID: input["sessionID"] }, payload: { prompt: input["prompt"] } }).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
),
)
const Endpoint5_26 = (raw: RawClient["server.session"]) => (input: Endpoint5_26Input) =>
preserveStream<Endpoint5_26Output>()(
const Endpoint5_27 = (raw: RawClient["server.session"]) => (input: Endpoint5_27Input) =>
preserveStream<Endpoint5_27Output>()(
Stream.unwrap(
raw["session.log"]({
params: { sessionID: input["sessionID"] },
@@ -532,18 +539,18 @@ const Endpoint5_26 = (raw: RawClient["server.session"]) => (input: Endpoint5_26I
),
)
const Endpoint5_27 = (raw: RawClient["server.session"]) => (input: Endpoint5_27Input) =>
preserveEffect<Endpoint5_27Output>()(
raw["session.interrupt"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)),
)
const Endpoint5_28 = (raw: RawClient["server.session"]) => (input: Endpoint5_28Input) =>
preserveEffect<Endpoint5_28Output>()(
raw["session.background"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)),
raw["session.interrupt"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)),
)
const Endpoint5_29 = (raw: RawClient["server.session"]) => (input: Endpoint5_29Input) =>
preserveEffect<Endpoint5_29Output>()(
raw["session.background"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)),
)
const Endpoint5_30 = (raw: RawClient["server.session"]) => (input: Endpoint5_30Input) =>
preserveEffect<Endpoint5_30Output>()(
raw["session.message"]({ params: { sessionID: input["sessionID"], messageID: input["messageID"] } }).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
@@ -560,23 +567,24 @@ const adaptGroup5 = (raw: RawClient["server.session"]) => ({
switchAgent: Endpoint5_6(raw),
switchModel: Endpoint5_7(raw),
rename: Endpoint5_8(raw),
move: Endpoint5_9(raw),
prompt: Endpoint5_10(raw),
command: Endpoint5_11(raw),
skill: Endpoint5_12(raw),
synthetic: Endpoint5_13(raw),
shell: Endpoint5_14(raw),
compact: Endpoint5_15(raw),
wait: Endpoint5_16(raw),
revert: { stage: Endpoint5_17(raw), clear: Endpoint5_18(raw), commit: Endpoint5_19(raw) },
context: Endpoint5_20(raw),
pending: { list: Endpoint5_21(raw) },
instructions: { entry: { list: Endpoint5_22(raw), put: Endpoint5_23(raw), remove: Endpoint5_24(raw) } },
generate: Endpoint5_25(raw),
log: Endpoint5_26(raw),
interrupt: Endpoint5_27(raw),
background: Endpoint5_28(raw),
message: Endpoint5_29(raw),
archive: Endpoint5_9(raw),
move: Endpoint5_10(raw),
prompt: Endpoint5_11(raw),
command: Endpoint5_12(raw),
skill: Endpoint5_13(raw),
synthetic: Endpoint5_14(raw),
shell: Endpoint5_15(raw),
compact: Endpoint5_16(raw),
wait: Endpoint5_17(raw),
revert: { stage: Endpoint5_18(raw), clear: Endpoint5_19(raw), commit: Endpoint5_20(raw) },
context: Endpoint5_21(raw),
pending: { list: Endpoint5_22(raw) },
instructions: { entry: { list: Endpoint5_23(raw), put: Endpoint5_24(raw), remove: Endpoint5_25(raw) } },
generate: Endpoint5_26(raw),
log: Endpoint5_27(raw),
interrupt: Endpoint5_28(raw),
background: Endpoint5_29(raw),
message: Endpoint5_30(raw),
})
const Endpoint6_0 = (raw: RawClient["server.message"]) => (input: Endpoint6_0Input) =>
@@ -28,6 +28,8 @@ import type {
SessionSwitchModelOutput,
SessionRenameInput,
SessionRenameOutput,
SessionArchiveInput,
SessionArchiveOutput,
SessionMoveInput,
SessionMoveOutput,
SessionPromptInput,
@@ -553,6 +555,17 @@ export function make(options: ClientOptions) {
},
requestOptions,
),
archive: (input: SessionArchiveInput, requestOptions?: RequestOptions) =>
request<SessionArchiveOutput>(
{
method: "POST",
path: `/api/session/${encodeURIComponent(input.sessionID)}/archive`,
successStatus: 204,
declaredStatuses: [404, 400, 401],
empty: true,
},
requestOptions,
),
move: (input: SessionMoveInput, requestOptions?: RequestOptions) =>
request<SessionMoveOutput>(
{
@@ -611,6 +611,16 @@ export type SessionRenamed = {
data: { sessionID: string; title: string }
}
export type SessionArchived = {
id: string
created: number
metadata?: { [x: string]: any }
type: "session.archived"
durable: { aggregateID: string; seq: number; version: 1 }
location?: LocationRef
data: { sessionID: string }
}
export type SessionDeleted = {
id: string
created: number
@@ -2159,6 +2169,7 @@ export type SessionEventDurable =
| SessionModelSelected
| SessionMoved
| SessionRenamed
| SessionArchived
| SessionDeleted
| SessionForked
| SessionInputPromoted
@@ -2253,6 +2264,7 @@ export type V2Event =
| SessionModelSelected
| SessionMoved
| SessionRenamed
| SessionArchived
| SessionUsageUpdated
| SessionDeleted
| SessionForked
@@ -2730,6 +2742,10 @@ export type SessionRenameInput = {
export type SessionRenameOutput = void
export type SessionArchiveInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] }
export type SessionArchiveOutput = void
export type SessionMoveInput = {
readonly sessionID: { readonly sessionID: string }["sessionID"]
readonly directory: { readonly directory: string; readonly workspaceID?: string }["directory"]
+2
View File
@@ -432,6 +432,7 @@ test("session methods use the public HTTP contract", async () => {
sessionID: "ses_test",
model: { id: "claude", providerID: "anthropic" },
})
await client.session.archive({ sessionID: "ses_test" })
const admitted = await client.session.prompt({
sessionID: "ses_test",
text: "Hello",
@@ -467,6 +468,7 @@ test("session methods use the public HTTP contract", async () => {
["POST", "http://localhost:3000/api/session"],
["POST", "http://localhost:3000/api/session/ses_test/agent"],
["POST", "http://localhost:3000/api/session/ses_test/model"],
["POST", "http://localhost:3000/api/session/ses_test/archive"],
["POST", "http://localhost:3000/api/session/ses_test/prompt"],
["POST", "http://localhost:3000/api/session/ses_test/generate"],
["POST", "http://localhost:3000/api/session/ses_test/synthetic"],
+1 -3
View File
@@ -6,9 +6,7 @@ import { Instructions } from "../instructions/index"
import { CodeModeCatalog } from "./catalog"
// prettier-ignore
const prompt = (hasMoreTools: boolean) => `The Code Mode tool catalog below is ${hasMoreTools ? "partial" : "complete"}.
The Code Mode catalog and \`search\` results are the complete set of tools available within Code Mode. Tools presented elsewhere are not available in this runtime.${hasMoreTools ? `
const prompt = (hasMoreTools: boolean) => `The Code Mode tool catalog below is ${hasMoreTools ? "partial" : "complete"}.${hasMoreTools ? `
## Search
+1 -1
View File
@@ -33,7 +33,7 @@ type CollectedFiles = {
// Invariant model-facing guidance; the changing tool catalog is delivered through Instructions.
const description = [
"Run JavaScript in a confined Code Mode runtime to orchestrate tool calls and compose their results.",
"Run JavaScript to orchestrate tool calls and compose their results through `{ code }` in a confined Code Mode runtime.",
"Imports, direct filesystem access, and timers are unavailable. Do not use `fetch`; all external access goes through `tools`.",
"Within `{ code }`, the only callable tools are those explicitly listed in the Code Mode catalog instructions or returned by `search`. Inside `{ code }`, ignore tools shown outside the Code Mode catalog. They are not available in the Code Mode runtime.",
'Call tools through `tools` using only exact paths and signatures from the catalog. Do not infer or normalize tool names; preserve bracket notation such as `tools.<namespace>["tool-name"](input)`.',
+56 -33
View File
@@ -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, Stream } from "effect"
import { Context, Deferred, Effect, Layer, Option, PubSub, Schema, Semaphore, Stream } from "effect"
import path from "path"
import { fileURLToPath, pathToFileURL } from "url"
import { Agent } from "../agent"
@@ -230,8 +230,10 @@ 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<void>()
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.
@@ -263,42 +265,63 @@ const layer = Layer.effect(
}
})
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 updates = Stream.merge(
config.changes().pipe(
Stream.filterEffect((update) => Effect.map(config.entries(), (entries) => isPluginSource(entries, update.path))),
Stream.merge(Stream.fromPubSub(configuredChanges)),
),
bus.subscribe([Event.Updated, SdkPlugins.Updated]),
).pipe(
// Make accepted work visible to flush before coalescing the burst.
Stream.mapEffect(() => Effect.sync(() => ++observed)),
)
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.runForEach((target) =>
const activate = Effect.fn("PluginSupervisor.activate")(function* (target: number) {
yield* lock.withPermit(
Effect.gen(function* () {
yield* activate()
if (observed === target) yield* Deferred.succeed(ready, undefined)
}).pipe(Effect.catchCause((cause) => Effect.logError("failed to reload plugins", { cause }))),
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 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 }))),
),
Effect.forkScoped({ startImmediately: true }),
)
yield* signals.pipe(
Stream.debounce("100 millis"),
Stream.mapEffect(attempt),
Stream.filter((settled) => settled),
Stream.take(1),
Stream.runDrain,
Effect.andThen(Deferred.succeed(ready, undefined)),
Effect.forkScoped({ startImmediately: true }),
)
return Service.of({ flush: Deferred.await(ready) })
}),
)
+6
View File
@@ -227,6 +227,7 @@ export interface Interface {
model: Model.Ref
}) => Effect.Effect<void, NotFoundError>
readonly rename: (input: { sessionID: SessionSchema.ID; title: string }) => Effect.Effect<void, NotFoundError>
readonly archive: (sessionID: SessionSchema.ID) => Effect.Effect<void, NotFoundError>
readonly move: (input: {
sessionID: SessionSchema.ID
directory: AbsolutePath
@@ -706,6 +707,11 @@ const layer = Layer.effect(
title: input.title,
})
}),
archive: Effect.fn("Session.archive")(function* (sessionID) {
const session = yield* result.get(sessionID)
if (session.time.archived) return
yield* bus.publish(SessionEvent.Archived, { sessionID })
}),
move: Effect.fn("Session.move")(function* (input) {
const current = yield* result.get(input.sessionID)
const value = input.directory.trim()
+1 -1
View File
@@ -53,7 +53,7 @@ export function fromRow(row: typeof SessionTable.$inferSelect): SessionSchema.In
time: {
created: DateTime.makeUnsafe(row.time_created),
updated: DateTime.makeUnsafe(row.time_updated),
archived: row.time_archived ? DateTime.makeUnsafe(row.time_archived) : undefined,
archived: row.time_archived !== null ? DateTime.makeUnsafe(row.time_archived) : undefined,
},
})
}
@@ -171,6 +171,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
},
"session.moved": () => Effect.void,
"session.renamed": () => Effect.void,
"session.archived": () => Effect.void,
"session.deleted": () => Effect.void,
"session.forked": () => Effect.void,
"session.input.promoted": () => Effect.void,
+11
View File
@@ -609,6 +609,17 @@ const layer = Layer.effectDiscard(
.run()
.pipe(Effect.orDie),
)
yield* bus.project(SessionEvent.Archived, (event) =>
db
.update(SessionTable)
.set({
time_archived: DateTime.toEpochMillis(event.created),
time_updated: DateTime.toEpochMillis(event.created),
})
.where(eq(SessionTable.id, event.data.sessionID))
.run()
.pipe(Effect.orDie),
)
yield* bus.project(SessionEvent.UsageRecorded, (event) => applyUsage(db, event.data.sessionID, event.data))
yield* bus.project(SessionEvent.Forked, (event) => projectFork(db, event))
yield* bus.project(SessionEvent.InputPromoted, (event) =>
+76 -100
View File
@@ -32,109 +32,85 @@ 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
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" },
],
},
],
})
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 })),
},
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" },
],
})
: 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}` : ""}`
},
],
})
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)
})
.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 }),
}),
)
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)
@@ -44,9 +44,6 @@ describe("CodeModeInstructions", () => {
it.effect("renders the initial catalog, semantic deltas, and removal", () =>
Effect.gen(function* () {
const initialized = yield* readInitial(CodeModeInstructions.make([echo]))
expect(initialized.text).toContain(
"The Code Mode catalog and `search` results are the complete set of tools available within Code Mode. Tools presented elsewhere are not available in this runtime.",
)
expect(initialized.text).toContain("## Available tools")
expect(initialized.text).not.toContain("## Search")
expect(initialized.text).toContain(` - ${echo.signature} // Echo text`)
+17 -4
View File
@@ -1,7 +1,6 @@
import { expect } from "bun:test"
import { Model } from "@opencode-ai/ai"
import { LLMClient, LLMEvent, LLMResponse, 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"
@@ -10,7 +9,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 } from "effect"
import { Effect, Layer, Stream } from "effect"
import { testEffect } from "./lib/effect"
const selected = Info.make({
@@ -65,7 +64,21 @@ const aisdk = Layer.mock(AISDK.Service, {
},
model: () => Effect.succeed(runtime),
})
const client = TestLLM.clientLayer.pipe(Layer.provide(TestLLM.layer({ fallback: TestLLM.text("OK", "generate") })))
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 resolver = ModelResolver.layer.pipe(Layer.provide(Layer.mergeAll(catalog, integrations, npm, aisdk)))
const it = testEffect(Generate.layer.pipe(Layer.provide(Layer.merge(resolver, client))))
+28
View File
@@ -660,4 +660,32 @@ describe("Session.create", () => {
).toBe("Session.NotFoundError")
}),
)
it.effect("archives a Session through one durable event", () =>
Effect.gen(function* () {
const session = yield* Session.Service
const created = yield* session.create({ location })
yield* session.archive(created.id)
yield* session.archive(created.id)
expect((yield* session.get(created.id)).time.archived).toBeDefined()
const events = Array.from(yield* logEvents(session, created.id).pipe(Stream.runCollect))
expect(events.map((event) => event.type)).toContain("session.archived")
expect(events.filter((event) => event.type === "session.archived")).toHaveLength(1)
}),
)
it.effect("rejects archiving a missing Session", () =>
Effect.gen(function* () {
const session = yield* Session.Service
expect(
yield* session.archive(Session.ID.make("ses_missing_archive")).pipe(
Effect.flip,
Effect.map((error) => error._tag),
),
).toBe("Session.NotFoundError")
}),
)
})
+978 -773
View File
@@ -1,8 +1,8 @@
import { describe, expect, test } from "bun:test"
import {
LLMClient,
LLMError,
LLMEvent,
LLMRequest,
Message,
Model,
SystemPart,
@@ -11,9 +11,10 @@ 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"
@@ -77,59 +78,77 @@ 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"
let requests: LLMRequest[] = []
const requests: LLMRequest[] = []
const emptyCodeMode = `\n\n${CodeModeInstructions.render({ total: 0, shown: 0, namespaces: [] })}`
type ToolBarrier = {
readonly count: number
readonly started: Deferred.Deferred<void>
readonly release: Deferred.Deferred<void>
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<void>(), release: Deferred.make<void>() }).pipe(
Effect.map((deferreds) => {
const barrier = { count, ...deferreds, active: 0, maxActive: 0 }
toolBarrier = barrier
return barrier
}),
let response: LLMEvent[] = []
let responses: LLMEvent[][] | undefined
let responseStream: Stream.Stream<LLMEvent, LLMError> | undefined
let responseStreams: Stream.Stream<LLMEvent, LLMError>[] | undefined
let streamGate: Deferred.Deferred<void> | undefined
let streamStarted: Deferred.Deferred<void> | undefined
let streamFailure: LLMError | undefined
let toolExecutionGate: Deferred.Deferred<void> | undefined
let toolExecutionsStarted: Deferred.Deferred<void> | 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,
),
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
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" } }),
],
}
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 })
@@ -202,7 +221,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({}),
@@ -216,7 +235,7 @@ const permissionFail = {
resources: ["src/index.ts"],
}),
}),
}
})
const permission = Layer.succeed(
Permission.Service,
Permission.Service.of({
@@ -228,7 +247,11 @@ const permission = Layer.succeed(
list: () => Effect.die("unused"),
}),
)
const transformTools = (registry: Tool.Interface, tools: Readonly<Record<string, Info>>, options?: Tool.Options) =>
const transformTools = (
registry: Tool.Interface,
tools: Readonly<Record<string, Info>>,
options?: Tool.Options,
) =>
registry.transform((draft) =>
Object.entries(tools).forEach(([name, tool]) =>
draft.add({ ...tool, name, options: { ...tool.options, ...options } }),
@@ -236,10 +259,9 @@ const transformTools = (registry: Tool.Interface, tools: Readonly<Record<string,
)
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 }),
@@ -248,24 +270,32 @@ const echo = Layer.effectDiscard(
Effect.gen(function* () {
authorizations.push(context)
executions.push(text)
yield* awaitToolBarrier
activeToolExecutions++
maxActiveToolExecutions = Math.max(maxActiveToolExecutions, activeToolExecutions)
if (activeToolExecutions === toolExecutionsReady && toolExecutionsStarted) {
yield* Deferred.succeed(toolExecutionsStarted, undefined)
}
if (toolExecutionGate) yield* Deferred.await(toolExecutionGate)
return { output: { text }, content: text }
}),
},
defect: {
}).pipe(Effect.ensuring(Effect.sync(() => activeToolExecutions--))),
}),
defect: ({
name: "defect",
description: "Fail unexpectedly",
input: Schema.Struct({}),
output: Schema.Struct({}),
execute: () => awaitToolBarrier.pipe(Effect.andThen(Effect.die("unexpected tool defect"))),
},
storefail: {
execute: () =>
(toolExecutionGate ? Deferred.await(toolExecutionGate) : Effect.void).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 },
),
@@ -439,16 +469,11 @@ 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* () {
@@ -481,9 +506,10 @@ const setup = Effect.gen(function* () {
yield* Effect.forEach(SystemPromptPlugin.Plugins, (plugin) => plugin.effect(pluginHost), {
discard: true,
})
requests = (yield* TestLLM.Service).requests
requests.length = 0
authorizations.length = 0
executions.length = 0
response = []
systemBaseline = "Initial context"
systemRemoved = false
systemUnavailable = false
@@ -492,7 +518,17 @@ const setup = Effect.gen(function* () {
pluginFlushHook = Effect.void
currentModel = model
skillBaselines.clear()
toolBarrier = undefined
responses = undefined
streamFailure = undefined
responseStream = undefined
responseStreams = undefined
streamGate = undefined
streamStarted = undefined
toolExecutionGate = undefined
toolExecutionsStarted = undefined
toolExecutionsReady = 5
activeToolExecutions = 0
maxActiveToolExecutions = 0
yield* agents.transform((draft) =>
draft.update(Agent.ID.make("build"), (agent) => {
agent.mode = "primary"
@@ -531,8 +567,9 @@ const rateLimited = (retryAfterMs?: number) =>
const setupOverflowRecovery = Effect.gen(function* () {
const session = yield* setup
yield* TestLLM.push(TestLLM.text("Earlier answer", "text-earlier"))
yield* runPrompt(session, "Earlier question ".repeat(700))
response = reply.text("Earlier answer", "text-earlier")
yield* admit(session, "Earlier question ".repeat(700))
yield* session.resume(sessionID)
currentModel = recoveryModel
requests.length = 0
return session
@@ -544,7 +581,6 @@ 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* () {
@@ -583,9 +619,6 @@ 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 })
@@ -709,7 +742,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
yield* TestLLM.push(fixture.completeEvents)
response = fixture.completeEvents
yield* session.resume(sessionID)
@@ -736,7 +769,7 @@ const verifyPartialFlushOnFailure = (kind: FragmentKind) =>
const fixture = fragmentFixture(kind, fragmentID(kind, "partial"), ["Partial"])
const failure = providerUnavailable()
yield* admit(session, prompt)
yield* TestLLM.push(TestLLM.failAfter(failure, ...fixture.partialEvents))
responseStream = Stream.concat(Stream.fromIterable(fixture.partialEvents), Stream.fail(failure))
expect(yield* session.resume(sessionID).pipe(Effect.flip)).toBe(failure)
expect(yield* session.context(sessionID)).toMatchObject([
@@ -769,11 +802,9 @@ const verifyPartialFlushOnInterruption = (kind: FragmentKind) =>
const fixture = fragmentFixture(kind, fragmentID(kind, "interrupted"), ["Partial"])
const streamed = yield* Deferred.make<void>()
yield* admit(session, prompt)
yield* TestLLM.push(
Stream.concat(
Stream.fromIterable(fixture.partialEvents),
Stream.fromEffect(Deferred.succeed(streamed, undefined)).pipe(Stream.flatMap(() => Stream.never)),
),
responseStream = Stream.concat(
Stream.fromIterable(fixture.partialEvents),
Stream.fromEffect(Deferred.succeed(streamed, undefined)).pipe(Stream.flatMap(() => Stream.never)),
)
const runner = yield* SessionRunner.Service
@@ -809,7 +840,7 @@ describe("SessionRunnerLLM", () => {
}),
)
yield* admit(session, "Original message")
yield* TestLLM.push(TestLLM.tool("call-removed", "echo", { text: "blocked" }))
responses = [reply.tool("call-removed", "echo", { text: "blocked" })]
yield* session.resume(sessionID)
@@ -841,10 +872,9 @@ 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 }),
@@ -855,12 +885,12 @@ describe("SessionRunnerLLM", () => {
yield* context.progress({ phase: "reading" })
return { output: { answer: query.toUpperCase() } }
}),
},
}),
},
{ codemode: false },
)
yield* admit(session, "Use application context")
yield* TestLLM.push(TestLLM.tool("call-location", "location_context", { query: "hello" }), [])
responses = [reply.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"),
@@ -904,42 +934,50 @@ 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")
yield* TestLLM.push(TestLLM.tool("call-reloaded", "reloaded", {}), [])
const stream = yield* TestLLM.gate
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<void>()
streamStarted = yield* Deferred.make<void>()
const run = yield* session.resume(sessionID).pipe(Effect.forkChild)
yield* stream.started
yield* Deferred.await(streamStarted)
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* stream.release
yield* Deferred.succeed(streamGate, undefined)
yield* Fiber.join(run)
expect(executions).toEqual(["advertised"])
@@ -981,16 +1019,16 @@ describe("SessionRunnerLLM", () => {
const session = yield* setup
const secondStarted = yield* Deferred.make<void>()
const releaseSecond = yield* Deferred.make<void>()
yield* TestLLM.push(
Stream.fromIterable(TestLLM.tool("call-echo", "echo", { text: "background started" })),
responseStreams = [
Stream.fromIterable(reply.tool("call-echo", "echo", { text: "background started" })),
Stream.unwrap(
Deferred.succeed(secondStarted, undefined).pipe(
Effect.andThen(Deferred.await(releaseSecond)),
Effect.as(Stream.fromIterable(TestLLM.stop())),
Effect.as(Stream.fromIterable(reply.stop())),
),
),
Stream.fromIterable(TestLLM.text("Handled completion", "text-completion")),
)
Stream.fromIterable(reply.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)
@@ -1000,7 +1038,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")
}),
)
@@ -1008,7 +1046,9 @@ describe("SessionRunnerLLM", () => {
Effect.gen(function* () {
const session = yield* setup
yield* admit(session, "First")
yield* runPrompt(session, "Second")
yield* admit(session, "Second")
yield* session.resume(sessionID)
expect(requests).toHaveLength(1)
expect(requests[0]?.model).toBe(model)
@@ -1031,9 +1071,12 @@ describe("SessionRunnerLLM", () => {
if (event.type === "session.instructions.updated") instructionEvents.push(event)
}),
)
yield* runPrompt(session, "First")
yield* admit(session, "First")
yield* session.resume(sessionID)
systemBaseline = "Changed context"
yield* runPrompt(session, "Second")
yield* admit(session, "Second")
yield* session.resume(sessionID)
yield* unsubscribe
expect(instructionEvents).toHaveLength(2)
@@ -1070,7 +1113,7 @@ describe("SessionRunnerLLM", () => {
yield* session.wait(sessionID)
expect(requests).toHaveLength(1)
expect(messageRoles(requests[0])).toEqual(["user"])
expect(requests[0]?.messages.map((message) => message.role)).toEqual(["user"])
}),
)
@@ -1079,7 +1122,8 @@ describe("SessionRunnerLLM", () => {
const session = yield* setup
const bus = yield* Bus.Service
const { db } = yield* Database.Service
yield* runPrompt(session, "First")
yield* admit(session, "First")
yield* session.resume(sessionID)
yield* bus.publish(SessionEvent.Moved, {
sessionID,
@@ -1101,11 +1145,14 @@ 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
yield* runPrompt(session, "First")
const first = yield* admit(session, "First")
yield* session.resume(sessionID)
systemBaseline = "Changed context"
const second = yield* runPrompt(session, "Second")
const second = yield* admit(session, "Second")
yield* session.resume(sessionID)
systemBaseline = "Latest context"
yield* runPrompt(session, "Third")
yield* admit(session, "Third")
yield* session.resume(sessionID)
const forked = yield* session.fork({ sessionID, messageID: second.id })
expect(
@@ -1154,9 +1201,11 @@ describe("SessionRunnerLLM", () => {
it.effect("caps nested fork instruction ancestry at the selected message", () =>
Effect.gen(function* () {
const session = yield* setup
yield* runPrompt(session, "First")
yield* admit(session, "First")
yield* session.resume(sessionID)
systemBaseline = "Changed context"
const second = yield* runPrompt(session, "Second")
const second = yield* admit(session, "Second")
yield* session.resume(sessionID)
const child = yield* session.fork({ sessionID, messageID: second.id })
const inheritedFirst = (yield* session.messages({ sessionID: child.id })).find(
@@ -1175,7 +1224,6 @@ describe("SessionRunnerLLM", () => {
initial_values: { "test/context": Instructions.hash("Initial context") },
current_values: { "test/context": Instructions.hash("Initial context") },
})
return undefined
}),
)
@@ -1183,7 +1231,8 @@ describe("SessionRunnerLLM", () => {
Effect.gen(function* () {
const session = yield* setup
const { db } = yield* Database.Service
yield* runPrompt(session, "First")
yield* admit(session, "First")
yield* session.resume(sessionID)
yield* db.delete(InstructionStateTable).where(eq(InstructionStateTable.session_id, sessionID)).run()
yield* admit(session, "Second")
requests.length = 0
@@ -1192,7 +1241,7 @@ describe("SessionRunnerLLM", () => {
expect(requests).toHaveLength(1)
expect(requests[0]?.system.map((part) => part.text)).toEqual([defaultSystem, "Initial context"])
expect(messageRoles(requests[0])).toEqual(["user", "user"])
expect(requests[0]?.messages.map((message) => message.role)).toEqual(["user", "user"])
expect(
yield* db
.select({ id: EventTable.id })
@@ -1210,21 +1259,24 @@ describe("SessionRunnerLLM", () => {
it.effect("keeps the initial instructions stable and derives a chronological update from values", () =>
Effect.gen(function* () {
const session = yield* setup
yield* runPrompt(session, "First")
yield* admit(session, "First")
yield* session.resume(sessionID)
systemBaseline = "Changed context"
yield* runPrompt(session, "Second")
yield* admit(session, "Second")
yield* session.resume(sessionID)
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(messageRoles(requests[1])).toEqual(["user", "system", "user"])
expect(requests[1]?.messages.map((message) => message.role)).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
@@ -1255,7 +1307,7 @@ describe("SessionRunnerLLM", () => {
currentModel = Model.make({ id: "gpt-5", provider: "openai", route: OpenAIChat.route })
yield* admit(session, "First")
yield* TestLLM.push(TestLLM.text("Done", "text-provider-prompt"))
response = reply.text("Done", "text-provider-prompt")
yield* session.resume(sessionID)
expect(requests.at(-1)?.system.map((part) => part.text)).toEqual([
@@ -1278,7 +1330,7 @@ describe("SessionRunnerLLM", () => {
)
yield* admit(session, "First")
yield* TestLLM.push(TestLLM.text("Done", "text-empty-agent-system"))
response = reply.text("Done", "text-empty-agent-system")
yield* session.resume(sessionID)
expect(requests.at(-1)?.system.map((part) => part.text)).toEqual([
@@ -1300,7 +1352,7 @@ describe("SessionRunnerLLM", () => {
)
yield* admit(session, "First")
yield* TestLLM.push(TestLLM.text("Done", "text-build"))
response = reply.text("Done", "text-build")
yield* session.resume(sessionID)
expect(requests.at(-1)?.system.map((part) => part.text)).toEqual(["Build agent instructions", "Initial context"])
@@ -1324,7 +1376,7 @@ describe("SessionRunnerLLM", () => {
})
yield* admit(session, "First")
yield* TestLLM.push(TestLLM.text("Done", "text-reviewer"))
response = reply.text("Done", "text-reviewer")
yield* session.resume(sessionID)
expect(requests.at(-1)?.system.map((part) => part.text)).toEqual(["Reviewer instructions", "Initial context"])
@@ -1344,7 +1396,7 @@ describe("SessionRunnerLLM", () => {
)
yield* admit(session, "First")
yield* TestLLM.push(TestLLM.text("Done", "text-no-system"))
response = reply.text("Done", "text-no-system")
yield* session.resume(sessionID)
expect(requests.at(-1)?.system.map((part) => part.text)).toEqual(["Build agent instructions", "Initial context"])
@@ -1370,7 +1422,7 @@ describe("SessionRunnerLLM", () => {
.pipe(Effect.orDie)
yield* admit(session, "First")
yield* TestLLM.push(TestLLM.text("Done", "text-selected"))
response = reply.text("Done", "text-selected")
yield* session.resume(sessionID)
expect(requests.at(-1)?.system.map((part) => part.text)).toEqual(["Reviewer instructions", "Initial context"])
@@ -1392,7 +1444,7 @@ describe("SessionRunnerLLM", () => {
yield* session.prompt({ sessionID, text: "Inspect files", resume: false })
requests.length = 0
yield* TestLLM.push([])
response = []
const failure = yield* session.resume(sessionID).pipe(Effect.flip)
expect(failure).toMatchObject({
@@ -1413,7 +1465,7 @@ describe("SessionRunnerLLM", () => {
yield* session.prompt({ sessionID, text: "Wait for plugins", resume: false })
requests.length = 0
yield* TestLLM.push([])
response = []
const running = yield* session.resume(sessionID).pipe(Effect.forkChild({ startImmediately: true }))
yield* Effect.yieldNow
@@ -1437,19 +1489,22 @@ describe("SessionRunnerLLM", () => {
}),
)
skillBaselines.set(Agent.ID.make("build"), "Build skills")
yield* runPrompt(session, "First")
yield* admit(session, "First")
yield* session.resume(sessionID)
skillBaselines.set(Agent.ID.make("reviewer"), "Reviewer skills")
yield* bus.publish(SessionEvent.AgentSelected, {
sessionID,
agent: Agent.ID.make("reviewer"),
})
yield* runPrompt(session, "Second")
yield* admit(session, "Second")
yield* session.resume(sessionID)
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"))
}),
)
@@ -1470,7 +1525,9 @@ describe("SessionRunnerLLM", () => {
})
.pipe(Effect.asVoid)
})
yield* runPrompt(session, "First")
yield* admit(session, "First")
yield* session.resume(sessionID)
expect(requests.map((request) => request.system.map((part) => part.text))).toEqual([
[defaultSystem, "Initial context\n\nBuild skills"],
@@ -1493,7 +1550,9 @@ describe("SessionRunnerLLM", () => {
})
.pipe(Effect.asVoid)
})
yield* runPrompt(session, "First")
yield* admit(session, "First")
yield* session.resume(sessionID)
expect(requests.map((request) => request.model)).toEqual([model])
expect(requests.map((request) => request.system.map((part) => part.text))).toEqual([
[defaultSystem, "Initial context"],
@@ -1504,11 +1563,14 @@ describe("SessionRunnerLLM", () => {
it.effect("admits removed context as a chronological System message", () =>
Effect.gen(function* () {
const session = yield* setup
yield* runPrompt(session, "First")
systemRemoved = true
yield* runPrompt(session, "Second")
yield* admit(session, "First")
expect(messageRoles(requests[1])).toEqual(["user", "system", "user"])
yield* session.resume(sessionID)
systemRemoved = true
yield* admit(session, "Second")
yield* session.resume(sessionID)
expect(requests[1]?.messages.map((message) => message.role)).toEqual(["user", "system", "user"])
expect(requests[1]?.messages.at(1)?.content).toEqual([
{ type: "text", text: "System context source removed: test/context" },
])
@@ -1521,7 +1583,9 @@ describe("SessionRunnerLLM", () => {
const session = yield* setup
const contextEntries = yield* InstructionEntry.Service
yield* contextEntries.put({ sessionID, key: "deploy-target", value: "production" })
yield* runPrompt(session, "First")
yield* admit(session, "First")
yield* session.resume(sessionID)
// String values render verbatim inside the initial tagged block.
expect(requests[0]?.system.map((part) => part.text)).toEqual([
@@ -1531,9 +1595,10 @@ 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* runPrompt(session, "Second")
yield* admit(session, "Second")
yield* session.resume(sessionID)
expect(messageRoles(requests[1])).toEqual(["user", "system", "user"])
expect(requests[1]?.messages.map((message) => message.role)).toEqual(["user", "system", "user"])
expect(requests[1]?.messages.at(1)?.content).toEqual([
{
type: "text",
@@ -1551,9 +1616,10 @@ describe("SessionRunnerLLM", () => {
// Deleting the row announces removal through the stored removal text.
yield* contextEntries.remove({ sessionID, key: "deploy-target" })
yield* runPrompt(session, "Third")
yield* admit(session, "Third")
yield* session.resume(sessionID)
expect(messageRoles(requests[2])).toEqual(["user", "system", "user", "system", "user"])
expect(requests[2]?.messages.map((message) => message.role)).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.' },
])
@@ -1566,10 +1632,12 @@ describe("SessionRunnerLLM", () => {
const session = yield* setup
const entries = yield* InstructionEntry.Service
yield* entries.put({ sessionID, key: "nullable", value: "present" })
yield* runPrompt(session, "First")
yield* admit(session, "First")
yield* session.resume(sessionID)
yield* entries.put({ sessionID, key: "nullable", value: null })
yield* runPrompt(session, "Second")
yield* admit(session, "Second")
yield* session.resume(sessionID)
expect(requests[1]?.messages.at(1)?.content).toEqual([
{
@@ -1605,22 +1673,26 @@ describe("SessionRunnerLLM", () => {
Effect.gen(function* () {
const session = yield* setup
const bus = yield* Bus.Service
yield* runPrompt(session, "First")
yield* admit(session, "First")
yield* session.resume(sessionID)
systemBaseline = "Changed context"
yield* runPrompt(session, "Second")
yield* admit(session, "Second")
yield* session.resume(sessionID)
yield* bus.publish(SessionEvent.ModelSelected, {
sessionID,
model: { id: ID.make("replacement"), providerID: Provider.ID.make("fake") },
})
systemBaseline = "Replacement context"
yield* runPrompt(session, "Third")
yield* admit(session, "Third")
yield* session.resume(sessionID)
expect(requests.map((request) => request.system.map((part) => part.text))).toEqual([
[defaultSystem, "Initial context"],
[defaultSystem, "Initial context"],
[defaultSystem, "Initial context"],
])
expect(messageRoles(requests[1])).toEqual(["user", "system", "user"])
expect(requests[1]?.messages.map((message) => message.role)).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",
@@ -1630,7 +1702,8 @@ describe("SessionRunnerLLM", () => {
])
yield* replaySessionProjection(sessionID)
expect(yield* session.messages({ sessionID })).toHaveLength(4)
yield* runPrompt(session, "Fourth")
yield* admit(session, "Fourth")
yield* session.resume(sessionID)
}),
)
@@ -1638,16 +1711,20 @@ describe("SessionRunnerLLM", () => {
Effect.gen(function* () {
const session = yield* setup
const bus = yield* Bus.Service
yield* runPrompt(session, "First")
yield* admit(session, "First")
yield* session.resume(sessionID)
yield* bus.publish(SessionEvent.ModelSelected, {
sessionID,
model: { id: ID.make("replacement"), providerID: Provider.ID.make("fake") },
})
systemUnavailable = true
yield* runPrompt(session, "Second")
yield* admit(session, "Second")
yield* session.resume(sessionID)
systemUnavailable = false
systemBaseline = "Replacement context"
yield* runPrompt(session, "Third")
yield* admit(session, "Third")
yield* session.resume(sessionID)
expect(requests.map((request) => request.system.map((part) => part.text))).toEqual([
[defaultSystem, "Initial context"],
@@ -1661,7 +1738,9 @@ describe("SessionRunnerLLM", () => {
Effect.gen(function* () {
const session = yield* setup
const bus = yield* Bus.Service
yield* runPrompt(session, "First")
yield* admit(session, "First")
yield* session.resume(sessionID)
yield* bus.publish(SessionEvent.Compaction.Started, {
sessionID,
reason: "manual",
@@ -1674,16 +1753,18 @@ describe("SessionRunnerLLM", () => {
recent: "",
})
systemBaseline = "Replacement context"
yield* runPrompt(session, "Second")
yield* admit(session, "Second")
yield* session.resume(sessionID)
expect(requests.map((request) => request.system.map((part) => part.text))).toEqual([
[defaultSystem, "Initial context"],
[defaultSystem, "Initial context"],
])
expect(messageRoles(requests[1])).toEqual(["user", "system", "user"])
expect(requests[1]?.messages.map((message) => message.role)).toEqual(["user", "system", "user"])
expect(requests[1]?.messages.at(1)?.content).toEqual([{ type: "text", text: "Replacement context" }])
yield* replaySessionProjection(sessionID)
yield* runPrompt(session, "Third")
yield* admit(session, "Third")
yield* session.resume(sessionID)
}),
)
@@ -1691,16 +1772,17 @@ describe("SessionRunnerLLM", () => {
Effect.gen(function* () {
const session = yield* setup
currentModel = recoveryModel
const stream = yield* TestLLM.gate
yield* TestLLM.push(
TestLLM.tool("call-active", "echo", { text: "active" }),
streamGate = yield* Deferred.make<void>()
streamStarted = yield* Deferred.make<void>()
responses = [
reply.tool("call-active", "echo", { text: "active" }),
[LLMEvent.textDelta({ id: "summary", text: "durable summary" })],
TestLLM.text("Steer complete", "text-steer"),
TestLLM.text("Queue complete", "text-queue"),
)
reply.text("Steer complete", "text-steer"),
reply.text("Queue complete", "text-queue"),
]
yield* admit(session, "Active work")
const active = yield* session.resume(sessionID).pipe(Effect.forkChild)
yield* stream.started
yield* Deferred.await(streamStarted)
const first = yield* session.compact({ sessionID })
const second = yield* session.compact({ sessionID })
@@ -1720,7 +1802,7 @@ describe("SessionRunnerLLM", () => {
})
expect(yield* SessionPending.has((yield* Database.Service).db, sessionID, "steer")).toBe(false)
yield* stream.release
yield* Deferred.succeed(streamGate, undefined)
yield* Fiber.join(active)
expect(requests).toHaveLength(4)
@@ -1741,15 +1823,16 @@ describe("SessionRunnerLLM", () => {
Effect.gen(function* () {
const session = yield* setup
currentModel = recoveryModel
const stream = yield* TestLLM.gate
yield* TestLLM.push(
TestLLM.text("Active complete", "text-active-failure"),
streamGate = yield* Deferred.make<void>()
streamStarted = yield* Deferred.make<void>()
responses = [
reply.text("Active complete", "text-active-failure"),
[],
TestLLM.text("Continued", "text-after-failure"),
)
reply.text("Continued", "text-after-failure"),
]
yield* admit(session, "Active work")
const active = yield* session.resume(sessionID).pipe(Effect.forkChild)
yield* stream.started
yield* Deferred.await(streamStarted)
const compaction = yield* session.compact({ sessionID })
yield* session.prompt({
@@ -1758,7 +1841,7 @@ describe("SessionRunnerLLM", () => {
delivery: "queue",
resume: false,
})
yield* stream.release
yield* Deferred.succeed(streamGate, undefined)
yield* Fiber.join(active)
expect(requests).toHaveLength(3)
@@ -1803,11 +1886,12 @@ describe("SessionRunnerLLM", () => {
it.effect("manually compacts when the model has no context limit", () =>
Effect.gen(function* () {
const session = yield* setup
yield* TestLLM.push(TestLLM.text("Earlier answer", "text-manual-unknown-history"))
yield* runPrompt(session, "Earlier question")
response = reply.text("Earlier answer", "text-manual-unknown-history")
yield* admit(session, "Earlier question")
yield* session.resume(sessionID)
requests.length = 0
yield* TestLLM.push(TestLLM.text("Manual summary", "text-manual-unknown-summary"))
response = reply.text("Manual summary", "text-manual-unknown-summary")
const compaction = yield* session.compact({ sessionID })
yield* session.resume(sessionID)
@@ -1824,10 +1908,11 @@ describe("SessionRunnerLLM", () => {
it.effect("preserves provider errors from manual compaction", () =>
Effect.gen(function* () {
const session = yield* setup
yield* TestLLM.push(TestLLM.text("Earlier answer", "text-manual-provider-history"))
yield* runPrompt(session, "Earlier question")
response = reply.text("Earlier answer", "text-manual-provider-history")
yield* admit(session, "Earlier question")
yield* session.resume(sessionID)
yield* TestLLM.push([LLMEvent.providerError({ message: "summary unavailable" })])
response = [LLMEvent.providerError({ message: "summary unavailable" })]
const compaction = yield* session.compact({ sessionID })
yield* session.resume(sessionID)
@@ -1842,10 +1927,11 @@ describe("SessionRunnerLLM", () => {
it.effect("preserves typed provider failures from manual compaction", () =>
Effect.gen(function* () {
const session = yield* setup
yield* TestLLM.push(TestLLM.text("Earlier answer", "text-manual-failure-history"))
yield* runPrompt(session, "Earlier question")
response = reply.text("Earlier answer", "text-manual-failure-history")
yield* admit(session, "Earlier question")
yield* session.resume(sessionID)
yield* TestLLM.push(Stream.fail(providerUnavailable()))
responseStream = Stream.fail(providerUnavailable())
const compaction = yield* session.compact({ sessionID })
yield* session.resume(sessionID)
@@ -1860,16 +1946,15 @@ describe("SessionRunnerLLM", () => {
it.effect("records cancelled manual compaction without surfacing an internal failure", () =>
Effect.gen(function* () {
const session = yield* setup
yield* TestLLM.push(TestLLM.text("Earlier answer", "text-manual-interrupt-history"))
yield* runPrompt(session, "Earlier question")
response = reply.text("Earlier answer", "text-manual-interrupt-history")
yield* admit(session, "Earlier question")
yield* session.resume(sessionID)
const streamed = yield* Deferred.make<void>()
const partial = fragmentFixture("text", "text-manual-interrupt-summary", ["Partial summary"])
yield* TestLLM.push(
Stream.concat(
Stream.fromIterable(partial.partialEvents),
Stream.fromEffect(Deferred.succeed(streamed, undefined)).pipe(Stream.flatMap(() => Stream.never)),
),
responseStream = 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)
@@ -1890,8 +1975,9 @@ describe("SessionRunnerLLM", () => {
it.effect("settles an admitted manual compaction when pre-start resolution throws", () =>
Effect.gen(function* () {
const session = yield* setup
yield* TestLLM.push(TestLLM.text("Earlier answer", "text-manual-resolution-history"))
yield* runPrompt(session, "Earlier question")
response = reply.text("Earlier answer", "text-manual-resolution-history")
yield* admit(session, "Earlier question")
yield* session.resume(sessionID)
const compaction = yield* session.compact({ sessionID })
modelResolveHook = Effect.die("model resolution failed")
@@ -1915,16 +2001,18 @@ describe("SessionRunnerLLM", () => {
it.effect("automatically compacts into a completed summary and retained recent turn", () =>
Effect.gen(function* () {
const session = yield* setup
yield* TestLLM.push(TestLLM.textWithUsage("Earlier answer", "text-first", 3_950))
yield* runPrompt(session, "Earlier question ".repeat(180))
response = reply.textWithUsage("Earlier answer", "text-first", 3_950)
yield* admit(session, "Earlier question ".repeat(180))
yield* session.resume(sessionID)
currentModel = compactModel
requests.length = 0
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))
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)
expect(requests).toHaveLength(2)
expect(userTexts(requests[0])[0]).toContain("## Objective")
@@ -1941,11 +2029,12 @@ describe("SessionRunnerLLM", () => {
requests.length = 0
executions.length = 0
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))
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)
expect(requests).toHaveLength(2)
expect(userTexts(requests[0])[0]).toContain(
@@ -1963,12 +2052,14 @@ describe("SessionRunnerLLM", () => {
Effect.gen(function* () {
const session = yield* setup
currentModel = fullOutputModel
yield* TestLLM.push(TestLLM.textWithUsage("Earlier answer", "text-full-output-first", 9_500))
yield* runPrompt(session, "Earlier question")
response = reply.textWithUsage("Earlier answer", "text-full-output-first", 9_500)
yield* admit(session, "Earlier question")
yield* session.resume(sessionID)
requests.length = 0
yield* TestLLM.push(TestLLM.text("Continued", "text-full-output-final"))
yield* runPrompt(session, "Continue")
response = reply.text("Continued", "text-full-output-final")
yield* admit(session, "Continue")
yield* session.resume(sessionID)
expect(requests).toHaveLength(1)
expect(userTexts(requests[0])).toContain("Continue")
@@ -1979,15 +2070,16 @@ describe("SessionRunnerLLM", () => {
it.effect("stops after required automatic compaction fails", () =>
Effect.gen(function* () {
const session = yield* setup
yield* TestLLM.push(TestLLM.textWithUsage("Earlier answer", "text-before-failed-compaction", 3_950))
yield* runPrompt(session, "Earlier question ".repeat(180))
response = reply.textWithUsage("Earlier answer", "text-before-failed-compaction", 3_950)
yield* admit(session, "Earlier question ".repeat(180))
yield* session.resume(sessionID)
currentModel = compactModel
requests.length = 0
yield* TestLLM.push(
responses = [
[LLMEvent.providerError({ message: "Unsupported parameter: max_output_tokens" })],
TestLLM.text("Must not run", "text-after-failed-compaction"),
)
reply.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" })
@@ -2007,15 +2099,16 @@ describe("SessionRunnerLLM", () => {
it.effect("forces one compaction and retries after provider context overflow", () =>
Effect.gen(function* () {
const session = yield* setupOverflowRecovery
yield* TestLLM.push(
responses = [
[
LLMEvent.stepStart({ index: 0 }),
LLMEvent.providerError({ message: "prompt too long", classification: "context-overflow" }),
],
TestLLM.text("## Objective\n- Recover overflow", "text-summary"),
TestLLM.text("Recovered", "text-final"),
)
yield* runPrompt(session, "Continue")
reply.text("## Objective\n- Recover overflow", "text-summary"),
reply.text("Recovered", "text-final"),
]
yield* admit(session, "Continue")
yield* session.resume(sessionID)
expect(requests).toHaveLength(3)
expect(userTexts(requests[1])[0]).toContain("## Objective")
@@ -2036,12 +2129,13 @@ describe("SessionRunnerLLM", () => {
Effect.gen(function* () {
const session = yield* setupOverflowRecovery
currentModel = model
yield* TestLLM.push(
responses = [
[LLMEvent.providerError({ message: "prompt too long", classification: "context-overflow" })],
TestLLM.text("## Objective\n- Recover unknown limit", "text-summary-unknown-limit"),
TestLLM.text("Recovered", "text-final-unknown-limit"),
)
yield* runPrompt(session, "Continue")
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)
expect(requests).toHaveLength(3)
expect(yield* session.context(sessionID)).toMatchObject([
@@ -2055,12 +2149,13 @@ describe("SessionRunnerLLM", () => {
Effect.gen(function* () {
const session = yield* setupOverflowRecovery
currentModel = undersizedContextModel
yield* TestLLM.push(
responses = [
[LLMEvent.providerError({ message: "prompt too long", classification: "context-overflow" })],
TestLLM.text("## Objective\n- Recover undersized limit", "text-summary-undersized-limit"),
TestLLM.text("Recovered", "text-final-undersized-limit"),
)
yield* runPrompt(session, "Continue")
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)
expect(requests).toHaveLength(3)
expect(yield* session.context(sessionID)).toMatchObject([
@@ -2077,7 +2172,7 @@ describe("SessionRunnerLLM", () => {
LLMEvent.stepStart({ index: 0 }),
LLMEvent.providerError({ message: "prompt too long", classification: "context-overflow" }),
]
yield* TestLLM.push(overflow(), TestLLM.text("## Objective\n- Recover once", "text-summary"), overflow())
responses = [overflow(), reply.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")
@@ -2092,23 +2187,22 @@ describe("SessionRunnerLLM", () => {
it.effect("recovers once from a raw context overflow failure", () =>
Effect.gen(function* () {
const session = yield* setupOverflowRecovery
yield* TestLLM.push(
Stream.fail(
new LLMError({
module: "test",
method: "stream",
reason: new InvalidRequestReason({
message: "prompt too long",
classification: "context-overflow",
}),
responseStream = Stream.fail(
new LLMError({
module: "test",
method: "stream",
reason: new InvalidRequestReason({
message: "prompt too long",
classification: "context-overflow",
}),
),
}),
)
yield* TestLLM.push(
TestLLM.text("## Objective\n- Recover raw overflow", "text-summary"),
TestLLM.text("Recovered", "text-final"),
)
yield* runPrompt(session, "Continue")
responses = [
reply.text("## Objective\n- Recover raw overflow", "text-summary"),
reply.text("Recovered", "text-final"),
]
yield* admit(session, "Continue")
yield* session.resume(sessionID)
expect(requests).toHaveLength(3)
expect(yield* session.context(sessionID)).toMatchObject([
@@ -2121,10 +2215,10 @@ describe("SessionRunnerLLM", () => {
it.effect("publishes the original overflow when recovery summarization fails", () =>
Effect.gen(function* () {
const session = yield* setupOverflowRecovery
yield* TestLLM.push(
responses = [
[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")
@@ -2149,29 +2243,26 @@ describe("SessionRunnerLLM", () => {
it.effect("interrupts overflow recovery while the summary provider is running", () =>
Effect.gen(function* () {
const session = yield* setupOverflowRecovery
yield* TestLLM.push(
responses = [
[LLMEvent.providerError({ message: "prompt too long", classification: "context-overflow" })],
TestLLM.text("## Objective\n- Interrupted", "text-summary"),
)
const first = yield* TestLLM.gate
reply.text("## Objective\n- Interrupted", "text-summary"),
]
const firstGate = yield* Deferred.make<void>()
const summaryGate = yield* Deferred.make<void>()
streamGate = firstGate
yield* admit(session, "Continue")
const run = yield* session.resume(sessionID).pipe(Effect.forkChild)
yield* first.started
const summary = yield* TestLLM.gate
yield* first.release
yield* summary.started
while (requests.length < 1) yield* Effect.yieldNow
streamGate = summaryGate
yield* Deferred.succeed(firstGate, undefined)
while (requests.length < 2) yield* Effect.yieldNow
yield* session.interrupt(sessionID)
const exit = yield* Fiber.await(run)
expect(Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause)).toBeTrue()
expect(yield* Fiber.await(run)).toMatchObject({ _tag: "Failure" })
streamGate = undefined
expect(requests).toHaveLength(2)
expect(yield* session.context(sessionID)).toContainEqual(
expect.objectContaining({
type: "compaction",
status: "failed",
reason: "auto",
error: { type: "compaction.interrupted", message: "Compaction was interrupted" },
}),
expect.objectContaining({ type: "compaction", status: "failed", reason: "auto" }),
)
}),
)
@@ -2180,9 +2271,12 @@ describe("SessionRunnerLLM", () => {
Effect.gen(function* () {
const session = yield* setup
const bus = yield* Bus.Service
yield* runPrompt(session, "First")
yield* admit(session, "First")
yield* session.resume(sessionID)
systemBaseline = "Changed context"
yield* runPrompt(session, "Second")
yield* admit(session, "Second")
yield* session.resume(sessionID)
yield* bus.publish(SessionEvent.Compaction.Started, {
sessionID,
reason: "manual",
@@ -2195,7 +2289,8 @@ describe("SessionRunnerLLM", () => {
recent: "",
})
systemUnavailable = true
yield* runPrompt(session, "Third")
yield* admit(session, "Third")
yield* session.resume(sessionID)
// 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"])
@@ -2208,49 +2303,50 @@ describe("SessionRunnerLLM", () => {
const session = yield* setup
yield* admit(session, "Use tools")
yield* TestLLM.push(
TestLLM.complete(
{
reason: { normalized: "tool-calls" },
usage: {
inputTokens: 10,
nonCachedInputTokens: 8,
outputTokens: 4,
reasoningTokens: 1,
cacheReadInputTokens: 2,
},
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" },
],
},
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" } },
}),
),
)
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" } }),
]
yield* session.resume(sessionID)
@@ -2302,12 +2398,12 @@ describe("SessionRunnerLLM", () => {
const session = yield* setup
yield* admit(session, "Echo this")
yield* TestLLM.push(TestLLM.tool("call-echo", "echo", { text: "hello" }), TestLLM.text("Done", "text-final"))
responses = [reply.tool("call-echo", "echo", { text: "hello" }), reply.text("Done", "text-final")]
yield* session.resume(sessionID)
expect(requests).toHaveLength(2)
expect(messageRoles(requests[1])).toEqual(["user", "assistant", "tool"])
expect(requests[1]?.messages.map((message) => message.role)).toEqual(["user", "assistant", "tool"])
expect(authorizations).toMatchObject([{ sessionID, callID: "call-echo" }])
expect(executions).toEqual(["hello"])
const context = yield* session.context(sessionID)
@@ -2332,7 +2428,7 @@ describe("SessionRunnerLLM", () => {
{ type: "assistant", finish: "stop", content: [{ type: "text", text: "Done" }] },
])
const assistant = requireAssistant(context)
expect(yield* recordedStepSettlementTypes(sessionID, assistant.id)).toEqual([
expect((yield* recordedStepSettlementEvents(sessionID, assistant.id)).map((event) => event.type)).toEqual([
"session.step.started.1",
"session.tool.called.1",
"session.tool.success.2",
@@ -2347,16 +2443,18 @@ describe("SessionRunnerLLM", () => {
const bus = yield* Bus.Service
yield* admit(session, "Echo this")
yield* TestLLM.push(TestLLM.tool("call-echo", "echo", { text: "hello" }), TestLLM.stop())
const tools = yield* blockTools()
responses = [reply.tool("call-echo", "echo", { text: "hello" }), reply.stop()]
toolExecutionGate = yield* Deferred.make<void>()
toolExecutionsStarted = yield* Deferred.make<void>()
toolExecutionsReady = 1
const run = yield* Effect.forkChild(session.resume(sessionID))
yield* tools.started
yield* Deferred.await(toolExecutionsStarted)
yield* bus.publish(SessionEvent.ModelSelected, {
sessionID,
model: { id: ID.make("replacement"), providerID: Provider.ID.make("fake") },
})
systemBaseline = "Replacement context"
yield* tools.release
yield* Deferred.succeed(toolExecutionGate, undefined)
yield* Fiber.join(run)
expect(requests.map((request) => request.model)).toEqual([model, replacementModel])
@@ -2364,7 +2462,7 @@ describe("SessionRunnerLLM", () => {
[defaultSystem, "Initial context"],
[defaultSystem, "Initial context"],
])
expect(systemTexts(requests[1])).toContain("Replacement context")
expect(systemTexts(requests[1]!)).toContain("Replacement context")
}),
)
@@ -2373,31 +2471,32 @@ describe("SessionRunnerLLM", () => {
const session = yield* setup
yield* admit(session, "Think first")
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 },
},
}),
),
)
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* session.resume(sessionID)
yield* replaySessionProjection(sessionID)
@@ -2421,7 +2520,7 @@ describe("SessionRunnerLLM", () => {
])
yield* admit(session, "Continue")
yield* TestLLM.push([])
response = []
yield* session.resume(sessionID)
expect(requests[1]?.messages[1]?.content).toEqual([
@@ -2444,16 +2543,17 @@ describe("SessionRunnerLLM", () => {
const session = yield* setup
yield* admit(session, "Check first")
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 } },
}),
),
)
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* session.resume(sessionID)
yield* replaySessionProjection(sessionID)
@@ -2466,7 +2566,7 @@ describe("SessionRunnerLLM", () => {
])
yield* admit(session, "Continue")
yield* TestLLM.push([])
response = []
yield* session.resume(sessionID)
expect(requests[1]?.messages[1]?.content).toEqual([
@@ -2484,32 +2584,33 @@ describe("SessionRunnerLLM", () => {
const session = yield* setup
yield* admit(session, "Search first")
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 } },
}),
),
)
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* session.resume(sessionID)
yield* replaySessionProjection(sessionID)
yield* admit(session, "Continue")
yield* TestLLM.push([])
response = []
yield* session.resume(sessionID)
expect(messageRoles(requests[1])).toEqual(["user", "assistant", "user"])
expect(requests[1]?.messages.map((message) => message.role)).toEqual(["user", "assistant", "user"])
expect(requests[1]?.messages[1]?.content).toMatchObject([
{
type: "tool-call",
@@ -2537,7 +2638,8 @@ describe("SessionRunnerLLM", () => {
const session = yield* setup
yield* admit(session, "Echo five times")
const tools = yield* blockTools(5)
toolExecutionGate = yield* Deferred.make<void>()
toolExecutionsStarted = yield* Deferred.make<void>()
const providerGate = yield* Deferred.make<void>()
const initial = Stream.fromIterable([
LLMEvent.stepStart({ index: 0 }),
@@ -2549,15 +2651,16 @@ describe("SessionRunnerLLM", () => {
LLMEvent.stepFinish({ index: 0, reason: { normalized: "tool-calls" } }),
LLMEvent.finish({ reason: { normalized: "tool-calls" } }),
])
yield* TestLLM.push(
Stream.concat(initial, Stream.fromEffect(Deferred.await(providerGate)).pipe(Stream.flatMap(() => final))),
responseStream = Stream.concat(
initial,
Stream.fromEffect(Deferred.await(providerGate)).pipe(Stream.flatMap(() => final)),
)
const run = yield* session.resume(sessionID).pipe(Effect.forkChild)
yield* tools.started
yield* Deferred.await(toolExecutionsStarted)
expect(executions).toHaveLength(5)
expect(yield* tools.maxActive).toBe(5)
expect(maxActiveToolExecutions).toBe(5)
expect(yield* session.context(sessionID)).toMatchObject([
{ type: "user", text: "Echo five times" },
{
@@ -2574,11 +2677,13 @@ describe("SessionRunnerLLM", () => {
yield* Effect.yieldNow
expect(requests).toHaveLength(1)
yield* tools.release
yield* Deferred.succeed(toolExecutionGate, undefined)
yield* Fiber.join(run)
toolExecutionGate = undefined
toolExecutionsStarted = undefined
expect(executions).toHaveLength(5)
expect(yield* tools.maxActive).toBe(5)
expect(maxActiveToolExecutions).toBe(5)
expect(requests).toHaveLength(2)
}),
)
@@ -2588,15 +2693,17 @@ describe("SessionRunnerLLM", () => {
const session = yield* setup
yield* admit(session, "Echo twice")
yield* TestLLM.push(
TestLLM.tool("tool_0", "echo", { text: "first" }),
TestLLM.tool("tool_0", "echo", { text: "second" }),
responses = [
reply.tool("tool_0", "echo", { text: "first" }),
reply.tool("tool_0", "echo", { text: "second" }),
[],
)
]
yield* session.resume(sessionID)
const expected = [
expect(executions).toEqual(["first", "second"])
expect(requests).toHaveLength(3)
expect(yield* session.context(sessionID)).toMatchObject([
{ type: "user", text: "Echo twice" },
{
type: "assistant",
@@ -2618,14 +2725,33 @@ 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(expected)
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" }] },
},
],
},
])
}),
)
@@ -2634,18 +2760,21 @@ describe("SessionRunnerLLM", () => {
const session = yield* setup
yield* admit(session, "Run once")
yield* TestLLM.push(TestLLM.text("Once", "text-once"))
const stream = yield* TestLLM.gate
response = reply.text("Once", "text-once")
streamGate = yield* Deferred.make<void>()
streamStarted = yield* Deferred.make<void>()
const first = yield* session.resume(sessionID).pipe(Effect.forkChild)
yield* stream.started
yield* Deferred.await(streamStarted)
const second = yield* session.resume(sessionID).pipe(Effect.forkChild)
yield* Effect.yieldNow
expect(requests).toHaveLength(1)
yield* stream.release
yield* Deferred.succeed(streamGate, undefined)
yield* Fiber.join(first)
yield* Fiber.join(second)
streamGate = undefined
streamStarted = undefined
expect(requests).toHaveLength(1)
expect(yield* session.context(sessionID)).toMatchObject([
@@ -2660,19 +2789,22 @@ describe("SessionRunnerLLM", () => {
const session = yield* setup
yield* admit(session, "Start working")
yield* TestLLM.push(TestLLM.stop(), TestLLM.stop())
const stream = yield* TestLLM.gate
responses = [reply.stop(), reply.stop()]
streamGate = yield* Deferred.make<void>()
streamStarted = yield* Deferred.make<void>()
const first = yield* session.resume(sessionID).pipe(Effect.forkChild)
yield* stream.started
yield* Deferred.await(streamStarted)
yield* session.prompt({ sessionID, text: "Change direction" })
yield* stream.release
yield* Deferred.succeed(streamGate, undefined)
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",
@@ -2687,23 +2819,26 @@ describe("SessionRunnerLLM", () => {
const session = yield* setup
yield* admit(session, "Start working")
yield* TestLLM.push(TestLLM.tool("call-echo", "echo", { text: "hello" }), TestLLM.stop(), TestLLM.stop())
const stream = yield* TestLLM.gate
responses = [reply.tool("call-echo", "echo", { text: "hello" }), reply.stop(), reply.stop()]
streamGate = yield* Deferred.make<void>()
streamStarted = yield* Deferred.make<void>()
const first = yield* session.resume(sessionID).pipe(Effect.forkChild)
yield* stream.started
yield* Deferred.await(streamStarted)
yield* session.prompt({
sessionID,
text: "Wait until continuation ends",
delivery: "queue",
})
yield* stream.release
yield* Deferred.succeed(streamGate, undefined)
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"])
}),
)
@@ -2713,11 +2848,12 @@ describe("SessionRunnerLLM", () => {
const { db } = yield* Database.Service
yield* admit(session, "Interrupt current work")
yield* TestLLM.push([], TestLLM.stop())
const stream = yield* TestLLM.gate
responses = [[], reply.stop()]
streamGate = yield* Deferred.make<void>()
streamStarted = yield* Deferred.make<void>()
const run = yield* session.resume(sessionID).pipe(Effect.forkChild)
yield* stream.started
yield* Deferred.await(streamStarted)
yield* session.prompt({
sessionID,
text: "Run after interrupt",
@@ -2728,13 +2864,15 @@ describe("SessionRunnerLLM", () => {
expect(requests).toHaveLength(1)
expect(yield* SessionPending.has(db, sessionID, "queue")).toBe(true)
const resumed = yield* session.resume(sessionID).pipe(Effect.forkChild)
yield* stream.started
yield* stream.release
while (requests.length < 2) yield* Effect.yieldNow
yield* Deferred.succeed(streamGate, undefined)
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"])
}),
)
@@ -2744,11 +2882,12 @@ describe("SessionRunnerLLM", () => {
const { db } = yield* Database.Service
yield* admit(session, "Interrupt current work")
yield* TestLLM.push([], TestLLM.stop())
const stream = yield* TestLLM.gate
responses = [[], reply.stop()]
streamGate = yield* Deferred.make<void>()
streamStarted = yield* Deferred.make<void>()
const run = yield* session.resume(sessionID).pipe(Effect.forkChild)
yield* stream.started
yield* Deferred.await(streamStarted)
yield* session.prompt({
sessionID,
text: "Steer after interrupt",
@@ -2759,13 +2898,15 @@ describe("SessionRunnerLLM", () => {
expect(yield* SessionPending.has(db, sessionID, "steer")).toBe(true)
const resumed = yield* session.resume(sessionID).pipe(Effect.forkChild)
yield* stream.started
yield* stream.release
while (requests.length < 2) yield* Effect.yieldNow
yield* Deferred.succeed(streamGate, undefined)
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"])
}),
)
@@ -2774,20 +2915,23 @@ describe("SessionRunnerLLM", () => {
const session = yield* setup
yield* admit(session, "Start working")
yield* TestLLM.push(TestLLM.stop(), TestLLM.stop(), TestLLM.stop())
const stream = yield* TestLLM.gate
responses = [reply.stop(), reply.stop(), reply.stop()]
streamGate = yield* Deferred.make<void>()
streamStarted = yield* Deferred.make<void>()
const first = yield* session.resume(sessionID).pipe(Effect.forkChild)
yield* stream.started
yield* Deferred.await(streamStarted)
yield* session.prompt({ sessionID, text: "Queue first", delivery: "queue" })
yield* session.prompt({ sessionID, text: "Queue second", delivery: "queue" })
yield* stream.release
yield* Deferred.succeed(streamGate, undefined)
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"])
}),
)
@@ -2802,13 +2946,13 @@ describe("SessionRunnerLLM", () => {
resume: false,
})
yield* TestLLM.push(TestLLM.stop(), TestLLM.stop())
responses = [reply.stop(), reply.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"])
}),
)
@@ -2817,36 +2961,39 @@ describe("SessionRunnerLLM", () => {
const session = yield* setup
yield* admit(session, "Start working")
yield* TestLLM.push(TestLLM.stop(), TestLLM.stop(), TestLLM.stop(), TestLLM.stop())
const firstStream = yield* TestLLM.gate
responses = [reply.stop(), reply.stop(), reply.stop(), reply.stop()]
const firstGate = yield* Deferred.make<void>()
const secondGate = yield* Deferred.make<void>()
streamGate = firstGate
const first = yield* session.resume(sessionID).pipe(Effect.forkChild)
yield* firstStream.started
while (requests.length < 1) yield* Effect.yieldNow
yield* session.prompt({ sessionID, text: "Queue first", delivery: "queue" })
yield* session.prompt({ sessionID, text: "Queue second", delivery: "queue" })
const secondStream = yield* TestLLM.gate
yield* firstStream.release
yield* secondStream.started
streamGate = secondGate
yield* Deferred.succeed(firstGate, undefined)
while (requests.length < 2) yield* Effect.yieldNow
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* secondStream.release
yield* Deferred.succeed(secondGate, undefined)
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",
@@ -2862,19 +3009,22 @@ describe("SessionRunnerLLM", () => {
const session = yield* setup
yield* admit(session, "Start working")
yield* TestLLM.push(TestLLM.stop(), TestLLM.stop())
const stream = yield* TestLLM.gate
responses = [reply.stop(), reply.stop()]
streamGate = yield* Deferred.make<void>()
streamStarted = yield* Deferred.make<void>()
const first = yield* session.resume(sessionID).pipe(Effect.forkChild)
yield* stream.started
yield* Deferred.await(streamStarted)
yield* session.prompt({ sessionID, text: "First steer" })
yield* session.prompt({ sessionID, text: "Second steer" })
yield* stream.release
yield* Deferred.succeed(streamGate, undefined)
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)
@@ -2886,21 +3036,23 @@ describe("SessionRunnerLLM", () => {
const session = yield* setup
yield* admit(session, "Start working")
const failure = invalidRequest()
yield* TestLLM.push(Stream.fail(failure))
const stream = yield* TestLLM.gate
streamFailure = invalidRequest()
streamGate = yield* Deferred.make<void>()
streamStarted = yield* Deferred.make<void>()
const first = yield* session.resume(sessionID).pipe(Effect.forkChild)
yield* stream.started
yield* Deferred.await(streamStarted)
yield* session.prompt({ sessionID, text: "Recover with this" })
yield* stream.release
expect(yield* Fiber.join(first).pipe(Effect.flip)).toBe(failure)
yield* Deferred.succeed(streamGate, undefined)
expect(yield* Fiber.join(first).pipe(Effect.flip)).toBe(streamFailure)
yield* TestLLM.push([])
streamFailure = undefined
streamGate = undefined
streamStarted = undefined
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"])
}),
)
@@ -2937,11 +3089,11 @@ describe("SessionRunnerLLM", () => {
executed: false,
})
requests.length = 0
yield* TestLLM.push([])
response = []
yield* session.resume(sessionID)
expect(requests).toHaveLength(1)
expect(messageRoles(requests[0])).toEqual(["user", "assistant", "tool"])
expect(requests[0]?.messages.map((message) => message.role)).toEqual(["user", "assistant", "tool"])
expect(yield* session.context(sessionID)).toMatchObject([
{ type: "user", text: "Recover interrupted tool" },
{
@@ -2995,11 +3147,11 @@ describe("SessionRunnerLLM", () => {
state: { itemId: "call-hosted-interrupted" },
})
requests.length = 0
yield* TestLLM.push([])
response = []
yield* session.resume(sessionID)
expect(requests).toHaveLength(1)
expect(messageRoles(requests[0])).toEqual(["user", "assistant"])
expect(requests[0]?.messages.map((message) => message.role)).toEqual(["user", "assistant"])
expect(requests[0]?.messages[1]?.content).toMatchObject([
{
type: "tool-call",
@@ -3032,11 +3184,11 @@ describe("SessionRunnerLLM", () => {
name: "echo",
})
requests.length = 0
yield* TestLLM.push([])
response = []
yield* session.resume(sessionID)
expect(requests).toHaveLength(1)
expect(messageRoles(requests[0])).toEqual(["user", "assistant", "tool"])
expect(requests[0]?.messages.map((message) => message.role)).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" } }] },
@@ -3054,13 +3206,11 @@ describe("SessionRunnerLLM", () => {
resume: false,
})
const stream = yield* TestLLM.gate
yield* (yield* SessionExecution.Service).wake(sessionID)
yield* stream.started
yield* stream.release
while (requests.length === 0) yield* Effect.yieldNow
expect(requests).toHaveLength(1)
expect(userTexts(requests[0])).toEqual(["Wait in queue"])
expect(userTexts(requests[0]!)).toEqual(["Wait in queue"])
}),
)
@@ -3076,14 +3226,12 @@ describe("SessionRunnerLLM", () => {
expect(yield* session.resume(sessionID).pipe(Effect.catchDefect(Effect.succeed))).toBe(defect)
fail = false
requests.length = 0
yield* TestLLM.push(TestLLM.stop())
response = reply.stop()
const stream = yield* TestLLM.gate
yield* (yield* SessionExecution.Service).wake(sessionID)
yield* stream.started
yield* stream.release
while (requests.length === 0) yield* Effect.yieldNow
expect(userTexts(requests[0])).toEqual(["Recover promoted input"])
expect(userTexts(requests[0]!)).toEqual(["Recover promoted input"])
}),
)
@@ -3096,17 +3244,21 @@ describe("SessionRunnerLLM", () => {
? Effect.die("fail after prompt promotion commits")
: Effect.void,
)
yield* runPrompt(session, "Run committed promotion")
yield* admit(session, "Run committed promotion")
yield* session.resume(sessionID)
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* runPrompt(session, "Run correlated request")
yield* admit(session, "Run correlated request")
yield* session.resume(sessionID)
expect(requests[0]?.http?.headers).toEqual({
"x-session-affinity": sessionID,
@@ -3130,7 +3282,9 @@ describe("SessionRunnerLLM", () => {
.where(eq(SessionTable.id, sessionID))
.run()
.pipe(Effect.orDie)
yield* runPrompt(session, "Run child request")
yield* admit(session, "Run child request")
yield* session.resume(sessionID)
expect(requests[0]?.http?.headers?.["x-parent-session-id"]).toBe(parentID)
}),
@@ -3147,21 +3301,25 @@ describe("SessionRunnerLLM", () => {
resume: false,
})
const stream = yield* TestLLM.gate
streamGate = yield* Deferred.make<void>()
streamStarted = yield* Deferred.make<void>()
const first = yield* session.resume(sessionID).pipe(Effect.forkChild)
yield* stream.started
yield* Deferred.await(streamStarted)
streamStarted = yield* Deferred.make<void>()
const second = yield* session.resume(otherSessionID).pipe(Effect.forkChild)
yield* stream.started
yield* Deferred.await(streamStarted)
expect(requests).toHaveLength(2)
expect(requests.map((request) => request.providerOptions?.openai?.promptCacheKey)).toEqual([
sessionID,
otherSessionID,
])
yield* stream.release
yield* Deferred.succeed(streamGate, undefined)
yield* Fiber.join(first)
yield* Fiber.join(second)
streamGate = undefined
streamStarted = undefined
}),
)
@@ -3198,20 +3356,23 @@ describe("SessionRunnerLLM", () => {
const session = yield* setup
yield* admit(session, "Retry after failure")
yield* TestLLM.push(Stream.fail(invalidRequest()))
const stream = yield* TestLLM.gate
streamFailure = invalidRequest()
streamGate = yield* Deferred.make<void>()
streamStarted = yield* Deferred.make<void>()
const first = yield* session.resume(sessionID).pipe(Effect.forkChild)
yield* stream.started
yield* Deferred.await(streamStarted)
const second = yield* session.resume(sessionID).pipe(Effect.forkChild)
yield* Effect.yieldNow
expect(requests).toHaveLength(1)
yield* stream.release
yield* Deferred.succeed(streamGate, undefined)
const [firstExit, secondExit] = yield* Effect.all([Fiber.await(first), Fiber.await(second)])
expect(secondExit).toEqual(firstExit)
yield* TestLLM.push([])
streamFailure = undefined
streamGate = undefined
streamStarted = undefined
yield* session.resume(sessionID)
expect(requests).toHaveLength(2)
}),
@@ -3222,7 +3383,7 @@ describe("SessionRunnerLLM", () => {
const session = yield* setup
yield* admit(session, "Call missing")
yield* TestLLM.push(TestLLM.tool("call-missing", "missing", {}), TestLLM.text("Recovered", "text-after-error"))
responses = [reply.tool("call-missing", "missing", {}), reply.text("Recovered", "text-after-error")]
yield* session.resume(sessionID)
expect(requests).toHaveLength(2)
@@ -3251,12 +3412,12 @@ describe("SessionRunnerLLM", () => {
const session = yield* setup
yield* admit(session, "Call defect")
yield* TestLLM.push(TestLLM.tool("call-defect", "defect", {}), TestLLM.text("Recovered", "text-after-defect"))
responses = [reply.tool("call-defect", "defect", {}), reply.text("Recovered", "text-after-defect")]
yield* session.resume(sessionID)
expect(requests).toHaveLength(2)
expect(messageRoles(requests[1])).toEqual(["user", "assistant", "tool"])
expect(requests[1]?.messages.map((message) => message.role)).toEqual(["user", "assistant", "tool"])
const context = yield* session.context(sessionID)
expect(context).toMatchObject([
{ type: "user", text: "Call defect" },
@@ -3276,7 +3437,7 @@ describe("SessionRunnerLLM", () => {
{ type: "assistant", finish: "stop", content: [{ type: "text", text: "Recovered" }] },
])
const assistant = requireAssistant(context)
expect(yield* recordedStepSettlementTypes(sessionID, assistant.id)).toEqual([
expect((yield* recordedStepSettlementEvents(sessionID, assistant.id)).map((event) => event.type)).toEqual([
"session.step.started.1",
"session.tool.called.1",
"session.tool.failed.2",
@@ -3289,10 +3450,9 @@ 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({}),
@@ -3301,13 +3461,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")
yield* TestLLM.push(TestLLM.tool("call-blocked", "blocked", {}), TestLLM.stop())
responses = [reply.tool("call-blocked", "blocked", {}), reply.stop()]
yield* session.resume(sessionID)
@@ -3329,22 +3489,21 @@ 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")
yield* TestLLM.push(TestLLM.tool("call-declined", "declined", {}))
response = reply.tool("call-declined", "declined", {})
const exit = yield* session.resume(sessionID).pipe(Effect.exit)
@@ -3371,10 +3530,9 @@ 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({}),
@@ -3383,13 +3541,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")
yield* TestLLM.push(TestLLM.tool("call-corrected", "corrected", {}), TestLLM.stop())
responses = [reply.tool("call-corrected", "corrected", {}), reply.stop()]
yield* session.resume(sessionID)
@@ -3413,10 +3571,10 @@ describe("SessionRunnerLLM", () => {
const registry = yield* Tool.Service
yield* transformTools(registry, { permissionfail: permissionFail }, { codemode: false })
yield* admit(session, "Reject permission")
yield* TestLLM.push(TestLLM.tool("call-permission", "permissionfail", {}), [
LLMEvent.stepStart({ index: 0 }),
LLMEvent.stepFinish({ index: 0, reason: { normalized: "stop" } }),
])
responses = [
reply.tool("call-permission", "permissionfail", {}),
[LLMEvent.stepStart({ index: 0 }), LLMEvent.stepFinish({ index: 0, reason: { normalized: "stop" } })],
]
yield* session.resume(sessionID)
@@ -3449,22 +3607,21 @@ 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")
yield* TestLLM.push(TestLLM.tool("call-question", "question", {}), [])
responses = [reply.tool("call-question", "question", {}), []]
const run = yield* session.resume(sessionID).pipe(Effect.exit, Effect.forkChild)
const exit = yield* Fiber.join(run)
@@ -3493,19 +3650,21 @@ describe("SessionRunnerLLM", () => {
const session = yield* setup
yield* admit(session, "Settle before failing")
const failure = providerUnavailable()
const tools = yield* blockTools()
yield* TestLLM.push(
TestLLM.failAfter(
failure,
toolExecutionGate = yield* Deferred.make<void>()
responseStream = Stream.concat(
Stream.fromIterable([
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)
yield* tools.started
yield* tools.release
while (executions.length === 0) yield* Effect.yieldNow
yield* Effect.yieldNow
yield* Deferred.succeed(toolExecutionGate, undefined)
expect(yield* Fiber.join(run).pipe(Effect.flip)).toBe(failure)
toolExecutionGate = undefined
const context = yield* session.context(sessionID)
expect(context).toMatchObject([
@@ -3522,7 +3681,7 @@ describe("SessionRunnerLLM", () => {
},
])
const assistant = requireAssistant(context)
expect(yield* recordedStepSettlementTypes(sessionID, assistant.id)).toEqual([
expect((yield* recordedStepSettlementEvents(sessionID, assistant.id)).map((event) => event.type)).toEqual([
"session.step.started.1",
"session.tool.called.1",
"session.tool.success.2",
@@ -3535,17 +3694,19 @@ describe("SessionRunnerLLM", () => {
Effect.gen(function* () {
const session = yield* setup
yield* admit(session, "Interrupt blocked tool")
const tools = yield* blockTools()
yield* TestLLM.push(
TestLLM.hangAfter(
toolExecutionGate = yield* Deferred.make<void>()
responseStream = Stream.concat(
Stream.fromIterable([
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)
yield* tools.started
while (executions.length === 0) yield* Effect.yieldNow
yield* session.interrupt(sessionID)
toolExecutionGate = undefined
expect(yield* Fiber.await(run)).toMatchObject({ _tag: "Failure" })
yield* session.interrupt(sessionID)
@@ -3564,7 +3725,7 @@ describe("SessionRunnerLLM", () => {
},
])
const assistant = requireAssistant(context)
expect(yield* recordedStepSettlementTypes(sessionID, assistant.id)).toEqual([
expect((yield* recordedStepSettlementEvents(sessionID, assistant.id)).map((event) => event.type)).toEqual([
"session.step.started.1",
"session.tool.called.1",
"session.tool.failed.2",
@@ -3578,9 +3739,10 @@ describe("SessionRunnerLLM", () => {
{ type: "assistant", content: [{ type: "tool", id: "call-before-interrupt", state: { status: "error" } }] },
])
requests.length = 0
yield* TestLLM.push([])
responseStream = undefined
response = []
yield* session.resume(sessionID)
expect(messageRoles(requests[0])).toEqual(["user", "assistant", "tool"])
expect(requests[0]?.messages.map((message) => message.role)).toEqual(["user", "assistant", "tool"])
}),
)
@@ -3588,12 +3750,15 @@ describe("SessionRunnerLLM", () => {
Effect.gen(function* () {
const session = yield* setup
yield* admit(session, "Interrupt provider")
const stream = yield* TestLLM.gate
streamGate = yield* Deferred.make<void>()
streamStarted = yield* Deferred.make<void>()
const run = yield* session.resume(sessionID).pipe(Effect.forkChild)
yield* stream.started
yield* Deferred.await(streamStarted)
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)
@@ -3610,13 +3775,16 @@ describe("SessionRunnerLLM", () => {
Effect.gen(function* () {
const session = yield* setup
yield* admit(session, "Interrupt tool settlement")
const tools = yield* blockTools()
yield* TestLLM.push(TestLLM.tool("call-await-interrupt", "echo", { text: "blocked" }))
toolExecutionGate = yield* Deferred.make<void>()
toolExecutionsStarted = yield* Deferred.make<void>()
toolExecutionsReady = 1
response = reply.tool("call-await-interrupt", "echo", { text: "blocked" })
const runner = yield* SessionRunner.Service
const run = yield* runner.drain({ sessionID, force: true }).pipe(Effect.forkChild)
yield* tools.started
yield* Deferred.await(toolExecutionsStarted)
yield* Fiber.interrupt(run)
toolExecutionGate = undefined
expect(yield* Fiber.await(run)).toMatchObject({ _tag: "Failure" })
expect(yield* session.context(sessionID)).toMatchObject([
@@ -3651,10 +3819,10 @@ describe("SessionRunnerLLM", () => {
)
yield* admit(session, "Finish at the limit")
yield* TestLLM.push(
TestLLM.tool("call-terminal", "echo", { text: "done" }),
TestLLM.tool("call-forbidden", "echo", { text: "forbidden" }),
)
responses = [
reply.tool("call-terminal", "echo", { text: "done" }),
reply.tool("call-forbidden", "echo", { text: "forbidden" }),
]
yield* session.resume(sessionID)
@@ -3687,18 +3855,21 @@ describe("SessionRunnerLLM", () => {
)
yield* admit(session, "Start work")
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
responses = [
reply.tool("call-before-steer", "echo", { text: "before" }),
reply.tool("call-after-steer", "echo", { text: "after" }),
reply.stop(),
]
streamGate = yield* Deferred.make<void>()
streamStarted = yield* Deferred.make<void>()
const run = yield* session.resume(sessionID).pipe(Effect.forkChild)
yield* stream.started
yield* Deferred.await(streamStarted)
yield* session.prompt({ sessionID, text: "Change direction" })
yield* stream.release
yield* Deferred.succeed(streamGate, undefined)
yield* Fiber.join(run)
streamGate = undefined
streamStarted = undefined
expect(requests).toHaveLength(3)
expect(requests[1]?.toolChoice).toBeUndefined()
@@ -3711,12 +3882,11 @@ describe("SessionRunnerLLM", () => {
it.effect("projects provider errors as terminal assistant step failures", () =>
Effect.gen(function* () {
const session = yield* setup
yield* TestLLM.push([
LLMEvent.stepStart({ index: 0 }),
LLMEvent.providerError({ message: "Provider unavailable" }),
])
yield* admit(session, "Fail durably")
expect((yield* runPrompt(session, "Fail durably").pipe(Effect.flip)).message).toBe("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(requests).toHaveLength(1)
expect(yield* session.context(sessionID)).toMatchObject([
@@ -3729,9 +3899,11 @@ describe("SessionRunnerLLM", () => {
it.effect("projects provider errors emitted before assistant step start", () =>
Effect.gen(function* () {
const session = yield* setup
yield* TestLLM.push([LLMEvent.providerError({ message: "Provider unavailable" })])
yield* admit(session, "Fail before step")
expect((yield* runPrompt(session, "Fail before step").pipe(Effect.flip)).message).toBe("Provider unavailable")
response = [LLMEvent.providerError({ message: "Provider unavailable" })]
expect((yield* session.resume(sessionID).pipe(Effect.flip)).message).toBe("Provider unavailable")
expect(requests).toHaveLength(1)
expect(yield* session.context(sessionID)).toMatchObject([
@@ -3744,20 +3916,20 @@ describe("SessionRunnerLLM", () => {
it.effect("projects content-filter finishes as visible terminal failures", () =>
Effect.gen(function* () {
const session = yield* setup
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" }),
),
)
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" } }),
]
expect((yield* runPrompt(session, "Blocked response").pipe(Effect.flip)).message).toBe(
"Provider blocked the response",
)
expect((yield* session.resume(sessionID).pipe(Effect.flip)).message).toBe("Provider blocked the response")
expect(yield* session.context(sessionID)).toMatchObject([
{ type: "user" },
{
@@ -3781,18 +3953,22 @@ describe("SessionRunnerLLM", () => {
Effect.gen(function* () {
const session = yield* setup
yield* admit(session, "Tool before blocked response")
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" } }),
),
)
toolExecutionGate = yield* Deferred.make<void>()
toolExecutionsStarted = yield* Deferred.make<void>()
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 run = yield* session.resume(sessionID).pipe(Effect.forkChild)
yield* tools.started
yield* tools.release
yield* Deferred.await(toolExecutionsStarted)
yield* Deferred.succeed(toolExecutionGate, undefined)
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)
@@ -3811,14 +3987,16 @@ describe("SessionRunnerLLM", () => {
it.effect("does not recover context overflow after durable assistant output", () =>
Effect.gen(function* () {
const session = yield* setup
yield* TestLLM.push([
yield* admit(session, "Fail after output")
response = [
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* runPrompt(session, "Fail after output").pipe(Effect.flip)).message).toBe("prompt too long")
]
expect((yield* session.resume(sessionID).pipe(Effect.flip)).message).toBe("prompt too long")
expect(requests).toHaveLength(1)
expect(yield* session.context(sessionID)).toMatchObject([
@@ -3836,10 +4014,11 @@ 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()
yield* TestLLM.push(Stream.fail(failure))
responseStream = Stream.fail(failure)
expect(yield* runPrompt(session, "Fail raw stream durably").pipe(Effect.flip)).toBe(failure)
expect(yield* session.resume(sessionID).pipe(Effect.flip)).toBe(failure)
yield* replaySessionProjection(sessionID)
expect(yield* session.context(sessionID)).toMatchObject([
{ type: "user", text: "Fail raw stream durably" },
@@ -3852,11 +4031,11 @@ describe("SessionRunnerLLM", () => {
Effect.gen(function* () {
const session = yield* setup
yield* admit(session, "Retry transport")
yield* TestLLM.push(Stream.fail(providerUnavailable()))
yield* TestLLM.push(TestLLM.text("Recovered", "retry-success"))
responseStream = Stream.fail(providerUnavailable())
response = reply.text("Recovered", "retry-success")
const run = yield* session.resume(sessionID).pipe(Effect.forkChild)
yield* TestLLM.wait(1)
while (requests.length < 1) yield* Effect.yieldNow
yield* TestClock.adjust("1999 millis")
expect(requests).toHaveLength(1)
yield* TestClock.adjust("1 millis")
@@ -3879,11 +4058,11 @@ describe("SessionRunnerLLM", () => {
Effect.gen(function* () {
const session = yield* setup
yield* admit(session, "Retry rate limit")
yield* TestLLM.push(Stream.fail(rateLimited(5_000)))
yield* TestLLM.push(TestLLM.text("Recovered", "retry-after-success"))
responseStream = Stream.fail(rateLimited(5_000))
response = reply.text("Recovered", "retry-after-success")
const run = yield* session.resume(sessionID).pipe(Effect.forkChild)
yield* TestLLM.wait(1)
while (requests.length < 1) yield* Effect.yieldNow
yield* TestClock.adjust("4999 millis")
expect(requests).toHaveLength(1)
yield* TestClock.adjust("1 millis")
@@ -3895,17 +4074,15 @@ 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()
yield* TestLLM.push(
TestLLM.failAfter(
failure,
LLMEvent.stepStart({ index: 0 }),
LLMEvent.textStart({ id: "partial-rate-limit" }),
LLMEvent.textDelta({ id: "partial-rate-limit", text: "Partial" }),
),
)
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)))
expect(yield* runPrompt(session, "Do not replay partial output").pipe(Effect.flip)).toBe(failure)
expect(yield* session.resume(sessionID).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([
@@ -3924,16 +4101,15 @@ describe("SessionRunnerLLM", () => {
Effect.gen(function* () {
const session = yield* setup
yield* admit(session, "Exhaust retries")
const failure = providerUnavailable()
yield* TestLLM.always(Stream.fail(failure))
streamFailure = providerUnavailable()
const run = yield* session.resume(sessionID).pipe(Effect.forkChild)
yield* TestLLM.wait(1)
while (requests.length < 1) yield* Effect.yieldNow
for (const [index, delay] of [2_000, 4_000, 8_000, 16_000].entries()) {
yield* TestClock.adjust(delay)
yield* TestLLM.wait(index + 2)
while (requests.length < index + 2) yield* Effect.yieldNow
}
expect(yield* Fiber.join(run).pipe(Effect.flip)).toBe(failure)
expect(yield* Fiber.join(run).pipe(Effect.flip)).toBe(streamFailure)
expect(requests).toHaveLength(5)
const database = (yield* Database.Service).db
@@ -3974,11 +4150,11 @@ describe("SessionRunnerLLM", () => {
)
yield* admit(session, "Retry without consuming a step")
const failure = providerUnavailable()
yield* TestLLM.push(Stream.fail(failure))
yield* TestLLM.push(TestLLM.tool("call-after-retry", "echo", { text: "recovered" }), TestLLM.stop())
responseStream = Stream.fail(failure)
responses = [reply.tool("call-after-retry", "echo", { text: "recovered" }), reply.stop()]
const run = yield* session.resume(sessionID).pipe(Effect.forkChild)
yield* TestLLM.wait(1)
while (requests.length < 1) yield* Effect.yieldNow
yield* TestClock.adjust("2 seconds")
yield* Fiber.join(run)
@@ -4009,10 +4185,11 @@ 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()
yield* TestLLM.push(Stream.fail(failure))
streamFailure = failure
expect(yield* runPrompt(session, "Do not retry").pipe(Effect.flip)).toBe(failure)
expect(yield* session.resume(sessionID).pipe(Effect.flip)).toBe(failure)
expect(requests).toHaveLength(1)
expect(yield* recordedEventTypes(sessionID)).not.toContain("session.retry.scheduled.1")
}),
@@ -4021,25 +4198,24 @@ 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" }),
})
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' }),
),
)
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)))
expect(yield* runPrompt(session, "Call a malformed tool").pipe(Effect.flip)).toBe(failure)
expect(yield* session.resume(sessionID).pipe(Effect.flip)).toBe(failure)
const assistant = requireAssistant(yield* session.context(sessionID))
yield* TestLLM.push(TestLLM.stop())
yield* runPrompt(session, "Continue")
response = reply.stop()
yield* admit(session, "Continue")
yield* session.resume(sessionID)
expect(yield* recordedStepSettlementEvents(sessionID, assistant.id)).toMatchObject([
{ type: "session.step.started.1" },
@@ -4061,10 +4237,12 @@ 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}`
yield* TestLLM.push(
TestLLM.toolCalls(
responses = [
[
LLMEvent.stepStart({ index: 0 }),
LLMEvent.toolInputStart({ id: "call-malformed", name: "echo" }),
LLMEvent.toolInputDelta({ id: "call-malformed", name: "echo", text: raw }),
LLMEvent.toolInputEnd({ id: "call-malformed", name: "echo" }),
@@ -4073,11 +4251,13 @@ describe("SessionRunnerLLM", () => {
name: "echo",
raw,
}),
),
TestLLM.stop(),
)
LLMEvent.stepFinish({ index: 0, reason: { normalized: "tool-calls" } }),
LLMEvent.finish({ reason: { normalized: "tool-calls" } }),
],
reply.stop(),
]
yield* runPrompt(session, "Recover malformed tool input")
yield* session.resume(sessionID)
expect(requests).toHaveLength(2)
expect(executions).toEqual([])
@@ -4133,7 +4313,7 @@ describe("SessionRunnerLLM", () => {
})
if (!failed) throw new Error("Malformed tool assistant missing")
expect(failed.error).toBeUndefined()
expect(yield* recordedStepSettlementTypes(sessionID, failed.id)).toEqual([
expect((yield* recordedStepSettlementEvents(sessionID, failed.id)).map((event) => event.type)).toEqual([
"session.step.started.1",
"session.tool.failed.2",
"session.step.ended.1",
@@ -4156,24 +4336,31 @@ describe("SessionRunnerLLM", () => {
Effect.gen(function* () {
const session = yield* setup
yield* admit(session, "Run parallel tools")
const tools = yield* blockTools()
yield* TestLLM.push(
TestLLM.toolCalls(
toolExecutionGate = yield* Deferred.make<void>()
toolExecutionsStarted = yield* Deferred.make<void>()
toolExecutionsReady = 1
responses = [
[
LLMEvent.stepStart({ index: 0 }),
LLMEvent.toolCall({ id: "call-valid", name: "echo", input: { text: "valid" } }),
LLMEvent.toolInputError({
id: "call-malformed",
name: "echo",
raw: '{"text":"partial',
}),
),
TestLLM.stop(),
)
LLMEvent.stepFinish({ index: 0, reason: { normalized: "tool-calls" } }),
LLMEvent.finish({ reason: { normalized: "tool-calls" } }),
],
reply.stop(),
]
const run = yield* session.resume(sessionID).pipe(Effect.forkChild)
yield* tools.started
yield* Deferred.await(toolExecutionsStarted)
expect(requests).toHaveLength(1)
yield* tools.release
yield* Deferred.succeed(toolExecutionGate, undefined)
yield* Fiber.join(run)
toolExecutionGate = undefined
toolExecutionsStarted = undefined
expect(requests).toHaveLength(2)
expect(executions).toEqual(["valid"])
@@ -4192,20 +4379,23 @@ describe("SessionRunnerLLM", () => {
Effect.gen(function* () {
const session = yield* setup
yield* admit(session, "Interrupt malformed recovery")
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',
}),
),
)
toolExecutionGate = yield* Deferred.make<void>()
toolExecutionsStarted = yield* Deferred.make<void>()
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 run = yield* session.resume(sessionID).pipe(Effect.forkChild)
yield* tools.started
yield* Deferred.await(toolExecutionsStarted)
while (
!(yield* session.context(sessionID)).some(
(message) =>
@@ -4215,6 +4405,8 @@ 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)
@@ -4235,21 +4427,19 @@ 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" }),
})
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' }),
),
)
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)))
expect(yield* runPrompt(session, "Fail malformed hosted input").pipe(Effect.flip)).toBe(failure)
expect(yield* session.resume(sessionID).pipe(Effect.flip)).toBe(failure)
expect(requireAssistant(yield* session.context(sessionID))).toMatchObject({
error: { type: "provider.invalid-output", message: "Invalid hosted tool input" },
content: [
@@ -4267,24 +4457,22 @@ 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" }),
})
yield* TestLLM.push(
TestLLM.failAfter(
failure,
LLMEvent.stepStart({ index: 0 }),
LLMEvent.toolInputError({
id: "call-malformed",
name: "echo",
raw: '{"text":"partial',
}),
),
)
responseStream = Stream.fromIterable([
LLMEvent.stepStart({ index: 0 }),
LLMEvent.toolInputError({
id: "call-malformed",
name: "echo",
raw: '{"text":"partial',
}),
]).pipe(Stream.concat(Stream.fail(failure)))
expect(yield* runPrompt(session, "Fail after malformed input").pipe(Effect.flip)).toBe(failure)
expect(yield* session.resume(sessionID).pipe(Effect.flip)).toBe(failure)
expect(requireAssistant(yield* session.context(sessionID))).toMatchObject({
error: { type: "provider.invalid-output", message: "Provider failed after malformed input" },
content: [
@@ -4303,22 +4491,25 @@ describe("SessionRunnerLLM", () => {
it.effect("continues after repeated malformed tool input", () =>
Effect.gen(function* () {
const session = yield* setup
const malformed = (id: string) =>
TestLLM.toolCalls(
LLMEvent.toolInputError({
id,
name: "echo",
raw: '{"text":"partial',
}),
)
yield* TestLLM.push(
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 = [
malformed("call-first"),
TestLLM.tool("call-valid-between", "echo", { text: "valid" }),
reply.tool("call-valid-between", "echo", { text: "valid" }),
malformed("call-second"),
TestLLM.stop(),
)
reply.stop(),
]
yield* runPrompt(session, "Keep producing malformed tools")
yield* session.resume(sessionID)
expect(requests).toHaveLength(4)
expect(executions).toEqual(["valid"])
@@ -4335,17 +4526,20 @@ describe("SessionRunnerLLM", () => {
agent.steps = 2
}),
)
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* 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")]
yield* runPrompt(session, "Stop malformed tools at the step limit")
yield* session.resume(sessionID)
expect(requests).toHaveLength(2)
expect(requests[0]?.toolChoice).toBeUndefined()
@@ -4358,23 +4552,28 @@ describe("SessionRunnerLLM", () => {
Effect.gen(function* () {
const session = yield* setup
yield* admit(session, "Do not continue failed provider")
const tools = yield* blockTools()
yield* TestLLM.push([
toolExecutionGate = yield* Deferred.make<void>()
toolExecutionsStarted = yield* Deferred.make<void>()
toolExecutionsReady = 1
response = [
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* tools.started
yield* tools.release
yield* Deferred.await(toolExecutionsStarted)
yield* Deferred.succeed(toolExecutionGate, undefined)
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* recordedStepSettlementTypes(sessionID, assistant.id)).toEqual([
expect((yield* recordedStepSettlementEvents(sessionID, assistant.id)).map((event) => event.type)).toEqual([
"session.step.started.1",
"session.tool.called.1",
"session.tool.success.2",
@@ -4386,15 +4585,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* TestLLM.push([
yield* admit(session, "Fail hosted tool durably")
response = [
LLMEvent.stepStart({ index: 0 }),
hostedCall("call-hosted-provider-error", "effect"),
LLMEvent.providerError({ message: "Provider unavailable" }),
])
]
expect((yield* runPrompt(session, "Fail hosted tool durably").pipe(Effect.flip)).message).toBe(
"Provider unavailable",
)
expect((yield* session.resume(sessionID).pipe(Effect.flip)).message).toBe("Provider unavailable")
expect(requests).toHaveLength(1)
const context = yield* session.context(sessionID)
@@ -4406,7 +4605,7 @@ describe("SessionRunnerLLM", () => {
},
])
const assistant = requireAssistant(context)
expect(yield* recordedStepSettlementTypes(sessionID, assistant.id)).toEqual([
expect((yield* recordedStepSettlementEvents(sessionID, assistant.id)).map((event) => event.type)).toEqual([
"session.step.started.1",
"session.tool.called.1",
"session.tool.failed.2",
@@ -4418,15 +4617,14 @@ describe("SessionRunnerLLM", () => {
it.effect("preserves a tool defect before provider failure settlement", () =>
Effect.gen(function* () {
const session = yield* setup
yield* TestLLM.push([
yield* admit(session, "Defect while provider fails")
response = [
LLMEvent.stepStart({ index: 0 }),
LLMEvent.toolCall({ id: "call-defect-provider-error", name: "defect", input: {} }),
LLMEvent.providerError({ message: "Provider unavailable" }),
])
]
expect((yield* runPrompt(session, "Defect while provider fails").pipe(Effect.flip)).message).toBe(
"Provider unavailable",
)
expect((yield* session.resume(sessionID).pipe(Effect.flip)).message).toBe("Provider unavailable")
const context = yield* session.context(sessionID)
const assistant = requireAssistant(context)
@@ -4445,15 +4643,13 @@ describe("SessionRunnerLLM", () => {
Effect.gen(function* () {
const session = yield* setup
yield* admit(session, "Storage fails while provider fails")
yield* TestLLM.push([
response = [
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" },
@@ -4464,11 +4660,10 @@ describe("SessionRunnerLLM", () => {
it.effect("durably fails a hosted tool left unresolved at normal provider EOF", () =>
Effect.gen(function* () {
const session = yield* setup
yield* TestLLM.push([LLMEvent.stepStart({ index: 0 }), hostedCall("call-hosted-eof", "effect")])
yield* admit(session, "Fail hosted tool at EOF")
response = [LLMEvent.stepStart({ index: 0 }), hostedCall("call-hosted-eof", "effect")]
expect((yield* runPrompt(session, "Fail hosted tool at EOF").pipe(Effect.flip)).message).toBe(
"Provider did not return a tool result",
)
expect((yield* session.resume(sessionID).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([
@@ -4497,9 +4692,15 @@ describe("SessionRunnerLLM", () => {
it.effect("fails an unresolved hosted tool before one clean step end", () =>
Effect.gen(function* () {
const session = yield* setup
yield* TestLLM.push(TestLLM.stop(hostedCall("call-hosted-clean-end", "effect")))
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* runPrompt(session, "Settle hosted tool before ending")
yield* session.resume(sessionID)
const assistant = requireAssistant(yield* session.context(sessionID))
const bus = yield* recordedStepSettlementEvents(sessionID, assistant.id)
@@ -4521,24 +4722,21 @@ describe("SessionRunnerLLM", () => {
yield* admit(session, "Fail unresolved tools")
const failure = invalidRequest()
const providerFailed = yield* Deferred.make<void>()
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)),
),
),
toolExecutionGate = yield* Deferred.make<void>()
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 run = yield* session.resume(sessionID).pipe(Effect.forkChild)
yield* Deferred.await(providerFailed)
yield* tools.release
yield* Deferred.succeed(toolExecutionGate, undefined)
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)
@@ -4559,15 +4757,14 @@ 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()
yield* TestLLM.push(
Stream.concat(
Stream.fromIterable([LLMEvent.stepStart({ index: 0 }), hostedCall("call-hosted-raw-failure", "effect")]),
Stream.fail(failure),
),
responseStream = Stream.concat(
Stream.fromIterable([LLMEvent.stepStart({ index: 0 }), hostedCall("call-hosted-raw-failure", "effect")]),
Stream.fail(failure),
)
expect(yield* runPrompt(session, "Fail hosted tool on raw failure").pipe(Effect.flip)).toBe(failure)
expect(yield* session.resume(sessionID).pipe(Effect.flip)).toBe(failure)
expect(requests).toHaveLength(1)
const assistant = requireAssistant(yield* session.context(sessionID))
const bus = yield* recordedStepSettlementEvents(sessionID, assistant.id)
@@ -4596,13 +4793,15 @@ describe("SessionRunnerLLM", () => {
it.effect("rejects a second text start before the open fragment ends", () =>
Effect.gen(function* () {
const session = yield* setup
yield* TestLLM.push([
yield* admit(session, "Two blocks")
response = [
LLMEvent.stepStart({ index: 0 }),
LLMEvent.textStart({ id: "text-1" }),
LLMEvent.textStart({ id: "text-2" }),
])
]
const defect = yield* runPrompt(session, "Two blocks").pipe(Effect.catchDefect(Effect.succeed))
const defect = yield* session.resume(sessionID).pipe(Effect.catchDefect(Effect.succeed))
expect(defect).toBeInstanceOf(Error)
if (!(defect instanceof Error)) return
expect(defect.message).toBe("text start before end: text-2")
@@ -4612,18 +4811,21 @@ describe("SessionRunnerLLM", () => {
it.effect("projects sequential text fragments as separate content parts", () =>
Effect.gen(function* () {
const session = yield* setup
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" }),
),
)
yield* admit(session, "Two blocks")
yield* runPrompt(session, "Two blocks")
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)
expect(yield* session.context(sessionID)).toMatchObject([
{ type: "user", text: "Two blocks" },
@@ -4653,7 +4855,7 @@ describe("SessionRunnerLLM", () => {
it.effect("rejects duplicate streamed text starts", () =>
Effect.gen(function* () {
const session = yield* setup
yield* TestLLM.push([LLMEvent.textStart({ id: "text-1" }), LLMEvent.textStart({ id: "text-1" })])
response = [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)
@@ -4665,16 +4867,19 @@ describe("SessionRunnerLLM", () => {
it.effect("transitions streamed raw tool input to parsed called input", () =>
Effect.gen(function* () {
const session = yield* setup
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"),
),
)
yield* admit(session, "Call provider tool")
yield* runPrompt(session, "Call provider tool")
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)
expect(yield* session.context(sessionID)).toMatchObject([
{ type: "user", text: "Call provider tool" },
@@ -4689,7 +4894,7 @@ describe("SessionRunnerLLM", () => {
it.effect("rejects malformed streamed tool input ordering", () =>
Effect.gen(function* () {
const session = yield* setup
yield* TestLLM.push([LLMEvent.toolInputDelta({ id: "call-1", name: "read", text: "{}" })])
response = [LLMEvent.toolInputDelta({ id: "call-1", name: "read", text: "{}" })]
const defect = yield* session.resume(sessionID).pipe(Effect.catchDefect(Effect.succeed))
expect(defect).toBeInstanceOf(Error)
+1 -1
View File
@@ -22,7 +22,7 @@ const createCodeMode = (tools: ReadonlyMap<string, Info>) =>
test("execute describes invariant Code Mode behavior", () => {
expect(createCodeMode(new Map()).description).toBe(
[
"Run JavaScript in a confined Code Mode runtime to orchestrate tool calls and compose their results.",
"Run JavaScript to orchestrate tool calls and compose their results through `{ code }` in a confined Code Mode runtime.",
"Imports, direct filesystem access, and timers are unavailable. Do not use `fetch`; all external access goes through `tools`.",
"Within `{ code }`, the only callable tools are those explicitly listed in the Code Mode catalog instructions or returned by `search`. Inside `{ code }`, ignore tools shown outside the Code Mode catalog. They are not available in the Code Mode runtime.",
'Call tools through `tools` using only exact paths and signatures from the catalog. Do not infer or normalize tool names; preserve bracket notation such as `tools.<namespace>["tool-name"](input)`.',
+16 -147
View File
@@ -30,15 +30,6 @@ 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<string, KV.Value>()
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: {} }],
@@ -47,11 +38,6 @@ 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: {} }],
@@ -74,15 +60,11 @@ const websearch = Layer.succeed(
WebSearch.Service.of({
transform: () => Effect.die("unused"),
reload: () => Effect.die("unused"),
providers: () => Effect.succeed(providers),
providers: () => Effect.succeed([]),
default: () => Effect.succeed(undefined),
query: (input) =>
Effect.gen(function* () {
Effect.sync(() => {
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
}),
}),
@@ -91,11 +73,7 @@ const form = Layer.succeed(
Form.Service,
Form.Service.of({
create: () => Effect.die("unused"),
ask: (input) =>
Effect.sync(() => {
formRequests.push(input)
return formResponses.shift() ?? formResponse
}),
ask: () => Effect.die("unused"),
get: () => Effect.die("unused"),
list: () => Effect.die("unused"),
state: () => Effect.die("unused"),
@@ -106,19 +84,22 @@ const form = Layer.succeed(
const kv = Layer.succeed(
KV.Service,
KV.Service.of({
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),
get: () => Effect.succeed(undefined),
set: () => Effect.void,
remove: () => Effect.void,
}),
)
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", () => {
@@ -221,116 +202,4 @@ 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)
}),
)
})
+2 -21
View File
@@ -113,22 +113,7 @@ export interface Page {
readonly render: (input: { readonly data?: Record<string, any> }) => JSX.Element
}
export interface SlotMap {
readonly app: Readonly<Record<string, never>>
readonly "home.footer": Readonly<Record<string, never>>
readonly "sidebar.content": {
readonly sessionID: string
}
readonly "sidebar.footer": Readonly<Record<string, never>>
}
export type SlotName = keyof SlotMap
export type Slot<Name extends SlotName = SlotName> = (props: SlotMap[Name]) => JSX.Element
export interface App {
readonly version: string
readonly channel: string
}
export type Slot = (props: Record<string, any>) => JSX.Element
export type ToastVariant = "info" | "success" | "warning" | "error"
@@ -323,21 +308,17 @@ 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 extends SlotName>(name: Name, render: Slot<Name>) => () => void
readonly slot: (name: string, render: Slot) => () => void
}
export interface Context {
readonly options: Readonly<Record<string, any>>
readonly location: LocationRef | undefined
readonly app: App
readonly renderer: CliRenderer
readonly client: OpenCodeClient
readonly data: Data
+15
View File
@@ -269,6 +269,21 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo
}),
),
)
.add(
HttpApiEndpoint.post("session.archive", "/api/session/:sessionID/archive", {
params: { sessionID: Session.ID },
success: HttpApiSchema.NoContent,
error: SessionNotFoundError,
})
.middleware(sessionLocationMiddleware)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.session.archive",
summary: "Archive session",
description: "Archive a session.",
}),
),
)
.add(
HttpApiEndpoint.post("session.move", "/api/session/:sessionID/move", {
params: { sessionID: Session.ID },
+8
View File
@@ -86,6 +86,13 @@ export const Renamed = Event.durable({
})
export type Renamed = typeof Renamed.Type
export const Archived = Event.durable({
type: "session.archived",
...options,
schema: Base,
})
export type Archived = typeof Archived.Type
export const UsageRecorded = Event.durable({
type: "session.usage.recorded",
...options,
@@ -550,6 +557,7 @@ export const Definitions = Event.inventory(
ModelSelected,
Moved,
Renamed,
Archived,
UsageUpdated,
Deleted,
Forked,
@@ -104,6 +104,7 @@ describe("public event manifest", () => {
"session.model.selected.1",
"session.moved.1",
"session.renamed.1",
"session.archived.1",
"session.usage.recorded.1",
"session.forked.2",
"session.input.promoted.1",
+16
View File
@@ -201,6 +201,22 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
return HttpApiSchema.NoContent.make()
}),
)
.handle(
"session.archive",
Effect.fn(function* (ctx) {
yield* session.archive(ctx.params.sessionID).pipe(
Effect.catchTag("Session.NotFoundError", (error) =>
Effect.fail(
new SessionNotFoundError({
sessionID: error.sessionID,
message: `Session not found: ${error.sessionID}`,
}),
),
),
)
return HttpApiSchema.NoContent.make()
}),
)
.handle(
"session.move",
Effect.fn(function* (ctx) {
-36
View File
@@ -1,36 +0,0 @@
{
"$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:"
}
}
-9
View File
@@ -1,9 +0,0 @@
#!/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`
-45
View File
@@ -1,45 +0,0 @@
#!/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<string, string | { import: string; types: string }>
}
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 })
}
-77
View File
@@ -1,77 +0,0 @@
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<OklchColor | undefined>((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)}`
}
-57
View File
@@ -1,57 +0,0 @@
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,
},
}
}
-76
View File
@@ -1,76 +0,0 @@
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<keyof Theme, "thinkingOpacity" | "_hasSelectedListItemText">
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<string, HexColor | RefName>
theme: Omit<Record<ThemeColor, ColorValue>, "selectedListItemText" | "backgroundMenu"> & {
selectedListItemText?: ColorValue
backgroundMenu?: ColorValue
thinkingOpacity?: number
}
}
-10
View File
@@ -1,10 +0,0 @@
/* This file is auto-generated by SST. Do not edit. */
/* tslint:disable */
/* eslint-disable */
/* deno-fmt-ignore-file */
/* biome-ignore-all lint: auto-generated */
/// <reference path="../../sst-env.d.ts" />
import "sst"
export {}
-11
View File
@@ -1,11 +0,0 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "./tsconfig.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "dist",
"noEmit": false,
"declaration": true
},
"include": ["src"]
}
-12
View File
@@ -1,12 +0,0 @@
{
"$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
}
}
-1
View File
@@ -83,7 +83,6 @@
"@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:",
+4 -1
View File
@@ -1127,7 +1127,10 @@ function App(props: { pair?: DialogPairCredentials }) {
</Match>
</Switch>
</box>
<PluginSlot name="app" input={{}} mode="all" />
<box flexShrink={0}>
<PluginSlot name="app.bottom" />
</box>
<PluginSlot name="app" />
</Show>
</box>
</box>
+4 -2
View File
@@ -1,6 +1,5 @@
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,
@@ -15,9 +14,12 @@ 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/component"
import { createComponentTheme, type ComponentTheme } from "../theme/v2/component"
import { resolveThemeDocument } from "../theme/v2/resolve"
import { themeModes } from "../theme/v2/select"
import { createEffect, createMemo, onCleanup, onMount, type Accessor, type ParentProps } from "solid-js"
import { createStore, produce } from "solid-js/store"
import { createSimpleContext } from "./helper"
@@ -1,17 +1,20 @@
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 { abbreviateHome } from "../../runtime"
import { FilePath } from "../../ui/file-path"
import { stringWidth } from "../../util/string-width"
function Directory(props: { context: Plugin.Context; maxWidth: number }) {
const paths = useTuiPaths()
const directory = createMemo(() =>
props.context.location ? props.context.ui.format.path(props.context.location.directory) : undefined,
props.context.location ? abbreviateHome(props.context.location.directory, paths.home) : undefined,
)
return (
<Show when={directory()}>
{(value) => <FilePath value={value()} maxWidth={props.maxWidth} fg={props.context.theme.themeV2.text.subdued} />}
{(value) => <FilePath value={value()} maxWidth={props.maxWidth} fg={props.context.theme.text.subdued} />}
</Show>
)
}
@@ -24,18 +27,16 @@ function Mcp(props: { context: Plugin.Context }) {
return (
<Show when={list().length}>
<box gap={1} flexDirection="row" flexShrink={0}>
<text fg={props.context.theme.themeV2.text.default}>
<text fg={props.context.theme.text.default}>
<Switch>
<Match when={failed()}>
<span style={{ fg: props.context.theme.themeV2.text.feedback.error.default }}> </span>
<span style={{ fg: props.context.theme.text.feedback.error.default }}> </span>
</Match>
<Match when={true}>
<span
style={{
fg:
count() > 0
? props.context.theme.themeV2.text.feedback.success.default
: props.context.theme.themeV2.text.subdued,
count() > 0 ? props.context.theme.text.feedback.success.default : props.context.theme.text.subdued,
}}
>
{" "}
@@ -44,13 +45,14 @@ function Mcp(props: { context: Plugin.Context }) {
</Switch>
{count()} MCP
</text>
<text fg={props.context.theme.themeV2.text.subdued}>/status</text>
<text fg={props.context.theme.text.subdued}>/status</text>
</box>
</Show>
)
}
function View(props: { context: Plugin.Context }) {
const app = useTuiApp()
const dimensions = useTerminalDimensions()
const mcpWidth = createMemo(() => {
const list = props.context.data.location.mcp.server.list(props.context.location) ?? []
@@ -72,12 +74,12 @@ function View(props: { context: Plugin.Context }) {
>
<Directory
context={props.context}
maxWidth={Math.max(2, dimensions().width - 8 - stringWidth(props.context.app.version) - mcpWidth())}
maxWidth={Math.max(2, dimensions().width - 8 - stringWidth(app.version) - mcpWidth())}
/>
<Mcp context={props.context} />
<box flexGrow={1} />
<box flexShrink={0}>
<text fg={props.context.theme.themeV2.text.subdued}>{props.context.app.version}</text>
<text fg={props.context.theme.text.subdued}>{app.version}</text>
</box>
</box>
)
@@ -1,15 +1,18 @@
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 ? props.context.ui.format.path(props.context.location.directory) : undefined,
props.context.location ? abbreviateHome(props.context.location.directory, paths.home) : undefined,
)
return (
<Show when={directory()}>
{(value) => <FilePath value={value()} maxWidth={38} fg={props.context.theme.themeV2.text.subdued} />}
</Show>
<Show when={directory()}>{(value) => <FilePath value={value()} maxWidth={38} fg={themeV2.text.subdued} />}</Show>
)
}
@@ -16,6 +16,7 @@ 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"
@@ -1050,6 +1051,7 @@ function DiffViewerHelpDialog(props: { context: Plugin.Context }) {
}
function Commands(props: { context: Plugin.Context }) {
const dialog = useDialog()
props.context.keymap.layer(() => ({
mode: "global",
commands: [
@@ -1081,7 +1083,7 @@ function Commands(props: { context: Plugin.Context }) {
returnRoute,
},
})
props.context.ui.dialog.clear()
dialog.clear()
},
},
],
@@ -1,9 +1,12 @@
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 }) {
props.context.keymap.layer(() => ({
const dialog = useDialog()
Keymap.createLayer(() => ({
mode: "global",
commands: [
{
@@ -13,7 +16,7 @@ function Commands(props: { context: Plugin.Context }) {
palette: true,
run() {
props.context.ui.router.navigate({ type: "plugin", name: "scrap" })
props.context.ui.dialog.clear()
dialog.clear()
},
},
],
@@ -26,7 +29,7 @@ function Scrap(props: { context: Plugin.Context }) {
const { themeV2 } = useTheme()
const { themeV2: elevatedTheme } = useTheme().contextual("elevated")
props.context.keymap.layer(() => ({
Keymap.createLayer(() => ({
commands: [
{
bind: "escape",
+7 -22
View File
@@ -13,7 +13,7 @@ import {
import path from "path"
import { stat } from "fs/promises"
import { fileURLToPath, pathToFileURL } from "url"
import type { Context, Dialog, Page, Slot, SlotMap, SlotName, Toast } from "@opencode-ai/plugin/tui/context"
import type { Context, Dialog, Page, Slot, 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"
@@ -21,7 +21,7 @@ import { useClient } from "../context/client"
import { useData } from "../context/data"
import { Keymap } from "../context/keymap"
import { useRoute } from "../context/route"
import { useTuiApp, useTuiLifecycle, useTuiPaths } from "../context/runtime"
import { useTuiLifecycle } from "../context/runtime"
import { useLocation } from "../context/location"
import { useTheme } from "../context/theme"
import { DialogAlert } from "../ui/dialog-alert"
@@ -31,7 +31,6 @@ 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 {
@@ -48,7 +47,7 @@ type Value = {
readonly ready: () => boolean
readonly list: () => ReadonlyArray<State>
readonly route: (id: string, name: string) => Page["render"] | undefined
readonly slot: <Name extends SlotName>(name: Name) => ReadonlyArray<Slot<Name>>
readonly slot: (name: string) => ReadonlyArray<Slot>
readonly activate: (id: string) => Promise<boolean>
readonly deactivate: (id: string) => Promise<boolean>
}
@@ -76,8 +75,6 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver }>
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()
@@ -123,6 +120,7 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver }>
dialog.setCentered(options.centered ?? false)
},
clear() {
if (!activeDialog) return
dialog.clear()
},
alert(options) {
@@ -220,12 +218,11 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver }>
get location() {
return location.current
},
app: { version: app.version, channel: app.channel },
renderer,
client: client.api,
data,
attention,
theme,
theme: theme.themeV2,
keymap: {
layer: Keymap.createLayer,
dispatch: keymap.dispatch,
@@ -238,9 +235,6 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver }>
ui: {
dialog: dialogApi,
toast: toastApi,
format: {
path: (value) => abbreviateHome(value, paths.home),
},
router: {
register(page) {
if (store.registrations[item.plugin.id]?.routes[page.name])
@@ -536,16 +530,7 @@ export function PluginRoute(props: { readonly fallback: (id: string, name: strin
return <>{content()}</>
}
export function PluginSlot<Name extends SlotName>(props: {
readonly name: Name
readonly input: SlotMap[Name]
readonly mode: "all" | "replace"
}) {
export function PluginSlot(props: { readonly name: string; readonly input?: Record<string, any> }) {
const plugins = usePlugin()
const renderers = createMemo(() => {
const items = plugins.slot(props.name)
if (props.mode === "replace") return items.slice(-1)
return items
})
return <For each={renderers()}>{(render) => render(props.input)}</For>
return <For each={plugins.slot(props.name)}>{(render) => render(props.input ?? {})}</For>
}
+2 -1
View File
@@ -85,10 +85,11 @@ export function Home() {
/>
</pluginRuntime.Slot>
</box>
<PluginSlot name="home.bottom" />
<box flexGrow={1} minHeight={0} />
</box>
<box width="100%" flexShrink={0}>
<PluginSlot name="home.footer" input={{}} mode="replace" />
<PluginSlot name="home.footer" />
</box>
<Show when={forms()[0]?.id} keyed>
{(_) => {
+1 -3
View File
@@ -5,13 +5,11 @@ 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,
)
@@ -63,7 +61,7 @@ export function Footer() {
</text>
</Match>
<Match when={connected()}>
<Show when={permission.mode !== "auto" && permissions().length > 0}>
<Show when={permissions().length > 0}>
<text fg={themeV2.text.feedback.warning.default}>
<span style={{ fg: themeV2.text.feedback.warning.default }}></span> {permissions().length} Permission
{permissions().length > 1 ? "s" : ""}
+5 -1
View File
@@ -746,7 +746,11 @@ export function FormPrompt(props: { form: FormWithLocation }) {
<Show when={!confirm() && answerField()}>
<box paddingLeft={1} gap={1}>
<box>
<text fg={themeV2.text.default}>{answerField()!.description ?? formLabel(answerField()!)}</text>
<text fg={themeV2.text.default}>
{answerField()!.description ?? formLabel(answerField()!)}
{answerField()!.required ? " (required)" : ""}
{multi() ? " (select all that apply)" : ""}
</text>
</box>
<Show when={textual() ? answerField()!.key : undefined} keyed>
<box paddingLeft={1}>
+24 -19
View File
@@ -77,6 +77,7 @@ 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"
@@ -157,7 +158,6 @@ 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(() => promptedPermissions().length > 0 || forms().length > 0)
const disabled = createMemo(() => permissions().length > 0 || forms().length > 0)
const pending = createMemo(() => {
const completed = messages().findLast((x) => x.type === "assistant" && x.time.completed)?.id
@@ -915,6 +915,7 @@ export function Session() {
>
<box flexDirection="row" flexGrow={1} minHeight={0}>
<box flexGrow={1} minHeight={0} paddingBottom={1} paddingLeft={2} paddingRight={2} gap={1}>
<PluginSlot name="session.header" input={{ sessionID: route.sessionID }} />
<Show when={session()}>
<scrollbox
ref={(r) => (scroll = r)}
@@ -959,6 +960,7 @@ export function Session() {
</Show>
</scrollbox>
<box flexShrink={0}>
<PluginSlot name="session.composer.top" input={{ sessionID: route.sessionID }} />
<Composer
sessionID={route.sessionID}
open={composer.open || (!!session()?.parentID && forms().length === 0)}
@@ -967,10 +969,10 @@ export function Session() {
/>
<Switch>
<Match when={composer.open || (!!session()?.parentID && forms().length === 0)}>{null}</Match>
<Match when={promptedPermissions().length > 0}>
<Show when={promptedPermissions()[0]?.id} keyed>
<Match when={permissions().length > 0}>
<Show when={permissions()[0]?.id} keyed>
{(_) => {
const request = promptedPermissions()[0]
const request = permissions()[0]
return request ? (
<PermissionPrompt request={request} directory={session()?.location.directory} />
) : null
@@ -2325,17 +2327,6 @@ 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
@@ -2350,10 +2341,16 @@ 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 = useToolPermission(() => props.part)
const permission = createMemo(() => {
const request = data.session.permission.list(ctx.sessionID)?.[0]
return request?.source?.type === "tool" && request.source.callID === props.part.id
})
const error = createMemo(() => (props.part.state.status === "error" ? props.part.state.error.message : undefined))
@@ -2532,10 +2529,15 @@ 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 = useToolPermission(() => props.part)
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
})
return (
<box
border={["left"]}
@@ -2612,7 +2614,10 @@ function Shell(props: ToolProps) {
const client = useClient()
const data = useData()
const pathFormatter = usePathFormatter()
const permission = useToolPermission(() => props.part)
const permission = createMemo(() => {
const request = data.session.permission.list(ctx.sessionID)?.[0]
return request?.source?.type === "tool" && request.source.callID === props.part.id
})
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")
+2 -2
View File
@@ -53,12 +53,12 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
</Show>
</box>
</pluginRuntime.Slot>
<PluginSlot name="sidebar.content" input={{ sessionID: props.sessionID }} mode="all" />
<PluginSlot name="sidebar.content" input={{ sessionID: props.sessionID }} />
</box>
</scrollbox>
<box flexShrink={0} gap={1} paddingTop={1}>
<PluginSlot name="sidebar.footer" input={{}} mode="replace" />
<PluginSlot name="sidebar.footer" />
</box>
</box>
</Show>
+3 -1
View File
@@ -1,7 +1,9 @@
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 }
+74 -2
View File
@@ -1,5 +1,4 @@
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" }
@@ -34,7 +33,80 @@ 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 { ColorValue, HexColor, RefName, Theme, ThemeColor, ThemeV1Json, Variant } from "@opencode-ai/theme/tui/v1"
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<keyof Theme, "thinkingOpacity" | "_hasSelectedListItemText">
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<string, HexColor | RefName>
theme: Omit<Record<ThemeColor, ColorValue>, "selectedListItemText" | "backgroundMenu"> & {
selectedListItemText?: ColorValue
backgroundMenu?: ColorValue
thinkingOpacity?: number
}
}
export const DEFAULT_THEMES: Record<string, ThemeV1Json> = {
aura,
@@ -1,6 +1,6 @@
import type { RGBA } from "@opentui/core"
import type { Accessor } from "solid-js"
import type { Mode, ResolvedThemeView } from "@opencode-ai/theme/tui"
import type { Mode, ResolvedThemeView } from "./index"
export function createComponentTheme(current: Accessor<ResolvedThemeView>, mode: Accessor<Mode>) {
return {
@@ -1,4 +1,4 @@
import type { HueName, ThemeDocument } from "./schema.js"
import type { HueName, ThemeDocument } from "./schema"
export const DEFAULT_CATEGORICAL = [
"blue",
@@ -4,8 +4,8 @@ import type {
StatefulColorDefinition,
TextDefinition,
ThemeTokensDefinition,
} from "./index.js"
import { ActionState } from "./schema.js"
} from "./index"
import { ActionState } from "./schema"
export function expandTheme<Definition extends ModeDefinition>(definition: Definition): Definition {
return {
+57
View File
@@ -0,0 +1,57 @@
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,
},
}
}
@@ -29,7 +29,7 @@ export {
type ContextKey,
type TextDefinition,
type ThemeTokensDefinition,
} from "./schema.js"
} from "./schema"
export type {
Categorical,
@@ -42,9 +42,7 @@ export type {
ResolvedTheme,
ResolvedThemeView,
StatefulColor,
} 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"
} from "./types"
export { DEFAULT_CATEGORICAL } from "./defaults"
export { migrateV1 } from "./v1-migrate"
export { selectTheme, selectThemeMode, supportsThemeMode, themeModes } from "./select"
@@ -1,8 +1,8 @@
import { RGBA } from "@opentui/core"
import { Schema } from "effect"
import { DEFAULT_CATEGORICAL, DEFAULT_THEME } from "./defaults.js"
import { expandTheme, expandTokens, mergeTheme } from "./expand.js"
import { fallback } from "./fallback.js"
import { DEFAULT_CATEGORICAL, DEFAULT_THEME } from "./defaults"
import { expandTheme, expandTokens, mergeTheme } from "./expand"
import { fallback } from "./fallback"
import {
ActionState,
ActionVariant,
@@ -12,7 +12,7 @@ import {
HueStep,
ThemeDefinition,
ThemeDocument,
} from "./schema.js"
} from "./schema"
import type {
ActionStateKey,
HueDefinition,
@@ -22,8 +22,8 @@ import type {
ResolvedThemeView,
StatefulColorDefinition,
ThemeTokensDefinition,
} from "./index.js"
import { selectTheme, selectThemeMode } from "./select.js"
} from "./index"
import { selectTheme, selectThemeMode } from "./select"
const decodeThemeDefinitionSchema = Schema.decodeUnknownSync(ThemeDefinition)
@@ -1,4 +1,4 @@
import { expandTheme, mergeTheme } from "./expand.js"
import { expandTheme, mergeTheme } from "./expand"
import type {
FileThemeDefinition,
MergeModeDefinition,
@@ -6,7 +6,7 @@ import type {
ModeDefinition,
ThemeDefinition,
ThemeDocument,
} from "./index.js"
} from "./index"
export function selectTheme(
document: ThemeDocument & { light: ThemeDefinition; dark: ThemeDefinition },
+52
View File
@@ -0,0 +1,52 @@
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<ResolvedTheme>
readonly mode: Accessor<Mode>
readonly component: ComponentTheme
}
const ThemeContext = createContext<ThemeRuntime>()
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
}
@@ -1,5 +1,5 @@
import { SyntaxStyle, type RGBA, type ThemeTokenStyle } from "@opentui/core"
import type { Mode, ResolvedThemeView } from "./index.js"
import type { Mode, ResolvedThemeView } from "./index"
export function generateSyntax(theme: ResolvedThemeView, mode: Mode) {
const step = mode === "light" ? 800 : 200
@@ -9,7 +9,7 @@ import type {
MarkdownToken,
ContextKey,
SyntaxToken,
} from "./schema.js"
} from "./schema"
export type ResolvedActionState = "default" | ActionState
export type ResolvedFormfieldState = ResolvedActionState
@@ -1,9 +1,9 @@
import { RGBA } from "@opentui/core"
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"
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"
type ThemeColor = Exclude<keyof Theme, "thinkingOpacity" | "_hasSelectedListItemText">
type ChromaticHue = "red" | "orange" | "yellow" | "green" | "cyan" | "blue" | "purple"
@@ -2,9 +2,10 @@
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"
+5 -2
View File
@@ -1,8 +1,11 @@
import { expect, test } from "bun:test"
import { createSignal } from "solid-js"
import { RGBA } from "@opentui/core"
import { DEFAULT_THEME, resolveTheme, selectTheme, type ContextKey } from "@opencode-ai/theme/tui"
import { createComponentTheme } from "../../../src/theme/component"
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"
test("provides reactive properties, states, contexts, and color operations", () => {
const [resolved, setResolved] = createSignal(resolveTheme(selectTheme(DEFAULT_THEME, "light")))
+4 -8
View File
@@ -1,14 +1,10 @@
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")
+2 -9
View File
@@ -1,13 +1,6 @@
import { expect, test } from "bun:test"
import {
selectTheme,
selectThemeMode,
supportsThemeMode,
themeModes,
type HueDefinition,
type ThemeDefinition,
type ThemeDocument,
} from "@opencode-ai/theme/tui"
import type { HueDefinition, ThemeDefinition, ThemeDocument } from "../../../src/theme/v2"
import { selectTheme, selectThemeMode, supportsThemeMode, themeModes } from "../../../src/theme/v2/select"
const hue = {} as HueDefinition
const light = { hue, categorical: ["blue"], text: { default: "#111111", subdued: "#222222" } } satisfies ThemeDefinition
+1 -1
View File
@@ -1,5 +1,5 @@
import { expect, test } from "bun:test"
import type { BackgroundDefinition, TextDefinition, ThemeDefinition, ThemeDocument } from "@opencode-ai/theme/tui"
import type { BackgroundDefinition, TextDefinition, ThemeDefinition, ThemeDocument } from "../../../src/theme/v2"
const text = {
default: "$hue.neutral.900",
@@ -1,13 +1,9 @@
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)
-1
View File
@@ -18,7 +18,6 @@
},
"devDependencies": {
"@astrojs/cloudflare": "14.1.4",
"@opencode-ai/theme": "workspace:*",
"@types/bun": "catalog:",
"astro": "7.1.3",
"effect": "catalog:",
+2 -2
View File
@@ -2,7 +2,7 @@
import { Schema, SchemaAST } from "effect"
import { format } from "prettier"
import { ThemeDefinition, ThemeDocument } from "@opencode-ai/theme/tui"
import { ThemeDefinition, ThemeDocument } from "../../tui/src/theme/v2/schema"
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
\`@opencode-ai/theme/tui\`. Changes to the runtime schema update
\`packages/tui/src/theme/v2/schema.ts\`. Changes to the runtime schema update
this section through \`bun run generate\`.
### Hue tokens
@@ -30,7 +30,7 @@
## Token reference
This reference is generated from the Effect schema in
`@opencode-ai/theme/tui`. Changes to the runtime schema update
`packages/tui/src/theme/v2/schema.ts`. Changes to the runtime schema update
this section through `bun run generate`.
### Hue tokens
-3
View File
@@ -38,9 +38,6 @@ 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`