Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions packages/app/e2e/prompt/prompt-slash-terminal.spec.ts
Original file line number Diff line number Diff line change
@@ -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)
})
1 change: 1 addition & 0 deletions packages/app/e2e/selectors.ts
Original file line number Diff line number Diff line change
@@ -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"]'
Expand Down
8 changes: 5 additions & 3 deletions packages/app/e2e/settings/settings-keybinds.spec.ts
Original file line number Diff line number Diff line change
@@ -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 }) => {
Expand Down Expand Up @@ -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 }) => {
Expand Down
6 changes: 4 additions & 2 deletions packages/app/e2e/terminal/terminal-init.spec.ts
Original file line number Diff line number Diff line change
@@ -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)
Expand Down
6 changes: 4 additions & 2 deletions packages/app/e2e/terminal/terminal-tabs.spec.ts
Original file line number Diff line number Diff line change
@@ -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 = {
Expand All @@ -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)
}
Expand Down
8 changes: 5 additions & 3 deletions packages/app/e2e/terminal/terminal.spec.ts
Original file line number Diff line number Diff line change
@@ -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()
})
37 changes: 37 additions & 0 deletions packages/opencode/src/provider/transform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,23 @@ function mimeToModality(mime: string): Modality | undefined {
return undefined
}

const USER_FINAL_TURN_SAFETY_NET_REMINDER = [
"<system-reminder>",
"Continue from the conversation above.",
"Follow the most recent assistant instructions and tool results.",
"Do not repeat completed work.",
"</system-reminder>",
].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) {
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -252,6 +286,9 @@ export namespace ProviderTransform {
export function message(msgs: ModelMessage[], model: Provider.Model, options: Record<string, unknown>) {
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") ||
Expand Down
89 changes: 88 additions & 1 deletion packages/opencode/src/session/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
"<system-reminder>",
"Continue with your task using the conversation and tool results above.",
"Do not repeat completed work.",
"</system-reminder>",
].join("\n")
const MAX_STEPS_SYSTEM_REMINDER = [
"<system-reminder>",
MAX_STEPS,
"</system-reminder>",
].join("\n")

export namespace SessionPrompt {
const log = Log.create({ service: "session.prompt" })
Expand Down Expand Up @@ -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
Expand All @@ -664,7 +680,7 @@ export namespace SessionPrompt {
system,
messages: [
...MessageV2.toModelMessages(msgs, model),
...(isLastStep
...(isLastStep && !ProviderTransform.requiresUserFinalTurn(model)
? [
{
role: "assistant" as const,
Expand Down Expand Up @@ -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,
},
],
},
]
}
}
Original file line number Diff line number Diff line change
@@ -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", () => {
Expand Down Expand Up @@ -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."),
}),
)
})
})
Loading
Loading