fix(goal): preserve running goals and pending drafts

This commit is contained in:
marius-kilocode
2026-09-08 13:22:02 +02:00
parent 8a2403b9f5
commit e97d11dd4e
13 changed files with 580 additions and 144 deletions
+2
View File
@@ -8,6 +8,8 @@ Keep working toward a session goal with `/goal`, with shared pause, resume, and
Compose multiline goals with images and file attachments in VS Code. Select `/goal` to enter goal mode, or cancel to keep the draft as ordinary chat. Keep drafts and attachments when submission fails.
Keep the current goal running when replacement attachments are invalid. Make pending Goal submissions read-only, and preserve the draft when Cancel exits Goal mode before acknowledgement.
Disable clarification questions during active goals and delegated work while keeping permission approvals unchanged. Make safe, reversible decisions autonomously and report completion or blockers.
Retain Active, Complete, Blocked, and Paused goals with their objective and reason until explicitly cleared. Let the working model explicitly report completion or a blocker with the Goal-only reporting tool, without a separate evaluator or independent verification claim. Pause no-action turns that have no explicit report. Keep complete goals complete after a backend restart and label their resume action as Restart.
@@ -33,7 +33,6 @@ Object.assign(globalThis, {
MutationObserver: window.MutationObserver,
IntersectionObserver: window.IntersectionObserver,
ResizeObserver: window.ResizeObserver,
IntersectionObserver: window.IntersectionObserver,
CustomEvent: window.CustomEvent,
customElements: window.customElements,
Event: window.Event,
@@ -67,7 +66,9 @@ const { PromptInput } = await import("../../webview-ui/src/components/chat/Promp
const { IndexingProvider } = await import("../../webview-ui/src/context/indexing")
const { MemoryProvider } = await import("../../webview-ui/src/context/memory")
const { SpeechToTextModelsProvider } = await import("../../webview-ui/src/context/speech-to-text-models")
const { drafts, imageDrafts, savePromptDraft } = await import("../../webview-ui/src/utils/draft-store")
const { drafts, imageDrafts, reviewDrafts, browserDrafts, savePromptDraft } = await import(
"../../webview-ui/src/utils/draft-store"
)
const [settings, setSettings] = createSignal<{
model?: string
@@ -931,12 +932,12 @@ try {
assert(element)
return element
}
const seed = async (text: string) => {
const seed = async (text: string, sid = "composer") => {
setComposer(false)
await settle()
value.setCurrentSessionID("composer")
await emit({ type: "sessionStatus", sessionID: "composer", status: "idle" })
savePromptDraft(key, text, [], [image])
value.setCurrentSessionID(sid)
await emit({ type: "sessionStatus", sessionID: sid, status: "idle" })
savePromptDraft(`acceptance:session:${sid}`, text, [], [image])
setComposer(true)
await settle()
await emit({
@@ -953,7 +954,9 @@ try {
input().dispatchEvent(new window.KeyboardEvent("keydown", { key: "Enter", bubbles: true }))
return
}
const button = host.querySelector<HTMLButtonElement>('[aria-label="prompt.action.send"]')
const button = host.querySelector<HTMLButtonElement>(
'[aria-label="prompt.action.send"], [aria-label="prompt.goal.start"]',
)
assert(button)
button.click()
}
@@ -1005,6 +1008,187 @@ try {
await emit({ type: "terminalContextResult", requestId: request.requestId, content: "terminal output" })
retained(text, count)
}
await catalog("org-a", [recommended.modelID], recommended.modelID)
for (const sid of ["composer", "cloud:preview"]) {
await seed("/goal", sid)
submit(false)
await settle()
assert(host.querySelector(".prompt-goal-header"))
const text = "Fix the failing tests"
input().value = text
input().dispatchEvent(new window.Event("input", { bubbles: true }))
await settle()
const scope = `acceptance:session:${sid}`
for (const success of [false, true]) {
const button = host.querySelector<HTMLButtonElement>('[aria-label="prompt.goal.start"]')
assert(button)
assert.equal(button.getAttribute("aria-disabled"), "false")
const count = requests().length
const messages = value.messages().length
button.click()
await settle()
assert.equal(requests().length, count + 1)
const request = requests().at(-1)
assert(request?.type === (sid.startsWith("cloud:") ? "importAndSend" : "sendCommand"))
assert.equal(request.command, "goal")
assert.equal(request.type === "sendCommand" ? request.arguments : request.commandArgs, `-- ${text}`)
assert.equal(request.modelID, recommended.modelID)
assert.deepEqual(request.files, [{ mime: image.mime, url: image.dataUrl, filename: image.filename }])
assert.equal(value.messages().length, messages, "Goal sends must not add optimistic chat messages")
assert.equal(input().value, text, "Keep the Goal draft until its acknowledgement")
assert.equal(button.getAttribute("aria-disabled"), "true")
assert.equal(input().readOnly, true)
assert.equal(input().getAttribute("aria-disabled"), "true")
input().value = "Rejected pending edit"
input().dispatchEvent(new window.Event("input", { bubbles: true }))
for (const key of ["ArrowUp", "ArrowDown", "Backspace", "Tab", "Enter"]) {
input().setSelectionRange(0, 0)
input().dispatchEvent(new window.KeyboardEvent("keydown", { key, bubbles: true }))
}
const clipboard = new window.DataTransfer()
clipboard.items.add(new window.File(["image"], "extra.png", { type: "image/png" }))
const paste = new window.ClipboardEvent("paste", { bubbles: true, cancelable: true, clipboardData: clipboard })
input().dispatchEvent(paste)
assert.equal(paste.defaultPrevented, true)
const transfer = new window.DataTransfer()
transfer.setData("application/vnd.code.uri-list", "file:///test/extra.txt")
const drop = new window.DragEvent("drop", { bubbles: true, cancelable: true, dataTransfer: transfer })
input().dispatchEvent(drop)
assert.equal(drop.defaultPrevented, true)
const remove = host.querySelector<HTMLButtonElement>(".image-attachment-remove")
assert(remove?.disabled)
remove.dispatchEvent(new window.MouseEvent("click", { bubbles: true }))
await settle()
assert.equal(input().value, text)
assert.equal(requests().length, count + 1)
assert.deepEqual(imageDrafts.get(scope), [image])
assert(host.querySelector(`img[src="${image.dataUrl}"]`))
await emit({ type: "sessionCommandCompleted", messageID: "unrelated-command" })
assert.equal(button.getAttribute("aria-disabled"), "true")
await emit(
success
? { type: "sessionCommandCompleted", messageID: request.messageID }
: { type: "sendMessageFailed", sessionID: sid, messageID: request.messageID, error: "Goal rejected" },
)
assert.equal(input().value, success ? "" : text)
assert.equal(input().readOnly, false)
assert.equal(!!host.querySelector(".prompt-goal-header"), !success)
assert.equal(drafts.has(scope), !success)
assert.equal(imageDrafts.has(scope), !success)
assert.equal(value.submitting(), false)
}
}
for (const success of [false, true]) {
for (const cancel of ["button", "escape"] as const) {
await seed("/goal")
submit(false)
await settle()
input().value = "Retain this objective"
input().dispatchEvent(new window.Event("input", { bubbles: true }))
submit(false)
await settle()
const request = requests().at(-1)
assert(request?.type === "sendCommand")
assert.equal(input().readOnly, true)
if (cancel === "escape")
input().dispatchEvent(new window.KeyboardEvent("keydown", { key: "Escape", bubbles: true }))
if (cancel === "button") host.querySelector<HTMLButtonElement>(".prompt-goal-header button")!.click()
await settle()
assert.equal(host.querySelector(".prompt-goal-header"), null)
assert.equal(input().readOnly, false)
assert.equal(input().getAttribute("aria-disabled"), "false")
assert.equal(input().value, "Retain this objective")
// Cancel must preserve even an unchanged draft when the accepted command later succeeds.
if (!success) {
input().value += " with a new edit"
input().dispatchEvent(new window.Event("input", { bubbles: true }))
}
const draft = input().value
await emit(
success
? { type: "sessionCommandCompleted", messageID: request.messageID }
: { type: "sendMessageFailed", sessionID: "composer", messageID: request.messageID, error: "Rejected" },
)
assert.equal(input().value, draft)
value.setCurrentSessionID("other-composer")
await settle()
assert.equal(drafts.get(key), draft)
assert.deepEqual(imageDrafts.get(key), [image])
value.setCurrentSessionID("composer")
await settle()
assert.equal(input().value, draft)
}
}
for (const success of [false, true]) {
await seed("/goal")
submit(false)
await settle()
input().value = "Original session objective"
input().dispatchEvent(new window.Event("input", { bubbles: true }))
submit(false)
await settle()
const request = requests().at(-1)
assert(request?.type === "sendCommand")
await emit({ type: "appendChatBoxMessage", text: "New context from the editor" })
assert.equal(input().value, "Original session objective", "Host mutations must wait for admission")
savePromptDraft("acceptance:session:other-composer", "Other session draft", [], [{ ...image, id: "other" }])
value.setCurrentSessionID("other-composer")
await settle()
assert.equal(input().readOnly, false)
assert.equal(input().value, "Other session draft")
input().value += " edited"
input().dispatchEvent(new window.Event("input", { bubbles: true }))
await emit(
success
? { type: "sessionCommandCompleted", messageID: request.messageID }
: { type: "sendMessageFailed", sessionID: "composer", messageID: request.messageID, error: "Rejected" },
)
assert.equal(input().value, "Other session draft edited")
assert.deepEqual(imageDrafts.get("acceptance:session:other-composer"), [{ ...image, id: "other" }])
value.setCurrentSessionID("composer")
await settle()
assert.equal(
input().value,
success ? "New context from the editor" : "Original session objective\n\nNew context from the editor",
)
assert.equal(input().readOnly, false)
assert.equal(imageDrafts.has(key), !success)
}
{
await seed("/goal")
submit(false)
await settle()
const comment = { id: "goal-review", file: "test.ts", line: 1, side: "additions", comment: "Keep this review" }
const browser = { id: "goal-browser", sessionId: "composer", selector: "button" }
await emit({ type: "setChatBoxMessage", text: "Retain attachments", review: [comment], browser: [browser] })
submit(false)
await settle()
assert.equal(input().readOnly, true)
const buttons = host.querySelectorAll<HTMLButtonElement>(
".prompt-review-row-remove, .prompt-review-comments-header [data-component=button]",
)
assert.equal(buttons.length, 4)
buttons.forEach((button) => button.click())
assert.equal(host.querySelectorAll(".prompt-review-row").length, 2)
await emit({ type: "appendReviewComments", sessionID: "composer", comments: [{ ...comment, id: "later-review" }] })
await emit({
type: "appendChatBoxMessage",
text: "",
browser: { ...browser, id: "later-browser", selector: "#later" },
})
await emit({ type: "appendChatBoxMessage", text: "Retain delayed editor text" })
assert.equal(input().value, "Retain attachments")
assert.equal(host.querySelectorAll(".prompt-review-row").length, 2)
setComposer(false)
await settle()
assert.equal(drafts.get(key), "Retain attachments\n\nRetain delayed editor text")
assert.equal(reviewDrafts.get(key)?.length, 2)
assert.equal(browserDrafts.get(key)?.length, 2)
assert.deepEqual(imageDrafts.get(key), [image])
const request = requests().at(-1)
assert(request?.type === "sendCommand")
await emit({ type: "sessionCommandCompleted", messageID: request.messageID })
}
setComposer(false)
await settle()
await catalog("org-a", [recommended.modelID], recommended.modelID)
@@ -1131,6 +1315,46 @@ try {
assert.equal(value.questions().length, 1)
assert.equal(value.suggestions().length, 1)
}
for (const args of ["", "pause", "clear", "resume", "A new goal"]) {
const control = ["", "pause", "clear"].includes(args)
const before = snapshot("cloud:preview")
const start = sent.length
const messageID = `goal-cloud-${phase}-${args}`
const accepted = value.sendCommand(
"goal",
args,
undefined,
undefined,
undefined,
undefined,
undefined,
"cloud:preview",
{ messageID },
)
if (!control && phase !== "ready") {
assert.equal(accepted, false)
assert.deepEqual(sent.slice(start), [])
continue
}
assert.equal(accepted, true)
const request = sent.at(-1)
assert(request?.type === "importAndSend")
assert.equal(request.cloudSessionId, "preview")
assert.equal(request.command, "goal")
assert.equal(request.commandArgs, args)
assert.equal(request.messageID, messageID)
if (control) {
assert.deepEqual(
sent.slice(start).map((message) => message.type),
["importAndSend"],
)
assert.equal(request.providerID, undefined)
assert.equal(request.modelID, undefined)
assert.equal(request.agent, undefined)
assert.equal(request.variant, undefined)
assert.equal(snapshot("cloud:preview"), before)
}
}
}
await catalog("org-a", [recommended.modelID], recommended.modelID)
await emit({ type: "sessionUpdated", session: { ...info("root"), goal: { ...goal, active: false } } })
@@ -1140,9 +1364,9 @@ try {
await emit({ type: "questionResolved", requestID: "goal-question" })
await emit({ type: "suggestionResolved", requestID: "goal-suggestion" })
await emit({ type: "sessionStatus", sessionID: "root", status: "idle" })
const start = sent.length
const idle = sent.length
value.abort()
assert.equal(sent.length, start)
assert.equal(sent.length, idle)
for (const update of [setOperation, setRun]) {
update(true)
await settle()
@@ -322,18 +322,24 @@ describe("sendMessage / sendCommand draft id contract", () => {
it("sendCommand seeds the pending agent before resolving draft-scoped settings", () => {
const body = extractFunctionBody(source, "sendCommand")
expect(body).toMatch(
/if \(!sid && !draftID && effectiveDraftID\) agentDrafts\.seed\(effectiveDraftID\)[\s\S]*const settings = submission\(scope, effectiveSelection \?\? undefined\)/,
/if \(!sid && !draftID && effectiveDraftID\) agentDrafts\.seed\(effectiveDraftID\)[\s\S]*submission\(scope, effectiveSelection\)/,
)
})
it("sendMessage and sendCommand post the settings returned by submission", () => {
expect(extractFunctionBody(source, "sendMessage")).toContain("const settings = submission(scope, selection)")
expect(extractFunctionBody(source, "sendCommand")).toContain(
"const settings = submission(scope, effectiveSelection ?? undefined)",
"const { model, ...settings } = submission(scope, effectiveSelection)",
)
expect(extractFunctionBody(source, "submission")).toContain("agent: resolvePromptAgent({")
})
it("does not resolve submission defaults for model-free Goal controls", () => {
const body = extractFunctionBody(source, "sendCommand")
expect(body).toMatch(/if \(!effectiveSelection\) return\s+const \{ model, \.\.\.settings \} = submission/)
expect(body).not.toContain("effectiveSelection ?? undefined")
})
it("createSession and clearCurrentSession do not pin the provisional default agent", () => {
expect(extractFunctionBody(source, "createSession")).toContain("setPendingAgentSelection(null)")
expect(extractFunctionBody(source, "createSession")).not.toContain("setPendingAgentSelection(defaultAgent())")
@@ -237,6 +237,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const git = useGitChangesContext(vscode, ctx, hasGit)
const imageAttach = useImageAttachments()
imageAttach.setFilePathDropHandler((paths) => {
if (readonly()) return
const cwd = server.workspaceDirectory()
const resolved = paths.map((p) => convertToMentionPath(p, cwd))
const ref = textareaRef
@@ -292,6 +293,25 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
],
)
const locked = () => !!props.edit && props.edit.sessionID === session.currentSessionID()
const readonly = () => locked() || (goal.active() && goal.pending())
// Host-supplied drafts and attachments must wait, not disappear during Goal admission.
const deferred = new Map<string, ((key: string) => void)[]>()
let flushing = false
const defer = (key: string, work: (key: string) => void) => {
if (flushing || !goal.pending(key)) return false
deferred.set(key, [...(deferred.get(key) ?? []), work])
return true
}
createEffect(() => {
const key = draftKey()
if (goal.pending(key)) return
queueMicrotask(() => {
if (draftKey() !== key || goal.pending(key)) return
const work = deferred.get(key)
deferred.delete(key)
work?.forEach((apply) => apply(key))
})
})
const saveDraft = (
key: string,
next: string,
@@ -467,10 +487,13 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
references.set(draftKey(), next)
}
const remove = (id: string) => replace(browsers().filter((item) => item.id !== id))
const remove = (id: string) => {
if (!readonly()) replace(browsers().filter((item) => item.id !== id))
}
const clear = () => replace([])
const removeReviewComment = (id: string) => {
if (readonly()) return
replaceReviewComments(reviewComments().filter((item) => item.id !== id))
}
@@ -652,8 +675,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const speechModel = () => selectedSpeechToTextModel(config(), speechModels.models())
const hasInput = () =>
text().trim().length > 0 || imageAttach.images().length > 0 || reviewComments().length > 0 || browsers().length > 0
const sendReady = () => !isDisabled() && !terminal.pending() && !git.pending() && !props.blocked?.()
const sendReady = () => !isDisabled() && goalReady() && !terminal.pending() && !git.pending() && !props.blocked?.()
const canContinue = () => !goal.active() && speech.state() === "idle" && !hasInput() && session.canResume()
const goalReady = () => !goal.pending() && (!goal.active() || (!enhancing() && !imageAttach.pending()))
const canSend = () =>
sendReady() &&
(speech.state() === "recording" ||
@@ -833,7 +857,23 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
}),
})
const restoreBox = (message: Extract<ExtensionMessage, { type: "setChatBoxMessage" }>) => {
const restoreBox = (message: Extract<ExtensionMessage, { type: "setChatBoxMessage" }>, key = draftKey()) => {
if (defer(key, (key) => restoreBox(message, key))) return
if (key !== draftKey()) {
savePromptDraft(
key,
message.text,
message.review ?? reviewDrafts.get(key) ?? [],
message.images?.map((image) => ({ ...image, id: crypto.randomUUID(), filename: image.filename ?? "image" })) ??
imageDrafts.get(key) ??
[],
scrollDrafts.get(key),
message.browser ?? references.get(key) ?? [],
)
if (message.paths || message.sessions)
mentionDrafts.set(key, { paths: message.paths ?? [], sessions: message.sessions ?? [] })
return
}
setText(message.text)
if (message.paths?.length) mention.seedFromParts(message.paths, message.text)
else mention.seedFromText(message.text)
@@ -858,7 +898,17 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
}
}
const appendBox = (message: Extract<ExtensionMessage, { type: "appendChatBoxMessage" }>) => {
const appendBox = (message: Extract<ExtensionMessage, { type: "appendChatBoxMessage" }>, key = draftKey()) => {
if (defer(key, (key) => appendBox(message, key))) return
if (key !== draftKey()) {
if (message.browser) {
references.set(key, mergeBrowserReferences(references.get(key) ?? [], message.browser))
return
}
const current = drafts.get(key) ?? ""
drafts.set(key, current + (current && !current.endsWith("\n") ? "\n\n" : "") + message.text)
return
}
const reference = message.browser
if (reference) {
if (reference.sessionId !== sid()) return
@@ -879,14 +929,17 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
}
}
const appendReviews = (message: Extract<ExtensionMessage, { type: "appendReviewComments" }>) => {
const target = message.sessionID
? promptDraftKey(boxKey(), message.sessionID, {
draft: props.pendingSessionID ?? session.draftSessionID(),
current: session.currentSessionID(),
})
: draftKey()
const appendReviews = (message: Extract<ExtensionMessage, { type: "appendReviewComments" }>, key?: string) => {
const target =
key ??
(message.sessionID
? promptDraftKey(boxKey(), message.sessionID, {
draft: props.pendingSessionID ?? session.draftSessionID(),
current: session.currentSessionID(),
})
: draftKey())
if (!target) return
if (defer(target, (key) => appendReviews(message, key))) return
if (target !== draftKey()) {
reviewDrafts.set(target, mergeReviewComments(reviewDrafts.get(target) ?? [], message.comments))
return
@@ -907,6 +960,11 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const source = scopeDraftKey(boxKey(), raw)
const target = scopeDraftKey(boxKey(), sessionDraftKey(message.session.id))
goal.move(source, target)
const queued = deferred.get(source)
if (queued) {
deferred.set(target, [...queued, ...(deferred.get(target) ?? [])])
deferred.delete(source)
}
if (source === draftKey()) saveDraft(source, text(), reviewComments(), imageAttach.images())
const from = reviewDrafts.get(source)
const to = reviewDrafts.get(target)
@@ -985,6 +1043,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
}
if (message.type === "filePickerResult") {
if (defer(draftKey(), () => mention.insertFilePickerResult(message.path, message.requestId))) return
mention.insertFilePickerResult(message.path, message.requestId)
}
})
@@ -992,6 +1051,10 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
onCleanup(() => {
props.onEditReady?.(false)
// Keep delayed host input in its draft even if the composer unmounts before acknowledgement.
flushing = true
for (const [key, work] of deferred) work.forEach((apply) => apply(key))
deferred.clear()
// Persist current draft before unmounting
saveDraft(draftKey(), text(), reviewComments(), imageAttach.images())
if (sandboxRetry) clearTimeout(sandboxRetry)
@@ -1000,6 +1063,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
})
const acceptSuggestion = () => {
if (readonly()) return
const result = ghost.accept()
if (!result) return
@@ -1042,7 +1106,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
}
const handlePaste = (e: ClipboardEvent) => {
if (locked()) {
if (readonly()) {
e.preventDefault()
return
}
@@ -1058,6 +1122,10 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const handleInput = (e: InputEvent) => {
const target = e.target as HTMLTextAreaElement
if (readonly()) {
target.value = text()
return
}
const val = target.value
setText(val)
preEnhanceText = null
@@ -1084,6 +1152,10 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
}
const handleKeyDown = (e: KeyboardEvent) => {
if (goal.pending()) {
escape(e)
return
}
if (locked()) return
// Undo enhanced prompt with Ctrl+Z / ⌘Z
if (e.key === "z" && (e.metaKey || e.ctrlKey) && !e.shiftKey && preEnhanceText !== null) {
@@ -1457,6 +1529,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const attachments = allFiles.length > 0 ? allFiles : undefined
if (objective) {
mention.closeMention()
slash.close()
ghost.dismiss()
goal.send(key, stamp, [
"goal",
`-- ${message}`,
@@ -1535,7 +1610,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
onDragOver={imageAttach.handleDragOver}
onDragLeave={imageAttach.handleDragLeave}
onDrop={(event) => {
if (locked()) {
if (readonly()) {
event.preventDefault()
return
}
@@ -1555,12 +1630,20 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
comments={reviewComments()}
sessionID={sid()}
onRemove={removeReviewComment}
onClear={(ids) => replaceReviewComments(reviewComments().filter((item) => !ids.includes(item.id)))}
onClear={(ids) => {
if (!readonly()) replaceReviewComments(reviewComments().filter((item) => !ids.includes(item.id)))
}}
/>
</Show>
<Show when={browsers().length > 0}>
<div data-component="browser-references">
<BrowserReferences references={browsers()} onRemove={remove} onClear={clear} />
<BrowserReferences
references={browsers()}
onRemove={remove}
onClear={() => {
if (!readonly()) clear()
}}
/>
</div>
</Show>
<Show when={mention.showMention()}>
@@ -1702,7 +1785,10 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
<button
type="button"
class="image-attachment-remove"
onClick={() => imageAttach.remove(img.id)}
disabled={readonly()}
onClick={() => {
if (!readonly()) imageAttach.remove(img.id)
}}
aria-label="Remove image"
>
×
@@ -1747,7 +1833,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
<textarea
ref={textareaRef}
class="prompt-input"
classList={{ "prompt-input--disabled": isDisabled() }}
classList={{ "prompt-input--disabled": !server.isConnected() || readonly() }}
placeholder={placeholder()}
value={text()}
onInput={handleInput}
@@ -1778,8 +1864,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
if (textareaRef) mention.snapSelection(textareaRef)
}}
onScroll={syncHighlightScroll}
aria-disabled={isDisabled()}
readOnly={locked()}
aria-disabled={!server.isConnected() || readonly()}
readOnly={readonly()}
rows={1}
dir="auto"
/>
@@ -16,10 +16,17 @@ export function useGoalComposer(
const goal = {
active: () => owner() === key(),
ready: (text: string) => owner() !== key() || !!text.trim(),
pending: () => Object.values(requests()).includes(key()),
pending: (scope = key()) => Object.values(requests()).includes(scope),
activate: () => setOwner(key()),
cancel: () => setOwner(undefined),
cancel: () => {
// Cancel exits composition, not the accepted backend command. Retain its draft on acknowledgement.
for (const [id, scope] of Object.entries(requests())) {
if (scope === key()) submitted.delete(id)
}
setOwner(undefined)
},
prepare: (draft: string, reset: () => void) => {
if (goal.pending()) return false
if (!goal.active() && draft === "/goal") {
goal.activate()
reset()
@@ -28,7 +35,7 @@ export function useGoalComposer(
return goal.ready(draft)
},
send: (scope: string, stamp: string, args: Parameters<SessionContextValue["sendCommand"]>) => {
if (key() !== scope || !goal.active()) return
if (key() !== scope || !goal.active() || goal.pending()) return
const messageID = Identifier.ascending("message")
submitted.set(messageID, stamp)
goal.begin(messageID, scope)
@@ -2254,13 +2254,12 @@ export const SessionProvider: ParentComponent = (props) => {
recordModelUsage(effectiveSelection.providerID, effectiveSelection.modelID)
}
const selection = effectiveSelection && {
...effectiveSelection,
agent: promptAgent(scope),
variant: variants.request(scope),
}
const messageID = (() => overrides?.messageID ?? Identifier.ascending("message"))()
const settings = (() => {
if (!effectiveSelection) return
const { model, ...settings } = submission(scope, effectiveSelection)
return { ...model, ...settings }
})()
const messageID = overrides?.messageID ?? Identifier.ascending("message")
// Cloud previews need import-then-command; post importAndSend with command metadata
const preview = sid?.startsWith("cloud:")
@@ -2269,16 +2268,12 @@ export const SessionProvider: ParentComponent = (props) => {
? cloudPreviewId()
: null
if (preview) {
const settings = submission(scope, effectiveSelection ?? undefined)
vscode.postMessage({
type: "importAndSend",
cloudSessionId: preview,
text: `/${command} ${args}`.trim(),
messageID: Identifier.ascending("message"),
providerID: settings.model?.providerID,
modelID: settings.model?.modelID,
agent: settings.agent,
variant: settings.variant,
messageID,
...settings,
files,
command,
commandArgs: args,
@@ -2299,7 +2294,6 @@ export const SessionProvider: ParentComponent = (props) => {
setDraftSessionID(scope)
}
}
const settings = submission(scope, effectiveSelection ?? undefined)
vscode.postMessage({
type: "sendCommand",
command,
@@ -2307,10 +2301,7 @@ export const SessionProvider: ParentComponent = (props) => {
messageID,
sessionID: sid,
draftID: effectiveDraftID,
providerID: settings.model?.providerID,
modelID: settings.model?.modelID,
agent: settings.agent,
variant: settings.variant,
...settings,
files,
agentManagerContext: context,
})
@@ -15,6 +15,7 @@ export type FilePathDropHandler = (paths: string[]) => void
export function useImageAttachments() {
const [images, setImages] = createSignal<ImageAttachment[]>([])
const [dragging, setDragging] = createSignal(false)
const [pending, setPending] = createSignal(0)
let onFilePaths: FilePathDropHandler | undefined
/** Register a handler for file path drops (text/URI-list). */
@@ -25,6 +26,8 @@ export function useImageAttachments() {
const add = (file: File) => {
if (!isAcceptedImageType(file.type)) return
const reader = new FileReader()
setPending((count) => count + 1)
reader.onloadend = () => setPending((count) => count - 1)
reader.onload = () => {
const attachment: ImageAttachment = {
id: crypto.randomUUID(),
@@ -96,6 +99,7 @@ export function useImageAttachments() {
return {
images,
dragging,
pending: () => pending() > 0,
add,
remove,
clear,
+2 -2
View File
@@ -440,12 +440,12 @@ export const RunCommand = effectCmd({
const input = { initial: undefined as string | undefined, loaded: false }
async function loadInput() {
if (input.loaded) return
// kilocode_change start - bound the stdin wait when argv already carries a
// Bound the stdin wait when argv already carries a
// message or command; a launcher-held-open pipe never EOFs (see run-stdin.ts)
const piped = process.stdin.isTTY
? undefined
: await readPipedStdin({ bound: rawMessage.trim().length > 0 || args.command !== undefined })
// kilocode_change end
message = resolveRunInput(message, piped) ?? ""
input.initial = resolveRunInput(rawMessage, piped)
input.loaded = true
@@ -155,7 +155,7 @@ export namespace Goal {
}
export function make(ops: {
create: (input: PromptInput) => Effect.Effect<SessionV1.WithParts>
create: (input: PromptInput) => Effect.Effect<Effect.Effect<SessionV1.WithParts>>
prompt: (input: PromptInput, ticket: KiloSessionControl.Ticket) => Effect.Effect<SessionV1.WithParts, unknown>
cancel: (id: SessionID, preserve?: boolean) => Effect.Effect<void>
control: {
@@ -252,6 +252,27 @@ export namespace Goal {
})
if (starting) yield* admit(true)
if (intent && !intent.current()) return yield* Effect.interrupt
// Resolve attachments without changing the transcript, model, or running goal.
const prepared = starting
? yield* KiloSessionPrompt.intake(
id,
ops
.create({
sessionID: id,
messageID: input.messageID,
agent: input.agent,
model: input.model ? Provider.parseModel(input.model) : undefined,
variant: input.variant,
parts: [
{ type: "text", text: `/goal ${objective ?? args}`, ignored: true },
...(input.parts ?? []),
],
})
.pipe(Effect.raceFirst(cancelled)),
)
: undefined
if (starting) yield* admit(true)
if (intent && !intent.current()) return yield* Effect.interrupt
if (args && GoalState.active(id)) yield* ops.cancel(id, true)
if (intent && !intent.current()) return yield* Effect.interrupt
if (args === "pause" || args === "clear") yield* pause(id, true)
@@ -284,17 +305,7 @@ export namespace Goal {
: starting
? "Goal active. Work uses model credits. The working model reports completion or blockers with goal_report; completion is not independently verified. No progress or errors pause the goal. Use Stop or /goal pause to pause."
: "Goal paused. Use /goal resume to continue."
// Validate and persist attachments before acknowledging the command.
const user = starting
? (yield* ops.create({
sessionID: id,
messageID: input.messageID,
agent: input.agent,
model: input.model ? Provider.parseModel(input.model) : undefined,
variant: input.variant,
parts: [{ type: "text", text: `/goal ${objective ?? args}`, ignored: true }, ...(input.parts ?? [])],
})).info
: undefined
const user = prepared ? (yield* prepared).info : undefined
if (user && user.role !== "user") return yield* Effect.die(new Error("Expected a user message"))
if (!valid()) return yield* Effect.interrupt
const model =
+43 -26
View File
@@ -223,7 +223,7 @@ export const layer = Layer.effect(
const goals = yield* Goal.make({
control,
cancel: (id, preserve) => cancel(id, "tree", preserve),
create: (input) => createUserMessage(input),
create: (input) => prepare(input, true).pipe(Effect.scoped),
prompt: (input, ticket) => prompt(input, ticket),
})
// kilocode_change end
@@ -843,14 +843,16 @@ export const layer = Layer.effect(
return yield* provider.defaultModel().pipe(Effect.orDie)
})
const createUserMessage = Effect.fn("SessionPrompt.createUserMessage")(function* (input: PromptInput) {
// kilocode_change start - prepare Goal admission without persisting or cancelling prior work
const prepare = Effect.fn("SessionPrompt.prepare")(function* (input: PromptInput, defer = false) {
// kilocode_change end
const agentName = input.agent ?? (yield* sessions.get(input.sessionID).pipe(Effect.orDie)).agent // kilocode_change
const ag = agentName ? yield* agents.get(agentName) : yield* agents.defaultInfo()
if (!ag) {
const available = (yield* agents.list()).filter((a) => !a.hidden).map((a) => a.name)
const hint = available.length ? ` Available agents: ${available.join(", ")}` : ""
const error = new NamedError.Unknown({ message: `Agent not found: "${agentName}".${hint}` })
yield* events.publish(Session.Event.Error, { sessionID: input.sessionID, error: error.toObject() })
if (!defer) yield* events.publish(Session.Event.Error, { sessionID: input.sessionID, error: error.toObject() }) // kilocode_change - admission failure must not fail the running Goal
throw error
}
const model = input.model ?? ag.model ?? (yield* currentModel(input.sessionID))
@@ -886,25 +888,29 @@ export const layer = Layer.effect(
editorContext: input.editorContext, // kilocode_change
}
const current = yield* sessions.get(input.sessionID).pipe(Effect.orDie)
if (
current.agent !== info.agent ||
current.model?.providerID !== info.model.providerID ||
current.model?.id !== info.model.modelID ||
(current.model?.variant === "default" ? undefined : current.model?.variant) !== info.model.variant
) {
yield* sessions.setAgentModel({
sessionID: input.sessionID,
agent: info.agent,
model: {
id: info.model.modelID,
providerID: info.model.providerID,
variant: info.model.variant ?? "default",
},
time: info.time.created,
})
}
// kilocode_change start - defer Goal model changes until admission succeeds
const select = Effect.gen(function* () {
const current = yield* sessions.get(input.sessionID).pipe(Effect.orDie)
if (
current.agent !== info.agent ||
current.model?.providerID !== info.model.providerID ||
current.model?.id !== info.model.modelID ||
(current.model?.variant === "default" ? undefined : current.model?.variant) !== info.model.variant
) {
yield* sessions.setAgentModel({
sessionID: input.sessionID,
agent: info.agent,
model: {
id: info.model.modelID,
providerID: info.model.providerID,
variant: info.model.variant ?? "default",
},
time: info.time.created,
})
}
})
if (!defer) yield* select
// kilocode_change end
yield* Effect.addFinalizer(() => instruction.clear(info.id))
type Draft<T> = T extends SessionV1.Part ? Omit<T, "id"> & { id?: string } : never
@@ -1023,6 +1029,7 @@ export const layer = Layer.effect(
}
} else {
const error = Cause.squash(exit.cause)
if (defer) return yield* Effect.die(error) // kilocode_change - reject invalid Goal attachments during admission
yield* Effect.logError("failed to read MCP resource", { error, clientName, uri })
const message = error instanceof Error ? error.message : String(error)
pieces.push({
@@ -1182,6 +1189,7 @@ export const layer = Layer.effect(
}
} else {
const error = Cause.squash(exit.cause)
if (defer) return yield* Effect.die(error) // kilocode_change - reject invalid Goal attachments during admission
yield* Effect.logError("failed to read file", { error, filepath })
const message = error instanceof Error ? error.message : String(error)
yield* events.publish(Session.Event.Error, {
@@ -1204,6 +1212,7 @@ export const layer = Layer.effect(
const exit = yield* execRead(args).pipe(Effect.exit)
if (Exit.isFailure(exit)) {
const error = Cause.squash(exit.cause)
if (defer) return yield* Effect.die(error) // kilocode_change - reject invalid Goal attachments during admission
yield* Effect.logError("failed to read directory", { error, filepath })
const message = error instanceof Error ? error.message : String(error)
yield* events.publish(Session.Event.Error, {
@@ -1296,6 +1305,7 @@ export const layer = Layer.effect(
}).pipe(Effect.exit)
if (Exit.isFailure(access)) {
const error = Cause.squash(access.cause)
if (defer) return yield* Effect.die(error)
if (
error instanceof Image.InvalidDataUrlError ||
error instanceof Image.DecodeError ||
@@ -1416,11 +1426,18 @@ export const layer = Layer.effect(
})
}
yield* sessions.updateMessage(info)
for (const part of parts) yield* sessions.updatePart(part)
// kilocode_change start - commit the prepared Goal message only after cancellation fences
return Effect.gen(function* () {
if (defer) yield* select
yield* sessions.updateMessage(info)
for (const part of parts) yield* sessions.updatePart(part)
return { info, parts }
}, Effect.scoped)
return { info, parts }
})
// kilocode_change end
}) // kilocode_change - scope preparation and persistence together for normal prompts
const createUserMessage = (input: PromptInput) => prepare(input).pipe(Effect.flatten, Effect.scoped) // kilocode_change
// kilocode_change start
const prompt: (
@@ -1,8 +1,5 @@
import { expect, spyOn, test } from "bun:test"
import { expect } from "bun:test"
import { Effect } from "effect"
import yargs from "yargs"
import { RunCommand } from "@/cli/cmd/run"
import { UI } from "@/cli/ui"
import { cliIt } from "../../../lib/cli-process"
const diagnostic = "Goal start and resume require the TUI. Run kilo, then use /goal <text> or /goal resume."
@@ -59,54 +56,28 @@ for (const scenario of [
)
}
test.each(["Fix failing tests\n", " resume\n"])(
"piped goal %j rejects before deferred session lookup",
async (text) => {
const calls: string[] = []
using server = listen(calls)
using stdin = spyOn(Bun.stdin, "text").mockResolvedValue(text)
using error = spyOn(UI, "error").mockImplementation(() => {})
using exit = spyOn(process, "exit").mockImplementation(() => {
throw new Error("headless goal rejected")
})
const tty = Object.getOwnPropertyDescriptor(process.stdin, "isTTY")
const code = process.exitCode
Object.defineProperty(process.stdin, "isTTY", { configurable: true, value: false })
try {
const failure = await yargs()
.command(RunCommand)
.exitProcess(false)
.fail((message, err) => {
throw err ?? new Error(message)
for (const text of ["Fix failing tests\n", " resume\n"]) {
cliIt.concurrent(
`piped goal ${JSON.stringify(text)} rejects before deferred session lookup`,
({ opencode }) =>
Effect.gen(function* () {
const calls: string[] = []
using server = listen(calls)
const child = yield* opencode.startRun(undefined, {
command: "goal",
stdin: "pipe",
extraArgs: ["--session", "ses_goal", "--fork", "--share", "--attach", server.url.toString()],
})
.parseAsync([
"run",
"--command",
"goal",
"--session",
"ses_goal",
"--fork",
"--share",
"--attach",
server.url.toString(),
])
.then(
() => undefined,
(err: unknown) => err,
)
expect(failure).toBeInstanceOf(Error)
expect(String(failure)).toContain("headless goal rejected")
expect(stdin).toHaveBeenCalledTimes(1)
expect(error).toHaveBeenCalledWith(diagnostic)
expect(exit).toHaveBeenCalledWith(1)
expect(calls).toEqual([])
} finally {
process.exitCode = code ?? 0
if (tty) Object.defineProperty(process.stdin, "isTTY", tty)
if (!tty) delete (process.stdin as { isTTY?: boolean }).isTTY
}
},
)
yield* Effect.promise(() => child.stdin.write(text))
child.stdin.end()
const result = yield* child.result
opencode.expectExit(result, 1)
expect(result.stderr).toContain(diagnostic)
expect(calls).toEqual([])
}),
60_000,
)
}
cliIt.concurrent(
"headless goal status, pause, and clear still dispatch without sharing or draining",
@@ -1,4 +1,5 @@
import path from "path"
import { pathToFileURL } from "node:url"
import { expect, spyOn, test } from "bun:test"
import { Cause, Effect, Exit, Fiber, Latch, Stream } from "effect"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
@@ -472,12 +473,128 @@ it.instance(
})
.pipe(Effect.exit)
expect(Exit.isFailure(result)).toBe(true)
expect(GoalState.read(yield* run.metadata)?.active).toBe(false)
expect(GoalState.read(yield* run.metadata)).toBeUndefined()
expect(yield* run.llm.hits).toHaveLength(0)
}),
30_000,
)
for (const kind of ["image", "file"] as const) {
it.instance(
`invalid replacement ${kind} preserves the objective and held execution`,
Effect.gen(function* () {
const run = yield* setup()
const instance = yield* TestInstance
const gate = Promise.withResolvers<void>()
yield* Effect.addFinalizer(() => Effect.sync(gate.resolve))
yield* run.llm.push(reply().wait(gate.promise).text("Original execution finished").stop())
yield* run.command(objective)
yield* run.wait(1)
const base = KiloSessionPromptQueue.active(run.session.id)
const before = yield* run.sessions.messages({ sessionID: run.session.id })
const metadata = yield* run.metadata
const selection = yield* run.sessions.get(run.session.id)
const result = yield* run.prompt
.command({
sessionID: run.session.id,
agent: "ask",
model: "test/selected-model",
command: "goal",
arguments: "-- Invalid replacement",
parts: [
kind === "image"
? {
type: "file",
mime: "image/png",
url: "data:image/png;base64,bm90LWFuLWltYWdl",
filename: "invalid.png",
}
: {
type: "file",
mime: "text/plain",
url: pathToFileURL(path.join(instance.directory, "missing.txt")).href,
filename: "missing.txt",
},
],
})
.pipe(Effect.exit)
expect(Exit.isFailure(result)).toBe(true)
expect(yield* run.metadata).toEqual(metadata)
expect(GoalState.active(run.session.id)).toBe(true)
expect(KiloSessionPromptQueue.active(run.session.id)).toBe(base)
expect((yield* run.status.get(run.session.id)).type).toBe("busy")
const after = yield* run.sessions.messages({ sessionID: run.session.id })
expect(after.map((message) => message.info.id)).toEqual(before.map((message) => message.info.id))
expect(after.filter((message) => message.info.role === "user").map((message) => message.parts)).toEqual(
before.filter((message) => message.info.role === "user").map((message) => message.parts),
)
expect(yield* run.sessions.get(run.session.id)).toMatchObject({ agent: selection.agent, model: selection.model })
gate.resolve()
yield* run.paused
const last = (yield* run.sessions.messages({ sessionID: run.session.id })).at(-1)
expect(last?.info.role === "assistant" && last.info.error).toBeUndefined()
expect(last?.parts).toContainEqual(expect.objectContaining({ type: "text", text: "Original execution finished" }))
expect(yield* run.llm.hits).toHaveLength(1)
}),
30_000,
)
}
for (const action of ["replace", "stop"] as const) {
it.instance(
`fences valid attachment preparation against ${action}`,
Effect.gen(function* () {
const run = yield* setup()
yield* run.llm.push(reply().hang(), reply().hang())
yield* run.command(objective)
yield* run.wait(1)
const agents = yield* Agent.Service
const ready = yield* Latch.make()
const release = yield* Latch.make()
const get = agents.get
const stub = spyOn(agents, "get").mockImplementationOnce((name) =>
ready.open.pipe(Effect.andThen(release.await), Effect.andThen(get(name))),
)
yield* Effect.addFinalizer(() => release.open.pipe(Effect.andThen(Effect.sync(() => stub.mockRestore()))))
const id = MessageID.ascending()
const pending = yield* run.prompt
.command({
sessionID: run.session.id,
messageID: id,
agent: "code",
model: "test/test-model",
command: "goal",
arguments: "-- Prepared replacement",
parts: [
{ type: "file", mime: "text/plain", url: "data:text/plain;base64,Y29udGV4dA==", filename: "context.txt" },
],
})
.pipe(Effect.forkChild)
yield* awaitWithTimeout(ready.await, "replacement did not reach preparation")
expect(GoalState.read(yield* run.metadata)).toMatchObject({ text: objective, active: true })
expect((yield* run.status.get(run.session.id)).type).toBe("busy")
if (action === "stop") yield* awaitWithTimeout(run.prompt.cancel(run.session.id), "Stop waited for admission")
yield* release.open
const exit = yield* awaitWithTimeout(Fiber.await(pending), "replacement did not settle")
if (action === "stop") {
expect(Exit.hasInterrupts(exit)).toBe(true)
expect(GoalState.read(yield* run.metadata)).toMatchObject({ text: objective, active: false })
expect(
(yield* run.sessions.messages({ sessionID: run.session.id })).some((message) => message.info.id === id),
).toBe(false)
expect(yield* run.llm.hits).toHaveLength(1)
return
}
expect(Exit.isSuccess(exit)).toBe(true)
yield* run.wait(2)
expect(GoalState.read(yield* run.metadata)).toMatchObject({ text: "Prepared replacement", active: true })
expect(JSON.stringify((yield* run.llm.hits).at(-1)?.body)).toContain("context")
yield* run.prompt.cancel(run.session.id)
}),
30_000,
)
}
for (const text of ["pause", "resume", "clear"]) {
it.instance(
`accepts the literal composer objective ${text}`,
+1 -1
View File
@@ -9,7 +9,7 @@
"packages/opencode/src/kilo-sessions/kilo-sessions.ts": { "count": 1, "owner": "session-runtime", "reason": "Kilo session coordination state" },
"packages/opencode/src/kilocode/background-process/index.ts": { "count": 1, "owner": "process-runtime", "reason": "Directory-keyed background process registry" },
"packages/opencode/src/kilocode/session/control.ts": { "count": 1, "owner": "session-runtime", "reason": "SessionPrompt-owned pause gates must remain isolated by directory and session in the shared backend" },
"packages/opencode/src/kilocode/session/goal.ts": { "count": 1, "owner": "session-runtime", "reason": "SessionPrompt-owned goal worker scopes must remain isolated by directory in the shared backend and close on instance disposal" }
"packages/opencode/src/kilocode/session/goal/runner.ts": { "count": 1, "owner": "session-runtime", "reason": "SessionPrompt-owned goal worker scopes must remain isolated by directory in the shared backend and close on instance disposal" }
}
},
"kilo-database-constructors": {