Compare commits

..
Author SHA1 Message Date
Aiden Cline 8997d8662f fix(schema): keep legacy blobs on replay, import, and V1 migration
Freeze session.message.content.updated at the stored shape and translate
on replay, run persisted() on CLI session import, write native from the
V1 migration, regenerate the OpenAPI docs, and mirror text.ended state in
the client fold.
2026-09-22 00:37:59 -05:00
Aiden Cline e4dd41b033 test(core): use native for text and reasoning provider blobs 2026-09-21 23:39:41 -05:00
Aiden Cline 347bf1d749 Merge remote-tracking branch 'origin/v2' into message-native 2026-09-21 23:37:54 -05:00
Aiden Cline e7c4bffd38 refactor(schema): drop native migration and fix fixtures 2026-09-21 23:27:12 -05:00
Aiden Cline 85962e49b7 refactor(schema): rename message provider blobs to native 2026-09-20 21:41:03 -05:00
59 changed files with 237 additions and 860 deletions
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-8hc0Typ9cA1NDpToM0Pq7q3AutSp+I80Sakthq10F4c=",
"aarch64-linux": "sha256-7bzI4zWOdxuoMdMHMuqIAOgnuzWiHmpdCMYCYPbs+3c=",
"aarch64-darwin": "sha256-EWDHUSVH2AjsNvoKvzC392H6GjCWVh07cOg2x0mAsng=",
"x86_64-darwin": "sha256-7b2BRdRRVG+PMCSd/cm4E+slziwWIKLVttYkotdSrK4="
"x86_64-linux": "sha256-/jah4P2a0aGJNJ0aMdlFEbGHAXx33lqxHbc7UmpLFDg=",
"aarch64-linux": "sha256-L3SoZ24qNXicsE2FK6LATQjOmjPxY679RjugrUyO/1Y=",
"aarch64-darwin": "sha256-pI9NT8KWUPi+JCk6DYMqIAYmBqTbi13uL4VdNV3WS6Y=",
"x86_64-darwin": "sha256-rMAGhTTz46KA5Ya7E5J0af7Bn1QzTDhTaNfNJm8qfsw="
}
}
@@ -703,7 +703,7 @@ function messageContent(
return {
type: "reasoning",
text: part.text,
state: jsonRecord(part.metadata),
native: jsonRecord(part.metadata),
time: part.time
? { created: part.time.start, ...(part.time.end === undefined ? {} : { completed: part.time.end }) }
: undefined,
@@ -1,145 +0,0 @@
import { base64Encode } from "@opencode/util/encode"
import { expect, test } from "@playwright/test"
import { mockOpenCodeServer } from "../utils/mock-server"
import { expectSessionTitle } from "../utils/waits"
const directory = "C:/OpenCode/BtwSidebar"
const projectID = "proj_btw_sidebar"
const sessionID = "ses_btw_sidebar"
const otherSessionID = "ses_btw_sidebar_other"
const title = "Side question session"
const otherTitle = "Other side question session"
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
const sessionHref = (id: string) => `/server/${base64Encode(server)}/session/${id}`
test.use({ viewport: { width: 1440, height: 900 } })
test("answers /btw in the side panel without admitting a prompt", async ({ page }) => {
const generations: { sessionID: string; prompt: string }[] = []
const prompts: unknown[] = []
const generated = Promise.withResolvers<void>()
await mockOpenCodeServer(page, {
directory,
project: {
id: projectID,
worktree: directory,
vcs: "git",
name: "btw-sidebar",
time: { created: 1700000000000, updated: 1700000000000 },
sandboxes: [],
},
provider: {
all: [
{
id: "opencode",
name: "OpenCode",
models: { test: { id: "test", name: "Test", limit: { context: 200_000 } } },
},
],
connected: ["opencode"],
default: { providerID: "opencode", modelID: "test" },
},
sessions: [
{
id: sessionID,
slug: sessionID,
projectID,
directory,
title,
version: "dev",
time: { created: 1700000000000, updated: 1700000000000 },
},
{
id: otherSessionID,
slug: otherSessionID,
projectID,
directory,
title: otherTitle,
version: "dev",
time: { created: 1700000001000, updated: 1700000001000 },
},
],
pageMessages: () => ({ items: [] }),
vcsDiff: [],
onPrompt: (input) => prompts.push(input),
generate: async (input) => {
generations.push(input)
if (input.sessionID === otherSessionID) return { text: "This answer belongs to the **other session**." }
await generated.promise
return {
text: "The retry loop uses **exponential backoff** and stops after three attempts.\n\n```ts\nconst delay = 2 ** attempt\n```",
}
},
})
await page.addInitScript(
({ directory, server, sessionID, otherSessionID }) => {
localStorage.setItem(
"opencode.global.dat:server",
JSON.stringify({
projects: { local: [{ worktree: directory, expanded: true }] },
lastProject: { local: directory },
}),
)
localStorage.setItem(
"opencode.window.browser.dat:tabs",
JSON.stringify([
{ type: "session", server, sessionId: sessionID },
{ type: "session", server, sessionId: otherSessionID },
]),
)
},
{ directory, server, sessionID, otherSessionID },
)
await page.goto(sessionHref(sessionID))
await expectSessionTitle(page, title)
const editor = page.locator('[data-component="composer-editor"]')
await expect(editor).toBeEditable()
await editor.fill("/btw")
const suggestion = page.locator('[data-suggestion-id="session.btw"]')
await expect(suggestion).toBeVisible()
await suggestion.click()
await expect(editor).toHaveText("/btw ")
await editor.press("Enter")
const panel = page.locator('[data-slot="session-btw-panel"]')
await expect(panel).toBeHidden()
await expect(page.getByText("Add a question after /btw", { exact: true })).toBeVisible()
expect(generations).toEqual([])
expect(prompts).toEqual([])
await editor.fill("/btw how does the retry loop work?")
await editor.press("Enter")
const tab = page.getByRole("tab", { name: "/btw" })
await expect(panel).toBeVisible()
await expect(panel.getByRole("textbox")).toHaveCount(0)
await expect(panel.getByRole("status")).toContainText("Working")
await expect(tab).toHaveAttribute("data-selected", "")
generated.resolve()
await expect(panel.getByText("how does the retry loop work?", { exact: true })).toBeVisible()
await expect(panel.getByText("exponential backoff", { exact: false })).toBeVisible()
await expect(panel.getByText("const delay = 2 ** attempt", { exact: true })).toBeVisible()
expect(generations).toHaveLength(1)
expect(generations[0]?.sessionID).toBe(sessionID)
expect(generations[0]?.prompt).toContain("how does the retry loop work?")
expect(prompts).toEqual([])
await expect(editor).toHaveText("")
await page.locator(`[data-titlebar-tab-link][href="${sessionHref(otherSessionID)}"]`).click()
await expectSessionTitle(page, otherTitle)
await editor.fill("/btw what belongs here?")
await editor.press("Enter")
await expect(panel.getByText("other session", { exact: false })).toBeVisible()
await page.locator(`[data-titlebar-tab-link][href="${sessionHref(sessionID)}"]`).click()
await expectSessionTitle(page, title)
await expect(panel.getByText("exponential backoff", { exact: false })).toBeVisible()
await expect(panel.getByText("other session", { exact: false })).toHaveCount(0)
await page.reload()
await expectSessionTitle(page, title)
await expect(page.getByRole("tab", { name: "/btw" })).toHaveCount(0)
await expect(page.locator('[data-slot="session-btw-panel"]')).toHaveCount(0)
})
@@ -2,8 +2,6 @@ import { expect, test } from "@playwright/test"
import { fixture, pageMessages } from "../smoke/session-timeline.fixture"
import { mockOpenCodeServer } from "../utils/mock-server"
test.use({ permissions: ["clipboard-read", "clipboard-write"] })
test.beforeEach(async ({ page }) => {
const sessions = fixture.sessions.map((session) => ({ ...session }))
await mockOpenCodeServer(page, {
@@ -97,7 +95,7 @@ test("renames and closes the session tab from its context menu", async ({ page }
await expect(tab).toBeFocused()
await tab.press("Shift+F10")
await page.getByRole("menuitem", { name: "Rename", exact: true }).click()
const input = page.locator('[data-slot="tab-title"][contenteditable="plaintext-only"]')
const input = page.locator('[data-slot="tab-title"][contenteditable="true"]')
await expect(input).toBeFocused()
await input.fill("Renamed from tab")
await input.press("Enter")
@@ -114,28 +112,6 @@ test("renames and closes the session tab from its context menu", async ({ page }
).toBeVisible()
})
test("pastes rich text into the session tab title as plain text", async ({ page }) => {
const tab = page.locator('[data-slot="titlebar-tabs"] a').filter({ hasText: fixture.expected.targetTitle })
await tab.click({ button: "right" })
await page.getByRole("menuitem", { name: "Rename", exact: true }).click()
const input = page.locator('[data-slot="tab-title"][contenteditable="plaintext-only"]')
await expect(input).toBeFocused()
await page.evaluate(async () => {
await navigator.clipboard.write([
new ClipboardItem({
"text/html": new Blob(['<span style="font-size: 48px">Rich title</span>'], { type: "text/html" }),
"text/plain": new Blob(["Rich title"], { type: "text/plain" }),
}),
])
})
await input.press("ControlOrMeta+A")
await input.press("ControlOrMeta+V")
await expect(input).toHaveText("Rich title")
await expect(input.locator("*")).toHaveCount(0)
await input.press("Enter")
await expect(page.getByRole("heading", { name: "Rich title", exact: true })).toBeVisible()
})
test("renames an inactive tab without switching sessions", async ({ page }) => {
await page.getByRole("button", { name: "Home", exact: true }).click()
await page.locator('[data-component="home-session-row"]').filter({ hasText: fixture.expected.sourceTitle }).click()
@@ -143,7 +119,7 @@ test("renames an inactive tab without switching sessions", async ({ page }) => {
const tab = page.locator('[data-slot="titlebar-tabs"] a').filter({ hasText: fixture.expected.targetTitle })
await tab.click({ button: "right" })
await page.getByRole("menuitem", { name: "Rename", exact: true }).click()
const input = page.locator('[data-slot="tab-title"][contenteditable="plaintext-only"]')
const input = page.locator('[data-slot="tab-title"][contenteditable="true"]')
await expect(input).toBeFocused()
await input.fill("Inactive tab renamed")
await input.press("Tab")
-7
View File
@@ -197,13 +197,6 @@ const Group = HttpApiGroup.make("mock")
success: Json,
}),
)
.add(
HttpApiEndpoint.post("sessionGenerate", "/api/session/:sessionID/generate", {
params: SessionParams,
payload: Schema.Struct({ prompt: Schema.String }),
success: Json,
}),
)
.add(
HttpApiEndpoint.post("sessionSwitchAgent", "/api/session/:sessionID/agent", {
params: SessionParams,
-7
View File
@@ -42,7 +42,6 @@ export interface MockServerConfig {
sessionStatus?: Record<string, unknown> | (() => Record<string, unknown>)
inbox?: unknown[] | (() => unknown[])
onPrompt?: (input: { sessionID: string; body: Record<string, unknown> }) => void
generate?: (input: { sessionID: string; prompt: string }) => { text: string } | Promise<{ text: string }>
onInboxChange?: (input: { sessionID: string; inboxID: string; action: "cancel" | "steer" | "queue" }) => void
}
@@ -457,12 +456,6 @@ function mockHandlers(config: MockServerConfig, state: { cursors: Map<string, st
},
}
}),
sessionGenerate: (ctx) =>
Effect.promise(async () => ({
data: (await config.generate?.({ sessionID: ctx.params.sessionID, prompt: ctx.payload.prompt })) ?? {
text: "Side-question answer",
},
})),
sessionInboxCancel: (ctx) =>
Effect.sync(() =>
config.onInboxChange?.({ sessionID: ctx.params.sessionID, inboxID: ctx.params.inboxID, action: "cancel" }),
@@ -1,32 +0,0 @@
import { describe, expect, test } from "bun:test"
import { parseClientSlashCommand } from "./client-slash-command"
const options = [
{ id: "session.btw", trigger: "btw", arguments: true, type: "builtin" as const },
{ id: "custom.btw", trigger: "custom", type: "custom" as const },
{ id: "model.choose", trigger: "model", type: "builtin" as const },
]
describe("parseClientSlashCommand", () => {
test("parses inline and multiline arguments", () => {
expect(parseClientSlashCommand(options, "/btw why this approach?")).toEqual({
id: "session.btw",
input: "why this approach?",
})
expect(parseClientSlashCommand(options, "/btw\nwhy this approach?")).toEqual({
id: "session.btw",
input: "why this approach?",
})
})
test("accepts a bare argument command", () => {
expect(parseClientSlashCommand(options, "/btw")).toEqual({ id: "session.btw", input: "" })
})
test("rejects prefixes, custom commands, and ordinary slash commands", () => {
expect(parseClientSlashCommand(options, "/btwx nope")).toBeUndefined()
expect(parseClientSlashCommand(options, "/custom nope")).toBeUndefined()
expect(parseClientSlashCommand(options, "/model opus")).toBeUndefined()
expect(parseClientSlashCommand(options, "ask /btw later")).toBeUndefined()
})
})
@@ -1,24 +0,0 @@
type ClientSlashCommand = {
id: string
trigger: string
arguments?: boolean
type: "builtin" | "custom"
}
export function parseSlashCommand(text: string) {
if (!text.startsWith("/")) return
const separator = text.search(/\s/)
const name = text.slice(1, separator === -1 ? undefined : separator)
return { name, input: separator === -1 ? "" : text.slice(separator).trim() }
}
export function parseClientSlashCommand(options: readonly ClientSlashCommand[], text: string) {
const command = parseSlashCommand(text)
if (!command) return
const option = options.find((item) => item.type === "builtin" && item.arguments && item.trigger === command.name)
if (!option) return
return {
id: option.id,
input: command.input,
}
}
+3 -9
View File
@@ -24,7 +24,6 @@ import { createComposerHistory } from "./history/store"
import { composerPlaceholder } from "./placeholder"
import { createComposerSubmit } from "./submit"
import { useAttachmentDestination } from "./attachments/destination"
import { parseClientSlashCommand } from "./client-slash-command"
export type ComposerModel = ComposerEditorModel & {
readonly model: ComposerControls["model"]
@@ -74,7 +73,9 @@ export function createComposerModel(adapter: ComposerAdapter, options?: { queue?
return [...result, path]
}, [])
})
const attachments = createMemo(() => prompt.current().filter(isAttachment))
const attachments = createMemo(() =>
prompt.current().filter(isAttachment),
)
const commentCount = createMemo(() => {
if (mode() === "shell") return 0
return prompt.context.items().filter((item) => !!item.comment?.trim()).length
@@ -241,7 +242,6 @@ export function createComposerModel(adapter: ComposerAdapter, options?: { queue?
trigger: item.slash!,
title: item.title,
description: item.description,
arguments: item.slashArguments,
type: "builtin" as const,
})),
])
@@ -299,11 +299,6 @@ export function createComposerModel(adapter: ComposerAdapter, options?: { queue?
clear: comments.clear,
restore: restoreHistoryComments,
},
clientCommand: (text) => {
const selected = parseClientSlashCommand(slashCommands(), text)
if (!selected) return
return () => command.trigger(selected.id, "slash", selected.input)
},
})
const controller = createComposerEditor({
store: prompt.store,
@@ -345,7 +340,6 @@ export function createComposerModel(adapter: ComposerAdapter, options?: { queue?
if (item.kind !== "command") return
const selected = slashCommands().find((entry) => entry.id === item.id)
if (!selected || selected.type === "custom") return
if (selected.arguments) return
return () => command.trigger(selected.id, "slash")
},
attachments: {
+7 -60
View File
@@ -54,17 +54,14 @@ function submitInput(
mode: "normal" | "shell" = "normal",
commands: () => readonly { name: string }[] | undefined = () => [],
history: string[] = [],
clientCommand?: (text: string) => (() => void | Promise<void>) | undefined,
) {
return createComposerSubmit({
adapter,
mode: () => mode,
commands,
clientCommand,
editor: () => undefined,
queueScroll() {},
addToHistory: (prompt) =>
history.push(`add:${prompt.map((part) => ("content" in part ? part.content : part.type)).join("")}`),
addToHistory: (prompt) => history.push(`add:${prompt.map((part) => ("content" in part ? part.content : part.type)).join("")}`),
removeFromHistory: (prompt) =>
history.push(`remove:${prompt.map((part) => ("content" in part ? part.content : part.type)).join("")}`),
resetHistory() {},
@@ -121,61 +118,6 @@ function session(input: {
}
describe("Composer submission", () => {
test("runs a client argument command without admitting it to the session", async () => {
const state = createMemoryComposerState().capture()
state.set([
{ type: "text", content: "/btw why this approach?", start: 0, end: 23 },
{
type: "image",
id: "attachment",
filename: "diagram.png",
mime: "image/png",
blob: { id: "attachment", url: "data:image/png;base64,YQ==" },
},
])
state.context.add({ type: "file", path: "src/retry.ts" })
const calls: string[] = []
const target = session({
calls,
prompt: async () => {
throw new Error("client command must not call prompt")
},
})
const adapter: ActiveComposerAdapter = {
kind: "active-session",
state,
ready: () => true,
controls,
working: () => false,
session: () => target,
interrupt: async () => undefined,
submitted() {},
setEditor() {},
}
const history: string[] = []
await submitInput(adapter, undefined, "normal", undefined, history, (text) => {
expect(text).toBe("/btw why this approach?")
return () => {
calls.push("btw")
}
}).submit(new Event("submit"))
expect(calls).toEqual(["btw"])
expect(history).toEqual([])
expect(state.current()).toEqual([
{ type: "text", content: "", start: 0, end: 0 },
{
type: "image",
id: "attachment",
filename: "diagram.png",
mime: "image/png",
blob: { id: "attachment", url: "data:image/png;base64,YQ==" },
},
])
expect(state.context.items()).toHaveLength(1)
})
test("applies the captured agent and model before a custom command without passing over its overrides", async () => {
const state = createMemoryComposerState({ prompt: "/review changes" }).capture()
const calls: string[] = []
@@ -683,7 +625,12 @@ describe("Composer submission", () => {
},
}
await submitInput(adapter, undefined, "normal", () => catalog).submit(new Event("submit"))
await submitInput(
adapter,
undefined,
"normal",
() => catalog,
).submit(new Event("submit"))
expect(await sent.promise).toBe("command")
expect(requests).toEqual([
+10 -39
View File
@@ -11,7 +11,6 @@ import { setCursorPosition } from "./editor/dom"
import { blobDataUrl, resolveBlobUrl } from "@/runtime/persistence/drafts"
import { isAttachment } from "./prompt-parts"
import type { ModelSelection } from "@/providers/models/selection"
import { parseSlashCommand } from "./client-slash-command"
const submitting = new WeakSet<object>()
@@ -38,7 +37,6 @@ type ComposerSubmitInput = {
setMode: (mode: "normal" | "shell") => void
closePopover: () => void
delivery?: (alternate: boolean) => ComposerDelivery
clientCommand?: (text: string) => (() => void | Promise<void>) | undefined
notify: {
missingSelection: () => void
failed: (kind: "shell" | "command" | "prompt", error: unknown) => void
@@ -54,31 +52,15 @@ export function createComposerSubmit(input: ComposerSubmitInput) {
const submit = async (event: globalThis.Event, options?: { alternate?: boolean }) => {
event.preventDefault()
const prompt = clonePrompt(input.adapter.state.current())
const text = submissionText(prompt)
const clientCommand = input.mode() === "normal" ? input.clientCommand?.(text) : undefined
if (clientCommand) {
if (submitting.has(input.adapter.state)) return
submitting.add(input.adapter.state)
try {
clearClientCommand(input, prompt)
await clientCommand()
} catch (error) {
input.notify.failed("command", error)
} finally {
submitting.delete(input.adapter.state)
}
return
}
const submission = createComposerSubmission({
target: input.adapter.state,
prompt,
prompt: clonePrompt(input.adapter.state.current()),
context: input.adapter.state.context.items().map((item) => ({
...item,
selection: item.selection ? { ...item.selection } : undefined,
})),
})
const read = readSubmission(input, submission.prompt, submission.context, text, options?.alternate ?? false)
const read = readSubmission(input, submission.prompt, submission.context, options?.alternate ?? false)
if (!read) {
if (input.adapter.working() && input.adapter.kind === "active-session") void input.adapter.interrupt()
return
@@ -168,17 +150,6 @@ export function createComposerSubmit(input: ComposerSubmitInput) {
}
}
function clearClientCommand(input: ComposerSubmitInput, prompt: Prompt) {
input.adapter.state.set([{ type: "text", content: "", start: 0, end: 0 }, ...prompt.filter(isAttachment)], 0)
input.adapter.state.mode.set("normal")
input.setMode("normal")
input.closePopover()
}
function submissionText(prompt: Prompt) {
return prompt.map((part) => ("content" in part ? part.content : "")).join("")
}
function handoffMessage(value: ComposerSubmission): SessionMessageUser {
return {
id: value.id,
@@ -222,9 +193,9 @@ function readSubmission(
input: ComposerSubmitInput,
prompt: Prompt,
context: ComposerSubmission["context"],
text: string,
alternate: boolean,
): ComposerSubmission | undefined {
const text = prompt.map((part) => ("content" in part ? part.content : "")).join("")
const mode = input.mode()
if (mode === "shell" && !text.trim()) return
const images = prompt.filter((part): part is ImageAttachmentPart => part.type === "image")
@@ -327,11 +298,14 @@ async function sendShell(session: ComposerSession, value: ComposerSubmission) {
}
function findCommand(commands: ReturnType<ComposerSubmitInput["commands"]>, text: string) {
const parsed = parseSlashCommand(text)
if (!parsed || !commands?.some((item) => item.name === parsed.name)) return
return { command: parsed.name, arguments: parsed.input }
if (!text.startsWith("/")) return
const [name, ...arguments_] = text.split(" ")
const command = name.slice(1)
if (!commands?.some((item) => item.name === command)) return
return { command, arguments: arguments_.join(" ") }
}
async function sendCommand(
session: ComposerSession,
value: ComposerSubmission,
@@ -416,10 +390,7 @@ async function sendPrompt(
async function buildSubmissionRequest(session: ComposerSession, value: ComposerSubmission) {
const images = await Promise.all(
value.images.map(async (attachment) => ({
...attachment,
dataUrl: await blobDataUrl(attachment.blob, attachment.mime),
})),
value.images.map(async (attachment) => ({ ...attachment, dataUrl: await blobDataUrl(attachment.blob, attachment.mime) })),
)
return buildPromptRequest({
prompt: value.prompt,
@@ -47,7 +47,7 @@ export function HomeCommandPalette(props: {
state.cleanup = undefined
dialog.close()
if (item.type === "command") {
void item.option?.onSelect?.("palette")
item.option?.onSelect?.("palette")
return
}
if (item.type === "session") props.onSelectSession(item)
-7
View File
@@ -151,8 +151,6 @@ export const dict = {
"command.session.compact.description": "Summarize the session to reduce context size",
"command.session.fork": "Fork from message",
"command.session.fork.description": "Create a new session from a previous message",
"command.session.btw": "Ask a side question",
"command.session.btw.description": "Get a one-shot answer without adding to the conversation",
"command.session.export": "Export session",
"command.session.export.description": "Export the full session transcript as JSON",
"command.session.import": "Import session",
@@ -758,7 +756,6 @@ export const dict = {
"session.tab.browser": "Browser",
"session.tab.add": "Add tab",
"session.tab.context": "Context",
"session.tab.btw": "/btw",
"session.tab.unknown": "Unknown Session",
"session.panel.reviewAndFiles": "Review and files",
"session.error.notFound": "This session cannot be found",
@@ -936,10 +933,6 @@ export const dict = {
"common.dismiss": "Dismiss",
"common.moreCountSuffix": " (+{{count}} more)",
"common.requestFailed": "Request failed",
"session.btw.questionRequired": "Add a question after /btw",
"session.btw.error": "Couldnt answer that question",
"session.btw.retry": "Retry",
"session.btw.copy": "Copy answer",
"common.moreOptions": "More options",
"common.learnMore": "Learn more",
"common.rename": "Rename",
-114
View File
@@ -1,114 +0,0 @@
import { createEffect, onCleanup } from "solid-js"
import { createStore } from "solid-js/store"
import { useLanguage } from "@/runtime/i18n/language"
import { useServerSDK } from "@/runtime/server/client"
import { useCommand } from "@/shell/commands/command"
import { showToast } from "@/shell/notifications/toast"
import { SESSION_BTW_TAB } from "@/session/helpers"
import type { SessionModel } from "../model"
const instructions = [
"The user is asking a quick side question about the conversation so far.",
"Answer directly and concisely in markdown from what you already know.",
"Do not call any tools and do not take any actions.",
].join(" ")
const empty = {
question: "",
answer: "",
error: false,
pending: false,
}
export function createSessionBtw(session: SessionModel) {
const command = useCommand()
const language = useLanguage()
const server = useServerSDK()
const [states, setStates] = createStore<Record<string, typeof empty>>({})
const requests = new Map<string, number>()
const controllers = new Map<string, AbortController>()
const state = () => states[session.identity.sessionKey()] ?? empty
createEffect(() => {
const key = session.identity.sessionKey()
onCleanup(() => {
const controller = controllers.get(key)
if (!controller) return
controller.abort()
controllers.delete(key)
if (states[key]?.pending) setStates(key, { pending: false, error: true })
})
})
const open = () => {
session.layout.view().reviewPanel.open()
const tabs = session.layout.tabs()
if (tabs.active() !== SESSION_BTW_TAB) tabs.open(SESSION_BTW_TAB)
}
const ask = (value?: string) => {
const question = value?.trim()
if (!question) {
showToast({ title: language.t("session.btw.questionRequired") })
return
}
open()
const sessionID = session.identity.sessionID()
if (!sessionID) return
const key = session.identity.sessionKey()
const request = (requests.get(key) ?? 0) + 1
requests.set(key, request)
controllers.get(key)?.abort()
const controller = new AbortController()
controllers.set(key, controller)
const owner = session.ownership.capture()
setStates(key, { question, answer: "", error: false, pending: true })
return server.api.session
.generate(
{
sessionID,
prompt: [instructions, question].join("\n\n"),
},
{ signal: controller.signal },
)
.then((result) => {
owner.run(() => {
if (requests.get(key) !== request) return
setStates(key, { answer: result.text.trim(), pending: false })
})
})
.catch(() => {
owner.run(() => {
if (controller.signal.aborted || requests.get(key) !== request) return
setStates(key, { error: true, pending: false })
})
})
.finally(() => {
if (controllers.get(key) === controller) controllers.delete(key)
})
}
command.register("session.btw", () => [
{
id: "session.btw",
title: language.t("command.session.btw"),
description: language.t("command.session.btw.description"),
category: language.t("command.category.session"),
slash: "btw",
slashArguments: true,
hidden: true,
disabled: !session.isDesktop(),
onSelect: (_source, input) => ask(input),
},
])
return {
answer: () => state().answer,
error: () => state().error,
pending: () => state().pending,
question: () => state().question,
retry: () => ask(state().question),
}
}
export type SessionBtwModel = ReturnType<typeof createSessionBtw>
-80
View File
@@ -1,80 +0,0 @@
import { createEffect, createSignal, Match, Show, Switch } from "solid-js"
import { Button } from "@opencode/ui/button"
import { Icon } from "@opencode/ui/icon"
import { IconButton } from "@opencode/ui/icon-button"
import { ScrollView } from "@opencode/ui/scroll-view"
import { TextShimmer } from "@opencode/ui/text-shimmer"
import { Tooltip } from "@opencode/ui/tooltip"
import { Markdown } from "@opencode/session-ui/markdown"
import { useLanguage } from "@/runtime/i18n/language"
import { usePlatform } from "@/runtime/platform/platform"
import { showToast } from "@/shell/notifications/toast"
import type { SessionBtwModel } from "./model"
export function SessionBtwPanel(props: { btw: SessionBtwModel }) {
const language = useLanguage()
const platform = usePlatform()
const [copied, setCopied] = createSignal(false)
createEffect(() => {
props.btw.answer()
setCopied(false)
})
const copy = () => {
const answer = props.btw.answer()
if (!answer) return
void (platform.writeClipboardText?.(answer) ?? navigator.clipboard.writeText(answer)).then(
() => setCopied(true),
() => showToast({ title: language.t("common.requestFailed") }),
)
}
return (
<div class="flex h-full min-h-0 flex-col bg-v2-background-bg-base" data-slot="session-btw-panel">
<div class="flex shrink-0 items-start justify-between gap-3 border-b border-v2-border-border-base px-5 py-4">
<div class="min-w-0 text-13-regular text-text-weak">{props.btw.question()}</div>
<Show when={props.btw.answer()}>
<Tooltip value={copied() ? language.t("common.copied") : language.t("session.btw.copy")}>
<IconButton
size="small"
variant="ghost-muted"
icon={<Icon name={copied() ? "check" : "outline-copy"} />}
aria-label={copied() ? language.t("common.copied") : language.t("session.btw.copy")}
onClick={copy}
/>
</Tooltip>
</Show>
</div>
<div class="relative min-h-0 flex-1">
<Switch>
<Match when={props.btw.pending()}>
<div
data-component="session-working"
role="status"
class="flex h-9 items-center px-5 pt-3 text-[13px] font-[530] leading-text-compact"
>
<TextShimmer text={language.t("session.timeline.working")} active />
</div>
</Match>
<Match when={props.btw.error()}>
<div class="flex h-full flex-col items-center justify-center gap-3 px-8 pb-24 text-center">
<div class="text-13-regular text-text-weak">{language.t("session.btw.error")}</div>
<Button size="small" variant="outline" onClick={props.btw.retry}>
{language.t("session.btw.retry")}
</Button>
</div>
</Match>
<Match when={props.btw.answer()}>
<ScrollView class="absolute inset-0">
<div class="px-5 py-4 pb-8">
<Markdown text={props.btw.answer()} class="text-14-regular" />
</div>
</ScrollView>
</Match>
</Switch>
</div>
</div>
)
}
@@ -37,7 +37,6 @@ import { useSettings } from "@/settings/model"
import { createFileTabListSync } from "@/session/files/file-tab-scroll"
import {
SESSION_OPEN_FILE_TAB,
SESSION_BTW_TAB,
isSessionBrowserTab,
sessionBrowserTab,
createOpenSessionFileTab,
@@ -75,7 +74,6 @@ export function SessionSidePanel(props: {
size: Sizing
stacked?: boolean
browser: ReturnType<typeof createSessionBrowser>
btwPanel: () => JSX.Element
}) {
const layout = useLayout()
const settings = useSettings()
@@ -229,7 +227,7 @@ export function SessionSidePanel(props: {
})
const fileBrowserVisible = createMemo(() => {
const active = activeTab()
return active === SESSION_OPEN_FILE_TAB || active === activeFileTab()
return active !== "review" && active !== "context" && active !== "empty" && !isSessionBrowserTab(active)
})
const openFileKeybind = createMemo(() => command.keybindParts("file.open"))
const openBrowserKeybind = createMemo(() => command.keybindParts("browser.open"))
@@ -387,14 +385,6 @@ export function SessionSidePanel(props: {
/>
}
>
<Match when={tab === SESSION_BTW_TAB}>
<SortableTab tab={tab} index={tabs().all().indexOf(tab)} onTabClose={tabs().close}>
<div class="flex items-center gap-1.5">
<Icon name="bubble-5" size="small" />
<span>{language.t("session.tab.btw")}</span>
</div>
</SortableTab>
</Match>
<Match when={isSessionBrowserTab(tab)}>
<Show when={props.browser.tabs().find((item) => sessionBrowserTab(item.id) === tab)}>
{(item) => (
@@ -593,12 +583,6 @@ export function SessionSidePanel(props: {
</Tabs.Content>
</Show>
<Show when={activeTab() === SESSION_BTW_TAB}>
<Tabs.Content value={SESSION_BTW_TAB} class="flex h-full min-h-0 flex-col overflow-hidden">
{props.btwPanel()}
</Tabs.Content>
</Show>
<Show when={props.browser.opened()}>
<div
id={browserTabPanelID}
-19
View File
@@ -2,7 +2,6 @@ import { describe, expect, test } from "bun:test"
import { createMemo, createRoot } from "solid-js"
import { createStore } from "solid-js/store"
import {
SESSION_BTW_TAB,
SESSION_BROWSER_TAB,
sessionBrowserTab,
SESSION_OPEN_FILE_TAB,
@@ -237,24 +236,6 @@ describe("createSessionTabs", () => {
})
})
test("exposes the BTW tab without treating it as a file tab", () => {
createRoot((dispose) => {
const tabs = createMemo(() => ({ active: () => SESSION_BTW_TAB, all: () => [SESSION_BTW_TAB] }))
const result = createSessionTabs({
tabs,
pathFromTab: () => undefined,
normalizeTab: (tab) => tab,
})
expect(result.panelTabs()).toEqual([SESSION_BTW_TAB])
expect(result.openedTabs()).toEqual([])
expect(result.activeTab()).toBe(SESSION_BTW_TAB)
expect(result.activeFileTab()).toBeUndefined()
expect(result.closableTab()).toBe(SESSION_BTW_TAB)
dispose()
})
})
test("exposes one browser tab without treating it as a file tab", () => {
createRoot((dispose) => {
const tabs = createMemo(() => ({ active: () => SESSION_BROWSER_TAB, all: () => [SESSION_BROWSER_TAB] }))
+2 -8
View File
@@ -2,11 +2,10 @@ import { batch, createMemo, onCleanup, onMount, type Accessor } from "solid-js"
import { createStore } from "solid-js/store"
import { makeEventListener } from "@solid-primitives/event-listener"
import { same } from "@/runtime/persistence/equality"
import { isSessionBrowserTab, SESSION_BTW_TAB, SESSION_OPEN_FILE_TAB } from "@/shell/state/session-tabs"
import { isSessionBrowserTab, SESSION_OPEN_FILE_TAB } from "@/shell/state/session-tabs"
export {
SESSION_BROWSER_TAB,
SESSION_BTW_TAB,
SESSION_OPEN_FILE_TAB,
sessionBrowserTab,
isSessionBrowserTab,
@@ -64,17 +63,13 @@ export const createSessionTabs = (input: TabsInput) => {
{ equals: same },
)
const openedTabs = createMemo(
() =>
panelTabs().filter(
(tab) => tab !== SESSION_OPEN_FILE_TAB && tab !== SESSION_BTW_TAB && !isSessionBrowserTab(tab),
),
() => panelTabs().filter((tab) => tab !== SESSION_OPEN_FILE_TAB && !isSessionBrowserTab(tab)),
emptyTabs,
{ equals: same },
)
const activeTab = createMemo(() => {
const active = input.tabs().active()
if (active === "context") return active
if (active === SESSION_BTW_TAB) return active
if (active === SESSION_OPEN_FILE_TAB && openFileOpen()) return active
if (active && isSessionBrowserTab(active) && browser()) return active
if (active === "review" && review()) return active
@@ -94,7 +89,6 @@ export const createSessionTabs = (input: TabsInput) => {
const closableTab = createMemo<string | undefined>(() => {
const active = activeTab()
if (active === "context") return active
if (active === SESSION_BTW_TAB) return active
if (active === SESSION_OPEN_FILE_TAB && openFileOpen()) return active
if (active && isSessionBrowserTab(active) && browser()) return active
if (!openedTabs().includes(active)) return
-4
View File
@@ -14,8 +14,6 @@ import { ReviewPanel } from "./panel"
import { SessionReviewTab } from "./review-tab"
import type { ChangeMode, SessionReviewModel } from "./model"
import type { createSessionBrowser } from "../browser/model"
import type { SessionBtwModel } from "../btw/model"
import { SessionBtwPanel } from "../btw/panel"
const MobilePanelDrawer = lazy(async () => {
const { MobilePanelDrawer } = await import("@/shell/mobile-panel-drawer")
@@ -129,7 +127,6 @@ export function SessionMobileReview(props: { review: SessionReviewModel }) {
export function SessionDesktopReview(props: {
review: SessionReviewModel
browser: ReturnType<typeof createSessionBrowser>
btw: SessionBtwModel
present?: boolean
}) {
return (
@@ -156,7 +153,6 @@ export function SessionDesktopReview(props: {
size={props.review.screen.size}
stacked={props.review.screen.side.layout().stacked}
browser={props.browser}
btwPanel={() => <SessionBtwPanel btw={props.btw} />}
/>
</Suspense>
)
+1 -8
View File
@@ -39,7 +39,6 @@ import { createAnimatedPresence } from "@/runtime/animated-presence"
import { createSessionBrowser } from "./browser/model"
import { createTimelineCache } from "./timeline/cache"
import { ArtifactMarkdownProvider, ArtifactOpenerProvider } from "./files/open-artifact"
import { createSessionBtw } from "./btw/model"
const SessionMobileFiles = lazy(async () => {
const { SessionMobileFiles } = await import("./files/session-mobile-files")
@@ -72,7 +71,6 @@ function SessionScreenContent(props: { session: SessionModel; browser: ReturnTyp
return info ? projectForSession(info, server.ctx.sync.data.project) : undefined
})
const isDesktop = session.isDesktop
const btw = createSessionBtw(session)
const screen = createSessionScreenLayout(session)
const timeline = createSessionTimelineInteraction(session)
const timelineSearch = createTimelineSearchController({
@@ -453,12 +451,7 @@ function SessionScreenContent(props: { session: SessionModel; browser: ReturnTyp
setStore("sideReviewPresent", false)
}}
>
<SessionDesktopReview
review={review}
browser={browser}
btw={btw}
present={store.sideReviewPresent}
/>
<SessionDesktopReview review={review} browser={browser} present={store.sideReviewPresent} />
</div>
</Show>
</div>
+6 -7
View File
@@ -88,12 +88,11 @@ export interface CommandOption {
category?: string
keybind?: KeybindConfig
slash?: string
slashArguments?: boolean
suggested?: boolean
disabled?: boolean
hidden?: boolean
when?: (event: KeyboardEvent) => boolean
onSelect?: (source?: "palette" | "keybind" | "slash", input?: string) => void | Promise<void>
onSelect?: (source?: "palette" | "keybind" | "slash") => void
onHighlight?: () => (() => void) | void
}
@@ -390,9 +389,9 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
return map
})
const run = (id: string, source?: CommandSource, input?: string) => {
const run = (id: string, source?: CommandSource) => {
const option = optionMap().get(id)
return option?.onSelect?.(source, input)
option?.onSelect?.(source)
}
const showPalette = () => {
@@ -421,7 +420,7 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
if (!option) return
event.preventDefault()
event.stopPropagation()
void option.onSelect?.("keybind")
option.onSelect?.("keybind")
}
onMount(() => {
@@ -455,8 +454,8 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
return {
register,
trigger(id: string, source?: CommandSource, input?: string) {
return run(id, source, input)
trigger(id: string, source?: CommandSource) {
run(id, source)
},
keybind(id: string) {
const config = keybindConfig(id)
+1 -1
View File
@@ -166,7 +166,7 @@ export function createCommandPaletteModel(props: { filesOnly?: () => boolean; on
state.cleanup = undefined
dialog.close()
if (item.type === "command") {
void item.option?.onSelect?.("palette")
item.option?.onSelect?.("palette")
return
}
if (item.type === "session") {
+1 -1
View File
@@ -96,7 +96,7 @@ describe("layout persistence", () => {
test("keeps scoped state and salvages valid tab entries", () => {
const key = "local\u0000L3Byb2plY3Q/session"
const value = decode({
sessionTabs: { old: { all: ["old"] }, [key]: { all: ["a", null, "a", "b", "btw"], active: "btw" } },
sessionTabs: { old: { all: ["old"] }, [key]: { all: ["a", null, "a", "b"], active: 12 } },
sessionView: { old: { scroll: {} }, [key]: { scroll: {}, reviewOpen: ["a", null, "b"] } },
})
expect(value.sessionTabs).toEqual({ [key]: { all: ["a", "b"], active: undefined } })
+3 -4
View File
@@ -18,7 +18,7 @@ import type { ProjectAvatarVariant } from "@opencode/ui/project-avatar"
import { SessionStateKey } from "@/runtime/server/scope"
import { createSessionKeyReader, ensureSessionKey, pruneSessionKeys } from "./helpers"
import { requireServerKey } from "@/shell/routes/session"
import { closeSessionTab, openSessionTab, previewSessionTab, SESSION_BTW_TAB, type SessionTabs } from "./session-tabs"
import { closeSessionTab, openSessionTab, previewSessionTab, type SessionTabs } from "./session-tabs"
export { createSessionKeyReader, ensureSessionKey, pruneSessionKeys }
@@ -97,9 +97,8 @@ const normalizeSessionTabList = (path: ReturnType<typeof createPathHelpers> | un
const normalizeStoredSessionTabs = (key: string, tabs: SessionTabs) => {
const path = sessionPath(key)
return {
all: normalizeSessionTabList(path, tabs.all).filter((tab) => tab !== SESSION_BTW_TAB),
active:
tabs.active === SESSION_BTW_TAB ? undefined : tabs.active ? normalizeSessionTab(path, tabs.active) : tabs.active,
all: normalizeSessionTabList(path, tabs.all),
active: tabs.active ? normalizeSessionTab(path, tabs.active) : tabs.active,
}
}
@@ -1,6 +1,5 @@
export const SESSION_OPEN_FILE_TAB = "open-file"
export const SESSION_BROWSER_TAB = "browser"
export const SESSION_BTW_TAB = "btw"
export const sessionBrowserTab = (tabID: string) => `${SESSION_BROWSER_TAB}:${tabID}`
export const isSessionBrowserTab = (tab: string | undefined) =>
!!tab && (tab === SESSION_BROWSER_TAB || tab.startsWith(`${SESSION_BROWSER_TAB}:`))
+1 -1
View File
@@ -276,7 +276,7 @@ export function TabNavItem(props: {
"overflow-hidden text-clip whitespace-nowrap": !editing(),
"select-text": editing(),
}}
contenteditable={editing() ? "plaintext-only" : undefined}
contenteditable={editing() ? true : undefined}
onDblClick={openRename}
onKeyDown={(event) => {
event.stopPropagation()
@@ -298,7 +298,7 @@ export function TitlebarTabStrip(props: {
: [new PointerActivationConstraints.Distance({ value: 4 })],
preventActivation: (event) =>
isTabCloseTarget(event.target) ||
(event.target instanceof Element && !!event.target.closest("[contenteditable]")),
(event.target instanceof Element && !!event.target.closest('[contenteditable="true"]')),
}),
]}
modifiers={[
@@ -1,8 +1,9 @@
import { OpenCode } from "@opencode/client"
import { Service } from "@opencode/client/effect/service"
import { Session } from "@opencode/schema/session"
import { SessionMessage } from "@opencode/schema/session-message"
import { SessionTransfer } from "@opencode/schema/session-transfer"
import { Effect, Option, Schema } from "effect"
import { Effect, Option, Predicate, Schema } from "effect"
import { EOL } from "node:os"
import path from "node:path"
import { Commands } from "../../commands"
@@ -23,7 +24,13 @@ export default Runtime.handler(
catch: (cause) =>
new Error(`Failed to read session data: ${cause instanceof Error ? cause.message : String(cause)}`),
})
const data = yield* Schema.decodeUnknownEffect(Schema.fromJsonString(SessionTransfer.Data))(text)
const raw = yield* Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Unknown))(text)
// Exports written before provider blobs were renamed to `native` still carry the old keys.
const data = yield* Schema.decodeUnknownEffect(SessionTransfer.Data)(
Predicate.isObject(raw) && Array.isArray(raw.messages)
? { ...raw, messages: raw.messages.map(SessionMessage.persisted) }
: raw,
)
const encoded = Schema.encodeSync(SessionTransfer.Data)(data)
const server = yield* ServerConnection.resolve({
server: Option.getOrUndefined(input.server),
+1 -1
View File
@@ -564,7 +564,7 @@ export async function runNonInteractivePrompt(input: Input) {
messageID: message.id,
type: "reasoning",
text,
metadata: item.state,
metadata: item.native,
time: { start: message.time.created, end: timestamp },
}
renderedReasoning.set(key, item.text)
+23 -27
View File
@@ -509,12 +509,12 @@ export type PromptAgentAttachment = { name: string; mention?: PromptMention }
export type PromptSkillAttachment = { id: string; name: string; text?: string; mention?: PromptMention }
export type SessionMessageAssistantText = { type: "text"; text: string; state?: SessionMessageProviderState }
export type SessionMessageAssistantText = { type: "text"; text: string; native?: SessionMessageProviderState }
export type SessionMessageAssistantReasoning = {
type: "reasoning"
text: string
state?: SessionMessageProviderState
native?: SessionMessageProviderState
time?: { created: number; completed?: number }
}
@@ -1354,15 +1354,6 @@ export type SessionToolCalled = {
}
}
export type SessionMessageAssistantText1 = { type: "text"; text: string; state?: SessionMessageProviderState1 }
export type SessionMessageAssistantReasoning1 = {
type: "reasoning"
text: string
state?: SessionMessageProviderState1
time?: { created: number; completed?: number }
}
export type ToolContent1 = ToolTextContent | ToolFileContent1
export type FormNumberField = {
@@ -1762,7 +1753,7 @@ export type SessionMessageCompactionCompleted = {
status: "completed"
reason: "auto" | "manual"
model?: ModelRef
providerState?: SessionMessageProviderState
native?: SessionMessageProviderState
summary: string
recent: string
providerContext?: SessionProviderContext
@@ -2228,7 +2219,7 @@ export type SessionMessageAssistant = {
snapshot?: { start?: string; end?: string; files?: Array<string> }
finish?: "stop" | "length" | "tool-calls" | "content-filter" | "error" | "unknown"
rawFinish?: string
providerState?: SessionMessageProviderState
native?: SessionMessageProviderState
cost?: MoneyUSD
tokens?: TokenUsageInfo
error?: SessionStructuredError
@@ -2236,8 +2227,13 @@ export type SessionMessageAssistant = {
}
export type SessionMessageAssistantContentEncoded =
| SessionMessageAssistantText1
| SessionMessageAssistantReasoning1
| { type: "text"; text: string; state?: SessionMessageProviderState1 }
| {
type: "reasoning"
text: string
time?: { created: number; completed?: number }
state?: SessionMessageProviderState1
}
| SessionMessageAssistantTool1
export type FormInfo = { id: string; sessionID: string; title: string; metadata?: FormMetadata; fields: FormFields }
@@ -3102,11 +3098,11 @@ export type SessionImportInput = {
readonly agent: string
readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string }
readonly content: ReadonlyArray<
| { readonly type: "text"; readonly text: string; readonly state?: { readonly [x: string]: JsonValue } }
| { readonly type: "text"; readonly text: string; readonly native?: { readonly [x: string]: JsonValue } }
| {
readonly type: "reasoning"
readonly text: string
readonly state?: { readonly [x: string]: JsonValue }
readonly native?: { readonly [x: string]: JsonValue }
readonly time?: { readonly created: number; readonly completed?: number }
}
| {
@@ -3180,7 +3176,7 @@ export type SessionImportInput = {
readonly snapshot?: { readonly start?: string; readonly end?: string; readonly files?: ReadonlyArray<string> }
readonly finish?: "stop" | "length" | "tool-calls" | "content-filter" | "error" | "unknown"
readonly rawFinish?: string
readonly providerState?: { readonly [x: string]: JsonValue }
readonly native?: { readonly [x: string]: JsonValue }
readonly cost?: number
readonly tokens?: {
readonly input: number
@@ -3214,7 +3210,7 @@ export type SessionImportInput = {
readonly status: "completed"
readonly reason: "auto" | "manual"
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string }
readonly providerState?: { readonly [x: string]: JsonValue }
readonly native?: { readonly [x: string]: JsonValue }
readonly summary: string
readonly recent: string
readonly providerContext?: {
@@ -3419,11 +3415,11 @@ export type SessionImportInput = {
readonly agent: string
readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string }
readonly content: ReadonlyArray<
| { readonly type: "text"; readonly text: string; readonly state?: { readonly [x: string]: JsonValue } }
| { readonly type: "text"; readonly text: string; readonly native?: { readonly [x: string]: JsonValue } }
| {
readonly type: "reasoning"
readonly text: string
readonly state?: { readonly [x: string]: JsonValue }
readonly native?: { readonly [x: string]: JsonValue }
readonly time?: { readonly created: number; readonly completed?: number }
}
| {
@@ -3497,7 +3493,7 @@ export type SessionImportInput = {
readonly snapshot?: { readonly start?: string; readonly end?: string; readonly files?: ReadonlyArray<string> }
readonly finish?: "stop" | "length" | "tool-calls" | "content-filter" | "error" | "unknown"
readonly rawFinish?: string
readonly providerState?: { readonly [x: string]: JsonValue }
readonly native?: { readonly [x: string]: JsonValue }
readonly cost?: number
readonly tokens?: {
readonly input: number
@@ -3531,7 +3527,7 @@ export type SessionImportInput = {
readonly status: "completed"
readonly reason: "auto" | "manual"
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string }
readonly providerState?: { readonly [x: string]: JsonValue }
readonly native?: { readonly [x: string]: JsonValue }
readonly summary: string
readonly recent: string
readonly providerContext?: {
@@ -3736,11 +3732,11 @@ export type SessionImportInput = {
readonly agent: string
readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string }
readonly content: ReadonlyArray<
| { readonly type: "text"; readonly text: string; readonly state?: { readonly [x: string]: JsonValue } }
| { readonly type: "text"; readonly text: string; readonly native?: { readonly [x: string]: JsonValue } }
| {
readonly type: "reasoning"
readonly text: string
readonly state?: { readonly [x: string]: JsonValue }
readonly native?: { readonly [x: string]: JsonValue }
readonly time?: { readonly created: number; readonly completed?: number }
}
| {
@@ -3814,7 +3810,7 @@ export type SessionImportInput = {
readonly snapshot?: { readonly start?: string; readonly end?: string; readonly files?: ReadonlyArray<string> }
readonly finish?: "stop" | "length" | "tool-calls" | "content-filter" | "error" | "unknown"
readonly rawFinish?: string
readonly providerState?: { readonly [x: string]: JsonValue }
readonly native?: { readonly [x: string]: JsonValue }
readonly cost?: number
readonly tokens?: {
readonly input: number
@@ -3848,7 +3844,7 @@ export type SessionImportInput = {
readonly status: "completed"
readonly reason: "auto" | "manual"
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string }
readonly providerState?: { readonly [x: string]: JsonValue }
readonly native?: { readonly [x: string]: JsonValue }
readonly summary: string
readonly recent: string
readonly providerContext?: {
+8 -7
View File
@@ -846,7 +846,7 @@ export function createData(config: CreateDataInput) {
existing.error = undefined
existing.finish = undefined
existing.rawFinish = undefined
existing.providerState = undefined
existing.native = undefined
existing.time.created = event.data.started
existing.time.streamed = undefined
existing.time.completed = undefined
@@ -880,7 +880,7 @@ export function createData(config: CreateDataInput) {
assistant.time.completed = event.created
assistant.finish = event.data.finish
assistant.rawFinish = event.data.rawFinish
assistant.providerState = event.data.providerState
assistant.native = event.data.providerState
assistant.cost = event.data.cost
assistant.tokens = event.data.tokens
if (event.data.snapshot) assistant.snapshot = { ...assistant.snapshot, end: event.data.snapshot }
@@ -892,7 +892,7 @@ export function createData(config: CreateDataInput) {
assistant.time.completed = event.created
assistant.finish = event.data.finish ?? "error"
assistant.rawFinish = event.data.rawFinish
assistant.providerState = event.data.providerState
assistant.native = event.data.providerState
assistant.error = event.data.error
assistant.retry = undefined
if (event.data.cost !== undefined && event.data.tokens !== undefined) {
@@ -914,6 +914,7 @@ export function createData(config: CreateDataInput) {
case "session.text.ended":
message.editText(event.data.sessionID, event.data.assistantMessageID, (text) => {
text.text = event.data.text
text.native = event.data.state
})
return
case "session.tool.input.started":
@@ -984,7 +985,7 @@ export function createData(config: CreateDataInput) {
assistant.content.push({
type: "reasoning",
text: "",
state: event.data.state,
native: event.data.state,
time: { created: event.created },
})
})
@@ -998,7 +999,7 @@ export function createData(config: CreateDataInput) {
message.editReasoning(event.data.sessionID, event.data.assistantMessageID, (reasoning) => {
reasoning.text = event.data.text
reasoning.time = { created: reasoning.time?.created ?? event.created, completed: event.created }
if (event.data.state !== undefined) reasoning.state = event.data.state
if (event.data.state !== undefined) reasoning.native = event.data.state
})
return
case "session.retry.scheduled":
@@ -1105,7 +1106,7 @@ export function createData(config: CreateDataInput) {
status: "completed",
reason: event.data.reason,
model: event.data.model,
providerState: event.data.providerState,
native: event.data.providerState,
providerContext: event.data.providerContext,
summary: event.data.text,
recent: event.data.recent,
@@ -1120,7 +1121,7 @@ export function createData(config: CreateDataInput) {
status: "completed",
reason: event.data.reason,
model: event.data.model,
providerState: event.data.providerState,
native: event.data.providerState,
providerContext: event.data.providerContext,
summary: event.data.text,
recent: event.data.recent,
@@ -135,7 +135,7 @@ test.each(["started", "cancelled", "failed"])(
status: "completed",
summary: "Summary",
model,
providerState,
native: providerState,
providerContext,
cost: 0.01,
tokens,
@@ -379,13 +379,13 @@ export function transformSession(input: TransformInput): TransformResult {
return []
const content = owned.flatMap((part): Array<Record<string, unknown>> => {
if (part.type === "text")
return [{ type: "text", text: part.text, ...(part.metadata ? { state: part.metadata } : {}) }]
return [{ type: "text", text: part.text, ...(part.metadata ? { native: part.metadata } : {}) }]
if (part.type === "reasoning")
return [
{
type: "reasoning",
text: part.text,
...(part.metadata ? { state: part.metadata } : {}),
...(part.metadata ? { native: part.metadata } : {}),
time: { created: part.time.start, ...(part.time.end === undefined ? {} : { completed: part.time.end }) },
},
]
+1 -1
View File
@@ -60,7 +60,7 @@ export const latestCompaction = Effect.fnUntraced(function* (
})
export const decodeMessageRow = (row: typeof SessionMessageTable.$inferSelect) =>
decode({ ...row.data, id: row.id, type: row.type }).pipe(
decode(SessionMessage.persisted({ ...row.data, id: row.id, type: row.type })).pipe(
Effect.tap((message) =>
SessionProviderContext.isCheckpoint(message)
? SessionProviderContext.validate(message.providerContext)
+1 -1
View File
@@ -125,7 +125,7 @@ const promotedFromMessage = Effect.fn("SessionInbox.promotedFromMessage")(functi
if (row === undefined) return undefined
if (row.session_id !== sessionID || (row.type !== "user" && row.type !== "synthetic"))
return yield* new LifecycleConflict({ id })
const message = decodeMessage({ ...row.data, id: row.id, type: row.type })
const message = decodeMessage(SessionMessage.persisted({ ...row.data, id: row.id, type: row.type }))
const base = { id, sessionID, time: { created: message.time.created }, delivery }
if (message.type === "user")
return User.make({
+11 -9
View File
@@ -83,7 +83,9 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
"session.message.content.updated": (event) =>
updateOwnedAssistant(event.data.messageID, (draft) => {
draft.content = castDraft(
Schema.decodeUnknownSync(Schema.Array(SessionMessage.AssistantContent))(event.data.content),
Schema.decodeUnknownSync(Schema.Array(SessionMessage.AssistantContent))(
SessionMessage.persistedContent(event.data.content),
),
)
}),
"session.usage.recorded": () => Effect.void,
@@ -222,7 +224,7 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
draft.error = undefined
draft.finish = undefined
draft.rawFinish = undefined
draft.providerState = undefined
draft.native = undefined
draft.time.created = DateTime.makeUnsafe(event.data.started)
draft.time.streamed = undefined
draft.time.completed = undefined
@@ -263,7 +265,7 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
draft.time.completed = created
draft.finish = event.data.finish
draft.rawFinish = event.data.rawFinish
draft.providerState = castDraft(event.data.providerState)
draft.native = castDraft(event.data.providerState)
draft.cost = event.data.cost
draft.tokens = event.data.tokens
projectTerminalSnapshot(draft, event)
@@ -274,7 +276,7 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
draft.time.completed = created
draft.finish = event.data.finish ?? "error"
draft.rawFinish = event.data.rawFinish
draft.providerState = castDraft(event.data.providerState)
draft.native = castDraft(event.data.providerState)
draft.error = castDraft(event.data.error)
draft.retry = undefined
if (event.data.cost !== undefined && event.data.tokens !== undefined) {
@@ -294,7 +296,7 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
const match = latestText(draft)
if (match) {
match.text = event.data.text
match.state = castDraft(event.data.state)
match.native = castDraft(event.data.state)
}
})
},
@@ -382,7 +384,7 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
SessionMessage.AssistantReasoning.make({
type: "reasoning",
text: "",
state: event.data.state,
native: event.data.state,
time: { created },
}),
),
@@ -395,7 +397,7 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
if (match) {
match.text = event.data.text
match.time = { created: match.time?.created ?? created, completed: created }
if (event.data.state !== undefined) match.state = event.data.state
if (event.data.state !== undefined) match.native = event.data.state
}
})
},
@@ -431,7 +433,7 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
metadata: event.metadata ? { ...current.metadata, ...event.metadata } : current.metadata,
reason: event.data.reason,
model: event.data.model,
providerState: event.data.providerState,
native: event.data.providerState,
summary: event.data.text,
providerContext: event.data.providerContext,
recent: event.data.recent,
@@ -448,7 +450,7 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
metadata: event.metadata,
reason: event.data.reason,
model: event.data.model,
providerState: event.data.providerState,
native: event.data.providerState,
summary: event.data.text,
providerContext: event.data.providerContext,
recent: event.data.recent,
+1 -1
View File
@@ -228,7 +228,7 @@ const projectFork = Effect.fn("SessionProjector.projectFork")(function* (
function run(db: DatabaseService, event: MessageEvent) {
return Effect.gen(function* () {
const decodeRow = (row: typeof SessionMessageTable.$inferSelect) =>
decodeMessage({ ...row.data, id: row.id, type: row.type })
decodeMessage(SessionMessage.persisted({ ...row.data, id: row.id, type: row.type }))
const updateMessage = (message: SessionMessage.Info) => {
const encoded = encodeMessage(message)
const { id, type, ...data } = encoded
+3 -1
View File
@@ -107,7 +107,9 @@ const plan = Effect.fn("SessionRevert.plan")(function* (db: Database.Interface["
const decode = Schema.decodeUnknownEffect(SessionMessage.Info)
const files = new Map<RelativePath, Snapshot.ID>()
for (const row of rows) {
const message = yield* decode({ ...row.data, id: row.id, type: row.type }).pipe(Effect.orDie)
const message = yield* decode(SessionMessage.persisted({ ...row.data, id: row.id, type: row.type })).pipe(
Effect.orDie,
)
if (message.type !== "assistant" || !message.snapshot?.start) continue
for (const file of message.snapshot.files ?? [])
if (!files.has(file)) files.set(file, Snapshot.ID.make(message.snapshot.start))
@@ -162,7 +162,7 @@ const assistant = (message: SessionMessage.Assistant, model: Model.Ref, provider
text: item.text,
// Text can carry provider-bound state (e.g. Gemini thought signatures),
// which is only replayable against the model that produced it.
providerMetadata: reuseProviderMetadata ? providerMetadata(providerMetadataKey, item.state) : undefined,
providerMetadata: reuseProviderMetadata ? providerMetadata(providerMetadataKey, item.native) : undefined,
},
]
// Let the destination adapter handle readable reasoning after a model/provider switch.
@@ -172,7 +172,7 @@ const assistant = (message: SessionMessage.Assistant, model: Model.Ref, provider
{
type: "reasoning",
text: item.text,
providerMetadata: providerMetadata(providerMetadataKey, item.state),
providerMetadata: providerMetadata(providerMetadataKey, item.native),
},
]
: item.text.length > 0
+3 -3
View File
@@ -269,13 +269,13 @@ function sanitizeMessage(message: SessionMessage.Info): SessionMessage.Info {
return {
...content,
text: redact("text", message.id, content.text),
state: content.state ? { redacted: `text-state:${message.id}` } : undefined,
native: content.native ? { redacted: `text-native:${message.id}` } : undefined,
}
if (content.type === "reasoning")
return {
...content,
text: redact("reasoning", message.id, content.text),
state: content.state ? { redacted: `reasoning-state:${message.id}` } : undefined,
native: content.native ? { redacted: `reasoning-native:${message.id}` } : undefined,
}
return {
...content,
@@ -299,7 +299,7 @@ function sanitizeMessage(message: SessionMessage.Info): SessionMessage.Info {
summary: redact("compaction-summary", message.id, message.summary),
recent: redact("compaction-recent", message.id, message.recent),
...(message.status === "completed"
? { providerState: metadata("compaction-provider-state", message.id, message.providerState) }
? { native: metadata("compaction-native", message.id, message.native) }
: {}),
}
}
-9
View File
@@ -422,15 +422,6 @@ describe("MCP OAuth", () => {
expect(tokenRequests[0]?.get("grant_type")).toBe("refresh_token")
})
test("requests offline_access without forcing a consent prompt", async () => {
const { server } = authorizationServer({ scopes_supported: ["read", "offline_access"] })
const { url } = await Effect.runPromise(
Effect.scoped(start(server, { client_id: "client", scope: "read" })),
).finally(() => server.stop(true))
expect(url.searchParams.get("scope")).toBe("read offline_access")
expect(url.searchParams.has("prompt")).toBe(false)
})
test("forwards iss from the redirect so issuer-advertising servers can complete", async () => {
const { server } = authorizationServer({ authorization_response_iss_parameter_supported: true })
const result = await Effect.runPromise(
+4 -4
View File
@@ -1272,7 +1272,7 @@ describe("SessionTransfer", () => {
const runningCompactionID = SessionMessage.ID.create()
const completedCompactionID = SessionMessage.ID.create()
const model = Model.Ref.make({ id: Model.ID.make("model"), providerID: Provider.ID.make("provider") })
const providerState = { responseId: "summary-response" }
const native = { responseId: "summary-response" }
yield* transfer.import({
data: {
@@ -1328,7 +1328,7 @@ describe("SessionTransfer", () => {
status: "completed",
reason: "manual",
model,
providerState,
native,
summary: "summary",
recent: "recent",
time: { created: DateTime.makeUnsafe(9) },
@@ -1345,10 +1345,10 @@ describe("SessionTransfer", () => {
completedCompactionID,
])
expect(yield* Bus.latestSequence(db, sessionID)).toBe(4)
expect((yield* transfer.export({ sessionID })).messages.at(-1)).toMatchObject({ model, providerState })
expect((yield* transfer.export({ sessionID })).messages.at(-1)).toMatchObject({ model, native })
expect((yield* transfer.export({ sessionID, sanitize: true })).messages.at(-1)).toMatchObject({
model,
providerState: { redacted: `compaction-provider-state:${completedCompactionID}` },
native: { redacted: `compaction-native:${completedCompactionID}` },
})
}),
)
@@ -426,7 +426,7 @@ it.live("compaction hooks supply the summary instead of provider compaction", ()
status: "completed",
summary: "## Objective\n- hooked summary",
recent: "",
providerState: { responseId: "plugin" },
native: { responseId: "plugin" },
metadata: { plugin: "custom" },
tokens: { input: 10, output: 5 },
})
+2 -2
View File
@@ -690,7 +690,7 @@ describe("SessionProjector", () => {
type: "assistant",
finish: "stop",
rawFinish: "stop_sequence",
providerState: { response: "ended" },
native: { response: "ended" },
cost: Money.USD.make(1),
tokens: { input: 2, output: 3, reasoning: 4, cache: { read: 5, write: 6 } },
snapshot: { end: "snap_ended", files: ["src/ended.ts"] },
@@ -700,7 +700,7 @@ describe("SessionProjector", () => {
type: "assistant",
finish: "content-filter",
rawFinish: "blocked",
providerState: { response: "failed" },
native: { response: "failed" },
error: { type: "provider.invalid-request", message: "Failed" },
snapshot: { end: "snap_failed", files: ["src/failed.ts"] },
time: { completed: created },
@@ -72,7 +72,7 @@ describe("toLLMMessages", () => {
SessionMessage.AssistantReasoning.make({
type: "reasoning",
text: "",
state: { signature: "sig_1" },
native: { signature: "sig_1" },
}),
]),
],
@@ -711,7 +711,7 @@ Recent work
SessionMessage.AssistantReasoning.make({
type: "reasoning",
text: "Think",
state: { signature: "sig_1" },
native: { signature: "sig_1" },
}),
SessionMessage.AssistantTool.make({
type: "tool",
@@ -860,7 +860,7 @@ Recent work
SessionMessage.AssistantReasoning.make({
type: "reasoning",
text: "Think",
state: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" },
native: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" },
}),
],
time: { created, completed: created },
@@ -891,7 +891,7 @@ Recent work
SessionMessage.AssistantReasoning.make({
type: "reasoning",
text: "Think",
state: { signature: "signed" },
native: { signature: "signed" },
}),
],
time: { created, completed: created },
@@ -918,7 +918,7 @@ Recent work
SessionMessage.AssistantReasoning.make({
type: "reasoning",
text: "Partial thought",
state: { itemId: "rs_failed", reasoningEncryptedContent: null },
native: { itemId: "rs_failed", reasoningEncryptedContent: null },
}),
SessionMessage.AssistantTool.make({
type: "tool",
@@ -1016,7 +1016,7 @@ Recent work
SessionMessage.AssistantReasoning.make({
type: "reasoning",
text: "Visible thought",
state: { signature: "sig_old" },
native: { signature: "sig_old" },
}),
SessionMessage.AssistantTool.make({
type: "tool",
@@ -1110,7 +1110,7 @@ Recent work
SessionMessage.AssistantReasoning.make({
type: "reasoning",
text: "Visible thought",
state: { reasoningEncryptedContent: "encrypted" },
native: { reasoningEncryptedContent: "encrypted" },
}),
],
time: { created, completed: created },
@@ -1140,7 +1140,7 @@ Recent work
SessionMessage.AssistantText.make({
type: "text",
text: "Checking.",
state: { phase: "commentary" },
native: { phase: "commentary" },
}),
],
error: { type: "provider.unknown", message: "Interrupted after commentary" },
@@ -1171,7 +1171,7 @@ Recent work
SessionMessage.AssistantText.make({
type: "text",
text: "Checking.",
state: { phase: "commentary" },
native: { phase: "commentary" },
}),
],
time: { created, completed: created },
+8 -8
View File
@@ -2558,7 +2558,7 @@ describe("SessionRunnerLLM", () => {
expect(s.executions).toEqual(["x".repeat(4_000)])
expect((yield* s.messages).find((message) => message.type === "compaction")).toMatchObject({
model: { id: s.currentModel.id, providerID: s.currentModel.provider, variant },
providerState: { responseId: "summary" },
native: { responseId: "summary" },
})
// Compare wire content without the cache breakpoints that move to the new final message.
@@ -3508,12 +3508,12 @@ describe("SessionRunnerLLM", () => {
{
type: "reasoning",
text: "Signed thought",
state: { signature: "sig_1" },
native: { signature: "sig_1" },
},
{
type: "reasoning",
text: "Encrypted thought",
state: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" },
native: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" },
},
]),
])
@@ -3565,7 +3565,7 @@ describe("SessionRunnerLLM", () => {
{
type: "reasoning",
text: "thinking",
state: { reasoningField: "reasoning", reasoningDetails: details },
native: { reasoningField: "reasoning", reasoningDetails: details },
},
{ type: "text", text: "Hello world" },
]),
@@ -3609,7 +3609,7 @@ describe("SessionRunnerLLM", () => {
expect(yield* s.context).toMatchObject([
Expected.user("Check first"),
Expected.assistant({}, [
{ type: "text", text: "Checking.", state: { itemId: "msg_commentary", phase: "commentary" } },
{ type: "text", text: "Checking.", native: { itemId: "msg_commentary", phase: "commentary" } },
]),
])
@@ -4966,7 +4966,7 @@ describe("SessionRunnerLLM", () => {
type: "assistant",
finish: "stop",
rawFinish: "end_turn",
providerState: { responseId: "response-1", serviceTier: "priority" },
native: { responseId: "response-1", serviceTier: "priority" },
content: [Expected.text("Complete")],
},
])
@@ -4997,7 +4997,7 @@ describe("SessionRunnerLLM", () => {
type: "assistant",
finish: "content-filter",
rawFinish: "SAFETY",
providerState: {
native: {
responseId: "response-blocked",
refusal: { category: "safety", explanation: "Prompt blocked" },
},
@@ -5444,7 +5444,7 @@ describe("SessionRunnerLLM", () => {
{
type: "reasoning",
text: "",
state: { itemId: "rs_disconnected", reasoningEncryptedContent: "encrypted-state" },
native: { itemId: "rs_disconnected", reasoningEncryptedContent: "encrypted-state" },
},
]),
{ type: "synthetic", text: INCOMPLETE_STREAM_CONTINUATION },
+2 -2
View File
@@ -337,8 +337,8 @@ describe("V1Migration.transformSession", () => {
agent: "build",
model: { id: "model", providerID: "provider", variant: "fast" },
content: [
{ type: "text", text: "", state: { separator: true } },
{ type: "reasoning", text: "think", state: { provider: 1 }, time: { created: 21, completed: 22 } },
{ type: "text", text: "", native: { separator: true } },
{ type: "reasoning", text: "think", native: { provider: 1 }, time: { created: 21, completed: 22 } },
],
snapshot: { start: "snap_start", end: "snap_end", files: ["a.ts", "b.ts", "c.ts"] },
finish: "stop",
+4 -4
View File
@@ -17320,7 +17320,7 @@
"rawFinish": {
"type": "string"
},
"providerState": {
"native": {
"$ref": "#/components/schemas/Session.Message.ProviderState_4"
},
"cost": {
@@ -17349,7 +17349,7 @@
"text": {
"type": "string"
},
"state": {
"native": {
"$ref": "#/components/schemas/Session.Message.ProviderState_1"
},
"time": {
@@ -17396,7 +17396,7 @@
"text": {
"type": "string"
},
"state": {
"native": {
"$ref": "#/components/schemas/Session.Message.ProviderState"
}
},
@@ -17509,7 +17509,7 @@
"model": {
"$ref": "#/components/schemas/Model.Ref"
},
"providerState": {
"native": {
"$ref": "#/components/schemas/Session.Message.ProviderState_5"
},
"summary": {
+1 -1
View File
@@ -601,7 +601,7 @@ export namespace Compaction {
...Base,
reason: Started.data.fields.reason,
model: SessionMessage.CompactionCompleted.fields.model,
providerState: SessionMessage.CompactionCompleted.fields.providerState,
providerState: SessionMessage.CompactionCompleted.fields.native,
providerContext: SessionMessage.CompactionCompleted.fields.providerContext,
text: Schema.String,
recent: Schema.String,
+43 -6
View File
@@ -1,6 +1,6 @@
export * as SessionMessage from "./session-message.js"
import { Schema } from "effect"
import { Predicate, Schema, Struct } from "effect"
import { SessionProviderContext } from "./session-provider-context.js"
import { optional } from "./schema.js"
import { Content } from "./tool.js"
@@ -177,14 +177,14 @@ export interface AssistantText extends Schema.Schema.Type<typeof AssistantText>
export const AssistantText = Schema.Struct({
type: Schema.tag("text"),
text: Schema.String,
state: ProviderState.pipe(optional),
native: ProviderState.pipe(optional),
}).annotate({ identifier: "Session.Message.Assistant.Text" })
export interface AssistantReasoning extends Schema.Schema.Type<typeof AssistantReasoning> {}
export const AssistantReasoning = Schema.Struct({
type: Schema.tag("reasoning"),
text: Schema.String,
state: ProviderState.pipe(optional),
native: ProviderState.pipe(optional),
time: Schema.Struct({
created: DateTimeUtcFromMillis,
completed: DateTimeUtcFromMillis.pipe(optional),
@@ -196,7 +196,18 @@ export const AssistantContent = Schema.Union([AssistantText, AssistantReasoning,
)
export type AssistantContent = AssistantText | AssistantReasoning | AssistantTool
export const AssistantContentEncoded = Schema.toEncoded(AssistantContent).annotate({
/**
* Frozen at the shape older releases stored: text and reasoning carried their
* provider blob as `state`. Only replayed durable events still use it; read it
* through `persistedContent` before decoding as `AssistantContent`.
*/
export const AssistantContentEncoded = Schema.toEncoded(
Schema.Union([
Schema.Struct({ ...Struct.omit(AssistantText.fields, ["native"]), state: ProviderState.pipe(optional) }),
Schema.Struct({ ...Struct.omit(AssistantReasoning.fields, ["native"]), state: ProviderState.pipe(optional) }),
AssistantTool,
]).pipe(Schema.toTaggedUnion("type")),
).annotate({
identifier: "Session.Message.AssistantContent.Encoded",
})
export type AssistantContentEncoded = typeof AssistantContentEncoded.Type
@@ -222,7 +233,7 @@ export const Assistant = Schema.Struct({
}).pipe(optional),
finish: FinishReason.pipe(optional),
rawFinish: Schema.String.pipe(optional),
providerState: ProviderState.pipe(optional),
native: ProviderState.pipe(optional),
cost: Money.USD.pipe(optional),
tokens: TokenUsage.Info.pipe(optional),
error: SessionError.Error.pipe(optional),
@@ -258,7 +269,7 @@ export const CompactionCompleted = Schema.Struct({
status: Schema.tag("completed"),
reason: Schema.Literals(["auto", "manual"]),
model: Model.Ref.pipe(optional),
providerState: ProviderState.pipe(optional),
native: ProviderState.pipe(optional),
summary: Schema.String,
recent: Schema.String,
providerContext: SessionProviderContext.Info.pipe(optional),
@@ -318,3 +329,29 @@ export type Info =
| Compaction
| Idle
export type Type = Info["type"]
/** Reads messages stored before provider blobs were renamed to `native`. Tool parts are unchanged. */
export function persisted(input: unknown) {
if (!Predicate.isObject(input)) return input
const message =
input.type === "assistant" || input.type === "compaction" ? rename(input, "providerState", "native") : input
if (message.type !== "assistant" || !Array.isArray(message.content)) return message
const content = persistedContent(message.content)
return content === message.content ? message : { ...message, content }
}
/** Reads assistant content stored before text and reasoning blobs were renamed to `native`. */
export function persistedContent(content: ReadonlyArray<unknown>) {
const next = content.map((part) => {
if (!Predicate.isObject(part) || (part.type !== "text" && part.type !== "reasoning")) return part
return rename(part, "state", "native")
})
return next.every((part, index) => part === content[index]) ? content : next
}
function rename(record: Record<string, unknown>, from: string, to: string) {
if (record[from] === undefined || record[to] !== undefined) return record
const value = record[from]
const rest = Object.fromEntries(Object.entries(record).filter(([key]) => key !== from))
return { ...rest, [to]: value }
}
@@ -257,8 +257,8 @@ describe("contract hygiene", () => {
text: "hello",
})
expect(
SessionMessage.AssistantReasoning.make({ type: "reasoning", text: "thinking", state: { id: "opaque" } }),
).toEqual({ type: "reasoning", text: "thinking", state: { id: "opaque" } })
SessionMessage.AssistantReasoning.make({ type: "reasoning", text: "thinking", native: { id: "opaque" } }),
).toEqual({ type: "reasoning", text: "thinking", native: { id: "opaque" } })
expect(
SessionMessage.AssistantTool.make({
type: "tool",
+36 -2
View File
@@ -23,14 +23,48 @@ test("assistant terminal diagnostics remain optional and round trip", () => {
...assistant,
finish: "content-filter",
rawFinish: "SAFETY",
providerState: { promptFeedback: { blockReason: "SAFETY" } },
native: { promptFeedback: { blockReason: "SAFETY" } },
}),
),
).toMatchObject({
finish: "content-filter",
rawFinish: "SAFETY",
providerState: { promptFeedback: { blockReason: "SAFETY" } },
native: { promptFeedback: { blockReason: "SAFETY" } },
})
const legacy = SessionMessage.persisted({
...assistant,
providerState: { promptFeedback: { blockReason: "SAFETY" } },
content: [{ type: "text", text: "hello", state: { signature: "sig" } }],
})
expect(decode(legacy)).toMatchObject({
native: { promptFeedback: { blockReason: "SAFETY" } },
content: [{ type: "text", native: { signature: "sig" } }],
})
expect(encode(decode(legacy))).not.toHaveProperty("providerState")
expect(SessionMessage.persisted(assistant)).toBe(assistant)
})
test("replayed content updates keep the stored provider blob shape", () => {
const content = [
{ type: "text", text: "hello", state: { signature: "sig" } },
{ type: "reasoning", text: "think", state: { id: "rs_1" }, time: { created: 1 } },
{ type: "tool", id: "call", name: "read", state: { status: "streaming", input: "" }, time: { created: 1 } },
] as const
const decoded = Schema.decodeUnknownSync(SessionEvent.MessageContentUpdated.data)({
sessionID: "ses_terminal",
messageID: "msg_terminal",
content,
})
expect(decoded.content).toEqual(content)
expect(
Schema.decodeUnknownSync(Schema.Array(SessionMessage.AssistantContent))(
SessionMessage.persistedContent(decoded.content),
),
).toMatchObject([
{ type: "text", native: { signature: "sig" } },
{ type: "reasoning", native: { id: "rs_1" } },
{ type: "tool", state: { status: "streaming" } },
])
})
test("failed steps only override the assistant finish for content filters", () => {
@@ -190,13 +190,13 @@ export const streamingDocument = document(
{
type: "reasoning",
text: "## Checking the current contract\n\nThe assistant content is nested on each current Session message.",
state: { phase: "streaming" },
native: { phase: "streaming" },
time: { created: STORY_TIME + 11_100 },
},
{
type: "text",
text: "I have the typed rows in place. Next I am checking the streaming presentation",
state: { phase: "streaming" },
native: { phase: "streaming" },
},
],
}),
@@ -47,7 +47,7 @@ function MermaidTimeline(props: { streaming: boolean }) {
"```mermaid\nsequenceDiagram\n Client->>Server: Send prompt\n Server->>Model: Generate response\n Model-->>Client: Response\n" +
(completed() ? "```" : ""),
].join("\n\n"),
...(completed() ? {} : { state: { phase: "streaming" } }),
...(completed() ? {} : { native: { phase: "streaming" } }),
},
],
},
+1 -28
View File
@@ -727,7 +727,7 @@ function App(props: { pair?: DialogPairCredentials }) {
title: "New session",
suggested: route.data.type === "session",
category: "Session",
slash: { name: "new" },
slash: { name: "new", aliases: ["clear"] },
run: () => {
const model = local.model.current()
const agent = local.agent.current()
@@ -749,33 +749,6 @@ function App(props: { pair?: DialogPairCredentials }) {
dialog.clear()
},
},
{
name: "session.clear",
title: "Clear session",
category: "Session",
slash: { name: "clear" },
run: () => {
const model = local.model.current()
const agent = local.agent.current()
const current =
route.data.type === "session"
? (data.session.get(route.data.sessionID)?.location ?? location.ref)
: undefined
sessionTabs.close()
route.navigate({
type: "home",
location: newSessionLocation(
config.data.session.new_location,
data.location.default().directory,
current,
location.error?.location,
),
})
if (agent) local.agent.set(agent.id)
if (model) local.model.set(model)
dialog.clear()
},
},
{
name: "open.menu",
title: "Open session or project",
-67
View File
@@ -1,67 +0,0 @@
import { expect, test } from "bun:test"
import { createAppFixture } from "./fixture/app"
import { directory, json } from "./fixture/tui-client"
import { tmpdir } from "./fixture/fixture"
const location = { directory, project: { id: "project", directory, canonical: directory } }
const session = {
id: "ses_clear",
title: "Session to clear",
projectID: "project",
location: { directory },
agent: "build",
model: { providerID: "provider", id: "model" },
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 0, updated: 0 },
}
function render(state: string) {
return createAppFixture({
state,
args: { sessionID: session.id },
config: { animations: false, tabs: { mode: "on" } },
fetch: (url) => {
if (url.pathname === "/api/fs/list") return json({ location, data: [] })
if (url.pathname === "/api/location") return json(location)
if (url.pathname === "/api/session") return json({ data: [session], cursor: {} })
if (url.pathname === `/api/session/${session.id}`) return json({ data: session })
if (/^\/api\/session\/[^/]+\/(message|inbox|permission)$/.test(url.pathname))
return json({ data: [], cursor: {} })
if (url.pathname === "/api/agent")
return json({ location, data: [{ id: "build", mode: "primary", hidden: false, permissions: [] }] })
if (url.pathname === "/api/provider") return json({ location, data: [{ id: "provider", name: "Provider" }] })
if (url.pathname === "/api/model")
return json({
location,
data: [{ id: "model", providerID: "provider", name: "Model", variants: [] }],
})
},
})
}
test("/clear replaces the active session tab", async () => {
await using state = await tmpdir()
await using setup = await render(state.path)
await setup.waitForFrame((frame) => frame.includes("Session to clear"))
await setup.mockInput.typeText("/clear")
setup.mockInput.pressEnter()
const frame = await setup.waitForFrame((frame) => !frame.includes("Session to clear"))
expect(frame).not.toContain("Session to clear")
})
test("/new keeps the active session tab", async () => {
await using state = await tmpdir()
await using setup = await render(state.path)
await setup.waitForFrame((frame) => frame.includes("Session to clear"))
await setup.mockInput.typeText("/new")
setup.mockInput.pressEnter()
const frame = await setup.waitForFrame(
(frame) => frame.includes("New session") && frame.includes("Session to clear"),
)
expect(frame).toContain("Session to clear")
})
@@ -1,5 +1,5 @@
diff --git a/dist/index.cjs b/dist/index.cjs
index 635f1c0134274c89e7efbcae2adf3d9ecba575ee..5eaa7b451c8c7e12e51f61c2a835ff6f7a8ea0ae 100644
index 635f1c0134274c89e7efbcae2adf3d9ecba575ee..0054b779ff7b550ecf6d4008e177a888003f9c92 100644
--- a/dist/index.cjs
+++ b/dist/index.cjs
@@ -977,7 +977,6 @@ async function discoverMetadataWithFallback(serverUrl, wellKnownType, fetchFn, o
@@ -10,16 +10,8 @@ index 635f1c0134274c89e7efbcae2adf3d9ecba575ee..5eaa7b451c8c7e12e51f61c2a835ff6f
}
let response = await tryMetadataDiscovery(url, protocolVersion, fetchFn);
if (!opts?.metadataUrl && shouldAttemptFallback(response, issuer.pathname)) response = await tryMetadataDiscovery(new URL(`/.well-known/${wellKnownType}`, issuer), protocolVersion, fetchFn);
@@ -1158,7 +1157,6 @@ async function startAuthorization(authorizationServerUrl, { metadata, clientInfo
authorizationUrl.searchParams.set("redirect_uri", String(redirectUrl));
if (state) authorizationUrl.searchParams.set("state", state);
if (scope) authorizationUrl.searchParams.set("scope", scope);
- if (scope?.split(" ").includes("offline_access")) authorizationUrl.searchParams.append("prompt", "consent");
if (resource) authorizationUrl.searchParams.set("resource", resource.href);
return {
authorizationUrl,
diff --git a/dist/index.mjs b/dist/index.mjs
index f02ce3ca394e826fc27c9848dbe9a214bf6ba7a9..73a93a066c3540e131fa0770a74b4a7e8d9343a0 100644
index f02ce3ca394e826fc27c9848dbe9a214bf6ba7a9..8e27c9320e14ba8816fda752f250f6ddb1cc7ed0 100644
--- a/dist/index.mjs
+++ b/dist/index.mjs
@@ -974,7 +974,6 @@ async function discoverMetadataWithFallback(serverUrl, wellKnownType, fetchFn, o
@@ -30,11 +22,3 @@ index f02ce3ca394e826fc27c9848dbe9a214bf6ba7a9..73a93a066c3540e131fa0770a74b4a7e
}
let response = await tryMetadataDiscovery(url, protocolVersion, fetchFn);
if (!opts?.metadataUrl && shouldAttemptFallback(response, issuer.pathname)) response = await tryMetadataDiscovery(new URL(`/.well-known/${wellKnownType}`, issuer), protocolVersion, fetchFn);
@@ -1155,7 +1154,6 @@ async function startAuthorization(authorizationServerUrl, { metadata, clientInfo
authorizationUrl.searchParams.set("redirect_uri", String(redirectUrl));
if (state) authorizationUrl.searchParams.set("state", state);
if (scope) authorizationUrl.searchParams.set("scope", scope);
- if (scope?.split(" ").includes("offline_access")) authorizationUrl.searchParams.append("prompt", "consent");
if (resource) authorizationUrl.searchParams.set("resource", resource.href);
return {
authorizationUrl,
+4 -4
View File
@@ -17320,7 +17320,7 @@
"rawFinish": {
"type": "string"
},
"providerState": {
"native": {
"$ref": "#/components/schemas/Session.Message.ProviderState_4"
},
"cost": {
@@ -17349,7 +17349,7 @@
"text": {
"type": "string"
},
"state": {
"native": {
"$ref": "#/components/schemas/Session.Message.ProviderState_1"
},
"time": {
@@ -17396,7 +17396,7 @@
"text": {
"type": "string"
},
"state": {
"native": {
"$ref": "#/components/schemas/Session.Message.ProviderState"
}
},
@@ -17509,7 +17509,7 @@
"model": {
"$ref": "#/components/schemas/Model.Ref"
},
"providerState": {
"native": {
"$ref": "#/components/schemas/Session.Message.ProviderState_5"
},
"summary": {
+4 -4
View File
@@ -17320,7 +17320,7 @@
"rawFinish": {
"type": "string"
},
"providerState": {
"native": {
"$ref": "#/components/schemas/Session.Message.ProviderState_4"
},
"cost": {
@@ -17349,7 +17349,7 @@
"text": {
"type": "string"
},
"state": {
"native": {
"$ref": "#/components/schemas/Session.Message.ProviderState_1"
},
"time": {
@@ -17396,7 +17396,7 @@
"text": {
"type": "string"
},
"state": {
"native": {
"$ref": "#/components/schemas/Session.Message.ProviderState"
}
},
@@ -17509,7 +17509,7 @@
"model": {
"$ref": "#/components/schemas/Model.Ref"
},
"providerState": {
"native": {
"$ref": "#/components/schemas/Session.Message.ProviderState_5"
},
"summary": {