mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-09 02:49:57 -04:00
Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 60124ac57d | |||
| 3bb0d7fda0 | |||
| 8977881e09 | |||
| e6c9b6bef7 | |||
| 2092350cfa | |||
| 5fb0d7c99c | |||
| a1b4843a33 | |||
| cc7827fe08 | |||
| 76e4d88d21 | |||
| 439ed66c7b | |||
| 047d434aa2 | |||
| d7651519f3 | |||
| 1eb3a43add |
@@ -23,7 +23,6 @@ test("ignores persisted old layout preferences when opening drafts", async ({ pa
|
|||||||
await page.addInitScript(
|
await page.addInitScript(
|
||||||
({ directory, draftID, server }) => {
|
({ directory, draftID, server }) => {
|
||||||
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: false } }))
|
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: false } }))
|
||||||
localStorage.setItem("app-version.v1", JSON.stringify({ version: "1.17.20" }))
|
|
||||||
localStorage.setItem(
|
localStorage.setItem(
|
||||||
"opencode.window.browser.dat:tabs",
|
"opencode.window.browser.dat:tabs",
|
||||||
JSON.stringify([{ type: "draft", draftID, server, directory }]),
|
JSON.stringify([{ type: "draft", draftID, server, directory }]),
|
||||||
|
|||||||
@@ -153,4 +153,88 @@ describe("v2 session reducer", () => {
|
|||||||
|
|
||||||
expect(result).toMatchObject({ sessionID: "ses_1", missing: "msg_user", touched: [] })
|
expect(result).toMatchObject({ sessionID: "ses_1", missing: "msg_user", touched: [] })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("removes cancelled input from the pending promotion fold", () => {
|
||||||
|
const reducer = createV2SessionReducer()
|
||||||
|
reducer.reduce(
|
||||||
|
[],
|
||||||
|
event({
|
||||||
|
...base,
|
||||||
|
id: "evt_admitted",
|
||||||
|
type: "session.input.admitted",
|
||||||
|
data: {
|
||||||
|
sessionID: "ses_1",
|
||||||
|
inputID: "msg_user",
|
||||||
|
input: { type: "user", delivery: "queue", data: { text: "cancel me" } },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
reducer.reduce(
|
||||||
|
[],
|
||||||
|
event({
|
||||||
|
...base,
|
||||||
|
id: "evt_cancelled",
|
||||||
|
type: "session.input.cancelled",
|
||||||
|
data: { sessionID: "ses_1", inputID: "msg_user" },
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
const result = reducer.reduce(
|
||||||
|
[],
|
||||||
|
event({
|
||||||
|
...base,
|
||||||
|
id: "evt_promoted",
|
||||||
|
type: "session.input.promoted",
|
||||||
|
data: { sessionID: "ses_1", inputID: "msg_user" },
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(result).toMatchObject({ missing: "msg_user" })
|
||||||
|
})
|
||||||
|
|
||||||
|
test("keeps steered input available to the promotion fold", () => {
|
||||||
|
const reducer = createV2SessionReducer()
|
||||||
|
reducer.reduce(
|
||||||
|
[],
|
||||||
|
event({
|
||||||
|
...base,
|
||||||
|
id: "evt_admitted",
|
||||||
|
type: "session.input.admitted",
|
||||||
|
data: {
|
||||||
|
sessionID: "ses_1",
|
||||||
|
inputID: "msg_user",
|
||||||
|
input: { type: "user", delivery: "queue", data: { text: "steer me" } },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
reducer.reduce(
|
||||||
|
[],
|
||||||
|
event({
|
||||||
|
...base,
|
||||||
|
id: "evt_steered",
|
||||||
|
type: "session.input.steered",
|
||||||
|
data: { sessionID: "ses_1", inputID: "msg_user" },
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
reducer.reduce(
|
||||||
|
[],
|
||||||
|
event({
|
||||||
|
...base,
|
||||||
|
id: "evt_queued",
|
||||||
|
type: "session.input.queued",
|
||||||
|
data: { sessionID: "ses_1", inputID: "msg_user" },
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const result = reducer.reduce(
|
||||||
|
[],
|
||||||
|
event({
|
||||||
|
...base,
|
||||||
|
id: "evt_promoted",
|
||||||
|
type: "session.input.promoted",
|
||||||
|
data: { sessionID: "ses_1", inputID: "msg_user" },
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(result?.messages).toMatchObject([{ id: "msg_user", type: "user", text: "steer me" }])
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -29,6 +29,9 @@ export function createV2SessionReducer() {
|
|||||||
case "session.input.admitted":
|
case "session.input.admitted":
|
||||||
pending.set(key(sessionID, event.data.inputID), event.data.input)
|
pending.set(key(sessionID, event.data.inputID), event.data.input)
|
||||||
return result([...source])
|
return result([...source])
|
||||||
|
case "session.input.cancelled":
|
||||||
|
pending.delete(key(sessionID, event.data.inputID))
|
||||||
|
return
|
||||||
case "session.input.promoted": {
|
case "session.input.promoted": {
|
||||||
const input = pending.get(key(sessionID, event.data.inputID))
|
const input = pending.get(key(sessionID, event.data.inputID))
|
||||||
pending.delete(key(sessionID, event.data.inputID))
|
pending.delete(key(sessionID, event.data.inputID))
|
||||||
|
|||||||
@@ -263,38 +263,52 @@ export type Endpoint5_23Input = { readonly sessionID: Session.ID }
|
|||||||
export type Endpoint5_23Output = ReadonlyArray<SessionPending.Info>
|
export type Endpoint5_23Output = ReadonlyArray<SessionPending.Info>
|
||||||
export type SessionPendingListOperation<E = never> = (input: Endpoint5_23Input) => Effect.Effect<Endpoint5_23Output, E>
|
export type SessionPendingListOperation<E = never> = (input: Endpoint5_23Input) => Effect.Effect<Endpoint5_23Output, E>
|
||||||
|
|
||||||
export type Endpoint5_24Input = { readonly sessionID: Session.ID }
|
export type Endpoint5_24Input = { readonly sessionID: Session.ID; readonly inputID: SessionMessage.ID }
|
||||||
export type Endpoint5_24Output = ReadonlyArray<InstructionEntry.Info>
|
export type Endpoint5_24Output = void
|
||||||
export type SessionInstructionsEntryListOperation<E = never> = (
|
export type SessionPendingCancelOperation<E = never> = (
|
||||||
input: Endpoint5_24Input,
|
input: Endpoint5_24Input,
|
||||||
) => Effect.Effect<Endpoint5_24Output, E>
|
) => Effect.Effect<Endpoint5_24Output, E>
|
||||||
|
|
||||||
export type Endpoint5_25Input = {
|
export type Endpoint5_25Input = { readonly sessionID: Session.ID; readonly inputID: SessionMessage.ID }
|
||||||
|
export type Endpoint5_25Output = void
|
||||||
|
export type SessionPendingSteerOperation<E = never> = (input: Endpoint5_25Input) => Effect.Effect<Endpoint5_25Output, E>
|
||||||
|
|
||||||
|
export type Endpoint5_26Input = { readonly sessionID: Session.ID; readonly inputID: SessionMessage.ID }
|
||||||
|
export type Endpoint5_26Output = void
|
||||||
|
export type SessionPendingQueueOperation<E = never> = (input: Endpoint5_26Input) => Effect.Effect<Endpoint5_26Output, E>
|
||||||
|
|
||||||
|
export type Endpoint5_27Input = { readonly sessionID: Session.ID }
|
||||||
|
export type Endpoint5_27Output = ReadonlyArray<InstructionEntry.Info>
|
||||||
|
export type SessionInstructionsEntryListOperation<E = never> = (
|
||||||
|
input: Endpoint5_27Input,
|
||||||
|
) => Effect.Effect<Endpoint5_27Output, E>
|
||||||
|
|
||||||
|
export type Endpoint5_28Input = {
|
||||||
readonly sessionID: Session.ID
|
readonly sessionID: Session.ID
|
||||||
readonly key: InstructionEntry.Key
|
readonly key: InstructionEntry.Key
|
||||||
readonly value: Schema.Json
|
readonly value: Schema.Json
|
||||||
}
|
}
|
||||||
export type Endpoint5_25Output = void
|
export type Endpoint5_28Output = void
|
||||||
export type SessionInstructionsEntryPutOperation<E = never> = (
|
export type SessionInstructionsEntryPutOperation<E = never> = (
|
||||||
input: Endpoint5_25Input,
|
input: Endpoint5_28Input,
|
||||||
) => Effect.Effect<Endpoint5_25Output, E>
|
) => Effect.Effect<Endpoint5_28Output, E>
|
||||||
|
|
||||||
export type Endpoint5_26Input = { readonly sessionID: Session.ID; readonly key: InstructionEntry.Key }
|
export type Endpoint5_29Input = { readonly sessionID: Session.ID; readonly key: InstructionEntry.Key }
|
||||||
export type Endpoint5_26Output = void
|
export type Endpoint5_29Output = void
|
||||||
export type SessionInstructionsEntryRemoveOperation<E = never> = (
|
export type SessionInstructionsEntryRemoveOperation<E = never> = (
|
||||||
input: Endpoint5_26Input,
|
input: Endpoint5_29Input,
|
||||||
) => Effect.Effect<Endpoint5_26Output, E>
|
) => Effect.Effect<Endpoint5_29Output, E>
|
||||||
|
|
||||||
export type Endpoint5_27Input = { readonly sessionID: Session.ID; readonly prompt: string }
|
export type Endpoint5_30Input = { readonly sessionID: Session.ID; readonly prompt: string }
|
||||||
export type Endpoint5_27Output = { readonly text: string }
|
export type Endpoint5_30Output = { readonly text: string }
|
||||||
export type SessionGenerateOperation<E = never> = (input: Endpoint5_27Input) => Effect.Effect<Endpoint5_27Output, E>
|
export type SessionGenerateOperation<E = never> = (input: Endpoint5_30Input) => Effect.Effect<Endpoint5_30Output, E>
|
||||||
|
|
||||||
export type Endpoint5_28Input = {
|
export type Endpoint5_31Input = {
|
||||||
readonly sessionID: Session.ID
|
readonly sessionID: Session.ID
|
||||||
readonly after?: Event.Seq | undefined
|
readonly after?: Event.Seq | undefined
|
||||||
readonly follow?: boolean | undefined
|
readonly follow?: boolean | undefined
|
||||||
}
|
}
|
||||||
export type Endpoint5_28Output =
|
export type Endpoint5_31Output =
|
||||||
| (
|
| (
|
||||||
| {
|
| {
|
||||||
readonly id: Event.ID
|
readonly id: Event.ID
|
||||||
@@ -404,6 +418,33 @@ export type Endpoint5_28Output =
|
|||||||
readonly input: SessionPending.Message
|
readonly input: SessionPending.Message
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
| {
|
||||||
|
readonly id: Event.ID
|
||||||
|
readonly created: DateTime.Utc
|
||||||
|
readonly metadata?: { readonly [x: string]: unknown } | undefined
|
||||||
|
readonly type: "session.input.cancelled"
|
||||||
|
readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
|
||||||
|
readonly location?: Location.Ref | undefined
|
||||||
|
readonly data: { readonly sessionID: Session.ID; readonly inputID: SessionMessage.ID }
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
readonly id: Event.ID
|
||||||
|
readonly created: DateTime.Utc
|
||||||
|
readonly metadata?: { readonly [x: string]: unknown } | undefined
|
||||||
|
readonly type: "session.input.steered"
|
||||||
|
readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
|
||||||
|
readonly location?: Location.Ref | undefined
|
||||||
|
readonly data: { readonly sessionID: Session.ID; readonly inputID: SessionMessage.ID }
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
readonly id: Event.ID
|
||||||
|
readonly created: DateTime.Utc
|
||||||
|
readonly metadata?: { readonly [x: string]: unknown } | undefined
|
||||||
|
readonly type: "session.input.queued"
|
||||||
|
readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
|
||||||
|
readonly location?: Location.Ref | undefined
|
||||||
|
readonly data: { readonly sessionID: Session.ID; readonly inputID: SessionMessage.ID }
|
||||||
|
}
|
||||||
| {
|
| {
|
||||||
readonly id: Event.ID
|
readonly id: Event.ID
|
||||||
readonly created: DateTime.Utc
|
readonly created: DateTime.Utc
|
||||||
@@ -862,19 +903,19 @@ export type Endpoint5_28Output =
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
| EventLog.Synced
|
| EventLog.Synced
|
||||||
export type SessionLogOperation<E = never> = (input: Endpoint5_28Input) => Stream.Stream<Endpoint5_28Output, E>
|
export type SessionLogOperation<E = never> = (input: Endpoint5_31Input) => Stream.Stream<Endpoint5_31Output, E>
|
||||||
|
|
||||||
export type Endpoint5_29Input = { readonly sessionID: Session.ID }
|
export type Endpoint5_32Input = { readonly sessionID: Session.ID }
|
||||||
export type Endpoint5_29Output = void
|
export type Endpoint5_32Output = void
|
||||||
export type SessionInterruptOperation<E = never> = (input: Endpoint5_29Input) => Effect.Effect<Endpoint5_29Output, E>
|
export type SessionInterruptOperation<E = never> = (input: Endpoint5_32Input) => Effect.Effect<Endpoint5_32Output, E>
|
||||||
|
|
||||||
export type Endpoint5_30Input = { readonly sessionID: Session.ID }
|
export type Endpoint5_33Input = { readonly sessionID: Session.ID }
|
||||||
export type Endpoint5_30Output = void
|
export type Endpoint5_33Output = void
|
||||||
export type SessionBackgroundOperation<E = never> = (input: Endpoint5_30Input) => Effect.Effect<Endpoint5_30Output, E>
|
export type SessionBackgroundOperation<E = never> = (input: Endpoint5_33Input) => Effect.Effect<Endpoint5_33Output, E>
|
||||||
|
|
||||||
export type Endpoint5_31Input = { readonly sessionID: Session.ID; readonly messageID: SessionMessage.ID }
|
export type Endpoint5_34Input = { readonly sessionID: Session.ID; readonly messageID: SessionMessage.ID }
|
||||||
export type Endpoint5_31Output = SessionMessage.Info
|
export type Endpoint5_34Output = SessionMessage.Info
|
||||||
export type SessionMessageOperation<E = never> = (input: Endpoint5_31Input) => Effect.Effect<Endpoint5_31Output, E>
|
export type SessionMessageOperation<E = never> = (input: Endpoint5_34Input) => Effect.Effect<Endpoint5_34Output, E>
|
||||||
|
|
||||||
export interface SessionApi<E = never> {
|
export interface SessionApi<E = never> {
|
||||||
readonly list: SessionListOperation<E>
|
readonly list: SessionListOperation<E>
|
||||||
@@ -902,7 +943,12 @@ export interface SessionApi<E = never> {
|
|||||||
readonly commit: SessionRevertCommitOperation<E>
|
readonly commit: SessionRevertCommitOperation<E>
|
||||||
}
|
}
|
||||||
readonly context: SessionContextOperation<E>
|
readonly context: SessionContextOperation<E>
|
||||||
readonly pending: { readonly list: SessionPendingListOperation<E> }
|
readonly pending: {
|
||||||
|
readonly list: SessionPendingListOperation<E>
|
||||||
|
readonly cancel: SessionPendingCancelOperation<E>
|
||||||
|
readonly steer: SessionPendingSteerOperation<E>
|
||||||
|
readonly queue: SessionPendingQueueOperation<E>
|
||||||
|
}
|
||||||
readonly instructions: {
|
readonly instructions: {
|
||||||
readonly entry: {
|
readonly entry: {
|
||||||
readonly list: SessionInstructionsEntryListOperation<E>
|
readonly list: SessionInstructionsEntryListOperation<E>
|
||||||
|
|||||||
@@ -80,6 +80,12 @@ import type {
|
|||||||
Endpoint5_30Output,
|
Endpoint5_30Output,
|
||||||
Endpoint5_31Input,
|
Endpoint5_31Input,
|
||||||
Endpoint5_31Output,
|
Endpoint5_31Output,
|
||||||
|
Endpoint5_32Input,
|
||||||
|
Endpoint5_32Output,
|
||||||
|
Endpoint5_33Input,
|
||||||
|
Endpoint5_33Output,
|
||||||
|
Endpoint5_34Input,
|
||||||
|
Endpoint5_34Output,
|
||||||
Endpoint6_0Input,
|
Endpoint6_0Input,
|
||||||
Endpoint6_0Output,
|
Endpoint6_0Output,
|
||||||
Endpoint7_0Input,
|
Endpoint7_0Input,
|
||||||
@@ -523,37 +529,58 @@ const Endpoint5_23 = (raw: RawClient["server.session"]) => (input: Endpoint5_23I
|
|||||||
|
|
||||||
const Endpoint5_24 = (raw: RawClient["server.session"]) => (input: Endpoint5_24Input) =>
|
const Endpoint5_24 = (raw: RawClient["server.session"]) => (input: Endpoint5_24Input) =>
|
||||||
preserveEffect<Endpoint5_24Output>()(
|
preserveEffect<Endpoint5_24Output>()(
|
||||||
raw["session.instructions.entry.list"]({ params: { sessionID: input["sessionID"] } }).pipe(
|
raw["session.pending.cancel"]({ params: { sessionID: input["sessionID"], inputID: input["inputID"] } }).pipe(
|
||||||
Effect.mapError(mapClientError),
|
Effect.mapError(mapClientError),
|
||||||
Effect.map((value) => value.data),
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
const Endpoint5_25 = (raw: RawClient["server.session"]) => (input: Endpoint5_25Input) =>
|
const Endpoint5_25 = (raw: RawClient["server.session"]) => (input: Endpoint5_25Input) =>
|
||||||
preserveEffect<Endpoint5_25Output>()(
|
preserveEffect<Endpoint5_25Output>()(
|
||||||
raw["session.instructions.entry.put"]({
|
raw["session.pending.steer"]({ params: { sessionID: input["sessionID"], inputID: input["inputID"] } }).pipe(
|
||||||
params: { sessionID: input["sessionID"], key: input["key"] },
|
Effect.mapError(mapClientError),
|
||||||
payload: { value: input["value"] },
|
),
|
||||||
}).pipe(Effect.mapError(mapClientError)),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const Endpoint5_26 = (raw: RawClient["server.session"]) => (input: Endpoint5_26Input) =>
|
const Endpoint5_26 = (raw: RawClient["server.session"]) => (input: Endpoint5_26Input) =>
|
||||||
preserveEffect<Endpoint5_26Output>()(
|
preserveEffect<Endpoint5_26Output>()(
|
||||||
raw["session.instructions.entry.remove"]({ params: { sessionID: input["sessionID"], key: input["key"] } }).pipe(
|
raw["session.pending.queue"]({ params: { sessionID: input["sessionID"], inputID: input["inputID"] } }).pipe(
|
||||||
Effect.mapError(mapClientError),
|
Effect.mapError(mapClientError),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
const Endpoint5_27 = (raw: RawClient["server.session"]) => (input: Endpoint5_27Input) =>
|
const Endpoint5_27 = (raw: RawClient["server.session"]) => (input: Endpoint5_27Input) =>
|
||||||
preserveEffect<Endpoint5_27Output>()(
|
preserveEffect<Endpoint5_27Output>()(
|
||||||
raw["session.generate"]({ params: { sessionID: input["sessionID"] }, payload: { prompt: input["prompt"] } }).pipe(
|
raw["session.instructions.entry.list"]({ params: { sessionID: input["sessionID"] } }).pipe(
|
||||||
Effect.mapError(mapClientError),
|
Effect.mapError(mapClientError),
|
||||||
Effect.map((value) => value.data),
|
Effect.map((value) => value.data),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
const Endpoint5_28 = (raw: RawClient["server.session"]) => (input: Endpoint5_28Input) =>
|
const Endpoint5_28 = (raw: RawClient["server.session"]) => (input: Endpoint5_28Input) =>
|
||||||
preserveStream<Endpoint5_28Output>()(
|
preserveEffect<Endpoint5_28Output>()(
|
||||||
|
raw["session.instructions.entry.put"]({
|
||||||
|
params: { sessionID: input["sessionID"], key: input["key"] },
|
||||||
|
payload: { value: input["value"] },
|
||||||
|
}).pipe(Effect.mapError(mapClientError)),
|
||||||
|
)
|
||||||
|
|
||||||
|
const Endpoint5_29 = (raw: RawClient["server.session"]) => (input: Endpoint5_29Input) =>
|
||||||
|
preserveEffect<Endpoint5_29Output>()(
|
||||||
|
raw["session.instructions.entry.remove"]({ params: { sessionID: input["sessionID"], key: input["key"] } }).pipe(
|
||||||
|
Effect.mapError(mapClientError),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
const Endpoint5_30 = (raw: RawClient["server.session"]) => (input: Endpoint5_30Input) =>
|
||||||
|
preserveEffect<Endpoint5_30Output>()(
|
||||||
|
raw["session.generate"]({ params: { sessionID: input["sessionID"] }, payload: { prompt: input["prompt"] } }).pipe(
|
||||||
|
Effect.mapError(mapClientError),
|
||||||
|
Effect.map((value) => value.data),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
const Endpoint5_31 = (raw: RawClient["server.session"]) => (input: Endpoint5_31Input) =>
|
||||||
|
preserveStream<Endpoint5_31Output>()(
|
||||||
Stream.unwrap(
|
Stream.unwrap(
|
||||||
raw["session.log"]({
|
raw["session.log"]({
|
||||||
params: { sessionID: input["sessionID"] },
|
params: { sessionID: input["sessionID"] },
|
||||||
@@ -565,18 +592,18 @@ const Endpoint5_28 = (raw: RawClient["server.session"]) => (input: Endpoint5_28I
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
const Endpoint5_29 = (raw: RawClient["server.session"]) => (input: Endpoint5_29Input) =>
|
const Endpoint5_32 = (raw: RawClient["server.session"]) => (input: Endpoint5_32Input) =>
|
||||||
preserveEffect<Endpoint5_29Output>()(
|
preserveEffect<Endpoint5_32Output>()(
|
||||||
raw["session.interrupt"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)),
|
raw["session.interrupt"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)),
|
||||||
)
|
)
|
||||||
|
|
||||||
const Endpoint5_30 = (raw: RawClient["server.session"]) => (input: Endpoint5_30Input) =>
|
const Endpoint5_33 = (raw: RawClient["server.session"]) => (input: Endpoint5_33Input) =>
|
||||||
preserveEffect<Endpoint5_30Output>()(
|
preserveEffect<Endpoint5_33Output>()(
|
||||||
raw["session.background"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)),
|
raw["session.background"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)),
|
||||||
)
|
)
|
||||||
|
|
||||||
const Endpoint5_31 = (raw: RawClient["server.session"]) => (input: Endpoint5_31Input) =>
|
const Endpoint5_34 = (raw: RawClient["server.session"]) => (input: Endpoint5_34Input) =>
|
||||||
preserveEffect<Endpoint5_31Output>()(
|
preserveEffect<Endpoint5_34Output>()(
|
||||||
raw["session.message"]({ params: { sessionID: input["sessionID"], messageID: input["messageID"] } }).pipe(
|
raw["session.message"]({ params: { sessionID: input["sessionID"], messageID: input["messageID"] } }).pipe(
|
||||||
Effect.mapError(mapClientError),
|
Effect.mapError(mapClientError),
|
||||||
Effect.map((value) => value.data),
|
Effect.map((value) => value.data),
|
||||||
@@ -605,13 +632,13 @@ const adaptGroup5 = (raw: RawClient["server.session"]) => ({
|
|||||||
wait: Endpoint5_18(raw),
|
wait: Endpoint5_18(raw),
|
||||||
revert: { stage: Endpoint5_19(raw), clear: Endpoint5_20(raw), commit: Endpoint5_21(raw) },
|
revert: { stage: Endpoint5_19(raw), clear: Endpoint5_20(raw), commit: Endpoint5_21(raw) },
|
||||||
context: Endpoint5_22(raw),
|
context: Endpoint5_22(raw),
|
||||||
pending: { list: Endpoint5_23(raw) },
|
pending: { list: Endpoint5_23(raw), cancel: Endpoint5_24(raw), steer: Endpoint5_25(raw), queue: Endpoint5_26(raw) },
|
||||||
instructions: { entry: { list: Endpoint5_24(raw), put: Endpoint5_25(raw), remove: Endpoint5_26(raw) } },
|
instructions: { entry: { list: Endpoint5_27(raw), put: Endpoint5_28(raw), remove: Endpoint5_29(raw) } },
|
||||||
generate: Endpoint5_27(raw),
|
generate: Endpoint5_30(raw),
|
||||||
log: Endpoint5_28(raw),
|
log: Endpoint5_31(raw),
|
||||||
interrupt: Endpoint5_29(raw),
|
interrupt: Endpoint5_32(raw),
|
||||||
background: Endpoint5_30(raw),
|
background: Endpoint5_33(raw),
|
||||||
message: Endpoint5_31(raw),
|
message: Endpoint5_34(raw),
|
||||||
})
|
})
|
||||||
|
|
||||||
const Endpoint6_0 = (raw: RawClient["server.message"]) => (input: Endpoint6_0Input) =>
|
const Endpoint6_0 = (raw: RawClient["server.message"]) => (input: Endpoint6_0Input) =>
|
||||||
|
|||||||
@@ -58,6 +58,12 @@ import type {
|
|||||||
SessionContextOutput,
|
SessionContextOutput,
|
||||||
SessionPendingListInput,
|
SessionPendingListInput,
|
||||||
SessionPendingListOutput,
|
SessionPendingListOutput,
|
||||||
|
SessionPendingCancelInput,
|
||||||
|
SessionPendingCancelOutput,
|
||||||
|
SessionPendingSteerInput,
|
||||||
|
SessionPendingSteerOutput,
|
||||||
|
SessionPendingQueueInput,
|
||||||
|
SessionPendingQueueOutput,
|
||||||
SessionInstructionsEntryListInput,
|
SessionInstructionsEntryListInput,
|
||||||
SessionInstructionsEntryListOutput,
|
SessionInstructionsEntryListOutput,
|
||||||
SessionInstructionsEntryPutInput,
|
SessionInstructionsEntryPutInput,
|
||||||
@@ -766,6 +772,39 @@ export function make(options: ClientOptions) {
|
|||||||
},
|
},
|
||||||
requestOptions,
|
requestOptions,
|
||||||
).then((value) => value.data),
|
).then((value) => value.data),
|
||||||
|
cancel: (input: SessionPendingCancelInput, requestOptions?: RequestOptions) =>
|
||||||
|
request<SessionPendingCancelOutput>(
|
||||||
|
{
|
||||||
|
method: "DELETE",
|
||||||
|
path: `/api/session/${encodeURIComponent(input.sessionID)}/pending/${encodeURIComponent(input.inputID)}`,
|
||||||
|
successStatus: 204,
|
||||||
|
declaredStatuses: [409, 404, 401, 400],
|
||||||
|
empty: true,
|
||||||
|
},
|
||||||
|
requestOptions,
|
||||||
|
),
|
||||||
|
steer: (input: SessionPendingSteerInput, requestOptions?: RequestOptions) =>
|
||||||
|
request<SessionPendingSteerOutput>(
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
path: `/api/session/${encodeURIComponent(input.sessionID)}/pending/${encodeURIComponent(input.inputID)}/steer`,
|
||||||
|
successStatus: 204,
|
||||||
|
declaredStatuses: [409, 404, 401, 400],
|
||||||
|
empty: true,
|
||||||
|
},
|
||||||
|
requestOptions,
|
||||||
|
),
|
||||||
|
queue: (input: SessionPendingQueueInput, requestOptions?: RequestOptions) =>
|
||||||
|
request<SessionPendingQueueOutput>(
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
path: `/api/session/${encodeURIComponent(input.sessionID)}/pending/${encodeURIComponent(input.inputID)}/queue`,
|
||||||
|
successStatus: 204,
|
||||||
|
declaredStatuses: [409, 404, 401, 400],
|
||||||
|
empty: true,
|
||||||
|
},
|
||||||
|
requestOptions,
|
||||||
|
),
|
||||||
},
|
},
|
||||||
instructions: {
|
instructions: {
|
||||||
entry: {
|
entry: {
|
||||||
|
|||||||
@@ -502,6 +502,36 @@ export type SessionInputPromoted = {
|
|||||||
data: { sessionID: string; inputID: string }
|
data: { sessionID: string; inputID: string }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type SessionInputCancelled = {
|
||||||
|
id: string
|
||||||
|
created: number
|
||||||
|
metadata?: { [x: string]: any }
|
||||||
|
type: "session.input.cancelled"
|
||||||
|
durable: { aggregateID: string; seq: number; version: 1 }
|
||||||
|
location?: LocationRef
|
||||||
|
data: { sessionID: string; inputID: string }
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SessionInputSteered = {
|
||||||
|
id: string
|
||||||
|
created: number
|
||||||
|
metadata?: { [x: string]: any }
|
||||||
|
type: "session.input.steered"
|
||||||
|
durable: { aggregateID: string; seq: number; version: 1 }
|
||||||
|
location?: LocationRef
|
||||||
|
data: { sessionID: string; inputID: string }
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SessionInputQueued = {
|
||||||
|
id: string
|
||||||
|
created: number
|
||||||
|
metadata?: { [x: string]: any }
|
||||||
|
type: "session.input.queued"
|
||||||
|
durable: { aggregateID: string; seq: number; version: 1 }
|
||||||
|
location?: LocationRef
|
||||||
|
data: { sessionID: string; inputID: string }
|
||||||
|
}
|
||||||
|
|
||||||
export type SessionExecutionStarted = {
|
export type SessionExecutionStarted = {
|
||||||
id: string
|
id: string
|
||||||
created: number
|
created: number
|
||||||
@@ -1970,6 +2000,9 @@ export type SessionEventDurable =
|
|||||||
| SessionForked
|
| SessionForked
|
||||||
| SessionInputPromoted
|
| SessionInputPromoted
|
||||||
| SessionInputAdmitted
|
| SessionInputAdmitted
|
||||||
|
| SessionInputCancelled
|
||||||
|
| SessionInputSteered
|
||||||
|
| SessionInputQueued
|
||||||
| SessionExecutionStarted
|
| SessionExecutionStarted
|
||||||
| SessionExecutionSucceeded
|
| SessionExecutionSucceeded
|
||||||
| SessionExecutionFailed
|
| SessionExecutionFailed
|
||||||
@@ -2024,6 +2057,9 @@ export type V2Event =
|
|||||||
| SessionForked
|
| SessionForked
|
||||||
| SessionInputPromoted
|
| SessionInputPromoted
|
||||||
| SessionInputAdmitted
|
| SessionInputAdmitted
|
||||||
|
| SessionInputCancelled
|
||||||
|
| SessionInputSteered
|
||||||
|
| SessionInputQueued
|
||||||
| SessionExecutionStarted
|
| SessionExecutionStarted
|
||||||
| SessionExecutionSucceeded
|
| SessionExecutionSucceeded
|
||||||
| SessionExecutionFailed
|
| SessionExecutionFailed
|
||||||
@@ -3689,6 +3725,27 @@ export type SessionPendingListInput = { readonly sessionID: { readonly sessionID
|
|||||||
|
|
||||||
export type SessionPendingListOutput = { data: Array<SessionPendingInfo> }["data"]
|
export type SessionPendingListOutput = { data: Array<SessionPendingInfo> }["data"]
|
||||||
|
|
||||||
|
export type SessionPendingCancelInput = {
|
||||||
|
readonly sessionID: { readonly sessionID: string; readonly inputID: string }["sessionID"]
|
||||||
|
readonly inputID: { readonly sessionID: string; readonly inputID: string }["inputID"]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SessionPendingCancelOutput = void
|
||||||
|
|
||||||
|
export type SessionPendingSteerInput = {
|
||||||
|
readonly sessionID: { readonly sessionID: string; readonly inputID: string }["sessionID"]
|
||||||
|
readonly inputID: { readonly sessionID: string; readonly inputID: string }["inputID"]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SessionPendingSteerOutput = void
|
||||||
|
|
||||||
|
export type SessionPendingQueueInput = {
|
||||||
|
readonly sessionID: { readonly sessionID: string; readonly inputID: string }["sessionID"]
|
||||||
|
readonly inputID: { readonly sessionID: string; readonly inputID: string }["inputID"]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SessionPendingQueueOutput = void
|
||||||
|
|
||||||
export type SessionInstructionsEntryListInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] }
|
export type SessionInstructionsEntryListInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] }
|
||||||
|
|
||||||
export type SessionInstructionsEntryListOutput = { data: Array<InstructionEntryInfo> }["data"]
|
export type SessionInstructionsEntryListOutput = { data: Array<InstructionEntryInfo> }["data"]
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ test("effect entrypoint exposes canonical Schema contracts", () => {
|
|||||||
test("generated Effect API names canonical and composed outputs", async () => {
|
test("generated Effect API names canonical and composed outputs", async () => {
|
||||||
const source = await Bun.file(new URL("../src/effect/api/api.ts", import.meta.url)).text()
|
const source = await Bun.file(new URL("../src/effect/api/api.ts", import.meta.url)).text()
|
||||||
|
|
||||||
expect(source).toContain("export type Endpoint5_3Output = Session.Info")
|
expect(source).toContain("export type Endpoint5_5Output = Session.Info")
|
||||||
expect(source).toContain("export type Endpoint19_0Output = OpenCodeEvent")
|
expect(source).toContain("export type Endpoint19_0Output = OpenCodeEvent")
|
||||||
expect(source).not.toContain("HttpApiClient.ForApi")
|
expect(source).not.toContain("HttpApiClient.ForApi")
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ test("exposes every standard HTTP API group", () => {
|
|||||||
"projectCopy",
|
"projectCopy",
|
||||||
"vcs",
|
"vcs",
|
||||||
"debug",
|
"debug",
|
||||||
|
"migration",
|
||||||
"websearch",
|
"websearch",
|
||||||
"config",
|
"config",
|
||||||
])
|
])
|
||||||
@@ -356,6 +357,28 @@ test("session.pending.list uses the public HTTP contract", async () => {
|
|||||||
expect(requests).toEqual([{ method: "GET", url: "http://localhost:3000/api/session/ses_test/pending" }])
|
expect(requests).toEqual([{ method: "GET", url: "http://localhost:3000/api/session/ses_test/pending" }])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("session.pending mutations use the public HTTP contract", async () => {
|
||||||
|
const requests: Array<{ method: string; url: string }> = []
|
||||||
|
const client = OpenCode.make({
|
||||||
|
baseUrl: "http://localhost:3000",
|
||||||
|
fetch: async (input, init) => {
|
||||||
|
const request = input instanceof Request ? input : new Request(input, init)
|
||||||
|
requests.push({ method: request.method, url: request.url })
|
||||||
|
return new Response(null, { status: 204 })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
await client.session.pending.cancel({ sessionID: "ses_test", inputID: "msg_cancel" })
|
||||||
|
await client.session.pending.steer({ sessionID: "ses_test", inputID: "msg_steer" })
|
||||||
|
await client.session.pending.queue({ sessionID: "ses_test", inputID: "msg_queue" })
|
||||||
|
|
||||||
|
expect(requests).toEqual([
|
||||||
|
{ method: "DELETE", url: "http://localhost:3000/api/session/ses_test/pending/msg_cancel" },
|
||||||
|
{ method: "POST", url: "http://localhost:3000/api/session/ses_test/pending/msg_steer/steer" },
|
||||||
|
{ method: "POST", url: "http://localhost:3000/api/session/ses_test/pending/msg_queue/queue" },
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
test("event.subscribe exposes the Promise event stream wire projection", async () => {
|
test("event.subscribe exposes the Promise event stream wire projection", async () => {
|
||||||
const client = OpenCode.make({
|
const client = OpenCode.make({
|
||||||
baseUrl: "http://localhost:3000",
|
baseUrl: "http://localhost:3000",
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
"opencode": "./bin/opencode"
|
"opencode": "./bin/opencode"
|
||||||
},
|
},
|
||||||
"exports": {
|
"exports": {
|
||||||
|
"./environment": "./src/environment/index.ts",
|
||||||
"./session/runner": "./src/session/runner/index.ts",
|
"./session/runner": "./src/session/runner/index.ts",
|
||||||
"./instructions": "./src/instructions/index.ts",
|
"./instructions": "./src/instructions/index.ts",
|
||||||
"./*": "./src/*.ts"
|
"./*": "./src/*.ts"
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ export type Result =
|
|||||||
| { readonly type: "rejected"; readonly diagnostics: readonly Diagnostic[] }
|
| { readonly type: "rejected"; readonly diagnostics: readonly Diagnostic[] }
|
||||||
|
|
||||||
const options = { errors: "all", onExcessProperty: "ignore", propertyOrder: "original" } as const
|
const options = { errors: "all", onExcessProperty: "ignore", propertyOrder: "original" } as const
|
||||||
const unsupportedTopLevel = ["logLevel", "server", "small_model", "subagent_depth", "layout"] as const
|
const unsupportedTopLevel = ["logLevel", "server", "subagent_depth", "layout"] as const
|
||||||
const unsupportedExperimental = [
|
const unsupportedExperimental = [
|
||||||
"disable_paste_summary",
|
"disable_paste_summary",
|
||||||
"batch_tool",
|
"batch_tool",
|
||||||
@@ -113,6 +113,23 @@ export function normalize(input: unknown): Result {
|
|||||||
const legacyAgents = mapValues(decodeMap(input.agent, ConfigAgentV1.Info, ["agent"], diagnostics), (value) =>
|
const legacyAgents = mapValues(decodeMap(input.agent, ConfigAgentV1.Info, ["agent"], diagnostics), (value) =>
|
||||||
canonical(ConfigAgent.Info, ConfigMigrateV1.migrateAgent(value)),
|
canonical(ConfigAgent.Info, ConfigMigrateV1.migrateAgent(value)),
|
||||||
)
|
)
|
||||||
|
const legacySmallModel = own(input, "small_model")
|
||||||
|
? decodeValue(Schema.String, input.small_model, ["small_model"], diagnostics)
|
||||||
|
: undefined
|
||||||
|
const migratedSmallModel = legacySmallModel
|
||||||
|
? ConfigMigrateV1.migrate({ small_model: legacySmallModel }).agents?.title?.model
|
||||||
|
: undefined
|
||||||
|
if (legacySmallModel && !migratedSmallModel)
|
||||||
|
diagnostics.push({
|
||||||
|
kind: "unsupported",
|
||||||
|
path: ["small_model"],
|
||||||
|
message: "omitted unsupported legacy model reference",
|
||||||
|
})
|
||||||
|
if (migratedSmallModel)
|
||||||
|
legacyAgents.title = {
|
||||||
|
model: migratedSmallModel,
|
||||||
|
...legacyAgents.title,
|
||||||
|
}
|
||||||
const modeAgents = mapValues(decodeMap(input.mode, ConfigAgentV1.Info, ["mode"], diagnostics), (value) =>
|
const modeAgents = mapValues(decodeMap(input.mode, ConfigAgentV1.Info, ["mode"], diagnostics), (value) =>
|
||||||
canonical(ConfigAgent.Info, ConfigMigrateV1.migrateAgent({ ...value, mode: "primary" })),
|
canonical(ConfigAgent.Info, ConfigMigrateV1.migrateAgent({ ...value, mode: "primary" })),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -161,11 +161,14 @@ function isPathAction(action: string): action is PathAction {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function expandHome(resource: string, home: string) {
|
function expandHome(resource: string, home: string) {
|
||||||
if (resource.startsWith("~/")) return home + resource.slice(1)
|
|
||||||
if (resource === "~") return home
|
if (resource === "~") return home
|
||||||
if (resource === "$HOME") return home
|
if (resource === "$HOME") return home
|
||||||
if (resource.startsWith("$HOME/")) return home + resource.slice(5)
|
const relative = resource.startsWith("~/")
|
||||||
if (resource.startsWith("$HOME\\")) return home + resource.slice(5)
|
? resource.slice(2)
|
||||||
|
: resource.startsWith("$HOME/") || resource.startsWith("$HOME\\")
|
||||||
|
? resource.slice(6)
|
||||||
|
: undefined
|
||||||
|
if (relative !== undefined) return (path.posix.isAbsolute(home) ? path.posix : path.win32).join(home, relative)
|
||||||
return resource
|
return resource
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import type { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"
|
||||||
|
import type { FilesImpl } from "./files"
|
||||||
|
|
||||||
|
export interface Driver {
|
||||||
|
readonly spawner: ChildProcessSpawner["Service"]
|
||||||
|
readonly overrides?: Partial<FilesImpl>
|
||||||
|
}
|
||||||
|
|
||||||
|
export * as EnvironmentDriver from "./driver"
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
import { Effect, Stream } from "effect"
|
||||||
|
import { ChildProcess } from "effect/unstable/process"
|
||||||
|
import type { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"
|
||||||
|
import { collectStream } from "@opencode-ai/util/process"
|
||||||
|
import { Failed, NotFound, WrongKind, type FileInfo, type FileType, type FilesImpl } from "./files"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Files derived from spawning processes: one process per intent, "$1" is
|
||||||
|
* always the target path. Scripts report classification through an exit-code
|
||||||
|
* protocol (44/45/46) so failures never require parsing localized error text;
|
||||||
|
* LC_ALL=C pins the one stderr match that remains. Requires GNU coreutils and
|
||||||
|
* findutils in the target image — BSD and busybox userlands will not work.
|
||||||
|
* Malformed output from these scripts is our own bug and dies as a defect.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const MAX_DATA_BYTES = 64 * 1024 * 1024
|
||||||
|
const MAX_ERROR_BYTES = 64 * 1024
|
||||||
|
const NOT_FOUND = 44
|
||||||
|
const WRONG_KIND = 45
|
||||||
|
const FAILED = 46
|
||||||
|
const TAB = "\t"
|
||||||
|
|
||||||
|
const loadMetadata = (flags = "") => `
|
||||||
|
metadata=$(stat ${flags} -c '%F${TAB}%s${TAB}%Y' -- "$1" 2>&1) || {
|
||||||
|
case "$metadata" in
|
||||||
|
*'No such file or directory'*|*'Not a directory'*) exit ${NOT_FOUND} ;;
|
||||||
|
*) printf '%s' "$metadata" >&2; exit ${FAILED} ;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
const statScript = `
|
||||||
|
${loadMetadata()}
|
||||||
|
printf '%s\n' "$metadata"
|
||||||
|
`
|
||||||
|
|
||||||
|
const readScript = `
|
||||||
|
${loadMetadata("-L")}
|
||||||
|
kind=\${metadata%%${TAB}*}
|
||||||
|
if [ "$kind" != 'regular file' ] && [ "$kind" != 'regular empty file' ]; then
|
||||||
|
printf '%s' "$kind" >&2
|
||||||
|
exit ${WRONG_KIND}
|
||||||
|
fi
|
||||||
|
printf '%s\n' "$metadata"
|
||||||
|
if [ "$2" = range ]; then
|
||||||
|
dd if="$1" iflag=skip_bytes,count_bytes skip="$3" count="$4" status=none
|
||||||
|
else
|
||||||
|
cat -- "$1"
|
||||||
|
fi
|
||||||
|
`
|
||||||
|
|
||||||
|
const listScript = `
|
||||||
|
${loadMetadata()}
|
||||||
|
kind=\${metadata%%${TAB}*}
|
||||||
|
if [ "$kind" != directory ]; then
|
||||||
|
printf '%s' "$kind" >&2
|
||||||
|
exit ${WRONG_KIND}
|
||||||
|
fi
|
||||||
|
find "$1" -mindepth 1 -maxdepth 1 -printf '%y\\0%f\\0'
|
||||||
|
`
|
||||||
|
|
||||||
|
const moveScript = `
|
||||||
|
${loadMetadata()}
|
||||||
|
mv -- "$1" "$2"
|
||||||
|
`
|
||||||
|
|
||||||
|
interface Result {
|
||||||
|
readonly exitCode: number
|
||||||
|
readonly stdout: Uint8Array
|
||||||
|
readonly stderr: Uint8Array
|
||||||
|
}
|
||||||
|
|
||||||
|
export const execDefaults = (spawner: ChildProcessSpawner["Service"]): FilesImpl => {
|
||||||
|
const run = (
|
||||||
|
path: string,
|
||||||
|
script: string,
|
||||||
|
args: ReadonlyArray<string> = [],
|
||||||
|
stdin?: Uint8Array,
|
||||||
|
): Effect.Effect<Result, Failed> =>
|
||||||
|
Effect.scoped(
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const command = ChildProcess.make("sh", ["-c", script, "sh", path, ...args], {
|
||||||
|
env: { LC_ALL: "C" },
|
||||||
|
extendEnv: true,
|
||||||
|
stdin: stdin === undefined ? undefined : Stream.make(stdin),
|
||||||
|
})
|
||||||
|
const handle = yield* spawner.spawn(command).pipe(Effect.mapError((cause) => new Failed({ path, cause })))
|
||||||
|
const [stdout, stderr, exitCode] = yield* Effect.all(
|
||||||
|
[
|
||||||
|
collectStream(handle.stdout, MAX_DATA_BYTES),
|
||||||
|
collectStream(handle.stderr, MAX_ERROR_BYTES),
|
||||||
|
handle.exitCode,
|
||||||
|
],
|
||||||
|
{ concurrency: "unbounded" },
|
||||||
|
).pipe(Effect.mapError((cause) => new Failed({ path, cause })))
|
||||||
|
if (stdout.truncated || stderr.truncated) {
|
||||||
|
return yield* new Failed({ path, cause: new Error("Process output exceeded its collection limit") })
|
||||||
|
}
|
||||||
|
return { exitCode, stdout: stdout.buffer, stderr: stderr.buffer }
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const classify = <A>(
|
||||||
|
path: string,
|
||||||
|
result: Result,
|
||||||
|
success: (stdout: Uint8Array) => A,
|
||||||
|
): Effect.Effect<A, NotFound | WrongKind | Failed> => {
|
||||||
|
if (result.exitCode === 0) return Effect.sync(() => success(result.stdout))
|
||||||
|
if (result.exitCode === NOT_FOUND) return Effect.fail(new NotFound({ path }))
|
||||||
|
if (result.exitCode === WRONG_KIND) {
|
||||||
|
return Effect.fail(new WrongKind({ path, actual: parseType(new TextDecoder().decode(result.stderr)) }))
|
||||||
|
}
|
||||||
|
return Effect.fail(processFailure(path, result))
|
||||||
|
}
|
||||||
|
|
||||||
|
const complete = (path: string, result: Result) =>
|
||||||
|
result.exitCode === 0 ? Effect.void : Effect.fail(processFailure(path, result))
|
||||||
|
|
||||||
|
return {
|
||||||
|
stat: (path) => run(path, statScript).pipe(Effect.flatMap((result) => classifyPlain(path, result, parseInfo))),
|
||||||
|
read: (path, range) =>
|
||||||
|
run(
|
||||||
|
path,
|
||||||
|
readScript,
|
||||||
|
range === undefined ? ["whole"] : ["range", String(range.offset), String(range.length)],
|
||||||
|
).pipe(
|
||||||
|
Effect.flatMap((result) =>
|
||||||
|
classify(path, result, (stdout) => {
|
||||||
|
const newline = stdout.indexOf(10)
|
||||||
|
if (newline < 0) throw new Error("Missing read metadata header")
|
||||||
|
return {
|
||||||
|
info: parseInfo(stdout.slice(0, newline)),
|
||||||
|
bytes: stdout.slice(newline + 1),
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
write: (path, bytes) =>
|
||||||
|
run(path, `mkdir -p "$(dirname "$1")" && cat > "$1"`, [], bytes).pipe(
|
||||||
|
Effect.flatMap((result) => complete(path, result)),
|
||||||
|
),
|
||||||
|
list: (path) => run(path, listScript).pipe(Effect.flatMap((result) => classify(path, result, parseList))),
|
||||||
|
remove: (path) => run(path, `rm -rf -- "$1"`).pipe(Effect.flatMap((result) => complete(path, result))),
|
||||||
|
move: (from, to) =>
|
||||||
|
run(from, moveScript, [to]).pipe(Effect.flatMap((result) => classifyPlain(from, result, () => undefined))),
|
||||||
|
mkdir: (path) => run(path, `mkdir -p -- "$1"`).pipe(Effect.flatMap((result) => complete(path, result))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `classify` for scripts whose protocol never reports WrongKind. */
|
||||||
|
const classifyPlain = <A>(
|
||||||
|
path: string,
|
||||||
|
result: Result,
|
||||||
|
success: (stdout: Uint8Array) => A,
|
||||||
|
): Effect.Effect<A, NotFound | Failed> => {
|
||||||
|
if (result.exitCode === 0) return Effect.sync(() => success(result.stdout))
|
||||||
|
if (result.exitCode === NOT_FOUND) return Effect.fail(new NotFound({ path }))
|
||||||
|
return Effect.fail(processFailure(path, result))
|
||||||
|
}
|
||||||
|
|
||||||
|
const processFailure = (path: string, result: Result) =>
|
||||||
|
new Failed({
|
||||||
|
path,
|
||||||
|
cause: new Error(new TextDecoder().decode(result.stderr).trim() || `Process exited with code ${result.exitCode}`),
|
||||||
|
})
|
||||||
|
|
||||||
|
const parseInfo = (bytes: Uint8Array): FileInfo => {
|
||||||
|
const [rawType, rawSize, rawMtime] = new TextDecoder().decode(bytes).trim().split(TAB)
|
||||||
|
const size = Number(rawSize)
|
||||||
|
const mtimeMs = Number(rawMtime) * 1_000
|
||||||
|
if (!rawType || !Number.isFinite(size) || !Number.isFinite(mtimeMs)) throw new Error("Invalid stat output")
|
||||||
|
return { type: parseType(rawType), size, mtimeMs }
|
||||||
|
}
|
||||||
|
|
||||||
|
const parseType = (value: string): FileType => {
|
||||||
|
if (value === "regular file" || value === "regular empty file" || value === "f") return "file"
|
||||||
|
if (value === "directory" || value === "d") return "directory"
|
||||||
|
if (value === "symbolic link" || value === "l") return "symlink"
|
||||||
|
return "other"
|
||||||
|
}
|
||||||
|
|
||||||
|
const parseList = (bytes: Uint8Array) => {
|
||||||
|
const fields = new TextDecoder().decode(bytes).split("\0")
|
||||||
|
fields.pop()
|
||||||
|
if (fields.length % 2 !== 0) throw new Error("Invalid find output")
|
||||||
|
return Array.from({ length: fields.length / 2 }, (_, index) => ({
|
||||||
|
name: fields[index * 2 + 1],
|
||||||
|
type: parseType(fields[index * 2]),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
export * as EnvironmentExecDefaults from "./exec-defaults"
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { Effect, Schema } from "effect"
|
||||||
|
|
||||||
|
export const FileType = Schema.Literals(["file", "directory", "symlink", "other"])
|
||||||
|
export type FileType = typeof FileType.Type
|
||||||
|
|
||||||
|
export interface FileInfo {
|
||||||
|
readonly type: FileType
|
||||||
|
readonly size: number
|
||||||
|
readonly mtimeMs: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DirEntry {
|
||||||
|
readonly name: string
|
||||||
|
readonly type: FileType
|
||||||
|
}
|
||||||
|
|
||||||
|
export class NotFound extends Schema.TaggedErrorClass<NotFound>()("Environment.NotFound", {
|
||||||
|
path: Schema.String,
|
||||||
|
}) {}
|
||||||
|
|
||||||
|
export class WrongKind extends Schema.TaggedErrorClass<WrongKind>()("Environment.WrongKind", {
|
||||||
|
path: Schema.String,
|
||||||
|
actual: FileType,
|
||||||
|
}) {}
|
||||||
|
|
||||||
|
export class Failed extends Schema.TaggedErrorClass<Failed>()("Environment.Failed", {
|
||||||
|
path: Schema.String,
|
||||||
|
cause: Schema.Defect(),
|
||||||
|
}) {}
|
||||||
|
|
||||||
|
export interface FilesImpl {
|
||||||
|
/**
|
||||||
|
* Reads a file, following a final symlink so `info` describes the target whose bytes are returned.
|
||||||
|
* The process-backed default caps collected output at 64 MiB; larger whole-file reads fail with
|
||||||
|
* `Failed`, so callers must use ranges for larger files.
|
||||||
|
*/
|
||||||
|
readonly read: (
|
||||||
|
path: string,
|
||||||
|
range?: { readonly offset: number; readonly length: number },
|
||||||
|
) => Effect.Effect<{ readonly info: FileInfo; readonly bytes: Uint8Array }, NotFound | WrongKind | Failed>
|
||||||
|
readonly write: (path: string, bytes: Uint8Array) => Effect.Effect<void, Failed>
|
||||||
|
/** Describes the path entry itself, so a final symlink is reported as `symlink` rather than followed. */
|
||||||
|
readonly stat: (path: string) => Effect.Effect<FileInfo, NotFound | Failed>
|
||||||
|
/** Lists a directory entry without following a final symlink; intermediate symlinks are traversed. */
|
||||||
|
readonly list: (path: string) => Effect.Effect<ReadonlyArray<DirEntry>, NotFound | WrongKind | Failed>
|
||||||
|
readonly remove: (path: string) => Effect.Effect<void, Failed>
|
||||||
|
readonly move: (from: string, to: string) => Effect.Effect<void, NotFound | Failed>
|
||||||
|
readonly mkdir: (path: string) => Effect.Effect<void, Failed>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Files extends FilesImpl {}
|
||||||
|
|
||||||
|
export * as EnvironmentFiles from "./files"
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
export * as Environment from "./index"
|
||||||
|
|
||||||
|
export { type Driver } from "./driver"
|
||||||
|
export {
|
||||||
|
type DirEntry,
|
||||||
|
Failed,
|
||||||
|
type FileInfo,
|
||||||
|
type Files,
|
||||||
|
type FilesImpl,
|
||||||
|
type FileType,
|
||||||
|
NotFound,
|
||||||
|
WrongKind,
|
||||||
|
} from "./files"
|
||||||
|
export { execDefaults } from "./exec-defaults"
|
||||||
|
export { makeMemoryDriver, type MemoryDriver } from "./memory"
|
||||||
|
|
||||||
|
import type { Driver } from "./driver"
|
||||||
|
import { execDefaults } from "./exec-defaults"
|
||||||
|
import type { Files } from "./files"
|
||||||
|
|
||||||
|
export const makeFiles = (driver: Driver): Files => ({
|
||||||
|
...execDefaults(driver.spawner),
|
||||||
|
...driver.overrides,
|
||||||
|
})
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
import path from "node:path"
|
||||||
|
import { Effect, PlatformError } from "effect"
|
||||||
|
import { make } from "effect/unstable/process/ChildProcessSpawner"
|
||||||
|
import type { Driver } from "./driver"
|
||||||
|
import { Failed, NotFound, WrongKind, type FileInfo, type FilesImpl, type FileType } from "./files"
|
||||||
|
|
||||||
|
type Node =
|
||||||
|
| { readonly type: "file"; readonly bytes: Uint8Array; readonly mtimeMs: number }
|
||||||
|
| { readonly type: "directory"; readonly mtimeMs: number }
|
||||||
|
| { readonly type: "symlink"; readonly target: string; readonly mtimeMs: number }
|
||||||
|
|
||||||
|
export interface MemoryDriver extends Driver {
|
||||||
|
readonly symlink: (target: string, path: string) => Effect.Effect<void, Failed>
|
||||||
|
}
|
||||||
|
|
||||||
|
export const makeMemoryDriver = (): MemoryDriver => {
|
||||||
|
const nodes = new Map<string, Node>([["/", { type: "directory", mtimeMs: Date.now() }]])
|
||||||
|
const key = (value: string) => path.posix.resolve("/", value)
|
||||||
|
const info = (node: Node): FileInfo => ({
|
||||||
|
type: node.type,
|
||||||
|
size:
|
||||||
|
node.type === "file"
|
||||||
|
? node.bytes.length
|
||||||
|
: node.type === "symlink"
|
||||||
|
? new TextEncoder().encode(node.target).length
|
||||||
|
: 0,
|
||||||
|
mtimeMs: node.mtimeMs,
|
||||||
|
})
|
||||||
|
const resolveKey = (value: string, followFinal: boolean, seen = new Set<string>()): string | undefined => {
|
||||||
|
const normalized = key(value)
|
||||||
|
const parts = normalized.split("/").filter(Boolean)
|
||||||
|
const base = "/"
|
||||||
|
const walk = (current: string, index: number): string | undefined => {
|
||||||
|
if (index === parts.length) return current
|
||||||
|
const part = parts[index]
|
||||||
|
const candidate = path.posix.join(current, part)
|
||||||
|
const node = nodes.get(candidate)
|
||||||
|
if (node?.type !== "symlink" || (!followFinal && index === parts.length - 1)) return walk(candidate, index + 1)
|
||||||
|
if (seen.has(candidate)) return undefined
|
||||||
|
seen.add(candidate)
|
||||||
|
const target = path.posix.resolve(path.posix.dirname(candidate), node.target)
|
||||||
|
return resolveKey(path.posix.join(target, ...parts.slice(index + 1)), followFinal, seen)
|
||||||
|
}
|
||||||
|
return walk(base, 0)
|
||||||
|
}
|
||||||
|
const lookup = (value: string) => nodes.get(resolveKey(value, false) ?? key(value))
|
||||||
|
const requireParent = (value: string) => {
|
||||||
|
const parentPath = path.posix.dirname(key(value))
|
||||||
|
const parent = nodes.get(resolveKey(parentPath, true) ?? parentPath)
|
||||||
|
if (!parent) throw new Error(`Parent directory does not exist: ${path.posix.dirname(value)}`)
|
||||||
|
if (parent.type !== "directory") throw new Error(`Parent is not a directory: ${path.posix.dirname(value)}`)
|
||||||
|
}
|
||||||
|
const mkdirSync = (value: string) => {
|
||||||
|
const target = resolveKey(value, false) ?? key(value)
|
||||||
|
const existing = nodes.get(target)
|
||||||
|
if (existing?.type === "directory") return
|
||||||
|
if (existing) throw new Error(`Path is not a directory: ${value}`)
|
||||||
|
const parent = path.posix.dirname(target)
|
||||||
|
if (parent !== target) mkdirSync(parent)
|
||||||
|
nodes.set(target, { type: "directory", mtimeMs: Date.now() })
|
||||||
|
}
|
||||||
|
const failed = (value: string, cause: unknown) => new Failed({ path: value, cause })
|
||||||
|
const overrides: FilesImpl = {
|
||||||
|
stat: (value) => {
|
||||||
|
const node = lookup(value)
|
||||||
|
return node ? Effect.succeed(info(node)) : Effect.fail(new NotFound({ path: value }))
|
||||||
|
},
|
||||||
|
read: (value, range) => {
|
||||||
|
const original = lookup(value)
|
||||||
|
if (!original) return Effect.fail(new NotFound({ path: value }))
|
||||||
|
if (original.type === "directory") return Effect.fail(new WrongKind({ path: value, actual: "directory" }))
|
||||||
|
const resolved = resolveKey(value, true)
|
||||||
|
const node = resolved === undefined ? undefined : nodes.get(resolved)
|
||||||
|
if (!node) return Effect.fail(new NotFound({ path: value }))
|
||||||
|
if (node.type !== "file") return Effect.fail(new WrongKind({ path: value, actual: node.type }))
|
||||||
|
const bytes = range === undefined ? node.bytes : node.bytes.subarray(range.offset, range.offset + range.length)
|
||||||
|
return Effect.succeed({ info: info(node), bytes: bytes.slice() })
|
||||||
|
},
|
||||||
|
write: (value, bytes) =>
|
||||||
|
Effect.try({
|
||||||
|
try: () => {
|
||||||
|
mkdirSync(path.posix.dirname(key(value)))
|
||||||
|
const existing = lookup(value)
|
||||||
|
if (existing?.type === "directory") throw new Error(`Path is a directory: ${value}`)
|
||||||
|
const target = existing?.type === "symlink" ? resolveKey(value, true) : resolveKey(value, false)
|
||||||
|
if (!target) throw new Error(`Cannot resolve symlink: ${value}`)
|
||||||
|
requireParent(target)
|
||||||
|
nodes.set(target, { type: "file", bytes: bytes.slice(), mtimeMs: Date.now() })
|
||||||
|
},
|
||||||
|
catch: (cause) => failed(value, cause),
|
||||||
|
}),
|
||||||
|
list: (value) => {
|
||||||
|
const target = resolveKey(value, false) ?? key(value)
|
||||||
|
const node = nodes.get(target)
|
||||||
|
if (!node) return Effect.fail(new NotFound({ path: value }))
|
||||||
|
if (node.type !== "directory") return Effect.fail(new WrongKind({ path: value, actual: node.type }))
|
||||||
|
const entries = [...nodes.entries()]
|
||||||
|
.filter(([entry]) => entry !== target && path.posix.dirname(entry) === target)
|
||||||
|
.map(([entry, child]) => ({ name: path.posix.basename(entry), type: child.type satisfies FileType }))
|
||||||
|
.sort((a, b) => a.name.localeCompare(b.name))
|
||||||
|
return Effect.succeed(entries)
|
||||||
|
},
|
||||||
|
remove: (value) =>
|
||||||
|
Effect.sync(() => {
|
||||||
|
const target = resolveKey(value, false) ?? key(value)
|
||||||
|
for (const entry of nodes.keys()) {
|
||||||
|
if (entry === target || entry.startsWith(`${target}/`)) nodes.delete(entry)
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
move: (from, to) => {
|
||||||
|
const source = resolveKey(from, false) ?? key(from)
|
||||||
|
const node = nodes.get(source)
|
||||||
|
if (!node) return Effect.fail(new NotFound({ path: from }))
|
||||||
|
return Effect.try({
|
||||||
|
try: () => {
|
||||||
|
const requested = resolveKey(to, false) ?? key(to)
|
||||||
|
const destination =
|
||||||
|
nodes.get(requested)?.type === "directory"
|
||||||
|
? path.posix.join(requested, path.posix.basename(source))
|
||||||
|
: requested
|
||||||
|
if (node.type === "directory" && destination.startsWith(`${source}/`)) {
|
||||||
|
throw new Error(`Cannot move a directory into itself: ${from}`)
|
||||||
|
}
|
||||||
|
const existing = nodes.get(destination)
|
||||||
|
if (node.type === "directory" && existing && existing.type !== "directory") {
|
||||||
|
throw new Error(`Cannot overwrite a non-directory with a directory: ${to}`)
|
||||||
|
}
|
||||||
|
requireParent(destination)
|
||||||
|
const moved = [...nodes.entries()].filter(([entry]) => entry === source || entry.startsWith(`${source}/`))
|
||||||
|
for (const [entry] of moved) nodes.delete(entry)
|
||||||
|
for (const [entry, child] of moved) nodes.set(`${destination}${entry.slice(source.length)}`, child)
|
||||||
|
},
|
||||||
|
catch: (cause) => failed(from, cause),
|
||||||
|
})
|
||||||
|
},
|
||||||
|
mkdir: (value) => Effect.try({ try: () => mkdirSync(value), catch: (cause) => failed(value, cause) }),
|
||||||
|
}
|
||||||
|
|
||||||
|
const spawner = make((command) =>
|
||||||
|
Effect.suspend(() => {
|
||||||
|
const description = command._tag === "StandardCommand" ? command.command : "pipeline"
|
||||||
|
return Effect.fail(
|
||||||
|
PlatformError.systemError({
|
||||||
|
_tag: "Unknown",
|
||||||
|
module: "EnvironmentMemory",
|
||||||
|
method: "spawn",
|
||||||
|
pathOrDescriptor: description,
|
||||||
|
cause: failed(description, new Error("The memory driver cannot spawn processes")),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
spawner,
|
||||||
|
overrides,
|
||||||
|
symlink: (target, value) =>
|
||||||
|
Effect.try({
|
||||||
|
try: () => {
|
||||||
|
requireParent(value)
|
||||||
|
nodes.set(resolveKey(value, false) ?? key(value), { type: "symlink", target, mtimeMs: Date.now() })
|
||||||
|
},
|
||||||
|
catch: (cause) => failed(value, cause),
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export * as EnvironmentMemory from "./memory"
|
||||||
@@ -7,7 +7,7 @@ import { FSUtil } from "@opencode-ai/util/fs-util"
|
|||||||
import { Bom } from "@opencode-ai/util/bom"
|
import { Bom } from "@opencode-ai/util/bom"
|
||||||
|
|
||||||
export interface Target {
|
export interface Target {
|
||||||
readonly canonical: string
|
readonly absolute: string
|
||||||
readonly resource: string
|
readonly resource: string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -37,7 +37,7 @@ export interface Interface {
|
|||||||
export class Service extends Context.Service<Service, Interface>()("@opencode/FileMutation") {}
|
export class Service extends Context.Service<Service, Interface>()("@opencode/FileMutation") {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Serialize file changes by canonical target. Conditional writes compare and
|
* Serialize file changes by absolute target. Conditional writes compare and
|
||||||
* write under the same process-local lock so cooperating OpenCode mutations do
|
* write under the same process-local lock so cooperating OpenCode mutations do
|
||||||
* not overwrite changes made from the same stale content.
|
* not overwrite changes made from the same stale content.
|
||||||
*/
|
*/
|
||||||
@@ -49,11 +49,11 @@ const layer = Layer.effect(
|
|||||||
const withTargetLock =
|
const withTargetLock =
|
||||||
(target: Target) =>
|
(target: Target) =>
|
||||||
<A, E, R>(effect: Effect.Effect<A, E, R>) =>
|
<A, E, R>(effect: Effect.Effect<A, E, R>) =>
|
||||||
locks.withLock(target.canonical)(Effect.uninterruptible(effect))
|
locks.withLock(target.absolute)(Effect.uninterruptible(effect))
|
||||||
|
|
||||||
const writeResult = (target: Target, existed: boolean): WriteResult => ({
|
const writeResult = (target: Target, existed: boolean): WriteResult => ({
|
||||||
operation: "write",
|
operation: "write",
|
||||||
target: target.canonical,
|
target: target.absolute,
|
||||||
resource: target.resource,
|
resource: target.resource,
|
||||||
existed,
|
existed,
|
||||||
})
|
})
|
||||||
@@ -61,8 +61,8 @@ const layer = Layer.effect(
|
|||||||
const write = Effect.fn("FileMutation.write")((input: WriteInput) =>
|
const write = Effect.fn("FileMutation.write")((input: WriteInput) =>
|
||||||
withTargetLock(input.target)(
|
withTargetLock(input.target)(
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const existed = yield* fs.exists(input.target.canonical)
|
const existed = yield* fs.exists(input.target.absolute)
|
||||||
yield* fs.writeWithDirs(input.target.canonical, input.content)
|
yield* fs.writeWithDirs(input.target.absolute, input.content)
|
||||||
return writeResult(input.target, existed)
|
return writeResult(input.target, existed)
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
@@ -73,10 +73,10 @@ const layer = Layer.effect(
|
|||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const next = Bom.split(input.content)
|
const next = Bom.split(input.content)
|
||||||
const current = yield* fs
|
const current = yield* fs
|
||||||
.readFile(input.target.canonical)
|
.readFile(input.target.absolute)
|
||||||
.pipe(Effect.catchReason("PlatformError", "NotFound", () => Effect.succeed(undefined)))
|
.pipe(Effect.catchReason("PlatformError", "NotFound", () => Effect.succeed(undefined)))
|
||||||
yield* fs.writeWithDirs(
|
yield* fs.writeWithDirs(
|
||||||
input.target.canonical,
|
input.target.absolute,
|
||||||
Bom.join(next.text, Boolean(current && Bom.has(current)) || next.bom),
|
Bom.join(next.text, Boolean(current && Bom.has(current)) || next.bom),
|
||||||
)
|
)
|
||||||
return writeResult(input.target, current !== undefined)
|
return writeResult(input.target, current !== undefined)
|
||||||
|
|||||||
@@ -23,14 +23,9 @@ export const ResolveInput = Schema.Struct({
|
|||||||
})
|
})
|
||||||
export type ResolveInput = typeof ResolveInput.Type
|
export type ResolveInput = typeof ResolveInput.Type
|
||||||
|
|
||||||
export class PathError extends Schema.TaggedErrorClass<PathError>()("LocationMutation.PathError", {
|
|
||||||
path: Schema.String,
|
|
||||||
reason: Schema.Literal("non_directory_ancestor"),
|
|
||||||
}) {}
|
|
||||||
|
|
||||||
export interface ExternalDirectoryAuthorization {
|
export interface ExternalDirectoryAuthorization {
|
||||||
readonly action: "external_directory"
|
readonly action: "external_directory"
|
||||||
/** Canonical existing directory used as the external approval boundary. */
|
/** Lexical directory used as the external approval boundary. */
|
||||||
readonly directory: string
|
readonly directory: string
|
||||||
/** `external_directory` permission resource. */
|
/** `external_directory` permission resource. */
|
||||||
readonly resource: string
|
readonly resource: string
|
||||||
@@ -44,9 +39,9 @@ export const externalDirectoryPermission = (input: ExternalDirectoryAuthorizatio
|
|||||||
})
|
})
|
||||||
|
|
||||||
export interface Target {
|
export interface Target {
|
||||||
/** Canonical existing path, or missing path below a canonical directory. */
|
/** Absolute lexical path. */
|
||||||
readonly canonical: string
|
readonly absolute: string
|
||||||
/** Permission resource: Location-relative for internal paths, canonical for external paths. */
|
/** Permission resource: Location-relative for internal paths, absolute for external paths. */
|
||||||
readonly resource: string
|
readonly resource: string
|
||||||
readonly externalDirectory?: ExternalDirectoryAuthorization
|
readonly externalDirectory?: ExternalDirectoryAuthorization
|
||||||
}
|
}
|
||||||
@@ -57,25 +52,11 @@ export interface Interface {
|
|||||||
* from the Location. Paths outside it require separate `external_directory`
|
* from the Location. Paths outside it require separate `external_directory`
|
||||||
* approval. This does not approve the mutation.
|
* approval. This does not approve the mutation.
|
||||||
*/
|
*/
|
||||||
readonly resolve: (input: ResolveInput) => Effect.Effect<Target, PathError | FSUtil.Error>
|
readonly resolve: (input: ResolveInput) => Effect.Effect<Target, FSUtil.Error>
|
||||||
}
|
}
|
||||||
|
|
||||||
export class Service extends Context.Service<Service, Interface>()("@opencode/LocationMutation") {}
|
export class Service extends Context.Service<Service, Interface>()("@opencode/LocationMutation") {}
|
||||||
|
|
||||||
interface ResolvedPath {
|
|
||||||
readonly canonical: string
|
|
||||||
readonly type?:
|
|
||||||
| "File"
|
|
||||||
| "Directory"
|
|
||||||
| "SymbolicLink"
|
|
||||||
| "BlockDevice"
|
|
||||||
| "CharacterDevice"
|
|
||||||
| "FIFO"
|
|
||||||
| "Socket"
|
|
||||||
| "Unknown"
|
|
||||||
readonly directory: string
|
|
||||||
}
|
|
||||||
|
|
||||||
const slash = (value: string) => value.replaceAll("\\", "/")
|
const slash = (value: string) => value.replaceAll("\\", "/")
|
||||||
|
|
||||||
const layer = Layer.effect(
|
const layer = Layer.effect(
|
||||||
@@ -84,65 +65,33 @@ const layer = Layer.effect(
|
|||||||
const fs = yield* FSUtil.Service
|
const fs = yield* FSUtil.Service
|
||||||
const location = yield* Location.Service
|
const location = yield* Location.Service
|
||||||
|
|
||||||
function notFound<A>(effect: Effect.Effect<A, FSUtil.Error>) {
|
|
||||||
return effect.pipe(Effect.catchReason("PlatformError", "NotFound", () => Effect.succeed(undefined)))
|
|
||||||
}
|
|
||||||
|
|
||||||
const resolvePath = Effect.fnUntraced(function* (absolute: string) {
|
|
||||||
const existing = yield* notFound(fs.realPath(absolute))
|
|
||||||
if (existing !== undefined) {
|
|
||||||
const info = yield* fs.stat(existing)
|
|
||||||
return {
|
|
||||||
canonical: existing,
|
|
||||||
type: info.type,
|
|
||||||
directory: info.type === "Directory" ? existing : path.dirname(existing),
|
|
||||||
} satisfies ResolvedPath
|
|
||||||
}
|
|
||||||
|
|
||||||
let anchor = path.dirname(absolute)
|
|
||||||
while (true) {
|
|
||||||
const canonical = yield* notFound(fs.realPath(anchor))
|
|
||||||
if (canonical !== undefined) {
|
|
||||||
const info = yield* fs.stat(canonical)
|
|
||||||
if (info.type !== "Directory") {
|
|
||||||
return yield* new PathError({ path: absolute, reason: "non_directory_ancestor" })
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
canonical: path.resolve(canonical, path.relative(anchor, absolute)),
|
|
||||||
directory: canonical,
|
|
||||||
} satisfies ResolvedPath
|
|
||||||
}
|
|
||||||
const parent = path.dirname(anchor)
|
|
||||||
if (parent === anchor) return yield* new PathError({ path: absolute, reason: "non_directory_ancestor" })
|
|
||||||
anchor = parent
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const resolve = Effect.fn("LocationMutation.resolve")(function* (input: ResolveInput) {
|
const resolve = Effect.fn("LocationMutation.resolve")(function* (input: ResolveInput) {
|
||||||
const absolute = path.resolve(location.directory, input.path)
|
const absolute = path.resolve(location.directory, input.path)
|
||||||
// External access follows the requested path boundary. Symlinks reached through an
|
if (FSUtil.contains(location.directory, absolute)) {
|
||||||
// internal path intentionally retain internal permission semantics after canonicalization.
|
return {
|
||||||
const lexicallyInternal = FSUtil.contains(location.directory, absolute)
|
absolute,
|
||||||
|
resource: slash(path.relative(location.directory, absolute) || "."),
|
||||||
const resolved = yield* resolvePath(absolute)
|
} satisfies Target
|
||||||
const external = !lexicallyInternal
|
}
|
||||||
const resource = external ? slash(resolved.canonical) : slash(path.relative(location.directory, absolute) || ".")
|
const type =
|
||||||
const externalDirectory =
|
input.kind === "directory"
|
||||||
input.kind === "directory" && resolved.type === "Directory" ? resolved.canonical : resolved.directory
|
? "Directory"
|
||||||
|
: (yield* fs
|
||||||
|
.stat(absolute)
|
||||||
|
.pipe(Effect.catchReason("PlatformError", "NotFound", () => Effect.succeed(undefined))))?.type
|
||||||
|
const externalDirectory = type === "Directory" ? absolute : path.dirname(absolute)
|
||||||
const externalResource = slash(path.join(externalDirectory, "*"))
|
const externalResource = slash(path.join(externalDirectory, "*"))
|
||||||
return {
|
return {
|
||||||
canonical: resolved.canonical,
|
absolute,
|
||||||
resource,
|
resource: slash(absolute),
|
||||||
externalDirectory: external
|
externalDirectory: {
|
||||||
? {
|
action: "external_directory",
|
||||||
action: "external_directory",
|
directory: externalDirectory,
|
||||||
directory: externalDirectory,
|
resource: externalResource,
|
||||||
resource: externalResource,
|
save: slash(
|
||||||
save: slash(
|
path.join((yield* Project.root(fs, AbsolutePath.make(externalDirectory))) ?? externalDirectory, "*"),
|
||||||
path.join((yield* Project.root(fs, AbsolutePath.make(externalDirectory))) ?? externalDirectory, "*"),
|
),
|
||||||
),
|
},
|
||||||
}
|
|
||||||
: undefined,
|
|
||||||
} satisfies Target
|
} satisfies Target
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ import { SessionGenerateNode } from "./session/generate-node"
|
|||||||
import { McpTool } from "./tool/mcp"
|
import { McpTool } from "./tool/mcp"
|
||||||
import { ReadToolFileSystem } from "./tool/read-filesystem"
|
import { ReadToolFileSystem } from "./tool/read-filesystem"
|
||||||
import { Tool } from "./tool"
|
import { Tool } from "./tool"
|
||||||
|
import { ToolOutput } from "./tool-output"
|
||||||
import { Vcs } from "./vcs"
|
import { Vcs } from "./vcs"
|
||||||
|
|
||||||
export { LocationServiceMap } from "./location-service-map"
|
export { LocationServiceMap } from "./location-service-map"
|
||||||
@@ -78,6 +79,7 @@ const locationServiceNodes = [
|
|||||||
MCP.node,
|
MCP.node,
|
||||||
Permission.node,
|
Permission.node,
|
||||||
Tool.node,
|
Tool.node,
|
||||||
|
ToolOutput.node,
|
||||||
Image.node,
|
Image.node,
|
||||||
SkillInstructions.node,
|
SkillInstructions.node,
|
||||||
ReferenceInstructions.node,
|
ReferenceInstructions.node,
|
||||||
|
|||||||
@@ -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 { WebSearchExa } from "./exa"
|
||||||
|
import { WebSearchFirecrawl } from "./firecrawl"
|
||||||
import { WebSearchParallel } from "./parallel"
|
import { WebSearchParallel } from "./parallel"
|
||||||
|
|
||||||
export const WebSearchPlugins = [WebSearchExa.Plugin, WebSearchParallel.Plugin] as const
|
export const WebSearchPlugins = [WebSearchExa.Plugin, WebSearchFirecrawl.Plugin, WebSearchParallel.Plugin] as const
|
||||||
|
|||||||
@@ -133,6 +133,14 @@ export class CompactionConflictError extends Schema.TaggedErrorClass<CompactionC
|
|||||||
export class BusyError extends Schema.TaggedErrorClass<BusyError>()("Session.BusyError", {
|
export class BusyError extends Schema.TaggedErrorClass<BusyError>()("Session.BusyError", {
|
||||||
sessionID: SessionSchema.ID,
|
sessionID: SessionSchema.ID,
|
||||||
}) {}
|
}) {}
|
||||||
|
export class PendingInputConflictError extends Schema.TaggedErrorClass<PendingInputConflictError>()(
|
||||||
|
"Session.PendingInputConflictError",
|
||||||
|
{
|
||||||
|
sessionID: SessionSchema.ID,
|
||||||
|
inputID: SessionMessage.ID,
|
||||||
|
},
|
||||||
|
) {}
|
||||||
|
type PendingInputRef = { readonly sessionID: SessionSchema.ID; readonly inputID: SessionMessage.ID }
|
||||||
export class SkillNotFoundError extends Schema.TaggedErrorClass<SkillNotFoundError>()("Session.SkillNotFoundError", {
|
export class SkillNotFoundError extends Schema.TaggedErrorClass<SkillNotFoundError>()("Session.SkillNotFoundError", {
|
||||||
skill: Skill.ID,
|
skill: Skill.ID,
|
||||||
}) {}
|
}) {}
|
||||||
@@ -181,6 +189,9 @@ export interface Interface {
|
|||||||
* unhandled compaction barriers.
|
* unhandled compaction barriers.
|
||||||
*/
|
*/
|
||||||
readonly pending: (sessionID: SessionSchema.ID) => Effect.Effect<SessionPending.Info[], NotFoundError>
|
readonly pending: (sessionID: SessionSchema.ID) => Effect.Effect<SessionPending.Info[], NotFoundError>
|
||||||
|
readonly cancelPending: (input: PendingInputRef) => Effect.Effect<void, NotFoundError | PendingInputConflictError>
|
||||||
|
readonly steerPending: (input: PendingInputRef) => Effect.Effect<void, NotFoundError | PendingInputConflictError>
|
||||||
|
readonly queuePending: (input: PendingInputRef) => Effect.Effect<void, NotFoundError | PendingInputConflictError>
|
||||||
/**
|
/**
|
||||||
* Durable, ordered session log read. Replays durable session bus after
|
* Durable, ordered session log read. Replays durable session bus after
|
||||||
* the exclusive `after` cursor, emits a `Synced` marker at the captured
|
* the exclusive `after` cursor, emits a `Synced` marker at the captured
|
||||||
@@ -318,6 +329,31 @@ const layer = Layer.effect(
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const pendingConflict = Effect.fn("Session.pendingConflict")(function* (input: PendingInputRef) {
|
||||||
|
yield* result.get(input.sessionID)
|
||||||
|
return yield* new PendingInputConflictError(input)
|
||||||
|
})
|
||||||
|
const mutatePending = (
|
||||||
|
input: PendingInputRef,
|
||||||
|
mutation: (
|
||||||
|
bus: Bus.Interface,
|
||||||
|
input: { readonly id: SessionMessage.ID; readonly sessionID: SessionSchema.ID },
|
||||||
|
) => Effect.Effect<unknown>,
|
||||||
|
wake = false,
|
||||||
|
) =>
|
||||||
|
Effect.uninterruptible(
|
||||||
|
Effect.gen(function* () {
|
||||||
|
yield* mutation(bus, { sessionID: input.sessionID, id: input.inputID }).pipe(
|
||||||
|
Effect.catchDefect((defect) =>
|
||||||
|
defect instanceof SessionPending.LifecycleConflict
|
||||||
|
? pendingConflict(input)
|
||||||
|
: Effect.die(defect),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if (wake) yield* execution.wake(input.sessionID)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
const result = Service.of({
|
const result = Service.of({
|
||||||
create: Effect.fn("Session.create")(function* (input) {
|
create: Effect.fn("Session.create")(function* (input) {
|
||||||
const sessionID = input.id ?? SessionSchema.ID.create()
|
const sessionID = input.id ?? SessionSchema.ID.create()
|
||||||
@@ -507,6 +543,9 @@ const layer = Layer.effect(
|
|||||||
yield* result.get(sessionID)
|
yield* result.get(sessionID)
|
||||||
return yield* SessionPending.list(db, sessionID)
|
return yield* SessionPending.list(db, sessionID)
|
||||||
}),
|
}),
|
||||||
|
cancelPending: Effect.fn("Session.cancelPending")((input) => mutatePending(input, SessionPending.cancel)),
|
||||||
|
steerPending: Effect.fn("Session.steerPending")((input) => mutatePending(input, SessionPending.steer, true)),
|
||||||
|
queuePending: Effect.fn("Session.queuePending")((input) => mutatePending(input, SessionPending.queue)),
|
||||||
log: (input) =>
|
log: (input) =>
|
||||||
Stream.unwrap(
|
Stream.unwrap(
|
||||||
result
|
result
|
||||||
|
|||||||
@@ -90,6 +90,9 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
|
|||||||
"session.forked": () => Effect.void,
|
"session.forked": () => Effect.void,
|
||||||
"session.input.promoted": () => Effect.void,
|
"session.input.promoted": () => Effect.void,
|
||||||
"session.input.admitted": () => Effect.void,
|
"session.input.admitted": () => Effect.void,
|
||||||
|
"session.input.cancelled": () => Effect.void,
|
||||||
|
"session.input.steered": () => Effect.void,
|
||||||
|
"session.input.queued": () => Effect.void,
|
||||||
"session.execution.started": () => Effect.void,
|
"session.execution.started": () => Effect.void,
|
||||||
"session.execution.succeeded": () => clearCurrentRetry,
|
"session.execution.succeeded": () => clearCurrentRetry,
|
||||||
"session.execution.failed": () => clearCurrentRetry,
|
"session.execution.failed": () => clearCurrentRetry,
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ const decodeSynthetic = Schema.decodeUnknownSync(SyntheticData)
|
|||||||
const encodeSynthetic = Schema.encodeSync(SyntheticData)
|
const encodeSynthetic = Schema.encodeSync(SyntheticData)
|
||||||
const decodeMessage = Schema.decodeUnknownSync(SessionMessage.Info)
|
const decodeMessage = Schema.decodeUnknownSync(SessionMessage.Info)
|
||||||
const inboxLocks = KeyedMutex.makeUnsafe<SessionSchema.ID>()
|
const inboxLocks = KeyedMutex.makeUnsafe<SessionSchema.ID>()
|
||||||
|
type PendingRef = { readonly id: SessionMessage.ID; readonly sessionID: SessionSchema.ID }
|
||||||
|
|
||||||
export class LifecycleConflict extends Schema.TaggedErrorClass<LifecycleConflict>()(
|
export class LifecycleConflict extends Schema.TaggedErrorClass<LifecycleConflict>()(
|
||||||
"SessionPending.LifecycleConflict",
|
"SessionPending.LifecycleConflict",
|
||||||
@@ -294,10 +295,7 @@ export const projectCompactionAdmitted = Effect.fn("SessionPending.projectCompac
|
|||||||
*/
|
*/
|
||||||
export const projectPromoted = Effect.fn("SessionPending.projectPromoted")(function* (
|
export const projectPromoted = Effect.fn("SessionPending.projectPromoted")(function* (
|
||||||
db: DatabaseService,
|
db: DatabaseService,
|
||||||
input: {
|
input: PendingRef,
|
||||||
readonly id: SessionMessage.ID
|
|
||||||
readonly sessionID: SessionSchema.ID
|
|
||||||
},
|
|
||||||
) {
|
) {
|
||||||
if (yield* compaction(db, input.sessionID)) return yield* Effect.die(new LifecycleConflict({ id: input.id }))
|
if (yield* compaction(db, input.sessionID)) return yield* Effect.die(new LifecycleConflict({ id: input.id }))
|
||||||
const deleted = yield* db
|
const deleted = yield* db
|
||||||
@@ -312,6 +310,55 @@ export const projectPromoted = Effect.fn("SessionPending.projectPromoted")(funct
|
|||||||
return stored
|
return stored
|
||||||
})
|
})
|
||||||
|
|
||||||
|
export const projectCancelled = Effect.fn("SessionPending.projectCancelled")(function* (
|
||||||
|
db: DatabaseService,
|
||||||
|
input: PendingRef,
|
||||||
|
) {
|
||||||
|
const deleted = yield* db
|
||||||
|
.delete(SessionPendingTable)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(SessionPendingTable.id, input.id),
|
||||||
|
eq(SessionPendingTable.session_id, input.sessionID),
|
||||||
|
or(eq(SessionPendingTable.delivery, "queue"), eq(SessionPendingTable.delivery, "steer")),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.returning({ id: SessionPendingTable.id })
|
||||||
|
.get()
|
||||||
|
.pipe(Effect.orDie)
|
||||||
|
if (!deleted) return yield* Effect.die(new LifecycleConflict({ id: input.id }))
|
||||||
|
})
|
||||||
|
|
||||||
|
const projectDelivery = Effect.fn("SessionPending.projectDelivery")(function* (
|
||||||
|
db: DatabaseService,
|
||||||
|
input: PendingRef & { readonly from: Delivery; readonly to: Delivery },
|
||||||
|
) {
|
||||||
|
const updated = yield* db
|
||||||
|
.update(SessionPendingTable)
|
||||||
|
.set({ delivery: input.to })
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(SessionPendingTable.id, input.id),
|
||||||
|
eq(SessionPendingTable.session_id, input.sessionID),
|
||||||
|
eq(SessionPendingTable.delivery, input.from),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.returning({ id: SessionPendingTable.id })
|
||||||
|
.get()
|
||||||
|
.pipe(Effect.orDie)
|
||||||
|
if (!updated) return yield* Effect.die(new LifecycleConflict({ id: input.id }))
|
||||||
|
})
|
||||||
|
|
||||||
|
export const projectSteered = Effect.fn("SessionPending.projectSteered")(
|
||||||
|
(db: DatabaseService, input: PendingRef) =>
|
||||||
|
projectDelivery(db, { ...input, from: "queue", to: "steer" }),
|
||||||
|
)
|
||||||
|
|
||||||
|
export const projectQueued = Effect.fn("SessionPending.projectQueued")(
|
||||||
|
(db: DatabaseService, input: PendingRef) =>
|
||||||
|
projectDelivery(db, { ...input, from: "steer", to: "queue" }),
|
||||||
|
)
|
||||||
|
|
||||||
export const settleCompaction = Effect.fn("SessionPending.settleCompaction")(function* (
|
export const settleCompaction = Effect.fn("SessionPending.settleCompaction")(function* (
|
||||||
db: DatabaseService,
|
db: DatabaseService,
|
||||||
input: { readonly sessionID: SessionSchema.ID },
|
input: { readonly sessionID: SessionSchema.ID },
|
||||||
@@ -389,6 +436,39 @@ export const equivalent = (
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const publishMutation = <A, E, R>(input: PendingRef, effect: Effect.Effect<A, E, R>) =>
|
||||||
|
inboxLocks.withLock(input.sessionID)(effect).pipe(Effect.asVoid)
|
||||||
|
|
||||||
|
export const cancel = Effect.fn("SessionPending.cancel")((bus: Bus.Interface, input: PendingRef) =>
|
||||||
|
publishMutation(
|
||||||
|
input,
|
||||||
|
bus.publish(SessionEvent.InputCancelled, {
|
||||||
|
sessionID: input.sessionID,
|
||||||
|
inputID: input.id,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
export const steer = Effect.fn("SessionPending.steer")((bus: Bus.Interface, input: PendingRef) =>
|
||||||
|
publishMutation(
|
||||||
|
input,
|
||||||
|
bus.publish(SessionEvent.InputSteered, {
|
||||||
|
sessionID: input.sessionID,
|
||||||
|
inputID: input.id,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
export const queue = Effect.fn("SessionPending.queue")((bus: Bus.Interface, input: PendingRef) =>
|
||||||
|
publishMutation(
|
||||||
|
input,
|
||||||
|
bus.publish(SessionEvent.InputQueued, {
|
||||||
|
sessionID: input.sessionID,
|
||||||
|
inputID: input.id,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
const publish = Effect.fn("SessionPending.publish")(function* (
|
const publish = Effect.fn("SessionPending.publish")(function* (
|
||||||
db: DatabaseService,
|
db: DatabaseService,
|
||||||
bus: Bus.Interface,
|
bus: Bus.Interface,
|
||||||
|
|||||||
@@ -485,6 +485,24 @@ const layer = Layer.effectDiscard(
|
|||||||
.pipe(Effect.orDie)
|
.pipe(Effect.orDie)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
yield* bus.project(SessionEvent.InputCancelled, (event) =>
|
||||||
|
SessionPending.projectCancelled(db, {
|
||||||
|
id: event.data.inputID,
|
||||||
|
sessionID: event.data.sessionID,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
yield* bus.project(SessionEvent.InputSteered, (event) =>
|
||||||
|
SessionPending.projectSteered(db, {
|
||||||
|
id: event.data.inputID,
|
||||||
|
sessionID: event.data.sessionID,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
yield* bus.project(SessionEvent.InputQueued, (event) =>
|
||||||
|
SessionPending.projectQueued(db, {
|
||||||
|
id: event.data.inputID,
|
||||||
|
sessionID: event.data.sessionID,
|
||||||
|
}),
|
||||||
|
)
|
||||||
yield* bus.project(SessionEvent.Compaction.Admitted, (event) =>
|
yield* bus.project(SessionEvent.Compaction.Admitted, (event) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
if (event.durable === undefined)
|
if (event.durable === undefined)
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ import { StepFailedError } from "../error"
|
|||||||
import { toSessionError } from "../to-session-error"
|
import { toSessionError } from "../to-session-error"
|
||||||
import { SessionRunnerRetry } from "./retry"
|
import { SessionRunnerRetry } from "./retry"
|
||||||
import { SessionUsage } from "../usage"
|
import { SessionUsage } from "../usage"
|
||||||
|
import { ToolOutput } from "../../tool-output"
|
||||||
|
|
||||||
/** How one model call ended: settled, awaiting a scheduled retry, or restarted by compaction. */
|
/** How one model call ended: settled, awaiting a scheduled retry, or restarted by compaction. */
|
||||||
type CallOutcome = Data.TaggedEnum<{
|
type CallOutcome = Data.TaggedEnum<{
|
||||||
@@ -107,6 +108,7 @@ const layer = Layer.effect(
|
|||||||
const db = (yield* Database.Service).db
|
const db = (yield* Database.Service).db
|
||||||
const compaction = yield* SessionCompaction.Service
|
const compaction = yield* SessionCompaction.Service
|
||||||
const title = yield* SessionTitle.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.
|
// 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.
|
// The in-flight set coalesces overlapping steps while title presence records success durably.
|
||||||
const titlesRunning = new Set<SessionSchema.ID>()
|
const titlesRunning = new Set<SessionSchema.ID>()
|
||||||
@@ -334,6 +336,7 @@ const layer = Layer.effect(
|
|||||||
).pipe(
|
).pipe(
|
||||||
// The fiber owns its call: it publishes its own completion, masked so a
|
// The fiber owns its call: it publishes its own completion, masked so a
|
||||||
// finished execution always reaches its durable settlement.
|
// finished execution always reaches its durable settlement.
|
||||||
|
Effect.flatMap(toolOutput.truncate),
|
||||||
Effect.flatMap((outcome) => publisher.toolExecution(event.id, event.name, outcome)),
|
Effect.flatMap((outcome) => publisher.toolExecution(event.id, event.name, outcome)),
|
||||||
Effect.catchTag("Tool.Error", (error) =>
|
Effect.catchTag("Tool.Error", (error) =>
|
||||||
publisher.failTool(event.id, toSessionError(error)).pipe(Effect.asVoid),
|
publisher.failTool(event.id, toSessionError(error)).pipe(Effect.asVoid),
|
||||||
@@ -562,6 +565,7 @@ export const node = makeLocationNode({
|
|||||||
SessionCompaction.node,
|
SessionCompaction.node,
|
||||||
SessionTitle.node,
|
SessionTitle.node,
|
||||||
Snapshot.node,
|
Snapshot.node,
|
||||||
|
ToolOutput.node,
|
||||||
Database.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],
|
||||||
|
})
|
||||||
@@ -116,124 +116,120 @@ export const Plugin = {
|
|||||||
|
|
||||||
yield* ctx.tool
|
yield* ctx.tool
|
||||||
.transform((draft) =>
|
.transform((draft) =>
|
||||||
draft.add(
|
draft.add({
|
||||||
({
|
name,
|
||||||
name,
|
options: { codemode: false, permission: "edit" },
|
||||||
options: { codemode: false, permission: "edit" },
|
description:
|
||||||
description:
|
"Edit the contents of a file by finding and replacing exact text. When editing text from Read output, preserve the exact indentation (tabs or spaces) and omit the line-number prefix, such as `1: `. Never include the prefix in oldString or newString. The edit fails if oldString is not found. By default, oldString must identify a UNIQUE location. Multiple matches FAIL unless replaceAll is true. Add more surrounding context to disambiguate, or set replaceAll to true to replace every occurrence. Use replaceAll when the change should apply to every occurrence, such as renaming a variable.",
|
||||||
"Edit the contents of a file by finding and replacing exact text. When editing text from Read output, preserve the exact indentation (tabs or spaces) and omit the line-number prefix, such as `1: `. Never include the prefix in oldString or newString. The edit fails if oldString is not found. By default, oldString must identify a UNIQUE location. Multiple matches FAIL unless replaceAll is true. Add more surrounding context to disambiguate, or set replaceAll to true to replace every occurrence. Use replaceAll when the change should apply to every occurrence, such as renaming a variable.",
|
input: Input,
|
||||||
input: Input,
|
output: Output,
|
||||||
output: Output,
|
execute: (input, context) => {
|
||||||
execute: (input, context) => {
|
return Effect.gen(function* () {
|
||||||
return Effect.gen(function* () {
|
const permissionSource = {
|
||||||
const permissionSource = {
|
type: "tool" as const,
|
||||||
type: "tool" as const,
|
messageID: context.messageID,
|
||||||
messageID: context.messageID,
|
id: context.id,
|
||||||
id: context.id,
|
}
|
||||||
}
|
if (input.oldString === input.newString) {
|
||||||
if (input.oldString === input.newString) {
|
return yield* new ToolFailure({
|
||||||
return yield* new ToolFailure({
|
message: "No changes to apply: oldString and newString are identical.",
|
||||||
message: "No changes to apply: oldString and newString are identical.",
|
})
|
||||||
})
|
}
|
||||||
}
|
if (input.oldString === "") {
|
||||||
if (input.oldString === "") {
|
return yield* new ToolFailure({
|
||||||
return yield* new ToolFailure({
|
message: "oldString must not be empty. Use write to create or overwrite a file.",
|
||||||
message: "oldString must not be empty. Use write to create or overwrite a file.",
|
})
|
||||||
})
|
}
|
||||||
}
|
|
||||||
|
|
||||||
const target = yield* mutation.resolve({ path: input.path, kind: "file" })
|
const target = yield* mutation.resolve({ path: input.path, kind: "file" })
|
||||||
const external = target.externalDirectory
|
const external = target.externalDirectory
|
||||||
if (external) {
|
if (external) {
|
||||||
yield* permission.assert({
|
yield* permission.assert({
|
||||||
...LocationMutation.externalDirectoryPermission(external),
|
...LocationMutation.externalDirectoryPermission(external),
|
||||||
sessionID: context.sessionID,
|
sessionID: context.sessionID,
|
||||||
agent: context.agent,
|
agent: context.agent,
|
||||||
source: permissionSource,
|
source: permissionSource,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const info = yield* fs.stat(target.canonical).pipe(
|
const info = yield* fs
|
||||||
Effect.catchReason("PlatformError", "NotFound", () =>
|
.stat(target.absolute)
|
||||||
Effect.fail(new ToolFailure({ message: `File not found: ${input.path}` })),
|
.pipe(
|
||||||
),
|
Effect.catchReason("PlatformError", "NotFound", () =>
|
||||||
)
|
Effect.fail(new ToolFailure({ message: `File not found: ${input.path}` })),
|
||||||
if (info.type === "Directory") {
|
|
||||||
return yield* new ToolFailure({ message: `Path is a directory, not a file: ${input.path}` })
|
|
||||||
}
|
|
||||||
const original = yield* Bom.readFile(fs, target.canonical)
|
|
||||||
const source = original.text
|
|
||||||
const ending = source.includes(crlf) ? crlf : "\n"
|
|
||||||
const oldString = input.oldString.replaceAll(crlf, "\n").replaceAll("\n", ending)
|
|
||||||
const newString = input.newString.replaceAll(crlf, "\n").replaceAll("\n", ending)
|
|
||||||
const exact = findOccurrences(source, oldString)
|
|
||||||
// These one-to-one mappings preserve offsets into the original source.
|
|
||||||
const unicode =
|
|
||||||
exact.length > 0 ? [] : findOccurrences(normalizeForMatch(source), normalizeForMatch(oldString))
|
|
||||||
const trailing =
|
|
||||||
exact.length > 0 || unicode.length > 0
|
|
||||||
? []
|
|
||||||
: findLineOccurrences(source, oldString)
|
|
||||||
const matches = exact.length > 0 ? exact : unicode.length > 0 ? unicode : trailing
|
|
||||||
const replacements = matches.length
|
|
||||||
const replaced = (input.replaceAll === true ? matches : matches.slice(0, 1))
|
|
||||||
.toReversed()
|
|
||||||
.reduce(
|
|
||||||
(content, match) =>
|
|
||||||
`${content.slice(0, match.start)}${newString}${content.slice(match.end)}`,
|
|
||||||
source,
|
|
||||||
)
|
|
||||||
const preview =
|
|
||||||
replacements > 0 && (replacements === 1 || input.replaceAll === true)
|
|
||||||
? fileDiff(target.resource, source, replaced)
|
|
||||||
: undefined
|
|
||||||
yield* permission.assert({
|
|
||||||
action: "edit",
|
|
||||||
resources: [target.resource],
|
|
||||||
save: ["*"],
|
|
||||||
metadata: preview ? { files: [preview] } : undefined,
|
|
||||||
sessionID: context.sessionID,
|
|
||||||
agent: context.agent,
|
|
||||||
source: permissionSource,
|
|
||||||
})
|
|
||||||
if (replacements === 0) {
|
|
||||||
return yield* new ToolFailure({
|
|
||||||
message: `Could not find oldString in ${input.path}. It must match exactly, including whitespace and indentation.`,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
if (replacements > 1 && input.replaceAll !== true) {
|
|
||||||
return yield* new ToolFailure({
|
|
||||||
message: `Found ${replacements} matches for oldString, but expected exactly one. Add more surrounding context to make oldString unique, or set replaceAll to true to replace every occurrence.`,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
const replacementBom = replaced.startsWith("\uFEFF")
|
|
||||||
const result = yield* files.write({
|
|
||||||
target,
|
|
||||||
content: Bom.join(replaced, original.bom || replacementBom),
|
|
||||||
})
|
|
||||||
const bom = original.bom || replacementBom
|
|
||||||
const formatted = (yield* formatter.file(target.canonical))
|
|
||||||
? yield* Bom.syncFile(fs, target.canonical, bom)
|
|
||||||
: (yield* Bom.readFile(fs, target.canonical)).text
|
|
||||||
return {
|
|
||||||
files: [fileDiff(result.resource, source, formatted)],
|
|
||||||
replacements,
|
|
||||||
} satisfies Output
|
|
||||||
}).pipe(
|
|
||||||
Effect.map((output) => ({
|
|
||||||
output,
|
|
||||||
content: `Edited ${output.files[0]?.file} (${output.replacements} replacement${output.replacements === 1 ? "" : "s"})`,
|
|
||||||
metadata: { files: output.files },
|
|
||||||
})),
|
|
||||||
Effect.mapError((error) =>
|
|
||||||
error instanceof ToolFailure
|
|
||||||
? error
|
|
||||||
: new ToolFailure({ message: `Unable to edit ${input.path}`, error }),
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
},
|
if (info.type === "Directory") {
|
||||||
}),
|
return yield* new ToolFailure({ message: `Path is a directory, not a file: ${input.path}` })
|
||||||
),
|
}
|
||||||
|
const original = yield* Bom.readFile(fs, target.absolute)
|
||||||
|
const source = original.text
|
||||||
|
const ending = source.includes(crlf) ? crlf : "\n"
|
||||||
|
const oldString = input.oldString.replaceAll(crlf, "\n").replaceAll("\n", ending)
|
||||||
|
const newString = input.newString.replaceAll(crlf, "\n").replaceAll("\n", ending)
|
||||||
|
const exact = findOccurrences(source, oldString)
|
||||||
|
// These one-to-one mappings preserve offsets into the original source.
|
||||||
|
const unicode =
|
||||||
|
exact.length > 0 ? [] : findOccurrences(normalizeForMatch(source), normalizeForMatch(oldString))
|
||||||
|
const trailing = exact.length > 0 || unicode.length > 0 ? [] : findLineOccurrences(source, oldString)
|
||||||
|
const matches = exact.length > 0 ? exact : unicode.length > 0 ? unicode : trailing
|
||||||
|
const replacements = matches.length
|
||||||
|
const replaced = (input.replaceAll === true ? matches : matches.slice(0, 1))
|
||||||
|
.toReversed()
|
||||||
|
.reduce(
|
||||||
|
(content, match) => `${content.slice(0, match.start)}${newString}${content.slice(match.end)}`,
|
||||||
|
source,
|
||||||
|
)
|
||||||
|
const preview =
|
||||||
|
replacements > 0 && (replacements === 1 || input.replaceAll === true)
|
||||||
|
? fileDiff(target.resource, source, replaced)
|
||||||
|
: undefined
|
||||||
|
yield* permission.assert({
|
||||||
|
action: "edit",
|
||||||
|
resources: [target.resource],
|
||||||
|
save: ["*"],
|
||||||
|
metadata: preview ? { files: [preview] } : undefined,
|
||||||
|
sessionID: context.sessionID,
|
||||||
|
agent: context.agent,
|
||||||
|
source: permissionSource,
|
||||||
|
})
|
||||||
|
if (replacements === 0) {
|
||||||
|
return yield* new ToolFailure({
|
||||||
|
message: `Could not find oldString in ${input.path}. It must match exactly, including whitespace and indentation.`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (replacements > 1 && input.replaceAll !== true) {
|
||||||
|
return yield* new ToolFailure({
|
||||||
|
message: `Found ${replacements} matches for oldString, but expected exactly one. Add more surrounding context to make oldString unique, or set replaceAll to true to replace every occurrence.`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const replacementBom = replaced.startsWith("\uFEFF")
|
||||||
|
const result = yield* files.write({
|
||||||
|
target,
|
||||||
|
content: Bom.join(replaced, original.bom || replacementBom),
|
||||||
|
})
|
||||||
|
const bom = original.bom || replacementBom
|
||||||
|
const formatted = (yield* formatter.file(target.absolute))
|
||||||
|
? yield* Bom.syncFile(fs, target.absolute, bom)
|
||||||
|
: (yield* Bom.readFile(fs, target.absolute)).text
|
||||||
|
return {
|
||||||
|
files: [fileDiff(result.resource, source, formatted)],
|
||||||
|
replacements,
|
||||||
|
} satisfies Output
|
||||||
|
}).pipe(
|
||||||
|
Effect.map((output) => ({
|
||||||
|
output,
|
||||||
|
content: `Edited ${output.files[0]?.file} (${output.replacements} replacement${output.replacements === 1 ? "" : "s"})`,
|
||||||
|
metadata: { files: output.files },
|
||||||
|
})),
|
||||||
|
Effect.mapError((error) =>
|
||||||
|
error instanceof ToolFailure
|
||||||
|
? error
|
||||||
|
: new ToolFailure({ message: `Unable to edit ${input.path}`, error }),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
}),
|
||||||
)
|
)
|
||||||
.pipe(Effect.orDie)
|
.pipe(Effect.orDie)
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -50,96 +50,93 @@ export const Plugin = {
|
|||||||
|
|
||||||
yield* ctx.tool
|
yield* ctx.tool
|
||||||
.transform((draft) =>
|
.transform((draft) =>
|
||||||
draft.add(
|
draft.add({
|
||||||
({
|
name,
|
||||||
name,
|
options: { codemode: false },
|
||||||
options: { codemode: false },
|
description: 'Search file paths using a glob pattern (examples: "**/*.ts", "src/**/*.tsx").',
|
||||||
description:
|
input: Input,
|
||||||
'Search file paths using a glob pattern (examples: "**/*.ts", "src/**/*.tsx").',
|
output: Output,
|
||||||
input: Input,
|
execute: (input, context) =>
|
||||||
output: Output,
|
Effect.gen(function* () {
|
||||||
execute: (input, context) =>
|
const searchPath = input.path === "undefined" || input.path === "null" ? undefined : input.path
|
||||||
Effect.gen(function* () {
|
const source = { type: "tool" as const, messageID: context.messageID, id: context.id }
|
||||||
const searchPath = input.path === "undefined" || input.path === "null" ? undefined : input.path
|
const target = yield* mutation.resolve({ path: searchPath ?? ".", kind: "directory" })
|
||||||
const source = { type: "tool" as const, messageID: context.messageID, id: context.id }
|
const external = target.externalDirectory
|
||||||
const target = yield* mutation.resolve({ path: searchPath ?? ".", kind: "directory" })
|
if (external)
|
||||||
const external = target.externalDirectory
|
|
||||||
if (external)
|
|
||||||
yield* permission.assert({
|
|
||||||
...LocationMutation.externalDirectoryPermission(external),
|
|
||||||
sessionID: context.sessionID,
|
|
||||||
agent: context.agent,
|
|
||||||
source,
|
|
||||||
})
|
|
||||||
yield* permission.assert({
|
yield* permission.assert({
|
||||||
action: name,
|
...LocationMutation.externalDirectoryPermission(external),
|
||||||
resources: [input.pattern],
|
|
||||||
save: ["*"],
|
|
||||||
metadata: {
|
|
||||||
root: searchPath ?? ".",
|
|
||||||
path: searchPath,
|
|
||||||
limit: input.limit,
|
|
||||||
},
|
|
||||||
sessionID: context.sessionID,
|
sessionID: context.sessionID,
|
||||||
agent: context.agent,
|
agent: context.agent,
|
||||||
source,
|
source,
|
||||||
})
|
})
|
||||||
const info = yield* fs
|
yield* permission.assert({
|
||||||
.stat(target.canonical)
|
action: name,
|
||||||
.pipe(
|
resources: [input.pattern],
|
||||||
Effect.catchReason("PlatformError", "NotFound", () =>
|
save: ["*"],
|
||||||
Effect.fail(new ToolFailure({ message: `Search path does not exist: ${searchPath ?? "."}` })),
|
metadata: {
|
||||||
),
|
root: searchPath ?? ".",
|
||||||
)
|
path: searchPath,
|
||||||
if (info.type !== "Directory")
|
limit: input.limit,
|
||||||
return yield* Effect.fail(
|
},
|
||||||
new ToolFailure({ message: `Search path is not a directory: ${searchPath ?? "."}` }),
|
sessionID: context.sessionID,
|
||||||
)
|
agent: context.agent,
|
||||||
const root = path.resolve(location.directory, searchPath ?? ".")
|
source,
|
||||||
const limit = input.limit ?? FileSystem.DEFAULT_SEARCH_LIMIT
|
})
|
||||||
const entries = yield* ripgrep
|
const info = yield* fs
|
||||||
.glob({
|
.stat(target.absolute)
|
||||||
cwd: target.canonical,
|
.pipe(
|
||||||
pattern: input.pattern,
|
Effect.catchReason("PlatformError", "NotFound", () =>
|
||||||
limit: limit + 1,
|
Effect.fail(new ToolFailure({ message: `Search path does not exist: ${searchPath ?? "."}` })),
|
||||||
})
|
),
|
||||||
.pipe(
|
)
|
||||||
Effect.timeoutOrElse({
|
if (info.type !== "Directory")
|
||||||
duration: FileSystem.DEFAULT_SEARCH_TIMEOUT_MS,
|
return yield* Effect.fail(
|
||||||
orElse: () =>
|
new ToolFailure({ message: `Search path is not a directory: ${searchPath ?? "."}` }),
|
||||||
Effect.fail(
|
)
|
||||||
new ToolFailure({
|
const root = path.resolve(location.directory, searchPath ?? ".")
|
||||||
message: `Search timed out after ${FileSystem.DEFAULT_SEARCH_TIMEOUT_MS / 1_000} seconds. Consider using a more specific path or pattern.`,
|
const limit = input.limit ?? FileSystem.DEFAULT_SEARCH_LIMIT
|
||||||
}),
|
const entries = yield* ripgrep
|
||||||
),
|
.glob({
|
||||||
}),
|
cwd: target.absolute,
|
||||||
Effect.map((result) =>
|
pattern: input.pattern,
|
||||||
result.map((entry) =>
|
limit: limit + 1,
|
||||||
FileSystem.Entry.make({
|
})
|
||||||
...entry,
|
.pipe(
|
||||||
path: RelativePath.make(path.relative(location.directory, path.resolve(root, entry.path))),
|
Effect.timeoutOrElse({
|
||||||
|
duration: FileSystem.DEFAULT_SEARCH_TIMEOUT_MS,
|
||||||
|
orElse: () =>
|
||||||
|
Effect.fail(
|
||||||
|
new ToolFailure({
|
||||||
|
message: `Search timed out after ${FileSystem.DEFAULT_SEARCH_TIMEOUT_MS / 1_000} seconds. Consider using a more specific path or pattern.`,
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
|
}),
|
||||||
|
Effect.map((result) =>
|
||||||
|
result.map((entry) =>
|
||||||
|
FileSystem.Entry.make({
|
||||||
|
...entry,
|
||||||
|
path: RelativePath.make(path.relative(location.directory, path.resolve(root, entry.path))),
|
||||||
|
}),
|
||||||
),
|
),
|
||||||
)
|
|
||||||
return { entries: entries.slice(0, limit), truncated: entries.length > limit }
|
|
||||||
}).pipe(
|
|
||||||
Effect.map((result) => ({
|
|
||||||
output: result.entries,
|
|
||||||
content: toModelContent(
|
|
||||||
result.entries.map((entry) => ({ ...entry, path: path.resolve(location.directory, entry.path) })),
|
|
||||||
result.truncated,
|
|
||||||
),
|
),
|
||||||
metadata: { count: result.entries.length, truncated: result.truncated },
|
)
|
||||||
})),
|
return { entries: entries.slice(0, limit), truncated: entries.length > limit }
|
||||||
Effect.mapError((error) =>
|
}).pipe(
|
||||||
error instanceof ToolFailure
|
Effect.map((result) => ({
|
||||||
? error
|
output: result.entries,
|
||||||
: new ToolFailure({ message: `Unable to find files matching ${input.pattern}`, error }),
|
content: toModelContent(
|
||||||
|
result.entries.map((entry) => ({ ...entry, path: path.resolve(location.directory, entry.path) })),
|
||||||
|
result.truncated,
|
||||||
),
|
),
|
||||||
|
metadata: { count: result.entries.length, truncated: result.truncated },
|
||||||
|
})),
|
||||||
|
Effect.mapError((error) =>
|
||||||
|
error instanceof ToolFailure
|
||||||
|
? error
|
||||||
|
: new ToolFailure({ message: `Unable to find files matching ${input.pattern}`, error }),
|
||||||
),
|
),
|
||||||
}),
|
),
|
||||||
),
|
}),
|
||||||
)
|
)
|
||||||
.pipe(Effect.orDie)
|
.pipe(Effect.orDie)
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ type Prepared =
|
|||||||
})
|
})
|
||||||
|
|
||||||
interface Target {
|
interface Target {
|
||||||
readonly canonical: string
|
readonly absolute: string
|
||||||
readonly resource: string
|
readonly resource: string
|
||||||
readonly externalDirectory?: {
|
readonly externalDirectory?: {
|
||||||
readonly directory: string
|
readonly directory: string
|
||||||
@@ -76,267 +76,256 @@ export const Plugin = {
|
|||||||
|
|
||||||
yield* ctx.tool
|
yield* ctx.tool
|
||||||
.transform((draft) =>
|
.transform((draft) =>
|
||||||
draft.add(
|
draft.add({
|
||||||
({
|
name,
|
||||||
name,
|
options: { codemode: false, permission: "edit" },
|
||||||
options: { codemode: false, permission: "edit" },
|
description: DESCRIPTION,
|
||||||
description: DESCRIPTION,
|
input: Input,
|
||||||
input: Input,
|
output: Output,
|
||||||
output: Output,
|
execute: (input, context) => {
|
||||||
execute: (input, context) => {
|
const applied: Array<typeof Applied.Type> = []
|
||||||
const applied: Array<typeof Applied.Type> = []
|
const fail = (operation: string, error: unknown) => {
|
||||||
const fail = (operation: string, error: unknown) => {
|
const completed = applied.map((item) => item.resource).join(", ")
|
||||||
const completed = applied.map((item) => item.resource).join(", ")
|
return new ToolFailure({
|
||||||
return new ToolFailure({
|
message: `${operation}: ${errorMessage(error)}${completed ? `. Completed before failure: ${completed}` : ""}`,
|
||||||
message: `${operation}: ${errorMessage(error)}${completed ? `. Completed before failure: ${completed}` : ""}`,
|
})
|
||||||
})
|
}
|
||||||
}
|
return Effect.gen(function* () {
|
||||||
return Effect.gen(function* () {
|
const source = {
|
||||||
const source = {
|
type: "tool" as const,
|
||||||
type: "tool" as const,
|
messageID: context.messageID,
|
||||||
messageID: context.messageID,
|
id: context.id,
|
||||||
id: context.id,
|
}
|
||||||
|
if (!input.patchText) return yield* new ToolFailure({ message: "patchText is required" })
|
||||||
|
const hunks = yield* Effect.fromResult(Patch.parse(input.patchText)).pipe(
|
||||||
|
Effect.mapError((error) => new ToolFailure({ message: `patch verification failed: ${error.message}` })),
|
||||||
|
)
|
||||||
|
if (hunks.length === 0) {
|
||||||
|
return yield* new ToolFailure({ message: "patch rejected: empty patch" })
|
||||||
|
}
|
||||||
|
const prepared: Prepared[] = []
|
||||||
|
const targets: Target[] = []
|
||||||
|
const updates = new Map<string, string>()
|
||||||
|
for (const hunk of hunks) {
|
||||||
|
yield* Effect.gen(function* () {
|
||||||
|
const target = resolveTarget(location, hunk.path)
|
||||||
|
targets.push(target)
|
||||||
|
if (target.externalDirectory) {
|
||||||
|
yield* permission.assert({
|
||||||
|
action: "external_directory",
|
||||||
|
resources: [target.externalDirectory.resource],
|
||||||
|
save: [target.externalDirectory.resource],
|
||||||
|
metadata: {
|
||||||
|
filepath: target.absolute,
|
||||||
|
parentDir: target.externalDirectory.directory,
|
||||||
|
},
|
||||||
|
sessionID: context.sessionID,
|
||||||
|
agent: context.agent,
|
||||||
|
source,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
if (!input.patchText) return yield* new ToolFailure({ message: "patchText is required" })
|
if (hunk.type === "add") {
|
||||||
const hunks = yield* Effect.fromResult(Patch.parse(input.patchText)).pipe(
|
prepared.push({
|
||||||
Effect.mapError(
|
...hunk,
|
||||||
(error) => new ToolFailure({ message: `patch verification failed: ${error.message}` }),
|
target,
|
||||||
),
|
before: "",
|
||||||
)
|
after: Bom.split(
|
||||||
if (hunks.length === 0) {
|
hunk.contents.endsWith("\n") || hunk.contents === "" ? hunk.contents : `${hunk.contents}\n`,
|
||||||
return yield* new ToolFailure({ message: "patch rejected: empty patch" })
|
).text,
|
||||||
|
})
|
||||||
|
return
|
||||||
}
|
}
|
||||||
const prepared: Prepared[] = []
|
if (hunk.type === "delete") {
|
||||||
const targets: Target[] = []
|
const content = yield* Bom.readFile(fs, target.absolute).pipe(
|
||||||
const updates = new Map<string, string>()
|
Effect.mapError(
|
||||||
for (const hunk of hunks) {
|
(error) =>
|
||||||
yield* Effect.gen(function* () {
|
new ToolFailure({
|
||||||
const target = resolveTarget(location, hunk.path)
|
message: `patch verification failed: Failed to delete ${target.resource}: ${errorMessage(error)}`,
|
||||||
targets.push(target)
|
}),
|
||||||
if (target.externalDirectory) {
|
|
||||||
yield* permission.assert({
|
|
||||||
action: "external_directory",
|
|
||||||
resources: [target.externalDirectory.resource],
|
|
||||||
save: [target.externalDirectory.resource],
|
|
||||||
metadata: {
|
|
||||||
filepath: target.canonical,
|
|
||||||
parentDir: target.externalDirectory.directory,
|
|
||||||
},
|
|
||||||
sessionID: context.sessionID,
|
|
||||||
agent: context.agent,
|
|
||||||
source,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
if (hunk.type === "add") {
|
|
||||||
prepared.push({
|
|
||||||
...hunk,
|
|
||||||
target,
|
|
||||||
before: "",
|
|
||||||
after: Bom.split(
|
|
||||||
hunk.contents.endsWith("\n") || hunk.contents === ""
|
|
||||||
? hunk.contents
|
|
||||||
: `${hunk.contents}\n`,
|
|
||||||
).text,
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (hunk.type === "delete") {
|
|
||||||
const content = yield* Bom.readFile(fs, target.canonical).pipe(
|
|
||||||
Effect.mapError(
|
|
||||||
(error) =>
|
|
||||||
new ToolFailure({
|
|
||||||
message: `patch verification failed: Failed to delete ${target.resource}: ${errorMessage(error)}`,
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
prepared.push({ ...hunk, target, before: content.text, after: "" })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const previous = updates.get(target.canonical)
|
|
||||||
const original =
|
|
||||||
previous ??
|
|
||||||
(yield* Effect.gen(function* () {
|
|
||||||
const stats = yield* fs.stat(target.canonical).pipe(
|
|
||||||
Effect.mapError(
|
|
||||||
(error) =>
|
|
||||||
new ToolFailure({
|
|
||||||
message: `patch verification failed: Failed to read file to update ${target.canonical}: ${errorMessage(error)}`,
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
if (stats.type === "Directory") {
|
|
||||||
return yield* new ToolFailure({
|
|
||||||
message: `patch verification failed: Failed to read file to update ${target.canonical}: path is a directory`,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
const content = yield* Bom.readFile(fs, target.canonical).pipe(
|
|
||||||
Effect.mapError(
|
|
||||||
(error) =>
|
|
||||||
new ToolFailure({
|
|
||||||
message: `patch verification failed: Failed to read file to update ${target.canonical}: ${errorMessage(error)}`,
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
return Bom.join(content.text, content.bom)
|
|
||||||
}))
|
|
||||||
const before = Bom.split(original).text
|
|
||||||
const update = yield* Effect.try({
|
|
||||||
try: () => Patch.derive(hunk.path, hunk.chunks, original),
|
|
||||||
catch: (error) =>
|
|
||||||
new ToolFailure({ message: `patch verification failed: ${errorMessage(error)}` }),
|
|
||||||
})
|
|
||||||
const moveTarget = hunk.movePath ? resolveTarget(location, hunk.movePath) : undefined
|
|
||||||
if (moveTarget) targets.push(moveTarget)
|
|
||||||
if (moveTarget?.externalDirectory) {
|
|
||||||
yield* permission.assert({
|
|
||||||
action: "external_directory",
|
|
||||||
resources: [moveTarget.externalDirectory.resource],
|
|
||||||
save: [moveTarget.externalDirectory.resource],
|
|
||||||
metadata: {
|
|
||||||
filepath: moveTarget.canonical,
|
|
||||||
parentDir: moveTarget.externalDirectory.directory,
|
|
||||||
},
|
|
||||||
sessionID: context.sessionID,
|
|
||||||
agent: context.agent,
|
|
||||||
source,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
prepared.push({
|
|
||||||
...hunk,
|
|
||||||
target,
|
|
||||||
content: Patch.joinBom(update.content, update.bom),
|
|
||||||
before,
|
|
||||||
after: update.content,
|
|
||||||
moveTarget,
|
|
||||||
})
|
|
||||||
if (!moveTarget) updates.set(target.canonical, Patch.joinBom(update.content, update.bom))
|
|
||||||
}).pipe(
|
|
||||||
Effect.mapError((error) =>
|
|
||||||
error instanceof ToolFailure
|
|
||||||
? error
|
|
||||||
: new ToolFailure({ message: `Unable to prepare patch at ${hunk.path}`, error }),
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
prepared.push({ ...hunk, target, before: content.text, after: "" })
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
const previous = updates.get(target.absolute)
|
||||||
const patchFiles = prepared.map((change) => patchFile(change))
|
const original =
|
||||||
yield* permission.assert({
|
previous ??
|
||||||
action: "edit",
|
(yield* Effect.gen(function* () {
|
||||||
resources: [...new Set(targets.map((target) => target.resource))],
|
const stats = yield* fs.stat(target.absolute).pipe(
|
||||||
save: ["*"],
|
Effect.mapError(
|
||||||
metadata: {
|
(error) =>
|
||||||
filepath: targets.map((target) => target.resource).join(", "),
|
new ToolFailure({
|
||||||
diff: patchFiles.map((file) => `${file.patch}\n`).join(""),
|
message: `patch verification failed: Failed to read file to update ${target.absolute}: ${errorMessage(error)}`,
|
||||||
files: patchFiles,
|
}),
|
||||||
},
|
),
|
||||||
sessionID: context.sessionID,
|
)
|
||||||
agent: context.agent,
|
if (stats.type === "Directory") {
|
||||||
source,
|
return yield* new ToolFailure({
|
||||||
})
|
message: `patch verification failed: Failed to read file to update ${target.absolute}: path is a directory`,
|
||||||
|
|
||||||
yield* Effect.forEach(
|
|
||||||
prepared,
|
|
||||||
(change) =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
if (change.type === "add") {
|
|
||||||
yield* fs
|
|
||||||
.writeWithDirs(
|
|
||||||
change.target.canonical,
|
|
||||||
change.contents.endsWith("\n") || change.contents === ""
|
|
||||||
? change.contents
|
|
||||||
: `${change.contents}\n`,
|
|
||||||
)
|
|
||||||
.pipe(
|
|
||||||
Effect.mapError((error) => fail(`Failed to write ${change.target.resource}`, error)),
|
|
||||||
)
|
|
||||||
applied.push({
|
|
||||||
type: change.type,
|
|
||||||
resource: change.target.resource,
|
|
||||||
target: change.target.canonical,
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (change.type === "delete") {
|
|
||||||
yield* fs
|
|
||||||
.remove(change.target.canonical)
|
|
||||||
.pipe(
|
|
||||||
Effect.mapError((error) => fail(`Failed to delete ${change.target.resource}`, error)),
|
|
||||||
)
|
|
||||||
applied.push({
|
|
||||||
type: change.type,
|
|
||||||
resource: change.target.resource,
|
|
||||||
target: change.target.canonical,
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (change.moveTarget) {
|
|
||||||
const moveTarget = change.moveTarget
|
|
||||||
yield* fs
|
|
||||||
.writeWithDirs(moveTarget.canonical, change.content)
|
|
||||||
.pipe(Effect.mapError((error) => fail(`Failed to write ${moveTarget.resource}`, error)))
|
|
||||||
yield* fs.remove(change.target.canonical).pipe(
|
|
||||||
Effect.mapError((error) =>
|
|
||||||
fail(`Wrote ${moveTarget.resource} but failed to remove ${change.target.resource}`, error),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
applied.push({
|
|
||||||
type: change.type,
|
|
||||||
resource: change.moveTarget.resource,
|
|
||||||
target: change.moveTarget.canonical,
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
yield* fs
|
|
||||||
.writeWithDirs(change.target.canonical, change.content)
|
|
||||||
.pipe(Effect.mapError((error) => fail(`Failed to write ${change.target.resource}`, error)))
|
|
||||||
applied.push({
|
|
||||||
type: change.type,
|
|
||||||
resource: change.target.resource,
|
|
||||||
target: change.target.canonical,
|
|
||||||
})
|
})
|
||||||
}),
|
}
|
||||||
{ discard: true },
|
const content = yield* Bom.readFile(fs, target.absolute).pipe(
|
||||||
)
|
Effect.mapError(
|
||||||
const formatted = new Map<string, string>()
|
(error) =>
|
||||||
yield* Effect.forEach(
|
new ToolFailure({
|
||||||
[...new Set(applied.filter((item) => item.type !== "delete").map((item) => item.target))],
|
message: `patch verification failed: Failed to read file to update ${target.absolute}: ${errorMessage(error)}`,
|
||||||
(target) =>
|
}),
|
||||||
Effect.gen(function* () {
|
),
|
||||||
const current = yield* Bom.readFile(fs, target).pipe(
|
)
|
||||||
Effect.mapError((error) => fail(`Failed to read ${target}`, error)),
|
return Bom.join(content.text, content.bom)
|
||||||
)
|
}))
|
||||||
formatted.set(
|
const before = Bom.split(original).text
|
||||||
target,
|
const update = yield* Effect.try({
|
||||||
(yield* formatter.file(target))
|
try: () => Patch.derive(hunk.path, hunk.chunks, original),
|
||||||
? yield* Bom.syncFile(fs, target, current.bom).pipe(
|
catch: (error) => new ToolFailure({ message: `patch verification failed: ${errorMessage(error)}` }),
|
||||||
Effect.mapError((error) => fail(`Failed to sync ${target}`, error)),
|
|
||||||
)
|
|
||||||
: current.text,
|
|
||||||
)
|
|
||||||
}),
|
|
||||||
{ discard: true },
|
|
||||||
)
|
|
||||||
const files = yield* Effect.forEach(prepared, (change) => {
|
|
||||||
if (change.type === "delete") return Effect.succeed(patchFile(change))
|
|
||||||
const target = change.type === "update" && change.moveTarget ? change.moveTarget : change.target
|
|
||||||
return Effect.succeed(patchFile(change, formatted.get(target.canonical)))
|
|
||||||
})
|
})
|
||||||
return { applied, files }
|
const moveTarget = hunk.movePath ? resolveTarget(location, hunk.movePath) : undefined
|
||||||
|
if (moveTarget) targets.push(moveTarget)
|
||||||
|
if (moveTarget?.externalDirectory) {
|
||||||
|
yield* permission.assert({
|
||||||
|
action: "external_directory",
|
||||||
|
resources: [moveTarget.externalDirectory.resource],
|
||||||
|
save: [moveTarget.externalDirectory.resource],
|
||||||
|
metadata: {
|
||||||
|
filepath: moveTarget.absolute,
|
||||||
|
parentDir: moveTarget.externalDirectory.directory,
|
||||||
|
},
|
||||||
|
sessionID: context.sessionID,
|
||||||
|
agent: context.agent,
|
||||||
|
source,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
prepared.push({
|
||||||
|
...hunk,
|
||||||
|
target,
|
||||||
|
content: Patch.joinBom(update.content, update.bom),
|
||||||
|
before,
|
||||||
|
after: update.content,
|
||||||
|
moveTarget,
|
||||||
|
})
|
||||||
|
if (!moveTarget) updates.set(target.absolute, Patch.joinBom(update.content, update.bom))
|
||||||
}).pipe(
|
}).pipe(
|
||||||
Effect.map((output) => ({
|
|
||||||
output,
|
|
||||||
content: toModelOutput(output),
|
|
||||||
metadata: { files: output.files },
|
|
||||||
})),
|
|
||||||
Effect.mapError((error) =>
|
Effect.mapError((error) =>
|
||||||
error instanceof ToolFailure
|
error instanceof ToolFailure
|
||||||
? error
|
? error
|
||||||
: new ToolFailure({ message: "Unable to apply patch", error }),
|
: new ToolFailure({ message: `Unable to prepare patch at ${hunk.path}`, error }),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
},
|
}
|
||||||
}),
|
|
||||||
),
|
const patchFiles = prepared.map((change) => patchFile(change))
|
||||||
|
yield* permission.assert({
|
||||||
|
action: "edit",
|
||||||
|
resources: [...new Set(targets.map((target) => target.resource))],
|
||||||
|
save: ["*"],
|
||||||
|
metadata: {
|
||||||
|
filepath: targets.map((target) => target.resource).join(", "),
|
||||||
|
diff: patchFiles.map((file) => `${file.patch}\n`).join(""),
|
||||||
|
files: patchFiles,
|
||||||
|
},
|
||||||
|
sessionID: context.sessionID,
|
||||||
|
agent: context.agent,
|
||||||
|
source,
|
||||||
|
})
|
||||||
|
|
||||||
|
yield* Effect.forEach(
|
||||||
|
prepared,
|
||||||
|
(change) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
if (change.type === "add") {
|
||||||
|
yield* fs
|
||||||
|
.writeWithDirs(
|
||||||
|
change.target.absolute,
|
||||||
|
change.contents.endsWith("\n") || change.contents === ""
|
||||||
|
? change.contents
|
||||||
|
: `${change.contents}\n`,
|
||||||
|
)
|
||||||
|
.pipe(Effect.mapError((error) => fail(`Failed to write ${change.target.resource}`, error)))
|
||||||
|
applied.push({
|
||||||
|
type: change.type,
|
||||||
|
resource: change.target.resource,
|
||||||
|
target: change.target.absolute,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (change.type === "delete") {
|
||||||
|
yield* fs
|
||||||
|
.remove(change.target.absolute)
|
||||||
|
.pipe(Effect.mapError((error) => fail(`Failed to delete ${change.target.resource}`, error)))
|
||||||
|
applied.push({
|
||||||
|
type: change.type,
|
||||||
|
resource: change.target.resource,
|
||||||
|
target: change.target.absolute,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (change.moveTarget) {
|
||||||
|
const moveTarget = change.moveTarget
|
||||||
|
yield* fs
|
||||||
|
.writeWithDirs(moveTarget.absolute, change.content)
|
||||||
|
.pipe(Effect.mapError((error) => fail(`Failed to write ${moveTarget.resource}`, error)))
|
||||||
|
yield* fs
|
||||||
|
.remove(change.target.absolute)
|
||||||
|
.pipe(
|
||||||
|
Effect.mapError((error) =>
|
||||||
|
fail(`Wrote ${moveTarget.resource} but failed to remove ${change.target.resource}`, error),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
applied.push({
|
||||||
|
type: change.type,
|
||||||
|
resource: change.moveTarget.resource,
|
||||||
|
target: change.moveTarget.absolute,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
yield* fs
|
||||||
|
.writeWithDirs(change.target.absolute, change.content)
|
||||||
|
.pipe(Effect.mapError((error) => fail(`Failed to write ${change.target.resource}`, error)))
|
||||||
|
applied.push({
|
||||||
|
type: change.type,
|
||||||
|
resource: change.target.resource,
|
||||||
|
target: change.target.absolute,
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
{ discard: true },
|
||||||
|
)
|
||||||
|
const formatted = new Map<string, string>()
|
||||||
|
yield* Effect.forEach(
|
||||||
|
[...new Set(applied.filter((item) => item.type !== "delete").map((item) => item.target))],
|
||||||
|
(target) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const current = yield* Bom.readFile(fs, target).pipe(
|
||||||
|
Effect.mapError((error) => fail(`Failed to read ${target}`, error)),
|
||||||
|
)
|
||||||
|
formatted.set(
|
||||||
|
target,
|
||||||
|
(yield* formatter.file(target))
|
||||||
|
? yield* Bom.syncFile(fs, target, current.bom).pipe(
|
||||||
|
Effect.mapError((error) => fail(`Failed to sync ${target}`, error)),
|
||||||
|
)
|
||||||
|
: current.text,
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
{ discard: true },
|
||||||
|
)
|
||||||
|
const files = yield* Effect.forEach(prepared, (change) => {
|
||||||
|
if (change.type === "delete") return Effect.succeed(patchFile(change))
|
||||||
|
const target = change.type === "update" && change.moveTarget ? change.moveTarget : change.target
|
||||||
|
return Effect.succeed(patchFile(change, formatted.get(target.absolute)))
|
||||||
|
})
|
||||||
|
return { applied, files }
|
||||||
|
}).pipe(
|
||||||
|
Effect.map((output) => ({
|
||||||
|
output,
|
||||||
|
content: toModelOutput(output),
|
||||||
|
metadata: { files: output.files },
|
||||||
|
})),
|
||||||
|
Effect.mapError((error) =>
|
||||||
|
error instanceof ToolFailure ? error : new ToolFailure({ message: "Unable to apply patch", error }),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
}),
|
||||||
)
|
)
|
||||||
.pipe(Effect.orDie)
|
.pipe(Effect.orDie)
|
||||||
|
|
||||||
@@ -365,9 +354,7 @@ function errorMessage(error: unknown) {
|
|||||||
|
|
||||||
function patchFile(change: Prepared, after = change.after): typeof FileDiff.Info.Type {
|
function patchFile(change: Prepared, after = change.after): typeof FileDiff.Info.Type {
|
||||||
const target = (change.type === "update" ? change.moveTarget : undefined)?.resource ?? change.target.resource
|
const target = (change.type === "update" ? change.moveTarget : undefined)?.resource ?? change.target.resource
|
||||||
const patch = trimDiff(
|
const patch = trimDiff(createTwoFilesPatch(change.target.absolute, change.target.absolute, change.before, after))
|
||||||
createTwoFilesPatch(change.target.canonical, change.target.canonical, change.before, after),
|
|
||||||
)
|
|
||||||
const counts =
|
const counts =
|
||||||
change.type === "delete"
|
change.type === "delete"
|
||||||
? { additions: 0, deletions: change.before.split("\n").length }
|
? { additions: 0, deletions: change.before.split("\n").length }
|
||||||
@@ -416,22 +403,22 @@ function trimDiff(diff: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function resolveTarget(location: Location.Interface, value: string): Target {
|
function resolveTarget(location: Location.Interface, value: string): Target {
|
||||||
const canonical =
|
const absolute =
|
||||||
process.platform === "win32"
|
process.platform === "win32"
|
||||||
? FSUtil.normalizePath(path.resolve(location.directory, value))
|
? FSUtil.normalizePath(path.resolve(location.directory, value))
|
||||||
: path.resolve(location.directory, value)
|
: path.resolve(location.directory, value)
|
||||||
const projectRoot = path.parse(location.project.directory).root
|
const projectRoot = path.parse(location.project.directory).root
|
||||||
const external =
|
const external =
|
||||||
!FSUtil.contains(location.directory, canonical) &&
|
!FSUtil.contains(location.directory, absolute) &&
|
||||||
(location.project.directory === projectRoot || !FSUtil.contains(location.project.directory, canonical))
|
(location.project.directory === projectRoot || !FSUtil.contains(location.project.directory, absolute))
|
||||||
const directory = path.dirname(canonical)
|
const directory = path.dirname(absolute)
|
||||||
const resource =
|
const resource =
|
||||||
process.platform === "win32"
|
process.platform === "win32"
|
||||||
? FSUtil.normalizePathPattern(path.join(directory, "*"))
|
? FSUtil.normalizePathPattern(path.join(directory, "*"))
|
||||||
: path.join(directory, "*").replaceAll("\\", "/")
|
: path.join(directory, "*").replaceAll("\\", "/")
|
||||||
return {
|
return {
|
||||||
canonical,
|
absolute,
|
||||||
resource: path.relative(location.project.directory, canonical).replaceAll("\\", "/") || ".",
|
resource: path.relative(location.project.directory, absolute).replaceAll("\\", "/") || ".",
|
||||||
externalDirectory: external ? { directory, resource } : undefined,
|
externalDirectory: external ? { directory, resource } : undefined,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,11 +25,7 @@ const LocationInput = Schema.Struct({
|
|||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
export const Input = LocationInput
|
export const Input = LocationInput
|
||||||
const Output = Schema.Union([
|
const Output = Schema.Union([ReadToolFileSystem.FileContent, ReadToolFileSystem.TextPage, ReadToolFileSystem.ListPage])
|
||||||
ReadToolFileSystem.FileContent,
|
|
||||||
ReadToolFileSystem.TextPage,
|
|
||||||
ReadToolFileSystem.ListPage,
|
|
||||||
])
|
|
||||||
|
|
||||||
export const Plugin = {
|
export const Plugin = {
|
||||||
id: "opencode.tool.read",
|
id: "opencode.tool.read",
|
||||||
@@ -43,105 +39,103 @@ export const Plugin = {
|
|||||||
|
|
||||||
yield* ctx.tool
|
yield* ctx.tool
|
||||||
.transform((draft) =>
|
.transform((draft) =>
|
||||||
draft.add(
|
draft.add({
|
||||||
({
|
name,
|
||||||
name,
|
options: { codemode: false },
|
||||||
options: { codemode: false },
|
description:
|
||||||
description:
|
"Read the contents of a file or directory. Supports text files, images, and PDFs. Images and PDFs are presented directly to the model. Each text line is prefixed by its 1-based line number as <line>: <content>. The prefix is for reference and is not part of the file content. Directory entries are returned one per line. Use offset and limit to read large files or directories in sections. Prefer one larger read over many small slices, and use grep to find specific content in large files.",
|
||||||
"Read the contents of a file or directory. Supports text files, images, and PDFs. Images and PDFs are presented directly to the model. Each text line is prefixed by its 1-based line number as <line>: <content>. The prefix is for reference and is not part of the file content. Directory entries are returned one per line. Use offset and limit to read large files or directories in sections. Prefer one larger read over many small slices, and use grep to find specific content in large files.",
|
input: Input,
|
||||||
input: Input,
|
output: Output,
|
||||||
output: Output,
|
execute: (input, context) => {
|
||||||
execute: (input, context) => {
|
return Effect.gen(function* () {
|
||||||
return Effect.gen(function* () {
|
const source = {
|
||||||
const source = {
|
type: "tool" as const,
|
||||||
type: "tool" as const,
|
messageID: context.messageID,
|
||||||
messageID: context.messageID,
|
id: context.id,
|
||||||
id: context.id,
|
}
|
||||||
}
|
const target = yield* mutation.resolve({ path: input.path })
|
||||||
const target = yield* mutation.resolve({ path: input.path, kind: "directory" })
|
const external = target.externalDirectory
|
||||||
const external = target.externalDirectory
|
if (external)
|
||||||
if (external)
|
|
||||||
yield* permission.assert({
|
|
||||||
...LocationMutation.externalDirectoryPermission(external),
|
|
||||||
sessionID: context.sessionID,
|
|
||||||
agent: context.agent,
|
|
||||||
source,
|
|
||||||
})
|
|
||||||
const resource = target.resource
|
|
||||||
const absolute = AbsolutePath.make(target.canonical)
|
|
||||||
yield* permission.assert({
|
yield* permission.assert({
|
||||||
action: name,
|
...LocationMutation.externalDirectoryPermission(external),
|
||||||
resources: [resource],
|
|
||||||
save: ["*"],
|
|
||||||
sessionID: context.sessionID,
|
sessionID: context.sessionID,
|
||||||
agent: context.agent,
|
agent: context.agent,
|
||||||
source,
|
source,
|
||||||
})
|
})
|
||||||
const type = yield* reader.inspect(absolute).pipe(
|
const resource = target.resource
|
||||||
Effect.catchReason("PlatformError", "NotFound", () => missing(input.path, target.canonical)),
|
const absolute = AbsolutePath.make(target.absolute)
|
||||||
|
yield* permission.assert({
|
||||||
|
action: name,
|
||||||
|
resources: [resource],
|
||||||
|
save: ["*"],
|
||||||
|
sessionID: context.sessionID,
|
||||||
|
agent: context.agent,
|
||||||
|
source,
|
||||||
|
})
|
||||||
|
const type = yield* reader
|
||||||
|
.inspect(absolute)
|
||||||
|
.pipe(Effect.catchReason("PlatformError", "NotFound", () => missing(input.path, target.absolute)))
|
||||||
|
const content =
|
||||||
|
type === "directory"
|
||||||
|
? yield* reader.list(absolute, { offset: input.offset, limit: input.limit })
|
||||||
|
: yield* reader.read(absolute, resource, {
|
||||||
|
offset: input.offset,
|
||||||
|
limit: input.limit,
|
||||||
|
})
|
||||||
|
// After a successful read, discover nearby AGENTS.md walking up to the Location
|
||||||
|
// root exclusive and inject them as durable synthetic instructions. For a
|
||||||
|
// directory listing the walk starts at the directory itself (so its own AGENTS.md
|
||||||
|
// is discovered); for a file it starts at the file's dirname. External reads are
|
||||||
|
// skipped, and discovery failures never fail the read.
|
||||||
|
yield* Effect.gen(function* () {
|
||||||
|
if (target.externalDirectory !== undefined) return
|
||||||
|
const resolved = yield* fs.resolve(target.absolute)
|
||||||
|
const root = yield* fs.resolve(location.directory)
|
||||||
|
// up() searches its stop directory, so the Location-root AGENTS.md (already
|
||||||
|
// supplied by core initial instructions) is dropped by the dirname filter.
|
||||||
|
const discovered = yield* fs.up({
|
||||||
|
targets: [FILENAME],
|
||||||
|
start: type === "directory" ? resolved : dirname(resolved),
|
||||||
|
stop: root,
|
||||||
|
})
|
||||||
|
const candidates = (yield* Effect.forEach(discovered, fs.resolve)).filter(
|
||||||
|
(file) => dirname(file) !== root,
|
||||||
)
|
)
|
||||||
const content =
|
if (candidates.length === 0) return
|
||||||
type === "directory"
|
yield* sessionInstructions.load({ sessionID: context.sessionID, paths: candidates })
|
||||||
? yield* reader.list(absolute, { offset: input.offset, limit: input.limit })
|
|
||||||
: yield* reader.read(absolute, resource, {
|
|
||||||
offset: input.offset,
|
|
||||||
limit: input.limit,
|
|
||||||
})
|
|
||||||
// After a successful read, discover nearby AGENTS.md walking up to the Location
|
|
||||||
// root exclusive and inject them as durable synthetic instructions. For a
|
|
||||||
// directory listing the walk starts at the directory itself (so its own AGENTS.md
|
|
||||||
// is discovered); for a file it starts at the file's dirname. External reads are
|
|
||||||
// skipped, and discovery failures never fail the read.
|
|
||||||
yield* Effect.gen(function* () {
|
|
||||||
if (target.externalDirectory !== undefined) return
|
|
||||||
const resolved = yield* fs.resolve(target.canonical)
|
|
||||||
const root = yield* fs.resolve(location.directory)
|
|
||||||
// up() searches its stop directory, so the Location-root AGENTS.md (already
|
|
||||||
// supplied by core initial instructions) is dropped by the dirname filter.
|
|
||||||
const discovered = yield* fs.up({
|
|
||||||
targets: [FILENAME],
|
|
||||||
start: type === "directory" ? resolved : dirname(resolved),
|
|
||||||
stop: root,
|
|
||||||
})
|
|
||||||
const candidates = (yield* Effect.forEach(discovered, fs.resolve)).filter(
|
|
||||||
(file) => dirname(file) !== root,
|
|
||||||
)
|
|
||||||
if (candidates.length === 0) return
|
|
||||||
yield* sessionInstructions.load({ sessionID: context.sessionID, paths: candidates })
|
|
||||||
}).pipe(
|
|
||||||
Effect.catch(() => Effect.void),
|
|
||||||
Effect.catchDefect(() => Effect.void),
|
|
||||||
)
|
|
||||||
if (content.type === "file" && content.encoding === "base64" && !SUPPORTED_MEDIA_MIMES.has(content.mime))
|
|
||||||
return yield* Effect.fail(new ReadToolFileSystem.BinaryFileError({ resource }))
|
|
||||||
return content
|
|
||||||
}).pipe(
|
}).pipe(
|
||||||
Effect.map((output) => ({
|
Effect.catch(() => Effect.void),
|
||||||
output,
|
Effect.catchDefect(() => Effect.void),
|
||||||
content: toModelContent(input.path, input.offset, output),
|
|
||||||
})),
|
|
||||||
Effect.mapError((error) => {
|
|
||||||
if (error instanceof ToolFailure) return error
|
|
||||||
const message =
|
|
||||||
error instanceof ReadToolFileSystem.BinaryFileError ||
|
|
||||||
error instanceof ReadToolFileSystem.MediaIngestLimitError ||
|
|
||||||
error instanceof ReadToolFileSystem.MalformedUtf8Error ||
|
|
||||||
error instanceof ReadToolFileSystem.OffsetOutOfRangeError ||
|
|
||||||
error instanceof ReadToolFileSystem.PathKindError
|
|
||||||
? error.message
|
|
||||||
: `Unable to read ${input.path}`
|
|
||||||
return new ToolFailure({ message, error })
|
|
||||||
}),
|
|
||||||
)
|
)
|
||||||
},
|
if (content.type === "file" && content.encoding === "base64" && !SUPPORTED_MEDIA_MIMES.has(content.mime))
|
||||||
}),
|
return yield* Effect.fail(new ReadToolFileSystem.BinaryFileError({ resource }))
|
||||||
),
|
return content
|
||||||
|
}).pipe(
|
||||||
|
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
|
||||||
|
const message =
|
||||||
|
error instanceof ReadToolFileSystem.BinaryFileError ||
|
||||||
|
error instanceof ReadToolFileSystem.MediaIngestLimitError ||
|
||||||
|
error instanceof ReadToolFileSystem.OffsetOutOfRangeError ||
|
||||||
|
error instanceof ReadToolFileSystem.PathKindError
|
||||||
|
? error.message
|
||||||
|
: `Unable to read ${input.path}`
|
||||||
|
return new ToolFailure({ message, error })
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
}),
|
||||||
)
|
)
|
||||||
.pipe(Effect.orDie)
|
.pipe(Effect.orDie)
|
||||||
|
|
||||||
const missing = Effect.fn("ReadTool.missing")(function* (input: string, canonical: string) {
|
const missing = Effect.fn("ReadTool.missing")(function* (input: string, absolute: string) {
|
||||||
const base = basename(input).toLowerCase()
|
const base = basename(input).toLowerCase()
|
||||||
const suggestions = yield* fs.readDirectory(dirname(canonical)).pipe(
|
const suggestions = yield* fs.readDirectory(dirname(absolute)).pipe(
|
||||||
Effect.map((entries) =>
|
Effect.map((entries) =>
|
||||||
entries
|
entries
|
||||||
.filter((entry) => {
|
.filter((entry) => {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import type { Content } from "@opencode-ai/schema/tool"
|
|||||||
import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin"
|
import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin"
|
||||||
import { Deferred, Effect, Schema, Scope } from "effect"
|
import { Deferred, Effect, Schema, Scope } from "effect"
|
||||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||||
|
import { Config } from "../../config"
|
||||||
import { LocationMutation } from "../../location-mutation"
|
import { LocationMutation } from "../../location-mutation"
|
||||||
import { Permission } from "../../permission"
|
import { Permission } from "../../permission"
|
||||||
import { PluginRuntime } from "../../plugin/runtime"
|
import { PluginRuntime } from "../../plugin/runtime"
|
||||||
@@ -13,10 +14,10 @@ import { NonNegativeInt } from "../../schema"
|
|||||||
import { SessionSchema } from "../../session/schema"
|
import { SessionSchema } from "../../session/schema"
|
||||||
import { Shell } from "../../shell"
|
import { Shell } from "../../shell"
|
||||||
import { ShellParse } from "../../shell/parse"
|
import { ShellParse } from "../../shell/parse"
|
||||||
|
import { ToolOutput } from "../../tool-output"
|
||||||
|
|
||||||
export const name = "shell"
|
export const name = "shell"
|
||||||
export const DEFAULT_TIMEOUT_MS = 2 * 60 * 1_000
|
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_STARTED = "The command was moved to the background."
|
||||||
const BACKGROUND_INSTRUCTION =
|
const BACKGROUND_INSTRUCTION =
|
||||||
@@ -86,6 +87,7 @@ export const Plugin = {
|
|||||||
const mutation = yield* LocationMutation.Service
|
const mutation = yield* LocationMutation.Service
|
||||||
const shell = yield* Shell.Service
|
const shell = yield* Shell.Service
|
||||||
const permission = yield* Permission.Service
|
const permission = yield* Permission.Service
|
||||||
|
const config = yield* Config.Service
|
||||||
|
|
||||||
const notifyWhenDone = Effect.fn("ShellTool.notifyWhenDone")(function* (
|
const notifyWhenDone = Effect.fn("ShellTool.notifyWhenDone")(function* (
|
||||||
sessionID: SessionSchema.ID,
|
sessionID: SessionSchema.ID,
|
||||||
@@ -122,174 +124,182 @@ export const Plugin = {
|
|||||||
|
|
||||||
yield* ctx.tool
|
yield* ctx.tool
|
||||||
.transform((draft) =>
|
.transform((draft) =>
|
||||||
draft.add(
|
draft.add({
|
||||||
({
|
name,
|
||||||
name,
|
options: { codemode: false },
|
||||||
options: { codemode: false },
|
description: description(),
|
||||||
description: description(),
|
input: Input,
|
||||||
input: Input,
|
output: Output,
|
||||||
output: Output,
|
execute: (input, context) =>
|
||||||
execute: (input, context) =>
|
Effect.gen(function* () {
|
||||||
Effect.gen(function* () {
|
const source = {
|
||||||
const source = {
|
type: "tool" as const,
|
||||||
type: "tool" as const,
|
messageID: context.messageID,
|
||||||
messageID: context.messageID,
|
id: context.id,
|
||||||
id: context.id,
|
}
|
||||||
}
|
const timeout = input.background === true ? (input.timeout ?? 0) : (input.timeout ?? DEFAULT_TIMEOUT_MS)
|
||||||
const timeout = input.background === true ? (input.timeout ?? 0) : (input.timeout ?? DEFAULT_TIMEOUT_MS)
|
let finalTimeout = timeout
|
||||||
let finalTimeout = timeout
|
const info = yield* shell.create(
|
||||||
const info = yield* shell.create(
|
{
|
||||||
{
|
command: input.command,
|
||||||
command: input.command,
|
cwd: input.workdir,
|
||||||
cwd: input.workdir,
|
timeout,
|
||||||
timeout,
|
metadata: { sessionID: context.sessionID },
|
||||||
metadata: { sessionID: context.sessionID },
|
},
|
||||||
},
|
(invocation) =>
|
||||||
(invocation) =>
|
Effect.gen(function* () {
|
||||||
Effect.gen(function* () {
|
const target = yield* mutation.resolve({ path: invocation.cwd, kind: "directory" })
|
||||||
const target = yield* mutation.resolve({ path: invocation.cwd, kind: "directory" })
|
const parsed = yield* ShellParse.scan(invocation.command, invocation.shell, target.absolute)
|
||||||
const parsed = yield* ShellParse.scan(invocation.command, invocation.shell, target.canonical)
|
const directories = yield* Effect.forEach(parsed.directories, (directory) =>
|
||||||
const directories = yield* Effect.forEach(parsed.directories, (directory) =>
|
mutation.resolve({ path: path.resolve(target.absolute, directory), kind: "directory" }),
|
||||||
mutation.resolve({ path: path.resolve(target.canonical, directory), kind: "directory" }),
|
)
|
||||||
|
invocation.cwd = target.absolute
|
||||||
|
finalTimeout = invocation.timeout
|
||||||
|
const external = [target, ...directories]
|
||||||
|
.map((item) => item.externalDirectory)
|
||||||
|
.filter((item) => item !== undefined)
|
||||||
|
.filter(
|
||||||
|
(item, index, items) => items.findIndex((other) => other.resource === item.resource) === index,
|
||||||
)
|
)
|
||||||
invocation.cwd = target.canonical
|
if (external.length > 0)
|
||||||
finalTimeout = invocation.timeout
|
yield* permission.assert({
|
||||||
const external = [target, ...directories]
|
action: "external_directory",
|
||||||
.map((item) => item.externalDirectory)
|
resources: external.map((item) => item.resource),
|
||||||
.filter((item) => item !== undefined)
|
save: external.map((item) => item.save),
|
||||||
.filter((item, index, items) => items.findIndex((other) => other.resource === item.resource) === index)
|
sessionID: context.sessionID,
|
||||||
if (external.length > 0)
|
agent: context.agent,
|
||||||
yield* permission.assert({
|
source,
|
||||||
action: "external_directory",
|
})
|
||||||
resources: external.map((item) => item.resource),
|
if (parsed.commands.length > 0)
|
||||||
save: external.map((item) => item.save),
|
yield* permission.assert({
|
||||||
sessionID: context.sessionID,
|
action: name,
|
||||||
agent: context.agent,
|
resources: parsed.commands.map((command) => command.resource),
|
||||||
source,
|
save: parsed.commands.map((command) => command.save),
|
||||||
})
|
sessionID: context.sessionID,
|
||||||
if (parsed.commands.length > 0)
|
agent: context.agent,
|
||||||
yield* permission.assert({
|
source,
|
||||||
action: name,
|
})
|
||||||
resources: parsed.commands.map((command) => command.resource),
|
const workdir = yield* fsUtil
|
||||||
save: parsed.commands.map((command) => command.save),
|
.stat(target.absolute)
|
||||||
sessionID: context.sessionID,
|
.pipe(
|
||||||
agent: context.agent,
|
|
||||||
source,
|
|
||||||
})
|
|
||||||
const workdir = yield* fsUtil.stat(target.canonical).pipe(
|
|
||||||
Effect.catchReason("PlatformError", "NotFound", () =>
|
Effect.catchReason("PlatformError", "NotFound", () =>
|
||||||
Effect.fail(new Error(`Working directory does not exist: ${target.canonical}`)),
|
Effect.fail(new Error(`Working directory does not exist: ${target.absolute}`)),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
if (workdir.type !== "Directory")
|
if (workdir.type !== "Directory")
|
||||||
return yield* Effect.fail(new Error(`Working directory is not a directory: ${target.canonical}`))
|
return yield* Effect.fail(new Error(`Working directory is not a directory: ${target.absolute}`))
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
yield* context.progress({ shellID: info.id })
|
yield* context.progress({ shellID: info.id })
|
||||||
|
|
||||||
const captureShell = Effect.fn("ShellTool.captureShell")(function* () {
|
const captureShell = Effect.fn("ShellTool.captureShell")(function* () {
|
||||||
const latest = yield* shell.output(info.id, { cursor: Number.MAX_SAFE_INTEGER })
|
const configured = Config.latest(yield* config.entries(), "tool_output")
|
||||||
const truncated = latest.size > MAX_CAPTURE_BYTES
|
const maxLines = configured?.max_lines ?? ToolOutput.MAX_LINES
|
||||||
const page = yield* shell.output(info.id, {
|
const maxBytes = configured?.max_bytes ?? ToolOutput.MAX_BYTES
|
||||||
cursor: Math.max(0, latest.size - MAX_CAPTURE_BYTES),
|
const latest = yield* shell.output(info.id, { cursor: Number.MAX_SAFE_INTEGER })
|
||||||
limit: MAX_CAPTURE_BYTES,
|
const page = yield* shell.output(info.id, {
|
||||||
})
|
cursor: Math.max(0, latest.size - maxBytes),
|
||||||
const notice = truncated ? `\n\n[output truncated; full output saved to: ${info.file}]` : ""
|
limit: maxBytes,
|
||||||
return {
|
|
||||||
output: `${page.output || "(no output)"}${notice}`,
|
|
||||||
truncated,
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
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: `${output || "(no output)"}${notice}`,
|
||||||
|
truncated,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
const settleShell = Effect.fn("ShellTool.settleShell")(function* () {
|
const settleShell = Effect.fn("ShellTool.settleShell")(function* () {
|
||||||
const final = yield* shell.wait(info.id)
|
const final = yield* shell.wait(info.id)
|
||||||
const capture = yield* captureShell()
|
const capture = yield* captureShell()
|
||||||
|
|
||||||
// `exit` is optionalKey in the Output schema; a present-but-undefined key
|
|
||||||
// fails output encoding, so omit it when the process has no exit code.
|
|
||||||
if (final.status === "timeout") {
|
|
||||||
return {
|
|
||||||
...(final.exit !== undefined ? { exit: final.exit } : {}),
|
|
||||||
output: `${capture.output}\n\nCommand exceeded timeout of ${finalTimeout} ms. Retry with a larger timeout if the command is expected to take longer.`,
|
|
||||||
truncated: capture.truncated,
|
|
||||||
timeout: true,
|
|
||||||
status: "completed" as const,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
// `exit` is optionalKey in the Output schema; a present-but-undefined key
|
||||||
|
// fails output encoding, so omit it when the process has no exit code.
|
||||||
|
if (final.status === "timeout") {
|
||||||
return {
|
return {
|
||||||
...(final.exit !== undefined ? { exit: final.exit } : {}),
|
...(final.exit !== undefined ? { exit: final.exit } : {}),
|
||||||
output: capture.output,
|
output: `${capture.output}\n\nCommand exceeded timeout of ${finalTimeout} ms. Retry with a larger timeout if the command is expected to take longer.`,
|
||||||
truncated: capture.truncated,
|
truncated: capture.truncated,
|
||||||
|
timeout: true,
|
||||||
status: "completed" as const,
|
status: "completed" as const,
|
||||||
}
|
}
|
||||||
})
|
|
||||||
|
|
||||||
const settled = yield* Deferred.make<Output>()
|
|
||||||
const run = settleShell().pipe(
|
|
||||||
Effect.tap((output) => Deferred.succeed(settled, output)),
|
|
||||||
Effect.map((output) => output.output),
|
|
||||||
Effect.onInterrupt(() => shell.remove(info.id).pipe(Effect.ignore)),
|
|
||||||
)
|
|
||||||
const job = yield* runtime.job.start({
|
|
||||||
id: context.id,
|
|
||||||
type: name,
|
|
||||||
title: info.command,
|
|
||||||
metadata: { sessionID: context.sessionID, shellID: info.id },
|
|
||||||
run,
|
|
||||||
})
|
|
||||||
|
|
||||||
if (input.background === true) {
|
|
||||||
yield* runtime.job.background(job.id)
|
|
||||||
yield* notifyWhenDone(context.sessionID, context.id, info.command)
|
|
||||||
return {
|
|
||||||
output: BACKGROUND_STARTED,
|
|
||||||
shellID: info.id,
|
|
||||||
truncated: false,
|
|
||||||
status: "running" as const,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = yield* runtime.job.block({ id: job.id, sessionID: context.sessionID }).pipe(
|
return {
|
||||||
Effect.onInterrupt(() => runtime.job.cancel(job.id).pipe(Effect.ignore)),
|
...(final.exit !== undefined ? { exit: final.exit } : {}),
|
||||||
)
|
output: capture.output,
|
||||||
if (result?.type === "backgrounded") {
|
truncated: capture.truncated,
|
||||||
yield* shell.timeout(info.id, 0)
|
status: "completed" as const,
|
||||||
yield* notifyWhenDone(context.sessionID, context.id, info.command)
|
|
||||||
return {
|
|
||||||
output: BACKGROUND_STARTED,
|
|
||||||
shellID: info.id,
|
|
||||||
truncated: false,
|
|
||||||
status: "running" as const,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if (result?.info.status === "error")
|
})
|
||||||
return yield* Effect.fail(new Error(result.info.error ?? "Command failed"))
|
|
||||||
if (result?.info.status === "cancelled") return yield* Effect.fail(new Error("Command cancelled"))
|
|
||||||
|
|
||||||
return yield* Deferred.await(settled)
|
const settled = yield* Deferred.make<Output>()
|
||||||
}).pipe(
|
const run = settleShell().pipe(
|
||||||
Effect.map((output) => {
|
Effect.tap((output) => Deferred.succeed(settled, output)),
|
||||||
const content: Array<Content> = [{ type: "text", text: output.output }]
|
Effect.map((output) => output.output),
|
||||||
const model = modelOutput(output)
|
Effect.onInterrupt(() => shell.remove(info.id).pipe(Effect.ignore)),
|
||||||
if (model) content.push({ type: "text", text: model })
|
)
|
||||||
return {
|
const job = yield* runtime.job.start({
|
||||||
output,
|
id: context.id,
|
||||||
content,
|
type: name,
|
||||||
metadata: {
|
title: info.command,
|
||||||
truncated: output.truncated,
|
metadata: { sessionID: context.sessionID, shellID: info.id },
|
||||||
...("exit" in output && output.exit !== undefined ? { exit: output.exit } : {}),
|
run,
|
||||||
...("shellID" in output && output.shellID !== undefined ? { shellID: output.shellID } : {}),
|
})
|
||||||
...("timeout" in output && output.timeout !== undefined ? { timeout: output.timeout } : {}),
|
|
||||||
},
|
if (input.background === true) {
|
||||||
}
|
yield* runtime.job.background(job.id)
|
||||||
}),
|
yield* notifyWhenDone(context.sessionID, context.id, info.command)
|
||||||
Effect.mapError(
|
return {
|
||||||
(error) => new ToolFailure({ message: `Unable to execute command: ${input.command}`, error }),
|
output: BACKGROUND_STARTED,
|
||||||
),
|
shellID: info.id,
|
||||||
|
truncated: false,
|
||||||
|
status: "running" as const,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = yield* runtime.job
|
||||||
|
.block({ id: job.id, sessionID: context.sessionID })
|
||||||
|
.pipe(Effect.onInterrupt(() => runtime.job.cancel(job.id).pipe(Effect.ignore)))
|
||||||
|
if (result?.type === "backgrounded") {
|
||||||
|
yield* shell.timeout(info.id, 0)
|
||||||
|
yield* notifyWhenDone(context.sessionID, context.id, info.command)
|
||||||
|
return {
|
||||||
|
output: BACKGROUND_STARTED,
|
||||||
|
shellID: info.id,
|
||||||
|
truncated: false,
|
||||||
|
status: "running" as const,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (result?.info.status === "error")
|
||||||
|
return yield* Effect.fail(new Error(result.info.error ?? "Command failed"))
|
||||||
|
if (result?.info.status === "cancelled") return yield* Effect.fail(new Error("Command cancelled"))
|
||||||
|
|
||||||
|
return yield* Deferred.await(settled)
|
||||||
|
}).pipe(
|
||||||
|
Effect.map((output) => {
|
||||||
|
const content: Array<Content> = [{ type: "text", text: output.output }]
|
||||||
|
const model = modelOutput(output)
|
||||||
|
if (model) content.push({ type: "text", text: model })
|
||||||
|
return {
|
||||||
|
output,
|
||||||
|
content,
|
||||||
|
metadata: {
|
||||||
|
truncated: output.truncated,
|
||||||
|
...("exit" in output && output.exit !== undefined ? { exit: output.exit } : {}),
|
||||||
|
...("shellID" in output && output.shellID !== undefined ? { shellID: output.shellID } : {}),
|
||||||
|
...("timeout" in output && output.timeout !== undefined ? { timeout: output.timeout } : {}),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
Effect.mapError(
|
||||||
|
(error) => new ToolFailure({ message: `Unable to execute command: ${input.command}`, error }),
|
||||||
),
|
),
|
||||||
}),
|
),
|
||||||
),
|
}),
|
||||||
)
|
)
|
||||||
.pipe(Effect.orDie)
|
.pipe(Effect.orDie)
|
||||||
|
|
||||||
|
|||||||
@@ -54,59 +54,52 @@ export const Plugin = {
|
|||||||
|
|
||||||
yield* ctx.tool
|
yield* ctx.tool
|
||||||
.transform((draft) =>
|
.transform((draft) =>
|
||||||
draft.add(
|
draft.add({
|
||||||
({
|
name,
|
||||||
name,
|
options: { codemode: false, permission: "edit" },
|
||||||
options: { codemode: false, permission: "edit" },
|
description:
|
||||||
description:
|
"Writes a file to the local filesystem, overwriting if one exists.\n\nMissing parent directories are created automatically.\n\nUse this tool to create new files or overwrite existing files. For partial changes, use the edit tool instead.",
|
||||||
"Writes a file to the local filesystem, overwriting if one exists.\n\nMissing parent directories are created automatically.\n\nUse this tool to create new files or overwrite existing files. For partial changes, use the edit tool instead.",
|
input: Input,
|
||||||
input: Input,
|
output: Output,
|
||||||
output: Output,
|
execute: (input, context) =>
|
||||||
execute: (input, context) =>
|
Effect.gen(function* () {
|
||||||
Effect.gen(function* () {
|
const source = {
|
||||||
const source = {
|
type: "tool" as const,
|
||||||
type: "tool" as const,
|
messageID: context.messageID,
|
||||||
messageID: context.messageID,
|
id: context.id,
|
||||||
id: context.id,
|
}
|
||||||
}
|
const target = yield* mutation.resolve({ path: input.path, kind: "file" })
|
||||||
const target = yield* mutation.resolve({ path: input.path, kind: "file" })
|
const external = target.externalDirectory
|
||||||
const external = target.externalDirectory
|
if (external)
|
||||||
if (external)
|
yield* permission.assert({
|
||||||
yield* permission.assert({
|
...LocationMutation.externalDirectoryPermission(external),
|
||||||
...LocationMutation.externalDirectoryPermission(external),
|
sessionID: context.sessionID,
|
||||||
sessionID: context.sessionID,
|
agent: context.agent,
|
||||||
agent: context.agent,
|
source,
|
||||||
source,
|
})
|
||||||
})
|
const current = yield* Bom.readFile(fs, target.absolute).pipe(
|
||||||
const current = yield* Bom.readFile(fs, target.canonical).pipe(
|
Effect.catchReason("PlatformError", "NotFound", () => Effect.succeed(undefined)),
|
||||||
Effect.catchReason("PlatformError", "NotFound", () => Effect.succeed(undefined)),
|
)
|
||||||
)
|
const next = Bom.split(input.content)
|
||||||
const next = Bom.split(input.content)
|
const preview = fileDiff(target.resource, current?.text ?? "", next.text, current ? "modified" : "added")
|
||||||
const preview = fileDiff(
|
yield* permission.assert({
|
||||||
target.resource,
|
action: "edit",
|
||||||
current?.text ?? "",
|
resources: [target.resource],
|
||||||
next.text,
|
save: ["*"],
|
||||||
current ? "modified" : "added",
|
metadata: { files: [preview] },
|
||||||
)
|
sessionID: context.sessionID,
|
||||||
yield* permission.assert({
|
agent: context.agent,
|
||||||
action: "edit",
|
source,
|
||||||
resources: [target.resource],
|
})
|
||||||
save: ["*"],
|
const result = yield* files.writeTextPreservingBom({ target, content: input.content })
|
||||||
metadata: { files: [preview] },
|
const bom = (yield* Bom.readFile(fs, target.absolute)).bom
|
||||||
sessionID: context.sessionID,
|
if (yield* formatter.file(target.absolute)) yield* Bom.syncFile(fs, target.absolute, bom)
|
||||||
agent: context.agent,
|
return result
|
||||||
source,
|
}).pipe(
|
||||||
})
|
Effect.map((output) => ({ output, content: toModelOutput(output) })),
|
||||||
const result = yield* files.writeTextPreservingBom({ target, content: input.content })
|
Effect.mapError((error) => new ToolFailure({ message: `Unable to write ${input.path}`, error })),
|
||||||
const bom = (yield* Bom.readFile(fs, target.canonical)).bom
|
),
|
||||||
if (yield* formatter.file(target.canonical)) yield* Bom.syncFile(fs, target.canonical, bom)
|
}),
|
||||||
return result
|
|
||||||
}).pipe(
|
|
||||||
Effect.map((output) => ({ output, content: toModelOutput(output) })),
|
|
||||||
Effect.mapError((error) => new ToolFailure({ message: `Unable to write ${input.path}`, error })),
|
|
||||||
),
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
.pipe(Effect.orDie)
|
.pipe(Effect.orDie)
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -34,14 +34,6 @@ export class MediaIngestLimitError extends Schema.TaggedErrorClass<MediaIngestLi
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class MalformedUtf8Error extends Schema.TaggedErrorClass<MalformedUtf8Error>()("ReadTool.MalformedUtf8Error", {
|
|
||||||
resource: Schema.String,
|
|
||||||
}) {
|
|
||||||
override get message() {
|
|
||||||
return `File is not valid UTF-8: ${this.resource}`
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class OffsetOutOfRangeError extends Schema.TaggedErrorClass<OffsetOutOfRangeError>()(
|
export class OffsetOutOfRangeError extends Schema.TaggedErrorClass<OffsetOutOfRangeError>()(
|
||||||
"ReadTool.OffsetOutOfRangeError",
|
"ReadTool.OffsetOutOfRangeError",
|
||||||
{ offset: Schema.Number },
|
{ offset: Schema.Number },
|
||||||
@@ -61,13 +53,7 @@ export class PathKindError extends Schema.TaggedErrorClass<PathKindError>()("Rea
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type InspectError = FSUtil.Error | PathKindError
|
export type InspectError = FSUtil.Error | PathKindError
|
||||||
export type ReadError =
|
export type ReadError = FSUtil.Error | BinaryFileError | MediaIngestLimitError | OffsetOutOfRangeError | PathKindError
|
||||||
| FSUtil.Error
|
|
||||||
| BinaryFileError
|
|
||||||
| MediaIngestLimitError
|
|
||||||
| MalformedUtf8Error
|
|
||||||
| OffsetOutOfRangeError
|
|
||||||
| PathKindError
|
|
||||||
|
|
||||||
export const PageInput = Schema.Struct({
|
export const PageInput = Schema.Struct({
|
||||||
offset: Schema.optionalKey(NonNegativeInt),
|
offset: Schema.optionalKey(NonNegativeInt),
|
||||||
@@ -90,9 +76,15 @@ export class TextPage extends Schema.Class<TextPage>("ReadTool.TextPage")({
|
|||||||
next: Schema.optionalKey(PositiveInt),
|
next: Schema.optionalKey(PositiveInt),
|
||||||
}) {}
|
}) {}
|
||||||
|
|
||||||
|
export interface ListEntry extends Schema.Schema.Type<typeof ListEntry> {}
|
||||||
|
export const ListEntry = Schema.Struct({
|
||||||
|
path: RelativePath,
|
||||||
|
type: Schema.Literals(["file", "directory", "symlink"]),
|
||||||
|
}).annotate({ identifier: "ReadTool.ListEntry" })
|
||||||
|
|
||||||
export class ListPage extends Schema.Class<ListPage>("ReadTool.ListPage")({
|
export class ListPage extends Schema.Class<ListPage>("ReadTool.ListPage")({
|
||||||
type: Schema.Literal("list-page"),
|
type: Schema.Literal("list-page"),
|
||||||
entries: Schema.Array(FileSystem.Entry),
|
entries: Schema.Array(ListEntry),
|
||||||
truncated: Schema.Boolean,
|
truncated: Schema.Boolean,
|
||||||
next: Schema.optionalKey(PositiveInt),
|
next: Schema.optionalKey(PositiveInt),
|
||||||
}) {}
|
}) {}
|
||||||
@@ -109,36 +101,6 @@ export interface Interface {
|
|||||||
|
|
||||||
export class Service extends Context.Service<Service, Interface>()("@opencode/ReadToolFileSystem") {}
|
export class Service extends Context.Service<Service, Interface>()("@opencode/ReadToolFileSystem") {}
|
||||||
|
|
||||||
const extensions = new Set([
|
|
||||||
".zip",
|
|
||||||
".tar",
|
|
||||||
".gz",
|
|
||||||
".exe",
|
|
||||||
".dll",
|
|
||||||
".so",
|
|
||||||
".class",
|
|
||||||
".jar",
|
|
||||||
".war",
|
|
||||||
".7z",
|
|
||||||
".doc",
|
|
||||||
".docx",
|
|
||||||
".xls",
|
|
||||||
".xlsx",
|
|
||||||
".ppt",
|
|
||||||
".pptx",
|
|
||||||
".odt",
|
|
||||||
".ods",
|
|
||||||
".odp",
|
|
||||||
".bin",
|
|
||||||
".dat",
|
|
||||||
".obj",
|
|
||||||
".o",
|
|
||||||
".a",
|
|
||||||
".lib",
|
|
||||||
".wasm",
|
|
||||||
".pyc",
|
|
||||||
".pyo",
|
|
||||||
])
|
|
||||||
const startsWith = (bytes: Uint8Array, prefix: number[]) => prefix.every((value, index) => bytes[index] === value)
|
const startsWith = (bytes: Uint8Array, prefix: number[]) => prefix.every((value, index) => bytes[index] === value)
|
||||||
const mediaMime = (bytes: Uint8Array) => {
|
const mediaMime = (bytes: Uint8Array) => {
|
||||||
if (startsWith(bytes, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])) return "image/png"
|
if (startsWith(bytes, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])) return "image/png"
|
||||||
@@ -148,8 +110,7 @@ const mediaMime = (bytes: Uint8Array) => {
|
|||||||
return "image/webp"
|
return "image/webp"
|
||||||
if (startsWith(bytes, [0x25, 0x50, 0x44, 0x46, 0x2d])) return "application/pdf"
|
if (startsWith(bytes, [0x25, 0x50, 0x44, 0x46, 0x2d])) return "application/pdf"
|
||||||
}
|
}
|
||||||
const binary = (resource: string, bytes: Uint8Array) => {
|
const binary = (bytes: Uint8Array) => {
|
||||||
if (extensions.has(path.extname(resource).toLowerCase())) return true
|
|
||||||
if (bytes.length === 0) return false
|
if (bytes.length === 0) return false
|
||||||
let nonPrintable = 0
|
let nonPrintable = 0
|
||||||
for (const byte of bytes) {
|
for (const byte of bytes) {
|
||||||
@@ -158,16 +119,9 @@ const binary = (resource: string, bytes: Uint8Array) => {
|
|||||||
}
|
}
|
||||||
return nonPrintable / bytes.length > 0.3
|
return nonPrintable / bytes.length > 0.3
|
||||||
}
|
}
|
||||||
const decodeUtf8 = (resource: string, decoder: TextDecoder, bytes?: Uint8Array) =>
|
const decodeUtf8 = (decoder: TextDecoder, bytes?: Uint8Array) => decoder.decode(bytes, { stream: bytes !== undefined })
|
||||||
Effect.try({
|
|
||||||
try: () => decoder.decode(bytes, { stream: bytes !== undefined }),
|
|
||||||
catch: (error) => {
|
|
||||||
if (error instanceof TypeError) return new MalformedUtf8Error({ resource })
|
|
||||||
throw error
|
|
||||||
},
|
|
||||||
})
|
|
||||||
const decodeChunk = (resource: string, decoder: TextDecoder, bytes: Uint8Array) =>
|
const decodeChunk = (resource: string, decoder: TextDecoder, bytes: Uint8Array) =>
|
||||||
bytes.includes(0) ? Effect.fail(new BinaryFileError({ resource })) : decodeUtf8(resource, decoder, bytes)
|
bytes.includes(0) ? Effect.fail(new BinaryFileError({ resource })) : Effect.succeed(decodeUtf8(decoder, bytes))
|
||||||
|
|
||||||
export const inspect = Effect.fn("ReadTool.inspect")(function* (fs: FSUtil.Interface, input: string) {
|
export const inspect = Effect.fn("ReadTool.inspect")(function* (fs: FSUtil.Interface, input: string) {
|
||||||
const info = yield* fs.stat(input)
|
const info = yield* fs.stat(input)
|
||||||
@@ -218,19 +172,17 @@ export const read = Effect.fn("ReadTool.read")(function* (
|
|||||||
mime,
|
mime,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (extensions.has(path.extname(resource).toLowerCase()))
|
|
||||||
return yield* Effect.fail(new BinaryFileError({ resource }))
|
|
||||||
const paged = info.size > MAX_READ_BYTES || page.offset !== undefined || page.limit !== undefined
|
const paged = info.size > MAX_READ_BYTES || page.offset !== undefined || page.limit !== undefined
|
||||||
if (!paged) {
|
if (!paged) {
|
||||||
if (binary(resource, first)) return yield* Effect.fail(new BinaryFileError({ resource }))
|
if (binary(first)) return yield* Effect.fail(new BinaryFileError({ resource }))
|
||||||
const decoder = new TextDecoder("utf-8", { fatal: true })
|
const decoder = new TextDecoder()
|
||||||
const text = [yield* decodeUtf8(resource, decoder, first)]
|
const text = [decodeUtf8(decoder, first)]
|
||||||
while (true) {
|
while (true) {
|
||||||
const chunk = yield* file.readAlloc(64 * 1024)
|
const chunk = yield* file.readAlloc(64 * 1024)
|
||||||
if (Option.isNone(chunk)) break
|
if (Option.isNone(chunk)) break
|
||||||
text.push(yield* decodeChunk(resource, decoder, chunk.value))
|
text.push(yield* decodeChunk(resource, decoder, chunk.value))
|
||||||
}
|
}
|
||||||
text.push(yield* decodeUtf8(resource, decoder))
|
text.push(decodeUtf8(decoder))
|
||||||
return {
|
return {
|
||||||
type: "file" as const,
|
type: "file" as const,
|
||||||
uri: pathToFileURL(real).href,
|
uri: pathToFileURL(real).href,
|
||||||
@@ -243,7 +195,7 @@ export const read = Effect.fn("ReadTool.read")(function* (
|
|||||||
const offset = page.offset || 1
|
const offset = page.offset || 1
|
||||||
const limit = Math.min(page.limit || MAX_READ_LINES, MAX_READ_LINES)
|
const limit = Math.min(page.limit || MAX_READ_LINES, MAX_READ_LINES)
|
||||||
const lines: string[] = []
|
const lines: string[] = []
|
||||||
const decoder = new TextDecoder("utf-8", { fatal: true })
|
const decoder = new TextDecoder()
|
||||||
let pending = ""
|
let pending = ""
|
||||||
let discard = false
|
let discard = false
|
||||||
let line = 1
|
let line = 1
|
||||||
@@ -301,8 +253,8 @@ export const read = Effect.fn("ReadTool.read")(function* (
|
|||||||
const newline = chunk.indexOf(10, start)
|
const newline = chunk.indexOf(10, start)
|
||||||
const end = newline === -1 ? chunk.length : newline + 1
|
const end = newline === -1 ? chunk.length : newline + 1
|
||||||
const segment = chunk.subarray(start, end)
|
const segment = chunk.subarray(start, end)
|
||||||
if (binary(resource, segment)) return yield* Effect.fail(new BinaryFileError({ resource }))
|
if (binary(segment)) return yield* Effect.fail(new BinaryFileError({ resource }))
|
||||||
if (!consume(yield* decodeUtf8(resource, decoder, segment))) return false
|
if (!consume(decodeUtf8(decoder, segment))) return false
|
||||||
start = end
|
start = end
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
@@ -314,7 +266,7 @@ export const read = Effect.fn("ReadTool.read")(function* (
|
|||||||
done = !(yield* consumeChunk(chunk.value))
|
done = !(yield* consumeChunk(chunk.value))
|
||||||
}
|
}
|
||||||
if (!done) {
|
if (!done) {
|
||||||
const tail = yield* decodeUtf8(resource, decoder)
|
const tail = decodeUtf8(decoder)
|
||||||
if (!discard) pending += tail
|
if (!discard) pending += tail
|
||||||
if (pending) append(pending.endsWith("\r") ? pending.slice(0, -1) : pending)
|
if (pending) append(pending.endsWith("\r") ? pending.slice(0, -1) : pending)
|
||||||
}
|
}
|
||||||
@@ -336,26 +288,26 @@ export const list = Effect.fn("ReadTool.list")(function* (fs: FSUtil.Interface,
|
|||||||
const items = yield* fs.readDirectoryEntries(real)
|
const items = yield* fs.readDirectoryEntries(real)
|
||||||
const offset = page.offset || 1
|
const offset = page.offset || 1
|
||||||
const limit = Math.min(page.limit || MAX_READ_LINES, MAX_READ_LINES)
|
const limit = Math.min(page.limit || MAX_READ_LINES, MAX_READ_LINES)
|
||||||
const entries = yield* Effect.forEach(
|
const visible = items
|
||||||
items,
|
.flatMap((item) =>
|
||||||
(item) =>
|
item.type === "other"
|
||||||
Effect.gen(function* () {
|
? []
|
||||||
const absolute = path.join(real, item.name)
|
: [
|
||||||
const target = yield* fs.realPath(absolute).pipe(Effect.catch(() => Effect.void))
|
ListEntry.make({
|
||||||
if (!target || !FSUtil.contains(real, target)) return
|
path: RelativePath.make(item.name + (item.type === "directory" ? path.sep : "")),
|
||||||
const info = yield* fs.stat(target).pipe(Effect.catch(() => Effect.void))
|
type: item.type,
|
||||||
const type = info?.type === "Directory" ? "directory" : info?.type === "File" ? "file" : undefined
|
}),
|
||||||
if (!type) return
|
],
|
||||||
return FileSystem.Entry.make({
|
)
|
||||||
path: RelativePath.make(item.name + (type === "directory" ? path.sep : "")),
|
.sort((a, b) =>
|
||||||
type,
|
a.type === "directory"
|
||||||
})
|
? b.type === "directory"
|
||||||
}),
|
? a.path.localeCompare(b.path)
|
||||||
{ concurrency: 16 },
|
: -1
|
||||||
)
|
: b.type === "directory"
|
||||||
const visible = entries
|
? 1
|
||||||
.filter((item): item is FileSystem.Entry => item !== undefined)
|
: a.path.localeCompare(b.path),
|
||||||
.sort((a, b) => (a.type === b.type ? a.path.localeCompare(b.path) : a.type === "directory" ? -1 : 1))
|
)
|
||||||
const selected = visible.slice(offset - 1, offset - 1 + limit)
|
const selected = visible.slice(offset - 1, offset - 1 + limit)
|
||||||
const truncated = offset - 1 + selected.length < visible.length
|
const truncated = offset - 1 + selected.length < visible.length
|
||||||
return new ListPage({
|
return new ListPage({
|
||||||
|
|||||||
@@ -119,8 +119,16 @@ function agents(info: typeof ConfigV1.Info.Type) {
|
|||||||
...Object.entries(info.agent ?? {}),
|
...Object.entries(info.agent ?? {}),
|
||||||
...Object.entries(info.mode ?? {}).map(([name, agent]) => [name, { ...agent, mode: "primary" as const }] as const),
|
...Object.entries(info.mode ?? {}).map(([name, agent]) => [name, { ...agent, mode: "primary" as const }] as const),
|
||||||
]
|
]
|
||||||
if (!entries.length) return undefined
|
const result = Object.fromEntries(entries.flatMap(([name, agent]) => (agent ? [[name, migrateAgent(agent)]] : [])))
|
||||||
return Object.fromEntries(entries.flatMap(([name, agent]) => (agent ? [[name, migrateAgent(agent)]] : [])))
|
const small = modelSelection(info.small_model)
|
||||||
|
if (!small) return entries.length ? result : undefined
|
||||||
|
return {
|
||||||
|
...result,
|
||||||
|
title: {
|
||||||
|
model: small,
|
||||||
|
...result.title,
|
||||||
|
},
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function migrateAgent(info: ConfigAgentV1.Info) {
|
export function migrateAgent(info: ConfigAgentV1.Info) {
|
||||||
|
|||||||
@@ -126,6 +126,7 @@ describe("Agent", () => {
|
|||||||
|
|
||||||
yield* agent.transform((editor) => editor.update(id, () => {}))
|
yield* agent.transform((editor) => editor.update(id, () => {}))
|
||||||
const info = yield* agent.get(id)
|
const info = yield* agent.get(id)
|
||||||
|
expect(info?.mode).toBe("primary")
|
||||||
expect(info?.permissions.slice(0, Agent.Info.default(id).permissions.length)).toEqual(
|
expect(info?.permissions.slice(0, Agent.Info.default(id).permissions.length)).toEqual(
|
||||||
Agent.Info.default(id).permissions,
|
Agent.Info.default(id).permissions,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -51,6 +51,11 @@ describe("ConfigAgentPlugin.Plugin", () => {
|
|||||||
it.effect("matches Windows paths against home-relative permissions", () =>
|
it.effect("matches Windows paths against home-relative permissions", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const permissions = yield* loadHomePermissions("C:\\Users\\test")
|
const permissions = yield* loadHomePermissions("C:\\Users\\test")
|
||||||
|
expect(permissions).toContainEqual({
|
||||||
|
action: "external_directory",
|
||||||
|
resource: "C:\\Users\\test\\p\\**",
|
||||||
|
effect: "allow",
|
||||||
|
})
|
||||||
expect(
|
expect(
|
||||||
Permission.evaluate("external_directory", "C:\\Users\\test\\p\\opencode\\src\\*", permissions).effect,
|
Permission.evaluate("external_directory", "C:\\Users\\test\\p\\opencode\\src\\*", permissions).effect,
|
||||||
).toBe("allow")
|
).toBe("allow")
|
||||||
|
|||||||
@@ -512,6 +512,20 @@ describe("Config", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
it.effect("migrates the v1 small model to the title agent", () =>
|
||||||
|
Effect.sync(() => {
|
||||||
|
expect(
|
||||||
|
ConfigMigrateV1.migrate({
|
||||||
|
small_model: "anthropic/claude-haiku-4-5",
|
||||||
|
agent: { title: { prompt: "Custom title prompt" } },
|
||||||
|
}).agents?.title,
|
||||||
|
).toEqual({
|
||||||
|
model: { providerID: "anthropic", model: "claude-haiku-4-5" },
|
||||||
|
system: "Custom title prompt",
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
it.effect("migrates v1 provider lists to policies", () =>
|
it.effect("migrates v1 provider lists to policies", () =>
|
||||||
Effect.sync(() => {
|
Effect.sync(() => {
|
||||||
expect(
|
expect(
|
||||||
|
|||||||
@@ -149,6 +149,28 @@ describe("ConfigNormalize", () => {
|
|||||||
expect(() => Schema.decodeUnknownSync(Info)(result.encoded)).not.toThrow()
|
expect(() => Schema.decodeUnknownSync(Info)(result.encoded)).not.toThrow()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("migrates the legacy small model to the title agent", () => {
|
||||||
|
const result = normalized({
|
||||||
|
small_model: "anthropic/claude-haiku-4-5",
|
||||||
|
agent: { title: { prompt: "Custom title prompt" } },
|
||||||
|
})
|
||||||
|
expect(result.encoded.agents).toEqual({
|
||||||
|
title: {
|
||||||
|
model: { providerID: "anthropic", model: "claude-haiku-4-5" },
|
||||||
|
system: "Custom title prompt",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
expect(result.diagnostics).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("omits an invalid legacy small model without exposing its value", () => {
|
||||||
|
const secret = "do-not-log-this-value"
|
||||||
|
const result = normalized({ small_model: secret })
|
||||||
|
expect(result.encoded.agents).toBeUndefined()
|
||||||
|
expect(result.diagnostics.map((item) => [item.kind, item.path])).toEqual([["unsupported", ["small_model"]]])
|
||||||
|
expect(JSON.stringify(result.diagnostics)).not.toContain(secret)
|
||||||
|
})
|
||||||
|
|
||||||
test("recovers malformed named entries and retains a valid legacy collision", () => {
|
test("recovers malformed named entries and retains a valid legacy collision", () => {
|
||||||
const result = normalized({
|
const result = normalized({
|
||||||
command: { fallback: { template: "legacy" } },
|
command: { fallback: { template: "legacy" } },
|
||||||
@@ -390,7 +412,6 @@ describe("ConfigNormalize", () => {
|
|||||||
const secret = "do-not-log-this-value"
|
const secret = "do-not-log-this-value"
|
||||||
const result = normalized({
|
const result = normalized({
|
||||||
logLevel: "DEBUG",
|
logLevel: "DEBUG",
|
||||||
small_model: secret,
|
|
||||||
agent: { reviewer: { name: secret, prompt: "review" } },
|
agent: { reviewer: { name: secret, prompt: "review" } },
|
||||||
provider: {
|
provider: {
|
||||||
custom: {
|
custom: {
|
||||||
@@ -409,7 +430,6 @@ describe("ConfigNormalize", () => {
|
|||||||
})
|
})
|
||||||
expect(result.diagnostics.filter((item) => item.kind === "unsupported").map((item) => item.path)).toEqual([
|
expect(result.diagnostics.filter((item) => item.kind === "unsupported").map((item) => item.path)).toEqual([
|
||||||
["logLevel"],
|
["logLevel"],
|
||||||
["small_model"],
|
|
||||||
["agent", "reviewer", "name"],
|
["agent", "reviewer", "name"],
|
||||||
["provider", "custom", "id"],
|
["provider", "custom", "id"],
|
||||||
["provider", "custom", "whitelist"],
|
["provider", "custom", "whitelist"],
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import fs from "node:fs/promises"
|
||||||
|
import { Effect } from "effect"
|
||||||
|
import { ChildProcessSpawner } from "effect/unstable/process"
|
||||||
|
import { CrossSpawnSpawner } from "@opencode-ai/util/cross-spawn-spawner"
|
||||||
|
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||||
|
import { execDefaults, Failed, makeFiles, makeMemoryDriver } from "../src/environment/index"
|
||||||
|
import { tmpdir } from "./fixture/tmpdir"
|
||||||
|
import { environmentConformance } from "./lib/environment-conformance"
|
||||||
|
|
||||||
|
environmentConformance("memory environment", () =>
|
||||||
|
Effect.sync(() => {
|
||||||
|
const driver = makeMemoryDriver()
|
||||||
|
return {
|
||||||
|
files: makeFiles(driver),
|
||||||
|
root: `/workspace-${crypto.randomUUID()}`,
|
||||||
|
symlink: driver.symlink,
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
environmentConformance(
|
||||||
|
"GNU exec environment",
|
||||||
|
() =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
|
||||||
|
const tmp = yield* Effect.promise(() => tmpdir("opencode-environment-"))
|
||||||
|
return {
|
||||||
|
files: execDefaults(spawner),
|
||||||
|
root: tmp.path,
|
||||||
|
symlink: (target: string, link: string) =>
|
||||||
|
Effect.tryPromise({
|
||||||
|
try: () => fs.symlink(target, link),
|
||||||
|
catch: (cause) => new Failed({ path: link, cause }),
|
||||||
|
}),
|
||||||
|
dispose: Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||||
|
}
|
||||||
|
}).pipe(Effect.provide(LayerNode.compile(CrossSpawnSpawner.node))),
|
||||||
|
process.platform !== "linux",
|
||||||
|
)
|
||||||
@@ -43,7 +43,7 @@ describe("FileMutation", () => {
|
|||||||
|
|
||||||
expect(yield* (yield* FileMutation.Service).write({ target, content: "after" })).toEqual({
|
expect(yield* (yield* FileMutation.Service).write({ target, content: "after" })).toEqual({
|
||||||
operation: "write",
|
operation: "write",
|
||||||
target: target.canonical,
|
target: target.absolute,
|
||||||
resource: "hello.txt",
|
resource: "hello.txt",
|
||||||
existed: true,
|
existed: true,
|
||||||
})
|
})
|
||||||
@@ -62,11 +62,11 @@ describe("FileMutation", () => {
|
|||||||
|
|
||||||
expect(result).toEqual({
|
expect(result).toEqual({
|
||||||
operation: "write",
|
operation: "write",
|
||||||
target: target.canonical,
|
target: target.absolute,
|
||||||
resource: "src/nested/hello.txt",
|
resource: "src/nested/hello.txt",
|
||||||
existed: false,
|
existed: false,
|
||||||
})
|
})
|
||||||
expect(yield* Effect.promise(() => fs.readFile(result.target, "utf8"))).toBe("hello")
|
expect(yield* Effect.promise(() => fs.readFile(target.absolute, "utf8"))).toBe("hello")
|
||||||
}).pipe(provide(directory)),
|
}).pipe(provide(directory)),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -84,7 +84,7 @@ describe("FileMutation", () => {
|
|||||||
yield* files.writeTextPreservingBom({ target: created, content: "\uFEFF\uFEFF\uFEFFcreated" })
|
yield* files.writeTextPreservingBom({ target: created, content: "\uFEFF\uFEFF\uFEFFcreated" })
|
||||||
|
|
||||||
expect(yield* Effect.promise(() => fs.readFile(preservedPath, "utf8"))).toBe("\uFEFFafter")
|
expect(yield* Effect.promise(() => fs.readFile(preservedPath, "utf8"))).toBe("\uFEFFafter")
|
||||||
expect(yield* Effect.promise(() => fs.readFile(created.canonical, "utf8"))).toBe("\uFEFFcreated")
|
expect(yield* Effect.promise(() => fs.readFile(created.absolute, "utf8"))).toBe("\uFEFFcreated")
|
||||||
}).pipe(provide(directory)),
|
}).pipe(provide(directory)),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -99,7 +99,7 @@ describe("FileMutation", () => {
|
|||||||
|
|
||||||
expect(result).toEqual({
|
expect(result).toEqual({
|
||||||
operation: "write",
|
operation: "write",
|
||||||
target: target.canonical,
|
target: target.absolute,
|
||||||
resource: target.resource,
|
resource: target.resource,
|
||||||
existed: false,
|
existed: false,
|
||||||
})
|
})
|
||||||
@@ -109,7 +109,7 @@ describe("FileMutation", () => {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.live("serializes concurrent writes to the same canonical target", () =>
|
it.live("serializes concurrent writes to the same absolute target", () =>
|
||||||
withTmp((directory) =>
|
withTmp((directory) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const targetPath = path.join(directory, "shared.txt")
|
const targetPath = path.join(directory, "shared.txt")
|
||||||
@@ -152,7 +152,7 @@ describe("FileMutation", () => {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.live("allows distinct canonical targets to proceed independently", () =>
|
it.live("allows distinct absolute targets to proceed independently", () =>
|
||||||
withTmp((directory) =>
|
withTmp((directory) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const firstStarted = yield* Deferred.make<void>()
|
const firstStarted = yield* Deferred.make<void>()
|
||||||
|
|||||||
@@ -0,0 +1,159 @@
|
|||||||
|
import { describe, expect } from "bun:test"
|
||||||
|
import { Effect } from "effect"
|
||||||
|
import { Failed, NotFound, WrongKind, type Files } from "../../src/environment/index"
|
||||||
|
import { it } from "./effect"
|
||||||
|
|
||||||
|
export interface EnvironmentHarness {
|
||||||
|
readonly files: Files
|
||||||
|
readonly root: string
|
||||||
|
readonly symlink?: (target: string, path: string) => Effect.Effect<void, Failed>
|
||||||
|
readonly dispose?: Effect.Effect<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
export const environmentConformance = <E>(
|
||||||
|
name: string,
|
||||||
|
makeHarness: () => Effect.Effect<EnvironmentHarness, E>,
|
||||||
|
skip = false,
|
||||||
|
) => {
|
||||||
|
const check = <A, E2>(title: string, body: (harness: EnvironmentHarness) => Effect.Effect<A, E2>) =>
|
||||||
|
it.live(title, () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const harness = yield* Effect.acquireRelease(makeHarness(), (harness) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
yield* Effect.ignore(harness.files.remove(harness.root))
|
||||||
|
if (harness.dispose) yield* harness.dispose
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
yield* harness.files.mkdir(harness.root)
|
||||||
|
return yield* body(harness)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const bytes = (value: string) => new TextEncoder().encode(value)
|
||||||
|
const text = (value: Uint8Array) => new TextDecoder().decode(value)
|
||||||
|
const suite = skip ? describe.skip : describe
|
||||||
|
|
||||||
|
suite(name, () => {
|
||||||
|
check("writes, stats, and reads a file with its info", (harness) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const target = `${harness.root}/hello.txt`
|
||||||
|
yield* harness.files.write(target, bytes("hello"))
|
||||||
|
const result = yield* harness.files.read(target)
|
||||||
|
expect(text(result.bytes)).toBe("hello")
|
||||||
|
expect(result.info.type).toBe("file")
|
||||||
|
expect(result.info.size).toBe(5)
|
||||||
|
expect(yield* harness.files.stat(target)).toEqual(result.info)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
check("reports missing paths", (harness) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const target = `${harness.root}/missing`
|
||||||
|
expect(yield* Effect.flip(harness.files.read(target))).toBeInstanceOf(NotFound)
|
||||||
|
expect(yield* Effect.flip(harness.files.stat(target))).toBeInstanceOf(NotFound)
|
||||||
|
expect(yield* Effect.flip(harness.files.list(target))).toBeInstanceOf(NotFound)
|
||||||
|
expect(yield* Effect.flip(harness.files.move(target, `${harness.root}/other`))).toBeInstanceOf(NotFound)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
check("reports the actual kind", (harness) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const directory = `${harness.root}/directory`
|
||||||
|
const file = `${harness.root}/file`
|
||||||
|
yield* harness.files.mkdir(directory)
|
||||||
|
yield* harness.files.write(file, bytes("data"))
|
||||||
|
const readError = yield* Effect.flip(harness.files.read(directory))
|
||||||
|
const listError = yield* Effect.flip(harness.files.list(file))
|
||||||
|
expect(readError).toBeInstanceOf(WrongKind)
|
||||||
|
expect((readError as WrongKind).actual).toBe("directory")
|
||||||
|
expect(listError).toBeInstanceOf(WrongKind)
|
||||||
|
expect((listError as WrongKind).actual).toBe("file")
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
check("write creates parent directories", (harness) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const target = `${harness.root}/one/two/file`
|
||||||
|
yield* harness.files.write(target, bytes("nested"))
|
||||||
|
yield* harness.files.write(`${harness.root}/empty`, new Uint8Array())
|
||||||
|
expect((yield* harness.files.stat(`${harness.root}/one/two`)).type).toBe("directory")
|
||||||
|
expect(yield* harness.files.stat(`${harness.root}/empty`)).toMatchObject({ type: "file", size: 0 })
|
||||||
|
expect(text((yield* harness.files.read(target)).bytes)).toBe("nested")
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
check("reads byte ranges", (harness) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const target = `${harness.root}/range`
|
||||||
|
yield* harness.files.write(target, bytes("0123456789"))
|
||||||
|
expect(text((yield* harness.files.read(target, { offset: 2, length: 4 })).bytes)).toBe("2345")
|
||||||
|
expect(text((yield* harness.files.read(target, { offset: 8, length: 8 })).bytes)).toBe("89")
|
||||||
|
expect(text((yield* harness.files.read(target, { offset: 20, length: 4 })).bytes)).toBe("")
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
check("lists immediate entries with their kinds", (harness) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
yield* harness.files.write(`${harness.root}/file name`, bytes("data"))
|
||||||
|
yield* harness.files.mkdir(`${harness.root}/directory`)
|
||||||
|
yield* harness.files.write(`${harness.root}/directory/nested`, bytes("nested"))
|
||||||
|
const entries = yield* harness.files.list(harness.root)
|
||||||
|
expect(entries.toSorted((a, b) => a.name.localeCompare(b.name))).toEqual([
|
||||||
|
{ name: "directory", type: "directory" },
|
||||||
|
{ name: "file name", type: "file" },
|
||||||
|
])
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
check("reports symlinks without resolving them", (harness) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
if (!harness.symlink) return
|
||||||
|
yield* harness.files.write(`${harness.root}/target`, bytes("target"))
|
||||||
|
yield* harness.files.write(`${harness.root}/target-dir/file`, bytes("through link"))
|
||||||
|
yield* harness.symlink("target", `${harness.root}/link`)
|
||||||
|
yield* harness.symlink("target-dir", `${harness.root}/link-dir`)
|
||||||
|
expect((yield* harness.files.stat(`${harness.root}/link`)).type).toBe("symlink")
|
||||||
|
expect(yield* harness.files.list(harness.root)).toContainEqual({ name: "link", type: "symlink" })
|
||||||
|
expect(text((yield* harness.files.read(`${harness.root}/link-dir/file`)).bytes)).toBe("through link")
|
||||||
|
const listError = yield* Effect.flip(harness.files.list(`${harness.root}/link-dir`))
|
||||||
|
expect(listError).toBeInstanceOf(WrongKind)
|
||||||
|
expect((listError as WrongKind).actual).toBe("symlink")
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
check("follows symlinks when reading", (harness) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
if (!harness.symlink) return
|
||||||
|
yield* harness.files.write(`${harness.root}/target`, bytes("target content"))
|
||||||
|
yield* harness.files.mkdir(`${harness.root}/directory`)
|
||||||
|
yield* harness.symlink("target", `${harness.root}/file-link`)
|
||||||
|
yield* harness.symlink("directory", `${harness.root}/directory-link`)
|
||||||
|
yield* harness.symlink("missing", `${harness.root}/dangling-link`)
|
||||||
|
|
||||||
|
const result = yield* harness.files.read(`${harness.root}/file-link`)
|
||||||
|
expect(text(result.bytes)).toBe("target content")
|
||||||
|
expect(result.info.type).toBe("file")
|
||||||
|
expect(result.info.size).toBe(bytes("target content").length)
|
||||||
|
|
||||||
|
const directoryError = yield* Effect.flip(harness.files.read(`${harness.root}/directory-link`))
|
||||||
|
expect(directoryError).toBeInstanceOf(WrongKind)
|
||||||
|
expect((directoryError as WrongKind).actual).toBe("directory")
|
||||||
|
expect(yield* Effect.flip(harness.files.read(`${harness.root}/dangling-link`))).toBeInstanceOf(NotFound)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
check("moves files and removes trees idempotently", (harness) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const source = `${harness.root}/source/file`
|
||||||
|
const destination = `${harness.root}/destination`
|
||||||
|
yield* harness.files.write(source, bytes("moved"))
|
||||||
|
yield* harness.files.move(source, destination)
|
||||||
|
expect(text((yield* harness.files.read(destination)).bytes)).toBe("moved")
|
||||||
|
expect(yield* Effect.flip(harness.files.stat(source))).toBeInstanceOf(NotFound)
|
||||||
|
yield* harness.files.remove(`${harness.root}/source`)
|
||||||
|
yield* harness.files.remove(`${harness.root}/source`)
|
||||||
|
expect(yield* Effect.flip(harness.files.stat(`${harness.root}/source`))).toBeInstanceOf(NotFound)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -37,7 +37,7 @@ describe("LocationMutation", () => {
|
|||||||
const target = yield* (yield* LocationMutation.Service).resolve({ path: "hello.txt" })
|
const target = yield* (yield* LocationMutation.Service).resolve({ path: "hello.txt" })
|
||||||
|
|
||||||
expect(target).toMatchObject({
|
expect(target).toMatchObject({
|
||||||
canonical: yield* Effect.promise(() => fs.realpath(targetPath)),
|
absolute: targetPath,
|
||||||
resource: "hello.txt",
|
resource: "hello.txt",
|
||||||
})
|
})
|
||||||
expect(target.externalDirectory).toBeUndefined()
|
expect(target.externalDirectory).toBeUndefined()
|
||||||
@@ -50,10 +50,8 @@ describe("LocationMutation", () => {
|
|||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
yield* Effect.promise(() => fs.mkdir(path.join(directory, "src")))
|
yield* Effect.promise(() => fs.mkdir(path.join(directory, "src")))
|
||||||
const target = yield* (yield* LocationMutation.Service).resolve({ path: path.join("src", "new.txt") })
|
const target = yield* (yield* LocationMutation.Service).resolve({ path: path.join("src", "new.txt") })
|
||||||
const root = yield* Effect.promise(() => fs.realpath(directory))
|
|
||||||
|
|
||||||
expect(target).toMatchObject({
|
expect(target).toMatchObject({
|
||||||
canonical: path.join(root, "src", "new.txt"),
|
absolute: path.join(directory, "src", "new.txt"),
|
||||||
resource: "src/new.txt",
|
resource: "src/new.txt",
|
||||||
})
|
})
|
||||||
}).pipe(provide(directory)),
|
}).pipe(provide(directory)),
|
||||||
@@ -64,9 +62,9 @@ describe("LocationMutation", () => {
|
|||||||
withTmp((directory) =>
|
withTmp((directory) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const target = yield* (yield* LocationMutation.Service).resolve({ path: "../outside.txt" })
|
const target = yield* (yield* LocationMutation.Service).resolve({ path: "../outside.txt" })
|
||||||
const root = yield* Effect.promise(() => fs.realpath(path.dirname(directory)))
|
const root = path.dirname(directory)
|
||||||
expect(target).toMatchObject({
|
expect(target).toMatchObject({
|
||||||
canonical: path.join(root, "outside.txt"),
|
absolute: path.join(root, "outside.txt"),
|
||||||
resource: path.join(root, "outside.txt").replaceAll("\\", "/"),
|
resource: path.join(root, "outside.txt").replaceAll("\\", "/"),
|
||||||
})
|
})
|
||||||
expect(target.externalDirectory).toMatchObject({
|
expect(target.externalDirectory).toMatchObject({
|
||||||
@@ -77,7 +75,7 @@ describe("LocationMutation", () => {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.live("authorizes a prospective target below an external symlink by its in-location path", () =>
|
it.live("resolves a prospective target below an external symlink lexically", () =>
|
||||||
withTmp((directory) => {
|
withTmp((directory) => {
|
||||||
const outside = `${directory}-outside`
|
const outside = `${directory}-outside`
|
||||||
return Effect.gen(function* () {
|
return Effect.gen(function* () {
|
||||||
@@ -88,7 +86,7 @@ describe("LocationMutation", () => {
|
|||||||
})
|
})
|
||||||
const target = yield* (yield* LocationMutation.Service).resolve({ path: path.join("escape", "new.txt") })
|
const target = yield* (yield* LocationMutation.Service).resolve({ path: path.join("escape", "new.txt") })
|
||||||
expect(target).toMatchObject({
|
expect(target).toMatchObject({
|
||||||
canonical: path.join(yield* Effect.promise(() => fs.realpath(outside)), "new.txt"),
|
absolute: path.join(directory, "escape", "new.txt"),
|
||||||
resource: "escape/new.txt",
|
resource: "escape/new.txt",
|
||||||
})
|
})
|
||||||
expect(target.externalDirectory).toBeUndefined()
|
expect(target.externalDirectory).toBeUndefined()
|
||||||
@@ -107,7 +105,7 @@ describe("LocationMutation", () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
expect(yield* (yield* LocationMutation.Service).resolve({ path: "linked/new.txt" })).toMatchObject({
|
expect(yield* (yield* LocationMutation.Service).resolve({ path: "linked/new.txt" })).toMatchObject({
|
||||||
canonical: path.join(yield* Effect.promise(() => fs.realpath(directory)), "actual", "new.txt"),
|
absolute: path.join(directory, "linked", "new.txt"),
|
||||||
resource: "linked/new.txt",
|
resource: "linked/new.txt",
|
||||||
})
|
})
|
||||||
}).pipe(provide(directory)),
|
}).pipe(provide(directory)),
|
||||||
@@ -120,7 +118,7 @@ describe("LocationMutation", () => {
|
|||||||
const targetPath = path.join(directory, "new.txt")
|
const targetPath = path.join(directory, "new.txt")
|
||||||
const target = yield* (yield* LocationMutation.Service).resolve({ path: targetPath })
|
const target = yield* (yield* LocationMutation.Service).resolve({ path: targetPath })
|
||||||
expect(target).toMatchObject({
|
expect(target).toMatchObject({
|
||||||
canonical: path.join(yield* Effect.promise(() => fs.realpath(directory)), "new.txt"),
|
absolute: targetPath,
|
||||||
resource: "new.txt",
|
resource: "new.txt",
|
||||||
})
|
})
|
||||||
expect(target.externalDirectory).toBeUndefined()
|
expect(target.externalDirectory).toBeUndefined()
|
||||||
@@ -134,9 +132,9 @@ describe("LocationMutation", () => {
|
|||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const targetPath = path.join(outside, "new.txt")
|
const targetPath = path.join(outside, "new.txt")
|
||||||
const target = yield* (yield* LocationMutation.Service).resolve({ path: targetPath })
|
const target = yield* (yield* LocationMutation.Service).resolve({ path: targetPath })
|
||||||
const root = yield* Effect.promise(() => fs.realpath(outside))
|
const root = outside
|
||||||
expect(target).toMatchObject({
|
expect(target).toMatchObject({
|
||||||
canonical: path.join(root, "new.txt"),
|
absolute: path.join(root, "new.txt"),
|
||||||
resource: path.join(root, "new.txt").replaceAll("\\", "/"),
|
resource: path.join(root, "new.txt").replaceAll("\\", "/"),
|
||||||
})
|
})
|
||||||
expect(target.externalDirectory).toMatchObject({
|
expect(target.externalDirectory).toMatchObject({
|
||||||
@@ -155,24 +153,23 @@ describe("LocationMutation", () => {
|
|||||||
const targetPath = path.join(outside, "existing.txt")
|
const targetPath = path.join(outside, "existing.txt")
|
||||||
yield* Effect.promise(() => fs.writeFile(targetPath, "existing"))
|
yield* Effect.promise(() => fs.writeFile(targetPath, "existing"))
|
||||||
const target = yield* (yield* LocationMutation.Service).resolve({ path: targetPath })
|
const target = yield* (yield* LocationMutation.Service).resolve({ path: targetPath })
|
||||||
const root = yield* Effect.promise(() => fs.realpath(outside))
|
expect(target).toMatchObject({ absolute: targetPath })
|
||||||
expect(target).toMatchObject({ canonical: path.join(root, "existing.txt") })
|
expect(target.externalDirectory?.directory).toBe(outside)
|
||||||
expect(target.externalDirectory?.directory).toBe(root)
|
|
||||||
}).pipe(provide(directory)),
|
}).pipe(provide(directory)),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.live("anchors prospective external descendants at their stable existing directory", () =>
|
it.live("authorizes prospective external descendants at their lexical parent", () =>
|
||||||
withTmp((directory) =>
|
withTmp((directory) =>
|
||||||
withTmp((outside) =>
|
withTmp((outside) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const targetPath = path.join(outside, "new", "nested", "file.txt")
|
const targetPath = path.join(outside, "new", "nested", "file.txt")
|
||||||
const target = yield* (yield* LocationMutation.Service).resolve({ path: targetPath })
|
const target = yield* (yield* LocationMutation.Service).resolve({ path: targetPath })
|
||||||
const root = yield* Effect.promise(() => fs.realpath(outside))
|
const parent = path.dirname(targetPath)
|
||||||
expect(target.externalDirectory).toMatchObject({
|
expect(target.externalDirectory).toMatchObject({
|
||||||
directory: root,
|
directory: parent,
|
||||||
resource: path.join(root, "*").replaceAll("\\", "/"),
|
resource: path.join(parent, "*").replaceAll("\\", "/"),
|
||||||
})
|
})
|
||||||
}).pipe(provide(directory)),
|
}).pipe(provide(directory)),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1086,4 +1086,78 @@ describe("Session.pending", () => {
|
|||||||
expect(yield* session.pending(sessionID)).toEqual([])
|
expect(yield* session.pending(sessionID)).toEqual([])
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
it.effect("cancels pending input and allows its ID to be admitted again", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
yield* setup
|
||||||
|
const session = yield* Session.Service
|
||||||
|
const inputID = SessionMessage.ID.make("msg_cancelled_queue")
|
||||||
|
yield* session.prompt({
|
||||||
|
id: inputID,
|
||||||
|
sessionID,
|
||||||
|
text: "Queue this",
|
||||||
|
delivery: "queue",
|
||||||
|
resume: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
yield* session.cancelPending({ sessionID, inputID })
|
||||||
|
|
||||||
|
expect(yield* session.pending(sessionID)).toEqual([])
|
||||||
|
expect(yield* eventCount(Bus.versionedType(SessionEvent.InputCancelled.type, 1))).toBe(1)
|
||||||
|
expect(
|
||||||
|
yield* session.cancelPending({ sessionID, inputID }).pipe(Effect.flip),
|
||||||
|
).toMatchObject({ _tag: "Session.PendingInputConflictError", sessionID, inputID })
|
||||||
|
expect(yield* eventCount(Bus.versionedType(SessionEvent.InputCancelled.type, 1))).toBe(1)
|
||||||
|
|
||||||
|
const retried = yield* session.prompt({
|
||||||
|
id: inputID,
|
||||||
|
sessionID,
|
||||||
|
text: "Queue this",
|
||||||
|
delivery: "queue",
|
||||||
|
resume: false,
|
||||||
|
})
|
||||||
|
expect(retried).toMatchObject({ id: inputID, delivery: "queue" })
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("moves pending input between steer and queue delivery", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
yield* setup
|
||||||
|
const session = yield* Session.Service
|
||||||
|
const queued = yield* session.synthetic({
|
||||||
|
sessionID,
|
||||||
|
text: "Steer this",
|
||||||
|
delivery: "queue",
|
||||||
|
resume: false,
|
||||||
|
})
|
||||||
|
const alreadySteered = yield* session.prompt({ sessionID, text: "Already steer", resume: false })
|
||||||
|
wakeCalls.length = 0
|
||||||
|
|
||||||
|
yield* session.steerPending({ sessionID, inputID: queued.id })
|
||||||
|
|
||||||
|
expect(yield* session.pending(sessionID)).toMatchObject([
|
||||||
|
{ id: queued.id, delivery: "steer" },
|
||||||
|
{ id: alreadySteered.id, delivery: "steer" },
|
||||||
|
])
|
||||||
|
expect(wakeCalls).toEqual([sessionID])
|
||||||
|
expect(yield* eventCount(Bus.versionedType(SessionEvent.InputSteered.type, 1))).toBe(1)
|
||||||
|
|
||||||
|
wakeCalls.length = 0
|
||||||
|
yield* session.queuePending({ sessionID, inputID: queued.id })
|
||||||
|
expect(yield* session.pending(sessionID)).toMatchObject([
|
||||||
|
{ id: queued.id, delivery: "queue" },
|
||||||
|
{ id: alreadySteered.id, delivery: "steer" },
|
||||||
|
])
|
||||||
|
expect(wakeCalls).toEqual([])
|
||||||
|
expect(yield* eventCount(Bus.versionedType(SessionEvent.InputQueued.type, 1))).toBe(1)
|
||||||
|
|
||||||
|
expect(
|
||||||
|
yield* session.steerPending({ sessionID, inputID: alreadySteered.id }).pipe(Effect.flip),
|
||||||
|
).toMatchObject({ _tag: "Session.PendingInputConflictError", sessionID, inputID: alreadySteered.id })
|
||||||
|
yield* session.cancelPending({ sessionID, inputID: alreadySteered.id })
|
||||||
|
expect(wakeCalls).toEqual([])
|
||||||
|
expect(yield* eventCount(Bus.versionedType(SessionEvent.InputSteered.type, 1))).toBe(1)
|
||||||
|
expect(yield* eventCount(Bus.versionedType(SessionEvent.InputCancelled.type, 1))).toBe(1)
|
||||||
|
}),
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
})
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { describe, expect } from "bun:test"
|
import { describe, expect } from "bun:test"
|
||||||
|
import fs from "fs/promises"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import { Effect, FileSystem } from "effect"
|
import { Effect, FileSystem } from "effect"
|
||||||
import { LayerNodePlatform } from "@opencode-ai/util/effect/app-node-platform"
|
import { LayerNodePlatform } from "@opencode-ai/util/effect/app-node-platform"
|
||||||
@@ -50,22 +51,53 @@ describe("ReadToolFileSystem", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect("reports binary and malformed UTF-8 content as typed errors", () =>
|
it.effect("reads malformed UTF-8 lossily and still rejects null-byte binary content", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const { fs, files, directory } = yield* fixture
|
const { fs, files, directory } = yield* fixture
|
||||||
const binary = path.join(directory, "archive.dat")
|
const binary = path.join(directory, "archive.dat")
|
||||||
const malformed = path.join(directory, "malformed.txt")
|
const malformed = path.join(directory, "malformed.txt")
|
||||||
yield* files.writeFile(binary, Uint8Array.of(0, 1, 2, 3))
|
yield* files.writeFile(binary, Uint8Array.of(0, 1, 2, 3))
|
||||||
const malformedContent = new Uint8Array(64 * 1024 + 1).fill(97)
|
yield* files.writeFile(malformed, Uint8Array.of(0x68, 0x69, 0x80))
|
||||||
malformedContent[64 * 1024] = 0x80
|
|
||||||
yield* files.writeFile(malformed, malformedContent)
|
|
||||||
|
|
||||||
const binaryError = yield* ReadToolFileSystem.read(fs, binary, "archive.dat").pipe(Effect.flip)
|
const binaryError = yield* ReadToolFileSystem.read(fs, binary, "archive.dat").pipe(Effect.flip)
|
||||||
const malformedError = yield* ReadToolFileSystem.read(fs, malformed, "malformed.txt").pipe(Effect.flip)
|
const malformedResult = yield* ReadToolFileSystem.read(fs, malformed, "malformed.txt")
|
||||||
|
|
||||||
expect(binaryError).toBeInstanceOf(ReadToolFileSystem.BinaryFileError)
|
expect(binaryError).toBeInstanceOf(ReadToolFileSystem.BinaryFileError)
|
||||||
expect(binaryError.message).toBe("Cannot read binary file: archive.dat")
|
expect(binaryError.message).toBe("Cannot read binary file: archive.dat")
|
||||||
expect(malformedError).toBeInstanceOf(ReadToolFileSystem.MalformedUtf8Error)
|
expect(malformedResult).toMatchObject({ type: "file", content: "hi\uFFFD", encoding: "utf8" })
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("reads text despite a binary-associated extension", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const { fs, files, directory } = yield* fixture
|
||||||
|
const file = path.join(directory, "notes.docx")
|
||||||
|
yield* files.writeFileString(file, "plain text")
|
||||||
|
|
||||||
|
const result = yield* ReadToolFileSystem.read(fs, file, "notes.docx")
|
||||||
|
|
||||||
|
expect(result).toMatchObject({ type: "file", content: "plain text", encoding: "utf8" })
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("lists unresolved symlinks, including broken and escaping links", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
if (process.platform === "win32") return
|
||||||
|
const { fs: service, files, directory } = yield* fixture
|
||||||
|
const outside = yield* files.makeTempDirectoryScoped()
|
||||||
|
yield* files.makeDirectory(path.join(directory, "folder"))
|
||||||
|
yield* files.writeFileString(path.join(directory, "file.txt"), "hello")
|
||||||
|
yield* Effect.promise(() => fs.symlink(path.join(outside, "target.txt"), path.join(directory, "escape")))
|
||||||
|
yield* Effect.promise(() => fs.symlink(path.join(directory, "missing.txt"), path.join(directory, "broken")))
|
||||||
|
|
||||||
|
const result = yield* ReadToolFileSystem.list(service, directory)
|
||||||
|
|
||||||
|
expect(result.entries.map((entry) => ({ ...entry, path: String(entry.path) }))).toEqual([
|
||||||
|
{ path: `folder${path.sep}`, type: "directory" },
|
||||||
|
{ path: "broken", type: "symlink" },
|
||||||
|
{ path: "escape", type: "symlink" },
|
||||||
|
{ path: "file.txt", type: "file" },
|
||||||
|
])
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -148,13 +148,13 @@ const mutation = Layer.succeed(
|
|||||||
LocationMutation.Service,
|
LocationMutation.Service,
|
||||||
LocationMutation.Service.of({
|
LocationMutation.Service.of({
|
||||||
resolve: (input) => {
|
resolve: (input) => {
|
||||||
const canonical = path.resolve(process.cwd(), input.path)
|
const absolute = path.resolve(process.cwd(), input.path)
|
||||||
const external = path.isAbsolute(input.path) && !FSUtil.contains(process.cwd(), canonical)
|
const external = path.isAbsolute(input.path) && !FSUtil.contains(process.cwd(), absolute)
|
||||||
const resource = external ? canonical.replaceAll("\\", "/") : path.relative(process.cwd(), canonical) || "."
|
const resource = external ? absolute.replaceAll("\\", "/") : path.relative(process.cwd(), absolute) || "."
|
||||||
const directory = path.dirname(canonical)
|
const directory = path.dirname(absolute)
|
||||||
const externalResource = path.join(directory, "*").replaceAll("\\", "/")
|
const externalResource = path.join(directory, "*").replaceAll("\\", "/")
|
||||||
return Effect.succeed({
|
return Effect.succeed({
|
||||||
canonical,
|
absolute,
|
||||||
resource,
|
resource,
|
||||||
externalDirectory: external
|
externalDirectory: external
|
||||||
? {
|
? {
|
||||||
@@ -311,9 +311,7 @@ describe("ReadTool", () => {
|
|||||||
})
|
})
|
||||||
expect(settled.status).toBe("completed")
|
expect(settled.status).toBe("completed")
|
||||||
if (settled.status !== "completed") return
|
if (settled.status !== "completed") return
|
||||||
// Image base64 is carried by the content file item only; read produces no
|
expect(settled.metadata).toEqual({ truncated: false })
|
||||||
// metadata, so the original bytes are never persisted twice.
|
|
||||||
expect(settled.metadata).toBeUndefined()
|
|
||||||
expect(settled.content).toMatchObject([
|
expect(settled.content).toMatchObject([
|
||||||
{ type: "text", text: "Image read successfully" },
|
{ type: "text", text: "Image read successfully" },
|
||||||
{ type: "file", mime: "image/png", uri: `data:image/png;base64,${png}` },
|
{ type: "file", mime: "image/png", uri: `data:image/png;base64,${png}` },
|
||||||
@@ -621,10 +619,6 @@ describe("ReadTool", () => {
|
|||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const registry = yield* Tool.Service
|
const registry = yield* Tool.Service
|
||||||
for (const [error, message] of [
|
for (const [error, message] of [
|
||||||
[
|
|
||||||
new ReadToolFileSystem.MalformedUtf8Error({ resource: "invalid.txt" }),
|
|
||||||
"File is not valid UTF-8: invalid.txt",
|
|
||||||
],
|
|
||||||
[new ReadToolFileSystem.OffsetOutOfRangeError({ offset: 10 }), "Offset 10 is out of range"],
|
[new ReadToolFileSystem.OffsetOutOfRangeError({ offset: 10 }), "Offset 10 is out of range"],
|
||||||
[
|
[
|
||||||
new ReadToolFileSystem.PathKindError({ resource: "socket", expected: "a file" }),
|
new ReadToolFileSystem.PathKindError({ resource: "socket", expected: "a file" }),
|
||||||
@@ -721,17 +715,21 @@ describe("ReadTool", () => {
|
|||||||
const registry = yield* Tool.Service
|
const registry = yield* Tool.Service
|
||||||
|
|
||||||
const result = yield* executeTool(registry, {
|
const result = yield* executeTool(registry, {
|
||||||
sessionID,
|
sessionID,
|
||||||
...toolIdentity,
|
...toolIdentity,
|
||||||
call: {
|
call: {
|
||||||
type: "tool-call",
|
type: "tool-call",
|
||||||
id: "call-read-directory",
|
id: "call-read-directory",
|
||||||
name: "read",
|
name: "read",
|
||||||
input: { path: "src", offset: 2, limit: 10 },
|
input: { path: "src", offset: 2, limit: 10 },
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
expect(result).toMatchObject({ status: "completed", output: { entries: listResult.entries, truncated: true, next: 4 } })
|
expect(result).toMatchObject({
|
||||||
|
status: "completed",
|
||||||
|
output: { entries: listResult.entries, truncated: true, next: 4 },
|
||||||
|
})
|
||||||
if (result.status !== "completed") return
|
if (result.status !== "completed") return
|
||||||
|
expect(result.metadata).toEqual({ truncated: true })
|
||||||
expect(result.content).toEqual([
|
expect(result.content).toEqual([
|
||||||
{
|
{
|
||||||
type: "text",
|
type: "text",
|
||||||
@@ -806,6 +804,7 @@ describe("ReadTool", () => {
|
|||||||
output: { type: "text-page", content: "hello", mime: "text/plain", offset: 2, truncated: true, next: 3 },
|
output: { type: "text-page", content: "hello", mime: "text/plain", offset: 2, truncated: true, next: 3 },
|
||||||
})
|
})
|
||||||
if (result.status !== "completed") return
|
if (result.status !== "completed") return
|
||||||
|
expect(result.metadata).toEqual({ truncated: true })
|
||||||
expect(result.content).toEqual([
|
expect(result.content).toEqual([
|
||||||
{
|
{
|
||||||
type: "text",
|
type: "text",
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import { PluginRuntime } from "@opencode-ai/core/plugin/runtime"
|
|||||||
import { Shell } from "@opencode-ai/core/shell"
|
import { Shell } from "@opencode-ai/core/shell"
|
||||||
import { Shell as ShellSchema } from "@opencode-ai/schema/shell"
|
import { Shell as ShellSchema } from "@opencode-ai/schema/shell"
|
||||||
import { ShellTool } from "@opencode-ai/core/tool/plugin/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 { Tool } from "@opencode-ai/core/tool"
|
||||||
import { tmpdir } from "./fixture/tmpdir"
|
import { tmpdir } from "./fixture/tmpdir"
|
||||||
import { testEffect } from "./lib/effect"
|
import { testEffect } from "./lib/effect"
|
||||||
@@ -171,6 +172,9 @@ const overflowCommand = (bytes: number) =>
|
|||||||
isWindows
|
isWindows
|
||||||
? `[Console]::Out.Write('output-start' + ('x' * ${bytes}) + 'output-end'); Start-Sleep -Milliseconds 100`
|
? `[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`
|
: `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) =>
|
const progressOverflowCommand = (bytes: number, release: string) =>
|
||||||
isWindows
|
isWindows
|
||||||
? `[Console]::Out.Write(('x' * ${bytes})); while (!(Test-Path -LiteralPath '${release}')) { Start-Sleep -Milliseconds 50 }`
|
? `[Console]::Out.Write(('x' * ${bytes})); while (!(Test-Path -LiteralPath '${release}')) { Start-Sleep -Milliseconds 50 }`
|
||||||
@@ -477,7 +481,7 @@ describe("ShellTool", () => {
|
|||||||
Effect.promise(() => tmpdir()),
|
Effect.promise(() => tmpdir()),
|
||||||
(tmp) => {
|
(tmp) => {
|
||||||
reset()
|
reset()
|
||||||
const bytes = ShellTool.MAX_CAPTURE_BYTES + 1024
|
const bytes = ToolOutput.MAX_BYTES + 1024
|
||||||
return withSession(tmp.path, (registry) =>
|
return withSession(tmp.path, (registry) =>
|
||||||
executeTool(registry, call({ command: overflowCommand(bytes) }, "call-overflow")),
|
executeTool(registry, call({ command: overflowCommand(bytes) }, "call-overflow")),
|
||||||
).pipe(
|
).pipe(
|
||||||
@@ -501,6 +505,33 @@ describe("ShellTool", () => {
|
|||||||
{ timeout: 15_000 },
|
{ 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(
|
it.live(
|
||||||
"reports the shell ID for a running command",
|
"reports the shell ID for a running command",
|
||||||
() =>
|
() =>
|
||||||
@@ -515,7 +546,7 @@ describe("ShellTool", () => {
|
|||||||
const observed = yield* Deferred.make<string>()
|
const observed = yield* Deferred.make<string>()
|
||||||
yield* executeTool(registry, {
|
yield* executeTool(registry, {
|
||||||
...call(
|
...call(
|
||||||
{ command: progressOverflowCommand(ShellTool.MAX_CAPTURE_BYTES + 1024, release) },
|
{ command: progressOverflowCommand(ToolOutput.MAX_BYTES + 1024, release) },
|
||||||
"call-progress",
|
"call-progress",
|
||||||
),
|
),
|
||||||
progress: (update) =>
|
progress: (update) =>
|
||||||
|
|||||||
@@ -90,13 +90,7 @@ const withTool = <A, E, R>(directory: string, body: (registry: Tool.Interface) =
|
|||||||
}).pipe(
|
}).pipe(
|
||||||
Effect.provide(
|
Effect.provide(
|
||||||
AppNodeBuilder.build(
|
AppNodeBuilder.build(
|
||||||
LayerNode.group([
|
LayerNode.group([Tool.node, Tool.node, LocationMutation.node, FileMutation.node, writeToolNode]),
|
||||||
Tool.node,
|
|
||||||
Tool.node,
|
|
||||||
LocationMutation.node,
|
|
||||||
FileMutation.node,
|
|
||||||
writeToolNode,
|
|
||||||
]),
|
|
||||||
[
|
[
|
||||||
[FSUtil.node, filesystem],
|
[FSUtil.node, filesystem],
|
||||||
[Location.node, activeLocation],
|
[Location.node, activeLocation],
|
||||||
@@ -230,7 +224,10 @@ describe("WriteTool", () => {
|
|||||||
const deduplicated = path.join(tmp.path, "deduplicated.txt")
|
const deduplicated = path.join(tmp.path, "deduplicated.txt")
|
||||||
formatFile = (target) =>
|
formatFile = (target) =>
|
||||||
Effect.promise(async () => {
|
Effect.promise(async () => {
|
||||||
await fs.writeFile(target, `\uFEFF\uFEFF\uFEFF${(await fs.readFile(target, "utf8")).replace(/^\uFEFF+/, "")}`)
|
await fs.writeFile(
|
||||||
|
target,
|
||||||
|
`\uFEFF\uFEFF\uFEFF${(await fs.readFile(target, "utf8")).replace(/^\uFEFF+/, "")}`,
|
||||||
|
)
|
||||||
return true
|
return true
|
||||||
})
|
})
|
||||||
return Effect.promise(() =>
|
return Effect.promise(() =>
|
||||||
@@ -323,24 +320,22 @@ describe("WriteTool", () => {
|
|||||||
).pipe(
|
).pipe(
|
||||||
Effect.andThen((settled) =>
|
Effect.andThen((settled) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const canonicalTarget = path.join(yield* Effect.promise(() => fs.realpath(outside.path)), "external.txt")
|
const absoluteTarget = target
|
||||||
expect(assertions.map((input) => input.action)).toEqual(["external_directory", "edit"])
|
expect(assertions.map((input) => input.action)).toEqual(["external_directory", "edit"])
|
||||||
expect(assertions[0]).toMatchObject({
|
expect(assertions[0]).toMatchObject({
|
||||||
resources: [
|
resources: [path.join(outside.path, "*").replaceAll("\\", "/")],
|
||||||
path.join(yield* Effect.promise(() => fs.realpath(outside.path)), "*").replaceAll("\\", "/"),
|
|
||||||
],
|
|
||||||
})
|
})
|
||||||
expect(assertions[1]).toMatchObject({ resources: [canonicalTarget.replaceAll("\\", "/")], save: ["*"] })
|
expect(assertions[1]).toMatchObject({ resources: [absoluteTarget.replaceAll("\\", "/")], save: ["*"] })
|
||||||
expect(settled).toMatchObject({
|
expect(settled).toMatchObject({
|
||||||
status: "completed",
|
status: "completed",
|
||||||
output: {
|
output: {
|
||||||
target: canonicalTarget,
|
target: absoluteTarget,
|
||||||
resource: canonicalTarget.replaceAll("\\", "/"),
|
resource: absoluteTarget.replaceAll("\\", "/"),
|
||||||
existed: false,
|
existed: false,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("external")
|
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("external")
|
||||||
expect(writes).toEqual([canonicalTarget])
|
expect(writes).toEqual([absoluteTarget])
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -368,12 +363,10 @@ describe("WriteTool", () => {
|
|||||||
),
|
),
|
||||||
Effect.andThen(
|
Effect.andThen(
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const canonicalRepo = yield* Effect.promise(() => fs.realpath(repo))
|
|
||||||
const canonicalNested = yield* Effect.promise(() => fs.realpath(nested))
|
|
||||||
expect(assertions[0]).toMatchObject({
|
expect(assertions[0]).toMatchObject({
|
||||||
action: "external_directory",
|
action: "external_directory",
|
||||||
resources: [path.join(canonicalNested, "*").replaceAll("\\", "/")],
|
resources: [path.join(nested, "*").replaceAll("\\", "/")],
|
||||||
save: [path.join(canonicalRepo, "*").replaceAll("\\", "/")],
|
save: [path.join(repo, "*").replaceAll("\\", "/")],
|
||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -522,6 +522,45 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo
|
|||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
.add(
|
||||||
|
HttpApiEndpoint.delete("session.pending.cancel", "/api/session/:sessionID/pending/:inputID", {
|
||||||
|
params: { sessionID: Session.ID, inputID: SessionMessage.ID },
|
||||||
|
success: HttpApiSchema.NoContent,
|
||||||
|
error: [ConflictError, SessionNotFoundError],
|
||||||
|
}).annotateMerge(
|
||||||
|
OpenApi.annotations({
|
||||||
|
identifier: "v2.session.pending.cancel",
|
||||||
|
summary: "Cancel pending input",
|
||||||
|
description: "Cancel an input that has not yet been promoted into session history.",
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.add(
|
||||||
|
HttpApiEndpoint.post("session.pending.steer", "/api/session/:sessionID/pending/:inputID/steer", {
|
||||||
|
params: { sessionID: Session.ID, inputID: SessionMessage.ID },
|
||||||
|
success: HttpApiSchema.NoContent,
|
||||||
|
error: [ConflictError, SessionNotFoundError],
|
||||||
|
}).annotateMerge(
|
||||||
|
OpenApi.annotations({
|
||||||
|
identifier: "v2.session.pending.steer",
|
||||||
|
summary: "Steer queued input",
|
||||||
|
description: "Change a queued input to steer delivery and wake session execution.",
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.add(
|
||||||
|
HttpApiEndpoint.post("session.pending.queue", "/api/session/:sessionID/pending/:inputID/queue", {
|
||||||
|
params: { sessionID: Session.ID, inputID: SessionMessage.ID },
|
||||||
|
success: HttpApiSchema.NoContent,
|
||||||
|
error: [ConflictError, SessionNotFoundError],
|
||||||
|
}).annotateMerge(
|
||||||
|
OpenApi.annotations({
|
||||||
|
identifier: "v2.session.pending.queue",
|
||||||
|
summary: "Queue pending steer",
|
||||||
|
description: "Change a pending steer to queued delivery.",
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
.add(
|
.add(
|
||||||
HttpApiEndpoint.get("session.instructions.entry.list", "/api/session/:sessionID/instructions/entries", {
|
HttpApiEndpoint.get("session.instructions.entry.list", "/api/session/:sessionID/instructions/entries", {
|
||||||
params: { sessionID: Session.ID },
|
params: { sessionID: Session.ID },
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ export const Info = Schema.Struct({
|
|||||||
id,
|
id,
|
||||||
name: Name.make(id),
|
name: Name.make(id),
|
||||||
request: { settings: {}, headers: {}, body: {} },
|
request: { settings: {}, headers: {}, body: {} },
|
||||||
mode: "all",
|
mode: "primary",
|
||||||
hidden: false,
|
hidden: false,
|
||||||
permissions: [
|
permissions: [
|
||||||
{ action: "*", resource: "*", effect: "allow" },
|
{ action: "*", resource: "*", effect: "allow" },
|
||||||
|
|||||||
@@ -152,13 +152,15 @@ export const Forked = Event.durable({
|
|||||||
})
|
})
|
||||||
export type Forked = typeof Forked.Type
|
export type Forked = typeof Forked.Type
|
||||||
|
|
||||||
|
const InputRef = {
|
||||||
|
...Base,
|
||||||
|
inputID: SessionMessage.ID,
|
||||||
|
}
|
||||||
|
|
||||||
export const InputPromoted = Event.durable({
|
export const InputPromoted = Event.durable({
|
||||||
type: "session.input.promoted",
|
type: "session.input.promoted",
|
||||||
...options,
|
...options,
|
||||||
schema: {
|
schema: InputRef,
|
||||||
sessionID: SessionID,
|
|
||||||
inputID: SessionMessage.ID,
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
export type InputPromoted = typeof InputPromoted.Type
|
export type InputPromoted = typeof InputPromoted.Type
|
||||||
|
|
||||||
@@ -166,13 +168,33 @@ export const InputAdmitted = Event.durable({
|
|||||||
type: "session.input.admitted",
|
type: "session.input.admitted",
|
||||||
...options,
|
...options,
|
||||||
schema: {
|
schema: {
|
||||||
...Base,
|
...InputRef,
|
||||||
inputID: SessionMessage.ID,
|
|
||||||
input: SessionPending.Message,
|
input: SessionPending.Message,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
export type InputAdmitted = typeof InputAdmitted.Type
|
export type InputAdmitted = typeof InputAdmitted.Type
|
||||||
|
|
||||||
|
export const InputCancelled = Event.durable({
|
||||||
|
type: "session.input.cancelled",
|
||||||
|
...options,
|
||||||
|
schema: InputRef,
|
||||||
|
})
|
||||||
|
export type InputCancelled = typeof InputCancelled.Type
|
||||||
|
|
||||||
|
export const InputSteered = Event.durable({
|
||||||
|
type: "session.input.steered",
|
||||||
|
...options,
|
||||||
|
schema: InputRef,
|
||||||
|
})
|
||||||
|
export type InputSteered = typeof InputSteered.Type
|
||||||
|
|
||||||
|
export const InputQueued = Event.durable({
|
||||||
|
type: "session.input.queued",
|
||||||
|
...options,
|
||||||
|
schema: InputRef,
|
||||||
|
})
|
||||||
|
export type InputQueued = typeof InputQueued.Type
|
||||||
|
|
||||||
export namespace Execution {
|
export namespace Execution {
|
||||||
export const Started = Event.durable({ type: "session.execution.started", ...options, schema: Base })
|
export const Started = Event.durable({ type: "session.execution.started", ...options, schema: Base })
|
||||||
export type Started = typeof Started.Type
|
export type Started = typeof Started.Type
|
||||||
@@ -580,6 +602,9 @@ export const Definitions = Event.inventory(
|
|||||||
Forked,
|
Forked,
|
||||||
InputPromoted,
|
InputPromoted,
|
||||||
InputAdmitted,
|
InputAdmitted,
|
||||||
|
InputCancelled,
|
||||||
|
InputSteered,
|
||||||
|
InputQueued,
|
||||||
Execution.Started,
|
Execution.Started,
|
||||||
Execution.Succeeded,
|
Execution.Succeeded,
|
||||||
Execution.Failed,
|
Execution.Failed,
|
||||||
@@ -621,13 +646,16 @@ export const DurableDefinitions = Event.inventory(
|
|||||||
...Definitions.filter((definition) => definition.durability === "durable"),
|
...Definitions.filter((definition) => definition.durability === "durable"),
|
||||||
UsageRecorded,
|
UsageRecorded,
|
||||||
)
|
)
|
||||||
|
export const EphemeralDefinitions = Event.inventory(
|
||||||
|
...Definitions.filter((definition) => definition.durability === "ephemeral"),
|
||||||
|
)
|
||||||
|
|
||||||
export const Durable = Schema.Union(DurableDefinitions, { mode: "oneOf" })
|
export const Durable = Schema.Union(DurableDefinitions, { mode: "oneOf" })
|
||||||
.pipe(Schema.toTaggedUnion("type"))
|
.pipe(Schema.toTaggedUnion("type"))
|
||||||
.annotate({ identifier: "Session.Event.Durable" })
|
.annotate({ identifier: "Session.Event.Durable" })
|
||||||
export type DurableEvent = typeof Durable.Type
|
export type DurableEvent = typeof Durable.Type
|
||||||
|
|
||||||
export const All = Schema.Union(Event.inventory(...Definitions, UsageRecorded), { mode: "oneOf" }).pipe(
|
export const All = Schema.Union([Durable, ...EphemeralDefinitions], { mode: "oneOf" }).pipe(
|
||||||
Schema.toTaggedUnion("type"),
|
Schema.toTaggedUnion("type"),
|
||||||
)
|
)
|
||||||
export type Event = typeof All.Type
|
export type Event = typeof All.Type
|
||||||
|
|||||||
@@ -84,6 +84,9 @@ describe("public event manifest", () => {
|
|||||||
"session.forked.2",
|
"session.forked.2",
|
||||||
"session.input.promoted.1",
|
"session.input.promoted.1",
|
||||||
"session.input.admitted.1",
|
"session.input.admitted.1",
|
||||||
|
"session.input.cancelled.1",
|
||||||
|
"session.input.steered.1",
|
||||||
|
"session.input.queued.1",
|
||||||
"session.execution.started.1",
|
"session.execution.started.1",
|
||||||
"session.execution.succeeded.1",
|
"session.execution.succeeded.1",
|
||||||
"session.execution.failed.1",
|
"session.execution.failed.1",
|
||||||
|
|||||||
@@ -26,6 +26,22 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
|
|||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const session = yield* Session.Service
|
const session = yield* Session.Service
|
||||||
const transfer = yield* SessionTransfer.Service
|
const transfer = yield* SessionTransfer.Service
|
||||||
|
const pendingMutation = (effect: ReturnType<typeof session.cancelPending>, conflict: string) =>
|
||||||
|
effect.pipe(
|
||||||
|
Effect.catchTag(
|
||||||
|
"Session.NotFoundError",
|
||||||
|
(error) =>
|
||||||
|
new SessionNotFoundError({
|
||||||
|
sessionID: error.sessionID,
|
||||||
|
message: `Session not found: ${error.sessionID}`,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
Effect.catchTag(
|
||||||
|
"Session.PendingInputConflictError",
|
||||||
|
(error) => new ConflictError({ resource: error.inputID, message: `${conflict}: ${error.inputID}` }),
|
||||||
|
),
|
||||||
|
Effect.as(HttpApiSchema.NoContent.make()),
|
||||||
|
)
|
||||||
|
|
||||||
return handlers
|
return handlers
|
||||||
.handle(
|
.handle(
|
||||||
@@ -661,6 +677,33 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
|
|||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
.handle(
|
||||||
|
"session.pending.cancel",
|
||||||
|
Effect.fn(function* (ctx) {
|
||||||
|
return yield* pendingMutation(
|
||||||
|
session.cancelPending({ sessionID: ctx.params.sessionID, inputID: ctx.params.inputID }),
|
||||||
|
"Pending input can no longer be cancelled",
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.handle(
|
||||||
|
"session.pending.steer",
|
||||||
|
Effect.fn(function* (ctx) {
|
||||||
|
return yield* pendingMutation(
|
||||||
|
session.steerPending({ sessionID: ctx.params.sessionID, inputID: ctx.params.inputID }),
|
||||||
|
"Pending input is no longer queued",
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.handle(
|
||||||
|
"session.pending.queue",
|
||||||
|
Effect.fn(function* (ctx) {
|
||||||
|
return yield* pendingMutation(
|
||||||
|
session.queuePending({ sessionID: ctx.params.sessionID, inputID: ctx.params.inputID }),
|
||||||
|
"Pending input is no longer a steer",
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
)
|
||||||
.handle(
|
.handle(
|
||||||
"session.instructions.entry.list",
|
"session.instructions.entry.list",
|
||||||
Effect.fn(function* (ctx) {
|
Effect.fn(function* (ctx) {
|
||||||
|
|||||||
@@ -462,6 +462,7 @@ function newLayout() {
|
|||||||
function webSearchProviderLabel(provider: unknown) {
|
function webSearchProviderLabel(provider: unknown) {
|
||||||
if (provider === "parallel") return "Parallel Web Search"
|
if (provider === "parallel") return "Parallel Web Search"
|
||||||
if (provider === "exa") return "Exa Web Search"
|
if (provider === "exa") return "Exa Web Search"
|
||||||
|
if (provider === "firecrawl") return "Firecrawl Web Search"
|
||||||
return "Web Search"
|
return "Web Search"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -512,7 +512,7 @@ function App(props: { pair?: DialogPairCredentials }) {
|
|||||||
const terminalTitleEnabled = () => config.data.terminal?.title ?? true
|
const terminalTitleEnabled = () => config.data.terminal?.title ?? true
|
||||||
const copyOnSelectEnabled = () => config.data.terminal?.copy_on_select ?? process.platform !== "win32"
|
const copyOnSelectEnabled = () => config.data.terminal?.copy_on_select ?? process.platform !== "win32"
|
||||||
const pasteSummaryEnabled = () => config.data.prompt?.paste !== "full"
|
const pasteSummaryEnabled = () => config.data.prompt?.paste !== "full"
|
||||||
const tabsVertical = () => (config.data.tabs?.vertical ?? false) && sessionTabsFitVertically(dimensions().width)
|
const tabsVertical = () => config.data.tabs.layout === "vertical" && sessionTabsFitVertically(dimensions().width)
|
||||||
const tabsVisible = () =>
|
const tabsVisible = () =>
|
||||||
sessionTabs.enabled() && (sessionTabs.tabs().length > 0 || sessionTabs.newTab()) && route.data.type !== "plugin"
|
sessionTabs.enabled() && (sessionTabs.tabs().length > 0 || sessionTabs.newTab()) && route.data.type !== "plugin"
|
||||||
|
|
||||||
|
|||||||
@@ -101,12 +101,11 @@ export const settings: Setting[] = [
|
|||||||
labels: ["current directory", "global"],
|
labels: ["current directory", "global"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Vertical",
|
title: "Layout",
|
||||||
category: "Tabs",
|
category: "Tabs",
|
||||||
path: ["tabs", "vertical"],
|
path: ["tabs", "layout"],
|
||||||
default: false,
|
default: "horizontal",
|
||||||
values: [false, true],
|
values: ["horizontal", "vertical"],
|
||||||
labels: ["off", "on"],
|
|
||||||
keywords: ["sidebar", "orientation", "left"],
|
keywords: ["sidebar", "orientation", "left"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -8,17 +8,21 @@ import * as fuzzysort from "fuzzysort"
|
|||||||
import { useConnected } from "./use-connected"
|
import { useConnected } from "./use-connected"
|
||||||
import { useData } from "../context/data"
|
import { useData } from "../context/data"
|
||||||
import { modelPreferenceKey } from "../model-preference"
|
import { modelPreferenceKey } from "../model-preference"
|
||||||
|
import { useLocation } from "../context/location"
|
||||||
|
|
||||||
export function DialogModel(props: { providerID?: string }) {
|
export function DialogModel(props: { providerID?: string }) {
|
||||||
const local = useLocal()
|
const local = useLocal()
|
||||||
const data = useData()
|
const data = useData()
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
|
const location = useLocation()
|
||||||
const [query, setQuery] = createSignal("")
|
const [query, setQuery] = createSignal("")
|
||||||
const favoritePriority = new Set(local.model.favorite().map(modelPreferenceKey))
|
const favoritePriority = new Set(local.model.favorite().map(modelPreferenceKey))
|
||||||
|
|
||||||
const connected = useConnected()
|
const connected = useConnected()
|
||||||
const providers = createMemo(() => new Map((data.location.provider.list() ?? []).map((item) => [item.id, item])))
|
const providers = createMemo(
|
||||||
const models = createMemo(() => data.location.model.list() ?? [])
|
() => new Map((data.location.provider.list(location.ref) ?? []).map((item) => [item.id, item])),
|
||||||
|
)
|
||||||
|
const models = createMemo(() => data.location.model.list(location.ref) ?? [])
|
||||||
|
|
||||||
const showExtra = createMemo(() => connected() && !props.providerID)
|
const showExtra = createMemo(() => connected() && !props.providerID)
|
||||||
|
|
||||||
|
|||||||
@@ -53,12 +53,14 @@ import { useLocation } from "../../context/location"
|
|||||||
import { Keymap, type KeymapCommand } from "../../context/keymap"
|
import { Keymap, type KeymapCommand } from "../../context/keymap"
|
||||||
import { abbreviateHome } from "../../runtime"
|
import { abbreviateHome } from "../../runtime"
|
||||||
import { PluginSlot } from "../../plugin/render"
|
import { PluginSlot } from "../../plugin/render"
|
||||||
|
import type { SessionPending } from "@opencode-ai/schema/session-pending"
|
||||||
|
|
||||||
export type PromptProps = {
|
export type PromptProps = {
|
||||||
sessionID?: string
|
sessionID?: string
|
||||||
visible?: boolean
|
visible?: boolean
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
onSubmit?: () => void
|
onSubmit?: () => void
|
||||||
|
onEmptySubmit?: () => boolean | Promise<boolean>
|
||||||
ref?: (ref: PromptRef | undefined) => void
|
ref?: (ref: PromptRef | undefined) => void
|
||||||
hint?: JSX.Element
|
hint?: JSX.Element
|
||||||
right?: JSX.Element
|
right?: JSX.Element
|
||||||
@@ -327,10 +329,6 @@ export function Prompt(props: PromptProps) {
|
|||||||
if (!session) return
|
if (!session) return
|
||||||
const agent = session.agent && local.agent.list().find((agent) => agent.id === session.agent)
|
const agent = session.agent && local.agent.list().find((agent) => agent.id === session.agent)
|
||||||
if (agent && !args.agent) local.agent.set(agent.id)
|
if (agent && !args.agent) local.agent.set(agent.id)
|
||||||
if (session.model) {
|
|
||||||
local.model.set({ providerID: session.model.providerID, modelID: session.model.id })
|
|
||||||
local.model.variant.set(session.model.variant)
|
|
||||||
}
|
|
||||||
syncedSessionID = sessionID
|
syncedSessionID = sessionID
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -361,6 +359,20 @@ export function Prompt(props: PromptProps) {
|
|||||||
dialog.clear()
|
dialog.clear()
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: "Queue prompt",
|
||||||
|
name: "prompt.queue",
|
||||||
|
category: "Prompt",
|
||||||
|
palette: undefined,
|
||||||
|
run: async (_input: string | undefined, event?: KeyEvent) => {
|
||||||
|
event?.preventDefault()
|
||||||
|
event?.stopPropagation()
|
||||||
|
if (!input.focused) return
|
||||||
|
const handled = await submit("queue")
|
||||||
|
if (!handled) return
|
||||||
|
dialog.clear()
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: "Remove editor context",
|
title: "Remove editor context",
|
||||||
name: "prompt.editor_context.clear",
|
name: "prompt.editor_context.clear",
|
||||||
@@ -519,6 +531,11 @@ export function Prompt(props: PromptProps) {
|
|||||||
commands: promptCommands(),
|
commands: promptCommands(),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
Keymap.createLayer(() => ({
|
||||||
|
priority: 1,
|
||||||
|
bindings: ["prompt.queue"],
|
||||||
|
}))
|
||||||
|
|
||||||
Keymap.createLayer(() => ({
|
Keymap.createLayer(() => ({
|
||||||
bindings: [
|
bindings: [
|
||||||
"prompt.submit",
|
"prompt.submit",
|
||||||
@@ -904,7 +921,7 @@ export function Prompt(props: PromptProps) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
let submitting = false
|
let submitting = false
|
||||||
async function submit() {
|
async function submit(delivery: SessionPending.Delivery = "steer") {
|
||||||
// Prevent overlapping invocations (e.g. a double-pressed Enter, or the
|
// Prevent overlapping invocations (e.g. a double-pressed Enter, or the
|
||||||
// input's native onSubmit racing another dispatch). Without this guard,
|
// input's native onSubmit racing another dispatch). Without this guard,
|
||||||
// a second call slips past the empty-input check before the first call
|
// a second call slips past the empty-input check before the first call
|
||||||
@@ -914,13 +931,13 @@ export function Prompt(props: PromptProps) {
|
|||||||
if (submitting) return false
|
if (submitting) return false
|
||||||
submitting = true
|
submitting = true
|
||||||
try {
|
try {
|
||||||
return await submitInner()
|
return await submitInner(delivery)
|
||||||
} finally {
|
} finally {
|
||||||
submitting = false
|
submitting = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function submitInner() {
|
async function submitInner(delivery: SessionPending.Delivery) {
|
||||||
// IME: double-defer may fire before onContentChange flushes the last
|
// IME: double-defer may fire before onContentChange flushes the last
|
||||||
// composed character (e.g. Korean hangul) to the store, so read
|
// composed character (e.g. Korean hangul) to the store, so read
|
||||||
// plainText directly and sync before any downstream reads.
|
// plainText directly and sync before any downstream reads.
|
||||||
@@ -931,27 +948,76 @@ export function Prompt(props: PromptProps) {
|
|||||||
if (props.disabled) return false
|
if (props.disabled) return false
|
||||||
if (move.creating()) return false
|
if (move.creating()) return false
|
||||||
if (auto()?.visible) return false
|
if (auto()?.visible) return false
|
||||||
if (!store.prompt.text) return false
|
|
||||||
const trimmed = store.prompt.text.trim()
|
const trimmed = store.prompt.text.trim()
|
||||||
|
if (!trimmed) return delivery === "steer" ? (await props.onEmptySubmit?.()) === true : false
|
||||||
|
if (
|
||||||
|
delivery === "queue" &&
|
||||||
|
(store.mode === "shell" || trimmed === "exit" || trimmed === "quit" || trimmed === ":q")
|
||||||
|
) {
|
||||||
|
toast.show({ message: "This prompt cannot be queued", variant: "warning" })
|
||||||
|
return false
|
||||||
|
}
|
||||||
if (trimmed === "exit" || trimmed === "quit" || trimmed === ":q") {
|
if (trimmed === "exit" || trimmed === "quit" || trimmed === ":q") {
|
||||||
void exit()
|
void exit()
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
const slash = argumentSlash(store.prompt.text, keymapCommands())
|
const slash = argumentSlash(store.prompt.text, keymapCommands())
|
||||||
if (slash) {
|
if (slash) {
|
||||||
|
if (delivery === "queue") {
|
||||||
|
toast.show({ message: "This prompt cannot be queued", variant: "warning" })
|
||||||
|
return false
|
||||||
|
}
|
||||||
clearPrompt()
|
clearPrompt()
|
||||||
await slash.command.run(slash.input)
|
await slash.command.run(slash.input)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
const inputText = expandTrackedPastedText(
|
||||||
|
store.prompt.text,
|
||||||
|
input.extmarks.getAllForTypeId(promptPartTypeId).flatMap((extmark) => {
|
||||||
|
const ref = store.extmarkToPart.get(extmark.id)
|
||||||
|
if (ref?.type !== "pasted") return []
|
||||||
|
const part = store.prompt.pasted[ref.index]
|
||||||
|
if (!part) return []
|
||||||
|
return [{ start: extmark.start, end: extmark.end, text: part.text }]
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
const slashHead = parseSlashHead(inputText, /\s/)
|
||||||
|
const isSkill =
|
||||||
|
slashHead !== undefined &&
|
||||||
|
(data.location.skill.list(currentLocation.ref) ?? []).some(
|
||||||
|
(skill) => skill.slash === true && skill.id === slashHead.name,
|
||||||
|
)
|
||||||
|
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 editorSelection = editorContext()
|
||||||
|
const pendingEditorSelection = editorSelection && editor.labelState() === "pending" ? editorSelection : undefined
|
||||||
|
if (delivery === "queue" && pendingEditorSelection) {
|
||||||
|
toast.show({ message: "Editor context cannot be queued", variant: "warning" })
|
||||||
|
return false
|
||||||
|
}
|
||||||
const agent = local.agent.current()
|
const agent = local.agent.current()
|
||||||
if (!agent) return false
|
if (!agent) return false
|
||||||
const selectedModel = local.model.current()
|
const selection = local.model.selection()
|
||||||
if (!selectedModel) {
|
if (!selection) {
|
||||||
void promptModelWarning()
|
void promptModelWarning()
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
const usesModel = !props.sessionID || (store.mode !== "shell" && !isSkill)
|
||||||
|
if (usesModel && !local.model.available(selection)) {
|
||||||
|
toast.show({
|
||||||
|
title: "Model unavailable",
|
||||||
|
message: `${selection.providerID}/${selection.modelID} is not available in this session's location`,
|
||||||
|
variant: "warning",
|
||||||
|
})
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
const variant = local.model.variant.current()
|
const variant = selection.variant
|
||||||
let sessionID = props.sessionID
|
let sessionID = props.sessionID
|
||||||
let session = sessionID ? data.session.get(sessionID) : undefined
|
let session = sessionID ? data.session.get(sessionID) : undefined
|
||||||
let finishMoveProgress = false
|
let finishMoveProgress = false
|
||||||
@@ -969,8 +1035,8 @@ export function Prompt(props: PromptProps) {
|
|||||||
location: directory ? { directory } : location,
|
location: directory ? { directory } : location,
|
||||||
agent: agent.id,
|
agent: agent.id,
|
||||||
model: {
|
model: {
|
||||||
providerID: selectedModel.providerID,
|
providerID: selection.providerID,
|
||||||
id: selectedModel.modelID,
|
id: selection.modelID,
|
||||||
variant,
|
variant,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -990,21 +1056,8 @@ export function Prompt(props: PromptProps) {
|
|||||||
session = created
|
session = created
|
||||||
}
|
}
|
||||||
|
|
||||||
const inputText = expandTrackedPastedText(
|
|
||||||
store.prompt.text,
|
|
||||||
input.extmarks.getAllForTypeId(promptPartTypeId).flatMap((extmark) => {
|
|
||||||
const ref = store.extmarkToPart.get(extmark.id)
|
|
||||||
if (ref?.type !== "pasted") return []
|
|
||||||
const part = store.prompt.pasted[ref.index]
|
|
||||||
if (!part) return []
|
|
||||||
return [{ start: extmark.start, end: extmark.end, text: part.text }]
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
// Capture mode before it gets reset
|
// Capture mode before it gets reset
|
||||||
const currentMode = store.mode
|
const currentMode = store.mode
|
||||||
const editorSelection = editorContext()
|
|
||||||
const pendingEditorSelection = editorSelection && editor.labelState() === "pending" ? editorSelection : undefined
|
|
||||||
|
|
||||||
if (store.mode === "shell") {
|
if (store.mode === "shell") {
|
||||||
move.startSubmit()
|
move.startSubmit()
|
||||||
@@ -1013,43 +1066,31 @@ export function Prompt(props: PromptProps) {
|
|||||||
command: inputText,
|
command: inputText,
|
||||||
})
|
})
|
||||||
setStore("mode", "normal")
|
setStore("mode", "normal")
|
||||||
} else if (
|
} else if (slashHead && isCommand) {
|
||||||
inputText.startsWith("/") &&
|
|
||||||
(data.location.command.list(currentLocation.current) ?? []).some(
|
|
||||||
(command) => command.name === inputText.split("\n")[0].split(" ")[0].slice(1),
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
move.startSubmit()
|
move.startSubmit()
|
||||||
// Parse command from first line, preserve multi-line content in arguments
|
const model = { providerID: selection.providerID, id: selection.modelID, variant }
|
||||||
const firstLineEnd = inputText.indexOf("\n")
|
const cancelCommit = local.model.trackSessionCommit(sessionID, model)
|
||||||
const firstLine = firstLineEnd === -1 ? inputText : inputText.slice(0, firstLineEnd)
|
|
||||||
const [command, ...firstLineArgs] = firstLine.split(" ")
|
|
||||||
const restOfInput = firstLineEnd === -1 ? "" : inputText.slice(firstLineEnd + 1)
|
|
||||||
const args = firstLineArgs.join(" ") + (restOfInput ? "\n" + restOfInput : "")
|
|
||||||
|
|
||||||
void client.api.session
|
void client.api.session
|
||||||
.command({
|
.command({
|
||||||
sessionID,
|
sessionID,
|
||||||
command: command.slice(1),
|
command: slashHead.name,
|
||||||
arguments: args,
|
arguments: slashHead.arguments,
|
||||||
agent: agent.id,
|
agent: agent.id,
|
||||||
model: { providerID: selectedModel.providerID, id: selectedModel.modelID, variant },
|
model,
|
||||||
files: store.prompt.files,
|
files: store.prompt.files,
|
||||||
agents: store.prompt.agents,
|
agents: store.prompt.agents,
|
||||||
|
delivery,
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
|
cancelCommit()
|
||||||
toast.show({ title: "Failed to run command", message: errorMessage(error), variant: "error" })
|
toast.show({ title: "Failed to run command", message: errorMessage(error), variant: "error" })
|
||||||
})
|
})
|
||||||
} else if (
|
} else if (isSkill) {
|
||||||
inputText.startsWith("/") &&
|
|
||||||
(data.location.skill.list(currentLocation.current) ?? []).some(
|
|
||||||
(skill) => skill.slash === true && skill.id === inputText.split("\n")[0].split(" ")[0].slice(1),
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
move.startSubmit()
|
move.startSubmit()
|
||||||
void client.api.session.skill({
|
void client.api.session.skill({
|
||||||
sessionID,
|
sessionID,
|
||||||
skill: inputText.split("\n")[0].split(" ")[0].slice(1),
|
skill: slashHead.name,
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
move.startSubmit()
|
move.startSubmit()
|
||||||
@@ -1061,13 +1102,15 @@ export function Prompt(props: PromptProps) {
|
|||||||
await client.api.session.switchAgent({ sessionID, agent: agent.id })
|
await client.api.session.switchAgent({ sessionID, agent: agent.id })
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
session?.model?.providerID !== selectedModel.providerID ||
|
session?.model?.providerID !== selection.providerID ||
|
||||||
session.model.id !== selectedModel.modelID ||
|
session.model.id !== selection.modelID ||
|
||||||
(session.model.variant ?? "default") !== (variant ?? "default")
|
(session.model.variant ?? "default") !== (variant ?? "default")
|
||||||
) {
|
) {
|
||||||
await client.api.session.switchModel({
|
const model = { providerID: selection.providerID, id: selection.modelID, variant }
|
||||||
sessionID,
|
const cancelCommit = local.model.trackSessionCommit(sessionID, model)
|
||||||
model: { providerID: selectedModel.providerID, id: selectedModel.modelID, variant },
|
await client.api.session.switchModel({ sessionID, model }).catch((error) => {
|
||||||
|
cancelCommit()
|
||||||
|
throw error
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
if (session?.revert) {
|
if (session?.revert) {
|
||||||
@@ -1103,6 +1146,7 @@ export function Prompt(props: PromptProps) {
|
|||||||
text: inputText,
|
text: inputText,
|
||||||
files: store.prompt.files,
|
files: store.prompt.files,
|
||||||
agents: store.prompt.agents,
|
agents: store.prompt.agents,
|
||||||
|
delivery,
|
||||||
})
|
})
|
||||||
.then(
|
.then(
|
||||||
() => undefined,
|
() => undefined,
|
||||||
@@ -1320,10 +1364,7 @@ export function Prompt(props: PromptProps) {
|
|||||||
return `Ask anything... "${list()[store.placeholder % list().length]}"`
|
return `Ask anything... "${list()[store.placeholder % list().length]}"`
|
||||||
})()
|
})()
|
||||||
if (!value) return undefined
|
if (!value) return undefined
|
||||||
const width =
|
const width = dimensions().width < 44 ? dimensions().width - 5 : Math.min(75, dimensions().width - 4) - 5
|
||||||
dimensions().width < 44
|
|
||||||
? dimensions().width - 5
|
|
||||||
: Math.min(75, dimensions().width - 4) - 5
|
|
||||||
return Locale.takeWidth(value, Math.max(1, width)).trimEnd()
|
return Locale.takeWidth(value, Math.max(1, width)).trimEnd()
|
||||||
})
|
})
|
||||||
const locationLabel = createMemo(() => {
|
const locationLabel = createMemo(() => {
|
||||||
|
|||||||
@@ -132,8 +132,8 @@ export const Info = Schema.Struct({
|
|||||||
scope: Schema.optional(Schema.Literals(["global", "cwd"])).annotate({
|
scope: Schema.optional(Schema.Literals(["global", "cwd"])).annotate({
|
||||||
description: "Share tabs globally or keep a separate set for each working directory",
|
description: "Share tabs globally or keep a separate set for each working directory",
|
||||||
}),
|
}),
|
||||||
vertical: Schema.optional(Schema.Boolean).annotate({
|
layout: Schema.optional(Schema.Literals(["horizontal", "vertical"])).annotate({
|
||||||
description: "Show tabs in a left sidebar instead of a horizontal strip",
|
description: "Show tabs in a horizontal strip or vertical sidebar",
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
).annotate({ description: "Tab strip settings" }),
|
).annotate({ description: "Tab strip settings" }),
|
||||||
@@ -194,7 +194,7 @@ export type Resolved = Omit<Info, "attention" | "keybinds" | "leader" | "mouse"
|
|||||||
tabs: {
|
tabs: {
|
||||||
enabled: boolean
|
enabled: boolean
|
||||||
scope: "global" | "cwd"
|
scope: "global" | "cwd"
|
||||||
vertical?: boolean
|
layout: "horizontal" | "vertical"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -230,6 +230,7 @@ export function resolve(input: Info, options: { terminalSuspend: boolean }): Res
|
|||||||
...input.tabs,
|
...input.tabs,
|
||||||
enabled: input.tabs?.enabled ?? true,
|
enabled: input.tabs?.enabled ?? true,
|
||||||
scope: input.tabs?.scope ?? "cwd",
|
scope: input.tabs?.scope ?? "cwd",
|
||||||
|
layout: input.tabs?.layout ?? "horizontal",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -103,7 +103,8 @@ export const Definitions = {
|
|||||||
session_interrupt: keybind("escape", "Interrupt current session"),
|
session_interrupt: keybind("escape", "Interrupt current session"),
|
||||||
session_background: keybind("ctrl+b", "Background blocking session tools"),
|
session_background: keybind("ctrl+b", "Background blocking session tools"),
|
||||||
session_compact: keybind("<leader>c", "Compact the session"),
|
session_compact: keybind("<leader>c", "Compact the session"),
|
||||||
session_queued_prompts: keybind("<leader>q", "View pending work"),
|
session_queued_prompts: keybind("<leader>q", "View queued prompts"),
|
||||||
|
queued_prompt_delete: keybind("ctrl+d", "Delete queued prompt"),
|
||||||
session_child_first: keybind("down", "Toggle subagent picker"),
|
session_child_first: keybind("down", "Toggle subagent picker"),
|
||||||
session_parent: keybind("up", "Go to parent session"),
|
session_parent: keybind("up", "Go to parent session"),
|
||||||
session_pin_toggle: keybind("ctrl+f", "Pin or unpin session in the session list"),
|
session_pin_toggle: keybind("ctrl+f", "Pin or unpin session in the session list"),
|
||||||
@@ -161,6 +162,7 @@ export const Definitions = {
|
|||||||
display_thinking: keybind("none", "Toggle thinking blocks visibility"),
|
display_thinking: keybind("none", "Toggle thinking blocks visibility"),
|
||||||
|
|
||||||
prompt_submit: keybind("none", "Submit prompt"),
|
prompt_submit: keybind("none", "Submit prompt"),
|
||||||
|
prompt_queue: keybind("alt+return", "Queue prompt"),
|
||||||
prompt_editor_context_clear: keybind("none", "Clear editor context"),
|
prompt_editor_context_clear: keybind("none", "Clear editor context"),
|
||||||
prompt_skills: keybind("none", "Open skill selector"),
|
prompt_skills: keybind("none", "Open skill selector"),
|
||||||
prompt_stash: keybind("none", "Stash prompt"),
|
prompt_stash: keybind("none", "Stash prompt"),
|
||||||
@@ -170,7 +172,7 @@ export const Definitions = {
|
|||||||
input_clear: keybind("ctrl+c", "Clear input field"),
|
input_clear: keybind("ctrl+c", "Clear input field"),
|
||||||
input_paste: keybind({ key: "ctrl+v", preventDefault: false }, "Paste from clipboard"),
|
input_paste: keybind({ key: "ctrl+v", preventDefault: false }, "Paste from clipboard"),
|
||||||
input_submit: keybind("return", "Submit input"),
|
input_submit: keybind("return", "Submit input"),
|
||||||
input_newline: keybind("shift+return,ctrl+return,alt+return,ctrl+j", "Insert newline in input"),
|
input_newline: keybind("shift+return,ctrl+return,ctrl+j", "Insert newline in input"),
|
||||||
input_move_left: keybind("left,ctrl+b", "Move cursor left in input"),
|
input_move_left: keybind("left,ctrl+b", "Move cursor left in input"),
|
||||||
input_move_right: keybind("right,ctrl+f", "Move cursor right in input"),
|
input_move_right: keybind("right,ctrl+f", "Move cursor right in input"),
|
||||||
input_move_up: keybind("up", "Move cursor up in input"),
|
input_move_up: keybind("up", "Move cursor up in input"),
|
||||||
@@ -305,6 +307,7 @@ export const CommandMap = {
|
|||||||
session_background: "session.background",
|
session_background: "session.background",
|
||||||
session_compact: "session.compact",
|
session_compact: "session.compact",
|
||||||
session_queued_prompts: "session.queued_prompts",
|
session_queued_prompts: "session.queued_prompts",
|
||||||
|
queued_prompt_delete: "queued_prompt.delete",
|
||||||
session_child_first: "session.child.first",
|
session_child_first: "session.child.first",
|
||||||
session_parent: "session.parent",
|
session_parent: "session.parent",
|
||||||
session_pin_toggle: "session.pin.toggle",
|
session_pin_toggle: "session.pin.toggle",
|
||||||
@@ -359,6 +362,7 @@ export const CommandMap = {
|
|||||||
messages_redo: "session.redo",
|
messages_redo: "session.redo",
|
||||||
display_thinking: "session.toggle.thinking",
|
display_thinking: "session.toggle.thinking",
|
||||||
prompt_submit: "prompt.submit",
|
prompt_submit: "prompt.submit",
|
||||||
|
prompt_queue: "prompt.queue",
|
||||||
prompt_editor_context_clear: "prompt.editor_context.clear",
|
prompt_editor_context_clear: "prompt.editor_context.clear",
|
||||||
prompt_skills: "prompt.skills",
|
prompt_skills: "prompt.skills",
|
||||||
prompt_stash: "prompt.stash",
|
prompt_stash: "prompt.stash",
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import type {
|
|||||||
ModelInfo,
|
ModelInfo,
|
||||||
PermissionSavedInfo,
|
PermissionSavedInfo,
|
||||||
PermissionRequest,
|
PermissionRequest,
|
||||||
|
PermissionReplyInput,
|
||||||
Project,
|
Project,
|
||||||
ProviderInfo,
|
ProviderInfo,
|
||||||
ReferenceInfo,
|
ReferenceInfo,
|
||||||
@@ -31,11 +32,13 @@ import type {
|
|||||||
OpenCodeEvent,
|
OpenCodeEvent,
|
||||||
WebSearchProvider,
|
WebSearchProvider,
|
||||||
} from "@opencode-ai/client"
|
} from "@opencode-ai/client"
|
||||||
|
import { isPermissionNotFoundError } from "@opencode-ai/client"
|
||||||
import type { Plugin } from "@opencode-ai/plugin/tui"
|
import type { Plugin } from "@opencode-ai/plugin/tui"
|
||||||
import { createStore, produce, reconcile } from "solid-js/store"
|
import { createStore, produce, reconcile } from "solid-js/store"
|
||||||
import { createSimpleContext } from "./helper"
|
import { createSimpleContext } from "./helper"
|
||||||
import { useClient } from "./client"
|
import { useClient } from "./client"
|
||||||
import { nonEmptyToolContent } from "../util/tool-display"
|
import { nonEmptyToolContent } from "../util/tool-display"
|
||||||
|
import type { SessionPending } from "@opencode-ai/schema/session-pending"
|
||||||
import { createEffect, createSignal, onCleanup } from "solid-js"
|
import { createEffect, createSignal, onCleanup } from "solid-js"
|
||||||
|
|
||||||
export type DataSessionStatus = "idle" | "running"
|
export type DataSessionStatus = "idle" | "running"
|
||||||
@@ -168,14 +171,40 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
|||||||
|
|
||||||
function removePending(sessionID: string, inputID?: string) {
|
function removePending(sessionID: string, inputID?: string) {
|
||||||
if (!inputID) return
|
if (!inputID) return
|
||||||
|
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) {
|
||||||
|
const requests = store.session.permission[sessionID]
|
||||||
|
if (!requests?.some((request) => request.id === requestID)) return
|
||||||
setStore(
|
setStore(
|
||||||
"session",
|
"session",
|
||||||
"pending",
|
"permission",
|
||||||
sessionID,
|
sessionID,
|
||||||
(store.session.pending[sessionID] ?? []).filter((item) => item.id !== inputID),
|
requests.filter((request) => request.id !== requestID),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function updatePending(sessionID: string, inputID: string, delivery: SessionPending.Delivery) {
|
||||||
|
const index = store.session.pending[sessionID]?.findIndex((item) => item.id === inputID) ?? -1
|
||||||
|
const item = store.session.pending[sessionID]?.[index]
|
||||||
|
if (index < 0 || !item || item.type === "compaction" || item.delivery === delivery) return
|
||||||
|
setStore("session", "pending", sessionID, index, { ...item, delivery })
|
||||||
|
}
|
||||||
|
|
||||||
const message = {
|
const message = {
|
||||||
update(sessionID: string, fn: (messages: SessionMessageInfo[], index: Map<string, number>) => void) {
|
update(sessionID: string, fn: (messages: SessionMessageInfo[], index: Map<string, number>) => void) {
|
||||||
setStore(
|
setStore(
|
||||||
@@ -222,6 +251,12 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
|||||||
(item): item is SessionMessageAssistantReasoning => item.type === "reasoning" && !item.time?.completed,
|
(item): item is SessionMessageAssistantReasoning => item.type === "reasoning" && !item.time?.completed,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
reindex(messages: SessionMessageInfo[], index: Map<string, number>, start: number) {
|
||||||
|
for (let position = start; position < messages.length; position++) {
|
||||||
|
const item = messages[position]
|
||||||
|
if (item) index.set(item.id, position)
|
||||||
|
}
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
function index(sessionID: string) {
|
function index(sessionID: string) {
|
||||||
@@ -403,24 +438,36 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
|||||||
}
|
}
|
||||||
break
|
break
|
||||||
case "session.input.promoted": {
|
case "session.input.promoted": {
|
||||||
|
const admitted = store.session.input[event.data.sessionID]?.includes(event.data.inputID) ?? false
|
||||||
removePending(event.data.sessionID, event.data.inputID)
|
removePending(event.data.sessionID, event.data.inputID)
|
||||||
message.update(event.data.sessionID, (draft, index) => {
|
message.update(event.data.sessionID, (draft, index) => {
|
||||||
const position = index.get(event.data.inputID)
|
const position = index.get(event.data.inputID)
|
||||||
if (position === undefined) return
|
if (position === undefined) return
|
||||||
const existing = draft[position]
|
const existing = draft[position]
|
||||||
if (!existing || !store.session.input[event.data.sessionID]?.includes(event.data.inputID)) return
|
if (!existing || !admitted) return
|
||||||
existing.time.created = event.created
|
existing.time.created = event.created
|
||||||
draft.splice(position, 1)
|
draft.splice(position, 1)
|
||||||
draft.push(existing)
|
draft.push(existing)
|
||||||
index.clear()
|
message.reindex(draft, index, position)
|
||||||
draft.forEach((message, indexValue) => index.set(message.id, indexValue))
|
|
||||||
})
|
})
|
||||||
setStore(
|
break
|
||||||
"session",
|
}
|
||||||
"input",
|
case "session.input.steered":
|
||||||
event.data.sessionID,
|
updatePending(event.data.sessionID, event.data.inputID, "steer")
|
||||||
(store.session.input[event.data.sessionID] ?? []).filter((id) => id !== event.data.inputID),
|
break
|
||||||
)
|
case "session.input.queued":
|
||||||
|
updatePending(event.data.sessionID, event.data.inputID, "queue")
|
||||||
|
break
|
||||||
|
case "session.input.cancelled": {
|
||||||
|
removePending(event.data.sessionID, event.data.inputID)
|
||||||
|
if (messageIndex.get(event.data.sessionID)?.has(event.data.inputID))
|
||||||
|
message.update(event.data.sessionID, (draft, index) => {
|
||||||
|
const position = index.get(event.data.inputID)
|
||||||
|
if (position === undefined) return
|
||||||
|
draft.splice(position, 1)
|
||||||
|
index.delete(event.data.inputID)
|
||||||
|
message.reindex(draft, index, position)
|
||||||
|
})
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
case "session.input.admitted":
|
case "session.input.admitted":
|
||||||
@@ -840,14 +887,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
|||||||
])
|
])
|
||||||
break
|
break
|
||||||
case "permission.replied":
|
case "permission.replied":
|
||||||
setStore(
|
removePermission(event.data.sessionID, event.data.requestID)
|
||||||
"session",
|
|
||||||
"permission",
|
|
||||||
event.data.sessionID,
|
|
||||||
(store.session.permission[event.data.sessionID] ?? []).filter(
|
|
||||||
(request) => request.id !== event.data.requestID,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
break
|
break
|
||||||
case "form.created":
|
case "form.created":
|
||||||
if (store.session.form[event.data.form.sessionID]?.some((form) => form.id === event.data.form.id)) break
|
if (store.session.form[event.data.form.sessionID]?.some((form) => form.id === event.data.form.id)) break
|
||||||
@@ -1036,6 +1076,12 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
|||||||
invalidate(sessionID: string) {
|
invalidate(sessionID: string) {
|
||||||
sync.invalidate(`session.permission:${sessionID}`)
|
sync.invalidate(`session.permission:${sessionID}`)
|
||||||
},
|
},
|
||||||
|
async reply(input: PermissionReplyInput) {
|
||||||
|
await client.api.permission.reply(input).catch((error: unknown) => {
|
||||||
|
if (!isPermissionNotFoundError(error)) throw error
|
||||||
|
})
|
||||||
|
removePermission(input.sessionID, input.requestID)
|
||||||
|
},
|
||||||
},
|
},
|
||||||
form: {
|
form: {
|
||||||
list(sessionID: string, ref?: LocationRef) {
|
list(sessionID: string, ref?: LocationRef) {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
import { dedupeWith } from "effect/Array"
|
import { dedupeWith } from "effect/Array"
|
||||||
import { createSimpleContext } from "./helper"
|
import { createSimpleContext } from "./helper"
|
||||||
import { batch, createMemo } from "solid-js"
|
import { batch, createMemo, onCleanup } from "solid-js"
|
||||||
import { useEvent } from "./event"
|
import { useEvent } from "./event"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import { useTuiPaths } from "./runtime"
|
import { useTuiPaths } from "./runtime"
|
||||||
@@ -22,6 +22,7 @@ import { useToast } from "../ui/toast"
|
|||||||
import { useRoute } from "./route"
|
import { useRoute } from "./route"
|
||||||
import { useData } from "./data"
|
import { useData } from "./data"
|
||||||
import { usePermission } from "./permission"
|
import { usePermission } from "./permission"
|
||||||
|
import { useLocation } from "./location"
|
||||||
|
|
||||||
export function parseModel(model: string) {
|
export function parseModel(model: string) {
|
||||||
const [providerID, ...rest] = model.split("/")
|
const [providerID, ...rest] = model.split("/")
|
||||||
@@ -57,26 +58,29 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
|||||||
const args = useArgs()
|
const args = useArgs()
|
||||||
const event = useEvent()
|
const event = useEvent()
|
||||||
const permission = usePermission()
|
const permission = usePermission()
|
||||||
|
const location = useLocation()
|
||||||
|
|
||||||
|
const models = () => data.location.model.list(location.ref)
|
||||||
|
const providers = () => data.location.provider.list(location.ref)
|
||||||
|
|
||||||
function isModelValid(model: ModelPreferenceModel) {
|
function isModelValid(model: ModelPreferenceModel) {
|
||||||
return !!data.location.model
|
return !!models()?.some((item) => item.providerID === model.providerID && item.id === model.modelID)
|
||||||
.list()
|
|
||||||
?.some((item) => item.providerID === model.providerID && item.id === model.modelID)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function getFirstValidModel(...modelFns: (() => ModelPreferenceModel | undefined)[]) {
|
function getFirstValidModel(...modelFns: (() => ModelPreferenceModel | undefined)[]) {
|
||||||
for (const modelFn of modelFns) {
|
for (const modelFn of modelFns) {
|
||||||
const model = modelFn()
|
const model = modelFn()
|
||||||
if (!model) continue
|
if (model && isModelValid(model)) return model
|
||||||
if (isModelValid(model)) return model
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function createAgent() {
|
function createAgent() {
|
||||||
const agents = createMemo(() =>
|
const agents = createMemo(() =>
|
||||||
(data.location.agent.list() ?? []).filter((agent) => agent.mode !== "subagent" && !agent.hidden),
|
(data.location.agent.list(location.ref) ?? []).filter((agent) => agent.mode !== "subagent" && !agent.hidden),
|
||||||
|
)
|
||||||
|
const visibleAgents = createMemo(() =>
|
||||||
|
(data.location.agent.list(location.ref) ?? []).filter((agent) => !agent.hidden),
|
||||||
)
|
)
|
||||||
const visibleAgents = createMemo(() => (data.location.agent.list() ?? []).filter((agent) => !agent.hidden))
|
|
||||||
const [agentStore, setAgentStore] = createStore({
|
const [agentStore, setAgentStore] = createStore({
|
||||||
current: undefined as string | undefined,
|
current: undefined as string | undefined,
|
||||||
})
|
})
|
||||||
@@ -128,35 +132,40 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
|||||||
const agent = createAgent()
|
const agent = createAgent()
|
||||||
|
|
||||||
function createModel() {
|
function createModel() {
|
||||||
const [modelStore, setModelStore] = createStore<
|
type ModelSelection = ModelPreferenceModel & { variant?: string }
|
||||||
ModelPreference & {
|
const [preferences, setPreferences] = createStore<ModelPreference & { ready: boolean }>({
|
||||||
ready: boolean
|
|
||||||
model: Record<string, ModelPreferenceModel>
|
|
||||||
}
|
|
||||||
>({
|
|
||||||
ready: false,
|
ready: false,
|
||||||
model: {},
|
|
||||||
recent: [],
|
recent: [],
|
||||||
favorite: [],
|
favorite: [],
|
||||||
variant: {},
|
variant: {},
|
||||||
})
|
})
|
||||||
|
const [selectionState, setSelectionState] = createStore<{
|
||||||
|
newSessionModelByLocationAgent: Record<string, ModelPreferenceModel | undefined>
|
||||||
|
draftBySession: Record<string, ModelSelection | undefined>
|
||||||
|
}>({
|
||||||
|
newSessionModelByLocationAgent: {},
|
||||||
|
draftBySession: {},
|
||||||
|
})
|
||||||
|
|
||||||
const repository = createModelPreferenceRepository(path.join(paths.state, "model.json"))
|
const repository = createModelPreferenceRepository(path.join(paths.state, "model.json"))
|
||||||
const state = {
|
const pendingSelectionCommits = new Map<string, string>()
|
||||||
|
const selectionKey = (value: ModelSelection) =>
|
||||||
|
`${modelPreferenceKey(value)}:${normalizeModelVariant(value.variant) ?? "default"}`
|
||||||
|
const saveState = {
|
||||||
pending: false,
|
pending: false,
|
||||||
}
|
}
|
||||||
|
|
||||||
function save() {
|
function savePreferences() {
|
||||||
if (!modelStore.ready) {
|
if (!preferences.ready) {
|
||||||
state.pending = true
|
saveState.pending = true
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
state.pending = false
|
saveState.pending = false
|
||||||
void repository
|
void repository
|
||||||
.patch({
|
.patch({
|
||||||
recent: modelStore.recent,
|
recent: preferences.recent,
|
||||||
favorite: modelStore.favorite,
|
favorite: preferences.favorite,
|
||||||
variant: modelStore.variant,
|
variant: preferences.variant,
|
||||||
})
|
})
|
||||||
.catch(() => undefined)
|
.catch(() => undefined)
|
||||||
}
|
}
|
||||||
@@ -164,14 +173,14 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
|||||||
repository
|
repository
|
||||||
.load()
|
.load()
|
||||||
.then((value) => {
|
.then((value) => {
|
||||||
setModelStore("recent", value.recent)
|
setPreferences("recent", value.recent)
|
||||||
setModelStore("favorite", value.favorite)
|
setPreferences("favorite", value.favorite)
|
||||||
setModelStore("variant", value.variant)
|
setPreferences("variant", value.variant)
|
||||||
})
|
})
|
||||||
.catch(() => {})
|
.catch(() => {})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
setModelStore("ready", true)
|
setPreferences("ready", true)
|
||||||
if (state.pending) save()
|
if (saveState.pending) savePreferences()
|
||||||
})
|
})
|
||||||
|
|
||||||
const fallbackModel = createMemo(() => {
|
const fallbackModel = createMemo(() => {
|
||||||
@@ -185,13 +194,13 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const item of modelStore.recent) {
|
for (const item of preferences.recent) {
|
||||||
if (isModelValid(item)) {
|
if (isModelValid(item)) {
|
||||||
return item
|
return item
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const model = data.location.model.list()?.[0]
|
const model = models()?.[0]
|
||||||
if (!model) return undefined
|
if (!model) return undefined
|
||||||
return {
|
return {
|
||||||
providerID: model.providerID,
|
providerID: model.providerID,
|
||||||
@@ -199,30 +208,134 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const currentModel = createMemo(() => {
|
const newSessionModel = createMemo(() => {
|
||||||
const a = agent.current()
|
const a = agent.current()
|
||||||
return (
|
return getFirstValidModel(
|
||||||
getFirstValidModel(
|
() => a && selectionState.newSessionModelByLocationAgent[locationAgentKey(a.id)],
|
||||||
() => a && modelStore.model[a.id],
|
() => a?.model && { providerID: a.model.providerID, modelID: a.model.id },
|
||||||
() => a?.model && { providerID: a.model.providerID, modelID: a.model.id },
|
fallbackModel,
|
||||||
fallbackModel,
|
|
||||||
) ?? undefined
|
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const currentSelection = createMemo<ModelSelection | undefined>(() => {
|
||||||
|
if (route.data.type === "session") return sessionSelection(route.data.sessionID)
|
||||||
|
const model = newSessionModel()
|
||||||
|
if (!model) return
|
||||||
|
return { ...model, variant: normalizeModelVariant(preferences.variant[modelPreferenceKey(model)]) }
|
||||||
|
})
|
||||||
|
|
||||||
|
const currentModel = createMemo(() => {
|
||||||
|
const selection = currentSelection()
|
||||||
|
if (!selection) return
|
||||||
|
return { providerID: selection.providerID, modelID: selection.modelID }
|
||||||
|
})
|
||||||
|
|
||||||
|
function locationAgentKey(agentID: string) {
|
||||||
|
const ref = location.ref ?? data.location.default()
|
||||||
|
return `${JSON.stringify([ref.directory, ref.workspaceID])}:${agentID}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function durableSelection(sessionID: string): ModelSelection | undefined {
|
||||||
|
const model = data.session.get(sessionID)?.model
|
||||||
|
if (!model) return
|
||||||
|
return {
|
||||||
|
providerID: model.providerID,
|
||||||
|
modelID: model.id,
|
||||||
|
variant: normalizeModelVariant(model.variant),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function sessionSelection(sessionID: string) {
|
||||||
|
return selectionState.draftBySession[sessionID] ?? durableSelection(sessionID)
|
||||||
|
}
|
||||||
|
|
||||||
|
function setSessionDraft(sessionID: string, selection: ModelSelection) {
|
||||||
|
const durable = durableSelection(sessionID)
|
||||||
|
setSelectionState(
|
||||||
|
"draftBySession",
|
||||||
|
sessionID,
|
||||||
|
durable && selectionKey(durable) === selectionKey(selection) ? undefined : selection,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectModel(model: ModelPreferenceModel) {
|
||||||
|
if (route.data.type === "session") {
|
||||||
|
const sessionID = route.data.sessionID
|
||||||
|
const current = sessionSelection(sessionID)
|
||||||
|
const preferred = normalizeModelVariant(
|
||||||
|
current?.providerID === model.providerID && current.modelID === model.modelID
|
||||||
|
? current.variant
|
||||||
|
: preferences.variant[modelPreferenceKey(model)],
|
||||||
|
)
|
||||||
|
const info = models()?.find((item) => item.providerID === model.providerID && item.id === model.modelID)
|
||||||
|
const variant = preferred && info?.variants?.some((item) => item.id === preferred) ? preferred : undefined
|
||||||
|
setSessionDraft(sessionID, { ...model, variant })
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
const current = agent.current()
|
||||||
|
if (!current) return false
|
||||||
|
setSelectionState("newSessionModelByLocationAgent", locationAgentKey(current.id), model)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
onCleanup(
|
||||||
|
event.on("session.model.selected", (evt) => {
|
||||||
|
const expected = pendingSelectionCommits.get(evt.data.sessionID)
|
||||||
|
if (!expected) return
|
||||||
|
const committed = selectionKey({
|
||||||
|
providerID: evt.data.model.providerID,
|
||||||
|
modelID: evt.data.model.id,
|
||||||
|
variant: evt.data.model.variant,
|
||||||
|
})
|
||||||
|
if (committed !== expected) return
|
||||||
|
pendingSelectionCommits.delete(evt.data.sessionID)
|
||||||
|
const draft = selectionState.draftBySession[evt.data.sessionID]
|
||||||
|
if (draft && selectionKey(draft) === committed)
|
||||||
|
setSelectionState("draftBySession", evt.data.sessionID, undefined)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
onCleanup(
|
||||||
|
event.on("session.deleted", (evt) => {
|
||||||
|
pendingSelectionCommits.delete(evt.data.sessionID)
|
||||||
|
setSelectionState("draftBySession", evt.data.sessionID, undefined)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
current: currentModel,
|
current: currentModel,
|
||||||
|
selection: currentSelection,
|
||||||
|
available(model = currentModel()) {
|
||||||
|
return model ? isModelValid(model) : false
|
||||||
|
},
|
||||||
|
trackSessionCommit(
|
||||||
|
sessionID: string,
|
||||||
|
value: {
|
||||||
|
providerID: string
|
||||||
|
id: string
|
||||||
|
variant?: string
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
const committed = selectionKey({ providerID: value.providerID, modelID: value.id, variant: value.variant })
|
||||||
|
pendingSelectionCommits.set(sessionID, committed)
|
||||||
|
return () => {
|
||||||
|
if (pendingSelectionCommits.get(sessionID) === committed) pendingSelectionCommits.delete(sessionID)
|
||||||
|
}
|
||||||
|
},
|
||||||
get ready() {
|
get ready() {
|
||||||
return modelStore.ready
|
return preferences.ready
|
||||||
|
},
|
||||||
|
get catalogReady() {
|
||||||
|
return models() !== undefined
|
||||||
},
|
},
|
||||||
recent() {
|
recent() {
|
||||||
return modelStore.recent
|
return preferences.recent
|
||||||
},
|
},
|
||||||
favorite() {
|
favorite() {
|
||||||
return modelStore.favorite
|
return preferences.favorite
|
||||||
},
|
},
|
||||||
parsed: createMemo(() => {
|
parsed: createMemo(() => {
|
||||||
const value = currentModel()
|
const value = currentSelection()
|
||||||
if (!value) {
|
if (!value) {
|
||||||
return {
|
return {
|
||||||
provider: "Connect a provider",
|
provider: "Connect a provider",
|
||||||
@@ -230,33 +343,28 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
|||||||
reasoning: false,
|
reasoning: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const provider = data.location.provider.list()?.find((item) => item.id === value.providerID)
|
const provider = providers()?.find((item) => item.id === value.providerID)
|
||||||
const info = data.location.model
|
const info = models()?.find((item) => item.providerID === value.providerID && item.id === value.modelID)
|
||||||
.list()
|
|
||||||
?.find((item) => item.providerID === value.providerID && item.id === value.modelID)
|
|
||||||
return {
|
return {
|
||||||
provider: provider?.name ?? value.providerID,
|
provider: provider?.name ?? value.providerID,
|
||||||
model: info?.name ?? value.modelID,
|
model: info?.name ?? `${value.modelID} (unavailable)`,
|
||||||
reasoning: (info?.variants?.length ?? 0) !== 0,
|
reasoning: (info?.variants?.length ?? 0) !== 0,
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
cycle(direction: 1 | -1) {
|
cycle(direction: 1 | -1) {
|
||||||
const current = currentModel()
|
const current = currentSelection()
|
||||||
if (!current) return
|
if (!current) return
|
||||||
const recent = modelStore.recent
|
const recent = recentModels(current, preferences.recent).filter(isModelValid)
|
||||||
const index = recent.findIndex((x) => x.providerID === current.providerID && x.modelID === current.modelID)
|
const index = recent.findIndex((x) => x.providerID === current.providerID && x.modelID === current.modelID)
|
||||||
if (index === -1) return
|
let next = index === -1 ? (direction === 1 ? 0 : recent.length - 1) : index + direction
|
||||||
let next = index + direction
|
|
||||||
if (next < 0) next = recent.length - 1
|
if (next < 0) next = recent.length - 1
|
||||||
if (next >= recent.length) next = 0
|
if (next >= recent.length) next = 0
|
||||||
const val = recent[next]
|
const val = recent[next]
|
||||||
if (!val) return
|
if (!val) return
|
||||||
const a = agent.current()
|
selectModel({ ...val })
|
||||||
if (!a) return
|
|
||||||
setModelStore("model", a.id, { ...val })
|
|
||||||
},
|
},
|
||||||
cycleFavorite(direction: 1 | -1) {
|
cycleFavorite(direction: 1 | -1) {
|
||||||
const favorites = modelStore.favorite.filter((item) => isModelValid(item))
|
const favorites = preferences.favorite.filter((item) => isModelValid(item))
|
||||||
if (!favorites.length) {
|
if (!favorites.length) {
|
||||||
toast.show({
|
toast.show({
|
||||||
variant: "info",
|
variant: "info",
|
||||||
@@ -265,7 +373,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
|||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const current = currentModel()
|
const current = currentSelection()
|
||||||
let index = -1
|
let index = -1
|
||||||
if (current) {
|
if (current) {
|
||||||
index = favorites.findIndex((x) => x.providerID === current.providerID && x.modelID === current.modelID)
|
index = favorites.findIndex((x) => x.providerID === current.providerID && x.modelID === current.modelID)
|
||||||
@@ -279,45 +387,39 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
|||||||
}
|
}
|
||||||
const next = favorites[index]
|
const next = favorites[index]
|
||||||
if (!next) return
|
if (!next) return
|
||||||
const a = agent.current()
|
if (!selectModel({ ...next })) return
|
||||||
if (!a) return
|
setPreferences("recent", recentModels(next, preferences.recent))
|
||||||
setModelStore("model", a.id, { ...next })
|
savePreferences()
|
||||||
setModelStore("recent", recentModels(next, modelStore.recent))
|
|
||||||
save()
|
|
||||||
},
|
},
|
||||||
set(model: { providerID: string; modelID: string }, options?: { recent?: boolean }) {
|
set(model: { providerID: string; modelID: string }, options?: { recent?: boolean }) {
|
||||||
batch(() => {
|
batch(() => {
|
||||||
if (!isModelValid(model)) return
|
if (!isModelValid(model)) return
|
||||||
const a = agent.current()
|
if (!selectModel(model)) return
|
||||||
if (!a) return
|
|
||||||
setModelStore("model", a.id, model)
|
|
||||||
if (options?.recent) {
|
if (options?.recent) {
|
||||||
setModelStore("recent", recentModels(model, modelStore.recent))
|
setPreferences("recent", recentModels(model, preferences.recent))
|
||||||
save()
|
savePreferences()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
toggleFavorite(model: { providerID: string; modelID: string }) {
|
toggleFavorite(model: { providerID: string; modelID: string }) {
|
||||||
batch(() => {
|
batch(() => {
|
||||||
if (!isModelValid(model)) return
|
if (!isModelValid(model)) return
|
||||||
const exists = modelStore.favorite.some(
|
const exists = preferences.favorite.some(
|
||||||
(x) => x.providerID === model.providerID && x.modelID === model.modelID,
|
(x) => x.providerID === model.providerID && x.modelID === model.modelID,
|
||||||
)
|
)
|
||||||
const next = exists
|
const next = exists
|
||||||
? modelStore.favorite.filter((x) => x.providerID !== model.providerID || x.modelID !== model.modelID)
|
? preferences.favorite.filter((x) => x.providerID !== model.providerID || x.modelID !== model.modelID)
|
||||||
: [model, ...modelStore.favorite]
|
: [model, ...preferences.favorite]
|
||||||
setModelStore(
|
setPreferences(
|
||||||
"favorite",
|
"favorite",
|
||||||
next.map((x) => ({ providerID: x.providerID, modelID: x.modelID })),
|
next.map((x) => ({ providerID: x.providerID, modelID: x.modelID })),
|
||||||
)
|
)
|
||||||
save()
|
savePreferences()
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
variant: {
|
variant: {
|
||||||
selected() {
|
selected() {
|
||||||
const m = currentModel()
|
return currentSelection()?.variant
|
||||||
if (!m) return undefined
|
|
||||||
return normalizeModelVariant(modelStore.variant[modelPreferenceKey(m)])
|
|
||||||
},
|
},
|
||||||
current() {
|
current() {
|
||||||
const v = this.selected()
|
const v = this.selected()
|
||||||
@@ -325,18 +427,20 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
|||||||
return undefined
|
return undefined
|
||||||
},
|
},
|
||||||
list() {
|
list() {
|
||||||
const m = currentModel()
|
const m = currentSelection()
|
||||||
if (!m) return []
|
if (!m) return []
|
||||||
const info = data.location.model
|
const info = models()?.find((item) => item.providerID === m.providerID && item.id === m.modelID)
|
||||||
.list()
|
|
||||||
?.find((item) => item.providerID === m.providerID && item.id === m.modelID)
|
|
||||||
return info?.variants?.map((variant) => variant.id) ?? []
|
return info?.variants?.map((variant) => variant.id) ?? []
|
||||||
},
|
},
|
||||||
set(value: string | undefined) {
|
set(value: string | undefined) {
|
||||||
const m = currentModel()
|
const m = currentSelection()
|
||||||
if (!m) return
|
if (!m) return
|
||||||
setModelStore("variant", modelPreferenceKey(m), normalizeModelVariant(value))
|
if (route.data.type === "session") {
|
||||||
save()
|
setSessionDraft(route.data.sessionID, { ...m, variant: normalizeModelVariant(value) })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setPreferences("variant", modelPreferenceKey(m), normalizeModelVariant(value))
|
||||||
|
savePreferences()
|
||||||
},
|
},
|
||||||
cycle() {
|
cycle() {
|
||||||
const variants = this.list()
|
const variants = this.list()
|
||||||
|
|||||||
@@ -3,7 +3,9 @@ import { TextAttributes, type InputRenderable, type KeyEvent } from "@opentui/co
|
|||||||
import { useKeyboard, type JSX } from "@opentui/solid"
|
import { useKeyboard, type JSX } from "@opentui/solid"
|
||||||
import fuzzysort from "fuzzysort"
|
import fuzzysort from "fuzzysort"
|
||||||
import { createEffect, createMemo, createSignal, type Accessor } from "solid-js"
|
import { createEffect, createMemo, createSignal, type Accessor } from "solid-js"
|
||||||
|
import { Keymap } from "../context/keymap"
|
||||||
import { RunFooterMenu, createFooterMenuState, type RunFooterMenuItem } from "./footer.menu"
|
import { RunFooterMenu, createFooterMenuState, type RunFooterMenuItem } from "./footer.menu"
|
||||||
|
import { monoShortcut } from "./mono"
|
||||||
import type { RunFooterTheme } from "./theme"
|
import type { RunFooterTheme } from "./theme"
|
||||||
import type {
|
import type {
|
||||||
FooterQueuedPrompt,
|
FooterQueuedPrompt,
|
||||||
@@ -56,6 +58,10 @@ type SkillEntry = PanelEntry & {
|
|||||||
name: string
|
name: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type QueuedPromptEntry = PanelEntry & {
|
||||||
|
prompt: FooterQueuedPrompt
|
||||||
|
}
|
||||||
|
|
||||||
type SubagentEntry = PanelEntry & {
|
type SubagentEntry = PanelEntry & {
|
||||||
sessionID: string
|
sessionID: string
|
||||||
current: boolean
|
current: boolean
|
||||||
@@ -442,7 +448,7 @@ export function RunCommandMenuBody(props: {
|
|||||||
{
|
{
|
||||||
action: "queued" as const,
|
action: "queued" as const,
|
||||||
category: "Agent",
|
category: "Agent",
|
||||||
display: "View pending work",
|
display: "View queued prompts",
|
||||||
footer: `${props.queued().length} pending`,
|
footer: `${props.queued().length} pending`,
|
||||||
keywords: props
|
keywords: props
|
||||||
.queued()
|
.queued()
|
||||||
@@ -837,28 +843,48 @@ export function RunQueuedPromptSelectBody(props: {
|
|||||||
theme: Accessor<RunFooterTheme>
|
theme: Accessor<RunFooterTheme>
|
||||||
prompts: Accessor<FooterQueuedPrompt[]>
|
prompts: Accessor<FooterQueuedPrompt[]>
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
|
onSteer: (prompt: FooterQueuedPrompt) => void
|
||||||
|
onDelete: (prompt: FooterQueuedPrompt) => void
|
||||||
onRows?: (rows: number) => void
|
onRows?: (rows: number) => void
|
||||||
mono?: boolean
|
mono?: boolean
|
||||||
}) {
|
}) {
|
||||||
const entries = createMemo(() =>
|
const entries = createMemo<QueuedPromptEntry[]>(() =>
|
||||||
props.prompts().map((prompt) => ({
|
props.prompts().map((prompt) => ({
|
||||||
category: "",
|
category: "",
|
||||||
display: prompt.prompt.text.replaceAll("\n", " "),
|
display: prompt.prompt.text.replaceAll("\n", " "),
|
||||||
footer: prompt.delivery,
|
footer: "queued",
|
||||||
keywords: prompt.prompt.text,
|
keywords: prompt.prompt.text,
|
||||||
|
prompt,
|
||||||
})),
|
})),
|
||||||
)
|
)
|
||||||
const controller = createSearchablePanelController({
|
const controller = createSearchablePanelController({
|
||||||
entries,
|
entries,
|
||||||
limit: SUBAGENT_LIST_ROWS,
|
limit: SUBAGENT_LIST_ROWS,
|
||||||
onClose: props.onClose,
|
onClose: props.onClose,
|
||||||
onSelect: props.onClose,
|
onSelect: (item) => props.onSteer(item.prompt),
|
||||||
onRows: props.onRows,
|
onRows: props.onRows,
|
||||||
})
|
})
|
||||||
|
const shortcuts = Keymap.useShortcuts()
|
||||||
|
const deleteShortcut = () => monoShortcut(shortcuts.get("queued_prompt.delete") ?? "", props.mono ?? false)
|
||||||
|
Keymap.createLayer(() => ({
|
||||||
|
priority: 1,
|
||||||
|
commands: [
|
||||||
|
{
|
||||||
|
id: "queued_prompt.delete",
|
||||||
|
title: "Delete queued prompt",
|
||||||
|
group: "Prompt",
|
||||||
|
run() {
|
||||||
|
const item = controller.items()[controller.menu.selected()]
|
||||||
|
if (!item) return false
|
||||||
|
props.onDelete(item.prompt)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}))
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PanelShell
|
<PanelShell
|
||||||
title="Pending work"
|
title="Queued prompts"
|
||||||
query={controller.query()}
|
query={controller.query()}
|
||||||
count={controller.items().length}
|
count={controller.items().length}
|
||||||
total={entries().length}
|
total={entries().length}
|
||||||
@@ -866,6 +892,7 @@ export function RunQueuedPromptSelectBody(props: {
|
|||||||
theme={props.theme}
|
theme={props.theme}
|
||||||
inputRef={controller.inputRef}
|
inputRef={controller.inputRef}
|
||||||
onQuery={controller.setQuery}
|
onQuery={controller.setQuery}
|
||||||
|
hint={["enter steer", deleteShortcut() ? `${deleteShortcut()} delete` : undefined].filter(Boolean).join(" · ")}
|
||||||
mono={props.mono}
|
mono={props.mono}
|
||||||
>
|
>
|
||||||
<RunFooterMenu
|
<RunFooterMenu
|
||||||
@@ -875,7 +902,7 @@ export function RunQueuedPromptSelectBody(props: {
|
|||||||
offset={controller.menu.offset}
|
offset={controller.menu.offset}
|
||||||
rows={controller.menu.rows}
|
rows={controller.menu.rows}
|
||||||
limit={SUBAGENT_LIST_ROWS}
|
limit={SUBAGENT_LIST_ROWS}
|
||||||
empty="No pending work"
|
empty="No queued prompts"
|
||||||
border={false}
|
border={false}
|
||||||
paddingLeft={panelPad(props.mono)}
|
paddingLeft={panelPad(props.mono)}
|
||||||
paddingRight={panelPad(props.mono)}
|
paddingRight={panelPad(props.mono)}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
/** @jsxImportSource @opentui/solid */
|
/** @jsxImportSource @opentui/solid */
|
||||||
import type { TextareaRenderable } from "@opentui/core"
|
import { decodePasteBytes, stripAnsiSequences, type TextareaRenderable } from "@opentui/core"
|
||||||
import { useKeyboard } from "@opentui/solid"
|
import { useKeyboard, usePaste } from "@opentui/solid"
|
||||||
import { For, Show, createEffect, createMemo, createSignal, onCleanup } from "solid-js"
|
import { For, Show, createEffect, createMemo, createSignal, onCleanup } from "solid-js"
|
||||||
import {
|
import {
|
||||||
createFormBodyState,
|
createFormBodyState,
|
||||||
@@ -149,6 +149,14 @@ export function RunFormBody(props: {
|
|||||||
if (formSingle(props.request)) submit(next)
|
if (formSingle(props.request)) submit(next)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
usePaste((event) => {
|
||||||
|
const field = current()
|
||||||
|
if (!field || textual() || !custom() || confirm()) return
|
||||||
|
event.preventDefault()
|
||||||
|
const next = formPick(formSetSelected(state(), rows().length), props.request)
|
||||||
|
setState(formSetDraft(next, field, formInput(next, field) + stripAnsiSequences(decodePasteBytes(event.bytes))))
|
||||||
|
})
|
||||||
|
|
||||||
const moveField = (direction: -1 | 1) => {
|
const moveField = (direction: -1 | 1) => {
|
||||||
const next = (state().field + direction + props.request.fields.length + 1) % (props.request.fields.length + 1)
|
const next = (state().field + direction + props.request.fields.length + 1) % (props.request.fields.length + 1)
|
||||||
if (direction < 0 || confirm()) {
|
if (direction < 0 || confirm()) {
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
displayCharAt,
|
displayCharAt,
|
||||||
displaySlice,
|
displaySlice,
|
||||||
isExitCommand,
|
isExitCommand,
|
||||||
|
isCompactCommand,
|
||||||
mentionTriggerIndex,
|
mentionTriggerIndex,
|
||||||
isNewCommand,
|
isNewCommand,
|
||||||
movePromptHistory,
|
movePromptHistory,
|
||||||
@@ -31,7 +32,16 @@ import { realignEditorPromptParts, resolveEditorSlashValue } from "./prompt.edit
|
|||||||
import { monoTruncateMiddle } from "./mono"
|
import { monoTruncateMiddle } from "./mono"
|
||||||
import { FOOTER_MENU_ROWS, createFooterMenuState, type RunFooterMenuItem } from "./footer.menu"
|
import { FOOTER_MENU_ROWS, createFooterMenuState, type RunFooterMenuItem } from "./footer.menu"
|
||||||
import type { RunFooterTheme } from "./theme"
|
import type { RunFooterTheme } from "./theme"
|
||||||
import type { FooterState, RunAgent, RunCommand, RunPrompt, RunPromptPart, RunReference } from "./types"
|
import type {
|
||||||
|
FooterQueuedPrompt,
|
||||||
|
FooterState,
|
||||||
|
RunAgent,
|
||||||
|
RunCommand,
|
||||||
|
RunDelivery,
|
||||||
|
RunPrompt,
|
||||||
|
RunPromptPart,
|
||||||
|
RunReference,
|
||||||
|
} from "./types"
|
||||||
|
|
||||||
const AUTOCOMPLETE_ROWS = FOOTER_MENU_ROWS
|
const AUTOCOMPLETE_ROWS = FOOTER_MENU_ROWS
|
||||||
const AUTOCOMPLETE_BOTTOM_ROWS = 1
|
const AUTOCOMPLETE_BOTTOM_ROWS = 1
|
||||||
@@ -72,6 +82,8 @@ type PromptInput = {
|
|||||||
theme: Accessor<RunFooterTheme>
|
theme: Accessor<RunFooterTheme>
|
||||||
mono: Accessor<boolean>
|
mono: Accessor<boolean>
|
||||||
history?: Accessor<RunPrompt[]>
|
history?: Accessor<RunPrompt[]>
|
||||||
|
queuedPrompts: Accessor<FooterQueuedPrompt[]>
|
||||||
|
onQueuedPromptSteer: (inputID: string) => Promise<boolean>
|
||||||
onSubmit: (input: RunPrompt) => boolean | Promise<boolean>
|
onSubmit: (input: RunPrompt) => boolean | Promise<boolean>
|
||||||
onCycle: () => void
|
onCycle: () => void
|
||||||
onInterrupt: () => boolean
|
onInterrupt: () => boolean
|
||||||
@@ -980,8 +992,18 @@ export function createPromptState(input: PromptInput): PromptState {
|
|||||||
}))
|
}))
|
||||||
|
|
||||||
Keymap.createLayer(() => ({
|
Keymap.createLayer(() => ({
|
||||||
|
priority: 1,
|
||||||
enabled: input.prompt() && !visible(),
|
enabled: input.prompt() && !visible(),
|
||||||
commands: [
|
commands: [
|
||||||
|
{
|
||||||
|
id: "prompt.queue",
|
||||||
|
title: "Queue prompt",
|
||||||
|
group: "Prompt",
|
||||||
|
run() {
|
||||||
|
syncDraft()
|
||||||
|
submitPrompt(promptCopy(draft), "queue")
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: "prompt.editor",
|
id: "prompt.editor",
|
||||||
title: "Open editor",
|
title: "Open editor",
|
||||||
@@ -1116,7 +1138,8 @@ export function createPromptState(input: PromptInput): PromptState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const submitPrompt = (next: RunPrompt) => {
|
let submitting = false
|
||||||
|
const submitPrompt = (next: RunPrompt, delivery: RunDelivery = "steer") => {
|
||||||
if (!area || area.isDestroyed) {
|
if (!area || area.isDestroyed) {
|
||||||
draft = promptCopy(next)
|
draft = promptCopy(next)
|
||||||
}
|
}
|
||||||
@@ -1130,12 +1153,34 @@ export function createPromptState(input: PromptInput): PromptState {
|
|||||||
hide()
|
hide()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (submitting) return
|
||||||
|
|
||||||
if (!next.text.trim()) {
|
if (!next.text.trim()) {
|
||||||
|
const queued = delivery === "steer" ? input.queuedPrompts()[0] : undefined
|
||||||
|
if (queued) {
|
||||||
|
submitting = true
|
||||||
|
void input.onQueuedPromptSteer(queued.messageID).finally(() => {
|
||||||
|
submitting = false
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
input.onStatus(input.state().phase === "running" ? "waiting for current response" : "empty prompt ignored")
|
input.onStatus(input.state().phase === "running" ? "waiting for current response" : "empty prompt ignored")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const command = next.mode === "shell" ? undefined : selectedCommand(next.text, next.command)
|
const command = next.mode === "shell" ? undefined : selectedCommand(next.text, next.command)
|
||||||
|
if (
|
||||||
|
delivery === "queue" &&
|
||||||
|
(next.mode === "shell" ||
|
||||||
|
command?.source === "skill" ||
|
||||||
|
isNewCommand(next.text) ||
|
||||||
|
isCompactCommand(next.text) ||
|
||||||
|
isExitCommand(next.text) ||
|
||||||
|
next.text.trim().toLowerCase() === "/settings")
|
||||||
|
) {
|
||||||
|
input.onStatus("this prompt cannot be queued")
|
||||||
|
return
|
||||||
|
}
|
||||||
if (!command && next.mode !== "shell" && isExitCommand(next.text)) {
|
if (!command && next.mode !== "shell" && isExitCommand(next.text)) {
|
||||||
input.onExit()
|
input.onExit()
|
||||||
return
|
return
|
||||||
@@ -1157,24 +1202,28 @@ export function createPromptState(input: PromptInput): PromptState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const submit = command
|
const submit = command
|
||||||
? { ...next, command }
|
? { ...next, command, delivery }
|
||||||
: parsed?.type === "command"
|
: parsed?.type === "command"
|
||||||
? { ...next, command: parsed.command }
|
? { ...next, command: parsed.command, delivery }
|
||||||
: next
|
: { ...next, delivery }
|
||||||
const shellMode = next.mode === "shell"
|
const shellMode = next.mode === "shell"
|
||||||
|
|
||||||
|
submitting = true
|
||||||
resetDraft()
|
resetDraft()
|
||||||
queueMicrotask(async () => {
|
queueMicrotask(async () => {
|
||||||
if (await input.onSubmit(submit)) {
|
try {
|
||||||
push(next)
|
if (await input.onSubmit(submit)) {
|
||||||
if (shellMode) {
|
push(next)
|
||||||
setShellMode(false)
|
if (shellMode) {
|
||||||
draft = emptyPrompt(false)
|
setShellMode(false)
|
||||||
|
draft = emptyPrompt(false)
|
||||||
|
}
|
||||||
|
return
|
||||||
}
|
}
|
||||||
return
|
restore(next)
|
||||||
|
} finally {
|
||||||
|
submitting = false
|
||||||
}
|
}
|
||||||
|
|
||||||
restore(next)
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ import type {
|
|||||||
MiniSettingChange,
|
MiniSettingChange,
|
||||||
MiniSettings,
|
MiniSettings,
|
||||||
PermissionReply,
|
PermissionReply,
|
||||||
|
QueuedPromptAction,
|
||||||
RunAgent,
|
RunAgent,
|
||||||
RunCommand,
|
RunCommand,
|
||||||
RunInput,
|
RunInput,
|
||||||
@@ -96,6 +97,7 @@ type RunFooterOptions = {
|
|||||||
onVariantSelect?: (variant: string | undefined) => CycleResult | void | Promise<CycleResult | void>
|
onVariantSelect?: (variant: string | undefined) => CycleResult | void | Promise<CycleResult | void>
|
||||||
onInterrupt?: () => void
|
onInterrupt?: () => void
|
||||||
onBackground?: () => void
|
onBackground?: () => void
|
||||||
|
onQueuedPromptAction?: (action: QueuedPromptAction, inputID: string) => Promise<void>
|
||||||
onEditorOpen: (input: { value: string }) => Promise<string | undefined>
|
onEditorOpen: (input: { value: string }) => Promise<string | undefined>
|
||||||
onSubagentSelect?: (sessionID: string | undefined) => void
|
onSubagentSelect?: (sessionID: string | undefined) => void
|
||||||
onSubagentInterrupt?: (sessionID: string) => void
|
onSubagentInterrupt?: (sessionID: string) => void
|
||||||
@@ -343,6 +345,7 @@ export class RunFooter implements FooterApi {
|
|||||||
onCycle: footer.handleCycle,
|
onCycle: footer.handleCycle,
|
||||||
onInterrupt: footer.handleInterrupt,
|
onInterrupt: footer.handleInterrupt,
|
||||||
onBackground: options.onBackground,
|
onBackground: options.onBackground,
|
||||||
|
onQueuedPromptAction: options.onQueuedPromptAction,
|
||||||
onEditorOpen: options.onEditorOpen,
|
onEditorOpen: options.onEditorOpen,
|
||||||
onInputClear: footer.handleInputClear,
|
onInputClear: footer.handleInputClear,
|
||||||
onExitRequest: footer.handleExit,
|
onExitRequest: footer.handleExit,
|
||||||
|
|||||||
@@ -34,6 +34,8 @@ import { Keymap } from "../context/keymap"
|
|||||||
import { modelInfo } from "./variant.shared"
|
import { modelInfo } from "./variant.shared"
|
||||||
import { monoShortcut } from "./mono"
|
import { monoShortcut } from "./mono"
|
||||||
import { stringWidth } from "../util/string-width"
|
import { stringWidth } from "../util/string-width"
|
||||||
|
import { errorMessage } from "../util/error"
|
||||||
|
import { createSingleFlight } from "../util/single-flight"
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
FooterPromptRoute,
|
FooterPromptRoute,
|
||||||
@@ -46,6 +48,7 @@ import type {
|
|||||||
MiniSettingChange,
|
MiniSettingChange,
|
||||||
MiniSettings,
|
MiniSettings,
|
||||||
PermissionReply,
|
PermissionReply,
|
||||||
|
QueuedPromptAction,
|
||||||
RunAgent,
|
RunAgent,
|
||||||
RunCommand,
|
RunCommand,
|
||||||
RunInput,
|
RunInput,
|
||||||
@@ -92,13 +95,14 @@ type RunFooterViewProps = {
|
|||||||
mono: boolean
|
mono: boolean
|
||||||
miniSettings: () => MiniSettings
|
miniSettings: () => MiniSettings
|
||||||
history?: () => RunPrompt[]
|
history?: () => RunPrompt[]
|
||||||
onSubmit: (input: RunPrompt) => boolean
|
onSubmit: (input: RunPrompt) => boolean | Promise<boolean>
|
||||||
onPermissionReply: (input: PermissionReply) => void | Promise<void>
|
onPermissionReply: (input: PermissionReply) => void | Promise<void>
|
||||||
onFormReply: (input: FormReply) => void | Promise<void>
|
onFormReply: (input: FormReply) => void | Promise<void>
|
||||||
onFormCancel: (input: FormCancel) => void | Promise<void>
|
onFormCancel: (input: FormCancel) => void | Promise<void>
|
||||||
onCycle: () => void
|
onCycle: () => void
|
||||||
onInterrupt: () => boolean
|
onInterrupt: () => boolean
|
||||||
onBackground?: () => void
|
onBackground?: () => void
|
||||||
|
onQueuedPromptAction?: (action: QueuedPromptAction, inputID: string) => Promise<void>
|
||||||
onEditorOpen: (input: { value: string }) => Promise<string | undefined>
|
onEditorOpen: (input: { value: string }) => Promise<string | undefined>
|
||||||
onInputClear: () => void
|
onInputClear: () => void
|
||||||
onExitRequest?: () => boolean
|
onExitRequest?: () => boolean
|
||||||
@@ -132,6 +136,7 @@ export function RunFooterView(props: RunFooterViewProps) {
|
|||||||
const [route, setRoute] = createSignal<FooterPromptRoute>({ type: "composer" })
|
const [route, setRoute] = createSignal<FooterPromptRoute>({ type: "composer" })
|
||||||
const [subagentMenuRows, setSubagentMenuRows] = createSignal(RUN_SUBAGENT_PANEL_ROWS)
|
const [subagentMenuRows, setSubagentMenuRows] = createSignal(RUN_SUBAGENT_PANEL_ROWS)
|
||||||
const queuedPrompts = createMemo(() => props.queuedPrompts?.() ?? [])
|
const queuedPrompts = createMemo(() => props.queuedPrompts?.() ?? [])
|
||||||
|
const queue = createMemo(() => queuedPrompts().filter((item) => item.delivery === "queue"))
|
||||||
const skills = createMemo(() => (props.commands() ?? []).filter((item) => item.source === "skill"))
|
const skills = createMemo(() => (props.commands() ?? []).filter((item) => item.source === "skill"))
|
||||||
const prompt = createMemo(() => active().type === "prompt" && route().type === "composer")
|
const prompt = createMemo(() => active().type === "prompt" && route().type === "composer")
|
||||||
const selectingSubagent = createMemo(() => active().type === "prompt" && route().type === "subagent-menu")
|
const selectingSubagent = createMemo(() => active().type === "prompt" && route().type === "subagent-menu")
|
||||||
@@ -229,7 +234,7 @@ export function RunFooterView(props: RunFooterViewProps) {
|
|||||||
const details = [busy() ? "running" : "idle", `agent ${props.currentAgent()}`]
|
const details = [busy() ? "running" : "idle", `agent ${props.currentAgent()}`]
|
||||||
if (current) details.push(variant ? `${current} ${variant}` : current)
|
if (current) details.push(variant ? `${current} ${variant}` : current)
|
||||||
if (usage()) details.push(props.mono ? usage().replaceAll(" · ", " - ") : usage())
|
if (usage()) details.push(props.mono ? usage().replaceAll(" · ", " - ") : usage())
|
||||||
if (queuedPrompts().length > 0) details.push(`${queuedPrompts().length} pending`)
|
if (queue().length > 0) details.push(`${queue().length} queued`)
|
||||||
if (activeTabs().length > 0) details.push(`${activeTabs().length} subagent${activeTabs().length === 1 ? "" : "s"}`)
|
if (activeTabs().length > 0) details.push(`${activeTabs().length} subagent${activeTabs().length === 1 ? "" : "s"}`)
|
||||||
return details.join(props.mono ? " - " : " · ")
|
return details.join(props.mono ? " - " : " · ")
|
||||||
})
|
})
|
||||||
@@ -309,7 +314,7 @@ export function RunFooterView(props: RunFooterViewProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const openQueuedMenu = () => {
|
const openQueuedMenu = () => {
|
||||||
if (queuedPrompts().length === 0) return
|
if (queue().length === 0) return
|
||||||
setRoute({ type: "queued-menu" })
|
setRoute({ type: "queued-menu" })
|
||||||
props.onSubagentSelect?.(undefined)
|
props.onSubagentSelect?.(undefined)
|
||||||
}
|
}
|
||||||
@@ -318,6 +323,22 @@ export function RunFooterView(props: RunFooterViewProps) {
|
|||||||
setRoute({ type: "composer" })
|
setRoute({ type: "composer" })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const runQueuedAction = createSingleFlight<string>()
|
||||||
|
const queuedPromptAction = async (action: QueuedPromptAction, inputID: string) => {
|
||||||
|
const run = props.onQueuedPromptAction
|
||||||
|
if (!run) return false
|
||||||
|
const result = await runQueuedAction(inputID, async () => {
|
||||||
|
const error = await run(action, inputID).then(
|
||||||
|
() => undefined,
|
||||||
|
(error) => error,
|
||||||
|
)
|
||||||
|
if (!error) return true
|
||||||
|
props.onStatus(`failed to ${action === "cancel" ? "delete" : action} queued prompt: ${errorMessage(error)}`)
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
return result ?? false
|
||||||
|
}
|
||||||
|
|
||||||
const openTab = (sessionID: string) => {
|
const openTab = (sessionID: string) => {
|
||||||
setRoute({ type: "subagent", sessionID })
|
setRoute({ type: "subagent", sessionID })
|
||||||
props.onSubagentSelect?.(sessionID)
|
props.onSubagentSelect?.(sessionID)
|
||||||
@@ -357,6 +378,8 @@ export function RunFooterView(props: RunFooterViewProps) {
|
|||||||
theme,
|
theme,
|
||||||
mono: () => props.mono,
|
mono: () => props.mono,
|
||||||
history: props.history,
|
history: props.history,
|
||||||
|
queuedPrompts: queue,
|
||||||
|
onQueuedPromptSteer: (inputID) => queuedPromptAction("steer", inputID),
|
||||||
onSubmit: props.onSubmit,
|
onSubmit: props.onSubmit,
|
||||||
onCycle: props.onCycle,
|
onCycle: props.onCycle,
|
||||||
onInterrupt: props.onInterrupt,
|
onInterrupt: props.onInterrupt,
|
||||||
@@ -451,13 +474,12 @@ export function RunFooterView(props: RunFooterViewProps) {
|
|||||||
if (foregroundSubagents() && backgroundShortcut()) {
|
if (foregroundSubagents() && backgroundShortcut()) {
|
||||||
items.push({ key: backgroundShortcut(), label: "background" })
|
items.push({ key: backgroundShortcut(), label: "background" })
|
||||||
}
|
}
|
||||||
if (queuedPrompts().length > 0 && queuedShortcut()) {
|
if (queue().length > 0 && queuedShortcut()) {
|
||||||
items.push({ key: queuedShortcut(), label: `${queuedPrompts().length} pending` })
|
items.push({ key: queuedShortcut(), label: `${queue().length} queued` })
|
||||||
}
|
}
|
||||||
if (activeTabs().length > 0 && subagentShortcut()) {
|
if (activeTabs().length > 0 && subagentShortcut()) {
|
||||||
items.push({ key: subagentShortcut(), label: "subagents" })
|
items.push({ key: subagentShortcut(), label: "subagents" })
|
||||||
}
|
}
|
||||||
|
|
||||||
return items
|
return items
|
||||||
})
|
})
|
||||||
const commandHint = createMemo(() => {
|
const commandHint = createMemo(() => {
|
||||||
@@ -568,11 +590,11 @@ export function RunFooterView(props: RunFooterViewProps) {
|
|||||||
}))
|
}))
|
||||||
|
|
||||||
Keymap.createLayer(() => ({
|
Keymap.createLayer(() => ({
|
||||||
enabled: active().type === "prompt" && route().type === "composer" && queuedPrompts().length > 0,
|
enabled: active().type === "prompt" && route().type === "composer" && queue().length > 0,
|
||||||
commands: [
|
commands: [
|
||||||
{
|
{
|
||||||
id: "session.queued_prompts",
|
id: "session.queued_prompts",
|
||||||
title: "View pending work",
|
title: "View queued prompts",
|
||||||
group: "Session",
|
group: "Session",
|
||||||
run: openQueuedMenu,
|
run: openQueuedMenu,
|
||||||
},
|
},
|
||||||
@@ -630,7 +652,7 @@ export function RunFooterView(props: RunFooterViewProps) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
if (route().type !== "queued-menu" || queuedPrompts().length > 0) return
|
if (route().type !== "queued-menu" || queue().length > 0) return
|
||||||
closePanel()
|
closePanel()
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -734,8 +756,16 @@ export function RunFooterView(props: RunFooterViewProps) {
|
|||||||
<Match when={selectingQueued()}>
|
<Match when={selectingQueued()}>
|
||||||
<RunQueuedPromptSelectBody
|
<RunQueuedPromptSelectBody
|
||||||
theme={theme}
|
theme={theme}
|
||||||
prompts={queuedPrompts}
|
prompts={queue}
|
||||||
onClose={closePanel}
|
onClose={closePanel}
|
||||||
|
onSteer={(item) => {
|
||||||
|
void queuedPromptAction("steer", item.messageID).then((steered) => {
|
||||||
|
if (steered) closePanel()
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
onDelete={(item) => {
|
||||||
|
void queuedPromptAction("cancel", item.messageID)
|
||||||
|
}}
|
||||||
onRows={setSubagentMenuRows}
|
onRows={setSubagentMenuRows}
|
||||||
mono={props.mono}
|
mono={props.mono}
|
||||||
/>
|
/>
|
||||||
@@ -745,7 +775,7 @@ export function RunFooterView(props: RunFooterViewProps) {
|
|||||||
theme={theme}
|
theme={theme}
|
||||||
commands={props.commands}
|
commands={props.commands}
|
||||||
subagents={tabs}
|
subagents={tabs}
|
||||||
queued={queuedPrompts}
|
queued={queue}
|
||||||
variants={props.variants}
|
variants={props.variants}
|
||||||
variantCycle={variantCycle()}
|
variantCycle={variantCycle()}
|
||||||
onClose={closePanel}
|
onClose={closePanel}
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import type {
|
|||||||
MiniSettings,
|
MiniSettings,
|
||||||
MiniHost,
|
MiniHost,
|
||||||
PermissionReply,
|
PermissionReply,
|
||||||
|
QueuedPromptAction,
|
||||||
RunAgent,
|
RunAgent,
|
||||||
RunInput,
|
RunInput,
|
||||||
RunPrompt,
|
RunPrompt,
|
||||||
@@ -70,6 +71,7 @@ export type LifecycleInput = {
|
|||||||
onVariantSelect?: (variant: string | undefined) => CycleResult | void | Promise<CycleResult | void>
|
onVariantSelect?: (variant: string | undefined) => CycleResult | void | Promise<CycleResult | void>
|
||||||
onInterrupt?: () => void
|
onInterrupt?: () => void
|
||||||
onBackground?: () => void
|
onBackground?: () => void
|
||||||
|
onQueuedPromptAction?: (action: QueuedPromptAction, inputID: string) => Promise<void>
|
||||||
onSubagentSelect?: (sessionID: string | undefined) => void
|
onSubagentSelect?: (sessionID: string | undefined) => void
|
||||||
onSubagentInterrupt?: (sessionID: string) => void
|
onSubagentInterrupt?: (sessionID: string) => void
|
||||||
}
|
}
|
||||||
@@ -243,6 +245,7 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lif
|
|||||||
onVariantSelect: input.onVariantSelect,
|
onVariantSelect: input.onVariantSelect,
|
||||||
onInterrupt: input.onInterrupt,
|
onInterrupt: input.onInterrupt,
|
||||||
onBackground: input.onBackground,
|
onBackground: input.onBackground,
|
||||||
|
onQueuedPromptAction: input.onQueuedPromptAction,
|
||||||
onEditorOpen: async ({ value }) => {
|
onEditorOpen: async ({ value }) => {
|
||||||
if (closed || renderer.isDestroyed) {
|
if (closed || renderer.isDestroyed) {
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
import { SessionMessage } from "@opencode-ai/schema/session-message"
|
import { SessionMessage } from "@opencode-ai/schema/session-message"
|
||||||
import { Locale } from "../util/locale"
|
import { Locale } from "../util/locale"
|
||||||
import { isCompactCommand, isExitCommand, isNewCommand } from "./prompt.shared"
|
import { isCompactCommand, isExitCommand, isNewCommand } from "./prompt.shared"
|
||||||
import type { FooterApi, FooterEvent, RunPrompt } from "./types"
|
import type { FooterApi, FooterEvent, RunDelivery, RunPrompt } from "./types"
|
||||||
|
|
||||||
type Trace = {
|
type Trace = {
|
||||||
write(type: string, data?: unknown): void
|
write(type: string, data?: unknown): void
|
||||||
@@ -21,11 +21,11 @@ export type QueueInput = {
|
|||||||
footer: FooterApi
|
footer: FooterApi
|
||||||
initialInput?: string
|
initialInput?: string
|
||||||
trace?: Trace
|
trace?: Trace
|
||||||
onSend?: (prompt: RunPrompt, delivery: "steer" | "queue") => void
|
onSend?: (prompt: RunPrompt, delivery: RunDelivery) => void
|
||||||
onAdmissionError?: (prompt: RunPrompt, error: unknown) => void | Promise<void>
|
onAdmissionError?: (prompt: RunPrompt, error: unknown) => void | Promise<void>
|
||||||
onNewSession?: () => void | Promise<void>
|
onNewSession?: () => void | Promise<void>
|
||||||
onCompact?: () => void | Promise<void>
|
onCompact?: () => void | Promise<void>
|
||||||
admit: (prompt: RunPrompt, signal: AbortSignal) => Promise<void>
|
admit: (prompt: RunPrompt, delivery: RunDelivery, signal: AbortSignal) => Promise<void>
|
||||||
settle: () => Promise<void>
|
settle: () => Promise<void>
|
||||||
run: (prompt: RunPrompt, signal: AbortSignal, admitted: () => void) => Promise<void>
|
run: (prompt: RunPrompt, signal: AbortSignal, admitted: () => void) => Promise<void>
|
||||||
}
|
}
|
||||||
@@ -183,7 +183,7 @@ export async function runPromptQueue(input: QueueInput): Promise<void> {
|
|||||||
input.trace?.write("ui.commit", commit)
|
input.trace?.write("ui.commit", commit)
|
||||||
input.footer.append(commit)
|
input.footer.append(commit)
|
||||||
}
|
}
|
||||||
input.onSend?.(sent, "steer")
|
input.onSend?.(sent, sent.delivery ?? "steer")
|
||||||
|
|
||||||
if (state.closed) {
|
if (state.closed) {
|
||||||
break
|
break
|
||||||
@@ -276,10 +276,11 @@ export async function runPromptQueue(input: QueueInput): Promise<void> {
|
|||||||
const sent = { ...prompt, messageID: SessionMessage.ID.create() }
|
const sent = { ...prompt, messageID: SessionMessage.ID.create() }
|
||||||
const admission = state.admission
|
const admission = state.admission
|
||||||
admissionVersion += 1
|
admissionVersion += 1
|
||||||
input.onSend?.(sent, "queue")
|
const delivery = prompt.delivery ?? "queue"
|
||||||
|
input.onSend?.(sent, delivery)
|
||||||
admissions = admissions
|
admissions = admissions
|
||||||
.then(() => admission)
|
.then(() => admission)
|
||||||
.then(() => input.admit(sent, admissionController.signal))
|
.then(() => input.admit(sent, delivery, admissionController.signal))
|
||||||
.catch((error) => (state.closed ? undefined : input.onAdmissionError?.(sent, error)))
|
.catch((error) => (state.closed ? undefined : input.onAdmissionError?.(sent, error)))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -390,6 +390,15 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
|
|||||||
log?.write("send.background", { sessionID: state.sessionID })
|
log?.write("send.background", { sessionID: state.sessionID })
|
||||||
void state.sdk.session.background({ sessionID: state.sessionID }).catch(() => {})
|
void state.sdk.session.background({ sessionID: state.sessionID }).catch(() => {})
|
||||||
},
|
},
|
||||||
|
onQueuedPromptAction: async (action, inputID) => {
|
||||||
|
if (!state.sessionID) return
|
||||||
|
log?.write(`send.pending.${action}`, { sessionID: state.sessionID, inputID })
|
||||||
|
if (action === "steer") {
|
||||||
|
await state.sdk.session.pending.steer({ sessionID: state.sessionID, inputID })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await state.sdk.session.pending.cancel({ sessionID: state.sessionID, inputID })
|
||||||
|
},
|
||||||
onSubagentInterrupt: (sessionID) => {
|
onSubagentInterrupt: (sessionID) => {
|
||||||
log?.write("send.subagent.interrupt", { sessionID })
|
log?.write("send.subagent.interrupt", { sessionID })
|
||||||
void state.sdk.session.interrupt({ sessionID }).catch(() => {})
|
void state.sdk.session.interrupt({ sessionID }).catch(() => {})
|
||||||
@@ -892,7 +901,7 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
|
|||||||
trace: log,
|
trace: log,
|
||||||
onSend: (prompt, delivery) => {
|
onSend: (prompt, delivery) => {
|
||||||
state.shown = true
|
state.shown = true
|
||||||
state.history.push(prompt)
|
state.history.push({ ...prompt, delivery: undefined })
|
||||||
if (prompt.mode !== "shell" && delivery === "steer") {
|
if (prompt.mode !== "shell" && delivery === "steer") {
|
||||||
rememberLocal({
|
rememberLocal({
|
||||||
kind: "user",
|
kind: "user",
|
||||||
@@ -903,18 +912,21 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
admit: async (prompt, signal) => {
|
admit: async (prompt, delivery, signal) => {
|
||||||
await state.switching?.catch(() => {})
|
await state.switching?.catch(() => {})
|
||||||
const next = await ensureStream()
|
const next = await ensureStream()
|
||||||
await next.handle.queuePromptTurn({
|
await next.handle.admitPromptTurn(
|
||||||
agent: state.agent,
|
{
|
||||||
model: state.model,
|
agent: state.agent,
|
||||||
variant: state.activeVariant,
|
model: state.model,
|
||||||
prompt,
|
variant: state.activeVariant,
|
||||||
files: input.files,
|
prompt,
|
||||||
includeFiles: false,
|
files: input.files,
|
||||||
signal,
|
includeFiles: false,
|
||||||
})
|
signal,
|
||||||
|
},
|
||||||
|
delivery,
|
||||||
|
)
|
||||||
},
|
},
|
||||||
onAdmissionError: renderPromptError,
|
onAdmissionError: renderPromptError,
|
||||||
onCompact: async () => {
|
onCompact: async () => {
|
||||||
|
|||||||
@@ -653,6 +653,10 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
|
|||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (event.type === "session.input.cancelled") {
|
||||||
|
child.prompts.delete(event.data.inputID)
|
||||||
|
return
|
||||||
|
}
|
||||||
if (event.type === "session.step.started") {
|
if (event.type === "session.step.started") {
|
||||||
touch(child, event.created)
|
touch(child, event.created)
|
||||||
if (child.label === FALLBACK_LABEL && event.data.agent) child.label = Locale.titlecase(event.data.agent)
|
if (child.label === FALLBACK_LABEL && event.data.agent) child.label = Locale.titlecase(event.data.agent)
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import type {
|
|||||||
FooterQueuedPrompt,
|
FooterQueuedPrompt,
|
||||||
RunFilePart,
|
RunFilePart,
|
||||||
RunInput,
|
RunInput,
|
||||||
|
RunDelivery,
|
||||||
RunPrompt,
|
RunPrompt,
|
||||||
RunPromptPart,
|
RunPromptPart,
|
||||||
StreamCommit,
|
StreamCommit,
|
||||||
@@ -71,7 +72,7 @@ export type SessionResizeReplayInput = {
|
|||||||
|
|
||||||
export type SessionTransport = {
|
export type SessionTransport = {
|
||||||
runPromptTurn(input: SessionTurnInput, admitted?: () => void): Promise<void>
|
runPromptTurn(input: SessionTurnInput, admitted?: () => void): Promise<void>
|
||||||
queuePromptTurn(input: SessionTurnInput): Promise<void>
|
admitPromptTurn(input: SessionTurnInput, delivery: RunDelivery): Promise<void>
|
||||||
waitForIdle(): Promise<void>
|
waitForIdle(): Promise<void>
|
||||||
interruptActiveTurn(): Promise<void>
|
interruptActiveTurn(): Promise<void>
|
||||||
selectSubagent(sessionID: string | undefined): void
|
selectSubagent(sessionID: string | undefined): void
|
||||||
@@ -515,8 +516,12 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let syncedPending: string[] | undefined
|
||||||
const syncPending = () => {
|
const syncPending = () => {
|
||||||
const prompts = [...state.pending.values()]
|
const prompts = [...state.pending.values()].filter((item) => item.delivery === "queue")
|
||||||
|
const ids = prompts.map((item) => item.messageID)
|
||||||
|
if (syncedPending?.length === ids.length && syncedPending.every((id, index) => id === ids[index])) return
|
||||||
|
syncedPending = ids
|
||||||
input.trace?.write("ui.patch", { pending: prompts.length })
|
input.trace?.write("ui.patch", { pending: prompts.length })
|
||||||
input.footer.event({ type: "queued.prompts", prompts })
|
input.footer.event({ type: "queued.prompts", prompts })
|
||||||
}
|
}
|
||||||
@@ -934,6 +939,36 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
|
|||||||
write([], { phase: "running", status: "waiting for assistant" })
|
write([], { phase: "running", status: "waiting for assistant" })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (event.type === "session.input.steered") {
|
||||||
|
const pending = state.pending.get(event.data.inputID)
|
||||||
|
if (!pending) return
|
||||||
|
state.pending.set(event.data.inputID, { ...pending, delivery: "steer" })
|
||||||
|
syncPending()
|
||||||
|
if (state.messageIDs.has(event.data.inputID)) return
|
||||||
|
state.messageIDs.add(event.data.inputID)
|
||||||
|
write([
|
||||||
|
{
|
||||||
|
kind: "user",
|
||||||
|
source: "system",
|
||||||
|
text: pending.prompt.text,
|
||||||
|
phase: "start",
|
||||||
|
messageID: event.data.inputID,
|
||||||
|
},
|
||||||
|
])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (event.type === "session.input.queued") {
|
||||||
|
const pending = state.pending.get(event.data.inputID)
|
||||||
|
if (!pending) return
|
||||||
|
state.pending.set(event.data.inputID, { ...pending, delivery: "queue" })
|
||||||
|
syncPending()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (event.type === "session.input.cancelled") {
|
||||||
|
state.admitted.delete(event.data.inputID)
|
||||||
|
if (state.pending.delete(event.data.inputID)) syncPending()
|
||||||
|
return
|
||||||
|
}
|
||||||
if (event.type === "session.step.started") {
|
if (event.type === "session.step.started") {
|
||||||
state.stepModel = { providerID: event.data.model.providerID, modelID: event.data.model.id }
|
state.stepModel = { providerID: event.data.model.providerID, modelID: event.data.model.id }
|
||||||
write([], { phase: "running", status: "assistant responding" })
|
write([], { phase: "running", status: "assistant responding" })
|
||||||
@@ -1577,7 +1612,7 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
|
|||||||
let queuedResizeReplay: SessionResizeReplayInput | undefined
|
let queuedResizeReplay: SessionResizeReplayInput | undefined
|
||||||
let closing: Promise<void> | undefined
|
let closing: Promise<void> | undefined
|
||||||
|
|
||||||
const admitPrompt = async (next: SessionTurnInput, client: OpenCodeClient, delivery: "steer" | "queue") => {
|
const admitPrompt = async (next: SessionTurnInput, client: OpenCodeClient, delivery: RunDelivery) => {
|
||||||
const messageID = next.prompt.messageID
|
const messageID = next.prompt.messageID
|
||||||
if (!messageID) throw new Error("Prompt message ID is required")
|
if (!messageID) throw new Error("Prompt message ID is required")
|
||||||
const command = next.prompt.command
|
const command = next.prompt.command
|
||||||
@@ -1643,14 +1678,20 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
async queuePromptTurn(next) {
|
async admitPromptTurn(next, delivery) {
|
||||||
if (next.prompt.mode === "shell" || next.prompt.command?.source === "skill")
|
if (next.prompt.mode === "shell" || next.prompt.command?.source === "skill")
|
||||||
throw new Error("This prompt cannot be queued")
|
throw new Error("This prompt cannot be queued")
|
||||||
if (!state.connected) throw new Error("Event stream is reconnecting")
|
if (!state.connected) throw new Error("Event stream is reconnecting")
|
||||||
const client = sdk
|
const client = sdk
|
||||||
if (next.agent)
|
if (next.agent)
|
||||||
await client.session.switchAgent({ sessionID: input.sessionID, agent: next.agent }, { signal: next.signal })
|
await client.session.switchAgent({ sessionID: input.sessionID, agent: next.agent }, { signal: next.signal })
|
||||||
mergePending(await admitPrompt(next, client, "queue"))
|
if (!next.prompt.command) {
|
||||||
|
const selected = await resolveSelectedModel(input, client, next)
|
||||||
|
if (next.variant && !selected) throw new Error("Cannot select a variant before selecting a model")
|
||||||
|
if (selected)
|
||||||
|
await client.session.switchModel({ sessionID: input.sessionID, model: selected }, { signal: next.signal })
|
||||||
|
}
|
||||||
|
mergePending(await admitPrompt(next, client, delivery))
|
||||||
settlementClient = client
|
settlementClient = client
|
||||||
},
|
},
|
||||||
async waitForIdle() {
|
async waitForIdle() {
|
||||||
@@ -1688,7 +1729,7 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (command) {
|
if (command) {
|
||||||
await runTurnWait(next, messageID, client, () => admitPrompt(next, client, "steer"), admitted)
|
await runTurnWait(next, messageID, client, () => admitPrompt(next, client, next.prompt.delivery ?? "steer"), admitted)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1700,7 +1741,7 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
|
|||||||
if (selected)
|
if (selected)
|
||||||
await client.session.switchModel({ sessionID: input.sessionID, model: selected }, { signal: next.signal })
|
await client.session.switchModel({ sessionID: input.sessionID, model: selected }, { signal: next.signal })
|
||||||
|
|
||||||
await runTurnWait(next, messageID, client, () => admitPrompt(next, client, "steer"), admitted)
|
await runTurnWait(next, messageID, client, () => admitPrompt(next, client, next.prompt.delivery ?? "steer"), admitted)
|
||||||
},
|
},
|
||||||
async interruptActiveTurn() {
|
async interruptActiveTurn() {
|
||||||
// A running shell holds no drain, so session.interrupt cannot reach it;
|
// A running shell holds no drain, so session.interrupt cannot reach it;
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import type {
|
|||||||
} from "@opencode-ai/client/promise"
|
} from "@opencode-ai/client/promise"
|
||||||
import type { Config } from "../config"
|
import type { Config } from "../config"
|
||||||
import type { CliRenderer } from "@opentui/core"
|
import type { CliRenderer } from "@opentui/core"
|
||||||
|
import type { SessionPending } from "@opencode-ai/schema/session-pending"
|
||||||
|
|
||||||
export type RunFilePart = {
|
export type RunFilePart = {
|
||||||
type: "file"
|
type: "file"
|
||||||
@@ -71,10 +72,13 @@ export type RunProvider = {
|
|||||||
models: Record<string, RunProviderModel>
|
models: Record<string, RunProviderModel>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type RunDelivery = SessionPending.Delivery
|
||||||
|
|
||||||
export type RunPrompt = {
|
export type RunPrompt = {
|
||||||
messageID?: string
|
messageID?: string
|
||||||
text: string
|
text: string
|
||||||
parts: RunPromptPart[]
|
parts: RunPromptPart[]
|
||||||
|
delivery?: RunDelivery
|
||||||
mode?: "shell"
|
mode?: "shell"
|
||||||
command?: {
|
command?: {
|
||||||
name: string
|
name: string
|
||||||
@@ -87,9 +91,11 @@ export type RunPrompt = {
|
|||||||
export type FooterQueuedPrompt = {
|
export type FooterQueuedPrompt = {
|
||||||
messageID: string
|
messageID: string
|
||||||
prompt: RunPrompt
|
prompt: RunPrompt
|
||||||
delivery: "steer" | "queue"
|
delivery: RunDelivery
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type QueuedPromptAction = "steer" | "cancel"
|
||||||
|
|
||||||
export type RunAgent = {
|
export type RunAgent = {
|
||||||
id: string
|
id: string
|
||||||
name: string
|
name: string
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
import { createMemo, createSignal, For, onCleanup, onMount, Show } from "solid-js"
|
import { createMemo, createSignal, For, onCleanup, onMount, Show } from "solid-js"
|
||||||
import { useRenderer, useTerminalDimensions } from "@opentui/solid"
|
import { usePaste, useRenderer, useTerminalDimensions } from "@opentui/solid"
|
||||||
import type { ScrollBoxRenderable, TextareaRenderable } from "@opentui/core"
|
import { decodePasteBytes, stripAnsiSequences, type ScrollBoxRenderable, type TextareaRenderable } from "@opentui/core"
|
||||||
import open from "open"
|
import open from "open"
|
||||||
import { useTheme, useThemes } from "../../context/theme"
|
import { useTheme, useThemes } from "../../context/theme"
|
||||||
import type { FormField, FormValue } from "@opencode-ai/client"
|
import type { FormField, FormValue } from "@opencode-ai/client"
|
||||||
@@ -265,6 +265,19 @@ export function FormPrompt(props: { form: FormWithLocation }) {
|
|||||||
pick(row.value)
|
pick(row.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
usePaste((event) => {
|
||||||
|
if (keymap.mode.current() !== FORM_MODE) return
|
||||||
|
const current = answerField()
|
||||||
|
if (!current || textual() || !custom() || confirm()) return
|
||||||
|
event.preventDefault()
|
||||||
|
setStore("selected", rows().length)
|
||||||
|
setStore("custom", {
|
||||||
|
...store.custom,
|
||||||
|
[current.key]: input() + stripAnsiSequences(decodePasteBytes(event.bytes)),
|
||||||
|
})
|
||||||
|
setStore("editing", true)
|
||||||
|
})
|
||||||
|
|
||||||
function commitInput(text: string) {
|
function commitInput(text: string) {
|
||||||
const current = answerField()
|
const current = answerField()
|
||||||
if (!current) return false
|
if (!current) return false
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ import { useClient } from "../../context/client"
|
|||||||
import { useEditorContext } from "../../context/editor"
|
import { useEditorContext } from "../../context/editor"
|
||||||
import { openEditor } from "../../editor"
|
import { openEditor } from "../../editor"
|
||||||
import { useDialog } from "../../ui/dialog"
|
import { useDialog } from "../../ui/dialog"
|
||||||
|
import { DialogSelect } from "../../ui/dialog-select"
|
||||||
import { DialogSessionRename } from "../../component/dialog-session-rename"
|
import { DialogSessionRename } from "../../component/dialog-session-rename"
|
||||||
import { DialogMessage } from "./dialog-message"
|
import { DialogMessage } from "./dialog-message"
|
||||||
import { DialogFork } from "./dialog-fork"
|
import { DialogFork } from "./dialog-fork"
|
||||||
@@ -97,6 +98,8 @@ import { stringWidth } from "../../util/string-width"
|
|||||||
import { useArgs } from "../../context/args"
|
import { useArgs } from "../../context/args"
|
||||||
import { withTimestampedFallback } from "@opencode-ai/util/session-title-fallback"
|
import { withTimestampedFallback } from "@opencode-ai/util/session-title-fallback"
|
||||||
import { useSessionTabs } from "../../context/session-tabs"
|
import { useSessionTabs } from "../../context/session-tabs"
|
||||||
|
import { createSingleFlight } from "../../util/single-flight"
|
||||||
|
import type { SessionPending } from "@opencode-ai/schema/session-pending"
|
||||||
|
|
||||||
addDefaultParsers(parsers.parsers)
|
addDefaultParsers(parsers.parsers)
|
||||||
|
|
||||||
@@ -109,6 +112,7 @@ const NAVIGATION_SLACK_ID = "session-navigation-slack"
|
|||||||
const TRANSCRIPT_TAIL_ROWS = 40
|
const TRANSCRIPT_TAIL_ROWS = 40
|
||||||
const TRANSCRIPT_BACKFILL_CHUNK = 60
|
const TRANSCRIPT_BACKFILL_CHUNK = 60
|
||||||
const TRANSCRIPT_BACKFILL_DELAY = 120
|
const TRANSCRIPT_BACKFILL_DELAY = 120
|
||||||
|
type PendingAction = "steer" | "queue" | "cancel"
|
||||||
|
|
||||||
const context = createContext<{
|
const context = createContext<{
|
||||||
width: number
|
width: number
|
||||||
@@ -120,6 +124,8 @@ const context = createContext<{
|
|||||||
diffWrapMode: () => "word" | "none"
|
diffWrapMode: () => "word" | "none"
|
||||||
models: () => ModelInfo[]
|
models: () => ModelInfo[]
|
||||||
config: ReturnType<typeof useConfig>["data"]
|
config: ReturnType<typeof useConfig>["data"]
|
||||||
|
mutatePending: (action: PendingAction, inputID: string) => Promise<boolean>
|
||||||
|
pendingDelivery: (inputID: string) => SessionPending.Delivery | undefined
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
function use() {
|
function use() {
|
||||||
@@ -175,6 +181,13 @@ export function Session() {
|
|||||||
.flatMap((sessionID) => data.session.form.list(sessionID) ?? [])
|
.flatMap((sessionID) => data.session.form.list(sessionID) ?? [])
|
||||||
.concat(global)
|
.concat(global)
|
||||||
})
|
})
|
||||||
|
const pendingUsers = createMemo(() =>
|
||||||
|
data.session.pending.list(route.sessionID).flatMap((item) => (item.type === "user" ? [item] : [])),
|
||||||
|
)
|
||||||
|
const pendingDeliveries = createMemo(() => new Map(pendingUsers().map((item) => [item.id, item.delivery])))
|
||||||
|
const queuedPrompts = createMemo(() =>
|
||||||
|
pendingUsers().flatMap((item) => (item.delivery === "queue" ? [{ id: item.id, text: item.data.text }] : [])),
|
||||||
|
)
|
||||||
const [composer, setComposer] = createStore({
|
const [composer, setComposer] = createStore({
|
||||||
open: false,
|
open: false,
|
||||||
tab: undefined as string | undefined,
|
tab: undefined as string | undefined,
|
||||||
@@ -204,7 +217,7 @@ export function Session() {
|
|||||||
const availableWidth = createMemo(
|
const availableWidth = createMemo(
|
||||||
() =>
|
() =>
|
||||||
dimensions().width -
|
dimensions().width -
|
||||||
(config.tabs?.enabled && config.tabs.vertical && sessionTabsFitVertically(dimensions().width)
|
(config.tabs?.enabled && config.tabs.layout === "vertical" && sessionTabsFitVertically(dimensions().width)
|
||||||
? SESSION_SIDEBAR_WIDTH
|
? SESSION_SIDEBAR_WIDTH
|
||||||
: 0),
|
: 0),
|
||||||
)
|
)
|
||||||
@@ -227,7 +240,7 @@ export function Session() {
|
|||||||
permissions().forEach((request) => {
|
permissions().forEach((request) => {
|
||||||
if (autoApproved.has(request.id)) return
|
if (autoApproved.has(request.id)) return
|
||||||
autoApproved.add(request.id)
|
autoApproved.add(request.id)
|
||||||
void client.api.permission
|
void data.session.permission
|
||||||
.reply({
|
.reply({
|
||||||
sessionID: request.sessionID,
|
sessionID: request.sessionID,
|
||||||
reply: "once",
|
reply: "once",
|
||||||
@@ -361,7 +374,7 @@ export function Session() {
|
|||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
const current = prompt()
|
const current = prompt()
|
||||||
if (sent || !current || !synced() || !local.model.ready) return
|
if (sent || !current || !synced() || !local.model.ready || !local.model.catalogReady) return
|
||||||
if (!local.agent.current() || !local.model.current()) return
|
if (!local.agent.current() || !local.model.current()) return
|
||||||
if (!args.prompt || route.prompt?.text !== args.prompt || current.current.text !== args.prompt) return
|
if (!args.prompt || route.prompt?.text !== args.prompt || current.current.text !== args.prompt) return
|
||||||
sent = true
|
sent = true
|
||||||
@@ -369,6 +382,55 @@ export function Session() {
|
|||||||
})
|
})
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const renderer = useRenderer()
|
const renderer = useRenderer()
|
||||||
|
const runPendingAction = createSingleFlight<string>()
|
||||||
|
const mutatePending = async (action: PendingAction, inputID: string) => {
|
||||||
|
const result = await runPendingAction(inputID, async () => {
|
||||||
|
const request =
|
||||||
|
action === "steer"
|
||||||
|
? client.api.session.pending.steer({ sessionID: route.sessionID, inputID })
|
||||||
|
: action === "queue"
|
||||||
|
? client.api.session.pending.queue({ sessionID: route.sessionID, inputID })
|
||||||
|
: client.api.session.pending.cancel({ sessionID: route.sessionID, inputID })
|
||||||
|
const error = await request.then(
|
||||||
|
() => undefined,
|
||||||
|
(error) => error,
|
||||||
|
)
|
||||||
|
if (!error) return true
|
||||||
|
const label = action === "cancel" ? "delete" : action
|
||||||
|
toast.show({ title: `Failed to ${label} pending prompt`, message: errorMessage(error), variant: "error" })
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
return result ?? false
|
||||||
|
}
|
||||||
|
const openQueuedPrompts = () =>
|
||||||
|
dialog.replace(() => (
|
||||||
|
<DialogSelect
|
||||||
|
title="Queued prompts"
|
||||||
|
options={queuedPrompts().map((prompt, index) => ({
|
||||||
|
title: prompt.text,
|
||||||
|
value: prompt.id,
|
||||||
|
footer: `${index + 1} of ${queuedPrompts().length}`,
|
||||||
|
}))}
|
||||||
|
onSelect={(option) => {
|
||||||
|
void mutatePending("steer", option.value).then((steered) => {
|
||||||
|
if (steered) dialog.clear()
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
actions={[
|
||||||
|
{
|
||||||
|
command: "queued_prompt.delete",
|
||||||
|
title: "delete",
|
||||||
|
onTrigger: (option) => {
|
||||||
|
const last = queuedPrompts().length === 1
|
||||||
|
void mutatePending("cancel", option.value).then((cancelled) => {
|
||||||
|
if (cancelled && last) dialog.clear()
|
||||||
|
})
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
footerHints={[{ title: "steer", label: "enter" }]}
|
||||||
|
/>
|
||||||
|
))
|
||||||
const unavailable = (feature: string) => {
|
const unavailable = (feature: string) => {
|
||||||
toast.show({ message: `${feature} is not implemented for V2 sessions yet`, variant: "error", duration: 5000 })
|
toast.show({ message: `${feature} is not implemented for V2 sessions yet`, variant: "error", duration: 5000 })
|
||||||
dialog.clear()
|
dialog.clear()
|
||||||
@@ -871,6 +933,13 @@ export function Session() {
|
|||||||
dialog.clear()
|
dialog.clear()
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: "View queued prompts",
|
||||||
|
id: "session.queued_prompts",
|
||||||
|
group: "Session",
|
||||||
|
enabled: queuedPrompts().length > 0,
|
||||||
|
run: openQueuedPrompts,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: "Go to parent session",
|
title: "Go to parent session",
|
||||||
id: "session.parent",
|
id: "session.parent",
|
||||||
@@ -942,6 +1011,8 @@ export function Session() {
|
|||||||
diffWrapMode,
|
diffWrapMode,
|
||||||
models,
|
models,
|
||||||
config,
|
config,
|
||||||
|
mutatePending,
|
||||||
|
pendingDelivery: (inputID) => pendingDeliveries().get(inputID),
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<box flexDirection="row" flexGrow={1} minHeight={0}>
|
<box flexDirection="row" flexGrow={1} minHeight={0}>
|
||||||
@@ -997,6 +1068,9 @@ export function Session() {
|
|||||||
</Show>
|
</Show>
|
||||||
</scrollbox>
|
</scrollbox>
|
||||||
<box flexShrink={0}>
|
<box flexShrink={0}>
|
||||||
|
<Show when={!composer.open && !disabled() && queuedPrompts().length > 0}>
|
||||||
|
<QueuedPromptDock prompts={queuedPrompts()} onOpen={openQueuedPrompts} />
|
||||||
|
</Show>
|
||||||
<PluginSlot name="session.composer.top" input={{ sessionID: route.sessionID }} mode="all" />
|
<PluginSlot name="session.composer.top" input={{ sessionID: route.sessionID }} mode="all" />
|
||||||
<Composer
|
<Composer
|
||||||
sessionID={route.sessionID}
|
sessionID={route.sessionID}
|
||||||
@@ -1032,6 +1106,11 @@ export function Session() {
|
|||||||
onSubmit={() => {
|
onSubmit={() => {
|
||||||
toBottom()
|
toBottom()
|
||||||
}}
|
}}
|
||||||
|
onEmptySubmit={async () => {
|
||||||
|
const next = queuedPrompts()[0]
|
||||||
|
if (!next) return false
|
||||||
|
return mutatePending("steer", next.id)
|
||||||
|
}}
|
||||||
sessionID={route.sessionID}
|
sessionID={route.sessionID}
|
||||||
/>
|
/>
|
||||||
</Match>
|
</Match>
|
||||||
@@ -1813,6 +1892,7 @@ function ShellMessage(props: { message: Extract<SessionMessageInfo, { type: "she
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<box
|
<box
|
||||||
|
width="100%"
|
||||||
border={["left"]}
|
border={["left"]}
|
||||||
paddingTop={1}
|
paddingTop={1}
|
||||||
paddingBottom={1}
|
paddingBottom={1}
|
||||||
@@ -1840,18 +1920,20 @@ function UserMessage(props: { message: SessionMessageUser }) {
|
|||||||
const mode = themes.mode
|
const mode = themes.mode
|
||||||
const [hover, setHover] = createSignal(false)
|
const [hover, setHover] = createSignal(false)
|
||||||
const color = createMemo(() => local.agent.color(data.session.get(ctx.sessionID)?.agent ?? "build"))
|
const color = createMemo(() => local.agent.color(data.session.get(ctx.sessionID)?.agent ?? "build"))
|
||||||
const queued = createMemo(
|
const delivery = createMemo(() => ctx.pendingDelivery(props.message.id))
|
||||||
() => data.session.status(ctx.sessionID) === "running" && data.session.input.has(ctx.sessionID, props.message.id),
|
|
||||||
)
|
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const renderer = useRenderer()
|
const renderer = useRenderer()
|
||||||
const promptRef = usePromptRef()
|
const promptRef = usePromptRef()
|
||||||
|
|
||||||
|
const updatePendingSteer = async (action: "queue" | "cancel") => {
|
||||||
|
if (await ctx.mutatePending(action, props.message.id)) dialog.clear()
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Show when={props.message.text.trim() || files().length}>
|
<Show when={props.message.text.trim() || files().length}>
|
||||||
<box
|
<box
|
||||||
border={["left"]}
|
border={["left"]}
|
||||||
borderColor={queued() ? theme.border.default : color()}
|
borderColor={delivery() ? theme.border.default : color()}
|
||||||
customBorderChars={SplitBorder.customBorderChars}
|
customBorderChars={SplitBorder.customBorderChars}
|
||||||
>
|
>
|
||||||
<box
|
<box
|
||||||
@@ -1863,6 +1945,21 @@ function UserMessage(props: { message: SessionMessageUser }) {
|
|||||||
}}
|
}}
|
||||||
onMouseUp={() => {
|
onMouseUp={() => {
|
||||||
if (renderer.getSelection()?.getSelectedText()) return
|
if (renderer.getSelection()?.getSelectedText()) return
|
||||||
|
if (delivery() === "steer") {
|
||||||
|
dialog.replace(() => (
|
||||||
|
<DialogSelect
|
||||||
|
title="Pending steer"
|
||||||
|
options={[
|
||||||
|
{ title: "Move to queue", value: "queue" as const },
|
||||||
|
{ title: "Delete", value: "cancel" as const },
|
||||||
|
]}
|
||||||
|
onSelect={(option) => {
|
||||||
|
void updatePendingSteer(option.value)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
return
|
||||||
|
}
|
||||||
dialog.replace(() => (
|
dialog.replace(() => (
|
||||||
<DialogMessage
|
<DialogMessage
|
||||||
messageID={props.message.id}
|
messageID={props.message.id}
|
||||||
@@ -1910,6 +2007,35 @@ function UserMessage(props: { message: SessionMessageUser }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function QueuedPromptDock(props: { prompts: { id: string; text: string }[]; onOpen: () => void }) {
|
||||||
|
const theme = useTheme("elevated")
|
||||||
|
const next = createMemo(() => props.prompts[0]?.text)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<box
|
||||||
|
border={["left"]}
|
||||||
|
borderColor={theme.border.default}
|
||||||
|
customBorderChars={SplitBorder.customBorderChars}
|
||||||
|
onMouseUp={props.onOpen}
|
||||||
|
>
|
||||||
|
<box
|
||||||
|
width="100%"
|
||||||
|
paddingTop={1}
|
||||||
|
paddingBottom={1}
|
||||||
|
paddingLeft={2}
|
||||||
|
paddingRight={1}
|
||||||
|
backgroundColor={theme.background.default}
|
||||||
|
flexDirection="row"
|
||||||
|
>
|
||||||
|
<text fg={theme.text.subdued} wrapMode="none" truncate flexGrow={1} flexShrink={1} minWidth={0}>
|
||||||
|
<span style={{ fg: theme.text.default }}>{props.prompts.length} queued</span>
|
||||||
|
<Show when={next()}>{(text) => <> · {text()}</>}</Show>
|
||||||
|
</text>
|
||||||
|
</box>
|
||||||
|
</box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function AssistantRetry(props: { retry: SessionMessageAssistant["retry"] }) {
|
function AssistantRetry(props: { retry: SessionMessageAssistant["retry"] }) {
|
||||||
const theme = useTheme()
|
const theme = useTheme()
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -3,8 +3,7 @@ import { createMemo, For, Match, Show, Switch } from "solid-js"
|
|||||||
import { Portal, useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid"
|
import { Portal, useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid"
|
||||||
import type { TextareaRenderable } from "@opentui/core"
|
import type { TextareaRenderable } from "@opentui/core"
|
||||||
import { useTheme, useThemes } from "../../context/theme"
|
import { useTheme, useThemes } from "../../context/theme"
|
||||||
import type { PermissionRequest } from "@opencode-ai/client"
|
import type { PermissionReply, PermissionRequest } from "@opencode-ai/client"
|
||||||
import { useClient } from "../../context/client"
|
|
||||||
import { SplitBorder } from "../../ui/border"
|
import { SplitBorder } from "../../ui/border"
|
||||||
import { useData } from "../../context/data"
|
import { useData } from "../../context/data"
|
||||||
import { filetype } from "../../util/filetype"
|
import { filetype } from "../../util/filetype"
|
||||||
@@ -15,6 +14,7 @@ import { Keymap } from "../../context/keymap"
|
|||||||
import { usePathFormatter } from "../../context/path-format"
|
import { usePathFormatter } from "../../context/path-format"
|
||||||
import { SimulationSemantics } from "../../simulation/semantics"
|
import { SimulationSemantics } from "../../simulation/semantics"
|
||||||
import { PatchDiff } from "../../component/patch-diff"
|
import { PatchDiff } from "../../component/patch-diff"
|
||||||
|
import { useToast } from "../../ui/toast"
|
||||||
|
|
||||||
type PermissionStage = "permission" | "always" | "reject"
|
type PermissionStage = "permission" | "always" | "reject"
|
||||||
|
|
||||||
@@ -110,8 +110,8 @@ function EditBody(props: { file?: string; diff?: string; patch?: string }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function PermissionPrompt(props: { request: PermissionRequest; directory?: string }) {
|
export function PermissionPrompt(props: { request: PermissionRequest; directory?: string }) {
|
||||||
const client = useClient()
|
|
||||||
const data = useData()
|
const data = useData()
|
||||||
|
const toast = useToast()
|
||||||
const [store, setStore] = createStore({
|
const [store, setStore] = createStore({
|
||||||
stage: "permission" as PermissionStage,
|
stage: "permission" as PermissionStage,
|
||||||
})
|
})
|
||||||
@@ -132,6 +132,12 @@ export function PermissionPrompt(props: { request: PermissionRequest; directory?
|
|||||||
|
|
||||||
const theme = useTheme()
|
const theme = useTheme()
|
||||||
|
|
||||||
|
function reply(value: PermissionReply, message?: string) {
|
||||||
|
void data.session.permission
|
||||||
|
.reply({ sessionID: props.request.sessionID, requestID: props.request.id, reply: value, message })
|
||||||
|
.catch((error: unknown) => toast.error(error))
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Switch>
|
<Switch>
|
||||||
<Match when={store.stage === "always"}>
|
<Match when={store.stage === "always"}>
|
||||||
@@ -151,11 +157,7 @@ export function PermissionPrompt(props: { request: PermissionRequest; directory?
|
|||||||
onSelect={(option) => {
|
onSelect={(option) => {
|
||||||
setStore("stage", "permission")
|
setStore("stage", "permission")
|
||||||
if (option === "cancel") return
|
if (option === "cancel") return
|
||||||
void client.api.permission.reply({
|
reply("always")
|
||||||
sessionID: props.request.sessionID,
|
|
||||||
reply: "always",
|
|
||||||
requestID: props.request.id,
|
|
||||||
})
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Match>
|
</Match>
|
||||||
@@ -164,12 +166,7 @@ export function PermissionPrompt(props: { request: PermissionRequest; directory?
|
|||||||
action={props.request.action}
|
action={props.request.action}
|
||||||
instance={props.request.id}
|
instance={props.request.id}
|
||||||
onConfirm={(message) => {
|
onConfirm={(message) => {
|
||||||
void client.api.permission.reply({
|
reply("reject", message || undefined)
|
||||||
sessionID: props.request.sessionID,
|
|
||||||
reply: "reject",
|
|
||||||
requestID: props.request.id,
|
|
||||||
message: message || undefined,
|
|
||||||
})
|
|
||||||
}}
|
}}
|
||||||
onCancel={() => {
|
onCancel={() => {
|
||||||
setStore("stage", "permission")
|
setStore("stage", "permission")
|
||||||
@@ -265,18 +262,10 @@ export function PermissionPrompt(props: { request: PermissionRequest; directory?
|
|||||||
setStore("stage", "reject")
|
setStore("stage", "reject")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
void client.api.permission.reply({
|
reply("reject")
|
||||||
sessionID: props.request.sessionID,
|
|
||||||
reply: "reject",
|
|
||||||
requestID: props.request.id,
|
|
||||||
})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
void client.api.permission.reply({
|
reply("once")
|
||||||
sessionID: props.request.sessionID,
|
|
||||||
reply: "once",
|
|
||||||
requestID: props.request.id,
|
|
||||||
})
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -46,9 +46,14 @@ export function createSessionRows(sessionID: Accessor<string>) {
|
|||||||
function reduce() {
|
function reduce() {
|
||||||
const messages = data.session.message.list(sessionID())
|
const messages = data.session.message.list(sessionID())
|
||||||
const inputs = new Set(data.session.input.list(sessionID()))
|
const inputs = new Set(data.session.input.list(sessionID()))
|
||||||
|
const pending = data.session.pending.list(sessionID())
|
||||||
|
const queued = new Set(
|
||||||
|
pending.flatMap((item) => (item.type === "user" && item.delivery === "queue" ? [item.id] : [])),
|
||||||
|
)
|
||||||
|
const visible = queued.size === 0 ? messages : messages.filter((message) => !queued.has(message.id))
|
||||||
const boundary = revertBoundary()
|
const boundary = revertBoundary()
|
||||||
const rows = reduceSessionRows(
|
const rows = reduceSessionRows(
|
||||||
boundary ? messages.filter((message) => message.id < boundary) : messages,
|
boundary ? visible.filter((message) => message.id < boundary) : visible,
|
||||||
inputs,
|
inputs,
|
||||||
turnTokens(),
|
turnTokens(),
|
||||||
)
|
)
|
||||||
@@ -57,8 +62,7 @@ export function createSessionRows(sessionID: Accessor<string>) {
|
|||||||
rows.splice(
|
rows.splice(
|
||||||
position === -1 ? rows.length : position,
|
position === -1 ? rows.length : position,
|
||||||
0,
|
0,
|
||||||
...data.session.pending
|
...pending
|
||||||
.list(sessionID())
|
|
||||||
.filter((item) => item.type === "compaction")
|
.filter((item) => item.type === "compaction")
|
||||||
.map((item): SessionRow => ({ type: "compaction-queued", inputID: item.id })),
|
.map((item): SessionRow => ({ type: "compaction-queued", inputID: item.id })),
|
||||||
)
|
)
|
||||||
@@ -112,10 +116,11 @@ export function createSessionRows(sessionID: Accessor<string>) {
|
|||||||
createEffect(
|
createEffect(
|
||||||
on(
|
on(
|
||||||
() =>
|
() =>
|
||||||
data.session.pending
|
data.session.pending.list(sessionID()).flatMap((item) => {
|
||||||
.list(sessionID())
|
if (item.type === "compaction") return [`${item.id}:compaction`]
|
||||||
.filter((item) => item.type === "compaction")
|
if (item.type === "user" && item.delivery === "queue") return [`${item.id}:queue`]
|
||||||
.map((item) => item.id),
|
return []
|
||||||
|
}),
|
||||||
() => setRows(reconcile(reduce())),
|
() => setRows(reconcile(reduce())),
|
||||||
{ defer: true },
|
{ defer: true },
|
||||||
),
|
),
|
||||||
@@ -196,7 +201,9 @@ export function createSessionRows(sessionID: Accessor<string>) {
|
|||||||
|
|
||||||
const queuedStart = (rows: SessionRow[]) => {
|
const queuedStart = (rows: SessionRow[]) => {
|
||||||
const index = rows.findIndex(
|
const index = rows.findIndex(
|
||||||
(row) => row.type === "compaction-queued" || (row.type === "message" && isPending(row.messageID)),
|
(row) =>
|
||||||
|
row.type === "compaction-queued" ||
|
||||||
|
(row.type === "message" && isPending(row.messageID)),
|
||||||
)
|
)
|
||||||
return index === -1 ? rows.length : index
|
return index === -1 ? rows.length : index
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
export function webSearchProviderLabel(provider: unknown) {
|
||||||
if (provider === "parallel") return "Parallel Web Search"
|
if (provider === "parallel") return "Parallel Web Search"
|
||||||
if (provider === "exa") return "Exa Web Search"
|
if (provider === "exa") return "Exa Web Search"
|
||||||
|
if (provider === "firecrawl") return "Firecrawl Web Search"
|
||||||
return "Web Search"
|
return "Web Search"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -914,6 +914,106 @@ test("completes exploration when a queued prompt is promoted", async () => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("updates and removes queued inputs from durable lifecycle events", async () => {
|
||||||
|
const events = createEventStream()
|
||||||
|
const sessionID = "session-queue-management"
|
||||||
|
const calls = createFetch((url) => {
|
||||||
|
if (url.pathname === `/api/session/${sessionID}/message`) return json({ data: [], cursor: {} })
|
||||||
|
}, events)
|
||||||
|
let data!: ReturnType<typeof useData>
|
||||||
|
let rows!: ReturnType<typeof createSessionRows>
|
||||||
|
let client!: ReturnType<typeof useClient>
|
||||||
|
|
||||||
|
function Probe() {
|
||||||
|
client = useClient()
|
||||||
|
data = useData()
|
||||||
|
rows = createSessionRows(() => sessionID)
|
||||||
|
return <box />
|
||||||
|
}
|
||||||
|
|
||||||
|
const app = await testRender(() => (
|
||||||
|
<TestTuiContexts>
|
||||||
|
<ClientProvider api={createApi(calls.fetch)}>
|
||||||
|
<ProjectProvider>
|
||||||
|
<DataProvider>
|
||||||
|
<Probe />
|
||||||
|
</DataProvider>
|
||||||
|
</ProjectProvider>
|
||||||
|
</ClientProvider>
|
||||||
|
</TestTuiContexts>
|
||||||
|
))
|
||||||
|
|
||||||
|
try {
|
||||||
|
await wait(() => client.connection.status() === "connected")
|
||||||
|
emitEvent(events, {
|
||||||
|
id: "evt_queue_admitted",
|
||||||
|
created: 1,
|
||||||
|
type: "session.input.admitted",
|
||||||
|
durable: durable(sessionID),
|
||||||
|
data: {
|
||||||
|
sessionID,
|
||||||
|
inputID: "message-queued",
|
||||||
|
input: { type: "user", data: { text: "Steer me" }, delivery: "queue" },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
await wait(() => data.session.pending.list(sessionID).length === 1)
|
||||||
|
expect(rows).not.toContainEqual({ type: "message", messageID: "message-queued" })
|
||||||
|
|
||||||
|
emitEvent(events, {
|
||||||
|
id: "evt_queue_steered",
|
||||||
|
created: 2,
|
||||||
|
type: "session.input.steered",
|
||||||
|
durable: durable(sessionID, 1),
|
||||||
|
data: { sessionID, inputID: "message-queued" },
|
||||||
|
})
|
||||||
|
await wait(() =>
|
||||||
|
data.session.pending
|
||||||
|
.list(sessionID)
|
||||||
|
.some((item) => item.id === "message-queued" && item.type !== "compaction" && item.delivery === "steer"),
|
||||||
|
)
|
||||||
|
expect(rows).toContainEqual({ type: "message", messageID: "message-queued" })
|
||||||
|
|
||||||
|
emitEvent(events, {
|
||||||
|
id: "evt_queue_restored",
|
||||||
|
created: 3,
|
||||||
|
type: "session.input.queued",
|
||||||
|
durable: durable(sessionID, 2),
|
||||||
|
data: { sessionID, inputID: "message-queued" },
|
||||||
|
})
|
||||||
|
await wait(() =>
|
||||||
|
data.session.pending
|
||||||
|
.list(sessionID)
|
||||||
|
.some((item) => item.id === "message-queued" && item.type !== "compaction" && item.delivery === "queue"),
|
||||||
|
)
|
||||||
|
expect(rows).not.toContainEqual({ type: "message", messageID: "message-queued" })
|
||||||
|
|
||||||
|
emitEvent(events, {
|
||||||
|
id: "evt_cancel_admitted",
|
||||||
|
created: 4,
|
||||||
|
type: "session.input.admitted",
|
||||||
|
durable: durable(sessionID, 3),
|
||||||
|
data: {
|
||||||
|
sessionID,
|
||||||
|
inputID: "message-cancelled",
|
||||||
|
input: { type: "user", data: { text: "Delete me" }, delivery: "queue" },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
await wait(() => data.session.pending.list(sessionID).length === 2)
|
||||||
|
emitEvent(events, {
|
||||||
|
id: "evt_queue_cancelled",
|
||||||
|
created: 5,
|
||||||
|
type: "session.input.cancelled",
|
||||||
|
durable: durable(sessionID, 4),
|
||||||
|
data: { sessionID, inputID: "message-cancelled" },
|
||||||
|
})
|
||||||
|
await wait(() => !data.session.input.has(sessionID, "message-cancelled"))
|
||||||
|
expect(data.session.pending.list(sessionID).map((item) => item.id)).toEqual(["message-queued"])
|
||||||
|
expect(data.session.message.get(sessionID, "message-cancelled")).toBeUndefined()
|
||||||
|
} finally {
|
||||||
|
app.renderer.destroy()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
test("classifies live tool rows independently of their call ID", async () => {
|
test("classifies live tool rows independently of their call ID", async () => {
|
||||||
const events = createEventStream()
|
const events = createEventStream()
|
||||||
const sessionID = "session-tool-call-id"
|
const sessionID = "session-tool-call-id"
|
||||||
@@ -2067,6 +2167,64 @@ test("reconciles active session permissions when the event stream reconnects", a
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("dismisses a permission that expired before its reply", async () => {
|
||||||
|
const events = createEventStream()
|
||||||
|
const request = { id: "per_stale", sessionID: "ses_active", action: "read", resources: ["old.txt"] }
|
||||||
|
let replies = 0
|
||||||
|
const calls = createFetch((url, init) => {
|
||||||
|
if (url.pathname === "/api/session/ses_active/permission/per_stale/reply" && init.method === "POST") {
|
||||||
|
replies++
|
||||||
|
return json(
|
||||||
|
{
|
||||||
|
_tag: "PermissionNotFoundError",
|
||||||
|
requestID: request.id,
|
||||||
|
message: `Permission request not found: ${request.id}`,
|
||||||
|
},
|
||||||
|
{ status: 404 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}, events)
|
||||||
|
let data!: ReturnType<typeof useData>
|
||||||
|
|
||||||
|
function Probe() {
|
||||||
|
data = useData()
|
||||||
|
return <box />
|
||||||
|
}
|
||||||
|
|
||||||
|
const app = await testRender(() => (
|
||||||
|
<TestTuiContexts>
|
||||||
|
<ClientProvider api={createApi(calls.fetch)}>
|
||||||
|
<ProjectProvider>
|
||||||
|
<DataProvider>
|
||||||
|
<Probe />
|
||||||
|
</DataProvider>
|
||||||
|
</ProjectProvider>
|
||||||
|
</ClientProvider>
|
||||||
|
</TestTuiContexts>
|
||||||
|
))
|
||||||
|
|
||||||
|
try {
|
||||||
|
emitEvent(events, {
|
||||||
|
id: "evt_permission_asked_stale",
|
||||||
|
created: 0,
|
||||||
|
type: "permission.asked",
|
||||||
|
data: request,
|
||||||
|
})
|
||||||
|
await wait(() => data.session.permission.list(request.sessionID)?.length === 1)
|
||||||
|
|
||||||
|
await data.session.permission.reply({
|
||||||
|
sessionID: request.sessionID,
|
||||||
|
requestID: request.id,
|
||||||
|
reply: "once",
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(replies).toBe(1)
|
||||||
|
expect(data.session.permission.list(request.sessionID)).toEqual([])
|
||||||
|
} finally {
|
||||||
|
app.renderer.destroy()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
test("adds, dismisses, and refreshes form requests", async () => {
|
test("adds, dismisses, and refreshes form requests", async () => {
|
||||||
const events = createEventStream()
|
const events = createEventStream()
|
||||||
const calls = createFetch((url) => {
|
const calls = createFetch((url) => {
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import { TestTuiContexts } from "../../fixture/tui-environment"
|
|||||||
import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
|
import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
|
||||||
import { createApi, createEventStream, createFetch } from "../../fixture/tui-client"
|
import { createApi, createEventStream, createFetch } from "../../fixture/tui-client"
|
||||||
|
|
||||||
async function mountForm(root: string, width = 80) {
|
async function mountForm(root: string, width = 80, fields?: FormWithLocation["fields"]) {
|
||||||
const state = path.join(root, "state")
|
const state = path.join(root, "state")
|
||||||
await mkdir(state, { recursive: true })
|
await mkdir(state, { recursive: true })
|
||||||
|
|
||||||
@@ -37,7 +37,7 @@ async function mountForm(root: string, width = 80) {
|
|||||||
id: "frm_test",
|
id: "frm_test",
|
||||||
sessionID: "ses_test",
|
sessionID: "ses_test",
|
||||||
title: "Authorization required",
|
title: "Authorization required",
|
||||||
fields: [
|
fields: fields ?? [
|
||||||
{
|
{
|
||||||
key: "authorization",
|
key: "authorization",
|
||||||
type: "external",
|
type: "external",
|
||||||
@@ -126,3 +126,57 @@ test("includes external acknowledgements in progress", async () => {
|
|||||||
prompt.app.renderer.destroy()
|
prompt.app.renderer.destroy()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("pasting on a custom choice opens its editor without submitting", async () => {
|
||||||
|
await using tmp = await tmpdir()
|
||||||
|
const prompt = await mountForm(tmp.path, 80, [
|
||||||
|
{
|
||||||
|
key: "target",
|
||||||
|
type: "string",
|
||||||
|
options: [{ value: "staging", label: "Staging" }],
|
||||||
|
custom: true,
|
||||||
|
},
|
||||||
|
])
|
||||||
|
try {
|
||||||
|
await prompt.app.mockInput.pasteBracketedText("production\nwest")
|
||||||
|
await prompt.app.waitFor(() => prompt.app.renderer.currentFocusedEditor?.plainText === "production\nwest")
|
||||||
|
|
||||||
|
expect(prompt.app.captureCharFrame()).toContain("Type your own answer")
|
||||||
|
expect(prompt.replies).toEqual([])
|
||||||
|
} finally {
|
||||||
|
prompt.app.renderer.destroy()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test("text fields retain default paste behavior", async () => {
|
||||||
|
await using tmp = await tmpdir()
|
||||||
|
const prompt = await mountForm(tmp.path, 80, [{ key: "notes", type: "string" }])
|
||||||
|
try {
|
||||||
|
await prompt.app.mockInput.pasteBracketedText("normal paste")
|
||||||
|
|
||||||
|
expect(prompt.app.renderer.currentFocusedEditor?.plainText).toBe("normal paste")
|
||||||
|
expect(prompt.replies).toEqual([])
|
||||||
|
} finally {
|
||||||
|
prompt.app.renderer.destroy()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test("pasting on a choice without custom answers does not open an editor", async () => {
|
||||||
|
await using tmp = await tmpdir()
|
||||||
|
const prompt = await mountForm(tmp.path, 80, [
|
||||||
|
{
|
||||||
|
key: "target",
|
||||||
|
type: "string",
|
||||||
|
options: [{ value: "staging", label: "Staging" }],
|
||||||
|
},
|
||||||
|
])
|
||||||
|
try {
|
||||||
|
await prompt.app.mockInput.pasteBracketedText("production")
|
||||||
|
|
||||||
|
expect(prompt.app.renderer.currentFocusedEditor).toBeNull()
|
||||||
|
expect(prompt.app.captureCharFrame()).not.toContain("production")
|
||||||
|
expect(prompt.replies).toEqual([])
|
||||||
|
} finally {
|
||||||
|
prompt.app.renderer.destroy()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|||||||
@@ -18,7 +18,10 @@ test("validates mini replay settings", () => {
|
|||||||
test("validates the session tabs setting", () => {
|
test("validates the session tabs setting", () => {
|
||||||
const decode = Schema.decodeUnknownSync(Info)
|
const decode = Schema.decodeUnknownSync(Info)
|
||||||
|
|
||||||
expect(decode({ tabs: { enabled: true, vertical: true } })).toEqual({ tabs: { enabled: true, vertical: true } })
|
expect(decode({ tabs: { enabled: true, layout: "vertical" } })).toEqual({
|
||||||
|
tabs: { enabled: true, layout: "vertical" },
|
||||||
|
})
|
||||||
|
expect(() => decode({ tabs: { layout: true } })).toThrow()
|
||||||
expect(() => decode({ tabs: { enabled: "on" } })).toThrow()
|
expect(() => decode({ tabs: { enabled: "on" } })).toThrow()
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -39,12 +42,13 @@ test("resolves nested config and keybind defaults", () => {
|
|||||||
expect(config.scroll).toEqual({ speed: 2, acceleration: true })
|
expect(config.scroll).toEqual({ speed: 2, acceleration: true })
|
||||||
expect(config.diffs).toEqual({ view: "split" })
|
expect(config.diffs).toEqual({ view: "split" })
|
||||||
expect(config.debug).toEqual({ devtools: true })
|
expect(config.debug).toEqual({ devtools: true })
|
||||||
expect(config.tabs).toEqual({ enabled: true, scope: "cwd" })
|
expect(config.tabs).toEqual({ enabled: true, scope: "cwd", layout: "horizontal" })
|
||||||
})
|
})
|
||||||
|
|
||||||
test("shows resolved tab defaults in settings", () => {
|
test("shows resolved tab defaults in settings", () => {
|
||||||
expect(settings.find((setting) => setting.path.join(".") === "tabs.enabled")?.default).toBe(true)
|
expect(settings.find((setting) => setting.path.join(".") === "tabs.enabled")?.default).toBe(true)
|
||||||
expect(settings.find((setting) => setting.path.join(".") === "tabs.scope")?.default).toBe("cwd")
|
expect(settings.find((setting) => setting.path.join(".") === "tabs.scope")?.default).toBe("cwd")
|
||||||
|
expect(settings.find((setting) => setting.path.join(".") === "tabs.layout")?.default).toBe("horizontal")
|
||||||
})
|
})
|
||||||
|
|
||||||
test("provides config and its host interface", async () => {
|
test("provides config and its host interface", async () => {
|
||||||
|
|||||||
@@ -56,9 +56,9 @@ export function createFooterApiFixture(input: { events?: FooterEvent[]; commits?
|
|||||||
commits,
|
commits,
|
||||||
calls,
|
calls,
|
||||||
promptReady,
|
promptReady,
|
||||||
submit(text: string, mode?: RunPrompt["mode"]) {
|
submit(text: string, mode?: RunPrompt["mode"], delivery?: RunPrompt["delivery"]) {
|
||||||
if (prompts.size === 0) return false
|
if (prompts.size === 0) return false
|
||||||
const prompt: RunPrompt = mode ? { text, parts: [], mode } : { text, parts: [] }
|
const prompt: RunPrompt = { text, parts: [], ...(mode ? { mode } : {}), ...(delivery ? { delivery } : {}) }
|
||||||
for (const fn of [...prompts]) fn(prompt)
|
for (const fn of [...prompts]) fn(prompt)
|
||||||
return true
|
return true
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import { RunFooterView } from "../../src/mini/footer.view"
|
|||||||
import { RunEntryContent } from "../../src/mini/scrollback.writer"
|
import { RunEntryContent } from "../../src/mini/scrollback.writer"
|
||||||
import { RUN_THEME_FALLBACK, type RunTheme } from "../../src/mini/theme"
|
import { RUN_THEME_FALLBACK, type RunTheme } from "../../src/mini/theme"
|
||||||
import type {
|
import type {
|
||||||
|
FooterQueuedPrompt,
|
||||||
FooterState,
|
FooterState,
|
||||||
FooterSubagentState,
|
FooterSubagentState,
|
||||||
FooterSubagentTab,
|
FooterSubagentTab,
|
||||||
@@ -120,13 +121,15 @@ async function renderFooter(
|
|||||||
height?: number
|
height?: number
|
||||||
state?: Partial<FooterState>
|
state?: Partial<FooterState>
|
||||||
onCycle?: () => void
|
onCycle?: () => void
|
||||||
onSubmit?: (prompt: RunPrompt) => boolean
|
onSubmit?: (prompt: RunPrompt) => boolean | Promise<boolean>
|
||||||
view?: FooterView
|
view?: FooterView
|
||||||
onFormReply?: (input: unknown) => void
|
onFormReply?: (input: unknown) => void
|
||||||
miniSettings?: MiniSettings
|
miniSettings?: MiniSettings
|
||||||
mono?: boolean
|
mono?: boolean
|
||||||
onStatus?: (status: string) => void
|
onStatus?: (status: string) => void
|
||||||
onMiniSettingChange?: (change: MiniSettingChange) => void
|
onMiniSettingChange?: (change: MiniSettingChange) => void
|
||||||
|
queuedPrompts?: FooterQueuedPrompt[]
|
||||||
|
onQueuedPromptAction?: (action: "steer" | "cancel", inputID: string) => Promise<void>
|
||||||
} = {},
|
} = {},
|
||||||
) {
|
) {
|
||||||
const [view, setView] = createSignal<FooterView>(input.view ?? { type: "prompt" })
|
const [view, setView] = createSignal<FooterView>(input.view ?? { type: "prompt" })
|
||||||
@@ -164,6 +167,7 @@ async function renderFooter(
|
|||||||
state={state}
|
state={state}
|
||||||
view={view}
|
view={view}
|
||||||
subagent={subagents}
|
subagent={subagents}
|
||||||
|
queuedPrompts={() => input.queuedPrompts ?? []}
|
||||||
theme={input.theme ?? (() => RUN_THEME_FALLBACK)}
|
theme={input.theme ?? (() => RUN_THEME_FALLBACK)}
|
||||||
mono={input.mono ?? false}
|
mono={input.mono ?? false}
|
||||||
miniSettings={miniSettings}
|
miniSettings={miniSettings}
|
||||||
@@ -173,6 +177,7 @@ async function renderFooter(
|
|||||||
onFormCancel={() => {}}
|
onFormCancel={() => {}}
|
||||||
onCycle={input.onCycle ?? (() => {})}
|
onCycle={input.onCycle ?? (() => {})}
|
||||||
onInterrupt={() => false}
|
onInterrupt={() => false}
|
||||||
|
onQueuedPromptAction={input.onQueuedPromptAction}
|
||||||
onEditorOpen={async () => undefined}
|
onEditorOpen={async () => undefined}
|
||||||
onInputClear={() => {}}
|
onInputClear={() => {}}
|
||||||
onExit={() => {}}
|
onExit={() => {}}
|
||||||
@@ -272,6 +277,41 @@ test("direct footer preserves a partial multi-field form draft across permission
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("direct footer paste opens a custom choice editor without submitting", async () => {
|
||||||
|
const replies: unknown[] = []
|
||||||
|
const app = await renderFooter({
|
||||||
|
height: 12,
|
||||||
|
view: {
|
||||||
|
type: "form",
|
||||||
|
request: {
|
||||||
|
id: "frm_custom_paste",
|
||||||
|
sessionID: "ses_child",
|
||||||
|
title: "Deployment target",
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
key: "target",
|
||||||
|
type: "string",
|
||||||
|
options: [{ value: "staging", label: "Staging" }],
|
||||||
|
custom: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
onFormReply: (reply) => replies.push(reply),
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
await app.renderOnce()
|
||||||
|
await app.mockInput.pasteBracketedText("production\nwest")
|
||||||
|
await app.renderOnce()
|
||||||
|
|
||||||
|
expect(app.renderer.currentFocusedEditor?.plainText).toBe("production\nwest")
|
||||||
|
expect(replies).toEqual([])
|
||||||
|
} finally {
|
||||||
|
app.cleanup()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
function expectPaletteList(list: BoxRenderable, selectedIndex: number) {
|
function expectPaletteList(list: BoxRenderable, selectedIndex: number) {
|
||||||
expect(list.backgroundColor.toInts()).toEqual((RUN_THEME_FALLBACK.footer.shade as RGBA).toInts())
|
expect(list.backgroundColor.toInts()).toEqual((RUN_THEME_FALLBACK.footer.shade as RGBA).toInts())
|
||||||
expect((list.getChildren()[selectedIndex] as BoxRenderable).backgroundColor.toInts()).toEqual(
|
expect((list.getChildren()[selectedIndex] as BoxRenderable).backgroundColor.toInts()).toEqual(
|
||||||
@@ -913,7 +953,7 @@ test("direct subagent panel closes when moving up from the first item", async ()
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
test("direct pending panel shows durable delivery without edit actions", async () => {
|
test("direct queued panel steers and deletes selected prompts", async () => {
|
||||||
const [prompts] = createSignal([
|
const [prompts] = createSignal([
|
||||||
{
|
{
|
||||||
messageID: "m-1",
|
messageID: "m-1",
|
||||||
@@ -921,16 +961,22 @@ test("direct pending panel shows durable delivery without edit actions", async (
|
|||||||
delivery: "queue" as const,
|
delivery: "queue" as const,
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
|
const steered: string[] = []
|
||||||
|
const deleted: string[] = []
|
||||||
|
|
||||||
const app = await testRender(
|
const app = await testRender(
|
||||||
() => (
|
() => (
|
||||||
<box width={100} height={RUN_SUBAGENT_PANEL_ROWS}>
|
<Keymap.Provider config={tuiConfig}>
|
||||||
<RunQueuedPromptSelectBody
|
<box width={100} height={RUN_SUBAGENT_PANEL_ROWS}>
|
||||||
theme={() => RUN_THEME_FALLBACK.footer}
|
<RunQueuedPromptSelectBody
|
||||||
prompts={prompts}
|
theme={() => RUN_THEME_FALLBACK.footer}
|
||||||
onClose={() => {}}
|
prompts={prompts}
|
||||||
/>
|
onClose={() => {}}
|
||||||
</box>
|
onSteer={(prompt) => steered.push(prompt.messageID)}
|
||||||
|
onDelete={(prompt) => deleted.push(prompt.messageID)}
|
||||||
|
/>
|
||||||
|
</box>
|
||||||
|
</Keymap.Provider>
|
||||||
),
|
),
|
||||||
{ width: 100, height: RUN_SUBAGENT_PANEL_ROWS },
|
{ width: 100, height: RUN_SUBAGENT_PANEL_ROWS },
|
||||||
)
|
)
|
||||||
@@ -940,19 +986,98 @@ test("direct pending panel shows durable delivery without edit actions", async (
|
|||||||
const frame = app.captureCharFrame()
|
const frame = app.captureCharFrame()
|
||||||
const list = panelMenu(app.renderer.root)
|
const list = panelMenu(app.renderer.root)
|
||||||
|
|
||||||
expect(frame).toContain("Pending work")
|
expect(frame).toContain("Queued prompts")
|
||||||
expect(frame).toContain("fix the auth test")
|
expect(frame).toContain("fix the auth test")
|
||||||
expect(frame).toContain("queue")
|
expect(frame).toContain("queued")
|
||||||
|
expect(frame).toContain("enter steer · ctrl+d delete")
|
||||||
expect(frame).not.toContain("┌")
|
expect(frame).not.toContain("┌")
|
||||||
expect(frame).not.toContain("┃")
|
expect(frame).not.toContain("┃")
|
||||||
expectPaletteList(list, 0)
|
expectPaletteList(list, 0)
|
||||||
expect(frame).not.toContain("edit")
|
app.mockInput.pressEnter()
|
||||||
expect(frame).not.toContain("remove")
|
app.mockInput.pressKey("d", { ctrl: true })
|
||||||
|
expect(steered).toEqual(["m-1"])
|
||||||
|
expect(deleted).toEqual(["m-1"])
|
||||||
} finally {
|
} finally {
|
||||||
app.renderer.destroy()
|
app.renderer.destroy()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("direct footer steers the oldest queued prompt from an empty composer", async () => {
|
||||||
|
const steered: string[] = []
|
||||||
|
const app = await renderFooter({
|
||||||
|
queuedPrompts: [
|
||||||
|
{ messageID: "m-1", prompt: { text: "first", parts: [] }, delivery: "queue" },
|
||||||
|
{ messageID: "m-2", prompt: { text: "second", parts: [] }, delivery: "queue" },
|
||||||
|
],
|
||||||
|
onQueuedPromptAction: async (action, inputID) => {
|
||||||
|
if (action === "steer") steered.push(inputID)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
await app.renderOnce()
|
||||||
|
app.mockInput.pressEnter({ meta: true })
|
||||||
|
await Bun.sleep(0)
|
||||||
|
expect(steered).toEqual([])
|
||||||
|
app.mockInput.pressEnter()
|
||||||
|
await Bun.sleep(0)
|
||||||
|
expect(steered).toEqual(["m-1"])
|
||||||
|
} finally {
|
||||||
|
app.cleanup()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test("direct footer does not steer queued work on a double submit", async () => {
|
||||||
|
const submitted: RunPrompt[] = []
|
||||||
|
const steered: string[] = []
|
||||||
|
const app = await renderFooter({
|
||||||
|
queuedPrompts: [{ messageID: "m-1", prompt: { text: "queued", parts: [] }, delivery: "queue" }],
|
||||||
|
onSubmit: async (prompt) => {
|
||||||
|
submitted.push(prompt)
|
||||||
|
await Bun.sleep(10)
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
onQueuedPromptAction: async (action, inputID) => {
|
||||||
|
if (action === "steer") steered.push(inputID)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
await app.renderOnce()
|
||||||
|
await app.mockInput.typeText("send once")
|
||||||
|
app.mockInput.pressEnter()
|
||||||
|
app.mockInput.pressEnter()
|
||||||
|
await Bun.sleep(20)
|
||||||
|
expect(submitted).toHaveLength(1)
|
||||||
|
expect(steered).toEqual([])
|
||||||
|
} finally {
|
||||||
|
app.cleanup()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test("direct footer rejects local commands submitted with the queue shortcut", async () => {
|
||||||
|
const submitted: RunPrompt[] = []
|
||||||
|
const statuses: string[] = []
|
||||||
|
const app = await renderFooter({
|
||||||
|
onSubmit: (prompt) => {
|
||||||
|
submitted.push(prompt)
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
onStatus: (status) => statuses.push(status),
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
await app.renderOnce()
|
||||||
|
await app.mockInput.typeText("/settings ")
|
||||||
|
app.mockInput.pressEnter({ meta: true })
|
||||||
|
await Bun.sleep(0)
|
||||||
|
expect(submitted).toEqual([])
|
||||||
|
expect(statuses).toContain("this prompt cannot be queued")
|
||||||
|
} finally {
|
||||||
|
app.cleanup()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
// OpenTUI currently crashes Bun in the full `test/cli/run` directory run here.
|
// OpenTUI currently crashes Bun in the full `test/cli/run` directory run here.
|
||||||
// Re-enable after the upstream OpenTUI fix lands in this repo.
|
// Re-enable after the upstream OpenTUI fix lands in this repo.
|
||||||
test.skip("direct footer recreates the frame across command panel transitions", async () => {
|
test.skip("direct footer recreates the frame across command panel transitions", async () => {
|
||||||
@@ -1068,11 +1193,11 @@ test("direct footer submits slash autocomplete selections without dispatching sh
|
|||||||
await app.renderOnce()
|
await app.renderOnce()
|
||||||
|
|
||||||
expect(submits).toEqual([
|
expect(submits).toEqual([
|
||||||
{ text: "/review ", parts: [], command: { name: "review", arguments: "" } },
|
{ text: "/review ", parts: [], command: { name: "review", arguments: "" }, delivery: "steer" },
|
||||||
{ text: "/review ", parts: [], command: { name: "review", arguments: "" } },
|
{ text: "/review ", parts: [], command: { name: "review", arguments: "" }, delivery: "steer" },
|
||||||
{ text: "/review branch", parts: [], command: { name: "review", arguments: "branch" } },
|
{ text: "/review branch", parts: [], command: { name: "review", arguments: "branch" }, delivery: "steer" },
|
||||||
{ text: "/new ", parts: [] },
|
{ text: "/new ", parts: [], delivery: "steer" },
|
||||||
{ text: "/new ", parts: [] },
|
{ text: "/new ", parts: [], delivery: "steer" },
|
||||||
])
|
])
|
||||||
expect(app.renderer.currentFocusedEditor?.plainText).toBe("/settings ")
|
expect(app.renderer.currentFocusedEditor?.plainText).toBe("/settings ")
|
||||||
} finally {
|
} finally {
|
||||||
@@ -1100,7 +1225,9 @@ test("direct footer slash autocomplete keeps a real skills command", async () =>
|
|||||||
app.mockInput.pressEnter()
|
app.mockInput.pressEnter()
|
||||||
await app.renderOnce()
|
await app.renderOnce()
|
||||||
|
|
||||||
expect(submits).toEqual([{ text: "/skills ", parts: [], command: { name: "skills", arguments: "" } }])
|
expect(submits).toEqual([
|
||||||
|
{ text: "/skills ", parts: [], command: { name: "skills", arguments: "" }, delivery: "steer" },
|
||||||
|
])
|
||||||
expect(app.captureCharFrame()).not.toContain("Apply formatter fixes")
|
expect(app.captureCharFrame()).not.toContain("Apply formatter fixes")
|
||||||
} finally {
|
} finally {
|
||||||
app.cleanup()
|
app.cleanup()
|
||||||
@@ -1158,7 +1285,12 @@ test("direct footer tags skill slash submissions with their catalog source", asy
|
|||||||
await app.renderOnce()
|
await app.renderOnce()
|
||||||
|
|
||||||
expect(submits).toEqual([
|
expect(submits).toEqual([
|
||||||
{ text: "/formatter src", parts: [], command: { name: "formatter", arguments: "src", source: "skill" } },
|
{
|
||||||
|
text: "/formatter src",
|
||||||
|
parts: [],
|
||||||
|
command: { name: "formatter", arguments: "src", source: "skill" },
|
||||||
|
delivery: "steer",
|
||||||
|
},
|
||||||
])
|
])
|
||||||
} finally {
|
} finally {
|
||||||
app.cleanup()
|
app.cleanup()
|
||||||
@@ -1238,7 +1370,7 @@ test.skip("direct footer clears the synthetic skills draft when the panel closes
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
test("direct footer shows authoritative pending work while running", async () => {
|
test("direct footer shows authoritative queued work while running", async () => {
|
||||||
const [state] = createSignal<FooterState>({
|
const [state] = createSignal<FooterState>({
|
||||||
phase: "running",
|
phase: "running",
|
||||||
status: "",
|
status: "",
|
||||||
@@ -1342,9 +1474,9 @@ test("direct footer shows authoritative pending work while running", async () =>
|
|||||||
const hint = statusItems.at(-1)!
|
const hint = statusItems.at(-1)!
|
||||||
|
|
||||||
expect(spinner).toBeDefined()
|
expect(spinner).toBeDefined()
|
||||||
expect(frame).toContain("1 pending")
|
expect(frame).toContain("1 queued")
|
||||||
expect(frame).toContain("ctrl+b background")
|
expect(frame).toContain("ctrl+b background")
|
||||||
expect(frame).toContain("ctrl+x q 1 pending")
|
expect(frame).toContain("ctrl+x q 1 queued")
|
||||||
expect(frame).toContain("↓ subagents")
|
expect(frame).toContain("↓ subagents")
|
||||||
expect(frame).toContain("ctrl+p cmd")
|
expect(frame).toContain("ctrl+p cmd")
|
||||||
expect(frame).toContain("subagents · ctrl+p cmd")
|
expect(frame).toContain("subagents · ctrl+p cmd")
|
||||||
|
|||||||
@@ -82,7 +82,8 @@ describe("run runtime boot", () => {
|
|||||||
expect(result.keybinds.get("prompt.history.next")?.[0]?.key).toBe("down")
|
expect(result.keybinds.get("prompt.history.next")?.[0]?.key).toBe("down")
|
||||||
expect(result.keybinds.get("prompt.clear")?.[0]?.key).toBe("ctrl+c")
|
expect(result.keybinds.get("prompt.clear")?.[0]?.key).toBe("ctrl+c")
|
||||||
expect(result.keybinds.get("input.submit")?.[0]?.key).toBe("return")
|
expect(result.keybinds.get("input.submit")?.[0]?.key).toBe("return")
|
||||||
expect(result.keybinds.get("input.newline")?.[0]?.key).toBe("shift+return,ctrl+return,alt+return,ctrl+j")
|
expect(result.keybinds.get("input.newline")?.[0]?.key).toBe("shift+return,ctrl+return,ctrl+j")
|
||||||
|
expect(result.keybinds.get("prompt.queue")?.[0]?.key).toBe("alt+return")
|
||||||
})
|
})
|
||||||
|
|
||||||
test("preserves disabled leader from resolved tui config", async () => {
|
test("preserves disabled leader from resolved tui config", async () => {
|
||||||
|
|||||||
@@ -265,6 +265,33 @@ describe("run runtime queue", () => {
|
|||||||
await task
|
await task
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("preserves explicit steer and queue delivery for in-flight prompts", async () => {
|
||||||
|
const ui = createFooterApiFixture()
|
||||||
|
const admitted: string[] = []
|
||||||
|
const gate = Promise.withResolvers<void>()
|
||||||
|
|
||||||
|
const task = runPromptQueue({
|
||||||
|
footer: ui.api,
|
||||||
|
run: async (_input, _signal, onAdmitted) => {
|
||||||
|
onAdmitted()
|
||||||
|
await gate.promise
|
||||||
|
},
|
||||||
|
admit: async (input, delivery) => {
|
||||||
|
admitted.push(`${input.text}:${delivery}`)
|
||||||
|
},
|
||||||
|
settle: async () => ui.api.close(),
|
||||||
|
})
|
||||||
|
|
||||||
|
ui.submit("one")
|
||||||
|
ui.submit("two", undefined, "steer")
|
||||||
|
ui.submit("three", undefined, "queue")
|
||||||
|
while (admitted.length < 2) await Bun.sleep(0)
|
||||||
|
expect(admitted).toEqual(["two:steer", "three:queue"])
|
||||||
|
|
||||||
|
gate.resolve()
|
||||||
|
await task
|
||||||
|
})
|
||||||
|
|
||||||
test("continues durable admission after one fails", async () => {
|
test("continues durable admission after one fails", async () => {
|
||||||
const ui = createFooterApiFixture()
|
const ui = createFooterApiFixture()
|
||||||
const admitted: string[] = []
|
const admitted: string[] = []
|
||||||
@@ -308,7 +335,7 @@ describe("run runtime queue", () => {
|
|||||||
admitted()
|
admitted()
|
||||||
await new Promise<void>((resolve) => signal.addEventListener("abort", () => resolve(), { once: true }))
|
await new Promise<void>((resolve) => signal.addEventListener("abort", () => resolve(), { once: true }))
|
||||||
},
|
},
|
||||||
admit: async (_prompt, signal) => {
|
admit: async (_prompt, _delivery, signal) => {
|
||||||
admissionStarted.resolve()
|
admissionStarted.resolve()
|
||||||
await new Promise<void>((resolve) => {
|
await new Promise<void>((resolve) => {
|
||||||
if (signal.aborted) {
|
if (signal.aborted) {
|
||||||
|
|||||||
@@ -126,7 +126,7 @@ describe("run interactive runtime", () => {
|
|||||||
turnStarted.resolve()
|
turnStarted.resolve()
|
||||||
api.close()
|
api.close()
|
||||||
},
|
},
|
||||||
queuePromptTurn: async () => {},
|
admitPromptTurn: async () => {},
|
||||||
waitForIdle: async () => {},
|
waitForIdle: async () => {},
|
||||||
interruptActiveTurn: async () => {},
|
interruptActiveTurn: async () => {},
|
||||||
selectSubagent: () => {},
|
selectSubagent: () => {},
|
||||||
@@ -209,7 +209,7 @@ describe("run interactive runtime", () => {
|
|||||||
streamStarted.resolve()
|
streamStarted.resolve()
|
||||||
return {
|
return {
|
||||||
runPromptTurn: async () => {},
|
runPromptTurn: async () => {},
|
||||||
queuePromptTurn: async () => {},
|
admitPromptTurn: async () => {},
|
||||||
waitForIdle: async () => {},
|
waitForIdle: async () => {},
|
||||||
interruptActiveTurn: async () => {},
|
interruptActiveTurn: async () => {},
|
||||||
selectSubagent: () => {},
|
selectSubagent: () => {},
|
||||||
@@ -556,7 +556,7 @@ describe("run interactive runtime", () => {
|
|||||||
setTimeout(() => input.footer.close(), 0)
|
setTimeout(() => input.footer.close(), 0)
|
||||||
return {
|
return {
|
||||||
runPromptTurn: async () => {},
|
runPromptTurn: async () => {},
|
||||||
queuePromptTurn: async () => {},
|
admitPromptTurn: async () => {},
|
||||||
waitForIdle: async () => {},
|
waitForIdle: async () => {},
|
||||||
interruptActiveTurn: async () => {},
|
interruptActiveTurn: async () => {},
|
||||||
selectSubagent: () => {},
|
selectSubagent: () => {},
|
||||||
|
|||||||
@@ -669,6 +669,14 @@ describe("V2 mini transport", () => {
|
|||||||
data: { text: "follow up" },
|
data: { text: "follow up" },
|
||||||
delivery: "queue",
|
delivery: "queue",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "msg_cancelled",
|
||||||
|
sessionID: "ses_1",
|
||||||
|
timeCreated: 2,
|
||||||
|
type: "user",
|
||||||
|
data: { text: "remove me" },
|
||||||
|
delivery: "queue",
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -684,11 +692,14 @@ describe("V2 mini transport", () => {
|
|||||||
.findLast((item) => item.type === "queued.prompts")
|
.findLast((item) => item.type === "queued.prompts")
|
||||||
?.prompts.map((item) => [item.messageID, item.delivery])
|
?.prompts.map((item) => [item.messageID, item.delivery])
|
||||||
|
|
||||||
expect(pending()).toEqual([["msg_queued", "queue"]])
|
expect(pending()).toEqual([
|
||||||
|
["msg_queued", "queue"],
|
||||||
|
["msg_cancelled", "queue"],
|
||||||
|
])
|
||||||
events.push({
|
events.push({
|
||||||
id: "evt_promoted",
|
id: "evt_steered",
|
||||||
created: 2,
|
created: 3,
|
||||||
type: "session.input.promoted",
|
type: "session.input.steered",
|
||||||
durable: durable("ses_1", 2),
|
durable: durable("ses_1", 2),
|
||||||
data: { sessionID: "ses_1", inputID: "msg_queued" },
|
data: { sessionID: "ses_1", inputID: "msg_queued" },
|
||||||
})
|
})
|
||||||
@@ -697,19 +708,53 @@ describe("V2 mini transport", () => {
|
|||||||
expect(ui.commits).toContainEqual(
|
expect(ui.commits).toContainEqual(
|
||||||
expect.objectContaining({ kind: "user", messageID: "msg_queued", text: "follow up" }),
|
expect.objectContaining({ kind: "user", messageID: "msg_queued", text: "follow up" }),
|
||||||
)
|
)
|
||||||
expect(pending()).toEqual([])
|
expect(pending()).toEqual([["msg_cancelled", "queue"]])
|
||||||
|
events.push({
|
||||||
|
id: "evt_queued",
|
||||||
|
created: 4,
|
||||||
|
type: "session.input.queued",
|
||||||
|
durable: durable("ses_1", 3),
|
||||||
|
data: { sessionID: "ses_1", inputID: "msg_queued" },
|
||||||
|
})
|
||||||
|
while (pending()?.length !== 2) await Bun.sleep(0)
|
||||||
|
expect(pending()).toEqual([
|
||||||
|
["msg_queued", "queue"],
|
||||||
|
["msg_cancelled", "queue"],
|
||||||
|
])
|
||||||
|
events.push({
|
||||||
|
id: "evt_cancelled",
|
||||||
|
created: 5,
|
||||||
|
type: "session.input.cancelled",
|
||||||
|
durable: durable("ses_1", 4),
|
||||||
|
data: { sessionID: "ses_1", inputID: "msg_cancelled" },
|
||||||
|
})
|
||||||
|
while (pending()?.length !== 1) await Bun.sleep(0)
|
||||||
|
expect(pending()).toEqual([["msg_queued", "queue"]])
|
||||||
|
events.push({
|
||||||
|
id: "evt_promoted",
|
||||||
|
created: 6,
|
||||||
|
type: "session.input.promoted",
|
||||||
|
durable: durable("ses_1", 5),
|
||||||
|
data: { sessionID: "ses_1", inputID: "msg_queued" },
|
||||||
|
})
|
||||||
|
while (pending()?.length !== 0) await Bun.sleep(0)
|
||||||
|
expect(ui.commits.filter((item) => item.messageID === "msg_queued")).toHaveLength(1)
|
||||||
const prompt = spyOn(client.session, "prompt").mockImplementation(
|
const prompt = spyOn(client.session, "prompt").mockImplementation(
|
||||||
(request) => ok(promptAdmission(request)) as never,
|
(request) => ok(promptAdmission(request)) as never,
|
||||||
)
|
)
|
||||||
await transport.queuePromptTurn({
|
await transport.admitPromptTurn({
|
||||||
agent: "review",
|
agent: "review",
|
||||||
model: undefined,
|
model: { providerID: "test", modelID: "next" },
|
||||||
variant: undefined,
|
variant: "high",
|
||||||
prompt: { messageID: "msg_next", text: "another", parts: [] },
|
prompt: { messageID: "msg_next", text: "another", parts: [] },
|
||||||
files: [],
|
files: [],
|
||||||
includeFiles: false,
|
includeFiles: false,
|
||||||
})
|
}, "queue")
|
||||||
expect(client.session.switchAgent).toHaveBeenCalledWith({ sessionID: "ses_1", agent: "review" }, expect.anything())
|
expect(client.session.switchAgent).toHaveBeenCalledWith({ sessionID: "ses_1", agent: "review" }, expect.anything())
|
||||||
|
expect(client.session.switchModel).toHaveBeenCalledWith(
|
||||||
|
{ sessionID: "ses_1", model: { providerID: "test", id: "next", variant: "high" } },
|
||||||
|
expect.anything(),
|
||||||
|
)
|
||||||
expect(prompt).toHaveBeenCalledWith(expect.objectContaining({ delivery: "queue" }), expect.anything())
|
expect(prompt).toHaveBeenCalledWith(expect.objectContaining({ delivery: "queue" }), expect.anything())
|
||||||
events.push({
|
events.push({
|
||||||
id: "evt_earlier_admission",
|
id: "evt_earlier_admission",
|
||||||
@@ -722,15 +767,8 @@ describe("V2 mini transport", () => {
|
|||||||
input: { type: "user", data: { text: "earlier" }, delivery: "steer" },
|
input: { type: "user", data: { text: "earlier" }, delivery: "steer" },
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
while (true) {
|
await Bun.sleep(10)
|
||||||
const pending = ui.events.findLast((item) => item.type === "queued.prompts")
|
expect(pending()).toEqual([["msg_next", "queue"]])
|
||||||
if (pending?.type === "queued.prompts" && pending.prompts.length >= 2) break
|
|
||||||
await Bun.sleep(0)
|
|
||||||
}
|
|
||||||
expect(pending()).toEqual([
|
|
||||||
["msg_next", "queue"],
|
|
||||||
["msg_earlier", "steer"],
|
|
||||||
])
|
|
||||||
await transport.close()
|
await transport.close()
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -813,14 +851,14 @@ describe("V2 mini transport", () => {
|
|||||||
durable: durable("ses_1", 2),
|
durable: durable("ses_1", 2),
|
||||||
data: { sessionID: "ses_1", inputID: "msg_prompt" },
|
data: { sessionID: "ses_1", inputID: "msg_prompt" },
|
||||||
})
|
})
|
||||||
await transport.queuePromptTurn({
|
await transport.admitPromptTurn({
|
||||||
agent: undefined,
|
agent: undefined,
|
||||||
model: undefined,
|
model: undefined,
|
||||||
variant: undefined,
|
variant: undefined,
|
||||||
prompt: { messageID: "msg_queued", text: "follow up", parts: [] },
|
prompt: { messageID: "msg_queued", text: "follow up", parts: [] },
|
||||||
files: [],
|
files: [],
|
||||||
includeFiles: false,
|
includeFiles: false,
|
||||||
})
|
}, "queue")
|
||||||
events.push({
|
events.push({
|
||||||
id: "evt_queued_promoted",
|
id: "evt_queued_promoted",
|
||||||
created: 3,
|
created: 3,
|
||||||
|
|||||||
@@ -46,9 +46,9 @@ An agent's `mode` controls where it can run:
|
|||||||
|
|
||||||
| Mode | Behavior |
|
| Mode | Behavior |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `primary` | Can be selected as the main agent for a session. It cannot be launched as a subagent. |
|
| `primary` | Can be selected as the main agent for a session. It cannot be launched as a subagent. This is the default for a custom agent when `mode` is omitted. |
|
||||||
| `subagent` | Can run in a child session through the `subagent` tool, but cannot be selected as the main agent. |
|
| `subagent` | Can run in a child session through the `subagent` tool, but cannot be selected as the main agent. |
|
||||||
| `all` | Can be used either way. This is the default for a custom agent when `mode` is omitted. |
|
| `all` | Can be used either way. |
|
||||||
|
|
||||||
In the TUI, press <kbd>Tab</kbd> and <kbd>Shift</kbd>+<kbd>Tab</kbd> to cycle
|
In the TUI, press <kbd>Tab</kbd> and <kbd>Shift</kbd>+<kbd>Tab</kbd> to cycle
|
||||||
through visible primary and `all` agents, or use `/agents` to choose one.
|
through visible primary and `all` agents, or use `/agents` to choose one.
|
||||||
|
|||||||
@@ -419,6 +419,8 @@ The V1 provider filters do not have one-to-one native V2 config fields, but thei
|
|||||||
|
|
||||||
- `enabled_providers` becomes an internal deny-by-default provider policy followed by allows for the listed providers.
|
- `enabled_providers` becomes an internal deny-by-default provider policy followed by allows for the listed providers.
|
||||||
- `disabled_providers` becomes internal deny policies for the listed providers.
|
- `disabled_providers` becomes internal deny policies for the listed providers.
|
||||||
|
- `small_model` becomes the `model` selection for the built-in `title` agent. Native V2 configuration should use
|
||||||
|
`agents.title.model` instead.
|
||||||
|
|
||||||
You may keep these fields in V1 syntax. OpenCode normalizes them without warning.
|
You may keep these fields in V1 syntax. OpenCode normalizes them without warning.
|
||||||
|
|
||||||
@@ -430,7 +432,6 @@ they are not mistaken for active configuration:
|
|||||||
- `logLevel`: use `OPENCODE_LOG_LEVEL` when starting OpenCode.
|
- `logLevel`: use `OPENCODE_LOG_LEVEL` when starting OpenCode.
|
||||||
- `server`: use the V2 service and explicit server options; the server API is an intentional breaking change.
|
- `server`: use the V2 service and explicit server options; the server API is an intentional breaking change.
|
||||||
- `layout`: remove it; V1 already treated it as deprecated and always used stretch layout.
|
- `layout`: remove it; V1 already treated it as deprecated and always used stretch layout.
|
||||||
- `small_model`: V2 selects models for internal maintenance agents without a separate top-level field.
|
|
||||||
- Top-level `subagent_depth`: use `experimental.subagent_depth` instead.
|
- Top-level `subagent_depth`: use `experimental.subagent_depth` instead.
|
||||||
- `compaction.tail_turns` and `compaction.prune`: V2 uses `compaction.keep.tokens` and checkpoint-based compaction instead.
|
- `compaction.tail_turns` and `compaction.prune`: V2 uses `compaction.keep.tokens` and checkpoint-based compaction instead.
|
||||||
- Agent `name` inside V1 JSON configuration.
|
- Agent `name` inside V1 JSON configuration.
|
||||||
|
|||||||
Reference in New Issue
Block a user