Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 182494f885 | |||
| 2b767d8cb1 |
@@ -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"],
|
||||
|
||||
@@ -15,7 +15,6 @@
|
||||
],
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./testing": "./src/testing.ts",
|
||||
"./*": "./src/*.ts"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
)
|
||||
}),
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)`.',
|
||||
|
||||
@@ -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) })
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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`)
|
||||
|
||||
@@ -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))))
|
||||
|
||||
@@ -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")
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)`.',
|
||||
|
||||
@@ -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)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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:"
|
||||
}
|
||||
}
|
||||
@@ -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`
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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)}`
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
Vendored
-10
@@ -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 {}
|
||||
@@ -1,11 +0,0 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "dist",
|
||||
"noEmit": false,
|
||||
"declaration": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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:",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
{(_) => {
|
||||
|
||||
@@ -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" : ""}
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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 {
|
||||
@@ -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 },
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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")))
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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,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)
|
||||
|
||||
@@ -18,7 +18,6 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@astrojs/cloudflare": "14.1.4",
|
||||
"@opencode-ai/theme": "workspace:*",
|
||||
"@types/bun": "catalog:",
|
||||
"astro": "7.1.3",
|
||||
"effect": "catalog:",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`
|
||||
|
||||
|
||||
Reference in New Issue
Block a user