mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-18 13:49:25 -04:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c478ad52a9 |
@@ -402,6 +402,7 @@ export function usePromptInputV2Controller(props: PromptInputV2ControllerProps):
|
|||||||
},
|
},
|
||||||
submit: {
|
submit: {
|
||||||
stopping,
|
stopping,
|
||||||
|
pending: submission.stopping,
|
||||||
working,
|
working,
|
||||||
onSubmit: () => void submission.handleSubmit(new Event("submit")),
|
onSubmit: () => void submission.handleSubmit(new Event("submit")),
|
||||||
onStop: () => void submission.abort(),
|
onStop: () => void submission.abort(),
|
||||||
|
|||||||
@@ -261,6 +261,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
() => prompt.capture(),
|
() => prompt.capture(),
|
||||||
Math.floor(Math.random() * EXAMPLES.length),
|
Math.floor(Math.random() * EXAMPLES.length),
|
||||||
)
|
)
|
||||||
|
createEffect(() => {
|
||||||
|
if (!working()) setStore("stopping", false)
|
||||||
|
})
|
||||||
const buttonsSpring = useSpring(() => (store.mode === "normal" ? 1 : 0), { visualDuration: 0.2, bounce: 0 })
|
const buttonsSpring = useSpring(() => (store.mode === "normal" ? 1 : 0), { visualDuration: 0.2, bounce: 0 })
|
||||||
const motion = (value: number) => ({
|
const motion = (value: number) => ({
|
||||||
opacity: value,
|
opacity: value,
|
||||||
@@ -283,9 +286,10 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
.join("")
|
.join("")
|
||||||
return text.trim().length === 0 && imageAttachments().length === 0 && commentCount() === 0
|
return text.trim().length === 0 && imageAttachments().length === 0 && commentCount() === 0
|
||||||
})
|
})
|
||||||
const stopping = createMemo(() => working() && blank())
|
const stopAction = createMemo(() => working() && blank())
|
||||||
const tip = () => {
|
const tip = () => {
|
||||||
if (stopping()) {
|
if (store.stopping) return <span>{language.t("prompt.action.stop")}...</span>
|
||||||
|
if (stopAction()) {
|
||||||
return (
|
return (
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<span>{language.t("prompt.action.stop")}</span>
|
<span>{language.t("prompt.action.stop")}</span>
|
||||||
@@ -1198,36 +1202,46 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
return permission.isAutoAccepting(id, sdk().directory)
|
return permission.isAutoAccepting(id, sdk().directory)
|
||||||
})
|
})
|
||||||
|
|
||||||
const { abort, handleSubmit } =
|
const {
|
||||||
props.submission ??
|
abort: requestAbort,
|
||||||
createPromptSubmit({
|
handleSubmit,
|
||||||
prompt,
|
stopping: requestStopping,
|
||||||
info,
|
} = props.submission ??
|
||||||
imageAttachments,
|
createPromptSubmit({
|
||||||
commentCount,
|
prompt,
|
||||||
autoAccept: () => accepting(),
|
info,
|
||||||
mode: () => store.mode,
|
imageAttachments,
|
||||||
working,
|
commentCount,
|
||||||
editor: () => editorRef,
|
autoAccept: () => accepting(),
|
||||||
queueScroll,
|
mode: () => store.mode,
|
||||||
promptLength,
|
working,
|
||||||
addToHistory,
|
editor: () => editorRef,
|
||||||
resetHistoryNavigation: () => {
|
queueScroll,
|
||||||
resetHistoryNavigation(true)
|
promptLength,
|
||||||
},
|
addToHistory,
|
||||||
setMode: (mode) => setStore("mode", mode),
|
resetHistoryNavigation: () => {
|
||||||
setPopover: (popover) => {
|
resetHistoryNavigation(true)
|
||||||
if (!popover) return closePopover()
|
},
|
||||||
setStore({ popover, slashMenu: false, slashMenuQuery: "" })
|
setMode: (mode) => setStore("mode", mode),
|
||||||
},
|
setPopover: (popover) => {
|
||||||
newSessionWorktree: () => props.newSessionWorktree,
|
if (!popover) return closePopover()
|
||||||
onNewSessionWorktreeReset: props.onNewSessionWorktreeReset,
|
setStore({ popover, slashMenu: false, slashMenuQuery: "" })
|
||||||
shouldQueue: props.shouldQueue,
|
},
|
||||||
onQueue: props.onQueue,
|
newSessionWorktree: () => props.newSessionWorktree,
|
||||||
onAbort: props.onAbort,
|
onNewSessionWorktreeReset: props.onNewSessionWorktreeReset,
|
||||||
onSubmit: props.onSubmit,
|
shouldQueue: props.shouldQueue,
|
||||||
model: props.controls.model.selection,
|
onQueue: props.onQueue,
|
||||||
|
onAbort: props.onAbort,
|
||||||
|
onSubmit: props.onSubmit,
|
||||||
|
model: props.controls.model.selection,
|
||||||
|
})
|
||||||
|
const abort = () => {
|
||||||
|
if (store.stopping || requestStopping?.()) return Promise.resolve()
|
||||||
|
setStore("stopping", true)
|
||||||
|
return Promise.resolve(requestAbort()).finally(() => {
|
||||||
|
if (working()) setStore("stopping", false)
|
||||||
})
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const handleKeyDown = (event: KeyboardEvent) => {
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
if ((event.metaKey || event.ctrlKey) && !event.altKey && !event.shiftKey && event.key.toLowerCase() === "u") {
|
if ((event.metaKey || event.ctrlKey) && !event.altKey && !event.shiftKey && event.key.toLowerCase() === "u") {
|
||||||
@@ -1579,12 +1593,12 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
<IconButton
|
<IconButton
|
||||||
data-action="prompt-submit"
|
data-action="prompt-submit"
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={!working() && blank()}
|
disabled={store.stopping || (!working() && blank())}
|
||||||
tabIndex={store.mode === "normal" ? undefined : -1}
|
tabIndex={store.mode === "normal" ? undefined : -1}
|
||||||
icon={stopping() ? "stop" : store.mode === "shell" ? "arrow-undo-down" : "arrow-up"}
|
icon={stopAction() ? "stop" : store.mode === "shell" ? "arrow-undo-down" : "arrow-up"}
|
||||||
variant="primary"
|
variant="primary"
|
||||||
class="size-8"
|
class="size-8"
|
||||||
aria-label={stopping() ? language.t("prompt.action.stop") : language.t("prompt.action.send")}
|
aria-label={stopAction() ? language.t("prompt.action.stop") : language.t("prompt.action.send")}
|
||||||
/>
|
/>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ export type PromptInputState = ReturnType<typeof usePrompt>
|
|||||||
export type PromptInputSubmission = {
|
export type PromptInputSubmission = {
|
||||||
abort: () => Promise<void> | void
|
abort: () => Promise<void> | void
|
||||||
handleSubmit: (event: Event) => Promise<void> | void
|
handleSubmit: (event: Event) => Promise<void> | void
|
||||||
|
stopping?: () => boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export type PromptInputControls = {
|
export type PromptInputControls = {
|
||||||
|
|||||||
@@ -46,6 +46,9 @@ let selected = "/repo/worktree-a"
|
|||||||
let variant: string | undefined
|
let variant: string | undefined
|
||||||
let permissionServer = "server-a"
|
let permissionServer = "server-a"
|
||||||
let createSessionGate: Promise<void> | undefined
|
let createSessionGate: Promise<void> | undefined
|
||||||
|
let interruptGate: Promise<void> | undefined
|
||||||
|
let interruptCalls = 0
|
||||||
|
let interruptFailure = false
|
||||||
|
|
||||||
let promptValue: Prompt = [{ type: "text", content: "ls", start: 0, end: 2 }]
|
let promptValue: Prompt = [{ type: "text", content: "ls", start: 0, end: 2 }]
|
||||||
const [promptStore, setPromptStore] = createStore<PromptStore>({
|
const [promptStore, setPromptStore] = createStore<PromptStore>({
|
||||||
@@ -121,6 +124,11 @@ const clientFor = (directory: string) => {
|
|||||||
shell: async (input: { sessionID: string; id?: string; command: string }) => {
|
shell: async (input: { sessionID: string; id?: string; command: string }) => {
|
||||||
sentShell.push(input)
|
sentShell.push(input)
|
||||||
},
|
},
|
||||||
|
interrupt: async () => {
|
||||||
|
interruptCalls++
|
||||||
|
await interruptGate
|
||||||
|
if (interruptFailure) throw new Error("interrupt failed")
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
session: {
|
session: {
|
||||||
@@ -310,11 +318,74 @@ beforeEach(() => {
|
|||||||
variant = undefined
|
variant = undefined
|
||||||
permissionServer = "server-a"
|
permissionServer = "server-a"
|
||||||
createSessionGate = undefined
|
createSessionGate = undefined
|
||||||
|
interruptGate = undefined
|
||||||
|
interruptCalls = 0
|
||||||
|
interruptFailure = false
|
||||||
serverSessionSyncs = 0
|
serverSessionSyncs = 0
|
||||||
for (const key of Object.keys(storedSessions)) delete storedSessions[key]
|
for (const key of Object.keys(storedSessions)) delete storedSessions[key]
|
||||||
})
|
})
|
||||||
|
|
||||||
describe("prompt submit worktree selection", () => {
|
describe("prompt submit worktree selection", () => {
|
||||||
|
test("reports stopping immediately and suppresses duplicate interrupts", async () => {
|
||||||
|
params = { id: "session-1" }
|
||||||
|
let release = () => {}
|
||||||
|
interruptGate = new Promise<void>((resolve) => {
|
||||||
|
release = resolve
|
||||||
|
})
|
||||||
|
let working = true
|
||||||
|
const submit = createPromptSubmit({
|
||||||
|
prompt,
|
||||||
|
info: () => ({ id: "session-1" }),
|
||||||
|
imageAttachments: () => [],
|
||||||
|
commentCount: () => 0,
|
||||||
|
autoAccept: () => false,
|
||||||
|
mode: () => "normal",
|
||||||
|
working: () => working,
|
||||||
|
editor: () => undefined,
|
||||||
|
queueScroll: () => undefined,
|
||||||
|
promptLength: () => 0,
|
||||||
|
addToHistory: () => undefined,
|
||||||
|
resetHistoryNavigation: () => undefined,
|
||||||
|
setMode: () => undefined,
|
||||||
|
setPopover: () => undefined,
|
||||||
|
})
|
||||||
|
|
||||||
|
const first = submit.abort()
|
||||||
|
const second = submit.abort()
|
||||||
|
|
||||||
|
expect(submit.stopping()).toBe(true)
|
||||||
|
expect(interruptCalls).toBe(1)
|
||||||
|
release()
|
||||||
|
await Promise.all([first, second])
|
||||||
|
working = false
|
||||||
|
expect(submit.stopping()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("clears stopping when the interrupt request fails", async () => {
|
||||||
|
params = { id: "session-1" }
|
||||||
|
interruptFailure = true
|
||||||
|
const submit = createPromptSubmit({
|
||||||
|
prompt,
|
||||||
|
info: () => ({ id: "session-1" }),
|
||||||
|
imageAttachments: () => [],
|
||||||
|
commentCount: () => 0,
|
||||||
|
autoAccept: () => false,
|
||||||
|
mode: () => "normal",
|
||||||
|
working: () => true,
|
||||||
|
editor: () => undefined,
|
||||||
|
queueScroll: () => undefined,
|
||||||
|
promptLength: () => 0,
|
||||||
|
addToHistory: () => undefined,
|
||||||
|
resetHistoryNavigation: () => undefined,
|
||||||
|
setMode: () => undefined,
|
||||||
|
setPopover: () => undefined,
|
||||||
|
})
|
||||||
|
|
||||||
|
await submit.abort()
|
||||||
|
|
||||||
|
expect(submit.stopping()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
test("reads the latest worktree accessor value per submit", async () => {
|
test("reads the latest worktree accessor value per submit", async () => {
|
||||||
const submit = createPromptSubmit({
|
const submit = createPromptSubmit({
|
||||||
prompt,
|
prompt,
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { showToast } from "@/utils/toast"
|
|||||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||||
import { Binary } from "@opencode-ai/core/util/binary"
|
import { Binary } from "@opencode-ai/core/util/binary"
|
||||||
import { useNavigate, useParams, useSearchParams } from "@solidjs/router"
|
import { useNavigate, useParams, useSearchParams } from "@solidjs/router"
|
||||||
import { batch, startTransition, type Accessor } from "solid-js"
|
import { batch, createSignal, startTransition, type Accessor } from "solid-js"
|
||||||
import { useTabs } from "@/context/tabs"
|
import { useTabs } from "@/context/tabs"
|
||||||
import { useServerSync, type ServerSync } from "@/context/server-sync"
|
import { useServerSync, type ServerSync } from "@/context/server-sync"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
@@ -263,6 +263,12 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
|||||||
const params = useParams()
|
const params = useParams()
|
||||||
const [search] = useSearchParams<{ draftId?: string }>()
|
const [search] = useSearchParams<{ draftId?: string }>()
|
||||||
const tabs = useTabs()
|
const tabs = useTabs()
|
||||||
|
const [stopping, setStopping] = createSignal(false)
|
||||||
|
const isStopping = () => {
|
||||||
|
if (input.working()) return stopping()
|
||||||
|
setStopping(false)
|
||||||
|
return false
|
||||||
|
}
|
||||||
const pendingKey = (sessionID: string) => ScopedKey.from(sdk().scope, sessionID)
|
const pendingKey = (sessionID: string) => ScopedKey.from(sdk().scope, sessionID)
|
||||||
|
|
||||||
const errorMessage = (err: unknown) => {
|
const errorMessage = (err: unknown) => {
|
||||||
@@ -276,8 +282,10 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const abort = async () => {
|
const abort = async () => {
|
||||||
|
if (isStopping()) return
|
||||||
const sessionID = params.id
|
const sessionID = params.id
|
||||||
if (!sessionID) return Promise.resolve()
|
if (!sessionID) return Promise.resolve()
|
||||||
|
setStopping(true)
|
||||||
|
|
||||||
serverSync().session.set("todo", sessionID, [])
|
serverSync().session.set("todo", sessionID, [])
|
||||||
|
|
||||||
@@ -289,11 +297,12 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
|||||||
queued.abort.abort()
|
queued.abort.abort()
|
||||||
queued.cleanup()
|
queued.cleanup()
|
||||||
pending.delete(key)
|
pending.delete(key)
|
||||||
|
setStopping(false)
|
||||||
return Promise.resolve()
|
return Promise.resolve()
|
||||||
}
|
}
|
||||||
return sdk()
|
return sdk()
|
||||||
.api.session.interrupt({ sessionID })
|
.api.session.interrupt({ sessionID })
|
||||||
.catch(() => {})
|
.catch(() => setStopping(false))
|
||||||
}
|
}
|
||||||
|
|
||||||
const restoreCommentItems = (
|
const restoreCommentItems = (
|
||||||
@@ -649,5 +658,6 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
|||||||
return {
|
return {
|
||||||
abort,
|
abort,
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
|
stopping: isStopping,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export type PromptInputTransientState = {
|
|||||||
draggingType: "image" | "@mention" | null
|
draggingType: "image" | "@mention" | null
|
||||||
mode: "normal" | "shell"
|
mode: "normal" | "shell"
|
||||||
applyingHistory: boolean
|
applyingHistory: boolean
|
||||||
|
stopping: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
function resetPromptInputTransientState(setStore: SetStoreFunction<PromptInputTransientState>) {
|
function resetPromptInputTransientState(setStore: SetStoreFunction<PromptInputTransientState>) {
|
||||||
@@ -24,6 +25,7 @@ function resetPromptInputTransientState(setStore: SetStoreFunction<PromptInputTr
|
|||||||
draggingType: null,
|
draggingType: null,
|
||||||
mode: "normal",
|
mode: "normal",
|
||||||
applyingHistory: false,
|
applyingHistory: false,
|
||||||
|
stopping: false,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -38,6 +40,7 @@ export function createPromptInputTransientState(identity: Accessor<unknown>, pla
|
|||||||
draggingType: null,
|
draggingType: null,
|
||||||
mode: "normal",
|
mode: "normal",
|
||||||
applyingHistory: false,
|
applyingHistory: false,
|
||||||
|
stopping: false,
|
||||||
})
|
})
|
||||||
|
|
||||||
createComputed(on(identity, () => resetPromptInputTransientState(setStore), { defer: true }))
|
createComputed(on(identity, () => resetPromptInputTransientState(setStore), { defer: true }))
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ import { EventTable } from "@opencode-ai/core/event/sql"
|
|||||||
import { Project } from "@opencode-ai/core/project"
|
import { Project } from "@opencode-ai/core/project"
|
||||||
import { ProjectTable } from "@opencode-ai/core/project/sql"
|
import { ProjectTable } from "@opencode-ai/core/project/sql"
|
||||||
import { Form } from "@opencode-ai/core/form"
|
import { Form } from "@opencode-ai/core/form"
|
||||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
import { AbsolutePath, RelativePath } from "@opencode-ai/core/schema"
|
||||||
import { Session } from "@opencode-ai/core/session"
|
import { Session } from "@opencode-ai/core/session"
|
||||||
import { Snapshot } from "@opencode-ai/core/snapshot"
|
import { Snapshot } from "@opencode-ai/core/snapshot"
|
||||||
import { SessionEvent } from "@opencode-ai/core/session/event"
|
import { SessionEvent } from "@opencode-ai/core/session/event"
|
||||||
@@ -355,6 +355,18 @@ const pluginSupervisor = Layer.succeed(
|
|||||||
flush: Effect.suspend(() => pluginFlushHook),
|
flush: Effect.suspend(() => pluginFlushHook),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
let snapshotCaptureHook: () => Effect.Effect<Snapshot.ID | undefined> = () => Effect.succeed(undefined)
|
||||||
|
let snapshotFilesHook: (input: Snapshot.CompareInput) => Effect.Effect<readonly RelativePath[], Snapshot.Error> = () =>
|
||||||
|
Effect.succeed([])
|
||||||
|
const snapshots = Layer.succeed(
|
||||||
|
Snapshot.Service,
|
||||||
|
Snapshot.Service.of({
|
||||||
|
capture: () => snapshotCaptureHook(),
|
||||||
|
files: (input) => snapshotFilesHook(input),
|
||||||
|
diff: () => Effect.succeed([]),
|
||||||
|
restore: () => Effect.void,
|
||||||
|
}),
|
||||||
|
)
|
||||||
const promptCatalog = Layer.mock(Catalog.Service, {
|
const promptCatalog = Layer.mock(Catalog.Service, {
|
||||||
provider: {
|
provider: {
|
||||||
get: () => Effect.succeed(undefined),
|
get: () => Effect.succeed(undefined),
|
||||||
@@ -370,7 +382,7 @@ const promptCatalog = Layer.mock(Catalog.Service, {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
const runnerLayer = AppNodeBuilder.build(SessionRunnerLLM.node, [
|
const runnerLayer = AppNodeBuilder.build(SessionRunnerLLM.node, [
|
||||||
[Snapshot.node, Snapshot.noopLayer],
|
[Snapshot.node, snapshots],
|
||||||
[LayerNodePlatform.llmClient, client],
|
[LayerNodePlatform.llmClient, client],
|
||||||
[SessionRunnerModel.node, models],
|
[SessionRunnerModel.node, models],
|
||||||
[InstructionBuiltIns.node, systemContext],
|
[InstructionBuiltIns.node, systemContext],
|
||||||
@@ -437,7 +449,7 @@ const it = testEffect(
|
|||||||
[Location.node, Location.boundNode({ directory: AbsolutePath.make("/project") })],
|
[Location.node, Location.boundNode({ directory: AbsolutePath.make("/project") })],
|
||||||
[SkillInstructions.node, skillInstructions],
|
[SkillInstructions.node, skillInstructions],
|
||||||
[ReferenceInstructions.node, referenceInstructions],
|
[ReferenceInstructions.node, referenceInstructions],
|
||||||
[Snapshot.node, Snapshot.noopLayer],
|
[Snapshot.node, snapshots],
|
||||||
[SessionExecution.node, execution],
|
[SessionExecution.node, execution],
|
||||||
[Config.node, config],
|
[Config.node, config],
|
||||||
[PluginSupervisor.node, pluginSupervisor],
|
[PluginSupervisor.node, pluginSupervisor],
|
||||||
@@ -493,6 +505,8 @@ const setup = Effect.gen(function* () {
|
|||||||
systemLoadHook = Effect.void
|
systemLoadHook = Effect.void
|
||||||
modelResolveHook = Effect.void
|
modelResolveHook = Effect.void
|
||||||
pluginFlushHook = Effect.void
|
pluginFlushHook = Effect.void
|
||||||
|
snapshotCaptureHook = () => Effect.succeed(undefined)
|
||||||
|
snapshotFilesHook = () => Effect.succeed([])
|
||||||
currentModel = model
|
currentModel = model
|
||||||
skillBaselines.clear()
|
skillBaselines.clear()
|
||||||
toolBarrier = undefined
|
toolBarrier = undefined
|
||||||
@@ -3741,6 +3755,89 @@ describe("SessionRunnerLLM", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
it.effect("waits for the end snapshot before interrupted settlement", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const session = yield* setup
|
||||||
|
const endCaptureStarted = yield* Deferred.make<void>()
|
||||||
|
const releaseEndCapture = yield* Deferred.make<void>()
|
||||||
|
const runSettled = yield* Deferred.make<void>()
|
||||||
|
const interruptSettled = yield* Deferred.make<void>()
|
||||||
|
let captures = 0
|
||||||
|
snapshotCaptureHook = () => {
|
||||||
|
captures++
|
||||||
|
if (captures === 1) return Effect.succeed(Snapshot.ID.make("snapshot-start"))
|
||||||
|
return Deferred.succeed(endCaptureStarted, undefined).pipe(
|
||||||
|
Effect.andThen(Deferred.await(releaseEndCapture)),
|
||||||
|
Effect.as(Snapshot.ID.make("snapshot-end")),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
snapshotFilesHook = () => Effect.succeed([RelativePath.make("changed.txt")])
|
||||||
|
yield* admit(session, "Interrupt during end snapshot")
|
||||||
|
const stream = yield* TestLLM.gate
|
||||||
|
|
||||||
|
const run = yield* session
|
||||||
|
.resume(sessionID)
|
||||||
|
.pipe(Effect.ensuring(Deferred.succeed(runSettled, undefined)), Effect.forkChild)
|
||||||
|
yield* stream.started
|
||||||
|
const interrupt = yield* session
|
||||||
|
.interrupt(sessionID)
|
||||||
|
.pipe(Effect.ensuring(Deferred.succeed(interruptSettled, undefined)), Effect.forkChild)
|
||||||
|
yield* Deferred.await(endCaptureStarted)
|
||||||
|
|
||||||
|
expect(yield* Deferred.isDone(interruptSettled)).toBe(false)
|
||||||
|
expect(yield* Deferred.isDone(runSettled)).toBe(false)
|
||||||
|
|
||||||
|
yield* Deferred.succeed(releaseEndCapture, undefined)
|
||||||
|
yield* Fiber.join(interrupt)
|
||||||
|
expect(yield* Fiber.await(run)).toMatchObject({ _tag: "Failure" })
|
||||||
|
expect(requireAssistant(yield* session.context(sessionID))).toMatchObject({
|
||||||
|
finish: "error",
|
||||||
|
error: { type: "aborted", message: "Step interrupted" },
|
||||||
|
snapshot: {
|
||||||
|
start: "snapshot-start",
|
||||||
|
end: "snapshot-end",
|
||||||
|
files: ["changed.txt"],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("waits for unrelated database transactions before interrupted settlement", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const session = yield* setup
|
||||||
|
const { db } = yield* Database.Service
|
||||||
|
const transactionStarted = yield* Deferred.make<void>()
|
||||||
|
const releaseTransaction = yield* Deferred.make<void>()
|
||||||
|
const interruptSettled = yield* Deferred.make<void>()
|
||||||
|
yield* admit(session, "Interrupt during database contention")
|
||||||
|
const stream = yield* TestLLM.gate
|
||||||
|
|
||||||
|
const run = yield* session.resume(sessionID).pipe(Effect.forkChild)
|
||||||
|
yield* stream.started
|
||||||
|
const transaction = yield* db
|
||||||
|
.transaction(() =>
|
||||||
|
Deferred.succeed(transactionStarted, undefined).pipe(Effect.andThen(Deferred.await(releaseTransaction))),
|
||||||
|
)
|
||||||
|
.pipe(Effect.forkChild)
|
||||||
|
yield* Deferred.await(transactionStarted)
|
||||||
|
const interrupt = yield* session
|
||||||
|
.interrupt(sessionID)
|
||||||
|
.pipe(Effect.ensuring(Deferred.succeed(interruptSettled, undefined)), Effect.forkChild)
|
||||||
|
yield* Effect.yieldNow
|
||||||
|
|
||||||
|
expect(yield* Deferred.isDone(interruptSettled)).toBe(false)
|
||||||
|
|
||||||
|
yield* Deferred.succeed(releaseTransaction, undefined)
|
||||||
|
yield* Fiber.join(transaction)
|
||||||
|
yield* Fiber.join(interrupt)
|
||||||
|
expect(yield* Fiber.await(run)).toMatchObject({ _tag: "Failure" })
|
||||||
|
expect(requireAssistant(yield* session.context(sessionID))).toMatchObject({
|
||||||
|
finish: "error",
|
||||||
|
error: { type: "aborted", message: "Step interrupted" },
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
it.effect("durably fails blocked local tools when interrupted while awaiting settlement", () =>
|
it.effect("durably fails blocked local tools when interrupted while awaiting settlement", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const session = yield* setup
|
const session = yield* setup
|
||||||
|
|||||||
@@ -257,6 +257,7 @@ export function PromptInputV2(props: PromptInputV2Props) {
|
|||||||
<PromptInputV2SubmitButton
|
<PromptInputV2SubmitButton
|
||||||
mode={state.mode}
|
mode={state.mode}
|
||||||
stopping={view.submit.stopping()}
|
stopping={view.submit.stopping()}
|
||||||
|
pending={view.submit.pending?.() ?? false}
|
||||||
disabled={!props.controller.canSubmit()}
|
disabled={!props.controller.canSubmit()}
|
||||||
sendLabel={i18n.t("ui.promptInput.send")}
|
sendLabel={i18n.t("ui.promptInput.send")}
|
||||||
stopLabel={i18n.t("ui.promptInput.stop")}
|
stopLabel={i18n.t("ui.promptInput.stop")}
|
||||||
@@ -672,6 +673,7 @@ export function PromptInputV2Popover(props: {
|
|||||||
export function PromptInputV2SubmitButton(props: {
|
export function PromptInputV2SubmitButton(props: {
|
||||||
mode: PromptInputV2Mode
|
mode: PromptInputV2Mode
|
||||||
stopping: boolean
|
stopping: boolean
|
||||||
|
pending: boolean
|
||||||
disabled: boolean
|
disabled: boolean
|
||||||
sendLabel: string
|
sendLabel: string
|
||||||
stopLabel: string
|
stopLabel: string
|
||||||
@@ -682,12 +684,12 @@ export function PromptInputV2SubmitButton(props: {
|
|||||||
<TooltipV2
|
<TooltipV2
|
||||||
placement="top"
|
placement="top"
|
||||||
inactive={!props.stopping && props.disabled}
|
inactive={!props.stopping && props.disabled}
|
||||||
value={props.stopping ? props.stopLabel : props.sendLabel}
|
value={props.pending ? `${props.stopLabel}...` : props.stopping ? props.stopLabel : props.sendLabel}
|
||||||
>
|
>
|
||||||
<IconButton
|
<IconButton
|
||||||
data-action="prompt-submit"
|
data-action="prompt-submit"
|
||||||
type="button"
|
type="button"
|
||||||
disabled={!props.stopping && props.disabled}
|
disabled={props.pending || (!props.stopping && props.disabled)}
|
||||||
tabIndex={props.mode === "normal" ? undefined : -1}
|
tabIndex={props.mode === "normal" ? undefined : -1}
|
||||||
icon={props.stopping ? "stop" : props.mode === "shell" ? "arrow-undo-down" : "arrow-up"}
|
icon={props.stopping ? "stop" : props.mode === "shell" ? "arrow-undo-down" : "arrow-up"}
|
||||||
variant="primary"
|
variant="primary"
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ export type PromptInputV2ViewConfig = {
|
|||||||
variant?: PromptInputV2SelectControl
|
variant?: PromptInputV2SelectControl
|
||||||
submit: {
|
submit: {
|
||||||
stopping: Accessor<boolean>
|
stopping: Accessor<boolean>
|
||||||
|
pending?: Accessor<boolean>
|
||||||
working?: Accessor<boolean>
|
working?: Accessor<boolean>
|
||||||
onSubmit: () => void
|
onSubmit: () => void
|
||||||
onStop: () => void
|
onStop: () => void
|
||||||
|
|||||||
@@ -167,6 +167,16 @@ export function Prompt(props: PromptProps) {
|
|||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const status = createMemo(() => data.session.status(props.sessionID ?? ""))
|
const status = createMemo(() => data.session.status(props.sessionID ?? ""))
|
||||||
|
const [stoppingSession, setStoppingSession] = createSignal<string>()
|
||||||
|
const stopping = createMemo(() => stoppingSession() === props.sessionID && status() === "running")
|
||||||
|
createEffect(
|
||||||
|
on(
|
||||||
|
() => [props.sessionID, status()] as const,
|
||||||
|
([, current]) => {
|
||||||
|
if (current === "idle") setStoppingSession(undefined)
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
const history = usePromptHistory()
|
const history = usePromptHistory()
|
||||||
const stash = usePromptStash()
|
const stash = usePromptStash()
|
||||||
const keymap = Keymap.use()
|
const keymap = Keymap.use()
|
||||||
@@ -466,7 +476,7 @@ export function Prompt(props: PromptProps) {
|
|||||||
name: "session.interrupt",
|
name: "session.interrupt",
|
||||||
category: "Session",
|
category: "Session",
|
||||||
palette: undefined,
|
palette: undefined,
|
||||||
enabled: status() === "running",
|
enabled: status() === "running" && !stopping(),
|
||||||
run: () => {
|
run: () => {
|
||||||
if (auto()?.visible) return
|
if (auto()?.visible) return
|
||||||
if (!input.focused) return
|
if (!input.focused) return
|
||||||
@@ -484,9 +494,12 @@ export function Prompt(props: PromptProps) {
|
|||||||
}, 5000)
|
}, 5000)
|
||||||
|
|
||||||
if (store.interrupt >= 2) {
|
if (store.interrupt >= 2) {
|
||||||
void client.api.session.interrupt({
|
setStoppingSession(props.sessionID)
|
||||||
sessionID: props.sessionID,
|
void client.api.session
|
||||||
})
|
.interrupt({
|
||||||
|
sessionID: props.sessionID,
|
||||||
|
})
|
||||||
|
.catch(() => setStoppingSession(undefined))
|
||||||
setStore("interrupt", 0)
|
setStore("interrupt", 0)
|
||||||
}
|
}
|
||||||
dialog.clear()
|
dialog.clear()
|
||||||
@@ -1797,6 +1810,16 @@ export function Prompt(props: PromptProps) {
|
|||||||
<Slot path="prompt.footer.status" input={footerInput()}>
|
<Slot path="prompt.footer.status" input={footerInput()}>
|
||||||
<box flexGrow={1} flexShrink={1} minWidth={0}>
|
<box flexGrow={1} flexShrink={1} minWidth={0}>
|
||||||
<Switch>
|
<Switch>
|
||||||
|
<Match when={stopping()}>
|
||||||
|
<box flexDirection="row" gap={1} flexGrow={1} justifyContent="flex-start">
|
||||||
|
<box marginLeft={1}>
|
||||||
|
<Show when={config.animations ?? true} fallback={<text fg={theme.text.subdued}>[⋯]</text>}>
|
||||||
|
<spinner color={spinnerDef().color} frames={spinnerDef().frames} interval={40} />
|
||||||
|
</Show>
|
||||||
|
</box>
|
||||||
|
<text fg={theme.text.subdued}>Stopping...</text>
|
||||||
|
</box>
|
||||||
|
</Match>
|
||||||
<Match when={status() === "running"}>
|
<Match when={status() === "running"}>
|
||||||
<box flexDirection="row" gap={1} flexGrow={1} justifyContent="flex-start">
|
<box flexDirection="row" gap={1} flexGrow={1} justifyContent="flex-start">
|
||||||
<box marginLeft={1}>
|
<box marginLeft={1}>
|
||||||
|
|||||||
Reference in New Issue
Block a user