mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-09-19 10:02:04 +08:00
fix(goal): preserve running goals and pending drafts
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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}`,
|
||||
|
||||
@@ -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": {
|
||||
|
||||
Reference in New Issue
Block a user