Compare commits

..

7 Commits

Author SHA1 Message Date
Aiden Cline 5076497e11 fix(core): retain raw copilot completion usage 2026-08-11 00:37:09 -05:00
Aiden Cline f31582b907 fix(core): preserve raw copilot usage 2026-08-11 00:34:05 -05:00
Aiden Cline c1abcce820 test(core): use observed copilot usage fixtures 2026-08-11 00:20:54 -05:00
Aiden Cline 24d8e41aab fix(core): normalize copilot reasoning usage 2026-08-11 00:14:08 -05:00
Aiden Cline 2372edd5eb feat(tui): show previous agent in switch notices (#41661) 2026-08-10 23:09:26 -05:00
Kit Langton a5f7f8d3b5 fix(tui): deduplicate repeated image attachments (#41651) 2026-08-10 23:39:38 -04:00
Aiden Cline 4df276b9e8 feat(session): persist previous agent on switch (#41621) 2026-08-10 22:35:16 -05:00
29 changed files with 599 additions and 111 deletions
@@ -76,27 +76,6 @@ export async function sendFollowupDraft(input: FollowupSendInput) {
return true
}
const select = async () => {
const session = input.session()
if (session?.agent !== input.draft.agent) {
await input.api.switchAgent({ sessionID: input.draft.sessionID, agent: input.draft.agent })
}
if (
session?.model?.providerID === input.draft.model.providerID &&
session.model.id === input.draft.model.modelID &&
(session.model.variant ?? "default") === (input.draft.variant ?? "default")
)
return
await input.api.switchModel({
sessionID: input.draft.sessionID,
model: {
id: input.draft.model.modelID,
providerID: input.draft.model.providerID,
variant: input.draft.variant,
},
})
}
const [head, ...tail] = text.split(" ")
const cmd = head?.startsWith("/") ? head.slice(1) : undefined
if (cmd && input.sync.data.command.find((item) => item.name === cmd)) {
@@ -107,13 +86,18 @@ export async function sendFollowupDraft(input: FollowupSendInput) {
return false
}
await select()
const messageID = Identifier.ascending("message")
await input.api.command({
sessionID: input.draft.sessionID,
id: messageID,
command: cmd,
arguments: tail.join(" "),
agent: input.draft.agent,
model: {
id: input.draft.model.modelID,
providerID: input.draft.model.providerID,
variant: input.draft.variant,
},
files: await Promise.all(
images.map(async (attachment) => ({
uri: await blobDataUrl(attachment.blob, attachment.mime),
@@ -183,7 +167,24 @@ export async function sendFollowupDraft(input: FollowupSendInput) {
return false
}
await select()
const session = input.session()
if (session?.agent !== input.draft.agent) {
await input.api.switchAgent({ sessionID: input.draft.sessionID, agent: input.draft.agent })
}
if (
session?.model?.providerID !== input.draft.model.providerID ||
session.model.id !== input.draft.model.modelID ||
(session.model.variant ?? "default") !== (input.draft.variant ?? "default")
) {
await input.api.switchModel({
sessionID: input.draft.sessionID,
model: {
id: input.draft.model.modelID,
providerID: input.draft.model.providerID,
variant: input.draft.variant,
},
})
}
await input.api.prompt({
sessionID: input.draft.sessionID,
@@ -523,22 +524,14 @@ export function createPromptSubmit(input: PromptSubmitInput) {
clearInput()
const messageID = Identifier.ascending("message")
serverSync().session.set("session_status", session.id, { type: "busy" })
void (async () => {
if (session.agent !== agent) await sdk().api.session.switchAgent({ sessionID: session.id, agent })
if (
session.model?.providerID !== model.providerID ||
session.model.id !== model.modelID ||
(session.model.variant ?? "default") !== (variant ?? "default")
)
await sdk().api.session.switchModel({
sessionID: session.id,
model: { id: model.modelID, providerID: model.providerID, variant },
})
await sdk().api.session.command({
sdk()
.api.session.command({
sessionID: session.id,
id: messageID,
command: commandName,
arguments: args.join(" "),
agent,
model: { id: model.modelID, providerID: model.providerID, variant },
files: await Promise.all(
images.map(async (attachment) => ({
uri: await blobDataUrl(attachment.blob, attachment.mime),
@@ -546,14 +539,14 @@ export function createPromptSubmit(input: PromptSubmitInput) {
})),
),
})
})().catch((err) => {
serverSync().session.set("session_status", session.id, { type: "idle" })
showToast({
title: language.t("prompt.toast.commandSendFailed.title"),
description: formatServerError(err, language.t, language.t("common.requestFailed")),
.catch((err) => {
serverSync().session.set("session_status", session.id, { type: "idle" })
showToast({
title: language.t("prompt.toast.commandSendFailed.title"),
description: formatServerError(err, language.t, language.t("common.requestFailed")),
})
restoreInput()
})
restoreInput()
})
return
}
}
+2
View File
@@ -193,6 +193,8 @@ export type Endpoint5_13Input = {
readonly id?: SessionMessage.ID | undefined
readonly command: string
readonly arguments?: string | undefined
readonly agent?: Agent.ID | undefined
readonly model?: Model.Ref | undefined
readonly files?: ReadonlyArray<PromptInput.FileAttachment> | undefined
readonly agents?: ReadonlyArray<AgentAttachment> | undefined
readonly skills?: ReadonlyArray<PromptInput.SkillAttachment> | undefined
@@ -431,6 +431,8 @@ const Endpoint5_13 = (raw: RawClient["server.session"]) => (input: Endpoint5_13I
id: input["id"],
command: input["command"],
arguments: input["arguments"],
agent: input["agent"],
model: input["model"],
files: input["files"],
agents: input["agents"],
skills: input["skills"],
@@ -635,6 +635,8 @@ export function make(options: ClientOptions) {
id: input["id"],
command: input["command"],
arguments: input["arguments"],
agent: input["agent"],
model: input["model"],
files: input["files"],
agents: input["agents"],
skills: input["skills"],
@@ -41,6 +41,7 @@ export type SessionMessageAgentSelected = {
time: { created: number }
type: "agent-switched"
agent: string
previous?: string
}
export type PromptBase64 = string
@@ -2535,6 +2536,7 @@ export type SessionImportInput = {
readonly time: { readonly created: number }
readonly type: "agent-switched"
readonly agent: string
readonly previous?: string
}
| {
readonly id: string
@@ -2786,6 +2788,7 @@ export type SessionImportInput = {
readonly time: { readonly created: number }
readonly type: "agent-switched"
readonly agent: string
readonly previous?: string
}
| {
readonly id: string
@@ -3037,6 +3040,7 @@ export type SessionImportInput = {
readonly time: { readonly created: number }
readonly type: "agent-switched"
readonly agent: string
readonly previous?: string
}
| {
readonly id: string
@@ -3485,6 +3489,8 @@ export type SessionCommandInput = {
readonly id?: string | null
readonly command: string
readonly arguments?: string | null
readonly agent?: string | null
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null
readonly files?: ReadonlyArray<{
readonly uri: string
readonly name?: string
@@ -3506,6 +3512,8 @@ export type SessionCommandInput = {
readonly id?: string | null
readonly command: string
readonly arguments?: string | null
readonly agent?: string | null
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null
readonly files?: ReadonlyArray<{
readonly uri: string
readonly name?: string
@@ -3527,6 +3535,8 @@ export type SessionCommandInput = {
readonly id?: string | null
readonly command: string
readonly arguments?: string | null
readonly agent?: string | null
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null
readonly files?: ReadonlyArray<{
readonly uri: string
readonly name?: string
@@ -3544,10 +3554,58 @@ export type SessionCommandInput = {
readonly delivery?: "steer" | "queue" | null
readonly resume?: boolean | null
}["arguments"]
readonly agent?: {
readonly id?: string | null
readonly command: string
readonly arguments?: string | null
readonly agent?: string | null
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null
readonly files?: ReadonlyArray<{
readonly uri: string
readonly name?: string
readonly description?: string
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
}>
readonly agents?: ReadonlyArray<{
readonly name: string
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
}>
readonly skills?: ReadonlyArray<{
readonly id: string
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
}>
readonly delivery?: "steer" | "queue" | null
readonly resume?: boolean | null
}["agent"]
readonly model?: {
readonly id?: string | null
readonly command: string
readonly arguments?: string | null
readonly agent?: string | null
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null
readonly files?: ReadonlyArray<{
readonly uri: string
readonly name?: string
readonly description?: string
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
}>
readonly agents?: ReadonlyArray<{
readonly name: string
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
}>
readonly skills?: ReadonlyArray<{
readonly id: string
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
}>
readonly delivery?: "steer" | "queue" | null
readonly resume?: boolean | null
}["model"]
readonly files?: {
readonly id?: string | null
readonly command: string
readonly arguments?: string | null
readonly agent?: string | null
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null
readonly files?: ReadonlyArray<{
readonly uri: string
readonly name?: string
@@ -3569,6 +3627,8 @@ export type SessionCommandInput = {
readonly id?: string | null
readonly command: string
readonly arguments?: string | null
readonly agent?: string | null
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null
readonly files?: ReadonlyArray<{
readonly uri: string
readonly name?: string
@@ -3590,6 +3650,8 @@ export type SessionCommandInput = {
readonly id?: string | null
readonly command: string
readonly arguments?: string | null
readonly agent?: string | null
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null
readonly files?: ReadonlyArray<{
readonly uri: string
readonly name?: string
@@ -3611,6 +3673,8 @@ export type SessionCommandInput = {
readonly id?: string | null
readonly command: string
readonly arguments?: string | null
readonly agent?: string | null
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null
readonly files?: ReadonlyArray<{
readonly uri: string
readonly name?: string
@@ -3632,6 +3696,8 @@ export type SessionCommandInput = {
readonly id?: string | null
readonly command: string
readonly arguments?: string | null
readonly agent?: string | null
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null
readonly files?: ReadonlyArray<{
readonly uri: string
readonly name?: string
@@ -285,9 +285,8 @@ export class OpenAICompatibleChatLanguageModel implements LanguageModelV3 {
cacheWrite: undefined,
},
outputTokens: {
total: responseBody.usage?.completion_tokens ?? undefined,
...outputUsage(responseBody.usage),
text: undefined,
reasoning: responseBody.usage?.completion_tokens_details?.reasoning_tokens ?? undefined,
},
raw: responseBody.usage ?? undefined,
},
@@ -357,6 +356,7 @@ export class OpenAICompatibleChatLanguageModel implements LanguageModelV3 {
cachedTokens: number | undefined
}
totalTokens: number | undefined
rawCompletionTokens: number | undefined
} = {
completionTokens: undefined,
completionTokensDetails: {
@@ -369,6 +369,7 @@ export class OpenAICompatibleChatLanguageModel implements LanguageModelV3 {
cachedTokens: undefined,
},
totalTokens: undefined,
rawCompletionTokens: undefined,
}
let isFirstChunk = true
const providerOptionsName = this.providerOptionsName
@@ -432,11 +433,11 @@ export class OpenAICompatibleChatLanguageModel implements LanguageModelV3 {
} = value.usage
usage.promptTokens = prompt_tokens ?? undefined
usage.completionTokens = completion_tokens ?? undefined
usage.rawCompletionTokens = completion_tokens ?? undefined
const output = outputUsage(value.usage)
usage.completionTokens = output.total
usage.completionTokensDetails.reasoningTokens = output.reasoning
usage.totalTokens = total_tokens ?? undefined
if (completion_tokens_details?.reasoning_tokens != null) {
usage.completionTokensDetails.reasoningTokens = completion_tokens_details?.reasoning_tokens
}
if (completion_tokens_details?.accepted_prediction_tokens != null) {
usage.completionTokensDetails.acceptedPredictionTokens =
completion_tokens_details?.accepted_prediction_tokens
@@ -708,7 +709,7 @@ export class OpenAICompatibleChatLanguageModel implements LanguageModelV3 {
},
raw: {
prompt_tokens: usage.promptTokens ?? null,
completion_tokens: usage.completionTokens ?? null,
completion_tokens: usage.rawCompletionTokens ?? null,
total_tokens: usage.totalTokens ?? null,
},
},
@@ -727,6 +728,7 @@ const openaiCompatibleTokenUsageSchema = z
.object({
prompt_tokens: z.number().nullish(),
completion_tokens: z.number().nullish(),
reasoning_tokens: z.number().nullish(),
total_tokens: z.number().nullish(),
prompt_tokens_details: z
.object({
@@ -743,6 +745,17 @@ const openaiCompatibleTokenUsageSchema = z
})
.nullish()
function outputUsage(usage: z.infer<typeof openaiCompatibleTokenUsageSchema>) {
const nested = usage?.completion_tokens_details?.reasoning_tokens
return {
total:
usage?.completion_tokens == null
? undefined
: usage.completion_tokens + (nested == null ? (usage.reasoning_tokens ?? 0) : 0),
reasoning: nested ?? usage?.reasoning_tokens ?? undefined,
}
}
// limited version of the schema, focussed on what is needed for the implementation
// this approach limits breakages when the API changes and increases efficiency
const OpenAICompatibleChatResponseSchema = z.object({
+2
View File
@@ -310,6 +310,8 @@ export function fromPromise(plugin: Plugin) {
...input,
sessionID: Session.ID.make(input.sessionID),
id: input.id == null ? undefined : SessionMessage.ID.make(input.id),
agent: input.agent == null ? undefined : Agent.ID.make(input.agent),
model: input.model == null ? undefined : model(input.model),
skills: input.skills?.map((skill) => ({ ...skill, id: Skill.ID.make(skill.id) })),
arguments: input.arguments ?? undefined,
delivery: input.delivery ?? undefined,
+4 -2
View File
@@ -233,6 +233,8 @@ export interface Interface {
sessionID: SessionSchema.ID
command: string
arguments?: string
agent?: Agent.ID
model?: Model.Ref
files?: PromptInput.Prompt["files"]
agents?: PromptInput.Prompt["agents"]
skills?: PromptInput.Prompt["skills"]
@@ -623,13 +625,13 @@ const layer = Layer.effect(
const evaluated = yield* commands.evaluate({ name: input.command, arguments: input.arguments })
// TODO(v2 commands): decide whether command-level subtask/background execution belongs in v2 commands.
const agent = command.agent
const agent = command.agent ?? input.agent
const commandAgent = yield* Effect.gen(function* () {
if (!command.agent) return undefined
const agents = yield* Agent.Service.pipe(Effect.provide(locations.get(session.location)))
return yield* agents.get(Agent.ID.make(command.agent))
})
const model = command.model ?? commandAgent?.model
const model = command.model ?? commandAgent?.model ?? input.model
if (agent !== undefined && session.agent !== Agent.ID.make(agent))
yield* result.switchAgent({ sessionID: input.sessionID, agent: Agent.ID.make(agent) })
if (model !== undefined) yield* result.switchModel({ sessionID: input.sessionID, model })
+14 -9
View File
@@ -4,6 +4,7 @@ import { SessionEvent } from "./event"
import { SessionMessage } from "./message"
export interface Adapter {
readonly getAgent: () => Effect.Effect<SessionMessage.AgentSelected["agent"] | undefined, never, never>
readonly getModel: () => Effect.Effect<SessionMessage.ModelSelected["model"] | undefined, never, never>
readonly getCurrentAssistant: () => Effect.Effect<SessionMessage.Assistant | undefined, never, never>
readonly getAssistant: (
@@ -59,15 +60,19 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
"session.created": () => Effect.void,
"session.usage.recorded": () => Effect.void,
"session.agent.selected": (event) => {
return adapter.appendMessage(
SessionMessage.AgentSelected.make({
id: SessionMessage.ID.fromEvent(event.id),
type: "agent-switched",
metadata: event.metadata,
agent: event.data.agent,
time: { created: event.created },
}),
)
return Effect.gen(function* () {
const previous = yield* adapter.getAgent()
yield* adapter.appendMessage(
SessionMessage.AgentSelected.make({
id: SessionMessage.ID.fromEvent(event.id),
type: "agent-switched",
metadata: event.metadata,
agent: event.data.agent,
previous,
time: { created: event.created },
}),
)
})
},
"session.model.selected": (event) => {
return Effect.gen(function* () {
+21 -6
View File
@@ -5,6 +5,7 @@ import { DateTime, Effect, Layer, Schema, Stream } from "effect"
import { Database } from "../database/database"
import { Bus } from "../bus"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import { Agent } from "../agent"
import { Model } from "../model"
import { SessionEvent } from "./event"
import { SessionMessage } from "./message"
@@ -230,6 +231,17 @@ function run(db: DatabaseService, event: MessageEvent) {
}
const appendMessage = (message: SessionMessage.Info) => insertMessage(db, event, message)
const adapter: SessionMessageUpdater.Adapter = {
getAgent() {
return db
.select({ agent: SessionTable.agent })
.from(SessionTable)
.where(eq(SessionTable.id, event.data.sessionID))
.get()
.pipe(
Effect.orDie,
Effect.map((row) => (row?.agent ? Agent.ID.make(row.agent) : undefined)),
)
},
getModel() {
return db
.select({ model: SessionTable.model })
@@ -398,12 +410,15 @@ const layer = Layer.effectDiscard(
db.delete(SessionTable).where(eq(SessionTable.id, event.data.sessionID)).run().pipe(Effect.orDie),
)
yield* bus.project(SessionEvent.AgentSelected, (event) =>
db
.update(SessionTable)
.set({ agent: event.data.agent, time_updated: DateTime.toEpochMillis(event.created) })
.where(eq(SessionTable.id, event.data.sessionID))
.run()
.pipe(Effect.orDie, Effect.andThen(run(db, event))),
Effect.gen(function* () {
yield* run(db, event)
yield* db
.update(SessionTable)
.set({ agent: event.data.agent, time_updated: DateTime.toEpochMillis(event.created) })
.where(eq(SessionTable.id, event.data.sessionID))
.run()
.pipe(Effect.orDie)
}),
)
yield* bus.project(SessionEvent.ModelSelected, (event) =>
Effect.gen(function* () {
@@ -59,6 +59,25 @@ const attachmentContent = (file: FileAttachment): ContentPart[] => {
return []
}
const userAttachmentContent = (files: readonly FileAttachment[]) => {
const eligible = files.filter(
(file) => imageMimes.has(file.mime) && file.source.type === "inline" && file.mention?.text,
)
if (eligible.length < 2) return files.flatMap(attachmentContent)
const seen = new Map<string, string[]>()
return files.flatMap((file) => {
if (!imageMimes.has(file.mime) || file.source.type !== "inline" || !file.mention?.text)
return attachmentContent(file)
const metadata = JSON.stringify([file.mime, file.name ?? null, file.description ?? null, file.mention.text])
const matches = seen.get(metadata)
if (matches?.includes(file.data)) return []
if (matches) matches.push(file.data)
if (!matches) seen.set(metadata, [file.data])
return attachmentContent(file)
})
}
const decodeToolInput = Schema.decodeUnknownOption(Schema.UnknownFromJsonString)
const providerMetadata = (
@@ -186,7 +205,7 @@ function toLLMMessage(message: SessionMessage.Info, model: Model.Ref, providerMe
const content = [
...(message.skills ?? []).map((skill) => Message.text(skill.text)),
...(message.text === "" ? [] : [Message.text(message.text)]),
...(message.files ?? []).flatMap(attachmentContent),
...userAttachmentContent(message.files ?? []),
]
if (content.length === 0) return []
return [
@@ -204,7 +204,7 @@ describe("doStream", () => {
finishReason: { unified: "tool-calls" },
usage: {
inputTokens: { total: 19581 },
outputTokens: { total: 53 },
outputTokens: { total: 187, reasoning: 134 },
},
})
})
@@ -259,7 +259,7 @@ describe("doStream", () => {
finishReason: { unified: "stop" },
usage: {
inputTokens: { total: 5778 },
outputTokens: { total: 59 },
outputTokens: { total: 154, reasoning: 95 },
},
providerMetadata: {
copilot: {
@@ -391,7 +391,7 @@ describe("doStream", () => {
finishReason: { unified: "tool-calls" },
usage: {
inputTokens: { total: 3767 },
outputTokens: { total: 19 },
outputTokens: { total: 30, reasoning: 11 },
},
})
})
+4 -1
View File
@@ -647,7 +647,7 @@ describe("Session.create", () => {
it.effect("switches the selected agent through the durable Session event", () =>
Effect.gen(function* () {
const session = yield* Session.Service
const created = yield* session.create({ location })
const created = yield* session.create({ location, agent: Agent.ID.make("build") })
yield* session.switchAgent({ sessionID: created.id, agent: Agent.ID.make("plan") })
@@ -655,6 +655,9 @@ describe("Session.create", () => {
expect(
Array.from(yield* logEvents(session, created.id, true).pipe(Stream.drop(1), Stream.take(1), Stream.runCollect)),
).toMatchObject([{ type: "session.agent.selected", data: { agent: "plan" } }])
expect(yield* session.messages({ sessionID: created.id, order: "asc" })).toMatchObject([
{ type: "agent-switched", agent: "plan", previous: "build" },
])
}),
)
@@ -358,6 +358,7 @@ describe("SessionProjector", () => {
directory: "/project",
title: "test",
version: "test",
agent: "plan",
model: previousModel,
})
.run()
@@ -459,6 +460,10 @@ describe("SessionProjector", () => {
text: "synthetic context",
metadata: { source: "projector-test" },
})
expect(messages.find((message) => message.type === "agent-switched")).toMatchObject({
agent: build,
previous: "plan",
})
expect(messages.find((message) => message.type === "model-switched")).toMatchObject({ previous: previousModel })
expect(messages.find((message) => message.type === "shell")).toMatchObject({
command: "pwd",
@@ -373,6 +373,103 @@ Recent work
])
})
test("deduplicates provider media while preserving durable attachment references", () => {
const data = Base64.make("AAECAw==")
const messages = toLLMMessages(
[
SessionMessage.User.make({
id: id("user-duplicate-image"),
type: "user",
text: "[Image 1] [Image 1] [Image 2]",
files: [
FileAttachment.make({
data,
mime: "image/png",
source: { type: "inline" },
name: "image.png",
mention: { start: 0, end: 9, text: "[Image 1]" },
}),
FileAttachment.make({
data,
mime: "image/png",
source: { type: "inline" },
name: "image.png",
mention: { start: 10, end: 19, text: "[Image 1]" },
}),
FileAttachment.make({
data,
mime: "image/png",
source: { type: "inline" },
name: "image.png",
description: "alternate use",
mention: { start: 20, end: 29, text: "[Image 2]" },
}),
],
time: { created },
}),
],
model,
)
expect(messages[0]?.content).toEqual([
{ type: "text", text: "[Image 1] [Image 1] [Image 2]" },
{ type: "media", mediaType: "image/png", data, filename: "image.png" },
{
type: "media",
mediaType: "image/png",
data,
filename: "image.png",
metadata: { description: "alternate use" },
},
])
})
test("preserves provider media with distinct labels or URI sources", () => {
const data = Base64.make("AAECAw==")
const messages = toLLMMessages(
[
SessionMessage.User.make({
id: id("user-distinct-images"),
type: "user",
text: "[Image 1] [Image 2]",
files: [
FileAttachment.make({
data,
mime: "image/png",
source: { type: "inline" },
name: "image.png",
mention: { start: 0, end: 9, text: "[Image 1]" },
}),
FileAttachment.make({
data,
mime: "image/png",
source: { type: "inline" },
name: "image.png",
mention: { start: 10, end: 19, text: "[Image 2]" },
}),
FileAttachment.make({
data,
mime: "image/png",
source: { type: "uri", uri: "file:///project/image.png" },
name: "image.png",
mention: { start: 0, end: 9, text: "[Image 1]" },
}),
FileAttachment.make({
data,
mime: "image/png",
source: { type: "inline" },
name: "image.png",
}),
],
time: { created },
}),
],
model,
)
expect(messages[0]?.content.filter((part) => part.type === "media")).toHaveLength(4)
})
test("replays durable tool media into canonical tool messages without structured base64", () => {
const messages = toLLMMessages(
[
+3
View File
@@ -12733,6 +12733,9 @@
},
"agent": {
"type": "string"
},
"previous": {
"type": "string"
}
},
"required": ["id", "time", "type", "agent"],
+2
View File
@@ -341,6 +341,8 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo
id: SessionMessage.ID.pipe(Schema.optional),
command: Schema.String,
arguments: Schema.String.pipe(Schema.optional),
agent: Agent.ID.pipe(Schema.optional),
model: Model.Ref.pipe(Schema.optional),
files: PromptInput.Prompt.fields.files,
agents: PromptInput.Prompt.fields.agents,
skills: PromptInput.Prompt.fields.skills,
+1
View File
@@ -42,6 +42,7 @@ export const AgentSelected = Schema.Struct({
...Base,
type: Schema.tag("agent-switched"),
agent: Agent.ID,
previous: Agent.ID.pipe(optional),
}).annotate({ identifier: "Session.Message.AgentSelected" })
export interface ModelSelected extends Schema.Schema.Type<typeof ModelSelected> {}
+2
View File
@@ -355,6 +355,8 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
id: ctx.payload.id,
command: ctx.payload.command,
arguments: ctx.payload.arguments,
agent: ctx.payload.agent,
model: ctx.payload.model,
files: ctx.payload.files,
agents: ctx.payload.agents,
skills: ctx.payload.skills,
+23 -22
View File
@@ -60,6 +60,11 @@ import { Keymap, type KeymapCommand } from "../../context/keymap"
import { abbreviateHome } from "../../runtime"
import { PluginSlot } from "../../plugin/render"
import type { SessionPending } from "@opencode-ai/schema/session-pending"
import {
deduplicatePromptImages,
preserveMentionlessPromptAttachments,
promptAttachmentLabel,
} from "../../prompt/attachment"
import { DialogImagePreview } from "../dialog-image-preview"
export type PromptProps = {
@@ -331,7 +336,7 @@ export function Prompt(props: PromptProps) {
}
const imageAttachments = createMemo(() =>
(store.prompt.files ?? []).filter((file) => typeof file.uri === "string" && file.uri.startsWith("data:image/")),
(deduplicatePromptImages(store.prompt.files) ?? []).filter((file) => file.uri.startsWith("data:image/")),
)
const imagePreviewHeight = createMemo(() => Math.max(4, Math.min(8, Math.floor(dimensions().height / 4))))
const imagePreviewWidth = createMemo(() => imagePreviewHeight() * 2)
@@ -736,6 +741,7 @@ export function Prompt(props: PromptProps) {
setStore(
produce((draft) => {
const newMap = new Map<number, PromptPartRef>()
const fileExtmarks = new Map<number, NonNullable<PromptInfo["files"]>[number]>()
const files: NonNullable<PromptInfo["files"]> = []
const agents: NonNullable<PromptInfo["agents"]> = []
const skills: NonNullable<PromptInfo["skills"]> = []
@@ -749,9 +755,8 @@ export function Prompt(props: PromptProps) {
if (!part?.mention) continue
part.mention.start = extmark.start
part.mention.end = extmark.end
const index = files.length
files.push(part)
newMap.set(extmark.id, { type: "file", index })
fileExtmarks.set(extmark.id, part)
continue
}
if (ref.type === "agent") {
@@ -783,8 +788,19 @@ export function Prompt(props: PromptProps) {
newMap.set(extmark.id, { type: "pasted", index })
}
const nextFiles = preserveMentionlessPromptAttachments(draft.prompt.files, files)
const fileIndices = new Map(nextFiles.map((file, index) => [file, index]))
for (const [extmark, file] of fileExtmarks) {
const index = fileIndices.get(file)
if (index !== undefined) newMap.set(extmark, { type: "file", index })
}
draft.extmarkToPart = newMap
draft.prompt.files = files
if (
nextFiles.length !== draft.prompt.files?.length ||
nextFiles.some((file, index) => file !== draft.prompt.files?.[index])
)
draft.prompt.files = nextFiles
draft.prompt.agents = agents
draft.prompt.skills = skills
draft.prompt.pasted = pasted
@@ -1138,7 +1154,6 @@ export function Prompt(props: PromptProps) {
// Capture mode before it gets reset
const currentMode = store.mode
if (store.mode === "shell") {
move.startSubmit()
void client.api.session.shell({
@@ -1149,23 +1164,15 @@ export function Prompt(props: PromptProps) {
} else if (slashHead && isCommand) {
move.startSubmit()
const model = { providerID: selection.providerID, id: selection.modelID, variant }
if (session?.agent !== agent.id) await client.api.session.switchAgent({ sessionID, agent: agent.id })
const cancelCommit = local.model.trackSessionCommit(sessionID, model)
if (
session?.model?.providerID !== model.providerID ||
session.model.id !== model.id ||
(session.model.variant ?? "default") !== (model.variant ?? "default")
)
await client.api.session.switchModel({ sessionID, model }).catch((error) => {
cancelCommit()
throw error
})
void client.api.session
.command({
sessionID,
command: slashHead.name,
arguments: slashHead.arguments,
agent: agent.id,
model,
files: store.prompt.files,
agents: store.prompt.agents,
skills: store.prompt.skills?.length ? store.prompt.skills : undefined,
@@ -1384,13 +1391,7 @@ export function Prompt(props: PromptProps) {
function pasteAttachment(file: { filename?: string; uri: string }) {
const currentOffset = input.cursorOffset
const extmarkStart = currentOffset
const pdf = file.uri.startsWith("data:application/pdf;")
const count = pdf
? (store.prompt.files?.filter(
(attachment) => typeof attachment.uri === "string" && attachment.uri.startsWith("data:application/pdf;"),
).length ?? 0)
: imageAttachments().length
const virtualText = pdf ? `[PDF ${count + 1}]` : `[Image ${count + 1}]`
const virtualText = promptAttachmentLabel(store.prompt.files, { uri: file.uri, name: file.filename })
const extmarkEnd = extmarkStart + virtualText.length
const textToInsert = virtualText + " "
+4 -1
View File
@@ -386,7 +386,8 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
},
}))
break
case "session.agent.selected":
case "session.agent.selected": {
const previous = store.session.info[event.data.sessionID]?.agent
if (store.session.info[event.data.sessionID])
setStore("session", "info", event.data.sessionID, "agent", event.data.agent)
message.update(event.data.sessionID, (draft, index) => {
@@ -394,10 +395,12 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
id: messageIDFromEvent(event.id),
type: "agent-switched",
agent: event.data.agent,
previous,
time: { created: event.created },
})
})
break
}
case "session.model.selected":
if (store.session.info[event.data.sessionID])
setStore("session", "info", event.data.sessionID, "model", event.data.model)
+10 -10
View File
@@ -1653,6 +1653,8 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
)
}
const selected = await resolveSelectedModel(input, client, next)
if (next.variant && !selected) throw new Error("Cannot select a variant before selecting a model")
input.trace?.write("send.command", { sessionID: input.sessionID, messageID, command: command.name, delivery })
return client.session.command(
{
@@ -1660,6 +1662,8 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
id: messageID,
command: command.name,
arguments: command.arguments,
agent: next.agent,
model: selected,
files: attachments.files.length ? attachments.files : undefined,
agents: agents.length ? agents : undefined,
skills: skills.length ? skills : undefined,
@@ -1702,10 +1706,12 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
const client = sdk
if (next.agent)
await client.session.switchAgent({ sessionID: input.sessionID, agent: next.agent }, { signal: next.signal })
const selected = await resolveSelectedModel(input, client, next)
if (next.variant && !selected) throw new Error("Cannot select a variant before selecting a model")
if (selected)
await client.session.switchModel({ sessionID: input.sessionID, model: selected }, { signal: next.signal })
if (!next.prompt.command) {
const selected = await resolveSelectedModel(input, client, next)
if (next.variant && !selected) throw new Error("Cannot select a variant before selecting a model")
if (selected)
await client.session.switchModel({ sessionID: input.sessionID, model: selected }, { signal: next.signal })
}
mergePending(await admitPrompt(next, client, delivery))
settlementClient = client
},
@@ -1744,12 +1750,6 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
return
}
if (command) {
if (next.agent)
await client.session.switchAgent({ sessionID: input.sessionID, agent: next.agent }, { signal: next.signal })
const selected = await resolveSelectedModel(input, client, next)
if (next.variant && !selected) throw new Error("Cannot select a variant before selecting a model")
if (selected)
await client.session.switchModel({ sessionID: input.sessionID, model: selected }, { signal: next.signal })
await runTurnWait(
next,
messageID,
+96
View File
@@ -0,0 +1,96 @@
import type { PromptInput } from "@opencode-ai/schema"
type PromptFile = PromptInput.FileAttachment
type PromptFileIdentity = Pick<PromptFile, "uri" | "name" | "description">
type ProjectedFile = Readonly<{
data: string
mime: string
source: { type: string }
name?: string
description?: string
mention?: { text: string }
}>
function attachmentKind(uri: string) {
if (uri.startsWith("data:image/")) return "Image"
if (uri.startsWith("data:application/pdf;")) return "PDF"
return undefined
}
function attachmentMetadata(file: PromptFileIdentity) {
return JSON.stringify([file.name ?? null, file.description ?? null])
}
function deduplicateByIdentity<T>(
items: readonly T[],
identity: (item: T) => { metadata: string; payload: string } | undefined,
) {
const seen = new Map<string, string[]>()
return items.filter((item) => {
const key = identity(item)
if (!key) return true
const matches = seen.get(key.metadata)
if (matches?.includes(key.payload)) return false
if (matches) matches.push(key.payload)
if (!matches) seen.set(key.metadata, [key.payload])
return true
})
}
export function deduplicatePromptImages(files: readonly PromptFile[] | undefined) {
if (!files || files.length < 2) return files
return deduplicateByIdentity(files, (file) =>
file.uri.startsWith("data:image/") && file.mention?.text
? {
metadata: JSON.stringify([attachmentMetadata(file), file.mention.text]),
payload: file.uri,
}
: undefined,
)
}
export function preserveMentionlessPromptAttachments(
files: readonly PromptFile[] | undefined,
mentioned: PromptFile[],
) {
if (!files) return mentioned
const tracked = mentioned.values()
return files.flatMap((file) => {
if (!file.mention?.text) return [file]
const next = tracked.next()
return next.done ? [] : [next.value]
})
}
export function deduplicateVisibleImages<T extends ProjectedFile>(files: readonly T[]) {
return deduplicateByIdentity(files, (file) =>
file.mime.startsWith("image/") && file.source.type === "inline" && file.mention?.text
? {
metadata: JSON.stringify([file.mime, file.name ?? null, file.description ?? null, file.mention.text]),
payload: file.data,
}
: undefined,
)
}
export function promptAttachmentLabel(files: readonly PromptFile[] | undefined, file: PromptFileIdentity) {
const kind = attachmentKind(file.uri)
if (!kind) throw new Error(`Unsupported inline attachment: ${file.uri}`)
const metadata = attachmentMetadata(file)
const existing =
kind === "Image"
? files?.find(
(candidate) =>
candidate.uri === file.uri && attachmentMetadata(candidate) === metadata && candidate.mention?.text,
)?.mention?.text
: undefined
if (existing) return existing
const pattern = new RegExp(`^\\[${kind} (\\d+)\\]$`)
const count =
files?.reduce((highest, candidate) => {
const match = candidate.mention?.text.match(pattern)
return match ? Math.max(highest, Number(match[1])) : highest
}, 0) ?? 0
return `[${kind} ${count + 1}]`
}
+8 -2
View File
@@ -70,6 +70,7 @@ import stripAnsi from "strip-ansi"
import { usePromptRef } from "../../context/prompt"
import { sessionTabsFitVertically, SESSION_SIDEBAR_WIDTH } from "../../ui/layout"
import { projectedPromptInput } from "../../prompt/codec"
import { deduplicateVisibleImages } from "../../prompt/attachment"
import { useEpilogue } from "../../context/epilogue"
import { normalizePath } from "../../util/path"
import { PermissionPrompt } from "./permission"
@@ -1651,7 +1652,12 @@ function SessionSwitchMessageV2(props: { message: SessionMessageInfo }) {
const ctx = use()
const theme = useTheme()
const text = () => {
if (props.message.type === "agent-switched") return `Switched agent to ${props.message.agent}`
if (props.message.type === "agent-switched") {
const agent = Locale.titlecase(props.message.agent)
if (props.message.previous && props.message.previous !== props.message.agent)
return `Switched agent from ${Locale.titlecase(props.message.previous)} to ${agent}`
return `Switched agent to ${agent}`
}
if (props.message.type === "model-switched")
return switchLabel(props.message.model, ctx.models(), props.message.previous)
return ""
@@ -1899,7 +1905,7 @@ function UserMessage(props: { message: SessionMessageUser }) {
const ctx = use()
const data = useData()
const local = useLocal()
const files = createMemo(() => props.message.files ?? [])
const files = createMemo(() => deduplicateVisibleImages(props.message.files ?? []))
const skills = createMemo(() => props.message.skills ?? [])
const images = createMemo(() =>
files().flatMap((file) =>
@@ -2855,6 +2855,8 @@ describe("V2 mini transport", () => {
id: "msg_cmd",
command: "deploy",
arguments: "prod",
agent: "build",
model: { providerID: "test", id: "model" },
files: [
{ uri: "file:///tmp/context.txt", name: "context.txt" },
{
@@ -2866,11 +2868,9 @@ describe("V2 mini transport", () => {
skills: [{ id: "api-design", mention: { start: 13, end: 24, text: "/api-design" } }],
delivery: "steer",
})
expect(client.session.switchAgent).toHaveBeenCalledWith({ sessionID: "ses_1", agent: "build" }, expect.anything())
expect(client.session.switchModel).toHaveBeenCalledWith(
{ sessionID: "ses_1", model: { providerID: "test", id: "model" } },
expect.anything(),
)
// Selection rides the command payload; no separate client-side switch.
expect(client.session.switchAgent).not.toHaveBeenCalled()
expect(client.session.switchModel).not.toHaveBeenCalled()
await transport.close()
})
+125
View File
@@ -0,0 +1,125 @@
import { describe, expect, test } from "bun:test"
import {
deduplicatePromptImages,
deduplicateVisibleImages,
preserveMentionlessPromptAttachments,
promptAttachmentLabel,
} from "../../src/prompt/attachment"
describe("prompt attachments", () => {
test("deduplicates identical inline images while preserving other attachments", () => {
const files = [
{
uri: "data:image/png;base64,AAA",
name: "first.png",
mention: { start: 0, end: 9, text: "[Image 1]" },
},
{ uri: "file:///same", name: "first.txt" },
{ uri: "data:application/pdf;base64,CCC", name: "first.pdf" },
{
uri: "data:image/png;base64,BBB",
name: "second.png",
mention: { start: 10, end: 19, text: "[Image 2]" },
},
{
uri: "data:image/png;base64,AAA",
name: "first.png",
mention: { start: 20, end: 29, text: "[Image 1]" },
},
{
uri: "data:image/png;base64,AAA",
name: "first.png",
description: "alternate use",
mention: { start: 30, end: 39, text: "[Image 1]" },
},
{ uri: "file:///same", name: "second.txt" },
{ uri: "data:application/pdf;base64,CCC", name: "first.pdf" },
]
expect(deduplicatePromptImages(files)).toEqual([
files[0],
files[1],
files[2],
files[3],
files[5],
files[6],
files[7],
])
expect(files).toHaveLength(8)
})
test("reuses labels for identical image data", () => {
const first = "data:image/png;base64,AAA"
const second = "data:image/png;base64,BBB"
const files = [{ uri: first, mention: { start: 0, end: 9, text: "[Image 1]" } }]
expect(promptAttachmentLabel(files, { uri: first })).toBe("[Image 1]")
expect(promptAttachmentLabel([...files, { ...files[0], mention: undefined }], { uri: second })).toBe("[Image 2]")
expect(promptAttachmentLabel([{ uri: first }], { uri: first })).toBe("[Image 1]")
})
test("numbers PDFs independently from images", () => {
const files = [{ uri: "data:image/png;base64,AAA" }]
expect(promptAttachmentLabel(files, { uri: "data:application/pdf;base64,BBB" })).toBe("[PDF 1]")
})
test("does not reuse a label when attachment metadata differs", () => {
const uri = "data:image/png;base64,AAA"
const files = [{ uri, name: "one.png", mention: { start: 0, end: 9, text: "[Image 1]" } }]
expect(promptAttachmentLabel(files, { uri, name: "two.png" })).toBe("[Image 2]")
})
test("does not reuse numbers after an earlier attachment is removed", () => {
const files = [{ uri: "data:image/png;base64,BBB", mention: { start: 0, end: 9, text: "[Image 2]" } }]
expect(promptAttachmentLabel(files, { uri: "data:image/png;base64,CCC" })).toBe("[Image 3]")
})
test("preserves mentionless attachments when tracked mentions are synchronized", () => {
const mentionless = { uri: "data:image/png;base64,AAA" }
const emptyMention = {
uri: "data:image/png;base64,CCC",
mention: { start: 0, end: 0, text: "" },
}
const mentioned = {
uri: "data:image/png;base64,BBB",
mention: { start: 0, end: 9, text: "[Image 1]" },
}
const restored = preserveMentionlessPromptAttachments([mentionless, emptyMention, mentioned], [mentioned])
expect(restored).toEqual([mentionless, emptyMention, mentioned])
expect(restored.indexOf(mentioned)).toBe(2)
const another = {
uri: "data:image/png;base64,DDD",
mention: { start: 10, end: 19, text: "[Image 2]" },
}
expect(preserveMentionlessPromptAttachments([mentioned, mentionless, another], [another, mentioned])).toEqual([
another,
mentionless,
mentioned,
])
})
test("deduplicates visible inline image cards without dropping durable references", () => {
const file = {
data: "AAA",
mime: "image/png",
source: { type: "inline" },
name: "clipboard",
mention: { text: "[Image 1]" },
}
const files = [file, { ...file, mention: { text: "[Image 1]" } }]
expect(deduplicateVisibleImages(files)).toEqual([file])
expect(files).toHaveLength(2)
const distinct = [
{ ...file, mention: { text: "[Image 2]" } },
{ ...file, mention: undefined },
]
expect(deduplicateVisibleImages([file, ...distinct])).toEqual([file, ...distinct])
})
})
+17
View File
@@ -41,4 +41,21 @@ describe("prompt history", () => {
const b = entry("describe this", [{ name: "b.png", uri: "data:image/png;base64,BBB" }])
expect(isDuplicateEntry(a, b)).toBe(false)
})
test("preserves duplicate attachment mentions for prompt restoration", () => {
const value = entry("[Image 1] [Image 1]", [
{
name: "clipboard",
uri: "data:image/png;base64,AAA",
mention: { start: 0, end: 9, text: "[Image 1]" },
},
{
name: "clipboard",
uri: "data:image/png;base64,AAA",
mention: { start: 10, end: 19, text: "[Image 1]" },
},
])
expect(parsePromptHistory(JSON.stringify(value))).toEqual([value])
})
})
+3
View File
@@ -12733,6 +12733,9 @@
},
"agent": {
"type": "string"
},
"previous": {
"type": "string"
}
},
"required": ["id", "time", "type", "agent"],
+3
View File
@@ -12733,6 +12733,9 @@
},
"agent": {
"type": "string"
},
"previous": {
"type": "string"
}
},
"required": ["id", "time", "type", "agent"],