mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-07 09:39:46 -04:00
Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4e46277b8c | |||
| 10c4154eb9 | |||
| 80f854b70f | |||
| 1959800fce | |||
| d46d591adc | |||
| 5ca0914560 | |||
| 3af5c2b3b9 | |||
| 63fcf63324 | |||
| 41681a9915 | |||
| cc877b0a25 | |||
| e6c9b6bef7 | |||
| 2092350cfa | |||
| 5fb0d7c99c |
@@ -153,4 +153,88 @@ describe("v2 session reducer", () => {
|
||||
|
||||
expect(result).toMatchObject({ sessionID: "ses_1", missing: "msg_user", touched: [] })
|
||||
})
|
||||
|
||||
test("removes cancelled input from the pending promotion fold", () => {
|
||||
const reducer = createV2SessionReducer()
|
||||
reducer.reduce(
|
||||
[],
|
||||
event({
|
||||
...base,
|
||||
id: "evt_admitted",
|
||||
type: "session.input.admitted",
|
||||
data: {
|
||||
sessionID: "ses_1",
|
||||
inputID: "msg_user",
|
||||
input: { type: "user", delivery: "queue", data: { text: "cancel me" } },
|
||||
},
|
||||
}),
|
||||
)
|
||||
reducer.reduce(
|
||||
[],
|
||||
event({
|
||||
...base,
|
||||
id: "evt_cancelled",
|
||||
type: "session.input.cancelled",
|
||||
data: { sessionID: "ses_1", inputID: "msg_user" },
|
||||
}),
|
||||
)
|
||||
const result = reducer.reduce(
|
||||
[],
|
||||
event({
|
||||
...base,
|
||||
id: "evt_promoted",
|
||||
type: "session.input.promoted",
|
||||
data: { sessionID: "ses_1", inputID: "msg_user" },
|
||||
}),
|
||||
)
|
||||
|
||||
expect(result).toMatchObject({ missing: "msg_user" })
|
||||
})
|
||||
|
||||
test("keeps steered input available to the promotion fold", () => {
|
||||
const reducer = createV2SessionReducer()
|
||||
reducer.reduce(
|
||||
[],
|
||||
event({
|
||||
...base,
|
||||
id: "evt_admitted",
|
||||
type: "session.input.admitted",
|
||||
data: {
|
||||
sessionID: "ses_1",
|
||||
inputID: "msg_user",
|
||||
input: { type: "user", delivery: "queue", data: { text: "steer me" } },
|
||||
},
|
||||
}),
|
||||
)
|
||||
reducer.reduce(
|
||||
[],
|
||||
event({
|
||||
...base,
|
||||
id: "evt_steered",
|
||||
type: "session.input.steered",
|
||||
data: { sessionID: "ses_1", inputID: "msg_user" },
|
||||
}),
|
||||
)
|
||||
reducer.reduce(
|
||||
[],
|
||||
event({
|
||||
...base,
|
||||
id: "evt_queued",
|
||||
type: "session.input.queued",
|
||||
data: { sessionID: "ses_1", inputID: "msg_user" },
|
||||
}),
|
||||
)
|
||||
|
||||
const result = reducer.reduce(
|
||||
[],
|
||||
event({
|
||||
...base,
|
||||
id: "evt_promoted",
|
||||
type: "session.input.promoted",
|
||||
data: { sessionID: "ses_1", inputID: "msg_user" },
|
||||
}),
|
||||
)
|
||||
|
||||
expect(result?.messages).toMatchObject([{ id: "msg_user", type: "user", text: "steer me" }])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -29,6 +29,9 @@ export function createV2SessionReducer() {
|
||||
case "session.input.admitted":
|
||||
pending.set(key(sessionID, event.data.inputID), event.data.input)
|
||||
return result([...source])
|
||||
case "session.input.cancelled":
|
||||
pending.delete(key(sessionID, event.data.inputID))
|
||||
return
|
||||
case "session.input.promoted": {
|
||||
const input = pending.get(key(sessionID, event.data.inputID))
|
||||
pending.delete(key(sessionID, event.data.inputID))
|
||||
|
||||
@@ -50,7 +50,7 @@ const login = Effect.fn("cli.console.login.run")(function* (timeline: TimelineHo
|
||||
{
|
||||
integrationID,
|
||||
methodID: method.id,
|
||||
answers: server ? { server } : {},
|
||||
inputs: server ? { server } : {},
|
||||
location,
|
||||
},
|
||||
{ signal },
|
||||
|
||||
@@ -30,11 +30,9 @@ export default Runtime.handler(
|
||||
)
|
||||
if (!method)
|
||||
return yield* Effect.fail(new Error(`MCP server "${input.name}" is not an OAuth-capable remote server`))
|
||||
if (method.forms)
|
||||
return yield* Effect.fail(new Error(`MCP server "${input.name}" requires an interactive authentication form`))
|
||||
|
||||
const started = yield* Effect.promise(() =>
|
||||
client.integration.oauth.connect({ integrationID: integration.id, methodID: method.id, answers: {}, location }),
|
||||
client.integration.oauth.connect({ integrationID: integration.id, methodID: method.id, inputs: {}, location }),
|
||||
)
|
||||
const attempt = started.data
|
||||
if (attempt.mode === "code")
|
||||
|
||||
@@ -23,9 +23,9 @@ import type { Shell } from "@opencode-ai/schema/shell"
|
||||
import type { DateTime } from "effect"
|
||||
import type { Provider } from "@opencode-ai/schema/provider"
|
||||
import type { Integration } from "@opencode-ai/schema/integration"
|
||||
import type { Form } from "@opencode-ai/schema/form"
|
||||
import type { Mcp } from "@opencode-ai/schema/mcp"
|
||||
import type { Credential } from "@opencode-ai/schema/credential"
|
||||
import type { Form } from "@opencode-ai/schema/form"
|
||||
import type { Permission } from "@opencode-ai/schema/permission"
|
||||
import type { PermissionSaved } from "@opencode-ai/schema/permission-saved"
|
||||
import type { FileSystem } from "@opencode-ai/schema/filesystem"
|
||||
@@ -263,38 +263,52 @@ export type Endpoint5_23Input = { readonly sessionID: Session.ID }
|
||||
export type Endpoint5_23Output = ReadonlyArray<SessionPending.Info>
|
||||
export type SessionPendingListOperation<E = never> = (input: Endpoint5_23Input) => Effect.Effect<Endpoint5_23Output, E>
|
||||
|
||||
export type Endpoint5_24Input = { readonly sessionID: Session.ID }
|
||||
export type Endpoint5_24Output = ReadonlyArray<InstructionEntry.Info>
|
||||
export type SessionInstructionsEntryListOperation<E = never> = (
|
||||
export type Endpoint5_24Input = { readonly sessionID: Session.ID; readonly inputID: SessionMessage.ID }
|
||||
export type Endpoint5_24Output = void
|
||||
export type SessionPendingCancelOperation<E = never> = (
|
||||
input: Endpoint5_24Input,
|
||||
) => 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 key: InstructionEntry.Key
|
||||
readonly value: Schema.Json
|
||||
}
|
||||
export type Endpoint5_25Output = void
|
||||
export type Endpoint5_28Output = void
|
||||
export type SessionInstructionsEntryPutOperation<E = never> = (
|
||||
input: Endpoint5_25Input,
|
||||
) => Effect.Effect<Endpoint5_25Output, E>
|
||||
input: Endpoint5_28Input,
|
||||
) => Effect.Effect<Endpoint5_28Output, E>
|
||||
|
||||
export type Endpoint5_26Input = { readonly sessionID: Session.ID; readonly key: InstructionEntry.Key }
|
||||
export type Endpoint5_26Output = void
|
||||
export type Endpoint5_29Input = { readonly sessionID: Session.ID; readonly key: InstructionEntry.Key }
|
||||
export type Endpoint5_29Output = void
|
||||
export type SessionInstructionsEntryRemoveOperation<E = never> = (
|
||||
input: Endpoint5_26Input,
|
||||
) => Effect.Effect<Endpoint5_26Output, E>
|
||||
input: Endpoint5_29Input,
|
||||
) => Effect.Effect<Endpoint5_29Output, E>
|
||||
|
||||
export type Endpoint5_27Input = { readonly sessionID: Session.ID; readonly prompt: string }
|
||||
export type Endpoint5_27Output = { readonly text: string }
|
||||
export type SessionGenerateOperation<E = never> = (input: Endpoint5_27Input) => Effect.Effect<Endpoint5_27Output, E>
|
||||
export type Endpoint5_30Input = { readonly sessionID: Session.ID; readonly prompt: string }
|
||||
export type Endpoint5_30Output = { readonly text: string }
|
||||
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 after?: Event.Seq | undefined
|
||||
readonly follow?: boolean | undefined
|
||||
}
|
||||
export type Endpoint5_28Output =
|
||||
export type Endpoint5_31Output =
|
||||
| (
|
||||
| {
|
||||
readonly id: Event.ID
|
||||
@@ -404,6 +418,33 @@ export type Endpoint5_28Output =
|
||||
readonly input: SessionPending.Message
|
||||
}
|
||||
}
|
||||
| {
|
||||
readonly id: Event.ID
|
||||
readonly created: DateTime.Utc
|
||||
readonly metadata?: { readonly [x: string]: unknown } | undefined
|
||||
readonly type: "session.input.cancelled"
|
||||
readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
|
||||
readonly location?: Location.Ref | undefined
|
||||
readonly data: { readonly sessionID: Session.ID; readonly inputID: SessionMessage.ID }
|
||||
}
|
||||
| {
|
||||
readonly id: Event.ID
|
||||
readonly created: DateTime.Utc
|
||||
readonly metadata?: { readonly [x: string]: unknown } | undefined
|
||||
readonly type: "session.input.steered"
|
||||
readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
|
||||
readonly location?: Location.Ref | undefined
|
||||
readonly data: { readonly sessionID: Session.ID; readonly inputID: SessionMessage.ID }
|
||||
}
|
||||
| {
|
||||
readonly id: Event.ID
|
||||
readonly created: DateTime.Utc
|
||||
readonly metadata?: { readonly [x: string]: unknown } | undefined
|
||||
readonly type: "session.input.queued"
|
||||
readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
|
||||
readonly location?: Location.Ref | undefined
|
||||
readonly data: { readonly sessionID: Session.ID; readonly inputID: SessionMessage.ID }
|
||||
}
|
||||
| {
|
||||
readonly id: Event.ID
|
||||
readonly created: DateTime.Utc
|
||||
@@ -862,19 +903,19 @@ export type Endpoint5_28Output =
|
||||
}
|
||||
)
|
||||
| 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_29Output = void
|
||||
export type SessionInterruptOperation<E = never> = (input: Endpoint5_29Input) => Effect.Effect<Endpoint5_29Output, E>
|
||||
export type Endpoint5_32Input = { readonly sessionID: Session.ID }
|
||||
export type Endpoint5_32Output = void
|
||||
export type SessionInterruptOperation<E = never> = (input: Endpoint5_32Input) => Effect.Effect<Endpoint5_32Output, E>
|
||||
|
||||
export type Endpoint5_30Input = { readonly sessionID: Session.ID }
|
||||
export type Endpoint5_30Output = void
|
||||
export type SessionBackgroundOperation<E = never> = (input: Endpoint5_30Input) => Effect.Effect<Endpoint5_30Output, E>
|
||||
export type Endpoint5_33Input = { readonly sessionID: Session.ID }
|
||||
export type Endpoint5_33Output = void
|
||||
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_31Output = SessionMessage.Info
|
||||
export type SessionMessageOperation<E = never> = (input: Endpoint5_31Input) => Effect.Effect<Endpoint5_31Output, E>
|
||||
export type Endpoint5_34Input = { readonly sessionID: Session.ID; readonly messageID: SessionMessage.ID }
|
||||
export type Endpoint5_34Output = SessionMessage.Info
|
||||
export type SessionMessageOperation<E = never> = (input: Endpoint5_34Input) => Effect.Effect<Endpoint5_34Output, E>
|
||||
|
||||
export interface SessionApi<E = never> {
|
||||
readonly list: SessionListOperation<E>
|
||||
@@ -902,7 +943,12 @@ export interface SessionApi<E = never> {
|
||||
readonly commit: SessionRevertCommitOperation<E>
|
||||
}
|
||||
readonly context: SessionContextOperation<E>
|
||||
readonly pending: { readonly list: SessionPendingListOperation<E> }
|
||||
readonly pending: {
|
||||
readonly list: SessionPendingListOperation<E>
|
||||
readonly cancel: SessionPendingCancelOperation<E>
|
||||
readonly steer: SessionPendingSteerOperation<E>
|
||||
readonly queue: SessionPendingQueueOperation<E>
|
||||
}
|
||||
readonly instructions: {
|
||||
readonly entry: {
|
||||
readonly list: SessionInstructionsEntryListOperation<E>
|
||||
@@ -1006,7 +1052,6 @@ export type Endpoint10_3Input = {
|
||||
readonly integrationID: Integration.ID
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
readonly key: string
|
||||
readonly answers: Form.Answer
|
||||
readonly label?: string | undefined
|
||||
}
|
||||
export type Endpoint10_3Output = void
|
||||
@@ -1018,7 +1063,7 @@ export type Endpoint10_4Input = {
|
||||
readonly integrationID: Integration.ID
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
readonly methodID: Integration.MethodID
|
||||
readonly answers: Form.Answer
|
||||
readonly inputs: { readonly [x: string]: string }
|
||||
readonly label?: string | undefined
|
||||
}
|
||||
export type Endpoint10_4Output = { readonly location: Location.Info; readonly data: Integration.Attempt }
|
||||
|
||||
@@ -80,6 +80,12 @@ import type {
|
||||
Endpoint5_30Output,
|
||||
Endpoint5_31Input,
|
||||
Endpoint5_31Output,
|
||||
Endpoint5_32Input,
|
||||
Endpoint5_32Output,
|
||||
Endpoint5_33Input,
|
||||
Endpoint5_33Output,
|
||||
Endpoint5_34Input,
|
||||
Endpoint5_34Output,
|
||||
Endpoint6_0Input,
|
||||
Endpoint6_0Output,
|
||||
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) =>
|
||||
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.map((value) => value.data),
|
||||
),
|
||||
)
|
||||
|
||||
const Endpoint5_25 = (raw: RawClient["server.session"]) => (input: Endpoint5_25Input) =>
|
||||
preserveEffect<Endpoint5_25Output>()(
|
||||
raw["session.instructions.entry.put"]({
|
||||
params: { sessionID: input["sessionID"], key: input["key"] },
|
||||
payload: { value: input["value"] },
|
||||
}).pipe(Effect.mapError(mapClientError)),
|
||||
raw["session.pending.steer"]({ params: { sessionID: input["sessionID"], inputID: input["inputID"] } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
),
|
||||
)
|
||||
|
||||
const Endpoint5_26 = (raw: RawClient["server.session"]) => (input: Endpoint5_26Input) =>
|
||||
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),
|
||||
),
|
||||
)
|
||||
|
||||
const Endpoint5_27 = (raw: RawClient["server.session"]) => (input: Endpoint5_27Input) =>
|
||||
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.map((value) => value.data),
|
||||
),
|
||||
)
|
||||
|
||||
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(
|
||||
raw["session.log"]({
|
||||
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) =>
|
||||
preserveEffect<Endpoint5_29Output>()(
|
||||
const Endpoint5_32 = (raw: RawClient["server.session"]) => (input: Endpoint5_32Input) =>
|
||||
preserveEffect<Endpoint5_32Output>()(
|
||||
raw["session.interrupt"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const Endpoint5_30 = (raw: RawClient["server.session"]) => (input: Endpoint5_30Input) =>
|
||||
preserveEffect<Endpoint5_30Output>()(
|
||||
const Endpoint5_33 = (raw: RawClient["server.session"]) => (input: Endpoint5_33Input) =>
|
||||
preserveEffect<Endpoint5_33Output>()(
|
||||
raw["session.background"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const Endpoint5_31 = (raw: RawClient["server.session"]) => (input: Endpoint5_31Input) =>
|
||||
preserveEffect<Endpoint5_31Output>()(
|
||||
const Endpoint5_34 = (raw: RawClient["server.session"]) => (input: Endpoint5_34Input) =>
|
||||
preserveEffect<Endpoint5_34Output>()(
|
||||
raw["session.message"]({ params: { sessionID: input["sessionID"], messageID: input["messageID"] } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
Effect.map((value) => value.data),
|
||||
@@ -605,13 +632,13 @@ const adaptGroup5 = (raw: RawClient["server.session"]) => ({
|
||||
wait: Endpoint5_18(raw),
|
||||
revert: { stage: Endpoint5_19(raw), clear: Endpoint5_20(raw), commit: Endpoint5_21(raw) },
|
||||
context: Endpoint5_22(raw),
|
||||
pending: { list: Endpoint5_23(raw) },
|
||||
instructions: { entry: { list: Endpoint5_24(raw), put: Endpoint5_25(raw), remove: Endpoint5_26(raw) } },
|
||||
generate: Endpoint5_27(raw),
|
||||
log: Endpoint5_28(raw),
|
||||
interrupt: Endpoint5_29(raw),
|
||||
background: Endpoint5_30(raw),
|
||||
message: Endpoint5_31(raw),
|
||||
pending: { list: Endpoint5_23(raw), cancel: Endpoint5_24(raw), steer: Endpoint5_25(raw), queue: Endpoint5_26(raw) },
|
||||
instructions: { entry: { list: Endpoint5_27(raw), put: Endpoint5_28(raw), remove: Endpoint5_29(raw) } },
|
||||
generate: Endpoint5_30(raw),
|
||||
log: Endpoint5_31(raw),
|
||||
interrupt: Endpoint5_32(raw),
|
||||
background: Endpoint5_33(raw),
|
||||
message: Endpoint5_34(raw),
|
||||
})
|
||||
|
||||
const Endpoint6_0 = (raw: RawClient["server.message"]) => (input: Endpoint6_0Input) =>
|
||||
@@ -688,7 +715,7 @@ const Endpoint10_3 = (raw: RawClient["server.integration"]) => (input: Endpoint1
|
||||
raw["integration.connect.key"]({
|
||||
params: { integrationID: input["integrationID"] },
|
||||
query: { location: input["location"] },
|
||||
payload: { key: input["key"], answers: input["answers"], label: input["label"] },
|
||||
payload: { key: input["key"], label: input["label"] },
|
||||
}).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
@@ -697,7 +724,7 @@ const Endpoint10_4 = (raw: RawClient["server.integration"]) => (input: Endpoint1
|
||||
raw["integration.oauth.connect"]({
|
||||
params: { integrationID: input["integrationID"] },
|
||||
query: { location: input["location"] },
|
||||
payload: { methodID: input["methodID"], answers: input["answers"], label: input["label"] },
|
||||
payload: { methodID: input["methodID"], inputs: input["inputs"], label: input["label"] },
|
||||
}).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
|
||||
@@ -58,6 +58,12 @@ import type {
|
||||
SessionContextOutput,
|
||||
SessionPendingListInput,
|
||||
SessionPendingListOutput,
|
||||
SessionPendingCancelInput,
|
||||
SessionPendingCancelOutput,
|
||||
SessionPendingSteerInput,
|
||||
SessionPendingSteerOutput,
|
||||
SessionPendingQueueInput,
|
||||
SessionPendingQueueOutput,
|
||||
SessionInstructionsEntryListInput,
|
||||
SessionInstructionsEntryListOutput,
|
||||
SessionInstructionsEntryPutInput,
|
||||
@@ -766,6 +772,39 @@ export function make(options: ClientOptions) {
|
||||
},
|
||||
requestOptions,
|
||||
).then((value) => value.data),
|
||||
cancel: (input: SessionPendingCancelInput, requestOptions?: RequestOptions) =>
|
||||
request<SessionPendingCancelOutput>(
|
||||
{
|
||||
method: "DELETE",
|
||||
path: `/api/session/${encodeURIComponent(input.sessionID)}/pending/${encodeURIComponent(input.inputID)}`,
|
||||
successStatus: 204,
|
||||
declaredStatuses: [409, 404, 401, 400],
|
||||
empty: true,
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
steer: (input: SessionPendingSteerInput, requestOptions?: RequestOptions) =>
|
||||
request<SessionPendingSteerOutput>(
|
||||
{
|
||||
method: "POST",
|
||||
path: `/api/session/${encodeURIComponent(input.sessionID)}/pending/${encodeURIComponent(input.inputID)}/steer`,
|
||||
successStatus: 204,
|
||||
declaredStatuses: [409, 404, 401, 400],
|
||||
empty: true,
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
queue: (input: SessionPendingQueueInput, requestOptions?: RequestOptions) =>
|
||||
request<SessionPendingQueueOutput>(
|
||||
{
|
||||
method: "POST",
|
||||
path: `/api/session/${encodeURIComponent(input.sessionID)}/pending/${encodeURIComponent(input.inputID)}/queue`,
|
||||
successStatus: 204,
|
||||
declaredStatuses: [409, 404, 401, 400],
|
||||
empty: true,
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
},
|
||||
instructions: {
|
||||
entry: {
|
||||
@@ -991,7 +1030,7 @@ export function make(options: ClientOptions) {
|
||||
method: "POST",
|
||||
path: `/api/integration/${encodeURIComponent(input.integrationID)}/connect/key`,
|
||||
query: { location: input["location"] },
|
||||
body: { key: input["key"], answers: input["answers"], label: input["label"] },
|
||||
body: { key: input["key"], label: input["label"] },
|
||||
successStatus: 204,
|
||||
declaredStatuses: [400, 401],
|
||||
empty: true,
|
||||
@@ -1006,7 +1045,7 @@ export function make(options: ClientOptions) {
|
||||
method: "POST",
|
||||
path: `/api/integration/${encodeURIComponent(input.integrationID)}/connect/oauth`,
|
||||
query: { location: input["location"] },
|
||||
body: { methodID: input["methodID"], answers: input["answers"], label: input["label"] },
|
||||
body: { methodID: input["methodID"], inputs: input["inputs"], label: input["label"] },
|
||||
successStatus: 200,
|
||||
declaredStatuses: [400, 401],
|
||||
empty: false,
|
||||
|
||||
@@ -195,18 +195,12 @@ export type ProviderInfo = {
|
||||
body?: { [x: string]: any }
|
||||
}
|
||||
|
||||
export type FormWhen = {
|
||||
key: string
|
||||
op: "eq" | "neq"
|
||||
value: string | number | "Infinity" | "-Infinity" | "NaN" | boolean
|
||||
}
|
||||
|
||||
export type FormOption = { value: string; label: string; description?: string }
|
||||
|
||||
export type FormExternalField = { key: string; type: "external"; url: string; title?: string; description?: string }
|
||||
export type IntegrationWhen = { key: string; op: "eq" | "neq"; value: string }
|
||||
|
||||
export type IntegrationCommandMethod = { id: string; type: "command"; label: string; command: Array<string> }
|
||||
|
||||
export type IntegrationKeyMethod = { type: "key"; label?: string }
|
||||
|
||||
export type IntegrationEnvMethod = { type: "env"; names: Array<string> }
|
||||
|
||||
export type ConnectionCredentialInfo = { type: "credential"; id: string; label: string }
|
||||
@@ -291,6 +285,16 @@ export type ProjectDirectory = { directory: string; strategy?: string }
|
||||
|
||||
export type FormMetadata = { [x: string]: JsonValue }
|
||||
|
||||
export type FormWhen = {
|
||||
key: string
|
||||
op: "eq" | "neq"
|
||||
value: string | number | "Infinity" | "-Infinity" | "NaN" | boolean
|
||||
}
|
||||
|
||||
export type FormOption = { value: string; label: string; description?: string }
|
||||
|
||||
export type FormExternalField = { key: string; type: "external"; url: string; title?: string; description?: string }
|
||||
|
||||
export type FormValue = string | number | boolean | Array<string>
|
||||
|
||||
export type PermissionSource = { type: "tool"; messageID: string; id: string }
|
||||
@@ -498,6 +502,36 @@ export type SessionInputPromoted = {
|
||||
data: { sessionID: string; inputID: string }
|
||||
}
|
||||
|
||||
export type SessionInputCancelled = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
type: "session.input.cancelled"
|
||||
durable: { aggregateID: string; seq: number; version: 1 }
|
||||
location?: LocationRef
|
||||
data: { sessionID: string; inputID: string }
|
||||
}
|
||||
|
||||
export type SessionInputSteered = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
type: "session.input.steered"
|
||||
durable: { aggregateID: string; seq: number; version: 1 }
|
||||
location?: LocationRef
|
||||
data: { sessionID: string; inputID: string }
|
||||
}
|
||||
|
||||
export type SessionInputQueued = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
type: "session.input.queued"
|
||||
durable: { aggregateID: string; seq: number; version: 1 }
|
||||
location?: LocationRef
|
||||
data: { sessionID: string; inputID: string }
|
||||
}
|
||||
|
||||
export type SessionExecutionStarted = {
|
||||
id: string
|
||||
created: number
|
||||
@@ -1241,6 +1275,45 @@ export type ModelCost = {
|
||||
cache: { read: MoneyUSDPerMillionTokens; write: MoneyUSDPerMillionTokens }
|
||||
}
|
||||
|
||||
export type IntegrationTextPrompt = {
|
||||
type: "text"
|
||||
key: string
|
||||
message: string
|
||||
placeholder?: string
|
||||
when?: IntegrationWhen
|
||||
}
|
||||
|
||||
export type IntegrationSelectPrompt = {
|
||||
type: "select"
|
||||
key: string
|
||||
message: string
|
||||
options: Array<{ label: string; value: string; hint?: string }>
|
||||
when?: IntegrationWhen
|
||||
}
|
||||
|
||||
export type ConnectionInfo = ConnectionCredentialInfo | ConnectionEnvInfo
|
||||
|
||||
export type McpServer = {
|
||||
name: string
|
||||
status: McpStatusConnected | McpStatusPending | McpStatusDisabled | McpStatusFailed | McpStatusNeedsAuth
|
||||
integrationID?: string
|
||||
}
|
||||
|
||||
export type McpResourceCatalog = { resources: Array<McpResource>; templates: Array<McpResourceTemplate> }
|
||||
|
||||
export type Project = {
|
||||
id: string
|
||||
canonical: string
|
||||
vcs?: ProjectVcs
|
||||
name?: string
|
||||
icon?: ProjectIcon
|
||||
commands?: ProjectCommands
|
||||
time: ProjectTime
|
||||
sandboxes: Array<string>
|
||||
}
|
||||
|
||||
export type ProjectDirectories = Array<ProjectDirectory>
|
||||
|
||||
export type FormNumberField = {
|
||||
key: string
|
||||
title?: string
|
||||
@@ -1306,29 +1379,6 @@ export type FormMultiselectField = {
|
||||
default?: Array<string>
|
||||
}
|
||||
|
||||
export type ConnectionInfo = ConnectionCredentialInfo | ConnectionEnvInfo
|
||||
|
||||
export type McpServer = {
|
||||
name: string
|
||||
status: McpStatusConnected | McpStatusPending | McpStatusDisabled | McpStatusFailed | McpStatusNeedsAuth
|
||||
integrationID?: string
|
||||
}
|
||||
|
||||
export type McpResourceCatalog = { resources: Array<McpResource>; templates: Array<McpResourceTemplate> }
|
||||
|
||||
export type Project = {
|
||||
id: string
|
||||
canonical: string
|
||||
vcs?: ProjectVcs
|
||||
name?: string
|
||||
icon?: ProjectIcon
|
||||
commands?: ProjectCommands
|
||||
time: ProjectTime
|
||||
sandboxes: Array<string>
|
||||
}
|
||||
|
||||
export type ProjectDirectories = Array<ProjectDirectory>
|
||||
|
||||
export type FormAnswer = { [x: string]: FormValue }
|
||||
|
||||
export type PermissionRequest = {
|
||||
@@ -1609,6 +1659,13 @@ export type ModelInfo = {
|
||||
limit: { context: number; input?: number; output: number }
|
||||
}
|
||||
|
||||
export type IntegrationOAuthMethod = {
|
||||
id: string
|
||||
type: "oauth"
|
||||
label: string
|
||||
prompts?: Array<IntegrationTextPrompt | IntegrationSelectPrompt>
|
||||
}
|
||||
|
||||
export type FormField =
|
||||
| FormStringField
|
||||
| FormNumberField
|
||||
@@ -1862,9 +1919,15 @@ export type SessionMessageAssistantTool = {
|
||||
time: { created: number; ran?: number; completed?: number }
|
||||
}
|
||||
|
||||
export type IntegrationMethod =
|
||||
| IntegrationOAuthMethod
|
||||
| IntegrationCommandMethod
|
||||
| IntegrationKeyMethod
|
||||
| IntegrationEnvMethod
|
||||
|
||||
export type FormFields = [FormField, ...Array<FormField>]
|
||||
|
||||
export type FormFields3 = [FormField1, ...Array<FormField1>]
|
||||
export type FormFields1 = [FormField1, ...Array<FormField1>]
|
||||
|
||||
export type SessionPendingInfo = SessionPendingUser | SessionPendingSynthetic | SessionPendingCompaction
|
||||
|
||||
@@ -1886,13 +1949,16 @@ export type SessionMessageAssistant = {
|
||||
retry?: SessionMessageAssistantRetry
|
||||
}
|
||||
|
||||
export type IntegrationOAuthMethod = { id: string; type: "oauth"; label: string; forms?: FormFields }
|
||||
|
||||
export type IntegrationKeyMethod = { type: "key"; label?: string; forms?: FormFields }
|
||||
export type IntegrationInfo = {
|
||||
id: string
|
||||
name: string
|
||||
methods: Array<IntegrationMethod>
|
||||
connections: Array<ConnectionInfo>
|
||||
}
|
||||
|
||||
export type FormInfo = { id: string; sessionID: string; title: string; metadata?: FormMetadata; fields: FormFields }
|
||||
|
||||
export type FormInfo1 = { id: string; sessionID: string; title: string; metadata?: FormMetadata1; fields: FormFields3 }
|
||||
export type FormInfo1 = { id: string; sessionID: string; title: string; metadata?: FormMetadata1; fields: FormFields1 }
|
||||
|
||||
export type SessionInputAdmitted = {
|
||||
id: string
|
||||
@@ -1915,12 +1981,6 @@ export type SessionMessageInfo =
|
||||
| SessionMessageAssistant
|
||||
| SessionMessageCompaction
|
||||
|
||||
export type IntegrationMethod =
|
||||
| IntegrationOAuthMethod
|
||||
| IntegrationCommandMethod
|
||||
| IntegrationKeyMethod
|
||||
| IntegrationEnvMethod
|
||||
|
||||
export type FormCreated = {
|
||||
id: string
|
||||
created: number
|
||||
@@ -1940,6 +2000,9 @@ export type SessionEventDurable =
|
||||
| SessionForked
|
||||
| SessionInputPromoted
|
||||
| SessionInputAdmitted
|
||||
| SessionInputCancelled
|
||||
| SessionInputSteered
|
||||
| SessionInputQueued
|
||||
| SessionExecutionStarted
|
||||
| SessionExecutionSucceeded
|
||||
| SessionExecutionFailed
|
||||
@@ -1978,13 +2041,6 @@ export type SessionMessagesResponse = {
|
||||
cursor: { previous?: string | null; next?: string | null }
|
||||
}
|
||||
|
||||
export type IntegrationInfo = {
|
||||
id: string
|
||||
name: string
|
||||
methods: Array<IntegrationMethod>
|
||||
connections: Array<ConnectionInfo>
|
||||
}
|
||||
|
||||
export type V2Event =
|
||||
| ModelsDevRefreshed
|
||||
| IntegrationUpdated
|
||||
@@ -2001,6 +2057,9 @@ export type V2Event =
|
||||
| SessionForked
|
||||
| SessionInputPromoted
|
||||
| SessionInputAdmitted
|
||||
| SessionInputCancelled
|
||||
| SessionInputSteered
|
||||
| SessionInputQueued
|
||||
| SessionExecutionStarted
|
||||
| SessionExecutionSucceeded
|
||||
| SessionExecutionFailed
|
||||
@@ -3666,6 +3725,27 @@ export type SessionPendingListInput = { readonly sessionID: { readonly sessionID
|
||||
|
||||
export type SessionPendingListOutput = { data: Array<SessionPendingInfo> }["data"]
|
||||
|
||||
export type SessionPendingCancelInput = {
|
||||
readonly sessionID: { readonly sessionID: string; readonly inputID: string }["sessionID"]
|
||||
readonly inputID: { readonly sessionID: string; readonly inputID: string }["inputID"]
|
||||
}
|
||||
|
||||
export type SessionPendingCancelOutput = void
|
||||
|
||||
export type SessionPendingSteerInput = {
|
||||
readonly sessionID: { readonly sessionID: string; readonly inputID: string }["sessionID"]
|
||||
readonly inputID: { readonly sessionID: string; readonly inputID: string }["inputID"]
|
||||
}
|
||||
|
||||
export type SessionPendingSteerOutput = void
|
||||
|
||||
export type SessionPendingQueueInput = {
|
||||
readonly sessionID: { readonly sessionID: string; readonly inputID: string }["sessionID"]
|
||||
readonly inputID: { readonly sessionID: string; readonly inputID: string }["inputID"]
|
||||
}
|
||||
|
||||
export type SessionPendingQueueOutput = void
|
||||
|
||||
export type SessionInstructionsEntryListInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] }
|
||||
|
||||
export type SessionInstructionsEntryListOutput = { data: Array<InstructionEntryInfo> }["data"]
|
||||
@@ -3834,21 +3914,8 @@ export type IntegrationConnectKeyInput = {
|
||||
readonly location?: {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
}["location"]
|
||||
readonly key: {
|
||||
readonly key: string
|
||||
readonly answers: { readonly [x: string]: string | number | boolean | ReadonlyArray<string> }
|
||||
readonly label?: string | undefined
|
||||
}["key"]
|
||||
readonly answers: {
|
||||
readonly key: string
|
||||
readonly answers: { readonly [x: string]: string | number | boolean | ReadonlyArray<string> }
|
||||
readonly label?: string | undefined
|
||||
}["answers"]
|
||||
readonly label?: {
|
||||
readonly key: string
|
||||
readonly answers: { readonly [x: string]: string | number | boolean | ReadonlyArray<string> }
|
||||
readonly label?: string | undefined
|
||||
}["label"]
|
||||
readonly key: { readonly key: string; readonly label?: string | undefined }["key"]
|
||||
readonly label?: { readonly key: string; readonly label?: string | undefined }["label"]
|
||||
}
|
||||
|
||||
export type IntegrationConnectKeyOutput = void
|
||||
@@ -3860,17 +3927,17 @@ export type IntegrationOauthConnectInput = {
|
||||
}["location"]
|
||||
readonly methodID: {
|
||||
readonly methodID: string
|
||||
readonly answers: { readonly [x: string]: string | number | boolean | ReadonlyArray<string> }
|
||||
readonly inputs: { readonly [x: string]: string }
|
||||
readonly label?: string | undefined
|
||||
}["methodID"]
|
||||
readonly answers: {
|
||||
readonly inputs: {
|
||||
readonly methodID: string
|
||||
readonly answers: { readonly [x: string]: string | number | boolean | ReadonlyArray<string> }
|
||||
readonly inputs: { readonly [x: string]: string }
|
||||
readonly label?: string | undefined
|
||||
}["answers"]
|
||||
}["inputs"]
|
||||
readonly label?: {
|
||||
readonly methodID: string
|
||||
readonly answers: { readonly [x: string]: string | number | boolean | ReadonlyArray<string> }
|
||||
readonly inputs: { readonly [x: string]: string }
|
||||
readonly label?: string | undefined
|
||||
}["label"]
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ test("effect entrypoint exposes canonical Schema contracts", () => {
|
||||
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()
|
||||
|
||||
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).not.toContain("HttpApiClient.ForApi")
|
||||
})
|
||||
|
||||
@@ -32,6 +32,7 @@ test("exposes every standard HTTP API group", () => {
|
||||
"projectCopy",
|
||||
"vcs",
|
||||
"debug",
|
||||
"migration",
|
||||
"websearch",
|
||||
"config",
|
||||
])
|
||||
@@ -147,45 +148,6 @@ test("experimental wellknown integration add uses the public HTTP contract", asy
|
||||
expect(await request?.json()).toEqual({ url: "https://example.com" })
|
||||
})
|
||||
|
||||
test("integration connections submit form answers", async () => {
|
||||
const requests: Request[] = []
|
||||
const client = OpenCode.make({
|
||||
baseUrl: "http://localhost:3000",
|
||||
fetch: async (input, init) => {
|
||||
const request = input instanceof Request ? input : new Request(input, init)
|
||||
requests.push(request)
|
||||
if (request.url.endsWith("/connect/key")) return new Response(null, { status: 204 })
|
||||
return Response.json({
|
||||
location: { directory: "/tmp/project", project: { id: "proj_test", directory: "/tmp/project" } },
|
||||
data: {
|
||||
attemptID: "con_test",
|
||||
url: "https://example.com/authorize",
|
||||
instructions: "Authorize",
|
||||
mode: "auto",
|
||||
time: { created: 1, expires: 2 },
|
||||
},
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
await client.integration.connect.key({
|
||||
integrationID: "cloudflare-workers-ai",
|
||||
key: "secret",
|
||||
answers: { accountId: "account" },
|
||||
})
|
||||
await client.integration.oauth.connect({
|
||||
integrationID: "github-copilot",
|
||||
methodID: "device",
|
||||
answers: { deploymentType: "enterprise", enabled: true, scopes: ["read:user"] },
|
||||
})
|
||||
|
||||
expect(await requests[0].json()).toEqual({ key: "secret", answers: { accountId: "account" } })
|
||||
expect(await requests[1].json()).toEqual({
|
||||
methodID: "device",
|
||||
answers: { deploymentType: "enterprise", enabled: true, scopes: ["read:user"] },
|
||||
})
|
||||
})
|
||||
|
||||
test("health.stop sends exact replacement identity", async () => {
|
||||
let request: Request | undefined
|
||||
const client = OpenCode.make({
|
||||
@@ -395,6 +357,28 @@ test("session.pending.list uses the public HTTP contract", async () => {
|
||||
expect(requests).toEqual([{ method: "GET", url: "http://localhost:3000/api/session/ses_test/pending" }])
|
||||
})
|
||||
|
||||
test("session.pending mutations use the public HTTP contract", async () => {
|
||||
const requests: Array<{ method: string; url: string }> = []
|
||||
const client = OpenCode.make({
|
||||
baseUrl: "http://localhost:3000",
|
||||
fetch: async (input, init) => {
|
||||
const request = input instanceof Request ? input : new Request(input, init)
|
||||
requests.push({ method: request.method, url: request.url })
|
||||
return new Response(null, { status: 204 })
|
||||
},
|
||||
})
|
||||
|
||||
await client.session.pending.cancel({ sessionID: "ses_test", inputID: "msg_cancel" })
|
||||
await client.session.pending.steer({ sessionID: "ses_test", inputID: "msg_steer" })
|
||||
await client.session.pending.queue({ sessionID: "ses_test", inputID: "msg_queue" })
|
||||
|
||||
expect(requests).toEqual([
|
||||
{ method: "DELETE", url: "http://localhost:3000/api/session/ses_test/pending/msg_cancel" },
|
||||
{ method: "POST", url: "http://localhost:3000/api/session/ses_test/pending/msg_steer/steer" },
|
||||
{ method: "POST", url: "http://localhost:3000/api/session/ses_test/pending/msg_queue/queue" },
|
||||
])
|
||||
})
|
||||
|
||||
test("event.subscribe exposes the Promise event stream wire projection", async () => {
|
||||
const client = OpenCode.make({
|
||||
baseUrl: "http://localhost:3000",
|
||||
|
||||
@@ -180,14 +180,10 @@ export const layer = Layer.effect(
|
||||
Effect.gen(function* () {
|
||||
const entry = yield* find(input.id)
|
||||
if (entry.state.status !== "pending") return yield* new AlreadySettledError({ id: input.id })
|
||||
const invalid = validateAnswer(entry.form.fields, input.answer)
|
||||
const invalid = validateAnswer(entry.form, input.answer)
|
||||
if (invalid) return yield* new InvalidAnswerError({ id: input.id, message: invalid })
|
||||
const next: TerminalState = { status: "answered", answer: input.answer }
|
||||
yield* bus.publish(Form.Event.Replied, {
|
||||
id: input.id,
|
||||
sessionID: entry.form.sessionID,
|
||||
answer: input.answer,
|
||||
})
|
||||
yield* bus.publish(Form.Event.Replied, { id: input.id, sessionID: entry.form.sessionID, answer: input.answer })
|
||||
yield* Cache.set(forms, input.id, { ...entry, state: next })
|
||||
yield* Deferred.succeed(entry.deferred, next)
|
||||
}),
|
||||
@@ -227,12 +223,12 @@ export const locationLayer = layer
|
||||
|
||||
export const node = makeLocationNode({ service: Service, layer, deps: [Bus.node] })
|
||||
|
||||
export function validateAnswer(forms: ReadonlyArray<Form.Field>, answer: Answer) {
|
||||
const fields = new Map(forms.map((field) => [field.key, field] as const))
|
||||
function validateAnswer(form: Info, answer: Answer) {
|
||||
const fields = new Map(form.fields.map((field) => [field.key, field] as const))
|
||||
for (const key of Object.keys(answer)) {
|
||||
if (!fields.has(key)) return `Unknown form field: ${key}`
|
||||
}
|
||||
for (const field of forms) {
|
||||
for (const field of form.fields) {
|
||||
const value = answer[field.key]
|
||||
if (field.type === "external") {
|
||||
if (value !== true) return `External form field must be acknowledged: ${field.key}`
|
||||
@@ -268,7 +264,7 @@ function matches(when: Form.When, value: Form.Value | undefined) {
|
||||
// carry a value matching that field's type, and use a declared option when the field's options
|
||||
// are closed. Rejecting these at creation surfaces authoring mistakes to the caller instead of
|
||||
// silently never matching.
|
||||
export function validateFields(fields: ReadonlyArray<Form.Field>) {
|
||||
function validateFields(fields: ReadonlyArray<Form.Field>) {
|
||||
if (fields.length === 0) return "Form must have at least one field"
|
||||
const earlier = new Map<string, InputField>()
|
||||
const keys = new Set<string>()
|
||||
|
||||
@@ -24,7 +24,6 @@ import { Bus } from "./bus"
|
||||
import { IntegrationConnection } from "./integration/connection"
|
||||
import { AppProcess } from "@opencode-ai/util/process"
|
||||
import { ChildProcess } from "effect/unstable/process"
|
||||
import { Form } from "./form"
|
||||
|
||||
export const ID = Integration.ID
|
||||
export type ID = Integration.ID
|
||||
@@ -35,6 +34,18 @@ export type MethodID = Integration.MethodID
|
||||
export const AttemptID = Integration.AttemptID
|
||||
export type AttemptID = typeof AttemptID.Type
|
||||
|
||||
export const When = Integration.When
|
||||
export type When = Integration.When
|
||||
|
||||
export const TextPrompt = Integration.TextPrompt
|
||||
export type TextPrompt = Integration.TextPrompt
|
||||
|
||||
export const SelectPrompt = Integration.SelectPrompt
|
||||
export type SelectPrompt = Integration.SelectPrompt
|
||||
|
||||
export const Prompt = Integration.Prompt
|
||||
export type Prompt = Integration.Prompt
|
||||
|
||||
export const OAuthMethod = Integration.OAuthMethod
|
||||
export type OAuthMethod = Integration.OAuthMethod
|
||||
|
||||
@@ -53,6 +64,9 @@ export type Method = Integration.Method
|
||||
export const Info = Integration.Info
|
||||
export type Info = Integration.Info
|
||||
|
||||
export const Inputs = Integration.Inputs
|
||||
export type Inputs = Integration.Inputs
|
||||
|
||||
export type OAuthAuthorization = {
|
||||
readonly url: string
|
||||
readonly instructions: string
|
||||
@@ -71,7 +85,7 @@ export type OAuthAuthorization = {
|
||||
export interface OAuthImplementation {
|
||||
readonly integrationID: ID
|
||||
readonly method: OAuthMethod
|
||||
readonly authorize: (answers: Form.Answer) => Effect.Effect<OAuthAuthorization, unknown, Scope.Scope>
|
||||
readonly authorize: (inputs: Inputs) => Effect.Effect<OAuthAuthorization, unknown, Scope.Scope>
|
||||
readonly refresh?: (credential: Credential.OAuth) => Effect.Effect<Credential.OAuth, unknown>
|
||||
readonly label?: (credential: Credential.OAuth) => string | undefined
|
||||
}
|
||||
@@ -161,8 +175,6 @@ export interface Interface extends State.Transformable<Draft> {
|
||||
readonly integrationID: ID
|
||||
/** Secret entered by the user. */
|
||||
readonly key: string
|
||||
/** Values collected from the method's form fields. */
|
||||
readonly answers: Form.Answer
|
||||
/** User-facing label for the stored credential. */
|
||||
readonly label?: string
|
||||
}) => Effect.Effect<void, AuthorizationError>
|
||||
@@ -179,7 +191,7 @@ export interface Interface extends State.Transformable<Draft> {
|
||||
readonly connect: (input: {
|
||||
readonly integrationID: ID
|
||||
readonly methodID: MethodID
|
||||
readonly answers: Form.Answer
|
||||
readonly inputs: Inputs
|
||||
readonly label?: string
|
||||
}) => Effect.Effect<Attempt, AuthorizationError>
|
||||
/** Returns the current state of an OAuth attempt. */
|
||||
@@ -344,7 +356,7 @@ const layer = Layer.effect(
|
||||
return [...credentials, ...env]
|
||||
}
|
||||
|
||||
const project = (entry: Entry, connections: IntegrationConnection.Info[]): Info =>
|
||||
const project = (entry: Entry, connections: IntegrationConnection.Info[]) =>
|
||||
Info.make({
|
||||
id: entry.ref.id,
|
||||
name: entry.ref.name,
|
||||
@@ -535,20 +547,15 @@ const layer = Layer.effect(
|
||||
const connectOAuth = Effect.fn("Integration.oauth.connect")(function* (input: {
|
||||
readonly integrationID: ID
|
||||
readonly methodID: MethodID
|
||||
readonly answers: Form.Answer
|
||||
readonly inputs: Inputs
|
||||
readonly label?: string
|
||||
}) {
|
||||
const method = state.get().integrations.get(input.integrationID)?.implementations.get(input.methodID)
|
||||
if (!method) {
|
||||
return yield* Effect.die(new Error(`OAuth method not found: ${input.integrationID}/${input.methodID}`))
|
||||
}
|
||||
if (method.method.forms) {
|
||||
const invalid =
|
||||
Form.validateFields(method.method.forms) ?? Form.validateAnswer(method.method.forms, input.answers)
|
||||
if (invalid) return yield* new AuthorizationError({ cause: new Error(invalid) })
|
||||
}
|
||||
const attemptScope = yield* Scope.fork(scope)
|
||||
const authorization = yield* authorize(method.authorize(input.answers)).pipe(
|
||||
const authorization = yield* authorize(method.authorize(input.inputs)).pipe(
|
||||
Scope.provide(attemptScope),
|
||||
Effect.onExit((exit) => (Exit.isFailure(exit) ? Scope.close(attemptScope, exit) : Effect.void)),
|
||||
)
|
||||
@@ -692,23 +699,12 @@ const layer = Layer.effect(
|
||||
const method = state
|
||||
.get()
|
||||
.integrations.get(input.integrationID)
|
||||
?.methods.find((method) => method.type === "key")
|
||||
?.methods.some((method) => method.type === "key")
|
||||
if (!method) return yield* Effect.die(new Error(`Key method not found: ${input.integrationID}`))
|
||||
if (method.type === "key" && method.forms) {
|
||||
const invalid = Form.validateFields(method.forms) ?? Form.validateAnswer(method.forms, input.answers)
|
||||
if (invalid) return yield* new AuthorizationError({ cause: new Error(invalid) })
|
||||
}
|
||||
if (method.type === "key" && !method.forms && Object.keys(input.answers).length > 0) {
|
||||
return yield* new AuthorizationError({ cause: new Error("Key method does not accept form answers") })
|
||||
}
|
||||
yield* credentials.create({
|
||||
integrationID: input.integrationID,
|
||||
label: input.label,
|
||||
value: Credential.Key.make({
|
||||
type: "key",
|
||||
key: input.key,
|
||||
...(Object.keys(input.answers).length > 0 ? { configuration: input.answers } : {}),
|
||||
}),
|
||||
value: Credential.Key.make({ type: "key", key: input.key }),
|
||||
})
|
||||
yield* bus.publish(Integration.Event.ConnectionUpdated, { integrationID: input.integrationID })
|
||||
yield* bus.publish(Integration.Event.Updated, {})
|
||||
|
||||
@@ -46,6 +46,7 @@ import { SessionGenerateNode } from "./session/generate-node"
|
||||
import { McpTool } from "./tool/mcp"
|
||||
import { ReadToolFileSystem } from "./tool/read-filesystem"
|
||||
import { Tool } from "./tool"
|
||||
import { ToolOutput } from "./tool-output"
|
||||
import { Vcs } from "./vcs"
|
||||
|
||||
export { LocationServiceMap } from "./location-service-map"
|
||||
@@ -78,6 +79,7 @@ const locationServiceNodes = [
|
||||
MCP.node,
|
||||
Permission.node,
|
||||
Tool.node,
|
||||
ToolOutput.node,
|
||||
Image.node,
|
||||
SkillInstructions.node,
|
||||
ReferenceInstructions.node,
|
||||
|
||||
@@ -149,7 +149,6 @@ export const fromCatalogModel = (
|
||||
})
|
||||
const packageName = Provider.packageName(resolved.package)
|
||||
const key = apiKey(resolved, credential)
|
||||
const configuration = credential?.type === "key" ? credential.configuration : undefined
|
||||
|
||||
if (Provider.isAISDK(resolved.package) && packageName === "@ai-sdk/openai") {
|
||||
return Effect.succeed(
|
||||
@@ -176,7 +175,7 @@ export const fromCatalogModel = (
|
||||
.model({ id: resolved.modelID ?? resolved.id, compatibility: resolved.compatibility }),
|
||||
)
|
||||
}
|
||||
const configured = { ...resolved.settings, ...credential?.metadata, ...configuration }
|
||||
const configured = { ...resolved.settings, ...credential?.metadata }
|
||||
const mapping = Provider.isAISDK(resolved.package)
|
||||
? AISDKNative.map({
|
||||
packageName,
|
||||
@@ -191,7 +190,6 @@ export const fromCatalogModel = (
|
||||
draft.settings = Provider.mergeOverlay(draft.settings, {
|
||||
...nativeCredentialSettings(resolved.package ?? "", credential),
|
||||
...credential?.metadata,
|
||||
...configuration,
|
||||
})
|
||||
})
|
||||
return dependencies.loadAISDK(runtime).pipe(Effect.mapError(() => unsupported(resolved)))
|
||||
|
||||
@@ -47,13 +47,17 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: import("../p
|
||||
workspaceID: location.workspaceID,
|
||||
project: location.project,
|
||||
})
|
||||
const locationRef = (input?: { readonly location?: { readonly directory?: string; readonly workspace?: string } }) =>
|
||||
const locationRef = (input?: {
|
||||
readonly location?: { readonly directory?: string; readonly workspace?: string }
|
||||
}) =>
|
||||
input?.location === undefined
|
||||
? undefined
|
||||
: Location.Ref.make({
|
||||
directory: AbsolutePath.make(input.location.directory ?? location.directory),
|
||||
workspaceID:
|
||||
input.location.workspace === undefined ? location.workspaceID : Workspace.ID.make(input.location.workspace),
|
||||
input.location.workspace === undefined
|
||||
? location.workspaceID
|
||||
: Workspace.ID.make(input.location.workspace),
|
||||
})
|
||||
const isCurrentLocation = (ref: Location.Ref) =>
|
||||
ref.directory === location.directory && ref.workspaceID === location.workspaceID
|
||||
@@ -70,12 +74,7 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: import("../p
|
||||
ref && !isCurrentLocation(ref)
|
||||
? runtime.location.agent
|
||||
.list(ref)
|
||||
.pipe(
|
||||
Effect.map((result) => ({
|
||||
...result,
|
||||
data: result.data.find((agent) => agent.id === input.agentID),
|
||||
})),
|
||||
)
|
||||
.pipe(Effect.map((result) => ({ ...result, data: result.data.find((agent) => agent.id === input.agentID) })))
|
||||
: response(agents.get(input.agentID))
|
||||
return output.pipe(
|
||||
Effect.flatMap((result) =>
|
||||
@@ -163,7 +162,8 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: import("../p
|
||||
mutable(draft.model.get(Provider.ID.make(providerID), Model.ID.make(modelID))),
|
||||
update: (providerID, modelID, update) =>
|
||||
draft.model.update(Provider.ID.make(providerID), Model.ID.make(modelID), update),
|
||||
remove: (providerID, modelID) => draft.model.remove(Provider.ID.make(providerID), Model.ID.make(modelID)),
|
||||
remove: (providerID, modelID) =>
|
||||
draft.model.remove(Provider.ID.make(providerID), Model.ID.make(modelID)),
|
||||
default: {
|
||||
get: draft.model.default.get,
|
||||
set: (providerID, modelID) =>
|
||||
@@ -192,7 +192,6 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: import("../p
|
||||
integration.connection.key({
|
||||
integrationID: Integration.ID.make(input.integrationID),
|
||||
key: input.key,
|
||||
answers: input.answers,
|
||||
label: input.label,
|
||||
}),
|
||||
},
|
||||
@@ -202,7 +201,7 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: import("../p
|
||||
integration.oauth.connect({
|
||||
integrationID: Integration.ID.make(input.integrationID),
|
||||
methodID: Integration.MethodID.make(input.methodID),
|
||||
answers: input.answers,
|
||||
inputs: input.inputs,
|
||||
label: input.label,
|
||||
}),
|
||||
),
|
||||
@@ -365,14 +364,9 @@ function methodImplementation(input: IntegrationMethodRegistration): Integration
|
||||
const refresh = input.refresh
|
||||
return {
|
||||
integrationID: Integration.ID.make(input.integrationID),
|
||||
method: Schema.decodeUnknownSync(Integration.OAuthMethod)({
|
||||
id: Integration.MethodID.make(input.method.id),
|
||||
type: "oauth",
|
||||
label: input.method.label,
|
||||
...(input.method.forms === undefined ? {} : { forms: input.method.forms }),
|
||||
}),
|
||||
authorize: (answers) =>
|
||||
input.authorize(answers).pipe(
|
||||
method: { ...input.method, id: Integration.MethodID.make(input.method.id) },
|
||||
authorize: (inputs) =>
|
||||
input.authorize(inputs).pipe(
|
||||
Effect.map((authorization) => {
|
||||
if (authorization.mode === "auto") {
|
||||
return {
|
||||
@@ -404,11 +398,7 @@ function methodImplementation(input: IntegrationMethodRegistration): Integration
|
||||
}
|
||||
return {
|
||||
integrationID: Integration.ID.make(input.integrationID),
|
||||
method: Schema.decodeUnknownSync(Integration.KeyMethod)({
|
||||
type: "key",
|
||||
...(input.method.label === undefined ? {} : { label: input.method.label }),
|
||||
...(input.method.forms === undefined ? {} : { forms: input.method.forms }),
|
||||
}),
|
||||
method: { type: "key", label: input.method.label },
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -179,8 +179,8 @@ export function fromPromise(plugin: Plugin) {
|
||||
const refresh = input.refresh
|
||||
draft.method.update({
|
||||
...input,
|
||||
authorize: (answers) =>
|
||||
Effect.promise(() => input.authorize(answers)).pipe(
|
||||
authorize: (inputs) =>
|
||||
Effect.promise(() => input.authorize(inputs)).pipe(
|
||||
Effect.map((authorization) =>
|
||||
authorization.mode === "auto"
|
||||
? {
|
||||
@@ -359,17 +359,11 @@ type Wire<Value> = unknown extends Value
|
||||
? Value
|
||||
: Value extends DateTime.DateTime
|
||||
? number
|
||||
: Value extends readonly [infer Head, ...infer Tail]
|
||||
? [Wire<Head>, ...WireTuple<Tail>]
|
||||
: Value extends ReadonlyArray<infer Item>
|
||||
? Array<Wire<Item>>
|
||||
: Value extends object
|
||||
? { -readonly [Key in keyof Value]: Wire<Value[Key]> }
|
||||
: Value
|
||||
|
||||
type WireTuple<Value extends ReadonlyArray<unknown>> = {
|
||||
-readonly [Key in keyof Value]: Wire<Value[Key]>
|
||||
}
|
||||
: Value extends ReadonlyArray<infer Item>
|
||||
? Array<Wire<Item>>
|
||||
: Value extends object
|
||||
? { -readonly [Key in keyof Value]: Wire<Value[Key]> }
|
||||
: Value
|
||||
|
||||
function wire<Value>(value: Value): Wire<Value>
|
||||
function wire(value: unknown): unknown {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Effect } from "effect"
|
||||
import { define } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { Provider } from "../../provider"
|
||||
import { configuredSettings } from "./configured"
|
||||
|
||||
function selectLanguage(sdk: any, modelID: string, useChat: boolean) {
|
||||
if (useChat && sdk.chat) return sdk.chat(modelID)
|
||||
@@ -14,29 +13,6 @@ function selectLanguage(sdk: any, modelID: string, useChat: boolean) {
|
||||
export const AzurePlugin = define({
|
||||
id: "opencode.provider.azure",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
const configured = yield* configuredSettings(Provider.ID.azure)
|
||||
yield* ctx.integration.transform((draft) => {
|
||||
draft.method.update({
|
||||
integrationID: Provider.ID.azure,
|
||||
method: {
|
||||
type: "key",
|
||||
label: "API key",
|
||||
...(resolveResourceName(configured) || typeof configured?.baseURL === "string"
|
||||
? {}
|
||||
: {
|
||||
forms: [
|
||||
{
|
||||
type: "string",
|
||||
key: "resourceName",
|
||||
title: "Enter Azure Resource Name",
|
||||
placeholder: "e.g. my-models",
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
})
|
||||
})
|
||||
yield* ctx.catalog.transform((evt) => {
|
||||
for (const item of evt.provider.list()) {
|
||||
if (item.provider.id !== Provider.ID.azure && Provider.packageName(item.provider.package) !== "@ai-sdk/azure")
|
||||
|
||||
@@ -2,53 +2,10 @@ import os from "os"
|
||||
import { App } from "../../app"
|
||||
import { Effect, Option, Schema } from "effect"
|
||||
import { define } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { Provider } from "../../provider"
|
||||
import { configuredSettings } from "./configured"
|
||||
|
||||
const providerID = Provider.ID.make("cloudflare-ai-gateway")
|
||||
|
||||
export const CloudflareAIGatewayPlugin = define({
|
||||
id: "opencode.provider.cloudflare-ai-gateway",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
const configured = yield* configuredSettings(providerID)
|
||||
const complete = typeof configured?.baseURL === "string"
|
||||
yield* ctx.integration.transform((draft) => {
|
||||
const forms = [
|
||||
...(complete || process.env.CLOUDFLARE_ACCOUNT_ID || stringOption(configured ?? {}, "accountId")
|
||||
? []
|
||||
: [
|
||||
{
|
||||
type: "string" as const,
|
||||
key: "accountId",
|
||||
title: "Enter your Cloudflare Account ID",
|
||||
placeholder: "e.g. 1234567890abcdef1234567890abcdef",
|
||||
required: true,
|
||||
},
|
||||
]),
|
||||
...(complete ||
|
||||
process.env.CLOUDFLARE_GATEWAY_ID ||
|
||||
stringOption(configured ?? {}, "gatewayId") ||
|
||||
stringOption(configured ?? {}, "gateway")
|
||||
? []
|
||||
: [
|
||||
{
|
||||
type: "string" as const,
|
||||
key: "gatewayId",
|
||||
title: "Enter your Cloudflare AI Gateway ID",
|
||||
placeholder: "e.g. my-gateway",
|
||||
required: true,
|
||||
},
|
||||
]),
|
||||
]
|
||||
draft.method.update({
|
||||
integrationID: providerID,
|
||||
method: {
|
||||
type: "key",
|
||||
label: "Gateway API token",
|
||||
...(forms[0] ? { forms: [forms[0], ...forms.slice(1)] } : {}),
|
||||
},
|
||||
})
|
||||
})
|
||||
yield* ctx.aisdk.hook(
|
||||
"sdk",
|
||||
Effect.fn(function* (evt) {
|
||||
@@ -89,7 +46,7 @@ const decodeJson = Schema.decodeUnknownOption(Schema.UnknownFromJsonString)
|
||||
|
||||
function gatewayConfig(options: Record<string, unknown>): GatewayConfig | undefined {
|
||||
const accountId = process.env.CLOUDFLARE_ACCOUNT_ID ?? stringOption(options, "accountId")
|
||||
// Credential projection copies key metadata into options. The form stores the
|
||||
// Credential projection copies key metadata into options. The prompt stores the
|
||||
// gateway as gatewayId, while older config examples may use gateway.
|
||||
const gatewayId =
|
||||
process.env.CLOUDFLARE_GATEWAY_ID ?? stringOption(options, "gatewayId") ?? stringOption(options, "gateway")
|
||||
|
||||
@@ -3,36 +3,12 @@ import { App } from "../../app"
|
||||
import { Effect } from "effect"
|
||||
import { define } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { Provider } from "../../provider"
|
||||
import { configuredSettings } from "./configured"
|
||||
|
||||
const providerID = Provider.ID.make("cloudflare-workers-ai")
|
||||
|
||||
export const CloudflareWorkersAIPlugin = define({
|
||||
id: "opencode.provider.cloudflare-workers-ai",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
const configured = yield* configuredSettings(providerID)
|
||||
yield* ctx.integration.transform((draft) => {
|
||||
draft.method.update({
|
||||
integrationID: providerID,
|
||||
method: {
|
||||
type: "key",
|
||||
label: "API key",
|
||||
...(typeof configured?.baseURL === "string" || resolveAccountId(configured ?? {})
|
||||
? {}
|
||||
: {
|
||||
forms: [
|
||||
{
|
||||
type: "string",
|
||||
key: "accountId",
|
||||
title: "Enter your Cloudflare Account ID",
|
||||
placeholder: "e.g. 1234567890abcdef1234567890abcdef",
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
})
|
||||
})
|
||||
yield* ctx.catalog.transform((evt) => {
|
||||
const item = evt.provider.get(providerID)
|
||||
if (!item) return
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
import { Effect, Option } from "effect"
|
||||
import type { Document } from "@opencode-ai/schema/config"
|
||||
import { Catalog } from "../../catalog"
|
||||
import { Config } from "../../config"
|
||||
import { Provider } from "../../provider"
|
||||
|
||||
export const configuredSettings = Effect.fn("ProviderPlugin.configuredSettings")(function* (id: Provider.ID) {
|
||||
const catalog = yield* Catalog.Service
|
||||
const current = (yield* catalog.provider.get(id))?.settings
|
||||
const service = yield* Effect.serviceOption(Config.Service)
|
||||
const entries = Option.isSome(service) ? yield* service.value.entries() : []
|
||||
return entries
|
||||
.filter((entry): entry is Document => entry.type === "document")
|
||||
.reduce((settings, entry) => Provider.mergeOverlay(settings, entry.info.providers?.[id]?.settings), current)
|
||||
})
|
||||
@@ -46,33 +46,30 @@ const oauth = (app: App.Info) => ({
|
||||
id: methodID,
|
||||
type: "oauth",
|
||||
label: "Login with GitHub Copilot",
|
||||
forms: [
|
||||
prompts: [
|
||||
{
|
||||
type: "string",
|
||||
type: "select",
|
||||
key: "deploymentType",
|
||||
title: "Select GitHub deployment type",
|
||||
required: true,
|
||||
message: "Select GitHub deployment type",
|
||||
options: [
|
||||
{ label: "GitHub.com", value: "github.com", description: "Public" },
|
||||
{ label: "GitHub Enterprise", value: "enterprise", description: "Data residency or self-hosted" },
|
||||
{ label: "GitHub.com", value: "github.com", hint: "Public" },
|
||||
{ label: "GitHub Enterprise", value: "enterprise", hint: "Data residency or self-hosted" },
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "string",
|
||||
type: "text",
|
||||
key: "enterpriseUrl",
|
||||
title: "Enter your GitHub Enterprise URL or domain",
|
||||
message: "Enter your GitHub Enterprise URL or domain",
|
||||
placeholder: "company.ghe.com or https://company.ghe.com",
|
||||
required: true,
|
||||
when: [{ key: "deploymentType", op: "eq", value: "enterprise" }],
|
||||
when: { key: "deploymentType", op: "eq", value: "enterprise" },
|
||||
},
|
||||
],
|
||||
},
|
||||
authorize: (answers) =>
|
||||
authorize: (inputs) =>
|
||||
Effect.gen(function* () {
|
||||
const enterprise = answers.deploymentType === "enterprise"
|
||||
const enterpriseUrl = typeof answers.enterpriseUrl === "string" ? answers.enterpriseUrl : undefined
|
||||
if (enterprise && !enterpriseUrl) return yield* Effect.fail(new Error("Enterprise URL is required"))
|
||||
const domain = enterprise ? normalizeDomain(enterpriseUrl ?? "") : "github.com"
|
||||
const enterprise = inputs.deploymentType === "enterprise"
|
||||
if (enterprise && !inputs.enterpriseUrl) return yield* Effect.fail(new Error("Enterprise URL is required"))
|
||||
const domain = enterprise ? normalizeDomain(inputs.enterpriseUrl ?? "") : "github.com"
|
||||
const urls = oauthURLs(domain)
|
||||
const device = yield* request(urls.device, {
|
||||
method: "POST",
|
||||
|
||||
@@ -43,9 +43,9 @@ function oauth(http: HttpClient.HttpClient) {
|
||||
type: "oauth",
|
||||
label: "OpenCode Console account",
|
||||
},
|
||||
authorize: (answers) =>
|
||||
authorize: (inputs) =>
|
||||
Effect.gen(function* () {
|
||||
const server = yield* normalizeServer(typeof answers.server === "string" ? answers.server : defaultServer)
|
||||
const server = yield* normalizeServer(inputs.server ?? defaultServer)
|
||||
const device = yield* post(http, `${server}/auth/device/code`, { client_id: clientID }, Device)
|
||||
const verification = URL.canParse(device.verification_uri_complete)
|
||||
? new URL(device.verification_uri_complete)
|
||||
|
||||
@@ -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: {},
|
||||
})) ?? []
|
||||
)
|
||||
}),
|
||||
})
|
||||
})
|
||||
}),
|
||||
})
|
||||
@@ -1,4 +1,5 @@
|
||||
import { WebSearchExa } from "./exa"
|
||||
import { WebSearchFirecrawl } from "./firecrawl"
|
||||
import { WebSearchParallel } from "./parallel"
|
||||
|
||||
export const WebSearchPlugins = [WebSearchExa.Plugin, WebSearchParallel.Plugin] as const
|
||||
export const WebSearchPlugins = [WebSearchExa.Plugin, WebSearchFirecrawl.Plugin, WebSearchParallel.Plugin] as const
|
||||
|
||||
@@ -133,6 +133,14 @@ export class CompactionConflictError extends Schema.TaggedErrorClass<CompactionC
|
||||
export class BusyError extends Schema.TaggedErrorClass<BusyError>()("Session.BusyError", {
|
||||
sessionID: SessionSchema.ID,
|
||||
}) {}
|
||||
export class PendingInputConflictError extends Schema.TaggedErrorClass<PendingInputConflictError>()(
|
||||
"Session.PendingInputConflictError",
|
||||
{
|
||||
sessionID: SessionSchema.ID,
|
||||
inputID: SessionMessage.ID,
|
||||
},
|
||||
) {}
|
||||
type PendingInputRef = { readonly sessionID: SessionSchema.ID; readonly inputID: SessionMessage.ID }
|
||||
export class SkillNotFoundError extends Schema.TaggedErrorClass<SkillNotFoundError>()("Session.SkillNotFoundError", {
|
||||
skill: Skill.ID,
|
||||
}) {}
|
||||
@@ -181,6 +189,9 @@ export interface Interface {
|
||||
* unhandled compaction barriers.
|
||||
*/
|
||||
readonly pending: (sessionID: SessionSchema.ID) => Effect.Effect<SessionPending.Info[], NotFoundError>
|
||||
readonly cancelPending: (input: PendingInputRef) => Effect.Effect<void, NotFoundError | PendingInputConflictError>
|
||||
readonly steerPending: (input: PendingInputRef) => Effect.Effect<void, NotFoundError | PendingInputConflictError>
|
||||
readonly queuePending: (input: PendingInputRef) => Effect.Effect<void, NotFoundError | PendingInputConflictError>
|
||||
/**
|
||||
* Durable, ordered session log read. Replays durable session bus after
|
||||
* the exclusive `after` cursor, emits a `Synced` marker at the captured
|
||||
@@ -318,6 +329,29 @@ const layer = Layer.effect(
|
||||
),
|
||||
)
|
||||
|
||||
const mutatePending = (
|
||||
input: PendingInputRef,
|
||||
mutation: (
|
||||
bus: Bus.Interface,
|
||||
input: { readonly id: SessionMessage.ID; readonly sessionID: SessionSchema.ID },
|
||||
) => Effect.Effect<unknown>,
|
||||
wake = false,
|
||||
) =>
|
||||
Effect.uninterruptible(
|
||||
Effect.gen(function* () {
|
||||
yield* mutation(bus, { sessionID: input.sessionID, id: input.inputID }).pipe(
|
||||
Effect.catchDefect((defect) =>
|
||||
defect instanceof SessionPending.LifecycleConflict
|
||||
? result
|
||||
.get(input.sessionID)
|
||||
.pipe(Effect.andThen(Effect.fail(new PendingInputConflictError(input))))
|
||||
: Effect.die(defect),
|
||||
),
|
||||
)
|
||||
if (wake) yield* execution.wake(input.sessionID)
|
||||
}),
|
||||
)
|
||||
|
||||
const result = Service.of({
|
||||
create: Effect.fn("Session.create")(function* (input) {
|
||||
const sessionID = input.id ?? SessionSchema.ID.create()
|
||||
@@ -507,6 +541,9 @@ const layer = Layer.effect(
|
||||
yield* result.get(sessionID)
|
||||
return yield* SessionPending.list(db, sessionID)
|
||||
}),
|
||||
cancelPending: Effect.fn("Session.cancelPending")((input) => mutatePending(input, SessionPending.cancel)),
|
||||
steerPending: Effect.fn("Session.steerPending")((input) => mutatePending(input, SessionPending.steer, true)),
|
||||
queuePending: Effect.fn("Session.queuePending")((input) => mutatePending(input, SessionPending.queue)),
|
||||
log: (input) =>
|
||||
Stream.unwrap(
|
||||
result
|
||||
|
||||
@@ -90,6 +90,9 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
|
||||
"session.forked": () => Effect.void,
|
||||
"session.input.promoted": () => Effect.void,
|
||||
"session.input.admitted": () => Effect.void,
|
||||
"session.input.cancelled": () => Effect.void,
|
||||
"session.input.steered": () => Effect.void,
|
||||
"session.input.queued": () => Effect.void,
|
||||
"session.execution.started": () => Effect.void,
|
||||
"session.execution.succeeded": () => clearCurrentRetry,
|
||||
"session.execution.failed": () => clearCurrentRetry,
|
||||
|
||||
@@ -312,6 +312,63 @@ export const projectPromoted = Effect.fn("SessionPending.projectPromoted")(funct
|
||||
return stored
|
||||
})
|
||||
|
||||
export const projectCancelled = Effect.fn("SessionPending.projectCancelled")(function* (
|
||||
db: DatabaseService,
|
||||
input: {
|
||||
readonly id: SessionMessage.ID
|
||||
readonly sessionID: SessionSchema.ID
|
||||
},
|
||||
) {
|
||||
const deleted = yield* db
|
||||
.delete(SessionPendingTable)
|
||||
.where(
|
||||
and(
|
||||
eq(SessionPendingTable.id, input.id),
|
||||
eq(SessionPendingTable.session_id, input.sessionID),
|
||||
or(eq(SessionPendingTable.delivery, "queue"), eq(SessionPendingTable.delivery, "steer")),
|
||||
),
|
||||
)
|
||||
.returning({ id: SessionPendingTable.id })
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
if (!deleted) return yield* Effect.die(new LifecycleConflict({ id: input.id }))
|
||||
})
|
||||
|
||||
const projectDelivery = Effect.fn("SessionPending.projectDelivery")(function* (
|
||||
db: DatabaseService,
|
||||
input: {
|
||||
readonly id: SessionMessage.ID
|
||||
readonly sessionID: SessionSchema.ID
|
||||
readonly from: Delivery
|
||||
readonly to: Delivery
|
||||
},
|
||||
) {
|
||||
const updated = yield* db
|
||||
.update(SessionPendingTable)
|
||||
.set({ delivery: input.to })
|
||||
.where(
|
||||
and(
|
||||
eq(SessionPendingTable.id, input.id),
|
||||
eq(SessionPendingTable.session_id, input.sessionID),
|
||||
eq(SessionPendingTable.delivery, input.from),
|
||||
),
|
||||
)
|
||||
.returning({ id: SessionPendingTable.id })
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
if (!updated) return yield* Effect.die(new LifecycleConflict({ id: input.id }))
|
||||
})
|
||||
|
||||
export const projectSteered = Effect.fn("SessionPending.projectSteered")(
|
||||
(db: DatabaseService, input: { readonly id: SessionMessage.ID; readonly sessionID: SessionSchema.ID }) =>
|
||||
projectDelivery(db, { ...input, from: "queue", to: "steer" }),
|
||||
)
|
||||
|
||||
export const projectQueued = Effect.fn("SessionPending.projectQueued")(
|
||||
(db: DatabaseService, input: { readonly id: SessionMessage.ID; readonly sessionID: SessionSchema.ID }) =>
|
||||
projectDelivery(db, { ...input, from: "steer", to: "queue" }),
|
||||
)
|
||||
|
||||
export const settleCompaction = Effect.fn("SessionPending.settleCompaction")(function* (
|
||||
db: DatabaseService,
|
||||
input: { readonly sessionID: SessionSchema.ID },
|
||||
@@ -389,6 +446,42 @@ export const equivalent = (
|
||||
return false
|
||||
}
|
||||
|
||||
export const cancel = Effect.fn("SessionPending.cancel")(function* (
|
||||
bus: Bus.Interface,
|
||||
input: { readonly id: SessionMessage.ID; readonly sessionID: SessionSchema.ID },
|
||||
) {
|
||||
yield* inboxLocks.withLock(input.sessionID)(
|
||||
bus.publish(SessionEvent.InputCancelled, {
|
||||
sessionID: input.sessionID,
|
||||
inputID: input.id,
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
export const steer = Effect.fn("SessionPending.steer")(function* (
|
||||
bus: Bus.Interface,
|
||||
input: { readonly id: SessionMessage.ID; readonly sessionID: SessionSchema.ID },
|
||||
) {
|
||||
yield* inboxLocks.withLock(input.sessionID)(
|
||||
bus.publish(SessionEvent.InputSteered, {
|
||||
sessionID: input.sessionID,
|
||||
inputID: input.id,
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
export const queue = Effect.fn("SessionPending.queue")(function* (
|
||||
bus: Bus.Interface,
|
||||
input: { readonly id: SessionMessage.ID; readonly sessionID: SessionSchema.ID },
|
||||
) {
|
||||
yield* inboxLocks.withLock(input.sessionID)(
|
||||
bus.publish(SessionEvent.InputQueued, {
|
||||
sessionID: input.sessionID,
|
||||
inputID: input.id,
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
const publish = Effect.fn("SessionPending.publish")(function* (
|
||||
db: DatabaseService,
|
||||
bus: Bus.Interface,
|
||||
|
||||
@@ -485,6 +485,24 @@ const layer = Layer.effectDiscard(
|
||||
.pipe(Effect.orDie)
|
||||
}),
|
||||
)
|
||||
yield* bus.project(SessionEvent.InputCancelled, (event) =>
|
||||
SessionPending.projectCancelled(db, {
|
||||
id: event.data.inputID,
|
||||
sessionID: event.data.sessionID,
|
||||
}),
|
||||
)
|
||||
yield* bus.project(SessionEvent.InputSteered, (event) =>
|
||||
SessionPending.projectSteered(db, {
|
||||
id: event.data.inputID,
|
||||
sessionID: event.data.sessionID,
|
||||
}),
|
||||
)
|
||||
yield* bus.project(SessionEvent.InputQueued, (event) =>
|
||||
SessionPending.projectQueued(db, {
|
||||
id: event.data.inputID,
|
||||
sessionID: event.data.sessionID,
|
||||
}),
|
||||
)
|
||||
yield* bus.project(SessionEvent.Compaction.Admitted, (event) =>
|
||||
Effect.gen(function* () {
|
||||
if (event.durable === undefined)
|
||||
|
||||
@@ -32,6 +32,7 @@ import { StepFailedError } from "../error"
|
||||
import { toSessionError } from "../to-session-error"
|
||||
import { SessionRunnerRetry } from "./retry"
|
||||
import { SessionUsage } from "../usage"
|
||||
import { ToolOutput } from "../../tool-output"
|
||||
|
||||
/** How one model call ended: settled, awaiting a scheduled retry, or restarted by compaction. */
|
||||
type CallOutcome = Data.TaggedEnum<{
|
||||
@@ -107,6 +108,7 @@ const layer = Layer.effect(
|
||||
const db = (yield* Database.Service).db
|
||||
const compaction = yield* SessionCompaction.Service
|
||||
const title = yield* SessionTitle.Service
|
||||
const toolOutput = yield* ToolOutput.Service
|
||||
// Title generation is a side effect of a successful step; it must not delay continuation.
|
||||
// The in-flight set coalesces overlapping steps while title presence records success durably.
|
||||
const titlesRunning = new Set<SessionSchema.ID>()
|
||||
@@ -334,6 +336,7 @@ const layer = Layer.effect(
|
||||
).pipe(
|
||||
// The fiber owns its call: it publishes its own completion, masked so a
|
||||
// finished execution always reaches its durable settlement.
|
||||
Effect.flatMap(toolOutput.truncate),
|
||||
Effect.flatMap((outcome) => publisher.toolExecution(event.id, event.name, outcome)),
|
||||
Effect.catchTag("Tool.Error", (error) =>
|
||||
publisher.failTool(event.id, toSessionError(error)).pipe(Effect.asVoid),
|
||||
@@ -562,6 +565,7 @@ export const node = makeLocationNode({
|
||||
SessionCompaction.node,
|
||||
SessionTitle.node,
|
||||
Snapshot.node,
|
||||
ToolOutput.node,
|
||||
Database.node,
|
||||
],
|
||||
})
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
export * as ToolOutput from "./tool-output"
|
||||
|
||||
import path from "path"
|
||||
import type { Tool } from "@opencode-ai/schema/tool"
|
||||
import { Context, Duration, Effect, Layer, Schedule } from "effect"
|
||||
import { makeGlobalNode, makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { Config } from "./config"
|
||||
import { Identifier } from "./id/id"
|
||||
|
||||
export const MAX_LINES = 2_000
|
||||
export const MAX_BYTES = 50 * 1024 // 50 KiB
|
||||
export const RETENTION = Duration.days(7)
|
||||
export const DIRECTORY = "tool-output"
|
||||
|
||||
type Result = Tool.Result
|
||||
|
||||
export interface Interface {
|
||||
readonly truncate: (result: Result) => Effect.Effect<Result>
|
||||
readonly cleanup: () => Effect.Effect<void>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/ToolOutput") {}
|
||||
|
||||
const cleanup = Effect.fn("ToolOutput.cleanup")(function* (fs: FSUtil.Interface, directory: string) {
|
||||
const cutoff = Identifier.timestamp(
|
||||
Identifier.create("tool", "ascending", Date.now() - Duration.toMillis(RETENTION)),
|
||||
)
|
||||
const entries = yield* fs.readDirectory(directory).pipe(
|
||||
Effect.map((entries) => entries.filter((entry) => /^tool_[0-9a-f]{12}/.test(entry))),
|
||||
Effect.catch(() => Effect.succeed([])),
|
||||
)
|
||||
for (const entry of entries) {
|
||||
if (Identifier.timestamp(entry) >= cutoff) continue
|
||||
yield* fs.remove(path.join(directory, entry)).pipe(Effect.catch(() => Effect.void))
|
||||
}
|
||||
})
|
||||
|
||||
const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const config = yield* Config.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const global = yield* Global.Service
|
||||
const directory = path.join(global.data, DIRECTORY)
|
||||
|
||||
const truncate = Effect.fn("ToolOutput.truncate")(function* (result: Result) {
|
||||
if (result.metadata?.truncated !== undefined) return result
|
||||
const content =
|
||||
typeof result.content === "string" ? [{ type: "text" as const, text: result.content }] : (result.content ?? [])
|
||||
const text = content.flatMap((item) => (item.type === "text" ? [item.text] : [])).join("\n")
|
||||
const configured = Config.latest(yield* config.entries(), "tool_output")
|
||||
const maxLines = configured?.max_lines ?? MAX_LINES
|
||||
const maxBytes = configured?.max_bytes ?? MAX_BYTES
|
||||
const lines = text.split("\n")
|
||||
if (text.endsWith("\n")) lines.pop()
|
||||
const totalBytes = Buffer.byteLength(text, "utf-8")
|
||||
if (lines.length <= maxLines && totalBytes <= maxBytes)
|
||||
return { ...result, metadata: { ...result.metadata, truncated: false } }
|
||||
|
||||
const kept: string[] = []
|
||||
let bytes = 0
|
||||
let hitBytes = false
|
||||
for (const line of lines.slice(0, maxLines)) {
|
||||
const size = Buffer.byteLength(line, "utf-8") + (kept.length > 0 ? 1 : 0)
|
||||
if (bytes + size > maxBytes) {
|
||||
hitBytes = true
|
||||
break
|
||||
}
|
||||
kept.push(line)
|
||||
bytes += size
|
||||
}
|
||||
if (!hitBytes && kept.length === lines.length && totalBytes > bytes) hitBytes = true
|
||||
const removed = hitBytes ? totalBytes - bytes : lines.length - kept.length
|
||||
const unit = hitBytes ? (removed === 1 ? "byte" : "bytes") : removed === 1 ? "line" : "lines"
|
||||
const file = path.join(directory, Identifier.ascending("tool"))
|
||||
yield* fs.ensureDir(directory).pipe(Effect.orDie)
|
||||
yield* fs.writeFileString(file, text).pipe(Effect.orDie)
|
||||
const marker = `... ${removed} ${unit} truncated; full content saved to ${file} ...`
|
||||
const bounded: Tool.Content[] = []
|
||||
let remaining = kept.join("\n").length
|
||||
let seenText = false
|
||||
let marked = false
|
||||
for (const item of content) {
|
||||
if (item.type === "file") {
|
||||
bounded.push(item)
|
||||
continue
|
||||
}
|
||||
if (seenText && remaining > 0) remaining--
|
||||
seenText = true
|
||||
if (remaining >= item.text.length) {
|
||||
bounded.push(item)
|
||||
remaining -= item.text.length
|
||||
continue
|
||||
}
|
||||
if (remaining > 0) bounded.push({ ...item, text: item.text.slice(0, remaining) })
|
||||
if (!marked) bounded.push({ type: "text", text: marker })
|
||||
remaining = 0
|
||||
marked = true
|
||||
}
|
||||
if (!marked) bounded.push({ type: "text", text: marker })
|
||||
return {
|
||||
...result,
|
||||
content: bounded,
|
||||
metadata: { ...result.metadata, truncated: true, outputPath: file },
|
||||
}
|
||||
})
|
||||
|
||||
return Service.of({ truncate, cleanup: () => cleanup(fs, directory) })
|
||||
}),
|
||||
)
|
||||
|
||||
const cleanupLayer = Layer.effectDiscard(
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FSUtil.Service
|
||||
const global = yield* Global.Service
|
||||
yield* cleanup(fs, path.join(global.data, DIRECTORY)).pipe(
|
||||
Effect.repeat(Schedule.spaced(Duration.hours(1))),
|
||||
Effect.forkScoped,
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
const cleanupNode = makeGlobalNode({ name: "tool-output-cleanup", layer: cleanupLayer, deps: [FSUtil.node, Global.node] })
|
||||
|
||||
export const node = makeLocationNode({
|
||||
service: Service,
|
||||
layer,
|
||||
deps: [Config.node, FSUtil.node, Global.node, cleanupNode],
|
||||
})
|
||||
@@ -114,6 +114,7 @@ export const Plugin = {
|
||||
Effect.map((output) => ({
|
||||
output,
|
||||
content: toModelContent(input.path, input.offset, output),
|
||||
metadata: { truncated: output.type === "file" ? false : output.truncated },
|
||||
})),
|
||||
Effect.mapError((error) => {
|
||||
if (error instanceof ToolFailure) return error
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { Content } from "@opencode-ai/schema/tool"
|
||||
import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { Deferred, Effect, Schema, Scope } from "effect"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { Config } from "../../config"
|
||||
import { LocationMutation } from "../../location-mutation"
|
||||
import { Permission } from "../../permission"
|
||||
import { PluginRuntime } from "../../plugin/runtime"
|
||||
@@ -13,10 +14,10 @@ import { NonNegativeInt } from "../../schema"
|
||||
import { SessionSchema } from "../../session/schema"
|
||||
import { Shell } from "../../shell"
|
||||
import { ShellParse } from "../../shell/parse"
|
||||
import { ToolOutput } from "../../tool-output"
|
||||
|
||||
export const name = "shell"
|
||||
export const DEFAULT_TIMEOUT_MS = 2 * 60 * 1_000
|
||||
export const MAX_CAPTURE_BYTES = 1024 * 1024
|
||||
|
||||
const BACKGROUND_STARTED = "The command was moved to the background."
|
||||
const BACKGROUND_INSTRUCTION =
|
||||
@@ -86,6 +87,7 @@ export const Plugin = {
|
||||
const mutation = yield* LocationMutation.Service
|
||||
const shell = yield* Shell.Service
|
||||
const permission = yield* Permission.Service
|
||||
const config = yield* Config.Service
|
||||
|
||||
const notifyWhenDone = Effect.fn("ShellTool.notifyWhenDone")(function* (
|
||||
sessionID: SessionSchema.ID,
|
||||
@@ -191,15 +193,21 @@ export const Plugin = {
|
||||
yield* context.progress({ shellID: info.id })
|
||||
|
||||
const captureShell = Effect.fn("ShellTool.captureShell")(function* () {
|
||||
const configured = Config.latest(yield* config.entries(), "tool_output")
|
||||
const maxLines = configured?.max_lines ?? ToolOutput.MAX_LINES
|
||||
const maxBytes = configured?.max_bytes ?? ToolOutput.MAX_BYTES
|
||||
const latest = yield* shell.output(info.id, { cursor: Number.MAX_SAFE_INTEGER })
|
||||
const truncated = latest.size > MAX_CAPTURE_BYTES
|
||||
const page = yield* shell.output(info.id, {
|
||||
cursor: Math.max(0, latest.size - MAX_CAPTURE_BYTES),
|
||||
limit: MAX_CAPTURE_BYTES,
|
||||
cursor: Math.max(0, latest.size - maxBytes),
|
||||
limit: maxBytes,
|
||||
})
|
||||
const lines = page.output.split("\n")
|
||||
if (page.output.endsWith("\n")) lines.pop()
|
||||
const truncated = latest.size > maxBytes || lines.length > maxLines
|
||||
const output = lines.length > maxLines ? lines.slice(-maxLines).join("\n") : page.output
|
||||
const notice = truncated ? `\n\n[output truncated; full output saved to: ${info.file}]` : ""
|
||||
return {
|
||||
output: `${page.output || "(no output)"}${notice}`,
|
||||
output: `${output || "(no output)"}${notice}`,
|
||||
truncated,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -140,11 +140,7 @@ describe("Integration", () => {
|
||||
yield* integrations.transform((editor) =>
|
||||
editor.method.update({
|
||||
integrationID,
|
||||
method: {
|
||||
type: "key",
|
||||
label: "API key",
|
||||
forms: [{ type: "string", key: "accountId", title: "Account ID", required: true }],
|
||||
},
|
||||
method: { type: "key", label: "API key" },
|
||||
}),
|
||||
)
|
||||
const updated = yield* bus
|
||||
@@ -152,17 +148,9 @@ describe("Integration", () => {
|
||||
.pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
|
||||
yield* Effect.yieldNow
|
||||
|
||||
expect(
|
||||
yield* integrations.connection.key({ integrationID, key: "secret", answers: {} }).pipe(
|
||||
Effect.flip,
|
||||
Effect.map((error) => error.cause),
|
||||
),
|
||||
).toEqual(expect.objectContaining({ message: "Missing required form field: accountId" }))
|
||||
|
||||
yield* integrations.connection.key({
|
||||
integrationID,
|
||||
key: "secret",
|
||||
answers: { accountId: "account" },
|
||||
label: "Work",
|
||||
})
|
||||
|
||||
@@ -170,7 +158,7 @@ describe("Integration", () => {
|
||||
expect.objectContaining({
|
||||
integrationID,
|
||||
label: "Work",
|
||||
value: Credential.Key.make({ type: "key", key: "secret", configuration: { accountId: "account" } }),
|
||||
value: Credential.Key.make({ type: "key", key: "secret" }),
|
||||
}),
|
||||
])
|
||||
expect((yield* Fiber.join(updated)).length).toBe(1)
|
||||
@@ -255,7 +243,7 @@ describe("Integration", () => {
|
||||
const attempt = yield* integrations.oauth.connect({
|
||||
integrationID,
|
||||
methodID,
|
||||
answers: {},
|
||||
inputs: {},
|
||||
label: "Personal",
|
||||
})
|
||||
expect(attempt.mode).toBe("code")
|
||||
@@ -301,7 +289,7 @@ describe("Integration", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
const attempt = yield* integrations.oauth.connect({ integrationID, methodID, answers: {} })
|
||||
const attempt = yield* integrations.oauth.connect({ integrationID, methodID, inputs: {} })
|
||||
expect(
|
||||
yield* integrations.oauth.complete({ integrationID, attemptID: attempt.attemptID }).pipe(Effect.flip),
|
||||
).toBeInstanceOf(Integration.CodeRequiredError)
|
||||
@@ -339,7 +327,7 @@ describe("Integration", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
const attempt = yield* integrations.oauth.connect({ integrationID, methodID, answers: {} })
|
||||
const attempt = yield* integrations.oauth.connect({ integrationID, methodID, inputs: {} })
|
||||
yield* Effect.yieldNow
|
||||
expect(yield* integrations.oauth.status({ integrationID, attemptID: attempt.attemptID })).toEqual({
|
||||
status: "complete",
|
||||
@@ -377,7 +365,7 @@ describe("Integration", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
const attempt = yield* integrations.oauth.connect({ integrationID, methodID, answers: {} })
|
||||
const attempt = yield* integrations.oauth.connect({ integrationID, methodID, inputs: {} })
|
||||
const exit = yield* integrations.oauth
|
||||
.complete({ integrationID, attemptID: attempt.attemptID, code: "1234" })
|
||||
.pipe(Effect.exit)
|
||||
@@ -413,7 +401,7 @@ describe("Integration", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
const attempt = yield* integrations.oauth.connect({ integrationID, methodID, answers: {} })
|
||||
const attempt = yield* integrations.oauth.connect({ integrationID, methodID, inputs: {} })
|
||||
expect(attempt.time.expires - attempt.time.created).toBe(Duration.toMillis(Duration.minutes(10)))
|
||||
yield* TestClock.adjust(Duration.minutes(10))
|
||||
yield* Effect.yieldNow
|
||||
@@ -454,7 +442,7 @@ describe("Integration", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
const attempt = yield* integrations.oauth.connect({ integrationID, methodID, answers: {} })
|
||||
const attempt = yield* integrations.oauth.connect({ integrationID, methodID, inputs: {} })
|
||||
expect(attempt.time).toEqual({ created, expires: expiresAt })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -736,11 +736,7 @@ describe("ModelResolver", () => {
|
||||
headers: { "x-aisdk": "header" },
|
||||
body: { custom: true },
|
||||
}),
|
||||
Credential.Key.make({
|
||||
type: "key",
|
||||
key: "fallback-secret",
|
||||
configuration: { accountId: "account" },
|
||||
}),
|
||||
Credential.Key.make({ type: "key", key: "fallback-secret" }),
|
||||
{
|
||||
loadAISDK: (runtime) =>
|
||||
Effect.sync(() => {
|
||||
@@ -749,7 +745,7 @@ describe("ModelResolver", () => {
|
||||
modelID: "mistral-api-model",
|
||||
providerID: "test-provider",
|
||||
package: Provider.aisdk("@ai-sdk/mistral"),
|
||||
settings: { project: "test", apiKey: "fallback-secret", accountId: "account" },
|
||||
settings: { project: "test", apiKey: "fallback-secret" },
|
||||
headers: { "x-aisdk": "header" },
|
||||
body: { custom: true },
|
||||
})
|
||||
|
||||
@@ -10,7 +10,7 @@ import { Project } from "@opencode-ai/core/project"
|
||||
import { Provider } from "@opencode-ai/core/provider"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { WebSearch } from "@opencode-ai/core/websearch"
|
||||
import { Effect, Schema, Stream } from "effect"
|
||||
import { Effect, Stream } from "effect"
|
||||
|
||||
type Overrides = Partial<Omit<Plugin.Context, "options" | "session">> & {
|
||||
readonly session?: Partial<Plugin.Context["session"]>
|
||||
@@ -226,7 +226,8 @@ export function catalogHost(catalog: Catalog.Interface): Plugin.Context["catalog
|
||||
})),
|
||||
})
|
||||
}),
|
||||
remove: (providerID, modelID) => draft.model.remove(Provider.ID.make(providerID), Model.ID.make(modelID)),
|
||||
remove: (providerID, modelID) =>
|
||||
draft.model.remove(Provider.ID.make(providerID), Model.ID.make(modelID)),
|
||||
default: {
|
||||
get: () => {
|
||||
const value = draft.model.default.get()
|
||||
@@ -285,9 +286,9 @@ export function integrationHost(integration: Integration.Interface): Plugin.Cont
|
||||
const refresh = input.refresh
|
||||
draft.method.update({
|
||||
integrationID: Integration.ID.make(input.integrationID),
|
||||
method: oauthMethod(input.method, methodID),
|
||||
authorize: (answers) =>
|
||||
input.authorize(answers).pipe(
|
||||
method: { ...input.method, id: methodID },
|
||||
authorize: (inputs) =>
|
||||
input.authorize(inputs).pipe(
|
||||
Effect.map((authorization) => {
|
||||
if (authorization.mode === "auto") {
|
||||
return {
|
||||
@@ -353,7 +354,7 @@ export function integrationHost(integration: Integration.Interface): Plugin.Cont
|
||||
}
|
||||
draft.method.update({
|
||||
integrationID: Integration.ID.make(input.integrationID),
|
||||
method: keyMethod(input.method),
|
||||
method: input.method,
|
||||
})
|
||||
},
|
||||
remove: (id, item) => draft.method.remove(Integration.ID.make(id), internalMethod(item)),
|
||||
@@ -401,21 +402,26 @@ function oauthCredential(value: Credential.OAuth) {
|
||||
return Credential.OAuth.make({ ...value, methodID: Integration.MethodID.make(value.methodID) })
|
||||
}
|
||||
|
||||
function method(value: Integration.Method): IntegrationMethodRegistration["method"] {
|
||||
function method(value: Integration.Method) {
|
||||
if (value.type === "env") return { type: value.type, names: [...value.names] }
|
||||
if (value.type === "key") return { type: value.type, label: value.label, forms: mutable(value.forms) }
|
||||
if (value.type === "key") return { type: value.type, label: value.label }
|
||||
if (value.type === "command") return { ...value, command: [...value.command] }
|
||||
return {
|
||||
type: value.type,
|
||||
id: value.id,
|
||||
label: value.label,
|
||||
forms: mutable(value.forms),
|
||||
prompts: value.prompts?.map((prompt) => {
|
||||
if (prompt.type === "text") return { ...prompt }
|
||||
return { ...prompt, options: prompt.options.map((option) => ({ ...option })) }
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
function internalMethod(value: IntegrationMethodRegistration["method"]): Integration.Method {
|
||||
function internalMethod(
|
||||
value: IntegrationMethodRegistration["method"],
|
||||
): Integration.Method {
|
||||
if (value.type === "env") return value
|
||||
if (value.type === "key") return keyMethod(value)
|
||||
if (value.type === "key") return value
|
||||
if (value.type === "command") {
|
||||
return {
|
||||
...value,
|
||||
@@ -423,41 +429,10 @@ function internalMethod(value: IntegrationMethodRegistration["method"]): Integra
|
||||
command: [...value.command],
|
||||
}
|
||||
}
|
||||
return oauthMethod(value, Integration.MethodID.make(value.id))
|
||||
}
|
||||
|
||||
type Mutable<Value> = Value extends readonly [infer Head, ...infer Tail]
|
||||
? [Mutable<Head>, ...MutableTuple<Tail>]
|
||||
: Value extends ReadonlyArray<infer Item>
|
||||
? Array<Mutable<Item>>
|
||||
: Value extends object
|
||||
? { -readonly [Key in keyof Value]: Mutable<Value[Key]> }
|
||||
: Value
|
||||
|
||||
type MutableTuple<Value extends ReadonlyArray<unknown>> = {
|
||||
-readonly [Key in keyof Value]: Mutable<Value[Key]>
|
||||
}
|
||||
|
||||
function mutable<Value>(value: Value): Mutable<Value>
|
||||
function mutable(value: unknown): unknown {
|
||||
return structuredClone(value)
|
||||
}
|
||||
|
||||
function keyMethod(value: IntegrationMethodRegistration["method"] & { type: "key" }) {
|
||||
return Schema.decodeUnknownSync(Integration.KeyMethod)({
|
||||
type: "key",
|
||||
...(value.label === undefined ? {} : { label: value.label }),
|
||||
...(value.forms === undefined ? {} : { forms: value.forms }),
|
||||
})
|
||||
}
|
||||
|
||||
function oauthMethod(value: IntegrationMethodRegistration["method"] & { type: "oauth" }, id: Integration.MethodID) {
|
||||
return Schema.decodeUnknownSync(Integration.OAuthMethod)({
|
||||
id,
|
||||
type: "oauth",
|
||||
label: value.label,
|
||||
...(value.forms === undefined ? {} : { forms: value.forms }),
|
||||
})
|
||||
return {
|
||||
...value,
|
||||
id: Integration.MethodID.make(value.id),
|
||||
}
|
||||
}
|
||||
|
||||
function agentInfo(value: Agent.Info) {
|
||||
|
||||
@@ -8,7 +8,6 @@ import { Plugin } from "@opencode-ai/core/plugin"
|
||||
import { PluginHost } from "@opencode-ai/core/plugin/host"
|
||||
import { AzurePlugin } from "@opencode-ai/core/plugin/provider/azure"
|
||||
import { Provider } from "@opencode-ai/core/provider"
|
||||
import { Integration } from "@opencode-ai/core/integration"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { PluginTestLayer } from "./fixture"
|
||||
|
||||
@@ -61,27 +60,6 @@ function fakeSelectorSdk(calls: string[]) {
|
||||
}
|
||||
|
||||
describe("AzurePlugin", () => {
|
||||
it.effect("registers a resource name form when the environment does not provide one", () =>
|
||||
withEnv({ AZURE_RESOURCE_NAME: undefined, AZURE_COGNITIVE_SERVICES_RESOURCE_NAME: undefined }, () =>
|
||||
Effect.gen(function* () {
|
||||
yield* addPlugin()
|
||||
expect((yield* (yield* Integration.Service).get(Integration.ID.make("azure")))?.methods).toContainEqual({
|
||||
type: "key",
|
||||
label: "API key",
|
||||
forms: [
|
||||
{
|
||||
type: "string",
|
||||
key: "resourceName",
|
||||
title: "Enter Azure Resource Name",
|
||||
placeholder: "e.g. my-models",
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
})
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("resolves resourceName from env", () =>
|
||||
withEnv({ AZURE_RESOURCE_NAME: "from-env" }, () =>
|
||||
Effect.gen(function* () {
|
||||
@@ -217,17 +195,7 @@ describe("AzurePlugin", () => {
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* Plugin.Service
|
||||
const aisdk = yield* AISDK.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* catalog.transform((catalog) =>
|
||||
catalog.provider.update(Provider.ID.azure, (provider) => {
|
||||
provider.settings = { ...provider.settings, baseURL: "https://proxy.example.com/openai" }
|
||||
}),
|
||||
)
|
||||
yield* addPlugin()
|
||||
expect((yield* (yield* Integration.Service).get(Integration.ID.make("azure")))?.methods).toContainEqual({
|
||||
type: "key",
|
||||
label: "API key",
|
||||
})
|
||||
const result = yield* aisdk.runSDK({
|
||||
model: Model.Info.make({
|
||||
...Model.Info.default(Provider.ID.azure, Model.ID.make("deployment")),
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
import { AISDK } from "@opencode-ai/core/aisdk"
|
||||
import { describe, expect, mock } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { Catalog } from "@opencode-ai/core/catalog"
|
||||
import { Model } from "@opencode-ai/core/model"
|
||||
import { Plugin } from "@opencode-ai/core/plugin"
|
||||
import { PluginHost } from "@opencode-ai/core/plugin/host"
|
||||
import { CloudflareAIGatewayPlugin } from "@opencode-ai/core/plugin/provider/cloudflare-ai-gateway"
|
||||
import { Provider } from "@opencode-ai/core/provider"
|
||||
import { Integration } from "@opencode-ai/core/integration"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { PluginTestLayer } from "./fixture"
|
||||
|
||||
@@ -104,24 +102,6 @@ mock.module("ai-gateway-provider/providers/unified", () => ({
|
||||
}))
|
||||
|
||||
describe("CloudflareAIGatewayPlugin", () => {
|
||||
it.effect("registers account and gateway forms when the environment does not provide them", () =>
|
||||
withEnv({ CLOUDFLARE_ACCOUNT_ID: undefined, CLOUDFLARE_GATEWAY_ID: undefined }, () =>
|
||||
Effect.gen(function* () {
|
||||
yield* addPlugin()
|
||||
expect(
|
||||
(yield* (yield* Integration.Service).get(Integration.ID.make("cloudflare-ai-gateway")))?.methods,
|
||||
).toContainEqual({
|
||||
type: "key",
|
||||
label: "Gateway API token",
|
||||
forms: [
|
||||
expect.objectContaining({ type: "string", key: "accountId", required: true }),
|
||||
expect.objectContaining({ type: "string", key: "gatewayId", required: true }),
|
||||
],
|
||||
})
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("requires account, gateway, and token before creating the unified SDK", () =>
|
||||
withEnv(
|
||||
{
|
||||
@@ -377,16 +357,7 @@ describe("CloudflareAIGatewayPlugin", () => {
|
||||
resetCalls()
|
||||
const plugin = yield* Plugin.Service
|
||||
const aisdk = yield* AISDK.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* catalog.transform((catalog) =>
|
||||
catalog.provider.update(Provider.ID.make("cloudflare-ai-gateway"), (provider) => {
|
||||
provider.settings = { ...provider.settings, baseURL: "https://proxy.example/v1" }
|
||||
}),
|
||||
)
|
||||
yield* addPlugin()
|
||||
expect(
|
||||
(yield* (yield* Integration.Service).get(Integration.ID.make("cloudflare-ai-gateway")))?.methods,
|
||||
).toContainEqual({ type: "key", label: "Gateway API token" })
|
||||
|
||||
const result = yield* aisdk.runSDK({
|
||||
model: Model.Info.make({
|
||||
|
||||
@@ -7,7 +7,6 @@ import { Plugin } from "@opencode-ai/core/plugin"
|
||||
import { PluginHost } from "@opencode-ai/core/plugin/host"
|
||||
import { CloudflareWorkersAIPlugin } from "@opencode-ai/core/plugin/provider/cloudflare-workers-ai"
|
||||
import { Provider } from "@opencode-ai/core/provider"
|
||||
import { Integration } from "@opencode-ai/core/integration"
|
||||
import type { LanguageModelV3 } from "@ai-sdk/provider"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { PluginTestLayer } from "./fixture"
|
||||
@@ -80,29 +79,6 @@ function cloudflareHeaders(sdk: unknown, modelID = "@cf/model") {
|
||||
}
|
||||
|
||||
describe("CloudflareWorkersAIPlugin", () => {
|
||||
it.effect("registers an account form when the environment does not provide one", () =>
|
||||
withEnv({ CLOUDFLARE_ACCOUNT_ID: undefined }, () =>
|
||||
Effect.gen(function* () {
|
||||
yield* addPlugin()
|
||||
expect(
|
||||
(yield* (yield* Integration.Service).get(Integration.ID.make("cloudflare-workers-ai")))?.methods,
|
||||
).toContainEqual({
|
||||
type: "key",
|
||||
label: "API key",
|
||||
forms: [
|
||||
{
|
||||
type: "string",
|
||||
key: "accountId",
|
||||
title: "Enter your Cloudflare Account ID",
|
||||
placeholder: "e.g. 1234567890abcdef1234567890abcdef",
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
})
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("maps account ID to endpoint URL and creates an OpenAI-compatible SDK", () =>
|
||||
withEnv({ CLOUDFLARE_ACCOUNT_ID: "acct", CLOUDFLARE_API_KEY: "key" }, () =>
|
||||
Effect.gen(function* () {
|
||||
@@ -115,9 +91,6 @@ describe("CloudflareWorkersAIPlugin", () => {
|
||||
}),
|
||||
)
|
||||
yield* addPlugin()
|
||||
expect(
|
||||
(yield* (yield* Integration.Service).get(Integration.ID.make("cloudflare-workers-ai")))?.methods,
|
||||
).toContainEqual({ type: "key", label: "API key" })
|
||||
const provider = required(yield* catalog.provider.get(Provider.ID.make("cloudflare-workers-ai")))
|
||||
const sdk = yield* aisdk.runSDK({
|
||||
model: Model.Info.make({
|
||||
@@ -162,16 +135,7 @@ describe("CloudflareWorkersAIPlugin", () => {
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* Plugin.Service
|
||||
const aisdk = yield* AISDK.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* catalog.transform((catalog) =>
|
||||
catalog.provider.update(Provider.ID.make("cloudflare-workers-ai"), (provider) => {
|
||||
provider.settings = { ...provider.settings, baseURL: "https://proxy.example/v1" }
|
||||
}),
|
||||
)
|
||||
yield* addPlugin()
|
||||
expect(
|
||||
(yield* (yield* Integration.Service).get(Integration.ID.make("cloudflare-workers-ai")))?.methods,
|
||||
).toContainEqual({ type: "key", label: "API key" })
|
||||
const result = yield* aisdk.runSDK({
|
||||
model: Model.Info.make({
|
||||
...Model.Info.default(Provider.ID.make("cloudflare-workers-ai"), Model.ID.make("@cf/model")),
|
||||
|
||||
@@ -57,7 +57,7 @@ describe("GithubCopilotPlugin", () => {
|
||||
id: Integration.MethodID.make("device"),
|
||||
type: "oauth",
|
||||
label: "Login with GitHub Copilot",
|
||||
forms: expect.any(Array),
|
||||
prompts: expect.any(Array),
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -128,7 +128,7 @@ describe("OpencodePlugin", () => {
|
||||
const attempt = yield* integrations.oauth.connect({
|
||||
integrationID,
|
||||
methodID: Integration.MethodID.make("device"),
|
||||
answers: { server: `${server.url.origin}/console///?ignored=true#ignored` },
|
||||
inputs: { server: `${server.url.origin}/console///?ignored=true#ignored` },
|
||||
})
|
||||
expect(attempt.url).toBe(`${server.url.origin}/verify`)
|
||||
yield* eventually(
|
||||
@@ -155,7 +155,7 @@ describe("OpencodePlugin", () => {
|
||||
.connect({
|
||||
integrationID: Integration.ID.make("opencode"),
|
||||
methodID: Integration.MethodID.make("device"),
|
||||
answers: { server: "ftp://console.example.com" },
|
||||
inputs: { server: "ftp://console.example.com" },
|
||||
})
|
||||
.pipe(Effect.flip)
|
||||
expect(error).toBeInstanceOf(Integration.AuthorizationError)
|
||||
|
||||
@@ -67,7 +67,7 @@ describe("built-in web search providers", () => {
|
||||
name: "Exa",
|
||||
methods: [{ type: "key" }, { type: "env", names: ["EXA_API_KEY"] }],
|
||||
})
|
||||
yield* integrations.connection.key({ integrationID: Integration.ID.make("exa"), key: "exa secret", answers: {} })
|
||||
yield* integrations.connection.key({ integrationID: Integration.ID.make("exa"), key: "exa secret" })
|
||||
expect(yield* websearch.query({ query: "effect typescript", providerID: WebSearch.ID.make("exa") })).toEqual(
|
||||
new WebSearch.Response({
|
||||
providerID: WebSearch.ID.make("exa"),
|
||||
@@ -129,11 +129,7 @@ describe("built-in web search providers", () => {
|
||||
yield* WebSearchParallel.Plugin.effect(
|
||||
host({ integration: integrationHost(integrations), websearch: webSearchHost(websearch) }),
|
||||
)
|
||||
yield* integrations.connection.key({
|
||||
integrationID: Integration.ID.make("parallel"),
|
||||
key: "parallel-secret",
|
||||
answers: {},
|
||||
})
|
||||
yield* integrations.connection.key({ integrationID: Integration.ID.make("parallel"), key: "parallel-secret" })
|
||||
|
||||
const output = yield* websearch.query({
|
||||
query: "effect layers",
|
||||
|
||||
@@ -1086,4 +1086,78 @@ describe("Session.pending", () => {
|
||||
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)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -90,10 +90,15 @@ test("Core reuses the canonical shared schemas", async () => {
|
||||
[coreFileSystem.Match, FileSystem.Match],
|
||||
[coreIntegration.ID, Integration.ID],
|
||||
[coreIntegration.MethodID, Integration.MethodID],
|
||||
[coreIntegration.When, Integration.When],
|
||||
[coreIntegration.TextPrompt, Integration.TextPrompt],
|
||||
[coreIntegration.SelectPrompt, Integration.SelectPrompt],
|
||||
[coreIntegration.Prompt, Integration.Prompt],
|
||||
[coreIntegration.OAuthMethod, Integration.OAuthMethod],
|
||||
[coreIntegration.KeyMethod, Integration.KeyMethod],
|
||||
[coreIntegration.EnvMethod, Integration.EnvMethod],
|
||||
[coreIntegration.Method, Integration.Method],
|
||||
[coreIntegration.Inputs, Integration.Inputs],
|
||||
[coreIntegration.Ref, Integration.Ref],
|
||||
[coreLocation.Ref, Location.Ref],
|
||||
[coreAI.ProviderMetadata, AI.ProviderMetadata],
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import path from "path"
|
||||
import { Effect, Layer, Stream } from "effect"
|
||||
import { Config } from "@opencode-ai/core/config"
|
||||
import { Document, Info } from "@opencode-ai/schema/config"
|
||||
import { ConfigToolOutput } from "@opencode-ai/schema/config/tool-output"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { ToolOutput } from "@opencode-ai/core/tool-output"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { Identifier } from "@opencode-ai/core/id/id"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
import { it } from "./lib/effect"
|
||||
|
||||
const withStore = <A, E, R>(
|
||||
body: (output: ToolOutput.Interface, fs: FSUtil.Interface, root: string) => Effect.Effect<A, E, R>,
|
||||
info = new Info(),
|
||||
) =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => {
|
||||
const config = Layer.succeed(
|
||||
Config.Service,
|
||||
Config.Service.of({
|
||||
entries: () => Effect.succeed([new Document({ type: "document", info })]),
|
||||
changes: () => Stream.empty,
|
||||
}),
|
||||
)
|
||||
const layer = AppNodeBuilder.build(LayerNode.group([ToolOutput.node, FSUtil.node]), [
|
||||
[Config.node, config],
|
||||
[Global.node, Global.layerWith({ data: tmp.path })],
|
||||
])
|
||||
return Effect.gen(function* () {
|
||||
return yield* body(yield* ToolOutput.Service, yield* FSUtil.Service, tmp.path)
|
||||
}).pipe(Effect.provide(layer))
|
||||
},
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
)
|
||||
|
||||
describe("ToolOutput", () => {
|
||||
it.live("writes oversized text and returns a bounded preview", () =>
|
||||
withStore(
|
||||
(service, fs) =>
|
||||
Effect.gen(function* () {
|
||||
const output = { items: [1, 2, 3] }
|
||||
const result = yield* service.truncate({ output, content: "one\ntwo\nthree" })
|
||||
expect(result.output).toBe(output)
|
||||
expect(result.metadata).toMatchObject({ truncated: true })
|
||||
const outputPath = result.metadata?.outputPath
|
||||
expect(typeof outputPath).toBe("string")
|
||||
if (typeof outputPath !== "string") return
|
||||
expect(yield* fs.readFileString(outputPath)).toBe("one\ntwo\nthree")
|
||||
expect(result.content).toEqual([
|
||||
{ type: "text", text: "one\ntwo" },
|
||||
{ type: "text", text: `... 1 line truncated; full content saved to ${outputPath} ...` },
|
||||
])
|
||||
}),
|
||||
new Info({ tool_output: new ConfigToolOutput.Info({ max_lines: 2, max_bytes: 1_000 }) }),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("reports bytes omitted by the byte limit", () =>
|
||||
withStore(
|
||||
(output) =>
|
||||
Effect.gen(function* () {
|
||||
const result = yield* output.truncate({ content: "one\ntwo" })
|
||||
expect(result.content).toEqual([
|
||||
{ type: "text", text: "one" },
|
||||
{
|
||||
type: "text",
|
||||
text: expect.stringMatching(/^\.\.\. 4 bytes truncated; full content saved to .+ \.\.\.$/),
|
||||
},
|
||||
])
|
||||
}),
|
||||
new Info({ tool_output: new ConfigToolOutput.Info({ max_lines: 100, max_bytes: 5 }) }),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("preserves mixed content ordering", () =>
|
||||
withStore(
|
||||
(output) =>
|
||||
Effect.gen(function* () {
|
||||
const file = { type: "file" as const, uri: "file:///image.png", mime: "image/png" }
|
||||
const result = yield* output.truncate({
|
||||
content: [{ type: "text", text: "before" }, file, { type: "text", text: "after\nomitted" }],
|
||||
})
|
||||
expect(result.content).toEqual([
|
||||
{ type: "text", text: "before" },
|
||||
file,
|
||||
{ type: "text", text: "after" },
|
||||
{ type: "text", text: expect.stringMatching(/^\.\.\. 1 line truncated; full content saved to /) },
|
||||
])
|
||||
}),
|
||||
new Info({ tool_output: new ConfigToolOutput.Info({ max_lines: 2, max_bytes: 1_000 }) }),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("skips results that report a truncation state", () =>
|
||||
withStore((output) =>
|
||||
Effect.gen(function* () {
|
||||
const truncated = { content: "one\ntwo", metadata: { truncated: true, source: "tool" } }
|
||||
const retained = { content: "one\ntwo", metadata: { truncated: false, source: "tool" } }
|
||||
expect(yield* output.truncate(truncated)).toBe(truncated)
|
||||
expect(yield* output.truncate(retained)).toBe(retained)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("marks results that fit without changing their content", () =>
|
||||
withStore((output) =>
|
||||
Effect.gen(function* () {
|
||||
const content = [{ type: "text" as const, text: "small" }]
|
||||
expect(yield* output.truncate({ content })).toEqual({ content, metadata: { truncated: false } })
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("does not count a trailing newline as another line", () =>
|
||||
withStore(
|
||||
(output) =>
|
||||
Effect.gen(function* () {
|
||||
expect(yield* output.truncate({ content: "one\ntwo\n" })).toEqual({
|
||||
content: "one\ntwo\n",
|
||||
metadata: { truncated: false },
|
||||
})
|
||||
}),
|
||||
new Info({ tool_output: new ConfigToolOutput.Info({ max_lines: 2, max_bytes: 1_000 }) }),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("reports a trailing newline omitted by the byte limit", () =>
|
||||
withStore(
|
||||
(output) =>
|
||||
Effect.gen(function* () {
|
||||
const result = yield* output.truncate({ content: "one\n" })
|
||||
expect(result.content).toEqual([
|
||||
{ type: "text", text: "one" },
|
||||
{ type: "text", text: expect.stringMatching(/^\.\.\. 1 byte truncated; full content saved to /) },
|
||||
])
|
||||
}),
|
||||
new Info({ tool_output: new ConfigToolOutput.Info({ max_lines: 2, max_bytes: 3 }) }),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("removes expired managed files", () =>
|
||||
withStore((output, fs, root) =>
|
||||
Effect.gen(function* () {
|
||||
const directory = path.join(root, ToolOutput.DIRECTORY)
|
||||
const old = path.join(
|
||||
directory,
|
||||
Identifier.create("tool", "ascending", Date.now() - 8 * 24 * 60 * 60 * 1_000),
|
||||
)
|
||||
const recent = path.join(directory, Identifier.ascending("tool"))
|
||||
yield* fs.ensureDir(directory)
|
||||
yield* fs.writeFileString(old, "old")
|
||||
yield* fs.writeFileString(recent, "recent")
|
||||
yield* output.cleanup()
|
||||
expect(yield* fs.exists(old)).toBe(false)
|
||||
expect(yield* fs.exists(recent)).toBe(true)
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
@@ -311,9 +311,7 @@ describe("ReadTool", () => {
|
||||
})
|
||||
expect(settled.status).toBe("completed")
|
||||
if (settled.status !== "completed") return
|
||||
// Image base64 is carried by the content file item only; read produces no
|
||||
// metadata, so the original bytes are never persisted twice.
|
||||
expect(settled.metadata).toBeUndefined()
|
||||
expect(settled.metadata).toEqual({ truncated: false })
|
||||
expect(settled.content).toMatchObject([
|
||||
{ type: "text", text: "Image read successfully" },
|
||||
{ type: "file", mime: "image/png", uri: `data:image/png;base64,${png}` },
|
||||
@@ -731,6 +729,7 @@ describe("ReadTool", () => {
|
||||
output: { entries: listResult.entries, truncated: true, next: 4 },
|
||||
})
|
||||
if (result.status !== "completed") return
|
||||
expect(result.metadata).toEqual({ truncated: true })
|
||||
expect(result.content).toEqual([
|
||||
{
|
||||
type: "text",
|
||||
@@ -805,6 +804,7 @@ describe("ReadTool", () => {
|
||||
output: { type: "text-page", content: "hello", mime: "text/plain", offset: 2, truncated: true, next: 3 },
|
||||
})
|
||||
if (result.status !== "completed") return
|
||||
expect(result.metadata).toEqual({ truncated: true })
|
||||
expect(result.content).toEqual([
|
||||
{
|
||||
type: "text",
|
||||
|
||||
@@ -30,6 +30,7 @@ import { PluginRuntime } from "@opencode-ai/core/plugin/runtime"
|
||||
import { Shell } from "@opencode-ai/core/shell"
|
||||
import { Shell as ShellSchema } from "@opencode-ai/schema/shell"
|
||||
import { ShellTool } from "@opencode-ai/core/tool/plugin/shell"
|
||||
import { ToolOutput } from "@opencode-ai/core/tool-output"
|
||||
import { Tool } from "@opencode-ai/core/tool"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
import { testEffect } from "./lib/effect"
|
||||
@@ -171,6 +172,9 @@ const overflowCommand = (bytes: number) =>
|
||||
isWindows
|
||||
? `[Console]::Out.Write('output-start' + ('x' * ${bytes}) + 'output-end'); Start-Sleep -Milliseconds 100`
|
||||
: `printf output-start; head -c ${bytes} /dev/zero | tr '\\0' 'x'; printf output-end`
|
||||
const lineOverflowCommand = isWindows
|
||||
? "[Console]::Out.Write('one' + [Environment]::NewLine + 'two' + [Environment]::NewLine + 'three')"
|
||||
: "printf 'one\\ntwo\\nthree'"
|
||||
const progressOverflowCommand = (bytes: number, release: string) =>
|
||||
isWindows
|
||||
? `[Console]::Out.Write(('x' * ${bytes})); while (!(Test-Path -LiteralPath '${release}')) { Start-Sleep -Milliseconds 50 }`
|
||||
@@ -477,7 +481,7 @@ describe("ShellTool", () => {
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => {
|
||||
reset()
|
||||
const bytes = ShellTool.MAX_CAPTURE_BYTES + 1024
|
||||
const bytes = ToolOutput.MAX_BYTES + 1024
|
||||
return withSession(tmp.path, (registry) =>
|
||||
executeTool(registry, call({ command: overflowCommand(bytes) }, "call-overflow")),
|
||||
).pipe(
|
||||
@@ -501,6 +505,33 @@ describe("ShellTool", () => {
|
||||
{ timeout: 15_000 },
|
||||
)
|
||||
|
||||
it.live("uses configured line limits", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => {
|
||||
reset()
|
||||
return Effect.gen(function* () {
|
||||
yield* Effect.promise(() =>
|
||||
Bun.write(
|
||||
path.join(tmp.path, "opencode.json"),
|
||||
JSON.stringify({ tool_output: { max_lines: 2, max_bytes: 1_000 } }),
|
||||
),
|
||||
)
|
||||
const settled = yield* withSession(tmp.path, (registry) =>
|
||||
executeTool(registry, call({ command: lineOverflowCommand }, "call-line-overflow")),
|
||||
)
|
||||
expect(settled.metadata).toMatchObject({ exit: 0, truncated: true })
|
||||
const content = settled.content?.[0]
|
||||
if (!content || content.type !== "text") throw new Error("Expected text content")
|
||||
expect(content.text).not.toContain("one")
|
||||
expect(content.text).toStartWith("two\nthree")
|
||||
expect(content.text).toContain("output truncated; full output saved to:")
|
||||
})
|
||||
},
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]().then(() => undefined)),
|
||||
),
|
||||
)
|
||||
|
||||
it.live(
|
||||
"reports the shell ID for a running command",
|
||||
() =>
|
||||
@@ -515,7 +546,7 @@ describe("ShellTool", () => {
|
||||
const observed = yield* Deferred.make<string>()
|
||||
yield* executeTool(registry, {
|
||||
...call(
|
||||
{ command: progressOverflowCommand(ShellTool.MAX_CAPTURE_BYTES + 1024, release) },
|
||||
{ command: progressOverflowCommand(ToolOutput.MAX_BYTES + 1024, release) },
|
||||
"call-progress",
|
||||
),
|
||||
progress: (update) =>
|
||||
|
||||
@@ -8,10 +8,10 @@ import type {
|
||||
} from "@opencode-ai/client"
|
||||
import type { IntegrationApi } from "@opencode-ai/client/effect/api"
|
||||
import { Credential } from "@opencode-ai/schema/credential"
|
||||
import { Form } from "@opencode-ai/schema/form"
|
||||
import type { Effect, Scope } from "effect"
|
||||
import type { Transform } from "./registration.js"
|
||||
|
||||
type IntegrationInputs = Record<string, string>
|
||||
type IntegrationRef = { id: string; name: string }
|
||||
|
||||
export type IntegrationOAuthAuthorization = {
|
||||
@@ -31,7 +31,7 @@ export type IntegrationOAuthAuthorization = {
|
||||
export type IntegrationOAuthMethodRegistration = {
|
||||
readonly integrationID: string
|
||||
readonly method: IntegrationOAuthMethod
|
||||
readonly authorize: (answers: Form.Answer) => Effect.Effect<IntegrationOAuthAuthorization, unknown, Scope.Scope>
|
||||
readonly authorize: (inputs: IntegrationInputs) => Effect.Effect<IntegrationOAuthAuthorization, unknown, Scope.Scope>
|
||||
readonly refresh?: (credential: Credential.OAuth) => Effect.Effect<Credential.OAuth, unknown>
|
||||
readonly label?: (credential: Credential.OAuth) => string | undefined
|
||||
}
|
||||
|
||||
@@ -8,9 +8,9 @@ import type {
|
||||
} from "@opencode-ai/client"
|
||||
import type { IntegrationApi } from "@opencode-ai/client/promise/api"
|
||||
import { Credential } from "@opencode-ai/schema/credential"
|
||||
import { Form } from "@opencode-ai/schema/form"
|
||||
import type { Transform } from "./registration.js"
|
||||
|
||||
type IntegrationInputs = Record<string, string>
|
||||
type IntegrationRef = { id: string; name: string }
|
||||
|
||||
export type IntegrationOAuthAuthorization = {
|
||||
@@ -31,7 +31,7 @@ export type IntegrationOAuthAuthorization = {
|
||||
export type IntegrationOAuthMethodRegistration = {
|
||||
readonly integrationID: string
|
||||
readonly method: IntegrationOAuthMethod
|
||||
readonly authorize: (answers: Form.Answer) => Promise<IntegrationOAuthAuthorization>
|
||||
readonly authorize: (inputs: IntegrationInputs) => Promise<IntegrationOAuthAuthorization>
|
||||
readonly refresh?: (credential: Credential.OAuth) => Promise<Credential.OAuth>
|
||||
readonly label?: (credential: Credential.OAuth) => string | undefined
|
||||
}
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { Integration } from "@opencode-ai/schema/integration"
|
||||
import { Location } from "@opencode-ai/schema/location"
|
||||
import { Form } from "@opencode-ai/schema/form"
|
||||
import { Schema } from "effect"
|
||||
import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
|
||||
import { InvalidRequestError } from "../errors.js"
|
||||
import { LocationQuery, locationQueryOpenApi } from "./location.js"
|
||||
|
||||
const Inputs = Schema.Record(Schema.String, Schema.String)
|
||||
|
||||
export const IntegrationGroup = HttpApiGroup.make("server.integration")
|
||||
.add(
|
||||
HttpApiEndpoint.get("integration.list", "/api/integration", {
|
||||
@@ -58,7 +59,6 @@ export const IntegrationGroup = HttpApiGroup.make("server.integration")
|
||||
query: LocationQuery,
|
||||
payload: Schema.Struct({
|
||||
key: Schema.String,
|
||||
answers: Form.Answer,
|
||||
label: Schema.optional(Schema.String),
|
||||
}),
|
||||
success: HttpApiSchema.NoContent,
|
||||
@@ -79,7 +79,7 @@ export const IntegrationGroup = HttpApiGroup.make("server.integration")
|
||||
query: LocationQuery,
|
||||
payload: Schema.Struct({
|
||||
methodID: Integration.MethodID,
|
||||
answers: Form.Answer,
|
||||
inputs: Inputs,
|
||||
label: Schema.optional(Schema.String),
|
||||
}),
|
||||
success: Location.response(Integration.Attempt),
|
||||
|
||||
@@ -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(
|
||||
HttpApiEndpoint.get("session.instructions.entry.list", "/api/session/:sessionID/instructions/entries", {
|
||||
params: { sessionID: Session.ID },
|
||||
|
||||
@@ -5,7 +5,6 @@ import { optional } from "./schema.js"
|
||||
import { IntegrationMethodID } from "./integration-id.js"
|
||||
import { ascending } from "./identifier.js"
|
||||
import { NonNegativeInt, statics } from "./schema.js"
|
||||
import { Form } from "./form.js"
|
||||
|
||||
export const ID = Schema.String.pipe(
|
||||
Schema.brand("Credential.ID"),
|
||||
@@ -28,7 +27,6 @@ export const Key = Schema.Struct({
|
||||
type: Schema.Literal("key"),
|
||||
key: Schema.String,
|
||||
metadata: optional(Schema.Record(Schema.String, Schema.Unknown)),
|
||||
configuration: optional(Form.Answer),
|
||||
}).annotate({ identifier: "Credential.Key" })
|
||||
|
||||
export const Value = Schema.Union([OAuth, Key])
|
||||
|
||||
@@ -7,7 +7,6 @@ import { Connection } from "./connection.js"
|
||||
import { ascending } from "./identifier.js"
|
||||
import { statics } from "./schema.js"
|
||||
import { IntegrationID, IntegrationMethodID } from "./integration-id.js"
|
||||
import { Form } from "./form.js"
|
||||
|
||||
export const ID = IntegrationID
|
||||
export type ID = typeof ID.Type
|
||||
@@ -15,12 +14,46 @@ export type ID = typeof ID.Type
|
||||
export const MethodID = IntegrationMethodID
|
||||
export type MethodID = typeof MethodID.Type
|
||||
|
||||
export interface When extends Schema.Schema.Type<typeof When> {}
|
||||
export const When = Schema.Struct({
|
||||
key: Schema.String,
|
||||
op: Schema.Literals(["eq", "neq"]),
|
||||
value: Schema.String,
|
||||
}).annotate({ identifier: "Integration.When" })
|
||||
|
||||
export interface TextPrompt extends Schema.Schema.Type<typeof TextPrompt> {}
|
||||
export const TextPrompt = Schema.Struct({
|
||||
type: Schema.Literal("text"),
|
||||
key: Schema.String,
|
||||
message: Schema.String,
|
||||
placeholder: optional(Schema.String),
|
||||
when: optional(When),
|
||||
}).annotate({ identifier: "Integration.TextPrompt" })
|
||||
|
||||
export interface SelectPrompt extends Schema.Schema.Type<typeof SelectPrompt> {}
|
||||
export const SelectPrompt = Schema.Struct({
|
||||
type: Schema.Literal("select"),
|
||||
key: Schema.String,
|
||||
message: Schema.String,
|
||||
options: Schema.Array(
|
||||
Schema.Struct({
|
||||
label: Schema.String,
|
||||
value: Schema.String,
|
||||
hint: optional(Schema.String),
|
||||
}),
|
||||
),
|
||||
when: optional(When),
|
||||
}).annotate({ identifier: "Integration.SelectPrompt" })
|
||||
|
||||
export const Prompt = Schema.Union([TextPrompt, SelectPrompt]).pipe(Schema.toTaggedUnion("type"))
|
||||
export type Prompt = typeof Prompt.Type
|
||||
|
||||
export interface OAuthMethod extends Schema.Schema.Type<typeof OAuthMethod> {}
|
||||
export const OAuthMethod = Schema.Struct({
|
||||
id: MethodID,
|
||||
type: Schema.Literal("oauth"),
|
||||
label: Schema.String,
|
||||
forms: optional(Form.Fields),
|
||||
prompts: optional(Schema.Array(Prompt)),
|
||||
}).annotate({ identifier: "Integration.OAuthMethod" })
|
||||
|
||||
export interface CommandMethod extends Schema.Schema.Type<typeof CommandMethod> {}
|
||||
@@ -35,7 +68,6 @@ export interface KeyMethod extends Schema.Schema.Type<typeof KeyMethod> {}
|
||||
export const KeyMethod = Schema.Struct({
|
||||
type: Schema.Literal("key"),
|
||||
label: optional(Schema.String),
|
||||
forms: optional(Form.Fields),
|
||||
}).annotate({ identifier: "Integration.KeyMethod" })
|
||||
|
||||
export interface EnvMethod extends Schema.Schema.Type<typeof EnvMethod> {}
|
||||
@@ -49,6 +81,9 @@ export const Method = Schema.Union([OAuthMethod, CommandMethod, KeyMethod, EnvMe
|
||||
.annotate({ identifier: "Integration.Method" })
|
||||
export type Method = typeof Method.Type
|
||||
|
||||
export const Inputs = Schema.Record(Schema.String, Schema.String).annotate({ identifier: "Integration.Inputs" })
|
||||
export type Inputs = typeof Inputs.Type
|
||||
|
||||
const Updated = ephemeral({
|
||||
type: "integration.updated",
|
||||
schema: {},
|
||||
|
||||
@@ -173,6 +173,36 @@ export const InputAdmitted = Event.durable({
|
||||
})
|
||||
export type InputAdmitted = typeof InputAdmitted.Type
|
||||
|
||||
export const InputCancelled = Event.durable({
|
||||
type: "session.input.cancelled",
|
||||
...options,
|
||||
schema: {
|
||||
sessionID: SessionID,
|
||||
inputID: SessionMessage.ID,
|
||||
},
|
||||
})
|
||||
export type InputCancelled = typeof InputCancelled.Type
|
||||
|
||||
export const InputSteered = Event.durable({
|
||||
type: "session.input.steered",
|
||||
...options,
|
||||
schema: {
|
||||
sessionID: SessionID,
|
||||
inputID: SessionMessage.ID,
|
||||
},
|
||||
})
|
||||
export type InputSteered = typeof InputSteered.Type
|
||||
|
||||
export const InputQueued = Event.durable({
|
||||
type: "session.input.queued",
|
||||
...options,
|
||||
schema: {
|
||||
sessionID: SessionID,
|
||||
inputID: SessionMessage.ID,
|
||||
},
|
||||
})
|
||||
export type InputQueued = typeof InputQueued.Type
|
||||
|
||||
export namespace Execution {
|
||||
export const Started = Event.durable({ type: "session.execution.started", ...options, schema: Base })
|
||||
export type Started = typeof Started.Type
|
||||
@@ -580,6 +610,9 @@ export const Definitions = Event.inventory(
|
||||
Forked,
|
||||
InputPromoted,
|
||||
InputAdmitted,
|
||||
InputCancelled,
|
||||
InputSteered,
|
||||
InputQueued,
|
||||
Execution.Started,
|
||||
Execution.Succeeded,
|
||||
Execution.Failed,
|
||||
@@ -621,13 +654,16 @@ export const DurableDefinitions = Event.inventory(
|
||||
...Definitions.filter((definition) => definition.durability === "durable"),
|
||||
UsageRecorded,
|
||||
)
|
||||
export const EphemeralDefinitions = Event.inventory(
|
||||
...Definitions.filter((definition) => definition.durability === "ephemeral"),
|
||||
)
|
||||
|
||||
export const Durable = Schema.Union(DurableDefinitions, { mode: "oneOf" })
|
||||
.pipe(Schema.toTaggedUnion("type"))
|
||||
.annotate({ identifier: "Session.Event.Durable" })
|
||||
export type DurableEvent = typeof Durable.Type
|
||||
|
||||
export const All = Schema.Union(Event.inventory(...Definitions, UsageRecorded), { mode: "oneOf" }).pipe(
|
||||
export const All = Schema.Union([Durable, ...EphemeralDefinitions], { mode: "oneOf" }).pipe(
|
||||
Schema.toTaggedUnion("type"),
|
||||
)
|
||||
export type Event = typeof All.Type
|
||||
|
||||
@@ -84,6 +84,9 @@ describe("public event manifest", () => {
|
||||
"session.forked.2",
|
||||
"session.input.promoted.1",
|
||||
"session.input.admitted.1",
|
||||
"session.input.cancelled.1",
|
||||
"session.input.steered.1",
|
||||
"session.input.queued.1",
|
||||
"session.execution.started.1",
|
||||
"session.execution.succeeded.1",
|
||||
"session.execution.failed.1",
|
||||
|
||||
@@ -58,7 +58,6 @@ export const IntegrationHandler = HttpApiBuilder.group(Api, "server.integration"
|
||||
service.connection.key({
|
||||
integrationID: ctx.params.integrationID,
|
||||
key: ctx.payload.key,
|
||||
answers: ctx.payload.answers,
|
||||
label: ctx.payload.label,
|
||||
}),
|
||||
)
|
||||
@@ -74,7 +73,7 @@ export const IntegrationHandler = HttpApiBuilder.group(Api, "server.integration"
|
||||
service.oauth.connect({
|
||||
integrationID: ctx.params.integrationID,
|
||||
methodID: ctx.payload.methodID,
|
||||
answers: ctx.payload.answers,
|
||||
inputs: ctx.payload.inputs,
|
||||
label: ctx.payload.label,
|
||||
}),
|
||||
),
|
||||
|
||||
@@ -26,6 +26,22 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
|
||||
Effect.gen(function* () {
|
||||
const session = yield* Session.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
|
||||
.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(
|
||||
"session.instructions.entry.list",
|
||||
Effect.fn(function* (ctx) {
|
||||
|
||||
@@ -462,6 +462,7 @@ function newLayout() {
|
||||
function webSearchProviderLabel(provider: unknown) {
|
||||
if (provider === "parallel") return "Parallel Web Search"
|
||||
if (provider === "exa") return "Exa Web Search"
|
||||
if (provider === "firecrawl") return "Firecrawl Web Search"
|
||||
return "Web Search"
|
||||
}
|
||||
|
||||
|
||||
@@ -5,8 +5,6 @@ import type {
|
||||
IntegrationInfo,
|
||||
IntegrationOauthConnectOutput,
|
||||
IntegrationOAuthMethod,
|
||||
FormAnswer,
|
||||
FormFields,
|
||||
} from "@opencode-ai/client"
|
||||
import open from "open"
|
||||
import { createMemo, createSignal, onCleanup, onMount, Show } from "solid-js"
|
||||
@@ -20,7 +18,6 @@ import { DialogPrompt } from "../ui/dialog-prompt"
|
||||
import { DialogSelect } from "../ui/dialog-select"
|
||||
import { Link } from "../ui/link"
|
||||
import { useToast } from "../ui/toast"
|
||||
import { FormInput } from "../routes/session/form"
|
||||
|
||||
const INTEGRATION_PRIORITY: Record<string, number> = {
|
||||
opencode: 0,
|
||||
@@ -184,7 +181,7 @@ function openMethod(
|
||||
onConnected?: OnIntegrationConnected,
|
||||
) {
|
||||
if (method.type === "key") {
|
||||
void beginKey(integration, method, dialog, onConnected)
|
||||
dialog.replace(() => <KeyMethod integration={integration} method={method} onConnected={onConnected} />)
|
||||
return
|
||||
}
|
||||
if (method.type === "command") {
|
||||
@@ -194,21 +191,6 @@ function openMethod(
|
||||
void beginOAuth(integration, method, dialog, onConnected)
|
||||
}
|
||||
|
||||
async function beginKey(
|
||||
integration: IntegrationInfo,
|
||||
method: Extract<ConnectMethod, { type: "key" }>,
|
||||
dialog: ReturnType<typeof useDialog>,
|
||||
onConnected?: OnIntegrationConnected,
|
||||
) {
|
||||
const answers = method.forms
|
||||
? await formAnswers(dialog, method.label ?? `Connect ${integration.name}`, method.forms)
|
||||
: {}
|
||||
if (answers === null) return
|
||||
dialog.replace(() => (
|
||||
<KeyMethod integration={integration} method={method} answers={answers} onConnected={onConnected} />
|
||||
))
|
||||
}
|
||||
|
||||
function CommandStarting(props: {
|
||||
integration: IntegrationInfo
|
||||
method: Extract<ConnectMethod, { type: "command" }>
|
||||
@@ -354,7 +336,6 @@ function CommandView(props: { title: string; output: string; message: string })
|
||||
function KeyMethod(props: {
|
||||
integration: IntegrationInfo
|
||||
method: Extract<ConnectMethod, { type: "key" }>
|
||||
answers: FormAnswer
|
||||
onConnected?: OnIntegrationConnected
|
||||
}) {
|
||||
const data = useData()
|
||||
@@ -375,7 +356,6 @@ function KeyMethod(props: {
|
||||
integrationID: props.integration.id,
|
||||
location: location(data),
|
||||
key,
|
||||
answers: props.answers,
|
||||
})
|
||||
.then(() => connected(props.integration, data, dialog, toast, props.onConnected))
|
||||
.catch((cause) => setError(message(cause)))
|
||||
@@ -393,17 +373,17 @@ async function beginOAuth(
|
||||
dialog: ReturnType<typeof useDialog>,
|
||||
onConnected?: OnIntegrationConnected,
|
||||
) {
|
||||
const answers = method.forms ? await formAnswers(dialog, method.label, method.forms) : {}
|
||||
if (answers === null) return
|
||||
const inputs = method.prompts?.length ? await promptInputs(dialog, method.prompts) : {}
|
||||
if (inputs === null) return
|
||||
dialog.replace(() => (
|
||||
<OAuthStarting integration={integration} method={method} answers={answers} onConnected={onConnected} />
|
||||
<OAuthStarting integration={integration} method={method} inputs={inputs} onConnected={onConnected} />
|
||||
))
|
||||
}
|
||||
|
||||
function OAuthStarting(props: {
|
||||
integration: IntegrationInfo
|
||||
method: IntegrationOAuthMethod
|
||||
answers: FormAnswer
|
||||
inputs: Record<string, string>
|
||||
onConnected?: OnIntegrationConnected
|
||||
}) {
|
||||
const data = useData()
|
||||
@@ -417,7 +397,7 @@ function OAuthStarting(props: {
|
||||
integrationID: props.integration.id,
|
||||
location: location(data),
|
||||
methodID: props.method.id,
|
||||
answers: props.answers,
|
||||
inputs: props.inputs,
|
||||
})
|
||||
.then((result) => {
|
||||
if (result.data.mode === "code") {
|
||||
@@ -641,23 +621,49 @@ function OAuthView(props: {
|
||||
)
|
||||
}
|
||||
|
||||
async function formAnswers(dialog: ReturnType<typeof useDialog>, title: string, forms: FormFields) {
|
||||
return new Promise<FormAnswer | null>((resolve) => {
|
||||
dialog.replace(
|
||||
() => (
|
||||
<FormInput
|
||||
form={{ title, fields: forms }}
|
||||
onSubmit={resolve}
|
||||
onCancel={() => {
|
||||
dialog.clear()
|
||||
resolve(null)
|
||||
}}
|
||||
/>
|
||||
),
|
||||
() => resolve(null),
|
||||
)
|
||||
dialog.setSize("large")
|
||||
})
|
||||
async function promptInputs(
|
||||
dialog: ReturnType<typeof useDialog>,
|
||||
prompts: NonNullable<IntegrationOAuthMethod["prompts"]>,
|
||||
) {
|
||||
const inputs: Record<string, string> = {}
|
||||
for (const prompt of prompts) {
|
||||
if (prompt.when) {
|
||||
const value = inputs[prompt.when.key]
|
||||
if (value === undefined) continue
|
||||
const matches = prompt.when.op === "eq" ? value === prompt.when.value : value !== prompt.when.value
|
||||
if (!matches) continue
|
||||
}
|
||||
if (prompt.type === "select") {
|
||||
const value = await new Promise<string | null>((resolve) => {
|
||||
dialog.replace(
|
||||
() => (
|
||||
<DialogSelect
|
||||
title={prompt.message}
|
||||
options={prompt.options.map((option) => ({
|
||||
title: option.label,
|
||||
value: option.value,
|
||||
description: option.hint,
|
||||
}))}
|
||||
onSelect={(option) => resolve(option.value)}
|
||||
/>
|
||||
),
|
||||
() => resolve(null),
|
||||
)
|
||||
})
|
||||
if (value === null) return null
|
||||
inputs[prompt.key] = value
|
||||
continue
|
||||
}
|
||||
const value = await new Promise<string | null>((resolve) => {
|
||||
dialog.replace(
|
||||
() => <DialogPrompt title={prompt.message} placeholder={prompt.placeholder} onConfirm={resolve} />,
|
||||
() => resolve(null),
|
||||
)
|
||||
})
|
||||
if (value === null) return null
|
||||
inputs[prompt.key] = value
|
||||
}
|
||||
return inputs
|
||||
}
|
||||
|
||||
async function connected(
|
||||
|
||||
@@ -53,12 +53,14 @@ import { useLocation } from "../../context/location"
|
||||
import { Keymap, type KeymapCommand } from "../../context/keymap"
|
||||
import { abbreviateHome } from "../../runtime"
|
||||
import { PluginSlot } from "../../plugin/render"
|
||||
import type { SessionPending } from "@opencode-ai/schema/session-pending"
|
||||
|
||||
export type PromptProps = {
|
||||
sessionID?: string
|
||||
visible?: boolean
|
||||
disabled?: boolean
|
||||
onSubmit?: () => void
|
||||
onEmptySubmit?: () => boolean | Promise<boolean>
|
||||
ref?: (ref: PromptRef | undefined) => void
|
||||
hint?: JSX.Element
|
||||
right?: JSX.Element
|
||||
@@ -357,6 +359,20 @@ export function Prompt(props: PromptProps) {
|
||||
dialog.clear()
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Queue prompt",
|
||||
name: "prompt.queue",
|
||||
category: "Prompt",
|
||||
palette: undefined,
|
||||
run: async (_input: string | undefined, event?: KeyEvent) => {
|
||||
event?.preventDefault()
|
||||
event?.stopPropagation()
|
||||
if (!input.focused) return
|
||||
const handled = await submit("queue")
|
||||
if (!handled) return
|
||||
dialog.clear()
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Remove editor context",
|
||||
name: "prompt.editor_context.clear",
|
||||
@@ -515,6 +531,11 @@ export function Prompt(props: PromptProps) {
|
||||
commands: promptCommands(),
|
||||
}))
|
||||
|
||||
Keymap.createLayer(() => ({
|
||||
priority: 1,
|
||||
bindings: ["prompt.queue"],
|
||||
}))
|
||||
|
||||
Keymap.createLayer(() => ({
|
||||
bindings: [
|
||||
"prompt.submit",
|
||||
@@ -900,7 +921,7 @@ export function Prompt(props: PromptProps) {
|
||||
})
|
||||
|
||||
let submitting = false
|
||||
async function submit() {
|
||||
async function submit(delivery: SessionPending.Delivery = "steer") {
|
||||
// Prevent overlapping invocations (e.g. a double-pressed Enter, or the
|
||||
// input's native onSubmit racing another dispatch). Without this guard,
|
||||
// a second call slips past the empty-input check before the first call
|
||||
@@ -910,13 +931,13 @@ export function Prompt(props: PromptProps) {
|
||||
if (submitting) return false
|
||||
submitting = true
|
||||
try {
|
||||
return await submitInner()
|
||||
return await submitInner(delivery)
|
||||
} finally {
|
||||
submitting = false
|
||||
}
|
||||
}
|
||||
|
||||
async function submitInner() {
|
||||
async function submitInner(delivery: SessionPending.Delivery) {
|
||||
// IME: double-defer may fire before onContentChange flushes the last
|
||||
// composed character (e.g. Korean hangul) to the store, so read
|
||||
// plainText directly and sync before any downstream reads.
|
||||
@@ -927,14 +948,25 @@ export function Prompt(props: PromptProps) {
|
||||
if (props.disabled) return false
|
||||
if (move.creating()) return false
|
||||
if (auto()?.visible) return false
|
||||
if (!store.prompt.text) return false
|
||||
const trimmed = store.prompt.text.trim()
|
||||
if (!trimmed) return delivery === "steer" ? (await props.onEmptySubmit?.()) === true : false
|
||||
if (
|
||||
delivery === "queue" &&
|
||||
(store.mode === "shell" || trimmed === "exit" || trimmed === "quit" || trimmed === ":q")
|
||||
) {
|
||||
toast.show({ message: "This prompt cannot be queued", variant: "warning" })
|
||||
return false
|
||||
}
|
||||
if (trimmed === "exit" || trimmed === "quit" || trimmed === ":q") {
|
||||
void exit()
|
||||
return true
|
||||
}
|
||||
const slash = argumentSlash(store.prompt.text, keymapCommands())
|
||||
if (slash) {
|
||||
if (delivery === "queue") {
|
||||
toast.show({ message: "This prompt cannot be queued", variant: "warning" })
|
||||
return false
|
||||
}
|
||||
clearPrompt()
|
||||
await slash.command.run(slash.input)
|
||||
return true
|
||||
@@ -958,6 +990,10 @@ export function Prompt(props: PromptProps) {
|
||||
const isCommand =
|
||||
slashHead !== undefined &&
|
||||
(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 agent = local.agent.current()
|
||||
if (!agent) return false
|
||||
const selection = local.model.selection()
|
||||
@@ -1040,6 +1076,7 @@ export function Prompt(props: PromptProps) {
|
||||
model,
|
||||
files: store.prompt.files,
|
||||
agents: store.prompt.agents,
|
||||
delivery,
|
||||
})
|
||||
.catch((error) => {
|
||||
cancelCommit()
|
||||
@@ -1049,7 +1086,7 @@ export function Prompt(props: PromptProps) {
|
||||
move.startSubmit()
|
||||
void client.api.session.skill({
|
||||
sessionID,
|
||||
skill: slashHead!.name,
|
||||
skill: slashHead.name,
|
||||
})
|
||||
} else {
|
||||
move.startSubmit()
|
||||
@@ -1105,6 +1142,7 @@ export function Prompt(props: PromptProps) {
|
||||
text: inputText,
|
||||
files: store.prompt.files,
|
||||
agents: store.prompt.agents,
|
||||
delivery,
|
||||
})
|
||||
.then(
|
||||
() => undefined,
|
||||
|
||||
@@ -103,7 +103,8 @@ export const Definitions = {
|
||||
session_interrupt: keybind("escape", "Interrupt current session"),
|
||||
session_background: keybind("ctrl+b", "Background blocking session tools"),
|
||||
session_compact: keybind("<leader>c", "Compact the session"),
|
||||
session_queued_prompts: keybind("<leader>q", "View pending work"),
|
||||
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_parent: keybind("up", "Go to parent session"),
|
||||
session_pin_toggle: keybind("ctrl+f", "Pin or unpin session in the session list"),
|
||||
@@ -161,6 +162,7 @@ export const Definitions = {
|
||||
display_thinking: keybind("none", "Toggle thinking blocks visibility"),
|
||||
|
||||
prompt_submit: keybind("none", "Submit prompt"),
|
||||
prompt_queue: keybind("alt+return", "Queue prompt"),
|
||||
prompt_editor_context_clear: keybind("none", "Clear editor context"),
|
||||
prompt_skills: keybind("none", "Open skill selector"),
|
||||
prompt_stash: keybind("none", "Stash prompt"),
|
||||
@@ -170,7 +172,7 @@ export const Definitions = {
|
||||
input_clear: keybind("ctrl+c", "Clear input field"),
|
||||
input_paste: keybind({ key: "ctrl+v", preventDefault: false }, "Paste from clipboard"),
|
||||
input_submit: keybind("return", "Submit input"),
|
||||
input_newline: keybind("shift+return,ctrl+return,alt+return,ctrl+j", "Insert newline in input"),
|
||||
input_newline: keybind("shift+return,ctrl+return,ctrl+j", "Insert newline in input"),
|
||||
input_move_left: keybind("left,ctrl+b", "Move cursor left in input"),
|
||||
input_move_right: keybind("right,ctrl+f", "Move cursor right in input"),
|
||||
input_move_up: keybind("up", "Move cursor up in input"),
|
||||
@@ -305,6 +307,7 @@ export const CommandMap = {
|
||||
session_background: "session.background",
|
||||
session_compact: "session.compact",
|
||||
session_queued_prompts: "session.queued_prompts",
|
||||
queued_prompt_delete: "queued_prompt.delete",
|
||||
session_child_first: "session.child.first",
|
||||
session_parent: "session.parent",
|
||||
session_pin_toggle: "session.pin.toggle",
|
||||
@@ -359,6 +362,7 @@ export const CommandMap = {
|
||||
messages_redo: "session.redo",
|
||||
display_thinking: "session.toggle.thinking",
|
||||
prompt_submit: "prompt.submit",
|
||||
prompt_queue: "prompt.queue",
|
||||
prompt_editor_context_clear: "prompt.editor_context.clear",
|
||||
prompt_skills: "prompt.skills",
|
||||
prompt_stash: "prompt.stash",
|
||||
|
||||
@@ -38,6 +38,7 @@ import { createStore, produce, reconcile } from "solid-js/store"
|
||||
import { createSimpleContext } from "./helper"
|
||||
import { useClient } from "./client"
|
||||
import { nonEmptyToolContent } from "../util/tool-display"
|
||||
import type { SessionPending } from "@opencode-ai/schema/session-pending"
|
||||
import { createEffect, createSignal, onCleanup } from "solid-js"
|
||||
|
||||
export type DataSessionStatus = "idle" | "running"
|
||||
@@ -170,12 +171,20 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||
|
||||
function removePending(sessionID: string, inputID?: string) {
|
||||
if (!inputID) return
|
||||
setStore(
|
||||
"session",
|
||||
"pending",
|
||||
sessionID,
|
||||
(store.session.pending[sessionID] ?? []).filter((item) => item.id !== inputID),
|
||||
)
|
||||
if (store.session.pending[sessionID]?.some((item) => item.id === inputID))
|
||||
setStore(
|
||||
"session",
|
||||
"pending",
|
||||
sessionID,
|
||||
(store.session.pending[sessionID] ?? []).filter((item) => item.id !== inputID),
|
||||
)
|
||||
if (store.session.input[sessionID]?.includes(inputID))
|
||||
setStore(
|
||||
"session",
|
||||
"input",
|
||||
sessionID,
|
||||
(store.session.input[sessionID] ?? []).filter((id) => id !== inputID),
|
||||
)
|
||||
}
|
||||
|
||||
function 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 = {
|
||||
update(sessionID: string, fn: (messages: SessionMessageInfo[], index: Map<string, number>) => void) {
|
||||
setStore(
|
||||
@@ -235,6 +251,12 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||
(item): item is SessionMessageAssistantReasoning => item.type === "reasoning" && !item.time?.completed,
|
||||
)
|
||||
},
|
||||
reindex(messages: SessionMessageInfo[], index: Map<string, number>, start: number) {
|
||||
for (let position = start; position < messages.length; position++) {
|
||||
const item = messages[position]
|
||||
if (item) index.set(item.id, position)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
function index(sessionID: string) {
|
||||
@@ -416,24 +438,36 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||
}
|
||||
break
|
||||
case "session.input.promoted": {
|
||||
const admitted = store.session.input[event.data.sessionID]?.includes(event.data.inputID) ?? false
|
||||
removePending(event.data.sessionID, event.data.inputID)
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
const position = index.get(event.data.inputID)
|
||||
if (position === undefined) return
|
||||
const existing = draft[position]
|
||||
if (!existing || !store.session.input[event.data.sessionID]?.includes(event.data.inputID)) return
|
||||
if (!existing || !admitted) return
|
||||
existing.time.created = event.created
|
||||
draft.splice(position, 1)
|
||||
draft.push(existing)
|
||||
index.clear()
|
||||
draft.forEach((message, indexValue) => index.set(message.id, indexValue))
|
||||
message.reindex(draft, index, position)
|
||||
})
|
||||
setStore(
|
||||
"session",
|
||||
"input",
|
||||
event.data.sessionID,
|
||||
(store.session.input[event.data.sessionID] ?? []).filter((id) => id !== event.data.inputID),
|
||||
)
|
||||
break
|
||||
}
|
||||
case "session.input.steered":
|
||||
updatePending(event.data.sessionID, event.data.inputID, "steer")
|
||||
break
|
||||
case "session.input.queued":
|
||||
updatePending(event.data.sessionID, event.data.inputID, "queue")
|
||||
break
|
||||
case "session.input.cancelled": {
|
||||
removePending(event.data.sessionID, event.data.inputID)
|
||||
if (messageIndex.get(event.data.sessionID)?.has(event.data.inputID))
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
const position = index.get(event.data.inputID)
|
||||
if (position === undefined) return
|
||||
draft.splice(position, 1)
|
||||
index.delete(event.data.inputID)
|
||||
message.reindex(draft, index, position)
|
||||
})
|
||||
break
|
||||
}
|
||||
case "session.input.admitted":
|
||||
|
||||
@@ -3,7 +3,9 @@ import { TextAttributes, type InputRenderable, type KeyEvent } from "@opentui/co
|
||||
import { useKeyboard, type JSX } from "@opentui/solid"
|
||||
import fuzzysort from "fuzzysort"
|
||||
import { createEffect, createMemo, createSignal, type Accessor } from "solid-js"
|
||||
import { Keymap } from "../context/keymap"
|
||||
import { RunFooterMenu, createFooterMenuState, type RunFooterMenuItem } from "./footer.menu"
|
||||
import { monoShortcut } from "./mono"
|
||||
import type { RunFooterTheme } from "./theme"
|
||||
import type {
|
||||
FooterQueuedPrompt,
|
||||
@@ -56,6 +58,10 @@ type SkillEntry = PanelEntry & {
|
||||
name: string
|
||||
}
|
||||
|
||||
type QueuedPromptEntry = PanelEntry & {
|
||||
prompt: FooterQueuedPrompt
|
||||
}
|
||||
|
||||
type SubagentEntry = PanelEntry & {
|
||||
sessionID: string
|
||||
current: boolean
|
||||
@@ -442,7 +448,7 @@ export function RunCommandMenuBody(props: {
|
||||
{
|
||||
action: "queued" as const,
|
||||
category: "Agent",
|
||||
display: "View pending work",
|
||||
display: "View queued prompts",
|
||||
footer: `${props.queued().length} pending`,
|
||||
keywords: props
|
||||
.queued()
|
||||
@@ -837,28 +843,48 @@ export function RunQueuedPromptSelectBody(props: {
|
||||
theme: Accessor<RunFooterTheme>
|
||||
prompts: Accessor<FooterQueuedPrompt[]>
|
||||
onClose: () => void
|
||||
onSteer: (prompt: FooterQueuedPrompt) => void
|
||||
onDelete: (prompt: FooterQueuedPrompt) => void
|
||||
onRows?: (rows: number) => void
|
||||
mono?: boolean
|
||||
}) {
|
||||
const entries = createMemo(() =>
|
||||
const entries = createMemo<QueuedPromptEntry[]>(() =>
|
||||
props.prompts().map((prompt) => ({
|
||||
category: "",
|
||||
display: prompt.prompt.text.replaceAll("\n", " "),
|
||||
footer: prompt.delivery,
|
||||
footer: "queued",
|
||||
keywords: prompt.prompt.text,
|
||||
prompt,
|
||||
})),
|
||||
)
|
||||
const controller = createSearchablePanelController({
|
||||
entries,
|
||||
limit: SUBAGENT_LIST_ROWS,
|
||||
onClose: props.onClose,
|
||||
onSelect: props.onClose,
|
||||
onSelect: (item) => props.onSteer(item.prompt),
|
||||
onRows: props.onRows,
|
||||
})
|
||||
const shortcuts = Keymap.useShortcuts()
|
||||
const deleteShortcut = () => monoShortcut(shortcuts.get("queued_prompt.delete") ?? "", props.mono ?? false)
|
||||
Keymap.createLayer(() => ({
|
||||
priority: 1,
|
||||
commands: [
|
||||
{
|
||||
id: "queued_prompt.delete",
|
||||
title: "Delete queued prompt",
|
||||
group: "Prompt",
|
||||
run() {
|
||||
const item = controller.items()[controller.menu.selected()]
|
||||
if (!item) return false
|
||||
props.onDelete(item.prompt)
|
||||
},
|
||||
},
|
||||
],
|
||||
}))
|
||||
|
||||
return (
|
||||
<PanelShell
|
||||
title="Pending work"
|
||||
title="Queued prompts"
|
||||
query={controller.query()}
|
||||
count={controller.items().length}
|
||||
total={entries().length}
|
||||
@@ -866,6 +892,7 @@ export function RunQueuedPromptSelectBody(props: {
|
||||
theme={props.theme}
|
||||
inputRef={controller.inputRef}
|
||||
onQuery={controller.setQuery}
|
||||
hint={["enter steer", deleteShortcut() ? `${deleteShortcut()} delete` : undefined].filter(Boolean).join(" · ")}
|
||||
mono={props.mono}
|
||||
>
|
||||
<RunFooterMenu
|
||||
@@ -875,7 +902,7 @@ export function RunQueuedPromptSelectBody(props: {
|
||||
offset={controller.menu.offset}
|
||||
rows={controller.menu.rows}
|
||||
limit={SUBAGENT_LIST_ROWS}
|
||||
empty="No pending work"
|
||||
empty="No queued prompts"
|
||||
border={false}
|
||||
paddingLeft={panelPad(props.mono)}
|
||||
paddingRight={panelPad(props.mono)}
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
displayCharAt,
|
||||
displaySlice,
|
||||
isExitCommand,
|
||||
isCompactCommand,
|
||||
mentionTriggerIndex,
|
||||
isNewCommand,
|
||||
movePromptHistory,
|
||||
@@ -31,7 +32,16 @@ import { realignEditorPromptParts, resolveEditorSlashValue } from "./prompt.edit
|
||||
import { monoTruncateMiddle } from "./mono"
|
||||
import { FOOTER_MENU_ROWS, createFooterMenuState, type RunFooterMenuItem } from "./footer.menu"
|
||||
import type { RunFooterTheme } from "./theme"
|
||||
import type { FooterState, RunAgent, RunCommand, RunPrompt, RunPromptPart, RunReference } from "./types"
|
||||
import type {
|
||||
FooterQueuedPrompt,
|
||||
FooterState,
|
||||
RunAgent,
|
||||
RunCommand,
|
||||
RunDelivery,
|
||||
RunPrompt,
|
||||
RunPromptPart,
|
||||
RunReference,
|
||||
} from "./types"
|
||||
|
||||
const AUTOCOMPLETE_ROWS = FOOTER_MENU_ROWS
|
||||
const AUTOCOMPLETE_BOTTOM_ROWS = 1
|
||||
@@ -72,6 +82,8 @@ type PromptInput = {
|
||||
theme: Accessor<RunFooterTheme>
|
||||
mono: Accessor<boolean>
|
||||
history?: Accessor<RunPrompt[]>
|
||||
queuedPrompts: Accessor<FooterQueuedPrompt[]>
|
||||
onQueuedPromptSteer: (inputID: string) => Promise<boolean>
|
||||
onSubmit: (input: RunPrompt) => boolean | Promise<boolean>
|
||||
onCycle: () => void
|
||||
onInterrupt: () => boolean
|
||||
@@ -980,8 +992,18 @@ export function createPromptState(input: PromptInput): PromptState {
|
||||
}))
|
||||
|
||||
Keymap.createLayer(() => ({
|
||||
priority: 1,
|
||||
enabled: input.prompt() && !visible(),
|
||||
commands: [
|
||||
{
|
||||
id: "prompt.queue",
|
||||
title: "Queue prompt",
|
||||
group: "Prompt",
|
||||
run() {
|
||||
syncDraft()
|
||||
submitPrompt(promptCopy(draft), "queue")
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "prompt.editor",
|
||||
title: "Open editor",
|
||||
@@ -1116,7 +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) {
|
||||
draft = promptCopy(next)
|
||||
}
|
||||
@@ -1130,12 +1153,29 @@ export function createPromptState(input: PromptInput): PromptState {
|
||||
hide()
|
||||
}
|
||||
|
||||
if (submitting) return
|
||||
|
||||
if (!next.text.trim()) {
|
||||
const queued = delivery === "steer" ? input.queuedPrompts()[0] : undefined
|
||||
if (queued) {
|
||||
submitting = true
|
||||
void input.onQueuedPromptSteer(queued.messageID).finally(() => {
|
||||
submitting = false
|
||||
})
|
||||
return
|
||||
}
|
||||
input.onStatus(input.state().phase === "running" ? "waiting for current response" : "empty prompt ignored")
|
||||
return
|
||||
}
|
||||
|
||||
const command = next.mode === "shell" ? undefined : selectedCommand(next.text, next.command)
|
||||
if (
|
||||
delivery === "queue" &&
|
||||
(next.mode === "shell" || command?.source === "skill" || isNewCommand(next.text) || isCompactCommand(next.text))
|
||||
) {
|
||||
input.onStatus("this prompt cannot be queued")
|
||||
return
|
||||
}
|
||||
if (!command && next.mode !== "shell" && isExitCommand(next.text)) {
|
||||
input.onExit()
|
||||
return
|
||||
@@ -1157,24 +1197,28 @@ export function createPromptState(input: PromptInput): PromptState {
|
||||
}
|
||||
|
||||
const submit = command
|
||||
? { ...next, command }
|
||||
? { ...next, command, delivery }
|
||||
: parsed?.type === "command"
|
||||
? { ...next, command: parsed.command }
|
||||
: next
|
||||
? { ...next, command: parsed.command, delivery }
|
||||
: { ...next, delivery }
|
||||
const shellMode = next.mode === "shell"
|
||||
|
||||
submitting = true
|
||||
resetDraft()
|
||||
queueMicrotask(async () => {
|
||||
if (await input.onSubmit(submit)) {
|
||||
push(next)
|
||||
if (shellMode) {
|
||||
setShellMode(false)
|
||||
draft = emptyPrompt(false)
|
||||
try {
|
||||
if (await input.onSubmit(submit)) {
|
||||
push(next)
|
||||
if (shellMode) {
|
||||
setShellMode(false)
|
||||
draft = emptyPrompt(false)
|
||||
}
|
||||
return
|
||||
}
|
||||
return
|
||||
restore(next)
|
||||
} finally {
|
||||
submitting = false
|
||||
}
|
||||
|
||||
restore(next)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -51,6 +51,7 @@ import type {
|
||||
MiniSettingChange,
|
||||
MiniSettings,
|
||||
PermissionReply,
|
||||
QueuedPromptAction,
|
||||
RunAgent,
|
||||
RunCommand,
|
||||
RunInput,
|
||||
@@ -96,6 +97,7 @@ type RunFooterOptions = {
|
||||
onVariantSelect?: (variant: string | undefined) => CycleResult | void | Promise<CycleResult | void>
|
||||
onInterrupt?: () => void
|
||||
onBackground?: () => void
|
||||
onQueuedPromptAction?: (action: QueuedPromptAction, inputID: string) => Promise<void>
|
||||
onEditorOpen: (input: { value: string }) => Promise<string | undefined>
|
||||
onSubagentSelect?: (sessionID: string | undefined) => void
|
||||
onSubagentInterrupt?: (sessionID: string) => void
|
||||
@@ -343,6 +345,7 @@ export class RunFooter implements FooterApi {
|
||||
onCycle: footer.handleCycle,
|
||||
onInterrupt: footer.handleInterrupt,
|
||||
onBackground: options.onBackground,
|
||||
onQueuedPromptAction: options.onQueuedPromptAction,
|
||||
onEditorOpen: options.onEditorOpen,
|
||||
onInputClear: footer.handleInputClear,
|
||||
onExitRequest: footer.handleExit,
|
||||
|
||||
@@ -34,6 +34,8 @@ import { Keymap } from "../context/keymap"
|
||||
import { modelInfo } from "./variant.shared"
|
||||
import { monoShortcut } from "./mono"
|
||||
import { stringWidth } from "../util/string-width"
|
||||
import { errorMessage } from "../util/error"
|
||||
import { createSingleFlight } from "../util/single-flight"
|
||||
|
||||
import type {
|
||||
FooterPromptRoute,
|
||||
@@ -46,6 +48,7 @@ import type {
|
||||
MiniSettingChange,
|
||||
MiniSettings,
|
||||
PermissionReply,
|
||||
QueuedPromptAction,
|
||||
RunAgent,
|
||||
RunCommand,
|
||||
RunInput,
|
||||
@@ -92,13 +95,14 @@ type RunFooterViewProps = {
|
||||
mono: boolean
|
||||
miniSettings: () => MiniSettings
|
||||
history?: () => RunPrompt[]
|
||||
onSubmit: (input: RunPrompt) => boolean
|
||||
onSubmit: (input: RunPrompt) => boolean | Promise<boolean>
|
||||
onPermissionReply: (input: PermissionReply) => void | Promise<void>
|
||||
onFormReply: (input: FormReply) => void | Promise<void>
|
||||
onFormCancel: (input: FormCancel) => void | Promise<void>
|
||||
onCycle: () => void
|
||||
onInterrupt: () => boolean
|
||||
onBackground?: () => void
|
||||
onQueuedPromptAction?: (action: QueuedPromptAction, inputID: string) => Promise<void>
|
||||
onEditorOpen: (input: { value: string }) => Promise<string | undefined>
|
||||
onInputClear: () => void
|
||||
onExitRequest?: () => boolean
|
||||
@@ -132,6 +136,7 @@ export function RunFooterView(props: RunFooterViewProps) {
|
||||
const [route, setRoute] = createSignal<FooterPromptRoute>({ type: "composer" })
|
||||
const [subagentMenuRows, setSubagentMenuRows] = createSignal(RUN_SUBAGENT_PANEL_ROWS)
|
||||
const queuedPrompts = createMemo(() => props.queuedPrompts?.() ?? [])
|
||||
const queue = createMemo(() => queuedPrompts().filter((item) => item.delivery === "queue"))
|
||||
const skills = createMemo(() => (props.commands() ?? []).filter((item) => item.source === "skill"))
|
||||
const prompt = createMemo(() => active().type === "prompt" && route().type === "composer")
|
||||
const selectingSubagent = createMemo(() => active().type === "prompt" && route().type === "subagent-menu")
|
||||
@@ -229,7 +234,7 @@ export function RunFooterView(props: RunFooterViewProps) {
|
||||
const details = [busy() ? "running" : "idle", `agent ${props.currentAgent()}`]
|
||||
if (current) details.push(variant ? `${current} ${variant}` : current)
|
||||
if (usage()) details.push(props.mono ? usage().replaceAll(" · ", " - ") : usage())
|
||||
if (queuedPrompts().length > 0) details.push(`${queuedPrompts().length} pending`)
|
||||
if (queue().length > 0) details.push(`${queue().length} queued`)
|
||||
if (activeTabs().length > 0) details.push(`${activeTabs().length} subagent${activeTabs().length === 1 ? "" : "s"}`)
|
||||
return details.join(props.mono ? " - " : " · ")
|
||||
})
|
||||
@@ -309,7 +314,7 @@ export function RunFooterView(props: RunFooterViewProps) {
|
||||
}
|
||||
|
||||
const openQueuedMenu = () => {
|
||||
if (queuedPrompts().length === 0) return
|
||||
if (queue().length === 0) return
|
||||
setRoute({ type: "queued-menu" })
|
||||
props.onSubagentSelect?.(undefined)
|
||||
}
|
||||
@@ -318,6 +323,22 @@ export function RunFooterView(props: RunFooterViewProps) {
|
||||
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) => {
|
||||
setRoute({ type: "subagent", sessionID })
|
||||
props.onSubagentSelect?.(sessionID)
|
||||
@@ -357,6 +378,8 @@ export function RunFooterView(props: RunFooterViewProps) {
|
||||
theme,
|
||||
mono: () => props.mono,
|
||||
history: props.history,
|
||||
queuedPrompts: queue,
|
||||
onQueuedPromptSteer: (inputID) => queuedPromptAction("steer", inputID),
|
||||
onSubmit: props.onSubmit,
|
||||
onCycle: props.onCycle,
|
||||
onInterrupt: props.onInterrupt,
|
||||
@@ -451,13 +474,12 @@ export function RunFooterView(props: RunFooterViewProps) {
|
||||
if (foregroundSubagents() && backgroundShortcut()) {
|
||||
items.push({ key: backgroundShortcut(), label: "background" })
|
||||
}
|
||||
if (queuedPrompts().length > 0 && queuedShortcut()) {
|
||||
items.push({ key: queuedShortcut(), label: `${queuedPrompts().length} pending` })
|
||||
if (queue().length > 0 && queuedShortcut()) {
|
||||
items.push({ key: queuedShortcut(), label: `${queue().length} queued` })
|
||||
}
|
||||
if (activeTabs().length > 0 && subagentShortcut()) {
|
||||
items.push({ key: subagentShortcut(), label: "subagents" })
|
||||
}
|
||||
|
||||
return items
|
||||
})
|
||||
const commandHint = createMemo(() => {
|
||||
@@ -568,11 +590,11 @@ export function RunFooterView(props: RunFooterViewProps) {
|
||||
}))
|
||||
|
||||
Keymap.createLayer(() => ({
|
||||
enabled: active().type === "prompt" && route().type === "composer" && queuedPrompts().length > 0,
|
||||
enabled: active().type === "prompt" && route().type === "composer" && queue().length > 0,
|
||||
commands: [
|
||||
{
|
||||
id: "session.queued_prompts",
|
||||
title: "View pending work",
|
||||
title: "View queued prompts",
|
||||
group: "Session",
|
||||
run: openQueuedMenu,
|
||||
},
|
||||
@@ -630,7 +652,7 @@ export function RunFooterView(props: RunFooterViewProps) {
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (route().type !== "queued-menu" || queuedPrompts().length > 0) return
|
||||
if (route().type !== "queued-menu" || queue().length > 0) return
|
||||
closePanel()
|
||||
})
|
||||
|
||||
@@ -734,8 +756,16 @@ export function RunFooterView(props: RunFooterViewProps) {
|
||||
<Match when={selectingQueued()}>
|
||||
<RunQueuedPromptSelectBody
|
||||
theme={theme}
|
||||
prompts={queuedPrompts}
|
||||
prompts={queue}
|
||||
onClose={closePanel}
|
||||
onSteer={(item) => {
|
||||
void queuedPromptAction("steer", item.messageID).then((steered) => {
|
||||
if (steered) closePanel()
|
||||
})
|
||||
}}
|
||||
onDelete={(item) => {
|
||||
void queuedPromptAction("cancel", item.messageID)
|
||||
}}
|
||||
onRows={setSubagentMenuRows}
|
||||
mono={props.mono}
|
||||
/>
|
||||
@@ -745,7 +775,7 @@ export function RunFooterView(props: RunFooterViewProps) {
|
||||
theme={theme}
|
||||
commands={props.commands}
|
||||
subagents={tabs}
|
||||
queued={queuedPrompts}
|
||||
queued={queue}
|
||||
variants={props.variants}
|
||||
variantCycle={variantCycle()}
|
||||
onClose={closePanel}
|
||||
|
||||
@@ -22,6 +22,7 @@ import type {
|
||||
MiniSettings,
|
||||
MiniHost,
|
||||
PermissionReply,
|
||||
QueuedPromptAction,
|
||||
RunAgent,
|
||||
RunInput,
|
||||
RunPrompt,
|
||||
@@ -70,6 +71,7 @@ export type LifecycleInput = {
|
||||
onVariantSelect?: (variant: string | undefined) => CycleResult | void | Promise<CycleResult | void>
|
||||
onInterrupt?: () => void
|
||||
onBackground?: () => void
|
||||
onQueuedPromptAction?: (action: QueuedPromptAction, inputID: string) => Promise<void>
|
||||
onSubagentSelect?: (sessionID: string | undefined) => void
|
||||
onSubagentInterrupt?: (sessionID: string) => void
|
||||
}
|
||||
@@ -243,6 +245,7 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lif
|
||||
onVariantSelect: input.onVariantSelect,
|
||||
onInterrupt: input.onInterrupt,
|
||||
onBackground: input.onBackground,
|
||||
onQueuedPromptAction: input.onQueuedPromptAction,
|
||||
onEditorOpen: async ({ value }) => {
|
||||
if (closed || renderer.isDestroyed) {
|
||||
return
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
import { SessionMessage } from "@opencode-ai/schema/session-message"
|
||||
import { Locale } from "../util/locale"
|
||||
import { isCompactCommand, isExitCommand, isNewCommand } from "./prompt.shared"
|
||||
import type { FooterApi, FooterEvent, RunPrompt } from "./types"
|
||||
import type { FooterApi, FooterEvent, RunDelivery, RunPrompt } from "./types"
|
||||
|
||||
type Trace = {
|
||||
write(type: string, data?: unknown): void
|
||||
@@ -21,11 +21,11 @@ export type QueueInput = {
|
||||
footer: FooterApi
|
||||
initialInput?: string
|
||||
trace?: Trace
|
||||
onSend?: (prompt: RunPrompt, delivery: "steer" | "queue") => void
|
||||
onSend?: (prompt: RunPrompt, delivery: RunDelivery) => void
|
||||
onAdmissionError?: (prompt: RunPrompt, error: unknown) => void | Promise<void>
|
||||
onNewSession?: () => void | Promise<void>
|
||||
onCompact?: () => void | Promise<void>
|
||||
admit: (prompt: RunPrompt, signal: AbortSignal) => Promise<void>
|
||||
admit: (prompt: RunPrompt, delivery: RunDelivery, signal: AbortSignal) => Promise<void>
|
||||
settle: () => Promise<void>
|
||||
run: (prompt: RunPrompt, signal: AbortSignal, admitted: () => void) => Promise<void>
|
||||
}
|
||||
@@ -183,7 +183,7 @@ export async function runPromptQueue(input: QueueInput): Promise<void> {
|
||||
input.trace?.write("ui.commit", commit)
|
||||
input.footer.append(commit)
|
||||
}
|
||||
input.onSend?.(sent, "steer")
|
||||
input.onSend?.(sent, sent.delivery ?? "steer")
|
||||
|
||||
if (state.closed) {
|
||||
break
|
||||
@@ -276,10 +276,11 @@ export async function runPromptQueue(input: QueueInput): Promise<void> {
|
||||
const sent = { ...prompt, messageID: SessionMessage.ID.create() }
|
||||
const admission = state.admission
|
||||
admissionVersion += 1
|
||||
input.onSend?.(sent, "queue")
|
||||
const delivery = prompt.delivery ?? "queue"
|
||||
input.onSend?.(sent, delivery)
|
||||
admissions = admissions
|
||||
.then(() => admission)
|
||||
.then(() => input.admit(sent, admissionController.signal))
|
||||
.then(() => input.admit(sent, delivery, admissionController.signal))
|
||||
.catch((error) => (state.closed ? undefined : input.onAdmissionError?.(sent, error)))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -390,6 +390,15 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
|
||||
log?.write("send.background", { sessionID: state.sessionID })
|
||||
void state.sdk.session.background({ sessionID: state.sessionID }).catch(() => {})
|
||||
},
|
||||
onQueuedPromptAction: async (action, inputID) => {
|
||||
if (!state.sessionID) return
|
||||
log?.write(`send.pending.${action}`, { sessionID: state.sessionID, inputID })
|
||||
if (action === "steer") {
|
||||
await state.sdk.session.pending.steer({ sessionID: state.sessionID, inputID })
|
||||
return
|
||||
}
|
||||
await state.sdk.session.pending.cancel({ sessionID: state.sessionID, inputID })
|
||||
},
|
||||
onSubagentInterrupt: (sessionID) => {
|
||||
log?.write("send.subagent.interrupt", { sessionID })
|
||||
void state.sdk.session.interrupt({ sessionID }).catch(() => {})
|
||||
@@ -892,7 +901,7 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
|
||||
trace: log,
|
||||
onSend: (prompt, delivery) => {
|
||||
state.shown = true
|
||||
state.history.push(prompt)
|
||||
state.history.push({ ...prompt, delivery: undefined })
|
||||
if (prompt.mode !== "shell" && delivery === "steer") {
|
||||
rememberLocal({
|
||||
kind: "user",
|
||||
@@ -903,18 +912,21 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
|
||||
})
|
||||
}
|
||||
},
|
||||
admit: async (prompt, signal) => {
|
||||
admit: async (prompt, delivery, signal) => {
|
||||
await state.switching?.catch(() => {})
|
||||
const next = await ensureStream()
|
||||
await next.handle.queuePromptTurn({
|
||||
agent: state.agent,
|
||||
model: state.model,
|
||||
variant: state.activeVariant,
|
||||
prompt,
|
||||
files: input.files,
|
||||
includeFiles: false,
|
||||
signal,
|
||||
})
|
||||
await next.handle.admitPromptTurn(
|
||||
{
|
||||
agent: state.agent,
|
||||
model: state.model,
|
||||
variant: state.activeVariant,
|
||||
prompt,
|
||||
files: input.files,
|
||||
includeFiles: false,
|
||||
signal,
|
||||
},
|
||||
delivery,
|
||||
)
|
||||
},
|
||||
onAdmissionError: renderPromptError,
|
||||
onCompact: async () => {
|
||||
|
||||
@@ -653,6 +653,10 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
|
||||
}
|
||||
return
|
||||
}
|
||||
if (event.type === "session.input.cancelled") {
|
||||
child.prompts.delete(event.data.inputID)
|
||||
return
|
||||
}
|
||||
if (event.type === "session.step.started") {
|
||||
touch(child, event.created)
|
||||
if (child.label === FALLBACK_LABEL && event.data.agent) child.label = Locale.titlecase(event.data.agent)
|
||||
|
||||
@@ -26,6 +26,7 @@ import type {
|
||||
FooterQueuedPrompt,
|
||||
RunFilePart,
|
||||
RunInput,
|
||||
RunDelivery,
|
||||
RunPrompt,
|
||||
RunPromptPart,
|
||||
StreamCommit,
|
||||
@@ -71,7 +72,7 @@ export type SessionResizeReplayInput = {
|
||||
|
||||
export type SessionTransport = {
|
||||
runPromptTurn(input: SessionTurnInput, admitted?: () => void): Promise<void>
|
||||
queuePromptTurn(input: SessionTurnInput): Promise<void>
|
||||
admitPromptTurn(input: SessionTurnInput, delivery: RunDelivery): Promise<void>
|
||||
waitForIdle(): Promise<void>
|
||||
interruptActiveTurn(): Promise<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 prompts = [...state.pending.values()]
|
||||
const prompts = [...state.pending.values()].filter((item) => item.delivery === "queue")
|
||||
const ids = prompts.map((item) => item.messageID)
|
||||
if (syncedPending?.length === ids.length && syncedPending.every((id, index) => id === ids[index])) return
|
||||
syncedPending = ids
|
||||
input.trace?.write("ui.patch", { pending: prompts.length })
|
||||
input.footer.event({ type: "queued.prompts", prompts })
|
||||
}
|
||||
@@ -934,6 +939,36 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
|
||||
write([], { phase: "running", status: "waiting for assistant" })
|
||||
return
|
||||
}
|
||||
if (event.type === "session.input.steered") {
|
||||
const pending = state.pending.get(event.data.inputID)
|
||||
if (!pending) return
|
||||
state.pending.set(event.data.inputID, { ...pending, delivery: "steer" })
|
||||
syncPending()
|
||||
if (state.messageIDs.has(event.data.inputID)) return
|
||||
state.messageIDs.add(event.data.inputID)
|
||||
write([
|
||||
{
|
||||
kind: "user",
|
||||
source: "system",
|
||||
text: pending.prompt.text,
|
||||
phase: "start",
|
||||
messageID: event.data.inputID,
|
||||
},
|
||||
])
|
||||
return
|
||||
}
|
||||
if (event.type === "session.input.queued") {
|
||||
const pending = state.pending.get(event.data.inputID)
|
||||
if (!pending) return
|
||||
state.pending.set(event.data.inputID, { ...pending, delivery: "queue" })
|
||||
syncPending()
|
||||
return
|
||||
}
|
||||
if (event.type === "session.input.cancelled") {
|
||||
state.admitted.delete(event.data.inputID)
|
||||
if (state.pending.delete(event.data.inputID)) syncPending()
|
||||
return
|
||||
}
|
||||
if (event.type === "session.step.started") {
|
||||
state.stepModel = { providerID: event.data.model.providerID, modelID: event.data.model.id }
|
||||
write([], { phase: "running", status: "assistant responding" })
|
||||
@@ -1577,7 +1612,7 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
|
||||
let queuedResizeReplay: SessionResizeReplayInput | 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
|
||||
if (!messageID) throw new Error("Prompt message ID is required")
|
||||
const command = next.prompt.command
|
||||
@@ -1643,14 +1678,14 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
|
||||
}
|
||||
|
||||
return {
|
||||
async queuePromptTurn(next) {
|
||||
async admitPromptTurn(next, delivery) {
|
||||
if (next.prompt.mode === "shell" || next.prompt.command?.source === "skill")
|
||||
throw new Error("This prompt cannot be queued")
|
||||
if (!state.connected) throw new Error("Event stream is reconnecting")
|
||||
const client = sdk
|
||||
if (next.agent)
|
||||
await client.session.switchAgent({ sessionID: input.sessionID, agent: next.agent }, { signal: next.signal })
|
||||
mergePending(await admitPrompt(next, client, "queue"))
|
||||
mergePending(await admitPrompt(next, client, delivery))
|
||||
settlementClient = client
|
||||
},
|
||||
async waitForIdle() {
|
||||
@@ -1688,7 +1723,7 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
|
||||
return
|
||||
}
|
||||
if (command) {
|
||||
await runTurnWait(next, messageID, client, () => admitPrompt(next, client, "steer"), admitted)
|
||||
await runTurnWait(next, messageID, client, () => admitPrompt(next, client, next.prompt.delivery ?? "steer"), admitted)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1700,7 +1735,7 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
|
||||
if (selected)
|
||||
await client.session.switchModel({ sessionID: input.sessionID, model: selected }, { signal: next.signal })
|
||||
|
||||
await runTurnWait(next, messageID, client, () => admitPrompt(next, client, "steer"), admitted)
|
||||
await runTurnWait(next, messageID, client, () => admitPrompt(next, client, next.prompt.delivery ?? "steer"), admitted)
|
||||
},
|
||||
async interruptActiveTurn() {
|
||||
// A running shell holds no drain, so session.interrupt cannot reach it;
|
||||
|
||||
@@ -23,6 +23,7 @@ import type {
|
||||
} from "@opencode-ai/client/promise"
|
||||
import type { Config } from "../config"
|
||||
import type { CliRenderer } from "@opentui/core"
|
||||
import type { SessionPending } from "@opencode-ai/schema/session-pending"
|
||||
|
||||
export type RunFilePart = {
|
||||
type: "file"
|
||||
@@ -71,10 +72,13 @@ export type RunProvider = {
|
||||
models: Record<string, RunProviderModel>
|
||||
}
|
||||
|
||||
export type RunDelivery = SessionPending.Delivery
|
||||
|
||||
export type RunPrompt = {
|
||||
messageID?: string
|
||||
text: string
|
||||
parts: RunPromptPart[]
|
||||
delivery?: RunDelivery
|
||||
mode?: "shell"
|
||||
command?: {
|
||||
name: string
|
||||
@@ -87,9 +91,11 @@ export type RunPrompt = {
|
||||
export type FooterQueuedPrompt = {
|
||||
messageID: string
|
||||
prompt: RunPrompt
|
||||
delivery: "steer" | "queue"
|
||||
delivery: RunDelivery
|
||||
}
|
||||
|
||||
export type QueuedPromptAction = "steer" | "cancel"
|
||||
|
||||
export type RunAgent = {
|
||||
id: string
|
||||
name: string
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useRenderer, useTerminalDimensions } from "@opentui/solid"
|
||||
import type { ScrollBoxRenderable, TextareaRenderable } from "@opentui/core"
|
||||
import open from "open"
|
||||
import { useTheme, useThemes } from "../../context/theme"
|
||||
import type { FormAnswer, FormField, FormValue } from "@opencode-ai/client"
|
||||
import type { FormField, FormValue } from "@opencode-ai/client"
|
||||
import type { FormWithLocation } from "../../context/data"
|
||||
import { useClient } from "../../context/client"
|
||||
import { useClipboard } from "../../context/clipboard"
|
||||
@@ -44,27 +44,6 @@ function requestOptions(form: FormWithLocation) {
|
||||
|
||||
export function FormPrompt(props: { form: FormWithLocation }) {
|
||||
const client = useClient()
|
||||
return (
|
||||
<FormInput
|
||||
form={props.form}
|
||||
onSubmit={(answer) =>
|
||||
client.api.form.reply(
|
||||
{ sessionID: props.form.sessionID, formID: props.form.id, answer },
|
||||
requestOptions(props.form),
|
||||
)
|
||||
}
|
||||
onCancel={() =>
|
||||
client.api.form.cancel({ sessionID: props.form.sessionID, formID: props.form.id }, requestOptions(props.form))
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function FormInput(props: {
|
||||
form: Pick<FormWithLocation, "title" | "fields" | "metadata">
|
||||
onSubmit: (answer: FormAnswer) => Promise<unknown> | void
|
||||
onCancel: () => Promise<unknown> | void
|
||||
}) {
|
||||
const themes = useThemes()
|
||||
const theme = useTheme("elevated")
|
||||
const themeMode = themes.mode
|
||||
@@ -202,14 +181,23 @@ export function FormInput(props: {
|
||||
}
|
||||
|
||||
function replySingle(field: FormAnswerField, value: FormValue) {
|
||||
Promise.resolve(props.onSubmit({ [field.key]: value })).catch((error: unknown) => {
|
||||
setStore(
|
||||
"error",
|
||||
typeof error === "object" && error !== null && "message" in error && typeof error.message === "string"
|
||||
? error.message
|
||||
: "Invalid answer",
|
||||
client.api.form
|
||||
.reply(
|
||||
{
|
||||
sessionID: props.form.sessionID,
|
||||
formID: props.form.id,
|
||||
answer: { [field.key]: value },
|
||||
},
|
||||
requestOptions(props.form),
|
||||
)
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
setStore(
|
||||
"error",
|
||||
typeof error === "object" && error !== null && "message" in error && typeof error.message === "string"
|
||||
? error.message
|
||||
: "Invalid answer",
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
function pick(value: FormValue, customValue?: string) {
|
||||
@@ -362,7 +350,7 @@ export function FormInput(props: {
|
||||
}
|
||||
|
||||
function cancel() {
|
||||
void props.onCancel()
|
||||
void client.api.form.cancel({ sessionID: props.form.sessionID, formID: props.form.id }, requestOptions(props.form))
|
||||
}
|
||||
|
||||
function openExternal() {
|
||||
@@ -414,23 +402,28 @@ export function FormInput(props: {
|
||||
setStore("error", formValidateValue(invalid, store.answers[invalid.key]) ?? "Invalid answer")
|
||||
return
|
||||
}
|
||||
Promise.resolve(
|
||||
props.onSubmit(
|
||||
Object.fromEntries(
|
||||
fields().flatMap((field) => {
|
||||
const value = store.answers[field.key]
|
||||
return value === undefined ? [] : [[field.key, value] as const]
|
||||
}),
|
||||
),
|
||||
),
|
||||
).catch((error: unknown) => {
|
||||
setStore(
|
||||
"error",
|
||||
typeof error === "object" && error !== null && "message" in error && typeof error.message === "string"
|
||||
? error.message
|
||||
: "Invalid answer",
|
||||
client.api.form
|
||||
.reply(
|
||||
{
|
||||
sessionID: props.form.sessionID,
|
||||
formID: props.form.id,
|
||||
answer: Object.fromEntries(
|
||||
fields().flatMap((field) => {
|
||||
const value = store.answers[field.key]
|
||||
return value === undefined ? [] : [[field.key, value] as const]
|
||||
}),
|
||||
),
|
||||
},
|
||||
requestOptions(props.form),
|
||||
)
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
setStore(
|
||||
"error",
|
||||
typeof error === "object" && error !== null && "message" in error && typeof error.message === "string"
|
||||
? error.message
|
||||
: "Invalid answer",
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
onMount(() => onCleanup(keymap.mode.push(FORM_MODE)))
|
||||
@@ -458,7 +451,10 @@ export function FormInput(props: {
|
||||
group: "Form",
|
||||
run: () => {
|
||||
if (textual()) {
|
||||
void props.onCancel()
|
||||
void client.api.form.cancel(
|
||||
{ sessionID: props.form.sessionID, formID: props.form.id },
|
||||
requestOptions(props.form),
|
||||
)
|
||||
return
|
||||
}
|
||||
setStore("editing", false)
|
||||
|
||||
@@ -52,6 +52,7 @@ import { useClient } from "../../context/client"
|
||||
import { useEditorContext } from "../../context/editor"
|
||||
import { openEditor } from "../../editor"
|
||||
import { useDialog } from "../../ui/dialog"
|
||||
import { DialogSelect } from "../../ui/dialog-select"
|
||||
import { DialogSessionRename } from "../../component/dialog-session-rename"
|
||||
import { DialogMessage } from "./dialog-message"
|
||||
import { DialogFork } from "./dialog-fork"
|
||||
@@ -97,6 +98,8 @@ import { stringWidth } from "../../util/string-width"
|
||||
import { useArgs } from "../../context/args"
|
||||
import { withTimestampedFallback } from "@opencode-ai/util/session-title-fallback"
|
||||
import { useSessionTabs } from "../../context/session-tabs"
|
||||
import { createSingleFlight } from "../../util/single-flight"
|
||||
import type { SessionPending } from "@opencode-ai/schema/session-pending"
|
||||
|
||||
addDefaultParsers(parsers.parsers)
|
||||
|
||||
@@ -109,6 +112,7 @@ const NAVIGATION_SLACK_ID = "session-navigation-slack"
|
||||
const TRANSCRIPT_TAIL_ROWS = 40
|
||||
const TRANSCRIPT_BACKFILL_CHUNK = 60
|
||||
const TRANSCRIPT_BACKFILL_DELAY = 120
|
||||
type PendingAction = "steer" | "queue" | "cancel"
|
||||
|
||||
const context = createContext<{
|
||||
width: number
|
||||
@@ -120,6 +124,8 @@ const context = createContext<{
|
||||
diffWrapMode: () => "word" | "none"
|
||||
models: () => ModelInfo[]
|
||||
config: ReturnType<typeof useConfig>["data"]
|
||||
mutatePending: (action: PendingAction, inputID: string) => Promise<boolean>
|
||||
pendingDelivery: (inputID: string) => SessionPending.Delivery | undefined
|
||||
}>()
|
||||
|
||||
function use() {
|
||||
@@ -175,6 +181,13 @@ export function Session() {
|
||||
.flatMap((sessionID) => data.session.form.list(sessionID) ?? [])
|
||||
.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({
|
||||
open: false,
|
||||
tab: undefined as string | undefined,
|
||||
@@ -369,6 +382,54 @@ export function Session() {
|
||||
})
|
||||
const dialog = useDialog()
|
||||
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) => {
|
||||
void mutatePending("cancel", option.value).then((cancelled) => {
|
||||
if (cancelled && queuedPrompts().length <= 1) dialog.clear()
|
||||
})
|
||||
},
|
||||
},
|
||||
]}
|
||||
footerHints={[{ title: "steer", label: "enter" }]}
|
||||
/>
|
||||
))
|
||||
const unavailable = (feature: string) => {
|
||||
toast.show({ message: `${feature} is not implemented for V2 sessions yet`, variant: "error", duration: 5000 })
|
||||
dialog.clear()
|
||||
@@ -871,6 +932,13 @@ export function Session() {
|
||||
dialog.clear()
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "View queued prompts",
|
||||
id: "session.queued_prompts",
|
||||
group: "Session",
|
||||
enabled: queuedPrompts().length > 0,
|
||||
run: openQueuedPrompts,
|
||||
},
|
||||
{
|
||||
title: "Go to parent session",
|
||||
id: "session.parent",
|
||||
@@ -942,6 +1010,8 @@ export function Session() {
|
||||
diffWrapMode,
|
||||
models,
|
||||
config,
|
||||
mutatePending,
|
||||
pendingDelivery: (inputID) => pendingDeliveries().get(inputID),
|
||||
}}
|
||||
>
|
||||
<box flexDirection="row" flexGrow={1} minHeight={0}>
|
||||
@@ -997,6 +1067,9 @@ export function Session() {
|
||||
</Show>
|
||||
</scrollbox>
|
||||
<box flexShrink={0}>
|
||||
<Show when={!composer.open && !disabled() && queuedPrompts().length > 0}>
|
||||
<QueuedPromptDock prompts={queuedPrompts()} onOpen={openQueuedPrompts} />
|
||||
</Show>
|
||||
<PluginSlot name="session.composer.top" input={{ sessionID: route.sessionID }} mode="all" />
|
||||
<Composer
|
||||
sessionID={route.sessionID}
|
||||
@@ -1032,6 +1105,11 @@ export function Session() {
|
||||
onSubmit={() => {
|
||||
toBottom()
|
||||
}}
|
||||
onEmptySubmit={async () => {
|
||||
const next = queuedPrompts()[0]
|
||||
if (!next) return false
|
||||
return mutatePending("steer", next.id)
|
||||
}}
|
||||
sessionID={route.sessionID}
|
||||
/>
|
||||
</Match>
|
||||
@@ -1813,6 +1891,7 @@ function ShellMessage(props: { message: Extract<SessionMessageInfo, { type: "she
|
||||
|
||||
return (
|
||||
<box
|
||||
width="100%"
|
||||
border={["left"]}
|
||||
paddingTop={1}
|
||||
paddingBottom={1}
|
||||
@@ -1840,18 +1919,20 @@ function UserMessage(props: { message: SessionMessageUser }) {
|
||||
const mode = themes.mode
|
||||
const [hover, setHover] = createSignal(false)
|
||||
const color = createMemo(() => local.agent.color(data.session.get(ctx.sessionID)?.agent ?? "build"))
|
||||
const queued = createMemo(
|
||||
() => data.session.status(ctx.sessionID) === "running" && data.session.input.has(ctx.sessionID, props.message.id),
|
||||
)
|
||||
const delivery = createMemo(() => ctx.pendingDelivery(props.message.id))
|
||||
const dialog = useDialog()
|
||||
const renderer = useRenderer()
|
||||
const promptRef = usePromptRef()
|
||||
|
||||
const updatePendingSteer = async (action: "queue" | "cancel") => {
|
||||
if (await ctx.mutatePending(action, props.message.id)) dialog.clear()
|
||||
}
|
||||
|
||||
return (
|
||||
<Show when={props.message.text.trim() || files().length}>
|
||||
<box
|
||||
border={["left"]}
|
||||
borderColor={queued() ? theme.border.default : color()}
|
||||
borderColor={delivery() ? theme.border.default : color()}
|
||||
customBorderChars={SplitBorder.customBorderChars}
|
||||
>
|
||||
<box
|
||||
@@ -1863,6 +1944,21 @@ function UserMessage(props: { message: SessionMessageUser }) {
|
||||
}}
|
||||
onMouseUp={() => {
|
||||
if (renderer.getSelection()?.getSelectedText()) return
|
||||
if (delivery() === "steer") {
|
||||
dialog.replace(() => (
|
||||
<DialogSelect
|
||||
title="Pending steer"
|
||||
options={[
|
||||
{ title: "Move to queue", value: "queue" as const },
|
||||
{ title: "Delete", value: "cancel" as const },
|
||||
]}
|
||||
onSelect={(option) => {
|
||||
void updatePendingSteer(option.value)
|
||||
}}
|
||||
/>
|
||||
))
|
||||
return
|
||||
}
|
||||
dialog.replace(() => (
|
||||
<DialogMessage
|
||||
messageID={props.message.id}
|
||||
@@ -1910,6 +2006,35 @@ function UserMessage(props: { message: SessionMessageUser }) {
|
||||
)
|
||||
}
|
||||
|
||||
function QueuedPromptDock(props: { prompts: { id: string; text: string }[]; onOpen: () => void }) {
|
||||
const theme = useTheme("elevated")
|
||||
const next = createMemo(() => props.prompts[0]?.text)
|
||||
|
||||
return (
|
||||
<box
|
||||
border={["left"]}
|
||||
borderColor={theme.border.default}
|
||||
customBorderChars={SplitBorder.customBorderChars}
|
||||
onMouseUp={props.onOpen}
|
||||
>
|
||||
<box
|
||||
width="100%"
|
||||
paddingTop={1}
|
||||
paddingBottom={1}
|
||||
paddingLeft={2}
|
||||
paddingRight={1}
|
||||
backgroundColor={theme.background.default}
|
||||
flexDirection="row"
|
||||
>
|
||||
<text fg={theme.text.subdued} wrapMode="none" truncate flexGrow={1} flexShrink={1} minWidth={0}>
|
||||
<span style={{ fg: theme.text.default }}>{props.prompts.length} queued</span>
|
||||
<Show when={next()}>{(text) => <> · {text()}</>}</Show>
|
||||
</text>
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
function AssistantRetry(props: { retry: SessionMessageAssistant["retry"] }) {
|
||||
const theme = useTheme()
|
||||
return (
|
||||
|
||||
@@ -46,9 +46,14 @@ export function createSessionRows(sessionID: Accessor<string>) {
|
||||
function reduce() {
|
||||
const messages = data.session.message.list(sessionID())
|
||||
const inputs = new Set(data.session.input.list(sessionID()))
|
||||
const pending = data.session.pending.list(sessionID())
|
||||
const queued = new Set(
|
||||
pending.flatMap((item) => (item.type === "user" && item.delivery === "queue" ? [item.id] : [])),
|
||||
)
|
||||
const visible = queued.size === 0 ? messages : messages.filter((message) => !queued.has(message.id))
|
||||
const boundary = revertBoundary()
|
||||
const rows = reduceSessionRows(
|
||||
boundary ? messages.filter((message) => message.id < boundary) : messages,
|
||||
boundary ? visible.filter((message) => message.id < boundary) : visible,
|
||||
inputs,
|
||||
turnTokens(),
|
||||
)
|
||||
@@ -57,8 +62,7 @@ export function createSessionRows(sessionID: Accessor<string>) {
|
||||
rows.splice(
|
||||
position === -1 ? rows.length : position,
|
||||
0,
|
||||
...data.session.pending
|
||||
.list(sessionID())
|
||||
...pending
|
||||
.filter((item) => item.type === "compaction")
|
||||
.map((item): SessionRow => ({ type: "compaction-queued", inputID: item.id })),
|
||||
)
|
||||
@@ -112,10 +116,11 @@ export function createSessionRows(sessionID: Accessor<string>) {
|
||||
createEffect(
|
||||
on(
|
||||
() =>
|
||||
data.session.pending
|
||||
.list(sessionID())
|
||||
.filter((item) => item.type === "compaction")
|
||||
.map((item) => item.id),
|
||||
data.session.pending.list(sessionID()).flatMap((item) => {
|
||||
if (item.type === "compaction") return [`${item.id}:compaction`]
|
||||
if (item.type === "user" && item.delivery === "queue") return [`${item.id}:queue`]
|
||||
return []
|
||||
}),
|
||||
() => setRows(reconcile(reduce())),
|
||||
{ defer: true },
|
||||
),
|
||||
@@ -196,7 +201,9 @@ export function createSessionRows(sessionID: Accessor<string>) {
|
||||
|
||||
const queuedStart = (rows: SessionRow[]) => {
|
||||
const index = rows.findIndex(
|
||||
(row) => row.type === "compaction-queued" || (row.type === "message" && isPending(row.messageID)),
|
||||
(row) =>
|
||||
row.type === "compaction-queued" ||
|
||||
(row.type === "message" && isPending(row.messageID)),
|
||||
)
|
||||
return index === -1 ? rows.length : index
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,7 @@ export function primitiveInputSummary(input: Record<string, unknown>, omit: read
|
||||
export function webSearchProviderLabel(provider: unknown) {
|
||||
if (provider === "parallel") return "Parallel Web Search"
|
||||
if (provider === "exa") return "Exa Web Search"
|
||||
if (provider === "firecrawl") return "Firecrawl Web Search"
|
||||
return "Web Search"
|
||||
}
|
||||
|
||||
|
||||
@@ -914,6 +914,106 @@ test("completes exploration when a queued prompt is promoted", async () => {
|
||||
}
|
||||
})
|
||||
|
||||
test("updates and removes queued inputs from durable lifecycle events", async () => {
|
||||
const events = createEventStream()
|
||||
const sessionID = "session-queue-management"
|
||||
const calls = createFetch((url) => {
|
||||
if (url.pathname === `/api/session/${sessionID}/message`) return json({ data: [], cursor: {} })
|
||||
}, events)
|
||||
let data!: ReturnType<typeof useData>
|
||||
let rows!: ReturnType<typeof createSessionRows>
|
||||
let client!: ReturnType<typeof useClient>
|
||||
|
||||
function Probe() {
|
||||
client = useClient()
|
||||
data = useData()
|
||||
rows = createSessionRows(() => sessionID)
|
||||
return <box />
|
||||
}
|
||||
|
||||
const app = await testRender(() => (
|
||||
<TestTuiContexts>
|
||||
<ClientProvider api={createApi(calls.fetch)}>
|
||||
<ProjectProvider>
|
||||
<DataProvider>
|
||||
<Probe />
|
||||
</DataProvider>
|
||||
</ProjectProvider>
|
||||
</ClientProvider>
|
||||
</TestTuiContexts>
|
||||
))
|
||||
|
||||
try {
|
||||
await wait(() => client.connection.status() === "connected")
|
||||
emitEvent(events, {
|
||||
id: "evt_queue_admitted",
|
||||
created: 1,
|
||||
type: "session.input.admitted",
|
||||
durable: durable(sessionID),
|
||||
data: {
|
||||
sessionID,
|
||||
inputID: "message-queued",
|
||||
input: { type: "user", data: { text: "Steer me" }, delivery: "queue" },
|
||||
},
|
||||
})
|
||||
await wait(() => data.session.pending.list(sessionID).length === 1)
|
||||
expect(rows).not.toContainEqual({ type: "message", messageID: "message-queued" })
|
||||
|
||||
emitEvent(events, {
|
||||
id: "evt_queue_steered",
|
||||
created: 2,
|
||||
type: "session.input.steered",
|
||||
durable: durable(sessionID, 1),
|
||||
data: { sessionID, inputID: "message-queued" },
|
||||
})
|
||||
await wait(() =>
|
||||
data.session.pending
|
||||
.list(sessionID)
|
||||
.some((item) => item.id === "message-queued" && item.type !== "compaction" && item.delivery === "steer"),
|
||||
)
|
||||
expect(rows).toContainEqual({ type: "message", messageID: "message-queued" })
|
||||
|
||||
emitEvent(events, {
|
||||
id: "evt_queue_restored",
|
||||
created: 3,
|
||||
type: "session.input.queued",
|
||||
durable: durable(sessionID, 2),
|
||||
data: { sessionID, inputID: "message-queued" },
|
||||
})
|
||||
await wait(() =>
|
||||
data.session.pending
|
||||
.list(sessionID)
|
||||
.some((item) => item.id === "message-queued" && item.type !== "compaction" && item.delivery === "queue"),
|
||||
)
|
||||
expect(rows).not.toContainEqual({ type: "message", messageID: "message-queued" })
|
||||
|
||||
emitEvent(events, {
|
||||
id: "evt_cancel_admitted",
|
||||
created: 4,
|
||||
type: "session.input.admitted",
|
||||
durable: durable(sessionID, 3),
|
||||
data: {
|
||||
sessionID,
|
||||
inputID: "message-cancelled",
|
||||
input: { type: "user", data: { text: "Delete me" }, delivery: "queue" },
|
||||
},
|
||||
})
|
||||
await wait(() => data.session.pending.list(sessionID).length === 2)
|
||||
emitEvent(events, {
|
||||
id: "evt_queue_cancelled",
|
||||
created: 5,
|
||||
type: "session.input.cancelled",
|
||||
durable: durable(sessionID, 4),
|
||||
data: { sessionID, inputID: "message-cancelled" },
|
||||
})
|
||||
await wait(() => !data.session.input.has(sessionID, "message-cancelled"))
|
||||
expect(data.session.pending.list(sessionID).map((item) => item.id)).toEqual(["message-queued"])
|
||||
expect(data.session.message.get(sessionID, "message-cancelled")).toBeUndefined()
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("classifies live tool rows independently of their call ID", async () => {
|
||||
const events = createEventStream()
|
||||
const sessionID = "session-tool-call-id"
|
||||
|
||||
@@ -56,9 +56,9 @@ export function createFooterApiFixture(input: { events?: FooterEvent[]; commits?
|
||||
commits,
|
||||
calls,
|
||||
promptReady,
|
||||
submit(text: string, mode?: RunPrompt["mode"]) {
|
||||
submit(text: string, mode?: RunPrompt["mode"], delivery?: RunPrompt["delivery"]) {
|
||||
if (prompts.size === 0) return false
|
||||
const prompt: RunPrompt = mode ? { text, parts: [], mode } : { text, parts: [] }
|
||||
const prompt: RunPrompt = { text, parts: [], ...(mode ? { mode } : {}), ...(delivery ? { delivery } : {}) }
|
||||
for (const fn of [...prompts]) fn(prompt)
|
||||
return true
|
||||
},
|
||||
|
||||
@@ -21,6 +21,7 @@ import { RunFooterView } from "../../src/mini/footer.view"
|
||||
import { RunEntryContent } from "../../src/mini/scrollback.writer"
|
||||
import { RUN_THEME_FALLBACK, type RunTheme } from "../../src/mini/theme"
|
||||
import type {
|
||||
FooterQueuedPrompt,
|
||||
FooterState,
|
||||
FooterSubagentState,
|
||||
FooterSubagentTab,
|
||||
@@ -120,13 +121,15 @@ async function renderFooter(
|
||||
height?: number
|
||||
state?: Partial<FooterState>
|
||||
onCycle?: () => void
|
||||
onSubmit?: (prompt: RunPrompt) => boolean
|
||||
onSubmit?: (prompt: RunPrompt) => boolean | Promise<boolean>
|
||||
view?: FooterView
|
||||
onFormReply?: (input: unknown) => void
|
||||
miniSettings?: MiniSettings
|
||||
mono?: boolean
|
||||
onStatus?: (status: string) => void
|
||||
onMiniSettingChange?: (change: MiniSettingChange) => void
|
||||
queuedPrompts?: FooterQueuedPrompt[]
|
||||
onQueuedPromptAction?: (action: "steer" | "cancel", inputID: string) => Promise<void>
|
||||
} = {},
|
||||
) {
|
||||
const [view, setView] = createSignal<FooterView>(input.view ?? { type: "prompt" })
|
||||
@@ -164,6 +167,7 @@ async function renderFooter(
|
||||
state={state}
|
||||
view={view}
|
||||
subagent={subagents}
|
||||
queuedPrompts={() => input.queuedPrompts ?? []}
|
||||
theme={input.theme ?? (() => RUN_THEME_FALLBACK)}
|
||||
mono={input.mono ?? false}
|
||||
miniSettings={miniSettings}
|
||||
@@ -173,6 +177,7 @@ async function renderFooter(
|
||||
onFormCancel={() => {}}
|
||||
onCycle={input.onCycle ?? (() => {})}
|
||||
onInterrupt={() => false}
|
||||
onQueuedPromptAction={input.onQueuedPromptAction}
|
||||
onEditorOpen={async () => undefined}
|
||||
onInputClear={() => {}}
|
||||
onExit={() => {}}
|
||||
@@ -913,7 +918,7 @@ test("direct subagent panel closes when moving up from the first item", async ()
|
||||
}
|
||||
})
|
||||
|
||||
test("direct pending panel shows durable delivery without edit actions", async () => {
|
||||
test("direct queued panel steers and deletes selected prompts", async () => {
|
||||
const [prompts] = createSignal([
|
||||
{
|
||||
messageID: "m-1",
|
||||
@@ -921,16 +926,22 @@ test("direct pending panel shows durable delivery without edit actions", async (
|
||||
delivery: "queue" as const,
|
||||
},
|
||||
])
|
||||
const steered: string[] = []
|
||||
const deleted: string[] = []
|
||||
|
||||
const app = await testRender(
|
||||
() => (
|
||||
<box width={100} height={RUN_SUBAGENT_PANEL_ROWS}>
|
||||
<RunQueuedPromptSelectBody
|
||||
theme={() => RUN_THEME_FALLBACK.footer}
|
||||
prompts={prompts}
|
||||
onClose={() => {}}
|
||||
/>
|
||||
</box>
|
||||
<Keymap.Provider config={tuiConfig}>
|
||||
<box width={100} height={RUN_SUBAGENT_PANEL_ROWS}>
|
||||
<RunQueuedPromptSelectBody
|
||||
theme={() => RUN_THEME_FALLBACK.footer}
|
||||
prompts={prompts}
|
||||
onClose={() => {}}
|
||||
onSteer={(prompt) => steered.push(prompt.messageID)}
|
||||
onDelete={(prompt) => deleted.push(prompt.messageID)}
|
||||
/>
|
||||
</box>
|
||||
</Keymap.Provider>
|
||||
),
|
||||
{ width: 100, height: RUN_SUBAGENT_PANEL_ROWS },
|
||||
)
|
||||
@@ -940,19 +951,75 @@ test("direct pending panel shows durable delivery without edit actions", async (
|
||||
const frame = app.captureCharFrame()
|
||||
const list = panelMenu(app.renderer.root)
|
||||
|
||||
expect(frame).toContain("Pending work")
|
||||
expect(frame).toContain("Queued prompts")
|
||||
expect(frame).toContain("fix the auth test")
|
||||
expect(frame).toContain("queue")
|
||||
expect(frame).toContain("queued")
|
||||
expect(frame).toContain("enter steer · ctrl+d delete")
|
||||
expect(frame).not.toContain("┌")
|
||||
expect(frame).not.toContain("┃")
|
||||
expectPaletteList(list, 0)
|
||||
expect(frame).not.toContain("edit")
|
||||
expect(frame).not.toContain("remove")
|
||||
app.mockInput.pressEnter()
|
||||
app.mockInput.pressKey("d", { ctrl: true })
|
||||
expect(steered).toEqual(["m-1"])
|
||||
expect(deleted).toEqual(["m-1"])
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("direct footer steers the oldest queued prompt from an empty composer", async () => {
|
||||
const steered: string[] = []
|
||||
const app = await renderFooter({
|
||||
queuedPrompts: [
|
||||
{ messageID: "m-1", prompt: { text: "first", parts: [] }, delivery: "queue" },
|
||||
{ messageID: "m-2", prompt: { text: "second", parts: [] }, delivery: "queue" },
|
||||
],
|
||||
onQueuedPromptAction: async (action, inputID) => {
|
||||
if (action === "steer") steered.push(inputID)
|
||||
},
|
||||
})
|
||||
|
||||
try {
|
||||
await app.renderOnce()
|
||||
app.mockInput.pressEnter({ meta: true })
|
||||
await Bun.sleep(0)
|
||||
expect(steered).toEqual([])
|
||||
app.mockInput.pressEnter()
|
||||
await Bun.sleep(0)
|
||||
expect(steered).toEqual(["m-1"])
|
||||
} finally {
|
||||
app.cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
test("direct footer does not steer queued work on a double submit", async () => {
|
||||
const submitted: RunPrompt[] = []
|
||||
const steered: string[] = []
|
||||
const app = await renderFooter({
|
||||
queuedPrompts: [{ messageID: "m-1", prompt: { text: "queued", parts: [] }, delivery: "queue" }],
|
||||
onSubmit: async (prompt) => {
|
||||
submitted.push(prompt)
|
||||
await Bun.sleep(10)
|
||||
return true
|
||||
},
|
||||
onQueuedPromptAction: async (action, inputID) => {
|
||||
if (action === "steer") steered.push(inputID)
|
||||
},
|
||||
})
|
||||
|
||||
try {
|
||||
await app.renderOnce()
|
||||
await app.mockInput.typeText("send once")
|
||||
app.mockInput.pressEnter()
|
||||
app.mockInput.pressEnter()
|
||||
await Bun.sleep(20)
|
||||
expect(submitted).toHaveLength(1)
|
||||
expect(steered).toEqual([])
|
||||
} finally {
|
||||
app.cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
// OpenTUI currently crashes Bun in the full `test/cli/run` directory run here.
|
||||
// Re-enable after the upstream OpenTUI fix lands in this repo.
|
||||
test.skip("direct footer recreates the frame across command panel transitions", async () => {
|
||||
@@ -1068,11 +1135,11 @@ test("direct footer submits slash autocomplete selections without dispatching sh
|
||||
await app.renderOnce()
|
||||
|
||||
expect(submits).toEqual([
|
||||
{ text: "/review ", parts: [], command: { name: "review", arguments: "" } },
|
||||
{ text: "/review ", parts: [], command: { name: "review", arguments: "" } },
|
||||
{ text: "/review branch", parts: [], command: { name: "review", arguments: "branch" } },
|
||||
{ text: "/new ", parts: [] },
|
||||
{ text: "/new ", parts: [] },
|
||||
{ text: "/review ", parts: [], command: { name: "review", arguments: "" }, delivery: "steer" },
|
||||
{ text: "/review ", parts: [], command: { name: "review", arguments: "" }, delivery: "steer" },
|
||||
{ text: "/review branch", parts: [], command: { name: "review", arguments: "branch" }, delivery: "steer" },
|
||||
{ text: "/new ", parts: [], delivery: "steer" },
|
||||
{ text: "/new ", parts: [], delivery: "steer" },
|
||||
])
|
||||
expect(app.renderer.currentFocusedEditor?.plainText).toBe("/settings ")
|
||||
} finally {
|
||||
@@ -1100,7 +1167,9 @@ test("direct footer slash autocomplete keeps a real skills command", async () =>
|
||||
app.mockInput.pressEnter()
|
||||
await app.renderOnce()
|
||||
|
||||
expect(submits).toEqual([{ text: "/skills ", parts: [], command: { name: "skills", arguments: "" } }])
|
||||
expect(submits).toEqual([
|
||||
{ text: "/skills ", parts: [], command: { name: "skills", arguments: "" }, delivery: "steer" },
|
||||
])
|
||||
expect(app.captureCharFrame()).not.toContain("Apply formatter fixes")
|
||||
} finally {
|
||||
app.cleanup()
|
||||
@@ -1158,7 +1227,12 @@ test("direct footer tags skill slash submissions with their catalog source", asy
|
||||
await app.renderOnce()
|
||||
|
||||
expect(submits).toEqual([
|
||||
{ text: "/formatter src", parts: [], command: { name: "formatter", arguments: "src", source: "skill" } },
|
||||
{
|
||||
text: "/formatter src",
|
||||
parts: [],
|
||||
command: { name: "formatter", arguments: "src", source: "skill" },
|
||||
delivery: "steer",
|
||||
},
|
||||
])
|
||||
} finally {
|
||||
app.cleanup()
|
||||
@@ -1238,7 +1312,7 @@ test.skip("direct footer clears the synthetic skills draft when the panel closes
|
||||
}
|
||||
})
|
||||
|
||||
test("direct footer shows authoritative pending work while running", async () => {
|
||||
test("direct footer shows authoritative queued work while running", async () => {
|
||||
const [state] = createSignal<FooterState>({
|
||||
phase: "running",
|
||||
status: "",
|
||||
@@ -1342,9 +1416,9 @@ test("direct footer shows authoritative pending work while running", async () =>
|
||||
const hint = statusItems.at(-1)!
|
||||
|
||||
expect(spinner).toBeDefined()
|
||||
expect(frame).toContain("1 pending")
|
||||
expect(frame).toContain("1 queued")
|
||||
expect(frame).toContain("ctrl+b background")
|
||||
expect(frame).toContain("ctrl+x q 1 pending")
|
||||
expect(frame).toContain("ctrl+x q 1 queued")
|
||||
expect(frame).toContain("↓ subagents")
|
||||
expect(frame).toContain("ctrl+p cmd")
|
||||
expect(frame).toContain("subagents · ctrl+p cmd")
|
||||
|
||||
@@ -82,7 +82,8 @@ describe("run runtime boot", () => {
|
||||
expect(result.keybinds.get("prompt.history.next")?.[0]?.key).toBe("down")
|
||||
expect(result.keybinds.get("prompt.clear")?.[0]?.key).toBe("ctrl+c")
|
||||
expect(result.keybinds.get("input.submit")?.[0]?.key).toBe("return")
|
||||
expect(result.keybinds.get("input.newline")?.[0]?.key).toBe("shift+return,ctrl+return,alt+return,ctrl+j")
|
||||
expect(result.keybinds.get("input.newline")?.[0]?.key).toBe("shift+return,ctrl+return,ctrl+j")
|
||||
expect(result.keybinds.get("prompt.queue")?.[0]?.key).toBe("alt+return")
|
||||
})
|
||||
|
||||
test("preserves disabled leader from resolved tui config", async () => {
|
||||
|
||||
@@ -265,6 +265,33 @@ describe("run runtime queue", () => {
|
||||
await task
|
||||
})
|
||||
|
||||
test("preserves explicit steer and queue delivery for in-flight prompts", async () => {
|
||||
const ui = createFooterApiFixture()
|
||||
const admitted: string[] = []
|
||||
const gate = Promise.withResolvers<void>()
|
||||
|
||||
const task = runPromptQueue({
|
||||
footer: ui.api,
|
||||
run: async (_input, _signal, onAdmitted) => {
|
||||
onAdmitted()
|
||||
await gate.promise
|
||||
},
|
||||
admit: async (input, delivery) => {
|
||||
admitted.push(`${input.text}:${delivery}`)
|
||||
},
|
||||
settle: async () => ui.api.close(),
|
||||
})
|
||||
|
||||
ui.submit("one")
|
||||
ui.submit("two", undefined, "steer")
|
||||
ui.submit("three", undefined, "queue")
|
||||
while (admitted.length < 2) await Bun.sleep(0)
|
||||
expect(admitted).toEqual(["two:steer", "three:queue"])
|
||||
|
||||
gate.resolve()
|
||||
await task
|
||||
})
|
||||
|
||||
test("continues durable admission after one fails", async () => {
|
||||
const ui = createFooterApiFixture()
|
||||
const admitted: string[] = []
|
||||
@@ -308,7 +335,7 @@ describe("run runtime queue", () => {
|
||||
admitted()
|
||||
await new Promise<void>((resolve) => signal.addEventListener("abort", () => resolve(), { once: true }))
|
||||
},
|
||||
admit: async (_prompt, signal) => {
|
||||
admit: async (_prompt, _delivery, signal) => {
|
||||
admissionStarted.resolve()
|
||||
await new Promise<void>((resolve) => {
|
||||
if (signal.aborted) {
|
||||
|
||||
@@ -126,7 +126,7 @@ describe("run interactive runtime", () => {
|
||||
turnStarted.resolve()
|
||||
api.close()
|
||||
},
|
||||
queuePromptTurn: async () => {},
|
||||
admitPromptTurn: async () => {},
|
||||
waitForIdle: async () => {},
|
||||
interruptActiveTurn: async () => {},
|
||||
selectSubagent: () => {},
|
||||
@@ -209,7 +209,7 @@ describe("run interactive runtime", () => {
|
||||
streamStarted.resolve()
|
||||
return {
|
||||
runPromptTurn: async () => {},
|
||||
queuePromptTurn: async () => {},
|
||||
admitPromptTurn: async () => {},
|
||||
waitForIdle: async () => {},
|
||||
interruptActiveTurn: async () => {},
|
||||
selectSubagent: () => {},
|
||||
@@ -556,7 +556,7 @@ describe("run interactive runtime", () => {
|
||||
setTimeout(() => input.footer.close(), 0)
|
||||
return {
|
||||
runPromptTurn: async () => {},
|
||||
queuePromptTurn: async () => {},
|
||||
admitPromptTurn: async () => {},
|
||||
waitForIdle: async () => {},
|
||||
interruptActiveTurn: async () => {},
|
||||
selectSubagent: () => {},
|
||||
|
||||
@@ -669,6 +669,14 @@ describe("V2 mini transport", () => {
|
||||
data: { text: "follow up" },
|
||||
delivery: "queue",
|
||||
},
|
||||
{
|
||||
id: "msg_cancelled",
|
||||
sessionID: "ses_1",
|
||||
timeCreated: 2,
|
||||
type: "user",
|
||||
data: { text: "remove me" },
|
||||
delivery: "queue",
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
@@ -684,11 +692,14 @@ describe("V2 mini transport", () => {
|
||||
.findLast((item) => item.type === "queued.prompts")
|
||||
?.prompts.map((item) => [item.messageID, item.delivery])
|
||||
|
||||
expect(pending()).toEqual([["msg_queued", "queue"]])
|
||||
expect(pending()).toEqual([
|
||||
["msg_queued", "queue"],
|
||||
["msg_cancelled", "queue"],
|
||||
])
|
||||
events.push({
|
||||
id: "evt_promoted",
|
||||
created: 2,
|
||||
type: "session.input.promoted",
|
||||
id: "evt_steered",
|
||||
created: 3,
|
||||
type: "session.input.steered",
|
||||
durable: durable("ses_1", 2),
|
||||
data: { sessionID: "ses_1", inputID: "msg_queued" },
|
||||
})
|
||||
@@ -697,18 +708,48 @@ describe("V2 mini transport", () => {
|
||||
expect(ui.commits).toContainEqual(
|
||||
expect.objectContaining({ kind: "user", messageID: "msg_queued", text: "follow up" }),
|
||||
)
|
||||
expect(pending()).toEqual([])
|
||||
expect(pending()).toEqual([["msg_cancelled", "queue"]])
|
||||
events.push({
|
||||
id: "evt_queued",
|
||||
created: 4,
|
||||
type: "session.input.queued",
|
||||
durable: durable("ses_1", 3),
|
||||
data: { sessionID: "ses_1", inputID: "msg_queued" },
|
||||
})
|
||||
while (pending()?.length !== 2) await Bun.sleep(0)
|
||||
expect(pending()).toEqual([
|
||||
["msg_queued", "queue"],
|
||||
["msg_cancelled", "queue"],
|
||||
])
|
||||
events.push({
|
||||
id: "evt_cancelled",
|
||||
created: 5,
|
||||
type: "session.input.cancelled",
|
||||
durable: durable("ses_1", 4),
|
||||
data: { sessionID: "ses_1", inputID: "msg_cancelled" },
|
||||
})
|
||||
while (pending()?.length !== 1) await Bun.sleep(0)
|
||||
expect(pending()).toEqual([["msg_queued", "queue"]])
|
||||
events.push({
|
||||
id: "evt_promoted",
|
||||
created: 6,
|
||||
type: "session.input.promoted",
|
||||
durable: durable("ses_1", 5),
|
||||
data: { sessionID: "ses_1", inputID: "msg_queued" },
|
||||
})
|
||||
while (pending()?.length !== 0) await Bun.sleep(0)
|
||||
expect(ui.commits.filter((item) => item.messageID === "msg_queued")).toHaveLength(1)
|
||||
const prompt = spyOn(client.session, "prompt").mockImplementation(
|
||||
(request) => ok(promptAdmission(request)) as never,
|
||||
)
|
||||
await transport.queuePromptTurn({
|
||||
await transport.admitPromptTurn({
|
||||
agent: "review",
|
||||
model: undefined,
|
||||
variant: undefined,
|
||||
prompt: { messageID: "msg_next", text: "another", parts: [] },
|
||||
files: [],
|
||||
includeFiles: false,
|
||||
})
|
||||
}, "queue")
|
||||
expect(client.session.switchAgent).toHaveBeenCalledWith({ sessionID: "ses_1", agent: "review" }, expect.anything())
|
||||
expect(prompt).toHaveBeenCalledWith(expect.objectContaining({ delivery: "queue" }), expect.anything())
|
||||
events.push({
|
||||
@@ -722,15 +763,8 @@ describe("V2 mini transport", () => {
|
||||
input: { type: "user", data: { text: "earlier" }, delivery: "steer" },
|
||||
},
|
||||
})
|
||||
while (true) {
|
||||
const pending = ui.events.findLast((item) => item.type === "queued.prompts")
|
||||
if (pending?.type === "queued.prompts" && pending.prompts.length >= 2) break
|
||||
await Bun.sleep(0)
|
||||
}
|
||||
expect(pending()).toEqual([
|
||||
["msg_next", "queue"],
|
||||
["msg_earlier", "steer"],
|
||||
])
|
||||
await Bun.sleep(10)
|
||||
expect(pending()).toEqual([["msg_next", "queue"]])
|
||||
await transport.close()
|
||||
})
|
||||
|
||||
@@ -813,14 +847,14 @@ describe("V2 mini transport", () => {
|
||||
durable: durable("ses_1", 2),
|
||||
data: { sessionID: "ses_1", inputID: "msg_prompt" },
|
||||
})
|
||||
await transport.queuePromptTurn({
|
||||
await transport.admitPromptTurn({
|
||||
agent: undefined,
|
||||
model: undefined,
|
||||
variant: undefined,
|
||||
prompt: { messageID: "msg_queued", text: "follow up", parts: [] },
|
||||
files: [],
|
||||
includeFiles: false,
|
||||
})
|
||||
}, "queue")
|
||||
events.push({
|
||||
id: "evt_queued_promoted",
|
||||
created: 3,
|
||||
|
||||
Reference in New Issue
Block a user