diff --git a/packages/app/e2e/prompt/prompt-slash-terminal.spec.ts b/packages/app/e2e/prompt/prompt-slash-terminal.spec.ts
index eefce19dc0b..d44868e51fd 100644
--- a/packages/app/e2e/prompt/prompt-slash-terminal.spec.ts
+++ b/packages/app/e2e/prompt/prompt-slash-terminal.spec.ts
@@ -1,23 +1,25 @@
import { test, expect } from "../fixtures"
-import { promptSelector, terminalSelector } from "../selectors"
+import { promptSelector, terminalPanelSelector, terminalSelector } from "../selectors"
test("/terminal toggles the terminal panel", async ({ page, gotoSession }) => {
await gotoSession()
const prompt = page.locator(promptSelector)
+ const terminalPanel = page.locator(terminalPanelSelector)
const terminal = page.locator(terminalSelector)
- await expect(terminal).not.toBeVisible()
+ await expect(terminalPanel).toHaveCount(0)
await prompt.click()
await page.keyboard.type("/terminal")
await expect(page.locator('[data-slash-id="terminal.toggle"]').first()).toBeVisible()
await page.keyboard.press("Enter")
+ await expect(terminalPanel).toBeVisible()
await expect(terminal).toBeVisible()
await prompt.click()
await page.keyboard.type("/terminal")
await expect(page.locator('[data-slash-id="terminal.toggle"]').first()).toBeVisible()
await page.keyboard.press("Enter")
- await expect(terminal).not.toBeVisible()
+ await expect(terminalPanel).toHaveCount(0)
})
diff --git a/packages/app/e2e/selectors.ts b/packages/app/e2e/selectors.ts
index 2061a112849..eec02a4c968 100644
--- a/packages/app/e2e/selectors.ts
+++ b/packages/app/e2e/selectors.ts
@@ -1,4 +1,5 @@
export const promptSelector = '[data-component="prompt-input"]'
+export const terminalPanelSelector = "#terminal-panel"
export const terminalSelector = '[data-component="terminal"]'
export const sessionComposerDockSelector = '[data-component="session-prompt-dock"]'
export const questionDockSelector = '[data-component="dock-prompt"][data-kind="question"]'
diff --git a/packages/app/e2e/settings/settings-keybinds.spec.ts b/packages/app/e2e/settings/settings-keybinds.spec.ts
index e0d590b31af..c2b0c072a21 100644
--- a/packages/app/e2e/settings/settings-keybinds.spec.ts
+++ b/packages/app/e2e/settings/settings-keybinds.spec.ts
@@ -1,6 +1,6 @@
import { test, expect } from "../fixtures"
import { openSettings, closeDialog, withSession } from "../actions"
-import { keybindButtonSelector, terminalSelector } from "../selectors"
+import { keybindButtonSelector, terminalPanelSelector, terminalSelector } from "../selectors"
import { modKey } from "../utils"
test("changing sidebar toggle keybind works", async ({ page, gotoSession }) => {
@@ -298,14 +298,16 @@ test("changing terminal toggle keybind works", async ({ page, gotoSession }) =>
await closeDialog(page, dialog)
+ const terminalPanel = page.locator(terminalPanelSelector)
const terminal = page.locator(terminalSelector)
- await expect(terminal).not.toBeVisible()
+ await expect(terminalPanel).toHaveCount(0)
await page.keyboard.press(`${modKey}+Y`)
+ await expect(terminalPanel).toBeVisible()
await expect(terminal).toBeVisible()
await page.keyboard.press(`${modKey}+Y`)
- await expect(terminal).not.toBeVisible()
+ await expect(terminalPanel).toHaveCount(0)
})
test("terminal toggle keybind persists after reload", async ({ page, gotoSession }) => {
diff --git a/packages/app/e2e/terminal/terminal-init.spec.ts b/packages/app/e2e/terminal/terminal-init.spec.ts
index 18991bf7636..74b3870a33e 100644
--- a/packages/app/e2e/terminal/terminal-init.spec.ts
+++ b/packages/app/e2e/terminal/terminal-init.spec.ts
@@ -1,18 +1,20 @@
import { test, expect } from "../fixtures"
-import { promptSelector, terminalSelector } from "../selectors"
+import { promptSelector, terminalPanelSelector, terminalSelector } from "../selectors"
import { terminalToggleKey } from "../utils"
test("smoke terminal mounts and can create a second tab", async ({ page, gotoSession }) => {
await gotoSession()
+ const terminalPanel = page.locator(terminalPanelSelector)
const terminals = page.locator(terminalSelector)
const tabs = page.locator('#terminal-panel [data-slot="tabs-trigger"]')
- const opened = await terminals.first().isVisible()
+ const opened = await terminalPanel.isVisible().catch(() => false)
if (!opened) {
await page.keyboard.press(terminalToggleKey)
}
+ await expect(terminalPanel).toBeVisible()
await expect(terminals.first()).toBeVisible()
await expect(terminals.first().locator("textarea")).toHaveCount(1)
await expect(terminals).toHaveCount(1)
diff --git a/packages/app/e2e/terminal/terminal-tabs.spec.ts b/packages/app/e2e/terminal/terminal-tabs.spec.ts
index afa6254cd0a..5c021dfeed9 100644
--- a/packages/app/e2e/terminal/terminal-tabs.spec.ts
+++ b/packages/app/e2e/terminal/terminal-tabs.spec.ts
@@ -1,6 +1,6 @@
import type { Page } from "@playwright/test"
import { test, expect } from "../fixtures"
-import { terminalSelector } from "../selectors"
+import { terminalPanelSelector, terminalSelector } from "../selectors"
import { terminalToggleKey, workspacePersistKey } from "../utils"
type State = {
@@ -14,9 +14,11 @@ type State = {
}
async function open(page: Page) {
+ const terminalPanel = page.locator(terminalPanelSelector)
const terminal = page.locator(terminalSelector)
- const visible = await terminal.isVisible().catch(() => false)
+ const visible = await terminalPanel.isVisible().catch(() => false)
if (!visible) await page.keyboard.press(terminalToggleKey)
+ await expect(terminalPanel).toBeVisible()
await expect(terminal).toBeVisible()
await expect(terminal.locator("textarea")).toHaveCount(1)
}
diff --git a/packages/app/e2e/terminal/terminal.spec.ts b/packages/app/e2e/terminal/terminal.spec.ts
index ef88aa34e52..15f82a7b409 100644
--- a/packages/app/e2e/terminal/terminal.spec.ts
+++ b/packages/app/e2e/terminal/terminal.spec.ts
@@ -1,17 +1,19 @@
import { test, expect } from "../fixtures"
-import { terminalSelector } from "../selectors"
+import { terminalPanelSelector, terminalSelector } from "../selectors"
import { terminalToggleKey } from "../utils"
test("terminal panel can be toggled", async ({ page, gotoSession }) => {
await gotoSession()
+ const terminalPanel = page.locator(terminalPanelSelector)
const terminal = page.locator(terminalSelector)
- const initiallyOpen = await terminal.isVisible()
+ const initiallyOpen = await terminalPanel.isVisible().catch(() => false)
if (initiallyOpen) {
await page.keyboard.press(terminalToggleKey)
- await expect(terminal).toHaveCount(0)
+ await expect(terminalPanel).toHaveCount(0)
}
await page.keyboard.press(terminalToggleKey)
+ await expect(terminalPanel).toBeVisible()
await expect(terminal).toBeVisible()
})
diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts
index 407c2687825..b159c6cbaaa 100644
--- a/packages/opencode/src/provider/transform.ts
+++ b/packages/opencode/src/provider/transform.ts
@@ -17,9 +17,23 @@ function mimeToModality(mime: string): Modality | undefined {
return undefined
}
+const USER_FINAL_TURN_SAFETY_NET_REMINDER = [
+ "",
+ "Continue from the conversation above.",
+ "Follow the most recent assistant instructions and tool results.",
+ "Do not repeat completed work.",
+ "",
+].join("\n")
+
export namespace ProviderTransform {
export const OUTPUT_TOKEN_MAX = Flag.OPENCODE_EXPERIMENTAL_OUTPUT_TOKEN_MAX || 32_000
+ export function requiresUserFinalTurn(model: Provider.Model): boolean {
+ if (model.api.npm === "@ai-sdk/anthropic" || model.api.npm === "@ai-sdk/google-vertex/anthropic") return true
+ if (model.api.npm === "@ai-sdk/github-copilot" && model.id.toLowerCase().includes("claude")) return true
+ return false
+ }
+
// Maps npm package to the key the AI SDK expects for providerOptions
function sdkKey(npm: string): string | undefined {
switch (npm) {
@@ -171,6 +185,26 @@ export namespace ProviderTransform {
return msgs
}
+ function ensureUserFinalTurn(msgs: ModelMessage[], model: Provider.Model): ModelMessage[] {
+ if (!requiresUserFinalTurn(model)) return msgs
+
+ const last = msgs.at(-1)
+ if (!last || last.role === "user") return msgs
+
+ return [
+ ...msgs,
+ {
+ role: "user",
+ content: [
+ {
+ type: "text",
+ text: USER_FINAL_TURN_SAFETY_NET_REMINDER,
+ },
+ ],
+ },
+ ]
+ }
+
function applyCaching(msgs: ModelMessage[], model: Provider.Model): ModelMessage[] {
const system = msgs.filter((msg) => msg.role === "system").slice(0, 2)
const final = msgs.filter((msg) => msg.role !== "system").slice(-2)
@@ -252,6 +286,9 @@ export namespace ProviderTransform {
export function message(msgs: ModelMessage[], model: Provider.Model, options: Record) {
msgs = unsupportedParts(msgs, model)
msgs = normalizeMessages(msgs, model, options)
+ // The session loop should normally keep Anthropic-style providers user-final,
+ // but repair the prompt again here as a last-line transport guard.
+ msgs = ensureUserFinalTurn(msgs, model)
if (
(model.providerID === "anthropic" ||
model.api.id.includes("anthropic") ||
diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts
index 7698b78baba..5cd40799ae2 100644
--- a/packages/opencode/src/session/prompt.ts
+++ b/packages/opencode/src/session/prompt.ts
@@ -59,6 +59,17 @@ IMPORTANT:
- This tool provides your final answer - no further actions are taken after calling it`
const STRUCTURED_OUTPUT_SYSTEM_PROMPT = `IMPORTANT: The user has requested structured output. You MUST use the StructuredOutput tool to provide your final response. Do NOT respond with plain text - you MUST call the StructuredOutput tool with your answer formatted according to the schema.`
+const CONTINUE_TASK_SYSTEM_REMINDER = [
+ "",
+ "Continue with your task using the conversation and tool results above.",
+ "Do not repeat completed work.",
+ "",
+].join("\n")
+const MAX_STEPS_SYSTEM_REMINDER = [
+ "",
+ MAX_STEPS,
+ "",
+].join("\n")
export namespace SessionPrompt {
const log = Log.create({ service: "session.prompt" })
@@ -647,6 +658,11 @@ export namespace SessionPrompt {
}
}
+ msgs = appendSyntheticContinuationTurn(msgs, lastUser, model, {
+ forceFinalUser: isLastStep,
+ text: isLastStep ? MAX_STEPS_SYSTEM_REMINDER : CONTINUE_TASK_SYSTEM_REMINDER,
+ })
+
await Plugin.trigger("experimental.chat.messages.transform", {}, { messages: msgs })
// Build system prompt, adding structured output instruction if needed
@@ -664,7 +680,7 @@ export namespace SessionPrompt {
system,
messages: [
...MessageV2.toModelMessages(msgs, model),
- ...(isLastStep
+ ...(isLastStep && !ProviderTransform.requiresUserFinalTurn(model)
? [
{
role: "assistant" as const,
@@ -1959,4 +1975,75 @@ NOTE: At any point in time through this workflow you should feel free to ask the
return Session.setTitle({ sessionID: input.session.id, title })
}
}
+
+ /** @internal Exported for testing — appends an ephemeral user turn for Anthropic-style providers when looping */
+ export function appendSyntheticContinuationTurn(
+ messages: MessageV2.WithParts[],
+ lastUser: MessageV2.User,
+ model: Provider.Model,
+ options: {
+ text?: string
+ forceFinalUser?: boolean
+ } = {},
+ ): MessageV2.WithParts[] {
+ if (!ProviderTransform.requiresUserFinalTurn(model)) return messages
+
+ const lastMessage = messages.at(-1)
+ if (!lastMessage) return messages
+
+ const text = options.text ?? CONTINUE_TASK_SYSTEM_REMINDER
+ if (lastMessage.info.role === "user") {
+ if (!options.forceFinalUser) return messages
+
+ // When the first model call is also the last allowed step, keep the prompt
+ // user-final by ephemerally appending the reminder to the trailing user turn.
+ return [
+ ...messages.slice(0, -1),
+ {
+ ...lastMessage,
+ parts: [
+ ...lastMessage.parts,
+ {
+ id: Identifier.ascending("part"),
+ sessionID: lastMessage.info.sessionID,
+ messageID: lastMessage.info.id,
+ type: "text",
+ text,
+ synthetic: true,
+ },
+ ],
+ },
+ ]
+ }
+
+ if (lastMessage.info.role !== "assistant") return messages
+
+ const messageID = Identifier.ascending("message")
+ return [
+ ...messages,
+ {
+ info: {
+ id: messageID,
+ sessionID: lastUser.sessionID,
+ role: "user",
+ time: {
+ created: Date.now(),
+ },
+ agent: lastUser.agent,
+ model: lastUser.model,
+ variant: lastUser.variant,
+ },
+ parts: [
+ {
+ id: Identifier.ascending("part"),
+ sessionID: lastUser.sessionID,
+ messageID,
+ type: "text",
+ text,
+ synthetic: true,
+ },
+ ],
+ },
+ ]
+ }
}
diff --git a/packages/opencode/test/provider/copilot/convert-to-copilot-messages.test.ts b/packages/opencode/test/provider/copilot/convert-to-copilot-messages.test.ts
index 6f874db6d2e..8db6ec639d1 100644
--- a/packages/opencode/test/provider/copilot/convert-to-copilot-messages.test.ts
+++ b/packages/opencode/test/provider/copilot/convert-to-copilot-messages.test.ts
@@ -1,4 +1,5 @@
import { convertToOpenAICompatibleChatMessages as convertToCopilotMessages } from "@/provider/sdk/copilot/chat/convert-to-openai-compatible-chat-messages"
+import { ProviderTransform } from "@/provider/transform"
import { describe, test, expect } from "bun:test"
describe("system messages", () => {
@@ -521,3 +522,66 @@ describe("full conversation", () => {
expect(assistantMsg.reasoning_opaque).toBe("sig-abc")
})
})
+
+describe("final user turn guard", () => {
+ test("serializes a user-final Copilot Claude prompt after provider repair", () => {
+ const model = {
+ id: "claude-opus-4.6",
+ providerID: "github-copilot",
+ api: {
+ id: "claude-opus-4.6",
+ url: "https://api.githubcopilot.com",
+ npm: "@ai-sdk/github-copilot",
+ },
+ name: "Claude Opus 4.6",
+ capabilities: {
+ temperature: true,
+ reasoning: true,
+ attachment: true,
+ toolcall: true,
+ input: { text: true, audio: false, image: true, video: false, pdf: true },
+ output: { text: true, audio: false, image: false, video: false, pdf: false },
+ interleaved: false,
+ },
+ cost: {
+ input: 0.003,
+ output: 0.015,
+ cache: { read: 0.0003, write: 0.00375 },
+ },
+ limit: {
+ context: 200000,
+ output: 8192,
+ },
+ status: "active",
+ options: {},
+ headers: {},
+ } as any
+
+ const repaired = ProviderTransform.message(
+ [
+ {
+ role: "assistant",
+ content: [{ type: "text", text: "Let me keep going." }],
+ },
+ ] as any[],
+ model,
+ {},
+ )
+ const result = convertToCopilotMessages(repaired as any)
+
+ expect(result).toHaveLength(2)
+ expect(result[0]).toEqual({
+ role: "assistant",
+ content: "Let me keep going.",
+ tool_calls: undefined,
+ reasoning_text: undefined,
+ reasoning_opaque: undefined,
+ })
+ expect(result[1]).toEqual(
+ expect.objectContaining({
+ role: "user",
+ content: expect.stringContaining("Continue from the conversation above."),
+ }),
+ )
+ })
+})
diff --git a/packages/opencode/test/provider/transform.test.ts b/packages/opencode/test/provider/transform.test.ts
index 9dde1a7131e..68388630884 100644
--- a/packages/opencode/test/provider/transform.test.ts
+++ b/packages/opencode/test/provider/transform.test.ts
@@ -1008,9 +1008,10 @@ describe("ProviderTransform.message - anthropic empty content filtering", () =>
const result = ProviderTransform.message(msgs, anthropicModel, {})
- expect(result).toHaveLength(1)
+ expect(result).toHaveLength(2)
expect(result[0].content).toHaveLength(1)
expect(result[0].content[0]).toEqual({ type: "text", text: "Hello" })
+ expect(result[1].role).toBe("user")
})
test("filters out empty reasoning parts from array content", () => {
@@ -1027,9 +1028,10 @@ describe("ProviderTransform.message - anthropic empty content filtering", () =>
const result = ProviderTransform.message(msgs, anthropicModel, {})
- expect(result).toHaveLength(1)
+ expect(result).toHaveLength(2)
expect(result[0].content).toHaveLength(1)
expect(result[0].content[0]).toEqual({ type: "text", text: "Answer" })
+ expect(result[1].role).toBe("user")
})
test("removes entire message when all parts are empty", () => {
@@ -1065,7 +1067,7 @@ describe("ProviderTransform.message - anthropic empty content filtering", () =>
const result = ProviderTransform.message(msgs, anthropicModel, {})
- expect(result).toHaveLength(1)
+ expect(result).toHaveLength(2)
expect(result[0].content).toHaveLength(1)
expect(result[0].content[0]).toEqual({
type: "tool-call",
@@ -1073,6 +1075,7 @@ describe("ProviderTransform.message - anthropic empty content filtering", () =>
toolName: "bash",
input: { command: "ls" },
})
+ expect(result[1].role).toBe("user")
})
test("keeps messages with valid text alongside empty parts", () => {
@@ -1089,10 +1092,11 @@ describe("ProviderTransform.message - anthropic empty content filtering", () =>
const result = ProviderTransform.message(msgs, anthropicModel, {})
- expect(result).toHaveLength(1)
+ expect(result).toHaveLength(2)
expect(result[0].content).toHaveLength(2)
expect(result[0].content[0]).toEqual({ type: "reasoning", text: "Thinking..." })
expect(result[0].content[1]).toEqual({ type: "text", text: "Result" })
+ expect(result[1].role).toBe("user")
})
test("does not filter for non-anthropic providers", () => {
@@ -1413,6 +1417,143 @@ describe("ProviderTransform.message - strip openai metadata when store=false", (
})
})
+describe("ProviderTransform.message - user-final provider guard", () => {
+ const createClaudeModel = (overrides: Partial = {}) =>
+ ({
+ id: "claude-opus-4.6",
+ providerID: "github-copilot",
+ api: {
+ id: "claude-opus-4.6",
+ url: "https://api.githubcopilot.com",
+ npm: "@ai-sdk/github-copilot",
+ },
+ name: "Claude Opus 4.6",
+ capabilities: {
+ temperature: true,
+ reasoning: true,
+ attachment: true,
+ toolcall: true,
+ input: { text: true, audio: false, image: true, video: false, pdf: true },
+ output: { text: true, audio: false, image: false, video: false, pdf: false },
+ interleaved: false,
+ },
+ cost: {
+ input: 0.003,
+ output: 0.015,
+ cache: { read: 0.0003, write: 0.00375 },
+ },
+ limit: {
+ context: 200000,
+ output: 8192,
+ },
+ status: "active",
+ options: {},
+ headers: {},
+ ...overrides,
+ }) as any
+
+ test("appends a synthetic user reminder for Copilot Claude assistant-final prompts", () => {
+ const model = createClaudeModel()
+ const msgs = [
+ {
+ role: "assistant",
+ content: [{ type: "text", text: "Let me check that for you." }],
+ },
+ ] as any[]
+
+ const result = ProviderTransform.message(msgs, model, {}) as any[]
+
+ expect(result).toHaveLength(2)
+ expect(result[0].role).toBe("assistant")
+ expect(result[1]).toEqual(
+ expect.objectContaining({
+ role: "user",
+ content: [
+ expect.objectContaining({
+ type: "text",
+ text: expect.stringContaining("Follow the most recent assistant instructions and tool results."),
+ }),
+ ],
+ }),
+ )
+ })
+
+ test("appends a synthetic user reminder when an Anthropic-style prompt ends with a tool result", () => {
+ const model = createClaudeModel({
+ providerID: "anthropic",
+ api: {
+ id: "claude-sonnet-4",
+ url: "https://api.anthropic.com",
+ npm: "@ai-sdk/anthropic",
+ },
+ })
+ const msgs = [
+ {
+ role: "assistant",
+ content: [
+ {
+ type: "tool-call",
+ toolCallId: "call_1",
+ toolName: "bash",
+ input: { command: "ls" },
+ },
+ ],
+ },
+ {
+ role: "tool",
+ content: [
+ {
+ type: "tool-result",
+ toolCallId: "call_1",
+ toolName: "bash",
+ output: { type: "text", value: "file.txt" },
+ },
+ ],
+ },
+ ] as any[]
+
+ const result = ProviderTransform.message(msgs, model, {}) as any[]
+
+ expect(result).toHaveLength(3)
+ expect(result[2]).toEqual(
+ expect.objectContaining({
+ role: "user",
+ content: [
+ expect.objectContaining({
+ type: "text",
+ text: expect.stringContaining("Continue from the conversation above."),
+ }),
+ ],
+ }),
+ )
+ })
+
+ test("does not alter assistant-final prompts for providers that allow them", () => {
+ const model = createClaudeModel({
+ id: "openai/gpt-5.2",
+ providerID: "openai",
+ api: {
+ id: "gpt-5.2",
+ url: "https://api.openai.com",
+ npm: "@ai-sdk/openai",
+ },
+ name: "GPT-5.2",
+ })
+ const msgs = [
+ {
+ role: "assistant",
+ content: [{ type: "text", text: "Done." }],
+ },
+ ] as any[]
+
+ const result = ProviderTransform.message(msgs, model, {}) as any[]
+
+ expect(result).toHaveLength(1)
+ expect(result[0].role).toBe("assistant")
+ expect(result[0].content).toEqual([{ type: "text", text: "Done." }])
+ })
+})
+
describe("ProviderTransform.message - providerOptions key remapping", () => {
const createModel = (providerID: string, npm: string) =>
({
diff --git a/packages/opencode/test/session/prompt.test.ts b/packages/opencode/test/session/prompt.test.ts
index e8a8c65b03d..fcace906e88 100644
--- a/packages/opencode/test/session/prompt.test.ts
+++ b/packages/opencode/test/session/prompt.test.ts
@@ -209,3 +209,185 @@ describe("session.prompt agent variant", () => {
}
})
})
+
+describe("session.prompt continuation turns", () => {
+ test("appends a synthetic user turn for Copilot Claude when the loop would continue after an assistant turn", () => {
+ const userInfo: MessageV2.User = {
+ id: "msg_user",
+ sessionID: "ses_test",
+ role: "user",
+ time: { created: 1 },
+ agent: "build",
+ model: { providerID: "github-copilot", modelID: "claude-opus-4.6" },
+ }
+ const assistantInfo: MessageV2.Assistant = {
+ id: "msg_assistant",
+ sessionID: "ses_test",
+ role: "assistant",
+ time: { created: 2, completed: 3 },
+ parentID: "msg_user",
+ modelID: "claude-opus-4.6",
+ providerID: "github-copilot",
+ mode: "build",
+ agent: "build",
+ path: { cwd: "/tmp", root: "/tmp" },
+ cost: 0,
+ tokens: {
+ input: 0,
+ output: 0,
+ reasoning: 0,
+ cache: { read: 0, write: 0 },
+ },
+ finish: "tool-calls",
+ }
+ const messages: MessageV2.WithParts[] = [
+ {
+ info: userInfo,
+ parts: [
+ {
+ id: "prt_user",
+ messageID: "msg_user",
+ sessionID: "ses_test",
+ type: "text",
+ text: "Continue.",
+ },
+ ],
+ },
+ {
+ info: assistantInfo,
+ parts: [
+ {
+ id: "prt_assistant",
+ messageID: "msg_assistant",
+ sessionID: "ses_test",
+ type: "text",
+ text: "It's running. Let me check again.",
+ },
+ ],
+ },
+ ]
+
+ const result = SessionPrompt.appendSyntheticContinuationTurn(messages, userInfo, {
+ id: "claude-opus-4.6",
+ api: { npm: "@ai-sdk/github-copilot" },
+ } as any)
+
+ expect(result).toHaveLength(3)
+ expect(result[2].info.role).toBe("user")
+ expect(result[2].parts).toHaveLength(1)
+ expect(result[2].parts[0].type).toBe("text")
+ expect("synthetic" in result[2].parts[0] && result[2].parts[0].synthetic).toBe(true)
+ expect(result[2].parts[0].type === "text" && result[2].parts[0].text).toContain(
+ "Continue with your task using the conversation and tool results above.",
+ )
+ })
+
+ test("appends the max-step reminder to the trailing user turn when forcing a user-final prompt", () => {
+ const userInfo: MessageV2.User = {
+ id: "msg_user",
+ sessionID: "ses_test",
+ role: "user",
+ time: { created: 1 },
+ agent: "build",
+ model: { providerID: "github-copilot", modelID: "claude-opus-4.6" },
+ }
+ const messages: MessageV2.WithParts[] = [
+ {
+ info: userInfo,
+ parts: [
+ {
+ id: "prt_user",
+ messageID: "msg_user",
+ sessionID: "ses_test",
+ type: "text",
+ text: "Continue.",
+ },
+ ],
+ },
+ ]
+
+ const result = SessionPrompt.appendSyntheticContinuationTurn(
+ messages,
+ userInfo,
+ {
+ id: "claude-opus-4.6",
+ api: { npm: "@ai-sdk/github-copilot" },
+ } as any,
+ {
+ forceFinalUser: true,
+ text: "\nCRITICAL - MAXIMUM STEPS REACHED\n",
+ },
+ )
+
+ expect(result).toHaveLength(1)
+ expect(result[0].info.role).toBe("user")
+ expect(result[0].parts).toHaveLength(2)
+ expect(result[0].parts[1].type).toBe("text")
+ expect("synthetic" in result[0].parts[1] && result[0].parts[1].synthetic).toBe(true)
+ expect(result[0].parts[1].type === "text" && result[0].parts[1].text).toContain("MAXIMUM STEPS REACHED")
+ })
+
+ test("does not append a synthetic user turn for providers that allow assistant-final turns", () => {
+ const userInfo: MessageV2.User = {
+ id: "msg_user",
+ sessionID: "ses_test",
+ role: "user",
+ time: { created: 1 },
+ agent: "build",
+ model: { providerID: "openai", modelID: "gpt-5.2" },
+ }
+ const assistantInfo: MessageV2.Assistant = {
+ id: "msg_assistant",
+ sessionID: "ses_test",
+ role: "assistant",
+ time: { created: 2, completed: 3 },
+ parentID: "msg_user",
+ modelID: "gpt-5.2",
+ providerID: "openai",
+ mode: "build",
+ agent: "build",
+ path: { cwd: "/tmp", root: "/tmp" },
+ cost: 0,
+ tokens: {
+ input: 0,
+ output: 0,
+ reasoning: 0,
+ cache: { read: 0, write: 0 },
+ },
+ finish: "tool-calls",
+ }
+ const messages: MessageV2.WithParts[] = [
+ {
+ info: userInfo,
+ parts: [
+ {
+ id: "prt_user",
+ messageID: "msg_user",
+ sessionID: "ses_test",
+ type: "text",
+ text: "Continue.",
+ },
+ ],
+ },
+ {
+ info: assistantInfo,
+ parts: [
+ {
+ id: "prt_assistant",
+ messageID: "msg_assistant",
+ sessionID: "ses_test",
+ type: "text",
+ text: "Working on it.",
+ },
+ ],
+ },
+ ]
+
+ const result = SessionPrompt.appendSyntheticContinuationTurn(messages, userInfo, {
+ id: "gpt-5.2",
+ api: { npm: "@ai-sdk/openai" },
+ } as any)
+
+ expect(result).toStrictEqual(messages)
+ })
+})