mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-07 01:29:44 -04:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ff736fbbc8 |
@@ -10,6 +10,7 @@ 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
|
||||
|
||||
@@ -56,7 +57,7 @@ export const DialogSelectMcp: Component = () => {
|
||||
}
|
||||
const error = () => {
|
||||
const s = mcpStatus()
|
||||
if (s?.status === "failed") return s.error
|
||||
if (s?.status === "failed" || s?.status === "needs_client_registration") return s.error
|
||||
}
|
||||
const enabled = () => status() === "connected"
|
||||
return (
|
||||
|
||||
@@ -426,7 +426,8 @@ 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",
|
||||
"bg-icon-warning-base":
|
||||
status() === "needs_auth" || status() === "needs_client_registration",
|
||||
}}
|
||||
/>
|
||||
<span class="flex flex-col min-w-0 flex-1">
|
||||
|
||||
@@ -35,6 +35,7 @@ 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)
|
||||
})
|
||||
|
||||
@@ -47,6 +48,7 @@ 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")
|
||||
return input.mcp.some((status) => status === "needs_auth" || status === "needs_client_registration")
|
||||
}
|
||||
|
||||
export function hasNonBlockingServiceIssue(input: {
|
||||
|
||||
@@ -13,6 +13,7 @@ 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,88 +153,4 @@ 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,9 +29,6 @@ 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,6 +34,7 @@ function icon(status: McpServer["status"]) {
|
||||
case "needs_auth":
|
||||
return "⚠"
|
||||
case "failed":
|
||||
case "needs_client_registration":
|
||||
return "✗"
|
||||
default:
|
||||
return "○"
|
||||
@@ -44,6 +45,8 @@ 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:
|
||||
|
||||
@@ -251,52 +251,38 @@ 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; readonly inputID: SessionMessage.ID }
|
||||
export type Endpoint5_22Output = void
|
||||
export type SessionPendingCancelOperation<E = never> = (
|
||||
export type Endpoint5_22Input = { readonly sessionID: Session.ID }
|
||||
export type Endpoint5_22Output = ReadonlyArray<InstructionEntry.Info>
|
||||
export type SessionInstructionsEntryListOperation<E = never> = (
|
||||
input: Endpoint5_22Input,
|
||||
) => Effect.Effect<Endpoint5_22Output, E>
|
||||
|
||||
export type Endpoint5_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 = {
|
||||
export type Endpoint5_23Input = {
|
||||
readonly sessionID: Session.ID
|
||||
readonly key: InstructionEntry.Key
|
||||
readonly value: Schema.Json
|
||||
}
|
||||
export type Endpoint5_26Output = void
|
||||
export type Endpoint5_23Output = void
|
||||
export type SessionInstructionsEntryPutOperation<E = never> = (
|
||||
input: Endpoint5_26Input,
|
||||
) => Effect.Effect<Endpoint5_26Output, E>
|
||||
input: Endpoint5_23Input,
|
||||
) => Effect.Effect<Endpoint5_23Output, E>
|
||||
|
||||
export type Endpoint5_27Input = { readonly sessionID: Session.ID; readonly key: InstructionEntry.Key }
|
||||
export type Endpoint5_27Output = void
|
||||
export type Endpoint5_24Input = { readonly sessionID: Session.ID; readonly key: InstructionEntry.Key }
|
||||
export type Endpoint5_24Output = void
|
||||
export type SessionInstructionsEntryRemoveOperation<E = never> = (
|
||||
input: Endpoint5_27Input,
|
||||
) => Effect.Effect<Endpoint5_27Output, E>
|
||||
input: Endpoint5_24Input,
|
||||
) => Effect.Effect<Endpoint5_24Output, 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_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_29Input = {
|
||||
export type Endpoint5_26Input = {
|
||||
readonly sessionID: Session.ID
|
||||
readonly after?: Event.Seq | undefined
|
||||
readonly follow?: boolean | undefined
|
||||
}
|
||||
export type Endpoint5_29Output =
|
||||
export type Endpoint5_26Output =
|
||||
| (
|
||||
| {
|
||||
readonly id: Event.ID
|
||||
@@ -406,33 +392,6 @@ export type Endpoint5_29Output =
|
||||
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
|
||||
@@ -891,19 +850,19 @@ export type Endpoint5_29Output =
|
||||
}
|
||||
)
|
||||
| EventLog.Synced
|
||||
export type SessionLogOperation<E = never> = (input: Endpoint5_29Input) => Stream.Stream<Endpoint5_29Output, E>
|
||||
export type SessionLogOperation<E = never> = (input: Endpoint5_26Input) => Stream.Stream<Endpoint5_26Output, 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_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_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_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_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 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 interface SessionApi<E = never> {
|
||||
readonly list: SessionListOperation<E>
|
||||
@@ -929,12 +888,7 @@ export interface SessionApi<E = never> {
|
||||
readonly commit: SessionRevertCommitOperation<E>
|
||||
}
|
||||
readonly context: SessionContextOperation<E>
|
||||
readonly pending: {
|
||||
readonly list: SessionPendingListOperation<E>
|
||||
readonly cancel: SessionPendingCancelOperation<E>
|
||||
readonly steer: SessionPendingSteerOperation<E>
|
||||
readonly queue: SessionPendingQueueOperation<E>
|
||||
}
|
||||
readonly pending: { readonly list: SessionPendingListOperation<E> }
|
||||
readonly instructions: {
|
||||
readonly entry: {
|
||||
readonly list: SessionInstructionsEntryListOperation<E>
|
||||
|
||||
@@ -76,12 +76,6 @@ 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,
|
||||
@@ -507,58 +501,37 @@ 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.pending.cancel"]({ params: { sessionID: input["sessionID"], inputID: input["inputID"] } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
),
|
||||
)
|
||||
|
||||
const Endpoint5_23 = (raw: RawClient["server.session"]) => (input: Endpoint5_23Input) =>
|
||||
preserveEffect<Endpoint5_23Output>()(
|
||||
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.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.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) =>
|
||||
preserveEffect<Endpoint5_26Output>()(
|
||||
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)),
|
||||
)
|
||||
|
||||
const Endpoint5_27 = (raw: RawClient["server.session"]) => (input: Endpoint5_27Input) =>
|
||||
preserveEffect<Endpoint5_27Output>()(
|
||||
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(
|
||||
Effect.mapError(mapClientError),
|
||||
),
|
||||
)
|
||||
|
||||
const Endpoint5_28 = (raw: RawClient["server.session"]) => (input: Endpoint5_28Input) =>
|
||||
preserveEffect<Endpoint5_28Output>()(
|
||||
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(
|
||||
Effect.mapError(mapClientError),
|
||||
Effect.map((value) => value.data),
|
||||
),
|
||||
)
|
||||
|
||||
const Endpoint5_29 = (raw: RawClient["server.session"]) => (input: Endpoint5_29Input) =>
|
||||
preserveStream<Endpoint5_29Output>()(
|
||||
const Endpoint5_26 = (raw: RawClient["server.session"]) => (input: Endpoint5_26Input) =>
|
||||
preserveStream<Endpoint5_26Output>()(
|
||||
Stream.unwrap(
|
||||
raw["session.log"]({
|
||||
params: { sessionID: input["sessionID"] },
|
||||
@@ -570,18 +543,18 @@ const Endpoint5_29 = (raw: RawClient["server.session"]) => (input: Endpoint5_29I
|
||||
),
|
||||
)
|
||||
|
||||
const Endpoint5_30 = (raw: RawClient["server.session"]) => (input: Endpoint5_30Input) =>
|
||||
preserveEffect<Endpoint5_30Output>()(
|
||||
const Endpoint5_27 = (raw: RawClient["server.session"]) => (input: Endpoint5_27Input) =>
|
||||
preserveEffect<Endpoint5_27Output>()(
|
||||
raw["session.interrupt"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const Endpoint5_31 = (raw: RawClient["server.session"]) => (input: Endpoint5_31Input) =>
|
||||
preserveEffect<Endpoint5_31Output>()(
|
||||
const Endpoint5_28 = (raw: RawClient["server.session"]) => (input: Endpoint5_28Input) =>
|
||||
preserveEffect<Endpoint5_28Output>()(
|
||||
raw["session.background"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const Endpoint5_32 = (raw: RawClient["server.session"]) => (input: Endpoint5_32Input) =>
|
||||
preserveEffect<Endpoint5_32Output>()(
|
||||
const Endpoint5_29 = (raw: RawClient["server.session"]) => (input: Endpoint5_29Input) =>
|
||||
preserveEffect<Endpoint5_29Output>()(
|
||||
raw["session.message"]({ params: { sessionID: input["sessionID"], messageID: input["messageID"] } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
Effect.map((value) => value.data),
|
||||
@@ -608,13 +581,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), 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),
|
||||
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),
|
||||
})
|
||||
|
||||
const Endpoint6_0 = (raw: RawClient["server.message"]) => (input: Endpoint6_0Input) =>
|
||||
|
||||
@@ -54,12 +54,6 @@ import type {
|
||||
SessionContextOutput,
|
||||
SessionPendingListInput,
|
||||
SessionPendingListOutput,
|
||||
SessionPendingCancelInput,
|
||||
SessionPendingCancelOutput,
|
||||
SessionPendingSteerInput,
|
||||
SessionPendingSteerOutput,
|
||||
SessionPendingQueueInput,
|
||||
SessionPendingQueueOutput,
|
||||
SessionInstructionsEntryListInput,
|
||||
SessionInstructionsEntryListOutput,
|
||||
SessionInstructionsEntryPutInput,
|
||||
@@ -744,39 +738,6 @@ 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: {
|
||||
|
||||
@@ -259,6 +259,8 @@ 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 = {
|
||||
@@ -500,36 +502,6 @@ 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
|
||||
@@ -1289,7 +1261,13 @@ export type ConnectionInfo = ConnectionCredentialInfo | ConnectionEnvInfo
|
||||
|
||||
export type McpServer = {
|
||||
name: string
|
||||
status: McpStatusConnected | McpStatusPending | McpStatusDisabled | McpStatusFailed | McpStatusNeedsAuth
|
||||
status:
|
||||
| McpStatusConnected
|
||||
| McpStatusPending
|
||||
| McpStatusDisabled
|
||||
| McpStatusFailed
|
||||
| McpStatusNeedsAuth
|
||||
| McpStatusNeedsClientRegistration
|
||||
integrationID?: string
|
||||
}
|
||||
|
||||
@@ -1723,43 +1701,44 @@ export type AgentInfo = {
|
||||
export type ConfigEntry =
|
||||
| {
|
||||
type: "document"
|
||||
path?: string
|
||||
path?: string | null
|
||||
info: {
|
||||
$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
|
||||
$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
|
||||
agents?: {
|
||||
[x: string]: {
|
||||
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
|
||||
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
|
||||
}
|
||||
}
|
||||
snapshots?: boolean
|
||||
watcher?: { ignore?: Array<string> }
|
||||
} | null
|
||||
snapshots?: boolean | null
|
||||
watcher?: { ignore?: Array<string> | null } | null
|
||||
formatter?:
|
||||
| boolean
|
||||
| {
|
||||
[x: string]: {
|
||||
disabled?: boolean
|
||||
command?: Array<string>
|
||||
environment?: { [x: string]: string }
|
||||
extensions?: Array<string>
|
||||
disabled?: boolean | null
|
||||
command?: Array<string> | null
|
||||
environment?: { [x: string]: string } | null
|
||||
extensions?: Array<string> | null
|
||||
}
|
||||
}
|
||||
| null
|
||||
lsp?:
|
||||
| boolean
|
||||
| {
|
||||
@@ -1767,117 +1746,125 @@ export type ConfigEntry =
|
||||
| { disabled: true }
|
||||
| {
|
||||
command: Array<string>
|
||||
extensions?: Array<string>
|
||||
disabled?: boolean
|
||||
env?: { [x: string]: string }
|
||||
initialization?: { [x: string]: JsonValue }
|
||||
extensions?: Array<string> | null
|
||||
disabled?: boolean | null
|
||||
env?: { [x: string]: string } | null
|
||||
initialization?: { [x: string]: JsonValue } | null
|
||||
}
|
||||
}
|
||||
| null
|
||||
media?: {
|
||||
image?: { auto_resize?: boolean; max_width?: number; max_height?: number; max_base64_bytes?: number }
|
||||
}
|
||||
tool_output?: { max_lines?: number; max_bytes?: number }
|
||||
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
|
||||
mcp?: {
|
||||
timeout?: { startup?: number; catalog?: number; execution?: number }
|
||||
timeout?: { startup?: number | null; catalog?: number | null; execution?: number | null } | null
|
||||
servers?: {
|
||||
[x: string]:
|
||||
| {
|
||||
type: "local"
|
||||
command: Array<string>
|
||||
cwd?: string
|
||||
environment?: { [x: string]: string }
|
||||
disabled?: boolean
|
||||
codemode?: boolean
|
||||
timeout?: { startup?: number; catalog?: number; execution?: number }
|
||||
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
|
||||
}
|
||||
| {
|
||||
type: "remote"
|
||||
url: string
|
||||
headers?: { [x: string]: string }
|
||||
headers?: { [x: string]: string } | null
|
||||
oauth?:
|
||||
| {
|
||||
client_id?: string
|
||||
client_secret?: string
|
||||
scope?: string
|
||||
callback_port?: number
|
||||
redirect_uri?: string
|
||||
client_id?: string | null
|
||||
client_secret?: string | null
|
||||
scope?: string | null
|
||||
callback_port?: number | null
|
||||
redirect_uri?: string | null
|
||||
}
|
||||
| false
|
||||
disabled?: boolean
|
||||
codemode?: boolean
|
||||
timeout?: { startup?: number; catalog?: number; execution?: number }
|
||||
| null
|
||||
disabled?: boolean | null
|
||||
codemode?: boolean | null
|
||||
timeout?: { startup?: number | null; catalog?: number | null; execution?: number | null } | null
|
||||
}
|
||||
}
|
||||
}
|
||||
compaction?: { auto?: boolean; keep?: { tokens?: number }; buffer?: number }
|
||||
skills?: Array<string>
|
||||
} | null
|
||||
} | null
|
||||
compaction?: { auto?: boolean | null; keep?: { tokens?: number | null } | null; buffer?: number | null } | null
|
||||
skills?: Array<string> | null
|
||||
commands?: {
|
||||
[x: string]: {
|
||||
template: string
|
||||
description?: string
|
||||
agent?: string
|
||||
model?: string | { providerID: string; model: string; variant?: string }
|
||||
subtask?: boolean
|
||||
description?: string | null
|
||||
agent?: string | null
|
||||
model?: string | { providerID: string; model: string; variant?: string | null } | null
|
||||
subtask?: boolean | null
|
||||
}
|
||||
}
|
||||
instructions?: Array<string>
|
||||
} | null
|
||||
instructions?: Array<string> | null
|
||||
references?: {
|
||||
[x: string]:
|
||||
| string
|
||||
| { 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 }
|
||||
| { 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
|
||||
providers?: {
|
||||
[x: string]: {
|
||||
name?: string
|
||||
env?: Array<string>
|
||||
package?: string
|
||||
settings?: { [x: string]: JsonValue }
|
||||
headers?: { [x: string]: string }
|
||||
body?: { [x: string]: JsonValue }
|
||||
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
|
||||
models?: {
|
||||
[x: string]: {
|
||||
modelID?: string
|
||||
family?: string
|
||||
name?: string
|
||||
compatibility?: ModelCompatibility
|
||||
package?: string
|
||||
settings?: { [x: string]: JsonValue }
|
||||
headers?: { [x: string]: string }
|
||||
body?: { [x: string]: JsonValue }
|
||||
capabilities?: ModelCapabilities
|
||||
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
|
||||
variants?: Array<{
|
||||
id: string
|
||||
settings?: { [x: string]: JsonValue }
|
||||
headers?: { [x: string]: string }
|
||||
body?: { [x: string]: JsonValue }
|
||||
}>
|
||||
settings?: { [x: string]: JsonValue } | null
|
||||
headers?: { [x: string]: string } | null
|
||||
body?: { [x: string]: JsonValue } | null
|
||||
}> | null
|
||||
cost?:
|
||||
| {
|
||||
tier?: { type: "context"; size: number }
|
||||
tier?: { type: "context"; size: number } | null
|
||||
input: MoneyUSDPerMillionTokens
|
||||
output: MoneyUSDPerMillionTokens
|
||||
cache?: { read?: MoneyUSDPerMillionTokens; write?: MoneyUSDPerMillionTokens }
|
||||
cache?: { read?: MoneyUSDPerMillionTokens | null; write?: MoneyUSDPerMillionTokens | null } | null
|
||||
}
|
||||
| Array<{
|
||||
tier?: { type: "context"; size: number }
|
||||
tier?: { type: "context"; size: number } | null
|
||||
input: MoneyUSDPerMillionTokens
|
||||
output: MoneyUSDPerMillionTokens
|
||||
cache?: { read?: MoneyUSDPerMillionTokens; write?: MoneyUSDPerMillionTokens }
|
||||
cache?: { read?: MoneyUSDPerMillionTokens | null; write?: MoneyUSDPerMillionTokens | null } | null
|
||||
}>
|
||||
disabled?: boolean
|
||||
limit?: { context?: number; input?: number; output?: number }
|
||||
| null
|
||||
disabled?: boolean | null
|
||||
limit?: { context?: number | null; input?: number | null; output?: number | null } | null
|
||||
}
|
||||
}
|
||||
} | null
|
||||
}
|
||||
}
|
||||
} | null
|
||||
experimental?: {
|
||||
subagent_depth?: number
|
||||
policies?: Array<{ action: "provider.use"; resource: string; effect: "allow" | "deny" }>
|
||||
}
|
||||
subagent_depth?: number | null
|
||||
policies?: Array<{ action: "provider.use"; resource: string; effect: "allow" | "deny" }> | null
|
||||
} | null
|
||||
}
|
||||
}
|
||||
| { type: "directory"; path: string }
|
||||
@@ -1950,9 +1937,23 @@ export type IntegrationInfo = {
|
||||
connections: Array<ConnectionInfo>
|
||||
}
|
||||
|
||||
export type FormInfo = { id: string; sessionID: string; title: string; metadata?: FormMetadata; fields: FormFields }
|
||||
export type FormInfo = {
|
||||
id: string
|
||||
sessionID: string
|
||||
title: string
|
||||
coalesce?: string
|
||||
metadata?: FormMetadata
|
||||
fields: FormFields
|
||||
}
|
||||
|
||||
export type FormInfo1 = { id: string; sessionID: string; title: string; metadata?: FormMetadata1; fields: FormFields1 }
|
||||
export type FormInfo1 = {
|
||||
id: string
|
||||
sessionID: string
|
||||
title: string
|
||||
coalesce?: string
|
||||
metadata?: FormMetadata1
|
||||
fields: FormFields1
|
||||
}
|
||||
|
||||
export type SessionInputAdmitted = {
|
||||
id: string
|
||||
@@ -1994,9 +1995,6 @@ export type SessionEventDurable =
|
||||
| SessionForked
|
||||
| SessionInputPromoted
|
||||
| SessionInputAdmitted
|
||||
| SessionInputCancelled
|
||||
| SessionInputSteered
|
||||
| SessionInputQueued
|
||||
| SessionExecutionStarted
|
||||
| SessionExecutionSucceeded
|
||||
| SessionExecutionFailed
|
||||
@@ -2049,9 +2047,6 @@ export type V2Event =
|
||||
| SessionForked
|
||||
| SessionInputPromoted
|
||||
| SessionInputAdmitted
|
||||
| SessionInputCancelled
|
||||
| SessionInputSteered
|
||||
| SessionInputQueued
|
||||
| SessionExecutionStarted
|
||||
| SessionExecutionSucceeded
|
||||
| SessionExecutionFailed
|
||||
@@ -2970,27 +2965,6 @@ 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"]
|
||||
@@ -3290,28 +3264,41 @@ export type McpAddInput = {
|
||||
| {
|
||||
readonly type: "local"
|
||||
readonly command: ReadonlyArray<string>
|
||||
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 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 type: "remote"
|
||||
readonly url: string
|
||||
readonly headers?: { readonly [x: string]: string }
|
||||
readonly headers?: { readonly [x: string]: string } | undefined
|
||||
readonly oauth?:
|
||||
| {
|
||||
readonly client_id?: string
|
||||
readonly client_secret?: string
|
||||
readonly scope?: string
|
||||
readonly callback_port?: number
|
||||
readonly redirect_uri?: string
|
||||
readonly client_id?: string | undefined
|
||||
readonly client_secret?: string | undefined
|
||||
readonly scope?: string | undefined
|
||||
readonly callback_port?: number | undefined
|
||||
readonly redirect_uri?: string | undefined
|
||||
}
|
||||
| false
|
||||
readonly disabled?: boolean
|
||||
readonly codemode?: boolean
|
||||
readonly timeout?: { readonly startup?: number; readonly catalog?: number; readonly execution?: number }
|
||||
| undefined
|
||||
readonly disabled?: boolean | undefined
|
||||
readonly codemode?: boolean | undefined
|
||||
readonly timeout?:
|
||||
| {
|
||||
readonly startup?: number | undefined
|
||||
readonly catalog?: number | undefined
|
||||
readonly execution?: number | undefined
|
||||
}
|
||||
| undefined
|
||||
}
|
||||
}["config"]
|
||||
}
|
||||
|
||||
@@ -32,7 +32,6 @@ test("exposes every standard HTTP API group", () => {
|
||||
"projectCopy",
|
||||
"vcs",
|
||||
"debug",
|
||||
"migration",
|
||||
"websearch",
|
||||
"config",
|
||||
])
|
||||
@@ -357,28 +356,6 @@ 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",
|
||||
|
||||
@@ -233,13 +233,13 @@ export const layer = (options?: Options) => Layer.effect(
|
||||
|
||||
const supplementary = yield* Effect.forEach(directories, loadDirectory).pipe(Effect.orDie)
|
||||
return [
|
||||
...(yield* loadWellknown().pipe(Effect.orDie)),
|
||||
...claude,
|
||||
...agents,
|
||||
...(supplementary[0] ?? []),
|
||||
...explicit,
|
||||
...direct,
|
||||
...supplementary.slice(1).flat(),
|
||||
...(yield* loadWellknown().pipe(Effect.orDie)),
|
||||
...content,
|
||||
]
|
||||
})
|
||||
|
||||
@@ -132,6 +132,7 @@ export const layer = Layer.effect(
|
||||
id,
|
||||
sessionID: input.sessionID,
|
||||
title: input.title,
|
||||
...(input.coalesce === undefined ? {} : { coalesce: input.coalesce }),
|
||||
...(input.metadata === undefined ? {} : { metadata: input.metadata }),
|
||||
fields: input.fields,
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ export * as PluginPromise from "./promise"
|
||||
|
||||
import { define } from "@opencode-ai/plugin/effect/plugin"
|
||||
import type { Context, Plugin } from "@opencode-ai/plugin/promise/plugin"
|
||||
import type { SessionHooks, SessionHttp, SessionHttpMiddleware } from "@opencode-ai/plugin/promise/session"
|
||||
import type { Info } from "@opencode-ai/plugin/promise/tool"
|
||||
import { Agent } from "@opencode-ai/schema/agent"
|
||||
import { Integration } from "@opencode-ai/schema/integration"
|
||||
@@ -57,6 +58,62 @@ export function fromPromise(plugin: Plugin) {
|
||||
}),
|
||||
)
|
||||
|
||||
function sessionHook<Name extends keyof SessionHooks>(
|
||||
name: Name,
|
||||
callback: (event: SessionHooks[Name]) => Promise<void> | void,
|
||||
): Promise<Registration>
|
||||
function sessionHook(
|
||||
...registration: {
|
||||
[Name in keyof SessionHooks]: [
|
||||
name: Name,
|
||||
callback: (event: SessionHooks[Name]) => Promise<void> | void,
|
||||
]
|
||||
}[keyof SessionHooks]
|
||||
) {
|
||||
if (registration[0] !== "http")
|
||||
return register(
|
||||
host.session.hook(registration[0], (event) =>
|
||||
Effect.promise(() => Promise.resolve(registration[1](event))),
|
||||
),
|
||||
)
|
||||
return register(
|
||||
host.session.hook("http", (event) => {
|
||||
const middlewares: SessionHttpMiddleware[] = []
|
||||
const output: SessionHttp = {
|
||||
...event,
|
||||
use: (item) => {
|
||||
middlewares.push(item)
|
||||
},
|
||||
}
|
||||
return Effect.promise(() => Promise.resolve(registration[1](output))).pipe(
|
||||
Effect.flatMap(() =>
|
||||
Effect.forEach(
|
||||
middlewares,
|
||||
(item) =>
|
||||
event.use((input, next) =>
|
||||
Effect.tryPromise({
|
||||
try: (signal) => {
|
||||
const inputSignal = AbortSignal.any([signal, input.signal])
|
||||
return Promise.resolve(
|
||||
item(new Request(input, { signal: inputSignal }), (request) => {
|
||||
const requestSignal = AbortSignal.any([signal, request.signal])
|
||||
return Effect.runPromiseWith(
|
||||
context,
|
||||
)(next(new Request(request, { signal: requestSignal })), { signal: requestSignal })
|
||||
}),
|
||||
)
|
||||
},
|
||||
catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))),
|
||||
}),
|
||||
),
|
||||
{ discard: true },
|
||||
),
|
||||
),
|
||||
)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
const context2: Context = {
|
||||
app: host.app,
|
||||
options: host.options,
|
||||
@@ -265,8 +322,7 @@ export function fromPromise(plugin: Plugin) {
|
||||
),
|
||||
},
|
||||
session: {
|
||||
hook: (name, callback) =>
|
||||
register(host.session.hook(name, (event) => Effect.promise(() => Promise.resolve(callback(event))))),
|
||||
hook: sessionHook,
|
||||
create: (input) =>
|
||||
run(
|
||||
host.session.create(
|
||||
|
||||
@@ -221,18 +221,18 @@ export const OpenAIPlugin = define({
|
||||
}
|
||||
draft.cost = []
|
||||
// Match Codex CLI so context consumption and subscription usage stay consistent between clients.
|
||||
draft.limit = { ...draft.limit, context: 400_000, input: 272_000 }
|
||||
draft.limit = { ...draft.limit, context: 272_000, input: 272_000 }
|
||||
})
|
||||
}
|
||||
})
|
||||
yield* ctx.session.hook("http.request", (evt) =>
|
||||
Effect.sync(() => {
|
||||
if (!chatgpt || evt.model.providerID !== Provider.ID.openai) return
|
||||
const url = new URL(evt.request.url)
|
||||
evt.request.headers.set("originator", "opencode")
|
||||
evt.request.headers.set("session-id", evt.sessionID)
|
||||
if (url.origin !== "https://api.openai.com") return
|
||||
evt.request = new Request(`${codexBaseURL}${url.pathname.replace(/^\/v1/, "")}${url.search}`, evt.request)
|
||||
yield* ctx.session.hook("http", (evt) =>
|
||||
evt.use((request, next) => {
|
||||
if (!chatgpt || evt.model.providerID !== Provider.ID.openai) return next(request)
|
||||
const url = new URL(request.url)
|
||||
request.headers.set("originator", "opencode")
|
||||
request.headers.set("session-id", evt.sessionID)
|
||||
if (url.origin !== "https://api.openai.com") return next(request)
|
||||
return next(new Request(`${codexBaseURL}${url.pathname.replace(/^\/v1/, "")}${url.search}`, request))
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -133,14 +133,6 @@ 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,
|
||||
}) {}
|
||||
@@ -189,9 +181,6 @@ 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
|
||||
@@ -329,28 +318,6 @@ 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()
|
||||
@@ -540,9 +507,6 @@ 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
|
||||
|
||||
@@ -20,7 +20,7 @@ import type { Info } from "../model"
|
||||
import { SessionUsage } from "./usage"
|
||||
|
||||
const DEFAULT_BUFFER = 20_000
|
||||
const DEFAULT_KEEP_TOKENS = 15_000
|
||||
const DEFAULT_KEEP_TOKENS = 8_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,9 +90,6 @@ 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,
|
||||
|
||||
@@ -2,6 +2,7 @@ export * as SessionModelRequest from "./model-request"
|
||||
|
||||
import { LLM, Message, SystemPart, type LLMRequest } from "@opencode-ai/ai"
|
||||
import type { StreamOptions } from "@opencode-ai/ai/route"
|
||||
import type { SessionHttpHandler, SessionHttpMiddleware } from "@opencode-ai/plugin/effect/session"
|
||||
import type { Content } from "@opencode-ai/schema/tool"
|
||||
import { SessionError } from "@opencode-ai/schema/session-error"
|
||||
import { Cause, Config, Context, Effect, Layer, Result, Stream } from "effect"
|
||||
@@ -229,31 +230,44 @@ export const layer = Layer.effect(
|
||||
const options: StreamOptions = {
|
||||
http: (request, handler) =>
|
||||
Effect.gen(function* () {
|
||||
const before = yield* hooks.trigger("session", "http.request", {
|
||||
let latest = request
|
||||
const origins = new WeakMap<Response, HttpClientRequest.HttpClientRequest>()
|
||||
const middlewares: SessionHttpMiddleware[] = []
|
||||
const web = yield* HttpClientRequest.toWeb(request)
|
||||
yield* hooks.trigger("session", "http", {
|
||||
sessionID: session.id,
|
||||
agent: agent.id,
|
||||
model: resolved.ref,
|
||||
request: yield* HttpClientRequest.toWeb(request),
|
||||
use: (item) =>
|
||||
Effect.sync(() => {
|
||||
middlewares.push(item)
|
||||
}),
|
||||
})
|
||||
let sent = HttpClientRequest.fromWeb(before.request)
|
||||
if (before.request.body)
|
||||
sent = HttpClientRequest.bodyUint8Array(
|
||||
sent,
|
||||
new Uint8Array(yield* Effect.promise(() => before.request.clone().arrayBuffer())),
|
||||
before.request.headers.get("content-type") ?? undefined,
|
||||
)
|
||||
const response = yield* handler(sent)
|
||||
const after = yield* hooks.trigger("session", "http.response", {
|
||||
sessionID: session.id,
|
||||
agent: agent.id,
|
||||
model: resolved.ref,
|
||||
request: before.request,
|
||||
response: new Response(
|
||||
[204, 205, 304].includes(response.status) ? null : yield* Stream.toReadableStreamEffect(response.stream),
|
||||
{ status: response.status, headers: response.headers },
|
||||
),
|
||||
})
|
||||
return HttpClientResponse.fromWeb(sent, after.response)
|
||||
const send = (input: Request) =>
|
||||
Effect.gen(function* () {
|
||||
let sent = HttpClientRequest.fromWeb(input)
|
||||
if (input.body)
|
||||
sent = HttpClientRequest.bodyUint8Array(
|
||||
sent,
|
||||
new Uint8Array(yield* Effect.promise(() => input.clone().arrayBuffer())),
|
||||
input.headers.get("content-type") ?? undefined,
|
||||
)
|
||||
latest = sent
|
||||
const response = yield* handler(sent)
|
||||
const body = [204, 205, 304].includes(response.status)
|
||||
? null
|
||||
: yield* Stream.toReadableStreamEffect(response.stream)
|
||||
const output = new Response(body, { status: response.status, headers: response.headers })
|
||||
origins.set(output, sent)
|
||||
return output
|
||||
})
|
||||
const dispatch = middlewares.reduce<SessionHttpHandler>(
|
||||
(next, item) => (input: Request) => item(input, next),
|
||||
send,
|
||||
)
|
||||
const response = yield* dispatch(web)
|
||||
const origin = origins.get(response) ?? latest
|
||||
return HttpClientResponse.fromWeb(origin, response)
|
||||
}).pipe(Effect.mapError((cause) => (cause instanceof Error ? cause : new Error(String(cause))))),
|
||||
}
|
||||
if (promptCacheSnapshots) {
|
||||
|
||||
@@ -312,63 +312,6 @@ 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 },
|
||||
@@ -446,42 +389,6 @@ 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,
|
||||
|
||||
@@ -485,24 +485,6 @@ 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)
|
||||
|
||||
@@ -59,6 +59,7 @@ export const Plugin = {
|
||||
const response = yield* forms.ask({
|
||||
sessionID: context.sessionID,
|
||||
title: "Web Search",
|
||||
coalesce: `${context.messageID}:websearch-consent`,
|
||||
metadata: { kind: "websearch.provider" },
|
||||
fields: [
|
||||
{
|
||||
@@ -91,6 +92,7 @@ export const Plugin = {
|
||||
? yield* forms.ask({
|
||||
sessionID: context.sessionID,
|
||||
title: "Choose a web search provider",
|
||||
coalesce: `${context.messageID}:websearch-provider`,
|
||||
metadata: { kind: "websearch.provider" },
|
||||
fields: [
|
||||
{
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
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"
|
||||
@@ -13,12 +10,6 @@ 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",
|
||||
@@ -57,46 +48,42 @@ export function isV1(input: unknown) {
|
||||
}
|
||||
|
||||
export function migrate(info: typeof ConfigV1.Info.Type) {
|
||||
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),
|
||||
}),
|
||||
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] },
|
||||
),
|
||||
)
|
||||
providers: providers(info.provider),
|
||||
}
|
||||
}
|
||||
|
||||
function experimental(info: typeof ConfigV1.Info.Type) {
|
||||
@@ -167,22 +154,18 @@ export function migrateAgent(info: ConfigAgentV1.Info) {
|
||||
...(info.temperature === undefined ? {} : { temperature: info.temperature }),
|
||||
...(info.top_p === undefined ? {} : { top_p: info.top_p }),
|
||||
}
|
||||
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),
|
||||
}),
|
||||
),
|
||||
)
|
||||
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),
|
||||
}
|
||||
}
|
||||
|
||||
function commands(info?: Readonly<Record<string, ConfigCommandV1.Info>>) {
|
||||
|
||||
@@ -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()
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -15,6 +15,7 @@ const input = {
|
||||
id: formID,
|
||||
sessionID: SessionSchema.ID.make("ses_test"),
|
||||
title: "Test form",
|
||||
coalesce: "test-form",
|
||||
fields: [{ key: "name", type: "string", required: true }],
|
||||
} satisfies Form.CreateInput
|
||||
|
||||
@@ -32,6 +33,7 @@ describe("Form", () => {
|
||||
yield* Effect.addFinalizer(() => unsubscribe)
|
||||
const fiber = yield* service.ask(input).pipe(Effect.forkScoped)
|
||||
const form = yield* Deferred.await(created)
|
||||
expect(form.coalesce).toBe("test-form")
|
||||
|
||||
yield* service.cancel(form.id)
|
||||
|
||||
|
||||
@@ -151,9 +151,6 @@ describe("ModelResolver", () => {
|
||||
http: { body: { custom_extension: { enabled: true } } },
|
||||
},
|
||||
})
|
||||
const prepared = yield* compileRequest(LLM.request({ model: resolved, prompt: "Hello" }))
|
||||
expect(prepared.body.max_output_tokens).toBeUndefined()
|
||||
expect(JSON.stringify(prepared.body)).not.toContain("max_output_tokens")
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Message, SystemPart } from "@opencode-ai/ai"
|
||||
import { DateTime, Effect, Schema } from "effect"
|
||||
import { DateTime, Deferred, Effect, Fiber, Schema } from "effect"
|
||||
import { Agent } from "@opencode-ai/core/agent"
|
||||
import { Catalog } from "@opencode-ai/core/catalog"
|
||||
import { Model } from "@opencode-ai/core/model"
|
||||
@@ -15,7 +15,7 @@ import { SessionPending } from "@opencode-ai/core/session/pending"
|
||||
import { Tool } from "@opencode-ai/core/tool"
|
||||
import { Provider } from "@opencode-ai/core/provider"
|
||||
import { define } from "@opencode-ai/plugin/promise/plugin"
|
||||
import type { SessionHooks } from "@opencode-ai/plugin/effect/session"
|
||||
import type { SessionHooks, SessionHttpHandler } from "@opencode-ai/plugin/effect/session"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { PluginTestLayer } from "./fixture"
|
||||
import { host as testHost } from "./host"
|
||||
@@ -223,45 +223,102 @@ describe("fromPromise", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("adapts promise session HTTP request and response hooks", () =>
|
||||
it.effect("adapts promise session HTTP hooks", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* Plugin.Service
|
||||
const hooks = yield* PluginHooks.Service
|
||||
const host = yield* PluginHost.make(plugin)
|
||||
const bodies: string[] = []
|
||||
yield* PluginPromise.fromPromise(
|
||||
define({
|
||||
id: "promise-session-http",
|
||||
setup: async (ctx) => {
|
||||
await ctx.session.hook("http", (event) => {
|
||||
event.use(async (request, next) => {
|
||||
request.headers.set("x-hook", "promise")
|
||||
await next(request)
|
||||
const response = await next(request)
|
||||
return new Response(`${await response.text()}-response`)
|
||||
})
|
||||
})
|
||||
await ctx.session.hook("http", (event) => {
|
||||
event.use(async (request, next) => {
|
||||
const response = await next(request)
|
||||
return new Response(`${await response.text()}-outer`)
|
||||
})
|
||||
})
|
||||
},
|
||||
}),
|
||||
).effect(host)
|
||||
const middlewares: Parameters<PluginHooks.Domains["session"]["http"]["use"]>[0][] = []
|
||||
const event: PluginHooks.Domains["session"]["http"] = {
|
||||
sessionID: Session.ID.make("ses_promise_session_http"),
|
||||
agent: Agent.ID.make("build"),
|
||||
model: Model.Ref.make({ providerID: Provider.ID.make("test"), id: Model.ID.make("model") }),
|
||||
use: (item) =>
|
||||
Effect.sync(() => {
|
||||
middlewares.push(item)
|
||||
}),
|
||||
}
|
||||
|
||||
yield* hooks.trigger("session", "http", event)
|
||||
const request = middlewares.reduce<SessionHttpHandler>(
|
||||
(next, item) => (input: Request) => item(input, next),
|
||||
(input: Request) =>
|
||||
Effect.promise(() => input.text()).pipe(
|
||||
Effect.tap((body) => Effect.sync(() => bodies.push(body))),
|
||||
Effect.as(new Response(input.headers.get("x-hook") ?? "missing")),
|
||||
),
|
||||
)
|
||||
const response = yield* request(new Request("https://provider.test", { method: "POST", body: "payload" }))
|
||||
|
||||
expect(bodies).toEqual(["payload", "payload"])
|
||||
expect(yield* Effect.promise(() => response.text())).toBe("promise-response-outer")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("interrupts the Effect request through a promise session HTTP hook", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* Plugin.Service
|
||||
const hooks = yield* PluginHooks.Service
|
||||
const host = yield* PluginHost.make(plugin)
|
||||
yield* PluginPromise.fromPromise(
|
||||
define({
|
||||
id: "promise-session-http",
|
||||
id: "promise-session-http-interrupt",
|
||||
setup: async (ctx) => {
|
||||
await ctx.session.hook("http.request", (event) => {
|
||||
event.request = new Request("https://provider.test/changed", event.request)
|
||||
event.request.headers.set("x-hook", "promise")
|
||||
})
|
||||
await ctx.session.hook("http.response", async (event) => {
|
||||
event.response = new Response(`${await event.response.text()}-response`, {
|
||||
status: event.response.status,
|
||||
})
|
||||
await ctx.session.hook("http", (event) => {
|
||||
event.use((request, next) => next(request))
|
||||
})
|
||||
},
|
||||
}),
|
||||
).effect(host)
|
||||
const context = {
|
||||
sessionID: Session.ID.make("ses_promise_session_http"),
|
||||
const started = yield* Deferred.make<void>()
|
||||
const interrupted = yield* Deferred.make<void>()
|
||||
const middlewares: Parameters<PluginHooks.Domains["session"]["http"]["use"]>[0][] = []
|
||||
const event: PluginHooks.Domains["session"]["http"] = {
|
||||
sessionID: Session.ID.make("ses_promise_session_http_interrupt"),
|
||||
agent: Agent.ID.make("build"),
|
||||
model: Model.Ref.make({ providerID: Provider.ID.make("test"), id: Model.ID.make("model") }),
|
||||
use: (item) =>
|
||||
Effect.sync(() => {
|
||||
middlewares.push(item)
|
||||
}),
|
||||
}
|
||||
|
||||
const request = yield* hooks.trigger("session", "http.request", {
|
||||
...context,
|
||||
request: new Request("https://provider.test", { method: "POST", body: "payload" }),
|
||||
})
|
||||
const response = yield* hooks.trigger("session", "http.response", {
|
||||
...context,
|
||||
request: request.request,
|
||||
response: new Response(request.request.headers.get("x-hook") ?? "missing"),
|
||||
})
|
||||
yield* hooks.trigger("session", "http", event)
|
||||
const request = middlewares.reduce<SessionHttpHandler>(
|
||||
(next, item) => (input: Request) => item(input, next),
|
||||
() =>
|
||||
Deferred.succeed(started, undefined).pipe(
|
||||
Effect.andThen(Effect.never),
|
||||
Effect.onInterrupt(() => Deferred.succeed(interrupted, undefined)),
|
||||
),
|
||||
)
|
||||
const fiber = yield* request(new Request("https://provider.test")).pipe(Effect.forkChild)
|
||||
yield* Deferred.await(started)
|
||||
yield* Fiber.interrupt(fiber)
|
||||
|
||||
expect(request.request.url).toBe("https://provider.test/changed")
|
||||
expect(yield* Effect.promise(() => response.response.text())).toBe("promise-response")
|
||||
expect(yield* Deferred.isDone(interrupted)).toBeTrue()
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import { PluginHost } from "@opencode-ai/core/plugin/host"
|
||||
import { PluginHooks } from "@opencode-ai/core/plugin/hooks"
|
||||
import { OpenAIPlugin } from "@opencode-ai/core/plugin/provider/openai"
|
||||
import { Provider } from "@opencode-ai/core/provider"
|
||||
import type { SessionHttpHandler } from "@opencode-ai/plugin/effect/session"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { PluginTestLayer } from "./fixture"
|
||||
|
||||
@@ -30,13 +31,26 @@ function required<T>(value: T | undefined): T {
|
||||
}
|
||||
|
||||
const http = Effect.fn(function* (providerID: Provider.ID, url: string) {
|
||||
const event = yield* (yield* PluginHooks.Service).trigger("session", "http.request", {
|
||||
const middlewares: Parameters<PluginHooks.Domains["session"]["http"]["use"]>[0][] = []
|
||||
yield* (yield* PluginHooks.Service).trigger("session", "http", {
|
||||
sessionID: Session.ID.make("ses_test"),
|
||||
agent: Agent.ID.make("build"),
|
||||
model: Model.Ref.make({ providerID, id: Model.ID.make("gpt-5.5") }),
|
||||
request: new Request(url, { method: "POST", body: "{}" }),
|
||||
use: (item) =>
|
||||
Effect.sync(() => {
|
||||
middlewares.push(item)
|
||||
}),
|
||||
})
|
||||
return { url: event.request.url, headers: Object.fromEntries(event.request.headers.entries()) }
|
||||
const request = middlewares.reduce<SessionHttpHandler>(
|
||||
(next, item) => (input: Request) => item(input, next),
|
||||
(input: Request) => {
|
||||
const headers = new Headers(input.headers)
|
||||
headers.set("x-seen-url", input.url)
|
||||
return Effect.succeed(new Response(null, { headers }))
|
||||
},
|
||||
)
|
||||
const response = yield* request(new Request(url, { method: "POST", body: "{}" }))
|
||||
return { url: response.headers.get("x-seen-url"), headers: Object.fromEntries(response.headers.entries()) }
|
||||
})
|
||||
|
||||
describe("OpenAIPlugin", () => {
|
||||
@@ -126,7 +140,7 @@ describe("OpenAIPlugin", () => {
|
||||
const eligible = required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.5")))
|
||||
expect(eligible.package).toBe("@opencode-ai/ai/providers/openai")
|
||||
expect(eligible.cost).toEqual([])
|
||||
expect(eligible.limit).toEqual({ context: 400_000, input: 272_000, output: 128_000 })
|
||||
expect(eligible.limit).toEqual({ context: 272_000, input: 272_000, output: 128_000 })
|
||||
expect(eligible.enabled).toBe(true)
|
||||
expect(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.5-pro"))).enabled).toBe(
|
||||
false,
|
||||
@@ -135,14 +149,14 @@ describe("OpenAIPlugin", () => {
|
||||
false,
|
||||
)
|
||||
expect(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.4"))).limit).toEqual({
|
||||
context: 400_000,
|
||||
context: 272_000,
|
||||
input: 272_000,
|
||||
output: 64_000,
|
||||
})
|
||||
expect(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.6"))).enabled).toBe(false)
|
||||
const gpt56 = required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.6-sol")))
|
||||
expect(gpt56.enabled).toBe(true)
|
||||
expect(gpt56.limit).toEqual({ context: 400_000, input: 272_000, output: 128_000 })
|
||||
expect(gpt56.limit).toEqual({ context: 272_000, input: 272_000, output: 128_000 })
|
||||
expect(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-4.1"))).enabled).toBe(false)
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -1086,78 +1086,4 @@ 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)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -255,7 +255,6 @@ describe("SessionRunnerLLM recorded", () => {
|
||||
describe("SessionModelRequest HTTP bridge", () => {
|
||||
const bodies: Uint8Array[] = []
|
||||
const methods: string[] = []
|
||||
const headers: Array<string | undefined> = []
|
||||
const response = [
|
||||
'data: {"id":"chatcmpl_test","object":"chat.completion.chunk","created":0,"model":"gpt-4o-mini","choices":[{"index":0,"delta":{"role":"assistant","content":"Hello!"},"finish_reason":null}]}',
|
||||
'data: {"id":"chatcmpl_test","object":"chat.completion.chunk","created":0,"model":"gpt-4o-mini","choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}',
|
||||
@@ -269,7 +268,6 @@ describe("SessionModelRequest HTTP bridge", () => {
|
||||
if (request.body._tag !== "Uint8Array") throw new Error(`Unexpected request body: ${request.body._tag}`)
|
||||
methods.push(request.method)
|
||||
bodies.push(request.body.body.slice())
|
||||
headers.push(request.headers["x-hook"])
|
||||
return HttpClientResponse.fromWeb(
|
||||
request,
|
||||
new Response(response, { headers: { "content-type": "text/event-stream" } }),
|
||||
@@ -277,16 +275,14 @@ describe("SessionModelRequest HTTP bridge", () => {
|
||||
}),
|
||||
),
|
||||
)
|
||||
const httpIt = testEffect(
|
||||
const retryIt = testEffect(
|
||||
testLayer(LLMClient.layer.pipe(Layer.provide(RequestExecutor.layer.pipe(Layer.provide(transport))))),
|
||||
)
|
||||
|
||||
httpIt.effect("runs Effect HTTP request and response hooks around one provider request", () =>
|
||||
retryIt.effect("lets an Effect plugin send the same POST Request twice", () =>
|
||||
Effect.gen(function* () {
|
||||
bodies.length = 0
|
||||
methods.length = 0
|
||||
headers.length = 0
|
||||
const seen: string[] = []
|
||||
const agents = yield* Agent.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
const hooks = yield* PluginHooks.Service
|
||||
@@ -301,20 +297,13 @@ describe("SessionModelRequest HTTP bridge", () => {
|
||||
catalog: catalogHost(catalog),
|
||||
session: { hook: (name, callback) => hooks.register("session", name, callback) },
|
||||
})
|
||||
yield* pluginHost.session.hook("http.request", (event) =>
|
||||
Effect.sync(() => {
|
||||
seen.push("request")
|
||||
event.request.headers.set("x-hook", "effect")
|
||||
}),
|
||||
)
|
||||
yield* pluginHost.session.hook("http.response", (event) =>
|
||||
Effect.gen(function* () {
|
||||
seen.push(`response:${event.response.status}:${event.request.headers.get("x-hook")}`)
|
||||
event.response = new Response(
|
||||
(yield* Effect.promise(() => event.response.text())).replace("Hello!", "Hooked!"),
|
||||
event.response,
|
||||
)
|
||||
}),
|
||||
yield* pluginHost.session.hook("http", (event) =>
|
||||
event.use((request, next) =>
|
||||
Effect.gen(function* () {
|
||||
yield* next(request).pipe(Effect.flatMap((response) => Effect.promise(() => response.text())))
|
||||
return yield* next(request)
|
||||
}),
|
||||
),
|
||||
)
|
||||
yield* Effect.forEach(SystemPromptPlugin.Plugins, (plugin) => plugin.effect(pluginHost), { discard: true })
|
||||
const { db } = yield* Database.Service
|
||||
@@ -342,15 +331,10 @@ describe("SessionModelRequest HTTP bridge", () => {
|
||||
|
||||
yield* session.resume(retrySessionID)
|
||||
|
||||
expect(methods).toEqual(["POST"])
|
||||
expect(headers).toEqual(["effect"])
|
||||
expect(seen).toEqual(["request", "response:200:effect"])
|
||||
expect(bodies).toHaveLength(1)
|
||||
expect(methods).toEqual(["POST", "POST"])
|
||||
expect(bodies).toHaveLength(2)
|
||||
expect(bodies[0]?.byteLength).toBeGreaterThan(0)
|
||||
expect((yield* session.context(retrySessionID))[1]).toMatchObject({
|
||||
type: "assistant",
|
||||
content: [{ type: "text", text: "Hooked!" }],
|
||||
})
|
||||
expect(bodies[1]).toEqual(bodies[0])
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -241,6 +241,7 @@ describe("WebSearchTool registration", () => {
|
||||
{
|
||||
sessionID,
|
||||
title: "Web Search",
|
||||
coalesce: "msg_tool_test:websearch-consent",
|
||||
metadata: { kind: "websearch.provider" },
|
||||
fields: [
|
||||
{
|
||||
@@ -298,6 +299,7 @@ describe("WebSearchTool registration", () => {
|
||||
expect(formRequests[1]).toEqual({
|
||||
sessionID,
|
||||
title: "Choose a web search provider",
|
||||
coalesce: "msg_tool_test:websearch-provider",
|
||||
metadata: { kind: "websearch.provider" },
|
||||
fields: [
|
||||
{
|
||||
|
||||
@@ -44,12 +44,11 @@ export type SQLiteEffectSelectPrepare<
|
||||
TEffectHKT
|
||||
>
|
||||
|
||||
// Explicit variance prevents comparisons from recursively scanning Drizzle's conditional select types.
|
||||
export class SQLiteEffectSelectBuilder<
|
||||
out TSelection extends SelectedFields | undefined,
|
||||
out TRunResult,
|
||||
out TEffectHKT extends QueryEffectHKTBase = QueryEffectHKTBase,
|
||||
out TBuilderMode extends "db" | "qb" = "db",
|
||||
TSelection extends SelectedFields | undefined,
|
||||
TRunResult,
|
||||
TEffectHKT extends QueryEffectHKTBase = QueryEffectHKTBase,
|
||||
TBuilderMode extends "db" | "qb" = "db",
|
||||
> {
|
||||
static readonly [entityKind]: string = "SQLiteEffectSelectBuilder"
|
||||
|
||||
|
||||
@@ -303,11 +303,10 @@ export class SQLiteEffectPreparedQuery<
|
||||
}
|
||||
}
|
||||
|
||||
// Explicit variance prevents comparisons from recursively scanning the full Drizzle query-builder graph.
|
||||
export abstract class SQLiteEffectSession<
|
||||
out TEffectHKT extends QueryEffectHKTBase = QueryEffectHKTBase,
|
||||
out TRunResult = unknown,
|
||||
out TRelations extends AnyRelations = EmptyRelations,
|
||||
TEffectHKT extends QueryEffectHKTBase = QueryEffectHKTBase,
|
||||
TRunResult = unknown,
|
||||
TRelations extends AnyRelations = EmptyRelations,
|
||||
> {
|
||||
static readonly [entityKind]: string = "SQLiteEffectSession"
|
||||
|
||||
@@ -405,9 +404,9 @@ export abstract class SQLiteEffectSession<
|
||||
}
|
||||
|
||||
export abstract class SQLiteEffectTransaction<
|
||||
out TEffectHKT extends QueryEffectHKTBase,
|
||||
out TRunResult,
|
||||
out TRelations extends AnyRelations = EmptyRelations,
|
||||
TEffectHKT extends QueryEffectHKTBase,
|
||||
TRunResult,
|
||||
TRelations extends AnyRelations = EmptyRelations,
|
||||
> extends SQLiteEffectDatabase<TEffectHKT, TRunResult, TRelations> {
|
||||
static override readonly [entityKind]: string = "SQLiteEffectTransaction"
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { Message, SystemPart } from "@opencode-ai/ai"
|
||||
import type { Agent } from "@opencode-ai/schema/agent"
|
||||
import type { Model } from "@opencode-ai/schema/model"
|
||||
import type { Session } from "@opencode-ai/schema/session"
|
||||
import type { JsonSchema } from "effect"
|
||||
import type { Effect, JsonSchema } from "effect"
|
||||
import type { Hooks } from "./registration.js"
|
||||
|
||||
export interface SessionContext {
|
||||
@@ -15,25 +15,23 @@ export interface SessionContext {
|
||||
tools: Record<string, { description: string; input: JsonSchema.JsonSchema }>
|
||||
}
|
||||
|
||||
export interface SessionHttpRequest {
|
||||
export interface SessionHttp {
|
||||
readonly sessionID: Session.ID
|
||||
readonly agent: Agent.ID
|
||||
readonly model: Model.Ref
|
||||
request: Request
|
||||
readonly use: (middleware: SessionHttpMiddleware) => Effect.Effect<void>
|
||||
}
|
||||
|
||||
export interface SessionHttpResponse {
|
||||
readonly sessionID: Session.ID
|
||||
readonly agent: Agent.ID
|
||||
readonly model: Model.Ref
|
||||
readonly request: Request
|
||||
response: Response
|
||||
}
|
||||
export type SessionHttpHandler = (request: Request) => Effect.Effect<Response, Error>
|
||||
|
||||
export type SessionHttpMiddleware = (
|
||||
request: Request,
|
||||
next: SessionHttpHandler,
|
||||
) => Effect.Effect<Response, Error>
|
||||
|
||||
export interface SessionHooks {
|
||||
readonly context: SessionContext
|
||||
readonly "http.request": SessionHttpRequest
|
||||
readonly "http.response": SessionHttpResponse
|
||||
readonly http: SessionHttp
|
||||
}
|
||||
|
||||
export type SessionDomain = Pick<
|
||||
|
||||
@@ -15,25 +15,23 @@ export interface SessionContext {
|
||||
tools: Record<string, { description: string; input: JsonSchema.JsonSchema }>
|
||||
}
|
||||
|
||||
export interface SessionHttpRequest {
|
||||
export interface SessionHttp {
|
||||
readonly sessionID: Session.ID
|
||||
readonly agent: Agent.ID
|
||||
readonly model: Model.Ref
|
||||
request: Request
|
||||
readonly use: (middleware: SessionHttpMiddleware) => void
|
||||
}
|
||||
|
||||
export interface SessionHttpResponse {
|
||||
readonly sessionID: Session.ID
|
||||
readonly agent: Agent.ID
|
||||
readonly model: Model.Ref
|
||||
readonly request: Request
|
||||
response: Response
|
||||
}
|
||||
export type SessionHttpHandler = (request: Request) => Promise<Response>
|
||||
|
||||
export type SessionHttpMiddleware = (
|
||||
request: Request,
|
||||
next: SessionHttpHandler,
|
||||
) => Promise<Response> | Response
|
||||
|
||||
export interface SessionHooks {
|
||||
readonly context: SessionContext
|
||||
readonly "http.request": SessionHttpRequest
|
||||
readonly "http.response": SessionHttpResponse
|
||||
readonly http: SessionHttp
|
||||
}
|
||||
|
||||
export type SessionDomain = Pick<
|
||||
|
||||
+1539
-690
File diff suppressed because it is too large
Load Diff
@@ -491,45 +491,6 @@ 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 },
|
||||
|
||||
@@ -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, optional } from "./schema.js"
|
||||
import { AbsolutePath } 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: optional(Schema.String).annotate({
|
||||
$schema: Schema.optional(Schema.String).annotate({
|
||||
description: "JSON schema reference for configuration validation",
|
||||
}),
|
||||
shell: Schema.String.pipe(optional).annotate({
|
||||
shell: Schema.String.pipe(Schema.optional).annotate({
|
||||
description: "Default shell to use for terminal and shell tool execution",
|
||||
}),
|
||||
model: ConfigModel.Selection.pipe(optional).annotate({
|
||||
model: ConfigModel.Selection.pipe(Schema.optional).annotate({
|
||||
description: "Default model to use when no session or agent model is selected",
|
||||
}),
|
||||
default_agent: Schema.String.pipe(optional).annotate({
|
||||
default_agent: Schema.String.pipe(Schema.optional).annotate({
|
||||
description: "Default primary agent to use when no session agent is selected",
|
||||
}),
|
||||
autoupdate: Schema.Union([Schema.Boolean, Schema.Literal("notify")])
|
||||
.pipe(optional)
|
||||
.pipe(Schema.optional)
|
||||
.annotate({
|
||||
description: "Automatically update or notify when a new version is available",
|
||||
}),
|
||||
share: Schema.Literals(["manual", "auto", "disabled"]).pipe(optional).annotate({
|
||||
share: Schema.Literals(["manual", "auto", "disabled"]).pipe(Schema.optional).annotate({
|
||||
description: "Control whether sessions may be shared manually, automatically, or not at all",
|
||||
}),
|
||||
enterprise: Schema.Struct({
|
||||
url: Schema.String.pipe(optional),
|
||||
url: Schema.String.pipe(Schema.optional),
|
||||
})
|
||||
.pipe(optional)
|
||||
.pipe(Schema.optional)
|
||||
.annotate({
|
||||
description: "Enterprise sharing service configuration",
|
||||
}),
|
||||
username: Schema.String.pipe(optional).annotate({
|
||||
username: Schema.String.pipe(Schema.optional).annotate({
|
||||
description: "Username displayed in conversations and used for telemetry identity",
|
||||
}),
|
||||
permissions: Permission.Ruleset.pipe(optional).annotate({
|
||||
permissions: Permission.Ruleset.pipe(Schema.optional).annotate({
|
||||
description: "Ordered tool permission rules applied to agent tool use",
|
||||
}),
|
||||
agents: Schema.Record(Schema.String, ConfigAgent.Info).pipe(optional).annotate({
|
||||
agents: Schema.Record(Schema.String, ConfigAgent.Info).pipe(Schema.optional).annotate({
|
||||
description: "Named built-in agent overrides and custom agent definitions",
|
||||
}),
|
||||
snapshots: Schema.Boolean.pipe(optional).annotate({
|
||||
snapshots: Schema.Boolean.pipe(Schema.optional).annotate({
|
||||
description: "Enable snapshots used for undo and revert behavior",
|
||||
}),
|
||||
watcher: ConfigWatcher.Info.pipe(optional).annotate({
|
||||
watcher: ConfigWatcher.Info.pipe(Schema.optional).annotate({
|
||||
description: "Filesystem watcher configuration",
|
||||
}),
|
||||
formatter: ConfigFormatter.Info.pipe(optional).annotate({
|
||||
formatter: ConfigFormatter.Info.pipe(Schema.optional).annotate({
|
||||
description: "Enable built-in formatters or configure formatter overrides",
|
||||
}),
|
||||
lsp: ConfigLSP.Info.pipe(optional).annotate({
|
||||
lsp: ConfigLSP.Info.pipe(Schema.optional).annotate({
|
||||
description: "Enable built-in language servers or configure server overrides",
|
||||
}),
|
||||
media: ConfigMedia.Info.pipe(optional).annotate({
|
||||
media: ConfigMedia.Info.pipe(Schema.optional).annotate({
|
||||
description: "Media processing configuration",
|
||||
}),
|
||||
tool_output: ConfigToolOutput.Info.pipe(optional).annotate({
|
||||
tool_output: ConfigToolOutput.Info.pipe(Schema.optional).annotate({
|
||||
description: "Tool output truncation thresholds",
|
||||
}),
|
||||
mcp: ConfigMCP.Info.pipe(optional).annotate({
|
||||
mcp: ConfigMCP.Info.pipe(Schema.optional).annotate({
|
||||
description: "MCP server configuration",
|
||||
}),
|
||||
compaction: ConfigCompaction.Info.pipe(optional).annotate({
|
||||
compaction: ConfigCompaction.Info.pipe(Schema.optional).annotate({
|
||||
description: "Conversation compaction behavior",
|
||||
}),
|
||||
skills: Schema.String.pipe(Schema.Array, optional).annotate({
|
||||
skills: Schema.String.pipe(Schema.Array, Schema.optional).annotate({
|
||||
description: "Additional paths or URLs to discover skills from",
|
||||
}),
|
||||
commands: Schema.Record(Schema.String, ConfigCommand.Info).pipe(optional).annotate({
|
||||
commands: Schema.Record(Schema.String, ConfigCommand.Info).pipe(Schema.optional).annotate({
|
||||
description: "Named slash command definitions",
|
||||
}),
|
||||
instructions: Schema.String.pipe(Schema.Array, optional).annotate({
|
||||
instructions: Schema.String.pipe(Schema.Array, Schema.optional).annotate({
|
||||
description: "Additional paths or URLs supplying ambient instructions",
|
||||
}),
|
||||
references: ConfigReference.Info.pipe(optional).annotate({
|
||||
references: ConfigReference.Info.pipe(Schema.optional).annotate({
|
||||
description: "Named local directories or Git repositories available as external context",
|
||||
}),
|
||||
websearch: ConfigWebSearch.Info.pipe(optional).annotate({
|
||||
websearch: ConfigWebSearch.Info.pipe(Schema.optional).annotate({
|
||||
description: "Web search provider selection",
|
||||
}),
|
||||
plugins: ConfigPlugin.Plugins.pipe(optional).annotate({
|
||||
plugins: ConfigPlugin.Plugins.pipe(Schema.optional).annotate({
|
||||
description: "Ordered plugin enablement directives and external package declarations",
|
||||
}),
|
||||
warming: ConfigWarming.Warming.pipe(optional).annotate({
|
||||
warming: ConfigWarming.Warming.pipe(Schema.optional).annotate({
|
||||
description: "Keep recently active sessions warm with transient model requests (default: false)",
|
||||
}),
|
||||
providers: Schema.Record(Schema.String, ConfigProvider.Info).pipe(optional),
|
||||
experimental: ConfigExperimental.Info.pipe(optional),
|
||||
providers: Schema.Record(Schema.String, ConfigProvider.Info).pipe(Schema.optional),
|
||||
experimental: ConfigExperimental.Info.pipe(Schema.optional),
|
||||
}) {}
|
||||
|
||||
export class Document extends Schema.Class<Document>("Config.Document")({
|
||||
type: Schema.Literal("document"),
|
||||
path: Schema.String.pipe(optional),
|
||||
path: Schema.String.pipe(Schema.optional),
|
||||
info: Info,
|
||||
}) {}
|
||||
|
||||
|
||||
@@ -2,21 +2,21 @@ export * as ConfigAgent from "./agent.js"
|
||||
|
||||
import { Schema } from "effect"
|
||||
import { Permission } from "../permission.js"
|
||||
import { optional, PositiveInt } from "../schema.js"
|
||||
import { 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(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),
|
||||
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),
|
||||
}) {}
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
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(optional),
|
||||
agent: Schema.String.pipe(optional),
|
||||
model: ConfigModel.Selection.pipe(optional),
|
||||
subtask: Schema.Boolean.pipe(optional),
|
||||
description: Schema.String.pipe(Schema.optional),
|
||||
agent: Schema.String.pipe(Schema.optional),
|
||||
model: ConfigModel.Selection.pipe(Schema.optional),
|
||||
subtask: Schema.Boolean.pipe(Schema.optional),
|
||||
}) {}
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
export * as ConfigCompaction from "./compaction.js"
|
||||
|
||||
import { Schema } from "effect"
|
||||
import { NonNegativeInt, optional } from "../schema.js"
|
||||
import { NonNegativeInt } from "../schema.js"
|
||||
|
||||
export class Keep extends Schema.Class<Keep>("Config.Compaction.Keep")({
|
||||
tokens: NonNegativeInt.pipe(optional),
|
||||
tokens: NonNegativeInt.pipe(Schema.optional),
|
||||
}) {}
|
||||
|
||||
export class Info extends Schema.Class<Info>("Config.Compaction")({
|
||||
auto: Schema.Boolean.pipe(optional),
|
||||
keep: Keep.pipe(optional),
|
||||
buffer: NonNegativeInt.pipe(optional),
|
||||
auto: Schema.Boolean.pipe(Schema.optional),
|
||||
keep: Keep.pipe(Schema.optional),
|
||||
buffer: NonNegativeInt.pipe(Schema.optional),
|
||||
}) {}
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
export * as ConfigExperimental from "./experimental.js"
|
||||
|
||||
import { Schema } from "effect"
|
||||
import { NonNegativeInt, optional } from "../schema.js"
|
||||
import { NonNegativeInt } from "../schema.js"
|
||||
import { ConfigPolicy } from "./policy.js"
|
||||
|
||||
export class Info extends Schema.Class<Info>("ConfigExperimental.Info")({
|
||||
subagent_depth: NonNegativeInt.pipe(optional).annotate({
|
||||
subagent_depth: NonNegativeInt.pipe(Schema.optional).annotate({
|
||||
description: "Maximum subagent nesting depth. Defaults to 1.",
|
||||
}),
|
||||
policies: ConfigPolicy.Info.pipe(Schema.Array, optional).annotate({
|
||||
policies: ConfigPolicy.Info.pipe(Schema.Array, Schema.optional).annotate({
|
||||
description: "Ordered policies controlling access to configured resources",
|
||||
}),
|
||||
}) {}
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
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(optional),
|
||||
command: Schema.String.pipe(Schema.Array, optional),
|
||||
environment: Schema.Record(Schema.String, Schema.String).pipe(optional),
|
||||
extensions: Schema.String.pipe(Schema.Array, optional),
|
||||
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),
|
||||
}) {}
|
||||
|
||||
export const Info = Schema.Union([Schema.Boolean, Schema.Record(Schema.String, Entry)])
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
export * as ConfigLSP from "./lsp.js"
|
||||
|
||||
import { Schema } from "effect"
|
||||
import { optional } from "../schema.js"
|
||||
|
||||
export const Disabled = Schema.Struct({
|
||||
disabled: Schema.Literal(true),
|
||||
@@ -9,10 +8,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, optional),
|
||||
disabled: Schema.Boolean.pipe(optional),
|
||||
env: Schema.Record(Schema.String, Schema.String).pipe(optional),
|
||||
initialization: Schema.Record(Schema.String, Schema.Unknown).pipe(optional),
|
||||
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),
|
||||
}) {}
|
||||
|
||||
export const Entry = Schema.Union([Disabled, Server])
|
||||
|
||||
@@ -2,7 +2,6 @@ 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
|
||||
@@ -15,6 +14,6 @@ export type Remote = Mcp.RemoteConfig
|
||||
export const Server = Mcp.ServerConfig
|
||||
|
||||
export class Info extends Schema.Class<Info>("Config.MCP")({
|
||||
timeout: Timeout.pipe(optional),
|
||||
servers: Schema.Record(Schema.String, Server).pipe(optional),
|
||||
timeout: Timeout.pipe(Schema.optional),
|
||||
servers: Schema.Record(Schema.String, Server).pipe(Schema.optional),
|
||||
}) {}
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
export * as ConfigMedia from "./media.js"
|
||||
|
||||
import { Schema } from "effect"
|
||||
import { optional, PositiveInt } from "../schema.js"
|
||||
import { PositiveInt } from "../schema.js"
|
||||
|
||||
export class Image extends Schema.Class<Image>("Config.Media.Image")({
|
||||
auto_resize: Schema.Boolean.pipe(optional),
|
||||
max_width: PositiveInt.pipe(optional),
|
||||
max_height: PositiveInt.pipe(optional),
|
||||
max_base64_bytes: PositiveInt.pipe(optional),
|
||||
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),
|
||||
}) {}
|
||||
|
||||
export class Info extends Schema.Class<Info>("Config.Media")({
|
||||
image: Image.pipe(optional),
|
||||
image: Image.pipe(Schema.optional),
|
||||
}) {}
|
||||
|
||||
@@ -3,7 +3,6 @@ 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(/^[^#]+$/))
|
||||
@@ -12,7 +11,7 @@ const VariantID = Model.VariantID.check(Schema.isPattern(/^[^#]+$/))
|
||||
const Explicit = Schema.Struct({
|
||||
providerID: ProviderID,
|
||||
model: ModelID,
|
||||
variant: VariantID.pipe(optional),
|
||||
variant: VariantID.pipe(Schema.optional),
|
||||
})
|
||||
|
||||
const Short = Schema.String.check(Schema.isPattern(/^[^/#]+\/[^#]+(?:#[^#]+)?$/))
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
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(optional),
|
||||
options: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional),
|
||||
}) {}
|
||||
|
||||
export const Plugin = Schema.Union([Schema.String, Entry])
|
||||
|
||||
@@ -3,14 +3,13 @@ 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(optional),
|
||||
headers: Schema.Record(Schema.String, Schema.String).pipe(optional),
|
||||
body: JsonRecord.pipe(optional),
|
||||
settings: JsonRecord.pipe(Schema.optional),
|
||||
headers: Schema.Record(Schema.String, Schema.String).pipe(Schema.optional),
|
||||
body: JsonRecord.pipe(Schema.optional),
|
||||
}
|
||||
|
||||
export class Request extends Schema.Class<Request>("Config.Provider.Request")({
|
||||
@@ -19,47 +18,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(optional),
|
||||
write: Money.USDPerMillionTokens.pipe(optional),
|
||||
read: Money.USDPerMillionTokens.pipe(Schema.optional),
|
||||
write: Money.USDPerMillionTokens.pipe(Schema.optional),
|
||||
}) {}
|
||||
|
||||
class Cost extends Schema.Class<Cost>("Config.Model.Cost")({
|
||||
tier: Schema.Struct({
|
||||
type: Schema.Literal("context"),
|
||||
size: Schema.Int,
|
||||
}).pipe(optional),
|
||||
}).pipe(Schema.optional),
|
||||
input: Money.USDPerMillionTokens,
|
||||
output: Money.USDPerMillionTokens,
|
||||
cache: Cache.pipe(optional),
|
||||
cache: Cache.pipe(Schema.optional),
|
||||
}) {}
|
||||
|
||||
class Limit extends Schema.Class<Limit>("Config.Model.Limit")({
|
||||
context: Schema.Int.pipe(optional),
|
||||
input: Schema.Int.pipe(optional),
|
||||
output: Schema.Int.pipe(optional),
|
||||
context: Schema.Int.pipe(Schema.optional),
|
||||
input: Schema.Int.pipe(Schema.optional),
|
||||
output: Schema.Int.pipe(Schema.optional),
|
||||
}) {}
|
||||
|
||||
class Model extends Schema.Class<Model>("Config.Model")({
|
||||
modelID: ID.pipe(optional),
|
||||
family: Family.pipe(optional),
|
||||
name: Schema.String.pipe(optional),
|
||||
compatibility: Compatibility.pipe(optional),
|
||||
package: Schema.String.pipe(optional),
|
||||
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),
|
||||
...Overlays,
|
||||
capabilities: Capabilities.pipe(optional),
|
||||
capabilities: Capabilities.pipe(Schema.optional),
|
||||
variants: Schema.Struct({
|
||||
id: VariantID,
|
||||
...Overlays,
|
||||
}).pipe(Schema.Array, optional),
|
||||
cost: Schema.Union([Cost, Cost.pipe(Schema.Array)]).pipe(optional),
|
||||
disabled: Schema.Boolean.pipe(optional),
|
||||
limit: Limit.pipe(optional),
|
||||
}).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),
|
||||
}) {}
|
||||
|
||||
export class Info extends Schema.Class<Info>("Config.Provider")({
|
||||
name: Schema.String.pipe(optional),
|
||||
env: Schema.String.pipe(Schema.Array, optional),
|
||||
package: Schema.String.pipe(optional),
|
||||
name: Schema.String.pipe(Schema.optional),
|
||||
env: Schema.String.pipe(Schema.Array, Schema.optional),
|
||||
package: Schema.String.pipe(Schema.optional),
|
||||
...Overlays,
|
||||
models: Schema.Record(Schema.String, Model).pipe(optional),
|
||||
models: Schema.Record(Schema.String, Model).pipe(Schema.optional),
|
||||
}) {}
|
||||
|
||||
@@ -1,19 +1,18 @@
|
||||
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(optional),
|
||||
description: Schema.String.pipe(optional),
|
||||
hidden: Schema.Boolean.pipe(optional),
|
||||
branch: Schema.String.pipe(Schema.optional),
|
||||
description: Schema.String.pipe(Schema.optional),
|
||||
hidden: Schema.Boolean.pipe(Schema.optional),
|
||||
}) {}
|
||||
|
||||
export class Local extends Schema.Class<Local>("Config.Reference.Local")({
|
||||
path: Schema.String,
|
||||
description: Schema.String.pipe(optional),
|
||||
hidden: Schema.Boolean.pipe(optional),
|
||||
description: Schema.String.pipe(Schema.optional),
|
||||
hidden: Schema.Boolean.pipe(Schema.optional),
|
||||
}) {}
|
||||
|
||||
export const Entry = Schema.Union([Schema.String, Git, Local])
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
export * as ConfigToolOutput from "./tool-output.js"
|
||||
|
||||
import { Schema } from "effect"
|
||||
import { optional, PositiveInt } from "../schema.js"
|
||||
import { PositiveInt } from "../schema.js"
|
||||
|
||||
export class Info extends Schema.Class<Info>("Config.ToolOutput")({
|
||||
max_lines: PositiveInt.pipe(optional),
|
||||
max_bytes: PositiveInt.pipe(optional),
|
||||
max_lines: PositiveInt.pipe(Schema.optional),
|
||||
max_bytes: PositiveInt.pipe(Schema.optional),
|
||||
}) {}
|
||||
|
||||
@@ -1,16 +1,15 @@
|
||||
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(optional).annotate({
|
||||
prompt: Schema.String.pipe(Schema.optional).annotate({
|
||||
description: "Prompt sent for keep-alive requests",
|
||||
}),
|
||||
interval: Schema.DurationFromString.pipe(optional).annotate({
|
||||
interval: Schema.DurationFromString.pipe(Schema.optional).annotate({
|
||||
description: 'Idle time between keep-alive requests (default: "4 minutes")',
|
||||
}),
|
||||
duration: Schema.DurationFromString.pipe(optional).annotate({
|
||||
duration: Schema.DurationFromString.pipe(Schema.optional).annotate({
|
||||
description: 'Time after the last active request to keep a session warm (default: "30 minutes")',
|
||||
}),
|
||||
}) {}
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
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, optional),
|
||||
ignore: Schema.String.pipe(Schema.Array, Schema.optional),
|
||||
}) {}
|
||||
|
||||
@@ -124,6 +124,9 @@ const InfoBase = {
|
||||
// on non-session owners anywhere else.
|
||||
sessionID: Schema.String,
|
||||
title: Schema.String,
|
||||
coalesce: Schema.String.pipe(optional).annotate({
|
||||
description: "Client-local key for displaying equivalent pending forms once and broadcasting one response.",
|
||||
}),
|
||||
metadata: Metadata.pipe(optional),
|
||||
}
|
||||
|
||||
|
||||
+23
-19
@@ -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(optional).annotate({
|
||||
startup: PositiveInt.pipe(Schema.optional).annotate({
|
||||
description: "Maximum time in milliseconds to establish and initialize the MCP server.",
|
||||
}),
|
||||
catalog: PositiveInt.pipe(optional).annotate({
|
||||
catalog: PositiveInt.pipe(Schema.optional).annotate({
|
||||
description: "Maximum time in milliseconds to wait for MCP discovery requests such as tools/list and prompts/list.",
|
||||
}),
|
||||
execution: PositiveInt.pipe(optional).annotate({
|
||||
execution: PositiveInt.pipe(Schema.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(optional).annotate({
|
||||
cwd: Schema.String.pipe(Schema.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(optional),
|
||||
disabled: Schema.Boolean.pipe(optional),
|
||||
codemode: Schema.Boolean.pipe(optional).annotate({
|
||||
environment: Schema.Record(Schema.String, Schema.String).pipe(Schema.optional),
|
||||
disabled: Schema.Boolean.pipe(Schema.optional),
|
||||
codemode: Schema.Boolean.pipe(Schema.optional).annotate({
|
||||
description: "Expose this server's tools through Code Mode. Defaults to true.",
|
||||
}),
|
||||
timeout: TimeoutConfig.pipe(optional),
|
||||
timeout: TimeoutConfig.pipe(Schema.optional),
|
||||
}) {}
|
||||
|
||||
export class OAuthConfig extends Schema.Class<OAuthConfig>("Mcp.OAuthConfig")({
|
||||
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),
|
||||
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),
|
||||
}) {}
|
||||
|
||||
export class RemoteConfig extends Schema.Class<RemoteConfig>("Mcp.RemoteConfig")({
|
||||
type: Schema.Literal("remote"),
|
||||
url: Schema.String,
|
||||
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({
|
||||
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({
|
||||
description: "Expose this server's tools through Code Mode. Defaults to true.",
|
||||
}),
|
||||
timeout: TimeoutConfig.pipe(optional),
|
||||
timeout: TimeoutConfig.pipe(Schema.optional),
|
||||
}) {}
|
||||
|
||||
export const ServerConfig = Schema.Union([LocalConfig, RemoteConfig]).pipe(Schema.toTaggedUnion("type"))
|
||||
@@ -68,9 +68,13 @@ 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]).pipe(
|
||||
export const Status = Schema.Union([Connected, Pending, Disabled, Failed, NeedsAuth, NeedsClientRegistration]).pipe(
|
||||
Schema.toTaggedUnion("status"),
|
||||
)
|
||||
|
||||
|
||||
@@ -173,36 +173,6 @@ 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
|
||||
@@ -610,9 +580,6 @@ export const Definitions = Event.inventory(
|
||||
Forked,
|
||||
InputPromoted,
|
||||
InputAdmitted,
|
||||
InputCancelled,
|
||||
InputSteered,
|
||||
InputQueued,
|
||||
Execution.Started,
|
||||
Execution.Succeeded,
|
||||
Execution.Failed,
|
||||
@@ -654,16 +621,13 @@ 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([Durable, ...EphemeralDefinitions], { mode: "oneOf" }).pipe(
|
||||
export const All = Schema.Union(Event.inventory(...Definitions, UsageRecorded), { mode: "oneOf" }).pipe(
|
||||
Schema.toTaggedUnion("type"),
|
||||
)
|
||||
export type Event = typeof All.Type
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
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", () => {
|
||||
@@ -33,14 +29,7 @@ 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" },
|
||||
@@ -50,39 +39,4 @@ 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,9 +84,6 @@ 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",
|
||||
|
||||
@@ -24,22 +24,6 @@ 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(
|
||||
@@ -625,33 +609,6 @@ 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) {
|
||||
|
||||
@@ -16,9 +16,7 @@ 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,
|
||||
@@ -27,7 +25,6 @@ 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" } } },
|
||||
}),
|
||||
),
|
||||
)
|
||||
@@ -45,8 +42,9 @@ it.live("returns ordered config entries for the requested directory", () =>
|
||||
const response = yield* Effect.promise(() =>
|
||||
fetch(url, { headers: { authorization: `Basic ${btoa("opencode:secret")}` } }),
|
||||
)
|
||||
const body: unknown = yield* Effect.promise(() => response.json())
|
||||
const entries = Schema.decodeUnknownSync(Schema.Array(Config.Entry))(body)
|
||||
const entries = Schema.decodeUnknownSync(Schema.Array(Config.Entry))(
|
||||
yield* Effect.promise(() => response.json()),
|
||||
)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(Array.isArray(entries)).toBe(true)
|
||||
@@ -58,21 +56,7 @@ 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,7 +6,6 @@ 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"
|
||||
@@ -446,19 +445,6 @@ 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",
|
||||
@@ -516,7 +502,6 @@ function OAuthAuto(props: {
|
||||
instructions={props.attempt.instructions}
|
||||
message="Waiting for authorization..."
|
||||
copy
|
||||
open
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -574,14 +559,7 @@ function OAuthCode(props: {
|
||||
)
|
||||
}
|
||||
|
||||
function OAuthView(props: {
|
||||
title: string
|
||||
url?: string
|
||||
instructions?: string
|
||||
message: string
|
||||
copy?: boolean
|
||||
open?: boolean
|
||||
}) {
|
||||
function OAuthView(props: { title: string; url?: string; instructions?: string; message: string; copy?: boolean }) {
|
||||
const dialog = useDialog()
|
||||
const theme = useTheme("elevated")
|
||||
return (
|
||||
@@ -605,18 +583,11 @@ function OAuthView(props: {
|
||||
)}
|
||||
</Show>
|
||||
<text fg={theme.text.subdued}>{props.message}</text>
|
||||
<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>
|
||||
<Show when={props.copy}>
|
||||
<text fg={theme.text.default}>
|
||||
c <span style={{ fg: theme.text.subdued }}>copy</span>
|
||||
</text>
|
||||
</Show>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ import { useConfig } from "../config"
|
||||
import { getScrollAcceleration } from "../util/scroll"
|
||||
|
||||
function statusError(status: McpServer["status"]) {
|
||||
if (status.status === "failed") return status.error
|
||||
if (status.status === "failed" || status.status === "needs_client_registration") return status.error
|
||||
return undefined
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ 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 (
|
||||
@@ -45,6 +46,9 @@ 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>
|
||||
|
||||
@@ -59,7 +59,6 @@ export type PromptProps = {
|
||||
visible?: boolean
|
||||
disabled?: boolean
|
||||
onSubmit?: () => void
|
||||
onEmptySubmit?: () => boolean | Promise<boolean>
|
||||
ref?: (ref: PromptRef | undefined) => void
|
||||
hint?: JSX.Element
|
||||
right?: JSX.Element
|
||||
@@ -362,20 +361,6 @@ 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",
|
||||
@@ -534,11 +519,6 @@ export function Prompt(props: PromptProps) {
|
||||
commands: promptCommands(),
|
||||
}))
|
||||
|
||||
Keymap.createLayer(() => ({
|
||||
priority: 1,
|
||||
bindings: ["prompt.queue"],
|
||||
}))
|
||||
|
||||
Keymap.createLayer(() => ({
|
||||
bindings: [
|
||||
"prompt.submit",
|
||||
@@ -924,7 +904,7 @@ export function Prompt(props: PromptProps) {
|
||||
})
|
||||
|
||||
let submitting = false
|
||||
async function submit(delivery: "steer" | "queue" = "steer") {
|
||||
async function submit() {
|
||||
// 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
|
||||
@@ -934,13 +914,13 @@ export function Prompt(props: PromptProps) {
|
||||
if (submitting) return false
|
||||
submitting = true
|
||||
try {
|
||||
return await submitInner(delivery)
|
||||
return await submitInner()
|
||||
} finally {
|
||||
submitting = false
|
||||
}
|
||||
}
|
||||
|
||||
async function submitInner(delivery: "steer" | "queue") {
|
||||
async function submitInner() {
|
||||
// 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.
|
||||
@@ -951,39 +931,18 @@ 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()
|
||||
@@ -1077,7 +1036,6 @@ 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" })
|
||||
@@ -1145,7 +1103,6 @@ export function Prompt(props: PromptProps) {
|
||||
text: inputText,
|
||||
files: store.prompt.files,
|
||||
agents: store.prompt.agents,
|
||||
delivery,
|
||||
})
|
||||
.then(
|
||||
() => undefined,
|
||||
|
||||
@@ -104,7 +104,6 @@ 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"),
|
||||
@@ -162,7 +161,6 @@ 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"),
|
||||
@@ -172,7 +170,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,ctrl+j", "Insert newline in input"),
|
||||
input_newline: keybind("shift+return,ctrl+return,alt+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"),
|
||||
@@ -307,7 +305,6 @@ 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",
|
||||
@@ -362,7 +359,6 @@ 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",
|
||||
|
||||
@@ -168,27 +168,12 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||
|
||||
function removePending(sessionID: string, inputID?: string) {
|
||||
if (!inputID) return
|
||||
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 })
|
||||
setStore(
|
||||
"session",
|
||||
"pending",
|
||||
sessionID,
|
||||
(store.session.pending[sessionID] ?? []).filter((item) => item.id !== inputID),
|
||||
)
|
||||
}
|
||||
|
||||
const message = {
|
||||
@@ -237,12 +222,6 @@ 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) {
|
||||
@@ -424,36 +403,24 @@ 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 || !admitted) return
|
||||
if (!existing || !store.session.input[event.data.sessionID]?.includes(event.data.inputID)) return
|
||||
existing.time.created = event.created
|
||||
draft.splice(position, 1)
|
||||
draft.push(existing)
|
||||
message.reindex(draft, index, position)
|
||||
index.clear()
|
||||
draft.forEach((message, indexValue) => index.set(message.id, indexValue))
|
||||
})
|
||||
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)
|
||||
})
|
||||
setStore(
|
||||
"session",
|
||||
"input",
|
||||
event.data.sessionID,
|
||||
(store.session.input[event.data.sessionID] ?? []).filter((id) => id !== event.data.inputID),
|
||||
)
|
||||
break
|
||||
}
|
||||
case "session.input.admitted":
|
||||
|
||||
@@ -8,7 +8,13 @@ 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").length,
|
||||
() =>
|
||||
list().filter(
|
||||
(item) =>
|
||||
item.status.status === "failed" ||
|
||||
item.status.status === "needs_auth" ||
|
||||
item.status.status === "needs_client_registration",
|
||||
).length,
|
||||
)
|
||||
|
||||
const dot = (status: string) => {
|
||||
@@ -16,6 +22,7 @@ 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
|
||||
}
|
||||
|
||||
@@ -58,6 +65,7 @@ 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>
|
||||
|
||||
@@ -3,9 +3,7 @@ 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,
|
||||
@@ -58,10 +56,6 @@ type SkillEntry = PanelEntry & {
|
||||
name: string
|
||||
}
|
||||
|
||||
type QueuedPromptEntry = PanelEntry & {
|
||||
prompt: FooterQueuedPrompt
|
||||
}
|
||||
|
||||
type SubagentEntry = PanelEntry & {
|
||||
sessionID: string
|
||||
current: boolean
|
||||
@@ -843,48 +837,28 @@ 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<QueuedPromptEntry[]>(() =>
|
||||
const entries = createMemo(() =>
|
||||
props.prompts().map((prompt) => ({
|
||||
category: "",
|
||||
display: prompt.prompt.text.replaceAll("\n", " "),
|
||||
footer: "queued",
|
||||
footer: prompt.delivery,
|
||||
keywords: prompt.prompt.text,
|
||||
prompt,
|
||||
})),
|
||||
)
|
||||
const controller = createSearchablePanelController({
|
||||
entries,
|
||||
limit: SUBAGENT_LIST_ROWS,
|
||||
onClose: props.onClose,
|
||||
onSelect: (item) => props.onSteer(item.prompt),
|
||||
onSelect: props.onClose,
|
||||
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="Queued prompts"
|
||||
title="Pending work"
|
||||
query={controller.query()}
|
||||
count={controller.items().length}
|
||||
total={entries().length}
|
||||
@@ -892,7 +866,6 @@ 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
|
||||
@@ -902,7 +875,7 @@ export function RunQueuedPromptSelectBody(props: {
|
||||
offset={controller.menu.offset}
|
||||
rows={controller.menu.rows}
|
||||
limit={SUBAGENT_LIST_ROWS}
|
||||
empty="No queued prompts"
|
||||
empty="No pending work"
|
||||
border={false}
|
||||
paddingLeft={panelPad(props.mono)}
|
||||
paddingRight={panelPad(props.mono)}
|
||||
|
||||
@@ -19,7 +19,6 @@ import {
|
||||
displayCharAt,
|
||||
displaySlice,
|
||||
isExitCommand,
|
||||
isCompactCommand,
|
||||
mentionTriggerIndex,
|
||||
isNewCommand,
|
||||
movePromptHistory,
|
||||
@@ -32,15 +31,7 @@ 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 {
|
||||
FooterQueuedPrompt,
|
||||
FooterState,
|
||||
RunAgent,
|
||||
RunCommand,
|
||||
RunPrompt,
|
||||
RunPromptPart,
|
||||
RunReference,
|
||||
} from "./types"
|
||||
import type { FooterState, RunAgent, RunCommand, RunPrompt, RunPromptPart, RunReference } from "./types"
|
||||
|
||||
const AUTOCOMPLETE_ROWS = FOOTER_MENU_ROWS
|
||||
const AUTOCOMPLETE_BOTTOM_ROWS = 1
|
||||
@@ -81,8 +72,6 @@ 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
|
||||
@@ -991,18 +980,8 @@ 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",
|
||||
@@ -1137,8 +1116,7 @@ export function createPromptState(input: PromptInput): PromptState {
|
||||
}
|
||||
}
|
||||
|
||||
let submitting = false
|
||||
const submitPrompt = (next: RunPrompt, delivery: "steer" | "queue" = "steer") => {
|
||||
const submitPrompt = (next: RunPrompt) => {
|
||||
if (!area || area.isDestroyed) {
|
||||
draft = promptCopy(next)
|
||||
}
|
||||
@@ -1152,29 +1130,12 @@ 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
|
||||
@@ -1196,28 +1157,24 @@ export function createPromptState(input: PromptInput): PromptState {
|
||||
}
|
||||
|
||||
const submit = command
|
||||
? { ...next, command, delivery }
|
||||
? { ...next, command }
|
||||
: parsed?.type === "command"
|
||||
? { ...next, command: parsed.command, delivery }
|
||||
: { ...next, delivery }
|
||||
? { ...next, command: parsed.command }
|
||||
: next
|
||||
const shellMode = next.mode === "shell"
|
||||
|
||||
submitting = true
|
||||
resetDraft()
|
||||
queueMicrotask(async () => {
|
||||
try {
|
||||
if (await input.onSubmit(submit)) {
|
||||
push(next)
|
||||
if (shellMode) {
|
||||
setShellMode(false)
|
||||
draft = emptyPrompt(false)
|
||||
}
|
||||
return
|
||||
if (await input.onSubmit(submit)) {
|
||||
push(next)
|
||||
if (shellMode) {
|
||||
setShellMode(false)
|
||||
draft = emptyPrompt(false)
|
||||
}
|
||||
restore(next)
|
||||
} finally {
|
||||
submitting = false
|
||||
return
|
||||
}
|
||||
|
||||
restore(next)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -51,7 +51,6 @@ import type {
|
||||
MiniSettingChange,
|
||||
MiniSettings,
|
||||
PermissionReply,
|
||||
QueuedPromptAction,
|
||||
RunAgent,
|
||||
RunCommand,
|
||||
RunInput,
|
||||
@@ -97,7 +96,6 @@ 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
|
||||
@@ -345,7 +343,6 @@ 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,
|
||||
|
||||
@@ -34,7 +34,6 @@ 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,
|
||||
@@ -47,7 +46,6 @@ import type {
|
||||
MiniSettingChange,
|
||||
MiniSettings,
|
||||
PermissionReply,
|
||||
QueuedPromptAction,
|
||||
RunAgent,
|
||||
RunCommand,
|
||||
RunInput,
|
||||
@@ -94,14 +92,13 @@ type RunFooterViewProps = {
|
||||
mono: boolean
|
||||
miniSettings: () => MiniSettings
|
||||
history?: () => RunPrompt[]
|
||||
onSubmit: (input: RunPrompt) => boolean | Promise<boolean>
|
||||
onSubmit: (input: RunPrompt) => 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
|
||||
@@ -135,7 +132,6 @@ 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")
|
||||
@@ -233,7 +229,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 (queue().length > 0) details.push(`${queue().length} queued`)
|
||||
if (queuedPrompts().length > 0) details.push(`${queuedPrompts().length} pending`)
|
||||
if (activeTabs().length > 0) details.push(`${activeTabs().length} subagent${activeTabs().length === 1 ? "" : "s"}`)
|
||||
return details.join(props.mono ? " - " : " · ")
|
||||
})
|
||||
@@ -313,7 +309,7 @@ export function RunFooterView(props: RunFooterViewProps) {
|
||||
}
|
||||
|
||||
const openQueuedMenu = () => {
|
||||
if (queue().length === 0) return
|
||||
if (queuedPrompts().length === 0) return
|
||||
setRoute({ type: "queued-menu" })
|
||||
props.onSubagentSelect?.(undefined)
|
||||
}
|
||||
@@ -322,23 +318,6 @@ 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)
|
||||
@@ -378,8 +357,6 @@ 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,
|
||||
@@ -474,12 +451,13 @@ export function RunFooterView(props: RunFooterViewProps) {
|
||||
if (foregroundSubagents() && backgroundShortcut()) {
|
||||
items.push({ key: backgroundShortcut(), label: "background" })
|
||||
}
|
||||
if (queue().length > 0 && queuedShortcut()) {
|
||||
items.push({ key: queuedShortcut(), label: `${queue().length} queued` })
|
||||
if (queuedPrompts().length > 0 && queuedShortcut()) {
|
||||
items.push({ key: queuedShortcut(), label: `${queuedPrompts().length} pending` })
|
||||
}
|
||||
if (activeTabs().length > 0 && subagentShortcut()) {
|
||||
items.push({ key: subagentShortcut(), label: "subagents" })
|
||||
}
|
||||
|
||||
return items
|
||||
})
|
||||
const commandHint = createMemo(() => {
|
||||
@@ -590,7 +568,7 @@ export function RunFooterView(props: RunFooterViewProps) {
|
||||
}))
|
||||
|
||||
Keymap.createLayer(() => ({
|
||||
enabled: active().type === "prompt" && route().type === "composer" && queue().length > 0,
|
||||
enabled: active().type === "prompt" && route().type === "composer" && queuedPrompts().length > 0,
|
||||
commands: [
|
||||
{
|
||||
id: "session.queued_prompts",
|
||||
@@ -652,7 +630,7 @@ export function RunFooterView(props: RunFooterViewProps) {
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (route().type !== "queued-menu" || queue().length > 0) return
|
||||
if (route().type !== "queued-menu" || queuedPrompts().length > 0) return
|
||||
closePanel()
|
||||
})
|
||||
|
||||
@@ -756,16 +734,8 @@ export function RunFooterView(props: RunFooterViewProps) {
|
||||
<Match when={selectingQueued()}>
|
||||
<RunQueuedPromptSelectBody
|
||||
theme={theme}
|
||||
prompts={queue}
|
||||
prompts={queuedPrompts}
|
||||
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}
|
||||
/>
|
||||
@@ -775,7 +745,7 @@ export function RunFooterView(props: RunFooterViewProps) {
|
||||
theme={theme}
|
||||
commands={props.commands}
|
||||
subagents={tabs}
|
||||
queued={queue}
|
||||
queued={queuedPrompts}
|
||||
variants={props.variants}
|
||||
variantCycle={variantCycle()}
|
||||
onClose={closePanel}
|
||||
|
||||
@@ -22,7 +22,6 @@ import type {
|
||||
MiniSettings,
|
||||
MiniHost,
|
||||
PermissionReply,
|
||||
QueuedPromptAction,
|
||||
RunAgent,
|
||||
RunInput,
|
||||
RunPrompt,
|
||||
@@ -71,7 +70,6 @@ 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
|
||||
}
|
||||
@@ -245,7 +243,6 @@ 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
|
||||
|
||||
@@ -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, delivery: "steer" | "queue", signal: AbortSignal) => Promise<void>
|
||||
admit: (prompt: RunPrompt, 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, sent.delivery ?? "steer")
|
||||
input.onSend?.(sent, "steer")
|
||||
|
||||
if (state.closed) {
|
||||
break
|
||||
@@ -276,11 +276,10 @@ export async function runPromptQueue(input: QueueInput): Promise<void> {
|
||||
const sent = { ...prompt, messageID: SessionMessage.ID.create() }
|
||||
const admission = state.admission
|
||||
admissionVersion += 1
|
||||
const delivery = prompt.delivery ?? "queue"
|
||||
input.onSend?.(sent, delivery)
|
||||
input.onSend?.(sent, "queue")
|
||||
admissions = admissions
|
||||
.then(() => admission)
|
||||
.then(() => input.admit(sent, delivery, admissionController.signal))
|
||||
.then(() => input.admit(sent, admissionController.signal))
|
||||
.catch((error) => (state.closed ? undefined : input.onAdmissionError?.(sent, error)))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -390,15 +390,6 @@ 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(() => {})
|
||||
@@ -901,7 +892,7 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
|
||||
trace: log,
|
||||
onSend: (prompt, delivery) => {
|
||||
state.shown = true
|
||||
state.history.push({ ...prompt, delivery: undefined })
|
||||
state.history.push(prompt)
|
||||
if (prompt.mode !== "shell" && delivery === "steer") {
|
||||
rememberLocal({
|
||||
kind: "user",
|
||||
@@ -912,21 +903,18 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
|
||||
})
|
||||
}
|
||||
},
|
||||
admit: async (prompt, delivery, signal) => {
|
||||
admit: async (prompt, signal) => {
|
||||
await state.switching?.catch(() => {})
|
||||
const next = await ensureStream()
|
||||
await next.handle.admitPromptTurn(
|
||||
{
|
||||
agent: state.agent,
|
||||
model: state.model,
|
||||
variant: state.activeVariant,
|
||||
prompt,
|
||||
files: input.files,
|
||||
includeFiles: false,
|
||||
signal,
|
||||
},
|
||||
delivery,
|
||||
)
|
||||
await next.handle.queuePromptTurn({
|
||||
agent: state.agent,
|
||||
model: state.model,
|
||||
variant: state.activeVariant,
|
||||
prompt,
|
||||
files: input.files,
|
||||
includeFiles: false,
|
||||
signal,
|
||||
})
|
||||
},
|
||||
onAdmissionError: renderPromptError,
|
||||
onCompact: async () => {
|
||||
|
||||
@@ -653,10 +653,6 @@ 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)
|
||||
|
||||
@@ -71,7 +71,7 @@ export type SessionResizeReplayInput = {
|
||||
|
||||
export type SessionTransport = {
|
||||
runPromptTurn(input: SessionTurnInput, admitted?: () => void): Promise<void>
|
||||
admitPromptTurn(input: SessionTurnInput, delivery: "steer" | "queue"): Promise<void>
|
||||
queuePromptTurn(input: SessionTurnInput): Promise<void>
|
||||
waitForIdle(): Promise<void>
|
||||
interruptActiveTurn(): Promise<void>
|
||||
selectSubagent(sessionID: string | undefined): void
|
||||
@@ -515,12 +515,8 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
|
||||
)
|
||||
}
|
||||
|
||||
let syncedPending: string[] | undefined
|
||||
const syncPending = () => {
|
||||
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
|
||||
const prompts = [...state.pending.values()]
|
||||
input.trace?.write("ui.patch", { pending: prompts.length })
|
||||
input.footer.event({ type: "queued.prompts", prompts })
|
||||
}
|
||||
@@ -938,36 +934,6 @@ 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" })
|
||||
@@ -1677,14 +1643,14 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
|
||||
}
|
||||
|
||||
return {
|
||||
async admitPromptTurn(next, delivery) {
|
||||
async queuePromptTurn(next) {
|
||||
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, delivery))
|
||||
mergePending(await admitPrompt(next, client, "queue"))
|
||||
settlementClient = client
|
||||
},
|
||||
async waitForIdle() {
|
||||
@@ -1722,7 +1688,7 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
|
||||
return
|
||||
}
|
||||
if (command) {
|
||||
await runTurnWait(next, messageID, client, () => admitPrompt(next, client, next.prompt.delivery ?? "steer"), admitted)
|
||||
await runTurnWait(next, messageID, client, () => admitPrompt(next, client, "steer"), admitted)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1734,7 +1700,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, next.prompt.delivery ?? "steer"), admitted)
|
||||
await runTurnWait(next, messageID, client, () => admitPrompt(next, client, "steer"), admitted)
|
||||
},
|
||||
async interruptActiveTurn() {
|
||||
// A running shell holds no drain, so session.interrupt cannot reach it;
|
||||
|
||||
@@ -75,7 +75,6 @@ export type RunPrompt = {
|
||||
messageID?: string
|
||||
text: string
|
||||
parts: RunPromptPart[]
|
||||
delivery?: "steer" | "queue"
|
||||
mode?: "shell"
|
||||
command?: {
|
||||
name: string
|
||||
@@ -91,8 +90,6 @@ export type FooterQueuedPrompt = {
|
||||
delivery: "steer" | "queue"
|
||||
}
|
||||
|
||||
export type QueuedPromptAction = "steer" | "cancel"
|
||||
|
||||
export type RunAgent = {
|
||||
id: string
|
||||
name: string
|
||||
|
||||
@@ -93,13 +93,13 @@ export function Home() {
|
||||
<box width="100%" flexShrink={0}>
|
||||
<PluginSlot name="home.footer" input={{}} mode="replace" />
|
||||
</box>
|
||||
<Show when={forms()[0]?.id} keyed>
|
||||
<Show when={forms()[0]?.coalesce ?? forms()[0]?.id} keyed>
|
||||
{(_) => {
|
||||
const form = forms()[0]
|
||||
return form ? (
|
||||
<box position="absolute" zIndex={2000} left={0} right={0} bottom={1} paddingLeft={2} paddingRight={2}>
|
||||
<box width="100%">
|
||||
<FormPrompt form={form} />
|
||||
<FormPrompt form={form} forms={forms()} />
|
||||
</box>
|
||||
</box>
|
||||
) : null
|
||||
|
||||
@@ -42,7 +42,7 @@ function requestOptions(form: FormWithLocation) {
|
||||
}
|
||||
}
|
||||
|
||||
export function FormPrompt(props: { form: FormWithLocation }) {
|
||||
export function FormPrompt(props: { form: FormWithLocation; forms?: readonly FormWithLocation[] }) {
|
||||
const client = useClient()
|
||||
const themes = useThemes()
|
||||
const theme = useTheme("elevated")
|
||||
@@ -69,6 +69,11 @@ export function FormPrompt(props: { form: FormWithLocation }) {
|
||||
let textarea: TextareaRenderable | undefined
|
||||
let review: ScrollBoxRenderable | undefined
|
||||
|
||||
const forms = createMemo(() => {
|
||||
if (!props.form.coalesce) return [props.form]
|
||||
return (props.forms ?? [props.form]).filter((form) => form.coalesce === props.form.coalesce)
|
||||
})
|
||||
|
||||
const message = createMemo(() => {
|
||||
const value = props.form.metadata?.["message"]
|
||||
return typeof value === "string" ? value : undefined
|
||||
@@ -180,24 +185,30 @@ export function FormPrompt(props: { form: FormWithLocation }) {
|
||||
setStore("error", "")
|
||||
}
|
||||
|
||||
function replySingle(field: FormAnswerField, value: FormValue) {
|
||||
client.api.form
|
||||
.reply(
|
||||
{
|
||||
sessionID: props.form.sessionID,
|
||||
formID: props.form.id,
|
||||
answer: { [field.key]: value },
|
||||
},
|
||||
requestOptions(props.form),
|
||||
function reply(answer: Record<string, FormValue>) {
|
||||
Promise.all(
|
||||
forms().map((form) =>
|
||||
client.api.form.reply(
|
||||
{
|
||||
sessionID: form.sessionID,
|
||||
formID: form.id,
|
||||
answer,
|
||||
},
|
||||
requestOptions(form),
|
||||
),
|
||||
),
|
||||
).catch((error: unknown) => {
|
||||
setStore(
|
||||
"error",
|
||||
typeof error === "object" && error !== null && "message" in error && typeof error.message === "string"
|
||||
? error.message
|
||||
: "Invalid answer",
|
||||
)
|
||||
.catch((error: unknown) => {
|
||||
setStore(
|
||||
"error",
|
||||
typeof error === "object" && error !== null && "message" in error && typeof error.message === "string"
|
||||
? error.message
|
||||
: "Invalid answer",
|
||||
)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function replySingle(field: FormAnswerField, value: FormValue) {
|
||||
reply({ [field.key]: value })
|
||||
}
|
||||
|
||||
function pick(value: FormValue, customValue?: string) {
|
||||
@@ -350,7 +361,8 @@ export function FormPrompt(props: { form: FormWithLocation }) {
|
||||
}
|
||||
|
||||
function cancel() {
|
||||
void client.api.form.cancel({ sessionID: props.form.sessionID, formID: props.form.id }, requestOptions(props.form))
|
||||
for (const form of forms())
|
||||
void client.api.form.cancel({ sessionID: form.sessionID, formID: form.id }, requestOptions(form))
|
||||
}
|
||||
|
||||
function openExternal() {
|
||||
@@ -402,28 +414,14 @@ export function FormPrompt(props: { form: FormWithLocation }) {
|
||||
setStore("error", formValidateValue(invalid, store.answers[invalid.key]) ?? "Invalid answer")
|
||||
return
|
||||
}
|
||||
client.api.form
|
||||
.reply(
|
||||
{
|
||||
sessionID: props.form.sessionID,
|
||||
formID: props.form.id,
|
||||
answer: Object.fromEntries(
|
||||
fields().flatMap((field) => {
|
||||
const value = store.answers[field.key]
|
||||
return value === undefined ? [] : [[field.key, value] as const]
|
||||
}),
|
||||
),
|
||||
},
|
||||
requestOptions(props.form),
|
||||
)
|
||||
.catch((error: unknown) => {
|
||||
setStore(
|
||||
"error",
|
||||
typeof error === "object" && error !== null && "message" in error && typeof error.message === "string"
|
||||
? error.message
|
||||
: "Invalid answer",
|
||||
)
|
||||
})
|
||||
reply(
|
||||
Object.fromEntries(
|
||||
fields().flatMap((field) => {
|
||||
const value = store.answers[field.key]
|
||||
return value === undefined ? [] : [[field.key, value] as const]
|
||||
}),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
onMount(() => onCleanup(keymap.mode.push(FORM_MODE)))
|
||||
|
||||
@@ -52,7 +52,6 @@ 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"
|
||||
@@ -110,7 +109,6 @@ 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
|
||||
@@ -122,7 +120,6 @@ const context = createContext<{
|
||||
diffWrapMode: () => "word" | "none"
|
||||
models: () => ModelInfo[]
|
||||
config: ReturnType<typeof useConfig>["data"]
|
||||
mutatePending: (action: PendingAction, inputID: string) => Promise<boolean>
|
||||
}>()
|
||||
|
||||
function use() {
|
||||
@@ -178,11 +175,6 @@ 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,
|
||||
@@ -377,55 +369,6 @@ 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()
|
||||
@@ -937,13 +880,6 @@ 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",
|
||||
@@ -1015,7 +951,6 @@ export function Session() {
|
||||
diffWrapMode,
|
||||
models,
|
||||
config,
|
||||
mutatePending,
|
||||
}}
|
||||
>
|
||||
<box flexDirection="row" flexGrow={1} minHeight={0}>
|
||||
@@ -1071,9 +1006,6 @@ 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}
|
||||
@@ -1094,10 +1026,10 @@ export function Session() {
|
||||
</Show>
|
||||
</Match>
|
||||
<Match when={forms().length > 0}>
|
||||
<Show when={forms()[0]?.id} keyed>
|
||||
<Show when={forms()[0]?.coalesce ?? forms()[0]?.id} keyed>
|
||||
{(_) => {
|
||||
const form = forms()[0]
|
||||
return form ? <FormPrompt form={form} /> : null
|
||||
return form ? <FormPrompt form={form} forms={forms()} /> : null
|
||||
}}
|
||||
</Show>
|
||||
</Match>
|
||||
@@ -1109,11 +1041,6 @@ export function Session() {
|
||||
onSubmit={() => {
|
||||
toBottom()
|
||||
}}
|
||||
onEmptySubmit={async () => {
|
||||
const next = queuedPrompts()[0]
|
||||
if (!next) return false
|
||||
return mutatePending("steer", next.id)
|
||||
}}
|
||||
sessionID={route.sessionID}
|
||||
/>
|
||||
</Match>
|
||||
@@ -1895,7 +1822,6 @@ function ShellMessage(props: { message: Extract<SessionMessageInfo, { type: "she
|
||||
|
||||
return (
|
||||
<box
|
||||
width="100%"
|
||||
border={["left"]}
|
||||
paddingTop={1}
|
||||
paddingBottom={1}
|
||||
@@ -1923,23 +1849,18 @@ 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 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 queued = createMemo(
|
||||
() => data.session.status(ctx.sessionID) === "running" && data.session.input.has(ctx.sessionID, props.message.id),
|
||||
)
|
||||
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={delivery() ? theme.border.default : color()}
|
||||
borderColor={queued() ? theme.border.default : color()}
|
||||
customBorderChars={SplitBorder.customBorderChars}
|
||||
>
|
||||
<box
|
||||
@@ -1951,21 +1872,6 @@ 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}
|
||||
@@ -2013,35 +1919,6 @@ 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 (
|
||||
|
||||
@@ -46,14 +46,9 @@ 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 ? visible.filter((message) => message.id < boundary) : visible,
|
||||
boundary ? messages.filter((message) => message.id < boundary) : messages,
|
||||
inputs,
|
||||
turnTokens(),
|
||||
)
|
||||
@@ -62,7 +57,8 @@ export function createSessionRows(sessionID: Accessor<string>) {
|
||||
rows.splice(
|
||||
position === -1 ? rows.length : position,
|
||||
0,
|
||||
...pending
|
||||
...data.session.pending
|
||||
.list(sessionID())
|
||||
.filter((item) => item.type === "compaction")
|
||||
.map((item): SessionRow => ({ type: "compaction-queued", inputID: item.id })),
|
||||
)
|
||||
@@ -116,11 +112,10 @@ export function createSessionRows(sessionID: Accessor<string>) {
|
||||
createEffect(
|
||||
on(
|
||||
() =>
|
||||
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 []
|
||||
}),
|
||||
data.session.pending
|
||||
.list(sessionID())
|
||||
.filter((item) => item.type === "compaction")
|
||||
.map((item) => item.id),
|
||||
() => setRows(reconcile(reduce())),
|
||||
{ defer: true },
|
||||
),
|
||||
@@ -201,9 +196,7 @@ 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
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ export function Link(props: LinkProps) {
|
||||
open(props.href).catch(() => {})
|
||||
}}
|
||||
>
|
||||
<a href={props.href}>{displayText}</a>
|
||||
{displayText}
|
||||
</text>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -914,106 +914,6 @@ 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"
|
||||
|
||||
@@ -15,7 +15,7 @@ import { TestTuiContexts } from "../../fixture/tui-environment"
|
||||
import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
|
||||
import { createApi, createEventStream, createFetch } from "../../fixture/tui-client"
|
||||
|
||||
async function mountForm(root: string, width = 80) {
|
||||
async function mountForm(root: string, width = 80, coalesce = false) {
|
||||
const state = path.join(root, "state")
|
||||
await mkdir(state, { recursive: true })
|
||||
|
||||
@@ -24,7 +24,7 @@ async function mountForm(root: string, width = 80) {
|
||||
const events = createEventStream()
|
||||
const transport = createFetch(
|
||||
(url, request) =>
|
||||
url.pathname === "/api/session/ses_test/form/frm_test/reply"
|
||||
/^\/api\/session\/ses_test\/form\/frm_(?:test|other)\/reply$/.test(url.pathname)
|
||||
? request.json().then((answer) => {
|
||||
replies.push(answer)
|
||||
return new Response(null, { status: 204 })
|
||||
@@ -37,6 +37,7 @@ async function mountForm(root: string, width = 80) {
|
||||
id: "frm_test",
|
||||
sessionID: "ses_test",
|
||||
title: "Authorization required",
|
||||
...(coalesce ? { coalesce: "authorization" } : {}),
|
||||
fields: [
|
||||
{
|
||||
key: "authorization",
|
||||
@@ -71,7 +72,7 @@ async function mountForm(root: string, width = 80) {
|
||||
<ClientProvider api={createApi(transport.fetch)}>
|
||||
<ThemeProvider mode="dark" source={{ discover: () => Promise.resolve({}) }}>
|
||||
<ToastProvider>
|
||||
<FormPrompt form={form} />
|
||||
<FormPrompt form={form} forms={coalesce ? [form, { ...form, id: "frm_other" }] : undefined} />
|
||||
</ToastProvider>
|
||||
</ThemeProvider>
|
||||
</ClientProvider>
|
||||
@@ -126,3 +127,24 @@ test("includes external acknowledgements in progress", async () => {
|
||||
prompt.app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("replies to every coalesced form", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const prompt = await mountForm(tmp.path, 80, true)
|
||||
try {
|
||||
prompt.app.mockInput.pressKey("right")
|
||||
await prompt.app.waitForFrame((frame) => frame.includes("(acknowledgement required)"))
|
||||
prompt.app.mockInput.pressEnter()
|
||||
await prompt.app.waitForFrame((frame) => frame.includes("External action must be acknowledged"))
|
||||
prompt.app.mockInput.pressKey("left")
|
||||
prompt.app.mockInput.pressKey("c")
|
||||
await prompt.app.waitForFrame((frame) => frame.includes("press enter to confirm"))
|
||||
prompt.app.mockInput.pressEnter()
|
||||
await prompt.app.waitForFrame((frame) => frame.includes("Acknowledged"))
|
||||
prompt.app.mockInput.pressEnter()
|
||||
await prompt.app.waitFor(() => prompt.replies.length === 2)
|
||||
expect(prompt.replies).toEqual([{ answer: { authorization: true } }, { answer: { authorization: true } }])
|
||||
} finally {
|
||||
prompt.app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
@@ -56,9 +56,9 @@ export function createFooterApiFixture(input: { events?: FooterEvent[]; commits?
|
||||
commits,
|
||||
calls,
|
||||
promptReady,
|
||||
submit(text: string, mode?: RunPrompt["mode"], delivery?: RunPrompt["delivery"]) {
|
||||
submit(text: string, mode?: RunPrompt["mode"]) {
|
||||
if (prompts.size === 0) return false
|
||||
const prompt: RunPrompt = { text, parts: [], ...(mode ? { mode } : {}), ...(delivery ? { delivery } : {}) }
|
||||
const prompt: RunPrompt = mode ? { text, parts: [], mode } : { text, parts: [] }
|
||||
for (const fn of [...prompts]) fn(prompt)
|
||||
return true
|
||||
},
|
||||
|
||||
@@ -21,7 +21,6 @@ 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,
|
||||
@@ -121,15 +120,13 @@ async function renderFooter(
|
||||
height?: number
|
||||
state?: Partial<FooterState>
|
||||
onCycle?: () => void
|
||||
onSubmit?: (prompt: RunPrompt) => boolean | Promise<boolean>
|
||||
onSubmit?: (prompt: RunPrompt) => 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" })
|
||||
@@ -167,7 +164,6 @@ async function renderFooter(
|
||||
state={state}
|
||||
view={view}
|
||||
subagent={subagents}
|
||||
queuedPrompts={() => input.queuedPrompts ?? []}
|
||||
theme={input.theme ?? (() => RUN_THEME_FALLBACK)}
|
||||
mono={input.mono ?? false}
|
||||
miniSettings={miniSettings}
|
||||
@@ -177,7 +173,6 @@ async function renderFooter(
|
||||
onFormCancel={() => {}}
|
||||
onCycle={input.onCycle ?? (() => {})}
|
||||
onInterrupt={() => false}
|
||||
onQueuedPromptAction={input.onQueuedPromptAction}
|
||||
onEditorOpen={async () => undefined}
|
||||
onInputClear={() => {}}
|
||||
onExit={() => {}}
|
||||
@@ -918,7 +913,7 @@ test("direct subagent panel closes when moving up from the first item", async ()
|
||||
}
|
||||
})
|
||||
|
||||
test("direct queued panel steers and deletes selected prompts", async () => {
|
||||
test("direct pending panel shows durable delivery without edit actions", async () => {
|
||||
const [prompts] = createSignal([
|
||||
{
|
||||
messageID: "m-1",
|
||||
@@ -926,22 +921,16 @@ test("direct queued panel steers and deletes selected prompts", async () => {
|
||||
delivery: "queue" as const,
|
||||
},
|
||||
])
|
||||
const steered: string[] = []
|
||||
const deleted: string[] = []
|
||||
|
||||
const app = await testRender(
|
||||
() => (
|
||||
<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>
|
||||
<box width={100} height={RUN_SUBAGENT_PANEL_ROWS}>
|
||||
<RunQueuedPromptSelectBody
|
||||
theme={() => RUN_THEME_FALLBACK.footer}
|
||||
prompts={prompts}
|
||||
onClose={() => {}}
|
||||
/>
|
||||
</box>
|
||||
),
|
||||
{ width: 100, height: RUN_SUBAGENT_PANEL_ROWS },
|
||||
)
|
||||
@@ -951,75 +940,19 @@ test("direct queued panel steers and deletes selected prompts", async () => {
|
||||
const frame = app.captureCharFrame()
|
||||
const list = panelMenu(app.renderer.root)
|
||||
|
||||
expect(frame).toContain("Queued prompts")
|
||||
expect(frame).toContain("Pending work")
|
||||
expect(frame).toContain("fix the auth test")
|
||||
expect(frame).toContain("queued")
|
||||
expect(frame).toContain("enter steer · ctrl+d delete")
|
||||
expect(frame).toContain("queue")
|
||||
expect(frame).not.toContain("┌")
|
||||
expect(frame).not.toContain("┃")
|
||||
expectPaletteList(list, 0)
|
||||
app.mockInput.pressEnter()
|
||||
app.mockInput.pressKey("d", { ctrl: true })
|
||||
expect(steered).toEqual(["m-1"])
|
||||
expect(deleted).toEqual(["m-1"])
|
||||
expect(frame).not.toContain("edit")
|
||||
expect(frame).not.toContain("remove")
|
||||
} 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 () => {
|
||||
@@ -1135,11 +1068,11 @@ test("direct footer submits slash autocomplete selections without dispatching sh
|
||||
await app.renderOnce()
|
||||
|
||||
expect(submits).toEqual([
|
||||
{ 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" },
|
||||
{ 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: [] },
|
||||
])
|
||||
expect(app.renderer.currentFocusedEditor?.plainText).toBe("/settings ")
|
||||
} finally {
|
||||
@@ -1167,9 +1100,7 @@ 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: "" }, delivery: "steer" },
|
||||
])
|
||||
expect(submits).toEqual([{ text: "/skills ", parts: [], command: { name: "skills", arguments: "" } }])
|
||||
expect(app.captureCharFrame()).not.toContain("Apply formatter fixes")
|
||||
} finally {
|
||||
app.cleanup()
|
||||
@@ -1227,12 +1158,7 @@ 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" },
|
||||
delivery: "steer",
|
||||
},
|
||||
{ text: "/formatter src", parts: [], command: { name: "formatter", arguments: "src", source: "skill" } },
|
||||
])
|
||||
} finally {
|
||||
app.cleanup()
|
||||
@@ -1312,7 +1238,7 @@ test.skip("direct footer clears the synthetic skills draft when the panel closes
|
||||
}
|
||||
})
|
||||
|
||||
test("direct footer shows authoritative queued work while running", async () => {
|
||||
test("direct footer shows authoritative pending work while running", async () => {
|
||||
const [state] = createSignal<FooterState>({
|
||||
phase: "running",
|
||||
status: "",
|
||||
@@ -1416,9 +1342,9 @@ test("direct footer shows authoritative queued work while running", async () =>
|
||||
const hint = statusItems.at(-1)!
|
||||
|
||||
expect(spinner).toBeDefined()
|
||||
expect(frame).toContain("1 queued")
|
||||
expect(frame).toContain("1 pending")
|
||||
expect(frame).toContain("ctrl+b background")
|
||||
expect(frame).toContain("ctrl+x q 1 queued")
|
||||
expect(frame).toContain("ctrl+x q 1 pending")
|
||||
expect(frame).toContain("↓ subagents")
|
||||
expect(frame).toContain("ctrl+p cmd")
|
||||
expect(frame).toContain("subagents · ctrl+p cmd")
|
||||
|
||||
@@ -82,8 +82,7 @@ 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,ctrl+j")
|
||||
expect(result.keybinds.get("prompt.queue")?.[0]?.key).toBe("alt+return")
|
||||
expect(result.keybinds.get("input.newline")?.[0]?.key).toBe("shift+return,ctrl+return,alt+return,ctrl+j")
|
||||
})
|
||||
|
||||
test("preserves disabled leader from resolved tui config", async () => {
|
||||
|
||||
@@ -265,33 +265,6 @@ 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[] = []
|
||||
@@ -335,7 +308,7 @@ describe("run runtime queue", () => {
|
||||
admitted()
|
||||
await new Promise<void>((resolve) => signal.addEventListener("abort", () => resolve(), { once: true }))
|
||||
},
|
||||
admit: async (_prompt, _delivery, signal) => {
|
||||
admit: async (_prompt, signal) => {
|
||||
admissionStarted.resolve()
|
||||
await new Promise<void>((resolve) => {
|
||||
if (signal.aborted) {
|
||||
|
||||
@@ -126,7 +126,7 @@ describe("run interactive runtime", () => {
|
||||
turnStarted.resolve()
|
||||
api.close()
|
||||
},
|
||||
admitPromptTurn: async () => {},
|
||||
queuePromptTurn: async () => {},
|
||||
waitForIdle: async () => {},
|
||||
interruptActiveTurn: async () => {},
|
||||
selectSubagent: () => {},
|
||||
@@ -209,7 +209,7 @@ describe("run interactive runtime", () => {
|
||||
streamStarted.resolve()
|
||||
return {
|
||||
runPromptTurn: async () => {},
|
||||
admitPromptTurn: async () => {},
|
||||
queuePromptTurn: async () => {},
|
||||
waitForIdle: async () => {},
|
||||
interruptActiveTurn: async () => {},
|
||||
selectSubagent: () => {},
|
||||
@@ -556,7 +556,7 @@ describe("run interactive runtime", () => {
|
||||
setTimeout(() => input.footer.close(), 0)
|
||||
return {
|
||||
runPromptTurn: async () => {},
|
||||
admitPromptTurn: async () => {},
|
||||
queuePromptTurn: async () => {},
|
||||
waitForIdle: async () => {},
|
||||
interruptActiveTurn: async () => {},
|
||||
selectSubagent: () => {},
|
||||
|
||||
@@ -669,14 +669,6 @@ 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",
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
@@ -692,14 +684,11 @@ describe("V2 mini transport", () => {
|
||||
.findLast((item) => item.type === "queued.prompts")
|
||||
?.prompts.map((item) => [item.messageID, item.delivery])
|
||||
|
||||
expect(pending()).toEqual([
|
||||
["msg_queued", "queue"],
|
||||
["msg_cancelled", "queue"],
|
||||
])
|
||||
expect(pending()).toEqual([["msg_queued", "queue"]])
|
||||
events.push({
|
||||
id: "evt_steered",
|
||||
created: 3,
|
||||
type: "session.input.steered",
|
||||
id: "evt_promoted",
|
||||
created: 2,
|
||||
type: "session.input.promoted",
|
||||
durable: durable("ses_1", 2),
|
||||
data: { sessionID: "ses_1", inputID: "msg_queued" },
|
||||
})
|
||||
@@ -708,48 +697,18 @@ describe("V2 mini transport", () => {
|
||||
expect(ui.commits).toContainEqual(
|
||||
expect.objectContaining({ kind: "user", messageID: "msg_queued", text: "follow up" }),
|
||||
)
|
||||
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)
|
||||
expect(pending()).toEqual([])
|
||||
const prompt = spyOn(client.session, "prompt").mockImplementation(
|
||||
(request) => ok(promptAdmission(request)) as never,
|
||||
)
|
||||
await transport.admitPromptTurn({
|
||||
await transport.queuePromptTurn({
|
||||
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({
|
||||
@@ -763,8 +722,15 @@ describe("V2 mini transport", () => {
|
||||
input: { type: "user", data: { text: "earlier" }, delivery: "steer" },
|
||||
},
|
||||
})
|
||||
await Bun.sleep(10)
|
||||
expect(pending()).toEqual([["msg_next", "queue"]])
|
||||
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 transport.close()
|
||||
})
|
||||
|
||||
@@ -847,14 +813,14 @@ describe("V2 mini transport", () => {
|
||||
durable: durable("ses_1", 2),
|
||||
data: { sessionID: "ses_1", inputID: "msg_prompt" },
|
||||
})
|
||||
await transport.admitPromptTurn({
|
||||
await transport.queuePromptTurn({
|
||||
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": 15000
|
||||
"tokens": 8000
|
||||
},
|
||||
"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` | `15000` | Approximate number of tokens from the newest serialized conversation context to retain beside the summary. |
|
||||
| `keep.tokens` | `8000` | 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`
|
||||
|
||||
+8
-16
@@ -246,27 +246,19 @@ Runtime hooks intercept live operations:
|
||||
| `ctx.aisdk.hook("sdk", callback)` | `sdk`, after inspecting `model`, `package`, and `options` |
|
||||
| `ctx.aisdk.hook("language", callback)` | `language`, after inspecting `model`, `sdk`, and `options` |
|
||||
| `ctx.session.hook("context", callback)` | `system`, `messages`, and the `tools` record immediately before model dispatch |
|
||||
| `ctx.session.hook("http.request", callback)` | `request`, immediately before provider dispatch |
|
||||
| `ctx.session.hook("http.response", callback)` | `response`, immediately after the provider responds |
|
||||
| `ctx.session.hook("http", callback)` | `use`, registering request and response handling |
|
||||
| `ctx.tool.hook("execute.before", callback)` | `input`, before the selected tool executes |
|
||||
| `ctx.tool.hook("execute.after", callback)` | Terminal `result` on success or `error` on failure |
|
||||
|
||||
HTTP hooks can modify requests and responses. They apply to native models; AI
|
||||
SDK models do not currently pass through these hooks. Request and response
|
||||
bodies are one-shot streams. Use `clone()` when you intentionally need a
|
||||
separate reader, but be aware that its slower branch may buffer data. To inspect
|
||||
or modify chunks while preserving streaming, replace the body with one piped
|
||||
through a `TransformStream`.
|
||||
HTTP hooks can modify requests, inspect responses, retry, or return a
|
||||
response without calling the provider. It applies to native models; AI SDK
|
||||
models do not currently pass through this hook.
|
||||
|
||||
```ts
|
||||
await ctx.session.hook("http.request", (event) => {
|
||||
event.request.headers.set("x-session-id", event.sessionID)
|
||||
})
|
||||
|
||||
await ctx.session.hook("http.response", (event) => {
|
||||
event.response = new Response(event.response.body, {
|
||||
status: event.response.status,
|
||||
headers: { ...Object.fromEntries(event.response.headers), "x-plugin": "enabled" },
|
||||
await ctx.session.hook("http", (event) => {
|
||||
event.use((request, next) => {
|
||||
request.headers.set("x-session-id", event.sessionID)
|
||||
return next(request)
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
@@ -329,7 +329,7 @@ Control automatic context compaction and how much recent context it preserves.
|
||||
"compaction": {
|
||||
"auto": true,
|
||||
"keep": {
|
||||
"tokens": 15000
|
||||
"tokens": 8000
|
||||
},
|
||||
"buffer": 20000
|
||||
}
|
||||
|
||||
+1539
-690
File diff suppressed because it is too large
Load Diff
+1539
-690
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user