Compare commits

..

13 Commits

Author SHA1 Message Date
Kit Langton 13b2c81a25 feat(tui): manage pending steers 2026-08-06 21:52:32 -04:00
Kit Langton d81ae0f0d4 feat(tui): manage queued prompts 2026-08-06 21:28:32 -04:00
Kit Langton 9b021f5879 fix(tui): keep queued dock rail transparent 2026-08-06 22:08:40 +00:00
Kit Langton 7426ccc3ab fix(tui): remove queued dock background 2026-08-06 22:04:26 +00:00
Kit Langton ca8144359e fix(tui): reduce queued prompt chrome 2026-08-06 21:58:47 +00:00
Kit Langton 143f6a7f66 fix(tui): refine queued prompt dock 2026-08-06 21:47:26 +00:00
Kit Langton 98622d247a fix(tui): dock queued prompts by composer 2026-08-06 20:56:54 +00:00
Kit Langton 9d348a7f39 feat(tui): collapse large prompt queues 2026-08-06 20:49:01 +00:00
Kit Langton d4686f247b feat(tui): queue prompts with option enter 2026-08-06 20:26:05 +00:00
Dax 20fa444f31 fix(config): omit unset optional values (#40918) 2026-08-06 13:06:03 -07:00
Kit Langton c66d84169a fix(tui): open authorization links (#40912) 2026-08-06 15:26:13 -04:00
Aiden Cline 7dbe8c4c13 refactor(mcp): remove unused registration status (#40904) 2026-08-06 13:59:22 -05:00
Aiden Cline 8864b01d0b feat(core): increase retained compaction context (#40906) 2026-08-06 13:45:06 -05:00
93 changed files with 4147 additions and 5456 deletions
@@ -12,7 +12,6 @@ type GenericModelOptions = Omit<RouteDefaultsInput, "providerOptions"> &
ProviderAuthOption<"optional"> & {
readonly provider?: string
readonly baseURL: string
readonly queryParams?: Readonly<Record<string, string>>
readonly providerOptions?: OpenAIProviderOptionsInput
}
@@ -20,8 +19,6 @@ export interface Settings extends ProviderPackage.Settings {
readonly apiKey?: string
readonly baseURL: string
readonly provider?: string
readonly providerOptions?: OpenAIProviderOptionsInput
readonly queryParams?: Readonly<Record<string, string>>
}
export type FamilyModelOptions = Omit<RouteDefaultsInput, "providerOptions"> &
@@ -34,11 +31,11 @@ export const routes = [OpenAICompatibleChat.route]
export const configure = (input: GenericModelOptions) => {
const provider = input.provider ?? "openai-compatible"
const { provider: _, baseURL, apiKey: _apiKey, auth: _auth, queryParams, ...rest } = input
const { provider: _, baseURL, apiKey: _apiKey, auth: _auth, ...rest } = input
const route = OpenAICompatibleChat.route.with({
...rest,
provider,
endpoint: { baseURL, query: queryParams },
endpoint: { baseURL },
auth: AuthOptions.bearer(input, []),
})
return {
@@ -78,8 +75,6 @@ export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsIn
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
limits: settings.limits,
provider: settings.provider,
providerOptions: settings.providerOptions,
queryParams: settings.queryParams === undefined ? undefined : { ...settings.queryParams },
}).model(modelID)
export const baseten = define(profiles.baseten)
-18
View File
@@ -64,24 +64,6 @@ describe("provider package entrypoints", () => {
expect(xai.route.defaults.providerOptions).toMatchObject({ xai: { reasoningEffort: "high", store: false } })
})
test("maps OpenAI-compatible package settings onto the executable model", async () => {
const OpenAICompatible = await import("@opencode-ai/ai/providers/openai-compatible")
const selected = OpenAICompatible.model("custom-model", {
apiKey: "fixture",
baseURL: "https://provider.example.test/v1",
provider: "example",
queryParams: { version: "preview" },
providerOptions: { openai: { reasoningEffort: "high" } },
})
expect(String(selected.provider)).toBe("example")
expect(selected.route.endpoint).toMatchObject({
baseURL: "https://provider.example.test/v1",
query: { version: "preview" },
})
expect(selected.route.defaults.providerOptions).toEqual({ openai: { reasoningEffort: "high" } })
})
test("maps package settings onto the executable model", () => {
const selected = model("gpt-5", {
apiKey: "fixture",
@@ -10,7 +10,6 @@ const statusLabels = {
connected: "mcp.status.connected",
failed: "mcp.status.failed",
needs_auth: "mcp.status.needs_auth",
needs_client_registration: "mcp.status.needs_client_registration",
disabled: "mcp.status.disabled",
} as const
@@ -57,7 +56,7 @@ export const DialogSelectMcp: Component = () => {
}
const error = () => {
const s = mcpStatus()
if (s?.status === "failed" || s?.status === "needs_client_registration") return s.error
if (s?.status === "failed") return s.error
}
const enabled = () => status() === "connected"
return (
@@ -426,8 +426,7 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
"bg-icon-success-base": status() === "connected",
"bg-icon-critical-base": status() === "failed",
"bg-border-weak-base": status() === "disabled",
"bg-icon-warning-base":
status() === "needs_auth" || status() === "needs_client_registration",
"bg-icon-warning-base": status() === "needs_auth",
}}
/>
<span class="flex flex-col min-w-0 flex-1">
@@ -35,7 +35,6 @@ describe("hasNonBlockingServiceIssue", () => {
test("detects MCP failures that do not block chatting", () => {
expect(hasNonBlockingServiceIssue({ mcp: ["failed"], lsp: [] })).toBe(true)
expect(hasNonBlockingServiceIssue({ mcp: ["needs_auth"], lsp: [] })).toBe(true)
expect(hasNonBlockingServiceIssue({ mcp: ["needs_client_registration"], lsp: [] })).toBe(true)
expect(hasNonBlockingServiceIssue({ mcp: ["connected", "pending", "disabled"], lsp: [] })).toBe(false)
})
@@ -48,7 +47,6 @@ describe("hasNonBlockingServiceIssue", () => {
describe("hasServiceNeedingAttention", () => {
test("detects MCP states that need user attention", () => {
expect(hasServiceNeedingAttention({ mcp: ["needs_auth"] })).toBe(true)
expect(hasServiceNeedingAttention({ mcp: ["needs_client_registration"] })).toBe(true)
})
test("ignores states that do not need user attention", () => {
@@ -2,7 +2,7 @@ import type { LspStatus } from "@/types"
import type { McpServer } from "@opencode-ai/client/promise"
export function hasServiceNeedingAttention(input: { mcp: Array<McpServer["status"]["status"]> }) {
return input.mcp.some((status) => status === "needs_auth" || status === "needs_client_registration")
return input.mcp.some((status) => status === "needs_auth")
}
export function hasNonBlockingServiceIssue(input: {
@@ -13,7 +13,6 @@ export async function toggleMcp(input: {
needs_auth: input.authenticate,
disabled: input.connect,
failed: input.connect,
needs_client_registration: input.connect,
}[input.status]()
await input.refresh()
}
@@ -153,4 +153,88 @@ describe("v2 session reducer", () => {
expect(result).toMatchObject({ sessionID: "ses_1", missing: "msg_user", touched: [] })
})
test("removes cancelled input from the pending promotion fold", () => {
const reducer = createV2SessionReducer()
reducer.reduce(
[],
event({
...base,
id: "evt_admitted",
type: "session.input.admitted",
data: {
sessionID: "ses_1",
inputID: "msg_user",
input: { type: "user", delivery: "queue", data: { text: "cancel me" } },
},
}),
)
reducer.reduce(
[],
event({
...base,
id: "evt_cancelled",
type: "session.input.cancelled",
data: { sessionID: "ses_1", inputID: "msg_user" },
}),
)
const result = reducer.reduce(
[],
event({
...base,
id: "evt_promoted",
type: "session.input.promoted",
data: { sessionID: "ses_1", inputID: "msg_user" },
}),
)
expect(result).toMatchObject({ missing: "msg_user" })
})
test("keeps steered input available to the promotion fold", () => {
const reducer = createV2SessionReducer()
reducer.reduce(
[],
event({
...base,
id: "evt_admitted",
type: "session.input.admitted",
data: {
sessionID: "ses_1",
inputID: "msg_user",
input: { type: "user", delivery: "queue", data: { text: "steer me" } },
},
}),
)
reducer.reduce(
[],
event({
...base,
id: "evt_steered",
type: "session.input.steered",
data: { sessionID: "ses_1", inputID: "msg_user" },
}),
)
reducer.reduce(
[],
event({
...base,
id: "evt_queued",
type: "session.input.queued",
data: { sessionID: "ses_1", inputID: "msg_user" },
}),
)
const result = reducer.reduce(
[],
event({
...base,
id: "evt_promoted",
type: "session.input.promoted",
data: { sessionID: "ses_1", inputID: "msg_user" },
}),
)
expect(result?.messages).toMatchObject([{ id: "msg_user", type: "user", text: "steer me" }])
})
})
@@ -29,6 +29,9 @@ export function createV2SessionReducer() {
case "session.input.admitted":
pending.set(key(sessionID, event.data.inputID), event.data.input)
return result([...source])
case "session.input.cancelled":
pending.delete(key(sessionID, event.data.inputID))
return
case "session.input.promoted": {
const input = pending.get(key(sessionID, event.data.inputID))
pending.delete(key(sessionID, event.data.inputID))
@@ -34,7 +34,6 @@ function icon(status: McpServer["status"]) {
case "needs_auth":
return "⚠"
case "failed":
case "needs_client_registration":
return "✗"
default:
return "○"
@@ -45,8 +44,6 @@ function describe(status: McpServer["status"]) {
switch (status.status) {
case "needs_auth":
return "needs authentication"
case "needs_client_registration":
return `needs client registration: ${status.error}`
case "failed":
return `failed: ${status.error}`
default:
+73 -27
View File
@@ -251,38 +251,52 @@ 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_22Input = { readonly sessionID: Session.ID }
export type Endpoint5_22Output = ReadonlyArray<InstructionEntry.Info>
export type SessionInstructionsEntryListOperation<E = never> = (
export type Endpoint5_22Input = { readonly sessionID: Session.ID; readonly inputID: SessionMessage.ID }
export type Endpoint5_22Output = void
export type SessionPendingCancelOperation<E = never> = (
input: Endpoint5_22Input,
) => Effect.Effect<Endpoint5_22Output, E>
export type Endpoint5_23Input = {
export type Endpoint5_23Input = { readonly sessionID: Session.ID; readonly inputID: SessionMessage.ID }
export type Endpoint5_23Output = void
export type SessionPendingSteerOperation<E = never> = (input: Endpoint5_23Input) => Effect.Effect<Endpoint5_23Output, E>
export type Endpoint5_24Input = { readonly sessionID: Session.ID; readonly inputID: SessionMessage.ID }
export type Endpoint5_24Output = void
export type SessionPendingQueueOperation<E = never> = (input: Endpoint5_24Input) => Effect.Effect<Endpoint5_24Output, E>
export type Endpoint5_25Input = { readonly sessionID: Session.ID }
export type Endpoint5_25Output = ReadonlyArray<InstructionEntry.Info>
export type SessionInstructionsEntryListOperation<E = never> = (
input: Endpoint5_25Input,
) => Effect.Effect<Endpoint5_25Output, E>
export type Endpoint5_26Input = {
readonly sessionID: Session.ID
readonly key: InstructionEntry.Key
readonly value: Schema.Json
}
export type Endpoint5_23Output = void
export type Endpoint5_26Output = void
export type SessionInstructionsEntryPutOperation<E = never> = (
input: Endpoint5_23Input,
) => Effect.Effect<Endpoint5_23Output, E>
input: Endpoint5_26Input,
) => Effect.Effect<Endpoint5_26Output, E>
export type Endpoint5_24Input = { readonly sessionID: Session.ID; readonly key: InstructionEntry.Key }
export type Endpoint5_24Output = void
export type Endpoint5_27Input = { readonly sessionID: Session.ID; readonly key: InstructionEntry.Key }
export type Endpoint5_27Output = void
export type SessionInstructionsEntryRemoveOperation<E = never> = (
input: Endpoint5_24Input,
) => Effect.Effect<Endpoint5_24Output, E>
input: Endpoint5_27Input,
) => Effect.Effect<Endpoint5_27Output, 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_28Input = { readonly sessionID: Session.ID; readonly prompt: string }
export type Endpoint5_28Output = { readonly text: string }
export type SessionGenerateOperation<E = never> = (input: Endpoint5_28Input) => Effect.Effect<Endpoint5_28Output, E>
export type Endpoint5_26Input = {
export type Endpoint5_29Input = {
readonly sessionID: Session.ID
readonly after?: Event.Seq | undefined
readonly follow?: boolean | undefined
}
export type Endpoint5_26Output =
export type Endpoint5_29Output =
| (
| {
readonly id: Event.ID
@@ -392,6 +406,33 @@ export type Endpoint5_26Output =
readonly input: SessionPending.Message
}
}
| {
readonly id: Event.ID
readonly created: DateTime.Utc
readonly metadata?: { readonly [x: string]: unknown } | undefined
readonly type: "session.input.cancelled"
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 inputID: SessionMessage.ID }
}
| {
readonly id: Event.ID
readonly created: DateTime.Utc
readonly metadata?: { readonly [x: string]: unknown } | undefined
readonly type: "session.input.steered"
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 inputID: SessionMessage.ID }
}
| {
readonly id: Event.ID
readonly created: DateTime.Utc
readonly metadata?: { readonly [x: string]: unknown } | undefined
readonly type: "session.input.queued"
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 inputID: SessionMessage.ID }
}
| {
readonly id: Event.ID
readonly created: DateTime.Utc
@@ -850,19 +891,19 @@ export type Endpoint5_26Output =
}
)
| EventLog.Synced
export type SessionLogOperation<E = never> = (input: Endpoint5_26Input) => Stream.Stream<Endpoint5_26Output, E>
export type SessionLogOperation<E = never> = (input: Endpoint5_29Input) => Stream.Stream<Endpoint5_29Output, 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 Endpoint5_30Input = { readonly sessionID: Session.ID }
export type Endpoint5_30Output = void
export type SessionInterruptOperation<E = never> = (input: Endpoint5_30Input) => Effect.Effect<Endpoint5_30Output, 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 Endpoint5_31Input = { readonly sessionID: Session.ID }
export type Endpoint5_31Output = void
export type SessionBackgroundOperation<E = never> = (input: Endpoint5_31Input) => Effect.Effect<Endpoint5_31Output, 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_32Input = { readonly sessionID: Session.ID; readonly messageID: SessionMessage.ID }
export type Endpoint5_32Output = SessionMessage.Info
export type SessionMessageOperation<E = never> = (input: Endpoint5_32Input) => Effect.Effect<Endpoint5_32Output, E>
export interface SessionApi<E = never> {
readonly list: SessionListOperation<E>
@@ -888,7 +929,12 @@ export interface SessionApi<E = never> {
readonly commit: SessionRevertCommitOperation<E>
}
readonly context: SessionContextOperation<E>
readonly pending: { readonly list: SessionPendingListOperation<E> }
readonly pending: {
readonly list: SessionPendingListOperation<E>
readonly cancel: SessionPendingCancelOperation<E>
readonly steer: SessionPendingSteerOperation<E>
readonly queue: SessionPendingQueueOperation<E>
}
readonly instructions: {
readonly entry: {
readonly list: SessionInstructionsEntryListOperation<E>
+49 -22
View File
@@ -76,6 +76,12 @@ import type {
Endpoint5_28Output,
Endpoint5_29Input,
Endpoint5_29Output,
Endpoint5_30Input,
Endpoint5_30Output,
Endpoint5_31Input,
Endpoint5_31Output,
Endpoint5_32Input,
Endpoint5_32Output,
Endpoint6_0Input,
Endpoint6_0Output,
Endpoint7_0Input,
@@ -501,37 +507,58 @@ 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.cancel"]({ params: { sessionID: input["sessionID"], inputID: input["inputID"] } }).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
),
)
const Endpoint5_23 = (raw: RawClient["server.session"]) => (input: Endpoint5_23Input) =>
preserveEffect<Endpoint5_23Output>()(
raw["session.instructions.entry.put"]({
params: { sessionID: input["sessionID"], key: input["key"] },
payload: { value: input["value"] },
}).pipe(Effect.mapError(mapClientError)),
raw["session.pending.steer"]({ params: { sessionID: input["sessionID"], inputID: input["inputID"] } }).pipe(
Effect.mapError(mapClientError),
),
)
const Endpoint5_24 = (raw: RawClient["server.session"]) => (input: Endpoint5_24Input) =>
preserveEffect<Endpoint5_24Output>()(
raw["session.instructions.entry.remove"]({ params: { sessionID: input["sessionID"], key: input["key"] } }).pipe(
raw["session.pending.queue"]({ params: { sessionID: input["sessionID"], inputID: input["inputID"] } }).pipe(
Effect.mapError(mapClientError),
),
)
const Endpoint5_25 = (raw: RawClient["server.session"]) => (input: Endpoint5_25Input) =>
preserveEffect<Endpoint5_25Output>()(
raw["session.generate"]({ params: { sessionID: input["sessionID"] }, payload: { prompt: input["prompt"] } }).pipe(
raw["session.instructions.entry.list"]({ params: { sessionID: input["sessionID"] } }).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
),
)
const Endpoint5_26 = (raw: RawClient["server.session"]) => (input: Endpoint5_26Input) =>
preserveStream<Endpoint5_26Output>()(
preserveEffect<Endpoint5_26Output>()(
raw["session.instructions.entry.put"]({
params: { sessionID: input["sessionID"], key: input["key"] },
payload: { value: input["value"] },
}).pipe(Effect.mapError(mapClientError)),
)
const Endpoint5_27 = (raw: RawClient["server.session"]) => (input: Endpoint5_27Input) =>
preserveEffect<Endpoint5_27Output>()(
raw["session.instructions.entry.remove"]({ params: { sessionID: input["sessionID"], key: input["key"] } }).pipe(
Effect.mapError(mapClientError),
),
)
const Endpoint5_28 = (raw: RawClient["server.session"]) => (input: Endpoint5_28Input) =>
preserveEffect<Endpoint5_28Output>()(
raw["session.generate"]({ params: { sessionID: input["sessionID"] }, payload: { prompt: input["prompt"] } }).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
),
)
const Endpoint5_29 = (raw: RawClient["server.session"]) => (input: Endpoint5_29Input) =>
preserveStream<Endpoint5_29Output>()(
Stream.unwrap(
raw["session.log"]({
params: { sessionID: input["sessionID"] },
@@ -543,18 +570,18 @@ const Endpoint5_26 = (raw: RawClient["server.session"]) => (input: Endpoint5_26I
),
)
const Endpoint5_27 = (raw: RawClient["server.session"]) => (input: Endpoint5_27Input) =>
preserveEffect<Endpoint5_27Output>()(
const Endpoint5_30 = (raw: RawClient["server.session"]) => (input: Endpoint5_30Input) =>
preserveEffect<Endpoint5_30Output>()(
raw["session.interrupt"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)),
)
const Endpoint5_28 = (raw: RawClient["server.session"]) => (input: Endpoint5_28Input) =>
preserveEffect<Endpoint5_28Output>()(
const Endpoint5_31 = (raw: RawClient["server.session"]) => (input: Endpoint5_31Input) =>
preserveEffect<Endpoint5_31Output>()(
raw["session.background"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)),
)
const Endpoint5_29 = (raw: RawClient["server.session"]) => (input: Endpoint5_29Input) =>
preserveEffect<Endpoint5_29Output>()(
const Endpoint5_32 = (raw: RawClient["server.session"]) => (input: Endpoint5_32Input) =>
preserveEffect<Endpoint5_32Output>()(
raw["session.message"]({ params: { sessionID: input["sessionID"], messageID: input["messageID"] } }).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
@@ -581,13 +608,13 @@ const adaptGroup5 = (raw: RawClient["server.session"]) => ({
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),
pending: { list: Endpoint5_21(raw), cancel: Endpoint5_22(raw), steer: Endpoint5_23(raw), queue: Endpoint5_24(raw) },
instructions: { entry: { list: Endpoint5_25(raw), put: Endpoint5_26(raw), remove: Endpoint5_27(raw) } },
generate: Endpoint5_28(raw),
log: Endpoint5_29(raw),
interrupt: Endpoint5_30(raw),
background: Endpoint5_31(raw),
message: Endpoint5_32(raw),
})
const Endpoint6_0 = (raw: RawClient["server.message"]) => (input: Endpoint6_0Input) =>
@@ -54,6 +54,12 @@ import type {
SessionContextOutput,
SessionPendingListInput,
SessionPendingListOutput,
SessionPendingCancelInput,
SessionPendingCancelOutput,
SessionPendingSteerInput,
SessionPendingSteerOutput,
SessionPendingQueueInput,
SessionPendingQueueOutput,
SessionInstructionsEntryListInput,
SessionInstructionsEntryListOutput,
SessionInstructionsEntryPutInput,
@@ -738,6 +744,39 @@ export function make(options: ClientOptions) {
},
requestOptions,
).then((value) => value.data),
cancel: (input: SessionPendingCancelInput, requestOptions?: RequestOptions) =>
request<SessionPendingCancelOutput>(
{
method: "DELETE",
path: `/api/session/${encodeURIComponent(input.sessionID)}/pending/${encodeURIComponent(input.inputID)}`,
successStatus: 204,
declaredStatuses: [409, 404, 401, 400],
empty: true,
},
requestOptions,
),
steer: (input: SessionPendingSteerInput, requestOptions?: RequestOptions) =>
request<SessionPendingSteerOutput>(
{
method: "POST",
path: `/api/session/${encodeURIComponent(input.sessionID)}/pending/${encodeURIComponent(input.inputID)}/steer`,
successStatus: 204,
declaredStatuses: [409, 404, 401, 400],
empty: true,
},
requestOptions,
),
queue: (input: SessionPendingQueueInput, requestOptions?: RequestOptions) =>
request<SessionPendingQueueOutput>(
{
method: "POST",
path: `/api/session/${encodeURIComponent(input.sessionID)}/pending/${encodeURIComponent(input.inputID)}/queue`,
successStatus: 204,
declaredStatuses: [409, 404, 401, 400],
empty: true,
},
requestOptions,
),
},
instructions: {
entry: {
+167 -140
View File
@@ -259,8 +259,6 @@ export type McpStatusFailed = { status: "failed"; error: string }
export type McpStatusNeedsAuth = { status: "needs_auth" }
export type McpStatusNeedsClientRegistration = { status: "needs_client_registration"; error: string }
export type McpResource = { server: string; name: string; uri: string; description?: string; mimeType?: string }
export type McpResourceTemplate = {
@@ -502,6 +500,36 @@ export type SessionInputPromoted = {
data: { sessionID: string; inputID: string }
}
export type SessionInputCancelled = {
id: string
created: number
metadata?: { [x: string]: any }
type: "session.input.cancelled"
durable: { aggregateID: string; seq: number; version: 1 }
location?: LocationRef
data: { sessionID: string; inputID: string }
}
export type SessionInputSteered = {
id: string
created: number
metadata?: { [x: string]: any }
type: "session.input.steered"
durable: { aggregateID: string; seq: number; version: 1 }
location?: LocationRef
data: { sessionID: string; inputID: string }
}
export type SessionInputQueued = {
id: string
created: number
metadata?: { [x: string]: any }
type: "session.input.queued"
durable: { aggregateID: string; seq: number; version: 1 }
location?: LocationRef
data: { sessionID: string; inputID: string }
}
export type SessionExecutionStarted = {
id: string
created: number
@@ -1261,13 +1289,7 @@ export type ConnectionInfo = ConnectionCredentialInfo | ConnectionEnvInfo
export type McpServer = {
name: string
status:
| McpStatusConnected
| McpStatusPending
| McpStatusDisabled
| McpStatusFailed
| McpStatusNeedsAuth
| McpStatusNeedsClientRegistration
status: McpStatusConnected | McpStatusPending | McpStatusDisabled | McpStatusFailed | McpStatusNeedsAuth
integrationID?: string
}
@@ -1701,44 +1723,43 @@ export type AgentInfo = {
export type ConfigEntry =
| {
type: "document"
path?: string | null
path?: string
info: {
$schema?: string | null
shell?: string | null
model?: string | { providerID: string; model: string; variant?: string | null } | null
default_agent?: string | null
autoupdate?: boolean | "notify" | null
share?: "manual" | "auto" | "disabled" | null
enterprise?: { url?: string | null } | null
username?: string | null
permissions?: PermissionRuleset | null
$schema?: string
shell?: string
model?: string | { providerID: string; model: string; variant?: string }
default_agent?: string
autoupdate?: boolean | "notify"
share?: "manual" | "auto" | "disabled"
enterprise?: { url?: string }
username?: string
permissions?: PermissionRuleset
agents?: {
[x: string]: {
model?: string | { providerID: string; model: string; variant?: string | null } | null
request?: { headers?: { [x: string]: string } | null; body?: { [x: string]: JsonValue } | null } | null
system?: string | null
description?: string | null
mode?: "subagent" | "primary" | "all" | null
hidden?: boolean | null
color?: string | null
steps?: number | null
disabled?: boolean | null
permissions?: PermissionRuleset | null
model?: string | { providerID: string; model: string; variant?: string }
request?: { headers?: { [x: string]: string }; body?: { [x: string]: JsonValue } }
system?: string
description?: string
mode?: "subagent" | "primary" | "all"
hidden?: boolean
color?: string
steps?: number
disabled?: boolean
permissions?: PermissionRuleset
}
} | null
snapshots?: boolean | null
watcher?: { ignore?: Array<string> | null } | null
}
snapshots?: boolean
watcher?: { ignore?: Array<string> }
formatter?:
| boolean
| {
[x: string]: {
disabled?: boolean | null
command?: Array<string> | null
environment?: { [x: string]: string } | null
extensions?: Array<string> | null
disabled?: boolean
command?: Array<string>
environment?: { [x: string]: string }
extensions?: Array<string>
}
}
| null
lsp?:
| boolean
| {
@@ -1746,125 +1767,117 @@ export type ConfigEntry =
| { disabled: true }
| {
command: Array<string>
extensions?: Array<string> | null
disabled?: boolean | null
env?: { [x: string]: string } | null
initialization?: { [x: string]: JsonValue } | null
extensions?: Array<string>
disabled?: boolean
env?: { [x: string]: string }
initialization?: { [x: string]: JsonValue }
}
}
| null
media?: {
image?: {
auto_resize?: boolean | null
max_width?: number | null
max_height?: number | null
max_base64_bytes?: number | null
} | null
} | null
tool_output?: { max_lines?: number | null; max_bytes?: number | null } | null
image?: { auto_resize?: boolean; max_width?: number; max_height?: number; max_base64_bytes?: number }
}
tool_output?: { max_lines?: number; max_bytes?: number }
mcp?: {
timeout?: { startup?: number | null; catalog?: number | null; execution?: number | null } | null
timeout?: { startup?: number; catalog?: number; execution?: number }
servers?: {
[x: string]:
| {
type: "local"
command: Array<string>
cwd?: string | null
environment?: { [x: string]: string } | null
disabled?: boolean | null
codemode?: boolean | null
timeout?: { startup?: number | null; catalog?: number | null; execution?: number | null } | null
cwd?: string
environment?: { [x: string]: string }
disabled?: boolean
codemode?: boolean
timeout?: { startup?: number; catalog?: number; execution?: number }
}
| {
type: "remote"
url: string
headers?: { [x: string]: string } | null
headers?: { [x: string]: string }
oauth?:
| {
client_id?: string | null
client_secret?: string | null
scope?: string | null
callback_port?: number | null
redirect_uri?: string | null
client_id?: string
client_secret?: string
scope?: string
callback_port?: number
redirect_uri?: string
}
| false
| null
disabled?: boolean | null
codemode?: boolean | null
timeout?: { startup?: number | null; catalog?: number | null; execution?: number | null } | null
disabled?: boolean
codemode?: boolean
timeout?: { startup?: number; catalog?: number; execution?: number }
}
} | null
} | null
compaction?: { auto?: boolean | null; keep?: { tokens?: number | null } | null; buffer?: number | null } | null
skills?: Array<string> | null
}
}
compaction?: { auto?: boolean; keep?: { tokens?: number }; buffer?: number }
skills?: Array<string>
commands?: {
[x: string]: {
template: string
description?: string | null
agent?: string | null
model?: string | { providerID: string; model: string; variant?: string | null } | null
subtask?: boolean | null
description?: string
agent?: string
model?: string | { providerID: string; model: string; variant?: string }
subtask?: boolean
}
} | null
instructions?: Array<string> | null
}
instructions?: Array<string>
references?: {
[x: string]:
| string
| { repository: string; branch?: string | null; description?: string | null; hidden?: boolean | null }
| { path: string; description?: string | null; hidden?: boolean | null }
} | null
websearch?: { provider: string } | null
plugins?: Array<string | { package: string; options?: { [x: string]: JsonValue } | null }> | null
warming?: boolean | { prompt?: string | null; interval?: string | null; duration?: string | null } | null
| { repository: string; branch?: string; description?: string; hidden?: boolean }
| { path: string; description?: string; hidden?: boolean }
}
websearch?: { provider: string }
plugins?: Array<string | { package: string; options?: { [x: string]: JsonValue } }>
warming?: boolean | { prompt?: string; interval?: string; duration?: string }
providers?: {
[x: string]: {
name?: string | null
env?: Array<string> | null
package?: string | null
settings?: { [x: string]: JsonValue } | null
headers?: { [x: string]: string } | null
body?: { [x: string]: JsonValue } | null
name?: string
env?: Array<string>
package?: string
settings?: { [x: string]: JsonValue }
headers?: { [x: string]: string }
body?: { [x: string]: JsonValue }
models?: {
[x: string]: {
modelID?: string | null
family?: string | null
name?: string | null
compatibility?: ModelCompatibility | null
package?: string | null
settings?: { [x: string]: JsonValue } | null
headers?: { [x: string]: string } | null
body?: { [x: string]: JsonValue } | null
capabilities?: ModelCapabilities | null
modelID?: string
family?: string
name?: string
compatibility?: ModelCompatibility
package?: string
settings?: { [x: string]: JsonValue }
headers?: { [x: string]: string }
body?: { [x: string]: JsonValue }
capabilities?: ModelCapabilities
variants?: Array<{
id: string
settings?: { [x: string]: JsonValue } | null
headers?: { [x: string]: string } | null
body?: { [x: string]: JsonValue } | null
}> | null
settings?: { [x: string]: JsonValue }
headers?: { [x: string]: string }
body?: { [x: string]: JsonValue }
}>
cost?:
| {
tier?: { type: "context"; size: number } | null
tier?: { type: "context"; size: number }
input: MoneyUSDPerMillionTokens
output: MoneyUSDPerMillionTokens
cache?: { read?: MoneyUSDPerMillionTokens | null; write?: MoneyUSDPerMillionTokens | null } | null
cache?: { read?: MoneyUSDPerMillionTokens; write?: MoneyUSDPerMillionTokens }
}
| Array<{
tier?: { type: "context"; size: number } | null
tier?: { type: "context"; size: number }
input: MoneyUSDPerMillionTokens
output: MoneyUSDPerMillionTokens
cache?: { read?: MoneyUSDPerMillionTokens | null; write?: MoneyUSDPerMillionTokens | null } | null
cache?: { read?: MoneyUSDPerMillionTokens; write?: MoneyUSDPerMillionTokens }
}>
| null
disabled?: boolean | null
limit?: { context?: number | null; input?: number | null; output?: number | null } | null
disabled?: boolean
limit?: { context?: number; input?: number; output?: number }
}
} | null
}
}
} | null
}
experimental?: {
subagent_depth?: number | null
policies?: Array<{ action: "provider.use"; resource: string; effect: "allow" | "deny" }> | null
} | null
subagent_depth?: number
policies?: Array<{ action: "provider.use"; resource: string; effect: "allow" | "deny" }>
}
}
}
| { type: "directory"; path: string }
@@ -1981,6 +1994,9 @@ export type SessionEventDurable =
| SessionForked
| SessionInputPromoted
| SessionInputAdmitted
| SessionInputCancelled
| SessionInputSteered
| SessionInputQueued
| SessionExecutionStarted
| SessionExecutionSucceeded
| SessionExecutionFailed
@@ -2033,6 +2049,9 @@ export type V2Event =
| SessionForked
| SessionInputPromoted
| SessionInputAdmitted
| SessionInputCancelled
| SessionInputSteered
| SessionInputQueued
| SessionExecutionStarted
| SessionExecutionSucceeded
| SessionExecutionFailed
@@ -2951,6 +2970,27 @@ export type SessionPendingListInput = { readonly sessionID: { readonly sessionID
export type SessionPendingListOutput = { data: Array<SessionPendingInfo> }["data"]
export type SessionPendingCancelInput = {
readonly sessionID: { readonly sessionID: string; readonly inputID: string }["sessionID"]
readonly inputID: { readonly sessionID: string; readonly inputID: string }["inputID"]
}
export type SessionPendingCancelOutput = void
export type SessionPendingSteerInput = {
readonly sessionID: { readonly sessionID: string; readonly inputID: string }["sessionID"]
readonly inputID: { readonly sessionID: string; readonly inputID: string }["inputID"]
}
export type SessionPendingSteerOutput = void
export type SessionPendingQueueInput = {
readonly sessionID: { readonly sessionID: string; readonly inputID: string }["sessionID"]
readonly inputID: { readonly sessionID: string; readonly inputID: string }["inputID"]
}
export type SessionPendingQueueOutput = void
export type SessionInstructionsEntryListInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] }
export type SessionInstructionsEntryListOutput = { data: Array<InstructionEntryInfo> }["data"]
@@ -3250,41 +3290,28 @@ export type McpAddInput = {
| {
readonly type: "local"
readonly command: ReadonlyArray<string>
readonly cwd?: string | undefined
readonly environment?: { readonly [x: string]: string } | undefined
readonly disabled?: boolean | undefined
readonly codemode?: boolean | undefined
readonly timeout?:
| {
readonly startup?: number | undefined
readonly catalog?: number | undefined
readonly execution?: number | undefined
}
| undefined
readonly cwd?: string
readonly environment?: { readonly [x: string]: string }
readonly disabled?: boolean
readonly codemode?: boolean
readonly timeout?: { readonly startup?: number; readonly catalog?: number; readonly execution?: number }
}
| {
readonly type: "remote"
readonly url: string
readonly headers?: { readonly [x: string]: string } | undefined
readonly headers?: { readonly [x: string]: string }
readonly oauth?:
| {
readonly client_id?: string | undefined
readonly client_secret?: string | undefined
readonly scope?: string | undefined
readonly callback_port?: number | undefined
readonly redirect_uri?: string | undefined
readonly client_id?: string
readonly client_secret?: string
readonly scope?: string
readonly callback_port?: number
readonly redirect_uri?: string
}
| false
| undefined
readonly disabled?: boolean | undefined
readonly codemode?: boolean | undefined
readonly timeout?:
| {
readonly startup?: number | undefined
readonly catalog?: number | undefined
readonly execution?: number | undefined
}
| undefined
readonly disabled?: boolean
readonly codemode?: boolean
readonly timeout?: { readonly startup?: number; readonly catalog?: number; readonly execution?: number }
}
}["config"]
}
+23
View File
@@ -32,6 +32,7 @@ test("exposes every standard HTTP API group", () => {
"projectCopy",
"vcs",
"debug",
"migration",
"websearch",
"config",
])
@@ -356,6 +357,28 @@ test("session.pending.list uses the public HTTP contract", async () => {
expect(requests).toEqual([{ method: "GET", url: "http://localhost:3000/api/session/ses_test/pending" }])
})
test("session.pending mutations use the public HTTP contract", async () => {
const requests: Array<{ method: string; url: string }> = []
const client = OpenCode.make({
baseUrl: "http://localhost:3000",
fetch: async (input, init) => {
const request = input instanceof Request ? input : new Request(input, init)
requests.push({ method: request.method, url: request.url })
return new Response(null, { status: 204 })
},
})
await client.session.pending.cancel({ sessionID: "ses_test", inputID: "msg_cancel" })
await client.session.pending.steer({ sessionID: "ses_test", inputID: "msg_steer" })
await client.session.pending.queue({ sessionID: "ses_test", inputID: "msg_queue" })
expect(requests).toEqual([
{ method: "DELETE", url: "http://localhost:3000/api/session/ses_test/pending/msg_cancel" },
{ method: "POST", url: "http://localhost:3000/api/session/ses_test/pending/msg_steer/steer" },
{ method: "POST", url: "http://localhost:3000/api/session/ses_test/pending/msg_queue/queue" },
])
})
test("event.subscribe exposes the Promise event stream wire projection", async () => {
const client = OpenCode.make({
baseUrl: "http://localhost:3000",
-22
View File
@@ -12,7 +12,6 @@ export interface Mapping {
export interface MapInput {
readonly packageName: string | undefined
readonly providerID: string
readonly settings: Readonly<Record<string, unknown>>
readonly modelID: string
}
@@ -52,8 +51,6 @@ export function map(input: MapInput): Mapping | undefined {
...mapGoogleOptions(input.settings),
},
}
case "@ai-sdk/openai-compatible":
return mapOpenAICompatible(input, baseSettings)
case "@openrouter/ai-sdk-provider":
return mapOpenRouter(input.settings, baseSettings)
case "@ai-sdk/xai":
@@ -66,25 +63,6 @@ export function map(input: MapInput): Mapping | undefined {
},
}
}
return undefined
}
function mapOpenAICompatible(
input: MapInput,
baseSettings: Readonly<Record<string, unknown>>,
): Mapping | undefined {
if (typeof baseSettings.baseURL !== "string") return undefined
return {
package: "@opencode-ai/ai/providers/openai-compatible",
settings: {
baseURL: baseSettings.baseURL,
...mapAPIKey(input.settings),
provider: input.providerID,
...(isStringRecord(input.settings.queryParams) ? { queryParams: input.settings.queryParams } : {}),
...mapOpenAIOptions(input.settings),
},
...(isStringRecord(input.settings.headers) ? { headers: input.settings.headers } : {}),
}
}
function mapBedrockMantle(input: MapInput, baseSettings: Readonly<Record<string, unknown>>): Mapping | undefined {
+17 -30
View File
@@ -5,6 +5,8 @@ import { LanguageModel } from "@opencode-ai/ai"
// ast-grep-ignore: no-star-import
import * as AnthropicMessages from "@opencode-ai/ai/protocols/anthropic-messages"
// ast-grep-ignore: no-star-import
import * as OpenAICompatibleChat from "@opencode-ai/ai/protocols/openai-compatible-chat"
// ast-grep-ignore: no-star-import
import * as OpenAIResponses from "@opencode-ai/ai/protocols/openai-responses"
import { Auth, type AnyRoute } from "@opencode-ai/ai/route"
import { Context, Effect, Layer, Schema } from "effect"
@@ -16,7 +18,6 @@ import { Credential } from "./credential"
import { Integration } from "./integration"
import { Capabilities, ID, Info, Ref, VariantID } from "./model"
import { Npm } from "@opencode-ai/util/npm"
import { PluginHooks } from "./plugin/hooks"
import { Provider } from "./provider"
export class VariantUnavailableError extends Schema.TaggedErrorClass<VariantUnavailableError>()(
@@ -134,11 +135,6 @@ export const withVariant = (
export interface Dependencies {
readonly loadPackage?: (specifier: string) => Effect.Effect<Provider.ProviderPackage, Provider.LoadError>
readonly loadAISDK?: (model: Info) => Effect.Effect<LanguageModel, AISDK.InitError>
readonly resolveProvider?: (input: {
readonly model: Info
readonly credential?: Credential.Value
readonly settings: Record<string, unknown>
}) => Effect.Effect<Record<string, unknown>>
}
export const fromCatalogModel = (
@@ -148,6 +144,8 @@ export const fromCatalogModel = (
): Effect.Effect<LanguageModel, UnsupportedPackageError> => {
const resolved = produce(model, (draft) => {
if (draft.settings?.apiKey === "") delete draft.settings.apiKey
if (credential?.type === "key" && credential.metadata !== undefined)
draft.body = Provider.mergeOverlay(draft.body, credential.metadata)
})
const packageName = Provider.packageName(resolved.package)
const key = apiKey(resolved, credential)
@@ -166,11 +164,21 @@ export const fromCatalogModel = (
.model({ id: resolved.modelID ?? resolved.id, compatibility: resolved.compatibility }),
)
}
if (
Provider.isAISDK(resolved.package) &&
packageName === "@ai-sdk/openai-compatible" &&
typeof resolved.settings?.baseURL === "string"
) {
return Effect.succeed(
withDefaults(resolved, OpenAICompatibleChat.route)
.with({ auth: key === undefined ? Auth.none : Auth.bearer(key) })
.model({ id: resolved.modelID ?? resolved.id, compatibility: resolved.compatibility }),
)
}
const configured = { ...resolved.settings, ...credential?.metadata }
const mapping = Provider.isAISDK(resolved.package)
? AISDKNative.map({
packageName,
providerID: resolved.providerID,
settings: configured,
modelID: resolved.modelID ?? resolved.id,
})
@@ -249,25 +257,7 @@ export const resolveModel = (
variant: VariantID | undefined,
credential?: Credential.Value,
dependencies?: Dependencies,
) =>
withVariant(model, variant).pipe(
Effect.flatMap((model) => {
if (!dependencies?.resolveProvider) return fromCatalogModel(model, credential, dependencies)
return dependencies
.resolveProvider({ model, credential, settings: { ...model.settings, ...credential?.metadata } })
.pipe(
Effect.flatMap((settings) =>
fromCatalogModel(
produce(model, (draft) => {
draft.settings = settings
}),
credential,
dependencies,
),
),
)
}),
)
) => withVariant(model, variant).pipe(Effect.flatMap((model) => fromCatalogModel(model, credential, dependencies)))
export const supported = (model: Info) => Boolean(model.package)
@@ -279,7 +269,6 @@ export const layer = Layer.effect(
const integrations = yield* Integration.Service
const npm = yield* Npm.Service
const aisdk = yield* AISDK.Service
const hooks = yield* PluginHooks.Service
const load = Effect.fn("ModelResolver.resolveModel")(function* (selected: Info, variant?: VariantID) {
const provider = yield* catalog.provider.get(selected.providerID)
const connection = yield* integrations.connection.active(
@@ -292,8 +281,6 @@ export const layer = Layer.effect(
{
loadPackage: (specifier) => Provider.loadPackage(specifier, npm),
loadAISDK: (model) => aisdk.model(model),
resolveProvider: (input) =>
hooks.trigger("provider", "resolve", input).pipe(Effect.map((event) => event.settings)),
},
)
return {
@@ -331,5 +318,5 @@ export const layer = Layer.effect(
export const node = makeLocationNode({
service: Service,
layer,
deps: [Catalog.node, Integration.node, Npm.node, AISDK.node, PluginHooks.node],
deps: [Catalog.node, Integration.node, Npm.node, AISDK.node],
})
-2
View File
@@ -1,7 +1,6 @@
export * as PluginHooks from "./hooks"
import type { AISDKHooks } from "@opencode-ai/plugin/effect/aisdk"
import type { ProviderHooks } from "@opencode-ai/plugin/effect/provider"
import type { SessionHooks } from "@opencode-ai/plugin/effect/session"
import type { ShellHooks } from "@opencode-ai/plugin/effect/shell"
import type { ToolHooks } from "@opencode-ai/plugin/effect/tool"
@@ -11,7 +10,6 @@ import { State } from "../state"
export interface Domains {
readonly aisdk: AISDKHooks
readonly provider: ProviderHooks
readonly session: SessionHooks
readonly shell: ShellHooks
readonly tool: ToolHooks
-3
View File
@@ -273,9 +273,6 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: import("../p
plugin: {
list: () => response(plugin.list()),
},
provider: {
hook: (name, callback) => hooks.register("provider", name, callback),
},
reference: {
list: () => response(reference.list()),
reload: reference.reload,
-4
View File
@@ -84,10 +84,6 @@ export function fromPromise(plugin: Plugin) {
transform: transform(host.catalog),
reload: () => run(host.catalog.reload()),
},
provider: {
hook: (name, callback) =>
register(host.provider.hook(name, (event) => Effect.promise(() => Promise.resolve(callback(event))))),
},
command: {
list: (input) => run(host.command.list(input)),
transform: transform(host.command),
@@ -14,34 +14,72 @@ export const CloudflareWorkersAIPlugin = define({
if (!item) return
evt.provider.update(item.provider.id, (provider) => {
if (!Provider.isAISDK(provider.package)) return
const baseURL = resolveBaseURL(provider.settings ?? {})
if (baseURL) provider.settings = { ...provider.settings, baseURL }
provider.headers = Provider.mergeHeaders(provider.headers, {
"User-Agent": `${App.useragent(ctx.app)} cloudflare-workers-ai (${os.platform()} ${os.release()}; ${os.arch()})`,
})
if (typeof provider.settings?.baseURL === "string") return
const accountId = resolveAccountId(provider.settings ?? {})
if (accountId) provider.settings = { ...provider.settings, baseURL: workersEndpoint(accountId) }
})
})
yield* ctx.provider.hook(
"resolve",
yield* ctx.aisdk.hook(
"sdk",
Effect.fn(function* (evt) {
if (evt.model.providerID !== providerID) return
const baseURL = resolveBaseURL(evt.settings)
if (baseURL) evt.settings.baseURL = baseURL
if (evt.package !== "@ai-sdk/openai-compatible") return
const accountId = resolveAccountId(evt.options)
if (!hasWorkersEndpoint(evt.model) && !accountId) return
const mod = yield* Effect.promise(() => import("@ai-sdk/openai-compatible"))
evt.sdk = mod.createOpenAICompatible(
sdkOptions(
{
...evt.options,
baseURL: evt.options.baseURL ?? (accountId ? workersEndpoint(accountId) : undefined),
},
ctx.app,
) as any,
)
}),
)
yield* ctx.aisdk.hook(
"language",
Effect.fn(function* (evt) {
if (evt.model.providerID !== providerID) return
evt.language = evt.sdk.languageModel(evt.model.modelID ?? evt.model.id)
}),
)
}),
})
function resolveBaseURL(options: Record<string, unknown>) {
const baseURL = stringOption(options, "baseURL")
const accountId = process.env.CLOUDFLARE_ACCOUNT_ID ?? stringOption(options, "accountId")
if (!accountId) return baseURL
if (baseURL) return baseURL.replaceAll("${CLOUDFLARE_ACCOUNT_ID}", encodeURIComponent(accountId))
return workersEndpoint(accountId)
function resolveAccountId(options: Record<string, unknown>) {
return process.env.CLOUDFLARE_ACCOUNT_ID ?? stringOption(options, "accountId")
}
function workersEndpoint(accountId: string) {
return `https://api.cloudflare.com/client/v4/accounts/${encodeURIComponent(accountId)}/ai/v1`
return `https://api.cloudflare.com/client/v4/accounts/${accountId}/ai/v1`
}
function hasWorkersEndpoint(model: {
readonly package?: string
readonly settings?: Readonly<Record<string, unknown>>
}) {
return Provider.isAISDK(model.package) && typeof model.settings?.baseURL === "string"
}
function sdkOptions(options: Record<string, any>, app: App.Info) {
return {
...options,
baseURL: expandAccountId(options.baseURL),
apiKey: process.env.CLOUDFLARE_API_KEY ?? options.apiKey,
headers: {
"User-Agent": `${App.useragent(app)} cloudflare-workers-ai (${os.platform()} ${os.release()}; ${os.arch()})`,
...options.headers,
},
name: providerID,
}
}
function expandAccountId(baseURL: unknown) {
if (typeof baseURL !== "string") return baseURL
return baseURL.replaceAll("${CLOUDFLARE_ACCOUNT_ID}", process.env.CLOUDFLARE_ACCOUNT_ID ?? "${CLOUDFLARE_ACCOUNT_ID}")
}
function stringOption(options: Record<string, unknown>, key: string) {
+36
View File
@@ -133,6 +133,14 @@ export class CompactionConflictError extends Schema.TaggedErrorClass<CompactionC
export class BusyError extends Schema.TaggedErrorClass<BusyError>()("Session.BusyError", {
sessionID: SessionSchema.ID,
}) {}
export class PendingInputConflictError extends Schema.TaggedErrorClass<PendingInputConflictError>()(
"Session.PendingInputConflictError",
{
sessionID: SessionSchema.ID,
inputID: SessionMessage.ID,
},
) {}
type PendingInputRef = { readonly sessionID: SessionSchema.ID; readonly inputID: SessionMessage.ID }
export class SkillNotFoundError extends Schema.TaggedErrorClass<SkillNotFoundError>()("Session.SkillNotFoundError", {
skill: Skill.ID,
}) {}
@@ -181,6 +189,9 @@ export interface Interface {
* unhandled compaction barriers.
*/
readonly pending: (sessionID: SessionSchema.ID) => Effect.Effect<SessionPending.Info[], NotFoundError>
readonly cancelPending: (input: PendingInputRef) => Effect.Effect<void, NotFoundError | PendingInputConflictError>
readonly steerPending: (input: PendingInputRef) => Effect.Effect<void, NotFoundError | PendingInputConflictError>
readonly queuePending: (input: PendingInputRef) => Effect.Effect<void, NotFoundError | PendingInputConflictError>
/**
* Durable, ordered session log read. Replays durable session bus after
* the exclusive `after` cursor, emits a `Synced` marker at the captured
@@ -318,6 +329,28 @@ const layer = Layer.effect(
),
)
const mutatePending = (
input: PendingInputRef,
mutation: (
bus: Bus.Interface,
input: { readonly id: SessionMessage.ID; readonly sessionID: SessionSchema.ID },
) => Effect.Effect<unknown>,
wake = false,
) =>
Effect.uninterruptible(
Effect.gen(function* () {
yield* result.get(input.sessionID)
yield* mutation(bus, { sessionID: input.sessionID, id: input.inputID }).pipe(
Effect.catchDefect((defect) =>
defect instanceof SessionPending.LifecycleConflict
? new PendingInputConflictError(input)
: Effect.die(defect),
),
)
if (wake) yield* execution.wake(input.sessionID)
}),
)
const result = Service.of({
create: Effect.fn("Session.create")(function* (input) {
const sessionID = input.id ?? SessionSchema.ID.create()
@@ -507,6 +540,9 @@ const layer = Layer.effect(
yield* result.get(sessionID)
return yield* SessionPending.list(db, sessionID)
}),
cancelPending: Effect.fn("Session.cancelPending")((input) => mutatePending(input, SessionPending.cancel)),
steerPending: Effect.fn("Session.steerPending")((input) => mutatePending(input, SessionPending.steer, true)),
queuePending: Effect.fn("Session.queuePending")((input) => mutatePending(input, SessionPending.queue)),
log: (input) =>
Stream.unwrap(
result
+1 -1
View File
@@ -20,7 +20,7 @@ import type { Info } from "../model"
import { SessionUsage } from "./usage"
const DEFAULT_BUFFER = 20_000
const DEFAULT_KEEP_TOKENS = 8_000
const DEFAULT_KEEP_TOKENS = 15_000
const OUTPUT_TOKEN_MAX = 32_000
const TOOL_OUTPUT_MAX_CHARS = 2_000
const SUMMARY_TEMPLATE = `Output exactly the Markdown structure shown inside <template> and keep the section order unchanged. Do not include the <template> tags in your response.
@@ -90,6 +90,9 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
"session.forked": () => Effect.void,
"session.input.promoted": () => Effect.void,
"session.input.admitted": () => Effect.void,
"session.input.cancelled": () => Effect.void,
"session.input.steered": () => Effect.void,
"session.input.queued": () => Effect.void,
"session.execution.started": () => Effect.void,
"session.execution.succeeded": () => clearCurrentRetry,
"session.execution.failed": () => clearCurrentRetry,
+93
View File
@@ -312,6 +312,63 @@ export const projectPromoted = Effect.fn("SessionPending.projectPromoted")(funct
return stored
})
export const projectCancelled = Effect.fn("SessionPending.projectCancelled")(function* (
db: DatabaseService,
input: {
readonly id: SessionMessage.ID
readonly sessionID: SessionSchema.ID
},
) {
const deleted = yield* db
.delete(SessionPendingTable)
.where(
and(
eq(SessionPendingTable.id, input.id),
eq(SessionPendingTable.session_id, input.sessionID),
or(eq(SessionPendingTable.delivery, "queue"), eq(SessionPendingTable.delivery, "steer")),
),
)
.returning({ id: SessionPendingTable.id })
.get()
.pipe(Effect.orDie)
if (!deleted) return yield* Effect.die(new LifecycleConflict({ id: input.id }))
})
const projectDelivery = Effect.fn("SessionPending.projectDelivery")(function* (
db: DatabaseService,
input: {
readonly id: SessionMessage.ID
readonly sessionID: SessionSchema.ID
readonly from: Delivery
readonly to: Delivery
},
) {
const updated = yield* db
.update(SessionPendingTable)
.set({ delivery: input.to })
.where(
and(
eq(SessionPendingTable.id, input.id),
eq(SessionPendingTable.session_id, input.sessionID),
eq(SessionPendingTable.delivery, input.from),
),
)
.returning({ id: SessionPendingTable.id })
.get()
.pipe(Effect.orDie)
if (!updated) return yield* Effect.die(new LifecycleConflict({ id: input.id }))
})
export const projectSteered = Effect.fn("SessionPending.projectSteered")(
(db: DatabaseService, input: { readonly id: SessionMessage.ID; readonly sessionID: SessionSchema.ID }) =>
projectDelivery(db, { ...input, from: "queue", to: "steer" }),
)
export const projectQueued = Effect.fn("SessionPending.projectQueued")(
(db: DatabaseService, input: { readonly id: SessionMessage.ID; readonly sessionID: SessionSchema.ID }) =>
projectDelivery(db, { ...input, from: "steer", to: "queue" }),
)
export const settleCompaction = Effect.fn("SessionPending.settleCompaction")(function* (
db: DatabaseService,
input: { readonly sessionID: SessionSchema.ID },
@@ -389,6 +446,42 @@ export const equivalent = (
return false
}
export const cancel = Effect.fn("SessionPending.cancel")(function* (
bus: Bus.Interface,
input: { readonly id: SessionMessage.ID; readonly sessionID: SessionSchema.ID },
) {
yield* inboxLocks.withLock(input.sessionID)(
bus.publish(SessionEvent.InputCancelled, {
sessionID: input.sessionID,
inputID: input.id,
}),
)
})
export const steer = Effect.fn("SessionPending.steer")(function* (
bus: Bus.Interface,
input: { readonly id: SessionMessage.ID; readonly sessionID: SessionSchema.ID },
) {
yield* inboxLocks.withLock(input.sessionID)(
bus.publish(SessionEvent.InputSteered, {
sessionID: input.sessionID,
inputID: input.id,
}),
)
})
export const queue = Effect.fn("SessionPending.queue")(function* (
bus: Bus.Interface,
input: { readonly id: SessionMessage.ID; readonly sessionID: SessionSchema.ID },
) {
yield* inboxLocks.withLock(input.sessionID)(
bus.publish(SessionEvent.InputQueued, {
sessionID: input.sessionID,
inputID: input.id,
}),
)
})
const publish = Effect.fn("SessionPending.publish")(function* (
db: DatabaseService,
bus: Bus.Interface,
+18
View File
@@ -485,6 +485,24 @@ const layer = Layer.effectDiscard(
.pipe(Effect.orDie)
}),
)
yield* bus.project(SessionEvent.InputCancelled, (event) =>
SessionPending.projectCancelled(db, {
id: event.data.inputID,
sessionID: event.data.sessionID,
}),
)
yield* bus.project(SessionEvent.InputSteered, (event) =>
SessionPending.projectSteered(db, {
id: event.data.inputID,
sessionID: event.data.sessionID,
}),
)
yield* bus.project(SessionEvent.InputQueued, (event) =>
SessionPending.projectQueued(db, {
id: event.data.inputID,
sessionID: event.data.sessionID,
}),
)
yield* bus.project(SessionEvent.Compaction.Admitted, (event) =>
Effect.gen(function* () {
if (event.durable === undefined)
+64 -47
View File
@@ -1,5 +1,8 @@
export * as ConfigMigrateV1 from "./migrate"
import { Info } from "@opencode-ai/schema/config"
import { ConfigAgent } from "@opencode-ai/schema/config/agent"
import { Schema } from "effect"
import { ConfigV1 } from "./config"
import { ConfigAgentV1 } from "./agent"
import { ConfigCommandV1 } from "./command"
@@ -10,6 +13,12 @@ import { ConfigProviderOptionsV1 } from "./provider-options"
import { Provider } from "../../provider"
import { Model } from "../../model"
const decodeOptions = { errors: "all", onExcessProperty: "ignore", propertyOrder: "original" } as const
const decodeInfo = Schema.decodeUnknownSync(Schema.fromJsonString(Info), decodeOptions)
const encodeInfo = Schema.encodeSync(Info)
const decodeAgent = Schema.decodeUnknownSync(Schema.fromJsonString(ConfigAgent.Info), decodeOptions)
const encodeAgent = Schema.encodeSync(ConfigAgent.Info)
const keys = new Set([
"logLevel",
"server",
@@ -48,42 +57,46 @@ export function isV1(input: unknown) {
}
export function migrate(info: typeof ConfigV1.Info.Type) {
return {
$schema: info.$schema,
shell: info.shell,
model: modelSelection(info.model),
default_agent: info.default_agent,
autoupdate: info.autoupdate,
share: info.share ?? (info.autoshare ? "auto" : undefined),
enterprise: info.enterprise,
username: info.username,
permissions: permissions(info.permission, info.tools),
agents: agents(info),
snapshots: info.snapshot,
watcher: info.watcher,
formatter: info.formatter,
lsp: info.lsp,
media: info.attachment,
tool_output: info.tool_output,
mcp: mcp(info),
compaction: info.compaction && {
auto: info.compaction.auto,
prune: info.compaction.prune,
keep: {
tokens: info.compaction.preserve_recent_tokens,
},
buffer: info.compaction.reserved,
},
skills: info.skills && [...(info.skills.paths ?? []), ...(info.skills.urls ?? [])],
commands: commands(info.command),
instructions: info.instructions,
references: info.references ?? info.reference,
experimental: experimental(info),
plugins: info.plugin?.map((plugin) =>
typeof plugin === "string" ? plugin : { package: plugin[0], options: plugin[1] },
return encodeInfo(
decodeInfo(
JSON.stringify({
$schema: info.$schema,
shell: info.shell,
model: modelSelection(info.model),
default_agent: info.default_agent,
autoupdate: info.autoupdate,
share: info.share ?? (info.autoshare ? "auto" : undefined),
enterprise: info.enterprise,
username: info.username,
permissions: permissions(info.permission, info.tools),
agents: agents(info),
snapshots: info.snapshot,
watcher: info.watcher,
formatter: info.formatter,
lsp: info.lsp,
media: info.attachment,
tool_output: info.tool_output,
mcp: mcp(info),
compaction: info.compaction && {
auto: info.compaction.auto,
prune: info.compaction.prune,
keep: {
tokens: info.compaction.preserve_recent_tokens,
},
buffer: info.compaction.reserved,
},
skills: info.skills && [...(info.skills.paths ?? []), ...(info.skills.urls ?? [])],
commands: commands(info.command),
instructions: info.instructions,
references: info.references ?? info.reference,
experimental: experimental(info),
plugins: info.plugin?.map((plugin) =>
typeof plugin === "string" ? plugin : { package: plugin[0], options: plugin[1] },
),
providers: providers(info.provider),
}),
),
providers: providers(info.provider),
}
)
}
function experimental(info: typeof ConfigV1.Info.Type) {
@@ -154,18 +167,22 @@ export function migrateAgent(info: ConfigAgentV1.Info) {
...(info.temperature === undefined ? {} : { temperature: info.temperature }),
...(info.top_p === undefined ? {} : { top_p: info.top_p }),
}
return {
model: modelSelection(info.model, info.variant),
request: Object.keys(body).length ? { body } : undefined,
system: info.prompt,
description: info.description,
mode: info.mode,
hidden: info.hidden,
color: info.color === undefined ? undefined : info.color.startsWith("#") ? info.color : "#aaaaaa",
steps: info.steps,
disabled: info.disable,
permissions: permissions(info.permission),
}
return encodeAgent(
decodeAgent(
JSON.stringify({
model: modelSelection(info.model, info.variant),
request: Object.keys(body).length ? { body } : undefined,
system: info.prompt,
description: info.description,
mode: info.mode,
hidden: info.hidden,
color: info.color === undefined ? undefined : info.color.startsWith("#") ? info.color : "#aaaaaa",
steps: info.steps,
disabled: info.disable,
permissions: permissions(info.permission),
}),
),
)
}
function commands(info?: Readonly<Record<string, ConfigCommandV1.Info>>) {
+2 -52
View File
@@ -1,12 +1,8 @@
import { describe, expect, test } from "bun:test"
import { AISDKNative } from "@opencode-ai/core/aisdk-native"
const map = (
packageName: string,
settings: Readonly<Record<string, unknown>>,
modelID = "test-model",
providerID = "test-provider",
) => AISDKNative.map({ packageName, providerID, settings, modelID })
const map = (packageName: string, settings: Readonly<Record<string, unknown>>, modelID = "test-model") =>
AISDKNative.map({ packageName, settings, modelID })
describe("AISDKNative", () => {
test("maps both models.dev Bedrock packages to native providers", () => {
@@ -45,52 +41,6 @@ describe("AISDKNative", () => {
)
})
test("maps Cloudflare Workers AI to the generic OpenAI-compatible provider", () => {
expect(
map(
"@ai-sdk/openai-compatible",
{
accountId: "account/id",
apiKey: "secret",
baseURL: "https://api.cloudflare.com/client/v4/accounts/account%2Fid/ai/v1",
headers: { "x-custom": "value" },
queryParams: { version: "preview" },
reasoningEffort: "high",
},
"@cf/model",
"cloudflare-workers-ai",
),
).toEqual({
package: "@opencode-ai/ai/providers/openai-compatible",
settings: {
apiKey: "secret",
baseURL: "https://api.cloudflare.com/client/v4/accounts/account%2Fid/ai/v1",
provider: "cloudflare-workers-ai",
queryParams: { version: "preview" },
providerOptions: { openai: { reasoningEffort: "high" } },
},
headers: { "x-custom": "value" },
})
})
test("maps generic OpenAI-compatible providers to the native package", () => {
expect(
map("@ai-sdk/openai-compatible", {
apiKey: "secret",
baseURL: "https://provider.example/v1",
reasoningEffort: "high",
}),
).toEqual({
package: "@opencode-ai/ai/providers/openai-compatible",
settings: {
apiKey: "secret",
baseURL: "https://provider.example/v1",
provider: "test-provider",
providerOptions: { openai: { reasoningEffort: "high" } },
},
})
})
test("maps Bedrock provider and request options", () => {
expect(
map(
+1 -1
View File
@@ -516,12 +516,12 @@ describe("Config", () => {
})
expect(migrated.providers?.["azure-cognitive-services"]).toBeUndefined()
expect(migrated.providers?.["google-vertex"]).toMatchObject({
package: undefined,
settings: { project: "test-project", location: "us-central1" },
models: {
"claude-sonnet": { package: Provider.aisdk("@ai-sdk/google-vertex/anthropic") },
},
})
expect(migrated.providers?.["google-vertex"]).not.toHaveProperty("package")
expect(migrated.providers?.["google-vertex-anthropic"]).toBeUndefined()
}),
)
+1 -6
View File
@@ -7,7 +7,6 @@ import { Catalog } from "@opencode-ai/core/catalog"
import { Generate } from "@opencode-ai/core/generate"
import { Integration } from "@opencode-ai/core/integration"
import { ModelResolver } from "@opencode-ai/core/model-resolver"
import { PluginHooks } from "@opencode-ai/core/plugin/hooks"
import { ID, Info, Ref } from "@opencode-ai/core/model"
import { Provider } from "@opencode-ai/core/provider"
import { Npm } from "@opencode-ai/util/npm"
@@ -66,13 +65,9 @@ const aisdk = Layer.mock(AISDK.Service, {
},
model: () => Effect.succeed(runtime),
})
const hooks = Layer.mock(PluginHooks.Service, {
register: () => Effect.die("unused"),
trigger: (_domain, _name, event) => Effect.succeed(event),
})
const client = TestLLM.clientLayer.pipe(Layer.provide(TestLLM.layer({ fallback: TestLLM.text("OK", "generate") })))
const resolver = ModelResolver.layer.pipe(Layer.provide(Layer.mergeAll(catalog, integrations, npm, aisdk, hooks)))
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))))
const resolverIt = testEffect(resolver)
+2 -52
View File
@@ -131,56 +131,6 @@ describe("ModelResolver", () => {
}),
)
it.effect("routes Cloudflare Workers AI through the generic OpenAI-compatible provider", () =>
Effect.gen(function* () {
const resolved = yield* ModelResolver.resolveModel(
model(Provider.aisdk("@ai-sdk/openai-compatible"), {
providerID: Provider.ID.make("cloudflare-workers-ai"),
modelID: "@cf/meta/llama-3.1-8b-instruct",
settings: {
baseURL: "https://api.cloudflare.com/client/v4/accounts/${CLOUDFLARE_ACCOUNT_ID}/ai/v1",
queryParams: { version: "preview" },
reasoningEffort: "high",
},
}),
undefined,
Credential.Key.make({ type: "key", key: "secret", metadata: { accountId: "account/id" } }),
{
loadAISDK: () => Effect.die("AI SDK loader should not be called"),
resolveProvider: (input) =>
Effect.succeed({
...input.settings,
baseURL: String(input.settings.baseURL).replace(
"${CLOUDFLARE_ACCOUNT_ID}",
encodeURIComponent(String(input.settings.accountId)),
),
}),
},
)
const headers = yield* resolved.route.auth.apply({
request: LLM.request({ model: resolved, prompt: "Hello" }),
method: "POST",
url: "https://example.com",
body: "{}",
headers: Headers.empty,
})
expect(resolved.route.id).toBe("openai-compatible-chat")
expect(String(resolved.provider)).toBe("cloudflare-workers-ai")
expect(resolved.route.endpoint.baseURL).toBe("https://api.cloudflare.com/client/v4/accounts/account%2Fid/ai/v1")
expect(resolved.route.endpoint.query).toEqual({ version: "preview" })
expect(resolved.route.defaults.providerOptions).toEqual({ openai: { reasoningEffort: "high" } })
expect(resolved.route.defaults.http?.body).toEqual({ custom_extension: { enabled: true } })
const prepared = yield* compileRequest(LLM.request({ model: resolved, prompt: "Hello" }))
expect(prepared.body).toMatchObject({
reasoning_effort: "high",
stream_options: { include_usage: true },
})
expect(prepared.body).not.toHaveProperty("accountId")
expect(headers.authorization).toBe("Bearer secret")
}),
)
it.effect("uses the API modelID instead of the catalog ID for native OpenAI routes", () =>
Effect.gen(function* () {
const catalog = model(Provider.aisdk("@ai-sdk/openai"), {
@@ -406,7 +356,7 @@ describe("ModelResolver", () => {
}),
)
it.effect("keeps key credential metadata out of request bodies", () =>
it.effect("prefers stored credentials over configured auth", () =>
Effect.gen(function* () {
const credential = Credential.Key.make({ type: "key", key: "stored-secret", metadata: { tenant: "work" } })
const resolved = yield* ModelResolver.fromCatalogModel(
@@ -426,7 +376,7 @@ describe("ModelResolver", () => {
})
expect(headers.authorization).toBe("Bearer stored-secret")
expect(resolved.route.defaults.http?.body).toEqual({})
expect(resolved.route.defaults.http?.body).toEqual({ tenant: "work" })
}),
)
-3
View File
@@ -29,9 +29,6 @@ export function host(overrides: Overrides = {}): Plugin.Context {
aisdk: overrides.aisdk ?? {
hook: () => Effect.die("unused aisdk.hook"),
},
provider: overrides.provider ?? {
hook: () => Effect.die("unused provider.hook"),
},
catalog: overrides.catalog ?? {
provider: {
list: () => Effect.die("unused catalog.provider.list"),
@@ -1,13 +1,13 @@
import { Catalog } from "@opencode-ai/core/catalog"
import { Plugin } from "@opencode-ai/core/plugin"
import { PluginHost } from "@opencode-ai/core/plugin/host"
import { PluginHooks } from "@opencode-ai/core/plugin/hooks"
import { CloudflareWorkersAIPlugin } from "@opencode-ai/core/plugin/provider/cloudflare-workers-ai"
import { Provider } from "@opencode-ai/core/provider"
import { Credential } from "@opencode-ai/core/credential"
import { ID, Info } from "@opencode-ai/core/model"
import { AISDK } from "@opencode-ai/core/aisdk"
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { Catalog } from "@opencode-ai/core/catalog"
import { Model } from "@opencode-ai/core/model"
import { Plugin } from "@opencode-ai/core/plugin"
import { PluginHost } from "@opencode-ai/core/plugin/host"
import { CloudflareWorkersAIPlugin } from "@opencode-ai/core/plugin/provider/cloudflare-workers-ai"
import { Provider } from "@opencode-ai/core/provider"
import type { LanguageModelV3 } from "@ai-sdk/provider"
import { testEffect } from "../lib/effect"
import { PluginTestLayer } from "./fixture"
@@ -15,7 +15,9 @@ const it = testEffect(PluginTestLayer)
const addPlugin = Effect.fn(function* () {
const plugin = yield* Plugin.Service
yield* CloudflareWorkersAIPlugin.effect(yield* PluginHost.make(plugin))
const aisdk = yield* AISDK.Service
const host = yield* PluginHost.make(plugin)
yield* CloudflareWorkersAIPlugin.effect(host)
})
function required<T>(value: T | undefined): T {
@@ -23,130 +25,243 @@ function required<T>(value: T | undefined): T {
return value
}
function withEnv<A, E, R>(value: string | undefined, effect: () => Effect.Effect<A, E, R>) {
function withEnv<A, E, R>(vars: Record<string, string | undefined>, effect: () => Effect.Effect<A, E, R>) {
return Effect.acquireUseRelease(
Effect.sync(() => {
const previous = process.env.CLOUDFLARE_ACCOUNT_ID
if (value === undefined) delete process.env.CLOUDFLARE_ACCOUNT_ID
else process.env.CLOUDFLARE_ACCOUNT_ID = value
const previous = Object.fromEntries(Object.keys(vars).map((key) => [key, process.env[key]]))
Object.entries(vars).forEach(([key, value]) => {
if (value === undefined) delete process.env[key]
else process.env[key] = value
})
return previous
}),
effect,
(previous) =>
Effect.sync(() => {
if (previous === undefined) delete process.env.CLOUDFLARE_ACCOUNT_ID
else process.env.CLOUDFLARE_ACCOUNT_ID = previous
}),
Effect.sync(() =>
Object.entries(previous).forEach(([key, value]) => {
if (value === undefined) delete process.env[key]
else process.env[key] = value
}),
),
)
}
const providerID = Provider.ID.make("cloudflare-workers-ai")
function fakeSelectorSdk(calls: string[]) {
const make = (method: string) => (id: string) => {
calls.push(`${method}:${id}`)
return { modelId: id, provider: method, specificationVersion: "v3" } as unknown as LanguageModelV3
}
return {
responses: make("responses"),
messages: make("messages"),
chat: make("chat"),
languageModel: make("languageModel"),
}
}
function cloudflareLanguage(sdk: unknown, modelID = "@cf/model") {
return (sdk as { languageModel: (id: string) => { config: CloudflareConfig; provider: string } }).languageModel(
modelID,
)
}
type CloudflareConfig = {
url: (input: { path: string; modelId: string }) => string
headers: () => Record<string, string> | Promise<Record<string, string>>
}
function cloudflareURL(sdk: unknown, modelID = "@cf/model") {
return cloudflareLanguage(sdk, modelID).config.url({ path: "/chat/completions", modelId: modelID })
}
function cloudflareHeaders(sdk: unknown, modelID = "@cf/model") {
return cloudflareLanguage(sdk, modelID).config.headers()
}
describe("CloudflareWorkersAIPlugin", () => {
it.effect("resolves the account environment variable into the native endpoint", () =>
withEnv("account/id", () =>
it.effect("maps account ID to endpoint URL and creates an OpenAI-compatible SDK", () =>
withEnv({ CLOUDFLARE_ACCOUNT_ID: "acct", CLOUDFLARE_API_KEY: "key" }, () =>
Effect.gen(function* () {
const plugin = yield* Plugin.Service
const aisdk = yield* AISDK.Service
const catalog = yield* Catalog.Service
yield* catalog.transform((draft) =>
draft.provider.update(providerID, (provider) => {
provider.package = Provider.aisdk("@ai-sdk/openai-compatible")
yield* catalog.transform((catalog) =>
catalog.provider.update(Provider.ID.make("cloudflare-workers-ai"), (provider) => {
provider.package = Provider.aisdk("test-provider")
}),
)
yield* addPlugin()
const provider = required(yield* catalog.provider.get(Provider.ID.make("cloudflare-workers-ai")))
const sdk = yield* aisdk.runSDK({
model: Model.Info.make({
...Model.Info.default(Provider.ID.make("cloudflare-workers-ai"), Model.ID.make("@cf/model")),
modelID: Model.ID.make("@cf/model"),
package: provider.package,
settings: provider.settings,
}),
package: "@ai-sdk/openai-compatible",
options: { name: "cloudflare-workers-ai", headers: { custom: "header" } },
})
expect(provider).toMatchObject({
package: "aisdk:test-provider",
settings: { baseURL: "https://api.cloudflare.com/client/v4/accounts/acct/ai/v1" },
})
expect(sdk.sdk).toBeDefined()
}),
),
)
expect(required(yield* catalog.provider.get(providerID))).toMatchObject({
settings: { baseURL: "https://api.cloudflare.com/client/v4/accounts/account%2Fid/ai/v1" },
headers: { "User-Agent": expect.stringContaining("cloudflare-workers-ai") },
it.effect("preserves a configured endpoint URL instead of deriving one from account ID", () =>
withEnv({ CLOUDFLARE_ACCOUNT_ID: "acct" }, () =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
yield* catalog.transform((catalog) =>
catalog.provider.update(Provider.ID.make("cloudflare-workers-ai"), (provider) => {
provider.package = Provider.aisdk("test-provider")
provider.settings = { ...provider.settings, baseURL: "https://proxy.example/v1" }
}),
)
yield* addPlugin()
expect(required(yield* catalog.provider.get(Provider.ID.make("cloudflare-workers-ai")))).toMatchObject({
package: "aisdk:test-provider",
settings: { baseURL: "https://proxy.example/v1" },
})
}),
),
)
it.effect("resolves an account ID from provider settings", () =>
withEnv(undefined, () =>
it.effect("allows a configured baseURL without account ID", () =>
withEnv({ CLOUDFLARE_ACCOUNT_ID: undefined, CLOUDFLARE_API_KEY: "key" }, () =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
yield* catalog.transform((draft) =>
draft.provider.update(providerID, (provider) => {
provider.package = Provider.aisdk("@ai-sdk/openai-compatible")
provider.settings = { accountId: "configured/account" }
}),
)
const plugin = yield* Plugin.Service
const aisdk = yield* AISDK.Service
yield* addPlugin()
expect(required(yield* catalog.provider.get(providerID)).settings?.baseURL).toBe(
"https://api.cloudflare.com/client/v4/accounts/configured%2Faccount/ai/v1",
)
const result = yield* aisdk.runSDK({
model: Model.Info.make({
...Model.Info.default(Provider.ID.make("cloudflare-workers-ai"), Model.ID.make("@cf/model")),
modelID: Model.ID.make("@cf/model"),
package: "aisdk:@ai-sdk/openai-compatible",
settings: { baseURL: "https://proxy.example/v1" },
}),
package: "@ai-sdk/openai-compatible",
options: { name: "cloudflare-workers-ai", baseURL: "https://proxy.example/v1" },
})
expect(cloudflareURL(result.sdk)).toBe("https://proxy.example/v1/chat/completions")
}),
),
)
it.effect("resolves an account ID from credential metadata before native routing", () =>
withEnv(undefined, () =>
it.effect("uses env account ID over configured account ID", () =>
withEnv({ CLOUDFLARE_ACCOUNT_ID: "env-acct" }, () =>
Effect.gen(function* () {
yield* addPlugin()
const event = yield* (yield* PluginHooks.Service).trigger("provider", "resolve", {
model: Info.make({
id: ID.make("model"),
modelID: ID.make("@cf/model"),
providerID,
name: "Model",
package: Provider.aisdk("@ai-sdk/openai-compatible"),
settings: {},
capabilities: { tools: true, input: ["text"], output: ["text"] },
variants: [],
time: { released: 0 },
cost: [],
status: "active",
enabled: true,
limit: { context: 128_000, output: 8_192 },
const catalog = yield* Catalog.Service
yield* catalog.transform((catalog) =>
catalog.provider.update(Provider.ID.make("cloudflare-workers-ai"), (provider) => {
provider.package = Provider.aisdk("test-provider")
provider.settings = { ...provider.settings, accountId: "configured-acct" }
}),
credential: Credential.Key.make({ type: "key", key: "secret", metadata: { accountId: "stored/account" } }),
settings: {
accountId: "stored/account",
)
yield* addPlugin()
expect(required(yield* catalog.provider.get(Provider.ID.make("cloudflare-workers-ai")))).toMatchObject({
package: "aisdk:test-provider",
settings: { baseURL: "https://api.cloudflare.com/client/v4/accounts/env-acct/ai/v1" },
})
}),
),
)
it.effect("uses env API key over auth or configured API key and keeps the Cloudflare User-Agent", () =>
withEnv({ CLOUDFLARE_ACCOUNT_ID: "acct", CLOUDFLARE_API_KEY: "env-key" }, () =>
Effect.gen(function* () {
const plugin = yield* Plugin.Service
const aisdk = yield* AISDK.Service
yield* addPlugin()
const result = yield* aisdk.runSDK({
model: Model.Info.make({
...Model.Info.default(Provider.ID.make("cloudflare-workers-ai"), Model.ID.make("@cf/model")),
modelID: Model.ID.make("@cf/model"),
package: "aisdk:@ai-sdk/openai-compatible",
settings: { baseURL: "https://proxy.example/v1" },
}),
package: "@ai-sdk/openai-compatible",
options: {
name: "cloudflare-workers-ai",
apiKey: "auth-key",
baseURL: "https://proxy.example/v1",
headers: { custom: "header" },
},
})
const headers = yield* Effect.promise(() => Promise.resolve(cloudflareHeaders(result.sdk)))
expect(headers.authorization).toBe("Bearer env-key")
expect(headers.custom).toBe("header")
expect(headers["user-agent"]).toMatch(/^opencode\/.* cloudflare-workers-ai \(.+\) ai-sdk\/openai-compatible\//)
}),
),
)
it.effect("expands account ID vars in endpoint URLs", () =>
withEnv({ CLOUDFLARE_ACCOUNT_ID: "acct", CLOUDFLARE_API_KEY: "key" }, () =>
Effect.gen(function* () {
const plugin = yield* Plugin.Service
const aisdk = yield* AISDK.Service
yield* addPlugin()
const result = yield* aisdk.runSDK({
model: Model.Info.make({
...Model.Info.default(Provider.ID.make("cloudflare-workers-ai"), Model.ID.make("@cf/model")),
modelID: Model.ID.make("@cf/model"),
package: "aisdk:@ai-sdk/openai-compatible",
settings: { baseURL: "https://api.cloudflare.com/client/v4/accounts/${CLOUDFLARE_ACCOUNT_ID}/ai/v1" },
}),
package: "@ai-sdk/openai-compatible",
options: {
name: "cloudflare-workers-ai",
baseURL: "https://api.cloudflare.com/client/v4/accounts/${CLOUDFLARE_ACCOUNT_ID}/ai/v1",
},
})
expect(event.settings.baseURL).toBe(
"https://api.cloudflare.com/client/v4/accounts/stored%2Faccount/ai/v1",
expect(cloudflareURL(result.sdk)).toBe(
"https://api.cloudflare.com/client/v4/accounts/acct/ai/v1/chat/completions",
)
}),
),
)
it.effect("expands account placeholders and preserves configured endpoints", () =>
withEnv("env-account", () =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
yield* catalog.transform((draft) =>
draft.provider.update(providerID, (provider) => {
provider.package = Provider.aisdk("@ai-sdk/openai-compatible")
provider.settings = {
baseURL: "https://api.cloudflare.com/client/v4/accounts/${CLOUDFLARE_ACCOUNT_ID}/ai/v1",
}
}),
)
yield* addPlugin()
expect(required(yield* catalog.provider.get(providerID)).settings?.baseURL).toBe(
"https://api.cloudflare.com/client/v4/accounts/env-account/ai/v1",
)
}),
),
it.effect("selects languageModel with the API model ID", () =>
Effect.gen(function* () {
const plugin = yield* Plugin.Service
const aisdk = yield* AISDK.Service
const calls: string[] = []
yield* addPlugin()
const result = yield* aisdk.runLanguage({
model: Model.Info.make({
...Model.Info.default(Provider.ID.make("cloudflare-workers-ai"), Model.ID.make("alias")),
modelID: Model.ID.make("@cf/api-model"),
package: "aisdk:test-provider",
}),
sdk: fakeSelectorSdk(calls),
options: {},
})
expect(result.language).toBeDefined()
expect(calls).toEqual(["languageModel:@cf/api-model"])
}),
)
it.effect("preserves a custom endpoint when an account ID is configured", () =>
withEnv(undefined, () =>
it.effect("does not create an SDK for non OpenAI-compatible packages", () =>
withEnv({ CLOUDFLARE_ACCOUNT_ID: "acct", CLOUDFLARE_API_KEY: "key" }, () =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
yield* catalog.transform((draft) =>
draft.provider.update(providerID, (provider) => {
provider.package = Provider.aisdk("@ai-sdk/openai-compatible")
provider.settings = { accountId: "configured-account", baseURL: "https://proxy.example/v1" }
}),
)
const plugin = yield* Plugin.Service
const aisdk = yield* AISDK.Service
yield* addPlugin()
expect(required(yield* catalog.provider.get(providerID)).settings?.baseURL).toBe("https://proxy.example/v1")
const result = yield* aisdk.runSDK({
model: Model.Info.make({
...Model.Info.default(Provider.ID.make("cloudflare-workers-ai"), Model.ID.make("@cf/model")),
modelID: Model.ID.make("@cf/model"),
package: "aisdk:@ai-sdk/anthropic",
settings: { baseURL: "https://proxy.example/v1" },
}),
package: "@ai-sdk/anthropic",
options: { name: "cloudflare-workers-ai" },
})
expect(result.sdk).toBeUndefined()
}),
),
)
+74
View File
@@ -1086,4 +1086,78 @@ describe("Session.pending", () => {
expect(yield* session.pending(sessionID)).toEqual([])
}),
)
it.effect("cancels only queued input and allows its ID to be admitted again", () =>
Effect.gen(function* () {
yield* setup
const session = yield* Session.Service
const inputID = SessionMessage.ID.make("msg_cancelled_queue")
yield* session.prompt({
id: inputID,
sessionID,
text: "Queue this",
delivery: "queue",
resume: false,
})
yield* session.cancelPending({ sessionID, inputID })
expect(yield* session.pending(sessionID)).toEqual([])
expect(yield* eventCount(Bus.versionedType(SessionEvent.InputCancelled.type, 1))).toBe(1)
expect(
yield* session.cancelPending({ sessionID, inputID }).pipe(Effect.flip),
).toMatchObject({ _tag: "Session.PendingInputConflictError", sessionID, inputID })
expect(yield* eventCount(Bus.versionedType(SessionEvent.InputCancelled.type, 1))).toBe(1)
const retried = yield* session.prompt({
id: inputID,
sessionID,
text: "Queue this",
delivery: "queue",
resume: false,
})
expect(retried).toMatchObject({ id: inputID, delivery: "queue" })
}),
)
it.effect("moves pending input between steer and queue delivery", () =>
Effect.gen(function* () {
yield* setup
const session = yield* Session.Service
const queued = yield* session.synthetic({
sessionID,
text: "Steer this",
delivery: "queue",
resume: false,
})
const alreadySteered = yield* session.prompt({ sessionID, text: "Already steer", resume: false })
wakeCalls.length = 0
yield* session.steerPending({ sessionID, inputID: queued.id })
expect(yield* session.pending(sessionID)).toMatchObject([
{ id: queued.id, delivery: "steer" },
{ id: alreadySteered.id, delivery: "steer" },
])
expect(wakeCalls).toEqual([sessionID])
expect(yield* eventCount(Bus.versionedType(SessionEvent.InputSteered.type, 1))).toBe(1)
wakeCalls.length = 0
yield* session.queuePending({ sessionID, inputID: queued.id })
expect(yield* session.pending(sessionID)).toMatchObject([
{ id: queued.id, delivery: "queue" },
{ id: alreadySteered.id, delivery: "steer" },
])
expect(wakeCalls).toEqual([])
expect(yield* eventCount(Bus.versionedType(SessionEvent.InputQueued.type, 1))).toBe(1)
expect(
yield* session.steerPending({ sessionID, inputID: alreadySteered.id }).pipe(Effect.flip),
).toMatchObject({ _tag: "Session.PendingInputConflictError", sessionID, inputID: alreadySteered.id })
yield* session.cancelPending({ sessionID, inputID: alreadySteered.id })
expect(wakeCalls).toEqual([])
expect(yield* eventCount(Bus.versionedType(SessionEvent.InputSteered.type, 1))).toBe(1)
expect(yield* eventCount(Bus.versionedType(SessionEvent.InputCancelled.type, 1))).toBe(1)
}),
)
})
-2
View File
@@ -8,7 +8,6 @@ import type { CatalogDomain } from "./catalog.js"
import type { CommandDomain } from "./command.js"
import type { EventDomain } from "./event.js"
import type { IntegrationDomain } from "./integration.js"
import type { ProviderDomain } from "./provider.js"
import type { ReferenceDomain } from "./reference.js"
import type { SessionDomain } from "./session.js"
import type { ShellDomain } from "./shell.js"
@@ -26,7 +25,6 @@ export interface Context {
readonly event: EventDomain
readonly integration: IntegrationDomain
readonly plugin: PluginApi<unknown>
readonly provider: ProviderDomain
readonly reference: ReferenceDomain
readonly session: SessionDomain
readonly shell: ShellDomain
-15
View File
@@ -1,15 +0,0 @@
import type { Credential } from "@opencode-ai/schema/credential"
import type { Model } from "@opencode-ai/schema/model"
import type { Hooks } from "./registration.js"
export interface ProviderHooks {
resolve: {
readonly model: Model.Info
readonly credential?: Credential.Value
readonly settings: Record<string, unknown>
}
}
export interface ProviderDomain {
readonly hook: Hooks<ProviderHooks>
}
-2
View File
@@ -7,7 +7,6 @@ import type { CatalogDomain } from "./catalog.js"
import type { CommandDomain } from "./command.js"
import type { EventDomain } from "./event.js"
import type { IntegrationDomain } from "./integration.js"
import type { ProviderDomain } from "./provider.js"
import type { ReferenceDomain } from "./reference.js"
import type { SessionDomain } from "./session.js"
import type { ShellDomain } from "./shell.js"
@@ -25,7 +24,6 @@ export interface Context {
readonly event: EventDomain
readonly integration: IntegrationDomain
readonly plugin: PluginApi
readonly provider: ProviderDomain
readonly reference: ReferenceDomain
readonly session: SessionDomain
readonly shell: ShellDomain
-15
View File
@@ -1,15 +0,0 @@
import type { Credential } from "@opencode-ai/schema/credential"
import type { Model } from "@opencode-ai/schema/model"
import type { Hooks } from "./registration.js"
export interface ProviderHooks {
resolve: {
readonly model: Model.Info
readonly credential?: Credential.Value
readonly settings: Record<string, unknown>
}
}
export interface ProviderDomain {
readonly hook: Hooks<ProviderHooks>
}
File diff suppressed because it is too large Load Diff
+39
View File
@@ -491,6 +491,45 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo
}),
),
)
.add(
HttpApiEndpoint.delete("session.pending.cancel", "/api/session/:sessionID/pending/:inputID", {
params: { sessionID: Session.ID, inputID: SessionMessage.ID },
success: HttpApiSchema.NoContent,
error: [ConflictError, SessionNotFoundError],
}).annotateMerge(
OpenApi.annotations({
identifier: "v2.session.pending.cancel",
summary: "Cancel pending input",
description: "Cancel an input that has not yet been promoted into session history.",
}),
),
)
.add(
HttpApiEndpoint.post("session.pending.steer", "/api/session/:sessionID/pending/:inputID/steer", {
params: { sessionID: Session.ID, inputID: SessionMessage.ID },
success: HttpApiSchema.NoContent,
error: [ConflictError, SessionNotFoundError],
}).annotateMerge(
OpenApi.annotations({
identifier: "v2.session.pending.steer",
summary: "Steer queued input",
description: "Change a queued input to steer delivery and wake session execution.",
}),
),
)
.add(
HttpApiEndpoint.post("session.pending.queue", "/api/session/:sessionID/pending/:inputID/queue", {
params: { sessionID: Session.ID, inputID: SessionMessage.ID },
success: HttpApiSchema.NoContent,
error: [ConflictError, SessionNotFoundError],
}).annotateMerge(
OpenApi.annotations({
identifier: "v2.session.pending.queue",
summary: "Queue pending steer",
description: "Change a pending steer to queued delivery.",
}),
),
)
.add(
HttpApiEndpoint.get("session.instructions.entry.list", "/api/session/:sessionID/instructions/entries", {
params: { sessionID: Session.ID },
+30 -30
View File
@@ -3,7 +3,7 @@ export * as Config from "./config.js"
import { Schema } from "effect"
import { ephemeral, inventory } from "./event.js"
import { Permission } from "./permission.js"
import { AbsolutePath } from "./schema.js"
import { AbsolutePath, optional } from "./schema.js"
import { ConfigAgent } from "./config/agent.js"
import { ConfigMedia } from "./config/media.js"
import { ConfigCompaction } from "./config/compaction.js"
@@ -22,94 +22,94 @@ import { ConfigWatcher } from "./config/watcher.js"
import { ConfigWarming } from "./config/warming.js"
export class Info extends Schema.Class<Info>("Config.Info")({
$schema: Schema.optional(Schema.String).annotate({
$schema: optional(Schema.String).annotate({
description: "JSON schema reference for configuration validation",
}),
shell: Schema.String.pipe(Schema.optional).annotate({
shell: Schema.String.pipe(optional).annotate({
description: "Default shell to use for terminal and shell tool execution",
}),
model: ConfigModel.Selection.pipe(Schema.optional).annotate({
model: ConfigModel.Selection.pipe(optional).annotate({
description: "Default model to use when no session or agent model is selected",
}),
default_agent: Schema.String.pipe(Schema.optional).annotate({
default_agent: Schema.String.pipe(optional).annotate({
description: "Default primary agent to use when no session agent is selected",
}),
autoupdate: Schema.Union([Schema.Boolean, Schema.Literal("notify")])
.pipe(Schema.optional)
.pipe(optional)
.annotate({
description: "Automatically update or notify when a new version is available",
}),
share: Schema.Literals(["manual", "auto", "disabled"]).pipe(Schema.optional).annotate({
share: Schema.Literals(["manual", "auto", "disabled"]).pipe(optional).annotate({
description: "Control whether sessions may be shared manually, automatically, or not at all",
}),
enterprise: Schema.Struct({
url: Schema.String.pipe(Schema.optional),
url: Schema.String.pipe(optional),
})
.pipe(Schema.optional)
.pipe(optional)
.annotate({
description: "Enterprise sharing service configuration",
}),
username: Schema.String.pipe(Schema.optional).annotate({
username: Schema.String.pipe(optional).annotate({
description: "Username displayed in conversations and used for telemetry identity",
}),
permissions: Permission.Ruleset.pipe(Schema.optional).annotate({
permissions: Permission.Ruleset.pipe(optional).annotate({
description: "Ordered tool permission rules applied to agent tool use",
}),
agents: Schema.Record(Schema.String, ConfigAgent.Info).pipe(Schema.optional).annotate({
agents: Schema.Record(Schema.String, ConfigAgent.Info).pipe(optional).annotate({
description: "Named built-in agent overrides and custom agent definitions",
}),
snapshots: Schema.Boolean.pipe(Schema.optional).annotate({
snapshots: Schema.Boolean.pipe(optional).annotate({
description: "Enable snapshots used for undo and revert behavior",
}),
watcher: ConfigWatcher.Info.pipe(Schema.optional).annotate({
watcher: ConfigWatcher.Info.pipe(optional).annotate({
description: "Filesystem watcher configuration",
}),
formatter: ConfigFormatter.Info.pipe(Schema.optional).annotate({
formatter: ConfigFormatter.Info.pipe(optional).annotate({
description: "Enable built-in formatters or configure formatter overrides",
}),
lsp: ConfigLSP.Info.pipe(Schema.optional).annotate({
lsp: ConfigLSP.Info.pipe(optional).annotate({
description: "Enable built-in language servers or configure server overrides",
}),
media: ConfigMedia.Info.pipe(Schema.optional).annotate({
media: ConfigMedia.Info.pipe(optional).annotate({
description: "Media processing configuration",
}),
tool_output: ConfigToolOutput.Info.pipe(Schema.optional).annotate({
tool_output: ConfigToolOutput.Info.pipe(optional).annotate({
description: "Tool output truncation thresholds",
}),
mcp: ConfigMCP.Info.pipe(Schema.optional).annotate({
mcp: ConfigMCP.Info.pipe(optional).annotate({
description: "MCP server configuration",
}),
compaction: ConfigCompaction.Info.pipe(Schema.optional).annotate({
compaction: ConfigCompaction.Info.pipe(optional).annotate({
description: "Conversation compaction behavior",
}),
skills: Schema.String.pipe(Schema.Array, Schema.optional).annotate({
skills: Schema.String.pipe(Schema.Array, optional).annotate({
description: "Additional paths or URLs to discover skills from",
}),
commands: Schema.Record(Schema.String, ConfigCommand.Info).pipe(Schema.optional).annotate({
commands: Schema.Record(Schema.String, ConfigCommand.Info).pipe(optional).annotate({
description: "Named slash command definitions",
}),
instructions: Schema.String.pipe(Schema.Array, Schema.optional).annotate({
instructions: Schema.String.pipe(Schema.Array, optional).annotate({
description: "Additional paths or URLs supplying ambient instructions",
}),
references: ConfigReference.Info.pipe(Schema.optional).annotate({
references: ConfigReference.Info.pipe(optional).annotate({
description: "Named local directories or Git repositories available as external context",
}),
websearch: ConfigWebSearch.Info.pipe(Schema.optional).annotate({
websearch: ConfigWebSearch.Info.pipe(optional).annotate({
description: "Web search provider selection",
}),
plugins: ConfigPlugin.Plugins.pipe(Schema.optional).annotate({
plugins: ConfigPlugin.Plugins.pipe(optional).annotate({
description: "Ordered plugin enablement directives and external package declarations",
}),
warming: ConfigWarming.Warming.pipe(Schema.optional).annotate({
warming: ConfigWarming.Warming.pipe(optional).annotate({
description: "Keep recently active sessions warm with transient model requests (default: false)",
}),
providers: Schema.Record(Schema.String, ConfigProvider.Info).pipe(Schema.optional),
experimental: ConfigExperimental.Info.pipe(Schema.optional),
providers: Schema.Record(Schema.String, ConfigProvider.Info).pipe(optional),
experimental: ConfigExperimental.Info.pipe(optional),
}) {}
export class Document extends Schema.Class<Document>("Config.Document")({
type: Schema.Literal("document"),
path: Schema.String.pipe(Schema.optional),
path: Schema.String.pipe(optional),
info: Info,
}) {}
+11 -11
View File
@@ -2,21 +2,21 @@ export * as ConfigAgent from "./agent.js"
import { Schema } from "effect"
import { Permission } from "../permission.js"
import { PositiveInt } from "../schema.js"
import { optional, PositiveInt } from "../schema.js"
import { ConfigModel } from "./model.js"
import { ConfigProvider } from "./provider.js"
export const Color = Schema.String.check(Schema.isPattern(/^#[0-9a-fA-F]{6}$/))
export class Info extends Schema.Class<Info>("Config.Agent")({
model: ConfigModel.Selection.pipe(Schema.optional),
request: ConfigProvider.Request.pipe(Schema.optional),
system: Schema.String.pipe(Schema.optional),
description: Schema.String.pipe(Schema.optional),
mode: Schema.Literals(["subagent", "primary", "all"]).pipe(Schema.optional),
hidden: Schema.Boolean.pipe(Schema.optional),
color: Color.pipe(Schema.optional),
steps: PositiveInt.pipe(Schema.optional),
disabled: Schema.Boolean.pipe(Schema.optional),
permissions: Permission.Ruleset.pipe(Schema.optional),
model: ConfigModel.Selection.pipe(optional),
request: ConfigProvider.Request.pipe(optional),
system: Schema.String.pipe(optional),
description: Schema.String.pipe(optional),
mode: Schema.Literals(["subagent", "primary", "all"]).pipe(optional),
hidden: Schema.Boolean.pipe(optional),
color: Color.pipe(optional),
steps: PositiveInt.pipe(optional),
disabled: Schema.Boolean.pipe(optional),
permissions: Permission.Ruleset.pipe(optional),
}) {}
+5 -4
View File
@@ -1,12 +1,13 @@
export * as ConfigCommand from "./command.js"
import { Schema } from "effect"
import { optional } from "../schema.js"
import { ConfigModel } from "./model.js"
export class Info extends Schema.Class<Info>("Config.Command")({
template: Schema.String,
description: Schema.String.pipe(Schema.optional),
agent: Schema.String.pipe(Schema.optional),
model: ConfigModel.Selection.pipe(Schema.optional),
subtask: Schema.Boolean.pipe(Schema.optional),
description: Schema.String.pipe(optional),
agent: Schema.String.pipe(optional),
model: ConfigModel.Selection.pipe(optional),
subtask: Schema.Boolean.pipe(optional),
}) {}
+5 -5
View File
@@ -1,14 +1,14 @@
export * as ConfigCompaction from "./compaction.js"
import { Schema } from "effect"
import { NonNegativeInt } from "../schema.js"
import { NonNegativeInt, optional } from "../schema.js"
export class Keep extends Schema.Class<Keep>("Config.Compaction.Keep")({
tokens: NonNegativeInt.pipe(Schema.optional),
tokens: NonNegativeInt.pipe(optional),
}) {}
export class Info extends Schema.Class<Info>("Config.Compaction")({
auto: Schema.Boolean.pipe(Schema.optional),
keep: Keep.pipe(Schema.optional),
buffer: NonNegativeInt.pipe(Schema.optional),
auto: Schema.Boolean.pipe(optional),
keep: Keep.pipe(optional),
buffer: NonNegativeInt.pipe(optional),
}) {}
+3 -3
View File
@@ -1,14 +1,14 @@
export * as ConfigExperimental from "./experimental.js"
import { Schema } from "effect"
import { NonNegativeInt } from "../schema.js"
import { NonNegativeInt, optional } from "../schema.js"
import { ConfigPolicy } from "./policy.js"
export class Info extends Schema.Class<Info>("ConfigExperimental.Info")({
subagent_depth: NonNegativeInt.pipe(Schema.optional).annotate({
subagent_depth: NonNegativeInt.pipe(optional).annotate({
description: "Maximum subagent nesting depth. Defaults to 1.",
}),
policies: ConfigPolicy.Info.pipe(Schema.Array, Schema.optional).annotate({
policies: ConfigPolicy.Info.pipe(Schema.Array, optional).annotate({
description: "Ordered policies controlling access to configured resources",
}),
}) {}
+5 -4
View File
@@ -1,12 +1,13 @@
export * as ConfigFormatter from "./formatter.js"
import { Schema } from "effect"
import { optional } from "../schema.js"
export class Entry extends Schema.Class<Entry>("Config.Formatter.Entry")({
disabled: Schema.Boolean.pipe(Schema.optional),
command: Schema.String.pipe(Schema.Array, Schema.optional),
environment: Schema.Record(Schema.String, Schema.String).pipe(Schema.optional),
extensions: Schema.String.pipe(Schema.Array, Schema.optional),
disabled: Schema.Boolean.pipe(optional),
command: Schema.String.pipe(Schema.Array, optional),
environment: Schema.Record(Schema.String, Schema.String).pipe(optional),
extensions: Schema.String.pipe(Schema.Array, optional),
}) {}
export const Info = Schema.Union([Schema.Boolean, Schema.Record(Schema.String, Entry)])
+5 -4
View File
@@ -1,6 +1,7 @@
export * as ConfigLSP from "./lsp.js"
import { Schema } from "effect"
import { optional } from "../schema.js"
export const Disabled = Schema.Struct({
disabled: Schema.Literal(true),
@@ -8,10 +9,10 @@ export const Disabled = Schema.Struct({
export class Server extends Schema.Class<Server>("Config.LSP.Server")({
command: Schema.String.pipe(Schema.Array),
extensions: Schema.String.pipe(Schema.Array, Schema.optional),
disabled: Schema.Boolean.pipe(Schema.optional),
env: Schema.Record(Schema.String, Schema.String).pipe(Schema.optional),
initialization: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional),
extensions: Schema.String.pipe(Schema.Array, optional),
disabled: Schema.Boolean.pipe(optional),
env: Schema.Record(Schema.String, Schema.String).pipe(optional),
initialization: Schema.Record(Schema.String, Schema.Unknown).pipe(optional),
}) {}
export const Entry = Schema.Union([Disabled, Server])
+3 -2
View File
@@ -2,6 +2,7 @@ export * as ConfigMCP from "./mcp.js"
import { Schema } from "effect"
import { Mcp } from "../mcp.js"
import { optional } from "../schema.js"
export const Timeout = Mcp.TimeoutConfig
export type Timeout = Mcp.TimeoutConfig
@@ -14,6 +15,6 @@ export type Remote = Mcp.RemoteConfig
export const Server = Mcp.ServerConfig
export class Info extends Schema.Class<Info>("Config.MCP")({
timeout: Timeout.pipe(Schema.optional),
servers: Schema.Record(Schema.String, Server).pipe(Schema.optional),
timeout: Timeout.pipe(optional),
servers: Schema.Record(Schema.String, Server).pipe(optional),
}) {}
+6 -6
View File
@@ -1,15 +1,15 @@
export * as ConfigMedia from "./media.js"
import { Schema } from "effect"
import { PositiveInt } from "../schema.js"
import { optional, PositiveInt } from "../schema.js"
export class Image extends Schema.Class<Image>("Config.Media.Image")({
auto_resize: Schema.Boolean.pipe(Schema.optional),
max_width: PositiveInt.pipe(Schema.optional),
max_height: PositiveInt.pipe(Schema.optional),
max_base64_bytes: PositiveInt.pipe(Schema.optional),
auto_resize: Schema.Boolean.pipe(optional),
max_width: PositiveInt.pipe(optional),
max_height: PositiveInt.pipe(optional),
max_base64_bytes: PositiveInt.pipe(optional),
}) {}
export class Info extends Schema.Class<Info>("Config.Media")({
image: Image.pipe(Schema.optional),
image: Image.pipe(optional),
}) {}
+2 -1
View File
@@ -3,6 +3,7 @@ export * as ConfigModel from "./model.js"
import { Schema, SchemaGetter } from "effect"
import { Model } from "../model.js"
import { Provider } from "../provider.js"
import { optional } from "../schema.js"
const ProviderID = Provider.ID.check(Schema.isPattern(/^[^/#]+$/))
const ModelID = Model.ID.check(Schema.isPattern(/^[^#]+$/))
@@ -11,7 +12,7 @@ const VariantID = Model.VariantID.check(Schema.isPattern(/^[^#]+$/))
const Explicit = Schema.Struct({
providerID: ProviderID,
model: ModelID,
variant: VariantID.pipe(Schema.optional),
variant: VariantID.pipe(optional),
})
const Short = Schema.String.check(Schema.isPattern(/^[^/#]+\/[^#]+(?:#[^#]+)?$/))
+2 -1
View File
@@ -1,10 +1,11 @@
export * as ConfigPlugin from "./plugin.js"
import { Schema } from "effect"
import { optional } from "../schema.js"
export class Entry extends Schema.Class<Entry>("Config.Plugin.Entry")({
package: Schema.String,
options: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional),
options: Schema.Record(Schema.String, Schema.Unknown).pipe(optional),
}) {}
export const Plugin = Schema.Union([Schema.String, Entry])
+25 -24
View File
@@ -3,13 +3,14 @@ export * as ConfigProvider from "./provider.js"
import { Schema } from "effect"
import { Money } from "../money.js"
import { Capabilities, Compatibility, Family, ID, VariantID } from "../model.js"
import { optional } from "../schema.js"
const JsonRecord = Schema.Record(Schema.String, Schema.Json)
export const Overlays = {
settings: JsonRecord.pipe(Schema.optional),
headers: Schema.Record(Schema.String, Schema.String).pipe(Schema.optional),
body: JsonRecord.pipe(Schema.optional),
settings: JsonRecord.pipe(optional),
headers: Schema.Record(Schema.String, Schema.String).pipe(optional),
body: JsonRecord.pipe(optional),
}
export class Request extends Schema.Class<Request>("Config.Provider.Request")({
@@ -18,47 +19,47 @@ export class Request extends Schema.Class<Request>("Config.Provider.Request")({
}) {}
class Cache extends Schema.Class<Cache>("Config.Model.Cost.Cache")({
read: Money.USDPerMillionTokens.pipe(Schema.optional),
write: Money.USDPerMillionTokens.pipe(Schema.optional),
read: Money.USDPerMillionTokens.pipe(optional),
write: Money.USDPerMillionTokens.pipe(optional),
}) {}
class Cost extends Schema.Class<Cost>("Config.Model.Cost")({
tier: Schema.Struct({
type: Schema.Literal("context"),
size: Schema.Int,
}).pipe(Schema.optional),
}).pipe(optional),
input: Money.USDPerMillionTokens,
output: Money.USDPerMillionTokens,
cache: Cache.pipe(Schema.optional),
cache: Cache.pipe(optional),
}) {}
class Limit extends Schema.Class<Limit>("Config.Model.Limit")({
context: Schema.Int.pipe(Schema.optional),
input: Schema.Int.pipe(Schema.optional),
output: Schema.Int.pipe(Schema.optional),
context: Schema.Int.pipe(optional),
input: Schema.Int.pipe(optional),
output: Schema.Int.pipe(optional),
}) {}
class Model extends Schema.Class<Model>("Config.Model")({
modelID: ID.pipe(Schema.optional),
family: Family.pipe(Schema.optional),
name: Schema.String.pipe(Schema.optional),
compatibility: Compatibility.pipe(Schema.optional),
package: Schema.String.pipe(Schema.optional),
modelID: ID.pipe(optional),
family: Family.pipe(optional),
name: Schema.String.pipe(optional),
compatibility: Compatibility.pipe(optional),
package: Schema.String.pipe(optional),
...Overlays,
capabilities: Capabilities.pipe(Schema.optional),
capabilities: Capabilities.pipe(optional),
variants: Schema.Struct({
id: VariantID,
...Overlays,
}).pipe(Schema.Array, Schema.optional),
cost: Schema.Union([Cost, Cost.pipe(Schema.Array)]).pipe(Schema.optional),
disabled: Schema.Boolean.pipe(Schema.optional),
limit: Limit.pipe(Schema.optional),
}).pipe(Schema.Array, optional),
cost: Schema.Union([Cost, Cost.pipe(Schema.Array)]).pipe(optional),
disabled: Schema.Boolean.pipe(optional),
limit: Limit.pipe(optional),
}) {}
export class Info extends Schema.Class<Info>("Config.Provider")({
name: Schema.String.pipe(Schema.optional),
env: Schema.String.pipe(Schema.Array, Schema.optional),
package: Schema.String.pipe(Schema.optional),
name: Schema.String.pipe(optional),
env: Schema.String.pipe(Schema.Array, optional),
package: Schema.String.pipe(optional),
...Overlays,
models: Schema.Record(Schema.String, Model).pipe(Schema.optional),
models: Schema.Record(Schema.String, Model).pipe(optional),
}) {}
+6 -5
View File
@@ -1,18 +1,19 @@
export * as ConfigReference from "./reference.js"
import { Schema } from "effect"
import { optional } from "../schema.js"
export class Git extends Schema.Class<Git>("Config.Reference.Git")({
repository: Schema.String,
branch: Schema.String.pipe(Schema.optional),
description: Schema.String.pipe(Schema.optional),
hidden: Schema.Boolean.pipe(Schema.optional),
branch: Schema.String.pipe(optional),
description: Schema.String.pipe(optional),
hidden: Schema.Boolean.pipe(optional),
}) {}
export class Local extends Schema.Class<Local>("Config.Reference.Local")({
path: Schema.String,
description: Schema.String.pipe(Schema.optional),
hidden: Schema.Boolean.pipe(Schema.optional),
description: Schema.String.pipe(optional),
hidden: Schema.Boolean.pipe(optional),
}) {}
export const Entry = Schema.Union([Schema.String, Git, Local])
+3 -3
View File
@@ -1,9 +1,9 @@
export * as ConfigToolOutput from "./tool-output.js"
import { Schema } from "effect"
import { PositiveInt } from "../schema.js"
import { optional, PositiveInt } from "../schema.js"
export class Info extends Schema.Class<Info>("Config.ToolOutput")({
max_lines: PositiveInt.pipe(Schema.optional),
max_bytes: PositiveInt.pipe(Schema.optional),
max_lines: PositiveInt.pipe(optional),
max_bytes: PositiveInt.pipe(optional),
}) {}
+4 -3
View File
@@ -1,15 +1,16 @@
export * as ConfigWarming from "./warming.js"
import { Schema } from "effect"
import { optional } from "../schema.js"
export class Info extends Schema.Class<Info>("Config.Warming")({
prompt: Schema.String.pipe(Schema.optional).annotate({
prompt: Schema.String.pipe(optional).annotate({
description: "Prompt sent for keep-alive requests",
}),
interval: Schema.DurationFromString.pipe(Schema.optional).annotate({
interval: Schema.DurationFromString.pipe(optional).annotate({
description: 'Idle time between keep-alive requests (default: "4 minutes")',
}),
duration: Schema.DurationFromString.pipe(Schema.optional).annotate({
duration: Schema.DurationFromString.pipe(optional).annotate({
description: 'Time after the last active request to keep a session warm (default: "30 minutes")',
}),
}) {}
+2 -1
View File
@@ -1,7 +1,8 @@
export * as ConfigWatcher from "./watcher.js"
import { Schema } from "effect"
import { optional } from "../schema.js"
export class Info extends Schema.Class<Info>("Config.Watcher")({
ignore: Schema.String.pipe(Schema.Array, Schema.optional),
ignore: Schema.String.pipe(Schema.Array, optional),
}) {}
+19 -23
View File
@@ -5,13 +5,13 @@ import { optional, PositiveInt } from "./schema.js"
import { IntegrationID } from "./integration-id.js"
export class TimeoutConfig extends Schema.Class<TimeoutConfig>("Mcp.TimeoutConfig")({
startup: PositiveInt.pipe(Schema.optional).annotate({
startup: PositiveInt.pipe(optional).annotate({
description: "Maximum time in milliseconds to establish and initialize the MCP server.",
}),
catalog: PositiveInt.pipe(Schema.optional).annotate({
catalog: PositiveInt.pipe(optional).annotate({
description: "Maximum time in milliseconds to wait for MCP discovery requests such as tools/list and prompts/list.",
}),
execution: PositiveInt.pipe(Schema.optional).annotate({
execution: PositiveInt.pipe(optional).annotate({
description: "Maximum time in milliseconds to wait for MCP tool and prompt execution.",
}),
}) {}
@@ -19,35 +19,35 @@ export class TimeoutConfig extends Schema.Class<TimeoutConfig>("Mcp.TimeoutConfi
export class LocalConfig extends Schema.Class<LocalConfig>("Mcp.LocalConfig")({
type: Schema.Literal("local"),
command: Schema.String.pipe(Schema.Array),
cwd: Schema.String.pipe(Schema.optional).annotate({
cwd: Schema.String.pipe(optional).annotate({
description: "Working directory for the MCP server process. Relative paths resolve from the workspace directory.",
}),
environment: Schema.Record(Schema.String, Schema.String).pipe(Schema.optional),
disabled: Schema.Boolean.pipe(Schema.optional),
codemode: Schema.Boolean.pipe(Schema.optional).annotate({
environment: Schema.Record(Schema.String, Schema.String).pipe(optional),
disabled: Schema.Boolean.pipe(optional),
codemode: Schema.Boolean.pipe(optional).annotate({
description: "Expose this server's tools through Code Mode. Defaults to true.",
}),
timeout: TimeoutConfig.pipe(Schema.optional),
timeout: TimeoutConfig.pipe(optional),
}) {}
export class OAuthConfig extends Schema.Class<OAuthConfig>("Mcp.OAuthConfig")({
client_id: Schema.String.pipe(Schema.optional),
client_secret: Schema.String.pipe(Schema.optional),
scope: Schema.String.pipe(Schema.optional),
callback_port: Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 65535 })).pipe(Schema.optional),
redirect_uri: Schema.String.pipe(Schema.optional),
client_id: Schema.String.pipe(optional),
client_secret: Schema.String.pipe(optional),
scope: Schema.String.pipe(optional),
callback_port: Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 65535 })).pipe(optional),
redirect_uri: Schema.String.pipe(optional),
}) {}
export class RemoteConfig extends Schema.Class<RemoteConfig>("Mcp.RemoteConfig")({
type: Schema.Literal("remote"),
url: Schema.String,
headers: Schema.Record(Schema.String, Schema.String).pipe(Schema.optional),
oauth: Schema.Union([OAuthConfig, Schema.Literal(false)]).pipe(Schema.optional),
disabled: Schema.Boolean.pipe(Schema.optional),
codemode: Schema.Boolean.pipe(Schema.optional).annotate({
headers: Schema.Record(Schema.String, Schema.String).pipe(optional),
oauth: Schema.Union([OAuthConfig, Schema.Literal(false)]).pipe(optional),
disabled: Schema.Boolean.pipe(optional),
codemode: Schema.Boolean.pipe(optional).annotate({
description: "Expose this server's tools through Code Mode. Defaults to true.",
}),
timeout: TimeoutConfig.pipe(Schema.optional),
timeout: TimeoutConfig.pipe(optional),
}) {}
export const ServerConfig = Schema.Union([LocalConfig, RemoteConfig]).pipe(Schema.toTaggedUnion("type"))
@@ -68,13 +68,9 @@ const Failed = Schema.Struct({ status: Schema.Literal("failed"), error: Schema.S
const NeedsAuth = Schema.Struct({ status: Schema.Literal("needs_auth") }).annotate({
identifier: "Mcp.Status.NeedsAuth",
})
const NeedsClientRegistration = Schema.Struct({
status: Schema.Literal("needs_client_registration"),
error: Schema.String,
}).annotate({ identifier: "Mcp.Status.NeedsClientRegistration" })
export type Status = typeof Status.Type
export const Status = Schema.Union([Connected, Pending, Disabled, Failed, NeedsAuth, NeedsClientRegistration]).pipe(
export const Status = Schema.Union([Connected, Pending, Disabled, Failed, NeedsAuth]).pipe(
Schema.toTaggedUnion("status"),
)
+37 -1
View File
@@ -173,6 +173,36 @@ export const InputAdmitted = Event.durable({
})
export type InputAdmitted = typeof InputAdmitted.Type
export const InputCancelled = Event.durable({
type: "session.input.cancelled",
...options,
schema: {
sessionID: SessionID,
inputID: SessionMessage.ID,
},
})
export type InputCancelled = typeof InputCancelled.Type
export const InputSteered = Event.durable({
type: "session.input.steered",
...options,
schema: {
sessionID: SessionID,
inputID: SessionMessage.ID,
},
})
export type InputSteered = typeof InputSteered.Type
export const InputQueued = Event.durable({
type: "session.input.queued",
...options,
schema: {
sessionID: SessionID,
inputID: SessionMessage.ID,
},
})
export type InputQueued = typeof InputQueued.Type
export namespace Execution {
export const Started = Event.durable({ type: "session.execution.started", ...options, schema: Base })
export type Started = typeof Started.Type
@@ -580,6 +610,9 @@ export const Definitions = Event.inventory(
Forked,
InputPromoted,
InputAdmitted,
InputCancelled,
InputSteered,
InputQueued,
Execution.Started,
Execution.Succeeded,
Execution.Failed,
@@ -621,13 +654,16 @@ export const DurableDefinitions = Event.inventory(
...Definitions.filter((definition) => definition.durability === "durable"),
UsageRecorded,
)
export const EphemeralDefinitions = Event.inventory(
...Definitions.filter((definition) => definition.durability === "ephemeral"),
)
export const Durable = Schema.Union(DurableDefinitions, { mode: "oneOf" })
.pipe(Schema.toTaggedUnion("type"))
.annotate({ identifier: "Session.Event.Durable" })
export type DurableEvent = typeof Durable.Type
export const All = Schema.Union(Event.inventory(...Definitions, UsageRecorded), { mode: "oneOf" }).pipe(
export const All = Schema.Union([Durable, ...EphemeralDefinitions], { mode: "oneOf" }).pipe(
Schema.toTaggedUnion("type"),
)
export type Event = typeof All.Type
+47 -1
View File
@@ -1,6 +1,10 @@
import { describe, expect, test } from "bun:test"
import { Schema } from "effect"
import { Config } from "../src/config.js"
import { ConfigAgent } from "../src/config/agent.js"
import { ConfigMCP } from "../src/config/mcp.js"
import { ConfigProvider } from "../src/config/provider.js"
import { Mcp } from "../src/mcp.js"
import { AbsolutePath } from "../src/schema.js"
describe("Config.Entry", () => {
@@ -29,7 +33,14 @@ describe("Config.Entry", () => {
expect(decoded).toEqual(entries)
expect(decoded[0]).toBeInstanceOf(Config.Document)
expect(decoded[1]).not.toHaveProperty("path")
expect(decoded.map((entry) => entry.type)).toEqual(["document", "document", "directory", "file", "agents", "claude"])
expect(decoded.map((entry) => entry.type)).toEqual([
"document",
"document",
"directory",
"file",
"agents",
"claude",
])
expect(decoded[0]?.type === "document" ? decoded[0].info.permissions : undefined).toEqual([
{ action: "shell", resource: "*", effect: "ask" },
{ action: "shell", resource: "git status", effect: "allow" },
@@ -39,4 +50,39 @@ describe("Config.Entry", () => {
test("has a stable public identifier", () => {
expect(Config.Entry.ast.annotations?.identifier).toBe("Config.Entry")
})
test("omits undefined optional properties while encoding", () => {
const entry = new Config.Document({
type: "document",
path: undefined,
info: new Config.Info({
default_agent: undefined,
agents: { reviewer: new ConfigAgent.Info({ description: undefined }) },
mcp: new ConfigMCP.Info({
timeout: undefined,
servers: {
docs: new Mcp.RemoteConfig({
type: "remote",
url: "https://example.com/mcp",
headers: undefined,
oauth: new Mcp.OAuthConfig({ client_id: undefined }),
}),
},
}),
providers: { custom: new ConfigProvider.Info({ headers: undefined }) },
}),
})
const encoded = Schema.encodeSync(Config.Entry)(entry)
if (encoded.type !== "document") throw new Error("Expected a config document")
expect(encoded).not.toHaveProperty("path")
expect(encoded.info).not.toHaveProperty("default_agent")
expect(encoded.info.agents?.reviewer).not.toHaveProperty("description")
expect(encoded.info.mcp).not.toHaveProperty("timeout")
const docs = encoded.info.mcp?.servers?.docs
if (docs?.type !== "remote" || docs.oauth === false) throw new Error("Expected a remote MCP server")
expect(docs).not.toHaveProperty("headers")
expect(docs.oauth).not.toHaveProperty("client_id")
expect(encoded.info.providers?.custom).not.toHaveProperty("headers")
})
})
@@ -84,6 +84,9 @@ describe("public event manifest", () => {
"session.forked.2",
"session.input.promoted.1",
"session.input.admitted.1",
"session.input.cancelled.1",
"session.input.steered.1",
"session.input.queued.1",
"session.execution.started.1",
"session.execution.succeeded.1",
"session.execution.failed.1",
+43
View File
@@ -24,6 +24,22 @@ const DefaultSessionsLimit = 50
export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handlers) =>
Effect.gen(function* () {
const session = yield* Session.Service
const pendingMutation = (effect: ReturnType<typeof session.cancelPending>, conflict: string) =>
effect.pipe(
Effect.catchTag(
"Session.NotFoundError",
(error) =>
new SessionNotFoundError({
sessionID: error.sessionID,
message: `Session not found: ${error.sessionID}`,
}),
),
Effect.catchTag(
"Session.PendingInputConflictError",
(error) => new ConflictError({ resource: error.inputID, message: `${conflict}: ${error.inputID}` }),
),
Effect.as(HttpApiSchema.NoContent.make()),
)
return handlers
.handle(
@@ -609,6 +625,33 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
}
}),
)
.handle(
"session.pending.cancel",
Effect.fn(function* (ctx) {
return yield* pendingMutation(
session.cancelPending({ sessionID: ctx.params.sessionID, inputID: ctx.params.inputID }),
"Pending input can no longer be cancelled",
)
}),
)
.handle(
"session.pending.steer",
Effect.fn(function* (ctx) {
return yield* pendingMutation(
session.steerPending({ sessionID: ctx.params.sessionID, inputID: ctx.params.inputID }),
"Pending input is no longer queued",
)
}),
)
.handle(
"session.pending.queue",
Effect.fn(function* (ctx) {
return yield* pendingMutation(
session.queuePending({ sessionID: ctx.params.sessionID, inputID: ctx.params.inputID }),
"Pending input is no longer a steer",
)
}),
)
.handle(
"session.instructions.entry.list",
Effect.fn(function* (ctx) {
+20 -4
View File
@@ -16,7 +16,9 @@ it.live("returns ordered config entries for the requested directory", () =>
const global = path.join(tmp.path, "global")
const project = path.join(tmp.path, "project")
const config = path.join(project, "opencode.json")
yield* Effect.promise(() => Promise.all([fs.mkdir(global, { recursive: true }), fs.mkdir(project, { recursive: true })]))
yield* Effect.promise(() =>
Promise.all([fs.mkdir(global, { recursive: true }), fs.mkdir(project, { recursive: true })]),
)
yield* Effect.promise(() =>
fs.writeFile(
config,
@@ -25,6 +27,7 @@ it.live("returns ordered config entries for the requested directory", () =>
{ action: "shell", resource: "*", effect: "ask" },
{ action: "shell", resource: "git status", effect: "allow" },
],
mcp: { servers: { docs: { type: "remote", url: "https://example.com/mcp" } } },
}),
),
)
@@ -42,9 +45,8 @@ it.live("returns ordered config entries for the requested directory", () =>
const response = yield* Effect.promise(() =>
fetch(url, { headers: { authorization: `Basic ${btoa("opencode:secret")}` } }),
)
const entries = Schema.decodeUnknownSync(Schema.Array(Config.Entry))(
yield* Effect.promise(() => response.json()),
)
const body: unknown = yield* Effect.promise(() => response.json())
const entries = Schema.decodeUnknownSync(Schema.Array(Config.Entry))(body)
expect(response.status).toBe(200)
expect(Array.isArray(entries)).toBe(true)
@@ -56,7 +58,21 @@ it.live("returns ordered config entries for the requested directory", () =>
{ action: "shell", resource: "git status", effect: "allow" },
])
expect(entries.some((entry) => entry.type === "file" && entry.path === config)).toBe(true)
if (!Array.isArray(body)) throw new Error("Expected a config entry array")
const raw = body.find((entry) => isRecord(entry) && entry["type"] === "document" && entry["path"] === config)
if (!isRecord(raw) || !isRecord(raw["info"])) throw new Error("Expected a config document")
expect(raw["info"]).not.toHaveProperty("default_agent")
expect(raw["info"]).not.toHaveProperty("model")
const mcp = raw["info"]["mcp"]
if (!isRecord(mcp) || !isRecord(mcp["servers"]) || !isRecord(mcp["servers"]["docs"]))
throw new Error("Expected an MCP server config")
expect(mcp["servers"]["docs"]).not.toHaveProperty("headers")
expect(mcp["servers"]["docs"]).not.toHaveProperty("oauth")
}),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
),
)
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value)
}
@@ -6,6 +6,7 @@ import type {
IntegrationOauthConnectOutput,
IntegrationOAuthMethod,
} from "@opencode-ai/client"
import open from "open"
import { createMemo, createSignal, onCleanup, onMount, Show } from "solid-js"
import { useClipboard } from "../context/clipboard"
import { useData } from "../context/data"
@@ -445,6 +446,19 @@ function OAuthAuto(props: {
Keymap.createLayer(() => ({
mode: "modal",
commands: [
{
bind: "o",
title: "Open authorization URL",
group: "Dialog",
run: () => {
open(props.attempt.url).catch(() =>
toast.show({
message: "Could not open the browser. Copy the URL and continue manually.",
variant: "error",
}),
)
},
},
{
bind: "c",
title: "Copy authorization details",
@@ -502,6 +516,7 @@ function OAuthAuto(props: {
instructions={props.attempt.instructions}
message="Waiting for authorization..."
copy
open
/>
)
}
@@ -559,7 +574,14 @@ function OAuthCode(props: {
)
}
function OAuthView(props: { title: string; url?: string; instructions?: string; message: string; copy?: boolean }) {
function OAuthView(props: {
title: string
url?: string
instructions?: string
message: string
copy?: boolean
open?: boolean
}) {
const dialog = useDialog()
const theme = useTheme("elevated")
return (
@@ -583,11 +605,18 @@ function OAuthView(props: { title: string; url?: string; instructions?: string;
)}
</Show>
<text fg={theme.text.subdued}>{props.message}</text>
<Show when={props.copy}>
<text fg={theme.text.default}>
c <span style={{ fg: theme.text.subdued }}>copy</span>
</text>
</Show>
<box flexDirection="row" gap={2}>
<Show when={props.open}>
<text fg={theme.text.default}>
o <span style={{ fg: theme.text.subdued }}>open</span>
</text>
</Show>
<Show when={props.copy}>
<text fg={theme.text.default}>
c <span style={{ fg: theme.text.subdued }}>copy</span>
</text>
</Show>
</box>
</box>
)
}
+1 -1
View File
@@ -15,7 +15,7 @@ import { useConfig } from "../config"
import { getScrollAcceleration } from "../util/scroll"
function statusError(status: McpServer["status"]) {
if (status.status === "failed" || status.status === "needs_client_registration") return status.error
if (status.status === "failed") return status.error
return undefined
}
@@ -14,7 +14,6 @@ export function DialogStatus() {
if (status === "connected") return theme.text.feedback.success.default
if (status === "failed") return theme.text.feedback.error.default
if (status === "needs_auth") return theme.text.feedback.warning.default
if (status === "needs_client_registration") return theme.text.feedback.error.default
return theme.text.subdued
}
return (
@@ -46,9 +45,6 @@ export function DialogStatus() {
<Match when={item.status.status === "failed" && item.status}>{(val) => val().error}</Match>
<Match when={item.status.status === "disabled"}>Disabled in configuration</Match>
<Match when={item.status.status === "needs_auth"}>Needs authentication</Match>
<Match when={item.status.status === "needs_client_registration" && item.status}>
{(val) => (val() as { error: string }).error}
</Match>
</Switch>
</span>
</text>
+47 -4
View File
@@ -59,6 +59,7 @@ export type PromptProps = {
visible?: boolean
disabled?: boolean
onSubmit?: () => void
onEmptySubmit?: () => boolean | Promise<boolean>
ref?: (ref: PromptRef | undefined) => void
hint?: JSX.Element
right?: JSX.Element
@@ -361,6 +362,20 @@ export function Prompt(props: PromptProps) {
dialog.clear()
},
},
{
title: "Queue prompt",
name: "prompt.queue",
category: "Prompt",
palette: undefined,
run: async (_input: string | undefined, event?: KeyEvent) => {
event?.preventDefault()
event?.stopPropagation()
if (!input.focused) return
const handled = await submit("queue")
if (!handled) return
dialog.clear()
},
},
{
title: "Remove editor context",
name: "prompt.editor_context.clear",
@@ -519,6 +534,11 @@ export function Prompt(props: PromptProps) {
commands: promptCommands(),
}))
Keymap.createLayer(() => ({
priority: 1,
bindings: ["prompt.queue"],
}))
Keymap.createLayer(() => ({
bindings: [
"prompt.submit",
@@ -904,7 +924,7 @@ export function Prompt(props: PromptProps) {
})
let submitting = false
async function submit() {
async function submit(delivery: "steer" | "queue" = "steer") {
// Prevent overlapping invocations (e.g. a double-pressed Enter, or the
// input's native onSubmit racing another dispatch). Without this guard,
// a second call slips past the empty-input check before the first call
@@ -914,13 +934,13 @@ export function Prompt(props: PromptProps) {
if (submitting) return false
submitting = true
try {
return await submitInner()
return await submitInner(delivery)
} finally {
submitting = false
}
}
async function submitInner() {
async function submitInner(delivery: "steer" | "queue") {
// IME: double-defer may fire before onContentChange flushes the last
// composed character (e.g. Korean hangul) to the store, so read
// plainText directly and sync before any downstream reads.
@@ -931,18 +951,39 @@ export function Prompt(props: PromptProps) {
if (props.disabled) return false
if (move.creating()) return false
if (auto()?.visible) return false
if (!store.prompt.text) return false
const trimmed = store.prompt.text.trim()
if (!trimmed) return delivery === "steer" ? (await props.onEmptySubmit?.()) === true : false
if (
delivery === "queue" &&
(store.mode === "shell" || trimmed === "exit" || trimmed === "quit" || trimmed === ":q")
) {
toast.show({ message: "This prompt cannot be queued", variant: "warning" })
return false
}
if (trimmed === "exit" || trimmed === "quit" || trimmed === ":q") {
void exit()
return true
}
const slash = argumentSlash(store.prompt.text, keymapCommands())
if (slash) {
if (delivery === "queue") {
toast.show({ message: "This prompt cannot be queued", variant: "warning" })
return false
}
clearPrompt()
await slash.command.run(slash.input)
return true
}
if (
delivery === "queue" &&
store.prompt.text.startsWith("/") &&
(data.location.skill.list(currentLocation.current) ?? []).some(
(skill) => skill.slash === true && skill.id === store.prompt.text.split("\n")[0].split(" ")[0].slice(1),
)
) {
toast.show({ message: "Skills cannot be queued", variant: "warning" })
return false
}
const agent = local.agent.current()
if (!agent) return false
const selectedModel = local.model.current()
@@ -1036,6 +1077,7 @@ export function Prompt(props: PromptProps) {
model: { providerID: selectedModel.providerID, id: selectedModel.modelID, variant },
files: store.prompt.files,
agents: store.prompt.agents,
delivery,
})
.catch((error) => {
toast.show({ title: "Failed to run command", message: errorMessage(error), variant: "error" })
@@ -1103,6 +1145,7 @@ export function Prompt(props: PromptProps) {
text: inputText,
files: store.prompt.files,
agents: store.prompt.agents,
delivery,
})
.then(
() => undefined,
+5 -1
View File
@@ -104,6 +104,7 @@ export const Definitions = {
session_background: keybind("ctrl+b", "Background blocking session tools"),
session_compact: keybind("<leader>c", "Compact the session"),
session_queued_prompts: keybind("<leader>q", "View pending work"),
queued_prompt_delete: keybind("ctrl+d", "Delete queued prompt"),
session_child_first: keybind("down", "Toggle subagent picker"),
session_parent: keybind("up", "Go to parent session"),
session_pin_toggle: keybind("ctrl+f", "Pin or unpin session in the session list"),
@@ -161,6 +162,7 @@ export const Definitions = {
display_thinking: keybind("none", "Toggle thinking blocks visibility"),
prompt_submit: keybind("none", "Submit prompt"),
prompt_queue: keybind("alt+return", "Queue prompt"),
prompt_editor_context_clear: keybind("none", "Clear editor context"),
prompt_skills: keybind("none", "Open skill selector"),
prompt_stash: keybind("none", "Stash prompt"),
@@ -170,7 +172,7 @@ export const Definitions = {
input_clear: keybind("ctrl+c", "Clear input field"),
input_paste: keybind({ key: "ctrl+v", preventDefault: false }, "Paste from clipboard"),
input_submit: keybind("return", "Submit input"),
input_newline: keybind("shift+return,ctrl+return,alt+return,ctrl+j", "Insert newline in input"),
input_newline: keybind("shift+return,ctrl+return,ctrl+j", "Insert newline in input"),
input_move_left: keybind("left,ctrl+b", "Move cursor left in input"),
input_move_right: keybind("right,ctrl+f", "Move cursor right in input"),
input_move_up: keybind("up", "Move cursor up in input"),
@@ -305,6 +307,7 @@ export const CommandMap = {
session_background: "session.background",
session_compact: "session.compact",
session_queued_prompts: "session.queued_prompts",
queued_prompt_delete: "queued_prompt.delete",
session_child_first: "session.child.first",
session_parent: "session.parent",
session_pin_toggle: "session.pin.toggle",
@@ -359,6 +362,7 @@ export const CommandMap = {
messages_redo: "session.redo",
display_thinking: "session.toggle.thinking",
prompt_submit: "prompt.submit",
prompt_queue: "prompt.queue",
prompt_editor_context_clear: "prompt.editor_context.clear",
prompt_skills: "prompt.skills",
prompt_stash: "prompt.stash",
+48 -15
View File
@@ -168,12 +168,27 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
function removePending(sessionID: string, inputID?: string) {
if (!inputID) return
setStore(
"session",
"pending",
sessionID,
(store.session.pending[sessionID] ?? []).filter((item) => item.id !== inputID),
)
if (store.session.pending[sessionID]?.some((item) => item.id === inputID))
setStore(
"session",
"pending",
sessionID,
(store.session.pending[sessionID] ?? []).filter((item) => item.id !== inputID),
)
if (store.session.input[sessionID]?.includes(inputID))
setStore(
"session",
"input",
sessionID,
(store.session.input[sessionID] ?? []).filter((id) => id !== inputID),
)
}
function updatePending(sessionID: string, inputID: string, delivery: "steer" | "queue") {
const index = store.session.pending[sessionID]?.findIndex((item) => item.id === inputID) ?? -1
const item = store.session.pending[sessionID]?.[index]
if (index < 0 || !item || item.type === "compaction" || item.delivery === delivery) return
setStore("session", "pending", sessionID, index, { ...item, delivery })
}
const message = {
@@ -222,6 +237,12 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
(item): item is SessionMessageAssistantReasoning => item.type === "reasoning" && !item.time?.completed,
)
},
reindex(messages: SessionMessageInfo[], index: Map<string, number>, start: number) {
for (let position = start; position < messages.length; position++) {
const item = messages[position]
if (item) index.set(item.id, position)
}
},
}
function index(sessionID: string) {
@@ -403,24 +424,36 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
}
break
case "session.input.promoted": {
const admitted = store.session.input[event.data.sessionID]?.includes(event.data.inputID) ?? false
removePending(event.data.sessionID, event.data.inputID)
message.update(event.data.sessionID, (draft, index) => {
const position = index.get(event.data.inputID)
if (position === undefined) return
const existing = draft[position]
if (!existing || !store.session.input[event.data.sessionID]?.includes(event.data.inputID)) return
if (!existing || !admitted) return
existing.time.created = event.created
draft.splice(position, 1)
draft.push(existing)
index.clear()
draft.forEach((message, indexValue) => index.set(message.id, indexValue))
message.reindex(draft, index, position)
})
setStore(
"session",
"input",
event.data.sessionID,
(store.session.input[event.data.sessionID] ?? []).filter((id) => id !== event.data.inputID),
)
break
}
case "session.input.steered":
updatePending(event.data.sessionID, event.data.inputID, "steer")
break
case "session.input.queued":
updatePending(event.data.sessionID, event.data.inputID, "queue")
break
case "session.input.cancelled": {
removePending(event.data.sessionID, event.data.inputID)
if (messageIndex.get(event.data.sessionID)?.has(event.data.inputID))
message.update(event.data.sessionID, (draft, index) => {
const position = index.get(event.data.inputID)
if (position === undefined) return
draft.splice(position, 1)
index.delete(event.data.inputID)
message.reindex(draft, index, position)
})
break
}
case "session.input.admitted":
@@ -8,13 +8,7 @@ function View(props: { context: Plugin.Context; sessionID: string }) {
const list = createMemo(() => props.context.data.location.mcp.server.list(session()?.location) ?? [])
const on = createMemo(() => list().filter((item) => item.status.status === "connected").length)
const bad = createMemo(
() =>
list().filter(
(item) =>
item.status.status === "failed" ||
item.status.status === "needs_auth" ||
item.status.status === "needs_client_registration",
).length,
() => list().filter((item) => item.status.status === "failed" || item.status.status === "needs_auth").length,
)
const dot = (status: string) => {
@@ -22,7 +16,6 @@ function View(props: { context: Plugin.Context; sessionID: string }) {
if (status === "failed") return theme.text.feedback.error.default
if (status === "disabled") return theme.text.subdued
if (status === "needs_auth") return theme.text.feedback.warning.default
if (status === "needs_client_registration") return theme.text.feedback.error.default
return theme.text.subdued
}
@@ -65,7 +58,6 @@ function View(props: { context: Plugin.Context; sessionID: string }) {
</Match>
<Match when={item.status.status === "disabled"}>Disabled</Match>
<Match when={item.status.status === "needs_auth"}>Needs auth</Match>
<Match when={item.status.status === "needs_client_registration"}>Needs client ID</Match>
</Switch>
</span>
</text>
+32 -5
View File
@@ -3,7 +3,9 @@ import { TextAttributes, type InputRenderable, type KeyEvent } from "@opentui/co
import { useKeyboard, type JSX } from "@opentui/solid"
import fuzzysort from "fuzzysort"
import { createEffect, createMemo, createSignal, type Accessor } from "solid-js"
import { Keymap } from "../context/keymap"
import { RunFooterMenu, createFooterMenuState, type RunFooterMenuItem } from "./footer.menu"
import { monoShortcut } from "./mono"
import type { RunFooterTheme } from "./theme"
import type {
FooterQueuedPrompt,
@@ -56,6 +58,10 @@ type SkillEntry = PanelEntry & {
name: string
}
type QueuedPromptEntry = PanelEntry & {
prompt: FooterQueuedPrompt
}
type SubagentEntry = PanelEntry & {
sessionID: string
current: boolean
@@ -837,28 +843,48 @@ export function RunQueuedPromptSelectBody(props: {
theme: Accessor<RunFooterTheme>
prompts: Accessor<FooterQueuedPrompt[]>
onClose: () => void
onSteer: (prompt: FooterQueuedPrompt) => void
onDelete: (prompt: FooterQueuedPrompt) => void
onRows?: (rows: number) => void
mono?: boolean
}) {
const entries = createMemo(() =>
const entries = createMemo<QueuedPromptEntry[]>(() =>
props.prompts().map((prompt) => ({
category: "",
display: prompt.prompt.text.replaceAll("\n", " "),
footer: prompt.delivery,
footer: "queued",
keywords: prompt.prompt.text,
prompt,
})),
)
const controller = createSearchablePanelController({
entries,
limit: SUBAGENT_LIST_ROWS,
onClose: props.onClose,
onSelect: props.onClose,
onSelect: (item) => props.onSteer(item.prompt),
onRows: props.onRows,
})
const shortcuts = Keymap.useShortcuts()
const deleteShortcut = () => monoShortcut(shortcuts.get("queued_prompt.delete") ?? "", props.mono ?? false)
Keymap.createLayer(() => ({
priority: 1,
commands: [
{
id: "queued_prompt.delete",
title: "Delete queued prompt",
group: "Prompt",
run() {
const item = controller.items()[controller.menu.selected()]
if (!item) return false
props.onDelete(item.prompt)
},
},
],
}))
return (
<PanelShell
title="Pending work"
title="Queued prompts"
query={controller.query()}
count={controller.items().length}
total={entries().length}
@@ -866,6 +892,7 @@ export function RunQueuedPromptSelectBody(props: {
theme={props.theme}
inputRef={controller.inputRef}
onQuery={controller.setQuery}
hint={["enter steer", deleteShortcut() ? `${deleteShortcut()} delete` : undefined].filter(Boolean).join(" · ")}
mono={props.mono}
>
<RunFooterMenu
@@ -875,7 +902,7 @@ export function RunQueuedPromptSelectBody(props: {
offset={controller.menu.offset}
rows={controller.menu.rows}
limit={SUBAGENT_LIST_ROWS}
empty="No pending work"
empty="No queued prompts"
border={false}
paddingLeft={panelPad(props.mono)}
paddingRight={panelPad(props.mono)}
+56 -13
View File
@@ -19,6 +19,7 @@ import {
displayCharAt,
displaySlice,
isExitCommand,
isCompactCommand,
mentionTriggerIndex,
isNewCommand,
movePromptHistory,
@@ -31,7 +32,15 @@ import { realignEditorPromptParts, resolveEditorSlashValue } from "./prompt.edit
import { monoTruncateMiddle } from "./mono"
import { FOOTER_MENU_ROWS, createFooterMenuState, type RunFooterMenuItem } from "./footer.menu"
import type { RunFooterTheme } from "./theme"
import type { FooterState, RunAgent, RunCommand, RunPrompt, RunPromptPart, RunReference } from "./types"
import type {
FooterQueuedPrompt,
FooterState,
RunAgent,
RunCommand,
RunPrompt,
RunPromptPart,
RunReference,
} from "./types"
const AUTOCOMPLETE_ROWS = FOOTER_MENU_ROWS
const AUTOCOMPLETE_BOTTOM_ROWS = 1
@@ -72,6 +81,8 @@ type PromptInput = {
theme: Accessor<RunFooterTheme>
mono: Accessor<boolean>
history?: Accessor<RunPrompt[]>
queuedPrompts: Accessor<FooterQueuedPrompt[]>
onQueuedPromptSteer: (inputID: string) => Promise<boolean>
onSubmit: (input: RunPrompt) => boolean | Promise<boolean>
onCycle: () => void
onInterrupt: () => boolean
@@ -980,8 +991,18 @@ export function createPromptState(input: PromptInput): PromptState {
}))
Keymap.createLayer(() => ({
priority: 1,
enabled: input.prompt() && !visible(),
commands: [
{
id: "prompt.queue",
title: "Queue prompt",
group: "Prompt",
run() {
syncDraft()
submitPrompt(promptCopy(draft), "queue")
},
},
{
id: "prompt.editor",
title: "Open editor",
@@ -1116,7 +1137,8 @@ export function createPromptState(input: PromptInput): PromptState {
}
}
const submitPrompt = (next: RunPrompt) => {
let submitting = false
const submitPrompt = (next: RunPrompt, delivery: "steer" | "queue" = "steer") => {
if (!area || area.isDestroyed) {
draft = promptCopy(next)
}
@@ -1130,12 +1152,29 @@ export function createPromptState(input: PromptInput): PromptState {
hide()
}
if (submitting) return
if (!next.text.trim()) {
const queued = delivery === "steer" ? input.queuedPrompts()[0] : undefined
if (queued) {
submitting = true
void input.onQueuedPromptSteer(queued.messageID).finally(() => {
submitting = false
})
return
}
input.onStatus(input.state().phase === "running" ? "waiting for current response" : "empty prompt ignored")
return
}
const command = next.mode === "shell" ? undefined : selectedCommand(next.text, next.command)
if (
delivery === "queue" &&
(next.mode === "shell" || command?.source === "skill" || isNewCommand(next.text) || isCompactCommand(next.text))
) {
input.onStatus("this prompt cannot be queued")
return
}
if (!command && next.mode !== "shell" && isExitCommand(next.text)) {
input.onExit()
return
@@ -1157,24 +1196,28 @@ export function createPromptState(input: PromptInput): PromptState {
}
const submit = command
? { ...next, command }
? { ...next, command, delivery }
: parsed?.type === "command"
? { ...next, command: parsed.command }
: next
? { ...next, command: parsed.command, delivery }
: { ...next, delivery }
const shellMode = next.mode === "shell"
submitting = true
resetDraft()
queueMicrotask(async () => {
if (await input.onSubmit(submit)) {
push(next)
if (shellMode) {
setShellMode(false)
draft = emptyPrompt(false)
try {
if (await input.onSubmit(submit)) {
push(next)
if (shellMode) {
setShellMode(false)
draft = emptyPrompt(false)
}
return
}
return
restore(next)
} finally {
submitting = false
}
restore(next)
})
}
+3
View File
@@ -51,6 +51,7 @@ import type {
MiniSettingChange,
MiniSettings,
PermissionReply,
QueuedPromptAction,
RunAgent,
RunCommand,
RunInput,
@@ -96,6 +97,7 @@ type RunFooterOptions = {
onVariantSelect?: (variant: string | undefined) => CycleResult | void | Promise<CycleResult | void>
onInterrupt?: () => void
onBackground?: () => void
onQueuedPromptAction?: (action: QueuedPromptAction, inputID: string) => Promise<void>
onEditorOpen: (input: { value: string }) => Promise<string | undefined>
onSubagentSelect?: (sessionID: string | undefined) => void
onSubagentInterrupt?: (sessionID: string) => void
@@ -343,6 +345,7 @@ export class RunFooter implements FooterApi {
onCycle: footer.handleCycle,
onInterrupt: footer.handleInterrupt,
onBackground: options.onBackground,
onQueuedPromptAction: options.onQueuedPromptAction,
onEditorOpen: options.onEditorOpen,
onInputClear: footer.handleInputClear,
onExitRequest: footer.handleExit,
+40 -10
View File
@@ -34,6 +34,7 @@ import { Keymap } from "../context/keymap"
import { modelInfo } from "./variant.shared"
import { monoShortcut } from "./mono"
import { stringWidth } from "../util/string-width"
import { errorMessage } from "../util/error"
import type {
FooterPromptRoute,
@@ -46,6 +47,7 @@ import type {
MiniSettingChange,
MiniSettings,
PermissionReply,
QueuedPromptAction,
RunAgent,
RunCommand,
RunInput,
@@ -92,13 +94,14 @@ type RunFooterViewProps = {
mono: boolean
miniSettings: () => MiniSettings
history?: () => RunPrompt[]
onSubmit: (input: RunPrompt) => boolean
onSubmit: (input: RunPrompt) => boolean | Promise<boolean>
onPermissionReply: (input: PermissionReply) => void | Promise<void>
onFormReply: (input: FormReply) => void | Promise<void>
onFormCancel: (input: FormCancel) => void | Promise<void>
onCycle: () => void
onInterrupt: () => boolean
onBackground?: () => void
onQueuedPromptAction?: (action: QueuedPromptAction, inputID: string) => Promise<void>
onEditorOpen: (input: { value: string }) => Promise<string | undefined>
onInputClear: () => void
onExitRequest?: () => boolean
@@ -132,6 +135,7 @@ export function RunFooterView(props: RunFooterViewProps) {
const [route, setRoute] = createSignal<FooterPromptRoute>({ type: "composer" })
const [subagentMenuRows, setSubagentMenuRows] = createSignal(RUN_SUBAGENT_PANEL_ROWS)
const queuedPrompts = createMemo(() => props.queuedPrompts?.() ?? [])
const queue = createMemo(() => queuedPrompts().filter((item) => item.delivery === "queue"))
const skills = createMemo(() => (props.commands() ?? []).filter((item) => item.source === "skill"))
const prompt = createMemo(() => active().type === "prompt" && route().type === "composer")
const selectingSubagent = createMemo(() => active().type === "prompt" && route().type === "subagent-menu")
@@ -229,7 +233,7 @@ export function RunFooterView(props: RunFooterViewProps) {
const details = [busy() ? "running" : "idle", `agent ${props.currentAgent()}`]
if (current) details.push(variant ? `${current} ${variant}` : current)
if (usage()) details.push(props.mono ? usage().replaceAll(" · ", " - ") : usage())
if (queuedPrompts().length > 0) details.push(`${queuedPrompts().length} pending`)
if (queue().length > 0) details.push(`${queue().length} queued`)
if (activeTabs().length > 0) details.push(`${activeTabs().length} subagent${activeTabs().length === 1 ? "" : "s"}`)
return details.join(props.mono ? " - " : " · ")
})
@@ -309,7 +313,7 @@ export function RunFooterView(props: RunFooterViewProps) {
}
const openQueuedMenu = () => {
if (queuedPrompts().length === 0) return
if (queue().length === 0) return
setRoute({ type: "queued-menu" })
props.onSubagentSelect?.(undefined)
}
@@ -318,6 +322,23 @@ export function RunFooterView(props: RunFooterViewProps) {
setRoute({ type: "composer" })
}
const pendingQueueActions = new Set<string>()
const queuedPromptAction = async (action: QueuedPromptAction, inputID: string) => {
if (pendingQueueActions.has(inputID)) return false
const run = props.onQueuedPromptAction
if (!run) return false
pendingQueueActions.add(inputID)
const error = await run(action, inputID)
.then(
() => undefined,
(error) => error,
)
.finally(() => pendingQueueActions.delete(inputID))
if (!error) return true
props.onStatus(`failed to ${action === "cancel" ? "delete" : action} queued prompt: ${errorMessage(error)}`)
return false
}
const openTab = (sessionID: string) => {
setRoute({ type: "subagent", sessionID })
props.onSubagentSelect?.(sessionID)
@@ -357,6 +378,8 @@ export function RunFooterView(props: RunFooterViewProps) {
theme,
mono: () => props.mono,
history: props.history,
queuedPrompts: queue,
onQueuedPromptSteer: (inputID) => queuedPromptAction("steer", inputID),
onSubmit: props.onSubmit,
onCycle: props.onCycle,
onInterrupt: props.onInterrupt,
@@ -451,13 +474,12 @@ export function RunFooterView(props: RunFooterViewProps) {
if (foregroundSubagents() && backgroundShortcut()) {
items.push({ key: backgroundShortcut(), label: "background" })
}
if (queuedPrompts().length > 0 && queuedShortcut()) {
items.push({ key: queuedShortcut(), label: `${queuedPrompts().length} pending` })
if (queue().length > 0 && queuedShortcut()) {
items.push({ key: queuedShortcut(), label: `${queue().length} queued` })
}
if (activeTabs().length > 0 && subagentShortcut()) {
items.push({ key: subagentShortcut(), label: "subagents" })
}
return items
})
const commandHint = createMemo(() => {
@@ -568,7 +590,7 @@ export function RunFooterView(props: RunFooterViewProps) {
}))
Keymap.createLayer(() => ({
enabled: active().type === "prompt" && route().type === "composer" && queuedPrompts().length > 0,
enabled: active().type === "prompt" && route().type === "composer" && queue().length > 0,
commands: [
{
id: "session.queued_prompts",
@@ -630,7 +652,7 @@ export function RunFooterView(props: RunFooterViewProps) {
})
createEffect(() => {
if (route().type !== "queued-menu" || queuedPrompts().length > 0) return
if (route().type !== "queued-menu" || queue().length > 0) return
closePanel()
})
@@ -734,8 +756,16 @@ export function RunFooterView(props: RunFooterViewProps) {
<Match when={selectingQueued()}>
<RunQueuedPromptSelectBody
theme={theme}
prompts={queuedPrompts}
prompts={queue}
onClose={closePanel}
onSteer={(item) => {
void queuedPromptAction("steer", item.messageID).then((steered) => {
if (steered) closePanel()
})
}}
onDelete={(item) => {
void queuedPromptAction("cancel", item.messageID)
}}
onRows={setSubagentMenuRows}
mono={props.mono}
/>
@@ -745,7 +775,7 @@ export function RunFooterView(props: RunFooterViewProps) {
theme={theme}
commands={props.commands}
subagents={tabs}
queued={queuedPrompts}
queued={queue}
variants={props.variants}
variantCycle={variantCycle()}
onClose={closePanel}
@@ -22,6 +22,7 @@ import type {
MiniSettings,
MiniHost,
PermissionReply,
QueuedPromptAction,
RunAgent,
RunInput,
RunPrompt,
@@ -70,6 +71,7 @@ export type LifecycleInput = {
onVariantSelect?: (variant: string | undefined) => CycleResult | void | Promise<CycleResult | void>
onInterrupt?: () => void
onBackground?: () => void
onQueuedPromptAction?: (action: QueuedPromptAction, inputID: string) => Promise<void>
onSubagentSelect?: (sessionID: string | undefined) => void
onSubagentInterrupt?: (sessionID: string) => void
}
@@ -243,6 +245,7 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lif
onVariantSelect: input.onVariantSelect,
onInterrupt: input.onInterrupt,
onBackground: input.onBackground,
onQueuedPromptAction: input.onQueuedPromptAction,
onEditorOpen: async ({ value }) => {
if (closed || renderer.isDestroyed) {
return
+5 -4
View File
@@ -25,7 +25,7 @@ export type QueueInput = {
onAdmissionError?: (prompt: RunPrompt, error: unknown) => void | Promise<void>
onNewSession?: () => void | Promise<void>
onCompact?: () => void | Promise<void>
admit: (prompt: RunPrompt, signal: AbortSignal) => Promise<void>
admit: (prompt: RunPrompt, delivery: "steer" | "queue", signal: AbortSignal) => Promise<void>
settle: () => Promise<void>
run: (prompt: RunPrompt, signal: AbortSignal, admitted: () => void) => Promise<void>
}
@@ -183,7 +183,7 @@ export async function runPromptQueue(input: QueueInput): Promise<void> {
input.trace?.write("ui.commit", commit)
input.footer.append(commit)
}
input.onSend?.(sent, "steer")
input.onSend?.(sent, sent.delivery ?? "steer")
if (state.closed) {
break
@@ -276,10 +276,11 @@ export async function runPromptQueue(input: QueueInput): Promise<void> {
const sent = { ...prompt, messageID: SessionMessage.ID.create() }
const admission = state.admission
admissionVersion += 1
input.onSend?.(sent, "queue")
const delivery = prompt.delivery ?? "queue"
input.onSend?.(sent, delivery)
admissions = admissions
.then(() => admission)
.then(() => input.admit(sent, admissionController.signal))
.then(() => input.admit(sent, delivery, admissionController.signal))
.catch((error) => (state.closed ? undefined : input.onAdmissionError?.(sent, error)))
return
}
+23 -11
View File
@@ -390,6 +390,15 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
log?.write("send.background", { sessionID: state.sessionID })
void state.sdk.session.background({ sessionID: state.sessionID }).catch(() => {})
},
onQueuedPromptAction: async (action, inputID) => {
if (!state.sessionID) return
log?.write(`send.pending.${action}`, { sessionID: state.sessionID, inputID })
if (action === "steer") {
await state.sdk.session.pending.steer({ sessionID: state.sessionID, inputID })
return
}
await state.sdk.session.pending.cancel({ sessionID: state.sessionID, inputID })
},
onSubagentInterrupt: (sessionID) => {
log?.write("send.subagent.interrupt", { sessionID })
void state.sdk.session.interrupt({ sessionID }).catch(() => {})
@@ -892,7 +901,7 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
trace: log,
onSend: (prompt, delivery) => {
state.shown = true
state.history.push(prompt)
state.history.push({ ...prompt, delivery: undefined })
if (prompt.mode !== "shell" && delivery === "steer") {
rememberLocal({
kind: "user",
@@ -903,18 +912,21 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
})
}
},
admit: async (prompt, signal) => {
admit: async (prompt, delivery, signal) => {
await state.switching?.catch(() => {})
const next = await ensureStream()
await next.handle.queuePromptTurn({
agent: state.agent,
model: state.model,
variant: state.activeVariant,
prompt,
files: input.files,
includeFiles: false,
signal,
})
await next.handle.admitPromptTurn(
{
agent: state.agent,
model: state.model,
variant: state.activeVariant,
prompt,
files: input.files,
includeFiles: false,
signal,
},
delivery,
)
},
onAdmissionError: renderPromptError,
onCompact: async () => {
@@ -653,6 +653,10 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
}
return
}
if (event.type === "session.input.cancelled") {
child.prompts.delete(event.data.inputID)
return
}
if (event.type === "session.step.started") {
touch(child, event.created)
if (child.label === FALLBACK_LABEL && event.data.agent) child.label = Locale.titlecase(event.data.agent)
+40 -6
View File
@@ -71,7 +71,7 @@ export type SessionResizeReplayInput = {
export type SessionTransport = {
runPromptTurn(input: SessionTurnInput, admitted?: () => void): Promise<void>
queuePromptTurn(input: SessionTurnInput): Promise<void>
admitPromptTurn(input: SessionTurnInput, delivery: "steer" | "queue"): Promise<void>
waitForIdle(): Promise<void>
interruptActiveTurn(): Promise<void>
selectSubagent(sessionID: string | undefined): void
@@ -515,8 +515,12 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
)
}
let syncedPending: string[] | undefined
const syncPending = () => {
const prompts = [...state.pending.values()]
const prompts = [...state.pending.values()].filter((item) => item.delivery === "queue")
const ids = prompts.map((item) => item.messageID)
if (syncedPending?.length === ids.length && syncedPending.every((id, index) => id === ids[index])) return
syncedPending = ids
input.trace?.write("ui.patch", { pending: prompts.length })
input.footer.event({ type: "queued.prompts", prompts })
}
@@ -934,6 +938,36 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
write([], { phase: "running", status: "waiting for assistant" })
return
}
if (event.type === "session.input.steered") {
const pending = state.pending.get(event.data.inputID)
if (!pending) return
state.pending.set(event.data.inputID, { ...pending, delivery: "steer" })
syncPending()
if (state.messageIDs.has(event.data.inputID)) return
state.messageIDs.add(event.data.inputID)
write([
{
kind: "user",
source: "system",
text: pending.prompt.text,
phase: "start",
messageID: event.data.inputID,
},
])
return
}
if (event.type === "session.input.queued") {
const pending = state.pending.get(event.data.inputID)
if (!pending) return
state.pending.set(event.data.inputID, { ...pending, delivery: "queue" })
syncPending()
return
}
if (event.type === "session.input.cancelled") {
state.admitted.delete(event.data.inputID)
if (state.pending.delete(event.data.inputID)) syncPending()
return
}
if (event.type === "session.step.started") {
state.stepModel = { providerID: event.data.model.providerID, modelID: event.data.model.id }
write([], { phase: "running", status: "assistant responding" })
@@ -1643,14 +1677,14 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
}
return {
async queuePromptTurn(next) {
async admitPromptTurn(next, delivery) {
if (next.prompt.mode === "shell" || next.prompt.command?.source === "skill")
throw new Error("This prompt cannot be queued")
if (!state.connected) throw new Error("Event stream is reconnecting")
const client = sdk
if (next.agent)
await client.session.switchAgent({ sessionID: input.sessionID, agent: next.agent }, { signal: next.signal })
mergePending(await admitPrompt(next, client, "queue"))
mergePending(await admitPrompt(next, client, delivery))
settlementClient = client
},
async waitForIdle() {
@@ -1688,7 +1722,7 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
return
}
if (command) {
await runTurnWait(next, messageID, client, () => admitPrompt(next, client, "steer"), admitted)
await runTurnWait(next, messageID, client, () => admitPrompt(next, client, next.prompt.delivery ?? "steer"), admitted)
return
}
@@ -1700,7 +1734,7 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
if (selected)
await client.session.switchModel({ sessionID: input.sessionID, model: selected }, { signal: next.signal })
await runTurnWait(next, messageID, client, () => admitPrompt(next, client, "steer"), admitted)
await runTurnWait(next, messageID, client, () => admitPrompt(next, client, next.prompt.delivery ?? "steer"), admitted)
},
async interruptActiveTurn() {
// A running shell holds no drain, so session.interrupt cannot reach it;
+3
View File
@@ -75,6 +75,7 @@ export type RunPrompt = {
messageID?: string
text: string
parts: RunPromptPart[]
delivery?: "steer" | "queue"
mode?: "shell"
command?: {
name: string
@@ -90,6 +91,8 @@ export type FooterQueuedPrompt = {
delivery: "steer" | "queue"
}
export type QueuedPromptAction = "steer" | "cancel"
export type RunAgent = {
id: string
name: string
+127 -4
View File
@@ -52,6 +52,7 @@ import { useClient } from "../../context/client"
import { useEditorContext } from "../../context/editor"
import { openEditor } from "../../editor"
import { useDialog } from "../../ui/dialog"
import { DialogSelect } from "../../ui/dialog-select"
import { DialogSessionRename } from "../../component/dialog-session-rename"
import { DialogMessage } from "./dialog-message"
import { DialogFork } from "./dialog-fork"
@@ -109,6 +110,7 @@ const NAVIGATION_SLACK_ID = "session-navigation-slack"
const TRANSCRIPT_TAIL_ROWS = 40
const TRANSCRIPT_BACKFILL_CHUNK = 60
const TRANSCRIPT_BACKFILL_DELAY = 120
type PendingAction = "steer" | "queue" | "cancel"
const context = createContext<{
width: number
@@ -120,6 +122,7 @@ const context = createContext<{
diffWrapMode: () => "word" | "none"
models: () => ModelInfo[]
config: ReturnType<typeof useConfig>["data"]
mutatePending: (action: PendingAction, inputID: string) => Promise<boolean>
}>()
function use() {
@@ -175,6 +178,11 @@ export function Session() {
.flatMap((sessionID) => data.session.form.list(sessionID) ?? [])
.concat(global)
})
const queuedPrompts = createMemo(() =>
data.session.pending.list(route.sessionID).flatMap((item) =>
item.type === "user" && item.delivery === "queue" ? [{ id: item.id, text: item.data.text }] : [],
),
)
const [composer, setComposer] = createStore({
open: false,
tab: undefined as string | undefined,
@@ -369,6 +377,55 @@ export function Session() {
})
const dialog = useDialog()
const renderer = useRenderer()
const pendingQueueActions = new Set<string>()
const mutatePending = async (action: PendingAction, inputID: string) => {
if (pendingQueueActions.has(inputID)) return false
pendingQueueActions.add(inputID)
const request =
action === "steer"
? client.api.session.pending.steer({ sessionID: route.sessionID, inputID })
: action === "queue"
? client.api.session.pending.queue({ sessionID: route.sessionID, inputID })
: client.api.session.pending.cancel({ sessionID: route.sessionID, inputID })
const error = await request
.then(
() => undefined,
(error) => error,
)
.finally(() => pendingQueueActions.delete(inputID))
if (!error) return true
const label = action === "cancel" ? "delete" : action
toast.show({ title: `Failed to ${label} pending prompt`, message: errorMessage(error), variant: "error" })
return false
}
const openQueuedPrompts = () =>
dialog.replace(() => (
<DialogSelect
title="Queued prompts"
options={queuedPrompts().map((prompt, index) => ({
title: prompt.text,
value: prompt.id,
footer: `${index + 1} of ${queuedPrompts().length}`,
}))}
onSelect={(option) => {
void mutatePending("steer", option.value).then((steered) => {
if (steered) dialog.clear()
})
}}
actions={[
{
command: "queued_prompt.delete",
title: "delete",
onTrigger: (option) => {
void mutatePending("cancel", option.value).then((cancelled) => {
if (cancelled && queuedPrompts().length <= 1) dialog.clear()
})
},
},
]}
footerHints={[{ title: "steer", label: "enter" }]}
/>
))
const unavailable = (feature: string) => {
toast.show({ message: `${feature} is not implemented for V2 sessions yet`, variant: "error", duration: 5000 })
dialog.clear()
@@ -880,6 +937,13 @@ export function Session() {
dialog.clear()
},
},
{
title: "View queued prompts",
id: "session.queued_prompts",
group: "Session",
enabled: queuedPrompts().length > 0,
run: openQueuedPrompts,
},
{
title: "Go to parent session",
id: "session.parent",
@@ -951,6 +1015,7 @@ export function Session() {
diffWrapMode,
models,
config,
mutatePending,
}}
>
<box flexDirection="row" flexGrow={1} minHeight={0}>
@@ -1006,6 +1071,9 @@ export function Session() {
</Show>
</scrollbox>
<box flexShrink={0}>
<Show when={!composer.open && !disabled() && queuedPrompts().length > 0}>
<QueuedPromptDock prompts={queuedPrompts()} onOpen={openQueuedPrompts} />
</Show>
<PluginSlot name="session.composer.top" input={{ sessionID: route.sessionID }} mode="all" />
<Composer
sessionID={route.sessionID}
@@ -1041,6 +1109,11 @@ export function Session() {
onSubmit={() => {
toBottom()
}}
onEmptySubmit={async () => {
const next = queuedPrompts()[0]
if (!next) return false
return mutatePending("steer", next.id)
}}
sessionID={route.sessionID}
/>
</Match>
@@ -1822,6 +1895,7 @@ function ShellMessage(props: { message: Extract<SessionMessageInfo, { type: "she
return (
<box
width="100%"
border={["left"]}
paddingTop={1}
paddingBottom={1}
@@ -1849,18 +1923,23 @@ function UserMessage(props: { message: SessionMessageUser }) {
const mode = themes.mode
const [hover, setHover] = createSignal(false)
const color = createMemo(() => local.agent.color(data.session.get(ctx.sessionID)?.agent ?? "build"))
const queued = createMemo(
() => data.session.status(ctx.sessionID) === "running" && data.session.input.has(ctx.sessionID, props.message.id),
)
const delivery = createMemo(() => {
const pending = data.session.pending.list(ctx.sessionID).find((item) => item.id === props.message.id)
return pending?.type === "user" ? pending.delivery : undefined
})
const dialog = useDialog()
const renderer = useRenderer()
const promptRef = usePromptRef()
const updatePendingSteer = async (action: "queue" | "cancel") => {
if (await ctx.mutatePending(action, props.message.id)) dialog.clear()
}
return (
<Show when={props.message.text.trim() || files().length}>
<box
border={["left"]}
borderColor={queued() ? theme.border.default : color()}
borderColor={delivery() ? theme.border.default : color()}
customBorderChars={SplitBorder.customBorderChars}
>
<box
@@ -1872,6 +1951,21 @@ function UserMessage(props: { message: SessionMessageUser }) {
}}
onMouseUp={() => {
if (renderer.getSelection()?.getSelectedText()) return
if (delivery() === "steer") {
dialog.replace(() => (
<DialogSelect
title="Pending steer"
options={[
{ title: "Move to queue", value: "queue" as const },
{ title: "Delete", value: "cancel" as const },
]}
onSelect={(option) => {
void updatePendingSteer(option.value)
}}
/>
))
return
}
dialog.replace(() => (
<DialogMessage
messageID={props.message.id}
@@ -1919,6 +2013,35 @@ function UserMessage(props: { message: SessionMessageUser }) {
)
}
function QueuedPromptDock(props: { prompts: { id: string; text: string }[]; onOpen: () => void }) {
const theme = useTheme("elevated")
const next = createMemo(() => props.prompts[0]?.text)
return (
<box
border={["left"]}
borderColor={theme.border.default}
customBorderChars={SplitBorder.customBorderChars}
onMouseUp={props.onOpen}
>
<box
width="100%"
paddingTop={1}
paddingBottom={1}
paddingLeft={2}
paddingRight={1}
backgroundColor={theme.background.default}
flexDirection="row"
>
<text fg={theme.text.subdued} wrapMode="none" truncate flexGrow={1} flexShrink={1} minWidth={0}>
<span style={{ fg: theme.text.default }}>{props.prompts.length} queued</span>
<Show when={next()}>{(text) => <> · {text()}</>}</Show>
</text>
</box>
</box>
)
}
function AssistantRetry(props: { retry: SessionMessageAssistant["retry"] }) {
const theme = useTheme()
return (
+15 -8
View File
@@ -46,9 +46,14 @@ export function createSessionRows(sessionID: Accessor<string>) {
function reduce() {
const messages = data.session.message.list(sessionID())
const inputs = new Set(data.session.input.list(sessionID()))
const pending = data.session.pending.list(sessionID())
const queued = new Set(
pending.flatMap((item) => (item.type === "user" && item.delivery === "queue" ? [item.id] : [])),
)
const visible = queued.size === 0 ? messages : messages.filter((message) => !queued.has(message.id))
const boundary = revertBoundary()
const rows = reduceSessionRows(
boundary ? messages.filter((message) => message.id < boundary) : messages,
boundary ? visible.filter((message) => message.id < boundary) : visible,
inputs,
turnTokens(),
)
@@ -57,8 +62,7 @@ export function createSessionRows(sessionID: Accessor<string>) {
rows.splice(
position === -1 ? rows.length : position,
0,
...data.session.pending
.list(sessionID())
...pending
.filter((item) => item.type === "compaction")
.map((item): SessionRow => ({ type: "compaction-queued", inputID: item.id })),
)
@@ -112,10 +116,11 @@ export function createSessionRows(sessionID: Accessor<string>) {
createEffect(
on(
() =>
data.session.pending
.list(sessionID())
.filter((item) => item.type === "compaction")
.map((item) => item.id),
data.session.pending.list(sessionID()).flatMap((item) => {
if (item.type === "compaction") return [`${item.id}:compaction`]
if (item.type === "user" && item.delivery === "queue") return [`${item.id}:queue`]
return []
}),
() => setRows(reconcile(reduce())),
{ defer: true },
),
@@ -196,7 +201,9 @@ export function createSessionRows(sessionID: Accessor<string>) {
const queuedStart = (rows: SessionRow[]) => {
const index = rows.findIndex(
(row) => row.type === "compaction-queued" || (row.type === "message" && isPending(row.messageID)),
(row) =>
row.type === "compaction-queued" ||
(row.type === "message" && isPending(row.messageID)),
)
return index === -1 ? rows.length : index
}
+1 -1
View File
@@ -28,7 +28,7 @@ export function Link(props: LinkProps) {
open(props.href).catch(() => {})
}}
>
{displayText}
<a href={props.href}>{displayText}</a>
</text>
)
}
+100
View File
@@ -914,6 +914,106 @@ test("completes exploration when a queued prompt is promoted", async () => {
}
})
test("updates and removes queued inputs from durable lifecycle events", async () => {
const events = createEventStream()
const sessionID = "session-queue-management"
const calls = createFetch((url) => {
if (url.pathname === `/api/session/${sessionID}/message`) return json({ data: [], cursor: {} })
}, events)
let data!: ReturnType<typeof useData>
let rows!: ReturnType<typeof createSessionRows>
let client!: ReturnType<typeof useClient>
function Probe() {
client = useClient()
data = useData()
rows = createSessionRows(() => sessionID)
return <box />
}
const app = await testRender(() => (
<TestTuiContexts>
<ClientProvider api={createApi(calls.fetch)}>
<ProjectProvider>
<DataProvider>
<Probe />
</DataProvider>
</ProjectProvider>
</ClientProvider>
</TestTuiContexts>
))
try {
await wait(() => client.connection.status() === "connected")
emitEvent(events, {
id: "evt_queue_admitted",
created: 1,
type: "session.input.admitted",
durable: durable(sessionID),
data: {
sessionID,
inputID: "message-queued",
input: { type: "user", data: { text: "Steer me" }, delivery: "queue" },
},
})
await wait(() => data.session.pending.list(sessionID).length === 1)
expect(rows).not.toContainEqual({ type: "message", messageID: "message-queued" })
emitEvent(events, {
id: "evt_queue_steered",
created: 2,
type: "session.input.steered",
durable: durable(sessionID, 1),
data: { sessionID, inputID: "message-queued" },
})
await wait(() =>
data.session.pending
.list(sessionID)
.some((item) => item.id === "message-queued" && item.type !== "compaction" && item.delivery === "steer"),
)
expect(rows).toContainEqual({ type: "message", messageID: "message-queued" })
emitEvent(events, {
id: "evt_queue_restored",
created: 3,
type: "session.input.queued",
durable: durable(sessionID, 2),
data: { sessionID, inputID: "message-queued" },
})
await wait(() =>
data.session.pending
.list(sessionID)
.some((item) => item.id === "message-queued" && item.type !== "compaction" && item.delivery === "queue"),
)
expect(rows).not.toContainEqual({ type: "message", messageID: "message-queued" })
emitEvent(events, {
id: "evt_cancel_admitted",
created: 4,
type: "session.input.admitted",
durable: durable(sessionID, 3),
data: {
sessionID,
inputID: "message-cancelled",
input: { type: "user", data: { text: "Delete me" }, delivery: "queue" },
},
})
await wait(() => data.session.pending.list(sessionID).length === 2)
emitEvent(events, {
id: "evt_queue_cancelled",
created: 5,
type: "session.input.cancelled",
durable: durable(sessionID, 4),
data: { sessionID, inputID: "message-cancelled" },
})
await wait(() => !data.session.input.has(sessionID, "message-cancelled"))
expect(data.session.pending.list(sessionID).map((item) => item.id)).toEqual(["message-queued"])
expect(data.session.message.get(sessionID, "message-cancelled")).toBeUndefined()
} finally {
app.renderer.destroy()
}
})
test("classifies live tool rows independently of their call ID", async () => {
const events = createEventStream()
const sessionID = "session-tool-call-id"
+2 -2
View File
@@ -56,9 +56,9 @@ export function createFooterApiFixture(input: { events?: FooterEvent[]; commits?
commits,
calls,
promptReady,
submit(text: string, mode?: RunPrompt["mode"]) {
submit(text: string, mode?: RunPrompt["mode"], delivery?: RunPrompt["delivery"]) {
if (prompts.size === 0) return false
const prompt: RunPrompt = mode ? { text, parts: [], mode } : { text, parts: [] }
const prompt: RunPrompt = { text, parts: [], ...(mode ? { mode } : {}), ...(delivery ? { delivery } : {}) }
for (const fn of [...prompts]) fn(prompt)
return true
},
+97 -23
View File
@@ -21,6 +21,7 @@ import { RunFooterView } from "../../src/mini/footer.view"
import { RunEntryContent } from "../../src/mini/scrollback.writer"
import { RUN_THEME_FALLBACK, type RunTheme } from "../../src/mini/theme"
import type {
FooterQueuedPrompt,
FooterState,
FooterSubagentState,
FooterSubagentTab,
@@ -120,13 +121,15 @@ async function renderFooter(
height?: number
state?: Partial<FooterState>
onCycle?: () => void
onSubmit?: (prompt: RunPrompt) => boolean
onSubmit?: (prompt: RunPrompt) => boolean | Promise<boolean>
view?: FooterView
onFormReply?: (input: unknown) => void
miniSettings?: MiniSettings
mono?: boolean
onStatus?: (status: string) => void
onMiniSettingChange?: (change: MiniSettingChange) => void
queuedPrompts?: FooterQueuedPrompt[]
onQueuedPromptAction?: (action: "steer" | "cancel", inputID: string) => Promise<void>
} = {},
) {
const [view, setView] = createSignal<FooterView>(input.view ?? { type: "prompt" })
@@ -164,6 +167,7 @@ async function renderFooter(
state={state}
view={view}
subagent={subagents}
queuedPrompts={() => input.queuedPrompts ?? []}
theme={input.theme ?? (() => RUN_THEME_FALLBACK)}
mono={input.mono ?? false}
miniSettings={miniSettings}
@@ -173,6 +177,7 @@ async function renderFooter(
onFormCancel={() => {}}
onCycle={input.onCycle ?? (() => {})}
onInterrupt={() => false}
onQueuedPromptAction={input.onQueuedPromptAction}
onEditorOpen={async () => undefined}
onInputClear={() => {}}
onExit={() => {}}
@@ -913,7 +918,7 @@ test("direct subagent panel closes when moving up from the first item", async ()
}
})
test("direct pending panel shows durable delivery without edit actions", async () => {
test("direct queued panel steers and deletes selected prompts", async () => {
const [prompts] = createSignal([
{
messageID: "m-1",
@@ -921,16 +926,22 @@ test("direct pending panel shows durable delivery without edit actions", async (
delivery: "queue" as const,
},
])
const steered: string[] = []
const deleted: string[] = []
const app = await testRender(
() => (
<box width={100} height={RUN_SUBAGENT_PANEL_ROWS}>
<RunQueuedPromptSelectBody
theme={() => RUN_THEME_FALLBACK.footer}
prompts={prompts}
onClose={() => {}}
/>
</box>
<Keymap.Provider config={tuiConfig}>
<box width={100} height={RUN_SUBAGENT_PANEL_ROWS}>
<RunQueuedPromptSelectBody
theme={() => RUN_THEME_FALLBACK.footer}
prompts={prompts}
onClose={() => {}}
onSteer={(prompt) => steered.push(prompt.messageID)}
onDelete={(prompt) => deleted.push(prompt.messageID)}
/>
</box>
</Keymap.Provider>
),
{ width: 100, height: RUN_SUBAGENT_PANEL_ROWS },
)
@@ -940,19 +951,75 @@ test("direct pending panel shows durable delivery without edit actions", async (
const frame = app.captureCharFrame()
const list = panelMenu(app.renderer.root)
expect(frame).toContain("Pending work")
expect(frame).toContain("Queued prompts")
expect(frame).toContain("fix the auth test")
expect(frame).toContain("queue")
expect(frame).toContain("queued")
expect(frame).toContain("enter steer · ctrl+d delete")
expect(frame).not.toContain("┌")
expect(frame).not.toContain("┃")
expectPaletteList(list, 0)
expect(frame).not.toContain("edit")
expect(frame).not.toContain("remove")
app.mockInput.pressEnter()
app.mockInput.pressKey("d", { ctrl: true })
expect(steered).toEqual(["m-1"])
expect(deleted).toEqual(["m-1"])
} finally {
app.renderer.destroy()
}
})
test("direct footer steers the oldest queued prompt from an empty composer", async () => {
const steered: string[] = []
const app = await renderFooter({
queuedPrompts: [
{ messageID: "m-1", prompt: { text: "first", parts: [] }, delivery: "queue" },
{ messageID: "m-2", prompt: { text: "second", parts: [] }, delivery: "queue" },
],
onQueuedPromptAction: async (action, inputID) => {
if (action === "steer") steered.push(inputID)
},
})
try {
await app.renderOnce()
app.mockInput.pressEnter({ meta: true })
await Bun.sleep(0)
expect(steered).toEqual([])
app.mockInput.pressEnter()
await Bun.sleep(0)
expect(steered).toEqual(["m-1"])
} finally {
app.cleanup()
}
})
test("direct footer does not steer queued work on a double submit", async () => {
const submitted: RunPrompt[] = []
const steered: string[] = []
const app = await renderFooter({
queuedPrompts: [{ messageID: "m-1", prompt: { text: "queued", parts: [] }, delivery: "queue" }],
onSubmit: async (prompt) => {
submitted.push(prompt)
await Bun.sleep(10)
return true
},
onQueuedPromptAction: async (action, inputID) => {
if (action === "steer") steered.push(inputID)
},
})
try {
await app.renderOnce()
await app.mockInput.typeText("send once")
app.mockInput.pressEnter()
app.mockInput.pressEnter()
await Bun.sleep(20)
expect(submitted).toHaveLength(1)
expect(steered).toEqual([])
} finally {
app.cleanup()
}
})
// OpenTUI currently crashes Bun in the full `test/cli/run` directory run here.
// Re-enable after the upstream OpenTUI fix lands in this repo.
test.skip("direct footer recreates the frame across command panel transitions", async () => {
@@ -1068,11 +1135,11 @@ test("direct footer submits slash autocomplete selections without dispatching sh
await app.renderOnce()
expect(submits).toEqual([
{ text: "/review ", parts: [], command: { name: "review", arguments: "" } },
{ text: "/review ", parts: [], command: { name: "review", arguments: "" } },
{ text: "/review branch", parts: [], command: { name: "review", arguments: "branch" } },
{ text: "/new ", parts: [] },
{ text: "/new ", parts: [] },
{ text: "/review ", parts: [], command: { name: "review", arguments: "" }, delivery: "steer" },
{ text: "/review ", parts: [], command: { name: "review", arguments: "" }, delivery: "steer" },
{ text: "/review branch", parts: [], command: { name: "review", arguments: "branch" }, delivery: "steer" },
{ text: "/new ", parts: [], delivery: "steer" },
{ text: "/new ", parts: [], delivery: "steer" },
])
expect(app.renderer.currentFocusedEditor?.plainText).toBe("/settings ")
} finally {
@@ -1100,7 +1167,9 @@ test("direct footer slash autocomplete keeps a real skills command", async () =>
app.mockInput.pressEnter()
await app.renderOnce()
expect(submits).toEqual([{ text: "/skills ", parts: [], command: { name: "skills", arguments: "" } }])
expect(submits).toEqual([
{ text: "/skills ", parts: [], command: { name: "skills", arguments: "" }, delivery: "steer" },
])
expect(app.captureCharFrame()).not.toContain("Apply formatter fixes")
} finally {
app.cleanup()
@@ -1158,7 +1227,12 @@ test("direct footer tags skill slash submissions with their catalog source", asy
await app.renderOnce()
expect(submits).toEqual([
{ text: "/formatter src", parts: [], command: { name: "formatter", arguments: "src", source: "skill" } },
{
text: "/formatter src",
parts: [],
command: { name: "formatter", arguments: "src", source: "skill" },
delivery: "steer",
},
])
} finally {
app.cleanup()
@@ -1238,7 +1312,7 @@ test.skip("direct footer clears the synthetic skills draft when the panel closes
}
})
test("direct footer shows authoritative pending work while running", async () => {
test("direct footer shows authoritative queued work while running", async () => {
const [state] = createSignal<FooterState>({
phase: "running",
status: "",
@@ -1342,9 +1416,9 @@ test("direct footer shows authoritative pending work while running", async () =>
const hint = statusItems.at(-1)!
expect(spinner).toBeDefined()
expect(frame).toContain("1 pending")
expect(frame).toContain("1 queued")
expect(frame).toContain("ctrl+b background")
expect(frame).toContain("ctrl+x q 1 pending")
expect(frame).toContain("ctrl+x q 1 queued")
expect(frame).toContain("↓ subagents")
expect(frame).toContain("ctrl+p cmd")
expect(frame).toContain("subagents · ctrl+p cmd")
+2 -1
View File
@@ -82,7 +82,8 @@ describe("run runtime boot", () => {
expect(result.keybinds.get("prompt.history.next")?.[0]?.key).toBe("down")
expect(result.keybinds.get("prompt.clear")?.[0]?.key).toBe("ctrl+c")
expect(result.keybinds.get("input.submit")?.[0]?.key).toBe("return")
expect(result.keybinds.get("input.newline")?.[0]?.key).toBe("shift+return,ctrl+return,alt+return,ctrl+j")
expect(result.keybinds.get("input.newline")?.[0]?.key).toBe("shift+return,ctrl+return,ctrl+j")
expect(result.keybinds.get("prompt.queue")?.[0]?.key).toBe("alt+return")
})
test("preserves disabled leader from resolved tui config", async () => {
+28 -1
View File
@@ -265,6 +265,33 @@ describe("run runtime queue", () => {
await task
})
test("preserves explicit steer and queue delivery for in-flight prompts", async () => {
const ui = createFooterApiFixture()
const admitted: string[] = []
const gate = Promise.withResolvers<void>()
const task = runPromptQueue({
footer: ui.api,
run: async (_input, _signal, onAdmitted) => {
onAdmitted()
await gate.promise
},
admit: async (input, delivery) => {
admitted.push(`${input.text}:${delivery}`)
},
settle: async () => ui.api.close(),
})
ui.submit("one")
ui.submit("two", undefined, "steer")
ui.submit("three", undefined, "queue")
while (admitted.length < 2) await Bun.sleep(0)
expect(admitted).toEqual(["two:steer", "three:queue"])
gate.resolve()
await task
})
test("continues durable admission after one fails", async () => {
const ui = createFooterApiFixture()
const admitted: string[] = []
@@ -308,7 +335,7 @@ describe("run runtime queue", () => {
admitted()
await new Promise<void>((resolve) => signal.addEventListener("abort", () => resolve(), { once: true }))
},
admit: async (_prompt, signal) => {
admit: async (_prompt, _delivery, signal) => {
admissionStarted.resolve()
await new Promise<void>((resolve) => {
if (signal.aborted) {
+3 -3
View File
@@ -126,7 +126,7 @@ describe("run interactive runtime", () => {
turnStarted.resolve()
api.close()
},
queuePromptTurn: async () => {},
admitPromptTurn: async () => {},
waitForIdle: async () => {},
interruptActiveTurn: async () => {},
selectSubagent: () => {},
@@ -209,7 +209,7 @@ describe("run interactive runtime", () => {
streamStarted.resolve()
return {
runPromptTurn: async () => {},
queuePromptTurn: async () => {},
admitPromptTurn: async () => {},
waitForIdle: async () => {},
interruptActiveTurn: async () => {},
selectSubagent: () => {},
@@ -556,7 +556,7 @@ describe("run interactive runtime", () => {
setTimeout(() => input.footer.close(), 0)
return {
runPromptTurn: async () => {},
queuePromptTurn: async () => {},
admitPromptTurn: async () => {},
waitForIdle: async () => {},
interruptActiveTurn: async () => {},
selectSubagent: () => {},
@@ -669,6 +669,14 @@ describe("V2 mini transport", () => {
data: { text: "follow up" },
delivery: "queue",
},
{
id: "msg_cancelled",
sessionID: "ses_1",
timeCreated: 2,
type: "user",
data: { text: "remove me" },
delivery: "queue",
},
],
},
})
@@ -684,11 +692,14 @@ describe("V2 mini transport", () => {
.findLast((item) => item.type === "queued.prompts")
?.prompts.map((item) => [item.messageID, item.delivery])
expect(pending()).toEqual([["msg_queued", "queue"]])
expect(pending()).toEqual([
["msg_queued", "queue"],
["msg_cancelled", "queue"],
])
events.push({
id: "evt_promoted",
created: 2,
type: "session.input.promoted",
id: "evt_steered",
created: 3,
type: "session.input.steered",
durable: durable("ses_1", 2),
data: { sessionID: "ses_1", inputID: "msg_queued" },
})
@@ -697,18 +708,48 @@ describe("V2 mini transport", () => {
expect(ui.commits).toContainEqual(
expect.objectContaining({ kind: "user", messageID: "msg_queued", text: "follow up" }),
)
expect(pending()).toEqual([])
expect(pending()).toEqual([["msg_cancelled", "queue"]])
events.push({
id: "evt_queued",
created: 4,
type: "session.input.queued",
durable: durable("ses_1", 3),
data: { sessionID: "ses_1", inputID: "msg_queued" },
})
while (pending()?.length !== 2) await Bun.sleep(0)
expect(pending()).toEqual([
["msg_queued", "queue"],
["msg_cancelled", "queue"],
])
events.push({
id: "evt_cancelled",
created: 5,
type: "session.input.cancelled",
durable: durable("ses_1", 4),
data: { sessionID: "ses_1", inputID: "msg_cancelled" },
})
while (pending()?.length !== 1) await Bun.sleep(0)
expect(pending()).toEqual([["msg_queued", "queue"]])
events.push({
id: "evt_promoted",
created: 6,
type: "session.input.promoted",
durable: durable("ses_1", 5),
data: { sessionID: "ses_1", inputID: "msg_queued" },
})
while (pending()?.length !== 0) await Bun.sleep(0)
expect(ui.commits.filter((item) => item.messageID === "msg_queued")).toHaveLength(1)
const prompt = spyOn(client.session, "prompt").mockImplementation(
(request) => ok(promptAdmission(request)) as never,
)
await transport.queuePromptTurn({
await transport.admitPromptTurn({
agent: "review",
model: undefined,
variant: undefined,
prompt: { messageID: "msg_next", text: "another", parts: [] },
files: [],
includeFiles: false,
})
}, "queue")
expect(client.session.switchAgent).toHaveBeenCalledWith({ sessionID: "ses_1", agent: "review" }, expect.anything())
expect(prompt).toHaveBeenCalledWith(expect.objectContaining({ delivery: "queue" }), expect.anything())
events.push({
@@ -722,15 +763,8 @@ describe("V2 mini transport", () => {
input: { type: "user", data: { text: "earlier" }, delivery: "steer" },
},
})
while (true) {
const pending = ui.events.findLast((item) => item.type === "queued.prompts")
if (pending?.type === "queued.prompts" && pending.prompts.length >= 2) break
await Bun.sleep(0)
}
expect(pending()).toEqual([
["msg_next", "queue"],
["msg_earlier", "steer"],
])
await Bun.sleep(10)
expect(pending()).toEqual([["msg_next", "queue"]])
await transport.close()
})
@@ -813,14 +847,14 @@ describe("V2 mini transport", () => {
durable: durable("ses_1", 2),
data: { sessionID: "ses_1", inputID: "msg_prompt" },
})
await transport.queuePromptTurn({
await transport.admitPromptTurn({
agent: undefined,
model: undefined,
variant: undefined,
prompt: { messageID: "msg_queued", text: "follow up", parts: [] },
files: [],
includeFiles: false,
})
}, "queue")
events.push({
id: "evt_queued_promoted",
created: 3,
@@ -83,7 +83,7 @@ Add `compaction` to any [OpenCode configuration file](/config):
"auto": true,
"prune": false,
"keep": {
"tokens": 8000
"tokens": 15000
},
"buffer": 20000
}
@@ -94,7 +94,7 @@ Add `compaction` to any [OpenCode configuration file](/config):
| --- | ---: | --- |
| `auto` | `true` | Runs the preflight context-size check. It does not disable manual compaction or one-shot provider-overflow recovery. |
| `prune` | None | Accepted by the V2 schema, but currently has no runtime effect. V2 does not prune old tool outputs in place. |
| `keep.tokens` | `8000` | Approximate number of tokens from the newest serialized conversation context to retain beside the summary. |
| `keep.tokens` | `15000` | Approximate number of tokens from the newest serialized conversation context to retain beside the summary. |
| `buffer` | `20000` | Safety reserve below an explicit input limit. Without one, it is the minimum context reserve and the model output allowance wins when larger. |
`keep.tokens` and `buffer` accept non-negative integers. Larger `keep.tokens`
+1 -1
View File
@@ -329,7 +329,7 @@ Control automatic context compaction and how much recent context it preserves.
"compaction": {
"auto": true,
"keep": {
"tokens": 8000
"tokens": 15000
},
"buffer": 20000
}
+677 -1526
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff