mirror of
https://github.com/cline/cline.git
synced 2026-09-11 05:47:07 +08:00
fix(vscode): auto-continue the task when switching from plan to act (#11401)
* fix(vscode): enforce stop-before-start ordering for same-id session restarts The app reuses the taskId as the sessionId whenever it replaces or resumes a session (mode/MCP rebuilds, follow-up resume, history restore), but the old session's stop ran fire-and-forget, and core cleanup is keyed by sessionId across multiple awaits. A stop still in flight when the same-id replacement started could tear down the live successor: late sessions-map deletes, a late 'ended' emission, or a stalled status write landing on the replacement. Adopt the sequencing invariant the CLI has always used: never start a same-id session while its stop is in flight. SdkSessionLifecycle tracks in-flight stops in a pendingStops map keyed by sessionId, and startNewSession awaits the pending stop for a reused id before starting (with a log line so a wedged stop is diagnosable). Fresh-id starts never wait. fireAndForgetSend additionally captures the ActiveSession by object identity at send time so a send settling after a same-id replacement cannot flip the successor's run state. * fix(vscode): auto-continue the task when switching from plan to act In plan mode, the model's switch_to_act_mode tool call flipped the toggle but ended the run as aborted: the beforeModel stop hook fired after turn-started, leaving a dangling api_req_started spinner rendered as 'API Request Cancelled', and nothing continued the task after the act-mode rebuild. Manually toggling after a presented plan had the same dead end. The tool now declares lifecycle.completesRun so the run ends cleanly after the tool result, and the queued mode change rebuilds the session and auto-continues with a hidden continuation prompt. A manual plan to act toggle auto-continues only when the agent is idle after presenting its plan (not running and awaiting_followup; a pending ask_question blocks mid-run so it cannot false-positive). Composer content rides along: typed text becomes the continuation, attachments are forwarded and echoed, attachment-only toggles count as consumed. The RPC reports consumption only after the send was actually handed to the session, and the webview then clears only the exact submitted content, so failures and racing input never lose composer state. Failures before the send undo the optimistic running flip, report an error phase, and roll the mode back when the session was never replaced. Hidden prompts (the act continuation and the pre-existing task resumption prompt) shifted editMessageAndRegenerate's visible-to-SDK user message ordinal mapping; the new sdk-user-message-mapping module skips them in their persisted user_input-wrapped shape, counts attachment-only messages (which have visible bubbles), ignores tool-result rows, and attachment-only resumes now echo a bubble to keep both transcripts aligned. Follow-ups sent during a rebuild wait on waitForPendingRebuild instead of resuming a parallel session that the rebuild would kill. The plan-mode system prompt and tool description require explicit user approval in a message sent after the plan was presented, preventing the model from self-escalating to act mode. * fix(vscode): move the turn phase to error when a task resume fails askResponse optimistically sets the turn phase to streaming before delegating to the followup coordinator, but the coordinator's resume catch only posted an error row, leaving the footer stuck on Thinking/Cancel. Resume failures (auth errors, session start errors) now report back via onResumeFailed so the controller can set the phase to error.
This commit is contained in:
@@ -65,6 +65,12 @@ import { SdkTaskHistory, sessionHistoryRecordToHistoryItem } from "./sdk-task-hi
|
||||
import { SdkTaskStartCoordinator } from "./sdk-task-start-coordinator"
|
||||
import { createVscodeSdkTelemetryHandle, type VscodeSdkTelemetryHandle } from "./sdk-telemetry"
|
||||
import { isToolAutoApproved } from "./sdk-tool-policies"
|
||||
import {
|
||||
extractSdkUserText,
|
||||
findSdkUserMessageIndexByOrdinal,
|
||||
isSyntheticSdkUserMessage,
|
||||
type SdkUserMessage,
|
||||
} from "./sdk-user-message-mapping"
|
||||
import { createTaskProxy, type TaskProxy } from "./task-proxy"
|
||||
import { syncTelemetrySettingFromSharedGlobalSettings } from "./telemetry-settings-sync"
|
||||
import { TurnStateTracker } from "./turn-state-tracker"
|
||||
@@ -94,38 +100,6 @@ function metadataString(metadata: SessionHistoryRecord["metadata"] | undefined,
|
||||
return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined
|
||||
}
|
||||
|
||||
type SdkUserMessage = {
|
||||
role?: unknown
|
||||
content?: unknown
|
||||
}
|
||||
|
||||
function extractSdkUserText(message: SdkUserMessage): string {
|
||||
const { content } = message
|
||||
if (typeof content === "string") {
|
||||
return content.trim()
|
||||
}
|
||||
if (!Array.isArray(content)) {
|
||||
return ""
|
||||
}
|
||||
return content
|
||||
.map((block) => {
|
||||
if (!block || typeof block !== "object") {
|
||||
return ""
|
||||
}
|
||||
const typed = block as { type?: unknown; text?: unknown; content?: unknown }
|
||||
if (typed.type === "text" && typeof typed.text === "string") {
|
||||
return typed.text.trim()
|
||||
}
|
||||
if (typed.type === "file" && typeof typed.content === "string") {
|
||||
return typed.content.trim()
|
||||
}
|
||||
return ""
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join("\n")
|
||||
.trim()
|
||||
}
|
||||
|
||||
function dateStringToTimestamp(value: string | null | undefined): number {
|
||||
if (!value) {
|
||||
return 0
|
||||
@@ -379,6 +353,15 @@ export class Controller {
|
||||
emitClineAuthError: () => this.emitClineAuthError(),
|
||||
resetMessageTranslator: () => this.resetMessageTranslatorAndFence(),
|
||||
postStateToWebview: () => this.postStateToWebview(),
|
||||
getTurnPhase: () => this.turnStateTracker.currentPhase,
|
||||
resolveContextMentions: (text) => this.resolveContextMentions(text),
|
||||
onAutoContinueStarting: () => {
|
||||
this.turnStateTracker.set("streaming")
|
||||
this.messageTranslatorState.clearTurnOutcome()
|
||||
},
|
||||
onAutoContinueFailed: () => {
|
||||
this.turnStateTracker.set("error")
|
||||
},
|
||||
})
|
||||
this.mcpTools = new SdkMcpCoordinator({
|
||||
stateManager: this.stateManager,
|
||||
@@ -398,6 +381,7 @@ export class Controller {
|
||||
messages: this.messages,
|
||||
taskHistory: this.taskHistory,
|
||||
sessionConfigBuilder: this.sessionConfigBuilder,
|
||||
waitForPendingModeRebuild: () => this.mode.waitForPendingRebuild(),
|
||||
getTask: () => this.task,
|
||||
createTempSessionHost: () => VscodeSessionHost.create({ mcpHub: this.mcpHub }),
|
||||
getWorkspaceRoot: () => this.getWorkspaceRoot(),
|
||||
@@ -408,6 +392,9 @@ export class Controller {
|
||||
emitClineAuthError: () => this.emitClineAuthError(),
|
||||
resetMessageTranslator: () => this.resetMessageTranslatorAndFence(),
|
||||
postStateToWebview: () => this.postStateToWebview(),
|
||||
onResumeFailed: () => {
|
||||
this.turnStateTracker.set("error")
|
||||
},
|
||||
})
|
||||
this.taskControl = new SdkTaskControlCoordinator({
|
||||
sessions: this.sessions,
|
||||
@@ -1044,14 +1031,7 @@ export class Controller {
|
||||
await tempHost.dispose("editMessageAndRegenerate.readMessages")
|
||||
}
|
||||
}
|
||||
let seenUsers = 0
|
||||
const sdkTargetIndex = sdkMessages.findIndex((message) => {
|
||||
if (message.role !== "user" || !extractSdkUserText(message)) {
|
||||
return false
|
||||
}
|
||||
seenUsers += 1
|
||||
return seenUsers === userOrdinal
|
||||
})
|
||||
const sdkTargetIndex = findSdkUserMessageIndexByOrdinal(sdkMessages, userOrdinal)
|
||||
if (sdkTargetIndex === -1) {
|
||||
throw new Error("Could not map edited message to persisted conversation history")
|
||||
}
|
||||
@@ -1059,7 +1039,9 @@ export class Controller {
|
||||
const initialMessages = sdkMessages.slice(0, sdkTargetIndex) as Parameters<
|
||||
VscodeSessionHost["start"]
|
||||
>[0]["initialMessages"]
|
||||
const firstUserMessage = sdkMessages.find((message) => message.role === "user" && extractSdkUserText(message))
|
||||
const firstUserMessage = sdkMessages.find(
|
||||
(message) => message.role === "user" && !!extractSdkUserText(message) && !isSyntheticSdkUserMessage(message),
|
||||
)
|
||||
const historyTitle =
|
||||
userOrdinal === 1 ? editedText : extractSdkUserText(firstUserMessage ?? {}) || clineMessages[0]?.text || editedText
|
||||
const cwd = await this.getWorkspaceRoot()
|
||||
|
||||
@@ -49,7 +49,7 @@ You are in Plan mode. Your role is to explore, analyze, and plan -- not to execu
|
||||
- Do NOT edit files, write code, run destructive commands, or make any changes
|
||||
- Do NOT implement anything -- focus on understanding and alignment first
|
||||
|
||||
When the user aligns on a plan and is ready to proceed, use the switch_to_act_mode tool to switch to act mode and begin implementation.`
|
||||
Once the user has reviewed your plan and explicitly approved it in a follow-up message, use the switch_to_act_mode tool to switch to act mode and begin implementation. Calling switch_to_act_mode immediately starts execution, so never call it in the same turn you present a plan and never treat the original task request as approval -- end your turn after presenting the plan and wait for the user's response.`
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
|
||||
@@ -83,6 +83,40 @@ describe("SdkFollowupCoordinator", () => {
|
||||
)
|
||||
})
|
||||
|
||||
it("waits for an in-flight mode rebuild before deciding whether to resume a displayed task", async () => {
|
||||
const task = makeTask("task-1")
|
||||
const rebuiltSession = makeActiveSession({ isRunning: true })
|
||||
let resolveRebuild: () => void = () => {}
|
||||
const waitForPendingModeRebuild = vi.fn(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
resolveRebuild = resolve
|
||||
}),
|
||||
)
|
||||
const { coordinator, options } = makeCoordinator({ task, waitForPendingModeRebuild })
|
||||
options.sessions.getActiveSession.mockReturnValueOnce(undefined).mockReturnValue(rebuiltSession)
|
||||
|
||||
const sendPromise = coordinator.askResponse("sent during rebuild")
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
|
||||
expect(waitForPendingModeRebuild).toHaveBeenCalledOnce()
|
||||
expect(options.sessions.startNewSession).not.toHaveBeenCalled()
|
||||
expect(options.sessions.fireAndForgetSend).not.toHaveBeenCalled()
|
||||
|
||||
resolveRebuild()
|
||||
await sendPromise
|
||||
|
||||
expect(options.sessions.startNewSession).not.toHaveBeenCalled()
|
||||
expect(options.sessions.fireAndForgetSend).toHaveBeenCalledWith(
|
||||
rebuiltSession.sdkHost,
|
||||
"session-123",
|
||||
"resolved: sent during rebuild",
|
||||
undefined,
|
||||
undefined,
|
||||
"queue",
|
||||
)
|
||||
})
|
||||
|
||||
it("queues a message response after a pending tool approval is rejected", async () => {
|
||||
const activeSession = makeActiveSession({ isRunning: true })
|
||||
const { coordinator, options } = makeCoordinator({ activeSession })
|
||||
@@ -151,6 +185,42 @@ describe("SdkFollowupCoordinator", () => {
|
||||
expect(options.postStateToWebview).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it("echoes attachments on an attachment-only resume", async () => {
|
||||
const task = makeTask("task-1")
|
||||
const historyItem = {
|
||||
id: "task-1",
|
||||
ts: 1,
|
||||
task: "Original task",
|
||||
tokensIn: 0,
|
||||
tokensOut: 0,
|
||||
totalCost: 0,
|
||||
cwdOnTaskInitialization: "/task-cwd",
|
||||
}
|
||||
const { coordinator, options } = makeCoordinator({ task, historyItem })
|
||||
|
||||
await coordinator.askResponse(undefined, ["data:image/png;base64,abc"], [])
|
||||
|
||||
// The attachment-only resume shows a visible bubble for the attachment,
|
||||
// keeping the transcript aligned with the SDK message that carries it.
|
||||
expect(options.messages.appendAndEmit).toHaveBeenCalledWith(
|
||||
[
|
||||
expect.objectContaining({
|
||||
say: "user_feedback",
|
||||
text: "",
|
||||
images: ["data:image/png;base64,abc"],
|
||||
}),
|
||||
],
|
||||
expect.anything(),
|
||||
)
|
||||
expect(options.sessions.fireAndForgetSend).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
"resumed-session",
|
||||
expect.stringContaining("[TASK RESUMPTION]"),
|
||||
["data:image/png;base64,abc"],
|
||||
[],
|
||||
)
|
||||
})
|
||||
|
||||
it("emits auth errors when resume fails because the cline provider is unauthenticated", async () => {
|
||||
const task = makeTask("task-1")
|
||||
const { coordinator, options } = makeCoordinator({ task })
|
||||
@@ -160,6 +230,22 @@ describe("SdkFollowupCoordinator", () => {
|
||||
await coordinator.askResponse("continue")
|
||||
|
||||
expect(options.emitClineAuthError).toHaveBeenCalledOnce()
|
||||
expect(options.onResumeFailed).toHaveBeenCalledOnce()
|
||||
expect(options.postStateToWebview).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it("reports resume failures so the turn phase does not stay stuck in streaming", async () => {
|
||||
const task = makeTask("task-1")
|
||||
const { coordinator, options } = makeCoordinator({ task })
|
||||
options.sessions.startNewSession.mockRejectedValue(new Error("session start failed"))
|
||||
|
||||
await coordinator.askResponse("continue")
|
||||
|
||||
expect(options.onResumeFailed).toHaveBeenCalledOnce()
|
||||
expect(options.messages.emitSessionEvents).toHaveBeenCalledWith(
|
||||
[expect.objectContaining({ say: "error", text: expect.stringContaining("session start failed") })],
|
||||
{ type: "status", payload: { sessionId: "task-1", status: "error" } },
|
||||
)
|
||||
expect(options.postStateToWebview).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
@@ -213,6 +299,8 @@ function makeCoordinator(input: Partial<MakeCoordinatorInput> = {}) {
|
||||
emitClineAuthError: vi.fn(),
|
||||
resetMessageTranslator: vi.fn(),
|
||||
postStateToWebview: vi.fn().mockResolvedValue(undefined),
|
||||
waitForPendingModeRebuild: input.waitForPendingModeRebuild ?? vi.fn().mockResolvedValue(undefined),
|
||||
onResumeFailed: vi.fn(),
|
||||
} as unknown as SdkFollowupCoordinatorOptions & {
|
||||
interactions: SdkFollowupCoordinatorOptions["interactions"] & {
|
||||
resolvePendingToolApproval: ReturnType<typeof vi.fn>
|
||||
@@ -243,6 +331,7 @@ function makeCoordinator(input: Partial<MakeCoordinatorInput> = {}) {
|
||||
emitClineAuthError: ReturnType<typeof vi.fn>
|
||||
resetMessageTranslator: ReturnType<typeof vi.fn>
|
||||
postStateToWebview: ReturnType<typeof vi.fn>
|
||||
onResumeFailed: ReturnType<typeof vi.fn>
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -265,6 +354,7 @@ interface MakeCoordinatorInput {
|
||||
cwdOnTaskInitialization?: string
|
||||
}
|
||||
mode: "act" | "plan"
|
||||
waitForPendingModeRebuild: () => Promise<void>
|
||||
}
|
||||
|
||||
function makeActiveSession(input: { isRunning?: boolean } = {}) {
|
||||
|
||||
@@ -34,6 +34,14 @@ export interface SdkFollowupCoordinatorOptions {
|
||||
emitClineAuthError: () => void
|
||||
resetMessageTranslator: () => void
|
||||
postStateToWebview: () => Promise<void>
|
||||
/** Resolves once no plan/act mode rebuild is in flight. */
|
||||
waitForPendingModeRebuild: () => Promise<void>
|
||||
/**
|
||||
* Called when resuming a task fails. askResponse moved the turn phase to
|
||||
* streaming before delegating here, so the failure must move it to a
|
||||
* terminal phase or the footer stays stuck on Thinking/Cancel.
|
||||
*/
|
||||
onResumeFailed: () => void
|
||||
}
|
||||
|
||||
export class SdkFollowupCoordinator {
|
||||
@@ -48,9 +56,18 @@ export class SdkFollowupCoordinator {
|
||||
return
|
||||
}
|
||||
|
||||
const activeSession = this.options.sessions.getActiveSession()
|
||||
let activeSession = this.options.sessions.getActiveSession()
|
||||
const task = this.options.getTask()
|
||||
if ((!activeSession || !activeSession.isRunning) && task) {
|
||||
if (!activeSession?.isRunning && task) {
|
||||
// A mode rebuild clears the active session while the old stop is
|
||||
// awaited and only marks the replacement running after the
|
||||
// continuation send. Resuming in that window would start a parallel
|
||||
// session that the rebuild then kills, losing this message. Wait for
|
||||
// the rebuild and re-evaluate against the rebuilt session.
|
||||
await this.options.waitForPendingModeRebuild()
|
||||
activeSession = this.options.sessions.getActiveSession()
|
||||
}
|
||||
if (!activeSession?.isRunning && task) {
|
||||
Logger.log(`[SdkController] askResponse: No active session but task exists (${task.taskId}), resuming...`)
|
||||
await this.tryResumeSessionFromTask(task.taskId, prompt, images, files)
|
||||
return
|
||||
@@ -109,6 +126,7 @@ export class SdkFollowupCoordinator {
|
||||
{ type: "status", payload: { sessionId: taskId, status: "error" } },
|
||||
)
|
||||
}
|
||||
this.options.onResumeFailed()
|
||||
await this.options.postStateToWebview()
|
||||
}
|
||||
}
|
||||
@@ -149,8 +167,13 @@ export class SdkFollowupCoordinator {
|
||||
await this.options.taskHistory.updateTaskHistoryItem(historyItem)
|
||||
}
|
||||
|
||||
if (prompt?.trim()) {
|
||||
this.emitUserFeedback(startResult.sessionId, prompt)
|
||||
// Echo whenever the user supplied content, including attachment-only
|
||||
// resumes, and include the attachments in the bubble. This also keeps the
|
||||
// visible transcript aligned with SDK history for edit/regenerate ordinal
|
||||
// mapping: a resumption prompt carrying user attachments is counted as a
|
||||
// visible user message, a bare resumption prompt is not.
|
||||
if (prompt?.trim() || images?.length || files?.length) {
|
||||
this.emitUserFeedback(startResult.sessionId, prompt, images, files)
|
||||
}
|
||||
|
||||
await this.options.postStateToWebview()
|
||||
|
||||
@@ -21,7 +21,7 @@ describe("SdkModeCoordinator", () => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it("applies a queued switch_to_act_mode change", async () => {
|
||||
it("applies a queued switch_to_act_mode change and auto-continues the task", async () => {
|
||||
const activeSession = makeActiveSession()
|
||||
const { coordinator, state, options } = makeCoordinator({ activeSession })
|
||||
|
||||
@@ -32,8 +32,15 @@ describe("SdkModeCoordinator", () => {
|
||||
|
||||
expect(coordinator.hasPendingModeChange()).toBe(false)
|
||||
expect(state.mode).toBe("act")
|
||||
expect(options.sessions.setRunning).not.toHaveBeenCalledWith(true)
|
||||
expect(options.sessions.fireAndForgetSend).not.toHaveBeenCalled()
|
||||
expect(options.sessions.setRunning).toHaveBeenCalledWith(true)
|
||||
expect(options.onAutoContinueStarting).toHaveBeenCalledOnce()
|
||||
expect(options.sessions.fireAndForgetSend).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
"new-session",
|
||||
"The user approved switching to act mode. Continue with the approved plan now.",
|
||||
undefined,
|
||||
undefined,
|
||||
)
|
||||
expect(options.postStateToWebview).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
@@ -82,13 +89,211 @@ describe("SdkModeCoordinator", () => {
|
||||
expect(options.postStateToWebview).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it("does not auto-continue when togglePlanActMode switches plan -> act on an active session", async () => {
|
||||
it("auto-continues a plan -> act toggle when the agent is idle after presenting its plan", async () => {
|
||||
const activeSession = makeActiveSession()
|
||||
const task = makeTask("old-session")
|
||||
const { coordinator, options, state } = makeCoordinator({
|
||||
activeSession,
|
||||
task,
|
||||
mode: "plan",
|
||||
turnPhase: "awaiting_followup",
|
||||
})
|
||||
|
||||
// No typed input was consumed, so the webview should not clear it.
|
||||
await expect(coordinator.togglePlanActMode("act")).resolves.toBe(false)
|
||||
|
||||
expect(state.mode).toBe("act")
|
||||
expect(options.sessions.setRunning).toHaveBeenCalledWith(true)
|
||||
expect(options.onAutoContinueStarting).toHaveBeenCalledOnce()
|
||||
expect(options.sessions.fireAndForgetSend).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
"new-session",
|
||||
"The user approved switching to act mode. Continue with the approved plan now.",
|
||||
undefined,
|
||||
undefined,
|
||||
)
|
||||
// Canned prompt is not echoed as a user message.
|
||||
expect(options.messages.appendAndEmit).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("submits typed chatContent as the continuation when toggling plan -> act on a presented plan", async () => {
|
||||
const activeSession = makeActiveSession()
|
||||
const task = makeTask("old-session")
|
||||
const { coordinator, options } = makeCoordinator({
|
||||
activeSession,
|
||||
task,
|
||||
mode: "plan",
|
||||
turnPhase: "awaiting_followup",
|
||||
})
|
||||
|
||||
// Typed input was consumed, so the webview should clear it.
|
||||
await expect(
|
||||
coordinator.togglePlanActMode("act", {
|
||||
message: " go ahead and implement step 1 ",
|
||||
images: [],
|
||||
files: [],
|
||||
}),
|
||||
).resolves.toBe(true)
|
||||
|
||||
expect(options.sessions.fireAndForgetSend).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
"new-session",
|
||||
"go ahead and implement step 1",
|
||||
undefined,
|
||||
undefined,
|
||||
)
|
||||
expect(options.messages.appendAndEmit).toHaveBeenCalledWith(
|
||||
[expect.objectContaining({ say: "user_feedback", text: "go ahead and implement step 1" })],
|
||||
expect.anything(),
|
||||
)
|
||||
})
|
||||
|
||||
it("forwards attachments alongside the typed message when auto-continuing", async () => {
|
||||
const activeSession = makeActiveSession()
|
||||
const task = makeTask("old-session")
|
||||
const { coordinator, options } = makeCoordinator({
|
||||
activeSession,
|
||||
task,
|
||||
mode: "plan",
|
||||
turnPhase: "awaiting_followup",
|
||||
})
|
||||
|
||||
await expect(
|
||||
coordinator.togglePlanActMode("act", {
|
||||
message: "use this screenshot",
|
||||
images: ["data:image/png;base64,abc"],
|
||||
files: ["/tmp/notes.md"],
|
||||
}),
|
||||
).resolves.toBe(true)
|
||||
|
||||
expect(options.sessions.fireAndForgetSend).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
"new-session",
|
||||
"use this screenshot",
|
||||
["data:image/png;base64,abc"],
|
||||
["/tmp/notes.md"],
|
||||
)
|
||||
expect(options.messages.appendAndEmit).toHaveBeenCalledWith(
|
||||
[
|
||||
expect.objectContaining({
|
||||
say: "user_feedback",
|
||||
text: "use this screenshot",
|
||||
images: ["data:image/png;base64,abc"],
|
||||
files: ["/tmp/notes.md"],
|
||||
}),
|
||||
],
|
||||
expect.anything(),
|
||||
)
|
||||
})
|
||||
|
||||
it("consumes attachment-only chatContent and sends it with the canned prompt", async () => {
|
||||
const activeSession = makeActiveSession()
|
||||
const task = makeTask("old-session")
|
||||
const { coordinator, options } = makeCoordinator({
|
||||
activeSession,
|
||||
task,
|
||||
mode: "plan",
|
||||
turnPhase: "awaiting_followup",
|
||||
})
|
||||
|
||||
// Attachments alone count as consumed content, so the webview clears them.
|
||||
await expect(
|
||||
coordinator.togglePlanActMode("act", {
|
||||
message: undefined,
|
||||
images: ["data:image/png;base64,abc"],
|
||||
files: [],
|
||||
}),
|
||||
).resolves.toBe(true)
|
||||
|
||||
expect(options.sessions.fireAndForgetSend).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
"new-session",
|
||||
"The user approved switching to act mode. Continue with the approved plan now.",
|
||||
["data:image/png;base64,abc"],
|
||||
undefined,
|
||||
)
|
||||
expect(options.messages.appendAndEmit).toHaveBeenCalledWith(
|
||||
[
|
||||
expect.objectContaining({
|
||||
say: "user_feedback",
|
||||
text: "",
|
||||
images: ["data:image/png;base64,abc"],
|
||||
}),
|
||||
],
|
||||
expect.anything(),
|
||||
)
|
||||
})
|
||||
|
||||
it("resets the running state and reports an error phase when the continuation send setup fails", async () => {
|
||||
const activeSession = makeActiveSession()
|
||||
const task = makeTask("old-session")
|
||||
const { coordinator, options, state } = makeCoordinator({
|
||||
activeSession,
|
||||
task,
|
||||
mode: "plan",
|
||||
turnPhase: "awaiting_followup",
|
||||
})
|
||||
options.resolveContextMentions.mockRejectedValueOnce(new Error("mention resolution failed"))
|
||||
|
||||
// The send never happened, so the webview must keep the composer content.
|
||||
await expect(
|
||||
coordinator.togglePlanActMode("act", {
|
||||
message: "see @/broken/path",
|
||||
images: [],
|
||||
files: [],
|
||||
}),
|
||||
).resolves.toBe(false)
|
||||
|
||||
expect(options.onAutoContinueStarting).toHaveBeenCalledOnce()
|
||||
expect(options.sessions.fireAndForgetSend).not.toHaveBeenCalled()
|
||||
// The optimistic running flip is undone and the phase moves to error.
|
||||
expect(options.sessions.setRunning).toHaveBeenLastCalledWith(false)
|
||||
expect(options.onAutoContinueFailed).toHaveBeenCalledOnce()
|
||||
// Mentions resolve before the echo, so the unsent message is never
|
||||
// echoed into the transcript; only the error message is appended.
|
||||
expect(options.messages.appendAndEmit).toHaveBeenCalledOnce()
|
||||
expect(options.messages.appendAndEmit).toHaveBeenCalledWith(
|
||||
[expect.objectContaining({ say: "error" })],
|
||||
expect.anything(),
|
||||
)
|
||||
// The session WAS replaced with act-mode tools before the throw, so the
|
||||
// mode setting must not roll back.
|
||||
expect(state.mode).toBe("act")
|
||||
})
|
||||
|
||||
it("does not auto-continue while a follow-up question is pending", async () => {
|
||||
// handleAskQuestion sets the phase to awaiting_followup but blocks the
|
||||
// turn mid-run, so the session is still flagged running.
|
||||
const activeSession = makeActiveSession({ isRunning: true })
|
||||
const task = makeTask("old-session")
|
||||
const { coordinator, options, state } = makeCoordinator({
|
||||
activeSession,
|
||||
task,
|
||||
mode: "plan",
|
||||
turnPhase: "awaiting_followup",
|
||||
})
|
||||
|
||||
await expect(
|
||||
coordinator.togglePlanActMode("act", {
|
||||
message: "use postgres",
|
||||
images: [],
|
||||
files: [],
|
||||
}),
|
||||
).resolves.toBe(false)
|
||||
|
||||
expect(state.mode).toBe("act")
|
||||
expect(options.sessions.fireAndForgetSend).not.toHaveBeenCalled()
|
||||
expect(options.onAutoContinueStarting).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("does not auto-continue a plan -> act toggle while a turn is running", async () => {
|
||||
const activeSession = makeActiveSession({ isRunning: true })
|
||||
const task = makeTask("old-session")
|
||||
const { coordinator, options, state } = makeCoordinator({
|
||||
activeSession,
|
||||
task,
|
||||
mode: "plan",
|
||||
turnPhase: "streaming",
|
||||
})
|
||||
|
||||
await expect(coordinator.togglePlanActMode("act")).resolves.toBe(false)
|
||||
@@ -98,31 +303,35 @@ describe("SdkModeCoordinator", () => {
|
||||
expect(options.sessions.fireAndForgetSend).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("preserves user-supplied chatContent instead of submitting it during plan -> act toggle", async () => {
|
||||
it("preserves typed chatContent when the agent has not presented a plan", async () => {
|
||||
const activeSession = makeActiveSession()
|
||||
const task = makeTask("old-session")
|
||||
const { coordinator, options } = makeCoordinator({
|
||||
activeSession,
|
||||
task,
|
||||
mode: "plan",
|
||||
turnPhase: "completed",
|
||||
})
|
||||
|
||||
await coordinator.togglePlanActMode("act", {
|
||||
message: " go ahead and implement step 1 ",
|
||||
images: [],
|
||||
files: [],
|
||||
})
|
||||
await expect(
|
||||
coordinator.togglePlanActMode("act", {
|
||||
message: " go ahead and implement step 1 ",
|
||||
images: [],
|
||||
files: [],
|
||||
}),
|
||||
).resolves.toBe(false)
|
||||
|
||||
expect(options.sessions.fireAndForgetSend).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("does not auto-continue on act -> plan toggle even when an active session exists", async () => {
|
||||
it("does not auto-continue on act -> plan toggle even when the agent is awaiting followup", async () => {
|
||||
const activeSession = makeActiveSession()
|
||||
const task = makeTask("old-session")
|
||||
const { coordinator, options, state } = makeCoordinator({
|
||||
activeSession,
|
||||
task,
|
||||
mode: "act",
|
||||
turnPhase: "awaiting_followup",
|
||||
})
|
||||
|
||||
await coordinator.togglePlanActMode("plan", {
|
||||
@@ -135,9 +344,101 @@ describe("SdkModeCoordinator", () => {
|
||||
expect(options.sessions.fireAndForgetSend).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("does not mark a live continuation as failed when the post-send state post rejects", async () => {
|
||||
const activeSession = makeActiveSession()
|
||||
const task = makeTask("old-session")
|
||||
const { coordinator, options } = makeCoordinator({
|
||||
activeSession,
|
||||
task,
|
||||
mode: "plan",
|
||||
turnPhase: "awaiting_followup",
|
||||
})
|
||||
options.postStateToWebview.mockRejectedValueOnce(new Error("webview gone"))
|
||||
|
||||
// The continuation was already handed to the session, so the composer
|
||||
// content counts as consumed and the run must not be flagged as failed.
|
||||
await expect(
|
||||
coordinator.togglePlanActMode("act", {
|
||||
message: "go ahead",
|
||||
images: [],
|
||||
files: [],
|
||||
}),
|
||||
).resolves.toBe(true)
|
||||
|
||||
expect(options.sessions.fireAndForgetSend).toHaveBeenCalledOnce()
|
||||
expect(options.sessions.setRunning).not.toHaveBeenCalledWith(false)
|
||||
expect(options.onAutoContinueFailed).not.toHaveBeenCalled()
|
||||
// Only the user_feedback echo was emitted, no mode-switch error message.
|
||||
expect(options.messages.appendAndEmit).toHaveBeenCalledOnce()
|
||||
expect(options.messages.appendAndEmit).toHaveBeenCalledWith(
|
||||
[expect.objectContaining({ say: "user_feedback", text: "go ahead" })],
|
||||
expect.anything(),
|
||||
)
|
||||
})
|
||||
|
||||
it("preserves composer content when the rebuild aborts on a cline auth error", async () => {
|
||||
const activeSession = makeActiveSession()
|
||||
const task = makeTask("old-session")
|
||||
const { coordinator, options, state } = makeCoordinator({
|
||||
activeSession,
|
||||
task,
|
||||
mode: "plan",
|
||||
turnPhase: "awaiting_followup",
|
||||
config: {
|
||||
providerId: "cline",
|
||||
modelId: "cline-model",
|
||||
apiKey: undefined,
|
||||
},
|
||||
})
|
||||
|
||||
// The auth guard returns before the continuation is echoed or sent, so
|
||||
// the webview must not clear the typed message or attachments.
|
||||
await expect(
|
||||
coordinator.togglePlanActMode("act", {
|
||||
message: "go ahead but skip step 3",
|
||||
images: ["data:image/png;base64,abc"],
|
||||
files: [],
|
||||
}),
|
||||
).resolves.toBe(false)
|
||||
|
||||
expect(options.emitClineAuthError).toHaveBeenCalledOnce()
|
||||
expect(options.sessions.fireAndForgetSend).not.toHaveBeenCalled()
|
||||
expect(options.messages.appendAndEmit).not.toHaveBeenCalled()
|
||||
// The old plan session is still active, so the mode setting rolls back.
|
||||
expect(state.mode).toBe("plan")
|
||||
})
|
||||
|
||||
it("rolls back the mode when the rebuild fails before the session is replaced", async () => {
|
||||
const activeSession = makeActiveSession()
|
||||
const task = makeTask("old-session")
|
||||
const { coordinator, options, state } = makeCoordinator({
|
||||
activeSession,
|
||||
task,
|
||||
mode: "plan",
|
||||
turnPhase: "awaiting_followup",
|
||||
})
|
||||
options.loadInitialMessages.mockRejectedValueOnce(new Error("disk read failed"))
|
||||
|
||||
await expect(
|
||||
coordinator.togglePlanActMode("act", {
|
||||
message: "go ahead",
|
||||
images: [],
|
||||
files: [],
|
||||
}),
|
||||
).resolves.toBe(false)
|
||||
|
||||
expect(state.mode).toBe("plan")
|
||||
expect(options.sessions.fireAndForgetSend).not.toHaveBeenCalled()
|
||||
expect(options.onAutoContinueFailed).not.toHaveBeenCalled()
|
||||
expect(options.messages.appendAndEmit).toHaveBeenCalledWith(
|
||||
[expect.objectContaining({ say: "error" })],
|
||||
expect.anything(),
|
||||
)
|
||||
})
|
||||
|
||||
it("emits an auth error and skips replacement when the target cline provider has no token", async () => {
|
||||
const activeSession = makeActiveSession()
|
||||
const { coordinator, options } = makeCoordinator({
|
||||
const { coordinator, options, state } = makeCoordinator({
|
||||
activeSession,
|
||||
config: {
|
||||
providerId: "cline",
|
||||
@@ -151,6 +452,7 @@ describe("SdkModeCoordinator", () => {
|
||||
expect(options.emitClineAuthError).toHaveBeenCalledOnce()
|
||||
expect(options.sessions.replaceActiveSession).not.toHaveBeenCalled()
|
||||
expect(options.postStateToWebview).toHaveBeenCalledOnce()
|
||||
expect(state.mode).toBe("plan")
|
||||
})
|
||||
|
||||
it("cancels and finalizes a running turn before rebuilding for mode change", async () => {
|
||||
@@ -213,6 +515,10 @@ function makeCoordinator(input: Partial<MakeCoordinatorInput> = {}) {
|
||||
emitClineAuthError: vi.fn(),
|
||||
resetMessageTranslator: vi.fn(),
|
||||
postStateToWebview: vi.fn().mockResolvedValue(undefined),
|
||||
getTurnPhase: vi.fn(() => input.turnPhase ?? "idle"),
|
||||
resolveContextMentions: vi.fn(async (text: string) => text),
|
||||
onAutoContinueStarting: vi.fn(),
|
||||
onAutoContinueFailed: vi.fn(),
|
||||
} as unknown as SdkModeCoordinatorOptions & {
|
||||
stateManager: StateManager & {
|
||||
getGlobalSettingsKey: ReturnType<typeof vi.fn>
|
||||
@@ -243,6 +549,10 @@ function makeCoordinator(input: Partial<MakeCoordinatorInput> = {}) {
|
||||
emitClineAuthError: ReturnType<typeof vi.fn>
|
||||
resetMessageTranslator: ReturnType<typeof vi.fn>
|
||||
postStateToWebview: ReturnType<typeof vi.fn>
|
||||
getTurnPhase: ReturnType<typeof vi.fn>
|
||||
resolveContextMentions: ReturnType<typeof vi.fn>
|
||||
onAutoContinueStarting: ReturnType<typeof vi.fn>
|
||||
onAutoContinueFailed: ReturnType<typeof vi.fn>
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -261,6 +571,7 @@ interface MakeCoordinatorInput {
|
||||
apiKey: string | undefined
|
||||
}
|
||||
task: ReturnType<typeof makeTask>
|
||||
turnPhase: string
|
||||
}
|
||||
|
||||
function makeActiveSession(input: { isRunning?: boolean } = {}) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { ChatContent } from "@shared/ChatContent"
|
||||
import type { ClineMessage } from "@shared/ExtensionMessage"
|
||||
import type { ClineMessage, TurnPhase } from "@shared/ExtensionMessage"
|
||||
import type { Mode } from "@shared/storage/types"
|
||||
import type { StateManager } from "@/core/storage/StateManager"
|
||||
import { Logger } from "@/shared/services/Logger"
|
||||
@@ -15,6 +15,8 @@ type StartInput = Parameters<VscodeSessionHost["start"]>[0]
|
||||
type InitialMessages = StartInput["initialMessages"]
|
||||
type SessionConfig = Awaited<ReturnType<SdkSessionConfigBuilder["build"]>>
|
||||
|
||||
export const ACT_MODE_CONTINUATION_PROMPT = "The user approved switching to act mode. Continue with the approved plan now."
|
||||
|
||||
export interface SdkModeCoordinatorOptions {
|
||||
stateManager: StateManager
|
||||
sessions: SdkSessionLifecycle
|
||||
@@ -28,13 +30,48 @@ export interface SdkModeCoordinatorOptions {
|
||||
emitClineAuthError: () => void
|
||||
resetMessageTranslator: () => void
|
||||
postStateToWebview: () => Promise<void>
|
||||
/** Authoritative phase of the current turn, from the controller's TurnStateTracker. */
|
||||
getTurnPhase: () => TurnPhase
|
||||
resolveContextMentions: (text: string) => Promise<string>
|
||||
/**
|
||||
* Called right before an auto-continue send kicks off a new turn. Mirrors
|
||||
* initTask/askResponse: moves the turn phase to "streaming" (footer shows
|
||||
* Thinking + Cancel instead of the stale awaiting_followup state) and clears
|
||||
* the previous turn's completion signal.
|
||||
*/
|
||||
onAutoContinueStarting: () => void
|
||||
/**
|
||||
* Called when the rebuild throws after onAutoContinueStarting already flipped
|
||||
* the phase to "streaming" (e.g. resolveContextMentions failed). Moves the
|
||||
* phase to "error" so the footer matches the error message that was emitted,
|
||||
* instead of showing a phantom run.
|
||||
*/
|
||||
onAutoContinueFailed: () => void
|
||||
}
|
||||
|
||||
export class SdkModeCoordinator {
|
||||
private pendingModeChange: Mode | null = null
|
||||
private rebuildInFlight: Promise<void> | undefined
|
||||
|
||||
constructor(private readonly options: SdkModeCoordinatorOptions) {}
|
||||
|
||||
/**
|
||||
* Resolves once no mode rebuild is in flight. While a rebuild runs, the
|
||||
* active session is torn down and replaced (and only marked running after
|
||||
* the continuation send), so concurrent message paths must wait on this
|
||||
* instead of treating the gap as "no session" and resuming a parallel
|
||||
* session that the rebuild would then kill.
|
||||
*/
|
||||
async waitForPendingRebuild(): Promise<void> {
|
||||
while (this.rebuildInFlight) {
|
||||
const current = this.rebuildInFlight
|
||||
await current
|
||||
if (this.rebuildInFlight === current) {
|
||||
this.rebuildInFlight = undefined
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
queueSwitchToActMode(): void {
|
||||
this.pendingModeChange = "act"
|
||||
}
|
||||
@@ -50,11 +87,9 @@ export class SdkModeCoordinator {
|
||||
}
|
||||
this.pendingModeChange = null
|
||||
Logger.log(`[SdkController] applyPendingModeChange: switching to ${target}`)
|
||||
// Match CLI interactive behavior: switch_to_act_mode changes the active
|
||||
// session configuration after the current turn stops, but it does not submit
|
||||
// a follow-up prompt or continue executing act-mode tools on its own. The
|
||||
// user must explicitly send the next message in Act mode.
|
||||
await this.rebuildSessionForMode(target)
|
||||
// The tool result told the model to proceed with the plan, so rebuild with
|
||||
// act-mode tools and auto-continue rather than waiting for another user message.
|
||||
await this.rebuildSessionForMode(target, { autoContinue: target === "act" })
|
||||
}
|
||||
|
||||
async toggleActModeForYoloMode(): Promise<boolean> {
|
||||
@@ -67,19 +102,34 @@ export class SdkModeCoordinator {
|
||||
return true
|
||||
}
|
||||
|
||||
async togglePlanActMode(modeToSwitchTo: Mode, _chatContent?: ChatContent): Promise<boolean> {
|
||||
async togglePlanActMode(modeToSwitchTo: Mode, chatContent?: ChatContent): Promise<boolean> {
|
||||
const currentMode = this.options.stateManager.getGlobalSettingsKey("mode")
|
||||
if (currentMode === modeToSwitchTo) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (this.options.sessions.getActiveSession()) {
|
||||
// Match CLI interactive behavior: changing Plan/Act mode updates the
|
||||
// session configuration and preserves any typed input, but it does not
|
||||
// submit that input or auto-continue the agent. This prevents the extension
|
||||
// from entering Act mode and executing tools without an explicit user send.
|
||||
await this.rebuildSessionForMode(modeToSwitchTo)
|
||||
return false
|
||||
const activeSession = this.options.sessions.getActiveSession()
|
||||
if (activeSession) {
|
||||
// A plan -> act toggle while the agent is idle after presenting its plan
|
||||
// (awaiting_followup) is the user acting on that plan, so continue
|
||||
// automatically. Any other state only updates the session configuration
|
||||
// and waits for an explicit send. A pending ask_question also reports
|
||||
// awaiting_followup but blocks the turn mid-run, so isRunning stays
|
||||
// true and it cannot reach this branch.
|
||||
const planPresented = !activeSession.isRunning && this.options.getTurnPhase() === "awaiting_followup"
|
||||
const autoContinue = modeToSwitchTo === "act" && planPresented
|
||||
const userPrompt = chatContent?.message?.trim() || undefined
|
||||
const userImages = chatContent?.images?.length ? chatContent.images : undefined
|
||||
const userFiles = chatContent?.files?.length ? chatContent.files : undefined
|
||||
const hasUserContent = !!(userPrompt || userImages || userFiles)
|
||||
const continuationSent = await this.rebuildSessionForMode(modeToSwitchTo, {
|
||||
autoContinue,
|
||||
userContinuationPrompt: autoContinue ? userPrompt : undefined,
|
||||
userImages: autoContinue ? userImages : undefined,
|
||||
userFiles: autoContinue ? userFiles : undefined,
|
||||
})
|
||||
// True tells the webview the composer content was consumed, so it clears it.
|
||||
return continuationSent && hasUserContent
|
||||
}
|
||||
|
||||
this.options.stateManager.setGlobalState("mode", modeToSwitchTo)
|
||||
@@ -87,13 +137,47 @@ export class SdkModeCoordinator {
|
||||
return false
|
||||
}
|
||||
|
||||
async rebuildSessionForMode(newMode: Mode): Promise<void> {
|
||||
/**
|
||||
* Returns true only when the auto-continue send was actually handed to the
|
||||
* session, so callers can tell consumed user content apart from rebuilds
|
||||
* that bailed early (auth error, disposed session, thrown rebuild).
|
||||
*/
|
||||
async rebuildSessionForMode(
|
||||
newMode: Mode,
|
||||
options: {
|
||||
autoContinue?: boolean
|
||||
userContinuationPrompt?: string
|
||||
userImages?: string[]
|
||||
userFiles?: string[]
|
||||
} = {},
|
||||
): Promise<boolean> {
|
||||
const operation = this.performRebuildSessionForMode(newMode, options)
|
||||
// Expose the full rebuild (teardown, replacement, continuation send) to
|
||||
// waitForPendingRebuild. Errors are handled inside; the barrier only
|
||||
// tracks completion.
|
||||
this.rebuildInFlight = operation.then(
|
||||
() => undefined,
|
||||
() => undefined,
|
||||
)
|
||||
return operation
|
||||
}
|
||||
|
||||
private async performRebuildSessionForMode(
|
||||
newMode: Mode,
|
||||
options: {
|
||||
autoContinue?: boolean
|
||||
userContinuationPrompt?: string
|
||||
userImages?: string[]
|
||||
userFiles?: string[]
|
||||
},
|
||||
): Promise<boolean> {
|
||||
const previousMode = this.options.stateManager.getGlobalSettingsKey("mode")
|
||||
this.options.stateManager.setGlobalState("mode", newMode)
|
||||
|
||||
const activeSession = this.options.sessions.getActiveSession()
|
||||
if (!activeSession) {
|
||||
await this.options.postStateToWebview()
|
||||
return
|
||||
return false
|
||||
}
|
||||
|
||||
const { sdkHost: oldManager, sessionId: oldSessionId } = activeSession
|
||||
@@ -105,6 +189,9 @@ export class SdkModeCoordinator {
|
||||
await this.cancelRunningTurnForModeChange(oldManager, oldSessionId)
|
||||
}
|
||||
|
||||
let autoContinueStarted = false
|
||||
let continuationSent = false
|
||||
let sessionReplaced = false
|
||||
try {
|
||||
const initialMessages = await this.options.loadInitialMessages(oldManager, oldSessionId)
|
||||
const cwd = await this.options.getWorkspaceRoot()
|
||||
@@ -121,9 +208,12 @@ export class SdkModeCoordinator {
|
||||
Logger.warn(
|
||||
`[SdkController] Mode rebuild: new mode '${newMode}' provider is 'cline' but no auth token - emitting auth error`,
|
||||
)
|
||||
// The session still runs with the old mode's tools, so roll the
|
||||
// setting back to keep the UI toggle coherent with it.
|
||||
this.options.stateManager.setGlobalState("mode", previousMode)
|
||||
this.options.emitClineAuthError()
|
||||
await this.options.postStateToWebview()
|
||||
return
|
||||
return false
|
||||
}
|
||||
|
||||
const startInput = this.options.buildStartSessionInput(config, {
|
||||
@@ -136,9 +226,10 @@ export class SdkModeCoordinator {
|
||||
disposeReason: "modeChange",
|
||||
})
|
||||
if (!rebuildResult) {
|
||||
return
|
||||
return false
|
||||
}
|
||||
|
||||
sessionReplaced = true
|
||||
const { sdkHost, startResult } = rebuildResult
|
||||
const task = this.options.getTask()
|
||||
if (task && task.taskId !== startResult.sessionId) {
|
||||
@@ -149,11 +240,66 @@ export class SdkModeCoordinator {
|
||||
}
|
||||
|
||||
this.options.resetMessageTranslator()
|
||||
if (options.autoContinue) {
|
||||
const userPrompt = options.userContinuationPrompt
|
||||
const userImages = options.userImages
|
||||
const userFiles = options.userFiles
|
||||
// Mirror initTask/askResponse ordering: flip the phase and running flag
|
||||
// before anything is emitted or sent, so no listener ever sees a
|
||||
// user_feedback message while the phase still reads awaiting_followup.
|
||||
autoContinueStarted = true
|
||||
this.options.sessions.setRunning(true)
|
||||
this.options.onAutoContinueStarting()
|
||||
// Resolve mentions before echoing so a resolution failure cannot
|
||||
// leave an echoed-but-never-sent user message in the transcript.
|
||||
const prompt = userPrompt ? await this.options.resolveContextMentions(userPrompt) : ACT_MODE_CONTINUATION_PROMPT
|
||||
if (userPrompt || userImages?.length || userFiles?.length) {
|
||||
const userMessage: ClineMessage = {
|
||||
ts: Date.now(),
|
||||
type: "say",
|
||||
say: "user_feedback",
|
||||
text: userPrompt ?? "",
|
||||
images: userImages,
|
||||
files: userFiles,
|
||||
partial: false,
|
||||
}
|
||||
this.options.messages.appendAndEmit([userMessage], {
|
||||
type: "status",
|
||||
payload: { sessionId: startResult.sessionId, status: "running" },
|
||||
})
|
||||
}
|
||||
// Without a typed message the canned prompt drives the continuation; it
|
||||
// is intentionally not echoed as user_feedback, so no synthetic bubble
|
||||
// shows in chat. Attachments still ride along with the canned prompt.
|
||||
this.options.sessions.fireAndForgetSend(sdkHost, startResult.sessionId, prompt, userImages, userFiles)
|
||||
continuationSent = true
|
||||
}
|
||||
await this.options.postStateToWebview()
|
||||
|
||||
Logger.log(`[SdkController] Session rebuilt for mode ${newMode}: ${oldSessionId} -> ${startResult.sessionId}`)
|
||||
} catch (error) {
|
||||
Logger.error("[SdkController] Failed to rebuild session for mode change:", error)
|
||||
if (!sessionReplaced) {
|
||||
// The old session is still the active one and still has the old
|
||||
// mode's tools; leaving the setting flipped would show a toggle
|
||||
// that disagrees with what the agent can actually do.
|
||||
this.options.stateManager.setGlobalState("mode", previousMode)
|
||||
}
|
||||
if (continuationSent) {
|
||||
// The continuation is already running on the rebuilt session; the
|
||||
// only thing that can throw past the send is the post-rebuild state
|
||||
// post. Marking the live run as failed (or emitting a mode-switch
|
||||
// error) would lie about a turn that is actually in flight.
|
||||
return continuationSent
|
||||
}
|
||||
if (autoContinueStarted) {
|
||||
// The continuation send never happened (resolveContextMentions can
|
||||
// throw after the optimistic flip), so undo the running state and
|
||||
// move the phase to "error", otherwise the footer shows a phantom
|
||||
// run that nothing will ever finish.
|
||||
this.options.sessions.setRunning(false)
|
||||
this.options.onAutoContinueFailed()
|
||||
}
|
||||
const errorMessage: ClineMessage = {
|
||||
ts: Date.now(),
|
||||
type: "say",
|
||||
@@ -167,6 +313,7 @@ export class SdkModeCoordinator {
|
||||
})
|
||||
await this.options.postStateToWebview()
|
||||
}
|
||||
return continuationSent
|
||||
}
|
||||
|
||||
private async cancelRunningTurnForModeChange(oldManager: SdkSessionHost, oldSessionId: string): Promise<void> {
|
||||
|
||||
@@ -33,6 +33,10 @@ describe("SdkSessionConfigBuilder", () => {
|
||||
const planConfig = await builder.build({ cwd: "/workspace", mode: "plan" })
|
||||
const switchTool = planConfig.extraTools?.find((tool) => tool.name === "switch_to_act_mode")
|
||||
expect(switchTool).toBeDefined()
|
||||
// Ends the run cleanly after the tool result so the loop never starts an
|
||||
// iteration that the stop hook would abort (which surfaced in the webview
|
||||
// as "API Request Cancelled").
|
||||
expect(switchTool?.lifecycle?.completesRun).toBe(true)
|
||||
expect(await switchTool?.execute({}, {} as never)).toBe(
|
||||
"You successfully switched to act mode, proceed with the plan. You now have access to editing files and running commands. (The switch_to_act_mode tool is only available in plan mode.)",
|
||||
)
|
||||
|
||||
@@ -46,7 +46,8 @@ export class SdkSessionConfigBuilder {
|
||||
return createTool({
|
||||
name: "switch_to_act_mode",
|
||||
description:
|
||||
"Switch from plan mode to act mode. Call this after the user has confirmed they want to proceed with the plan. Do not call this proactively or before the user has agreed.",
|
||||
"Switch from plan mode to act mode. Switching to act mode immediately starts executing the plan, so only call this after the user has explicitly approved the plan in a message sent AFTER you presented it (e.g. 'looks good', 'go ahead', 'switch to act mode'). " +
|
||||
"Never call this in the same turn you present a plan, never call it proactively, and never treat the original task request as approval.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {},
|
||||
@@ -54,6 +55,13 @@ export class SdkSessionConfigBuilder {
|
||||
timeoutMs: 5000,
|
||||
retryable: false,
|
||||
maxRetries: 0,
|
||||
// End the run cleanly right after the tool result instead of letting the
|
||||
// loop start another iteration that the beforeModel stop hook would abort.
|
||||
// An aborted run leaves a dangling api_req_started spinner behind, which the
|
||||
// webview renders as "API Request Cancelled".
|
||||
lifecycle: {
|
||||
completesRun: true,
|
||||
},
|
||||
execute: async () => {
|
||||
const currentMode = this.options.stateManager.getGlobalSettingsKey("mode")
|
||||
if (currentMode === "act") {
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest"
|
||||
import { isAbortError, SdkSessionLifecycle } from "./sdk-session-lifecycle"
|
||||
|
||||
type StartInput = Parameters<SdkSessionLifecycle["startNewSession"]>[0]
|
||||
type SendHost = Parameters<SdkSessionLifecycle["fireAndForgetSend"]>[0]
|
||||
|
||||
const mockCreateSessionHost = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock("@/core/storage/StateManager", () => ({
|
||||
@@ -177,6 +180,158 @@ describe("SdkSessionLifecycle", () => {
|
||||
expect(lifecycle.getActiveSession()?.isRunning).toBe(false)
|
||||
})
|
||||
|
||||
it("skips completion bookkeeping when the session was replaced before the send settled", async () => {
|
||||
const onSendComplete = vi.fn()
|
||||
let resolveSend: () => void = () => {}
|
||||
const send = vi.fn(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
resolveSend = resolve
|
||||
}),
|
||||
)
|
||||
const sdkHost = makeSdkHost({
|
||||
start: vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ sessionId: "plan-session" })
|
||||
.mockResolvedValueOnce({ sessionId: "plan-session" }),
|
||||
send,
|
||||
})
|
||||
mockCreateSessionHost.mockResolvedValueOnce(sdkHost)
|
||||
const lifecycle = makeLifecycle({ onSendComplete })
|
||||
await lifecycle.startNewSession({} as StartInput)
|
||||
|
||||
lifecycle.fireAndForgetSend(sdkHost as unknown as SendHost, "plan-session", "make a plan")
|
||||
|
||||
// A mode-change rebuild replaces the session, reusing the SAME sessionId,
|
||||
// and starts an auto-continued turn on it.
|
||||
await lifecycle.replaceActiveSession({
|
||||
startInput: { config: {} } as unknown as StartInput,
|
||||
disposeReason: "modeChange",
|
||||
})
|
||||
lifecycle.setRunning(true)
|
||||
|
||||
// The old send settles only now; its bookkeeping must not touch the successor.
|
||||
resolveSend()
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
|
||||
expect(onSendComplete).not.toHaveBeenCalled()
|
||||
expect(lifecycle.getActiveSession()?.isRunning).toBe(true)
|
||||
})
|
||||
|
||||
it("skips error bookkeeping when the session was replaced before the send failed", async () => {
|
||||
const onSendError = vi.fn()
|
||||
let rejectSend: (error: Error) => void = () => {}
|
||||
const send = vi.fn(
|
||||
() =>
|
||||
new Promise<void>((_resolve, reject) => {
|
||||
rejectSend = reject
|
||||
}),
|
||||
)
|
||||
const sdkHost = makeSdkHost({
|
||||
start: vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ sessionId: "plan-session" })
|
||||
.mockResolvedValueOnce({ sessionId: "plan-session" }),
|
||||
send,
|
||||
})
|
||||
mockCreateSessionHost.mockResolvedValueOnce(sdkHost)
|
||||
const lifecycle = makeLifecycle({ onSendError })
|
||||
await lifecycle.startNewSession({} as StartInput)
|
||||
|
||||
lifecycle.fireAndForgetSend(sdkHost as unknown as SendHost, "plan-session", "make a plan")
|
||||
|
||||
await lifecycle.replaceActiveSession({
|
||||
startInput: { config: {} } as unknown as StartInput,
|
||||
disposeReason: "modeChange",
|
||||
})
|
||||
lifecycle.setRunning(true)
|
||||
|
||||
rejectSend(new Error("boom"))
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
|
||||
expect(onSendError).not.toHaveBeenCalled()
|
||||
expect(lifecycle.getActiveSession()?.isRunning).toBe(true)
|
||||
})
|
||||
|
||||
it("completes the old session stop before starting a same-id replacement", async () => {
|
||||
let resolveStop: () => void = () => {}
|
||||
const stop = vi.fn(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
resolveStop = resolve
|
||||
}),
|
||||
)
|
||||
const start = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ sessionId: "plan-session" })
|
||||
.mockResolvedValueOnce({ sessionId: "plan-session" })
|
||||
const sdkHost = makeSdkHost({ start, stop })
|
||||
mockCreateSessionHost.mockResolvedValueOnce(sdkHost)
|
||||
const lifecycle = makeLifecycle()
|
||||
await lifecycle.startNewSession({} as StartInput)
|
||||
|
||||
const replacePromise = lifecycle.replaceActiveSession({
|
||||
startInput: { config: { sessionId: "plan-session" } } as unknown as StartInput,
|
||||
disposeReason: "modeChange",
|
||||
})
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
|
||||
// Core cleanup deletes by sessionId, so the same-id replacement must not
|
||||
// start while the old stop is still in flight.
|
||||
expect(start).toHaveBeenCalledTimes(1)
|
||||
|
||||
resolveStop()
|
||||
const result = await replacePromise
|
||||
|
||||
expect(start).toHaveBeenCalledTimes(2)
|
||||
expect(result?.startResult.sessionId).toBe("plan-session")
|
||||
})
|
||||
|
||||
it("waits for a fire-and-forget stop before resuming the same sessionId", async () => {
|
||||
let resolveStop: () => void = () => {}
|
||||
const stop = vi.fn(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
resolveStop = resolve
|
||||
}),
|
||||
)
|
||||
const start = vi.fn().mockResolvedValueOnce({ sessionId: "task-1" }).mockResolvedValueOnce({ sessionId: "task-1" })
|
||||
const sdkHost = makeSdkHost({ start, stop })
|
||||
mockCreateSessionHost.mockResolvedValueOnce(sdkHost)
|
||||
const lifecycle = makeLifecycle()
|
||||
await lifecycle.startNewSession({} as StartInput)
|
||||
|
||||
// The follow-up resume path ends the idle session without awaiting the
|
||||
// stop, then starts a new session reusing the taskId as the sessionId.
|
||||
await lifecycle.endActiveSession("askResponse")
|
||||
const resumePromise = lifecycle.startNewSession({ config: { sessionId: "task-1" } } as unknown as StartInput)
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
|
||||
expect(start).toHaveBeenCalledTimes(1)
|
||||
|
||||
resolveStop()
|
||||
const result = await resumePromise
|
||||
|
||||
expect(start).toHaveBeenCalledTimes(2)
|
||||
expect(result.startResult.sessionId).toBe("task-1")
|
||||
})
|
||||
|
||||
it("starts a fresh-id session without waiting for an unrelated hung stop", async () => {
|
||||
const stop = vi.fn(() => new Promise<void>(() => {}))
|
||||
const start = vi.fn().mockResolvedValueOnce({ sessionId: "task-1" }).mockResolvedValueOnce({ sessionId: "task-2" })
|
||||
const sdkHost = makeSdkHost({ start, stop })
|
||||
mockCreateSessionHost.mockResolvedValueOnce(sdkHost)
|
||||
const lifecycle = makeLifecycle()
|
||||
await lifecycle.startNewSession({} as StartInput)
|
||||
|
||||
// A brand-new task does not reuse the old sessionId, so it must not be
|
||||
// delayed by the old session's stop.
|
||||
const result = await lifecycle.startNewSession({ config: {} } as unknown as StartInput)
|
||||
|
||||
expect(result.startResult.sessionId).toBe("task-2")
|
||||
expect(stop).toHaveBeenCalledWith("task-1")
|
||||
})
|
||||
|
||||
it("replaces the active session by stopping the old session and reusing the shared host", async () => {
|
||||
const oldUnsubscribe = vi.fn()
|
||||
const sdkHost = makeSdkHost({
|
||||
|
||||
@@ -31,6 +31,15 @@ export class SdkSessionLifecycle {
|
||||
private sharedHost: SdkSessionHost | undefined
|
||||
private sharedHostPromise: Promise<SdkSessionHost> | undefined
|
||||
private sharedHostUnsubscribe: (() => void) | undefined
|
||||
/**
|
||||
* Stops still in flight, keyed by sessionId. Mode/MCP rebuilds and
|
||||
* follow-up resumes reuse the sessionId of the session they replace, and
|
||||
* core cleanup is keyed by sessionId, so a same-id start that overlaps a
|
||||
* stop would be torn down by the old session's late cleanup.
|
||||
* startNewSession consults this map to enforce stop-before-start, the same
|
||||
* sequencing the CLI uses.
|
||||
*/
|
||||
private readonly pendingStops = new Map<string, Promise<void>>()
|
||||
|
||||
constructor(private readonly options: SdkSessionLifecycleOptions) {}
|
||||
|
||||
@@ -60,11 +69,15 @@ export class SdkSessionLifecycle {
|
||||
}
|
||||
|
||||
this.safeUnsubscribe(activeSession, reason)
|
||||
const stopPromise = this.stopSessionWithTimeout(activeSession.sdkHost, activeSession.sessionId, reason, options.timeoutMs)
|
||||
const stopPromise = this.trackSessionStop(activeSession.sdkHost, activeSession.sessionId, reason)
|
||||
if (options.awaitStop) {
|
||||
await stopPromise
|
||||
} else {
|
||||
void stopPromise
|
||||
const timeoutMs = options.timeoutMs ?? 3000
|
||||
const stopped = await this.waitForStop(stopPromise, timeoutMs)
|
||||
if (!stopped) {
|
||||
Logger.warn(
|
||||
`[SdkController] Timed out stopping SDK session ${activeSession.sessionId} after ${timeoutMs}ms (${reason})`,
|
||||
)
|
||||
}
|
||||
}
|
||||
return activeSession
|
||||
}
|
||||
@@ -86,6 +99,15 @@ export class SdkSessionLifecycle {
|
||||
await this.endActiveSession("startNewSession")
|
||||
}
|
||||
|
||||
// Same-id starts must wait for the previous session's stop to finish;
|
||||
// see pendingStops. A fresh id cannot conflict, so it never waits.
|
||||
const requestedSessionId = startInput.config?.sessionId?.trim()
|
||||
const pendingStop = requestedSessionId ? this.pendingStops.get(requestedSessionId) : undefined
|
||||
if (pendingStop) {
|
||||
Logger.log(`[SdkController] Waiting for session ${requestedSessionId} to stop before restarting it`)
|
||||
await pendingStop
|
||||
}
|
||||
|
||||
const autoApprovalSettings = StateManager.get().getGlobalSettingsKey("autoApprovalSettings")
|
||||
const toolPolicies = autoApprovalSettings ? buildToolPolicies(autoApprovalSettings, this.options.mcpHub) : undefined
|
||||
|
||||
@@ -125,6 +147,8 @@ export class SdkSessionLifecycle {
|
||||
|
||||
const { sessionId: oldSessionId } = oldSession
|
||||
|
||||
// No need to await the stop here: callers reuse oldSessionId in the
|
||||
// startInput, and startNewSession waits on the pending stop for it.
|
||||
await this.endActiveSession(options.disposeReason)
|
||||
|
||||
const { startResult, sdkHost } = await this.startNewSession({
|
||||
@@ -174,46 +198,43 @@ export class SdkSessionLifecycle {
|
||||
this.sharedHostUnsubscribe = this.createSafeUnsubscribe(sdkHost.subscribe(this.options.onSessionEvent), "shared-host")
|
||||
}
|
||||
|
||||
private async stopSessionWithTimeout(
|
||||
sdkHost: SdkSessionHost,
|
||||
sessionId: string,
|
||||
reason: string,
|
||||
timeoutMs = 3000,
|
||||
): Promise<void> {
|
||||
/**
|
||||
* Starts the session's stop and records it in pendingStops until it
|
||||
* settles. The returned promise never rejects.
|
||||
*/
|
||||
private trackSessionStop(sdkHost: SdkSessionHost, sessionId: string, reason: string): Promise<void> {
|
||||
const startedAt = Date.now()
|
||||
const stopResult = sdkHost.stop(sessionId).then(
|
||||
() => ({ ok: true as const }),
|
||||
(error) => ({ ok: false as const, error }),
|
||||
)
|
||||
const timeout = new Promise<"timeout">((resolve) => setTimeout(() => resolve("timeout"), timeoutMs))
|
||||
const result = await Promise.race([stopResult, timeout])
|
||||
|
||||
if (result === "timeout") {
|
||||
Logger.warn(`[SdkController] Timed out stopping SDK session ${sessionId} after ${timeoutMs}ms (${reason})`)
|
||||
stopResult.then((finalResult) => {
|
||||
if (finalResult.ok) {
|
||||
Logger.log(
|
||||
`[SdkController] SDK session ${sessionId} eventually stopped after ${Date.now() - startedAt}ms (${reason})`,
|
||||
)
|
||||
} else {
|
||||
Logger.warn(
|
||||
`[SdkController] SDK session ${sessionId} stop failed after timeout (${reason}):`,
|
||||
finalResult.error,
|
||||
)
|
||||
const stopPromise = sdkHost
|
||||
.stop(sessionId)
|
||||
.then(() => {
|
||||
const elapsed = Date.now() - startedAt
|
||||
if (elapsed > 250) {
|
||||
Logger.log(`[SdkController] SDK session ${sessionId} stopped in ${elapsed}ms (${reason})`)
|
||||
}
|
||||
})
|
||||
return
|
||||
}
|
||||
.catch((error: unknown) => {
|
||||
Logger.warn(`[SdkController] Failed to stop SDK session ${sessionId} (${reason}):`, error)
|
||||
})
|
||||
.finally(() => {
|
||||
if (this.pendingStops.get(sessionId) === stopPromise) {
|
||||
this.pendingStops.delete(sessionId)
|
||||
}
|
||||
})
|
||||
this.pendingStops.set(sessionId, stopPromise)
|
||||
return stopPromise
|
||||
}
|
||||
|
||||
const elapsed = Date.now() - startedAt
|
||||
if (result.ok) {
|
||||
if (elapsed > 250) {
|
||||
Logger.log(`[SdkController] SDK session ${sessionId} stopped in ${elapsed}ms (${reason})`)
|
||||
}
|
||||
return
|
||||
private async waitForStop(stopPromise: Promise<void>, timeoutMs: number): Promise<boolean> {
|
||||
let timeoutHandle: ReturnType<typeof setTimeout> | undefined
|
||||
try {
|
||||
const timeout = new Promise<"timeout">((resolve) => {
|
||||
timeoutHandle = setTimeout(() => resolve("timeout"), timeoutMs)
|
||||
})
|
||||
const result = await Promise.race([stopPromise.then(() => "stopped" as const), timeout])
|
||||
return result === "stopped"
|
||||
} finally {
|
||||
clearTimeout(timeoutHandle)
|
||||
}
|
||||
|
||||
Logger.warn(`[SdkController] Failed to stop SDK session ${sessionId} (${reason}):`, result.error)
|
||||
}
|
||||
|
||||
private async getOrCreateSharedHost(): Promise<SdkSessionHost> {
|
||||
@@ -252,6 +273,21 @@ export class SdkSessionLifecycle {
|
||||
files?: string[],
|
||||
delivery?: "queue" | "steer",
|
||||
): void {
|
||||
// Captured by object identity, not sessionId: rebuilds (mode change) reuse
|
||||
// the same sessionId for the replacement session, so only reference
|
||||
// equality can tell this send's session apart from a successor. If the
|
||||
// session was replaced by the time the send settles, the settle callbacks
|
||||
// must not run bookkeeping against the successor (e.g. flipping a live
|
||||
// auto-continued run to isRunning=false, which makes the event coordinator
|
||||
// treat the new turn's completion as a cancelled-turn straggler).
|
||||
const sessionAtSend = this.activeSession
|
||||
const isSuperseded = (label: string): boolean => {
|
||||
if (this.activeSession === sessionAtSend) {
|
||||
return false
|
||||
}
|
||||
Logger.debug(`[SdkController] Ignoring ${label} of superseded send for session: ${sessionId}`)
|
||||
return true
|
||||
}
|
||||
sdkHost
|
||||
.send({
|
||||
sessionId,
|
||||
@@ -265,6 +301,9 @@ export class SdkSessionLifecycle {
|
||||
Logger.log(`[SdkController] Message queued for session: ${sessionId}`)
|
||||
return
|
||||
}
|
||||
if (isSuperseded("completion")) {
|
||||
return
|
||||
}
|
||||
Logger.log(`[SdkController] Agent turn completed for session: ${sessionId}`)
|
||||
this.setRunning(false)
|
||||
await this.options.onSendComplete(sessionId)
|
||||
@@ -274,6 +313,9 @@ export class SdkSessionLifecycle {
|
||||
Logger.debug(`[SdkController] Agent turn aborted (expected): ${sessionId}`)
|
||||
return
|
||||
}
|
||||
if (isSuperseded("failure")) {
|
||||
return
|
||||
}
|
||||
Logger.error("[SdkController] Agent turn failed:", error)
|
||||
this.setRunning(false)
|
||||
await this.options.onSendError(error, sessionId)
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
import { describe, expect, it } from "vitest"
|
||||
import { ACT_MODE_CONTINUATION_PROMPT } from "./sdk-mode-coordinator"
|
||||
import { extractSdkUserText, findSdkUserMessageIndexByOrdinal, isSyntheticUserPrompt } from "./sdk-user-message-mapping"
|
||||
|
||||
// Persisted prompts are wrapped by formatModePrompt before they reach SDK
|
||||
// history; the mapping must recognize the wrapped shape, not just raw text.
|
||||
const wrapped = (text: string, mode = "act") => `<user_input mode="${mode}">${text}</user_input>`
|
||||
|
||||
describe("isSyntheticUserPrompt", () => {
|
||||
it("flags task resumption and act-mode continuation prompts", () => {
|
||||
expect(isSyntheticUserPrompt("[TASK RESUMPTION] Please continue where you left off.")).toBe(true)
|
||||
expect(isSyntheticUserPrompt(ACT_MODE_CONTINUATION_PROMPT)).toBe(true)
|
||||
})
|
||||
|
||||
it("flags the wrapped persisted shape of synthetic prompts", () => {
|
||||
expect(isSyntheticUserPrompt(wrapped(ACT_MODE_CONTINUATION_PROMPT))).toBe(true)
|
||||
expect(isSyntheticUserPrompt(wrapped("[TASK RESUMPTION] Please continue where you left off.", "plan"))).toBe(true)
|
||||
})
|
||||
|
||||
it("does not flag ordinary user messages, wrapped or raw", () => {
|
||||
expect(isSyntheticUserPrompt("make a plan for the auth refactor")).toBe(false)
|
||||
expect(isSyntheticUserPrompt(wrapped("go ahead and implement step 1"))).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("findSdkUserMessageIndexByOrdinal", () => {
|
||||
const user = (text: string) => ({ role: "user", content: text })
|
||||
const assistant = (text: string) => ({ role: "assistant", content: text })
|
||||
|
||||
it("maps visible ordinals one to one when no synthetic prompts exist", () => {
|
||||
const messages = [user("task"), assistant("plan"), user("follow-up")]
|
||||
|
||||
expect(findSdkUserMessageIndexByOrdinal(messages, 1)).toBe(0)
|
||||
expect(findSdkUserMessageIndexByOrdinal(messages, 2)).toBe(2)
|
||||
})
|
||||
|
||||
it("skips the hidden act-mode continuation prompt as persisted", () => {
|
||||
// Plan task, plan presented, empty-composer toggle to act (hidden canned
|
||||
// prompt in SDK history, no visible user_feedback), act work, follow-up.
|
||||
// Persisted prompts carry the formatModePrompt wrapper.
|
||||
const messages = [
|
||||
user(wrapped("plan the auth refactor", "plan")),
|
||||
assistant("here is the plan"),
|
||||
user(wrapped(ACT_MODE_CONTINUATION_PROMPT)),
|
||||
assistant("done with step 1"),
|
||||
user(wrapped("now do step 2")),
|
||||
]
|
||||
|
||||
// The visible transcript has 2 user messages; the 2nd must map past the
|
||||
// hidden continuation to index 4, not index 2.
|
||||
expect(findSdkUserMessageIndexByOrdinal(messages, 2)).toBe(4)
|
||||
})
|
||||
|
||||
it("skips task resumption prompts as persisted", () => {
|
||||
const messages = [
|
||||
user(wrapped("original task")),
|
||||
assistant("partial work"),
|
||||
user(wrapped("[TASK RESUMPTION] Please continue where you left off.")),
|
||||
assistant("resumed work"),
|
||||
user(wrapped("looks good, keep going")),
|
||||
]
|
||||
|
||||
expect(findSdkUserMessageIndexByOrdinal(messages, 2)).toBe(4)
|
||||
})
|
||||
|
||||
it("returns -1 when the ordinal exceeds the visible user messages", () => {
|
||||
const messages = [user("task"), user(ACT_MODE_CONTINUATION_PROMPT)]
|
||||
|
||||
expect(findSdkUserMessageIndexByOrdinal(messages, 2)).toBe(-1)
|
||||
})
|
||||
|
||||
it("counts an attachment-only continuation because it has a visible bubble", () => {
|
||||
// Attachment-only plan -> act toggle: the SDK message carries the canned
|
||||
// prompt text plus the user's image, and the webview shows a user_feedback
|
||||
// bubble for the attachment, so the message must be counted.
|
||||
const messages = [
|
||||
user(wrapped("plan the auth refactor", "plan")),
|
||||
assistant("here is the plan"),
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "text", text: wrapped(ACT_MODE_CONTINUATION_PROMPT) },
|
||||
{ type: "image", mediaType: "image/png", data: "abc" },
|
||||
],
|
||||
},
|
||||
assistant("done with step 1"),
|
||||
user(wrapped("now do step 2")),
|
||||
]
|
||||
|
||||
expect(findSdkUserMessageIndexByOrdinal(messages, 2)).toBe(2)
|
||||
expect(findSdkUserMessageIndexByOrdinal(messages, 3)).toBe(4)
|
||||
})
|
||||
|
||||
it("counts attachment-only user messages with no text", () => {
|
||||
const messages = [
|
||||
user("task"),
|
||||
{
|
||||
role: "user",
|
||||
content: [{ type: "image", mediaType: "image/png", data: "abc" }],
|
||||
},
|
||||
]
|
||||
|
||||
expect(findSdkUserMessageIndexByOrdinal(messages, 2)).toBe(1)
|
||||
})
|
||||
|
||||
it("does not count tool results even when they carry media blocks", () => {
|
||||
const messages = [
|
||||
user("task"),
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "tool_result", tool_use_id: "t1" },
|
||||
{ type: "image", mediaType: "image/png", data: "screenshot" },
|
||||
],
|
||||
},
|
||||
user("follow-up"),
|
||||
]
|
||||
|
||||
expect(findSdkUserMessageIndexByOrdinal(messages, 2)).toBe(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe("extractSdkUserText", () => {
|
||||
it("extracts text from string and block content", () => {
|
||||
expect(extractSdkUserText({ role: "user", content: " hello " })).toBe("hello")
|
||||
expect(
|
||||
extractSdkUserText({
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "text", text: "first" },
|
||||
{ type: "file", content: "second" },
|
||||
{ type: "image", source: "ignored" },
|
||||
],
|
||||
}),
|
||||
).toBe("first\nsecond")
|
||||
expect(extractSdkUserText({ role: "user", content: 42 })).toBe("")
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,100 @@
|
||||
import { normalizeUserInput } from "@cline/shared"
|
||||
import { ACT_MODE_CONTINUATION_PROMPT } from "./sdk-mode-coordinator"
|
||||
|
||||
export type SdkUserMessage = {
|
||||
role?: unknown
|
||||
content?: unknown
|
||||
}
|
||||
|
||||
export function extractSdkUserText(message: SdkUserMessage): string {
|
||||
const { content } = message
|
||||
if (typeof content === "string") {
|
||||
return content.trim()
|
||||
}
|
||||
if (!Array.isArray(content)) {
|
||||
return ""
|
||||
}
|
||||
return content
|
||||
.map((block) => {
|
||||
if (!block || typeof block !== "object") {
|
||||
return ""
|
||||
}
|
||||
const typed = block as { type?: unknown; text?: unknown; content?: unknown }
|
||||
if (typed.type === "text" && typeof typed.text === "string") {
|
||||
return typed.text.trim()
|
||||
}
|
||||
if (typed.type === "file" && typeof typed.content === "string") {
|
||||
return typed.content.trim()
|
||||
}
|
||||
return ""
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join("\n")
|
||||
.trim()
|
||||
}
|
||||
|
||||
/**
|
||||
* Prompts sent to the SDK without a visible user_feedback echo (task
|
||||
* resumption, plan -> act auto-continue). They exist in SDK history but not
|
||||
* in the visible transcript, so ordinal mapping between the two must skip
|
||||
* them or every later user message maps one slot too early.
|
||||
*/
|
||||
export function isSyntheticUserPrompt(text: string): boolean {
|
||||
// Persisted prompts are wrapped by formatModePrompt as
|
||||
// <user_input mode="...">...</user_input>; strip that before matching.
|
||||
const normalized = normalizeUserInput(text)
|
||||
return normalized.startsWith("[TASK RESUMPTION]") || normalized === ACT_MODE_CONTINUATION_PROMPT
|
||||
}
|
||||
|
||||
function hasAttachmentBlocks(message: SdkUserMessage): boolean {
|
||||
if (!Array.isArray(message.content)) {
|
||||
return false
|
||||
}
|
||||
let hasAttachment = false
|
||||
for (const block of message.content) {
|
||||
if (!block || typeof block !== "object") {
|
||||
continue
|
||||
}
|
||||
const type = (block as { type?: unknown }).type
|
||||
// Tool results are role "user" in SDK history but are not user input;
|
||||
// any media they carry must not make the message count as one.
|
||||
if (type === "tool_result" || type === "tool-result") {
|
||||
return false
|
||||
}
|
||||
if (type === "image" || type === "file") {
|
||||
hasAttachment = true
|
||||
}
|
||||
}
|
||||
return hasAttachment
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the SDK message has no visible user_feedback counterpart. An
|
||||
* attachment-only continuation carries the synthetic text alongside the
|
||||
* user's image/file blocks AND a visible bubble, so it must still be counted.
|
||||
*/
|
||||
export function isSyntheticSdkUserMessage(message: SdkUserMessage): boolean {
|
||||
const text = extractSdkUserText(message)
|
||||
return !!text && isSyntheticUserPrompt(text) && !hasAttachmentBlocks(message)
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps the Nth visible user message (1-based ordinal over task/user_feedback
|
||||
* rows) to its index in the persisted SDK message history, skipping synthetic
|
||||
* prompts that have no visible counterpart.
|
||||
*/
|
||||
export function findSdkUserMessageIndexByOrdinal(sdkMessages: SdkUserMessage[], userOrdinal: number): number {
|
||||
let seenUsers = 0
|
||||
return sdkMessages.findIndex((message) => {
|
||||
if (message.role !== "user") {
|
||||
return false
|
||||
}
|
||||
const text = extractSdkUserText(message)
|
||||
const hasUserContent = !!text || hasAttachmentBlocks(message)
|
||||
if (!hasUserContent || isSyntheticSdkUserMessage(message)) {
|
||||
return false
|
||||
}
|
||||
seenUsers += 1
|
||||
return seenUsers === userOrdinal
|
||||
})
|
||||
}
|
||||
@@ -1021,25 +1021,36 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
|
||||
const onModeToggle = useCallback(() => {
|
||||
void (async () => {
|
||||
const convertedProtoMode = mode === "plan" ? PlanActMode.ACT : PlanActMode.PLAN
|
||||
const submittedText = inputValue
|
||||
const submittedImages = selectedImages
|
||||
const submittedFiles = selectedFiles
|
||||
const response = await StateServiceClient.togglePlanActModeProto(
|
||||
TogglePlanActModeRequest.create({
|
||||
mode: convertedProtoMode,
|
||||
chatContent: {
|
||||
message: inputValue.trim() ? inputValue : undefined,
|
||||
images: selectedImages,
|
||||
files: selectedFiles,
|
||||
message: submittedText.trim() ? submittedText : undefined,
|
||||
images: submittedImages,
|
||||
files: submittedFiles,
|
||||
},
|
||||
}),
|
||||
)
|
||||
// Focus the textarea after mode toggle with slight delay
|
||||
setTimeout(() => {
|
||||
if (response.value) {
|
||||
setInputValue("")
|
||||
// The toggle consumed the composer content as the continuation
|
||||
// message. Clear only what was submitted: the rebuild can take a
|
||||
// moment and the user may have typed or attached new content in
|
||||
// the meantime, which must not be wiped.
|
||||
if ((textAreaRef.current?.value ?? "") === submittedText) {
|
||||
setInputValue("")
|
||||
}
|
||||
setSelectedImages((current) => (current === submittedImages ? [] : current))
|
||||
setSelectedFiles((current) => (current === submittedFiles ? [] : current))
|
||||
}
|
||||
textAreaRef.current?.focus()
|
||||
}, 100)
|
||||
})()
|
||||
}, [mode, inputValue, selectedImages, selectedFiles, setInputValue])
|
||||
}, [mode, inputValue, selectedImages, selectedFiles, setInputValue, setSelectedImages, setSelectedFiles])
|
||||
|
||||
useShortcut(usePlatform().togglePlanActKeys, onModeToggle, { disableTextInputs: false }) // important that we don't disable the text input here
|
||||
|
||||
|
||||
Reference in New Issue
Block a user