From 470c9911a0a34bbf1de1dbb2ce702a48555a06a6 Mon Sep 17 00:00:00 2001 From: aska Date: Tue, 2 Jun 2026 07:24:17 +0800 Subject: [PATCH 1/6] fix(app): add open-folder i18n labels --- packages/app/src/i18n/en.ts | 1 + packages/app/src/i18n/parity.test.ts | 6 ++++++ packages/app/src/i18n/zh.ts | 1 + packages/app/src/i18n/zht.ts | 1 + 4 files changed, 9 insertions(+) diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index 76c96dcc02af..a4885b1fae00 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -562,6 +562,7 @@ export const dict = { "session.files.all": "All files", "session.files.empty": "No files", "session.files.binaryContent": "Binary file (content cannot be displayed)", + "session.files.openFolder": "Open Folder", "session.messages.renderEarlier": "Render earlier messages", "session.messages.loadingEarlier": "Loading earlier messages...", diff --git a/packages/app/src/i18n/parity.test.ts b/packages/app/src/i18n/parity.test.ts index 8ad1beb56b0c..daac3a61f9ef 100644 --- a/packages/app/src/i18n/parity.test.ts +++ b/packages/app/src/i18n/parity.test.ts @@ -30,4 +30,10 @@ describe("i18n parity", () => { } } }) + + test("open folder label is present in English and Chinese locales", () => { + expect(en["session.files.openFolder"]).toBe("Open Folder") + expect(zh["session.files.openFolder"]).toBe("打开文件夹") + expect(zht["session.files.openFolder"]).toBe("開啟資料夾") + }) }) diff --git a/packages/app/src/i18n/zh.ts b/packages/app/src/i18n/zh.ts index 663f2ccf768f..222743073ac3 100644 --- a/packages/app/src/i18n/zh.ts +++ b/packages/app/src/i18n/zh.ts @@ -557,6 +557,7 @@ export const dict = { "session.header.open.ariaLabel": "在 {{app}} 中打开", "session.header.open.menu": "打开选项", "session.header.open.copyPath": "复制路径", + "session.files.openFolder": "打开文件夹", "status.popover.trigger": "状态", "status.popover.ariaLabel": "服务器配置", diff --git a/packages/app/src/i18n/zht.ts b/packages/app/src/i18n/zht.ts index edd6f7bc0647..3eda66dcc750 100644 --- a/packages/app/src/i18n/zht.ts +++ b/packages/app/src/i18n/zht.ts @@ -545,6 +545,7 @@ export const dict = { "session.header.open.ariaLabel": "在 {{app}} 中開啟", "session.header.open.menu": "開啟選項", "session.header.open.copyPath": "複製路徑", + "session.files.openFolder": "開啟資料夾", "status.popover.trigger": "狀態", "status.popover.ariaLabel": "伺服器設定", From 6c7f02d20174e41d7c20baa88527b752a8a65b06 Mon Sep 17 00:00:00 2001 From: aska Date: Tue, 2 Jun 2026 07:31:14 +0800 Subject: [PATCH 2/6] fix(app): restore open-folder action in session header Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- .../session/session-header.test.tsx | 118 ++++++++++++++++++ .../src/components/session/session-header.tsx | 90 ++++++++++--- 2 files changed, 192 insertions(+), 16 deletions(-) create mode 100644 packages/app/src/components/session/session-header.test.tsx diff --git a/packages/app/src/components/session/session-header.test.tsx b/packages/app/src/components/session/session-header.test.tsx new file mode 100644 index 000000000000..e28635a54246 --- /dev/null +++ b/packages/app/src/components/session/session-header.test.tsx @@ -0,0 +1,118 @@ +import { beforeAll, beforeEach, describe, expect, mock, test } from "bun:test" + +type OpenSessionHeaderDirectory = typeof import("./session-header").openSessionHeaderDirectory +type SessionHeaderOpenVisible = typeof import("./session-header").sessionHeaderOpenVisible + +let openSessionHeaderDirectory: OpenSessionHeaderDirectory +let sessionHeaderOpenVisible: SessionHeaderOpenVisible + +const projectDirectory = "E:\\works\\opencode" +const openPath = mock((_path: string, _app?: string) => Promise.resolve()) +const onError = mock(() => undefined) +const opening: Array = [] + +beforeAll(async () => { + const noop = () => null + mock.module("@/context/command", () => ({ useCommand: noop })) + mock.module("@/context/language", () => ({ useLanguage: noop })) + mock.module("@/context/layout", () => ({ useLayout: noop })) + mock.module("@/context/platform", () => ({ usePlatform: noop })) + mock.module("@/context/server", () => ({ useServer: noop })) + mock.module("@/context/settings", () => ({ useSettings: noop })) + mock.module("@/context/sync", () => ({ useSync: noop })) + mock.module("@/context/terminal", () => ({ useTerminal: noop })) + mock.module("@/pages/session/helpers", () => ({ focusTerminalById: noop })) + mock.module("@/pages/session/session-layout", () => ({ useSessionLayout: noop })) + mock.module("@opencode-ai/ui/app-icon", () => ({ AppIcon: noop })) + mock.module("@opencode-ai/ui/button", () => ({ Button: noop })) + mock.module("@opencode-ai/ui/dropdown-menu", () => ({ DropdownMenu: Object.assign(noop, {}) })) + mock.module("@opencode-ai/ui/icon", () => ({ Icon: noop })) + mock.module("@opencode-ai/ui/icon-button", () => ({ IconButton: noop })) + mock.module("@opencode-ai/ui/keybind", () => ({ Keybind: noop })) + mock.module("@opencode-ai/ui/spinner", () => ({ Spinner: noop })) + mock.module("@opencode-ai/ui/toast", () => ({ Toast: Object.assign(noop, { Region: noop }), showToast: noop })) + mock.module("@opencode-ai/ui/tooltip", () => ({ Tooltip: noop, TooltipKeybind: noop })) + mock.module("@opencode-ai/ui/v2/components/icon-button-v2.jsx", () => ({ IconButtonV2: noop })) + mock.module("@opencode-ai/ui/v2/components/icon.jsx", () => ({ Icon: noop })) + mock.module("../status-popover", () => ({ StatusPopover: noop, StatusPopoverV2: noop })) + + const mod = await import("./session-header") + openSessionHeaderDirectory = mod.openSessionHeaderDirectory + sessionHeaderOpenVisible = mod.sessionHeaderOpenVisible +}) + +beforeEach(() => { + openPath.mockReset() + openPath.mockImplementation(() => Promise.resolve()) + onError.mockClear() + opening.length = 0 +}) + +describe("SessionHeader V2 open folder action", () => { + test("uses the same local desktop visibility gate as the classic header", () => { + expect(sessionHeaderOpenVisible({ canOpen: true, directory: projectDirectory })).toBe(true) + expect(sessionHeaderOpenVisible({ canOpen: false, directory: projectDirectory })).toBe(false) + expect(sessionHeaderOpenVisible({ canOpen: true, directory: "" })).toBe(false) + }) + + test("opens the project directory with the selected open-with target", async () => { + openSessionHeaderDirectory({ + opening: false, + canOpen: true, + openPath, + directory: projectDirectory, + app: "vscode", + options: [ + { id: "finder" }, + { id: "vscode", openWith: "code" }, + ], + setOpening: (app) => opening.push(app), + onError, + }) + + expect(openPath.mock.calls).toEqual([[projectDirectory, "code"]]) + await settle() + expect(opening).toEqual(["vscode", undefined]) + }) + + test("skips unavailable openPath instead of producing a dead action", () => { + openSessionHeaderDirectory({ + opening: false, + canOpen: true, + openPath: undefined, + directory: projectDirectory, + app: "finder", + options: [{ id: "finder" }], + setOpening: (app) => opening.push(app), + onError, + }) + + expect(openPath).not.toHaveBeenCalled() + expect(opening).toEqual([]) + }) + + test("reports rejected openPath through the header error callback", async () => { + const error = new Error("open failed") + openPath.mockImplementation(() => Promise.reject(error)) + + openSessionHeaderDirectory({ + opening: false, + canOpen: true, + openPath, + directory: projectDirectory, + app: "finder", + options: [{ id: "finder" }], + setOpening: (app) => opening.push(app), + onError, + }) + + await settle() + expect(onError).toHaveBeenCalledWith(error) + expect(opening).toEqual(["finder", undefined]) + }) +}) + +async function settle() { + await Promise.resolve() + await Promise.resolve() +} diff --git a/packages/app/src/components/session/session-header.tsx b/packages/app/src/components/session/session-header.tsx index 8fd71c04e318..294dccfe01f2 100644 --- a/packages/app/src/components/session/session-header.tsx +++ b/packages/app/src/components/session/session-header.tsx @@ -1,4 +1,4 @@ -import { AppIcon } from "@opencode-ai/ui/app-icon" +import { AppIcon, type AppIconProps } from "@opencode-ai/ui/app-icon" import { Button } from "@opencode-ai/ui/button" import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu" import { Icon } from "@opencode-ai/ui/icon" @@ -48,6 +48,34 @@ const OPEN_APPS = [ type OpenApp = (typeof OPEN_APPS)[number] type OS = "macos" | "windows" | "linux" | "unknown" +type SessionHeaderOpenOption = { id: OpenApp; openWith?: string } + +export function sessionHeaderOpenVisible(input: { canOpen: boolean; directory: string }) { + return input.canOpen && !!input.directory +} + +export function openSessionHeaderDirectory(input: { + opening: boolean + canOpen: boolean + openPath?: (path: string, app?: string) => Promise + directory: string + app: OpenApp + options: readonly SessionHeaderOpenOption[] + setOpening: (app: OpenApp | undefined) => void + onError: (err: unknown) => void +}) { + if (input.opening || !input.canOpen || !input.openPath) return + if (!input.directory) return + + input.setOpening(input.app) + input + .openPath(input.directory, input.options.find((item) => item.id === input.app)?.openWith) + .catch(input.onError) + .finally(() => { + input.setOpening(undefined) + }) +} + const MAC_APPS = [ { id: "vscode", @@ -222,7 +250,7 @@ export function SessionHeader() { app: undefined as OpenApp | undefined, }) - const canOpen = createMemo(() => platform.platform === "desktop" && !!platform.openPath && server.isLocal()) + const canOpen = createMemo(() => platform.platform === "desktop" && !!platform.openPath && !!server.isLocal()) const current = createMemo( () => options().find((o) => o.id === prefs.app) ?? @@ -234,6 +262,12 @@ export function SessionHeader() { messageAgentColor(params.id ? sync.data.message[params.id] : undefined, sync.data.agent), ) const v2ActionsState = createMemo(() => ({ + openVisible: sessionHeaderOpenVisible({ canOpen: canOpen(), directory: projectDirectory() }), + openLabel: language.t("session.header.open.ariaLabel", { app: current().label }), + openIcon: current().icon, + opening: opening(), + openTint: tint(), + onOpen: () => openDir(current().id), statusVisible: status(), statusLabel: language.t("status.popover.trigger"), reviewLabel: language.t("command.review.toggle"), @@ -248,19 +282,16 @@ export function SessionHeader() { } const openDir = (app: OpenApp) => { - if (opening() || !canOpen() || !platform.openPath) return - const directory = projectDirectory() - if (!directory) return - - const item = options().find((o) => o.id === app) - const openWith = item && "openWith" in item ? item.openWith : undefined - setOpenRequest("app", app) - platform - .openPath(directory, openWith) - .catch((err: unknown) => showRequestError(language, err)) - .finally(() => { - setOpenRequest("app", undefined) - }) + openSessionHeaderDirectory({ + opening: opening(), + canOpen: canOpen(), + openPath: platform.openPath, + directory: projectDirectory(), + app, + options: options(), + setOpening: (value) => setOpenRequest("app", value), + onError: (err) => showRequestError(language, err), + }) } const copyPath = () => { @@ -322,7 +353,7 @@ export function SessionHeader() { {(mount) => ( @@ -520,6 +551,12 @@ export function SessionHeader() { } type SessionHeaderV2ActionsState = { + openVisible: boolean + openLabel: string + openIcon: AppIconProps["id"] + opening: boolean + openTint?: string + onOpen: () => void statusVisible: boolean statusLabel: string reviewLabel: string @@ -531,6 +568,27 @@ type SessionHeaderV2ActionsState = { function SessionHeaderV2Actions(props: { state: SessionHeaderV2ActionsState }) { return (
+ + + + }> + + + + } + /> + + From 68e898813060362a1f59721098270e72e42c355f Mon Sep 17 00:00:00 2001 From: aska Date: Tue, 2 Jun 2026 07:36:18 +0800 Subject: [PATCH 3/6] fix(app): restore open-folder behavior in file tree Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- packages/app/src/components/file-tree.test.ts | 246 +++++++++++++++++- packages/app/src/components/file-tree.tsx | 182 +++++++++---- 2 files changed, 370 insertions(+), 58 deletions(-) diff --git a/packages/app/src/components/file-tree.test.ts b/packages/app/src/components/file-tree.test.ts index 29e20b4807c5..19940948c841 100644 --- a/packages/app/src/components/file-tree.test.ts +++ b/packages/app/src/components/file-tree.test.ts @@ -1,38 +1,179 @@ -import { beforeAll, describe, expect, mock, test } from "bun:test" +import { beforeAll, beforeEach, describe, expect, mock, test } from "bun:test" +import type { JSX } from "solid-js" +import type { FileNode } from "@opencode-ai/sdk/v2" +let FileTree: typeof import("./file-tree").default let shouldListRoot: typeof import("./file-tree").shouldListRoot let shouldListExpanded: typeof import("./file-tree").shouldListExpanded let dirsToExpand: typeof import("./file-tree").dirsToExpand +let parentDirectoryPath: typeof import("./file-tree").parentDirectoryPath +let fileTreeOpenLocationPath: typeof import("./file-tree").fileTreeOpenLocationPath +let openFileTreeLocation: typeof import("./file-tree").openFileTreeLocation + +const openPath = mock((_path: string) => Promise.resolve()) +const contextMenuItems: { onSelect?: () => void }[] = [] +const contextMenuLabels: string[] = [] +let contextMenuRoots = 0 +let contextMenuTriggers = 0 +let fileTreeNodes: FileNode[] = [] +let platformOpenPath: ((path: string) => Promise) | undefined = openPath +let platformName = "desktop" +let localServer = true + +type ElementType = string | ((props: Record) => unknown) + +const createElement = (type: ElementType, props: Record | null, ...children: unknown[]) => { + const propsWithChildren: Record = { ...(props ?? {}) } + if (children.length === 1) propsWithChildren.children = children[0] + if (children.length > 1) propsWithChildren.children = children + if (typeof type === "function") return type(propsWithChildren) + return propsWithChildren.children +} + +const clearContextMenuCaptures = () => { + contextMenuItems.length = 0 + contextMenuLabels.length = 0 + contextMenuRoots = 0 + contextMenuTriggers = 0 +} + +const resetEnvironment = () => { + openPath.mockClear() + clearContextMenuCaptures() + fileTreeNodes = [] + platformOpenPath = openPath + platformName = "desktop" + localServer = true + document.body.innerHTML = "" +} + +const fileNode = (absolute?: string) => + ({ + name: "index.ts", + path: "src/index.ts", + absolute, + type: "file", + ignored: false, + }) as FileNode + +const folderNode = (absolute?: string) => + ({ + name: "components", + path: "src/components", + absolute, + type: "directory", + ignored: false, + }) as FileNode + +const renderTree = (nodes: FileNode[]) => { + fileTreeNodes = nodes + FileTree({ path: "root", draggable: false }) + return () => undefined +} + +const expectNoContextMenuAction = (node: FileNode, configure?: () => void) => { + resetEnvironment() + configure?.() + const dispose = renderTree([node]) + + expect(contextMenuRoots).toBe(0) + expect(contextMenuTriggers).toBe(0) + expect(contextMenuItems).toHaveLength(0) + expect(contextMenuLabels).toEqual([]) + expect(openPath).not.toHaveBeenCalled() + + dispose() +} beforeAll(async () => { + Reflect.set(globalThis, "React", { createElement }) + mock.module("solid-js/web", () => ({ + Dynamic: (props: { component?: ElementType; children?: JSX.Element }) => { + if (typeof props.component === "function") return props.component(props) + return props.children + }, + })) mock.module("@solidjs/router", () => ({ useNavigate: () => () => undefined, useParams: () => ({}), })) mock.module("@/context/file", () => ({ useFile: () => ({ + normalize: (path: string) => path, tree: { - state: () => undefined, + state: () => ({ loaded: true, expanded: false }), list: () => Promise.resolve(), - children: () => [], + children: (path: string) => (path === "root" ? fileTreeNodes : []), expand: () => {}, collapse: () => {}, }, }), })) + mock.module("@/context/language", () => ({ + useLanguage: () => ({ + t: (key: string) => key, + }), + })) + mock.module("@/context/platform", () => ({ + usePlatform: () => ({ + platform: platformName, + openPath: platformOpenPath, + }), + })) + mock.module("@/context/server", () => ({ + useServer: () => ({ + isLocal: () => localServer, + }), + })) mock.module("@opencode-ai/ui/collapsible", () => ({ - Collapsible: { + Collapsible: Object.assign((props: { children?: unknown }) => props.children, { Trigger: (props: { children?: unknown }) => props.children, Content: (props: { children?: unknown }) => props.children, - }, + }), })) + mock.module("@opencode-ai/ui/context-menu", () => { + const passthrough = (props: { children?: JSX.Element }) => props.children + return { + ContextMenu: Object.assign( + (props: { children?: JSX.Element }) => { + contextMenuRoots++ + return props.children + }, + { + Trigger: (props: { children?: JSX.Element }) => { + contextMenuTriggers++ + return props.children + }, + Portal: passthrough, + Content: passthrough, + Item: (props: { children?: JSX.Element; onSelect?: () => void }) => { + contextMenuItems.push({ onSelect: props.onSelect }) + return props.children + }, + ItemLabel: (props: { children?: JSX.Element }) => { + contextMenuLabels.push(String(props.children)) + return props.children + }, + }, + ), + } + }) mock.module("@opencode-ai/ui/file-icon", () => ({ FileIcon: () => null })) mock.module("@opencode-ai/ui/icon", () => ({ Icon: () => null })) + mock.module("@opencode-ai/ui/toast", () => ({ showToast: () => undefined })) mock.module("@opencode-ai/ui/tooltip", () => ({ Tooltip: (props: { children?: unknown }) => props.children })) const mod = await import("./file-tree") + FileTree = mod.default shouldListRoot = mod.shouldListRoot shouldListExpanded = mod.shouldListExpanded dirsToExpand = mod.dirsToExpand + parentDirectoryPath = mod.parentDirectoryPath + fileTreeOpenLocationPath = mod.fileTreeOpenLocationPath + openFileTreeLocation = mod.openFileTreeLocation +}) + +beforeEach(() => { + resetEnvironment() }) describe("file tree fetch discipline", () => { @@ -76,3 +217,98 @@ describe("file tree fetch discipline", () => { expect(dirsToExpand({ level: 1, filter, expanded: () => false })).toEqual([]) }) }) + +describe("file tree open location path", () => { + test("resolves a file path to its parent directory", () => { + expect(parentDirectoryPath("E:\\works\\opencode\\src\\index.ts")).toBe("E:\\works\\opencode\\src") + }) + + test("resolves a folder path to its parent directory", () => { + expect(parentDirectoryPath("E:\\works\\opencode\\src\\components")).toBe("E:\\works\\opencode\\src") + }) + + test("does not resolve missing, empty, or root-only paths", () => { + expect(parentDirectoryPath()).toBeUndefined() + expect(parentDirectoryPath("")).toBeUndefined() + expect(parentDirectoryPath("/")).toBeUndefined() + expect(parentDirectoryPath("E:\\")).toBeUndefined() + expect(fileTreeOpenLocationPath({} as Pick)).toBeUndefined() + }) +}) + +describe("file tree open location action", () => { + test("opens the resolved parent directory for file and folder rows", () => { + openFileTreeLocation({ + path: fileTreeOpenLocationPath({ absolute: "E:\\works\\opencode\\src\\index.ts" }), + openPath, + onError: () => undefined, + }) + + expect(openPath).toHaveBeenLastCalledWith("E:\\works\\opencode\\src") + + openPath.mockClear() + openFileTreeLocation({ + path: fileTreeOpenLocationPath({ absolute: "E:\\works\\opencode\\src\\components" }), + openPath, + onError: () => undefined, + }) + + expect(openPath).toHaveBeenLastCalledWith("E:\\works\\opencode\\src") + }) + + test("skips openPath when the node has no parent target", () => { + openFileTreeLocation({ + path: fileTreeOpenLocationPath({} as Pick), + openPath, + onError: () => undefined, + }) + + expect(openPath).not.toHaveBeenCalled() + }) +}) + +describe("file tree row context menu", () => { + test("renders an i18n open-folder action for file rows and opens the parent directory", () => { + const dispose = renderTree([fileNode("E:\\works\\opencode\\src\\index.ts")]) + + expect(contextMenuRoots).toBeGreaterThan(0) + expect(contextMenuTriggers).toBeGreaterThan(0) + expect(contextMenuLabels).toContain("session.files.openFolder") + + contextMenuItems[0]?.onSelect?.() + + expect(openPath).toHaveBeenLastCalledWith("E:\\works\\opencode\\src") + + dispose() + }) + + test("renders an i18n open-folder action for folder rows and opens the parent directory", () => { + const dispose = renderTree([folderNode("E:\\works\\opencode\\src\\components")]) + + expect(contextMenuRoots).toBeGreaterThan(0) + expect(contextMenuTriggers).toBeGreaterThan(0) + expect(contextMenuLabels).toContain("session.files.openFolder") + + contextMenuItems[0]?.onSelect?.() + + expect(openPath).toHaveBeenLastCalledWith("E:\\works\\opencode\\src") + expect(openPath).not.toHaveBeenCalledWith("E:\\works\\opencode\\src\\components") + + dispose() + }) + + test("hides the custom action when the row path or opener gate is unavailable", () => { + expectNoContextMenuAction(fileNode()) + expectNoContextMenuAction(fileNode("")) + expectNoContextMenuAction(folderNode("E:\\")) + expectNoContextMenuAction(fileNode("E:\\works\\opencode\\src\\index.ts"), () => { + platformOpenPath = undefined + }) + expectNoContextMenuAction(folderNode("E:\\works\\opencode\\src\\components"), () => { + localServer = false + }) + expectNoContextMenuAction(fileNode("E:\\works\\opencode\\src\\index.ts"), () => { + platformName = "web" + }) + }) +}) diff --git a/packages/app/src/components/file-tree.tsx b/packages/app/src/components/file-tree.tsx index 211ce05ef065..29e7c21b41e9 100644 --- a/packages/app/src/components/file-tree.tsx +++ b/packages/app/src/components/file-tree.tsx @@ -1,8 +1,13 @@ import { useFile } from "@/context/file" import { encodeFilePath } from "@/context/file/path" +import { useLanguage } from "@/context/language" +import { usePlatform } from "@/context/platform" +import { useServer } from "@/context/server" import { Collapsible } from "@opencode-ai/ui/collapsible" +import { ContextMenu } from "@opencode-ai/ui/context-menu" import { FileIcon } from "@opencode-ai/ui/file-icon" import { Icon } from "@opencode-ai/ui/icon" +import { showToast } from "@opencode-ai/ui/toast" import { createEffect, createMemo, @@ -14,6 +19,7 @@ import { Switch, untrack, type ComponentProps, + type JSX, type ParentProps, } from "solid-js" import { Dynamic } from "solid-js/web" @@ -85,6 +91,39 @@ const visibleKind = (node: FileNode, kinds?: ReadonlyMap, marks?: return kind } +export const parentDirectoryPath = (path?: string) => { + if (!path) return + + const trimmed = path.replace(/[\\/]+$/, "") + if (!trimmed) return + if (/^[A-Za-z]:$/.test(trimmed)) return + + const index = Math.max(trimmed.lastIndexOf("/"), trimmed.lastIndexOf("\\")) + if (index === -1) return + if (index === 0) return trimmed.slice(0, 1) + if (index === 2 && /^[A-Za-z]:/.test(trimmed)) return trimmed.slice(0, 3) + return trimmed.slice(0, index) +} + +export const fileTreeOpenLocationPath = (node: Pick) => parentDirectoryPath(node.absolute) + +export const openFileTreeLocation = (input: { + path?: string + openPath?: (path: string) => Promise + onError: (err: unknown) => void +}) => { + if (!input.path || !input.openPath) return + input.openPath(input.path).catch(input.onError) +} + +const showRequestError = (language: ReturnType, err: unknown) => { + showToast({ + variant: "error", + title: language.t("common.requestFailed"), + description: err instanceof Error ? err.message : String(err), + }) +} + const buildDragImage = (target: HTMLElement) => { const icon = target.querySelector('[data-component="file-icon"]') ?? target.querySelector("svg") const text = target.querySelector("span") @@ -209,8 +248,12 @@ export default function FileTree(props: { _chain?: readonly string[] }) { const file = useFile() + const language = useLanguage() + const platform = usePlatform() + const server = useServer() const level = props.level ?? 0 const draggable = () => props.draggable ?? true + const canOpenLocation = createMemo(() => platform.platform === "desktop" && !!platform.openPath && server.isLocal()) const key = (p: string) => file @@ -302,6 +345,33 @@ export default function FileTree(props: { return out }) + const openLocation = (path: string) => { + openFileTreeLocation({ + path, + openPath: canOpenLocation() ? platform.openPath : undefined, + onError: (err) => showRequestError(language, err), + }) + } + + const withNodeContextMenu = (node: FileNode, trigger: JSX.Element) => ( + + {(path) => ( + + + {trigger} + + + + openLocation(path())}> + {language.t("session.files.openFolder")} + + + + + )} + + ) + createEffect(() => { const current = filter() const dirs = dirsToExpand({ @@ -403,21 +473,24 @@ export default function FileTree(props: { open={expanded()} onOpenChange={(open) => (open ? file.tree.expand(node.path) : file.tree.collapse(node.path))} > - - -
- -
-
-
+ {withNodeContextMenu( + node, + + +
+ +
+
+
, + )}
- props.onFileClick?.(node)} - > -
- - - - - - - - - + {withNodeContextMenu( + node, + props.onFileClick?.(node)} + > +
+ + + + - - - - + + + + + + + + + , + )} ) From 8224f851f522a26e9e646cb21a740ce2da8b19cf Mon Sep 17 00:00:00 2001 From: aska Date: Tue, 2 Jun 2026 21:09:13 +0800 Subject: [PATCH 4/6] fix(core): coalesce session compactions Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- packages/opencode/src/session/compaction.ts | 190 ++++++++++-- .../opencode/test/session/compaction.test.ts | 285 +++++++++++++++++- 2 files changed, 451 insertions(+), 24 deletions(-) diff --git a/packages/opencode/src/session/compaction.ts b/packages/opencode/src/session/compaction.ts index c687df59bbe5..9ba45b7545d7 100644 --- a/packages/opencode/src/session/compaction.ts +++ b/packages/opencode/src/session/compaction.ts @@ -11,7 +11,7 @@ import { Plugin } from "@/plugin" import { Config } from "@/config/config" import { NotFoundError } from "@/storage/storage" -import { Effect, Layer, Context, Schema } from "effect" +import { Deferred, Effect, Exit, Layer, Context, Schema } from "effect" import * as DateTime from "effect/DateTime" import { InstanceState } from "@/effect/instance-state" import { isOverflow as overflow, usable } from "./overflow" @@ -93,6 +93,23 @@ type CompletedCompaction = { summary: string | undefined } +export type CreateResult = + | { type: "created"; messageID: MessageID } + | { type: "joined"; messageID?: MessageID } + | { type: "pending"; messageID: MessageID } + +export type State = + | { type: "active"; messageID?: MessageID; resumeRequested: boolean } + | { type: "pending"; messageID: MessageID } + | { type: "idle" } + +type ActiveCompaction = { + idle: Deferred.Deferred + created: Deferred.Deferred + messageID?: MessageID + resumeRequested: boolean +} + function summaryText(message: SessionLegacy.WithParts) { const text = message.parts .filter((part): part is SessionLegacy.TextPart => part.type === "text") @@ -121,6 +138,11 @@ function completedCompactions(messages: SessionLegacy.WithParts[]) { }) } +function pendingCompactionMessageID(messages: SessionLegacy.WithParts[]) { + const task = MessageV2.latest(messages).tasks.find((part) => part.type === "compaction") + return task?.messageID +} + function buildPrompt(input: { previousSummary?: string; context: string[] }) { const anchor = input.previousSummary ? [ @@ -203,7 +225,12 @@ export interface Interface { model: { providerID: ProviderV2.ID; modelID: ProviderV2.ModelID } auto: boolean overflow?: boolean - }) => Effect.Effect + }) => Effect.Effect + readonly state: (sessionID: SessionID) => Effect.Effect + readonly isActive: (sessionID: SessionID) => Effect.Effect + readonly waitForIdle: (sessionID: SessionID) => Effect.Effect + readonly markResume: (sessionID: SessionID) => Effect.Effect + readonly drainResume: (sessionID: SessionID) => Effect.Effect } export class Service extends Context.Service()("@opencode/SessionCompaction") {} @@ -221,6 +248,8 @@ export const layer = Layer.effect( const provider = yield* Provider.Service const events = yield* EventV2Bridge.Service const flags = yield* RuntimeFlags.Service + const active = new Map() + const resume = new Set() const isOverflow = Effect.fn("SessionCompaction.isOverflow")(function* (input: { tokens: SessionLegacy.Assistant["tokens"] @@ -341,7 +370,7 @@ export const layer = Layer.effect( } }) - const processCompaction = Effect.fn("SessionCompaction.process")(function* (input: { + const processCompactionInner = Effect.fn("SessionCompaction.process.inner")(function* (input: { parentID: MessageID messages: SessionLegacy.WithParts[] sessionID: SessionID @@ -583,6 +612,94 @@ export const layer = Layer.effect( return result }) + const makeActive = Effect.fnUntraced(function* (messageID?: MessageID) { + return { + idle: yield* Deferred.make(), + created: yield* Deferred.make(), + messageID, + resumeRequested: false, + } satisfies ActiveCompaction + }) + + const releaseActive = Effect.fnUntraced(function* (sessionID: SessionID, item: ActiveCompaction) { + if (active.get(sessionID) !== item) return + active.delete(sessionID) + yield* Deferred.succeed(item.created, undefined).pipe(Effect.ignore) + yield* Deferred.succeed(item.idle, undefined).pipe(Effect.ignore) + }) + + const processCompaction = Effect.fn("SessionCompaction.process")(function* (input: { + parentID: MessageID + messages: SessionLegacy.WithParts[] + sessionID: SessionID + auto: boolean + overflow?: boolean + }) { + const current = active.get(input.sessionID) + const item = current ?? (yield* makeActive(input.parentID)) + if (!current) { + active.set(input.sessionID, item) + if (resume.has(input.sessionID)) item.resumeRequested = true + yield* Deferred.succeed(item.created, undefined).pipe(Effect.ignore) + } + if (!item.messageID) item.messageID = input.parentID + + return yield* processCompactionInner(input).pipe(Effect.ensuring(releaseActive(input.sessionID, item))) + }) + + const pendingMarker = Effect.fn("SessionCompaction.pendingMarker")(function* (sessionID: SessionID) { + const messages = yield* session + .messages({ sessionID }) + .pipe(Effect.catchIf(NotFoundError.isInstance, () => Effect.succeed([]))) + return pendingCompactionMessageID(messages) + }) + + const state = Effect.fn("SessionCompaction.state")(function* (sessionID: SessionID) { + const current = active.get(sessionID) + if (current) + return { + type: "active" as const, + messageID: current.messageID, + resumeRequested: current.resumeRequested, + } + const messageID = yield* pendingMarker(sessionID) + if (messageID) return { type: "pending" as const, messageID } + return { type: "idle" as const } + }) + + const isActive = Effect.fn("SessionCompaction.isActive")(function* (sessionID: SessionID) { + return (yield* state(sessionID)).type !== "idle" + }) + + const waitForIdle: Interface["waitForIdle"] = Effect.fn("SessionCompaction.waitForIdle")(function* (sessionID) { + const current = active.get(sessionID) + if (!current) return + yield* Deferred.await(current.idle) + return yield* waitForIdle(sessionID) + }) + + const markResume = Effect.fn("SessionCompaction.markResume")(function* (sessionID: SessionID) { + const current = active.get(sessionID) + if (current) { + current.resumeRequested = true + resume.add(sessionID) + return + } + const messageID = yield* pendingMarker(sessionID) + if (messageID) { + resume.add(sessionID) + } + }) + + const drainResume = Effect.fn("SessionCompaction.drainResume")((sessionID: SessionID) => + Effect.sync(() => { + const current = active.get(sessionID) + if (current?.resumeRequested) current.resumeRequested = false + if (!resume.delete(sessionID)) return false + return true + }), + ) + const create = Effect.fn("SessionCompaction.create")(function* (input: { sessionID: SessionID agent: string @@ -590,29 +707,51 @@ export const layer = Layer.effect( auto: boolean overflow?: boolean }) { - const msg = yield* session.updateMessage({ - id: MessageID.ascending(), - role: "user", - model: input.model, - sessionID: input.sessionID, - agent: input.agent, - time: { created: Date.now() }, - }) - yield* session.updatePart({ - id: PartID.ascending(), - messageID: msg.id, - sessionID: msg.sessionID, - type: "compaction", - auto: input.auto, - overflow: input.overflow, - }) - if (flags.experimentalEventSystem) { - yield* events.publish(SessionEvent.Compaction.Started, { + const current = active.get(input.sessionID) + if (current) { + if (!current.messageID) yield* Deferred.await(current.created) + return { type: "joined" as const, messageID: current.messageID } + } + + const existing = yield* pendingMarker(input.sessionID) + if (existing) return { type: "pending" as const, messageID: existing } + + const next = yield* makeActive() + active.set(input.sessionID, next) + + const exit = yield* Effect.gen(function* () { + const msg = yield* session.updateMessage({ + id: MessageID.ascending(), + role: "user", + model: input.model, sessionID: input.sessionID, - timestamp: DateTime.makeUnsafe(Date.now()), - reason: input.auto ? "auto" : "manual", + agent: input.agent, + time: { created: Date.now() }, + }) + yield* session.updatePart({ + id: PartID.ascending(), + messageID: msg.id, + sessionID: msg.sessionID, + type: "compaction", + auto: input.auto, + overflow: input.overflow, }) + next.messageID = msg.id + yield* Deferred.succeed(next.created, undefined).pipe(Effect.ignore) + if (flags.experimentalEventSystem) { + yield* events.publish(SessionEvent.Compaction.Started, { + sessionID: input.sessionID, + timestamp: DateTime.makeUnsafe(Date.now()), + reason: input.auto ? "auto" : "manual", + }) + } + return { type: "created" as const, messageID: msg.id } + }).pipe(Effect.exit) + + if (Exit.isFailure(exit)) { + yield* releaseActive(input.sessionID, next) } + return yield* exit }) return Service.of({ @@ -620,6 +759,11 @@ export const layer = Layer.effect( prune, process: processCompaction, create, + state, + isActive, + waitForIdle, + markResume, + drainResume, }) }), ) diff --git a/packages/opencode/test/session/compaction.test.ts b/packages/opencode/test/session/compaction.test.ts index de797691378c..bffa75484137 100644 --- a/packages/opencode/test/session/compaction.test.ts +++ b/packages/opencode/test/session/compaction.test.ts @@ -26,7 +26,7 @@ import type { Provider } from "@/provider/provider" import * as SessionProcessorModule from "../../src/session/processor" import { Snapshot } from "../../src/snapshot" import { ProviderTest } from "../fake/provider" -import { testEffect } from "../lib/effect" +import { awaitWithTimeout, testEffect } from "../lib/effect" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { TestConfig } from "../fixture/config" import { RuntimeFlags } from "@/effect/runtime-flags" @@ -190,6 +190,7 @@ function createCompactionMarker(sessionID: SessionID) { type: "compaction", auto: false, }) + return msg }), ) } @@ -246,6 +247,20 @@ const env = Layer.mergeAll( ) const it = testEffect(env) +const itLocal = testEffect(Layer.empty) + +const failingPartSession = Layer.mock(SessionNs.Service)({ + messages: () => Effect.succeed([]), + updateMessage: (message) => Effect.succeed(message), + updatePart: () => Effect.die(new Error("part write failed")), +}) + +const createFailureEnv = Layer.mergeAll( + failingPartSession, + SessionCompaction.layer.pipe(Layer.provide(failingPartSession), Layer.provideMerge(deps)), +) + +const itCreateFailure = testEffect(createFailureEnv) const compactionEnv = Layer.mergeAll( SessionNs.defaultLayer, @@ -308,6 +323,12 @@ function readCompactionPart(sessionID: SessionID) { ) } +function compactionMarkers(messages: SessionLegacy.WithParts[]) { + return messages.filter( + (message) => message.info.role === "user" && message.parts.some((part) => part.type === "compaction"), + ) +} + function llm() { const queue: Array< Stream.Stream | ((input: LLM.StreamInput) => Stream.Stream) @@ -596,6 +617,268 @@ describe("session.compaction.create", () => { ), ) + itCompaction.instance( + "keeps created compaction active until a completed process finishes", + () => { + const stub = llm() + stub.push(reply("summary")) + return Effect.gen(function* () { + const compact = yield* SessionCompaction.Service + const ssn = yield* SessionNs.Service + const session = yield* ssn.create({}) + + const result = yield* compact.create({ + sessionID: session.id, + agent: "build", + model: ref, + auto: false, + }) + + expect(result.type).toBe("created") + if (result.type === "created") { + expect(yield* compact.state(session.id)).toEqual({ + type: "active", + messageID: result.messageID, + resumeRequested: false, + }) + yield* compact.markResume(session.id) + expect(yield* compact.state(session.id)).toEqual({ + type: "active", + messageID: result.messageID, + resumeRequested: true, + }) + const messages = yield* ssn.messages({ sessionID: session.id }) + yield* compact.process({ parentID: result.messageID, messages, sessionID: session.id, auto: false }) + } + yield* awaitWithTimeout(compact.waitForIdle(session.id), "compaction process active state did not release") + expect(yield* compact.state(session.id)).toEqual({ type: "idle" }) + expect(yield* compact.drainResume(session.id)).toBe(true) + expect(yield* compact.drainResume(session.id)).toBe(false) + }).pipe(withCompaction({ llm: stub.layer })) + }, + ) + + it.live( + "joins duplicate creates in one session and writes one marker", + provideTmpdirInstance(() => + Effect.gen(function* () { + const compact = yield* SessionCompaction.Service + const ssn = yield* SessionNs.Service + const info = yield* ssn.create({}) + + const results = yield* Effect.all( + [ + compact.create({ sessionID: info.id, agent: "build", model: ref, auto: false }), + compact.create({ sessionID: info.id, agent: "build", model: ref, auto: false }), + ], + { concurrency: "unbounded" }, + ) + + const messages = yield* ssn.messages({ sessionID: info.id }) + expect(compactionMarkers(messages)).toHaveLength(1) + expect(results.some((result) => result.type === "created")).toBe(true) + expect(results.some((result) => result.type === "joined" || result.type === "pending")).toBe(true) + }), + ), + ) + + itLocal.live( + "joined create waits for marker persistence before returning", + Effect.gen(function* () { + const writeStarted = yield* Deferred.make() + const releaseWrite = yield* Deferred.make() + const parts: SessionLegacy.Part[] = [] + const gatedSession = Layer.mock(SessionNs.Service)({ + messages: () => Effect.succeed([]), + updateMessage: (message) => Effect.succeed(message), + updatePart: (part) => + Effect.gen(function* () { + yield* Deferred.succeed(writeStarted, undefined).pipe(Effect.ignore) + yield* Deferred.await(releaseWrite) + parts.push(part) + return part + }), + }) + const effect = Effect.gen(function* () { + const compact = yield* SessionCompaction.Service + const sessionID = SessionID.descending() + const first = yield* compact + .create({ sessionID, agent: "build", model: ref, auto: false }) + .pipe(Effect.forkChild) + + yield* awaitWithTimeout(Deferred.await(writeStarted), "first compaction marker write did not start") + const second = yield* compact + .create({ sessionID, agent: "build", model: ref, auto: false }) + .pipe(Effect.forkChild) + + yield* Deferred.succeed(releaseWrite, undefined) + const firstResult = yield* Fiber.join(first) + const secondResult = yield* Fiber.join(second) + + expect(firstResult.type).toBe("created") + expect(secondResult).toEqual({ + type: "joined", + messageID: firstResult.type === "created" ? firstResult.messageID : undefined, + }) + expect(parts.filter((part) => part.type === "compaction")).toHaveLength(1) + }).pipe( + Effect.provide( + Layer.mergeAll(gatedSession, SessionCompaction.layer.pipe(Layer.provide(gatedSession), Layer.provideMerge(deps))), + ), + ) + yield* effect + }), + ) + + it.live( + "returns pending for an existing unprocessed marker", + provideTmpdirInstance(() => + Effect.gen(function* () { + const compact = yield* SessionCompaction.Service + const ssn = yield* SessionNs.Service + const info = yield* ssn.create({}) + const marker = yield* createCompactionMarker(info.id) + + const result = yield* compact.create({ + sessionID: info.id, + agent: "build", + model: ref, + auto: false, + }) + + expect(result).toEqual({ type: "pending", messageID: marker.id }) + expect(compactionMarkers(yield* ssn.messages({ sessionID: info.id }))).toHaveLength(1) + }), + ), + ) + + itCompaction.instance( + "preserves resume request for a history-pending marker until processing finishes", + () => { + const stub = llm() + stub.push(reply("summary")) + return Effect.gen(function* () { + const compact = yield* SessionCompaction.Service + const ssn = yield* SessionNs.Service + const session = yield* ssn.create({}) + const marker = yield* createCompactionMarker(session.id) + + expect(yield* compact.state(session.id)).toEqual({ type: "pending", messageID: marker.id }) + yield* compact.markResume(session.id) + + const messages = yield* ssn.messages({ sessionID: session.id }) + yield* compact.process({ parentID: marker.id, messages, sessionID: session.id, auto: false }) + yield* awaitWithTimeout(compact.waitForIdle(session.id), "history-pending compaction did not release active state") + + expect(yield* compact.state(session.id)).toEqual({ type: "idle" }) + expect(yield* compact.drainResume(session.id)).toBe(true) + expect(yield* compact.drainResume(session.id)).toBe(false) + }).pipe(withCompaction({ llm: stub.layer })) + }, + ) + + it.live( + "does not treat summarized compaction markers as pending", + provideTmpdirInstance((dir) => + Effect.gen(function* () { + const compact = yield* SessionCompaction.Service + const ssn = yield* SessionNs.Service + const info = yield* ssn.create({}) + const marker = yield* createCompactionMarker(info.id) + yield* createSummaryAssistantMessage(info.id, marker.id, dir, "summary") + + const result = yield* compact.create({ + sessionID: info.id, + agent: "build", + model: ref, + auto: false, + }) + + expect(result.type).toBe("created") + expect(compactionMarkers(yield* ssn.messages({ sessionID: info.id }))).toHaveLength(2) + }), + ), + ) + + it.live( + "keeps creates isolated across sessions", + provideTmpdirInstance(() => + Effect.gen(function* () { + const compact = yield* SessionCompaction.Service + const ssn = yield* SessionNs.Service + const first = yield* ssn.create({}) + const second = yield* ssn.create({}) + + const results = yield* Effect.all( + [ + compact.create({ sessionID: first.id, agent: "build", model: ref, auto: false }), + compact.create({ sessionID: second.id, agent: "build", model: ref, auto: false }), + ], + { concurrency: "unbounded" }, + ) + + expect(results.map((result) => result.type)).toEqual(["created", "created"]) + expect(compactionMarkers(yield* ssn.messages({ sessionID: first.id }))).toHaveLength(1) + expect(compactionMarkers(yield* ssn.messages({ sessionID: second.id }))).toHaveLength(1) + }), + ), + ) + + itCompaction.instance( + "keeps active state while process is running and releases on cancellation", + () => { + const ready = Effect.runSync(Deferred.make()) + return Effect.gen(function* () { + const compact = yield* SessionCompaction.Service + const ssn = yield* SessionNs.Service + const session = yield* ssn.create({}) + const created = yield* compact.create({ sessionID: session.id, agent: "build", model: ref, auto: false }) + expect(created.type).toBe("created") + if (created.type !== "created") return + + const messages = yield* ssn.messages({ sessionID: session.id }) + const fiber = yield* compact + .process({ parentID: created.messageID, messages, sessionID: session.id, auto: false }) + .pipe(Effect.forkChild) + + yield* awaitWithTimeout(Deferred.await(ready), "compaction process did not reach blocking plugin") + yield* compact.markResume(session.id) + expect(yield* compact.state(session.id)).toEqual({ + type: "active", + messageID: created.messageID, + resumeRequested: true, + }) + + yield* Fiber.interrupt(fiber) + yield* awaitWithTimeout(compact.waitForIdle(session.id), "cancelled compaction process kept active state") + expect(yield* compact.state(session.id)).toEqual({ type: "pending", messageID: created.messageID }) + expect(yield* compact.drainResume(session.id)).toBe(true) + expect(yield* compact.drainResume(session.id)).toBe(false) + }).pipe(withCompaction({ plugin: plugin(ready) })) + }, + ) + + itCreateFailure.live( + "releases active state when marker creation fails", + Effect.gen(function* () { + const compact = yield* SessionCompaction.Service + const sessionID = SessionID.descending() + + const exit = yield* compact + .create({ + sessionID, + agent: "build", + model: ref, + auto: false, + }) + .pipe(Effect.exit) + + expect(Exit.isFailure(exit)).toBe(true) + expect(yield* compact.state(sessionID)).toEqual({ type: "idle" }) + yield* awaitWithTimeout(compact.waitForIdle(sessionID), "failed compaction create kept active state") + }), + ) + it.live.skip( "projects a compaction message to v2 (v2 projector disabled)", provideTmpdirInstance(() => From 3cbbbfeea6bb0682954aabced9b1e68e0de6c7b5 Mon Sep 17 00:00:00 2001 From: aska Date: Tue, 2 Jun 2026 21:09:31 +0800 Subject: [PATCH 5/6] fix(core): handle empty response compaction stalls Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- packages/opencode/src/session/prompt.ts | 208 +++- .../test/session/processor-effect.test.ts | 75 ++ packages/opencode/test/session/prompt.test.ts | 993 +++++++++++++++++- 3 files changed, 1254 insertions(+), 22 deletions(-) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index e7c6a6236340..5d663b691865 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -61,6 +61,7 @@ import { referencePromptMetadata, referenceTextPart } from "./prompt/reference" import { SessionReminders } from "./reminders" import { SessionTools } from "./tools" import { LLMEvent } from "@opencode-ai/llm" +import { isOverflow } from "./overflow" // @ts-ignore globalThis.AI_SDK_LOG_WARNINGS = false @@ -87,8 +88,51 @@ function isOrphanedInterruptedTool(part: SessionLegacy.ToolPart) { return part.state.status === "error" && part.state.metadata?.interrupted === true } +function hasMeaningfulAssistantOutput(parts: SessionLegacy.Part[]) { + return parts.some((part) => { + if (part.type === "text") return part.text.trim().length > 0 + return part.type === "tool" || part.type === "patch" || part.type === "compaction" + }) +} + +function isEmptyAssistantResponse(info: SessionLegacy.Assistant, parts: SessionLegacy.Part[]) { + return !info.error && !hasMeaningfulAssistantOutput(parts) +} + +function hasContextPressure(msgs: SessionLegacy.WithParts[], model: Provider.Model, cfg: Config.Info) { + return msgs + .slice(msgs.findLastIndex((msg) => msg.parts.some((part) => part.type === "compaction")) + 1) + .some((msg) => msg.info.role === "assistant" && (msg.info.finish === "length" || isOverflow({ cfg, tokens: msg.info.tokens, model }))) +} + +function isAutomaticCompactionContinuation(msgs: SessionLegacy.WithParts[], lastUserMsg: SessionLegacy.WithParts | undefined) { + if (!lastUserMsg) return false + if ( + !lastUserMsg.parts.some( + (part) => part.type === "text" && part.synthetic && part.metadata?.compaction_continue === true, + ) + ) + return false + + const compaction = msgs.findLast( + (msg) => + msg.info.role === "user" && + msg.info.id < lastUserMsg.info.id && + msg.parts.some((part) => part.type === "compaction" && part.auto === true), + ) + if (!compaction) return false + return msgs.some( + (msg) => + msg.info.role === "assistant" && + msg.info.summary === true && + msg.info.parentID === compaction.info.id && + msg.info.id < lastUserMsg.info.id, + ) +} + export interface Interface { readonly cancel: (sessionID: SessionID) => Effect.Effect + readonly cancelOrdinary: (sessionID: SessionID) => Effect.Effect readonly prompt: (input: PromptInput) => Effect.Effect readonly loop: (input: LoopInput) => Effect.Effect readonly shell: (input: ShellInput) => Effect.Effect @@ -143,6 +187,12 @@ export const layer = Layer.effect( yield* state.cancel(sessionID) }) + const cancelOrdinary = Effect.fn("SessionPrompt.cancelOrdinary")(function* (sessionID: SessionID) { + yield* elog.info("cancel ordinary", { sessionID }) + if ((yield* compaction.state(sessionID)).type !== "idle") return + yield* state.cancel(sessionID) + }) + const resolvePromptParts = Effect.fn("SessionPrompt.resolvePromptParts")(function* (template: string) { const ctx = yield* InstanceState.context const parts: Types.DeepMutable = [{ type: "text", text: template }] @@ -1212,11 +1262,23 @@ export const layer = Layer.effect( return { info, parts } }, Effect.scoped) + const waitForPromptAppendBoundary = Effect.fn("SessionPrompt.waitForPromptAppendBoundary")(function* ( + sessionID: SessionID, + ) { + const compact = yield* compaction.state(sessionID) + if (compact.type === "idle") return + yield* compaction.markResume(sessionID) + if (compact.type === "active" && compact.messageID) yield* loop({ sessionID }) + if (compact.type === "pending") yield* loop({ sessionID }) + yield* compaction.waitForIdle(sessionID) + }) + const prompt: (input: PromptInput) => Effect.Effect = Effect.fn( "SessionPrompt.prompt", )(function* (input: PromptInput) { const session = yield* sessions.get(input.sessionID).pipe(Effect.orDie) yield* revert.cleanup(session) + yield* waitForPromptAppendBoundary(input.sessionID) const message = yield* createUserMessage(input) yield* sessions.touch(input.sessionID) @@ -1241,6 +1303,55 @@ export const layer = Layer.effect( throw new Error("Impossible") }) + const processCompactionTask = Effect.fn("SessionPrompt.processCompactionTask")(function* (input: { + messages: SessionLegacy.WithParts[] + parentID: MessageID + sessionID: SessionID + auto: boolean + overflow?: boolean + }) { + if (input.auto !== true) return yield* compaction.process(input) + + for (let attempt = 1; attempt <= 3; attempt++) { + const exit = yield* compaction.process(input).pipe(Effect.exit) + if (Exit.isSuccess(exit) && exit.value === "continue") return "continue" as const + if (Exit.isFailure(exit) && Cause.hasInterrupts(exit.cause)) return yield* Effect.failCause(exit.cause) + if (attempt === 3) break + + yield* status.set(input.sessionID, { + type: "retry", + attempt, + message: `Automatic compaction failed; retrying compaction ${attempt}/2`, + next: Date.now(), + }) + } + + const error = new NamedError.Unknown({ message: "Automatic compaction failed after 3 attempts" }) + yield* events.publish(Session.Event.Error, { sessionID: input.sessionID, error: error.toObject() }) + throw error + }) + + const handleAutoCompactionCreateResult = Effect.fn("SessionPrompt.handleAutoCompactionCreateResult")(function* ( + sessionID: SessionID, + result: SessionCompaction.CreateResult, + ) { + if (result.type !== "joined") return + yield* compaction.markResume(sessionID) + yield* compaction.waitForIdle(sessionID) + }) + + const waitForCompactionBoundary = Effect.fn("SessionPrompt.waitForCompactionBoundary")(function* ( + sessionID: SessionID, + ) { + const compact = yield* compaction.state(sessionID) + if (compact.type === "idle") return false + yield* compaction.markResume(sessionID) + if (compact.type === "active") { + yield* compaction.waitForIdle(sessionID) + } + return true + }) + const runLoop: (sessionID: SessionID) => Effect.Effect = Effect.fn("SessionPrompt.run")( function* (sessionID: SessionID) { const ctx = yield* InstanceState.context @@ -1301,32 +1412,50 @@ export const layer = Layer.effect( history: msgs, }).pipe(Effect.ignore, Effect.forkIn(scope)) - const model = yield* getModel(lastUser.model.providerID, lastUser.model.modelID, sessionID) const task = tasks.pop() - if (task?.type === "subtask") { - yield* handleSubtask({ task, model, lastUser, sessionID, session, msgs }) - continue - } - if (task?.type === "compaction") { - const result = yield* compaction.process({ + const compact = yield* compaction.state(sessionID) + const processing = + compact.type === "active" && + compact.messageID === task.messageID && + msgs.some( + (msg) => + msg.info.role === "assistant" && msg.info.summary === true && msg.info.parentID === task.messageID, + ) + if (processing) { + yield* compaction.markResume(sessionID) + yield* compaction.waitForIdle(sessionID) + continue + } + const result = yield* processCompactionTask({ messages: msgs, parentID: lastUser.id, sessionID, auto: task.auto, overflow: task.overflow, }) + if (yield* compaction.drainResume(sessionID)) continue if (result === "stop") break continue } + if (yield* waitForCompactionBoundary(sessionID)) continue + + const model = yield* getModel(lastUser.model.providerID, lastUser.model.modelID, sessionID) + + if (task?.type === "subtask") { + yield* handleSubtask({ task, model, lastUser, sessionID, session, msgs }) + continue + } + if ( lastFinished && lastFinished.summary !== true && (yield* compaction.isOverflow({ tokens: lastFinished.tokens, model })) ) { - yield* compaction.create({ sessionID, agent: lastUser.agent, model: lastUser.model, auto: true }) + const result = yield* compaction.create({ sessionID, agent: lastUser.agent, model: lastUser.model, auto: true }) + yield* handleAutoCompactionCreateResult(sessionID, result) continue } @@ -1346,6 +1475,8 @@ export const layer = Layer.effect( Effect.provideService(Session.Service, sessions), ) + if (yield* waitForCompactionBoundary(sessionID)) continue + const msg: SessionLegacy.Assistant = { id: MessageID.ascending(), parentID: lastUser.id, @@ -1373,6 +1504,11 @@ export const layer = Layer.effect( yield* sessions.updateMessage(msg) }) + if (yield* waitForCompactionBoundary(sessionID)) { + yield* finalizeInterruptedAssistant + continue + } + const handle = yield* processor .create({ assistantMessage: msg, @@ -1434,6 +1570,8 @@ export const layer = Layer.effect( yield* plugin.trigger("experimental.chat.messages.transform", {}, { messages: msgs }) + if (yield* waitForCompactionBoundary(sessionID)) return "continue" as const + const [skills, env, instructions, modelMsgs] = yield* Effect.all([ sys.skills(agent), sys.environment(model), @@ -1455,6 +1593,10 @@ export const layer = Layer.effect( model, toolChoice: format.type === "json_schema" ? "required" : undefined, }) + const currentParts = yield* MessageV2.parts(handle.message.id).pipe( + Effect.provideService(Database.Service, database), + ) + const isEmpty = isEmptyAssistantResponse(handle.message, currentParts) if (structured !== undefined) { handle.message.structured = structured @@ -1475,16 +1617,52 @@ export const layer = Layer.effect( } } - if (result === "stop") return "break" as const + const isCompactionContinue = isAutomaticCompactionContinuation(msgs, lastUserMsg) + if (isEmpty && isCompactionContinue) { + handle.message.error = new NamedError.Unknown({ + message: "Model returned an empty response after automatic compaction; stopping to avoid a compaction loop.", + }).toObject() + handle.message.finish = "error" + yield* sessions.updateMessage(handle.message) + return "break" as const + } + if (result === "compact") { - yield* compaction.create({ + const compact = yield* compaction.create({ sessionID, agent: lastUser.agent, model: lastUser.model, auto: true, - overflow: !handle.message.finish, + overflow: !handle.message.finish || handle.message.finish === "unknown", }) + yield* handleAutoCompactionCreateResult(sessionID, compact) + return "continue" as const } + + if (isEmpty) { + const cfg = yield* config.get().pipe(Effect.provideService(Config.Service, config)) + if (hasContextPressure(msgs, model, cfg)) { + const compact = yield* compaction.create({ + sessionID, + agent: lastUser.agent, + model: lastUser.model, + auto: true, + overflow: true, + }) + yield* handleAutoCompactionCreateResult(sessionID, compact) + return "continue" as const + } + + handle.message.error = new NamedError.Unknown({ + message: + "Model returned an empty response with no content, tool call, or patch; stopping instead of continuing silently.", + }).toObject() + handle.message.finish = "error" + yield* sessions.updateMessage(handle.message) + return "break" as const + } + + if (result === "stop") return "break" as const return "continue" as const }).pipe( Effect.ensuring(instruction.clear(handle.message.id)), @@ -1501,6 +1679,7 @@ export const layer = Layer.effect( const loop: (input: LoopInput) => Effect.Effect = Effect.fn("SessionPrompt.loop")( function* (input: LoopInput) { + if ((yield* compaction.state(input.sessionID)).type !== "idle") yield* compaction.markResume(input.sessionID) return yield* state.ensureRunning(input.sessionID, lastAssistant(input.sessionID), runLoop(input.sessionID)) }, ) @@ -1629,9 +1808,10 @@ export const layer = Layer.effect( return result }) - return Service.of({ - cancel, - prompt, + return Service.of({ + cancel, + cancelOrdinary, + prompt, loop, shell, command, diff --git a/packages/opencode/test/session/processor-effect.test.ts b/packages/opencode/test/session/processor-effect.test.ts index e68ad962febd..9ad4adb96b98 100644 --- a/packages/opencode/test/session/processor-effect.test.ts +++ b/packages/opencode/test/session/processor-effect.test.ts @@ -120,6 +120,30 @@ const waitFor = (check: Effect.Effect, message: string) => return yield* Effect.fail(new Error(message)) }) +function emptyUnknownResponse(input: { usage: { input: number; output: number } }) { + return raw({ + head: [ + { + id: "chatcmpl-test", + object: "chat.completion.chunk", + choices: [{ delta: { role: "assistant" } }], + }, + ], + tail: [ + { + id: "chatcmpl-test", + object: "chat.completion.chunk", + choices: [{ delta: {}, finish_reason: "unknown" }], + usage: { + prompt_tokens: input.usage.input, + completion_tokens: input.usage.output, + total_tokens: input.usage.input + input.usage.output, + }, + }, + ], + }) +} + const user = Effect.fn("TestSession.user")(function* (sessionID: SessionID, text: string) { const session = yield* Session.Service const msg = yield* session.updateMessage({ @@ -392,6 +416,57 @@ it.live("session.processor effect tests stop after token overflow requests compa ), ) +it.live("session.processor effect tests compact empty unknown responses near context limit", () => + provideTmpdirServer( + ({ dir, llm }) => + Effect.gen(function* () { + const { processors, session, provider } = yield* boot() + + yield* llm.push(emptyUnknownResponse({ usage: { input: 100, output: 0 } })) + + const chat = yield* session.create({}) + const parent = yield* user(chat.id, "compact empty") + const msg = yield* assistant(chat.id, parent.id, path.resolve(dir)) + const base = yield* provider.getModel(ref.providerID, ref.modelID) + const mdl = { ...base, limit: { context: 20, output: 10 } } + const handle = yield* processors.create({ + assistantMessage: msg, + sessionID: chat.id, + model: mdl, + }) + + const value = yield* handle.process({ + user: { + id: parent.id, + sessionID: chat.id, + role: "user", + time: parent.time, + agent: parent.agent, + model: { providerID: ref.providerID, modelID: ref.modelID }, + } satisfies SessionLegacy.User, + sessionID: chat.id, + model: mdl, + agent: agent(), + system: [], + messages: [{ role: "user", content: "compact empty" }], + tools: {}, + }) + + const parts = yield* MessageV2.parts(msg.id) + + expect(value).toBe("compact") + expect(handle.message.finish).toBe("unknown") + expect(handle.message.error).toBeUndefined() + expect(parts.some((part) => part.type === "text")).toBe(false) + expect(parts.some((part) => part.type === "reasoning")).toBe(false) + expect(parts.some((part) => part.type === "tool")).toBe(false) + expect(parts.some((part) => part.type === "step-finish" && part.reason === "unknown")).toBe(true) + }), + { config: (url) => providerCfg(url) }, + ), +) + + it.live("session.processor effect tests capture reasoning from http mock", () => provideTmpdirServer( ({ dir, llm }) => diff --git a/packages/opencode/test/session/prompt.test.ts b/packages/opencode/test/session/prompt.test.ts index f04925b98257..d231b478dbd3 100644 --- a/packages/opencode/test/session/prompt.test.ts +++ b/packages/opencode/test/session/prompt.test.ts @@ -53,7 +53,7 @@ import { Reference } from "../../src/reference/reference" import { RepositoryCache } from "../../src/reference/repository-cache" import { TestInstance } from "../fixture/fixture" import { awaitWithTimeout, pollWithTimeout, testEffect } from "../lib/effect" -import { reply, TestLLMServer } from "../lib/llm-server" +import { raw, reply, TestLLMServer } from "../lib/llm-server" import { RuntimeFlags } from "@/effect/runtime-flags" import { ProviderV2 } from "@opencode-ai/core/provider" @@ -165,7 +165,65 @@ const blockingProcessor = Layer.succeed( }), ) -function makePrompt(input?: { processor?: "blocking" }) { +const compactProcessorBeforeReturn: Array<(sessionID: SessionID) => Effect.Effect> = [] +const compactProcessorSummaryGate: Array> = [] +const compactProcessorNormalCalls: Array = [] +const compactProcessorNormalGate: Array> = [] +const compactProcessorNormalInterrupted: Array> = [] +const compactProcessorNormalResults: Array = [] +const compactingProcessor = Layer.effect( + SessionProcessor.Service, + Effect.gen(function* () { + const sessions = yield* Session.Service + return SessionProcessor.Service.of({ + create: (input) => + Effect.succeed({ + message: input.assistantMessage, + updateToolCall: () => Effect.succeed(undefined), + completeToolCall: () => Effect.void, + process: () => + Effect.gen(function* () { + if (input.assistantMessage.summary) { + const gate = compactProcessorSummaryGate.shift() + if (gate) yield* Deferred.await(gate) + input.assistantMessage.finish = "stop" + input.assistantMessage.time.completed = Date.now() + yield* sessions.updateMessage(input.assistantMessage) + yield* sessions.updatePart({ + id: PartID.ascending(), + messageID: input.assistantMessage.id, + sessionID: input.sessionID, + type: "text", + text: "summary", + }) + return "continue" as const + } + compactProcessorNormalCalls.push(input.assistantMessage.id) + const before = compactProcessorBeforeReturn.shift() + if (before) yield* before(input.sessionID) + const normalGate = compactProcessorNormalGate.shift() + if (normalGate) { + const interrupted = compactProcessorNormalInterrupted.shift() + yield* Deferred.await(normalGate).pipe( + Effect.onInterrupt(() => + interrupted ? Deferred.succeed(interrupted, undefined).pipe(Effect.asVoid) : Effect.void, + ), + ) + } + const result = compactProcessorNormalResults.shift() ?? "compact" + if (result === "stop") { + input.assistantMessage.finish = "stop" + input.assistantMessage.time.completed = Date.now() + yield* sessions.updateMessage(input.assistantMessage) + } + return result + }), + }), + }) + }), +) + +function makePrompt(input?: { processor?: "blocking" | "compact" }) { const deps = Layer.mergeAll( Session.defaultLayer, Snapshot.defaultLayer, @@ -205,7 +263,9 @@ function makePrompt(input?: { processor?: "blocking" }) { const proc = input?.processor === "blocking" ? blockingProcessor - : SessionProcessor.layer.pipe( + : input?.processor === "compact" + ? compactingProcessor + : SessionProcessor.layer.pipe( Layer.provide(summary), Layer.provide(Image.defaultLayer), Layer.provide(RuntimeFlags.layer({ experimentalEventSystem: true })), @@ -234,17 +294,18 @@ function makePrompt(input?: { processor?: "blocking" }) { ) } -function makeHttp(input?: { processor?: "blocking" }) { +function makeHttp(input?: { processor?: "blocking" | "compact" }) { return Layer.mergeAll(TestLLMServer.layer, makePrompt(input)) } -function makeHttpNoLLMServer(input?: { processor?: "blocking" }) { +function makeHttpNoLLMServer(input?: { processor?: "blocking" | "compact" }) { return makePrompt(input) } const it = testEffect(makeHttp()) const noLLMServer = testEffect(makeHttpNoLLMServer()) const raceNoLLMServer = testEffect(makeHttpNoLLMServer({ processor: "blocking" })) +const compactNoLLMServer = testEffect(makeHttpNoLLMServer({ processor: "compact" })) const unix = process.platform !== "win32" ? it.instance : it.instance.skip const unixNoLLMServer = process.platform !== "win32" ? noLLMServer.instance : noLLMServer.instance.skip @@ -295,6 +356,49 @@ function providerCfg(url: string) { } } +function providerCfgWithLimit(url: string, limit: { context: number; output: number }) { + return { + ...providerCfg(url), + provider: { + ...providerCfg(url).provider, + test: { + ...providerCfg(url).provider.test, + models: { + ...providerCfg(url).provider.test.models, + "test-model": { + ...providerCfg(url).provider.test.models["test-model"], + limit, + }, + }, + }, + }, + } +} + +function emptyUnknownResponse(input: { usage: { input: number; output: number } }) { + return raw({ + head: [ + { + id: "chatcmpl-test", + object: "chat.completion.chunk", + choices: [{ delta: { role: "assistant" } }], + }, + ], + tail: [ + { + id: "chatcmpl-test", + object: "chat.completion.chunk", + choices: [{ delta: {}, finish_reason: "unknown" }], + usage: { + prompt_tokens: input.usage.input, + completion_tokens: input.usage.output, + total_tokens: input.usage.input + input.usage.output, + }, + }, + ], + }) +} + const writeText = Effect.fn("test.writeText")(function* (file: string, text: string) { const fs = yield* AppFileSystem.Service yield* fs.writeWithDirs(file, text) @@ -366,6 +470,9 @@ const succeedVoid = (deferred: Deferred.Deferred) => { Effect.runSync(Deferred.succeed(deferred, void 0).pipe(Effect.ignore)) } +const compactionParts = (msgs: SessionLegacy.WithParts[]) => + msgs.flatMap((msg) => msg.parts).filter((part): part is SessionLegacy.CompactionPart => part.type === "compaction") + const user = Effect.fn("test.user")(function* (sessionID: SessionID, text: string) { const session = yield* Session.Service const msg = yield* session.updateMessage({ @@ -1117,7 +1224,7 @@ it.instance( } }), { git: true }, - 3_000, + 30_000, ) // Queue semantics @@ -1503,7 +1610,7 @@ it.instance( expect(yield* llm.calls).toBe(1) }), { git: true }, - 3_000, + 30_000, ) it.instance( @@ -1542,7 +1649,7 @@ it.instance( expect(yield* llm.calls).toBe(1) }), { git: true }, - 3_000, + 30_000, ) unix( @@ -2151,6 +2258,876 @@ it.instance("does not loop empty assistant turns for a simple reply", () => }), ) +it.instance("auto-compacts empty unknown responses near context limit once and continues", () => + Effect.gen(function* () { + const { llm } = yield* useServerConfig((url) => providerCfgWithLimit(url, { context: 20, output: 10 })) + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const session = yield* sessions.create({ title: "Prompt empty unknown compaction" }) + + yield* llm.push(emptyUnknownResponse({ usage: { input: 100, output: 0 } })) + yield* llm.text("summary") + yield* llm.text("done") + + const result = yield* prompt.prompt({ + sessionID: session.id, + agent: "build", + parts: [{ type: "text", text: "Trigger compaction" }], + }) + + expect(result.info.role).toBe("assistant") + expect(result.parts.some((part) => part.type === "text" && part.text === "done")).toBe(true) + expect(yield* llm.calls).toBe(3) + + const msgs = yield* sessions.messages({ sessionID: session.id }) + const compactionParts = msgs.flatMap((msg) => msg.parts).filter((part) => part.type === "compaction") + expect(compactionParts).toHaveLength(1) + expect(compactionParts[0]).toMatchObject({ type: "compaction", auto: true, overflow: true }) + + const syntheticContinue = msgs.find((msg) => + msg.parts.some( + (part) => + part.type === "text" && part.synthetic && part.metadata?.compaction_continue === true, + ), + ) + expect(syntheticContinue).toBeDefined() + const text = syntheticContinue?.parts.find((part) => part.type === "text") + if (text?.type === "text") expect(text.text).toContain("previous request exceeded the provider's size limit") + }), +) + +it.instance("coalesces concurrent overflow auto-compaction into one marker", () => + Effect.gen(function* () { + const { llm } = yield* useServerConfig((url) => providerCfgWithLimit(url, { context: 20, output: 10 })) + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const session = yield* sessions.create({ title: "Prompt overflow compaction storm" }) + const seeded = yield* seed(session.id, { finish: "stop" }) + seeded.assistant.tokens.input = 100 + yield* sessions.updateMessage(seeded.assistant) + yield* user(session.id, "continue after overflow") + + yield* llm.text("summary") + yield* llm.text("done") + + const first = yield* prompt.loop({ sessionID: session.id }).pipe(Effect.forkChild) + const second = yield* prompt.loop({ sessionID: session.id }).pipe(Effect.forkChild) + const firstExit = yield* Fiber.await(first) + const secondExit = yield* Fiber.await(second) + expect(Exit.isSuccess(firstExit)).toBe(true) + expect(Exit.isSuccess(secondExit)).toBe(true) + + const messages = yield* sessions.messages({ sessionID: session.id }) + expect(compactionParts(messages)).toHaveLength(1) + expect(compactionParts(messages)[0]).toMatchObject({ type: "compaction", auto: true }) + expect(yield* llm.calls).toBe(2) + }), +) + +compactNoLLMServer.instance( + "prompt processes ownerless active compaction before waiting", + () => + Effect.gen(function* () { + compactProcessorSummaryGate.length = 0 + compactProcessorNormalCalls.length = 0 + compactProcessorNormalResults.length = 0 + yield* Effect.addFinalizer(() => + Effect.sync(() => { + compactProcessorSummaryGate.length = 0 + compactProcessorNormalCalls.length = 0 + compactProcessorNormalResults.length = 0 + }), + ) + + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const compact = yield* SessionCompaction.Service + const session = yield* sessions.create({ title: "Prompt ownerless active compact" }) + yield* seed(session.id, { finish: "stop" }) + compactProcessorNormalResults.push("stop") + + const created = yield* compact.create({ sessionID: session.id, agent: "build", model: ref, auto: false }) + expect(created.type).toBe("created") + + const result = yield* awaitWithTimeout( + prompt.prompt({ + sessionID: session.id, + agent: "build", + model: ref, + parts: [{ type: "text", text: "after ownerless compact" }], + }), + "prompt did not process ownerless active compaction", + "10 seconds", + ) + + expect(result.info.role).toBe("assistant") + const messages = yield* sessions.messages({ sessionID: session.id }) + expect(compactionParts(messages)).toHaveLength(1) + expect(messages.filter((message) => message.info.role === "assistant" && message.info.summary === true)).toHaveLength(1) + expect( + messages.filter((message) => + message.info.role === "user" && + message.parts.some((part) => part.type === "text" && part.text === "after ownerless compact"), + ), + ).toHaveLength(1) + expect(compactProcessorNormalCalls).toHaveLength(1) + }), + { config: cfg }, +) + +compactNoLLMServer.instance( + "prompt waits for active compaction before appending user message", + () => + Effect.gen(function* () { + compactProcessorSummaryGate.length = 0 + compactProcessorNormalResults.length = 0 + yield* Effect.addFinalizer(() => + Effect.sync(() => { + compactProcessorSummaryGate.length = 0 + compactProcessorNormalResults.length = 0 + }), + ) + + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const compact = yield* SessionCompaction.Service + const session = yield* sessions.create({ title: "Prompt waits active compact" }) + yield* seed(session.id, { finish: "stop" }) + const gate = yield* Deferred.make() + compactProcessorSummaryGate.push(gate) + compactProcessorNormalResults.push("stop") + + const created = yield* compact.create({ sessionID: session.id, agent: "build", model: ref, auto: false }) + expect(created.type).toBe("created") + const markerMessages = yield* sessions.messages({ sessionID: session.id }) + const marker = compactionParts(markerMessages)[0] + if (!marker) throw new Error("expected compaction marker") + const compactFiber = yield* compact + .process({ messages: markerMessages, parentID: marker.messageID, sessionID: session.id, auto: false }) + .pipe(Effect.forkChild) + yield* pollWithTimeout( + Effect.gen(function* () { + return (yield* compact.state(session.id)).type === "active" ? (true as const) : undefined + }), + "compaction never became active", + ) + + const promptFiber = yield* prompt + .prompt({ + sessionID: session.id, + agent: "build", + model: ref, + parts: [{ type: "text", text: "waited direct prompt" }], + }) + .pipe(Effect.forkChild) + yield* pollWithTimeout( + Effect.gen(function* () { + const state = yield* compact.state(session.id) + return state.type === "active" && state.resumeRequested ? (true as const) : undefined + }), + "prompt did not mark active compaction for resume", + ) + + const during = yield* sessions.messages({ sessionID: session.id }) + expect( + during.filter((msg) => + msg.info.role === "user" && msg.parts.some((part) => part.type === "text" && part.text === "waited direct prompt"), + ), + ).toHaveLength(0) + + yield* Deferred.succeed(gate, undefined) + const [compactExit, promptExit] = yield* Effect.all([Fiber.await(compactFiber), Fiber.await(promptFiber)]) + expect(Exit.isSuccess(compactExit)).toBe(true) + expect(Exit.isSuccess(promptExit)).toBe(true) + + const after = yield* sessions.messages({ sessionID: session.id }) + expect( + after.filter((msg) => + msg.info.role === "user" && msg.parts.some((part) => part.type === "text" && part.text === "waited direct prompt"), + ), + ).toHaveLength(1) + expect(after.filter((msg) => msg.info.role === "assistant" && msg.info.agent === "build")).toHaveLength(2) + }), + { config: cfg }, +) + +compactNoLLMServer.instance( + "command waits for active compaction before appending derived prompt", + () => + Effect.gen(function* () { + compactProcessorSummaryGate.length = 0 + compactProcessorNormalResults.length = 0 + yield* Effect.addFinalizer(() => + Effect.sync(() => { + compactProcessorSummaryGate.length = 0 + compactProcessorNormalResults.length = 0 + }), + ) + + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const compact = yield* SessionCompaction.Service + const session = yield* sessions.create({ title: "Command waits active compact" }) + yield* seed(session.id, { finish: "stop" }) + const gate = yield* Deferred.make() + compactProcessorSummaryGate.push(gate) + compactProcessorNormalResults.push("stop") + + const created = yield* compact.create({ sessionID: session.id, agent: "build", model: ref, auto: false }) + expect(created.type).toBe("created") + const markerMessages = yield* sessions.messages({ sessionID: session.id }) + const marker = compactionParts(markerMessages)[0] + if (!marker) throw new Error("expected compaction marker") + const compactFiber = yield* compact + .process({ messages: markerMessages, parentID: marker.messageID, sessionID: session.id, auto: false }) + .pipe(Effect.forkChild) + yield* pollWithTimeout( + Effect.gen(function* () { + return (yield* compact.state(session.id)).type === "active" ? (true as const) : undefined + }), + "compaction never became active", + ) + + const commandFiber = yield* prompt + .command({ + sessionID: session.id, + command: "gate-probe", + arguments: "", + }) + .pipe(Effect.forkChild) + yield* pollWithTimeout( + Effect.gen(function* () { + const state = yield* compact.state(session.id) + return state.type === "active" && state.resumeRequested ? (true as const) : undefined + }), + "command prompt did not mark active compaction for resume", + ) + + const during = yield* sessions.messages({ sessionID: session.id }) + expect( + during.filter((msg) => + msg.info.role === "user" && msg.parts.some((part) => part.type === "text" && part.text === "waited command prompt"), + ), + ).toHaveLength(0) + + yield* Deferred.succeed(gate, undefined) + const [compactExit, commandExit] = yield* Effect.all([Fiber.await(compactFiber), Fiber.await(commandFiber)]) + expect(Exit.isSuccess(compactExit)).toBe(true) + expect(Exit.isSuccess(commandExit)).toBe(true) + + const after = yield* sessions.messages({ sessionID: session.id }) + expect( + after.filter((msg) => + msg.info.role === "user" && msg.parts.some((part) => part.type === "text" && part.text === "waited command prompt"), + ), + ).toHaveLength(1) + expect(after.filter((msg) => msg.info.role === "assistant" && msg.info.agent === "build")).toHaveLength(2) + }), + { + config: { + ...cfg, + command: { + "gate-probe": { + template: "waited command prompt", + }, + }, + }, + }, +) +compactNoLLMServer.instance( + "processor compact collision joins existing marker and resumes once", + () => + Effect.gen(function* () { + compactProcessorBeforeReturn.length = 0 + compactProcessorSummaryGate.length = 0 + compactProcessorNormalCalls.length = 0 + compactProcessorNormalResults.length = 0 + yield* Effect.addFinalizer(() => + Effect.sync(() => { + compactProcessorBeforeReturn.length = 0 + compactProcessorSummaryGate.length = 0 + compactProcessorNormalCalls.length = 0 + compactProcessorNormalResults.length = 0 + }), + ) + + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const compact = yield* SessionCompaction.Service + const session = yield* sessions.create({ title: "Processor compact collision" }) + const gate = yield* Deferred.make() + compactProcessorSummaryGate.push(gate) + compactProcessorNormalResults.push("compact", "stop") + + compactProcessorBeforeReturn.push((sessionID) => + Effect.gen(function* () { + const created = yield* compact.create({ sessionID, agent: "build", model: ref, auto: true, overflow: true }) + expect(created.type).toBe("created") + const messages = yield* sessions.messages({ sessionID }).pipe(Effect.orDie) + const marker = compactionParts(messages)[0] + if (!marker) throw new Error("expected compaction marker") + yield* compact + .process({ messages, parentID: marker.messageID, sessionID, auto: true, overflow: true }) + .pipe(Effect.forkChild) + }), + ) + + yield* user(session.id, "trigger processor compact") + const fiber = yield* prompt.loop({ sessionID: session.id }).pipe(Effect.forkChild) + yield* pollWithTimeout( + Effect.gen(function* () { + const messages = yield* sessions.messages({ sessionID: session.id }) + return compactionParts(messages).length === 1 ? (true as const) : undefined + }), + "processor compact did not create or join a compaction marker", + ) + yield* Deferred.succeed(gate, undefined) + + const exit = yield* Fiber.await(fiber) + expect(Exit.isSuccess(exit)).toBe(true) + const messages = yield* sessions.messages({ sessionID: session.id }) + expect(compactionParts(messages)).toHaveLength(1) + expect(compactionParts(messages)[0]).toMatchObject({ type: "compaction", auto: true, overflow: true }) + expect(compactProcessorNormalCalls).toHaveLength(2) + }), + { config: cfg }, +) + +compactNoLLMServer.instance( + "manual compact preempts blocked ordinary processor before processing summary", + () => + Effect.gen(function* () { + compactProcessorBeforeReturn.length = 0 + compactProcessorSummaryGate.length = 0 + compactProcessorNormalCalls.length = 0 + compactProcessorNormalGate.length = 0 + compactProcessorNormalInterrupted.length = 0 + compactProcessorNormalResults.length = 0 + yield* Effect.addFinalizer(() => + Effect.sync(() => { + compactProcessorBeforeReturn.length = 0 + compactProcessorSummaryGate.length = 0 + compactProcessorNormalCalls.length = 0 + compactProcessorNormalGate.length = 0 + compactProcessorNormalInterrupted.length = 0 + compactProcessorNormalResults.length = 0 + }), + ) + + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const compact = yield* SessionCompaction.Service + const session = yield* sessions.create({ title: "Manual compact preempts ordinary processor" }) + const ordinaryGate = yield* Deferred.make() + const ordinaryInterrupted = yield* Deferred.make() + const summaryGate = yield* Deferred.make() + compactProcessorNormalGate.push(ordinaryGate) + compactProcessorNormalInterrupted.push(ordinaryInterrupted) + compactProcessorSummaryGate.push(summaryGate) + compactProcessorNormalResults.push("stop") + + yield* user(session.id, "ordinary work") + const ordinary = yield* prompt.loop({ sessionID: session.id }).pipe(Effect.forkChild) + yield* pollWithTimeout( + Effect.sync(() => (compactProcessorNormalCalls.length === 1 ? (true as const) : undefined)), + "ordinary processor did not start", + ) + + const created = yield* compact.create({ sessionID: session.id, agent: "build", model: ref, auto: false }) + expect(created.type).toBe("created") + yield* prompt.cancel(session.id) + yield* awaitWithTimeout( + Deferred.await(ordinaryInterrupted), + "manual compact did not interrupt ordinary processor", + "10 seconds", + ) + const ordinaryExit = yield* Fiber.await(ordinary) + expect(Exit.isSuccess(ordinaryExit)).toBe(true) + + const compactRun = yield* prompt.loop({ sessionID: session.id }).pipe(Effect.forkChild) + yield* pollWithTimeout( + Effect.gen(function* () { + const messages = yield* sessions.messages({ sessionID: session.id }) + return messages.some((msg) => msg.info.role === "assistant" && msg.info.summary === true) + ? (true as const) + : undefined + }), + "summary processor did not start after ordinary interruption", + ) + + const during = yield* sessions.messages({ sessionID: session.id }) + expect( + during.flatMap((msg) => msg.parts).filter((part) => part.type === "text" && part.text === "ordinary output"), + ).toHaveLength(0) + + yield* Deferred.succeed(summaryGate, undefined) + const compactExit = yield* Fiber.await(compactRun) + expect(Exit.isSuccess(compactExit)).toBe(true) + const after = yield* sessions.messages({ sessionID: session.id }) + expect(compactionParts(after)).toHaveLength(1) + expect(after.filter((msg) => msg.info.role === "assistant" && msg.info.summary === true)).toHaveLength(1) + expect( + after.flatMap((msg) => msg.parts).filter((part) => part.type === "text" && part.text === "ordinary output"), + ).toHaveLength(0) + }), + { config: cfg }, +) + +compactNoLLMServer.instance( + "active compact drains one resume into one normal continuation", + () => + Effect.gen(function* () { + compactProcessorBeforeReturn.length = 0 + compactProcessorSummaryGate.length = 0 + compactProcessorNormalCalls.length = 0 + compactProcessorNormalGate.length = 0 + compactProcessorNormalInterrupted.length = 0 + compactProcessorNormalResults.length = 0 + yield* Effect.addFinalizer(() => + Effect.sync(() => { + compactProcessorBeforeReturn.length = 0 + compactProcessorSummaryGate.length = 0 + compactProcessorNormalCalls.length = 0 + compactProcessorNormalGate.length = 0 + compactProcessorNormalInterrupted.length = 0 + compactProcessorNormalResults.length = 0 + }), + ) + + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const compact = yield* SessionCompaction.Service + const session = yield* sessions.create({ title: "Compact resume once" }) + yield* seed(session.id, { finish: "stop" }) + const gate = yield* Deferred.make() + compactProcessorSummaryGate.push(gate) + compactProcessorNormalResults.push("stop") + + const created = yield* compact.create({ sessionID: session.id, agent: "build", model: ref, auto: false }) + expect(created.type).toBe("created") + const compactRun = yield* prompt.loop({ sessionID: session.id }).pipe(Effect.forkChild) + yield* pollWithTimeout( + Effect.gen(function* () { + return (yield* compact.state(session.id)).type === "active" ? (true as const) : undefined + }), + "compaction never became active", + ) + + const promptRun = yield* prompt + .prompt({ + sessionID: session.id, + agent: "build", + model: ref, + parts: [{ type: "text", text: "resume exactly once" }], + }) + .pipe(Effect.forkChild) + yield* pollWithTimeout( + Effect.gen(function* () { + const state = yield* compact.state(session.id) + return state.type === "active" && state.resumeRequested ? (true as const) : undefined + }), + "resume was not marked while compaction was active", + ) + + yield* Deferred.succeed(gate, undefined) + const [exit, promptExit] = yield* Effect.all([Fiber.await(compactRun), Fiber.await(promptRun)]) + expect(Exit.isSuccess(exit)).toBe(true) + expect(Exit.isSuccess(promptExit)).toBe(true) + + const messages = yield* sessions.messages({ sessionID: session.id }) + expect(compactionParts(messages)).toHaveLength(1) + expect(messages.filter((msg) => msg.info.role === "assistant" && msg.info.summary === true)).toHaveLength(1) + expect(compactProcessorNormalCalls).toHaveLength(1) + expect(messages.filter((msg) => msg.info.role === "assistant" && msg.info.agent === "build")).toHaveLength(2) + }), + { config: cfg }, +) + +compactNoLLMServer.instance( + "manual compact preempts a blocked ordinary processor before compaction runs", + () => + Effect.gen(function* () { + compactProcessorBeforeReturn.length = 0 + compactProcessorSummaryGate.length = 0 + compactProcessorNormalCalls.length = 0 + compactProcessorNormalResults.length = 0 + yield* Effect.addFinalizer(() => + Effect.sync(() => { + compactProcessorBeforeReturn.length = 0 + compactProcessorSummaryGate.length = 0 + compactProcessorNormalCalls.length = 0 + compactProcessorNormalResults.length = 0 + }), + ) + + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const compact = yield* SessionCompaction.Service + const session = yield* sessions.create({ title: "Manual compact preempts processor" }) + const blocked = yield* Deferred.make() + compactProcessorBeforeReturn.push(() => Deferred.await(blocked)) + compactProcessorNormalResults.push("stop") + + yield* user(session.id, "ordinary work before manual compact") + const ordinaryFiber = yield* prompt.loop({ sessionID: session.id }).pipe(Effect.forkChild) + yield* pollWithTimeout( + Effect.sync(() => (compactProcessorNormalCalls.length === 1 ? (true as const) : undefined)), + "ordinary processor never started", + ) + + yield* prompt.cancelOrdinary(session.id) + const ordinaryExit = yield* Fiber.await(ordinaryFiber) + expect(Exit.isSuccess(ordinaryExit)).toBe(true) + + const created = yield* compact.create({ sessionID: session.id, agent: "build", model: ref, auto: false }) + expect(created.type).toBe("created") + const compactExit = yield* prompt.loop({ sessionID: session.id }).pipe(Effect.exit) + expect(Exit.isSuccess(compactExit)).toBe(true) + + const messages = yield* sessions.messages({ sessionID: session.id }) + expect(compactionParts(messages)).toHaveLength(1) + expect(compactProcessorNormalCalls).toHaveLength(1) + expect(messages.filter((message) => message.info.role === "assistant" && message.info.error)).toHaveLength(1) + expect(messages.some((message) => message.info.role === "assistant" && message.info.summary === true)).toBe(true) + }), + { config: cfg }, +) + +it.instance("auto-compaction retries once after summary provider failure and continues", () => + Effect.gen(function* () { + const { llm } = yield* useServerConfig((url) => providerCfgWithLimit(url, { context: 20, output: 10 })) + const events = yield* EventV2Bridge.Service + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const session = yield* sessions.create({ title: "Prompt auto-compaction retry once" }) + const retryStatuses: SessionStatus.Info[] = [] + const off = yield* events.listen((evt) => { + if (evt.type !== SessionStatus.Event.Status.type) return Effect.void + const data = evt.data as typeof SessionStatus.Event.Status.data.Type + if (data.sessionID === session.id && data.status.type === "retry") retryStatuses.push(data.status) + return Effect.void + }) + yield* Effect.addFinalizer(() => off) + + yield* llm.push(emptyUnknownResponse({ usage: { input: 100, output: 0 } })) + yield* llm.error(400, { error: "compaction attempt 1 failed" }) + yield* llm.text("summary") + yield* llm.text("done") + + const result = yield* prompt.prompt({ + sessionID: session.id, + agent: "build", + parts: [{ type: "text", text: "Trigger compaction retry once" }], + }) + + expect(result.info.role).toBe("assistant") + expect(result.parts.some((part) => part.type === "text" && part.text === "done")).toBe(true) + expect(yield* llm.calls).toBe(4) + expect(retryStatuses).toHaveLength(1) + expect(retryStatuses[0]).toMatchObject({ + type: "retry", + attempt: 1, + message: "Automatic compaction failed; retrying compaction 1/2", + }) + + const msgs = yield* sessions.messages({ sessionID: session.id }) + const compactionParts = msgs.flatMap((msg) => msg.parts).filter((part) => part.type === "compaction") + expect(compactionParts).toHaveLength(1) + expect(compactionParts[0]).toMatchObject({ type: "compaction", auto: true }) + const syntheticContinue = msgs.find((msg) => + msg.parts.some((part) => part.type === "text" && part.synthetic && part.metadata?.compaction_continue === true), + ) + expect(syntheticContinue).toBeDefined() + }), +) + +it.instance("auto-compaction retries exactly two times and succeeds on second retry", () => + Effect.gen(function* () { + const { llm } = yield* useServerConfig((url) => providerCfgWithLimit(url, { context: 20, output: 10 })) + const events = yield* EventV2Bridge.Service + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const session = yield* sessions.create({ title: "Prompt auto-compaction retry twice" }) + const retryStatuses: SessionStatus.Info[] = [] + const off = yield* events.listen((evt) => { + if (evt.type !== SessionStatus.Event.Status.type) return Effect.void + const data = evt.data as typeof SessionStatus.Event.Status.data.Type + if (data.sessionID === session.id && data.status.type === "retry") retryStatuses.push(data.status) + return Effect.void + }) + yield* Effect.addFinalizer(() => off) + + yield* llm.push(emptyUnknownResponse({ usage: { input: 100, output: 0 } })) + yield* llm.error(400, { error: "compaction attempt 1 failed" }) + yield* llm.error(400, { error: "compaction attempt 2 failed" }) + yield* llm.text("summary") + yield* llm.text("done") + + const result = yield* prompt.prompt({ + sessionID: session.id, + agent: "build", + parts: [{ type: "text", text: "Trigger compaction retry twice" }], + }) + + expect(result.info.role).toBe("assistant") + expect(result.parts.some((part) => part.type === "text" && part.text === "done")).toBe(true) + expect(yield* llm.calls).toBe(5) + expect(retryStatuses).toHaveLength(2) + expect(retryStatuses.map((item) => item.type === "retry" ? item.attempt : undefined)).toEqual([1, 2]) + expect(retryStatuses.map((item) => item.type === "retry" ? item.message : undefined)).toEqual([ + "Automatic compaction failed; retrying compaction 1/2", + "Automatic compaction failed; retrying compaction 2/2", + ]) + + const msgs = yield* sessions.messages({ sessionID: session.id }) + const compactionParts = msgs.flatMap((msg) => msg.parts).filter((part) => part.type === "compaction") + expect(compactionParts).toHaveLength(1) + expect(compactionParts[0]).toMatchObject({ type: "compaction", auto: true }) + const syntheticContinue = msgs.find((msg) => + msg.parts.some((part) => part.type === "text" && part.synthetic && part.metadata?.compaction_continue === true), + ) + expect(syntheticContinue).toBeDefined() + }), +) + +it.instance("auto-compaction errors only after initial compaction attempt plus two retries fail", () => + Effect.gen(function* () { + const { llm } = yield* useServerConfig((url) => providerCfgWithLimit(url, { context: 20, output: 10 })) + const events = yield* EventV2Bridge.Service + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const session = yield* sessions.create({ title: "Prompt auto-compaction retry exhaustion" }) + const retryStatuses: SessionStatus.Info[] = [] + const off = yield* events.listen((evt) => { + if (evt.type !== SessionStatus.Event.Status.type) return Effect.void + const data = evt.data as typeof SessionStatus.Event.Status.data.Type + if (data.sessionID === session.id && data.status.type === "retry") retryStatuses.push(data.status) + return Effect.void + }) + yield* Effect.addFinalizer(() => off) + + yield* llm.push(emptyUnknownResponse({ usage: { input: 100, output: 0 } })) + yield* llm.error(400, { error: "compaction attempt 1 failed" }) + yield* llm.error(400, { error: "compaction attempt 2 failed" }) + yield* llm.error(400, { error: "compaction attempt 3 failed" }) + + const exit = yield* prompt + .prompt({ + sessionID: session.id, + agent: "build", + parts: [{ type: "text", text: "Trigger compaction retry exhaustion" }], + }) + .pipe(Effect.exit) + + expect(Exit.isFailure(exit)).toBe(true) + if (Exit.isFailure(exit)) { + expect(JSON.stringify(Cause.squash(exit.cause))).toContain("Automatic compaction failed after 3 attempts") + } + expect(yield* llm.calls).toBe(4) + expect(yield* llm.pending).toBe(0) + expect(retryStatuses).toHaveLength(2) + expect(retryStatuses.map((item) => item.type === "retry" ? item.attempt : undefined)).toEqual([1, 2]) + + const msgs = yield* sessions.messages({ sessionID: session.id }) + const compactionParts = msgs.flatMap((msg) => msg.parts).filter((part) => part.type === "compaction") + expect(compactionParts).toHaveLength(1) + expect(compactionParts[0]).toMatchObject({ type: "compaction", auto: true }) + const syntheticContinue = msgs.find((msg) => + msg.parts.some((part) => part.type === "text" && part.synthetic && part.metadata?.compaction_continue === true), + ) + expect(syntheticContinue).toBeUndefined() + }), +) + +it.instance("auto-compacts zero-token empty unknown responses after prior context pressure evidence", () => + Effect.gen(function* () { + const { llm } = yield* useServerConfig((url) => providerCfgWithLimit(url, { context: 100000, output: 1000 })) + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const session = yield* sessions.create({ title: "Prompt zero-token empty unknown compaction" }) + + yield* llm.push( + raw({ + head: [ + { + id: "chatcmpl-test", + object: "chat.completion.chunk", + choices: [{ delta: { role: "assistant" } }], + }, + ], + tail: [ + { + id: "chatcmpl-test", + object: "chat.completion.chunk", + choices: [{ delta: { content: "partial" } }], + }, + { + id: "chatcmpl-test", + object: "chat.completion.chunk", + choices: [{ delta: {}, finish_reason: "length" }], + usage: { prompt_tokens: 100, completion_tokens: 5, total_tokens: 105 }, + }, + ], + }), + ) + + const first = yield* prompt.prompt({ + sessionID: session.id, + agent: "build", + parts: [{ type: "text", text: "Record context pressure" }], + }) + + expect(first.info.role).toBe("assistant") + if (first.info.role === "assistant") expect(first.info.finish).toBe("length") + expect(first.parts.some((part) => part.type === "text" && part.text === "partial")).toBe(true) + + const before = yield* sessions.messages({ sessionID: session.id }) + expect(before.flatMap((msg) => msg.parts).filter((part) => part.type === "compaction")).toHaveLength(0) + + yield* llm.push(emptyUnknownResponse({ usage: { input: 0, output: 0 } })) + yield* llm.text("summary") + yield* llm.text("done") + + const result = yield* prompt.prompt({ + sessionID: session.id, + agent: "build", + parts: [{ type: "text", text: "Continue after zero-token empty" }], + }) + + expect(result.info.role).toBe("assistant") + expect(result.parts.some((part) => part.type === "text" && part.text === "done")).toBe(true) + expect(yield* llm.calls).toBe(4) + + const msgs = yield* sessions.messages({ sessionID: session.id }) + const compactionParts = msgs.flatMap((msg) => msg.parts).filter((part) => part.type === "compaction") + expect(compactionParts).toHaveLength(1) + expect(compactionParts[0]).toMatchObject({ type: "compaction", auto: true, overflow: true }) + }), +) + +it.instance("returns an error for fresh zero-token empty unknown responses without context pressure", () => + Effect.gen(function* () { + const { llm } = yield* useServerConfig(providerCfg) + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const session = yield* sessions.create({ title: "Prompt fresh empty unknown error" }) + + yield* llm.push(emptyUnknownResponse({ usage: { input: 0, output: 0 } })) + + const result = yield* prompt.prompt({ + sessionID: session.id, + agent: "build", + parts: [{ type: "text", text: "Return empty" }], + }) + + expect(result.info.role).toBe("assistant") + if (result.info.role === "assistant") { + expect(result.info.finish).toBe("error") + expect(JSON.stringify(result.info.error)).toContain("empty response") + expect(JSON.stringify(result.info.error)).toContain("continuing silently") + } + expect(yield* llm.calls).toBe(1) + }), +) + +it.instance("does not use auto-compaction loop guard for synthetic continuation metadata alone", () => + Effect.gen(function* () { + const { llm } = yield* useServerConfig(providerCfg) + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const session = yield* sessions.create({ title: "Prompt synthetic continuation without compaction" }) + const synthetic = yield* sessions.updateMessage({ + id: MessageID.ascending(), + role: "user", + sessionID: session.id, + agent: "build", + model: ref, + time: { created: Date.now() }, + }) + yield* sessions.updatePart({ + id: PartID.ascending(), + messageID: synthetic.id, + sessionID: session.id, + type: "text", + text: "Continue if you have next steps, or stop and ask for clarification if you are unsure how to proceed.", + synthetic: true, + metadata: { compaction_continue: true }, + }) + + yield* llm.push(emptyUnknownResponse({ usage: { input: 0, output: 0 } })) + + const result = yield* prompt.loop({ sessionID: session.id }) + + expect(result.info.role).toBe("assistant") + if (result.info.role === "assistant") { + expect(result.info.finish).toBe("error") + expect(JSON.stringify(result.info.error)).toContain("empty response") + expect(JSON.stringify(result.info.error)).not.toContain("after automatic compaction") + expect(JSON.stringify(result.info.error)).not.toContain("compaction loop") + } + expect(yield* llm.calls).toBe(1) + + const msgs = yield* sessions.messages({ sessionID: session.id }) + expect(msgs.flatMap((msg) => msg.parts).filter((part) => part.type === "compaction")).toHaveLength(0) + }), +) + +it.instance("returns an error for whitespace-only assistant output without context pressure", () => + Effect.gen(function* () { + const { llm } = yield* useServerConfig(providerCfg) + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const session = yield* sessions.create({ title: "Prompt whitespace-only error" }) + + yield* llm.text(" \n\t") + + const result = yield* prompt.prompt({ + sessionID: session.id, + agent: "build", + parts: [{ type: "text", text: "Return whitespace" }], + }) + + expect(result.info.role).toBe("assistant") + if (result.info.role === "assistant") { + expect(result.info.finish).toBe("error") + expect(JSON.stringify(result.info.error)).toContain("empty response") + expect(JSON.stringify(result.info.error)).toContain("continuing silently") + } + expect(yield* llm.calls).toBe(1) + }), +) + +it.instance("stops repeated empty unknown responses after auto-compaction", () => + Effect.gen(function* () { + const { llm } = yield* useServerConfig((url) => providerCfgWithLimit(url, { context: 20, output: 10 })) + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const session = yield* sessions.create({ title: "Prompt empty unknown loop guard" }) + + yield* llm.push(emptyUnknownResponse({ usage: { input: 100, output: 0 } })) + yield* llm.text("summary") + yield* llm.push(emptyUnknownResponse({ usage: { input: 100, output: 0 } })) + + const result = yield* prompt.prompt({ + sessionID: session.id, + agent: "build", + parts: [{ type: "text", text: "Trigger compaction loop guard" }], + }) + + expect(result.info.role).toBe("assistant") + if (result.info.role === "assistant") { + expect(result.info.finish).toBe("error") + expect(JSON.stringify(result.info.error)).toContain("empty response") + expect(JSON.stringify(result.info.error)).toContain("compaction") + } + expect(yield* llm.calls).toBe(3) + + const msgs = yield* sessions.messages({ sessionID: session.id }) + const compactionParts = msgs.flatMap((msg) => msg.parts).filter((part) => part.type === "compaction") + expect(compactionParts).toHaveLength(1) + }), +) + it.instance( "records aborted errors when prompt is cancelled mid-stream", () => From 6ddd80ed4b04207c6f35b2555287386ccbc8004b Mon Sep 17 00:00:00 2001 From: aska Date: Tue, 2 Jun 2026 21:09:49 +0800 Subject: [PATCH 6/6] fix(core): join http summarize compactions Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- .../instance/httpapi/handlers/session.ts | 65 +++++++- .../test/server/httpapi-session.test.ts | 157 +++++++++++++++++- 2 files changed, 215 insertions(+), 7 deletions(-) diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts index 773fb412365b..f72806f4dfe0 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts @@ -272,13 +272,61 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session", params: { sessionID: SessionID } payload: typeof SummarizePayload.Type }) { - yield* revertSvc.cleanup(yield* requireSession(ctx.params.sessionID)) - const messages = yield* SessionError.mapStorageNotFound(session.messages({ sessionID: ctx.params.sessionID })) + const sessionID = ctx.params.sessionID + yield* revertSvc.cleanup(yield* requireSession(sessionID)) + const messages = yield* SessionError.mapStorageNotFound(session.messages({ sessionID })) const defaultAgent = yield* agentSvc.defaultAgent() const currentAgent = messages.findLast((message) => message.info.role === "user")?.info.agent ?? defaultAgent + const state = yield* compactSvc.state(sessionID) + + if (state.type === "active") { + yield* compactSvc.markResume(sessionID) + if (state.messageID) yield* promptSvc.loop({ sessionID }) + yield* compactSvc.waitForIdle(sessionID) + yield* promptSvc.loop({ sessionID }) + return true + } + + if (state.type === "pending") { + yield* compactSvc.markResume(sessionID) + yield* promptSvc.loop({ sessionID }) + yield* compactSvc.waitForIdle(sessionID) + return true + } + + const beforeCancel = yield* compactSvc.state(sessionID) + if (beforeCancel.type === "active") { + yield* compactSvc.markResume(sessionID) + if (beforeCancel.messageID) yield* promptSvc.loop({ sessionID }) + yield* compactSvc.waitForIdle(sessionID) + yield* promptSvc.loop({ sessionID }) + return true + } + if (beforeCancel.type === "pending") { + yield* compactSvc.markResume(sessionID) + yield* promptSvc.loop({ sessionID }) + yield* compactSvc.waitForIdle(sessionID) + return true + } - yield* compactSvc.create({ - sessionID: ctx.params.sessionID, + yield* promptSvc.cancelOrdinary(sessionID) + const afterCancel = yield* compactSvc.state(sessionID) + if (afterCancel.type === "active") { + yield* compactSvc.markResume(sessionID) + if (afterCancel.messageID) yield* promptSvc.loop({ sessionID }) + yield* compactSvc.waitForIdle(sessionID) + yield* promptSvc.loop({ sessionID }) + return true + } + if (afterCancel.type === "pending") { + yield* compactSvc.markResume(sessionID) + yield* promptSvc.loop({ sessionID }) + yield* compactSvc.waitForIdle(sessionID) + return true + } + + const result = yield* compactSvc.create({ + sessionID, agent: currentAgent, model: { providerID: ctx.payload.providerID, @@ -286,7 +334,14 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session", }, auto: ctx.payload.auto ?? false, }) - yield* promptSvc.loop({ sessionID: ctx.params.sessionID }) + + if (result.type === "joined") { + yield* compactSvc.waitForIdle(sessionID) + yield* promptSvc.loop({ sessionID }) + return true + } + + yield* promptSvc.loop({ sessionID }) return true }) diff --git a/packages/opencode/test/server/httpapi-session.test.ts b/packages/opencode/test/server/httpapi-session.test.ts index 7eac7d4f35c3..d6cb458cc6fa 100644 --- a/packages/opencode/test/server/httpapi-session.test.ts +++ b/packages/opencode/test/server/httpapi-session.test.ts @@ -3,7 +3,7 @@ import { NodeHttpServer, NodeServices } from "@effect/platform-node" import { SessionLegacy } from "@opencode-ai/core/session/legacy" import { mkdir } from "node:fs/promises" import path from "node:path" -import { Cause, Config, Effect, Exit, Layer } from "effect" +import { Cause, Config, Deferred, Effect, Exit, Fiber, Layer, Scope } from "effect" import { HttpClient, HttpClientRequest, HttpClientResponse, HttpRouter, HttpServer } from "effect/unstable/http" import { layerWebSocketConstructorGlobal } from "effect/unstable/socket/Socket" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" @@ -35,7 +35,7 @@ import { resetDatabase } from "../fixture/db" import { disposeAllInstances, provideInstanceEffect, TestInstance, tmpdirScoped } from "../fixture/fixture" import { TestLLMServer } from "../lib/llm-server" import { testProviderConfig } from "../lib/test-provider" -import { testEffect } from "../lib/effect" +import { awaitWithTimeout, testEffect } from "../lib/effect" void Log.init({ print: false }) @@ -102,6 +102,58 @@ function createTextMessage(sessionID: SessionIDType, text: string) { }) } +const deferredAsPromise = (deferred: Deferred.Deferred): PromiseLike => ({ + then: (onfulfilled, onrejected) => { + Effect.runFork( + Deferred.await(deferred).pipe( + Effect.match({ + onFailure: (error) => { + onrejected?.(error) + }, + onSuccess: (value) => { + onfulfilled?.(value) + }, + }), + ), + ) + return deferredAsPromise(deferred) as PromiseLike + }, +}) + +const summarizeBody = JSON.stringify({ providerID: "test", modelID: "test-model" }) + +function summarizeRequest(sessionID: SessionIDType, headers: Record) { + return requestJson(pathFor(SessionPaths.summarize, { sessionID }), { + method: "POST", + headers: { ...headers, "content-type": "application/json" }, + body: summarizeBody, + }) +} + +const compactionMarkers = (messages: SessionLegacy.WithParts[]) => + messages.filter((message) => message.info.role === "user" && message.parts.some((part) => part.type === "compaction")) + +function createCompactionMarker(sessionID: SessionIDType) { + return Effect.gen(function* () { + const svc = yield* Session.Service + const message = yield* svc.updateMessage({ + id: MessageID.ascending(), + role: "user", + sessionID, + agent: "build", + model: { providerID: ProviderV2.ID.make("test"), modelID: ProviderV2.ModelID.make("test-model") }, + time: { created: Date.now() }, + }) + yield* svc.updatePart({ + id: PartID.ascending(), + sessionID, + messageID: message.id, + type: "compaction", + auto: false, + }) + }) +} + const localAdapter = (directory: string): WorkspaceAdapter => ({ name: "Local Test", description: "Create a local test workspace", @@ -434,6 +486,107 @@ describe("session HttpApi", () => { }).pipe(Effect.provide(TestLLMServer.layer), Effect.provide(CrossSpawnSpawner.defaultLayer)), ) + it.live("deduplicates concurrent manual summarize requests", () => + Effect.gen(function* () { + const llm = yield* TestLLMServer + const gate = yield* Deferred.make() + yield* llm.hold("manual summary", deferredAsPromise(gate)) + + const directory = yield* tmpdirScoped({ git: true, config: testProviderConfig(llm.url) }) + const headers = { "x-opencode-directory": directory } + const session = yield* createSession({ title: "summarize double" }).pipe(provideInstanceEffect(directory)) + yield* createTextMessage(session.id, "summarize this conversation").pipe(provideInstanceEffect(directory)) + const scope = yield* Scope.Scope + + const first = yield* summarizeRequest(session.id, headers).pipe(Effect.forkIn(scope)) + const second = yield* summarizeRequest(session.id, headers).pipe(Effect.forkIn(scope)) + yield* awaitWithTimeout(llm.wait(1), "manual summarize did not start compaction") + yield* Deferred.succeed(gate, undefined).pipe(Effect.ignore) + + const [firstExit, secondExit] = yield* Effect.all([Fiber.await(first), Fiber.await(second)]) + expect(firstExit).toMatchObject({ _tag: "Success", value: true }) + expect(secondExit).toMatchObject({ _tag: "Success", value: true }) + expect(yield* llm.calls).toBe(1) + + const messages = yield* Session.use.messages({ sessionID: session.id }).pipe(provideInstanceEffect(directory)) + expect(compactionMarkers(messages)).toHaveLength(1) + expect( + messages.some( + (message) => + message.info.role === "assistant" && + message.info.summary === true && + message.parts.some((part) => part.type === "text" && part.text.includes("manual summary")), + ), + ).toBe(true) + }).pipe(Effect.provide(TestLLMServer.layer), Effect.provide(CrossSpawnSpawner.defaultLayer)), + ) + + it.live("summarize processes an existing pending compaction marker", () => + Effect.gen(function* () { + const llm = yield* TestLLMServer + yield* llm.text("pending summary") + + const directory = yield* tmpdirScoped({ git: true, config: testProviderConfig(llm.url) }) + const headers = { "x-opencode-directory": directory } + const session = yield* createSession({ title: "summarize pending" }).pipe(provideInstanceEffect(directory)) + yield* createTextMessage(session.id, "history before pending marker").pipe(provideInstanceEffect(directory)) + yield* createCompactionMarker(session.id).pipe(provideInstanceEffect(directory)) + + expect(yield* summarizeRequest(session.id, headers)).toBe(true) + expect(yield* llm.calls).toBe(1) + + const messages = yield* Session.use.messages({ sessionID: session.id }).pipe(provideInstanceEffect(directory)) + expect(compactionMarkers(messages)).toHaveLength(1) + expect( + messages.some( + (message) => + message.info.role === "assistant" && + message.info.summary === true && + message.parts.some((part) => part.type === "text" && part.text.includes("pending summary")), + ), + ).toBe(true) + }).pipe(Effect.provide(TestLLMServer.layer), Effect.provide(CrossSpawnSpawner.defaultLayer)), + ) + + it.live("manual summarize preempts an ordinary in-flight prompt run", () => + Effect.gen(function* () { + const llm = yield* TestLLMServer + yield* llm.hang + yield* llm.text("summary after preempt") + + const directory = yield* tmpdirScoped({ git: true, config: testProviderConfig(llm.url) }) + const headers = { "x-opencode-directory": directory } + const session = yield* createSession({ title: "summarize preempt" }).pipe(provideInstanceEffect(directory)) + const scope = yield* Scope.Scope + const promptFiber = yield* requestJson(pathFor(SessionPaths.prompt, { sessionID: session.id }), { + method: "POST", + headers: { ...headers, "content-type": "application/json" }, + body: JSON.stringify({ + agent: "build", + model: { providerID: "test", modelID: "test-model" }, + parts: [{ type: "text", text: "start an ordinary run" }], + }), + }).pipe(Effect.exit, Effect.forkIn(scope)) + + yield* awaitWithTimeout(llm.wait(1), "ordinary prompt run did not start") + expect(yield* summarizeRequest(session.id, headers)).toBe(true) + yield* Fiber.await(promptFiber) + expect(yield* llm.calls).toBe(2) + + const messages = yield* Session.use.messages({ sessionID: session.id }).pipe(provideInstanceEffect(directory)) + expect(compactionMarkers(messages)).toHaveLength(1) + expect(messages.some((message) => message.info.role === "assistant" && message.info.error)).toBe(true) + expect( + messages.some( + (message) => + message.info.role === "assistant" && + message.info.summary === true && + message.parts.some((part) => part.type === "text" && part.text.includes("summary after preempt")), + ), + ).toBe(true) + }).pipe(Effect.provide(TestLLMServer.layer), Effect.provide(CrossSpawnSpawner.defaultLayer)), + ) + it.instance( "returns v2 public request errors for cursor and workspace query failures", () =>