Compare commits

...

5 Commits

Author SHA1 Message Date
Kit Langton eb45ada74e fix(tui): preserve burst form input 2026-08-07 10:39:39 -04:00
Kit Langton 120312286a feat(tui): type into custom form answers 2026-08-07 10:32:07 -04:00
Kit Langton 3bb0d7fda0 feat(core): add workspace environment foundation (#40967) 2026-08-07 10:28:27 -04:00
opencode-agent[bot] 8977881e09 feat(tui): queue prompts with option enter (#40922)
Co-authored-by: Kit Langton <kit.langton@gmail.com>
2026-08-07 10:22:18 -04:00
Shoubhit Dash e6c9b6bef7 feat(core): add firecrawl web search (#41042) 2026-08-07 16:51:09 +05:30
55 changed files with 2307 additions and 207 deletions
@@ -153,4 +153,88 @@ describe("v2 session reducer", () => {
expect(result).toMatchObject({ sessionID: "ses_1", missing: "msg_user", touched: [] }) 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": case "session.input.admitted":
pending.set(key(sessionID, event.data.inputID), event.data.input) pending.set(key(sessionID, event.data.inputID), event.data.input)
return result([...source]) return result([...source])
case "session.input.cancelled":
pending.delete(key(sessionID, event.data.inputID))
return
case "session.input.promoted": { case "session.input.promoted": {
const input = pending.get(key(sessionID, event.data.inputID)) const input = pending.get(key(sessionID, event.data.inputID))
pending.delete(key(sessionID, event.data.inputID)) pending.delete(key(sessionID, event.data.inputID))
+73 -27
View File
@@ -263,38 +263,52 @@ export type Endpoint5_23Input = { readonly sessionID: Session.ID }
export type Endpoint5_23Output = ReadonlyArray<SessionPending.Info> export type Endpoint5_23Output = ReadonlyArray<SessionPending.Info>
export type SessionPendingListOperation<E = never> = (input: Endpoint5_23Input) => Effect.Effect<Endpoint5_23Output, E> export type SessionPendingListOperation<E = never> = (input: Endpoint5_23Input) => Effect.Effect<Endpoint5_23Output, E>
export type Endpoint5_24Input = { readonly sessionID: Session.ID } export type Endpoint5_24Input = { readonly sessionID: Session.ID; readonly inputID: SessionMessage.ID }
export type Endpoint5_24Output = ReadonlyArray<InstructionEntry.Info> export type Endpoint5_24Output = void
export type SessionInstructionsEntryListOperation<E = never> = ( export type SessionPendingCancelOperation<E = never> = (
input: Endpoint5_24Input, input: Endpoint5_24Input,
) => Effect.Effect<Endpoint5_24Output, E> ) => Effect.Effect<Endpoint5_24Output, E>
export type Endpoint5_25Input = { export type Endpoint5_25Input = { readonly sessionID: Session.ID; readonly inputID: SessionMessage.ID }
export type Endpoint5_25Output = void
export type SessionPendingSteerOperation<E = never> = (input: Endpoint5_25Input) => Effect.Effect<Endpoint5_25Output, E>
export type Endpoint5_26Input = { readonly sessionID: Session.ID; readonly inputID: SessionMessage.ID }
export type Endpoint5_26Output = void
export type SessionPendingQueueOperation<E = never> = (input: Endpoint5_26Input) => Effect.Effect<Endpoint5_26Output, E>
export type Endpoint5_27Input = { readonly sessionID: Session.ID }
export type Endpoint5_27Output = ReadonlyArray<InstructionEntry.Info>
export type SessionInstructionsEntryListOperation<E = never> = (
input: Endpoint5_27Input,
) => Effect.Effect<Endpoint5_27Output, E>
export type Endpoint5_28Input = {
readonly sessionID: Session.ID readonly sessionID: Session.ID
readonly key: InstructionEntry.Key readonly key: InstructionEntry.Key
readonly value: Schema.Json readonly value: Schema.Json
} }
export type Endpoint5_25Output = void export type Endpoint5_28Output = void
export type SessionInstructionsEntryPutOperation<E = never> = ( export type SessionInstructionsEntryPutOperation<E = never> = (
input: Endpoint5_25Input, input: Endpoint5_28Input,
) => Effect.Effect<Endpoint5_25Output, E> ) => Effect.Effect<Endpoint5_28Output, E>
export type Endpoint5_26Input = { readonly sessionID: Session.ID; readonly key: InstructionEntry.Key } export type Endpoint5_29Input = { readonly sessionID: Session.ID; readonly key: InstructionEntry.Key }
export type Endpoint5_26Output = void export type Endpoint5_29Output = void
export type SessionInstructionsEntryRemoveOperation<E = never> = ( export type SessionInstructionsEntryRemoveOperation<E = never> = (
input: Endpoint5_26Input, input: Endpoint5_29Input,
) => Effect.Effect<Endpoint5_26Output, E> ) => Effect.Effect<Endpoint5_29Output, E>
export type Endpoint5_27Input = { readonly sessionID: Session.ID; readonly prompt: string } export type Endpoint5_30Input = { readonly sessionID: Session.ID; readonly prompt: string }
export type Endpoint5_27Output = { readonly text: string } export type Endpoint5_30Output = { readonly text: string }
export type SessionGenerateOperation<E = never> = (input: Endpoint5_27Input) => Effect.Effect<Endpoint5_27Output, E> export type SessionGenerateOperation<E = never> = (input: Endpoint5_30Input) => Effect.Effect<Endpoint5_30Output, E>
export type Endpoint5_28Input = { export type Endpoint5_31Input = {
readonly sessionID: Session.ID readonly sessionID: Session.ID
readonly after?: Event.Seq | undefined readonly after?: Event.Seq | undefined
readonly follow?: boolean | undefined readonly follow?: boolean | undefined
} }
export type Endpoint5_28Output = export type Endpoint5_31Output =
| ( | (
| { | {
readonly id: Event.ID readonly id: Event.ID
@@ -404,6 +418,33 @@ export type Endpoint5_28Output =
readonly input: SessionPending.Message 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 id: Event.ID
readonly created: DateTime.Utc readonly created: DateTime.Utc
@@ -862,19 +903,19 @@ export type Endpoint5_28Output =
} }
) )
| EventLog.Synced | EventLog.Synced
export type SessionLogOperation<E = never> = (input: Endpoint5_28Input) => Stream.Stream<Endpoint5_28Output, E> export type SessionLogOperation<E = never> = (input: Endpoint5_31Input) => Stream.Stream<Endpoint5_31Output, E>
export type Endpoint5_29Input = { readonly sessionID: Session.ID } export type Endpoint5_32Input = { readonly sessionID: Session.ID }
export type Endpoint5_29Output = void export type Endpoint5_32Output = void
export type SessionInterruptOperation<E = never> = (input: Endpoint5_29Input) => Effect.Effect<Endpoint5_29Output, E> export type SessionInterruptOperation<E = never> = (input: Endpoint5_32Input) => Effect.Effect<Endpoint5_32Output, E>
export type Endpoint5_30Input = { readonly sessionID: Session.ID } export type Endpoint5_33Input = { readonly sessionID: Session.ID }
export type Endpoint5_30Output = void export type Endpoint5_33Output = void
export type SessionBackgroundOperation<E = never> = (input: Endpoint5_30Input) => Effect.Effect<Endpoint5_30Output, E> export type SessionBackgroundOperation<E = never> = (input: Endpoint5_33Input) => Effect.Effect<Endpoint5_33Output, E>
export type Endpoint5_31Input = { readonly sessionID: Session.ID; readonly messageID: SessionMessage.ID } export type Endpoint5_34Input = { readonly sessionID: Session.ID; readonly messageID: SessionMessage.ID }
export type Endpoint5_31Output = SessionMessage.Info export type Endpoint5_34Output = SessionMessage.Info
export type SessionMessageOperation<E = never> = (input: Endpoint5_31Input) => Effect.Effect<Endpoint5_31Output, E> export type SessionMessageOperation<E = never> = (input: Endpoint5_34Input) => Effect.Effect<Endpoint5_34Output, E>
export interface SessionApi<E = never> { export interface SessionApi<E = never> {
readonly list: SessionListOperation<E> readonly list: SessionListOperation<E>
@@ -902,7 +943,12 @@ export interface SessionApi<E = never> {
readonly commit: SessionRevertCommitOperation<E> readonly commit: SessionRevertCommitOperation<E>
} }
readonly context: SessionContextOperation<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 instructions: {
readonly entry: { readonly entry: {
readonly list: SessionInstructionsEntryListOperation<E> readonly list: SessionInstructionsEntryListOperation<E>
+49 -22
View File
@@ -80,6 +80,12 @@ import type {
Endpoint5_30Output, Endpoint5_30Output,
Endpoint5_31Input, Endpoint5_31Input,
Endpoint5_31Output, Endpoint5_31Output,
Endpoint5_32Input,
Endpoint5_32Output,
Endpoint5_33Input,
Endpoint5_33Output,
Endpoint5_34Input,
Endpoint5_34Output,
Endpoint6_0Input, Endpoint6_0Input,
Endpoint6_0Output, Endpoint6_0Output,
Endpoint7_0Input, Endpoint7_0Input,
@@ -523,37 +529,58 @@ const Endpoint5_23 = (raw: RawClient["server.session"]) => (input: Endpoint5_23I
const Endpoint5_24 = (raw: RawClient["server.session"]) => (input: Endpoint5_24Input) => const Endpoint5_24 = (raw: RawClient["server.session"]) => (input: Endpoint5_24Input) =>
preserveEffect<Endpoint5_24Output>()( preserveEffect<Endpoint5_24Output>()(
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.mapError(mapClientError),
Effect.map((value) => value.data),
), ),
) )
const Endpoint5_25 = (raw: RawClient["server.session"]) => (input: Endpoint5_25Input) => const Endpoint5_25 = (raw: RawClient["server.session"]) => (input: Endpoint5_25Input) =>
preserveEffect<Endpoint5_25Output>()( preserveEffect<Endpoint5_25Output>()(
raw["session.instructions.entry.put"]({ raw["session.pending.steer"]({ params: { sessionID: input["sessionID"], inputID: input["inputID"] } }).pipe(
params: { sessionID: input["sessionID"], key: input["key"] }, Effect.mapError(mapClientError),
payload: { value: input["value"] }, ),
}).pipe(Effect.mapError(mapClientError)),
) )
const Endpoint5_26 = (raw: RawClient["server.session"]) => (input: Endpoint5_26Input) => const Endpoint5_26 = (raw: RawClient["server.session"]) => (input: Endpoint5_26Input) =>
preserveEffect<Endpoint5_26Output>()( preserveEffect<Endpoint5_26Output>()(
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), Effect.mapError(mapClientError),
), ),
) )
const Endpoint5_27 = (raw: RawClient["server.session"]) => (input: Endpoint5_27Input) => const Endpoint5_27 = (raw: RawClient["server.session"]) => (input: Endpoint5_27Input) =>
preserveEffect<Endpoint5_27Output>()( preserveEffect<Endpoint5_27Output>()(
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.mapError(mapClientError),
Effect.map((value) => value.data), Effect.map((value) => value.data),
), ),
) )
const Endpoint5_28 = (raw: RawClient["server.session"]) => (input: Endpoint5_28Input) => const Endpoint5_28 = (raw: RawClient["server.session"]) => (input: Endpoint5_28Input) =>
preserveStream<Endpoint5_28Output>()( preserveEffect<Endpoint5_28Output>()(
raw["session.instructions.entry.put"]({
params: { sessionID: input["sessionID"], key: input["key"] },
payload: { value: input["value"] },
}).pipe(Effect.mapError(mapClientError)),
)
const Endpoint5_29 = (raw: RawClient["server.session"]) => (input: Endpoint5_29Input) =>
preserveEffect<Endpoint5_29Output>()(
raw["session.instructions.entry.remove"]({ params: { sessionID: input["sessionID"], key: input["key"] } }).pipe(
Effect.mapError(mapClientError),
),
)
const Endpoint5_30 = (raw: RawClient["server.session"]) => (input: Endpoint5_30Input) =>
preserveEffect<Endpoint5_30Output>()(
raw["session.generate"]({ params: { sessionID: input["sessionID"] }, payload: { prompt: input["prompt"] } }).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
),
)
const Endpoint5_31 = (raw: RawClient["server.session"]) => (input: Endpoint5_31Input) =>
preserveStream<Endpoint5_31Output>()(
Stream.unwrap( Stream.unwrap(
raw["session.log"]({ raw["session.log"]({
params: { sessionID: input["sessionID"] }, params: { sessionID: input["sessionID"] },
@@ -565,18 +592,18 @@ const Endpoint5_28 = (raw: RawClient["server.session"]) => (input: Endpoint5_28I
), ),
) )
const Endpoint5_29 = (raw: RawClient["server.session"]) => (input: Endpoint5_29Input) => const Endpoint5_32 = (raw: RawClient["server.session"]) => (input: Endpoint5_32Input) =>
preserveEffect<Endpoint5_29Output>()( preserveEffect<Endpoint5_32Output>()(
raw["session.interrupt"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)), raw["session.interrupt"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)),
) )
const Endpoint5_30 = (raw: RawClient["server.session"]) => (input: Endpoint5_30Input) => const Endpoint5_33 = (raw: RawClient["server.session"]) => (input: Endpoint5_33Input) =>
preserveEffect<Endpoint5_30Output>()( preserveEffect<Endpoint5_33Output>()(
raw["session.background"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)), raw["session.background"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)),
) )
const Endpoint5_31 = (raw: RawClient["server.session"]) => (input: Endpoint5_31Input) => const Endpoint5_34 = (raw: RawClient["server.session"]) => (input: Endpoint5_34Input) =>
preserveEffect<Endpoint5_31Output>()( preserveEffect<Endpoint5_34Output>()(
raw["session.message"]({ params: { sessionID: input["sessionID"], messageID: input["messageID"] } }).pipe( raw["session.message"]({ params: { sessionID: input["sessionID"], messageID: input["messageID"] } }).pipe(
Effect.mapError(mapClientError), Effect.mapError(mapClientError),
Effect.map((value) => value.data), Effect.map((value) => value.data),
@@ -605,13 +632,13 @@ const adaptGroup5 = (raw: RawClient["server.session"]) => ({
wait: Endpoint5_18(raw), wait: Endpoint5_18(raw),
revert: { stage: Endpoint5_19(raw), clear: Endpoint5_20(raw), commit: Endpoint5_21(raw) }, revert: { stage: Endpoint5_19(raw), clear: Endpoint5_20(raw), commit: Endpoint5_21(raw) },
context: Endpoint5_22(raw), context: Endpoint5_22(raw),
pending: { list: Endpoint5_23(raw) }, pending: { list: Endpoint5_23(raw), cancel: Endpoint5_24(raw), steer: Endpoint5_25(raw), queue: Endpoint5_26(raw) },
instructions: { entry: { list: Endpoint5_24(raw), put: Endpoint5_25(raw), remove: Endpoint5_26(raw) } }, instructions: { entry: { list: Endpoint5_27(raw), put: Endpoint5_28(raw), remove: Endpoint5_29(raw) } },
generate: Endpoint5_27(raw), generate: Endpoint5_30(raw),
log: Endpoint5_28(raw), log: Endpoint5_31(raw),
interrupt: Endpoint5_29(raw), interrupt: Endpoint5_32(raw),
background: Endpoint5_30(raw), background: Endpoint5_33(raw),
message: Endpoint5_31(raw), message: Endpoint5_34(raw),
}) })
const Endpoint6_0 = (raw: RawClient["server.message"]) => (input: Endpoint6_0Input) => const Endpoint6_0 = (raw: RawClient["server.message"]) => (input: Endpoint6_0Input) =>
@@ -58,6 +58,12 @@ import type {
SessionContextOutput, SessionContextOutput,
SessionPendingListInput, SessionPendingListInput,
SessionPendingListOutput, SessionPendingListOutput,
SessionPendingCancelInput,
SessionPendingCancelOutput,
SessionPendingSteerInput,
SessionPendingSteerOutput,
SessionPendingQueueInput,
SessionPendingQueueOutput,
SessionInstructionsEntryListInput, SessionInstructionsEntryListInput,
SessionInstructionsEntryListOutput, SessionInstructionsEntryListOutput,
SessionInstructionsEntryPutInput, SessionInstructionsEntryPutInput,
@@ -766,6 +772,39 @@ export function make(options: ClientOptions) {
}, },
requestOptions, requestOptions,
).then((value) => value.data), ).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: { instructions: {
entry: { entry: {
@@ -502,6 +502,36 @@ export type SessionInputPromoted = {
data: { sessionID: string; inputID: string } 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 = { export type SessionExecutionStarted = {
id: string id: string
created: number created: number
@@ -1970,6 +2000,9 @@ export type SessionEventDurable =
| SessionForked | SessionForked
| SessionInputPromoted | SessionInputPromoted
| SessionInputAdmitted | SessionInputAdmitted
| SessionInputCancelled
| SessionInputSteered
| SessionInputQueued
| SessionExecutionStarted | SessionExecutionStarted
| SessionExecutionSucceeded | SessionExecutionSucceeded
| SessionExecutionFailed | SessionExecutionFailed
@@ -2024,6 +2057,9 @@ export type V2Event =
| SessionForked | SessionForked
| SessionInputPromoted | SessionInputPromoted
| SessionInputAdmitted | SessionInputAdmitted
| SessionInputCancelled
| SessionInputSteered
| SessionInputQueued
| SessionExecutionStarted | SessionExecutionStarted
| SessionExecutionSucceeded | SessionExecutionSucceeded
| SessionExecutionFailed | SessionExecutionFailed
@@ -3689,6 +3725,27 @@ export type SessionPendingListInput = { readonly sessionID: { readonly sessionID
export type SessionPendingListOutput = { data: Array<SessionPendingInfo> }["data"] 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 SessionInstructionsEntryListInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] }
export type SessionInstructionsEntryListOutput = { data: Array<InstructionEntryInfo> }["data"] export type SessionInstructionsEntryListOutput = { data: Array<InstructionEntryInfo> }["data"]
@@ -19,7 +19,7 @@ test("effect entrypoint exposes canonical Schema contracts", () => {
test("generated Effect API names canonical and composed outputs", async () => { test("generated Effect API names canonical and composed outputs", async () => {
const source = await Bun.file(new URL("../src/effect/api/api.ts", import.meta.url)).text() const source = await Bun.file(new URL("../src/effect/api/api.ts", import.meta.url)).text()
expect(source).toContain("export type Endpoint5_3Output = Session.Info") expect(source).toContain("export type Endpoint5_5Output = Session.Info")
expect(source).toContain("export type Endpoint19_0Output = OpenCodeEvent") expect(source).toContain("export type Endpoint19_0Output = OpenCodeEvent")
expect(source).not.toContain("HttpApiClient.ForApi") expect(source).not.toContain("HttpApiClient.ForApi")
}) })
+23
View File
@@ -32,6 +32,7 @@ test("exposes every standard HTTP API group", () => {
"projectCopy", "projectCopy",
"vcs", "vcs",
"debug", "debug",
"migration",
"websearch", "websearch",
"config", "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" }]) 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 () => { test("event.subscribe exposes the Promise event stream wire projection", async () => {
const client = OpenCode.make({ const client = OpenCode.make({
baseUrl: "http://localhost:3000", baseUrl: "http://localhost:3000",
+1
View File
@@ -17,6 +17,7 @@
"opencode": "./bin/opencode" "opencode": "./bin/opencode"
}, },
"exports": { "exports": {
"./environment": "./src/environment/index.ts",
"./session/runner": "./src/session/runner/index.ts", "./session/runner": "./src/session/runner/index.ts",
"./instructions": "./src/instructions/index.ts", "./instructions": "./src/instructions/index.ts",
"./*": "./src/*.ts" "./*": "./src/*.ts"
+9
View File
@@ -0,0 +1,9 @@
import type { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"
import type { FilesImpl } from "./files"
export interface Driver {
readonly spawner: ChildProcessSpawner["Service"]
readonly overrides?: Partial<FilesImpl>
}
export * as EnvironmentDriver from "./driver"
@@ -0,0 +1,192 @@
import { Effect, Stream } from "effect"
import { ChildProcess } from "effect/unstable/process"
import type { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"
import { collectStream } from "@opencode-ai/util/process"
import { Failed, NotFound, WrongKind, type FileInfo, type FileType, type FilesImpl } from "./files"
/**
* Files derived from spawning processes: one process per intent, "$1" is
* always the target path. Scripts report classification through an exit-code
* protocol (44/45/46) so failures never require parsing localized error text;
* LC_ALL=C pins the one stderr match that remains. Requires GNU coreutils and
* findutils in the target image — BSD and busybox userlands will not work.
* Malformed output from these scripts is our own bug and dies as a defect.
*/
const MAX_DATA_BYTES = 64 * 1024 * 1024
const MAX_ERROR_BYTES = 64 * 1024
const NOT_FOUND = 44
const WRONG_KIND = 45
const FAILED = 46
const TAB = "\t"
const loadMetadata = (flags = "") => `
metadata=$(stat ${flags} -c '%F${TAB}%s${TAB}%Y' -- "$1" 2>&1) || {
case "$metadata" in
*'No such file or directory'*|*'Not a directory'*) exit ${NOT_FOUND} ;;
*) printf '%s' "$metadata" >&2; exit ${FAILED} ;;
esac
}
`
const statScript = `
${loadMetadata()}
printf '%s\n' "$metadata"
`
const readScript = `
${loadMetadata("-L")}
kind=\${metadata%%${TAB}*}
if [ "$kind" != 'regular file' ] && [ "$kind" != 'regular empty file' ]; then
printf '%s' "$kind" >&2
exit ${WRONG_KIND}
fi
printf '%s\n' "$metadata"
if [ "$2" = range ]; then
dd if="$1" iflag=skip_bytes,count_bytes skip="$3" count="$4" status=none
else
cat -- "$1"
fi
`
const listScript = `
${loadMetadata()}
kind=\${metadata%%${TAB}*}
if [ "$kind" != directory ]; then
printf '%s' "$kind" >&2
exit ${WRONG_KIND}
fi
find "$1" -mindepth 1 -maxdepth 1 -printf '%y\\0%f\\0'
`
const moveScript = `
${loadMetadata()}
mv -- "$1" "$2"
`
interface Result {
readonly exitCode: number
readonly stdout: Uint8Array
readonly stderr: Uint8Array
}
export const execDefaults = (spawner: ChildProcessSpawner["Service"]): FilesImpl => {
const run = (
path: string,
script: string,
args: ReadonlyArray<string> = [],
stdin?: Uint8Array,
): Effect.Effect<Result, Failed> =>
Effect.scoped(
Effect.gen(function* () {
const command = ChildProcess.make("sh", ["-c", script, "sh", path, ...args], {
env: { LC_ALL: "C" },
extendEnv: true,
stdin: stdin === undefined ? undefined : Stream.make(stdin),
})
const handle = yield* spawner.spawn(command).pipe(Effect.mapError((cause) => new Failed({ path, cause })))
const [stdout, stderr, exitCode] = yield* Effect.all(
[
collectStream(handle.stdout, MAX_DATA_BYTES),
collectStream(handle.stderr, MAX_ERROR_BYTES),
handle.exitCode,
],
{ concurrency: "unbounded" },
).pipe(Effect.mapError((cause) => new Failed({ path, cause })))
if (stdout.truncated || stderr.truncated) {
return yield* new Failed({ path, cause: new Error("Process output exceeded its collection limit") })
}
return { exitCode, stdout: stdout.buffer, stderr: stderr.buffer }
}),
)
const classify = <A>(
path: string,
result: Result,
success: (stdout: Uint8Array) => A,
): Effect.Effect<A, NotFound | WrongKind | Failed> => {
if (result.exitCode === 0) return Effect.sync(() => success(result.stdout))
if (result.exitCode === NOT_FOUND) return Effect.fail(new NotFound({ path }))
if (result.exitCode === WRONG_KIND) {
return Effect.fail(new WrongKind({ path, actual: parseType(new TextDecoder().decode(result.stderr)) }))
}
return Effect.fail(processFailure(path, result))
}
const complete = (path: string, result: Result) =>
result.exitCode === 0 ? Effect.void : Effect.fail(processFailure(path, result))
return {
stat: (path) => run(path, statScript).pipe(Effect.flatMap((result) => classifyPlain(path, result, parseInfo))),
read: (path, range) =>
run(
path,
readScript,
range === undefined ? ["whole"] : ["range", String(range.offset), String(range.length)],
).pipe(
Effect.flatMap((result) =>
classify(path, result, (stdout) => {
const newline = stdout.indexOf(10)
if (newline < 0) throw new Error("Missing read metadata header")
return {
info: parseInfo(stdout.slice(0, newline)),
bytes: stdout.slice(newline + 1),
}
}),
),
),
write: (path, bytes) =>
run(path, `mkdir -p "$(dirname "$1")" && cat > "$1"`, [], bytes).pipe(
Effect.flatMap((result) => complete(path, result)),
),
list: (path) => run(path, listScript).pipe(Effect.flatMap((result) => classify(path, result, parseList))),
remove: (path) => run(path, `rm -rf -- "$1"`).pipe(Effect.flatMap((result) => complete(path, result))),
move: (from, to) =>
run(from, moveScript, [to]).pipe(Effect.flatMap((result) => classifyPlain(from, result, () => undefined))),
mkdir: (path) => run(path, `mkdir -p -- "$1"`).pipe(Effect.flatMap((result) => complete(path, result))),
}
}
/** `classify` for scripts whose protocol never reports WrongKind. */
const classifyPlain = <A>(
path: string,
result: Result,
success: (stdout: Uint8Array) => A,
): Effect.Effect<A, NotFound | Failed> => {
if (result.exitCode === 0) return Effect.sync(() => success(result.stdout))
if (result.exitCode === NOT_FOUND) return Effect.fail(new NotFound({ path }))
return Effect.fail(processFailure(path, result))
}
const processFailure = (path: string, result: Result) =>
new Failed({
path,
cause: new Error(new TextDecoder().decode(result.stderr).trim() || `Process exited with code ${result.exitCode}`),
})
const parseInfo = (bytes: Uint8Array): FileInfo => {
const [rawType, rawSize, rawMtime] = new TextDecoder().decode(bytes).trim().split(TAB)
const size = Number(rawSize)
const mtimeMs = Number(rawMtime) * 1_000
if (!rawType || !Number.isFinite(size) || !Number.isFinite(mtimeMs)) throw new Error("Invalid stat output")
return { type: parseType(rawType), size, mtimeMs }
}
const parseType = (value: string): FileType => {
if (value === "regular file" || value === "regular empty file" || value === "f") return "file"
if (value === "directory" || value === "d") return "directory"
if (value === "symbolic link" || value === "l") return "symlink"
return "other"
}
const parseList = (bytes: Uint8Array) => {
const fields = new TextDecoder().decode(bytes).split("\0")
fields.pop()
if (fields.length % 2 !== 0) throw new Error("Invalid find output")
return Array.from({ length: fields.length / 2 }, (_, index) => ({
name: fields[index * 2 + 1],
type: parseType(fields[index * 2]),
}))
}
export * as EnvironmentExecDefaults from "./exec-defaults"
+53
View File
@@ -0,0 +1,53 @@
import { Effect, Schema } from "effect"
export const FileType = Schema.Literals(["file", "directory", "symlink", "other"])
export type FileType = typeof FileType.Type
export interface FileInfo {
readonly type: FileType
readonly size: number
readonly mtimeMs: number
}
export interface DirEntry {
readonly name: string
readonly type: FileType
}
export class NotFound extends Schema.TaggedErrorClass<NotFound>()("Environment.NotFound", {
path: Schema.String,
}) {}
export class WrongKind extends Schema.TaggedErrorClass<WrongKind>()("Environment.WrongKind", {
path: Schema.String,
actual: FileType,
}) {}
export class Failed extends Schema.TaggedErrorClass<Failed>()("Environment.Failed", {
path: Schema.String,
cause: Schema.Defect(),
}) {}
export interface FilesImpl {
/**
* Reads a file, following a final symlink so `info` describes the target whose bytes are returned.
* The process-backed default caps collected output at 64 MiB; larger whole-file reads fail with
* `Failed`, so callers must use ranges for larger files.
*/
readonly read: (
path: string,
range?: { readonly offset: number; readonly length: number },
) => Effect.Effect<{ readonly info: FileInfo; readonly bytes: Uint8Array }, NotFound | WrongKind | Failed>
readonly write: (path: string, bytes: Uint8Array) => Effect.Effect<void, Failed>
/** Describes the path entry itself, so a final symlink is reported as `symlink` rather than followed. */
readonly stat: (path: string) => Effect.Effect<FileInfo, NotFound | Failed>
/** Lists a directory entry without following a final symlink; intermediate symlinks are traversed. */
readonly list: (path: string) => Effect.Effect<ReadonlyArray<DirEntry>, NotFound | WrongKind | Failed>
readonly remove: (path: string) => Effect.Effect<void, Failed>
readonly move: (from: string, to: string) => Effect.Effect<void, NotFound | Failed>
readonly mkdir: (path: string) => Effect.Effect<void, Failed>
}
export interface Files extends FilesImpl {}
export * as EnvironmentFiles from "./files"
+24
View File
@@ -0,0 +1,24 @@
export * as Environment from "./index"
export { type Driver } from "./driver"
export {
type DirEntry,
Failed,
type FileInfo,
type Files,
type FilesImpl,
type FileType,
NotFound,
WrongKind,
} from "./files"
export { execDefaults } from "./exec-defaults"
export { makeMemoryDriver, type MemoryDriver } from "./memory"
import type { Driver } from "./driver"
import { execDefaults } from "./exec-defaults"
import type { Files } from "./files"
export const makeFiles = (driver: Driver): Files => ({
...execDefaults(driver.spawner),
...driver.overrides,
})
+168
View File
@@ -0,0 +1,168 @@
import path from "node:path"
import { Effect, PlatformError } from "effect"
import { make } from "effect/unstable/process/ChildProcessSpawner"
import type { Driver } from "./driver"
import { Failed, NotFound, WrongKind, type FileInfo, type FilesImpl, type FileType } from "./files"
type Node =
| { readonly type: "file"; readonly bytes: Uint8Array; readonly mtimeMs: number }
| { readonly type: "directory"; readonly mtimeMs: number }
| { readonly type: "symlink"; readonly target: string; readonly mtimeMs: number }
export interface MemoryDriver extends Driver {
readonly symlink: (target: string, path: string) => Effect.Effect<void, Failed>
}
export const makeMemoryDriver = (): MemoryDriver => {
const nodes = new Map<string, Node>([["/", { type: "directory", mtimeMs: Date.now() }]])
const key = (value: string) => path.posix.resolve("/", value)
const info = (node: Node): FileInfo => ({
type: node.type,
size:
node.type === "file"
? node.bytes.length
: node.type === "symlink"
? new TextEncoder().encode(node.target).length
: 0,
mtimeMs: node.mtimeMs,
})
const resolveKey = (value: string, followFinal: boolean, seen = new Set<string>()): string | undefined => {
const normalized = key(value)
const parts = normalized.split("/").filter(Boolean)
const base = "/"
const walk = (current: string, index: number): string | undefined => {
if (index === parts.length) return current
const part = parts[index]
const candidate = path.posix.join(current, part)
const node = nodes.get(candidate)
if (node?.type !== "symlink" || (!followFinal && index === parts.length - 1)) return walk(candidate, index + 1)
if (seen.has(candidate)) return undefined
seen.add(candidate)
const target = path.posix.resolve(path.posix.dirname(candidate), node.target)
return resolveKey(path.posix.join(target, ...parts.slice(index + 1)), followFinal, seen)
}
return walk(base, 0)
}
const lookup = (value: string) => nodes.get(resolveKey(value, false) ?? key(value))
const requireParent = (value: string) => {
const parentPath = path.posix.dirname(key(value))
const parent = nodes.get(resolveKey(parentPath, true) ?? parentPath)
if (!parent) throw new Error(`Parent directory does not exist: ${path.posix.dirname(value)}`)
if (parent.type !== "directory") throw new Error(`Parent is not a directory: ${path.posix.dirname(value)}`)
}
const mkdirSync = (value: string) => {
const target = resolveKey(value, false) ?? key(value)
const existing = nodes.get(target)
if (existing?.type === "directory") return
if (existing) throw new Error(`Path is not a directory: ${value}`)
const parent = path.posix.dirname(target)
if (parent !== target) mkdirSync(parent)
nodes.set(target, { type: "directory", mtimeMs: Date.now() })
}
const failed = (value: string, cause: unknown) => new Failed({ path: value, cause })
const overrides: FilesImpl = {
stat: (value) => {
const node = lookup(value)
return node ? Effect.succeed(info(node)) : Effect.fail(new NotFound({ path: value }))
},
read: (value, range) => {
const original = lookup(value)
if (!original) return Effect.fail(new NotFound({ path: value }))
if (original.type === "directory") return Effect.fail(new WrongKind({ path: value, actual: "directory" }))
const resolved = resolveKey(value, true)
const node = resolved === undefined ? undefined : nodes.get(resolved)
if (!node) return Effect.fail(new NotFound({ path: value }))
if (node.type !== "file") return Effect.fail(new WrongKind({ path: value, actual: node.type }))
const bytes = range === undefined ? node.bytes : node.bytes.subarray(range.offset, range.offset + range.length)
return Effect.succeed({ info: info(node), bytes: bytes.slice() })
},
write: (value, bytes) =>
Effect.try({
try: () => {
mkdirSync(path.posix.dirname(key(value)))
const existing = lookup(value)
if (existing?.type === "directory") throw new Error(`Path is a directory: ${value}`)
const target = existing?.type === "symlink" ? resolveKey(value, true) : resolveKey(value, false)
if (!target) throw new Error(`Cannot resolve symlink: ${value}`)
requireParent(target)
nodes.set(target, { type: "file", bytes: bytes.slice(), mtimeMs: Date.now() })
},
catch: (cause) => failed(value, cause),
}),
list: (value) => {
const target = resolveKey(value, false) ?? key(value)
const node = nodes.get(target)
if (!node) return Effect.fail(new NotFound({ path: value }))
if (node.type !== "directory") return Effect.fail(new WrongKind({ path: value, actual: node.type }))
const entries = [...nodes.entries()]
.filter(([entry]) => entry !== target && path.posix.dirname(entry) === target)
.map(([entry, child]) => ({ name: path.posix.basename(entry), type: child.type satisfies FileType }))
.sort((a, b) => a.name.localeCompare(b.name))
return Effect.succeed(entries)
},
remove: (value) =>
Effect.sync(() => {
const target = resolveKey(value, false) ?? key(value)
for (const entry of nodes.keys()) {
if (entry === target || entry.startsWith(`${target}/`)) nodes.delete(entry)
}
}),
move: (from, to) => {
const source = resolveKey(from, false) ?? key(from)
const node = nodes.get(source)
if (!node) return Effect.fail(new NotFound({ path: from }))
return Effect.try({
try: () => {
const requested = resolveKey(to, false) ?? key(to)
const destination =
nodes.get(requested)?.type === "directory"
? path.posix.join(requested, path.posix.basename(source))
: requested
if (node.type === "directory" && destination.startsWith(`${source}/`)) {
throw new Error(`Cannot move a directory into itself: ${from}`)
}
const existing = nodes.get(destination)
if (node.type === "directory" && existing && existing.type !== "directory") {
throw new Error(`Cannot overwrite a non-directory with a directory: ${to}`)
}
requireParent(destination)
const moved = [...nodes.entries()].filter(([entry]) => entry === source || entry.startsWith(`${source}/`))
for (const [entry] of moved) nodes.delete(entry)
for (const [entry, child] of moved) nodes.set(`${destination}${entry.slice(source.length)}`, child)
},
catch: (cause) => failed(from, cause),
})
},
mkdir: (value) => Effect.try({ try: () => mkdirSync(value), catch: (cause) => failed(value, cause) }),
}
const spawner = make((command) =>
Effect.suspend(() => {
const description = command._tag === "StandardCommand" ? command.command : "pipeline"
return Effect.fail(
PlatformError.systemError({
_tag: "Unknown",
module: "EnvironmentMemory",
method: "spawn",
pathOrDescriptor: description,
cause: failed(description, new Error("The memory driver cannot spawn processes")),
}),
)
}),
)
return {
spawner,
overrides,
symlink: (target, value) =>
Effect.try({
try: () => {
requireParent(value)
nodes.set(resolveKey(value, false) ?? key(value), { type: "symlink", target, mtimeMs: Date.now() })
},
catch: (cause) => failed(value, cause),
}),
}
}
export * as EnvironmentMemory from "./memory"
@@ -0,0 +1,84 @@
export * as WebSearchFirecrawl from "./firecrawl"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { Effect, Option, Schema, Scope } from "effect"
import { HttpClient } from "effect/unstable/http"
import { App } from "../../app"
import { WebSearchMcp } from "./mcp"
export const endpoint = "https://mcp.firecrawl.dev/v2/mcp"
const McpInput = Schema.Struct({
query: Schema.String,
limit: Schema.Number.pipe(Schema.optional),
})
const McpOutput = Schema.Struct({
content: Schema.Array(Schema.Struct({ type: Schema.Literal("text"), text: Schema.String })),
})
const SearchResponse = Schema.fromJsonString(
Schema.Struct({
success: Schema.Boolean,
data: Schema.Struct({
web: Schema.Array(
Schema.Struct({
url: Schema.String,
title: Schema.NullOr(Schema.String).pipe(Schema.optional),
description: Schema.NullOr(Schema.String).pipe(Schema.optional),
}),
),
}),
}),
)
const decodeSearchResponse = Schema.decodeUnknownOption(SearchResponse)
export const Plugin = define<HttpClient.HttpClient | Scope.Scope>({
id: "opencode.websearch.firecrawl",
effect: Effect.fn("WebSearchFirecrawl.Plugin")(function* (ctx) {
const http = yield* HttpClient.HttpClient
yield* ctx.integration.transform((draft) => {
draft.update("firecrawl", (integration) => (integration.name = "Firecrawl"))
draft.method.update({
integrationID: "firecrawl",
method: { type: "key", label: "API key (optional)" },
})
draft.method.update({
integrationID: "firecrawl",
method: { type: "env", names: ["FIRECRAWL_API_KEY"] },
})
})
yield* ctx.websearch.transform((draft) => {
draft.add({
id: "firecrawl",
name: "Firecrawl",
execute: (input) =>
Effect.gen(function* () {
const connection = yield* ctx.integration.connection.active("firecrawl")
const credential = connection ? yield* ctx.integration.connection.resolve(connection) : undefined
const result = yield* WebSearchMcp.call(
http,
endpoint,
"firecrawl_search",
{ input: McpInput, output: McpOutput },
{ query: input.query, limit: 8 },
{
"User-Agent": App.useragent(ctx.app),
...(credential?.type === "key" ? { Authorization: `Bearer ${credential.key}` } : {}),
},
)
const content = result?.content.find((item) => item.text)
const response = content ? Option.getOrUndefined(decodeSearchResponse(content.text)) : undefined
return (
response?.data.web.map((item) => ({
url: item.url,
...(item.title ? { title: item.title } : {}),
...(item.description ? { content: item.description } : {}),
time: {},
})) ?? []
)
}),
})
})
}),
})
+2 -1
View File
@@ -1,4 +1,5 @@
import { WebSearchExa } from "./exa" import { WebSearchExa } from "./exa"
import { WebSearchFirecrawl } from "./firecrawl"
import { WebSearchParallel } from "./parallel" import { WebSearchParallel } from "./parallel"
export const WebSearchPlugins = [WebSearchExa.Plugin, WebSearchParallel.Plugin] as const export const WebSearchPlugins = [WebSearchExa.Plugin, WebSearchFirecrawl.Plugin, WebSearchParallel.Plugin] as const
+39
View File
@@ -133,6 +133,14 @@ export class CompactionConflictError extends Schema.TaggedErrorClass<CompactionC
export class BusyError extends Schema.TaggedErrorClass<BusyError>()("Session.BusyError", { export class BusyError extends Schema.TaggedErrorClass<BusyError>()("Session.BusyError", {
sessionID: SessionSchema.ID, 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", { export class SkillNotFoundError extends Schema.TaggedErrorClass<SkillNotFoundError>()("Session.SkillNotFoundError", {
skill: Skill.ID, skill: Skill.ID,
}) {} }) {}
@@ -181,6 +189,9 @@ export interface Interface {
* unhandled compaction barriers. * unhandled compaction barriers.
*/ */
readonly pending: (sessionID: SessionSchema.ID) => Effect.Effect<SessionPending.Info[], NotFoundError> 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 * Durable, ordered session log read. Replays durable session bus after
* the exclusive `after` cursor, emits a `Synced` marker at the captured * the exclusive `after` cursor, emits a `Synced` marker at the captured
@@ -318,6 +329,31 @@ const layer = Layer.effect(
), ),
) )
const pendingConflict = Effect.fn("Session.pendingConflict")(function* (input: PendingInputRef) {
yield* result.get(input.sessionID)
return yield* new PendingInputConflictError(input)
})
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* mutation(bus, { sessionID: input.sessionID, id: input.inputID }).pipe(
Effect.catchDefect((defect) =>
defect instanceof SessionPending.LifecycleConflict
? pendingConflict(input)
: Effect.die(defect),
),
)
if (wake) yield* execution.wake(input.sessionID)
}),
)
const result = Service.of({ const result = Service.of({
create: Effect.fn("Session.create")(function* (input) { create: Effect.fn("Session.create")(function* (input) {
const sessionID = input.id ?? SessionSchema.ID.create() const sessionID = input.id ?? SessionSchema.ID.create()
@@ -507,6 +543,9 @@ const layer = Layer.effect(
yield* result.get(sessionID) yield* result.get(sessionID)
return yield* SessionPending.list(db, 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) => log: (input) =>
Stream.unwrap( Stream.unwrap(
result result
@@ -90,6 +90,9 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
"session.forked": () => Effect.void, "session.forked": () => Effect.void,
"session.input.promoted": () => Effect.void, "session.input.promoted": () => Effect.void,
"session.input.admitted": () => 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.started": () => Effect.void,
"session.execution.succeeded": () => clearCurrentRetry, "session.execution.succeeded": () => clearCurrentRetry,
"session.execution.failed": () => clearCurrentRetry, "session.execution.failed": () => clearCurrentRetry,
+84 -4
View File
@@ -37,6 +37,7 @@ const decodeSynthetic = Schema.decodeUnknownSync(SyntheticData)
const encodeSynthetic = Schema.encodeSync(SyntheticData) const encodeSynthetic = Schema.encodeSync(SyntheticData)
const decodeMessage = Schema.decodeUnknownSync(SessionMessage.Info) const decodeMessage = Schema.decodeUnknownSync(SessionMessage.Info)
const inboxLocks = KeyedMutex.makeUnsafe<SessionSchema.ID>() const inboxLocks = KeyedMutex.makeUnsafe<SessionSchema.ID>()
type PendingRef = { readonly id: SessionMessage.ID; readonly sessionID: SessionSchema.ID }
export class LifecycleConflict extends Schema.TaggedErrorClass<LifecycleConflict>()( export class LifecycleConflict extends Schema.TaggedErrorClass<LifecycleConflict>()(
"SessionPending.LifecycleConflict", "SessionPending.LifecycleConflict",
@@ -294,10 +295,7 @@ export const projectCompactionAdmitted = Effect.fn("SessionPending.projectCompac
*/ */
export const projectPromoted = Effect.fn("SessionPending.projectPromoted")(function* ( export const projectPromoted = Effect.fn("SessionPending.projectPromoted")(function* (
db: DatabaseService, db: DatabaseService,
input: { input: PendingRef,
readonly id: SessionMessage.ID
readonly sessionID: SessionSchema.ID
},
) { ) {
if (yield* compaction(db, input.sessionID)) return yield* Effect.die(new LifecycleConflict({ id: input.id })) if (yield* compaction(db, input.sessionID)) return yield* Effect.die(new LifecycleConflict({ id: input.id }))
const deleted = yield* db const deleted = yield* db
@@ -312,6 +310,55 @@ export const projectPromoted = Effect.fn("SessionPending.projectPromoted")(funct
return stored return stored
}) })
export const projectCancelled = Effect.fn("SessionPending.projectCancelled")(function* (
db: DatabaseService,
input: PendingRef,
) {
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: PendingRef & { 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: PendingRef) =>
projectDelivery(db, { ...input, from: "queue", to: "steer" }),
)
export const projectQueued = Effect.fn("SessionPending.projectQueued")(
(db: DatabaseService, input: PendingRef) =>
projectDelivery(db, { ...input, from: "steer", to: "queue" }),
)
export const settleCompaction = Effect.fn("SessionPending.settleCompaction")(function* ( export const settleCompaction = Effect.fn("SessionPending.settleCompaction")(function* (
db: DatabaseService, db: DatabaseService,
input: { readonly sessionID: SessionSchema.ID }, input: { readonly sessionID: SessionSchema.ID },
@@ -389,6 +436,39 @@ export const equivalent = (
return false return false
} }
const publishMutation = <A, E, R>(input: PendingRef, effect: Effect.Effect<A, E, R>) =>
inboxLocks.withLock(input.sessionID)(effect).pipe(Effect.asVoid)
export const cancel = Effect.fn("SessionPending.cancel")((bus: Bus.Interface, input: PendingRef) =>
publishMutation(
input,
bus.publish(SessionEvent.InputCancelled, {
sessionID: input.sessionID,
inputID: input.id,
}),
),
)
export const steer = Effect.fn("SessionPending.steer")((bus: Bus.Interface, input: PendingRef) =>
publishMutation(
input,
bus.publish(SessionEvent.InputSteered, {
sessionID: input.sessionID,
inputID: input.id,
}),
),
)
export const queue = Effect.fn("SessionPending.queue")((bus: Bus.Interface, input: PendingRef) =>
publishMutation(
input,
bus.publish(SessionEvent.InputQueued, {
sessionID: input.sessionID,
inputID: input.id,
}),
),
)
const publish = Effect.fn("SessionPending.publish")(function* ( const publish = Effect.fn("SessionPending.publish")(function* (
db: DatabaseService, db: DatabaseService,
bus: Bus.Interface, bus: Bus.Interface,
+18
View File
@@ -485,6 +485,24 @@ const layer = Layer.effectDiscard(
.pipe(Effect.orDie) .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) => yield* bus.project(SessionEvent.Compaction.Admitted, (event) =>
Effect.gen(function* () { Effect.gen(function* () {
if (event.durable === undefined) if (event.durable === undefined)
+39
View File
@@ -0,0 +1,39 @@
import fs from "node:fs/promises"
import { Effect } from "effect"
import { ChildProcessSpawner } from "effect/unstable/process"
import { CrossSpawnSpawner } from "@opencode-ai/util/cross-spawn-spawner"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { execDefaults, Failed, makeFiles, makeMemoryDriver } from "../src/environment/index"
import { tmpdir } from "./fixture/tmpdir"
import { environmentConformance } from "./lib/environment-conformance"
environmentConformance("memory environment", () =>
Effect.sync(() => {
const driver = makeMemoryDriver()
return {
files: makeFiles(driver),
root: `/workspace-${crypto.randomUUID()}`,
symlink: driver.symlink,
}
}),
)
environmentConformance(
"GNU exec environment",
() =>
Effect.gen(function* () {
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
const tmp = yield* Effect.promise(() => tmpdir("opencode-environment-"))
return {
files: execDefaults(spawner),
root: tmp.path,
symlink: (target: string, link: string) =>
Effect.tryPromise({
try: () => fs.symlink(target, link),
catch: (cause) => new Failed({ path: link, cause }),
}),
dispose: Effect.promise(() => tmp[Symbol.asyncDispose]()),
}
}).pipe(Effect.provide(LayerNode.compile(CrossSpawnSpawner.node))),
process.platform !== "linux",
)
@@ -0,0 +1,159 @@
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { Failed, NotFound, WrongKind, type Files } from "../../src/environment/index"
import { it } from "./effect"
export interface EnvironmentHarness {
readonly files: Files
readonly root: string
readonly symlink?: (target: string, path: string) => Effect.Effect<void, Failed>
readonly dispose?: Effect.Effect<void>
}
export const environmentConformance = <E>(
name: string,
makeHarness: () => Effect.Effect<EnvironmentHarness, E>,
skip = false,
) => {
const check = <A, E2>(title: string, body: (harness: EnvironmentHarness) => Effect.Effect<A, E2>) =>
it.live(title, () =>
Effect.gen(function* () {
const harness = yield* Effect.acquireRelease(makeHarness(), (harness) =>
Effect.gen(function* () {
yield* Effect.ignore(harness.files.remove(harness.root))
if (harness.dispose) yield* harness.dispose
}),
)
yield* harness.files.mkdir(harness.root)
return yield* body(harness)
}),
)
const bytes = (value: string) => new TextEncoder().encode(value)
const text = (value: Uint8Array) => new TextDecoder().decode(value)
const suite = skip ? describe.skip : describe
suite(name, () => {
check("writes, stats, and reads a file with its info", (harness) =>
Effect.gen(function* () {
const target = `${harness.root}/hello.txt`
yield* harness.files.write(target, bytes("hello"))
const result = yield* harness.files.read(target)
expect(text(result.bytes)).toBe("hello")
expect(result.info.type).toBe("file")
expect(result.info.size).toBe(5)
expect(yield* harness.files.stat(target)).toEqual(result.info)
}),
)
check("reports missing paths", (harness) =>
Effect.gen(function* () {
const target = `${harness.root}/missing`
expect(yield* Effect.flip(harness.files.read(target))).toBeInstanceOf(NotFound)
expect(yield* Effect.flip(harness.files.stat(target))).toBeInstanceOf(NotFound)
expect(yield* Effect.flip(harness.files.list(target))).toBeInstanceOf(NotFound)
expect(yield* Effect.flip(harness.files.move(target, `${harness.root}/other`))).toBeInstanceOf(NotFound)
}),
)
check("reports the actual kind", (harness) =>
Effect.gen(function* () {
const directory = `${harness.root}/directory`
const file = `${harness.root}/file`
yield* harness.files.mkdir(directory)
yield* harness.files.write(file, bytes("data"))
const readError = yield* Effect.flip(harness.files.read(directory))
const listError = yield* Effect.flip(harness.files.list(file))
expect(readError).toBeInstanceOf(WrongKind)
expect((readError as WrongKind).actual).toBe("directory")
expect(listError).toBeInstanceOf(WrongKind)
expect((listError as WrongKind).actual).toBe("file")
}),
)
check("write creates parent directories", (harness) =>
Effect.gen(function* () {
const target = `${harness.root}/one/two/file`
yield* harness.files.write(target, bytes("nested"))
yield* harness.files.write(`${harness.root}/empty`, new Uint8Array())
expect((yield* harness.files.stat(`${harness.root}/one/two`)).type).toBe("directory")
expect(yield* harness.files.stat(`${harness.root}/empty`)).toMatchObject({ type: "file", size: 0 })
expect(text((yield* harness.files.read(target)).bytes)).toBe("nested")
}),
)
check("reads byte ranges", (harness) =>
Effect.gen(function* () {
const target = `${harness.root}/range`
yield* harness.files.write(target, bytes("0123456789"))
expect(text((yield* harness.files.read(target, { offset: 2, length: 4 })).bytes)).toBe("2345")
expect(text((yield* harness.files.read(target, { offset: 8, length: 8 })).bytes)).toBe("89")
expect(text((yield* harness.files.read(target, { offset: 20, length: 4 })).bytes)).toBe("")
}),
)
check("lists immediate entries with their kinds", (harness) =>
Effect.gen(function* () {
yield* harness.files.write(`${harness.root}/file name`, bytes("data"))
yield* harness.files.mkdir(`${harness.root}/directory`)
yield* harness.files.write(`${harness.root}/directory/nested`, bytes("nested"))
const entries = yield* harness.files.list(harness.root)
expect(entries.toSorted((a, b) => a.name.localeCompare(b.name))).toEqual([
{ name: "directory", type: "directory" },
{ name: "file name", type: "file" },
])
}),
)
check("reports symlinks without resolving them", (harness) =>
Effect.gen(function* () {
if (!harness.symlink) return
yield* harness.files.write(`${harness.root}/target`, bytes("target"))
yield* harness.files.write(`${harness.root}/target-dir/file`, bytes("through link"))
yield* harness.symlink("target", `${harness.root}/link`)
yield* harness.symlink("target-dir", `${harness.root}/link-dir`)
expect((yield* harness.files.stat(`${harness.root}/link`)).type).toBe("symlink")
expect(yield* harness.files.list(harness.root)).toContainEqual({ name: "link", type: "symlink" })
expect(text((yield* harness.files.read(`${harness.root}/link-dir/file`)).bytes)).toBe("through link")
const listError = yield* Effect.flip(harness.files.list(`${harness.root}/link-dir`))
expect(listError).toBeInstanceOf(WrongKind)
expect((listError as WrongKind).actual).toBe("symlink")
}),
)
check("follows symlinks when reading", (harness) =>
Effect.gen(function* () {
if (!harness.symlink) return
yield* harness.files.write(`${harness.root}/target`, bytes("target content"))
yield* harness.files.mkdir(`${harness.root}/directory`)
yield* harness.symlink("target", `${harness.root}/file-link`)
yield* harness.symlink("directory", `${harness.root}/directory-link`)
yield* harness.symlink("missing", `${harness.root}/dangling-link`)
const result = yield* harness.files.read(`${harness.root}/file-link`)
expect(text(result.bytes)).toBe("target content")
expect(result.info.type).toBe("file")
expect(result.info.size).toBe(bytes("target content").length)
const directoryError = yield* Effect.flip(harness.files.read(`${harness.root}/directory-link`))
expect(directoryError).toBeInstanceOf(WrongKind)
expect((directoryError as WrongKind).actual).toBe("directory")
expect(yield* Effect.flip(harness.files.read(`${harness.root}/dangling-link`))).toBeInstanceOf(NotFound)
}),
)
check("moves files and removes trees idempotently", (harness) =>
Effect.gen(function* () {
const source = `${harness.root}/source/file`
const destination = `${harness.root}/destination`
yield* harness.files.write(source, bytes("moved"))
yield* harness.files.move(source, destination)
expect(text((yield* harness.files.read(destination)).bytes)).toBe("moved")
expect(yield* Effect.flip(harness.files.stat(source))).toBeInstanceOf(NotFound)
yield* harness.files.remove(`${harness.root}/source`)
yield* harness.files.remove(`${harness.root}/source`)
expect(yield* Effect.flip(harness.files.stat(`${harness.root}/source`))).toBeInstanceOf(NotFound)
}),
)
})
}
+74
View File
@@ -1086,4 +1086,78 @@ describe("Session.pending", () => {
expect(yield* session.pending(sessionID)).toEqual([]) expect(yield* session.pending(sessionID)).toEqual([])
}), }),
) )
it.effect("cancels pending 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)
}),
)
}) })
+39
View File
@@ -522,6 +522,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( .add(
HttpApiEndpoint.get("session.instructions.entry.list", "/api/session/:sessionID/instructions/entries", { HttpApiEndpoint.get("session.instructions.entry.list", "/api/session/:sessionID/instructions/entries", {
params: { sessionID: Session.ID }, params: { sessionID: Session.ID },
+35 -7
View File
@@ -152,13 +152,15 @@ export const Forked = Event.durable({
}) })
export type Forked = typeof Forked.Type export type Forked = typeof Forked.Type
const InputRef = {
...Base,
inputID: SessionMessage.ID,
}
export const InputPromoted = Event.durable({ export const InputPromoted = Event.durable({
type: "session.input.promoted", type: "session.input.promoted",
...options, ...options,
schema: { schema: InputRef,
sessionID: SessionID,
inputID: SessionMessage.ID,
},
}) })
export type InputPromoted = typeof InputPromoted.Type export type InputPromoted = typeof InputPromoted.Type
@@ -166,13 +168,33 @@ export const InputAdmitted = Event.durable({
type: "session.input.admitted", type: "session.input.admitted",
...options, ...options,
schema: { schema: {
...Base, ...InputRef,
inputID: SessionMessage.ID,
input: SessionPending.Message, input: SessionPending.Message,
}, },
}) })
export type InputAdmitted = typeof InputAdmitted.Type export type InputAdmitted = typeof InputAdmitted.Type
export const InputCancelled = Event.durable({
type: "session.input.cancelled",
...options,
schema: InputRef,
})
export type InputCancelled = typeof InputCancelled.Type
export const InputSteered = Event.durable({
type: "session.input.steered",
...options,
schema: InputRef,
})
export type InputSteered = typeof InputSteered.Type
export const InputQueued = Event.durable({
type: "session.input.queued",
...options,
schema: InputRef,
})
export type InputQueued = typeof InputQueued.Type
export namespace Execution { export namespace Execution {
export const Started = Event.durable({ type: "session.execution.started", ...options, schema: Base }) export const Started = Event.durable({ type: "session.execution.started", ...options, schema: Base })
export type Started = typeof Started.Type export type Started = typeof Started.Type
@@ -580,6 +602,9 @@ export const Definitions = Event.inventory(
Forked, Forked,
InputPromoted, InputPromoted,
InputAdmitted, InputAdmitted,
InputCancelled,
InputSteered,
InputQueued,
Execution.Started, Execution.Started,
Execution.Succeeded, Execution.Succeeded,
Execution.Failed, Execution.Failed,
@@ -621,13 +646,16 @@ export const DurableDefinitions = Event.inventory(
...Definitions.filter((definition) => definition.durability === "durable"), ...Definitions.filter((definition) => definition.durability === "durable"),
UsageRecorded, UsageRecorded,
) )
export const EphemeralDefinitions = Event.inventory(
...Definitions.filter((definition) => definition.durability === "ephemeral"),
)
export const Durable = Schema.Union(DurableDefinitions, { mode: "oneOf" }) export const Durable = Schema.Union(DurableDefinitions, { mode: "oneOf" })
.pipe(Schema.toTaggedUnion("type")) .pipe(Schema.toTaggedUnion("type"))
.annotate({ identifier: "Session.Event.Durable" }) .annotate({ identifier: "Session.Event.Durable" })
export type DurableEvent = typeof Durable.Type 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"), Schema.toTaggedUnion("type"),
) )
export type Event = typeof All.Type export type Event = typeof All.Type
@@ -84,6 +84,9 @@ describe("public event manifest", () => {
"session.forked.2", "session.forked.2",
"session.input.promoted.1", "session.input.promoted.1",
"session.input.admitted.1", "session.input.admitted.1",
"session.input.cancelled.1",
"session.input.steered.1",
"session.input.queued.1",
"session.execution.started.1", "session.execution.started.1",
"session.execution.succeeded.1", "session.execution.succeeded.1",
"session.execution.failed.1", "session.execution.failed.1",
+43
View File
@@ -26,6 +26,22 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
Effect.gen(function* () { Effect.gen(function* () {
const session = yield* Session.Service const session = yield* Session.Service
const transfer = yield* SessionTransfer.Service const transfer = yield* SessionTransfer.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 return handlers
.handle( .handle(
@@ -661,6 +677,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( .handle(
"session.instructions.entry.list", "session.instructions.entry.list",
Effect.fn(function* (ctx) { Effect.fn(function* (ctx) {
@@ -462,6 +462,7 @@ function newLayout() {
function webSearchProviderLabel(provider: unknown) { function webSearchProviderLabel(provider: unknown) {
if (provider === "parallel") return "Parallel Web Search" if (provider === "parallel") return "Parallel Web Search"
if (provider === "exa") return "Exa Web Search" if (provider === "exa") return "Exa Web Search"
if (provider === "firecrawl") return "Firecrawl Web Search"
return "Web Search" return "Web Search"
} }
+49 -7
View File
@@ -53,12 +53,14 @@ import { useLocation } from "../../context/location"
import { Keymap, type KeymapCommand } from "../../context/keymap" import { Keymap, type KeymapCommand } from "../../context/keymap"
import { abbreviateHome } from "../../runtime" import { abbreviateHome } from "../../runtime"
import { PluginSlot } from "../../plugin/render" import { PluginSlot } from "../../plugin/render"
import type { SessionPending } from "@opencode-ai/schema/session-pending"
export type PromptProps = { export type PromptProps = {
sessionID?: string sessionID?: string
visible?: boolean visible?: boolean
disabled?: boolean disabled?: boolean
onSubmit?: () => void onSubmit?: () => void
onEmptySubmit?: () => boolean | Promise<boolean>
ref?: (ref: PromptRef | undefined) => void ref?: (ref: PromptRef | undefined) => void
hint?: JSX.Element hint?: JSX.Element
right?: JSX.Element right?: JSX.Element
@@ -357,6 +359,20 @@ export function Prompt(props: PromptProps) {
dialog.clear() 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", title: "Remove editor context",
name: "prompt.editor_context.clear", name: "prompt.editor_context.clear",
@@ -515,6 +531,11 @@ export function Prompt(props: PromptProps) {
commands: promptCommands(), commands: promptCommands(),
})) }))
Keymap.createLayer(() => ({
priority: 1,
bindings: ["prompt.queue"],
}))
Keymap.createLayer(() => ({ Keymap.createLayer(() => ({
bindings: [ bindings: [
"prompt.submit", "prompt.submit",
@@ -900,7 +921,7 @@ export function Prompt(props: PromptProps) {
}) })
let submitting = false let submitting = false
async function submit() { async function submit(delivery: SessionPending.Delivery = "steer") {
// Prevent overlapping invocations (e.g. a double-pressed Enter, or the // Prevent overlapping invocations (e.g. a double-pressed Enter, or the
// input's native onSubmit racing another dispatch). Without this guard, // input's native onSubmit racing another dispatch). Without this guard,
// a second call slips past the empty-input check before the first call // a second call slips past the empty-input check before the first call
@@ -910,13 +931,13 @@ export function Prompt(props: PromptProps) {
if (submitting) return false if (submitting) return false
submitting = true submitting = true
try { try {
return await submitInner() return await submitInner(delivery)
} finally { } finally {
submitting = false submitting = false
} }
} }
async function submitInner() { async function submitInner(delivery: SessionPending.Delivery) {
// IME: double-defer may fire before onContentChange flushes the last // IME: double-defer may fire before onContentChange flushes the last
// composed character (e.g. Korean hangul) to the store, so read // composed character (e.g. Korean hangul) to the store, so read
// plainText directly and sync before any downstream reads. // plainText directly and sync before any downstream reads.
@@ -927,14 +948,25 @@ export function Prompt(props: PromptProps) {
if (props.disabled) return false if (props.disabled) return false
if (move.creating()) return false if (move.creating()) return false
if (auto()?.visible) return false if (auto()?.visible) return false
if (!store.prompt.text) return false
const trimmed = store.prompt.text.trim() 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") { if (trimmed === "exit" || trimmed === "quit" || trimmed === ":q") {
void exit() void exit()
return true return true
} }
const slash = argumentSlash(store.prompt.text, keymapCommands()) const slash = argumentSlash(store.prompt.text, keymapCommands())
if (slash) { if (slash) {
if (delivery === "queue") {
toast.show({ message: "This prompt cannot be queued", variant: "warning" })
return false
}
clearPrompt() clearPrompt()
await slash.command.run(slash.input) await slash.command.run(slash.input)
return true return true
@@ -958,6 +990,16 @@ export function Prompt(props: PromptProps) {
const isCommand = const isCommand =
slashHead !== undefined && slashHead !== undefined &&
(data.location.command.list(currentLocation.ref) ?? []).some((command) => command.name === slashHead.name) (data.location.command.list(currentLocation.ref) ?? []).some((command) => command.name === slashHead.name)
if (delivery === "queue" && isSkill) {
toast.show({ message: "Skills cannot be queued", variant: "warning" })
return false
}
const editorSelection = editorContext()
const pendingEditorSelection = editorSelection && editor.labelState() === "pending" ? editorSelection : undefined
if (delivery === "queue" && pendingEditorSelection) {
toast.show({ message: "Editor context cannot be queued", variant: "warning" })
return false
}
const agent = local.agent.current() const agent = local.agent.current()
if (!agent) return false if (!agent) return false
const selection = local.model.selection() const selection = local.model.selection()
@@ -1016,8 +1058,6 @@ export function Prompt(props: PromptProps) {
// Capture mode before it gets reset // Capture mode before it gets reset
const currentMode = store.mode const currentMode = store.mode
const editorSelection = editorContext()
const pendingEditorSelection = editorSelection && editor.labelState() === "pending" ? editorSelection : undefined
if (store.mode === "shell") { if (store.mode === "shell") {
move.startSubmit() move.startSubmit()
@@ -1040,6 +1080,7 @@ export function Prompt(props: PromptProps) {
model, model,
files: store.prompt.files, files: store.prompt.files,
agents: store.prompt.agents, agents: store.prompt.agents,
delivery,
}) })
.catch((error) => { .catch((error) => {
cancelCommit() cancelCommit()
@@ -1049,7 +1090,7 @@ export function Prompt(props: PromptProps) {
move.startSubmit() move.startSubmit()
void client.api.session.skill({ void client.api.session.skill({
sessionID, sessionID,
skill: slashHead!.name, skill: slashHead.name,
}) })
} else { } else {
move.startSubmit() move.startSubmit()
@@ -1105,6 +1146,7 @@ export function Prompt(props: PromptProps) {
text: inputText, text: inputText,
files: store.prompt.files, files: store.prompt.files,
agents: store.prompt.agents, agents: store.prompt.agents,
delivery,
}) })
.then( .then(
() => undefined, () => undefined,
+6 -2
View File
@@ -103,7 +103,8 @@ export const Definitions = {
session_interrupt: keybind("escape", "Interrupt current session"), session_interrupt: keybind("escape", "Interrupt current session"),
session_background: keybind("ctrl+b", "Background blocking session tools"), session_background: keybind("ctrl+b", "Background blocking session tools"),
session_compact: keybind("<leader>c", "Compact the session"), session_compact: keybind("<leader>c", "Compact the session"),
session_queued_prompts: keybind("<leader>q", "View pending work"), session_queued_prompts: keybind("<leader>q", "View queued prompts"),
queued_prompt_delete: keybind("ctrl+d", "Delete queued prompt"),
session_child_first: keybind("down", "Toggle subagent picker"), session_child_first: keybind("down", "Toggle subagent picker"),
session_parent: keybind("up", "Go to parent session"), session_parent: keybind("up", "Go to parent session"),
session_pin_toggle: keybind("ctrl+f", "Pin or unpin session in the session list"), 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"), display_thinking: keybind("none", "Toggle thinking blocks visibility"),
prompt_submit: keybind("none", "Submit prompt"), prompt_submit: keybind("none", "Submit prompt"),
prompt_queue: keybind("alt+return", "Queue prompt"),
prompt_editor_context_clear: keybind("none", "Clear editor context"), prompt_editor_context_clear: keybind("none", "Clear editor context"),
prompt_skills: keybind("none", "Open skill selector"), prompt_skills: keybind("none", "Open skill selector"),
prompt_stash: keybind("none", "Stash prompt"), prompt_stash: keybind("none", "Stash prompt"),
@@ -170,7 +172,7 @@ export const Definitions = {
input_clear: keybind("ctrl+c", "Clear input field"), input_clear: keybind("ctrl+c", "Clear input field"),
input_paste: keybind({ key: "ctrl+v", preventDefault: false }, "Paste from clipboard"), input_paste: keybind({ key: "ctrl+v", preventDefault: false }, "Paste from clipboard"),
input_submit: keybind("return", "Submit input"), 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_left: keybind("left,ctrl+b", "Move cursor left in input"),
input_move_right: keybind("right,ctrl+f", "Move cursor right in input"), input_move_right: keybind("right,ctrl+f", "Move cursor right in input"),
input_move_up: keybind("up", "Move cursor up in input"), input_move_up: keybind("up", "Move cursor up in input"),
@@ -305,6 +307,7 @@ export const CommandMap = {
session_background: "session.background", session_background: "session.background",
session_compact: "session.compact", session_compact: "session.compact",
session_queued_prompts: "session.queued_prompts", session_queued_prompts: "session.queued_prompts",
queued_prompt_delete: "queued_prompt.delete",
session_child_first: "session.child.first", session_child_first: "session.child.first",
session_parent: "session.parent", session_parent: "session.parent",
session_pin_toggle: "session.pin.toggle", session_pin_toggle: "session.pin.toggle",
@@ -359,6 +362,7 @@ export const CommandMap = {
messages_redo: "session.redo", messages_redo: "session.redo",
display_thinking: "session.toggle.thinking", display_thinking: "session.toggle.thinking",
prompt_submit: "prompt.submit", prompt_submit: "prompt.submit",
prompt_queue: "prompt.queue",
prompt_editor_context_clear: "prompt.editor_context.clear", prompt_editor_context_clear: "prompt.editor_context.clear",
prompt_skills: "prompt.skills", prompt_skills: "prompt.skills",
prompt_stash: "prompt.stash", prompt_stash: "prompt.stash",
+49 -15
View File
@@ -38,6 +38,7 @@ import { createStore, produce, reconcile } from "solid-js/store"
import { createSimpleContext } from "./helper" import { createSimpleContext } from "./helper"
import { useClient } from "./client" import { useClient } from "./client"
import { nonEmptyToolContent } from "../util/tool-display" import { nonEmptyToolContent } from "../util/tool-display"
import type { SessionPending } from "@opencode-ai/schema/session-pending"
import { createEffect, createSignal, onCleanup } from "solid-js" import { createEffect, createSignal, onCleanup } from "solid-js"
export type DataSessionStatus = "idle" | "running" export type DataSessionStatus = "idle" | "running"
@@ -170,12 +171,20 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
function removePending(sessionID: string, inputID?: string) { function removePending(sessionID: string, inputID?: string) {
if (!inputID) return if (!inputID) return
setStore( if (store.session.pending[sessionID]?.some((item) => item.id === inputID))
"session", setStore(
"pending", "session",
sessionID, "pending",
(store.session.pending[sessionID] ?? []).filter((item) => item.id !== inputID), 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 removePermission(sessionID: string, requestID: string) { function removePermission(sessionID: string, requestID: string) {
@@ -189,6 +198,13 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
) )
} }
function updatePending(sessionID: string, inputID: string, delivery: SessionPending.Delivery) {
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 = { const message = {
update(sessionID: string, fn: (messages: SessionMessageInfo[], index: Map<string, number>) => void) { update(sessionID: string, fn: (messages: SessionMessageInfo[], index: Map<string, number>) => void) {
setStore( setStore(
@@ -235,6 +251,12 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
(item): item is SessionMessageAssistantReasoning => item.type === "reasoning" && !item.time?.completed, (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) { function index(sessionID: string) {
@@ -416,24 +438,36 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
} }
break break
case "session.input.promoted": { case "session.input.promoted": {
const admitted = store.session.input[event.data.sessionID]?.includes(event.data.inputID) ?? false
removePending(event.data.sessionID, event.data.inputID) removePending(event.data.sessionID, event.data.inputID)
message.update(event.data.sessionID, (draft, index) => { message.update(event.data.sessionID, (draft, index) => {
const position = index.get(event.data.inputID) const position = index.get(event.data.inputID)
if (position === undefined) return if (position === undefined) return
const existing = draft[position] 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 existing.time.created = event.created
draft.splice(position, 1) draft.splice(position, 1)
draft.push(existing) draft.push(existing)
index.clear() message.reindex(draft, index, position)
draft.forEach((message, indexValue) => index.set(message.id, indexValue))
}) })
setStore( break
"session", }
"input", case "session.input.steered":
event.data.sessionID, updatePending(event.data.sessionID, event.data.inputID, "steer")
(store.session.input[event.data.sessionID] ?? []).filter((id) => id !== event.data.inputID), 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 break
} }
case "session.input.admitted": case "session.input.admitted":
+33 -6
View File
@@ -3,7 +3,9 @@ import { TextAttributes, type InputRenderable, type KeyEvent } from "@opentui/co
import { useKeyboard, type JSX } from "@opentui/solid" import { useKeyboard, type JSX } from "@opentui/solid"
import fuzzysort from "fuzzysort" import fuzzysort from "fuzzysort"
import { createEffect, createMemo, createSignal, type Accessor } from "solid-js" import { createEffect, createMemo, createSignal, type Accessor } from "solid-js"
import { Keymap } from "../context/keymap"
import { RunFooterMenu, createFooterMenuState, type RunFooterMenuItem } from "./footer.menu" import { RunFooterMenu, createFooterMenuState, type RunFooterMenuItem } from "./footer.menu"
import { monoShortcut } from "./mono"
import type { RunFooterTheme } from "./theme" import type { RunFooterTheme } from "./theme"
import type { import type {
FooterQueuedPrompt, FooterQueuedPrompt,
@@ -56,6 +58,10 @@ type SkillEntry = PanelEntry & {
name: string name: string
} }
type QueuedPromptEntry = PanelEntry & {
prompt: FooterQueuedPrompt
}
type SubagentEntry = PanelEntry & { type SubagentEntry = PanelEntry & {
sessionID: string sessionID: string
current: boolean current: boolean
@@ -442,7 +448,7 @@ export function RunCommandMenuBody(props: {
{ {
action: "queued" as const, action: "queued" as const,
category: "Agent", category: "Agent",
display: "View pending work", display: "View queued prompts",
footer: `${props.queued().length} pending`, footer: `${props.queued().length} pending`,
keywords: props keywords: props
.queued() .queued()
@@ -837,28 +843,48 @@ export function RunQueuedPromptSelectBody(props: {
theme: Accessor<RunFooterTheme> theme: Accessor<RunFooterTheme>
prompts: Accessor<FooterQueuedPrompt[]> prompts: Accessor<FooterQueuedPrompt[]>
onClose: () => void onClose: () => void
onSteer: (prompt: FooterQueuedPrompt) => void
onDelete: (prompt: FooterQueuedPrompt) => void
onRows?: (rows: number) => void onRows?: (rows: number) => void
mono?: boolean mono?: boolean
}) { }) {
const entries = createMemo(() => const entries = createMemo<QueuedPromptEntry[]>(() =>
props.prompts().map((prompt) => ({ props.prompts().map((prompt) => ({
category: "", category: "",
display: prompt.prompt.text.replaceAll("\n", " "), display: prompt.prompt.text.replaceAll("\n", " "),
footer: prompt.delivery, footer: "queued",
keywords: prompt.prompt.text, keywords: prompt.prompt.text,
prompt,
})), })),
) )
const controller = createSearchablePanelController({ const controller = createSearchablePanelController({
entries, entries,
limit: SUBAGENT_LIST_ROWS, limit: SUBAGENT_LIST_ROWS,
onClose: props.onClose, onClose: props.onClose,
onSelect: props.onClose, onSelect: (item) => props.onSteer(item.prompt),
onRows: props.onRows, 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 ( return (
<PanelShell <PanelShell
title="Pending work" title="Queued prompts"
query={controller.query()} query={controller.query()}
count={controller.items().length} count={controller.items().length}
total={entries().length} total={entries().length}
@@ -866,6 +892,7 @@ export function RunQueuedPromptSelectBody(props: {
theme={props.theme} theme={props.theme}
inputRef={controller.inputRef} inputRef={controller.inputRef}
onQuery={controller.setQuery} onQuery={controller.setQuery}
hint={["enter steer", deleteShortcut() ? `${deleteShortcut()} delete` : undefined].filter(Boolean).join(" · ")}
mono={props.mono} mono={props.mono}
> >
<RunFooterMenu <RunFooterMenu
@@ -875,7 +902,7 @@ export function RunQueuedPromptSelectBody(props: {
offset={controller.menu.offset} offset={controller.menu.offset}
rows={controller.menu.rows} rows={controller.menu.rows}
limit={SUBAGENT_LIST_ROWS} limit={SUBAGENT_LIST_ROWS}
empty="No pending work" empty="No queued prompts"
border={false} border={false}
paddingLeft={panelPad(props.mono)} paddingLeft={panelPad(props.mono)}
paddingRight={panelPad(props.mono)} paddingRight={panelPad(props.mono)}
+18
View File
@@ -71,6 +71,7 @@ export function RunFormBody(props: {
return typeof value === "string" ? value : undefined return typeof value === "string" ? value : undefined
}) })
let area: TextareaRenderable | undefined let area: TextareaRenderable | undefined
let editingReady = false
createEffect(() => { createEffect(() => {
setState((previous) => formSync(previous, props.request)) setState((previous) => formSync(previous, props.request))
@@ -93,6 +94,7 @@ export function RunFormBody(props: {
if (!area || area.isDestroyed || !state().editing) return if (!area || area.isDestroyed || !state().editing) return
area.focus() area.focus()
area.cursorOffset = area.plainText.length area.cursorOffset = area.plainText.length
editingReady = true
}) })
}) })
@@ -209,6 +211,22 @@ export function RunFormBody(props: {
return return
} }
if (unsupported()) return if (unsupported()) return
const character =
!event.ctrl &&
!event.meta &&
!event.option &&
!event.super &&
!event.hyper &&
/^[^\p{C}\p{Zl}\p{Zp}]$/u.test(event.sequence)
? event.sequence
: undefined
if (custom() && state().selected === rows().length && character && !editingReady) {
const next = state().editing ? state() : formPick(state(), props.request)
if (!state().editing) editingReady = false
setState(formSetDraft(next, current(), formInput(next, current()) + character))
event.preventDefault()
return
}
if (state().editing) return if (state().editing) return
if ( if (
event.name === "tab" || event.name === "tab" ||
+62 -13
View File
@@ -19,6 +19,7 @@ import {
displayCharAt, displayCharAt,
displaySlice, displaySlice,
isExitCommand, isExitCommand,
isCompactCommand,
mentionTriggerIndex, mentionTriggerIndex,
isNewCommand, isNewCommand,
movePromptHistory, movePromptHistory,
@@ -31,7 +32,16 @@ import { realignEditorPromptParts, resolveEditorSlashValue } from "./prompt.edit
import { monoTruncateMiddle } from "./mono" import { monoTruncateMiddle } from "./mono"
import { FOOTER_MENU_ROWS, createFooterMenuState, type RunFooterMenuItem } from "./footer.menu" import { FOOTER_MENU_ROWS, createFooterMenuState, type RunFooterMenuItem } from "./footer.menu"
import type { RunFooterTheme } from "./theme" import type { RunFooterTheme } from "./theme"
import type { FooterState, RunAgent, RunCommand, RunPrompt, RunPromptPart, RunReference } from "./types" import type {
FooterQueuedPrompt,
FooterState,
RunAgent,
RunCommand,
RunDelivery,
RunPrompt,
RunPromptPart,
RunReference,
} from "./types"
const AUTOCOMPLETE_ROWS = FOOTER_MENU_ROWS const AUTOCOMPLETE_ROWS = FOOTER_MENU_ROWS
const AUTOCOMPLETE_BOTTOM_ROWS = 1 const AUTOCOMPLETE_BOTTOM_ROWS = 1
@@ -72,6 +82,8 @@ type PromptInput = {
theme: Accessor<RunFooterTheme> theme: Accessor<RunFooterTheme>
mono: Accessor<boolean> mono: Accessor<boolean>
history?: Accessor<RunPrompt[]> history?: Accessor<RunPrompt[]>
queuedPrompts: Accessor<FooterQueuedPrompt[]>
onQueuedPromptSteer: (inputID: string) => Promise<boolean>
onSubmit: (input: RunPrompt) => boolean | Promise<boolean> onSubmit: (input: RunPrompt) => boolean | Promise<boolean>
onCycle: () => void onCycle: () => void
onInterrupt: () => boolean onInterrupt: () => boolean
@@ -980,8 +992,18 @@ export function createPromptState(input: PromptInput): PromptState {
})) }))
Keymap.createLayer(() => ({ Keymap.createLayer(() => ({
priority: 1,
enabled: input.prompt() && !visible(), enabled: input.prompt() && !visible(),
commands: [ commands: [
{
id: "prompt.queue",
title: "Queue prompt",
group: "Prompt",
run() {
syncDraft()
submitPrompt(promptCopy(draft), "queue")
},
},
{ {
id: "prompt.editor", id: "prompt.editor",
title: "Open editor", title: "Open editor",
@@ -1116,7 +1138,8 @@ export function createPromptState(input: PromptInput): PromptState {
} }
} }
const submitPrompt = (next: RunPrompt) => { let submitting = false
const submitPrompt = (next: RunPrompt, delivery: RunDelivery = "steer") => {
if (!area || area.isDestroyed) { if (!area || area.isDestroyed) {
draft = promptCopy(next) draft = promptCopy(next)
} }
@@ -1130,12 +1153,34 @@ export function createPromptState(input: PromptInput): PromptState {
hide() hide()
} }
if (submitting) return
if (!next.text.trim()) { 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") input.onStatus(input.state().phase === "running" ? "waiting for current response" : "empty prompt ignored")
return return
} }
const command = next.mode === "shell" ? undefined : selectedCommand(next.text, next.command) 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) ||
isExitCommand(next.text) ||
next.text.trim().toLowerCase() === "/settings")
) {
input.onStatus("this prompt cannot be queued")
return
}
if (!command && next.mode !== "shell" && isExitCommand(next.text)) { if (!command && next.mode !== "shell" && isExitCommand(next.text)) {
input.onExit() input.onExit()
return return
@@ -1157,24 +1202,28 @@ export function createPromptState(input: PromptInput): PromptState {
} }
const submit = command const submit = command
? { ...next, command } ? { ...next, command, delivery }
: parsed?.type === "command" : parsed?.type === "command"
? { ...next, command: parsed.command } ? { ...next, command: parsed.command, delivery }
: next : { ...next, delivery }
const shellMode = next.mode === "shell" const shellMode = next.mode === "shell"
submitting = true
resetDraft() resetDraft()
queueMicrotask(async () => { queueMicrotask(async () => {
if (await input.onSubmit(submit)) { try {
push(next) if (await input.onSubmit(submit)) {
if (shellMode) { push(next)
setShellMode(false) if (shellMode) {
draft = emptyPrompt(false) setShellMode(false)
draft = emptyPrompt(false)
}
return
} }
return restore(next)
} finally {
submitting = false
} }
restore(next)
}) })
} }
+3
View File
@@ -51,6 +51,7 @@ import type {
MiniSettingChange, MiniSettingChange,
MiniSettings, MiniSettings,
PermissionReply, PermissionReply,
QueuedPromptAction,
RunAgent, RunAgent,
RunCommand, RunCommand,
RunInput, RunInput,
@@ -96,6 +97,7 @@ type RunFooterOptions = {
onVariantSelect?: (variant: string | undefined) => CycleResult | void | Promise<CycleResult | void> onVariantSelect?: (variant: string | undefined) => CycleResult | void | Promise<CycleResult | void>
onInterrupt?: () => void onInterrupt?: () => void
onBackground?: () => void onBackground?: () => void
onQueuedPromptAction?: (action: QueuedPromptAction, inputID: string) => Promise<void>
onEditorOpen: (input: { value: string }) => Promise<string | undefined> onEditorOpen: (input: { value: string }) => Promise<string | undefined>
onSubagentSelect?: (sessionID: string | undefined) => void onSubagentSelect?: (sessionID: string | undefined) => void
onSubagentInterrupt?: (sessionID: string) => void onSubagentInterrupt?: (sessionID: string) => void
@@ -343,6 +345,7 @@ export class RunFooter implements FooterApi {
onCycle: footer.handleCycle, onCycle: footer.handleCycle,
onInterrupt: footer.handleInterrupt, onInterrupt: footer.handleInterrupt,
onBackground: options.onBackground, onBackground: options.onBackground,
onQueuedPromptAction: options.onQueuedPromptAction,
onEditorOpen: options.onEditorOpen, onEditorOpen: options.onEditorOpen,
onInputClear: footer.handleInputClear, onInputClear: footer.handleInputClear,
onExitRequest: footer.handleExit, onExitRequest: footer.handleExit,
+41 -11
View File
@@ -34,6 +34,8 @@ import { Keymap } from "../context/keymap"
import { modelInfo } from "./variant.shared" import { modelInfo } from "./variant.shared"
import { monoShortcut } from "./mono" import { monoShortcut } from "./mono"
import { stringWidth } from "../util/string-width" import { stringWidth } from "../util/string-width"
import { errorMessage } from "../util/error"
import { createSingleFlight } from "../util/single-flight"
import type { import type {
FooterPromptRoute, FooterPromptRoute,
@@ -46,6 +48,7 @@ import type {
MiniSettingChange, MiniSettingChange,
MiniSettings, MiniSettings,
PermissionReply, PermissionReply,
QueuedPromptAction,
RunAgent, RunAgent,
RunCommand, RunCommand,
RunInput, RunInput,
@@ -92,13 +95,14 @@ type RunFooterViewProps = {
mono: boolean mono: boolean
miniSettings: () => MiniSettings miniSettings: () => MiniSettings
history?: () => RunPrompt[] history?: () => RunPrompt[]
onSubmit: (input: RunPrompt) => boolean onSubmit: (input: RunPrompt) => boolean | Promise<boolean>
onPermissionReply: (input: PermissionReply) => void | Promise<void> onPermissionReply: (input: PermissionReply) => void | Promise<void>
onFormReply: (input: FormReply) => void | Promise<void> onFormReply: (input: FormReply) => void | Promise<void>
onFormCancel: (input: FormCancel) => void | Promise<void> onFormCancel: (input: FormCancel) => void | Promise<void>
onCycle: () => void onCycle: () => void
onInterrupt: () => boolean onInterrupt: () => boolean
onBackground?: () => void onBackground?: () => void
onQueuedPromptAction?: (action: QueuedPromptAction, inputID: string) => Promise<void>
onEditorOpen: (input: { value: string }) => Promise<string | undefined> onEditorOpen: (input: { value: string }) => Promise<string | undefined>
onInputClear: () => void onInputClear: () => void
onExitRequest?: () => boolean onExitRequest?: () => boolean
@@ -132,6 +136,7 @@ export function RunFooterView(props: RunFooterViewProps) {
const [route, setRoute] = createSignal<FooterPromptRoute>({ type: "composer" }) const [route, setRoute] = createSignal<FooterPromptRoute>({ type: "composer" })
const [subagentMenuRows, setSubagentMenuRows] = createSignal(RUN_SUBAGENT_PANEL_ROWS) const [subagentMenuRows, setSubagentMenuRows] = createSignal(RUN_SUBAGENT_PANEL_ROWS)
const queuedPrompts = createMemo(() => props.queuedPrompts?.() ?? []) 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 skills = createMemo(() => (props.commands() ?? []).filter((item) => item.source === "skill"))
const prompt = createMemo(() => active().type === "prompt" && route().type === "composer") const prompt = createMemo(() => active().type === "prompt" && route().type === "composer")
const selectingSubagent = createMemo(() => active().type === "prompt" && route().type === "subagent-menu") const selectingSubagent = createMemo(() => active().type === "prompt" && route().type === "subagent-menu")
@@ -229,7 +234,7 @@ export function RunFooterView(props: RunFooterViewProps) {
const details = [busy() ? "running" : "idle", `agent ${props.currentAgent()}`] const details = [busy() ? "running" : "idle", `agent ${props.currentAgent()}`]
if (current) details.push(variant ? `${current} ${variant}` : current) if (current) details.push(variant ? `${current} ${variant}` : current)
if (usage()) details.push(props.mono ? usage().replaceAll(" · ", " - ") : usage()) 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"}`) if (activeTabs().length > 0) details.push(`${activeTabs().length} subagent${activeTabs().length === 1 ? "" : "s"}`)
return details.join(props.mono ? " - " : " · ") return details.join(props.mono ? " - " : " · ")
}) })
@@ -309,7 +314,7 @@ export function RunFooterView(props: RunFooterViewProps) {
} }
const openQueuedMenu = () => { const openQueuedMenu = () => {
if (queuedPrompts().length === 0) return if (queue().length === 0) return
setRoute({ type: "queued-menu" }) setRoute({ type: "queued-menu" })
props.onSubagentSelect?.(undefined) props.onSubagentSelect?.(undefined)
} }
@@ -318,6 +323,22 @@ export function RunFooterView(props: RunFooterViewProps) {
setRoute({ type: "composer" }) setRoute({ type: "composer" })
} }
const runQueuedAction = createSingleFlight<string>()
const queuedPromptAction = async (action: QueuedPromptAction, inputID: string) => {
const run = props.onQueuedPromptAction
if (!run) return false
const result = await runQueuedAction(inputID, async () => {
const error = await run(action, inputID).then(
() => undefined,
(error) => error,
)
if (!error) return true
props.onStatus(`failed to ${action === "cancel" ? "delete" : action} queued prompt: ${errorMessage(error)}`)
return false
})
return result ?? false
}
const openTab = (sessionID: string) => { const openTab = (sessionID: string) => {
setRoute({ type: "subagent", sessionID }) setRoute({ type: "subagent", sessionID })
props.onSubagentSelect?.(sessionID) props.onSubagentSelect?.(sessionID)
@@ -357,6 +378,8 @@ export function RunFooterView(props: RunFooterViewProps) {
theme, theme,
mono: () => props.mono, mono: () => props.mono,
history: props.history, history: props.history,
queuedPrompts: queue,
onQueuedPromptSteer: (inputID) => queuedPromptAction("steer", inputID),
onSubmit: props.onSubmit, onSubmit: props.onSubmit,
onCycle: props.onCycle, onCycle: props.onCycle,
onInterrupt: props.onInterrupt, onInterrupt: props.onInterrupt,
@@ -451,13 +474,12 @@ export function RunFooterView(props: RunFooterViewProps) {
if (foregroundSubagents() && backgroundShortcut()) { if (foregroundSubagents() && backgroundShortcut()) {
items.push({ key: backgroundShortcut(), label: "background" }) items.push({ key: backgroundShortcut(), label: "background" })
} }
if (queuedPrompts().length > 0 && queuedShortcut()) { if (queue().length > 0 && queuedShortcut()) {
items.push({ key: queuedShortcut(), label: `${queuedPrompts().length} pending` }) items.push({ key: queuedShortcut(), label: `${queue().length} queued` })
} }
if (activeTabs().length > 0 && subagentShortcut()) { if (activeTabs().length > 0 && subagentShortcut()) {
items.push({ key: subagentShortcut(), label: "subagents" }) items.push({ key: subagentShortcut(), label: "subagents" })
} }
return items return items
}) })
const commandHint = createMemo(() => { const commandHint = createMemo(() => {
@@ -568,11 +590,11 @@ export function RunFooterView(props: RunFooterViewProps) {
})) }))
Keymap.createLayer(() => ({ Keymap.createLayer(() => ({
enabled: active().type === "prompt" && route().type === "composer" && queuedPrompts().length > 0, enabled: active().type === "prompt" && route().type === "composer" && queue().length > 0,
commands: [ commands: [
{ {
id: "session.queued_prompts", id: "session.queued_prompts",
title: "View pending work", title: "View queued prompts",
group: "Session", group: "Session",
run: openQueuedMenu, run: openQueuedMenu,
}, },
@@ -630,7 +652,7 @@ export function RunFooterView(props: RunFooterViewProps) {
}) })
createEffect(() => { createEffect(() => {
if (route().type !== "queued-menu" || queuedPrompts().length > 0) return if (route().type !== "queued-menu" || queue().length > 0) return
closePanel() closePanel()
}) })
@@ -734,8 +756,16 @@ export function RunFooterView(props: RunFooterViewProps) {
<Match when={selectingQueued()}> <Match when={selectingQueued()}>
<RunQueuedPromptSelectBody <RunQueuedPromptSelectBody
theme={theme} theme={theme}
prompts={queuedPrompts} prompts={queue}
onClose={closePanel} onClose={closePanel}
onSteer={(item) => {
void queuedPromptAction("steer", item.messageID).then((steered) => {
if (steered) closePanel()
})
}}
onDelete={(item) => {
void queuedPromptAction("cancel", item.messageID)
}}
onRows={setSubagentMenuRows} onRows={setSubagentMenuRows}
mono={props.mono} mono={props.mono}
/> />
@@ -745,7 +775,7 @@ export function RunFooterView(props: RunFooterViewProps) {
theme={theme} theme={theme}
commands={props.commands} commands={props.commands}
subagents={tabs} subagents={tabs}
queued={queuedPrompts} queued={queue}
variants={props.variants} variants={props.variants}
variantCycle={variantCycle()} variantCycle={variantCycle()}
onClose={closePanel} onClose={closePanel}
@@ -22,6 +22,7 @@ import type {
MiniSettings, MiniSettings,
MiniHost, MiniHost,
PermissionReply, PermissionReply,
QueuedPromptAction,
RunAgent, RunAgent,
RunInput, RunInput,
RunPrompt, RunPrompt,
@@ -70,6 +71,7 @@ export type LifecycleInput = {
onVariantSelect?: (variant: string | undefined) => CycleResult | void | Promise<CycleResult | void> onVariantSelect?: (variant: string | undefined) => CycleResult | void | Promise<CycleResult | void>
onInterrupt?: () => void onInterrupt?: () => void
onBackground?: () => void onBackground?: () => void
onQueuedPromptAction?: (action: QueuedPromptAction, inputID: string) => Promise<void>
onSubagentSelect?: (sessionID: string | undefined) => void onSubagentSelect?: (sessionID: string | undefined) => void
onSubagentInterrupt?: (sessionID: string) => void onSubagentInterrupt?: (sessionID: string) => void
} }
@@ -243,6 +245,7 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lif
onVariantSelect: input.onVariantSelect, onVariantSelect: input.onVariantSelect,
onInterrupt: input.onInterrupt, onInterrupt: input.onInterrupt,
onBackground: input.onBackground, onBackground: input.onBackground,
onQueuedPromptAction: input.onQueuedPromptAction,
onEditorOpen: async ({ value }) => { onEditorOpen: async ({ value }) => {
if (closed || renderer.isDestroyed) { if (closed || renderer.isDestroyed) {
return return
+7 -6
View File
@@ -11,7 +11,7 @@
import { SessionMessage } from "@opencode-ai/schema/session-message" import { SessionMessage } from "@opencode-ai/schema/session-message"
import { Locale } from "../util/locale" import { Locale } from "../util/locale"
import { isCompactCommand, isExitCommand, isNewCommand } from "./prompt.shared" import { isCompactCommand, isExitCommand, isNewCommand } from "./prompt.shared"
import type { FooterApi, FooterEvent, RunPrompt } from "./types" import type { FooterApi, FooterEvent, RunDelivery, RunPrompt } from "./types"
type Trace = { type Trace = {
write(type: string, data?: unknown): void write(type: string, data?: unknown): void
@@ -21,11 +21,11 @@ export type QueueInput = {
footer: FooterApi footer: FooterApi
initialInput?: string initialInput?: string
trace?: Trace trace?: Trace
onSend?: (prompt: RunPrompt, delivery: "steer" | "queue") => void onSend?: (prompt: RunPrompt, delivery: RunDelivery) => void
onAdmissionError?: (prompt: RunPrompt, error: unknown) => void | Promise<void> onAdmissionError?: (prompt: RunPrompt, error: unknown) => void | Promise<void>
onNewSession?: () => void | Promise<void> onNewSession?: () => void | Promise<void>
onCompact?: () => void | Promise<void> onCompact?: () => void | Promise<void>
admit: (prompt: RunPrompt, signal: AbortSignal) => Promise<void> admit: (prompt: RunPrompt, delivery: RunDelivery, signal: AbortSignal) => Promise<void>
settle: () => Promise<void> settle: () => Promise<void>
run: (prompt: RunPrompt, signal: AbortSignal, admitted: () => void) => 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.trace?.write("ui.commit", commit)
input.footer.append(commit) input.footer.append(commit)
} }
input.onSend?.(sent, "steer") input.onSend?.(sent, sent.delivery ?? "steer")
if (state.closed) { if (state.closed) {
break break
@@ -276,10 +276,11 @@ export async function runPromptQueue(input: QueueInput): Promise<void> {
const sent = { ...prompt, messageID: SessionMessage.ID.create() } const sent = { ...prompt, messageID: SessionMessage.ID.create() }
const admission = state.admission const admission = state.admission
admissionVersion += 1 admissionVersion += 1
input.onSend?.(sent, "queue") const delivery = prompt.delivery ?? "queue"
input.onSend?.(sent, delivery)
admissions = admissions admissions = admissions
.then(() => admission) .then(() => admission)
.then(() => input.admit(sent, admissionController.signal)) .then(() => input.admit(sent, delivery, admissionController.signal))
.catch((error) => (state.closed ? undefined : input.onAdmissionError?.(sent, error))) .catch((error) => (state.closed ? undefined : input.onAdmissionError?.(sent, error)))
return return
} }
+23 -11
View File
@@ -390,6 +390,15 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
log?.write("send.background", { sessionID: state.sessionID }) log?.write("send.background", { sessionID: state.sessionID })
void state.sdk.session.background({ sessionID: state.sessionID }).catch(() => {}) 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) => { onSubagentInterrupt: (sessionID) => {
log?.write("send.subagent.interrupt", { sessionID }) log?.write("send.subagent.interrupt", { sessionID })
void state.sdk.session.interrupt({ sessionID }).catch(() => {}) void state.sdk.session.interrupt({ sessionID }).catch(() => {})
@@ -892,7 +901,7 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
trace: log, trace: log,
onSend: (prompt, delivery) => { onSend: (prompt, delivery) => {
state.shown = true state.shown = true
state.history.push(prompt) state.history.push({ ...prompt, delivery: undefined })
if (prompt.mode !== "shell" && delivery === "steer") { if (prompt.mode !== "shell" && delivery === "steer") {
rememberLocal({ rememberLocal({
kind: "user", 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(() => {}) await state.switching?.catch(() => {})
const next = await ensureStream() const next = await ensureStream()
await next.handle.queuePromptTurn({ await next.handle.admitPromptTurn(
agent: state.agent, {
model: state.model, agent: state.agent,
variant: state.activeVariant, model: state.model,
prompt, variant: state.activeVariant,
files: input.files, prompt,
includeFiles: false, files: input.files,
signal, includeFiles: false,
}) signal,
},
delivery,
)
}, },
onAdmissionError: renderPromptError, onAdmissionError: renderPromptError,
onCompact: async () => { onCompact: async () => {
@@ -653,6 +653,10 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
} }
return return
} }
if (event.type === "session.input.cancelled") {
child.prompts.delete(event.data.inputID)
return
}
if (event.type === "session.step.started") { if (event.type === "session.step.started") {
touch(child, event.created) touch(child, event.created)
if (child.label === FALLBACK_LABEL && event.data.agent) child.label = Locale.titlecase(event.data.agent) if (child.label === FALLBACK_LABEL && event.data.agent) child.label = Locale.titlecase(event.data.agent)
+48 -7
View File
@@ -26,6 +26,7 @@ import type {
FooterQueuedPrompt, FooterQueuedPrompt,
RunFilePart, RunFilePart,
RunInput, RunInput,
RunDelivery,
RunPrompt, RunPrompt,
RunPromptPart, RunPromptPart,
StreamCommit, StreamCommit,
@@ -71,7 +72,7 @@ export type SessionResizeReplayInput = {
export type SessionTransport = { export type SessionTransport = {
runPromptTurn(input: SessionTurnInput, admitted?: () => void): Promise<void> runPromptTurn(input: SessionTurnInput, admitted?: () => void): Promise<void>
queuePromptTurn(input: SessionTurnInput): Promise<void> admitPromptTurn(input: SessionTurnInput, delivery: RunDelivery): Promise<void>
waitForIdle(): Promise<void> waitForIdle(): Promise<void>
interruptActiveTurn(): Promise<void> interruptActiveTurn(): Promise<void>
selectSubagent(sessionID: string | undefined): void selectSubagent(sessionID: string | undefined): void
@@ -515,8 +516,12 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
) )
} }
let syncedPending: string[] | undefined
const syncPending = () => { 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.trace?.write("ui.patch", { pending: prompts.length })
input.footer.event({ type: "queued.prompts", prompts }) input.footer.event({ type: "queued.prompts", prompts })
} }
@@ -934,6 +939,36 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
write([], { phase: "running", status: "waiting for assistant" }) write([], { phase: "running", status: "waiting for assistant" })
return 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") { if (event.type === "session.step.started") {
state.stepModel = { providerID: event.data.model.providerID, modelID: event.data.model.id } state.stepModel = { providerID: event.data.model.providerID, modelID: event.data.model.id }
write([], { phase: "running", status: "assistant responding" }) write([], { phase: "running", status: "assistant responding" })
@@ -1577,7 +1612,7 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
let queuedResizeReplay: SessionResizeReplayInput | undefined let queuedResizeReplay: SessionResizeReplayInput | undefined
let closing: Promise<void> | undefined let closing: Promise<void> | undefined
const admitPrompt = async (next: SessionTurnInput, client: OpenCodeClient, delivery: "steer" | "queue") => { const admitPrompt = async (next: SessionTurnInput, client: OpenCodeClient, delivery: RunDelivery) => {
const messageID = next.prompt.messageID const messageID = next.prompt.messageID
if (!messageID) throw new Error("Prompt message ID is required") if (!messageID) throw new Error("Prompt message ID is required")
const command = next.prompt.command const command = next.prompt.command
@@ -1643,14 +1678,20 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
} }
return { return {
async queuePromptTurn(next) { async admitPromptTurn(next, delivery) {
if (next.prompt.mode === "shell" || next.prompt.command?.source === "skill") if (next.prompt.mode === "shell" || next.prompt.command?.source === "skill")
throw new Error("This prompt cannot be queued") throw new Error("This prompt cannot be queued")
if (!state.connected) throw new Error("Event stream is reconnecting") if (!state.connected) throw new Error("Event stream is reconnecting")
const client = sdk const client = sdk
if (next.agent) if (next.agent)
await client.session.switchAgent({ sessionID: input.sessionID, agent: next.agent }, { signal: next.signal }) await client.session.switchAgent({ sessionID: input.sessionID, agent: next.agent }, { signal: next.signal })
mergePending(await admitPrompt(next, client, "queue")) if (!next.prompt.command) {
const selected = await resolveSelectedModel(input, client, next)
if (next.variant && !selected) throw new Error("Cannot select a variant before selecting a model")
if (selected)
await client.session.switchModel({ sessionID: input.sessionID, model: selected }, { signal: next.signal })
}
mergePending(await admitPrompt(next, client, delivery))
settlementClient = client settlementClient = client
}, },
async waitForIdle() { async waitForIdle() {
@@ -1688,7 +1729,7 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
return return
} }
if (command) { 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 return
} }
@@ -1700,7 +1741,7 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
if (selected) if (selected)
await client.session.switchModel({ sessionID: input.sessionID, model: selected }, { signal: next.signal }) 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() { async interruptActiveTurn() {
// A running shell holds no drain, so session.interrupt cannot reach it; // A running shell holds no drain, so session.interrupt cannot reach it;
+7 -1
View File
@@ -23,6 +23,7 @@ import type {
} from "@opencode-ai/client/promise" } from "@opencode-ai/client/promise"
import type { Config } from "../config" import type { Config } from "../config"
import type { CliRenderer } from "@opentui/core" import type { CliRenderer } from "@opentui/core"
import type { SessionPending } from "@opencode-ai/schema/session-pending"
export type RunFilePart = { export type RunFilePart = {
type: "file" type: "file"
@@ -71,10 +72,13 @@ export type RunProvider = {
models: Record<string, RunProviderModel> models: Record<string, RunProviderModel>
} }
export type RunDelivery = SessionPending.Delivery
export type RunPrompt = { export type RunPrompt = {
messageID?: string messageID?: string
text: string text: string
parts: RunPromptPart[] parts: RunPromptPart[]
delivery?: RunDelivery
mode?: "shell" mode?: "shell"
command?: { command?: {
name: string name: string
@@ -87,9 +91,11 @@ export type RunPrompt = {
export type FooterQueuedPrompt = { export type FooterQueuedPrompt = {
messageID: string messageID: string
prompt: RunPrompt prompt: RunPrompt
delivery: "steer" | "queue" delivery: RunDelivery
} }
export type QueuedPromptAction = "steer" | "cancel"
export type RunAgent = { export type RunAgent = {
id: string id: string
name: string name: string
+20
View File
@@ -67,6 +67,7 @@ export function FormPrompt(props: { form: FormWithLocation }) {
}) })
let textarea: TextareaRenderable | undefined let textarea: TextareaRenderable | undefined
let editingReady = false
let review: ScrollBoxRenderable | undefined let review: ScrollBoxRenderable | undefined
const message = createMemo(() => { const message = createMemo(() => {
@@ -175,6 +176,23 @@ export function FormPrompt(props: { form: FormWithLocation }) {
return answer === value return answer === value
}) })
onCleanup(
keymap.intercept("key", ({ event, consume }) => {
if (keymap.mode.current() !== FORM_MODE) return
if (textual() || !other() || (store.editing && editingReady)) return
if (event.ctrl || event.meta || event.option || event.super || event.hyper) return
if (!/^[^\p{C}\p{Zl}\p{Zp}]$/u.test(event.sequence)) return
const current = answerField()
if (!current) return
setStore("custom", { ...store.custom, [current.key]: input() + event.sequence })
if (!store.editing) {
editingReady = false
setStore("editing", true)
}
consume()
}),
)
function answer(key: string, value: FormValue | undefined) { function answer(key: string, value: FormValue | undefined) {
setStore("answers", { ...store.answers, [key]: value }) setStore("answers", { ...store.answers, [key]: value })
setStore("error", "") setStore("error", "")
@@ -870,8 +888,10 @@ export function FormPrompt(props: { form: FormWithLocation }) {
textarea = val textarea = val
val.traits = { status: "ANSWER" } val.traits = { status: "ANSWER" }
queueMicrotask(() => { queueMicrotask(() => {
val.setText(input())
val.focus() val.focus()
val.gotoLineEnd() val.gotoLineEnd()
editingReady = true
}) })
}} }}
initialValue={input()} initialValue={input()}
+130 -4
View File
@@ -52,6 +52,7 @@ import { useClient } from "../../context/client"
import { useEditorContext } from "../../context/editor" import { useEditorContext } from "../../context/editor"
import { openEditor } from "../../editor" import { openEditor } from "../../editor"
import { useDialog } from "../../ui/dialog" import { useDialog } from "../../ui/dialog"
import { DialogSelect } from "../../ui/dialog-select"
import { DialogSessionRename } from "../../component/dialog-session-rename" import { DialogSessionRename } from "../../component/dialog-session-rename"
import { DialogMessage } from "./dialog-message" import { DialogMessage } from "./dialog-message"
import { DialogFork } from "./dialog-fork" import { DialogFork } from "./dialog-fork"
@@ -97,6 +98,8 @@ import { stringWidth } from "../../util/string-width"
import { useArgs } from "../../context/args" import { useArgs } from "../../context/args"
import { withTimestampedFallback } from "@opencode-ai/util/session-title-fallback" import { withTimestampedFallback } from "@opencode-ai/util/session-title-fallback"
import { useSessionTabs } from "../../context/session-tabs" import { useSessionTabs } from "../../context/session-tabs"
import { createSingleFlight } from "../../util/single-flight"
import type { SessionPending } from "@opencode-ai/schema/session-pending"
addDefaultParsers(parsers.parsers) addDefaultParsers(parsers.parsers)
@@ -109,6 +112,7 @@ const NAVIGATION_SLACK_ID = "session-navigation-slack"
const TRANSCRIPT_TAIL_ROWS = 40 const TRANSCRIPT_TAIL_ROWS = 40
const TRANSCRIPT_BACKFILL_CHUNK = 60 const TRANSCRIPT_BACKFILL_CHUNK = 60
const TRANSCRIPT_BACKFILL_DELAY = 120 const TRANSCRIPT_BACKFILL_DELAY = 120
type PendingAction = "steer" | "queue" | "cancel"
const context = createContext<{ const context = createContext<{
width: number width: number
@@ -120,6 +124,8 @@ const context = createContext<{
diffWrapMode: () => "word" | "none" diffWrapMode: () => "word" | "none"
models: () => ModelInfo[] models: () => ModelInfo[]
config: ReturnType<typeof useConfig>["data"] config: ReturnType<typeof useConfig>["data"]
mutatePending: (action: PendingAction, inputID: string) => Promise<boolean>
pendingDelivery: (inputID: string) => SessionPending.Delivery | undefined
}>() }>()
function use() { function use() {
@@ -175,6 +181,13 @@ export function Session() {
.flatMap((sessionID) => data.session.form.list(sessionID) ?? []) .flatMap((sessionID) => data.session.form.list(sessionID) ?? [])
.concat(global) .concat(global)
}) })
const pendingUsers = createMemo(() =>
data.session.pending.list(route.sessionID).flatMap((item) => (item.type === "user" ? [item] : [])),
)
const pendingDeliveries = createMemo(() => new Map(pendingUsers().map((item) => [item.id, item.delivery])))
const queuedPrompts = createMemo(() =>
pendingUsers().flatMap((item) => (item.delivery === "queue" ? [{ id: item.id, text: item.data.text }] : [])),
)
const [composer, setComposer] = createStore({ const [composer, setComposer] = createStore({
open: false, open: false,
tab: undefined as string | undefined, tab: undefined as string | undefined,
@@ -369,6 +382,55 @@ export function Session() {
}) })
const dialog = useDialog() const dialog = useDialog()
const renderer = useRenderer() const renderer = useRenderer()
const runPendingAction = createSingleFlight<string>()
const mutatePending = async (action: PendingAction, inputID: string) => {
const result = await runPendingAction(inputID, async () => {
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,
)
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
})
return result ?? 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) => {
const last = queuedPrompts().length === 1
void mutatePending("cancel", option.value).then((cancelled) => {
if (cancelled && last) dialog.clear()
})
},
},
]}
footerHints={[{ title: "steer", label: "enter" }]}
/>
))
const unavailable = (feature: string) => { const unavailable = (feature: string) => {
toast.show({ message: `${feature} is not implemented for V2 sessions yet`, variant: "error", duration: 5000 }) toast.show({ message: `${feature} is not implemented for V2 sessions yet`, variant: "error", duration: 5000 })
dialog.clear() dialog.clear()
@@ -871,6 +933,13 @@ export function Session() {
dialog.clear() dialog.clear()
}, },
}, },
{
title: "View queued prompts",
id: "session.queued_prompts",
group: "Session",
enabled: queuedPrompts().length > 0,
run: openQueuedPrompts,
},
{ {
title: "Go to parent session", title: "Go to parent session",
id: "session.parent", id: "session.parent",
@@ -942,6 +1011,8 @@ export function Session() {
diffWrapMode, diffWrapMode,
models, models,
config, config,
mutatePending,
pendingDelivery: (inputID) => pendingDeliveries().get(inputID),
}} }}
> >
<box flexDirection="row" flexGrow={1} minHeight={0}> <box flexDirection="row" flexGrow={1} minHeight={0}>
@@ -997,6 +1068,9 @@ export function Session() {
</Show> </Show>
</scrollbox> </scrollbox>
<box flexShrink={0}> <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" /> <PluginSlot name="session.composer.top" input={{ sessionID: route.sessionID }} mode="all" />
<Composer <Composer
sessionID={route.sessionID} sessionID={route.sessionID}
@@ -1032,6 +1106,11 @@ export function Session() {
onSubmit={() => { onSubmit={() => {
toBottom() toBottom()
}} }}
onEmptySubmit={async () => {
const next = queuedPrompts()[0]
if (!next) return false
return mutatePending("steer", next.id)
}}
sessionID={route.sessionID} sessionID={route.sessionID}
/> />
</Match> </Match>
@@ -1813,6 +1892,7 @@ function ShellMessage(props: { message: Extract<SessionMessageInfo, { type: "she
return ( return (
<box <box
width="100%"
border={["left"]} border={["left"]}
paddingTop={1} paddingTop={1}
paddingBottom={1} paddingBottom={1}
@@ -1840,18 +1920,20 @@ function UserMessage(props: { message: SessionMessageUser }) {
const mode = themes.mode const mode = themes.mode
const [hover, setHover] = createSignal(false) const [hover, setHover] = createSignal(false)
const color = createMemo(() => local.agent.color(data.session.get(ctx.sessionID)?.agent ?? "build")) const color = createMemo(() => local.agent.color(data.session.get(ctx.sessionID)?.agent ?? "build"))
const queued = createMemo( const delivery = createMemo(() => ctx.pendingDelivery(props.message.id))
() => data.session.status(ctx.sessionID) === "running" && data.session.input.has(ctx.sessionID, props.message.id),
)
const dialog = useDialog() const dialog = useDialog()
const renderer = useRenderer() const renderer = useRenderer()
const promptRef = usePromptRef() const promptRef = usePromptRef()
const updatePendingSteer = async (action: "queue" | "cancel") => {
if (await ctx.mutatePending(action, props.message.id)) dialog.clear()
}
return ( return (
<Show when={props.message.text.trim() || files().length}> <Show when={props.message.text.trim() || files().length}>
<box <box
border={["left"]} border={["left"]}
borderColor={queued() ? theme.border.default : color()} borderColor={delivery() ? theme.border.default : color()}
customBorderChars={SplitBorder.customBorderChars} customBorderChars={SplitBorder.customBorderChars}
> >
<box <box
@@ -1863,6 +1945,21 @@ function UserMessage(props: { message: SessionMessageUser }) {
}} }}
onMouseUp={() => { onMouseUp={() => {
if (renderer.getSelection()?.getSelectedText()) return 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(() => ( dialog.replace(() => (
<DialogMessage <DialogMessage
messageID={props.message.id} messageID={props.message.id}
@@ -1910,6 +2007,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"] }) { function AssistantRetry(props: { retry: SessionMessageAssistant["retry"] }) {
const theme = useTheme() const theme = useTheme()
return ( return (
+15 -8
View File
@@ -46,9 +46,14 @@ export function createSessionRows(sessionID: Accessor<string>) {
function reduce() { function reduce() {
const messages = data.session.message.list(sessionID()) const messages = data.session.message.list(sessionID())
const inputs = new Set(data.session.input.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 boundary = revertBoundary()
const rows = reduceSessionRows( const rows = reduceSessionRows(
boundary ? messages.filter((message) => message.id < boundary) : messages, boundary ? visible.filter((message) => message.id < boundary) : visible,
inputs, inputs,
turnTokens(), turnTokens(),
) )
@@ -57,8 +62,7 @@ export function createSessionRows(sessionID: Accessor<string>) {
rows.splice( rows.splice(
position === -1 ? rows.length : position, position === -1 ? rows.length : position,
0, 0,
...data.session.pending ...pending
.list(sessionID())
.filter((item) => item.type === "compaction") .filter((item) => item.type === "compaction")
.map((item): SessionRow => ({ type: "compaction-queued", inputID: item.id })), .map((item): SessionRow => ({ type: "compaction-queued", inputID: item.id })),
) )
@@ -112,10 +116,11 @@ export function createSessionRows(sessionID: Accessor<string>) {
createEffect( createEffect(
on( on(
() => () =>
data.session.pending data.session.pending.list(sessionID()).flatMap((item) => {
.list(sessionID()) if (item.type === "compaction") return [`${item.id}:compaction`]
.filter((item) => item.type === "compaction") if (item.type === "user" && item.delivery === "queue") return [`${item.id}:queue`]
.map((item) => item.id), return []
}),
() => setRows(reconcile(reduce())), () => setRows(reconcile(reduce())),
{ defer: true }, { defer: true },
), ),
@@ -196,7 +201,9 @@ export function createSessionRows(sessionID: Accessor<string>) {
const queuedStart = (rows: SessionRow[]) => { const queuedStart = (rows: SessionRow[]) => {
const index = rows.findIndex( 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 return index === -1 ? rows.length : index
} }
+12
View File
@@ -0,0 +1,12 @@
export function createSingleFlight<Key>() {
const pending = new Set<Key>()
return async <Value>(key: Key, run: () => Promise<Value>) => {
if (pending.has(key)) return
pending.add(key)
try {
return await run()
} finally {
pending.delete(key)
}
}
}
+1
View File
@@ -22,6 +22,7 @@ export function primitiveInputSummary(input: Record<string, unknown>, omit: read
export function webSearchProviderLabel(provider: unknown) { export function webSearchProviderLabel(provider: unknown) {
if (provider === "parallel") return "Parallel Web Search" if (provider === "parallel") return "Parallel Web Search"
if (provider === "exa") return "Exa Web Search" if (provider === "exa") return "Exa Web Search"
if (provider === "firecrawl") return "Firecrawl Web Search"
return "Web Search" return "Web Search"
} }
+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 () => { test("classifies live tool rows independently of their call ID", async () => {
const events = createEventStream() const events = createEventStream()
const sessionID = "session-tool-call-id" const sessionID = "session-tool-call-id"
+33 -4
View File
@@ -15,7 +15,7 @@ import { TestTuiContexts } from "../../fixture/tui-environment"
import { createTuiResolvedConfig } from "../../fixture/tui-runtime" import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
import { createApi, createEventStream, createFetch } from "../../fixture/tui-client" import { createApi, createEventStream, createFetch } from "../../fixture/tui-client"
async function mountForm(root: string, width = 80) { async function mountForm(root: string, width = 80, input?: FormWithLocation) {
const state = path.join(root, "state") const state = path.join(root, "state")
await mkdir(state, { recursive: true }) await mkdir(state, { recursive: true })
@@ -33,7 +33,7 @@ async function mountForm(root: string, width = 80) {
events, events,
) )
const config = createTuiResolvedConfig() const config = createTuiResolvedConfig()
const form = { const form = input ?? ({
id: "frm_test", id: "frm_test",
sessionID: "ses_test", sessionID: "ses_test",
title: "Authorization required", title: "Authorization required",
@@ -45,7 +45,7 @@ async function mountForm(root: string, width = 80) {
title: "Authorize access", title: "Authorize access",
}, },
], ],
} satisfies FormWithLocation } satisfies FormWithLocation)
const { FormPrompt } = await import("../../../src/routes/session/form") const { FormPrompt } = await import("../../../src/routes/session/form")
function Harness() { function Harness() {
@@ -84,7 +84,7 @@ async function mountForm(root: string, width = 80) {
const app = await testRender(() => <Harness />, { width, height: 20, kittyKeyboard: true }) const app = await testRender(() => <Harness />, { width, height: 20, kittyKeyboard: true })
app.renderer.start() app.renderer.start()
await app.waitForFrame((frame) => frame.includes("Authorization required")) await app.waitForFrame((frame) => frame.includes(form.title))
return { app, copied, replies } return { app, copied, replies }
} }
@@ -126,3 +126,32 @@ test("includes external acknowledgements in progress", async () => {
prompt.app.renderer.destroy() prompt.app.renderer.destroy()
} }
}) })
test("typing starts a highlighted custom answer without losing the first character", async () => {
await using tmp = await tmpdir()
const prompt = await mountForm(tmp.path, 80, {
id: "frm_test",
sessionID: "ses_test",
title: "Choose a target",
fields: [
{
key: "target",
type: "string",
options: [{ value: "production", label: "Production" }],
custom: true,
},
],
})
try {
prompt.app.mockInput.pressKey("x")
await prompt.app.renderOnce()
expect(prompt.app.renderer.currentFocusedEditor).toBeNull()
prompt.app.mockInput.pressKey("j")
await prompt.app.mockInput.typeText("123")
await prompt.app.waitFor(() => prompt.app.renderer.currentFocusedEditor?.plainText === "123")
expect(prompt.app.renderer.currentFocusedEditor?.plainText).toBe("123")
} finally {
prompt.app.renderer.destroy()
}
})
+2 -2
View File
@@ -56,9 +56,9 @@ export function createFooterApiFixture(input: { events?: FooterEvent[]; commits?
commits, commits,
calls, calls,
promptReady, promptReady,
submit(text: string, mode?: RunPrompt["mode"]) { submit(text: string, mode?: RunPrompt["mode"], delivery?: RunPrompt["delivery"]) {
if (prompts.size === 0) return false 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) for (const fn of [...prompts]) fn(prompt)
return true return true
}, },
+151 -23
View File
@@ -21,6 +21,7 @@ import { RunFooterView } from "../../src/mini/footer.view"
import { RunEntryContent } from "../../src/mini/scrollback.writer" import { RunEntryContent } from "../../src/mini/scrollback.writer"
import { RUN_THEME_FALLBACK, type RunTheme } from "../../src/mini/theme" import { RUN_THEME_FALLBACK, type RunTheme } from "../../src/mini/theme"
import type { import type {
FooterQueuedPrompt,
FooterState, FooterState,
FooterSubagentState, FooterSubagentState,
FooterSubagentTab, FooterSubagentTab,
@@ -120,13 +121,15 @@ async function renderFooter(
height?: number height?: number
state?: Partial<FooterState> state?: Partial<FooterState>
onCycle?: () => void onCycle?: () => void
onSubmit?: (prompt: RunPrompt) => boolean onSubmit?: (prompt: RunPrompt) => boolean | Promise<boolean>
view?: FooterView view?: FooterView
onFormReply?: (input: unknown) => void onFormReply?: (input: unknown) => void
miniSettings?: MiniSettings miniSettings?: MiniSettings
mono?: boolean mono?: boolean
onStatus?: (status: string) => void onStatus?: (status: string) => void
onMiniSettingChange?: (change: MiniSettingChange) => void onMiniSettingChange?: (change: MiniSettingChange) => void
queuedPrompts?: FooterQueuedPrompt[]
onQueuedPromptAction?: (action: "steer" | "cancel", inputID: string) => Promise<void>
} = {}, } = {},
) { ) {
const [view, setView] = createSignal<FooterView>(input.view ?? { type: "prompt" }) const [view, setView] = createSignal<FooterView>(input.view ?? { type: "prompt" })
@@ -164,6 +167,7 @@ async function renderFooter(
state={state} state={state}
view={view} view={view}
subagent={subagents} subagent={subagents}
queuedPrompts={() => input.queuedPrompts ?? []}
theme={input.theme ?? (() => RUN_THEME_FALLBACK)} theme={input.theme ?? (() => RUN_THEME_FALLBACK)}
mono={input.mono ?? false} mono={input.mono ?? false}
miniSettings={miniSettings} miniSettings={miniSettings}
@@ -173,6 +177,7 @@ async function renderFooter(
onFormCancel={() => {}} onFormCancel={() => {}}
onCycle={input.onCycle ?? (() => {})} onCycle={input.onCycle ?? (() => {})}
onInterrupt={() => false} onInterrupt={() => false}
onQueuedPromptAction={input.onQueuedPromptAction}
onEditorOpen={async () => undefined} onEditorOpen={async () => undefined}
onInputClear={() => {}} onInputClear={() => {}}
onExit={() => {}} onExit={() => {}}
@@ -272,6 +277,37 @@ test("direct footer preserves a partial multi-field form draft across permission
} }
}) })
test("direct footer typing starts a highlighted custom answer without losing the first character", async () => {
const request: FormInfo = {
id: "frm_custom",
sessionID: "ses_child",
title: "Choose a target",
fields: [
{
key: "target",
type: "string",
options: [{ value: "production", label: "Production" }],
custom: true,
},
],
}
const app = await renderFooter({ height: 12, view: { type: "form", request } })
try {
await app.renderOnce()
app.mockInput.pressKey("x")
await app.renderOnce()
expect(app.renderer.currentFocusedEditor).toBeNull()
app.mockInput.pressKey("j")
await app.mockInput.typeText("hello")
await app.waitFor(() => app.renderer.currentFocusedEditor?.plainText === "hello")
expect(app.renderer.currentFocusedEditor?.plainText).toBe("hello")
} finally {
app.cleanup()
}
})
function expectPaletteList(list: BoxRenderable, selectedIndex: number) { function expectPaletteList(list: BoxRenderable, selectedIndex: number) {
expect(list.backgroundColor.toInts()).toEqual((RUN_THEME_FALLBACK.footer.shade as RGBA).toInts()) expect(list.backgroundColor.toInts()).toEqual((RUN_THEME_FALLBACK.footer.shade as RGBA).toInts())
expect((list.getChildren()[selectedIndex] as BoxRenderable).backgroundColor.toInts()).toEqual( expect((list.getChildren()[selectedIndex] as BoxRenderable).backgroundColor.toInts()).toEqual(
@@ -913,7 +949,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([ const [prompts] = createSignal([
{ {
messageID: "m-1", messageID: "m-1",
@@ -921,16 +957,22 @@ test("direct pending panel shows durable delivery without edit actions", async (
delivery: "queue" as const, delivery: "queue" as const,
}, },
]) ])
const steered: string[] = []
const deleted: string[] = []
const app = await testRender( const app = await testRender(
() => ( () => (
<box width={100} height={RUN_SUBAGENT_PANEL_ROWS}> <Keymap.Provider config={tuiConfig}>
<RunQueuedPromptSelectBody <box width={100} height={RUN_SUBAGENT_PANEL_ROWS}>
theme={() => RUN_THEME_FALLBACK.footer} <RunQueuedPromptSelectBody
prompts={prompts} theme={() => RUN_THEME_FALLBACK.footer}
onClose={() => {}} prompts={prompts}
/> onClose={() => {}}
</box> onSteer={(prompt) => steered.push(prompt.messageID)}
onDelete={(prompt) => deleted.push(prompt.messageID)}
/>
</box>
</Keymap.Provider>
), ),
{ width: 100, height: RUN_SUBAGENT_PANEL_ROWS }, { width: 100, height: RUN_SUBAGENT_PANEL_ROWS },
) )
@@ -940,19 +982,98 @@ test("direct pending panel shows durable delivery without edit actions", async (
const frame = app.captureCharFrame() const frame = app.captureCharFrame()
const list = panelMenu(app.renderer.root) 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("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("┌")
expect(frame).not.toContain("┃") expect(frame).not.toContain("┃")
expectPaletteList(list, 0) expectPaletteList(list, 0)
expect(frame).not.toContain("edit") app.mockInput.pressEnter()
expect(frame).not.toContain("remove") app.mockInput.pressKey("d", { ctrl: true })
expect(steered).toEqual(["m-1"])
expect(deleted).toEqual(["m-1"])
} finally { } finally {
app.renderer.destroy() 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()
}
})
test("direct footer rejects local commands submitted with the queue shortcut", async () => {
const submitted: RunPrompt[] = []
const statuses: string[] = []
const app = await renderFooter({
onSubmit: (prompt) => {
submitted.push(prompt)
return true
},
onStatus: (status) => statuses.push(status),
})
try {
await app.renderOnce()
await app.mockInput.typeText("/settings ")
app.mockInput.pressEnter({ meta: true })
await Bun.sleep(0)
expect(submitted).toEqual([])
expect(statuses).toContain("this prompt cannot be queued")
} finally {
app.cleanup()
}
})
// OpenTUI currently crashes Bun in the full `test/cli/run` directory run here. // OpenTUI currently crashes Bun in the full `test/cli/run` directory run here.
// Re-enable after the upstream OpenTUI fix lands in this repo. // Re-enable after the upstream OpenTUI fix lands in this repo.
test.skip("direct footer recreates the frame across command panel transitions", async () => { test.skip("direct footer recreates the frame across command panel transitions", async () => {
@@ -1068,11 +1189,11 @@ test("direct footer submits slash autocomplete selections without dispatching sh
await app.renderOnce() await app.renderOnce()
expect(submits).toEqual([ expect(submits).toEqual([
{ text: "/review ", parts: [], command: { name: "review", arguments: "" } }, { text: "/review ", parts: [], command: { name: "review", arguments: "" }, delivery: "steer" },
{ text: "/review ", parts: [], command: { name: "review", arguments: "" } }, { text: "/review ", parts: [], command: { name: "review", arguments: "" }, delivery: "steer" },
{ text: "/review branch", parts: [], command: { name: "review", arguments: "branch" } }, { text: "/review branch", parts: [], command: { name: "review", arguments: "branch" }, delivery: "steer" },
{ text: "/new ", parts: [] }, { text: "/new ", parts: [], delivery: "steer" },
{ text: "/new ", parts: [] }, { text: "/new ", parts: [], delivery: "steer" },
]) ])
expect(app.renderer.currentFocusedEditor?.plainText).toBe("/settings ") expect(app.renderer.currentFocusedEditor?.plainText).toBe("/settings ")
} finally { } finally {
@@ -1100,7 +1221,9 @@ test("direct footer slash autocomplete keeps a real skills command", async () =>
app.mockInput.pressEnter() app.mockInput.pressEnter()
await app.renderOnce() 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") expect(app.captureCharFrame()).not.toContain("Apply formatter fixes")
} finally { } finally {
app.cleanup() app.cleanup()
@@ -1158,7 +1281,12 @@ test("direct footer tags skill slash submissions with their catalog source", asy
await app.renderOnce() await app.renderOnce()
expect(submits).toEqual([ 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 { } finally {
app.cleanup() app.cleanup()
@@ -1238,7 +1366,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>({ const [state] = createSignal<FooterState>({
phase: "running", phase: "running",
status: "", status: "",
@@ -1342,9 +1470,9 @@ test("direct footer shows authoritative pending work while running", async () =>
const hint = statusItems.at(-1)! const hint = statusItems.at(-1)!
expect(spinner).toBeDefined() expect(spinner).toBeDefined()
expect(frame).toContain("1 pending") expect(frame).toContain("1 queued")
expect(frame).toContain("ctrl+b background") 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("↓ subagents")
expect(frame).toContain("ctrl+p cmd") expect(frame).toContain("ctrl+p cmd")
expect(frame).toContain("subagents · 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.history.next")?.[0]?.key).toBe("down")
expect(result.keybinds.get("prompt.clear")?.[0]?.key).toBe("ctrl+c") 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.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 () => { test("preserves disabled leader from resolved tui config", async () => {
+28 -1
View File
@@ -265,6 +265,33 @@ describe("run runtime queue", () => {
await task 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 () => { test("continues durable admission after one fails", async () => {
const ui = createFooterApiFixture() const ui = createFooterApiFixture()
const admitted: string[] = [] const admitted: string[] = []
@@ -308,7 +335,7 @@ describe("run runtime queue", () => {
admitted() admitted()
await new Promise<void>((resolve) => signal.addEventListener("abort", () => resolve(), { once: true })) await new Promise<void>((resolve) => signal.addEventListener("abort", () => resolve(), { once: true }))
}, },
admit: async (_prompt, signal) => { admit: async (_prompt, _delivery, signal) => {
admissionStarted.resolve() admissionStarted.resolve()
await new Promise<void>((resolve) => { await new Promise<void>((resolve) => {
if (signal.aborted) { if (signal.aborted) {
+3 -3
View File
@@ -126,7 +126,7 @@ describe("run interactive runtime", () => {
turnStarted.resolve() turnStarted.resolve()
api.close() api.close()
}, },
queuePromptTurn: async () => {}, admitPromptTurn: async () => {},
waitForIdle: async () => {}, waitForIdle: async () => {},
interruptActiveTurn: async () => {}, interruptActiveTurn: async () => {},
selectSubagent: () => {}, selectSubagent: () => {},
@@ -209,7 +209,7 @@ describe("run interactive runtime", () => {
streamStarted.resolve() streamStarted.resolve()
return { return {
runPromptTurn: async () => {}, runPromptTurn: async () => {},
queuePromptTurn: async () => {}, admitPromptTurn: async () => {},
waitForIdle: async () => {}, waitForIdle: async () => {},
interruptActiveTurn: async () => {}, interruptActiveTurn: async () => {},
selectSubagent: () => {}, selectSubagent: () => {},
@@ -556,7 +556,7 @@ describe("run interactive runtime", () => {
setTimeout(() => input.footer.close(), 0) setTimeout(() => input.footer.close(), 0)
return { return {
runPromptTurn: async () => {}, runPromptTurn: async () => {},
queuePromptTurn: async () => {}, admitPromptTurn: async () => {},
waitForIdle: async () => {}, waitForIdle: async () => {},
interruptActiveTurn: async () => {}, interruptActiveTurn: async () => {},
selectSubagent: () => {}, selectSubagent: () => {},
@@ -669,6 +669,14 @@ describe("V2 mini transport", () => {
data: { text: "follow up" }, data: { text: "follow up" },
delivery: "queue", 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") .findLast((item) => item.type === "queued.prompts")
?.prompts.map((item) => [item.messageID, item.delivery]) ?.prompts.map((item) => [item.messageID, item.delivery])
expect(pending()).toEqual([["msg_queued", "queue"]]) expect(pending()).toEqual([
["msg_queued", "queue"],
["msg_cancelled", "queue"],
])
events.push({ events.push({
id: "evt_promoted", id: "evt_steered",
created: 2, created: 3,
type: "session.input.promoted", type: "session.input.steered",
durable: durable("ses_1", 2), durable: durable("ses_1", 2),
data: { sessionID: "ses_1", inputID: "msg_queued" }, data: { sessionID: "ses_1", inputID: "msg_queued" },
}) })
@@ -697,19 +708,53 @@ describe("V2 mini transport", () => {
expect(ui.commits).toContainEqual( expect(ui.commits).toContainEqual(
expect.objectContaining({ kind: "user", messageID: "msg_queued", text: "follow up" }), 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( const prompt = spyOn(client.session, "prompt").mockImplementation(
(request) => ok(promptAdmission(request)) as never, (request) => ok(promptAdmission(request)) as never,
) )
await transport.queuePromptTurn({ await transport.admitPromptTurn({
agent: "review", agent: "review",
model: undefined, model: { providerID: "test", modelID: "next" },
variant: undefined, variant: "high",
prompt: { messageID: "msg_next", text: "another", parts: [] }, prompt: { messageID: "msg_next", text: "another", parts: [] },
files: [], files: [],
includeFiles: false, includeFiles: false,
}) }, "queue")
expect(client.session.switchAgent).toHaveBeenCalledWith({ sessionID: "ses_1", agent: "review" }, expect.anything()) expect(client.session.switchAgent).toHaveBeenCalledWith({ sessionID: "ses_1", agent: "review" }, expect.anything())
expect(client.session.switchModel).toHaveBeenCalledWith(
{ sessionID: "ses_1", model: { providerID: "test", id: "next", variant: "high" } },
expect.anything(),
)
expect(prompt).toHaveBeenCalledWith(expect.objectContaining({ delivery: "queue" }), expect.anything()) expect(prompt).toHaveBeenCalledWith(expect.objectContaining({ delivery: "queue" }), expect.anything())
events.push({ events.push({
id: "evt_earlier_admission", id: "evt_earlier_admission",
@@ -722,15 +767,8 @@ describe("V2 mini transport", () => {
input: { type: "user", data: { text: "earlier" }, delivery: "steer" }, input: { type: "user", data: { text: "earlier" }, delivery: "steer" },
}, },
}) })
while (true) { await Bun.sleep(10)
const pending = ui.events.findLast((item) => item.type === "queued.prompts") expect(pending()).toEqual([["msg_next", "queue"]])
if (pending?.type === "queued.prompts" && pending.prompts.length >= 2) break
await Bun.sleep(0)
}
expect(pending()).toEqual([
["msg_next", "queue"],
["msg_earlier", "steer"],
])
await transport.close() await transport.close()
}) })
@@ -813,14 +851,14 @@ describe("V2 mini transport", () => {
durable: durable("ses_1", 2), durable: durable("ses_1", 2),
data: { sessionID: "ses_1", inputID: "msg_prompt" }, data: { sessionID: "ses_1", inputID: "msg_prompt" },
}) })
await transport.queuePromptTurn({ await transport.admitPromptTurn({
agent: undefined, agent: undefined,
model: undefined, model: undefined,
variant: undefined, variant: undefined,
prompt: { messageID: "msg_queued", text: "follow up", parts: [] }, prompt: { messageID: "msg_queued", text: "follow up", parts: [] },
files: [], files: [],
includeFiles: false, includeFiles: false,
}) }, "queue")
events.push({ events.push({
id: "evt_queued_promoted", id: "evt_queued_promoted",
created: 3, created: 3,