Compare commits

..

9 Commits

Author SHA1 Message Date
Kit Langton 13b2c81a25 feat(tui): manage pending steers 2026-08-06 21:52:32 -04:00
Kit Langton d81ae0f0d4 feat(tui): manage queued prompts 2026-08-06 21:28:32 -04:00
Kit Langton 9b021f5879 fix(tui): keep queued dock rail transparent 2026-08-06 22:08:40 +00:00
Kit Langton 7426ccc3ab fix(tui): remove queued dock background 2026-08-06 22:04:26 +00:00
Kit Langton ca8144359e fix(tui): reduce queued prompt chrome 2026-08-06 21:58:47 +00:00
Kit Langton 143f6a7f66 fix(tui): refine queued prompt dock 2026-08-06 21:47:26 +00:00
Kit Langton 98622d247a fix(tui): dock queued prompts by composer 2026-08-06 20:56:54 +00:00
Kit Langton 9d348a7f39 feat(tui): collapse large prompt queues 2026-08-06 20:49:01 +00:00
Kit Langton d4686f247b feat(tui): queue prompts with option enter 2026-08-06 20:26:05 +00:00
52 changed files with 1555 additions and 2080 deletions
@@ -153,4 +153,88 @@ describe("v2 session reducer", () => {
expect(result).toMatchObject({ sessionID: "ses_1", missing: "msg_user", touched: [] })
})
test("removes cancelled input from the pending promotion fold", () => {
const reducer = createV2SessionReducer()
reducer.reduce(
[],
event({
...base,
id: "evt_admitted",
type: "session.input.admitted",
data: {
sessionID: "ses_1",
inputID: "msg_user",
input: { type: "user", delivery: "queue", data: { text: "cancel me" } },
},
}),
)
reducer.reduce(
[],
event({
...base,
id: "evt_cancelled",
type: "session.input.cancelled",
data: { sessionID: "ses_1", inputID: "msg_user" },
}),
)
const result = reducer.reduce(
[],
event({
...base,
id: "evt_promoted",
type: "session.input.promoted",
data: { sessionID: "ses_1", inputID: "msg_user" },
}),
)
expect(result).toMatchObject({ missing: "msg_user" })
})
test("keeps steered input available to the promotion fold", () => {
const reducer = createV2SessionReducer()
reducer.reduce(
[],
event({
...base,
id: "evt_admitted",
type: "session.input.admitted",
data: {
sessionID: "ses_1",
inputID: "msg_user",
input: { type: "user", delivery: "queue", data: { text: "steer me" } },
},
}),
)
reducer.reduce(
[],
event({
...base,
id: "evt_steered",
type: "session.input.steered",
data: { sessionID: "ses_1", inputID: "msg_user" },
}),
)
reducer.reduce(
[],
event({
...base,
id: "evt_queued",
type: "session.input.queued",
data: { sessionID: "ses_1", inputID: "msg_user" },
}),
)
const result = reducer.reduce(
[],
event({
...base,
id: "evt_promoted",
type: "session.input.promoted",
data: { sessionID: "ses_1", inputID: "msg_user" },
}),
)
expect(result?.messages).toMatchObject([{ id: "msg_user", type: "user", text: "steer me" }])
})
})
@@ -29,6 +29,9 @@ export function createV2SessionReducer() {
case "session.input.admitted":
pending.set(key(sessionID, event.data.inputID), event.data.input)
return result([...source])
case "session.input.cancelled":
pending.delete(key(sessionID, event.data.inputID))
return
case "session.input.promoted": {
const input = pending.get(key(sessionID, event.data.inputID))
pending.delete(key(sessionID, event.data.inputID))
+73 -27
View File
@@ -251,38 +251,52 @@ export type Endpoint5_21Input = { readonly sessionID: Session.ID }
export type Endpoint5_21Output = ReadonlyArray<SessionPending.Info>
export type SessionPendingListOperation<E = never> = (input: Endpoint5_21Input) => Effect.Effect<Endpoint5_21Output, E>
export type Endpoint5_22Input = { readonly sessionID: Session.ID }
export type Endpoint5_22Output = ReadonlyArray<InstructionEntry.Info>
export type SessionInstructionsEntryListOperation<E = never> = (
export type Endpoint5_22Input = { readonly sessionID: Session.ID; readonly inputID: SessionMessage.ID }
export type Endpoint5_22Output = void
export type SessionPendingCancelOperation<E = never> = (
input: Endpoint5_22Input,
) => Effect.Effect<Endpoint5_22Output, E>
export type Endpoint5_23Input = {
export type Endpoint5_23Input = { readonly sessionID: Session.ID; readonly inputID: SessionMessage.ID }
export type Endpoint5_23Output = void
export type SessionPendingSteerOperation<E = never> = (input: Endpoint5_23Input) => Effect.Effect<Endpoint5_23Output, E>
export type Endpoint5_24Input = { readonly sessionID: Session.ID; readonly inputID: SessionMessage.ID }
export type Endpoint5_24Output = void
export type SessionPendingQueueOperation<E = never> = (input: Endpoint5_24Input) => Effect.Effect<Endpoint5_24Output, E>
export type Endpoint5_25Input = { readonly sessionID: Session.ID }
export type Endpoint5_25Output = ReadonlyArray<InstructionEntry.Info>
export type SessionInstructionsEntryListOperation<E = never> = (
input: Endpoint5_25Input,
) => Effect.Effect<Endpoint5_25Output, E>
export type Endpoint5_26Input = {
readonly sessionID: Session.ID
readonly key: InstructionEntry.Key
readonly value: Schema.Json
}
export type Endpoint5_23Output = void
export type Endpoint5_26Output = void
export type SessionInstructionsEntryPutOperation<E = never> = (
input: Endpoint5_23Input,
) => Effect.Effect<Endpoint5_23Output, E>
input: Endpoint5_26Input,
) => Effect.Effect<Endpoint5_26Output, E>
export type Endpoint5_24Input = { readonly sessionID: Session.ID; readonly key: InstructionEntry.Key }
export type Endpoint5_24Output = void
export type Endpoint5_27Input = { readonly sessionID: Session.ID; readonly key: InstructionEntry.Key }
export type Endpoint5_27Output = void
export type SessionInstructionsEntryRemoveOperation<E = never> = (
input: Endpoint5_24Input,
) => Effect.Effect<Endpoint5_24Output, E>
input: Endpoint5_27Input,
) => Effect.Effect<Endpoint5_27Output, E>
export type Endpoint5_25Input = { readonly sessionID: Session.ID; readonly prompt: string }
export type Endpoint5_25Output = { readonly text: string }
export type SessionGenerateOperation<E = never> = (input: Endpoint5_25Input) => Effect.Effect<Endpoint5_25Output, E>
export type Endpoint5_28Input = { readonly sessionID: Session.ID; readonly prompt: string }
export type Endpoint5_28Output = { readonly text: string }
export type SessionGenerateOperation<E = never> = (input: Endpoint5_28Input) => Effect.Effect<Endpoint5_28Output, E>
export type Endpoint5_26Input = {
export type Endpoint5_29Input = {
readonly sessionID: Session.ID
readonly after?: Event.Seq | undefined
readonly follow?: boolean | undefined
}
export type Endpoint5_26Output =
export type Endpoint5_29Output =
| (
| {
readonly id: Event.ID
@@ -392,6 +406,33 @@ export type Endpoint5_26Output =
readonly input: SessionPending.Message
}
}
| {
readonly id: Event.ID
readonly created: DateTime.Utc
readonly metadata?: { readonly [x: string]: unknown } | undefined
readonly type: "session.input.cancelled"
readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
readonly location?: Location.Ref | undefined
readonly data: { readonly sessionID: Session.ID; readonly inputID: SessionMessage.ID }
}
| {
readonly id: Event.ID
readonly created: DateTime.Utc
readonly metadata?: { readonly [x: string]: unknown } | undefined
readonly type: "session.input.steered"
readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
readonly location?: Location.Ref | undefined
readonly data: { readonly sessionID: Session.ID; readonly inputID: SessionMessage.ID }
}
| {
readonly id: Event.ID
readonly created: DateTime.Utc
readonly metadata?: { readonly [x: string]: unknown } | undefined
readonly type: "session.input.queued"
readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
readonly location?: Location.Ref | undefined
readonly data: { readonly sessionID: Session.ID; readonly inputID: SessionMessage.ID }
}
| {
readonly id: Event.ID
readonly created: DateTime.Utc
@@ -850,19 +891,19 @@ export type Endpoint5_26Output =
}
)
| EventLog.Synced
export type SessionLogOperation<E = never> = (input: Endpoint5_26Input) => Stream.Stream<Endpoint5_26Output, E>
export type SessionLogOperation<E = never> = (input: Endpoint5_29Input) => Stream.Stream<Endpoint5_29Output, E>
export type Endpoint5_27Input = { readonly sessionID: Session.ID }
export type Endpoint5_27Output = void
export type SessionInterruptOperation<E = never> = (input: Endpoint5_27Input) => Effect.Effect<Endpoint5_27Output, E>
export type Endpoint5_30Input = { readonly sessionID: Session.ID }
export type Endpoint5_30Output = void
export type SessionInterruptOperation<E = never> = (input: Endpoint5_30Input) => Effect.Effect<Endpoint5_30Output, E>
export type Endpoint5_28Input = { readonly sessionID: Session.ID }
export type Endpoint5_28Output = void
export type SessionBackgroundOperation<E = never> = (input: Endpoint5_28Input) => Effect.Effect<Endpoint5_28Output, E>
export type Endpoint5_31Input = { readonly sessionID: Session.ID }
export type Endpoint5_31Output = void
export type SessionBackgroundOperation<E = never> = (input: Endpoint5_31Input) => Effect.Effect<Endpoint5_31Output, E>
export type Endpoint5_29Input = { readonly sessionID: Session.ID; readonly messageID: SessionMessage.ID }
export type Endpoint5_29Output = SessionMessage.Info
export type SessionMessageOperation<E = never> = (input: Endpoint5_29Input) => Effect.Effect<Endpoint5_29Output, E>
export type Endpoint5_32Input = { readonly sessionID: Session.ID; readonly messageID: SessionMessage.ID }
export type Endpoint5_32Output = SessionMessage.Info
export type SessionMessageOperation<E = never> = (input: Endpoint5_32Input) => Effect.Effect<Endpoint5_32Output, E>
export interface SessionApi<E = never> {
readonly list: SessionListOperation<E>
@@ -888,7 +929,12 @@ export interface SessionApi<E = never> {
readonly commit: SessionRevertCommitOperation<E>
}
readonly context: SessionContextOperation<E>
readonly pending: { readonly list: SessionPendingListOperation<E> }
readonly pending: {
readonly list: SessionPendingListOperation<E>
readonly cancel: SessionPendingCancelOperation<E>
readonly steer: SessionPendingSteerOperation<E>
readonly queue: SessionPendingQueueOperation<E>
}
readonly instructions: {
readonly entry: {
readonly list: SessionInstructionsEntryListOperation<E>
+49 -22
View File
@@ -76,6 +76,12 @@ import type {
Endpoint5_28Output,
Endpoint5_29Input,
Endpoint5_29Output,
Endpoint5_30Input,
Endpoint5_30Output,
Endpoint5_31Input,
Endpoint5_31Output,
Endpoint5_32Input,
Endpoint5_32Output,
Endpoint6_0Input,
Endpoint6_0Output,
Endpoint7_0Input,
@@ -501,37 +507,58 @@ const Endpoint5_21 = (raw: RawClient["server.session"]) => (input: Endpoint5_21I
const Endpoint5_22 = (raw: RawClient["server.session"]) => (input: Endpoint5_22Input) =>
preserveEffect<Endpoint5_22Output>()(
raw["session.instructions.entry.list"]({ params: { sessionID: input["sessionID"] } }).pipe(
raw["session.pending.cancel"]({ params: { sessionID: input["sessionID"], inputID: input["inputID"] } }).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
),
)
const Endpoint5_23 = (raw: RawClient["server.session"]) => (input: Endpoint5_23Input) =>
preserveEffect<Endpoint5_23Output>()(
raw["session.instructions.entry.put"]({
params: { sessionID: input["sessionID"], key: input["key"] },
payload: { value: input["value"] },
}).pipe(Effect.mapError(mapClientError)),
raw["session.pending.steer"]({ params: { sessionID: input["sessionID"], inputID: input["inputID"] } }).pipe(
Effect.mapError(mapClientError),
),
)
const Endpoint5_24 = (raw: RawClient["server.session"]) => (input: Endpoint5_24Input) =>
preserveEffect<Endpoint5_24Output>()(
raw["session.instructions.entry.remove"]({ params: { sessionID: input["sessionID"], key: input["key"] } }).pipe(
raw["session.pending.queue"]({ params: { sessionID: input["sessionID"], inputID: input["inputID"] } }).pipe(
Effect.mapError(mapClientError),
),
)
const Endpoint5_25 = (raw: RawClient["server.session"]) => (input: Endpoint5_25Input) =>
preserveEffect<Endpoint5_25Output>()(
raw["session.generate"]({ params: { sessionID: input["sessionID"] }, payload: { prompt: input["prompt"] } }).pipe(
raw["session.instructions.entry.list"]({ params: { sessionID: input["sessionID"] } }).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
),
)
const Endpoint5_26 = (raw: RawClient["server.session"]) => (input: Endpoint5_26Input) =>
preserveStream<Endpoint5_26Output>()(
preserveEffect<Endpoint5_26Output>()(
raw["session.instructions.entry.put"]({
params: { sessionID: input["sessionID"], key: input["key"] },
payload: { value: input["value"] },
}).pipe(Effect.mapError(mapClientError)),
)
const Endpoint5_27 = (raw: RawClient["server.session"]) => (input: Endpoint5_27Input) =>
preserveEffect<Endpoint5_27Output>()(
raw["session.instructions.entry.remove"]({ params: { sessionID: input["sessionID"], key: input["key"] } }).pipe(
Effect.mapError(mapClientError),
),
)
const Endpoint5_28 = (raw: RawClient["server.session"]) => (input: Endpoint5_28Input) =>
preserveEffect<Endpoint5_28Output>()(
raw["session.generate"]({ params: { sessionID: input["sessionID"] }, payload: { prompt: input["prompt"] } }).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
),
)
const Endpoint5_29 = (raw: RawClient["server.session"]) => (input: Endpoint5_29Input) =>
preserveStream<Endpoint5_29Output>()(
Stream.unwrap(
raw["session.log"]({
params: { sessionID: input["sessionID"] },
@@ -543,18 +570,18 @@ const Endpoint5_26 = (raw: RawClient["server.session"]) => (input: Endpoint5_26I
),
)
const Endpoint5_27 = (raw: RawClient["server.session"]) => (input: Endpoint5_27Input) =>
preserveEffect<Endpoint5_27Output>()(
const Endpoint5_30 = (raw: RawClient["server.session"]) => (input: Endpoint5_30Input) =>
preserveEffect<Endpoint5_30Output>()(
raw["session.interrupt"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)),
)
const Endpoint5_28 = (raw: RawClient["server.session"]) => (input: Endpoint5_28Input) =>
preserveEffect<Endpoint5_28Output>()(
const Endpoint5_31 = (raw: RawClient["server.session"]) => (input: Endpoint5_31Input) =>
preserveEffect<Endpoint5_31Output>()(
raw["session.background"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)),
)
const Endpoint5_29 = (raw: RawClient["server.session"]) => (input: Endpoint5_29Input) =>
preserveEffect<Endpoint5_29Output>()(
const Endpoint5_32 = (raw: RawClient["server.session"]) => (input: Endpoint5_32Input) =>
preserveEffect<Endpoint5_32Output>()(
raw["session.message"]({ params: { sessionID: input["sessionID"], messageID: input["messageID"] } }).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
@@ -581,13 +608,13 @@ const adaptGroup5 = (raw: RawClient["server.session"]) => ({
wait: Endpoint5_16(raw),
revert: { stage: Endpoint5_17(raw), clear: Endpoint5_18(raw), commit: Endpoint5_19(raw) },
context: Endpoint5_20(raw),
pending: { list: Endpoint5_21(raw) },
instructions: { entry: { list: Endpoint5_22(raw), put: Endpoint5_23(raw), remove: Endpoint5_24(raw) } },
generate: Endpoint5_25(raw),
log: Endpoint5_26(raw),
interrupt: Endpoint5_27(raw),
background: Endpoint5_28(raw),
message: Endpoint5_29(raw),
pending: { list: Endpoint5_21(raw), cancel: Endpoint5_22(raw), steer: Endpoint5_23(raw), queue: Endpoint5_24(raw) },
instructions: { entry: { list: Endpoint5_25(raw), put: Endpoint5_26(raw), remove: Endpoint5_27(raw) } },
generate: Endpoint5_28(raw),
log: Endpoint5_29(raw),
interrupt: Endpoint5_30(raw),
background: Endpoint5_31(raw),
message: Endpoint5_32(raw),
})
const Endpoint6_0 = (raw: RawClient["server.message"]) => (input: Endpoint6_0Input) =>
@@ -54,6 +54,12 @@ import type {
SessionContextOutput,
SessionPendingListInput,
SessionPendingListOutput,
SessionPendingCancelInput,
SessionPendingCancelOutput,
SessionPendingSteerInput,
SessionPendingSteerOutput,
SessionPendingQueueInput,
SessionPendingQueueOutput,
SessionInstructionsEntryListInput,
SessionInstructionsEntryListOutput,
SessionInstructionsEntryPutInput,
@@ -738,6 +744,39 @@ export function make(options: ClientOptions) {
},
requestOptions,
).then((value) => value.data),
cancel: (input: SessionPendingCancelInput, requestOptions?: RequestOptions) =>
request<SessionPendingCancelOutput>(
{
method: "DELETE",
path: `/api/session/${encodeURIComponent(input.sessionID)}/pending/${encodeURIComponent(input.inputID)}`,
successStatus: 204,
declaredStatuses: [409, 404, 401, 400],
empty: true,
},
requestOptions,
),
steer: (input: SessionPendingSteerInput, requestOptions?: RequestOptions) =>
request<SessionPendingSteerOutput>(
{
method: "POST",
path: `/api/session/${encodeURIComponent(input.sessionID)}/pending/${encodeURIComponent(input.inputID)}/steer`,
successStatus: 204,
declaredStatuses: [409, 404, 401, 400],
empty: true,
},
requestOptions,
),
queue: (input: SessionPendingQueueInput, requestOptions?: RequestOptions) =>
request<SessionPendingQueueOutput>(
{
method: "POST",
path: `/api/session/${encodeURIComponent(input.sessionID)}/pending/${encodeURIComponent(input.inputID)}/queue`,
successStatus: 204,
declaredStatuses: [409, 404, 401, 400],
empty: true,
},
requestOptions,
),
},
instructions: {
entry: {
@@ -500,6 +500,36 @@ export type SessionInputPromoted = {
data: { sessionID: string; inputID: string }
}
export type SessionInputCancelled = {
id: string
created: number
metadata?: { [x: string]: any }
type: "session.input.cancelled"
durable: { aggregateID: string; seq: number; version: 1 }
location?: LocationRef
data: { sessionID: string; inputID: string }
}
export type SessionInputSteered = {
id: string
created: number
metadata?: { [x: string]: any }
type: "session.input.steered"
durable: { aggregateID: string; seq: number; version: 1 }
location?: LocationRef
data: { sessionID: string; inputID: string }
}
export type SessionInputQueued = {
id: string
created: number
metadata?: { [x: string]: any }
type: "session.input.queued"
durable: { aggregateID: string; seq: number; version: 1 }
location?: LocationRef
data: { sessionID: string; inputID: string }
}
export type SessionExecutionStarted = {
id: string
created: number
@@ -1964,6 +1994,9 @@ export type SessionEventDurable =
| SessionForked
| SessionInputPromoted
| SessionInputAdmitted
| SessionInputCancelled
| SessionInputSteered
| SessionInputQueued
| SessionExecutionStarted
| SessionExecutionSucceeded
| SessionExecutionFailed
@@ -2016,6 +2049,9 @@ export type V2Event =
| SessionForked
| SessionInputPromoted
| SessionInputAdmitted
| SessionInputCancelled
| SessionInputSteered
| SessionInputQueued
| SessionExecutionStarted
| SessionExecutionSucceeded
| SessionExecutionFailed
@@ -2934,6 +2970,27 @@ export type SessionPendingListInput = { readonly sessionID: { readonly sessionID
export type SessionPendingListOutput = { data: Array<SessionPendingInfo> }["data"]
export type SessionPendingCancelInput = {
readonly sessionID: { readonly sessionID: string; readonly inputID: string }["sessionID"]
readonly inputID: { readonly sessionID: string; readonly inputID: string }["inputID"]
}
export type SessionPendingCancelOutput = void
export type SessionPendingSteerInput = {
readonly sessionID: { readonly sessionID: string; readonly inputID: string }["sessionID"]
readonly inputID: { readonly sessionID: string; readonly inputID: string }["inputID"]
}
export type SessionPendingSteerOutput = void
export type SessionPendingQueueInput = {
readonly sessionID: { readonly sessionID: string; readonly inputID: string }["sessionID"]
readonly inputID: { readonly sessionID: string; readonly inputID: string }["inputID"]
}
export type SessionPendingQueueOutput = void
export type SessionInstructionsEntryListInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] }
export type SessionInstructionsEntryListOutput = { data: Array<InstructionEntryInfo> }["data"]
+23
View File
@@ -32,6 +32,7 @@ test("exposes every standard HTTP API group", () => {
"projectCopy",
"vcs",
"debug",
"migration",
"websearch",
"config",
])
@@ -356,6 +357,28 @@ test("session.pending.list uses the public HTTP contract", async () => {
expect(requests).toEqual([{ method: "GET", url: "http://localhost:3000/api/session/ses_test/pending" }])
})
test("session.pending mutations use the public HTTP contract", async () => {
const requests: Array<{ method: string; url: string }> = []
const client = OpenCode.make({
baseUrl: "http://localhost:3000",
fetch: async (input, init) => {
const request = input instanceof Request ? input : new Request(input, init)
requests.push({ method: request.method, url: request.url })
return new Response(null, { status: 204 })
},
})
await client.session.pending.cancel({ sessionID: "ses_test", inputID: "msg_cancel" })
await client.session.pending.steer({ sessionID: "ses_test", inputID: "msg_steer" })
await client.session.pending.queue({ sessionID: "ses_test", inputID: "msg_queue" })
expect(requests).toEqual([
{ method: "DELETE", url: "http://localhost:3000/api/session/ses_test/pending/msg_cancel" },
{ method: "POST", url: "http://localhost:3000/api/session/ses_test/pending/msg_steer/steer" },
{ method: "POST", url: "http://localhost:3000/api/session/ses_test/pending/msg_queue/queue" },
])
})
test("event.subscribe exposes the Promise event stream wire projection", async () => {
const client = OpenCode.make({
baseUrl: "http://localhost:3000",
+16 -34
View File
@@ -24,7 +24,8 @@ import { Global } from "@opencode-ai/util/global"
import { Location } from "./location"
import { AbsolutePath } from "./schema"
import { ConfigVariable } from "./config/variable"
import { ConfigNormalize } from "./config/normalize"
import { ConfigV1 } from "./v1/config/config"
import { ConfigMigrateV1 } from "./v1/config/migrate"
import { WellKnown } from "./wellknown"
export function latest<K extends keyof Info>(entries: readonly Entry[], key: K): Info[K] | undefined {
@@ -92,43 +93,24 @@ export const layer = (options?: Options) => Layer.effect(
const reloadLock = Semaphore.makeUnsafe(1)
const decodeOptions = { errors: "all", onExcessProperty: "ignore", propertyOrder: "original" } as const
const decodeInfo = Schema.decodeUnknownOption(Info, decodeOptions)
const parseInfo = Effect.fn("Config.parseInfo")(function* (text: string, source: string) {
const decodeV1Info = Schema.decodeUnknownOption(ConfigV1.Info, decodeOptions)
const parseInfo = (text: string) => {
const errors: ParseError[] = []
const input: unknown = parse(text, errors, { allowTrailingComma: true })
if (errors.length) {
yield* Effect.logWarning("configuration normalization diagnostic", {
source,
path: "$",
kind: "invalid",
action: "rejected malformed JSON or JSONC document",
})
return
}
const result = ConfigNormalize.normalize(input)
yield* Effect.forEach(result.diagnostics, (diagnostic) =>
Effect.logWarning("configuration normalization diagnostic", {
source,
path: diagnostic.path[0] === "$" ? "$" : `$.${diagnostic.path.join(".")}`,
kind: diagnostic.kind,
action: diagnostic.message,
}),
if (errors.length) return
return Option.getOrUndefined(
ConfigMigrateV1.isV1(input)
? decodeV1Info(input).pipe(Option.map(ConfigMigrateV1.migrate), Option.flatMap(decodeInfo))
: decodeInfo(input),
)
if (result.type === "rejected") return
const info = Option.getOrUndefined(decodeInfo(result.encoded))
if (info) return info
yield* Effect.logWarning("configuration normalization diagnostic", {
source,
path: "$",
kind: "invalid",
action: "rejected canonical configuration after final validation",
})
})
}
const loadFile = Effect.fnUntraced(function* (filepath: string) {
const text = yield* fs.readFileStringSafe(filepath)
if (text === undefined) return
if (!text) return
const substituted = yield* ConfigVariable.substitute({ type: "path", path: filepath, text })
const info = yield* parseInfo(substituted, filepath)
const info = parseInfo(substituted)
if (!info) return
return new Document({ type: "document", path: filepath, info })
})
@@ -159,7 +141,7 @@ export const layer = (options?: Options) => Layer.effect(
text: JSON.stringify(config),
env: variables,
}).pipe(
Effect.flatMap((text) => parseInfo(text, entry.origin)),
Effect.map(parseInfo),
Effect.map((info) => (info ? new Document({ type: "document", info }) : undefined)),
),
).pipe(Effect.map((documents) => documents.filter((document) => document !== undefined)))
@@ -236,14 +218,14 @@ export const layer = (options?: Options) => Layer.effect(
Effect.orDie,
)
: []
const content = options?.content !== undefined
const content = options?.content
? yield* ConfigVariable.substitute({
type: "virtual",
source: "OPENCODE_CONFIG_CONTENT",
dir: location.directory,
text: options.content,
}).pipe(
Effect.flatMap((text) => parseInfo(text, "OPENCODE_CONFIG_CONTENT")),
Effect.map(parseInfo),
Effect.map((info) => (info ? [new Document({ type: "document", info })] : [])),
Effect.orDie,
)
-796
View File
@@ -1,796 +0,0 @@
export * as ConfigNormalize from "./normalize"
import { isDeepStrictEqual } from "node:util"
import { Option, Schema } from "effect"
import { Info } from "@opencode-ai/schema/config"
import { ConfigAgent } from "@opencode-ai/schema/config/agent"
import { ConfigCommand } from "@opencode-ai/schema/config/command"
import { ConfigCompaction } from "@opencode-ai/schema/config/compaction"
import { ConfigFormatter } from "@opencode-ai/schema/config/formatter"
import { ConfigLSP } from "@opencode-ai/schema/config/lsp"
import { ConfigMedia } from "@opencode-ai/schema/config/media"
import { ConfigMCP } from "@opencode-ai/schema/config/mcp"
import { ConfigPlugin } from "@opencode-ai/schema/config/plugin"
import { ConfigPolicy } from "@opencode-ai/schema/config/policy"
import { ConfigProvider } from "@opencode-ai/schema/config/provider"
import { ConfigReference } from "@opencode-ai/schema/config/reference"
import { ConfigExperimental } from "@opencode-ai/schema/config/experimental"
import { Permission } from "@opencode-ai/schema/permission"
import { ConfigAgentV1 } from "../v1/config/agent"
import { ConfigAttachmentV1 } from "../v1/config/attachment"
import { ConfigCommandV1 } from "../v1/config/command"
import { ConfigMCPV1 } from "../v1/config/mcp"
import { ConfigPermissionV1 } from "../v1/config/permission"
import { ConfigPluginV1 } from "../v1/config/plugin"
import { ConfigProviderV1 } from "../v1/config/provider"
import { ConfigMigrateV1 } from "../v1/config/migrate"
import { PositiveInt } from "../schema"
export interface Diagnostic {
readonly kind: "conflict" | "invalid" | "unsupported"
readonly path: readonly string[]
readonly message: string
}
export type Result =
| {
readonly type: "normalized"
readonly encoded: Readonly<Record<string, unknown>>
readonly diagnostics: readonly Diagnostic[]
}
| { readonly type: "rejected"; readonly diagnostics: readonly Diagnostic[] }
const options = { errors: "all", onExcessProperty: "ignore", propertyOrder: "original" } as const
const unsupportedTopLevel = ["logLevel", "server", "small_model", "subagent_depth", "layout"] as const
const unsupportedExperimental = [
"disable_paste_summary",
"batch_tool",
"openTelemetry",
"primary_tools",
"continue_loop_on_deny",
] as const
const unsupportedProvider = ["id", "whitelist", "blacklist"] as const
const unsupportedModel = ["release_date", "attachment", "reasoning", "temperature", "experimental"] as const
export function normalize(input: unknown): Result {
if (!isRecord(input))
return {
type: "rejected",
diagnostics: [
{ kind: "invalid", path: ["$"], message: "rejected configuration because its root is not an object" },
],
}
const diagnostics: Diagnostic[] = []
const encoded: Record<string, unknown> = {}
unsupportedTopLevel.forEach((key) => unsupportedIfPresent(input, key, [key], diagnostics))
const legacySnapshots = own(input, "snapshot")
? decodeEncoded(Schema.Boolean, input.snapshot, ["snapshot"], diagnostics)
: undefined
const legacyShare = own(input, "autoshare")
? decodeValue(Schema.Boolean, input.autoshare, ["autoshare"], diagnostics) === true
? "auto"
: undefined
: undefined
const legacyMedia = own(input, "attachment")
? decodeValue(ConfigAttachmentV1.Info, input.attachment, ["attachment"], diagnostics)
: undefined
if (legacyMedia !== undefined) {
const migrated = ConfigMigrateV1.migrate({ attachment: legacyMedia }).media
if (migrated !== undefined) encoded.media = canonical(ConfigMedia.Info, migrated)
}
if (legacySnapshots !== undefined) encoded.snapshots = legacySnapshots
if (legacyShare !== undefined) encoded.share = legacyShare
const legacyReferences = decodeEncodedMap(input.reference, ConfigReference.Entry, ["reference"], diagnostics)
const nativeReferences = decodeEncodedMap(input.references, ConfigReference.Entry, ["references"], diagnostics)
mergeMap(
encoded,
"references",
legacyReferences,
nativeReferences,
isRecord(input.reference) || isRecord(input.references),
diagnostics,
)
const legacyCommands = decodeMap(input.command, ConfigCommandV1.Info, ["command"], diagnostics)
diagnoseSelectionMap(input.command, ["command"], diagnostics)
const migratedCommands = mapValues(legacyCommands, (value) => {
const migrated = ConfigMigrateV1.commands({ value })?.value
return migrated === undefined ? undefined : canonical(ConfigCommand.Info, migrated)
})
const nativeCommands = decodeEncodedMap(input.commands, ConfigCommand.Info, ["commands"], diagnostics)
mergeMap(
encoded,
"commands",
migratedCommands,
nativeCommands,
isRecord(input.command) || isRecord(input.commands),
diagnostics,
)
const legacyAgents = mapValues(decodeMap(input.agent, ConfigAgentV1.Info, ["agent"], diagnostics), (value) =>
canonical(ConfigAgent.Info, ConfigMigrateV1.migrateAgent(value)),
)
const modeAgents = mapValues(decodeMap(input.mode, ConfigAgentV1.Info, ["mode"], diagnostics), (value) =>
canonical(ConfigAgent.Info, ConfigMigrateV1.migrateAgent({ ...value, mode: "primary" })),
)
const migratedAgents = mergeMaps(legacyAgents, modeAgents, ["agents"], diagnostics)
const nativeAgents = decodeEncodedMap(input.agents, ConfigAgent.Info, ["agents"], diagnostics)
diagnoseAgentUnsupported(input.agent, ["agent"], diagnostics)
diagnoseAgentUnsupported(input.mode, ["mode"], diagnostics)
mergeMap(
encoded,
"agents",
migratedAgents,
nativeAgents,
isRecord(input.agent) || isRecord(input.mode) || isRecord(input.agents),
diagnostics,
)
const legacyProviders = migrateProviders(input.provider, diagnostics)
const nativeProviders = decodeEncodedMap(input.providers, ConfigProvider.Info, ["providers"], diagnostics)
mergeMap(
encoded,
"providers",
legacyProviders,
nativeProviders,
isRecord(input.provider) || isRecord(input.providers),
diagnostics,
)
const toolRules = migrateTools(input.tools, diagnostics)
const permissionRules = migratePermissions(input.permission, diagnostics)
const nativePermissions = decodeEncodedList(input.permissions, Permission.Rule, ["permissions"], diagnostics)
const permissions = [...toolRules, ...permissionRules, ...nativePermissions]
if (permissions.length || Array.isArray(input.permissions)) encoded.permissions = permissions
const legacyPlugins = decodeList(input.plugin, ConfigPluginV1.Spec, ["plugin"], diagnostics).map((plugin) =>
typeof plugin === "string" ? plugin : { package: plugin[0], options: plugin[1] },
)
const nativePlugins = decodeEncodedList(input.plugins, ConfigPlugin.Plugin, ["plugins"], diagnostics)
if (legacyPlugins.length || nativePlugins.length || Array.isArray(input.plugin) || Array.isArray(input.plugins))
encoded.plugins = [...legacyPlugins, ...nativePlugins]
normalizeSkills(input, encoded, diagnostics)
normalizeMcp(input, encoded, diagnostics)
normalizeCompaction(input, encoded, diagnostics)
normalizeExperimental(input, encoded, diagnostics)
normalizeWatcher(input, encoded, diagnostics)
normalizeFormatter(input, encoded, diagnostics)
normalizeLsp(input, encoded, diagnostics)
const nativeAtomic = {
$schema: Info.fields.$schema,
shell: Info.fields.shell,
model: Info.fields.model,
default_agent: Info.fields.default_agent,
autoupdate: Info.fields.autoupdate,
share: Info.fields.share,
enterprise: Info.fields.enterprise,
username: Info.fields.username,
snapshots: Info.fields.snapshots,
media: Info.fields.media,
tool_output: Info.fields.tool_output,
websearch: Info.fields.websearch,
warming: Info.fields.warming,
}
Object.entries(nativeAtomic).forEach(([key, schema]) => {
if (!own(input, key)) return
const value = decodeEncoded(schema, input[key], [key], diagnostics)
if (value === undefined) return
overlay(encoded, key, value, [key], diagnostics)
})
const instructions = decodeEncodedList(input.instructions, Schema.String, ["instructions"], diagnostics)
if (instructions.length || Array.isArray(input.instructions)) encoded.instructions = instructions
return { type: "normalized", encoded, diagnostics }
}
function normalizeSkills(input: Record<string, unknown>, encoded: Record<string, unknown>, diagnostics: Diagnostic[]) {
if (!own(input, "skills")) return
if (Array.isArray(input.skills)) {
encoded.skills = decodeEncodedList(input.skills, Schema.String, ["skills"], diagnostics)
return
}
if (!isRecord(input.skills)) {
invalid(["skills"], diagnostics)
return
}
encoded.skills = [
...decodeEncodedList(input.skills.paths, Schema.String, ["skills", "paths"], diagnostics),
...decodeEncodedList(input.skills.urls, Schema.String, ["skills", "urls"], diagnostics),
]
}
function normalizeMcp(input: Record<string, unknown>, encoded: Record<string, unknown>, diagnostics: Diagnostic[]) {
const legacyServers: Record<string, unknown> = {}
const nativeServers: Record<string, unknown> = {}
const timeout: Record<string, unknown> = {}
if (isRecord(input.experimental) && own(input.experimental, "mcp_timeout")) {
const value = decodeEncoded(
PositiveInt,
input.experimental.mcp_timeout,
["experimental", "mcp_timeout"],
diagnostics,
)
if (value !== undefined) {
timeout.catalog = value
timeout.execution = value
}
}
if (own(input, "mcp")) {
if (!isRecord(input.mcp)) invalid(["mcp"], diagnostics)
if (isRecord(input.mcp)) {
Object.entries(input.mcp).forEach(([name, value]) => {
const path = ["mcp", name]
if (isEnabledOnlyMcp(value)) {
diagnostics.push({ kind: "unsupported", path, message: "omitted enabled-only legacy MCP entry" })
return
}
if (name === "servers" && !isDirectLegacyMcp(value)) {
Object.entries(decodeEncodedMap(value, ConfigMCP.Server, path, diagnostics)).forEach(([key, server]) =>
setOwn(nativeServers, key, server),
)
return
}
if (name === "timeout" && !isDirectLegacyMcp(value)) {
normalizeMcpTimeout(value, timeout, path, diagnostics)
return
}
const server = decodeValue(ConfigMCPV1.Info, value, path, diagnostics)
if (server !== undefined)
setOwn(legacyServers, name, canonical(ConfigMCP.Server, ConfigMigrateV1.migrateMcp(server)))
})
}
}
const servers = mergeMaps(legacyServers, nativeServers, ["mcp", "servers"], diagnostics)
if (!Object.keys(servers).length && !Object.keys(timeout).length) {
if (isRecord(input.mcp) && !Object.keys(input.mcp).length) encoded.mcp = {}
return
}
encoded.mcp = {
...(Object.keys(timeout).length ? { timeout } : {}),
...(Object.keys(servers).length ? { servers } : {}),
}
}
function normalizeMcpTimeout(
value: unknown,
timeout: Record<string, unknown>,
path: string[],
diagnostics: Diagnostic[],
) {
if (!isRecord(value)) {
invalid(path, diagnostics)
return
}
const recognized = ["startup", "catalog", "execution"].filter((key) => own(value, key))
if (Object.keys(value).length && !recognized.length) {
invalid(path, diagnostics)
return
}
recognized.forEach((key) => {
const leaf = decodeEncoded(
ConfigMCP.Timeout.fields[key as keyof typeof ConfigMCP.Timeout.fields],
value[key],
[...path, key],
diagnostics,
)
if (leaf === undefined) return
overlay(timeout, key, leaf, [...path, key], diagnostics)
})
}
function normalizeCompaction(
input: Record<string, unknown>,
encoded: Record<string, unknown>,
diagnostics: Diagnostic[],
) {
if (!own(input, "compaction")) return
if (!isRecord(input.compaction)) {
invalid(["compaction"], diagnostics)
return
}
unsupportedIfPresent(input.compaction, "tail_turns", ["compaction", "tail_turns"], diagnostics)
unsupportedIfPresent(input.compaction, "prune", ["compaction", "prune"], diagnostics)
const result: Record<string, unknown> = {}
if (own(input.compaction, "auto")) {
const value = decodeEncoded(
ConfigCompaction.Info.fields.auto,
input.compaction.auto,
["compaction", "auto"],
diagnostics,
)
if (value !== undefined) result.auto = value
}
const legacyTokens = own(input.compaction, "preserve_recent_tokens")
? decodeEncoded(
ConfigCompaction.Keep.fields.tokens,
input.compaction.preserve_recent_tokens,
["compaction", "preserve_recent_tokens"],
diagnostics,
)
: undefined
const nativeKeep = isRecord(input.compaction.keep) ? input.compaction.keep : undefined
if (own(input.compaction, "keep") && !nativeKeep) invalid(["compaction", "keep"], diagnostics)
const nativeTokens =
nativeKeep && own(nativeKeep, "tokens")
? decodeEncoded(
ConfigCompaction.Keep.fields.tokens,
nativeKeep.tokens,
["compaction", "keep", "tokens"],
diagnostics,
)
: undefined
const tokens = prefer(legacyTokens, nativeTokens, ["compaction", "keep", "tokens"], diagnostics)
if (tokens !== undefined) result.keep = { tokens }
const legacyBuffer = own(input.compaction, "reserved")
? decodeEncoded(
ConfigCompaction.Info.fields.buffer,
input.compaction.reserved,
["compaction", "reserved"],
diagnostics,
)
: undefined
const nativeBuffer = own(input.compaction, "buffer")
? decodeEncoded(ConfigCompaction.Info.fields.buffer, input.compaction.buffer, ["compaction", "buffer"], diagnostics)
: undefined
const buffer = prefer(legacyBuffer, nativeBuffer, ["compaction", "buffer"], diagnostics)
if (buffer !== undefined) result.buffer = buffer
if (Object.keys(result).length || !Object.keys(input.compaction).length) encoded.compaction = result
}
function normalizeExperimental(
input: Record<string, unknown>,
encoded: Record<string, unknown>,
diagnostics: Diagnostic[],
) {
const result: Record<string, unknown> = {}
const generated: unknown[] = []
const enabled = decodeProviderList(input, "enabled_providers", diagnostics)
if (enabled.present && (!enabled.nonEmpty || enabled.values.length)) {
generated.push({ action: "provider.use", resource: "*", effect: "deny" })
generated.push(
...enabled.values.map((resource) => ({
action: "provider.use",
resource: ConfigMigrateV1.providerID(resource),
effect: "allow",
})),
)
}
const disabled = decodeProviderList(input, "disabled_providers", diagnostics)
generated.push(
...disabled.values.map((resource) => ({
action: "provider.use",
resource: ConfigMigrateV1.providerID(resource),
effect: "deny",
})),
)
const native: unknown[] = []
if (own(input, "experimental")) {
if (!isRecord(input.experimental)) invalid(["experimental"], diagnostics)
if (isRecord(input.experimental)) {
const experimental = input.experimental
unsupportedExperimental.forEach((key) =>
unsupportedIfPresent(experimental, key, ["experimental", key], diagnostics),
)
if (own(experimental, "subagent_depth")) {
const value = decodeEncoded(
ConfigExperimental.Info.fields.subagent_depth,
experimental.subagent_depth,
["experimental", "subagent_depth"],
diagnostics,
)
if (value !== undefined) result.subagent_depth = value
}
native.push(
...decodeEncodedList(experimental.policies, ConfigPolicy.Info, ["experimental", "policies"], diagnostics),
)
}
}
if (generated.length || native.length || (isRecord(input.experimental) && Array.isArray(input.experimental.policies)))
result.policies = [...generated, ...native]
if (Object.keys(result).length || (isRecord(input.experimental) && !Object.keys(input.experimental).length))
encoded.experimental = result
}
function normalizeWatcher(input: Record<string, unknown>, encoded: Record<string, unknown>, diagnostics: Diagnostic[]) {
if (!own(input, "watcher")) return
if (!isRecord(input.watcher)) {
invalid(["watcher"], diagnostics)
return
}
const ignore = decodeEncodedList(input.watcher.ignore, Schema.String, ["watcher", "ignore"], diagnostics)
encoded.watcher = ignore.length || Array.isArray(input.watcher.ignore) ? { ignore } : {}
}
function normalizeFormatter(
input: Record<string, unknown>,
encoded: Record<string, unknown>,
diagnostics: Diagnostic[],
) {
if (!own(input, "formatter")) return
if (typeof input.formatter === "boolean") {
const value = decodeEncoded(ConfigFormatter.Info, input.formatter, ["formatter"], diagnostics)
if (value !== undefined) encoded.formatter = value
return
}
const entries = decodeEncodedMap(input.formatter, ConfigFormatter.Entry, ["formatter"], diagnostics)
if (isRecord(input.formatter) && (!Object.keys(input.formatter).length || Object.keys(entries).length))
encoded.formatter = entries
}
function normalizeLsp(input: Record<string, unknown>, encoded: Record<string, unknown>, diagnostics: Diagnostic[]) {
if (!own(input, "lsp")) return
if (typeof input.lsp === "boolean") {
const value = decodeEncoded(ConfigLSP.Info, input.lsp, ["lsp"], diagnostics)
if (value !== undefined) encoded.lsp = value
return
}
const entries = decodeEncodedMap(input.lsp, ConfigLSP.Entry, ["lsp"], diagnostics)
if (isRecord(input.lsp) && (!Object.keys(input.lsp).length || Object.keys(entries).length)) encoded.lsp = entries
}
function migrateTools(value: unknown, diagnostics: Diagnostic[]) {
if (value === undefined) return []
if (!isRecord(value)) {
invalid(["tools"], diagnostics)
return []
}
return Object.entries(value).flatMap(([action, raw]) => {
const enabled = decodeValue(Schema.Boolean, raw, ["tools", action], diagnostics)
if (enabled === undefined) return []
return [{ action: ConfigMigrateV1.normalizeAction(action), resource: "*", effect: enabled ? "allow" : "deny" }]
})
}
function migratePermissions(value: unknown, diagnostics: Diagnostic[]) {
if (value === undefined) return []
if (typeof value === "string") {
const effect = decodeValue(ConfigPermissionV1.Action, value, ["permission"], diagnostics)
return effect === undefined ? [] : [{ action: "*", resource: "*", effect }]
}
if (!isRecord(value)) {
invalid(["permission"], diagnostics)
return []
}
return Object.entries(value).flatMap(([action, raw]) => {
if (typeof raw === "string") {
const effect = decodeValue(ConfigPermissionV1.Action, raw, ["permission", action], diagnostics)
return effect === undefined ? [] : [{ action: ConfigMigrateV1.normalizeAction(action), resource: "*", effect }]
}
if (!isRecord(raw)) {
invalid(["permission", action], diagnostics)
return []
}
return Object.entries(raw).flatMap(([resource, effect], index) => {
const decoded = decodeValue(ConfigPermissionV1.Action, effect, ["permission", action, String(index)], diagnostics)
return decoded === undefined
? []
: [{ action: ConfigMigrateV1.normalizeAction(action), resource, effect: decoded }]
})
})
}
function migrateProviders(value: unknown, diagnostics: Diagnostic[]) {
if (value === undefined) return {}
if (!isRecord(value)) {
invalid(["provider"], diagnostics)
return {}
}
const candidates = Object.entries(value).flatMap(([name, raw]) => {
const path = ["provider", name]
diagnoseProviderUnsupported(raw, path, diagnostics)
if (invalidProviderOverlays(raw, path, diagnostics)) return []
const provider = decodeValue(ConfigProviderV1.Info, raw, path, diagnostics)
if (provider === undefined) return []
const destination = ConfigMigrateV1.providerID(name)
return [
{
name,
destination,
provider: canonical(ConfigProvider.Info, ConfigMigrateV1.migrateProvider(name, provider)),
},
]
})
const current = new Set(candidates.filter((item) => item.name === item.destination).map((item) => item.destination))
const result: Record<string, unknown> = {}
candidates.forEach((item) => {
if (item.name !== item.destination && current.has(item.destination)) return
setOwn(result, item.destination, item.provider)
})
return result
}
function invalidProviderOverlays(value: unknown, path: string[], diagnostics: Diagnostic[]) {
if (!isRecord(value) || !isRecord(value.options)) return false
const headersInvalid =
own(value.options, "headers") &&
(!isPlainRecord(value.options.headers) ||
Object.values(value.options.headers).some((item) => typeof item !== "string"))
const bodyInvalid = own(value.options, "body") && !isPlainRecord(value.options.body)
if (headersInvalid) invalid([...path, "options", "headers"], diagnostics)
if (bodyInvalid) invalid([...path, "options", "body"], diagnostics)
return headersInvalid || bodyInvalid
}
function diagnoseProviderUnsupported(value: unknown, path: string[], diagnostics: Diagnostic[]) {
if (!isRecord(value)) return
unsupportedProvider.forEach((key) => unsupportedIfPresent(value, key, [...path, key], diagnostics))
if (!isRecord(value.models)) return
Object.entries(value.models).forEach(([name, model]) => {
if (!isRecord(model)) return
unsupportedModel.forEach((key) => unsupportedIfPresent(model, key, [...path, "models", name, key], diagnostics))
if (own(model, "status") && model.status !== "deprecated")
unsupportedIfPresent(model, "status", [...path, "models", name, "status"], diagnostics)
if (own(model, "interleaved") && typeof model.interleaved === "boolean")
unsupportedIfPresent(model, "interleaved", [...path, "models", name, "interleaved"], diagnostics)
})
}
function diagnoseAgentUnsupported(value: unknown, path: string[], diagnostics: Diagnostic[]) {
if (!isRecord(value)) return
Object.entries(value).forEach(([name, agent]) => {
if (!isRecord(agent)) return
unsupportedIfPresent(agent, "name", [...path, name, "name"], diagnostics)
diagnoseSelection(agent, [...path, name], diagnostics)
})
}
function diagnoseSelectionMap(value: unknown, path: string[], diagnostics: Diagnostic[]) {
if (!isRecord(value)) return
Object.entries(value).forEach(([name, entry]) => {
if (isRecord(entry)) diagnoseSelection(entry, [...path, name], diagnostics)
})
}
function diagnoseSelection(value: Record<string, unknown>, path: string[], diagnostics: Diagnostic[]) {
const modelValid = typeof value.model === "string" && /^[^/#]+\/[^#]+$/.test(value.model)
if (own(value, "model") && typeof value.model === "string" && !modelValid)
diagnostics.push({
kind: "unsupported",
path: [...path, "model"],
message: "omitted unsupported legacy model reference",
})
if (
own(value, "variant") &&
typeof value.variant === "string" &&
(!modelValid || value.variant.length === 0 || value.variant.includes("#"))
)
diagnostics.push({
kind: "unsupported",
path: [...path, "variant"],
message: "omitted unsupported legacy model variant",
})
}
function decodeProviderList(
input: Record<string, unknown>,
key: "enabled_providers" | "disabled_providers",
diagnostics: Diagnostic[],
) {
if (!own(input, key)) return { present: false, nonEmpty: false, values: [] as string[] }
if (!Array.isArray(input[key])) {
invalid([key], diagnostics)
return { present: true, nonEmpty: true, values: [] as string[] }
}
return {
present: true,
nonEmpty: input[key].length > 0,
values: decodeList(input[key], Schema.String, [key], diagnostics),
}
}
function decodeEncodedMap<S extends Schema.Codec<unknown, unknown, never, never>>(
value: unknown,
schema: S,
path: string[],
diagnostics: Diagnostic[],
) {
if (value === undefined) return {}
if (!isRecord(value)) {
invalid(path, diagnostics)
return {}
}
return Object.fromEntries(
Object.entries(value).flatMap(([name, raw]) => {
const decoded = decodeEncoded(schema, raw, [...path, name], diagnostics)
return decoded === undefined ? [] : [[name, decoded]]
}),
)
}
function decodeMap<S extends Schema.Codec<unknown, unknown, never, never>>(
value: unknown,
schema: S,
path: string[],
diagnostics: Diagnostic[],
) {
if (value === undefined) return {} as Record<string, S["Type"]>
if (!isRecord(value)) {
invalid(path, diagnostics)
return {} as Record<string, S["Type"]>
}
return Object.fromEntries(
Object.entries(value).flatMap(([name, raw]) => {
const decoded = decodeValue(schema, raw, [...path, name], diagnostics)
return decoded === undefined ? [] : [[name, decoded]]
}),
) as Record<string, S["Type"]>
}
function decodeEncodedList<S extends Schema.Codec<unknown, unknown, never, never>>(
value: unknown,
schema: S,
path: string[],
diagnostics: Diagnostic[],
) {
if (value === undefined) return [] as S["Encoded"][]
if (!Array.isArray(value)) {
invalid(path, diagnostics)
return [] as S["Encoded"][]
}
return value.flatMap((item, index) => {
const decoded = decodeEncoded(schema, item, [...path, String(index)], diagnostics)
return decoded === undefined ? [] : [decoded]
})
}
function decodeList<S extends Schema.Codec<unknown, unknown, never, never>>(
value: unknown,
schema: S,
path: string[],
diagnostics: Diagnostic[],
) {
if (value === undefined) return [] as S["Type"][]
if (!Array.isArray(value)) {
invalid(path, diagnostics)
return [] as S["Type"][]
}
return value.flatMap((item, index) => {
const decoded = decodeValue(schema, item, [...path, String(index)], diagnostics)
return decoded === undefined ? [] : [decoded]
})
}
function decodeValue<S extends Schema.Codec<unknown, unknown, never, never>>(
schema: S,
value: unknown,
path: string[],
diagnostics: Diagnostic[],
) {
const decoded = Schema.decodeUnknownOption(schema, options)(value)
if (Option.isSome(decoded)) return decoded.value
invalid(path, diagnostics)
return undefined
}
function decodeEncoded<S extends Schema.Codec<unknown, unknown, never, never>>(
schema: S,
value: unknown,
path: string[],
diagnostics: Diagnostic[],
) {
const decoded = Schema.decodeUnknownOption(schema, options)(value)
if (Option.isNone(decoded)) {
invalid(path, diagnostics)
return undefined
}
const encoded = Schema.encodeUnknownOption(schema, options)(decoded.value)
if (Option.isSome(encoded)) return plain(encoded.value)
invalid(path, diagnostics)
return undefined
}
function canonical<S extends Schema.Codec<unknown, unknown, never, never>>(schema: S, value: unknown) {
return plain(
Option.getOrThrow(
Schema.decodeUnknownOption(
schema,
options,
)(plain(value)).pipe(Option.flatMap((decoded) => Schema.encodeUnknownOption(schema, options)(decoded))),
),
)
}
function plain(value: unknown): unknown {
if (Array.isArray(value)) return value.map(plain)
if (!isRecord(value)) return value
return Object.fromEntries(
Object.entries(value).flatMap(([key, item]) => (item === undefined ? [] : [[key, plain(item)]])),
)
}
function mergeMap(
target: Record<string, unknown>,
key: string,
legacy: Readonly<Record<string, unknown>>,
native: Readonly<Record<string, unknown>>,
present: boolean,
diagnostics: Diagnostic[],
) {
const merged = mergeMaps(legacy, native, [key], diagnostics)
if (present) target[key] = merged
}
function mergeMaps(
legacy: Readonly<Record<string, unknown>>,
native: Readonly<Record<string, unknown>>,
path: string[],
diagnostics: Diagnostic[],
) {
const result = Object.fromEntries(Object.entries(legacy))
Object.entries(native).forEach(([name, value]) => {
if (own(result, name) && !isDeepStrictEqual(result[name], value)) conflict([...path, name], diagnostics)
setOwn(result, name, value)
})
return result
}
function mapValues<A>(input: Readonly<Record<string, A>>, map: (value: A) => unknown) {
return Object.fromEntries(
Object.entries(input).flatMap(([key, value]) => {
const mapped = map(value)
return mapped === undefined ? [] : [[key, mapped]]
}),
)
}
function overlay(
target: Record<string, unknown>,
key: string,
value: unknown,
path: string[],
diagnostics: Diagnostic[],
) {
if (own(target, key) && !isDeepStrictEqual(target[key], value)) conflict(path, diagnostics)
target[key] = value
}
function prefer(legacy: unknown, native: unknown, path: string[], diagnostics: Diagnostic[]) {
if (native === undefined) return legacy
if (legacy !== undefined && !isDeepStrictEqual(legacy, native)) conflict(path, diagnostics)
return native
}
function unsupportedIfPresent(value: Record<string, unknown>, key: string, path: string[], diagnostics: Diagnostic[]) {
if (!own(value, key)) return
diagnostics.push({ kind: "unsupported", path, message: "omitted unsupported legacy setting" })
}
function invalid(path: string[], diagnostics: Diagnostic[]) {
diagnostics.push({ kind: "invalid", path, message: "skipped malformed recognized value" })
}
function conflict(path: string[], diagnostics: Diagnostic[]) {
diagnostics.push({ kind: "conflict", path, message: "retained native value over legacy value" })
}
function isDirectLegacyMcp(value: unknown) {
return isRecord(value) && (value.type === "local" || value.type === "remote")
}
function isEnabledOnlyMcp(value: unknown) {
return isRecord(value) && !own(value, "type") && typeof value.enabled === "boolean"
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value)
}
function isPlainRecord(value: unknown): value is Record<string, unknown> {
if (!isRecord(value)) return false
const prototype = Object.getPrototypeOf(value)
return prototype === Object.prototype || prototype === null
}
function own(value: Record<string, unknown>, key: string) {
return Object.prototype.hasOwnProperty.call(value, key)
}
function setOwn(value: Record<string, unknown>, key: string, item: unknown) {
Object.defineProperty(value, key, { value: item, enumerable: true, configurable: true, writable: true })
}
+1 -2
View File
@@ -23,7 +23,7 @@ export interface Interface {
| "synthetic"
| "wait"
>
readonly job: Pick<Job.Interface, "get" | "start" | "wait" | "block" | "background" | "cancel">
readonly job: Pick<Job.Interface, "start" | "wait" | "block" | "background" | "cancel">
readonly location: {
readonly agent: {
readonly list: (
@@ -69,7 +69,6 @@ export const layerWithCell = (cell: Cell) =>
wait: (sessionID) => require(cell, (runtime) => runtime.session.wait(sessionID)),
},
job: {
get: (id) => require(cell, (runtime) => runtime.job.get(id)),
start: (input) => require(cell, (runtime) => runtime.job.start(input)),
wait: (input) => require(cell, (runtime) => runtime.job.wait(input)),
block: (input) => require(cell, (runtime) => runtime.job.block(input)),
+36
View File
@@ -133,6 +133,14 @@ export class CompactionConflictError extends Schema.TaggedErrorClass<CompactionC
export class BusyError extends Schema.TaggedErrorClass<BusyError>()("Session.BusyError", {
sessionID: SessionSchema.ID,
}) {}
export class PendingInputConflictError extends Schema.TaggedErrorClass<PendingInputConflictError>()(
"Session.PendingInputConflictError",
{
sessionID: SessionSchema.ID,
inputID: SessionMessage.ID,
},
) {}
type PendingInputRef = { readonly sessionID: SessionSchema.ID; readonly inputID: SessionMessage.ID }
export class SkillNotFoundError extends Schema.TaggedErrorClass<SkillNotFoundError>()("Session.SkillNotFoundError", {
skill: Skill.ID,
}) {}
@@ -181,6 +189,9 @@ export interface Interface {
* unhandled compaction barriers.
*/
readonly pending: (sessionID: SessionSchema.ID) => Effect.Effect<SessionPending.Info[], NotFoundError>
readonly cancelPending: (input: PendingInputRef) => Effect.Effect<void, NotFoundError | PendingInputConflictError>
readonly steerPending: (input: PendingInputRef) => Effect.Effect<void, NotFoundError | PendingInputConflictError>
readonly queuePending: (input: PendingInputRef) => Effect.Effect<void, NotFoundError | PendingInputConflictError>
/**
* Durable, ordered session log read. Replays durable session bus after
* the exclusive `after` cursor, emits a `Synced` marker at the captured
@@ -318,6 +329,28 @@ const layer = Layer.effect(
),
)
const mutatePending = (
input: PendingInputRef,
mutation: (
bus: Bus.Interface,
input: { readonly id: SessionMessage.ID; readonly sessionID: SessionSchema.ID },
) => Effect.Effect<unknown>,
wake = false,
) =>
Effect.uninterruptible(
Effect.gen(function* () {
yield* result.get(input.sessionID)
yield* mutation(bus, { sessionID: input.sessionID, id: input.inputID }).pipe(
Effect.catchDefect((defect) =>
defect instanceof SessionPending.LifecycleConflict
? new PendingInputConflictError(input)
: Effect.die(defect),
),
)
if (wake) yield* execution.wake(input.sessionID)
}),
)
const result = Service.of({
create: Effect.fn("Session.create")(function* (input) {
const sessionID = input.id ?? SessionSchema.ID.create()
@@ -507,6 +540,9 @@ const layer = Layer.effect(
yield* result.get(sessionID)
return yield* SessionPending.list(db, sessionID)
}),
cancelPending: Effect.fn("Session.cancelPending")((input) => mutatePending(input, SessionPending.cancel)),
steerPending: Effect.fn("Session.steerPending")((input) => mutatePending(input, SessionPending.steer, true)),
queuePending: Effect.fn("Session.queuePending")((input) => mutatePending(input, SessionPending.queue)),
log: (input) =>
Stream.unwrap(
result
@@ -90,6 +90,9 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
"session.forked": () => Effect.void,
"session.input.promoted": () => Effect.void,
"session.input.admitted": () => Effect.void,
"session.input.cancelled": () => Effect.void,
"session.input.steered": () => Effect.void,
"session.input.queued": () => Effect.void,
"session.execution.started": () => Effect.void,
"session.execution.succeeded": () => clearCurrentRetry,
"session.execution.failed": () => clearCurrentRetry,
+93
View File
@@ -312,6 +312,63 @@ export const projectPromoted = Effect.fn("SessionPending.projectPromoted")(funct
return stored
})
export const projectCancelled = Effect.fn("SessionPending.projectCancelled")(function* (
db: DatabaseService,
input: {
readonly id: SessionMessage.ID
readonly sessionID: SessionSchema.ID
},
) {
const deleted = yield* db
.delete(SessionPendingTable)
.where(
and(
eq(SessionPendingTable.id, input.id),
eq(SessionPendingTable.session_id, input.sessionID),
or(eq(SessionPendingTable.delivery, "queue"), eq(SessionPendingTable.delivery, "steer")),
),
)
.returning({ id: SessionPendingTable.id })
.get()
.pipe(Effect.orDie)
if (!deleted) return yield* Effect.die(new LifecycleConflict({ id: input.id }))
})
const projectDelivery = Effect.fn("SessionPending.projectDelivery")(function* (
db: DatabaseService,
input: {
readonly id: SessionMessage.ID
readonly sessionID: SessionSchema.ID
readonly from: Delivery
readonly to: Delivery
},
) {
const updated = yield* db
.update(SessionPendingTable)
.set({ delivery: input.to })
.where(
and(
eq(SessionPendingTable.id, input.id),
eq(SessionPendingTable.session_id, input.sessionID),
eq(SessionPendingTable.delivery, input.from),
),
)
.returning({ id: SessionPendingTable.id })
.get()
.pipe(Effect.orDie)
if (!updated) return yield* Effect.die(new LifecycleConflict({ id: input.id }))
})
export const projectSteered = Effect.fn("SessionPending.projectSteered")(
(db: DatabaseService, input: { readonly id: SessionMessage.ID; readonly sessionID: SessionSchema.ID }) =>
projectDelivery(db, { ...input, from: "queue", to: "steer" }),
)
export const projectQueued = Effect.fn("SessionPending.projectQueued")(
(db: DatabaseService, input: { readonly id: SessionMessage.ID; readonly sessionID: SessionSchema.ID }) =>
projectDelivery(db, { ...input, from: "steer", to: "queue" }),
)
export const settleCompaction = Effect.fn("SessionPending.settleCompaction")(function* (
db: DatabaseService,
input: { readonly sessionID: SessionSchema.ID },
@@ -389,6 +446,42 @@ export const equivalent = (
return false
}
export const cancel = Effect.fn("SessionPending.cancel")(function* (
bus: Bus.Interface,
input: { readonly id: SessionMessage.ID; readonly sessionID: SessionSchema.ID },
) {
yield* inboxLocks.withLock(input.sessionID)(
bus.publish(SessionEvent.InputCancelled, {
sessionID: input.sessionID,
inputID: input.id,
}),
)
})
export const steer = Effect.fn("SessionPending.steer")(function* (
bus: Bus.Interface,
input: { readonly id: SessionMessage.ID; readonly sessionID: SessionSchema.ID },
) {
yield* inboxLocks.withLock(input.sessionID)(
bus.publish(SessionEvent.InputSteered, {
sessionID: input.sessionID,
inputID: input.id,
}),
)
})
export const queue = Effect.fn("SessionPending.queue")(function* (
bus: Bus.Interface,
input: { readonly id: SessionMessage.ID; readonly sessionID: SessionSchema.ID },
) {
yield* inboxLocks.withLock(input.sessionID)(
bus.publish(SessionEvent.InputQueued, {
sessionID: input.sessionID,
inputID: input.id,
}),
)
})
const publish = Effect.fn("SessionPending.publish")(function* (
db: DatabaseService,
bus: Bus.Interface,
+18
View File
@@ -485,6 +485,24 @@ const layer = Layer.effectDiscard(
.pipe(Effect.orDie)
}),
)
yield* bus.project(SessionEvent.InputCancelled, (event) =>
SessionPending.projectCancelled(db, {
id: event.data.inputID,
sessionID: event.data.sessionID,
}),
)
yield* bus.project(SessionEvent.InputSteered, (event) =>
SessionPending.projectSteered(db, {
id: event.data.inputID,
sessionID: event.data.sessionID,
}),
)
yield* bus.project(SessionEvent.InputQueued, (event) =>
SessionPending.projectQueued(db, {
id: event.data.inputID,
sessionID: event.data.sessionID,
}),
)
yield* bus.project(SessionEvent.Compaction.Admitted, (event) =>
Effect.gen(function* () {
if (event.durable === undefined)
+18 -58
View File
@@ -23,10 +23,6 @@ export const Input = Schema.Struct({
agent: Schema.String.annotate({ description: "The type of specialized agent to use for this task" }),
description: Schema.String.annotate({ description: "A short 3-5 word label for the task, displayed to the user" }),
prompt: Schema.String.annotate({ description: "The task for the subagent to perform" }),
sessionID: Schema.optionalKey(SessionSchema.ID).annotate({
description:
"Continue a specific previous subagent conversation by passing its sessionID. Calls without a sessionID start a new conversation.",
}),
background: Schema.optionalKey(Schema.Boolean).annotate({
description:
"Run the subagent in the background and return immediately. You will be notified when it completes. DO NOT sleep, poll, or proactively check on its progress.",
@@ -40,8 +36,7 @@ export const Output = Schema.Struct({
})
export const description = [
"Spawns an agent in a child session to work on the specified task.",
"The output includes a sessionID you can pass back later to continue that specific conversation with the subagent.",
"New child sessions start with fresh context, so include all relevant context and instructions when you don't pass a sessionID.",
"Include all relevant context and instructions in the prompt because the child starts with fresh context.",
"Foreground (default) runs the subagent to completion and returns its final response.",
"Background mode (background=true) launches it asynchronously and returns immediately; you are notified when it finishes.",
"Use background only for independent work that can run while you continue elsewhere.",
@@ -82,7 +77,7 @@ export const Plugin = {
) {
yield* runtime.session.synthetic({
sessionID: parentID,
text: `<subagent sessionID="${childID}" state="${state}" description="${description}">\n${text}\n</subagent>`,
text: `<subagent id="${childID}" state="${state}" description="${description}">\n${text}\n</subagent>`,
description,
metadata: { source: "subagent", childID, agent, state },
})
@@ -169,51 +164,22 @@ export const Plugin = {
})
.pipe(Effect.mapError((error) => new ToolFailure({ message: `Subagent denied: ${agent.id}`, error })))
if (input.sessionID !== undefined && input.background === true)
return yield* new ToolFailure({
message: "Continuing a subagent in the background is not implemented yet",
})
const existing =
input.sessionID === undefined
? undefined
: yield* runtime.session.get(input.sessionID).pipe(
Effect.mapError(
(error) =>
new ToolFailure({ message: `Subagent session not found: ${input.sessionID}`, error }),
),
)
if (existing !== undefined && existing.parentID !== context.sessionID)
return yield* new ToolFailure({
message: `Session ${existing.id} is not a child of the current session`,
})
if (existing !== undefined && existing.agent !== agent.id)
return yield* new ToolFailure({
message: `Session ${existing.id} belongs to agent ${existing.agent ?? "unknown"}, not ${agent.id}`,
})
if (existing !== undefined && (yield* runtime.job.get(existing.id))?.status === "running")
return yield* new ToolFailure({
message: "Continuing a running subagent is not implemented yet",
})
// Model selection is policy/config/session state, not an LLM-facing tool argument.
const model = agent.model ?? parent.model
const child =
existing ??
(yield* runtime.session
.create({
parentID: context.sessionID,
title: input.description,
agent: Agent.ID.make(input.agent),
model,
// TODO(opencode kkdvxn): derive restricted subagent permissions from the parent
// session (V1 deriveSubagentSessionPermission). MVP uses the agent's own permissions.
})
.pipe(
Effect.mapError(
(error) => new ToolFailure({ message: `Parent session not found: ${context.sessionID}`, error }),
),
))
const child = yield* runtime.session
.create({
parentID: context.sessionID,
title: input.description,
agent: Agent.ID.make(input.agent),
model,
// TODO(opencode kkdvxn): derive restricted subagent permissions from the parent
// session (V1 deriveSubagentSessionPermission). MVP uses the agent's own permissions.
})
.pipe(
Effect.mapError(
(error) => new ToolFailure({ message: `Parent session not found: ${context.sessionID}`, error }),
),
)
const background = input.background === true
yield* context.progress({
@@ -224,10 +190,7 @@ export const Plugin = {
// The child session owns its agent/model (set at create); prompt only admits input.
yield* runtime.session.prompt({
sessionID: child.id,
text:
existing === undefined
? ["You are a subagent spawned by another session.", input.prompt].join("\n")
: input.prompt,
text: ["You are a subagent spawned by another session.", input.prompt].join("\n"),
resume: false,
})
yield* runtime.session.resume(child.id)
@@ -275,10 +238,7 @@ export const Plugin = {
}).pipe(
Effect.map((output) => ({
output,
content:
output.status === "completed"
? `<subagent sessionID="${output.sessionID}" state="completed">\n${output.output}\n</subagent>`
: output.output,
content: output.output,
metadata: { sessionID: output.sessionID, status: output.status },
})),
),
+45 -7
View File
@@ -18,6 +18,44 @@ const decodeInfo = Schema.decodeUnknownSync(Schema.fromJsonString(Info), decodeO
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",
"command",
"reference",
"snapshot",
"plugin",
"autoshare",
"disabled_providers",
"enabled_providers",
"small_model",
"mode",
"agent",
"provider",
"permission",
"tools",
"attachment",
"layout",
])
export function isV1(input: unknown) {
if (typeof input !== "object" || input === null || Array.isArray(input)) return false
const record = input as Record<string, unknown>
if (Object.keys(record).some((key) => keys.has(key))) return true
// `mcp` exists in both versions, so presence alone is ambiguous: v1 lists servers directly under
// `mcp`, while v2 nests them under `mcp.servers`. Only the v1 shape (a server entry with `type`)
// counts, so a bare `mcp`-only file still migrates instead of silently parsing to zero servers.
const mcp = record.mcp
return (
typeof mcp === "object" &&
mcp !== null &&
!Array.isArray(mcp) &&
!("servers" in mcp) &&
Object.values(mcp).some((server) => typeof server === "object" && server !== null && "type" in server)
)
}
export function migrate(info: typeof ConfigV1.Info.Type) {
return encodeInfo(
decodeInfo(
@@ -107,7 +145,7 @@ function permissions(info?: ConfigPermissionV1.Info, tools?: Readonly<Record<str
}
// Map v1 permission/tool keys onto their renamed v2 tool actions so migrated rules keep matching.
export function normalizeAction(action: string) {
function normalizeAction(action: string) {
if (action === "write" || action === "patch") return "edit"
if (action === "task") return "subagent"
if (action === "bash") return "shell"
@@ -147,7 +185,7 @@ export function migrateAgent(info: ConfigAgentV1.Info) {
)
}
export function commands(info?: Readonly<Record<string, ConfigCommandV1.Info>>) {
function commands(info?: Readonly<Record<string, ConfigCommandV1.Info>>) {
if (!info) return undefined
return Object.fromEntries(
Object.entries(info).map(([id, command]) => [
@@ -184,7 +222,7 @@ function mcp(info: typeof ConfigV1.Info.Type) {
return { timeout: timeout === undefined ? undefined : { catalog: timeout, execution: timeout }, servers }
}
export function migrateMcp(info: ConfigMCPV1.Info) {
function migrateMcp(info: ConfigMCPV1.Info) {
const disabled = info.enabled === undefined ? undefined : !info.enabled
if (info.type === "local")
return {
@@ -223,7 +261,7 @@ function providers(info?: Readonly<Record<string, ConfigProviderV1.Info>>) {
)
}
export function migrateProvider(sourceID: string, info: ConfigProviderV1.Info) {
function migrateProvider(sourceID: string, info: ConfigProviderV1.Info) {
if (sourceID === "azure-cognitive-services") return migrateAzureCognitiveServicesProvider(info)
if (sourceID === "google-vertex-anthropic") return migrateGoogleVertexAnthropicProvider(info)
return migrateStandardProvider(info)
@@ -235,7 +273,7 @@ function migrateStandardProvider(info: ConfigProviderV1.Info) {
name: info.name,
env: info.env,
package: info.npm ? Provider.aisdk(info.npm) : undefined,
settings: info.api ? { ...options.settings, baseURL: info.api } : info.options ? options.settings : undefined,
settings: info.api ? { ...options.settings, baseURL: info.api } : options.settings,
headers: info.options && options.headers,
body: info.options && options.body,
models:
@@ -279,8 +317,8 @@ function migrateGoogleVertexAnthropicProvider(info: ConfigProviderV1.Info) {
}
}
// Rename these only while migrating unambiguous V1 fields.
export function providerID(input: string) {
// Rename these only in files detected as V1 by a field that exists only in the old config format.
function providerID(input: string) {
if (input === "azure-cognitive-services") return "azure"
if (input === "google-vertex-anthropic") return "google-vertex"
return input
+24 -93
View File
@@ -1,7 +1,7 @@
import path from "path"
import fs from "fs/promises"
import { describe, expect } from "bun:test"
import { Effect, Fiber, Layer, Logger, PubSub, Schema, Stream } from "effect"
import { Effect, Fiber, Layer, PubSub, Schema, Stream } from "effect"
import { FastCheck } from "effect/testing"
import { Config } from "@opencode-ai/core/config"
import { AgentsDirectory, Directory, Document, Event, Info } from "@opencode-ai/schema/config"
@@ -307,7 +307,7 @@ describe("Config", () => {
}),
)
it.live("loads authenticated wellknown config below project config", () =>
it.live("loads authenticated wellknown config at highest priority", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
(tmp) =>
@@ -370,7 +370,7 @@ describe("Config", () => {
return yield* Effect.gen(function* () {
const config = yield* Config.Service
const bus = yield* Bus.Service
expect(Config.latest(yield* config.entries(), "shell")).toBe("project")
expect(Config.latest(yield* config.entries(), "shell")).toBe("secret")
const updated = yield* bus
.subscribe(Event.Updated)
.pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
@@ -378,7 +378,7 @@ describe("Config", () => {
key = "next"
yield* bus.publish(Integration.Event.ConnectionUpdated, { integrationID })
expect(yield* Fiber.join(updated)).toHaveLength(1)
expect(Config.latest(yield* config.entries(), "shell")).toBe("project")
expect(Config.latest(yield* config.entries(), "shell")).toBe("next")
}).pipe(
Effect.provide(testLayer(project, global, project, undefined, undefined, credentialNode, wellknownNode)),
)
@@ -387,96 +387,27 @@ describe("Config", () => {
),
)
it.live("logs redacted source-aware diagnostics for every config source", () => {
const output: Array<Record<string, unknown>> = []
const logger = Logger.map(Logger.formatStructured, (entry) => {
if (!Array.isArray(entry.message) || entry.message[0] !== "configuration normalization diagnostic") return
const details = entry.message[1]
if (typeof details === "object" && details !== null) output.push(details as Record<string, unknown>)
})
return Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
(tmp) =>
Effect.gen(function* () {
const global = path.join(tmp.path, "global")
const project = path.join(tmp.path, "project")
const malformed = path.join(tmp.path, "malformed.json")
yield* Effect.promise(async () => {
await fs.mkdir(global, { recursive: true })
await fs.mkdir(project, { recursive: true })
await fs.writeFile(path.join(global, "opencode.json"), "null")
await fs.writeFile(path.join(project, "opencode.json"), "")
await fs.writeFile(malformed, '{ "credential": "file-secret"')
})
const integrationID = Integration.ID.make("https://invalid.example.com")
const entry: WellKnown.Entry = {
origin: "https://invalid.example.com",
integrationID,
manifest: { auth: { command: ["login"], env: "TOKEN" } },
}
const credentialNode = makeGlobalNode({
service: Credential.Service,
layer: Layer.succeed(
Credential.Service,
Credential.Service.of({
all: () => Effect.die("unused Credential.all"),
list: () =>
Effect.succeed([
new Credential.Info({
id: Credential.ID.create(),
integrationID,
label: "default",
value: Credential.Key.make({ type: "key", key: "wellknown-secret" }),
}),
]),
get: () => Effect.die("unused Credential.get"),
create: () => Effect.die("unused Credential.create"),
update: () => Effect.die("unused Credential.update"),
remove: () => Effect.die("unused Credential.remove"),
}),
),
deps: [],
})
const wellknownNode = makeGlobalNode({
service: WellKnown.Service,
layer: Layer.succeed(
WellKnown.Service,
WellKnown.Service.of({
entries: () => Effect.succeed([entry]),
snapshot: () => [entry],
refresh: () => Effect.succeed(false),
add: () => Effect.die("unused Wellknown.add"),
remove: () => Effect.die("unused Wellknown.remove"),
// Exercise the loader boundary against a malformed implementation response.
resolve: () => Effect.succeed([null as unknown as WellKnown.Config]),
}),
),
deps: [],
})
it.effect("detects v1 configuration from any v1-only top-level key", () =>
Effect.sync(() => {
expect(ConfigMigrateV1.isV1({ snapshot: false })).toBe(true)
expect(ConfigMigrateV1.isV1({ snapshot: false, agents: {} })).toBe(true)
expect(ConfigMigrateV1.isV1({ reference: {} })).toBe(true)
expect(ConfigMigrateV1.isV1({ shell: "/bin/zsh", model: "anthropic/claude" })).toBe(false)
expect(ConfigMigrateV1.isV1({ references: {} })).toBe(false)
}),
)
yield* Config.Service.use((config) => config.entries()).pipe(
Effect.provide(
testLayer(project, global, project, undefined, undefined, credentialNode, wellknownNode, {
file: malformed,
content: "",
}),
),
)
expect(output.map((item) => `${item.source}:${item.path}:${item.kind}`).toSorted()).toEqual(
[
`${path.join(global, "opencode.json")}:$:invalid`,
`${path.join(project, "opencode.json")}:$:invalid`,
`${malformed}:$:invalid`,
"https://invalid.example.com:$:invalid",
"OPENCODE_CONFIG_CONTENT:$:invalid",
].toSorted(),
)
expect(JSON.stringify(output)).not.toContain("secret")
}),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(Effect.provide(Logger.layer([logger])))
})
it.effect("detects a bare v1-shaped mcp block while leaving v2 mcp config alone", () =>
Effect.sync(() => {
// V1 lists servers directly under `mcp`, so a file with only `$schema` + `mcp` still migrates.
expect(ConfigMigrateV1.isV1({ mcp: { context7: { type: "local", command: ["npx"] } } })).toBe(true)
expect(ConfigMigrateV1.isV1({ $schema: "x", mcp: { executor: { type: "remote", url: "https://x" } } })).toBe(true)
// Current config nests under `mcp.servers`, so it must not be misdetected and re-migrated.
expect(ConfigMigrateV1.isV1({ mcp: { servers: { context7: { type: "local", command: ["npx"] } } } })).toBe(false)
expect(ConfigMigrateV1.isV1({ mcp: {} })).toBe(false)
expect(ConfigMigrateV1.isV1({ mcp: { timeout: { execution: 1000 } } })).toBe(false)
}),
)
it.effect("migrates arbitrary v1 configuration into valid v2 configuration", () =>
Effect.sync(() => {
@@ -1,489 +0,0 @@
import { describe, expect, test } from "bun:test"
import { Duration, Schema } from "effect"
import { FastCheck } from "effect/testing"
import { ConfigNormalize } from "@opencode-ai/core/config/normalize"
import { Info } from "@opencode-ai/schema/config"
const options = { errors: "all", onExcessProperty: "ignore", propertyOrder: "original" } as const
function normalized(input: unknown) {
const result = ConfigNormalize.normalize(input)
expect(result.type).toBe("normalized")
if (result.type !== "normalized") throw new Error("expected normalized config")
return result
}
function decoded(input: unknown) {
return Schema.decodeUnknownSync(Info, options)(normalized(input).encoded)
}
function withoutEmptyCompatibilityContainers(input: Record<string, unknown>) {
const result = structuredClone(input)
if (typeof result.mcp === "object" && result.mcp !== null && !Array.isArray(result.mcp)) {
const mcp = result.mcp as Record<string, unknown>
const originallyEmpty = !Object.keys(mcp).length
for (const key of ["servers", "timeout"]) {
if (
typeof mcp[key] === "object" &&
mcp[key] !== null &&
!Array.isArray(mcp[key]) &&
!Object.keys(mcp[key]).length
)
delete mcp[key]
}
if (!originallyEmpty && !Object.keys(mcp).length) delete result.mcp
}
if (typeof result.compaction === "object" && result.compaction !== null && !Array.isArray(result.compaction)) {
const compaction = result.compaction as Record<string, unknown>
const originallyEmpty = !Object.keys(compaction).length
if (
typeof compaction.keep === "object" &&
compaction.keep !== null &&
!Array.isArray(compaction.keep) &&
!Object.keys(compaction.keep).length
)
delete compaction.keep
if (!originallyEmpty && !Object.keys(compaction).length) delete result.compaction
}
return result
}
describe("ConfigNormalize", () => {
test("rejects every non-object root with one root diagnostic", () => {
for (const input of [null, [], "config", true, 1]) {
expect(ConfigNormalize.normalize(input)).toEqual({
type: "rejected",
diagnostics: [
{
kind: "invalid",
path: ["$"],
message: "rejected configuration because its root is not an object",
},
],
})
}
})
test("keeps unrelated native fields when a legacy field is present", () => {
const result = decoded({ snapshot: false, agents: { reviewer: { system: "Use V2" } } })
expect(result.snapshots).toBe(false)
expect(result.agents?.reviewer?.system).toBe("Use V2")
})
test("canonicalizes transformed native values through decode then encode", () => {
const result = normalized({ warming: { interval: "4 minutes", duration: "30 minutes" } })
expect(result.encoded.warming).toEqual({ interval: "240000 millis", duration: "1800000 millis" })
const info = Schema.decodeUnknownSync(Info)(result.encoded)
if (typeof info.warming === "boolean" || info.warming === undefined) throw new Error("expected warming info")
expect(Duration.toMillis(info.warming.interval ?? Duration.zero)).toBe(240_000)
expect(Duration.toMillis(info.warming.duration ?? Duration.zero)).toBe(1_800_000)
})
test("preserves arbitrary JSON-round-tripped native configuration", () => {
FastCheck.assert(
FastCheck.property(Schema.toArbitrary(Info), (info) => {
const source = JSON.parse(JSON.stringify(Schema.encodeSync(Info)(info)))
const result = normalized(source)
expect(Schema.decodeUnknownSync(Info)(result.encoded)).toEqual(
Schema.decodeUnknownSync(Info)(withoutEmptyCompatibilityContainers(source)),
)
}),
{ numRuns: 100 },
)
})
test("merges named maps by entry and gives valid native entries precedence", () => {
const result = normalized({
reference: { legacy: { path: "../legacy" }, duplicate: { path: "../old" } },
references: { native: { path: "../native" }, duplicate: { path: "../new" } },
command: { legacy: { template: "legacy" }, duplicate: { template: "old" } },
commands: { native: { template: "native" }, duplicate: { template: "new" } },
})
expect(result.encoded.references).toEqual({
legacy: { path: "../legacy" },
native: { path: "../native" },
duplicate: { path: "../new" },
})
expect(result.encoded.commands).toEqual({
legacy: { template: "legacy" },
native: { template: "native" },
duplicate: { template: "new" },
})
expect(result.diagnostics.filter((item) => item.kind === "conflict").map((item) => item.path)).toEqual([
["references", "duplicate"],
["commands", "duplicate"],
])
})
test("does not report canonical-equal duplicates as conflicts", () => {
const result = normalized({
snapshot: false,
snapshots: false,
reference: { docs: { path: "../docs" } },
references: { docs: { path: "../docs" } },
agent: { reviewer: { prompt: "same" } },
agents: { reviewer: { system: "same" } },
provider: { custom: { name: "same" } },
providers: { custom: { name: "same" } },
compaction: { preserve_recent_tokens: 1000, keep: { tokens: 1000 } },
})
expect(result.diagnostics.filter((item) => item.kind === "conflict")).toEqual([])
})
test("uses agent then mode then native agent precedence", () => {
const result = normalized({
agent: { reviewer: { prompt: "agent" }, agentOnly: { prompt: "agent-only" } },
mode: { reviewer: { prompt: "mode" }, modeOnly: { prompt: "mode-only" } },
agents: { reviewer: { system: "native" }, nativeOnly: { system: "native-only" } },
})
expect(result.encoded.agents).toEqual({
reviewer: { system: "native" },
agentOnly: { system: "agent-only" },
modeOnly: { system: "mode-only", mode: "primary" },
nativeOnly: { system: "native-only" },
})
expect(result.diagnostics.filter((item) => item.kind === "conflict").map((item) => item.path)).toEqual([
["agents", "reviewer"],
["agents", "reviewer"],
])
expect(() => Schema.decodeUnknownSync(Info)(result.encoded)).not.toThrow()
})
test("recovers malformed named entries and retains a valid legacy collision", () => {
const result = normalized({
command: { fallback: { template: "legacy" } },
commands: {
fallback: { template: 1 },
valid: { template: "native" },
invalid: { template: false },
},
providers: {
valid: { name: "Valid" },
invalid: { env: [1] },
},
})
expect(result.encoded.commands).toEqual({ fallback: { template: "legacy" }, valid: { template: "native" } })
expect(result.encoded.providers).toEqual({ valid: { name: "Valid" } })
expect(result.diagnostics.filter((item) => item.kind === "invalid").map((item) => item.path)).toEqual([
["commands", "fallback"],
["commands", "invalid"],
["providers", "invalid"],
])
})
test("uses a valid retired provider alias when the canonical legacy entry is malformed", () => {
const result = normalized({
provider: {
"azure-cognitive-services": { models: { deployment: {} } },
azure: { env: [1] },
},
})
expect(result.encoded.providers).toHaveProperty("azure.models.deployment")
expect(result.diagnostics.filter((item) => item.kind === "invalid").map((item) => item.path)).toContainEqual([
"provider",
"azure",
])
})
test("preserves permission source order and appends native rules", () => {
expect(
normalized({
tools: { bash: true, write: false },
permission: { read: "allow", custom: { first: "deny", second: "ask" }, task: "allow" },
permissions: [{ action: "native", resource: "*", effect: "deny" }],
}).encoded.permissions,
).toEqual([
{ action: "shell", resource: "*", effect: "allow" },
{ action: "edit", resource: "*", effect: "deny" },
{ action: "read", resource: "*", effect: "allow" },
{ action: "custom", resource: "first", effect: "deny" },
{ action: "custom", resource: "second", effect: "ask" },
{ action: "subagent", resource: "*", effect: "allow" },
{ action: "native", resource: "*", effect: "deny" },
])
})
test("redacts permission resource keys from invalid diagnostics", () => {
const result = normalized({
permission: { bash: { "curl -H Authorization:Bearer TOPSECRET *": "bogus" } },
})
expect(result.diagnostics).toEqual([
{
kind: "invalid",
path: ["permission", "bash", "0"],
message: "skipped malformed recognized value",
},
])
expect(JSON.stringify(result.diagnostics)).not.toContain("TOPSECRET")
})
test("recovers list items for skills, plugins, instructions, and permissions", () => {
const result = normalized({
skills: { paths: ["./skills", 1], urls: [false, "https://example.com/skills"] },
plugin: ["legacy", ["tuple", {}], [1, {}]],
plugins: ["native", { package: "object" }, { package: 1 }],
instructions: ["one", 2, "three"],
permissions: [
{ action: "read", resource: "*", effect: "allow" },
{ action: "read", resource: "*", effect: "invalid" },
],
})
expect(result.encoded.skills).toEqual(["./skills", "https://example.com/skills"])
expect(result.encoded.plugins).toEqual([
"legacy",
{ package: "tuple", options: {} },
"native",
{ package: "object" },
])
expect(result.encoded.instructions).toEqual(["one", "three"])
expect(result.encoded.permissions).toEqual([{ action: "read", resource: "*", effect: "allow" }])
expect(result.diagnostics.filter((item) => item.kind === "invalid")).toHaveLength(6)
})
test("omits malformed collection roots instead of synthesizing empty values", () => {
const result = normalized({
commands: [],
providers: "invalid",
references: false,
agents: 1,
plugins: {},
permissions: {},
instructions: {},
})
expect(result.encoded).toEqual({})
expect(result.diagnostics.filter((item) => item.kind === "invalid").map((item) => item.path)).toEqual([
["references"],
["commands"],
["agents"],
["providers"],
["permissions"],
["plugins"],
["instructions"],
])
})
test("omits all-invalid formatter and LSP maps while preserving explicit empty maps", () => {
const invalid = normalized({
formatter: { prettier: { command: [1] } },
lsp: { typescript: { command: [1] } },
})
expect(invalid.encoded).not.toHaveProperty("formatter")
expect(invalid.encoded).not.toHaveProperty("lsp")
expect(invalid.diagnostics.filter((item) => item.kind === "invalid").map((item) => item.path)).toEqual([
["formatter", "prettier"],
["lsp", "typescript"],
])
expect(normalized({ formatter: {}, lsp: {} }).encoded).toMatchObject({ formatter: {}, lsp: {} })
})
test("combines legacy and native MCP servers and merges timeout leaves", () => {
const result = normalized({
experimental: { mcp_timeout: 5000 },
mcp: {
legacy: { type: "local", command: ["legacy"] },
duplicate: { type: "remote", url: "https://legacy.example.com" },
servers: {
native: { type: "local", command: ["native"] },
duplicate: { type: "remote", url: "https://native.example.com" },
invalid: { type: "local", command: [1] },
},
timeout: { startup: 1000, catalog: 6000 },
},
})
expect(result.encoded.mcp).toEqual({
timeout: { catalog: 6000, execution: 5000, startup: 1000 },
servers: {
legacy: { type: "local", command: ["legacy"], disabled: undefined, timeout: undefined },
duplicate: { type: "remote", url: "https://native.example.com" },
native: { type: "local", command: ["native"] },
},
})
expect(
result.diagnostics.some((item) => item.kind === "conflict" && item.path.join(".") === "mcp.servers.duplicate"),
).toBe(true)
expect(
result.diagnostics.some((item) => item.kind === "conflict" && item.path.join(".") === "mcp.timeout.catalog"),
).toBe(true)
expect(
result.diagnostics.some((item) => item.kind === "invalid" && item.path.join(".") === "mcp.servers.invalid"),
).toBe(true)
})
test("uses raw MCP discriminators for reserved server names", () => {
const result = normalized({
mcp: {
servers: { type: "local", command: ["reserved-servers"] },
timeout: { type: "remote", url: "https://reserved.example.com" },
},
})
expect((result.encoded.mcp as { servers: Record<string, unknown> }).servers).toEqual({
servers: { type: "local", command: ["reserved-servers"], disabled: undefined, timeout: undefined },
timeout: { type: "remote", url: "https://reserved.example.com", disabled: undefined, timeout: undefined },
})
const enabledOnly = normalized({ mcp: { servers: { enabled: true }, timeout: { enabled: false } } })
expect(enabledOnly.encoded.mcp).toBeUndefined()
expect(enabledOnly.diagnostics.map((item) => [item.kind, item.path])).toEqual([
["unsupported", ["mcp", "servers"]],
["unsupported", ["mcp", "timeout"]],
])
})
test("merges bounded compaction leaves and omits unsupported leaves", () => {
const result = normalized({
compaction: {
auto: false,
preserve_recent_tokens: 1000,
keep: { tokens: 2000 },
reserved: 3000,
buffer: 4000,
tail_turns: 2,
prune: true,
},
})
expect(result.encoded.compaction).toEqual({ auto: false, keep: { tokens: 2000 }, buffer: 4000 })
expect(result.diagnostics.map((item) => [item.kind, item.path])).toEqual([
["unsupported", ["compaction", "tail_turns"]],
["unsupported", ["compaction", "prune"]],
["conflict", ["compaction", "keep", "tokens"]],
["conflict", ["compaction", "buffer"]],
])
})
test("distinguishes empty, mixed, and wholly malformed enabled provider lists", () => {
expect(normalized({ enabled_providers: [] }).encoded.experimental).toEqual({
policies: [{ action: "provider.use", resource: "*", effect: "deny" }],
})
expect(normalized({ enabled_providers: [1, "anthropic", false] }).encoded.experimental).toEqual({
policies: [
{ action: "provider.use", resource: "*", effect: "deny" },
{ action: "provider.use", resource: "anthropic", effect: "allow" },
],
})
expect(normalized({ enabled_providers: [1, false] }).encoded.experimental).toBeUndefined()
expect(normalized({ enabled_providers: "anthropic" }).encoded.experimental).toBeUndefined()
})
test("appends native policies after migrated provider policies", () => {
expect(
normalized({
enabled_providers: ["anthropic"],
disabled_providers: ["openai"],
experimental: {
subagent_depth: 0,
policies: [{ action: "provider.use", resource: "custom", effect: "allow" }],
},
}).encoded.experimental,
).toEqual({
subagent_depth: 0,
policies: [
{ action: "provider.use", resource: "*", effect: "deny" },
{ action: "provider.use", resource: "anthropic", effect: "allow" },
{ action: "provider.use", resource: "openai", effect: "deny" },
{ action: "provider.use", resource: "custom", effect: "allow" },
],
})
})
test("reports unsupported legacy settings without including their values", () => {
const secret = "do-not-log-this-value"
const result = normalized({
logLevel: "DEBUG",
small_model: secret,
agent: { reviewer: { name: secret, prompt: "review" } },
provider: {
custom: {
id: secret,
whitelist: ["model"],
models: {
model: {
release_date: secret,
status: "active",
interleaved: true,
},
},
},
},
experimental: { openTelemetry: true },
})
expect(result.diagnostics.filter((item) => item.kind === "unsupported").map((item) => item.path)).toEqual([
["logLevel"],
["small_model"],
["agent", "reviewer", "name"],
["provider", "custom", "id"],
["provider", "custom", "whitelist"],
["provider", "custom", "models", "model", "release_date"],
["provider", "custom", "models", "model", "status"],
["provider", "custom", "models", "model", "interleaved"],
["experimental", "openTelemetry"],
])
expect(JSON.stringify(result.diagnostics)).not.toContain(secret)
})
test("diagnoses unsupported legacy model selections without dropping their entries", () => {
const result = normalized({
command: {
invalidModel: { template: "one", model: "invalid" },
invalidVariant: { template: "two", model: "anthropic/model", variant: "bad#variant" },
missingModel: { template: "three", variant: "high" },
},
agent: { invalid: { prompt: "agent", model: "invalid", variant: "" } },
})
expect(Object.keys(result.encoded.commands as Record<string, unknown>)).toEqual([
"invalidModel",
"invalidVariant",
"missingModel",
])
expect(Object.keys(result.encoded.agents as Record<string, unknown>)).toEqual(["invalid"])
expect(result.diagnostics.filter((item) => item.kind === "unsupported").map((item) => item.path)).toEqual([
["command", "invalidModel", "model"],
["command", "invalidVariant", "variant"],
["command", "missingModel", "variant"],
["agent", "invalid", "model"],
["agent", "invalid", "variant"],
])
})
test("invalid legacy provider overlays skip only that provider", () => {
const result = normalized({
provider: {
headers: { options: { headers: { valid: "yes", invalid: 1 } } },
body: { options: { body: "not-an-object" } },
valid: { options: { headers: { valid: "yes" }, body: { trace: true } } },
},
})
expect(result.encoded.providers).toEqual({
valid: { settings: {}, headers: { valid: "yes" }, body: { trace: true } },
})
expect(result.diagnostics.filter((item) => item.kind === "invalid").map((item) => item.path)).toEqual([
["provider", "headers", "options", "headers"],
["provider", "body", "options", "body"],
])
})
test("preserves explicit false, zero, empty list, and empty map presence", () => {
const result = normalized({
snapshot: false,
autoshare: false,
references: {},
commands: {},
agents: {},
providers: {},
plugins: [],
instructions: [],
experimental: { subagent_depth: 0 },
})
expect(result.encoded).toMatchObject({
snapshots: false,
references: {},
commands: {},
agents: {},
providers: {},
plugins: [],
instructions: [],
experimental: { subagent_depth: 0 },
})
expect(result.encoded.share).toBeUndefined()
})
})
+74
View File
@@ -1086,4 +1086,78 @@ describe("Session.pending", () => {
expect(yield* session.pending(sessionID)).toEqual([])
}),
)
it.effect("cancels only queued input and allows its ID to be admitted again", () =>
Effect.gen(function* () {
yield* setup
const session = yield* Session.Service
const inputID = SessionMessage.ID.make("msg_cancelled_queue")
yield* session.prompt({
id: inputID,
sessionID,
text: "Queue this",
delivery: "queue",
resume: false,
})
yield* session.cancelPending({ sessionID, inputID })
expect(yield* session.pending(sessionID)).toEqual([])
expect(yield* eventCount(Bus.versionedType(SessionEvent.InputCancelled.type, 1))).toBe(1)
expect(
yield* session.cancelPending({ sessionID, inputID }).pipe(Effect.flip),
).toMatchObject({ _tag: "Session.PendingInputConflictError", sessionID, inputID })
expect(yield* eventCount(Bus.versionedType(SessionEvent.InputCancelled.type, 1))).toBe(1)
const retried = yield* session.prompt({
id: inputID,
sessionID,
text: "Queue this",
delivery: "queue",
resume: false,
})
expect(retried).toMatchObject({ id: inputID, delivery: "queue" })
}),
)
it.effect("moves pending input between steer and queue delivery", () =>
Effect.gen(function* () {
yield* setup
const session = yield* Session.Service
const queued = yield* session.synthetic({
sessionID,
text: "Steer this",
delivery: "queue",
resume: false,
})
const alreadySteered = yield* session.prompt({ sessionID, text: "Already steer", resume: false })
wakeCalls.length = 0
yield* session.steerPending({ sessionID, inputID: queued.id })
expect(yield* session.pending(sessionID)).toMatchObject([
{ id: queued.id, delivery: "steer" },
{ id: alreadySteered.id, delivery: "steer" },
])
expect(wakeCalls).toEqual([sessionID])
expect(yield* eventCount(Bus.versionedType(SessionEvent.InputSteered.type, 1))).toBe(1)
wakeCalls.length = 0
yield* session.queuePending({ sessionID, inputID: queued.id })
expect(yield* session.pending(sessionID)).toMatchObject([
{ id: queued.id, delivery: "queue" },
{ id: alreadySteered.id, delivery: "steer" },
])
expect(wakeCalls).toEqual([])
expect(yield* eventCount(Bus.versionedType(SessionEvent.InputQueued.type, 1))).toBe(1)
expect(
yield* session.steerPending({ sessionID, inputID: alreadySteered.id }).pipe(Effect.flip),
).toMatchObject({ _tag: "Session.PendingInputConflictError", sessionID, inputID: alreadySteered.id })
yield* session.cancelPending({ sessionID, inputID: alreadySteered.id })
expect(wakeCalls).toEqual([])
expect(yield* eventCount(Bus.versionedType(SessionEvent.InputSteered.type, 1))).toBe(1)
expect(yield* eventCount(Bus.versionedType(SessionEvent.InputCancelled.type, 1))).toBe(1)
}),
)
})
+4 -148
View File
@@ -240,7 +240,7 @@ describe("SubagentTool", () => {
expect(settled).toMatchObject({
status: "completed",
metadata: { status: "completed" },
content: [{ type: "text", text: expect.stringContaining(childText) }],
content: [{ type: "text", text: childText }],
})
expect(settled.metadata).toEqual({
sessionID: outputSessionID(settled.metadata),
@@ -283,15 +283,9 @@ describe("SubagentTool", () => {
expect(settled).toMatchObject({
status: "completed",
metadata: { status: "completed" },
content: [{ type: "text", text: expect.stringContaining(childText) }],
content: [{ type: "text", text: childText }],
})
const child = yield* sessions.get(outputSessionID(settled.metadata))
expect(settled.content).toEqual([
{
type: "text",
text: `<subagent sessionID="${child.id}" state="completed">\n${childText}\n</subagent>`,
},
])
expect(settled.metadata).toEqual({ sessionID: child.id, status: "completed" })
expect(progress[0]?.metadata).toEqual({ sessionID: child.id, status: "running" })
expect(child).toMatchObject({
@@ -321,144 +315,6 @@ describe("SubagentTool", () => {
),
)
it.live("continues an existing child session", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
).pipe(
Effect.flatMap((dir) =>
Effect.gen(function* () {
const location = Location.Ref.make({ directory: AbsolutePath.make(dir.path) })
const sessions = yield* Session.Service
const parent = yield* sessions.create({ location, model: parentModel })
yield* withSubagent(parent.location)
const locations = yield* LocationServiceMap.Service
const registry = yield* Tool.Service.pipe(Effect.provide(locations.get(parent.location)))
yield* waitForTool(registry, SubagentTool.name)
const first = yield* executeTool(registry, {
sessionID: parent.id,
...toolIdentity,
call: {
type: "tool-call",
id: "call-subagent-first",
name: SubagentTool.name,
input: { agent: "reviewer", description: "review", prompt: "review this" },
},
})
const childID = outputSessionID(first.metadata)
const second = yield* executeTool(registry, {
sessionID: parent.id,
...toolIdentity,
call: {
type: "tool-call",
id: "call-subagent-second",
name: SubagentTool.name,
input: {
agent: "reviewer",
description: "follow up",
prompt: "continue this",
sessionID: childID,
},
},
})
expect(outputSessionID(second.metadata)).toBe(childID)
expect((yield* sessions.list({ parentID: parent.id })).data).toHaveLength(1)
expect((yield* sessions.get(childID)).title).toBe("review")
expect(
(yield* sessions.pending(childID)).flatMap((message) =>
message.type === "user" ? [message.data.text] : [],
),
).toEqual(["You are a subagent spawned by another session.\nreview this", "continue this"])
expect(second.content).toEqual([
{
type: "text",
text: `<subagent sessionID="${childID}" state="completed">\n${childText}\n</subagent>`,
},
])
}),
),
),
)
it.live("rejects background continuation", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
).pipe(
Effect.flatMap((dir) =>
Effect.gen(function* () {
const location = Location.Ref.make({ directory: AbsolutePath.make(dir.path) })
const sessions = yield* Session.Service
const parent = yield* sessions.create({ location })
const child = yield* sessions.create({
parentID: parent.id,
title: "review",
agent: Agent.ID.make("reviewer"),
})
yield* withSubagent(parent.location)
const locations = yield* LocationServiceMap.Service
const registry = yield* Tool.Service.pipe(Effect.provide(locations.get(parent.location)))
yield* waitForTool(registry, SubagentTool.name)
expect(
yield* executeTool(registry, {
sessionID: parent.id,
...toolIdentity,
call: {
type: "tool-call",
id: "call-background-continuation",
name: SubagentTool.name,
input: {
agent: "reviewer",
description: "follow up",
prompt: "continue this",
sessionID: child.id,
background: true,
},
},
}),
).toEqual({
status: "error",
error: {
type: "tool.execution",
message: "Continuing a subagent in the background is not implemented yet",
},
})
const jobs = yield* Job.Service
yield* jobs.start({ id: child.id, type: "subagent", run: Effect.never })
yield* jobs.background(child.id)
expect(
yield* executeTool(registry, {
sessionID: parent.id,
...toolIdentity,
call: {
type: "tool-call",
id: "call-running-continuation",
name: SubagentTool.name,
input: {
agent: "reviewer",
description: "follow up",
prompt: "continue this",
sessionID: child.id,
},
},
}),
).toEqual({
status: "error",
error: {
type: "tool.execution",
message: "Continuing a running subagent is not implemented yet",
},
})
yield* jobs.cancel(child.id)
}),
),
),
)
it.live("returns child runner failures as tool errors", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
@@ -537,7 +393,7 @@ describe("SubagentTool", () => {
expect(settled.content).toEqual([{ type: "text", text: expect.stringContaining(`id: ${childID}`) }])
const admission = Array.from(yield* Fiber.join(admitted))[0]
expect(admission?.data.input.data.text).toContain(`<subagent sessionID="${childID}" state="completed"`)
expect(admission?.data.input.data.text).toContain(`<subagent id="${childID}" state="completed"`)
expect(admission?.data.input.data).toMatchObject({
description: "background review",
metadata: {
@@ -551,7 +407,7 @@ describe("SubagentTool", () => {
yield* SessionPending.promote(database.db, bus, parent.id, "steer")
const synthetic = (yield* sessions.context(parent.id)).filter((message) => message.type === "synthetic")
expect(synthetic).toHaveLength(1)
expect(synthetic[0]?.text).toContain(`<subagent sessionID="${childID}" state="completed"`)
expect(synthetic[0]?.text).toContain(`<subagent id="${childID}" state="completed"`)
expect(synthetic[0]?.text).toContain(childText)
}),
),
+39
View File
@@ -491,6 +491,45 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo
}),
),
)
.add(
HttpApiEndpoint.delete("session.pending.cancel", "/api/session/:sessionID/pending/:inputID", {
params: { sessionID: Session.ID, inputID: SessionMessage.ID },
success: HttpApiSchema.NoContent,
error: [ConflictError, SessionNotFoundError],
}).annotateMerge(
OpenApi.annotations({
identifier: "v2.session.pending.cancel",
summary: "Cancel pending input",
description: "Cancel an input that has not yet been promoted into session history.",
}),
),
)
.add(
HttpApiEndpoint.post("session.pending.steer", "/api/session/:sessionID/pending/:inputID/steer", {
params: { sessionID: Session.ID, inputID: SessionMessage.ID },
success: HttpApiSchema.NoContent,
error: [ConflictError, SessionNotFoundError],
}).annotateMerge(
OpenApi.annotations({
identifier: "v2.session.pending.steer",
summary: "Steer queued input",
description: "Change a queued input to steer delivery and wake session execution.",
}),
),
)
.add(
HttpApiEndpoint.post("session.pending.queue", "/api/session/:sessionID/pending/:inputID/queue", {
params: { sessionID: Session.ID, inputID: SessionMessage.ID },
success: HttpApiSchema.NoContent,
error: [ConflictError, SessionNotFoundError],
}).annotateMerge(
OpenApi.annotations({
identifier: "v2.session.pending.queue",
summary: "Queue pending steer",
description: "Change a pending steer to queued delivery.",
}),
),
)
.add(
HttpApiEndpoint.get("session.instructions.entry.list", "/api/session/:sessionID/instructions/entries", {
params: { sessionID: Session.ID },
+37 -1
View File
@@ -173,6 +173,36 @@ export const InputAdmitted = Event.durable({
})
export type InputAdmitted = typeof InputAdmitted.Type
export const InputCancelled = Event.durable({
type: "session.input.cancelled",
...options,
schema: {
sessionID: SessionID,
inputID: SessionMessage.ID,
},
})
export type InputCancelled = typeof InputCancelled.Type
export const InputSteered = Event.durable({
type: "session.input.steered",
...options,
schema: {
sessionID: SessionID,
inputID: SessionMessage.ID,
},
})
export type InputSteered = typeof InputSteered.Type
export const InputQueued = Event.durable({
type: "session.input.queued",
...options,
schema: {
sessionID: SessionID,
inputID: SessionMessage.ID,
},
})
export type InputQueued = typeof InputQueued.Type
export namespace Execution {
export const Started = Event.durable({ type: "session.execution.started", ...options, schema: Base })
export type Started = typeof Started.Type
@@ -580,6 +610,9 @@ export const Definitions = Event.inventory(
Forked,
InputPromoted,
InputAdmitted,
InputCancelled,
InputSteered,
InputQueued,
Execution.Started,
Execution.Succeeded,
Execution.Failed,
@@ -621,13 +654,16 @@ export const DurableDefinitions = Event.inventory(
...Definitions.filter((definition) => definition.durability === "durable"),
UsageRecorded,
)
export const EphemeralDefinitions = Event.inventory(
...Definitions.filter((definition) => definition.durability === "ephemeral"),
)
export const Durable = Schema.Union(DurableDefinitions, { mode: "oneOf" })
.pipe(Schema.toTaggedUnion("type"))
.annotate({ identifier: "Session.Event.Durable" })
export type DurableEvent = typeof Durable.Type
export const All = Schema.Union(Event.inventory(...Definitions, UsageRecorded), { mode: "oneOf" }).pipe(
export const All = Schema.Union([Durable, ...EphemeralDefinitions], { mode: "oneOf" }).pipe(
Schema.toTaggedUnion("type"),
)
export type Event = typeof All.Type
@@ -84,6 +84,9 @@ describe("public event manifest", () => {
"session.forked.2",
"session.input.promoted.1",
"session.input.admitted.1",
"session.input.cancelled.1",
"session.input.steered.1",
"session.input.queued.1",
"session.execution.started.1",
"session.execution.succeeded.1",
"session.execution.failed.1",
+43
View File
@@ -24,6 +24,22 @@ const DefaultSessionsLimit = 50
export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handlers) =>
Effect.gen(function* () {
const session = yield* Session.Service
const pendingMutation = (effect: ReturnType<typeof session.cancelPending>, conflict: string) =>
effect.pipe(
Effect.catchTag(
"Session.NotFoundError",
(error) =>
new SessionNotFoundError({
sessionID: error.sessionID,
message: `Session not found: ${error.sessionID}`,
}),
),
Effect.catchTag(
"Session.PendingInputConflictError",
(error) => new ConflictError({ resource: error.inputID, message: `${conflict}: ${error.inputID}` }),
),
Effect.as(HttpApiSchema.NoContent.make()),
)
return handlers
.handle(
@@ -609,6 +625,33 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
}
}),
)
.handle(
"session.pending.cancel",
Effect.fn(function* (ctx) {
return yield* pendingMutation(
session.cancelPending({ sessionID: ctx.params.sessionID, inputID: ctx.params.inputID }),
"Pending input can no longer be cancelled",
)
}),
)
.handle(
"session.pending.steer",
Effect.fn(function* (ctx) {
return yield* pendingMutation(
session.steerPending({ sessionID: ctx.params.sessionID, inputID: ctx.params.inputID }),
"Pending input is no longer queued",
)
}),
)
.handle(
"session.pending.queue",
Effect.fn(function* (ctx) {
return yield* pendingMutation(
session.queuePending({ sessionID: ctx.params.sessionID, inputID: ctx.params.inputID }),
"Pending input is no longer a steer",
)
}),
)
.handle(
"session.instructions.entry.list",
Effect.fn(function* (ctx) {
+15 -41
View File
@@ -19,20 +19,13 @@ function statusError(status: McpServer["status"]) {
return undefined
}
function Status(props: { status: McpServer["status"]; loading: boolean }) {
if (props.loading || props.status.status === "pending") {
return <>Connecting </>
function Status(props: { enabled: boolean; loading: boolean }) {
const theme = useTheme("elevated")
if (props.loading) return <span style={{ fg: theme.text.subdued }}> Loading</span>
if (props.enabled) {
return <span style={{ fg: theme.text.feedback.success.default, attributes: TextAttributes.BOLD }}> Enabled</span>
}
if (props.status.status === "connected") {
return <span style={{ attributes: TextAttributes.BOLD }}>Connected </span>
}
if (props.status.status === "failed") {
return <>Failed !</>
}
if (props.status.status === "needs_auth") {
return <>Sign in required </>
}
return <>Disabled </>
return <span style={{ fg: theme.text.subdued }}> Disabled</span>
}
export function DialogMcp() {
@@ -45,13 +38,6 @@ export function DialogMcp() {
const [detail, setDetail] = createSignal<McpServer>()
const [loading, setLoading] = createSignal<string | null>(null)
const statusColor = (status: McpServer["status"]) => {
if (status.status === "connected") return theme.text.feedback.success.default
if (status.status === "failed") return theme.text.feedback.error.default
if (status.status === "needs_auth") return theme.text.feedback.warning.default
return theme.text.subdued
}
const servers = createMemo(() =>
pipe(
data.location.mcp.server.list() ?? [],
@@ -67,29 +53,17 @@ export function DialogMcp() {
const options = createMemo(() => {
const loadingMcp = loading()
return servers().map((server) => {
const pending = loadingMcp === server.name || server.status.status === "pending"
return {
value: server.name,
title: server.name,
footer: <Status status={server.status} loading={pending} />,
footerColor: pending ? theme.text.subdued : statusColor(server.status),
}
})
})
const focusedServer = createMemo(() => servers().find((server) => server.name === focused()))
const toggleTitle = createMemo(() => {
const status = focusedServer()?.status.status
if (status === "connected") return "disconnect"
if (status === "failed") return "retry"
if (status === "needs_auth") return "sign in"
return "connect"
return servers().map((server) => ({
value: server.name,
title: server.name,
description: server.status.status,
footer: <Status enabled={server.status.status === "connected"} loading={loadingMcp === server.name} />,
}))
})
const focusedError = createMemo(() => {
const server = focusedServer()
const name = focused()
const server = servers().find((entry) => entry.name === name)
return server ? statusError(server.status) : undefined
})
@@ -126,7 +100,7 @@ export function DialogMcp() {
onSelect={(option) => open(option.value as string)}
actions={[
{
title: toggleTitle(),
title: "toggle",
command: "dialog.mcp.toggle",
onTrigger: (option) => {
setFocused(option.value as string)
+47 -4
View File
@@ -59,6 +59,7 @@ export type PromptProps = {
visible?: boolean
disabled?: boolean
onSubmit?: () => void
onEmptySubmit?: () => boolean | Promise<boolean>
ref?: (ref: PromptRef | undefined) => void
hint?: JSX.Element
right?: JSX.Element
@@ -361,6 +362,20 @@ export function Prompt(props: PromptProps) {
dialog.clear()
},
},
{
title: "Queue prompt",
name: "prompt.queue",
category: "Prompt",
palette: undefined,
run: async (_input: string | undefined, event?: KeyEvent) => {
event?.preventDefault()
event?.stopPropagation()
if (!input.focused) return
const handled = await submit("queue")
if (!handled) return
dialog.clear()
},
},
{
title: "Remove editor context",
name: "prompt.editor_context.clear",
@@ -519,6 +534,11 @@ export function Prompt(props: PromptProps) {
commands: promptCommands(),
}))
Keymap.createLayer(() => ({
priority: 1,
bindings: ["prompt.queue"],
}))
Keymap.createLayer(() => ({
bindings: [
"prompt.submit",
@@ -904,7 +924,7 @@ export function Prompt(props: PromptProps) {
})
let submitting = false
async function submit() {
async function submit(delivery: "steer" | "queue" = "steer") {
// Prevent overlapping invocations (e.g. a double-pressed Enter, or the
// input's native onSubmit racing another dispatch). Without this guard,
// a second call slips past the empty-input check before the first call
@@ -914,13 +934,13 @@ export function Prompt(props: PromptProps) {
if (submitting) return false
submitting = true
try {
return await submitInner()
return await submitInner(delivery)
} finally {
submitting = false
}
}
async function submitInner() {
async function submitInner(delivery: "steer" | "queue") {
// IME: double-defer may fire before onContentChange flushes the last
// composed character (e.g. Korean hangul) to the store, so read
// plainText directly and sync before any downstream reads.
@@ -931,18 +951,39 @@ export function Prompt(props: PromptProps) {
if (props.disabled) return false
if (move.creating()) return false
if (auto()?.visible) return false
if (!store.prompt.text) return false
const trimmed = store.prompt.text.trim()
if (!trimmed) return delivery === "steer" ? (await props.onEmptySubmit?.()) === true : false
if (
delivery === "queue" &&
(store.mode === "shell" || trimmed === "exit" || trimmed === "quit" || trimmed === ":q")
) {
toast.show({ message: "This prompt cannot be queued", variant: "warning" })
return false
}
if (trimmed === "exit" || trimmed === "quit" || trimmed === ":q") {
void exit()
return true
}
const slash = argumentSlash(store.prompt.text, keymapCommands())
if (slash) {
if (delivery === "queue") {
toast.show({ message: "This prompt cannot be queued", variant: "warning" })
return false
}
clearPrompt()
await slash.command.run(slash.input)
return true
}
if (
delivery === "queue" &&
store.prompt.text.startsWith("/") &&
(data.location.skill.list(currentLocation.current) ?? []).some(
(skill) => skill.slash === true && skill.id === store.prompt.text.split("\n")[0].split(" ")[0].slice(1),
)
) {
toast.show({ message: "Skills cannot be queued", variant: "warning" })
return false
}
const agent = local.agent.current()
if (!agent) return false
const selectedModel = local.model.current()
@@ -1036,6 +1077,7 @@ export function Prompt(props: PromptProps) {
model: { providerID: selectedModel.providerID, id: selectedModel.modelID, variant },
files: store.prompt.files,
agents: store.prompt.agents,
delivery,
})
.catch((error) => {
toast.show({ title: "Failed to run command", message: errorMessage(error), variant: "error" })
@@ -1103,6 +1145,7 @@ export function Prompt(props: PromptProps) {
text: inputText,
files: store.prompt.files,
agents: store.prompt.agents,
delivery,
})
.then(
() => undefined,
+5 -1
View File
@@ -104,6 +104,7 @@ export const Definitions = {
session_background: keybind("ctrl+b", "Background blocking session tools"),
session_compact: keybind("<leader>c", "Compact the session"),
session_queued_prompts: keybind("<leader>q", "View pending work"),
queued_prompt_delete: keybind("ctrl+d", "Delete queued prompt"),
session_child_first: keybind("down", "Toggle subagent picker"),
session_parent: keybind("up", "Go to parent session"),
session_pin_toggle: keybind("ctrl+f", "Pin or unpin session in the session list"),
@@ -161,6 +162,7 @@ export const Definitions = {
display_thinking: keybind("none", "Toggle thinking blocks visibility"),
prompt_submit: keybind("none", "Submit prompt"),
prompt_queue: keybind("alt+return", "Queue prompt"),
prompt_editor_context_clear: keybind("none", "Clear editor context"),
prompt_skills: keybind("none", "Open skill selector"),
prompt_stash: keybind("none", "Stash prompt"),
@@ -170,7 +172,7 @@ export const Definitions = {
input_clear: keybind("ctrl+c", "Clear input field"),
input_paste: keybind({ key: "ctrl+v", preventDefault: false }, "Paste from clipboard"),
input_submit: keybind("return", "Submit input"),
input_newline: keybind("shift+return,ctrl+return,alt+return,ctrl+j", "Insert newline in input"),
input_newline: keybind("shift+return,ctrl+return,ctrl+j", "Insert newline in input"),
input_move_left: keybind("left,ctrl+b", "Move cursor left in input"),
input_move_right: keybind("right,ctrl+f", "Move cursor right in input"),
input_move_up: keybind("up", "Move cursor up in input"),
@@ -305,6 +307,7 @@ export const CommandMap = {
session_background: "session.background",
session_compact: "session.compact",
session_queued_prompts: "session.queued_prompts",
queued_prompt_delete: "queued_prompt.delete",
session_child_first: "session.child.first",
session_parent: "session.parent",
session_pin_toggle: "session.pin.toggle",
@@ -359,6 +362,7 @@ export const CommandMap = {
messages_redo: "session.redo",
display_thinking: "session.toggle.thinking",
prompt_submit: "prompt.submit",
prompt_queue: "prompt.queue",
prompt_editor_context_clear: "prompt.editor_context.clear",
prompt_skills: "prompt.skills",
prompt_stash: "prompt.stash",
+48 -15
View File
@@ -168,12 +168,27 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
function removePending(sessionID: string, inputID?: string) {
if (!inputID) return
setStore(
"session",
"pending",
sessionID,
(store.session.pending[sessionID] ?? []).filter((item) => item.id !== inputID),
)
if (store.session.pending[sessionID]?.some((item) => item.id === inputID))
setStore(
"session",
"pending",
sessionID,
(store.session.pending[sessionID] ?? []).filter((item) => item.id !== inputID),
)
if (store.session.input[sessionID]?.includes(inputID))
setStore(
"session",
"input",
sessionID,
(store.session.input[sessionID] ?? []).filter((id) => id !== inputID),
)
}
function updatePending(sessionID: string, inputID: string, delivery: "steer" | "queue") {
const index = store.session.pending[sessionID]?.findIndex((item) => item.id === inputID) ?? -1
const item = store.session.pending[sessionID]?.[index]
if (index < 0 || !item || item.type === "compaction" || item.delivery === delivery) return
setStore("session", "pending", sessionID, index, { ...item, delivery })
}
const message = {
@@ -222,6 +237,12 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
(item): item is SessionMessageAssistantReasoning => item.type === "reasoning" && !item.time?.completed,
)
},
reindex(messages: SessionMessageInfo[], index: Map<string, number>, start: number) {
for (let position = start; position < messages.length; position++) {
const item = messages[position]
if (item) index.set(item.id, position)
}
},
}
function index(sessionID: string) {
@@ -403,24 +424,36 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
}
break
case "session.input.promoted": {
const admitted = store.session.input[event.data.sessionID]?.includes(event.data.inputID) ?? false
removePending(event.data.sessionID, event.data.inputID)
message.update(event.data.sessionID, (draft, index) => {
const position = index.get(event.data.inputID)
if (position === undefined) return
const existing = draft[position]
if (!existing || !store.session.input[event.data.sessionID]?.includes(event.data.inputID)) return
if (!existing || !admitted) return
existing.time.created = event.created
draft.splice(position, 1)
draft.push(existing)
index.clear()
draft.forEach((message, indexValue) => index.set(message.id, indexValue))
message.reindex(draft, index, position)
})
setStore(
"session",
"input",
event.data.sessionID,
(store.session.input[event.data.sessionID] ?? []).filter((id) => id !== event.data.inputID),
)
break
}
case "session.input.steered":
updatePending(event.data.sessionID, event.data.inputID, "steer")
break
case "session.input.queued":
updatePending(event.data.sessionID, event.data.inputID, "queue")
break
case "session.input.cancelled": {
removePending(event.data.sessionID, event.data.inputID)
if (messageIndex.get(event.data.sessionID)?.has(event.data.inputID))
message.update(event.data.sessionID, (draft, index) => {
const position = index.get(event.data.inputID)
if (position === undefined) return
draft.splice(position, 1)
index.delete(event.data.inputID)
message.reindex(draft, index, position)
})
break
}
case "session.input.admitted":
+32 -5
View File
@@ -3,7 +3,9 @@ import { TextAttributes, type InputRenderable, type KeyEvent } from "@opentui/co
import { useKeyboard, type JSX } from "@opentui/solid"
import fuzzysort from "fuzzysort"
import { createEffect, createMemo, createSignal, type Accessor } from "solid-js"
import { Keymap } from "../context/keymap"
import { RunFooterMenu, createFooterMenuState, type RunFooterMenuItem } from "./footer.menu"
import { monoShortcut } from "./mono"
import type { RunFooterTheme } from "./theme"
import type {
FooterQueuedPrompt,
@@ -56,6 +58,10 @@ type SkillEntry = PanelEntry & {
name: string
}
type QueuedPromptEntry = PanelEntry & {
prompt: FooterQueuedPrompt
}
type SubagentEntry = PanelEntry & {
sessionID: string
current: boolean
@@ -837,28 +843,48 @@ export function RunQueuedPromptSelectBody(props: {
theme: Accessor<RunFooterTheme>
prompts: Accessor<FooterQueuedPrompt[]>
onClose: () => void
onSteer: (prompt: FooterQueuedPrompt) => void
onDelete: (prompt: FooterQueuedPrompt) => void
onRows?: (rows: number) => void
mono?: boolean
}) {
const entries = createMemo(() =>
const entries = createMemo<QueuedPromptEntry[]>(() =>
props.prompts().map((prompt) => ({
category: "",
display: prompt.prompt.text.replaceAll("\n", " "),
footer: prompt.delivery,
footer: "queued",
keywords: prompt.prompt.text,
prompt,
})),
)
const controller = createSearchablePanelController({
entries,
limit: SUBAGENT_LIST_ROWS,
onClose: props.onClose,
onSelect: props.onClose,
onSelect: (item) => props.onSteer(item.prompt),
onRows: props.onRows,
})
const shortcuts = Keymap.useShortcuts()
const deleteShortcut = () => monoShortcut(shortcuts.get("queued_prompt.delete") ?? "", props.mono ?? false)
Keymap.createLayer(() => ({
priority: 1,
commands: [
{
id: "queued_prompt.delete",
title: "Delete queued prompt",
group: "Prompt",
run() {
const item = controller.items()[controller.menu.selected()]
if (!item) return false
props.onDelete(item.prompt)
},
},
],
}))
return (
<PanelShell
title="Pending work"
title="Queued prompts"
query={controller.query()}
count={controller.items().length}
total={entries().length}
@@ -866,6 +892,7 @@ export function RunQueuedPromptSelectBody(props: {
theme={props.theme}
inputRef={controller.inputRef}
onQuery={controller.setQuery}
hint={["enter steer", deleteShortcut() ? `${deleteShortcut()} delete` : undefined].filter(Boolean).join(" · ")}
mono={props.mono}
>
<RunFooterMenu
@@ -875,7 +902,7 @@ export function RunQueuedPromptSelectBody(props: {
offset={controller.menu.offset}
rows={controller.menu.rows}
limit={SUBAGENT_LIST_ROWS}
empty="No pending work"
empty="No queued prompts"
border={false}
paddingLeft={panelPad(props.mono)}
paddingRight={panelPad(props.mono)}
+56 -13
View File
@@ -19,6 +19,7 @@ import {
displayCharAt,
displaySlice,
isExitCommand,
isCompactCommand,
mentionTriggerIndex,
isNewCommand,
movePromptHistory,
@@ -31,7 +32,15 @@ import { realignEditorPromptParts, resolveEditorSlashValue } from "./prompt.edit
import { monoTruncateMiddle } from "./mono"
import { FOOTER_MENU_ROWS, createFooterMenuState, type RunFooterMenuItem } from "./footer.menu"
import type { RunFooterTheme } from "./theme"
import type { FooterState, RunAgent, RunCommand, RunPrompt, RunPromptPart, RunReference } from "./types"
import type {
FooterQueuedPrompt,
FooterState,
RunAgent,
RunCommand,
RunPrompt,
RunPromptPart,
RunReference,
} from "./types"
const AUTOCOMPLETE_ROWS = FOOTER_MENU_ROWS
const AUTOCOMPLETE_BOTTOM_ROWS = 1
@@ -72,6 +81,8 @@ type PromptInput = {
theme: Accessor<RunFooterTheme>
mono: Accessor<boolean>
history?: Accessor<RunPrompt[]>
queuedPrompts: Accessor<FooterQueuedPrompt[]>
onQueuedPromptSteer: (inputID: string) => Promise<boolean>
onSubmit: (input: RunPrompt) => boolean | Promise<boolean>
onCycle: () => void
onInterrupt: () => boolean
@@ -980,8 +991,18 @@ export function createPromptState(input: PromptInput): PromptState {
}))
Keymap.createLayer(() => ({
priority: 1,
enabled: input.prompt() && !visible(),
commands: [
{
id: "prompt.queue",
title: "Queue prompt",
group: "Prompt",
run() {
syncDraft()
submitPrompt(promptCopy(draft), "queue")
},
},
{
id: "prompt.editor",
title: "Open editor",
@@ -1116,7 +1137,8 @@ export function createPromptState(input: PromptInput): PromptState {
}
}
const submitPrompt = (next: RunPrompt) => {
let submitting = false
const submitPrompt = (next: RunPrompt, delivery: "steer" | "queue" = "steer") => {
if (!area || area.isDestroyed) {
draft = promptCopy(next)
}
@@ -1130,12 +1152,29 @@ export function createPromptState(input: PromptInput): PromptState {
hide()
}
if (submitting) return
if (!next.text.trim()) {
const queued = delivery === "steer" ? input.queuedPrompts()[0] : undefined
if (queued) {
submitting = true
void input.onQueuedPromptSteer(queued.messageID).finally(() => {
submitting = false
})
return
}
input.onStatus(input.state().phase === "running" ? "waiting for current response" : "empty prompt ignored")
return
}
const command = next.mode === "shell" ? undefined : selectedCommand(next.text, next.command)
if (
delivery === "queue" &&
(next.mode === "shell" || command?.source === "skill" || isNewCommand(next.text) || isCompactCommand(next.text))
) {
input.onStatus("this prompt cannot be queued")
return
}
if (!command && next.mode !== "shell" && isExitCommand(next.text)) {
input.onExit()
return
@@ -1157,24 +1196,28 @@ export function createPromptState(input: PromptInput): PromptState {
}
const submit = command
? { ...next, command }
? { ...next, command, delivery }
: parsed?.type === "command"
? { ...next, command: parsed.command }
: next
? { ...next, command: parsed.command, delivery }
: { ...next, delivery }
const shellMode = next.mode === "shell"
submitting = true
resetDraft()
queueMicrotask(async () => {
if (await input.onSubmit(submit)) {
push(next)
if (shellMode) {
setShellMode(false)
draft = emptyPrompt(false)
try {
if (await input.onSubmit(submit)) {
push(next)
if (shellMode) {
setShellMode(false)
draft = emptyPrompt(false)
}
return
}
return
restore(next)
} finally {
submitting = false
}
restore(next)
})
}
+3
View File
@@ -51,6 +51,7 @@ import type {
MiniSettingChange,
MiniSettings,
PermissionReply,
QueuedPromptAction,
RunAgent,
RunCommand,
RunInput,
@@ -96,6 +97,7 @@ type RunFooterOptions = {
onVariantSelect?: (variant: string | undefined) => CycleResult | void | Promise<CycleResult | void>
onInterrupt?: () => void
onBackground?: () => void
onQueuedPromptAction?: (action: QueuedPromptAction, inputID: string) => Promise<void>
onEditorOpen: (input: { value: string }) => Promise<string | undefined>
onSubagentSelect?: (sessionID: string | undefined) => void
onSubagentInterrupt?: (sessionID: string) => void
@@ -343,6 +345,7 @@ export class RunFooter implements FooterApi {
onCycle: footer.handleCycle,
onInterrupt: footer.handleInterrupt,
onBackground: options.onBackground,
onQueuedPromptAction: options.onQueuedPromptAction,
onEditorOpen: options.onEditorOpen,
onInputClear: footer.handleInputClear,
onExitRequest: footer.handleExit,
+40 -10
View File
@@ -34,6 +34,7 @@ import { Keymap } from "../context/keymap"
import { modelInfo } from "./variant.shared"
import { monoShortcut } from "./mono"
import { stringWidth } from "../util/string-width"
import { errorMessage } from "../util/error"
import type {
FooterPromptRoute,
@@ -46,6 +47,7 @@ import type {
MiniSettingChange,
MiniSettings,
PermissionReply,
QueuedPromptAction,
RunAgent,
RunCommand,
RunInput,
@@ -92,13 +94,14 @@ type RunFooterViewProps = {
mono: boolean
miniSettings: () => MiniSettings
history?: () => RunPrompt[]
onSubmit: (input: RunPrompt) => boolean
onSubmit: (input: RunPrompt) => boolean | Promise<boolean>
onPermissionReply: (input: PermissionReply) => void | Promise<void>
onFormReply: (input: FormReply) => void | Promise<void>
onFormCancel: (input: FormCancel) => void | Promise<void>
onCycle: () => void
onInterrupt: () => boolean
onBackground?: () => void
onQueuedPromptAction?: (action: QueuedPromptAction, inputID: string) => Promise<void>
onEditorOpen: (input: { value: string }) => Promise<string | undefined>
onInputClear: () => void
onExitRequest?: () => boolean
@@ -132,6 +135,7 @@ export function RunFooterView(props: RunFooterViewProps) {
const [route, setRoute] = createSignal<FooterPromptRoute>({ type: "composer" })
const [subagentMenuRows, setSubagentMenuRows] = createSignal(RUN_SUBAGENT_PANEL_ROWS)
const queuedPrompts = createMemo(() => props.queuedPrompts?.() ?? [])
const queue = createMemo(() => queuedPrompts().filter((item) => item.delivery === "queue"))
const skills = createMemo(() => (props.commands() ?? []).filter((item) => item.source === "skill"))
const prompt = createMemo(() => active().type === "prompt" && route().type === "composer")
const selectingSubagent = createMemo(() => active().type === "prompt" && route().type === "subagent-menu")
@@ -229,7 +233,7 @@ export function RunFooterView(props: RunFooterViewProps) {
const details = [busy() ? "running" : "idle", `agent ${props.currentAgent()}`]
if (current) details.push(variant ? `${current} ${variant}` : current)
if (usage()) details.push(props.mono ? usage().replaceAll(" · ", " - ") : usage())
if (queuedPrompts().length > 0) details.push(`${queuedPrompts().length} pending`)
if (queue().length > 0) details.push(`${queue().length} queued`)
if (activeTabs().length > 0) details.push(`${activeTabs().length} subagent${activeTabs().length === 1 ? "" : "s"}`)
return details.join(props.mono ? " - " : " · ")
})
@@ -309,7 +313,7 @@ export function RunFooterView(props: RunFooterViewProps) {
}
const openQueuedMenu = () => {
if (queuedPrompts().length === 0) return
if (queue().length === 0) return
setRoute({ type: "queued-menu" })
props.onSubagentSelect?.(undefined)
}
@@ -318,6 +322,23 @@ export function RunFooterView(props: RunFooterViewProps) {
setRoute({ type: "composer" })
}
const pendingQueueActions = new Set<string>()
const queuedPromptAction = async (action: QueuedPromptAction, inputID: string) => {
if (pendingQueueActions.has(inputID)) return false
const run = props.onQueuedPromptAction
if (!run) return false
pendingQueueActions.add(inputID)
const error = await run(action, inputID)
.then(
() => undefined,
(error) => error,
)
.finally(() => pendingQueueActions.delete(inputID))
if (!error) return true
props.onStatus(`failed to ${action === "cancel" ? "delete" : action} queued prompt: ${errorMessage(error)}`)
return false
}
const openTab = (sessionID: string) => {
setRoute({ type: "subagent", sessionID })
props.onSubagentSelect?.(sessionID)
@@ -357,6 +378,8 @@ export function RunFooterView(props: RunFooterViewProps) {
theme,
mono: () => props.mono,
history: props.history,
queuedPrompts: queue,
onQueuedPromptSteer: (inputID) => queuedPromptAction("steer", inputID),
onSubmit: props.onSubmit,
onCycle: props.onCycle,
onInterrupt: props.onInterrupt,
@@ -451,13 +474,12 @@ export function RunFooterView(props: RunFooterViewProps) {
if (foregroundSubagents() && backgroundShortcut()) {
items.push({ key: backgroundShortcut(), label: "background" })
}
if (queuedPrompts().length > 0 && queuedShortcut()) {
items.push({ key: queuedShortcut(), label: `${queuedPrompts().length} pending` })
if (queue().length > 0 && queuedShortcut()) {
items.push({ key: queuedShortcut(), label: `${queue().length} queued` })
}
if (activeTabs().length > 0 && subagentShortcut()) {
items.push({ key: subagentShortcut(), label: "subagents" })
}
return items
})
const commandHint = createMemo(() => {
@@ -568,7 +590,7 @@ export function RunFooterView(props: RunFooterViewProps) {
}))
Keymap.createLayer(() => ({
enabled: active().type === "prompt" && route().type === "composer" && queuedPrompts().length > 0,
enabled: active().type === "prompt" && route().type === "composer" && queue().length > 0,
commands: [
{
id: "session.queued_prompts",
@@ -630,7 +652,7 @@ export function RunFooterView(props: RunFooterViewProps) {
})
createEffect(() => {
if (route().type !== "queued-menu" || queuedPrompts().length > 0) return
if (route().type !== "queued-menu" || queue().length > 0) return
closePanel()
})
@@ -734,8 +756,16 @@ export function RunFooterView(props: RunFooterViewProps) {
<Match when={selectingQueued()}>
<RunQueuedPromptSelectBody
theme={theme}
prompts={queuedPrompts}
prompts={queue}
onClose={closePanel}
onSteer={(item) => {
void queuedPromptAction("steer", item.messageID).then((steered) => {
if (steered) closePanel()
})
}}
onDelete={(item) => {
void queuedPromptAction("cancel", item.messageID)
}}
onRows={setSubagentMenuRows}
mono={props.mono}
/>
@@ -745,7 +775,7 @@ export function RunFooterView(props: RunFooterViewProps) {
theme={theme}
commands={props.commands}
subagents={tabs}
queued={queuedPrompts}
queued={queue}
variants={props.variants}
variantCycle={variantCycle()}
onClose={closePanel}
@@ -22,6 +22,7 @@ import type {
MiniSettings,
MiniHost,
PermissionReply,
QueuedPromptAction,
RunAgent,
RunInput,
RunPrompt,
@@ -70,6 +71,7 @@ export type LifecycleInput = {
onVariantSelect?: (variant: string | undefined) => CycleResult | void | Promise<CycleResult | void>
onInterrupt?: () => void
onBackground?: () => void
onQueuedPromptAction?: (action: QueuedPromptAction, inputID: string) => Promise<void>
onSubagentSelect?: (sessionID: string | undefined) => void
onSubagentInterrupt?: (sessionID: string) => void
}
@@ -243,6 +245,7 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lif
onVariantSelect: input.onVariantSelect,
onInterrupt: input.onInterrupt,
onBackground: input.onBackground,
onQueuedPromptAction: input.onQueuedPromptAction,
onEditorOpen: async ({ value }) => {
if (closed || renderer.isDestroyed) {
return
+5 -4
View File
@@ -25,7 +25,7 @@ export type QueueInput = {
onAdmissionError?: (prompt: RunPrompt, error: unknown) => void | Promise<void>
onNewSession?: () => void | Promise<void>
onCompact?: () => void | Promise<void>
admit: (prompt: RunPrompt, signal: AbortSignal) => Promise<void>
admit: (prompt: RunPrompt, delivery: "steer" | "queue", signal: AbortSignal) => Promise<void>
settle: () => Promise<void>
run: (prompt: RunPrompt, signal: AbortSignal, admitted: () => void) => Promise<void>
}
@@ -183,7 +183,7 @@ export async function runPromptQueue(input: QueueInput): Promise<void> {
input.trace?.write("ui.commit", commit)
input.footer.append(commit)
}
input.onSend?.(sent, "steer")
input.onSend?.(sent, sent.delivery ?? "steer")
if (state.closed) {
break
@@ -276,10 +276,11 @@ export async function runPromptQueue(input: QueueInput): Promise<void> {
const sent = { ...prompt, messageID: SessionMessage.ID.create() }
const admission = state.admission
admissionVersion += 1
input.onSend?.(sent, "queue")
const delivery = prompt.delivery ?? "queue"
input.onSend?.(sent, delivery)
admissions = admissions
.then(() => admission)
.then(() => input.admit(sent, admissionController.signal))
.then(() => input.admit(sent, delivery, admissionController.signal))
.catch((error) => (state.closed ? undefined : input.onAdmissionError?.(sent, error)))
return
}
+23 -11
View File
@@ -390,6 +390,15 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
log?.write("send.background", { sessionID: state.sessionID })
void state.sdk.session.background({ sessionID: state.sessionID }).catch(() => {})
},
onQueuedPromptAction: async (action, inputID) => {
if (!state.sessionID) return
log?.write(`send.pending.${action}`, { sessionID: state.sessionID, inputID })
if (action === "steer") {
await state.sdk.session.pending.steer({ sessionID: state.sessionID, inputID })
return
}
await state.sdk.session.pending.cancel({ sessionID: state.sessionID, inputID })
},
onSubagentInterrupt: (sessionID) => {
log?.write("send.subagent.interrupt", { sessionID })
void state.sdk.session.interrupt({ sessionID }).catch(() => {})
@@ -892,7 +901,7 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
trace: log,
onSend: (prompt, delivery) => {
state.shown = true
state.history.push(prompt)
state.history.push({ ...prompt, delivery: undefined })
if (prompt.mode !== "shell" && delivery === "steer") {
rememberLocal({
kind: "user",
@@ -903,18 +912,21 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
})
}
},
admit: async (prompt, signal) => {
admit: async (prompt, delivery, signal) => {
await state.switching?.catch(() => {})
const next = await ensureStream()
await next.handle.queuePromptTurn({
agent: state.agent,
model: state.model,
variant: state.activeVariant,
prompt,
files: input.files,
includeFiles: false,
signal,
})
await next.handle.admitPromptTurn(
{
agent: state.agent,
model: state.model,
variant: state.activeVariant,
prompt,
files: input.files,
includeFiles: false,
signal,
},
delivery,
)
},
onAdmissionError: renderPromptError,
onCompact: async () => {
@@ -653,6 +653,10 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
}
return
}
if (event.type === "session.input.cancelled") {
child.prompts.delete(event.data.inputID)
return
}
if (event.type === "session.step.started") {
touch(child, event.created)
if (child.label === FALLBACK_LABEL && event.data.agent) child.label = Locale.titlecase(event.data.agent)
+40 -6
View File
@@ -71,7 +71,7 @@ export type SessionResizeReplayInput = {
export type SessionTransport = {
runPromptTurn(input: SessionTurnInput, admitted?: () => void): Promise<void>
queuePromptTurn(input: SessionTurnInput): Promise<void>
admitPromptTurn(input: SessionTurnInput, delivery: "steer" | "queue"): Promise<void>
waitForIdle(): Promise<void>
interruptActiveTurn(): Promise<void>
selectSubagent(sessionID: string | undefined): void
@@ -515,8 +515,12 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
)
}
let syncedPending: string[] | undefined
const syncPending = () => {
const prompts = [...state.pending.values()]
const prompts = [...state.pending.values()].filter((item) => item.delivery === "queue")
const ids = prompts.map((item) => item.messageID)
if (syncedPending?.length === ids.length && syncedPending.every((id, index) => id === ids[index])) return
syncedPending = ids
input.trace?.write("ui.patch", { pending: prompts.length })
input.footer.event({ type: "queued.prompts", prompts })
}
@@ -934,6 +938,36 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
write([], { phase: "running", status: "waiting for assistant" })
return
}
if (event.type === "session.input.steered") {
const pending = state.pending.get(event.data.inputID)
if (!pending) return
state.pending.set(event.data.inputID, { ...pending, delivery: "steer" })
syncPending()
if (state.messageIDs.has(event.data.inputID)) return
state.messageIDs.add(event.data.inputID)
write([
{
kind: "user",
source: "system",
text: pending.prompt.text,
phase: "start",
messageID: event.data.inputID,
},
])
return
}
if (event.type === "session.input.queued") {
const pending = state.pending.get(event.data.inputID)
if (!pending) return
state.pending.set(event.data.inputID, { ...pending, delivery: "queue" })
syncPending()
return
}
if (event.type === "session.input.cancelled") {
state.admitted.delete(event.data.inputID)
if (state.pending.delete(event.data.inputID)) syncPending()
return
}
if (event.type === "session.step.started") {
state.stepModel = { providerID: event.data.model.providerID, modelID: event.data.model.id }
write([], { phase: "running", status: "assistant responding" })
@@ -1643,14 +1677,14 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
}
return {
async queuePromptTurn(next) {
async admitPromptTurn(next, delivery) {
if (next.prompt.mode === "shell" || next.prompt.command?.source === "skill")
throw new Error("This prompt cannot be queued")
if (!state.connected) throw new Error("Event stream is reconnecting")
const client = sdk
if (next.agent)
await client.session.switchAgent({ sessionID: input.sessionID, agent: next.agent }, { signal: next.signal })
mergePending(await admitPrompt(next, client, "queue"))
mergePending(await admitPrompt(next, client, delivery))
settlementClient = client
},
async waitForIdle() {
@@ -1688,7 +1722,7 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
return
}
if (command) {
await runTurnWait(next, messageID, client, () => admitPrompt(next, client, "steer"), admitted)
await runTurnWait(next, messageID, client, () => admitPrompt(next, client, next.prompt.delivery ?? "steer"), admitted)
return
}
@@ -1700,7 +1734,7 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
if (selected)
await client.session.switchModel({ sessionID: input.sessionID, model: selected }, { signal: next.signal })
await runTurnWait(next, messageID, client, () => admitPrompt(next, client, "steer"), admitted)
await runTurnWait(next, messageID, client, () => admitPrompt(next, client, next.prompt.delivery ?? "steer"), admitted)
},
async interruptActiveTurn() {
// A running shell holds no drain, so session.interrupt cannot reach it;
+3
View File
@@ -75,6 +75,7 @@ export type RunPrompt = {
messageID?: string
text: string
parts: RunPromptPart[]
delivery?: "steer" | "queue"
mode?: "shell"
command?: {
name: string
@@ -90,6 +91,8 @@ export type FooterQueuedPrompt = {
delivery: "steer" | "queue"
}
export type QueuedPromptAction = "steer" | "cancel"
export type RunAgent = {
id: string
name: string
+127 -4
View File
@@ -52,6 +52,7 @@ import { useClient } from "../../context/client"
import { useEditorContext } from "../../context/editor"
import { openEditor } from "../../editor"
import { useDialog } from "../../ui/dialog"
import { DialogSelect } from "../../ui/dialog-select"
import { DialogSessionRename } from "../../component/dialog-session-rename"
import { DialogMessage } from "./dialog-message"
import { DialogFork } from "./dialog-fork"
@@ -109,6 +110,7 @@ const NAVIGATION_SLACK_ID = "session-navigation-slack"
const TRANSCRIPT_TAIL_ROWS = 40
const TRANSCRIPT_BACKFILL_CHUNK = 60
const TRANSCRIPT_BACKFILL_DELAY = 120
type PendingAction = "steer" | "queue" | "cancel"
const context = createContext<{
width: number
@@ -120,6 +122,7 @@ const context = createContext<{
diffWrapMode: () => "word" | "none"
models: () => ModelInfo[]
config: ReturnType<typeof useConfig>["data"]
mutatePending: (action: PendingAction, inputID: string) => Promise<boolean>
}>()
function use() {
@@ -175,6 +178,11 @@ export function Session() {
.flatMap((sessionID) => data.session.form.list(sessionID) ?? [])
.concat(global)
})
const queuedPrompts = createMemo(() =>
data.session.pending.list(route.sessionID).flatMap((item) =>
item.type === "user" && item.delivery === "queue" ? [{ id: item.id, text: item.data.text }] : [],
),
)
const [composer, setComposer] = createStore({
open: false,
tab: undefined as string | undefined,
@@ -369,6 +377,55 @@ export function Session() {
})
const dialog = useDialog()
const renderer = useRenderer()
const pendingQueueActions = new Set<string>()
const mutatePending = async (action: PendingAction, inputID: string) => {
if (pendingQueueActions.has(inputID)) return false
pendingQueueActions.add(inputID)
const request =
action === "steer"
? client.api.session.pending.steer({ sessionID: route.sessionID, inputID })
: action === "queue"
? client.api.session.pending.queue({ sessionID: route.sessionID, inputID })
: client.api.session.pending.cancel({ sessionID: route.sessionID, inputID })
const error = await request
.then(
() => undefined,
(error) => error,
)
.finally(() => pendingQueueActions.delete(inputID))
if (!error) return true
const label = action === "cancel" ? "delete" : action
toast.show({ title: `Failed to ${label} pending prompt`, message: errorMessage(error), variant: "error" })
return false
}
const openQueuedPrompts = () =>
dialog.replace(() => (
<DialogSelect
title="Queued prompts"
options={queuedPrompts().map((prompt, index) => ({
title: prompt.text,
value: prompt.id,
footer: `${index + 1} of ${queuedPrompts().length}`,
}))}
onSelect={(option) => {
void mutatePending("steer", option.value).then((steered) => {
if (steered) dialog.clear()
})
}}
actions={[
{
command: "queued_prompt.delete",
title: "delete",
onTrigger: (option) => {
void mutatePending("cancel", option.value).then((cancelled) => {
if (cancelled && queuedPrompts().length <= 1) dialog.clear()
})
},
},
]}
footerHints={[{ title: "steer", label: "enter" }]}
/>
))
const unavailable = (feature: string) => {
toast.show({ message: `${feature} is not implemented for V2 sessions yet`, variant: "error", duration: 5000 })
dialog.clear()
@@ -880,6 +937,13 @@ export function Session() {
dialog.clear()
},
},
{
title: "View queued prompts",
id: "session.queued_prompts",
group: "Session",
enabled: queuedPrompts().length > 0,
run: openQueuedPrompts,
},
{
title: "Go to parent session",
id: "session.parent",
@@ -951,6 +1015,7 @@ export function Session() {
diffWrapMode,
models,
config,
mutatePending,
}}
>
<box flexDirection="row" flexGrow={1} minHeight={0}>
@@ -1006,6 +1071,9 @@ export function Session() {
</Show>
</scrollbox>
<box flexShrink={0}>
<Show when={!composer.open && !disabled() && queuedPrompts().length > 0}>
<QueuedPromptDock prompts={queuedPrompts()} onOpen={openQueuedPrompts} />
</Show>
<PluginSlot name="session.composer.top" input={{ sessionID: route.sessionID }} mode="all" />
<Composer
sessionID={route.sessionID}
@@ -1041,6 +1109,11 @@ export function Session() {
onSubmit={() => {
toBottom()
}}
onEmptySubmit={async () => {
const next = queuedPrompts()[0]
if (!next) return false
return mutatePending("steer", next.id)
}}
sessionID={route.sessionID}
/>
</Match>
@@ -1822,6 +1895,7 @@ function ShellMessage(props: { message: Extract<SessionMessageInfo, { type: "she
return (
<box
width="100%"
border={["left"]}
paddingTop={1}
paddingBottom={1}
@@ -1849,18 +1923,23 @@ function UserMessage(props: { message: SessionMessageUser }) {
const mode = themes.mode
const [hover, setHover] = createSignal(false)
const color = createMemo(() => local.agent.color(data.session.get(ctx.sessionID)?.agent ?? "build"))
const queued = createMemo(
() => data.session.status(ctx.sessionID) === "running" && data.session.input.has(ctx.sessionID, props.message.id),
)
const delivery = createMemo(() => {
const pending = data.session.pending.list(ctx.sessionID).find((item) => item.id === props.message.id)
return pending?.type === "user" ? pending.delivery : undefined
})
const dialog = useDialog()
const renderer = useRenderer()
const promptRef = usePromptRef()
const updatePendingSteer = async (action: "queue" | "cancel") => {
if (await ctx.mutatePending(action, props.message.id)) dialog.clear()
}
return (
<Show when={props.message.text.trim() || files().length}>
<box
border={["left"]}
borderColor={queued() ? theme.border.default : color()}
borderColor={delivery() ? theme.border.default : color()}
customBorderChars={SplitBorder.customBorderChars}
>
<box
@@ -1872,6 +1951,21 @@ function UserMessage(props: { message: SessionMessageUser }) {
}}
onMouseUp={() => {
if (renderer.getSelection()?.getSelectedText()) return
if (delivery() === "steer") {
dialog.replace(() => (
<DialogSelect
title="Pending steer"
options={[
{ title: "Move to queue", value: "queue" as const },
{ title: "Delete", value: "cancel" as const },
]}
onSelect={(option) => {
void updatePendingSteer(option.value)
}}
/>
))
return
}
dialog.replace(() => (
<DialogMessage
messageID={props.message.id}
@@ -1919,6 +2013,35 @@ function UserMessage(props: { message: SessionMessageUser }) {
)
}
function QueuedPromptDock(props: { prompts: { id: string; text: string }[]; onOpen: () => void }) {
const theme = useTheme("elevated")
const next = createMemo(() => props.prompts[0]?.text)
return (
<box
border={["left"]}
borderColor={theme.border.default}
customBorderChars={SplitBorder.customBorderChars}
onMouseUp={props.onOpen}
>
<box
width="100%"
paddingTop={1}
paddingBottom={1}
paddingLeft={2}
paddingRight={1}
backgroundColor={theme.background.default}
flexDirection="row"
>
<text fg={theme.text.subdued} wrapMode="none" truncate flexGrow={1} flexShrink={1} minWidth={0}>
<span style={{ fg: theme.text.default }}>{props.prompts.length} queued</span>
<Show when={next()}>{(text) => <> · {text()}</>}</Show>
</text>
</box>
</box>
)
}
function AssistantRetry(props: { retry: SessionMessageAssistant["retry"] }) {
const theme = useTheme()
return (
+15 -8
View File
@@ -46,9 +46,14 @@ export function createSessionRows(sessionID: Accessor<string>) {
function reduce() {
const messages = data.session.message.list(sessionID())
const inputs = new Set(data.session.input.list(sessionID()))
const pending = data.session.pending.list(sessionID())
const queued = new Set(
pending.flatMap((item) => (item.type === "user" && item.delivery === "queue" ? [item.id] : [])),
)
const visible = queued.size === 0 ? messages : messages.filter((message) => !queued.has(message.id))
const boundary = revertBoundary()
const rows = reduceSessionRows(
boundary ? messages.filter((message) => message.id < boundary) : messages,
boundary ? visible.filter((message) => message.id < boundary) : visible,
inputs,
turnTokens(),
)
@@ -57,8 +62,7 @@ export function createSessionRows(sessionID: Accessor<string>) {
rows.splice(
position === -1 ? rows.length : position,
0,
...data.session.pending
.list(sessionID())
...pending
.filter((item) => item.type === "compaction")
.map((item): SessionRow => ({ type: "compaction-queued", inputID: item.id })),
)
@@ -112,10 +116,11 @@ export function createSessionRows(sessionID: Accessor<string>) {
createEffect(
on(
() =>
data.session.pending
.list(sessionID())
.filter((item) => item.type === "compaction")
.map((item) => item.id),
data.session.pending.list(sessionID()).flatMap((item) => {
if (item.type === "compaction") return [`${item.id}:compaction`]
if (item.type === "user" && item.delivery === "queue") return [`${item.id}:queue`]
return []
}),
() => setRows(reconcile(reduce())),
{ defer: true },
),
@@ -196,7 +201,9 @@ export function createSessionRows(sessionID: Accessor<string>) {
const queuedStart = (rows: SessionRow[]) => {
const index = rows.findIndex(
(row) => row.type === "compaction-queued" || (row.type === "message" && isPending(row.messageID)),
(row) =>
row.type === "compaction-queued" ||
(row.type === "message" && isPending(row.messageID)),
)
return index === -1 ? rows.length : index
}
+1 -2
View File
@@ -84,8 +84,7 @@ export function DialogPrompt(props: DialogPromptProps) {
<box gap={1}>
{props.description?.()}
<textarea
height={1}
wrapMode="none"
height={3}
ref={(val: TextareaRenderable) => {
textarea = val
setTextareaTarget(val)
+1 -14
View File
@@ -71,7 +71,6 @@ export interface DialogSelectOption<T = any> {
detailsColor?: RGBA
detailsWrap?: boolean
footer?: JSX.Element | string
footerColor?: RGBA
titleWidth?: number
truncateTitle?: boolean | "left"
category?: string
@@ -728,7 +727,6 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
footer={
flatten() ? (option.searchFooter ?? option.category ?? option.footer) : option.footer
}
footerColor={option.footerColor}
titleWidth={option.titleWidth}
truncateTitle={option.truncateTitle}
description={option.description !== category ? option.description : undefined}
@@ -786,7 +784,6 @@ function Option(props: {
current?: boolean
muted?: boolean
footer?: JSX.Element | string
footerColor?: RGBA
titleWidth?: number
truncateTitle?: boolean | "left"
gutter?: () => JSX.Element
@@ -835,17 +832,7 @@ function Option(props: {
</text>
<Show when={props.footer}>
<box flexShrink={0}>
<text
fg={
props.active && !props.muted
? text()
: props.muted && (props.active || props.current)
? theme.text.subdued
: (props.footerColor ?? theme.text.subdued)
}
>
{props.footer}
</text>
<text fg={props.active && !props.muted ? text() : theme.text.subdued}>{props.footer}</text>
</box>
</Show>
</>
+100
View File
@@ -914,6 +914,106 @@ test("completes exploration when a queued prompt is promoted", async () => {
}
})
test("updates and removes queued inputs from durable lifecycle events", async () => {
const events = createEventStream()
const sessionID = "session-queue-management"
const calls = createFetch((url) => {
if (url.pathname === `/api/session/${sessionID}/message`) return json({ data: [], cursor: {} })
}, events)
let data!: ReturnType<typeof useData>
let rows!: ReturnType<typeof createSessionRows>
let client!: ReturnType<typeof useClient>
function Probe() {
client = useClient()
data = useData()
rows = createSessionRows(() => sessionID)
return <box />
}
const app = await testRender(() => (
<TestTuiContexts>
<ClientProvider api={createApi(calls.fetch)}>
<ProjectProvider>
<DataProvider>
<Probe />
</DataProvider>
</ProjectProvider>
</ClientProvider>
</TestTuiContexts>
))
try {
await wait(() => client.connection.status() === "connected")
emitEvent(events, {
id: "evt_queue_admitted",
created: 1,
type: "session.input.admitted",
durable: durable(sessionID),
data: {
sessionID,
inputID: "message-queued",
input: { type: "user", data: { text: "Steer me" }, delivery: "queue" },
},
})
await wait(() => data.session.pending.list(sessionID).length === 1)
expect(rows).not.toContainEqual({ type: "message", messageID: "message-queued" })
emitEvent(events, {
id: "evt_queue_steered",
created: 2,
type: "session.input.steered",
durable: durable(sessionID, 1),
data: { sessionID, inputID: "message-queued" },
})
await wait(() =>
data.session.pending
.list(sessionID)
.some((item) => item.id === "message-queued" && item.type !== "compaction" && item.delivery === "steer"),
)
expect(rows).toContainEqual({ type: "message", messageID: "message-queued" })
emitEvent(events, {
id: "evt_queue_restored",
created: 3,
type: "session.input.queued",
durable: durable(sessionID, 2),
data: { sessionID, inputID: "message-queued" },
})
await wait(() =>
data.session.pending
.list(sessionID)
.some((item) => item.id === "message-queued" && item.type !== "compaction" && item.delivery === "queue"),
)
expect(rows).not.toContainEqual({ type: "message", messageID: "message-queued" })
emitEvent(events, {
id: "evt_cancel_admitted",
created: 4,
type: "session.input.admitted",
durable: durable(sessionID, 3),
data: {
sessionID,
inputID: "message-cancelled",
input: { type: "user", data: { text: "Delete me" }, delivery: "queue" },
},
})
await wait(() => data.session.pending.list(sessionID).length === 2)
emitEvent(events, {
id: "evt_queue_cancelled",
created: 5,
type: "session.input.cancelled",
durable: durable(sessionID, 4),
data: { sessionID, inputID: "message-cancelled" },
})
await wait(() => !data.session.input.has(sessionID, "message-cancelled"))
expect(data.session.pending.list(sessionID).map((item) => item.id)).toEqual(["message-queued"])
expect(data.session.message.get(sessionID, "message-cancelled")).toBeUndefined()
} finally {
app.renderer.destroy()
}
})
test("classifies live tool rows independently of their call ID", async () => {
const events = createEventStream()
const sessionID = "session-tool-call-id"
+2 -2
View File
@@ -56,9 +56,9 @@ export function createFooterApiFixture(input: { events?: FooterEvent[]; commits?
commits,
calls,
promptReady,
submit(text: string, mode?: RunPrompt["mode"]) {
submit(text: string, mode?: RunPrompt["mode"], delivery?: RunPrompt["delivery"]) {
if (prompts.size === 0) return false
const prompt: RunPrompt = mode ? { text, parts: [], mode } : { text, parts: [] }
const prompt: RunPrompt = { text, parts: [], ...(mode ? { mode } : {}), ...(delivery ? { delivery } : {}) }
for (const fn of [...prompts]) fn(prompt)
return true
},
+97 -23
View File
@@ -21,6 +21,7 @@ import { RunFooterView } from "../../src/mini/footer.view"
import { RunEntryContent } from "../../src/mini/scrollback.writer"
import { RUN_THEME_FALLBACK, type RunTheme } from "../../src/mini/theme"
import type {
FooterQueuedPrompt,
FooterState,
FooterSubagentState,
FooterSubagentTab,
@@ -120,13 +121,15 @@ async function renderFooter(
height?: number
state?: Partial<FooterState>
onCycle?: () => void
onSubmit?: (prompt: RunPrompt) => boolean
onSubmit?: (prompt: RunPrompt) => boolean | Promise<boolean>
view?: FooterView
onFormReply?: (input: unknown) => void
miniSettings?: MiniSettings
mono?: boolean
onStatus?: (status: string) => void
onMiniSettingChange?: (change: MiniSettingChange) => void
queuedPrompts?: FooterQueuedPrompt[]
onQueuedPromptAction?: (action: "steer" | "cancel", inputID: string) => Promise<void>
} = {},
) {
const [view, setView] = createSignal<FooterView>(input.view ?? { type: "prompt" })
@@ -164,6 +167,7 @@ async function renderFooter(
state={state}
view={view}
subagent={subagents}
queuedPrompts={() => input.queuedPrompts ?? []}
theme={input.theme ?? (() => RUN_THEME_FALLBACK)}
mono={input.mono ?? false}
miniSettings={miniSettings}
@@ -173,6 +177,7 @@ async function renderFooter(
onFormCancel={() => {}}
onCycle={input.onCycle ?? (() => {})}
onInterrupt={() => false}
onQueuedPromptAction={input.onQueuedPromptAction}
onEditorOpen={async () => undefined}
onInputClear={() => {}}
onExit={() => {}}
@@ -913,7 +918,7 @@ test("direct subagent panel closes when moving up from the first item", async ()
}
})
test("direct pending panel shows durable delivery without edit actions", async () => {
test("direct queued panel steers and deletes selected prompts", async () => {
const [prompts] = createSignal([
{
messageID: "m-1",
@@ -921,16 +926,22 @@ test("direct pending panel shows durable delivery without edit actions", async (
delivery: "queue" as const,
},
])
const steered: string[] = []
const deleted: string[] = []
const app = await testRender(
() => (
<box width={100} height={RUN_SUBAGENT_PANEL_ROWS}>
<RunQueuedPromptSelectBody
theme={() => RUN_THEME_FALLBACK.footer}
prompts={prompts}
onClose={() => {}}
/>
</box>
<Keymap.Provider config={tuiConfig}>
<box width={100} height={RUN_SUBAGENT_PANEL_ROWS}>
<RunQueuedPromptSelectBody
theme={() => RUN_THEME_FALLBACK.footer}
prompts={prompts}
onClose={() => {}}
onSteer={(prompt) => steered.push(prompt.messageID)}
onDelete={(prompt) => deleted.push(prompt.messageID)}
/>
</box>
</Keymap.Provider>
),
{ width: 100, height: RUN_SUBAGENT_PANEL_ROWS },
)
@@ -940,19 +951,75 @@ test("direct pending panel shows durable delivery without edit actions", async (
const frame = app.captureCharFrame()
const list = panelMenu(app.renderer.root)
expect(frame).toContain("Pending work")
expect(frame).toContain("Queued prompts")
expect(frame).toContain("fix the auth test")
expect(frame).toContain("queue")
expect(frame).toContain("queued")
expect(frame).toContain("enter steer · ctrl+d delete")
expect(frame).not.toContain("┌")
expect(frame).not.toContain("┃")
expectPaletteList(list, 0)
expect(frame).not.toContain("edit")
expect(frame).not.toContain("remove")
app.mockInput.pressEnter()
app.mockInput.pressKey("d", { ctrl: true })
expect(steered).toEqual(["m-1"])
expect(deleted).toEqual(["m-1"])
} finally {
app.renderer.destroy()
}
})
test("direct footer steers the oldest queued prompt from an empty composer", async () => {
const steered: string[] = []
const app = await renderFooter({
queuedPrompts: [
{ messageID: "m-1", prompt: { text: "first", parts: [] }, delivery: "queue" },
{ messageID: "m-2", prompt: { text: "second", parts: [] }, delivery: "queue" },
],
onQueuedPromptAction: async (action, inputID) => {
if (action === "steer") steered.push(inputID)
},
})
try {
await app.renderOnce()
app.mockInput.pressEnter({ meta: true })
await Bun.sleep(0)
expect(steered).toEqual([])
app.mockInput.pressEnter()
await Bun.sleep(0)
expect(steered).toEqual(["m-1"])
} finally {
app.cleanup()
}
})
test("direct footer does not steer queued work on a double submit", async () => {
const submitted: RunPrompt[] = []
const steered: string[] = []
const app = await renderFooter({
queuedPrompts: [{ messageID: "m-1", prompt: { text: "queued", parts: [] }, delivery: "queue" }],
onSubmit: async (prompt) => {
submitted.push(prompt)
await Bun.sleep(10)
return true
},
onQueuedPromptAction: async (action, inputID) => {
if (action === "steer") steered.push(inputID)
},
})
try {
await app.renderOnce()
await app.mockInput.typeText("send once")
app.mockInput.pressEnter()
app.mockInput.pressEnter()
await Bun.sleep(20)
expect(submitted).toHaveLength(1)
expect(steered).toEqual([])
} finally {
app.cleanup()
}
})
// OpenTUI currently crashes Bun in the full `test/cli/run` directory run here.
// Re-enable after the upstream OpenTUI fix lands in this repo.
test.skip("direct footer recreates the frame across command panel transitions", async () => {
@@ -1068,11 +1135,11 @@ test("direct footer submits slash autocomplete selections without dispatching sh
await app.renderOnce()
expect(submits).toEqual([
{ text: "/review ", parts: [], command: { name: "review", arguments: "" } },
{ text: "/review ", parts: [], command: { name: "review", arguments: "" } },
{ text: "/review branch", parts: [], command: { name: "review", arguments: "branch" } },
{ text: "/new ", parts: [] },
{ text: "/new ", parts: [] },
{ text: "/review ", parts: [], command: { name: "review", arguments: "" }, delivery: "steer" },
{ text: "/review ", parts: [], command: { name: "review", arguments: "" }, delivery: "steer" },
{ text: "/review branch", parts: [], command: { name: "review", arguments: "branch" }, delivery: "steer" },
{ text: "/new ", parts: [], delivery: "steer" },
{ text: "/new ", parts: [], delivery: "steer" },
])
expect(app.renderer.currentFocusedEditor?.plainText).toBe("/settings ")
} finally {
@@ -1100,7 +1167,9 @@ test("direct footer slash autocomplete keeps a real skills command", async () =>
app.mockInput.pressEnter()
await app.renderOnce()
expect(submits).toEqual([{ text: "/skills ", parts: [], command: { name: "skills", arguments: "" } }])
expect(submits).toEqual([
{ text: "/skills ", parts: [], command: { name: "skills", arguments: "" }, delivery: "steer" },
])
expect(app.captureCharFrame()).not.toContain("Apply formatter fixes")
} finally {
app.cleanup()
@@ -1158,7 +1227,12 @@ test("direct footer tags skill slash submissions with their catalog source", asy
await app.renderOnce()
expect(submits).toEqual([
{ text: "/formatter src", parts: [], command: { name: "formatter", arguments: "src", source: "skill" } },
{
text: "/formatter src",
parts: [],
command: { name: "formatter", arguments: "src", source: "skill" },
delivery: "steer",
},
])
} finally {
app.cleanup()
@@ -1238,7 +1312,7 @@ test.skip("direct footer clears the synthetic skills draft when the panel closes
}
})
test("direct footer shows authoritative pending work while running", async () => {
test("direct footer shows authoritative queued work while running", async () => {
const [state] = createSignal<FooterState>({
phase: "running",
status: "",
@@ -1342,9 +1416,9 @@ test("direct footer shows authoritative pending work while running", async () =>
const hint = statusItems.at(-1)!
expect(spinner).toBeDefined()
expect(frame).toContain("1 pending")
expect(frame).toContain("1 queued")
expect(frame).toContain("ctrl+b background")
expect(frame).toContain("ctrl+x q 1 pending")
expect(frame).toContain("ctrl+x q 1 queued")
expect(frame).toContain("↓ subagents")
expect(frame).toContain("ctrl+p cmd")
expect(frame).toContain("subagents · ctrl+p cmd")
+2 -1
View File
@@ -82,7 +82,8 @@ describe("run runtime boot", () => {
expect(result.keybinds.get("prompt.history.next")?.[0]?.key).toBe("down")
expect(result.keybinds.get("prompt.clear")?.[0]?.key).toBe("ctrl+c")
expect(result.keybinds.get("input.submit")?.[0]?.key).toBe("return")
expect(result.keybinds.get("input.newline")?.[0]?.key).toBe("shift+return,ctrl+return,alt+return,ctrl+j")
expect(result.keybinds.get("input.newline")?.[0]?.key).toBe("shift+return,ctrl+return,ctrl+j")
expect(result.keybinds.get("prompt.queue")?.[0]?.key).toBe("alt+return")
})
test("preserves disabled leader from resolved tui config", async () => {
+28 -1
View File
@@ -265,6 +265,33 @@ describe("run runtime queue", () => {
await task
})
test("preserves explicit steer and queue delivery for in-flight prompts", async () => {
const ui = createFooterApiFixture()
const admitted: string[] = []
const gate = Promise.withResolvers<void>()
const task = runPromptQueue({
footer: ui.api,
run: async (_input, _signal, onAdmitted) => {
onAdmitted()
await gate.promise
},
admit: async (input, delivery) => {
admitted.push(`${input.text}:${delivery}`)
},
settle: async () => ui.api.close(),
})
ui.submit("one")
ui.submit("two", undefined, "steer")
ui.submit("three", undefined, "queue")
while (admitted.length < 2) await Bun.sleep(0)
expect(admitted).toEqual(["two:steer", "three:queue"])
gate.resolve()
await task
})
test("continues durable admission after one fails", async () => {
const ui = createFooterApiFixture()
const admitted: string[] = []
@@ -308,7 +335,7 @@ describe("run runtime queue", () => {
admitted()
await new Promise<void>((resolve) => signal.addEventListener("abort", () => resolve(), { once: true }))
},
admit: async (_prompt, signal) => {
admit: async (_prompt, _delivery, signal) => {
admissionStarted.resolve()
await new Promise<void>((resolve) => {
if (signal.aborted) {
+3 -3
View File
@@ -126,7 +126,7 @@ describe("run interactive runtime", () => {
turnStarted.resolve()
api.close()
},
queuePromptTurn: async () => {},
admitPromptTurn: async () => {},
waitForIdle: async () => {},
interruptActiveTurn: async () => {},
selectSubagent: () => {},
@@ -209,7 +209,7 @@ describe("run interactive runtime", () => {
streamStarted.resolve()
return {
runPromptTurn: async () => {},
queuePromptTurn: async () => {},
admitPromptTurn: async () => {},
waitForIdle: async () => {},
interruptActiveTurn: async () => {},
selectSubagent: () => {},
@@ -556,7 +556,7 @@ describe("run interactive runtime", () => {
setTimeout(() => input.footer.close(), 0)
return {
runPromptTurn: async () => {},
queuePromptTurn: async () => {},
admitPromptTurn: async () => {},
waitForIdle: async () => {},
interruptActiveTurn: async () => {},
selectSubagent: () => {},
@@ -669,6 +669,14 @@ describe("V2 mini transport", () => {
data: { text: "follow up" },
delivery: "queue",
},
{
id: "msg_cancelled",
sessionID: "ses_1",
timeCreated: 2,
type: "user",
data: { text: "remove me" },
delivery: "queue",
},
],
},
})
@@ -684,11 +692,14 @@ describe("V2 mini transport", () => {
.findLast((item) => item.type === "queued.prompts")
?.prompts.map((item) => [item.messageID, item.delivery])
expect(pending()).toEqual([["msg_queued", "queue"]])
expect(pending()).toEqual([
["msg_queued", "queue"],
["msg_cancelled", "queue"],
])
events.push({
id: "evt_promoted",
created: 2,
type: "session.input.promoted",
id: "evt_steered",
created: 3,
type: "session.input.steered",
durable: durable("ses_1", 2),
data: { sessionID: "ses_1", inputID: "msg_queued" },
})
@@ -697,18 +708,48 @@ describe("V2 mini transport", () => {
expect(ui.commits).toContainEqual(
expect.objectContaining({ kind: "user", messageID: "msg_queued", text: "follow up" }),
)
expect(pending()).toEqual([])
expect(pending()).toEqual([["msg_cancelled", "queue"]])
events.push({
id: "evt_queued",
created: 4,
type: "session.input.queued",
durable: durable("ses_1", 3),
data: { sessionID: "ses_1", inputID: "msg_queued" },
})
while (pending()?.length !== 2) await Bun.sleep(0)
expect(pending()).toEqual([
["msg_queued", "queue"],
["msg_cancelled", "queue"],
])
events.push({
id: "evt_cancelled",
created: 5,
type: "session.input.cancelled",
durable: durable("ses_1", 4),
data: { sessionID: "ses_1", inputID: "msg_cancelled" },
})
while (pending()?.length !== 1) await Bun.sleep(0)
expect(pending()).toEqual([["msg_queued", "queue"]])
events.push({
id: "evt_promoted",
created: 6,
type: "session.input.promoted",
durable: durable("ses_1", 5),
data: { sessionID: "ses_1", inputID: "msg_queued" },
})
while (pending()?.length !== 0) await Bun.sleep(0)
expect(ui.commits.filter((item) => item.messageID === "msg_queued")).toHaveLength(1)
const prompt = spyOn(client.session, "prompt").mockImplementation(
(request) => ok(promptAdmission(request)) as never,
)
await transport.queuePromptTurn({
await transport.admitPromptTurn({
agent: "review",
model: undefined,
variant: undefined,
prompt: { messageID: "msg_next", text: "another", parts: [] },
files: [],
includeFiles: false,
})
}, "queue")
expect(client.session.switchAgent).toHaveBeenCalledWith({ sessionID: "ses_1", agent: "review" }, expect.anything())
expect(prompt).toHaveBeenCalledWith(expect.objectContaining({ delivery: "queue" }), expect.anything())
events.push({
@@ -722,15 +763,8 @@ describe("V2 mini transport", () => {
input: { type: "user", data: { text: "earlier" }, delivery: "steer" },
},
})
while (true) {
const pending = ui.events.findLast((item) => item.type === "queued.prompts")
if (pending?.type === "queued.prompts" && pending.prompts.length >= 2) break
await Bun.sleep(0)
}
expect(pending()).toEqual([
["msg_next", "queue"],
["msg_earlier", "steer"],
])
await Bun.sleep(10)
expect(pending()).toEqual([["msg_next", "queue"]])
await transport.close()
})
@@ -813,14 +847,14 @@ describe("V2 mini transport", () => {
durable: durable("ses_1", 2),
data: { sessionID: "ses_1", inputID: "msg_prompt" },
})
await transport.queuePromptTurn({
await transport.admitPromptTurn({
agent: undefined,
model: undefined,
variant: undefined,
prompt: { messageID: "msg_queued", text: "follow up", parts: [] },
files: [],
includeFiles: false,
})
}, "queue")
events.push({
id: "evt_queued_promoted",
created: 3,
@@ -81,6 +81,7 @@ Add `compaction` to any [OpenCode configuration file](/config):
"$schema": "https://opencode.ai/config.json",
"compaction": {
"auto": true,
"prune": false,
"keep": {
"tokens": 15000
},
@@ -92,6 +93,7 @@ Add `compaction` to any [OpenCode configuration file](/config):
| Field | Default | V2 behavior |
| --- | ---: | --- |
| `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. |
| `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. |
@@ -133,6 +135,8 @@ behavior.
## Current limitations
- `prune` is reserved configuration; V1-style in-place tool-output pruning is
not implemented in V2.
- Compaction requires a resolvable model with a positive catalog context limit.
There is no separate compaction-model setting or fallback model.
- Summary generation can fail if the summary prompt itself cannot fit beside
+17 -54
View File
@@ -16,17 +16,15 @@ V2 has three intentional breaking changes:
- The [server API and clients](#server-api-and-clients) have new contracts.
- [TUI configuration](#tui-configuration) moves from layered `tui.json(c)` files to one global `cli.json` file (auto migrated).
Supported V1 functionality outside those areas is intended to remain compatible with V1. Some fields accepted by the V1
schema never had a V2 equivalent and are intentionally ignored; these are listed under
[Accepted but unsupported fields](#accepted-but-unsupported-fields).
All other functionality is intended to remain compatible with V1.
Existing supported server config fields, agent definitions, command definitions, skills, and other files in `.opencode/`
should continue to work without changes. If supported behavior described in this guide stops working in V2, treat it as a
beta compatibility bug rather than an expected migration requirement.
Existing server config files, agent definitions, command definitions, skills, and other files in `.opencode/` should
continue to work without changes. If one of these stops working in V2, treat it as a beta compatibility bug rather than
an expected migration requirement.
<Callout type="tip">
Run `/report` if supported V1 functionality does not work in V2. The report skill collects diagnostics and helps you
file a compatibility issue.
Run `/report` if existing V1 functionality does not work in V2. The report skill collects diagnostics and helps you file
a compatibility issue.
</Callout>
<Callout type="warning">
@@ -62,9 +60,8 @@ V2 reads existing global and project configuration from the same locations as V1
<project>/.opencode/opencode.json(c)
```
V2 reads these same locations. It normalizes supported V1 and native V2 fields in memory without rewriting the source
file. Existing supported V1 configuration is intended to keep working, so you do not need to convert it to try or adopt
V2.
V2 reads these same locations. It detects V1-shaped configuration and translates it in memory without rewriting the
source file. Existing V1 configuration is intended to keep working, so you do not need to convert it to try or adopt V2.
### Ask OpenCode to migrate
@@ -79,13 +76,7 @@ Preserve its behavior and all unrelated settings.
```
OpenCode can inspect the complete file, apply the relevant changes below, and avoid rewriting settings that do not need to
change. Conversion does not need to happen all at once: supported V1 and native V2 fields may coexist at the top level.
When both forms set the same canonical value, a valid native V2 value takes precedence regardless of JSON key order.
Nested mixing is intentionally bounded. OpenCode recognizes mixed V1 and V2 members within `mcp`, `compaction`, and
`experimental`, but it does not recursively infer formats inside individual agents, providers, commands, or models. Keep
each of those nested entries entirely in one format. Supported V1 syntax remains quiet by itself; malformed values,
unsupported legacy fields, and conflicting V1/V2 values produce warnings while unrelated valid settings continue to load.
change. Do not mix V1 and V2 field names manually in one file.
### Sharing
@@ -259,8 +250,8 @@ V2 groups the retained-context token budget under `keep` and gives the reserve a
}
```
`auto` keeps its name. V2 has no native `tail_turns` or `prune` field; both legacy fields are ignored with a warning. Recent
context is retained by token budget instead. See [Compaction](/compaction).
`auto` and `prune` keep their names. V2 has no native `tail_turns` field; recent context is retained by token budget instead.
See [Compaction](/compaction).
### Skills
@@ -363,17 +354,6 @@ Rename the singular `provider` map to `providers`. V2 separates the runtime pack
V1 `npm` becomes `package`, and AI SDK packages receive the `aisdk:` prefix. `api` becomes `settings.baseURL`. Provider
`options` are separated into `settings`, `headers`, and `body` according to their request role. See [Providers](/providers).
V2 consolidated two legacy provider namespaces:
| V1 provider ID | Canonical V2 provider ID |
| --- | --- |
| `azure-cognitive-services` | `azure` |
| `google-vertex-anthropic` | `google-vertex` |
Migration of unambiguous V1 provider, agent, command, and provider-filter fields uses these canonical IDs. The shared
top-level `model` field keeps its exact provider ID because the same syntax is valid in native V2 config; update that field
to the canonical ID when migrating a legacy built-in provider.
### Models and variants
Models remain nested under their provider, but several model fields become more explicit:
@@ -410,39 +390,22 @@ Models remain nested under their provider, but several model fields become more
See [Models](/models) for the complete native model shape.
### Supported fields without direct native equivalents
### Fields without native equivalents
Most fields that keep the same shape, including `shell`, `model`, `default_agent`, `autoupdate`, `watcher`, `formatter`,
`lsp`, `instructions`, `enterprise`, and `tool_output`, require no migration.
The V1 provider filters do not have one-to-one native V2 config fields, but their behavior remains supported:
- `enabled_providers` becomes an internal deny-by-default provider policy followed by allows for the listed providers.
- `disabled_providers` becomes internal deny policies for the listed providers.
You may keep these fields in V1 syntax. OpenCode normalizes them without warning.
### Accepted but unsupported fields
The V1 schema also accepted fields that have no supported V2 behavior. V2 ignores these values and emits a warning so
they are not mistaken for active configuration:
These V1 fields do not have one-to-one native V2 config fields:
- `logLevel`: use `OPENCODE_LOG_LEVEL` when starting OpenCode.
- `server`: use the V2 service and explicit server options; the server API is an intentional breaking change.
- `layout`: remove it; V1 already treated it as deprecated and always used stretch layout.
- `enabled_providers` and `disabled_providers`: there is no native provider allowlist or denylist field yet.
- `small_model`: V2 selects models for internal maintenance agents without a separate top-level field.
- Top-level `subagent_depth`: use `experimental.subagent_depth` instead.
- `compaction.tail_turns` and `compaction.prune`: V2 uses `compaction.keep.tokens` and checkpoint-based compaction instead.
- Agent `name` inside V1 JSON configuration.
- An enabled-only V1 MCP entry without a `type`.
- V1 experimental fields `disable_paste_summary`, `batch_tool`, `openTelemetry`, `primary_tools`, and
`continue_loop_on_deny`.
- V1 provider fields `id`, `whitelist`, and `blacklist`.
- V1 provider-model fields `release_date`, `attachment`, `reasoning`, `temperature`, `experimental`, a non-`deprecated`
`status`, and boolean `interleaved`.
- `compaction.tail_turns`: V2 uses `compaction.keep.tokens` instead.
Ignoring these fields is intentional and is not a compatibility regression. If V2 does not preserve behavior identified
as supported elsewhere in this guide, run `/report`.
If your V1 configuration relies on a field without a native equivalent, keep using the supported V1 format rather than
forcing a manual conversion. Run `/report` if V2 does not preserve the behavior you rely on.
### Agent files
-163
View File
@@ -1,163 +0,0 @@
# Mixed V1/V2 Config Normalization Plan
Status: **Implemented and verified**
## Goal
Replace whole-document V1/V2 detection with one config-domain compatibility pipeline. Supported V1 fields, native V2 fields, and practical mixtures of both should load without an unrelated legacy key changing how the rest of the document is decoded.
## Decision
Normalize recognized fields independently into the encoded side of the V2 `Config.Info` schema, then perform one final complete-document V2 decode:
```text
JSON/JSONC encoded input
-> parse and retain source-property presence
-> validate each recognized field or collection entry
-> migrate supported V1 candidates to V2 encoded values
-> decode and re-encode native V2 candidates
-> merge with native V2 precedence
-> decode Config.Info once
-> log redacted diagnostics
```
There is no whole-document version classification and no independent whole-document V1 and V2 decode.
The encoded boundary matters because schemas such as warming durations transform strings into runtime values. Decoded values must not be fed back into the encoded side of `Config.Info`.
## Behavior
| Situation | Result |
| --- | --- |
| Supported V1-only field | Migrate it to its canonical V2 destination. |
| Native V2 field | Preserve it after schema decode and encode. |
| Disjoint V1 and V2 map entries | Preserve both. |
| Same canonical scalar, map entry, or nested leaf | Valid native V2 wins regardless of JSON key order. |
| Malformed native value with valid legacy fallback | Skip native value, log it, and retain legacy value. |
| Malformed collection entry | Skip only the explicitly supported recovery unit. |
| Unsupported accepted V1 setting | Omit it and log a redacted warning. |
| Unknown field | Continue ignoring it for forward compatibility. |
Valid supported V1 syntax does not warn merely because it is legacy.
## Field Precedence
| Destination | Lowest to highest precedence |
| --- | --- |
| `snapshots` | `snapshot` < `snapshots` |
| `share` | `autoshare` < `share` |
| `references[name]` | `reference[name]` < `references[name]` |
| `agents[name]` | `agent[name]` < `mode[name]` < `agents[name]` |
| `commands[name]` | `command[name]` < `commands[name]` |
| `providers[name]` | `provider[name]` < `providers[name]` |
| `permissions` | `tools` rules < `permission` rules < native `permissions` |
| `plugins` | migrated `plugin` items < native `plugins` items |
| `media` | `attachment` < `media` |
| `experimental.policies` | enabled-provider policies < disabled-provider policies < native policies |
| `mcp.servers[name]` | direct legacy server < native `servers[name]` |
| `mcp.timeout.*` | `experimental.mcp_timeout` < native timeout leaf |
| `compaction.keep.tokens` | `preserve_recent_tokens` < `keep.tokens` |
| `compaction.buffer` | `reserved` < `buffer` |
Ordered rules and plugin directives retain both forms, with migrated V1 entries first and native V2 entries last.
## Shared Shapes
### Skills
- A V2 array retains each valid string item.
- A V1 object combines valid `paths` followed by valid `urls`.
- Empty and unknown-only V1 objects normalize to an empty array under permissive excess-property handling.
### MCP
- Direct entries under `mcp` are V1 servers.
- Entries under `mcp.servers` are native V2 servers.
- Both sets are merged by server name, with a complete native server replacing a duplicate legacy server.
- A malformed native duplicate is skipped so a valid legacy server remains.
- Native global timeout leaves override only matching values migrated from `experimental.mcp_timeout`.
- Raw `type` and `enabled` discriminators preserve legacy servers that happen to be named `servers` or `timeout`.
### Compaction
- `preserve_recent_tokens` becomes `keep.tokens`.
- `reserved` becomes `buffer`.
- Native leaves win conflicts.
- `tail_turns` and `prune` remain unsupported and produce warnings.
### Experimental
- `subagent_depth` is shared.
- Legacy provider lists generate ordered canonical policies.
- Native policies follow generated policies.
- An explicit empty `enabled_providers` keeps deny-all behavior.
- A non-empty list with no valid items contributes no policy, avoiding accidental deny-all from malformed input.
## Recovery Units
Named commands, agents, providers, MCP servers, formatters, language servers, and references recover independently. Plugin, permission, skill, instruction, provider-ID, and policy arrays recover by item. Top-level legacy permissions recover by action/resource rule. Complex interiors of one agent, provider, command, or MCP server remain atomic rather than being recursively salvaged.
Every decoder preserves `propertyOrder: "original"` because V1 permission precedence depends on user order. Excess properties remain ignored except for the explicit unsupported inventory.
## Provider IDs
Provider ID compatibility remains a config migration concern only. Existing V1 agent, command, provider, and provider-policy adapters continue using the migration helper's retired-ID mapping.
The shared top-level `model` field remains exact because its string and object forms are valid native V2 syntax and provider declarations may come from a different config layer. It is never reinterpreted based on unrelated legacy fields.
This change does not add runtime provider aliases or modify provider policy evaluation, catalog state, model resolution, Sessions, plugins, Server behavior, or generation.
## Diagnostics
Diagnostics contain only source, JSON path, category, and action. They never include raw values because config may contain credentials after substitution.
Malformed JSON, empty content, and valid non-object roots reject one document with a source-aware warning. Malformed recognized fields and entries are skipped at their recovery boundary while unrelated valid configuration continues loading.
## Implementation
- Add a pure `ConfigNormalize.normalize` module under `packages/core/src/config/`.
- Reuse field migration primitives from `packages/core/src/v1/config/migrate.ts`.
- Replace `ConfigMigrateV1.isV1` in `packages/core/src/config.ts` with normalization and one final V2 decode.
- Log diagnostics uniformly for files, `OPENCODE_CONFIG_CONTENT`, and well-known virtual config.
- Add property and table-driven config normalization tests.
- Update migration and compaction documentation.
## Verification
The implementation must establish:
1. Valid native V2 config preserves decoded meaning after encoded normalization.
2. Supported V1 fields preserve existing behavior.
3. Adding a legacy field cannot change unrelated native field interpretation.
4. Native V2 wins canonical conflicts independent of key order.
5. One malformed entry does not remove valid siblings.
6. Mixed MCP, compaction, and experimental values normalize deterministically.
7. Diagnostics are precise and value-redacted.
8. False, zero, empty, and absent values retain distinct presence semantics.
Run from `packages/core`:
```sh
bun test test/config
bun typecheck
```
Run from `packages/www` after documentation changes:
```sh
bun typecheck
bun validate
bun run build
```
## Non-Goals
- Runtime provider alias resolution.
- Provider policy or catalog changes.
- Model resolver or Session changes.
- Plugin API changes.
- Server or Protocol changes.
- Generation lifecycle changes.
- Recursive V1/V2 inference inside one agent, provider, command, or model.
- Restoring removed V1 functionality.
- Rewriting user files on disk.