Compare commits

...
50 changed files with 999 additions and 437 deletions
@@ -11,6 +11,23 @@ story("raises the docked composer only in dark mode", async ({ mount, page }) =>
await expect(composer).toHaveCSS("background-color", "rgb(36, 36, 36)")
})
story("centers add menu shortcuts in a consistent column", async ({ mount, page }) => {
const component = await mount("opencode-composer-flow--empty-draft")
await component.locator('[data-action="composer-attach"]').click()
const shortcuts = page.locator('[role="menu"] [data-slot="menu-v2-item-shortcut"]')
await expect(shortcuts).toHaveCount(4)
const boxes = await shortcuts.evaluateAll((items) =>
items.map((item) => {
const box = item.getBoundingClientRect()
return { width: box.width, center: box.left + box.width / 2 }
}),
)
expect(new Set(boxes.map((box) => box.width)).size).toBe(1)
expect(new Set(boxes.map((box) => box.center)).size).toBe(1)
})
for (const draft of ["empty-draft", "multiline-draft", "mixed-attachments"]) {
story(`select all stays inside the composer with ${draft}`, async ({ mount, page }) => {
const component = await mount(`opencode-composer-flow--${draft}`)
@@ -0,0 +1,13 @@
import { expect, story } from "../../storybook/playwright/story"
story("keeps the comment options button pressed while its menu is open", async ({ mount, page }) => {
const component = await mount("ui-line-comment--display")
const trigger = component.locator('[data-slot="line-comment-v2-overflow"]')
const rest = await trigger.evaluate((element) => getComputedStyle(element).backgroundColor)
await trigger.click()
await expect(page.getByRole("menu")).toBeVisible()
await expect(trigger).toHaveAttribute("data-expanded", "")
await expect(trigger).not.toHaveCSS("background-color", rest)
})
@@ -8,7 +8,8 @@ const projectID = "proj_composer_editing"
const sessionID = "ses_composer_editing"
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
test("preserves the draft when a populated command menu triggers a built-in", async ({ page }) => {
test("keeps a narrow session composer contained when invoking a built-in", async ({ page }) => {
await page.setViewportSize({ width: 800, height: 600 })
await mockOpenCodeServer(page, {
directory,
project: {
@@ -40,6 +41,8 @@ test("preserves the draft when a populated command menu triggers a built-in", as
.poll(() => input.evaluate((element) => getComputedStyle(element, "::before").content))
.toBe(`"${String.fromCodePoint(0x200b)}"`)
await expectAppVisible(composer)
await expect(page.locator('[data-slot="session-chat-panel"]')).toHaveCSS("min-width", "0px")
await expect.poll(() => page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true)
await input.fill("keep me")
await composer.getByRole("button", { name: "Add images and files" }).click()
@@ -93,11 +93,18 @@ test("opens and searches project files inline", async ({ page }) => {
await contextButton.click()
await expect(panel.getByRole("tab", { name: "Context", selected: true })).toBeVisible()
await panel.getByRole("button", { name: "Open file" }).click()
await expect(panel.getByRole("tab", { name: "Open file", selected: true })).toBeVisible()
const openFileTab = panel.getByRole("tab", { name: "Open file" })
const openFileTabClose = openFileTab.locator("..").getByRole("button", { name: "Close tab" })
await expect(openFileTab).toHaveAttribute("data-selected", "")
await expect(openFileTab.locator("use")).toHaveAttribute("href", "#opencode-v2-icon-file-tree")
await expect(openFileTab.getByText("Open file", { exact: true }).locator("..")).not.toHaveClass(/italic/)
await expect(openFileTabClose).toHaveAttribute("data-variant", "ghost-muted")
await expect(openFileTabClose).toHaveCSS("opacity", "1")
await expect(sidebarToggle).toBeDisabled()
await expect(sidebar).toBeVisible()
await contextButton.click()
await expect(panel.getByRole("tab", { name: "Context", selected: true })).toBeVisible()
await expect(openFileTabClose).toHaveCSS("opacity", "0")
await expect(sidebar).toBeHidden()
await panel.getByRole("button", { name: "Open file" }).click()
const filter = panel.getByRole("combobox", { name: "Filter files" })
@@ -0,0 +1,69 @@
import { expect, test } from "@playwright/test"
import { fixture, pageMessages } from "../performance/timeline/session-timeline-stress.fixture"
import { installStressSessionTabs, stressSessionHref } from "../performance/timeline/timeline-test-helpers"
import { mockOpenCodeServer } from "../utils/mock-server"
for (const direction of ["ltr", "rtl"] as const) {
test(`session header groups controls and exposes server status in ${direction}`, async ({ page }) => {
await mockOpenCodeServer(page, {
directory: fixture.directory,
project: fixture.project,
sessions: fixture.sessions,
provider: fixture.provider,
pageMessages,
})
await installStressSessionTabs(page)
await page.addInitScript(() => {
const settings = JSON.parse(localStorage.getItem("settings.v3") ?? "{}")
localStorage.setItem(
"settings.v3",
JSON.stringify({ ...settings, general: { ...settings.general, showStatus: true } }),
)
})
await page.goto(stressSessionHref(fixture.targetID))
const header = page.locator("[data-session-title]")
const more = header.getByRole("button", { name: "More options", exact: true })
const review = header.getByRole("button", { name: "Toggle review", exact: true })
const details = header.getByRole("button", { name: "Session details", exact: true })
await expect(header.getByRole("heading")).toHaveText(fixture.expected.targetTitle)
await page.evaluate((direction) => document.documentElement.setAttribute("dir", direction), direction)
await expect(review).toBeVisible()
await expect(details).toBeVisible()
const status = page.locator('[data-slot="titlebar-v2"]').getByRole("button", { name: "Status" })
await expect(status).toBeVisible()
await expect
.poll(async () => {
const boxes = await Promise.all(
[header.getByRole("heading"), more, review, details].map((button) => button.boundingBox()),
)
const [title, menu, sidebar, summary] = boxes
if (!title || !menu || !sidebar || !summary) return false
return direction === "ltr"
? Math.abs(title.x + title.width - menu.x) <= 1 &&
menu.x + menu.width <= summary.x &&
summary.x + summary.width <= sidebar.x
: Math.abs(menu.x + menu.width - title.x) <= 1 &&
sidebar.x + sidebar.width <= summary.x &&
summary.x + summary.width <= menu.x
})
.toBe(true)
await review.click()
await expect(review).toHaveAttribute("aria-expanded", "true")
await expect(page.locator("#review-panel")).toBeVisible()
await review.click()
await expect(review).toHaveAttribute("aria-expanded", "false")
await more.click()
await expect(page.getByRole("menuitem", { name: "Server status", exact: true })).toHaveCount(0)
await page.keyboard.press("Escape")
await status.click()
const mcp = page.getByRole("tab", { name: "MCP", exact: true })
const plugins = page.getByRole("tab", { name: "Plugins", exact: true })
await expect(mcp).toHaveAttribute("aria-selected", "true")
await plugins.click()
await expect(plugins).toHaveAttribute("aria-selected", "true")
await page.keyboard.press("Escape")
await expect(mcp).toBeHidden()
})
}
@@ -22,8 +22,7 @@ test("navigates to a subagent child session missing from the session list", asyn
await expectSessionTitle(page, taskDescription)
await expect(page.getByRole("heading", { name: parentTitle })).toHaveCount(0)
const titlebarRight = page.locator("#opencode-titlebar-right")
await expect(titlebarRight.getByRole("button", { name: "Toggle review" })).toHaveCount(1)
await expect(page.getByRole("button", { name: "Toggle review", exact: true })).toBeVisible()
})
test("returns to the parent session with Escape", async ({ page }) => {
@@ -9,6 +9,32 @@ const sessionB = session("ses_tab_b", "Tab B session")
const sessionC = session("ses_tab_c", "Tab C session")
const unresolvedSessionID = "ses_tab_unresolved"
test("new session tab hugs its content", async ({ page }) => {
await mockServer(page)
await page.addInitScript(
({ server, sessionID, directory }) => {
localStorage.setItem(
"opencode.window.browser.dat:tabs",
JSON.stringify([
{ type: "session", server, sessionId: sessionID },
{ type: "draft", server, directory, draftID: "draft_tab_width" },
]),
)
},
{ server, sessionID: sessionA.id, directory: sessionA.directory },
)
const href = `/server/${base64Encode(server)}/session/${sessionA.id}`
await page.goto(href)
const sessionTab = page.locator(`[data-titlebar-tab-slot]:has(a[href="${href}"])`)
const draftTab = page.locator('[data-titlebar-tab-slot]:has(a[href^="/new-session?draftId="])')
await expect(draftTab).toContainText("New session")
const width = (await draftTab.boundingBox())?.width ?? 0
expect(width).toBeGreaterThan(100)
expect(width).toBeLessThan((await sessionTab.boundingBox())?.width ?? 0)
})
test("pressing mouse down on a tab navigates before mouse up", async ({ page }) => {
await mockServer(page)
await page.addInitScript(
@@ -121,7 +147,10 @@ test("vertical tabs show project details, resize, and navigate", async ({ page }
await mockServer(page)
await page.addInitScript(
({ server, sessionA, sessionB }) => {
localStorage.setItem("settings.v3", JSON.stringify({ appearance: { tabLayout: "vertical" } }))
localStorage.setItem(
"settings.v3",
JSON.stringify({ appearance: { tabLayout: "vertical" }, general: { showStatus: true } }),
)
localStorage.setItem(
"opencode.window.browser.dat:tabs",
JSON.stringify([
@@ -144,7 +173,26 @@ test("vertical tabs show project details, resize, and navigate", async ({ page }
await expect(tabA).toContainText(sessionA.title)
await expect(tabB).toContainText(sessionB.title)
await expect(tabB.locator('[data-slot="tab-project"]')).toHaveText("tab-project")
await expect(sidebar.getByRole("button", { name: "Home", exact: true })).toHaveText("Home")
await expect(sidebar.getByRole("button", { name: "New session" })).toBeVisible()
await expect(sidebar.locator('[data-slot="vertical-tabs-footer"]')).toBeVisible()
const status = sidebar.getByRole("button", { name: "Status", exact: true })
await expect(status).toBeVisible()
await expect
.poll(async () => {
const bounds = await sidebar.boundingBox()
const button = await status.boundingBox()
return !!bounds && !!button && bounds.x + bounds.width - button.x - button.width <= 12
})
.toBe(true)
await expect(page.locator('[data-slot="titlebar-v2"]')).toBeHidden()
await expect
.poll(async () => {
const button = await sidebar.getByRole("button", { name: "New session" }).boundingBox()
const tab = await tabA.boundingBox()
return !!button && !!tab && button.y + button.height < tab.y
})
.toBe(true)
await expect(page.locator('[data-slot="titlebar-tabs"]')).toHaveCount(0)
const handle = sidebar.locator('[data-component="resize-handle"]')
+4 -1
View File
@@ -555,7 +555,10 @@ export function ComposerEditorAddMenu(props: {
aria-label={props.title}
/>
<Menu.Portal>
<Menu.Content style={{ "min-width": "180px" }}>
<Menu.Content
class="[&_[data-slot=menu-v2-item-shortcut]]:w-8 [&_[data-slot=menu-v2-item-shortcut]]:justify-center"
style={{ "min-width": "180px" }}
>
<Menu.Item onSelect={props.onAttach} shortcut={props.attachShortcut}>
{props.attachLabel}
</Menu.Item>
@@ -359,6 +359,7 @@ export function SessionSidePanel(props: {
>
<Tabs.Trigger
value={SESSION_OPEN_FILE_TAB}
class="group"
onMiddleClick={() => tabs().close(SESSION_OPEN_FILE_TAB)}
closeButton={
<Tooltip
@@ -373,16 +374,29 @@ export function SessionSidePanel(props: {
placement="bottom"
gutter={10}
>
<Tabs.CloseButton
onClick={() => tabs().close(SESSION_OPEN_FILE_TAB)}
<IconButton
size="small"
variant="ghost-muted"
class="hover-reveal relative z-10 group-hover:opacity-100"
classList={{ "opacity-100": activeTab() === SESSION_OPEN_FILE_TAB }}
onPointerDown={(event) => {
event.preventDefault()
event.stopPropagation()
}}
onClick={(event) => {
event.preventDefault()
event.stopPropagation()
tabs().close(SESSION_OPEN_FILE_TAB)
}}
icon={<Icon name="xmark-small" />}
aria-label={language.t("common.closeTab")}
/>
</Tooltip>
}
hideCloseButton
>
<div class="flex items-center gap-1.5 italic">
<Icon name="open-file" size="small" />
<div class="flex items-center gap-1.5">
<Icon name="file-tree" size="small" />
<span>{language.t("command.file.open")}</span>
</div>
</Tabs.Trigger>
@@ -1,11 +1,10 @@
import { Show, type JSX } from "solid-js"
import { Show } from "solid-js"
import { Icon } from "@opencode-ai/ui/icon"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { Keybind } from "@opencode-ai/ui/keybind"
import { Tooltip } from "@opencode-ai/ui/tooltip"
export type SessionHeaderActionsState = {
status?: { label: string; content: () => JSX.Element }
reviewLabel: string
reviewKeybind: string[]
reviewVisible: boolean
@@ -16,13 +15,6 @@ export type SessionHeaderActionsState = {
export function SessionHeaderActions(props: { state: SessionHeaderActionsState }) {
return (
<div class="flex items-center gap-2">
<Show when={props.state.status}>
{(status) => (
<Tooltip appearance="standard" placement="bottom" value={status().label}>
{status().content()}
</Tooltip>
)}
</Show>
<Show when={props.state.reviewVisible}>
<Tooltip
class="shrink-0"
@@ -40,7 +32,7 @@ export function SessionHeaderActions(props: { state: SessionHeaderActionsState }
type="button"
variant="ghost-muted"
size="large"
class="!w-9 shrink-0"
class="shrink-0"
state={props.state.reviewOpened ? "pressed" : undefined}
onClick={props.state.onReviewToggle}
aria-label={props.state.reviewLabel}
@@ -1,4 +1,4 @@
import { createMemo } from "solid-js"
import { createMemo, Show } from "solid-js"
import { createMediaQuery } from "@solid-primitives/media"
import { useCommand } from "@/shell/commands/command"
import { useLanguage } from "@/runtime/i18n/language"
@@ -7,6 +7,7 @@ import { useSessionLayout } from "@/session/session-layout"
import { reviewTooltipKeybind } from "@/shell/commands/tooltip-keybind"
import { StatusPopover } from "@/shell/status/status-popover"
import { TitlebarRight } from "@/shell/titlebar/right-slot"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import { SessionHeaderActions, type SessionHeaderActionsState } from "./session-header-actions"
export function SessionHeader() {
@@ -15,14 +16,9 @@ export function SessionHeader() {
const settings = useSettings()
const { view } = useSessionLayout()
const status = settings.visibility.status
const isDesktop = createMediaQuery("(min-width: 768px)")
const actions = createMemo<SessionHeaderActionsState>(() => ({
status:
isDesktop() && status()
? { label: language.t("status.popover.trigger"), content: () => <StatusPopover /> }
: undefined,
reviewLabel: language.t("command.review.toggle"),
reviewKeybind: reviewTooltipKeybind(command),
reviewVisible: isDesktop(),
@@ -31,8 +27,15 @@ export function SessionHeader() {
}))
return (
<TitlebarRight>
<>
<TitlebarRight>
<Show when={isDesktop() && settings.visibility.status()}>
<Tooltip appearance="standard" placement="bottom" value={language.t("status.popover.trigger")}>
<StatusPopover />
</Tooltip>
</Show>
</TitlebarRight>
<SessionHeaderActions state={actions()} />
</TitlebarRight>
</>
)
}
+2 -3
View File
@@ -12,7 +12,6 @@ import {
} from "solid-js"
import { createStore } from "solid-js/store"
import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
import { SessionHeader } from "@/session/header/session-header"
import { MessageTimeline, SessionSummaryPanel } from "@/session/timeline/message-timeline"
import { useServer } from "@/runtime/server/current"
import { projectForSession } from "@/shell/layout/helpers"
@@ -273,12 +272,12 @@ export function SessionScreen(props: { session: SessionModel }) {
return (
<>
<SessionHeader />
<div class="flex-1 min-h-0 flex flex-col gap-2 px-2 pb-[var(--shell-bottom-inset,8px)] pt-[var(--shell-top-inset,8px)]">
<div ref={screen.panel.ref} class="relative flex-1 min-h-0 flex flex-col md:flex-row gap-2">
<div
classList={{
"@container relative z-10 shrink-0 flex flex-col min-h-0 h-full flex-1 md:flex-none transition-[width]": true,
"@container relative z-10 min-w-0 shrink-0 flex flex-col min-h-0 h-full flex-1 md:flex-none transition-[width]":
true,
"duration-[240ms] ease-[cubic-bezier(0.22,1,0.36,1)] will-change-[width] motion-reduce:transition-none":
!screen.size.active() && sidePresence.animate(),
"transition-none": screen.size.active() || !sidePresence.animate(),
@@ -32,6 +32,7 @@ import { parseCommentNote, readPromptPresentation } from "@/composer/comment-not
import { useCommand } from "@/shell/commands/command"
import { useSettings } from "@/settings/model"
import { SessionTitleHeader } from "../session-identity-header"
import { SessionHeader } from "@/session/header/session-header"
type BackgroundTask = {
id: string
@@ -732,6 +733,58 @@ function MessageTimelineView(
/>
</Show>
</Show>
<Show when={sessionID()} keyed>
{(id) => (
<Menu
gutter={6}
placement="bottom-end"
open={title.menuOpen}
onOpenChange={(open) => setTitle("menuOpen", open)}
>
<Menu.Trigger
as={IconButton}
icon={<Icon name="outline-dots" />}
variant="ghost-muted"
size="large"
class="shrink-0"
aria-label={language.t("common.moreOptions")}
aria-expanded={title.menuOpen}
/>
<Menu.Portal>
<Menu.Content
style={{ "min-width": "160px" }}
onCloseAutoFocus={(event) => {
if (!title.pendingRename) return
event.preventDefault()
setTitle("pendingRename", false)
openTitleEditor()
}}
>
<Show when={!parentID()}>
<Menu.Item
onSelect={() => {
setTitle("pendingRename", true)
setTitle("menuOpen", false)
}}
>
{language.t("common.rename")}
</Menu.Item>
<Menu.Item onSelect={() => void props.action.export(id)}>
{language.t("common.export")}...
</Menu.Item>
</Show>
<Show when={!parentID()}>
{/* TODO: Need a session archive API. */}
<Menu.Separator />
<Menu.Item onSelect={() => props.action.showDelete(id)}>
{language.t("common.delete")}...
</Menu.Item>
</Show>
</Menu.Content>
</Menu.Portal>
</Menu>
)}
</Show>
</div>
</div>
<Show when={sessionID()} keyed>
@@ -775,56 +828,7 @@ function MessageTimelineView(
</Popover>
)}
</Show>
<Show when={!parentID()}>
<Menu
gutter={6}
placement="bottom-end"
open={title.menuOpen}
onOpenChange={(open) => {
setTitle("menuOpen", open)
if (open) return
}}
>
<Menu.Trigger
as={IconButton}
icon={<Icon name="outline-dots" />}
variant="ghost-muted"
size="large"
aria-label={language.t("common.moreOptions")}
aria-expanded={title.menuOpen}
/>
<Menu.Portal>
<Menu.Content
style={{ width: "120px", "min-width": "120px" }}
onCloseAutoFocus={(event) => {
if (title.pendingRename) {
event.preventDefault()
setTitle("pendingRename", false)
openTitleEditor()
return
}
}}
>
<Menu.Item
onSelect={() => {
setTitle("pendingRename", true)
setTitle("menuOpen", false)
}}
>
{language.t("common.rename")}
</Menu.Item>
<Menu.Item onSelect={() => void props.action.export(id)}>
{language.t("common.export")}...
</Menu.Item>
{/* TODO: Need a session archive API. */}
<Menu.Separator />
<Menu.Item onSelect={() => props.action.showDelete(id)}>
{language.t("common.delete")}...
</Menu.Item>
</Menu.Content>
</Menu.Portal>
</Menu>
</Show>
<SessionHeader />
</div>
)}
</Show>
+1 -1
View File
@@ -65,7 +65,7 @@ export default function Layout(props: ParentProps) {
<aside
ref={(element) => setState("tabsMount", element)}
data-slot="vertical-tabs-sidebar"
class="relative flex min-h-0 shrink-0 flex-col bg-v2-background-bg-deep px-2.5 pb-[var(--shell-bottom-inset,8px)] pt-[var(--shell-top-inset,8px)]"
class="relative flex h-full min-h-0 shrink-0 flex-col bg-v2-background-bg-deep px-2.5 pb-[var(--shell-bottom-inset,8px)] pt-[var(--shell-top-inset,8px)]"
style={{
width: `${state.tabsWidth}px`,
"padding-bottom": "max(8px, env(safe-area-inset-bottom, 0px))",
@@ -7,6 +7,8 @@ import { useGlobal } from "@/runtime/server/runtime"
import { hasNonBlockingServiceIssue, hasServiceNeedingAttention, serverStatusDotClass } from "./indicator"
import { useData, useServer } from "@/runtime/server/current"
import { useWorkspaceLocation } from "@/workspaces/location"
import { useSettings } from "@/settings/model"
import { createMediaQuery } from "@solid-primitives/media"
const Body = lazy(() => import("./body").then((x) => ({ default: x.StatusPopoverBody })))
@@ -16,6 +18,8 @@ export function StatusPopover() {
const global = useGlobal()
const data = useData()
const sdk = useWorkspaceLocation()
const settings = useSettings()
const desktop = createMediaQuery("(min-width: 768px)")
const [shown, setShown] = createSignal(false)
const serverHealth = () => global.servers.health[server.key]?.healthy
const mcp = () => data.location.mcp.server.list({ directory: sdk().directory })
@@ -37,6 +41,8 @@ export function StatusPopover() {
serverHealth: serverHealth(),
attention: attention(),
issue: issue(),
placement: desktop() && settings.appearance.tabLayout() === "vertical" ? "top-start" : "bottom-end",
shift: desktop() && settings.appearance.tabLayout() === "vertical" ? 0 : -168,
label: language.t("status.popover.trigger"),
onOpenChange: setShown,
body: () => (
@@ -55,6 +61,8 @@ type StatusPopoverState = {
serverHealth: boolean | undefined
attention: boolean
issue: boolean
placement: "top-start" | "bottom-end"
shift: number
label: string
onOpenChange: (value: boolean) => void
body: () => JSX.Element
@@ -77,8 +85,8 @@ function StatusPopoverView(props: { state: StatusPopoverState }) {
class:
"[&_[data-slot=popover-body]]:p-0 w-[360px] max-w-[calc(100vw-40px)] bg-transparent border-0 shadow-none rounded-xl",
gutter: 4,
placement: "bottom-end" as const,
shift: -168,
placement: props.state.placement,
shift: props.state.shift,
}
return (
@@ -86,6 +86,10 @@
gap: 2px;
}
[data-slot="vertical-tabs-sidebar"] [data-titlebar-tab-list][data-orientation="vertical"] {
gap: 4px;
}
[data-titlebar-tab-slot] {
--tab-separator: var(--v2-background-bg-layer-03);
position: relative;
+9 -4
View File
@@ -393,8 +393,11 @@ export function DraftTabItem(props: {
data-active={props.active}
data-dragging={props.dragging}
data-state={props.active || props.pressed ? "pressed" : undefined}
class="group relative flex h-7 w-full min-w-0 flex-row items-center gap-1.5 overflow-hidden rounded-[6px] px-1.5 [container-type:inline-size] whitespace-nowrap"
classList={{ invisible: props.hidden }}
class="group relative flex h-7 min-w-0 flex-row items-center gap-1.5 overflow-hidden rounded-[6px] px-1.5 whitespace-nowrap"
classList={{
invisible: props.hidden,
"w-full [container-type:inline-size]": props.orientation === "vertical",
}}
onMouseDown={(event) => {
if (event.button !== MIDDLE_MOUSE_BUTTON) return
event.preventDefault()
@@ -427,14 +430,16 @@ export function DraftTabItem(props: {
if (props.suppressNavigation) return
props.onNavigate()
}}
class="flex h-full min-w-0 flex-1 flex-row items-center gap-1.5 text-[13px] font-medium text-v2-text-text-faint group-data-[active='true']:text-v2-text-text-base [-webkit-user-drag:none]"
class="flex h-full min-w-0 flex-row items-center gap-1.5 text-[13px] font-medium text-v2-text-text-faint group-data-[active='true']:text-v2-text-text-base [-webkit-user-drag:none]"
classList={{ "flex-1": props.orientation === "vertical", "flex-none pe-10": props.orientation !== "vertical" }}
>
<span class="flex size-4 shrink-0 items-center justify-center">
<Icon name="edit" />
</span>
<span
data-titlebar-tab-title
class="min-w-0 flex-1 overflow-hidden text-clip whitespace-nowrap outline-none leading-4"
class="min-w-0 overflow-hidden text-clip whitespace-nowrap outline-none leading-4"
classList={{ "flex-1": props.orientation === "vertical", "flex-none": props.orientation !== "vertical" }}
>
{props.title}
</span>
@@ -212,7 +212,7 @@ function DraftTabSlot(props: {
data-orientation={props.orientation}
class="relative flex"
classList={{
"w-56 min-w-7 max-w-56 flex-shrink": props.orientation === "horizontal",
"w-max min-w-7 max-w-56 shrink-0": props.orientation === "horizontal",
"w-full shrink-0": props.orientation === "vertical",
}}
>
+91 -44
View File
@@ -37,6 +37,7 @@ const windowsTitlebarHeight = 44 // Includes the content inset; matches the nati
const minTitlebarZoom = 0.25
const windowsControlsBaseWidth = 138 // 3 native Windows caption buttons at 46px each.
const macTrafficLightsBaseWidth = 84
const macTrafficLightsTopClearance = 28
export type TitlebarUpdate = {
version: string | undefined
@@ -63,6 +64,7 @@ export function Titlebar(props: {
const windows = createMemo(() => platform.platform === "desktop" && platform.os === "windows")
const linux = createMemo(() => platform.platform === "desktop" && platform.os === "linux")
const macTrafficLights = createMemo(() => mac() && !platform.windowFullscreen?.())
const macVerticalTabs = createMemo(() => mac() && !!props.verticalTabs)
const zoom = () => platform.webviewZoom?.() ?? 1
const titlebarZoom = () => (windows() ? Math.max(zoom(), minTitlebarZoom) : zoom())
const minHeight = () => {
@@ -105,6 +107,7 @@ export function Titlebar(props: {
const rightState = createMemo<TitlebarRightState>(() => ({
update: updateState(),
}))
const hideVerticalTitlebar = createMemo(() => !!props.verticalTabs && !windows())
const back = () => {
const next = backPath(history)
@@ -140,6 +143,7 @@ export function Titlebar(props: {
return (
<header
data-slot="titlebar-v2"
hidden={hideVerticalTitlebar()}
classList={{
"shrink-0 relative flex flex-row h-9 bg-v2-background-bg-deep overflow-visible": true,
"order-last": bottom(),
@@ -311,6 +315,48 @@ export function Titlebar(props: {
}
}
const toggleHome = () => tabs.toggleHome({ home: layout.route().type === "home", current: currentTab() })
const homeButton = (vertical = false) => (
<Show
when={vertical}
fallback={
<Tooltip
placement="bottom"
value={
<>
{language.t("home.title")}
<Keybind keys={command.keybindParts("home.toggle")} variant="neutral" />
</>
}
class="shrink-0"
>
<IconButton
type="button"
variant="ghost-muted"
size="large"
class="!w-9 shrink-0"
icon={<Icon name="grid-plus" />}
state={layout.route().type === "home" ? "pressed" : undefined}
onClick={toggleHome}
aria-label={language.t("home.title")}
aria-pressed={layout.route().type === "home"}
/>
</Tooltip>
}
>
<button
type="button"
data-action="vertical-tabs-home"
data-state={layout.route().type === "home" ? "pressed" : undefined}
class="mb-1 flex h-7 w-full shrink-0 items-center gap-1.5 rounded-[6px] px-1.5 text-[13px] leading-4 text-v2-text-text-faint hover:bg-v2-background-bg-layer-02 hover:text-v2-text-text-base data-[state=pressed]:bg-v2-background-bg-layer-02 data-[state=pressed]:text-v2-text-text-base"
onClick={toggleHome}
aria-label={language.t("home.title")}
aria-pressed={layout.route().type === "home"}
>
<Icon name="grid-plus" />
{language.t("home.title")}
</button>
</Show>
)
command.register("titlebar-home", () => [
{
@@ -386,36 +432,13 @@ export function Titlebar(props: {
"md:pl-4": !macTrafficLights(),
}}
>
<Show when={!mobile()}>
<Show when={!mobile() && !props.verticalTabs}>
<ChannelIndicator debugTools={props.debugTools} />
</Show>
<Show when={windows() || linux()}>
<WindowsAppMenu command={command} platform={platform} />
</Show>
<Show when={!mobile()}>
<Tooltip
placement="bottom"
value={
<>
{language.t("home.title")}
<Keybind keys={command.keybindParts("home.toggle")} variant="neutral" />
</>
}
class="shrink-0"
>
<IconButton
type="button"
variant="ghost-muted"
size="large"
class="!w-9 shrink-0"
icon={<Icon name="grid-plus" />}
state={layout.route().type === "home" ? "pressed" : undefined}
onClick={toggleHome}
aria-label={language.t("home.title")}
aria-pressed={layout.route().type === "home"}
/>
</Tooltip>
</Show>
<Show when={!mobile() && !props.verticalTabs}>{homeButton()}</Show>
<Show
when={!mobile()}
@@ -595,31 +618,53 @@ export function Titlebar(props: {
{(vertical) => (
<Show when={vertical().mount} keyed>
{(mount) => (
<Portal mount={mount}>
<TitlebarTabStrip
orientation="vertical"
tabs={tabsStore}
currentTab={currentTab()}
onNavigate={(tab, el) => {
tabs.select(tab)
el?.scrollIntoView({ behavior: "instant", block: "nearest" })
}}
onClose={(tab) => {
const index = tabsStore.findIndex((item) => tabKey(item) === tabKey(tab))
if (index !== -1) tabsStoreActions.closeTab(index)
}}
onReorder={(keys) => tabsStoreActions.reorder(keys)}
/>
<Portal
mount={mount}
ref={(element) => (element.className = "flex size-full min-h-0 flex-col")}
>
<Show when={macVerticalTabs()}>
<div
class="relative w-full shrink-0"
style={{ height: `${macTrafficLightsTopClearance / zoom()}px` }}
data-tauri-drag-region
></div>
</Show>
{homeButton(true)}
<button
type="button"
data-action="vertical-tabs-new-session"
class="mt-1 flex h-7 w-full shrink-0 items-center gap-1.5 rounded-[6px] px-1.5 text-[13px] leading-4 text-v2-text-text-faint hover:bg-v2-background-bg-layer-02 hover:text-v2-text-text-base"
class="flex h-7 w-full shrink-0 items-center gap-1.5 rounded-[6px] px-1.5 text-[13px] leading-4 text-v2-text-text-faint hover:bg-v2-background-bg-layer-02 hover:text-v2-text-text-base"
onClick={openNewTab}
aria-label={language.t("command.session.new")}
>
<Icon name="plus" />
{language.t("command.session.new")}
</button>
<div class="my-1 h-px w-full shrink-0 bg-v2-border-border-muted" aria-hidden="true" />
<div class="flex min-h-0 flex-1 flex-col gap-1">
<TitlebarTabStrip
orientation="vertical"
tabs={tabsStore}
currentTab={currentTab()}
onNavigate={(tab, el) => {
tabs.select(tab)
el?.scrollIntoView({ behavior: "instant", block: "nearest" })
}}
onClose={(tab) => {
const index = tabsStore.findIndex((item) => tabKey(item) === tabKey(tab))
if (index !== -1) tabsStoreActions.closeTab(index)
}}
onReorder={(keys) => tabsStoreActions.reorder(keys)}
/>
</div>
<div data-slot="vertical-tabs-footer" class="relative mt-auto h-9 w-full shrink-0">
<div class="absolute bottom-0 left-0 flex h-9 items-center">
<ChannelIndicator debugTools={props.debugTools} />
</div>
<div class="absolute bottom-0 right-0 flex h-9 items-center">
<TitlebarRightMount />
</div>
</div>
</Portal>
)}
</Show>
@@ -629,7 +674,7 @@ export function Titlebar(props: {
<Show when={!mobile()}>
<div class="flex-1" />
</Show>
<TitlebarRight state={rightState()} />
<TitlebarRight state={rightState()} mount={!props.verticalTabs} />
</div>
)
}}
@@ -652,13 +697,15 @@ type TitlebarRightState = {
update: TitlebarUpdatePillState
}
function TitlebarRight(props: { state: TitlebarRightState }) {
function TitlebarRight(props: { state: TitlebarRightState; mount?: boolean }) {
return (
<div class="relative z-20 flex shrink-0 items-center justify-end gap-0 overflow-visible">
<Show when={props.state.update.visible}>
<TitlebarUpdateIconButton state={props.state.update} />
</Show>
<TitlebarRightMount />
<Show when={props.mount !== false}>
<TitlebarRightMount />
</Show>
</div>
)
}
-12
View File
@@ -23,16 +23,6 @@ Guidelines:
Complete the user's search request efficiently and report your findings clearly.`
const PROMPT_COMPACTION = `You are an anchored context summarization assistant for coding sessions.
Summarize only the conversation history you are given. The newest turns may be kept verbatim outside your summary, so focus on the older context that still matters for continuing the work.
If the prompt includes a <previous-summary> block, treat it as the current anchored summary. Update it with the new history by preserving still-true details, removing stale details, and merging in new facts.
Always follow the exact output structure requested by the user prompt. Keep every section, preserve exact file paths and identifiers when known, and prefer terse bullets over paragraphs.
Do not answer the conversation itself. Do not mention that you are summarizing, compacting, or merging context. Respond in the same language as the conversation.`
const PROMPT_TITLE = `You are a title generator. You output ONLY a thread title. Nothing else.
<task>
@@ -144,8 +134,6 @@ export const Plugin = define({
item.name = Agent.Name.make("Compaction")
item.mode = "primary"
item.hidden = true
item.system = PROMPT_COMPACTION
item.permissions.push({ action: "*", resource: "*", effect: "deny" })
})
draft.update(Agent.ID.make("title"), (item) => {
+1
View File
@@ -56,6 +56,7 @@ export const Plugin = define({
// Compaction and committed reverts can strip reminders while the session's agent stays
// put. Reconcile per request, appending near the tail so the cached prefix stays warm.
yield* ctx.session.hook("context", (event) => {
if (event.purpose === "title") return Effect.void
const reminder = lastReminder(event.messages, enterReminder)
const missing = event.agent === plan && reminder !== enterReminder
const stale = event.agent !== plan && reminder === enterReminder
@@ -35,6 +35,7 @@ function make(id: string, select: (modelID: string) => string | undefined) {
effect: Effect.fn(`SystemPromptPlugin.${id}`)(function* (ctx) {
yield* ctx.session.hook("context", (event) =>
Effect.gen(function* () {
if (event.purpose === "title") return
if ((yield* ctx.agent.get({ agentID: event.agent })).data.system) return
const system = event.system[0]
if (!system) return
+1
View File
@@ -56,6 +56,7 @@ export const Plugin = define({
yield* ctx.session.hook("context", (event) =>
Effect.gen(function* () {
if (event.purpose === "title") return
const active = sessions.get(event.sessionID)
const settings = yield* loadSettings()
if (!settings) {
+216 -158
View File
@@ -1,6 +1,6 @@
export * as SessionCompaction from "./compaction.js"
import { LLMClient, LLMEvent, Message, type ContentPart } from "@opencode-ai/ai"
import { LLMClient, LLMEvent, LLMRequest, Message, type ContentPart } from "@opencode-ai/ai"
import { Agent } from "@opencode-ai/schema/agent"
import { SessionError } from "@opencode-ai/schema/session-error"
import { Context, Effect, Layer, Stream } from "effect"
@@ -18,6 +18,8 @@ import { Token } from "../util/token.js"
import { SessionUsage } from "./usage.js"
import { State } from "../state.js"
import { toLLMMessages } from "./runner/to-llm-message.js"
import type { AgentNotFoundError } from "./error.js"
import type { Instructions } from "../instructions/index.js"
const DEFAULT_BUFFER = 20_000
const DEFAULT_KEEP_TOKENS = 15_000
@@ -25,13 +27,16 @@ const OUTPUT_TOKEN_MAX = 32_000
const TOOL_OUTPUT_MAX_CHARS = 2_000
const IMAGE_TOKEN_ESTIMATE = 1_500
const PDF_TOKEN_ESTIMATE = 2_000
const SUMMARY_TEMPLATE = `Output exactly the Markdown structure shown inside <template> and keep the section order unchanged. Do not include the <template> tags in your response.
const SUMMARY_TEMPLATE = `You MUST use this format for your response (you may omit sections that aren't applicable). Do not include the <template> tags in your response.
<template>
## Objective
- [one or two brief sentences describing what the user is trying to accomplish]
## Important Details
- [constraints/preferences, decisions and why, important facts/assumptions, exact context needed to continue, or "(none)"]
## Requirements
- [constraints, preferences, requirements, and scope boundaries, or "(none)"]
## Decisions
- [decisions already made and why, or "(none)"]
## Work State
### Completed
@@ -44,19 +49,26 @@ const SUMMARY_TEMPLATE = `Output exactly the Markdown structure shown inside <te
- [blockers, failing commands, or unknowns; otherwise "(none)"]
## Next Move
1. [immediate concrete action, or "(none)"]
2. [next action if known, or "(none)"]
1. [ordered list of next actions, or "(none)"]
## Relevant Files
- [file or directory path: why it matters, or "(none)"]
</template>
List files and directories that are important to the conversation. Include paths outside the current working directory when relevant. If none are relevant, write "(none)".
- \`[exact path]\`: [why it matters]
Rules:
- Keep every section, even when empty.
## Additional Context
- [important facts, assumptions, unresolved questions, exact references, or other context needed to continue that does not fit above; when uncertain, preserve it here, or "(none)"]
</template>`
const SUMMARY_RULES = `Rules:
- Use terse bullets, not prose paragraphs.
- Preserve exact file paths, symbols, commands, error strings, URLs, and identifiers when known.
- Carry forward only user questions or requests that remain unanswered or require further action. Do not repeat ones that newer history has answered or resolved. Preserve exact wording when carrying one forward.
- Preserve consequential workflow state, including whether changes are uncommitted, committed, pushed, under review, or merged.
- Do not include ambient environment metadata such as the session ID, current working directory, repository root, current branch, or worktree path. The next agent receives current environment information separately. Include these details only when they directly affect the task.
- Do not mention the summary process or that context was compacted.`
const SUMMARY_HEADINGS = SUMMARY_TEMPLATE.split("\n").filter((line) => line.startsWith("##"))
export type Settings = {
auto: boolean
buffer: number
@@ -68,13 +80,13 @@ export type Draft = {
}
export type AutoInput = {
readonly session: SessionSchema.Info
readonly messages: readonly SessionMessage.Info[]
readonly resolved: SessionRunnerModel.Resolved
readonly context: SessionContext.Loaded
readonly prepare: SessionModelRequest.Interface["prepare"]
}
type RequiredInput = Pick<AutoInput, "messages" | "resolved"> & {
type RequiredInput = {
readonly messages: readonly SessionMessage.Info[]
readonly resolved: SessionRunnerModel.Resolved
readonly context: SessionContext.Loaded
}
@@ -83,20 +95,21 @@ export type ManualInput = {
readonly messages: readonly SessionMessage.Info[]
readonly inputID: SessionMessage.ID
readonly started?: boolean
/** Invoked after content planning, not when the caller captures the operation. */
readonly resolveModel: SessionContext.Interface["resolveModel"]
/** Empty compaction controls do not preflight model or instruction availability. */
readonly resolveContext: (
session: SessionSchema.Info,
) => Effect.Effect<
SessionContext.Loaded & { readonly instructionUpdate: string },
SessionRunnerModel.Error | AgentNotFoundError | Instructions.InitializationBlocked
>
readonly prepare: SessionModelRequest.Interface["prepare"]
}
type Plan = {
readonly session: SessionSchema.Info
readonly resolved: SessionRunnerModel.Resolved
type ExecuteInput = AutoInput & {
readonly reason: SessionMessage.Compaction["reason"]
readonly prompt: string
readonly recent: string
readonly inputID?: SessionMessage.ID
readonly started?: boolean
readonly prepare: SessionModelRequest.Interface["prepare"]
readonly instructionUpdate?: string
}
export type Outcome =
@@ -194,7 +207,9 @@ export const serializeToolContent = (content: SessionMessage.ToolStateCompleted[
)
.join("\n")
const serialize = (message: SessionMessage.Info) => {
const serializeRecentMessage = (message: SessionMessage.Info) => {
// Checkpoints and instruction updates are handled outside the serialized tail.
if (message.type === "compaction" || message.type === "system") return ""
if (message.type === "user") {
const files =
message.files?.map(
@@ -226,7 +241,6 @@ const serialize = (message: SessionMessage.Info) => {
})
.join("\n")
}
if (message.type === "system") return `[System update]: ${message.text}`
if (message.type === "synthetic") return `[Synthetic context]: ${message.text}`
if (message.type === "skill") return `[Skill activated: ${message.name}]\n${message.text}`
if (message.type === "shell")
@@ -236,70 +250,74 @@ const serialize = (message: SessionMessage.Info) => {
return ""
}
const select = (
messages: readonly SessionMessage.Info[],
tokens: number,
): { readonly head: string; readonly recent: string } | undefined => {
const conversation = messages
.filter((message) => message.type !== "compaction" && message.type !== "system")
.flatMap((message) => {
const text = serialize(message)
return text ? [{ message, text }] : []
})
if (conversation.length === 0) return undefined
let total = 0
let split = conversation.length
for (let index = conversation.length - 1; index >= 0; index--) {
const next = total + Token.estimate(conversation[index].text)
if (split < conversation.length && next > tokens) break
total = next
split = index
}
while (split > 0 && conversation[split].message.type !== "user") split--
if (split === 0) {
const latestUser = conversation.findLastIndex((item) => item.message.type === "user")
if (latestUser > 0) split = latestUser
}
const splitHistory = (messages: readonly SessionMessage.Info[], keepTokens: number) => {
const tailStart = findTailStart(messages, keepTokens)
if (tailStart === undefined) return
return {
head: conversation
.slice(0, split)
.map((item) => item.text)
.join("\n\n"),
recent: conversation
.slice(split)
.map((item) => item.text)
.join("\n\n"),
messages: messages.slice(0, tailStart),
recent: messages.slice(tailStart).map(serializeRecentMessage).filter(Boolean).join("\n\n"),
}
}
export const buildPrompt = (input: { readonly previousSummary?: string; readonly context: readonly string[] }) =>
[
input.previousSummary
? `Update the anchored summary below using the conversation history below.\nPreserve still-true details, remove stale details, and merge in the new facts.\n<previous-summary>\n${input.previousSummary}\n</previous-summary>`
: "Create a new anchored summary from the conversation history.",
SUMMARY_TEMPLATE,
"The following is the conversation history:",
...input.context,
].join("\n\n")
const findTailStart = (messages: readonly SessionMessage.Info[], keepTokens: number) => {
const conversation = messages.flatMap((message, index) => {
const text = serializeRecentMessage(message)
return text ? [{ message, text, index }] : []
})
if (conversation.length === 0) return undefined
// Keep at least the newest entry, even if it exceeds the allowance.
let total = 0
let start = conversation.length
for (let index = conversation.length - 1; index >= 0; index--) {
const next = total + Token.estimate(conversation[index].text)
if (start < conversation.length && next > keepTokens) break
total = next
start = index
}
// Start at a user boundary so an assistant's tool calls and results stay together.
while (start > 0 && conversation[start].message.type !== "user") start--
if (start > 0) return conversation[start].index
// If everything fits, retain only the latest exchange to leave an older prefix to summarize.
const latestUser = conversation.findLastIndex((item) => item.message.type === "user")
if (latestUser > 0) return conversation[latestUser].index
const planContent = (messages: readonly SessionMessage.Info[], tokens: number) => {
const selected = select(messages, tokens)
if (!selected) return
const previousSummary = messages.findLast(
(message): message is SessionMessage.CompactionCompleted =>
message.type === "compaction" && message.status === "completed",
)
const previousRecent = previousSummary?.recent ?? ""
const summarizeRecent = !previousRecent && !selected.head
return {
prompt: buildPrompt({
previousSummary: previousSummary?.summary,
context: summarizeRecent ? [selected.recent] : [previousRecent, selected.head].filter(Boolean),
}),
recent: summarizeRecent ? "" : selected.recent,
}
// Without an older retained tail to summarize, summarize everything and retain nothing.
return previousSummary?.recent ? conversation[0].index : messages.length
}
export const buildPrompt = (update: boolean) => {
const shared = [
"Summarize only the history shown. More recent context may be retained and presented after this summary.",
SUMMARY_TEMPLATE,
SUMMARY_RULES,
"Do not continue the task or call tools.",
"Return only the structured summary in the requested format. Do not include a preamble, explanation, or other commentary.",
]
if (update) {
return [
"Update the existing checkpoint in the conversation above into one consolidated summary.",
"Newer history always takes precedence over the existing checkpoint. Preserve previous information unless newer history clearly contradicts, supersedes, resolves, or makes it stale. When uncertain and there is no conflict, retain it under Additional Context.",
"Incorporate newer requirements, decisions, progress, and context. Reconcile Work State and Next Move: move completed work out of Active, remove resolved blockers and answered questions, and preserve unresolved or pending work.",
"Return only the updated Markdown sections. Do not reproduce the `<conversation-checkpoint>`, `<summary>`, or `<recent-context>` wrapper tags from the previous checkpoint.",
...shared,
].join("\n\n")
}
return [
"You MUST summarize the conversation above into a structured summary that will be given to another agent to resume the work.",
...shared,
].join("\n\n")
}
const hasSummarySection = (summary: string) =>
summary.split("\n").some((line) => SUMMARY_HEADINGS.includes(line.trim()))
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
@@ -326,13 +344,22 @@ export const layer = Layer.effect(
yield* bus.publish(SessionEvent.Compaction.Failed, input)
return { status: "failed" as const, error: input.error }
})
const execute = Effect.fn("SessionCompaction.execute")(function* (plan: Plan) {
if (!plan.started)
const execute = Effect.fn("SessionCompaction.execute")(function* (input: ExecuteInput) {
const context = input.context
const history = splitHistory(context.messages, state.get().tokens)
if (!history)
return yield* failed({
sessionID: context.session.id,
reason: input.reason,
error: { type: "compaction.unavailable", message: "Nothing to compact yet" },
inputID: input.inputID,
})
if (!input.started)
yield* bus.publish(SessionEvent.Compaction.Started, {
sessionID: plan.session.id,
reason: plan.reason,
recent: plan.recent,
inputID: plan.inputID,
sessionID: context.session.id,
reason: input.reason,
recent: history.recent,
inputID: input.inputID,
})
const chunks: string[] = []
@@ -341,92 +368,125 @@ export const layer = Layer.effect(
const recordUsage = Effect.suspend(() =>
usage
? bus.publish(SessionEvent.UsageRecorded, {
sessionID: plan.session.id,
sessionID: context.session.id,
source: "compaction",
...usage,
})
: Effect.void,
)
const prepared = yield* plan.prepare({
scope: { session: plan.session, agentID: Agent.ID.make("compaction"), model: plan.resolved },
transcript: { system: [], messages: [Message.user(plan.prompt)] },
contextHooks: false,
const transcript = SessionModelRequest.baseTranscript({
agent: context.agent.info,
model: context.model,
tools: context.tools,
initial: context.initial,
messages: history.messages,
})
yield* llm.stream(prepared.request, prepared.options).pipe(
Stream.runForEach((event) => {
if (LLMEvent.is.providerError(event))
failure = {
type: event.classification === "context-overflow" ? "provider.invalid-request" : "provider.error",
message: event.message,
}
if (LLMEvent.is.textDelta(event)) {
chunks.push(event.text)
return bus.publish(SessionEvent.Compaction.Delta, {
sessionID: plan.session.id,
text: event.text,
})
}
if (LLMEvent.is.stepFinish(event)) {
const step = SessionUsage.record(event.usage, plan.resolved.cost)
usage = usage ? SessionUsage.add(usage, step) : step
}
return Effect.void
}),
Effect.catchTag("AI.Error", (error) =>
Effect.sync(() => {
failure = toSessionError(error)
}),
),
Effect.onInterrupt(() =>
recordUsage.pipe(
Effect.andThen(
plan.reason === "auto"
? failed({
sessionID: plan.session.id,
reason: plan.reason,
error: { type: "compaction.interrupted", message: "Compaction was interrupted" },
inputID: plan.inputID,
}).pipe(Effect.asVoid)
: Effect.void,
const prepared = yield* input.prepare({
purpose: "compaction",
scope: {
session: context.session,
agentID: Agent.ID.make("compaction"),
contextAgentID: context.agent.id,
model: context.model,
tools: context.tools,
},
transcript: {
system: transcript.system,
messages: [
...transcript.messages,
...(input.instructionUpdate ? [Message.system(input.instructionUpdate)] : []),
Message.user(
buildPrompt(
history.messages.some((message) => message.type === "compaction" && message.status === "completed"),
),
),
),
),
)
],
},
})
// Ignored tool calls never enter the follow-up history or need fabricated results.
for (let attempt = 0; attempt < 2; attempt++) {
chunks.length = 0
yield* llm
.stream(
attempt === 0
? prepared.request
: LLMRequest.update(prepared.request, {
messages: [
...prepared.request.messages,
Message.user(
"The previous response did not fill in the required summary template. Do not call tools. Return the summary as text using the exact section headings from the template.",
),
],
}),
prepared.options,
)
.pipe(
Stream.runForEach((event) => {
if (LLMEvent.is.providerError(event))
failure = {
type: event.classification === "context-overflow" ? "provider.invalid-request" : "provider.error",
message: event.message,
}
if (LLMEvent.is.textDelta(event)) {
chunks.push(event.text)
return bus.publish(SessionEvent.Compaction.Delta, {
sessionID: context.session.id,
text: event.text,
})
}
if (LLMEvent.is.stepFinish(event)) {
const step = SessionUsage.record(event.usage, context.model.cost)
usage = usage ? SessionUsage.add(usage, step) : step
}
return Effect.void
}),
Effect.catchTag("AI.Error", (error) =>
Effect.sync(() => {
failure = toSessionError(error)
}),
),
Effect.onInterrupt(() =>
recordUsage.pipe(
Effect.andThen(
input.reason === "auto"
? failed({
sessionID: context.session.id,
reason: input.reason,
error: { type: "compaction.interrupted", message: "Compaction was interrupted" },
inputID: input.inputID,
}).pipe(Effect.asVoid)
: Effect.void,
),
),
),
)
if (failure || hasSummarySection(chunks.join(""))) break
}
yield* recordUsage
const summary = chunks.join("")
if (failure || !summary.trim()) {
const error = failure ?? { type: "compaction.failed" as const, message: "Compaction produced no summary" }
if (failure || !hasSummarySection(summary)) {
const error = failure ?? {
type: "compaction.failed" as const,
message: summary.trim()
? "Compaction summary did not match the required template"
: "Compaction produced no summary",
}
return yield* failed({
sessionID: plan.session.id,
reason: plan.reason,
sessionID: context.session.id,
reason: input.reason,
error,
inputID: plan.inputID,
inputID: input.inputID,
})
}
yield* bus.publish(SessionEvent.Compaction.Ended, {
sessionID: plan.session.id,
reason: plan.reason,
sessionID: context.session.id,
reason: input.reason,
text: summary,
recent: plan.recent,
recent: history.recent,
})
return { status: "completed" as const }
})
const compact = Effect.fn("SessionCompaction.compact")(function* (input: AutoInput) {
const content = planContent(input.messages, state.get().tokens)
if (content)
return yield* execute({
session: input.session,
resolved: input.resolved,
prepare: input.prepare,
reason: "auto",
...content,
})
return yield* failed({
sessionID: input.session.id,
reason: "auto",
error: { type: "compaction.unavailable", message: "Nothing to compact yet" },
})
})
const compact = (input: AutoInput) => execute({ ...input, reason: "auto" })
const required = (input: RequiredInput) => {
const config = state.get()
if (!config.auto) return false
@@ -444,15 +504,14 @@ export const layer = Layer.effect(
return estimateTokens(input) >= promptCeiling
}
const compactManual = Effect.fn("SessionCompaction.compactManual")(function* (input: ManualInput) {
const content = planContent(input.messages, state.get().tokens)
if (!content)
if (findTailStart(input.messages, state.get().tokens) === undefined)
return yield* failed({
sessionID: input.session.id,
reason: "manual",
error: { type: "compaction.unavailable", message: "Nothing to compact yet" },
inputID: input.inputID,
})
return yield* input.resolveModel(input.session).pipe(
return yield* input.resolveContext(input.session).pipe(
Effect.matchEffect({
onFailure: (cause) =>
failed({
@@ -461,15 +520,14 @@ export const layer = Layer.effect(
error: toSessionError(cause),
inputID: input.inputID,
}),
onSuccess: (resolved) =>
onSuccess: (context) =>
execute({
session: input.session,
resolved,
context,
instructionUpdate: context.instructionUpdate,
prepare: input.prepare,
reason: "manual",
inputID: input.inputID,
started: input.started,
...content,
}),
}),
)
+1
View File
@@ -36,6 +36,7 @@ export const generate = Effect.fn("SessionGenerate.generate")(function* (input:
messages: history.messages,
})
const prepared = yield* context.prepare({
purpose: "generate",
scope: { session: selection.session, agentID: selection.agent.id, model, tools: selection.tools },
transcript: {
system: transcript.system,
+7 -9
View File
@@ -57,11 +57,14 @@ export interface Prepared {
}
interface PrepareInput {
readonly purpose: PluginHooks.Domains["session"]["context"]["purpose"]
readonly scope: {
readonly session: SessionSchema.Info
readonly agentID: Agent.ID
/** Agent whose context an auxiliary request reuses, without changing its request-hook identity. */
readonly contextAgentID?: Agent.ID
readonly model: SessionRunnerModel.Resolved
/** Omitted for requests that carry no tools (title, compaction). */
/** Omitted for requests that carry no tool definitions, such as titles. */
readonly tools?: Tool.Snapshot
}
readonly transcript: {
@@ -69,12 +72,6 @@ interface PrepareInput {
readonly messages: Array<Message>
}
readonly toolChoice?: LLM.RequestInput["toolChoice"]
/**
* Session context hooks shape the agent conversation. Requests that are not
* part of the conversation (title, compaction) opt out: their transcripts
* pass through unchanged.
*/
readonly contextHooks?: false
/** Stateful Session WebSocket channels require an explicit durable-runner opt-in. */
readonly webSocket?: "session"
}
@@ -300,15 +297,16 @@ export const layer = Layer.effect(
const definitions = Object.fromEntries(Array.from(given, ([definition, tool]) => [tool.name, definition]))
const context: PluginHooks.Domains["session"]["context"] = {
sessionID: session.id,
agent: input.scope.agentID,
agent: input.scope.contextAgentID ?? input.scope.agentID,
model: resolved.ref,
purpose: input.purpose,
system: input.transcript.system,
messages: input.transcript.messages,
tools: definitions,
generation: {},
providerOptions: {},
}
if (input.contextHooks !== false) yield* hooks.trigger("session", "context", context)
yield* hooks.trigger("session", "context", context)
// Match each surviving entry back to its tool, by recognizing a moved definition or
// by key. Identity wins so a definition moved onto another tool's name still executes
// the tool it describes. Entries matching neither were invented by a hook and dropped.
+20 -5
View File
@@ -9,6 +9,7 @@ import { SessionCompaction } from "../compaction.js"
import { SessionContext } from "../context.js"
import { SessionEvent } from "../event.js"
import { SessionInbox } from "../inbox.js"
import { SessionHistory } from "../history.js"
import { SessionModelRequest } from "../model-request.js"
import { SessionModelTransport } from "../model-transport.js"
import { SessionMessage } from "../message.js"
@@ -104,7 +105,22 @@ const layer = Layer.effect(
Effect.gen(function* () {
return yield* compaction.compactManual({
session,
resolveModel: context.resolveModel,
resolveContext: (session) =>
Effect.gen(function* () {
const selected = yield* context.select(session.id)
const model = yield* context.resolveModel(selected.session)
// Preview updates without admitting them after the already-delivered compaction marker.
const history = yield* SessionHistory.preview(db, session.id, selected.instructions)
return {
session: selected.session,
agent: selected.agent,
tools: selected.tools,
model,
initial: history.initial,
messages: history.messages,
instructionUpdate: history.instructionUpdate,
}
}),
prepare: context.prepare,
messages: yield* store.context(sessionID),
inputID: pending.id,
@@ -180,12 +196,10 @@ const layer = Layer.effect(
const loaded = initial ?? (yield* prepareContext(sessionID).pipe(Effect.flatMap(context.load)))
initial = undefined
const compactionInput = {
session: loaded.session,
messages: loaded.messages,
resolved: loaded.model,
context: loaded,
prepare: context.prepare,
}
if (compaction.required({ ...compactionInput, context: loaded })) {
if (compaction.required({ messages: loaded.messages, resolved: loaded.model, context: loaded })) {
const compacted = yield* compaction.compact(compactionInput)
if (compacted.status !== "completed") return yield* new StepFailedError({ error: compacted.error })
assistantMessageID = SessionMessage.ID.create()
@@ -200,6 +214,7 @@ const layer = Layer.effect(
messages: loaded.messages,
})
const prepared = yield* context.prepare({
purpose: "session",
scope: { session: loaded.session, agentID: loaded.agent.id, model: loaded.model, tools: loaded.tools },
transcript: {
system: transcript.system,
+1 -1
View File
@@ -64,12 +64,12 @@ export const layer = Layer.effect(
: Effect.void,
)
const prepared = yield* context.prepare({
purpose: "title",
scope: { session: input.session, agentID: input.agent.id, model: input.model },
transcript: {
system: input.agent.system ? [SystemPart.make(input.agent.system)] : [],
messages: [Message.user(input.text)],
},
contextHooks: false,
})
yield* llm.stream(prepared.request, prepared.options).pipe(
Stream.runForEach((event) => {
+7
View File
@@ -190,6 +190,13 @@ describe("Agent", () => {
])
expect((yield* agent.get(Agent.defaultID))?.system).toBeUndefined()
const permissions = (yield* agent.get(Agent.defaultID))?.permissions ?? []
const compaction = yield* agent.get(Agent.ID.make("compaction"))
expect(compaction?.mode).toBe("primary")
expect(compaction?.hidden).toBe(true)
expect(compaction?.system).toBeUndefined()
expect(compaction?.model).toBeUndefined()
expect(compaction?.request).toEqual(Agent.Info.default(Agent.ID.make("compaction")).request)
expect(compaction?.permissions).toEqual(permissions.filter((rule) => rule.action !== "question"))
expect(
Permission.evaluate("external_directory", path.join(global.data, "shell", "*", "*"), permissions).effect,
).toBe("allow")
+17 -16
View File
@@ -42,7 +42,7 @@ const it = testEffect(
AppNodeBuilder.build(LayerNode.group([SessionCompaction.node, SessionModelRequest.node, Config.node, Bus.node]), [
llmClient.replace(
Layer.mock(LLMClient.Service)({
stream: () => Stream.make(LLMEvent.textDelta({ id: "summary", text: "summary" })),
stream: () => Stream.make(LLMEvent.textDelta({ id: "summary", text: "## Objective\n- summary" })),
}),
),
Config.node.replace(config),
@@ -77,25 +77,26 @@ describe("ConfigCompactionPlugin.Plugin", () => {
const started = yield* bus
.subscribe(SessionEvent.Compaction.Started)
.pipe(Stream.runHead, Effect.forkScoped({ startImmediately: true }))
const messages = [
SessionMessage.User.make({
id: SessionMessage.ID.create(),
type: "user",
text: "Older context",
time: { created: DateTime.makeUnsafe(0) },
}),
SessionMessage.User.make({
id: SessionMessage.ID.create(),
type: "user",
text: "Recent context",
time: { created: DateTime.makeUnsafe(1) },
}),
]
expect(
yield* compaction.compactManual({
session,
resolveModel: () => Effect.succeed(resolved),
resolveContext: () => Effect.succeed({ ...nearInput.context, messages, instructionUpdate: "" }),
prepare: modelRequests.prepare,
messages: [
{
id: SessionMessage.ID.create(),
type: "user",
text: "Older context",
time: { created: DateTime.makeUnsafe(0) },
},
{
id: SessionMessage.ID.create(),
type: "user",
text: "Recent context",
time: { created: DateTime.makeUnsafe(1) },
},
],
messages,
inputID: SessionMessage.ID.make("msg_compaction_manual"),
}),
).toEqual({ status: "completed" })
+17 -1
View File
@@ -117,10 +117,15 @@ const run = Effect.fnUntraced(function* (events: ReadonlyArray<SessionEvent.Agen
return { persisted, contextHook, toolHook, files: Environment.makeFiles(driver), planAgent }
})
const request = (agent: Agent.ID, messages: Array<Message>): SessionContext => ({
const request = (
agent: Agent.ID,
messages: Array<Message>,
purpose: SessionContext["purpose"] = "session",
): SessionContext => ({
sessionID,
agent,
model: { id: Model.ID.make("test-model"), providerID: Provider.ID.make("test") },
purpose,
system: [],
messages,
tools: {},
@@ -242,6 +247,17 @@ describe("plan plugin reminders", () => {
expect(persisted).toHaveLength(1)
}),
)
it.effect("does not reconcile reminders for auxiliary requests", () =>
Effect.gen(function* () {
const { enter } = yield* reminders
const { persisted, contextHook } = yield* run()
const messages = [Message.user(enter)]
yield* contextHook(request(build, messages, "title"))
expect(messages).toHaveLength(1)
expect(persisted).toHaveLength(0)
}),
)
})
describe("plan plugin mutations", () => {
@@ -226,6 +226,7 @@ describe("OpenAIPlugin", () => {
const program = Effect.gen(function* () {
const requests = yield* SessionModelRequest.Service
return yield* requests.prepare({
purpose: "session",
scope: {
session: Session.Info.make({
id: sessionID,
@@ -25,10 +25,15 @@ const makeHost = Effect.gen(function* () {
return yield* PluginHost.make(plugins)
})
const context = (id: string, system = fallback): SessionHooks["context"] => ({
const context = (
id: string,
system = fallback,
purpose: SessionHooks["context"]["purpose"] = "session",
): SessionHooks["context"] => ({
sessionID: Session.ID.make("ses_system_prompt"),
agent: Agent.ID.make("build"),
model: Model.Ref.make({ providerID: Provider.ID.make("test"), id: Model.ID.make(id) }),
purpose,
system: [SystemPart.make(system)],
messages: [],
tools: {},
@@ -145,6 +150,19 @@ describe("SystemPromptPlugin", () => {
}),
)
it.effect("preserves title request prompts", () =>
Effect.gen(function* () {
const hooks = yield* PluginHooks.Service
const pluginHost = yield* makeHost
yield* SystemPromptPlugin.OpenAIPlugin.effect(pluginHost)
const title = context("gpt-5", fallback, "title")
yield* hooks.trigger("session", "context", title)
expect(title.system[0]?.text).toBe(fallback)
}),
)
it.effect("skips the hook when agent lookup fails", () =>
Effect.gen(function* () {
const agents = yield* Agent.Service
+65 -46
View File
@@ -7,6 +7,7 @@ import { llmClient } from "@opencode-ai/core/effect/app-node-platform"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Bus } from "@opencode-ai/core/bus"
import { EventTable } from "@opencode-ai/core/event/sql"
import { PluginHooks } from "@opencode-ai/core/plugin/hooks"
import { SessionCompaction } from "@opencode-ai/core/session/compaction"
import { SessionEvent } from "@opencode-ai/core/session/event"
import { SessionMessage } from "@opencode-ai/core/session/message"
@@ -51,7 +52,7 @@ const client = Layer.mock(LLMClient.Service)({
stream: (request: LLMRequest) => {
requests.push(request)
return Stream.make(
LLMEvent.textDelta({ id: "summary", text: "manual summary" }),
LLMEvent.textDelta({ id: "summary", text: "## Objective\n- manual summary" }),
LLMEvent.stepFinish({
index: 0,
reason: { normalized: "stop" },
@@ -83,6 +84,7 @@ const it = testEffect(
Bus.node,
SessionProjector.node,
SessionStore.node,
PluginHooks.node,
SessionCompaction.node,
SessionModelRequest.node,
]),
@@ -91,7 +93,7 @@ const it = testEffect(
)
test("compaction prompt preserves detailed work state and relevant files", () => {
const prompt = SessionCompaction.buildPrompt({ context: ["conversation history"] })
const prompt = SessionCompaction.buildPrompt(false)
expect(prompt).toContain("## Work State\n### Completed")
expect(prompt).toContain("### Active")
@@ -123,29 +125,24 @@ test("compaction truncation does not split surrogate pairs", () => {
})
test("compaction prompt requires the checkpoint headings in order", () => {
const prompt = SessionCompaction.buildPrompt({ context: ["Conversation history"] })
const prompt = SessionCompaction.buildPrompt(false)
expect(prompt.match(/^#{2,3} .+$/gm)).toEqual([
"## Objective",
"## Important Details",
"## Requirements",
"## Decisions",
"## Work State",
"### Completed",
"### Active",
"### Blocked",
"## Next Move",
"## Relevant Files",
"## Additional Context",
])
expect(prompt).toContain("one or two brief sentences")
expect(prompt).toContain("constraints/preferences, decisions and why")
expect(prompt).toContain("immediate concrete action")
expect(prompt).toContain("next action if known")
expect(prompt).toContain("Keep every section, even when empty.")
})
test("compaction points an existing summary to the following history", () => {
const prompt = SessionCompaction.buildPrompt({ previousSummary: "Previous summary", context: ["Recent history"] })
expect(prompt.split("\n", 1)[0]).toBe("Update the anchored summary below using the conversation history below.")
expect(prompt).not.toContain("conversation history above")
test("compaction prompts prohibit task execution", () => {
for (const update of [false, true])
expect(SessionCompaction.buildPrompt(update)).toContain("Do not continue the task or call tools")
})
it.effect("auto compaction estimates current content against the buffered prompt ceiling", () =>
@@ -305,6 +302,16 @@ const insertSession = (id: Session.ID, overrides?: Partial<typeof SessionTable.$
.pipe(Effect.flatMap((session) => (session ? Effect.succeed(session) : Effect.die(`session missing: ${id}`))))
})
const loaded = (session: Session.Info, messages: readonly SessionMessage.Info[]) => ({
session,
messages,
model: resolved,
agent: { id: Agent.defaultID, info: Agent.Info.default(Agent.defaultID) },
initial: "Session instructions",
instructionUpdate: "",
tools: { definitions: [], execute: () => Effect.die("Compaction must not execute tools") },
})
it.effect("manual compaction summarizes short context instead of no-op", () =>
Effect.gen(function* () {
requests = []
@@ -329,6 +336,32 @@ it.effect("manual compaction summarizes short context instead of no-op", () =>
}
const session = yield* insertSession(sessionID, { parent_id: parentID })
const modelRequests = yield* SessionModelRequest.Service
const messages = [
userMessage,
SessionMessage.Shell.make({
id: SessionMessage.ID.create(),
type: "shell",
shellID: Shell.ID.make("sh_background"),
status: "exited",
command: "pwd",
metadata: { background: true },
output: { output: "display-only-output", cursor: 19, size: 19, truncated: false },
time: { created: DateTime.makeUnsafe(0), completed: DateTime.makeUnsafe(1) },
}),
SessionMessage.Synthetic.make({
id: SessionMessage.ID.create(),
type: "synthetic",
text: "User shell pwd completed: /project",
time: { created: DateTime.makeUnsafe(2) },
}),
]
const purposes: string[] = []
const hooks = yield* PluginHooks.Service
yield* hooks.register("session", "context", (event) =>
Effect.sync(() => {
purposes.push(event.purpose)
}),
)
const delta = yield* bus
.subscribe(SessionEvent.Compaction.Delta)
@@ -337,33 +370,18 @@ it.effect("manual compaction summarizes short context instead of no-op", () =>
expect(
yield* compaction.compactManual({
session,
resolveModel: () => Effect.succeed(resolved),
resolveContext: () => Effect.succeed(loaded(session, messages)),
prepare: modelRequests.prepare,
messages: [
userMessage,
SessionMessage.Shell.make({
id: SessionMessage.ID.create(),
type: "shell",
shellID: Shell.ID.make("sh_background"),
status: "exited",
command: "pwd",
metadata: { background: true },
output: { output: "display-only-output", cursor: 19, size: 19, truncated: false },
time: { created: DateTime.makeUnsafe(0), completed: DateTime.makeUnsafe(1) },
}),
SessionMessage.Synthetic.make({
id: SessionMessage.ID.create(),
type: "synthetic",
text: "User shell pwd completed: /project",
time: { created: DateTime.makeUnsafe(2) },
}),
],
messages,
inputID: SessionMessage.ID.make("msg_manual_compaction"),
}),
).toEqual({ status: "completed" })
expect(Array.from(yield* Fiber.join(delta)).map((event) => event.data.text)).toEqual(["manual summary"])
expect(Array.from(yield* Fiber.join(delta)).map((event) => event.data.text)).toEqual([
"## Objective\n- manual summary",
])
expect(requests).toHaveLength(1)
expect(purposes).toEqual(["compaction"])
expect(requests[0]?.promptCacheKey).toBe(sessionID)
expect(requests[0]?.http?.headers).toEqual({
"x-session-affinity": sessionID,
@@ -380,7 +398,7 @@ it.effect("manual compaction summarizes short context instead of no-op", () =>
expect(JSON.stringify(requests[0]?.messages)).toContain("User shell pwd completed: /project")
expect(JSON.stringify(requests[0]?.messages)).not.toContain("display-only-output")
expect(yield* store.context(sessionID)).toMatchObject([
{ type: "compaction", reason: "manual", summary: "manual summary", recent: "" },
{ type: "compaction", reason: "manual", summary: "## Objective\n- manual summary", recent: "" },
])
expect(yield* store.get(sessionID)).toMatchObject({
cost: 0.0000233,
@@ -415,7 +433,7 @@ it.effect("manual compaction records model resolution failures without calling t
expect(
yield* compaction.compactManual({
session,
resolveModel: () =>
resolveContext: () =>
Effect.fail(
new SessionRunnerModel.ModelUnavailableError({
providerID: Provider.ID.make("test"),
@@ -461,19 +479,20 @@ it.effect("forked session compaction reuses the fork root prompt cache key", ()
fork_boundary: { type: "before", messageID: SessionMessage.ID.create() },
})
const modelRequests = yield* SessionModelRequest.Service
const messages = [
SessionMessage.User.make({
id: SessionMessage.ID.create(),
type: "user",
text: "Summarize the forked conversation.",
time: { created: DateTime.makeUnsafe(0) },
}),
]
expect(
yield* compaction.compactManual({
session,
resolveModel: () => Effect.succeed(resolved),
resolveContext: () => Effect.succeed(loaded(session, messages)),
prepare: modelRequests.prepare,
messages: [
{
id: SessionMessage.ID.create(),
type: "user",
text: "Summarize the forked conversation.",
time: { created: DateTime.makeUnsafe(0) },
},
],
messages,
inputID: SessionMessage.ID.make("msg_fork_compaction"),
}),
).toEqual({ status: "completed" })
@@ -17,6 +17,7 @@ import { Location } from "@opencode-ai/core/location"
import { McpInstructions } from "@opencode-ai/core/mcp/instructions"
import { ID } from "@opencode-ai/core/model"
import { Project } from "@opencode-ai/core/project"
import { PluginHooks } from "@opencode-ai/core/plugin/hooks"
import { Provider } from "@opencode-ai/core/provider"
import { ReferenceInstructions } from "@opencode-ai/core/reference/instructions"
import { AbsolutePath } from "@opencode-ai/core/schema"
@@ -131,6 +132,7 @@ const it = testEffect(
SessionProjector.node,
SessionStore.node,
Agent.node,
PluginHooks.node,
InstructionBuiltIns.node,
SessionContext.node,
llmClient,
@@ -302,12 +304,20 @@ it.effect(
})
instruction = "Changed context"
const before = yield* durableState(db, sessionID)
const purposes: string[] = []
const hooks = yield* PluginHooks.Service
yield* hooks.register("session", "context", (event) =>
Effect.sync(() => {
purposes.push(event.purpose)
}),
)
const result = yield* SessionGenerate.generate({ session, prompt: "Summarize privately" }).pipe(
Effect.provideService(Instance.Service, instances),
)
expect(result).toBe("Transient answer")
expect(purposes).toEqual(["generate"])
expect(requests).toHaveLength(1)
expect(requests[0]?.model).toBe(model)
expect(requests[0]?.system.map((part) => part.text)).toContain("Initial context")
+182 -38
View File
@@ -4,6 +4,7 @@ import {
LLMEvent,
LLMRequest,
Message,
SystemPart,
LanguageModel,
ToolFailure,
TransportError,
@@ -13,6 +14,8 @@ import {
UnknownProviderError,
} from "@opencode-ai/ai"
import { OpenAIChat } from "@opencode-ai/ai/protocols/openai-chat"
import { AnthropicMessages, OpenAIResponses } from "@opencode-ai/ai/protocols"
import { compileRequest } from "@opencode-ai/ai/route/client"
import { TestLLM } from "@opencode-ai/ai/testing"
import { Catalog } from "@opencode-ai/core/catalog"
import { Database } from "@opencode-ai/core/database/database"
@@ -70,7 +73,7 @@ import { SkillInstructions } from "@opencode-ai/core/skill/instructions"
import { ReferenceInstructions } from "@opencode-ai/core/reference/instructions"
import { McpInstructions } from "@opencode-ai/core/mcp/instructions"
import { SessionSystemPrompt } from "@opencode-ai/core/session/system-prompt"
import { ID } from "@opencode-ai/core/model"
import { ID, Model } from "@opencode-ai/core/model"
import { Location } from "@opencode-ai/core/location"
import { Provider } from "@opencode-ai/core/provider"
import { Cause, Context, Deferred, Effect, Exit, Fiber, Layer, Queue, Schema, Scope, Stream } from "effect"
@@ -1392,7 +1395,7 @@ describe("SessionRunnerLLM", () => {
yield* s.admit("Echo before moving")
yield* s.llm.push(
TestLLM.tool("call-entry", "echo", { text: "moving" }),
TestLLM.text("Entry summary", "entry-summary"),
TestLLM.text("## Objective\n- Entry summary", "entry-summary"),
TestLLM.text("Continued", "entry-continuation"),
)
const stream = yield* s.llm.gate
@@ -1427,8 +1430,7 @@ describe("SessionRunnerLLM", () => {
yield* runner.drain({ sessionID, force: false, continuation: moved.continuation })
expect(s.requests).toHaveLength(3)
expect(userTexts(s.requests[1])[0]).toContain("Create a new anchored summary")
expect(userTexts(s.requests[2])[0]).toContain("<summary>\nEntry summary\n</summary>")
expect(userTexts(s.requests[2])[0]).toContain("<summary>\n## Objective\n- Entry summary\n</summary>")
expect(yield* s.inbox).toEqual([])
})
@@ -1895,28 +1897,29 @@ describe("SessionRunnerLLM", () => {
scenario("moves the epoch at compaction and narrates later changes", function* (s) {
yield* s.runPrompt("First")
yield* s.bus.publish(SessionEvent.Compaction.Started, {
sessionID,
reason: "manual",
recent: "",
})
yield* s.bus.publish(SessionEvent.Compaction.Ended, {
sessionID,
reason: "manual",
text: "summary",
recent: "",
})
s.systemBaseline = "Changed before compaction"
yield* s.llm.push(TestLLM.text("## Objective\n- summary", "epoch-summary"))
yield* s.session.compact({ sessionID })
yield* s.resume
expect(systemTexts(s.requests[1])).toEqual(["Changed before compaction"])
expect((yield* s.context).some((message) => message.type === "system")).toBe(false)
s.systemBaseline = "Replacement context"
yield* s.runPrompt("Second")
expect(s.requests.map((request) => request.system.map((part) => part.text))).toEqual([
[defaultSystem, "Initial context"],
[defaultSystem, "Initial context"],
[defaultSystem, "Initial context"],
])
expect(messageRoles(s.requests[1])).toEqual(["user", "system", "user"])
expect(s.requests[1]?.messages.at(1)?.content).toEqual([Expected.text("Replacement context")])
expect(messageRoles(s.requests[2])).toEqual(["user", "system", "user"])
expect(s.requests[2]?.messages.at(1)?.content).toEqual([Expected.text("Replacement context")])
yield* replaySessionProjection(sessionID)
yield* s.runPrompt("Third")
const latest = yield* s.runPrompt("Third")
expect(systemTexts(s.requests[3])).toEqual(["Replacement context"])
const fork = yield* s.session.fork({ sessionID, boundary: { type: "before", messageID: latest.id } })
expect(
(yield* s.session.context(fork.id)).flatMap((message) => (message.type === "system" ? [message.text] : [])),
).toEqual(["Replacement context"])
})
scenario("runs steers before queued compaction and later queued input", function* (s) {
@@ -1924,7 +1927,7 @@ describe("SessionRunnerLLM", () => {
yield* s.llm.push(
TestLLM.tool("call-active", "echo", { text: "active" }),
TestLLM.text("Steer complete", "text-steer"),
[LLMEvent.textDelta({ id: "summary", text: "durable summary" })],
[LLMEvent.textDelta({ id: "summary", text: "## Objective\n- durable summary" })],
TestLLM.text("Queue complete", "text-queue"),
)
yield* s.admit("Active work")
@@ -1951,13 +1954,12 @@ describe("SessionRunnerLLM", () => {
expect(s.requests).toHaveLength(4)
expect(userTexts(s.requests[1])).toContain("Steer after compaction")
expect(userTexts(s.requests[1])).toContain("Completion after compaction")
expect(userTexts(s.requests[2])[0]).toContain("Create a new anchored summary")
expect(userTexts(s.requests[3])).toContain("Queue after compaction")
expect(yield* SessionInbox.find(s.db, first.id)).toBeUndefined()
expect((yield* s.messages).find((message) => message.id === first.id)).toMatchObject({
type: "compaction",
status: "completed",
summary: "durable summary",
summary: "## Objective\n- durable summary",
})
})
@@ -1966,6 +1968,7 @@ describe("SessionRunnerLLM", () => {
yield* s.llm.push(
TestLLM.text("Active complete", "text-active-failure"),
[],
[],
TestLLM.text("Continued", "text-after-failure"),
)
yield* s.admit("Active work")
@@ -1980,8 +1983,8 @@ describe("SessionRunnerLLM", () => {
})
yield* active.finish
expect(s.requests).toHaveLength(3)
expect(userTexts(s.requests[2])).toContain("Continue after failure")
expect(s.requests).toHaveLength(4)
expect(userTexts(s.requests[3])).toContain("Continue after failure")
expect(yield* SessionInbox.find(s.db, compaction.id)).toBeUndefined()
expect((yield* s.messages).find((message) => message.id === compaction.id)).toMatchObject({
type: "compaction",
@@ -2020,7 +2023,7 @@ describe("SessionRunnerLLM", () => {
yield* s.runPrompt("Earlier question")
s.requests.length = 0
yield* s.llm.push(TestLLM.text("Manual summary", "text-manual-unknown-summary"))
yield* s.llm.push(TestLLM.text("## Objective\n- Manual summary", "text-manual-unknown-summary"))
const compaction = yield* s.session.compact({ sessionID, delivery: "steer" })
yield* s.resume
@@ -2029,7 +2032,7 @@ describe("SessionRunnerLLM", () => {
expect((yield* s.messages).find((message) => message.id === compaction.id)).toMatchObject({
type: "compaction",
status: "completed",
summary: "Manual summary",
summary: "## Objective\n- Manual summary",
})
})
@@ -2037,7 +2040,7 @@ describe("SessionRunnerLLM", () => {
s.currentModel = recoveryModel
yield* s.llm.push(
TestLLM.text("Active complete", "text-active-steer-compact"),
[LLMEvent.textDelta({ id: "summary", text: "durable summary" })],
[LLMEvent.textDelta({ id: "summary", text: "## Objective\n- durable summary" })],
TestLLM.text("Queue complete", "text-queue-after-compact"),
)
yield* s.admit("Active work")
@@ -2050,13 +2053,12 @@ describe("SessionRunnerLLM", () => {
// Steer-delivered compaction runs at the boundary after the active step, ahead of
// the queued prompt, and consuming it does not trigger an input-free model call.
expect(s.requests).toHaveLength(3)
expect(userTexts(s.requests[1])[0]).toContain("Create a new anchored summary")
expect(userTexts(s.requests[2])).toContain("Queued prompt")
expect(yield* SessionInbox.find(s.db, compaction.id)).toBeUndefined()
expect((yield* s.messages).find((message) => message.id === compaction.id)).toMatchObject({
type: "compaction",
status: "completed",
summary: "durable summary",
summary: "## Objective\n- durable summary",
})
})
@@ -2064,7 +2066,7 @@ describe("SessionRunnerLLM", () => {
s.currentModel = recoveryModel
yield* s.llm.push(
TestLLM.tool("call-active", "echo", { text: "active" }),
[LLMEvent.textDelta({ id: "summary", text: "durable summary" })],
[LLMEvent.textDelta({ id: "summary", text: "## Objective\n- durable summary" })],
TestLLM.text("Continued", "text-continued-after-compact"),
)
yield* s.admit("Active work")
@@ -2075,11 +2077,10 @@ describe("SessionRunnerLLM", () => {
// The compaction summary is requested before the tool turn's continuation step.
expect(s.requests).toHaveLength(3)
expect(userTexts(s.requests[1])[0]).toContain("Create a new anchored summary")
expect((yield* s.messages).find((message) => message.id === compaction.id)).toMatchObject({
type: "compaction",
status: "completed",
summary: "durable summary",
summary: "## Objective\n- durable summary",
})
})
@@ -2098,6 +2099,148 @@ describe("SessionRunnerLLM", () => {
})
})
for (const route of [OpenAIChat.route, OpenAIResponses.route, AnthropicMessages.route]) {
for (const reason of ["manual", "auto"] as const) {
scenario(`preserves the session request prefix during ${reason} compaction (${route.id})`, function* (s) {
const agents = yield* Agent.Service
const hooks = yield* PluginHooks.Service
const agentID = Agent.ID.make("reviewer")
const variant = Model.VariantID.make("test-variant")
s.currentModel = LanguageModel.make({
id: route === AnthropicMessages.route ? "claude-sonnet-4-6" : "gpt-5",
provider: route === AnthropicMessages.route ? "anthropic" : "openai",
route,
})
yield* agents.transform((draft) =>
draft.update(agentID, (agent) => {
agent.system = "Review the project carefully."
}),
)
yield* s.bus.publish(SessionEvent.AgentSelected, { sessionID, agent: agentID })
yield* s.bus.publish(SessionEvent.ModelSelected, {
sessionID,
model: { id: ID.make(s.currentModel.id), providerID: Provider.ID.make(s.currentModel.provider), variant },
})
const requestAgents: Agent.ID[] = []
yield* hooks.register("session", "context", (event) =>
Effect.sync(() => {
expect(event.agent).toBe(agentID)
expect(event.model.variant).toBe(variant)
event.system.push(SystemPart.make("Hook-provided instructions"))
event.tools.echo.description = "Hook-provided tool description"
event.generation.maxTokens = 4_000
}),
)
yield* hooks.register("session", "model.request", (event) =>
Effect.sync(() => {
requestAgents.push(event.agent)
}),
)
yield* s.llm.push(
TestLLM.tool("call-prefix", "echo", { text: "x".repeat(4_000) }),
TestLLM.textWithUsage("Earlier answer", "prefix-answer", 185_000),
TestLLM.text("## Objective\n- Checkpoint summary", "prefix-summary"),
)
yield* s.runPrompt("Review these changes")
if (reason === "manual") {
yield* s.session.compact({ sessionID })
yield* s.resume
}
if (reason === "auto") {
yield* s.llm.push(TestLLM.text("Continued", "prefix-continued"))
yield* s.runPrompt("Retained recent request")
}
const normal = s.requests[1]
const compact = s.requests[2]
expect(compact.messages.slice(0, normal.messages.length)).toEqual([...normal.messages])
expect(compact.messages.at(-1)).toMatchObject({
role: "user",
content: [{ type: "text", text: SessionCompaction.buildPrompt(false) }],
})
expect(userTexts(compact)).not.toContain("Retained recent request")
for (const field of [
"model",
"system",
"tools",
"generation",
"providerOptions",
"toolChoice",
"cache",
"promptCacheKey",
"http",
] as const)
expect(compact[field]).toEqual(normal[field])
expect(compact.toolChoice).toBeUndefined()
expect(compact.system.map((part) => part.text)).toContain("Review the project carefully.")
expect(requestAgents[2]).toBe(Agent.ID.make("compaction"))
expect(s.executions).toEqual(["x".repeat(4_000)])
// Compare wire content without the cache breakpoints that move to the new final message.
const before = yield* compileRequest(LLMRequest.update(normal, { cache: "none" }))
const after = yield* compileRequest(LLMRequest.update(compact, { cache: "none" }))
const key = route === OpenAIResponses.route ? "input" : "messages"
const input = Schema.decodeUnknownSync(Schema.Array(Schema.Unknown))
const prefix = input(before.body[key])
expect(input(after.body[key]).slice(0, prefix.length)).toEqual([...prefix])
expect(after.body).toMatchObject(
Object.fromEntries(Object.entries(before.body).filter(([name]) => name !== key)),
)
})
}
}
for (const response of ["tools", "reasoning", "invalid text"] as const) {
for (const summary of [true, false]) {
scenario(
`compaction ${summary ? "recovers" : "stops"} after one template reminder for ${response}`,
function* (s) {
yield* s.llm.push(TestLLM.text("Earlier answer", "summary-history"))
yield* s.runPrompt("Earlier question")
s.requests.length = 0
const invalid =
response === "tools"
? TestLLM.tool("call-summary", "echo", { text: "must not execute" })
: response === "reasoning"
? TestLLM.stop(
LLMEvent.reasoningDelta({ id: "summary-reasoning", text: "## Objective\n- Not a summary" }),
)
: TestLLM.text("Let me search the codebase. I will fill in ## Objective later.", "invalid-summary")
yield* s.llm.push(
invalid,
summary ? TestLLM.text("### Active\n- Recovered summary", "summary-recovered") : invalid,
)
const compact = yield* s.session.compact({ sessionID })
yield* s.resume
expect(s.requests).toHaveLength(2)
expect(s.requests[1].messages.slice(0, -1)).toEqual([...s.requests[0].messages])
expect(userTexts(s.requests[1]).at(-1)).toContain("did not fill in the required summary template")
expect(s.requests.every((request) => request.toolChoice === undefined)).toBe(true)
expect(s.executions).toEqual([])
expect((yield* s.messages).find((message) => message.id === compact.id)).toMatchObject(
summary
? { status: "completed", summary: "### Active\n- Recovered summary" }
: {
status: "failed",
error: {
type: "compaction.failed",
message:
response === "invalid text"
? "Compaction summary did not match the required template"
: "Compaction produced no summary",
},
},
)
if (!summary)
expect(
(yield* s.context).some((message) => message.type === "user" && message.text === "Earlier question"),
).toBe(true)
},
)
}
}
scenario("preserves typed provider failures from manual compaction", function* (s) {
yield* s.llm.push(TestLLM.text("Earlier answer", "text-manual-failure-history"))
yield* s.runPrompt("Earlier question")
@@ -2176,7 +2319,7 @@ describe("SessionRunnerLLM", () => {
yield* s.runPrompt("Recent exact request ".repeat(180))
expect(s.requests).toHaveLength(2)
expect(userTexts(s.requests[0])[0]).toContain("## Objective")
expect(userTexts(s.requests[0]).at(-1)).toContain("## Objective")
expect(userTexts(s.requests[1])).toHaveLength(1)
expect(userTexts(s.requests[1])[0]).toContain("<summary>\n## Objective\n- Preserve the task\n</summary>")
expect(userTexts(s.requests[1])[0]).toContain(`[User]: ${"Recent exact request ".repeat(180)}`)
@@ -2186,6 +2329,7 @@ describe("SessionRunnerLLM", () => {
expect(context[0]).toMatchObject({
type: "compaction",
summary: "## Objective\n- Preserve the task",
recent: `[User]: ${"Recent exact request ".repeat(180)}`,
})
s.requests.length = 0
@@ -2197,13 +2341,13 @@ describe("SessionRunnerLLM", () => {
yield* s.runPrompt("Newest exact request ".repeat(180))
expect(s.requests).toHaveLength(2)
expect(userTexts(s.requests[0])[0]).toContain(
"<previous-summary>\n## Objective\n- Preserve the task\n</previous-summary>",
)
expect(userTexts(s.requests[0])[0]).toContain("<summary>\n## Objective\n- Preserve the task\n</summary>")
expect(userTexts(s.requests[0])[0]).toContain("Recent exact request")
expect(userTexts(s.requests[0]).at(-1)).toBe(SessionCompaction.buildPrompt(true))
expect((yield* store.context(sessionID))[0]).toMatchObject({
type: "compaction",
summary: "## Objective\n- Preserve the updated task",
recent: `[User]: ${"Newest exact request ".repeat(180)}`,
})
})
@@ -2259,7 +2403,7 @@ describe("SessionRunnerLLM", () => {
yield* s.runPrompt("Continue")
expect(s.requests).toHaveLength(3)
expect(userTexts(s.requests[1])[0]).toContain("## Objective")
expect(userTexts(s.requests[1]).at(-1)).toContain("## Objective")
expect(userTexts(s.requests[2])[0]).toContain("<summary>\n## Objective\n- Recover overflow\n</summary>")
expect(yield* s.context).toMatchObject([
{ type: "compaction", summary: "## Objective\n- Recover overflow" },
@@ -2283,7 +2427,7 @@ describe("SessionRunnerLLM", () => {
yield* s.admit("Continue")
yield* s.llm.push(
[LLMEvent.providerError({ message: "prompt too long", classification: "context-overflow" })],
TestLLM.text("Overflow summary", "overflow-summary"),
TestLLM.text("## Objective\n- Overflow summary", "overflow-summary"),
TestLLM.text("Recovered", "overflow-recovered"),
TestLLM.stop(),
TestLLM.stop(),
@@ -2320,7 +2464,7 @@ describe("SessionRunnerLLM", () => {
expect(s.requests[2]?.model).toBe(replacementModel)
expect(s.requests[2]?.system.map((part) => part.text)).toEqual([defaultSystem, "Initial context"])
expect(systemTexts(s.requests[2])).toContain("Changed during compaction")
expect(userTexts(s.requests[2])[0]).toContain("<summary>\nOverflow summary\n</summary>")
expect(userTexts(s.requests[2])[0]).toContain("<summary>\n## Objective\n- Overflow summary\n</summary>")
expect(userTexts(s.requests[2]).join("\n")).not.toContain("Queued during compaction")
expect(userTexts(s.requests[2]).join("\n")).not.toContain("Steered during compaction")
expect((yield* s.inbox).map((item) => item.id)).toEqual([queued.id, steered.id])
+7
View File
@@ -272,7 +272,13 @@ it.effect("falls back to the primary model when the small model fails", () =>
yield* prompt(sessionID, "Fall back when title generation fails")
const attempted: Model.Ref[] = []
const purposes: string[] = []
const hooks = yield* PluginHooks.Service
yield* hooks.register("session", "context", (event) =>
Effect.sync(() => {
purposes.push(event.purpose)
}),
)
yield* hooks.register("session", "model.request", (event) =>
Effect.sync(() => {
attempted.push(event.model)
@@ -283,6 +289,7 @@ it.effect("falls back to the primary model when the small model fails", () =>
yield* title.generate(sessionID)
expect(requests.map((request) => String(request.model.id))).toEqual(["title-small", "title-model"])
expect(purposes).toEqual(["title", "title"])
expect(attempted.map((model) => String(model.variant))).toEqual(["low", "high"])
const store = yield* SessionStore.Service
expect((yield* store.get(sessionID))?.title).toBe("Generated Title")
+3 -1
View File
@@ -94,10 +94,12 @@ await ctx.aisdk.hook("language", (event) => {
})
```
Session context is mutable immediately before provider dispatch:
Session context is mutable immediately before provider dispatch. The purpose identifies whether the request belongs to
the session loop, direct generation, title generation, or compaction:
```ts
await ctx.session.hook("context", (event) => {
if (event.purpose !== "session") return
event.tools.read.description = "Read a file using narrow line ranges."
delete event.tools.write
})
+3 -1
View File
@@ -88,12 +88,14 @@ yield *
Hooks run sequentially in registration order. Later hooks observe mutations made by earlier hooks.
Session context is mutable immediately before provider dispatch:
Session context is mutable immediately before provider dispatch. The purpose identifies whether the request belongs to
the session loop, direct generation, title generation, or compaction:
```ts
yield *
ctx.session.hook("context", (event) =>
Effect.sync(() => {
if (event.purpose !== "session") return
event.tools.read.description = "Read a file using narrow line ranges."
delete event.tools.write
}),
+4
View File
@@ -18,10 +18,14 @@ export interface SessionPrompt {
delivery: SessionInbox.Delivery
}
export type SessionContextPurpose = "session" | "generate" | "title" | "compaction"
export interface SessionContext {
readonly sessionID: Session.ID
readonly agent: Agent.ID
readonly model: Model.Ref
/** Identifies the operation preparing this model context. */
readonly purpose: SessionContextPurpose
system: Array<SystemPart>
messages: Array<Message>
tools: Record<string, { description: string; input: JsonSchema.JsonSchema }>
+4
View File
@@ -18,10 +18,14 @@ export interface SessionPrompt {
delivery: SessionInbox.Delivery
}
export type SessionContextPurpose = "session" | "generate" | "title" | "compaction"
export interface SessionContext {
readonly sessionID: Session.ID
readonly agent: Agent.ID
readonly model: Model.Ref
/** Identifies the operation preparing this model context. */
readonly purpose: SessionContextPurpose
system: Array<SystemPart>
messages: Array<Message>
tools: Record<string, { description: string; input: JsonSchema.JsonSchema }>
@@ -41,6 +41,7 @@ it.live(
const boots: Session.ID[] = []
const executed: Session.ID[] = []
const commands: Session.ID[] = []
const purposes: string[] = []
const llm = yield* TestLLM.Test.pipe(Effect.provide(TestLLM.testLayer()))
const model = SessionRunnerModel.resolved(
LanguageModel.make({ id: "instance-model", provider: "test", route: OpenAIChat.route }),
@@ -94,6 +95,7 @@ it.live(
)
yield* ctx.session.hook("context", (event) =>
Effect.sync(() => {
purposes.push(event.purpose)
event.generation.temperature = config.temperature
}),
)
@@ -220,6 +222,7 @@ it.live(
])
}
expect(commands).toEqual([first.id, second.id])
expect(purposes).toEqual(["session", "session", "session", "session", "generate", "generate"])
expect(
(yield* llm.requests()).map((request) => ({
temperature: request.generation?.temperature,
@@ -0,0 +1,6 @@
import { expect, story } from "../../storybook/playwright/story"
story("renders the line comment cancel action as a ghost button", async ({ mount }) => {
const root = await mount("ui-line-comment--editor-filled")
await expect(root.getByRole("button", { name: "Cancel" })).toHaveAttribute("data-variant", "ghost")
})
+2 -3
View File
@@ -1621,12 +1621,11 @@ function TurnTokenUsage(props: {
}))
const summary = createMemo(() => {
const items = steps()
const last = items[items.length - 1]
return {
count: items.length,
newTokens: items.reduce((sum, item) => sum + item.newTokens, 0),
cached: last?.cached ?? 0,
total: last?.total ?? 0,
cached: items.reduce((sum, item) => sum + item.cached, 0),
total: items.reduce((sum, item) => sum + item.total, 0),
reuseDrops: items.filter((item) => item.reuseDrop !== undefined).length,
}
})
@@ -103,7 +103,7 @@
background-color: var(--v2-overlay-simple-overlay-hover);
}
[data-slot="line-comment-v2-overflow"]:is(:active, [data-state="pressed"]) {
[data-slot="line-comment-v2-overflow"]:is(:active, [data-state="pressed"], [data-expanded]) {
background-color: var(--v2-overlay-simple-overlay-pressed);
}
@@ -1,9 +1,10 @@
// @ts-nocheck
import { createSignal } from "solid-js"
import { LineCommentEditor, LineComment, LineCommentOverflowIcon } from "./line-comment"
import { Menu } from "../../navigation/menu/menu"
const docs = `### Overview
Line comment **display** and **editor** cards aligned with OpenCode line-comment specs (raised \`#FAFAFA\` surface, footer line context, \`Button\` neutral + contrast actions).
Line comment **display** and **editor** cards aligned with OpenCode line-comment specs (raised \`#FAFAFA\` surface, footer line context, \`Button\` ghost + contrast actions).
### Display
- \`LineComment\`: column stack (body + meta) beside optional \`actions\` (overflow).
@@ -35,9 +36,17 @@ export const Display = {
comment="Consider guarding against empty arrays."
selection="Comment on line 40"
actions={
<button type="button" data-slot="line-comment-v2-overflow" aria-label="Comment actions">
<LineCommentOverflowIcon />
</button>
<Menu gutter={4}>
<Menu.Trigger as="button" type="button" data-slot="line-comment-v2-overflow" aria-label="Comment actions">
<LineCommentOverflowIcon />
</Menu.Trigger>
<Menu.Portal>
<Menu.Content>
<Menu.Item>Edit</Menu.Item>
<Menu.Item>Delete</Menu.Item>
</Menu.Content>
</Menu.Portal>
</Menu>
}
/>
</div>
@@ -294,7 +294,7 @@ export function LineCommentEditor(props: LineCommentEditorProps) {
<div data-slot="line-comment-v2-footer">
<div data-slot="line-comment-v2-footer-meta">{local.selection}</div>
<div data-slot="line-comment-v2-footer-actions">
<Button type="button" size="normal" variant="neutral" onClick={() => local.onCancel()}>
<Button type="button" size="normal" variant="ghost" onClick={() => local.onCancel()}>
{local.cancelLabel ?? i18n.t("ui.lineComment.cancel")}
</Button>
<Button type="button" size="normal" variant="contrast" disabled={!canSubmit()} onClick={submit}>
+1 -1
View File
@@ -134,7 +134,7 @@ const icons = {
},
"window-analytics": {
viewBox: "0 0 16 16",
body: `<g transform="translate(1 2)"><path d="M7 4H11M7 8H11M0.5 0.5V11.5H13.5V0.5H0.5ZM3.5 3.5H4.5V4.5H3.5V3.5ZM3.5 7.5H4.5V8.5H3.5V7.5Z" stroke="currentColor" stroke-miterlimit="10" stroke-linecap="square"/></g>`,
body: `<path d="M14.5 9.8333V13.5H1.5V2.5H7.1667M9.5 2.5V7.5H14.5V2.5H9.5Z" stroke="currentColor" stroke-miterlimit="10" stroke-linecap="square"/>`,
},
trash: {
viewBox: "0 0 20 20",
@@ -1088,7 +1088,10 @@ await ctx.session.hook("context", (event) => {
Context changes affect only the outgoing model call, not persisted history or
configuration. The hook runs again for subsequent calls such as tool-driven
continuations, but not for title or compaction requests.
continuations, transient session generation, and compaction, but not for title requests.
Compaction context hooks receive the selected session agent. Its model-request
and HTTP hooks retain the `compaction` agent identity for provider-specific handling.
Request overrides follow these rules:
+13 -4
View File
@@ -77,10 +77,19 @@ preserves more recent detail but leaves less room for future work. Larger
## Checkpoint contents
V2 uses the session's selected or default model to generate the summary, with
tools disabled and at most 4096 output tokens. The summary records the
objective, important details, completed and active work, blockers, next moves,
and relevant files.
V2 uses the session's selected agent, model, and variant to generate the summary.
The request reuses the normal instructions, tool definitions, and structured
history prefix, then appends a user message requesting a checkpoint. Context
hooks run as they do for normal session requests.
Compaction does not dispatch local tool calls or override tool choice. The
summary must contain at least one heading from the requested template, such as
`## Objective`. If it does not, V2 makes one additional request asking the model
to fill in the template correctly. A second invalid response fails compaction.
Provider-hosted tools remain subject to the selected provider's behavior.
The summary records the objective, requirements, decisions, completed and active
work, blockers, next moves, relevant files, and additional context.
The newest serialized context up to `keep.tokens` is retained separately. This
is not a byte-for-byte transcript: tool output is limited to 2000 characters,