diff --git a/CHANGELOG.md b/CHANGELOG.md index f86982c0..852a6eb0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,35 @@ # Changelog +## 0.1.43 - 2026-09-04 + +MendCode v0.1.43 adds the current OpenAI GPT-5.6 family and GPT-6 Astra to +the model and Codex OAuth surfaces while keeping the terminal session view +compact during concurrent work. + +### Changed + +- Add GPT-6 Astra plus GPT-5.6 Sol, Terra, and Luna API and ChatGPT OAuth + presets with documented limits, pricing, reasoning levels, and fast/pro + variants where the provider exposes them. +- Keep the OpenAI model picker populated from an in-process fallback when a + cached or bundled `models.dev` catalog predates the current model family; + live provider metadata still takes precedence. +- Keep Commands history in the Ctrl+T horizontal widget tray with a direct + action to open the originating session. +- Record the Codex CLI `0.153.3` comparison and the compatibility decisions in + the release audit. + +### Fixed + +- Allow GPT-6 Astra through the ChatGPT OAuth model filter with its documented + context and input limits while retaining the existing Responses Lite scope + for GPT-5.6 tiers. + +### Tests + +- Add model-catalog fallback, model-preset, Astra OAuth filtering, mode + normalization, and context-limit regressions. + ## 0.1.42 - 2026-09-03 MendCode v0.1.42 makes queued prompts, cancellation, reconnection, and communication between sessions reliable under concurrent work. diff --git a/docs/README.md b/docs/README.md index e6f9882b..92fd8383 100644 --- a/docs/README.md +++ b/docs/README.md @@ -40,6 +40,7 @@ Use this index by intent. **Use MendCode** explains the public workflow, **Featu - [Package index](package-index.md): workspace packages versus runtime `.mendcode` packages. - [MendCode Source Map](source-map.md): one maintained map from public features to implementation and test paths. - [Releasing](releasing.md): installer contract, checksums, release notes, and public installer smoke tests. +- [Codex CLI and model audit](audits/codex-cli-0.153.3-gpt56-2026-09-04.md): upstream comparison and the GPT-5.6/GPT-6 Astra compatibility decisions for 0.1.43. - [Changelog](../CHANGELOG.md): versioned behavior changes and regression coverage for recent releases. - [Supply chain security](supply-chain-security.md): provenance, SBOM, pinned actions, dependency review, and scanner policy. - [Public readiness audit](public-readiness-audit.md): branch, secret, dependency, legacy-reference, and public-surface checks. diff --git a/docs/audits/codex-cli-0.153.3-gpt56-2026-09-04.md b/docs/audits/codex-cli-0.153.3-gpt56-2026-09-04.md new file mode 100644 index 00000000..017a11b3 --- /dev/null +++ b/docs/audits/codex-cli-0.153.3-gpt56-2026-09-04.md @@ -0,0 +1,46 @@ +# Codex CLI and OpenAI model audit — 2026-09-04 + +This audit records the upstream Codex CLI comparison used for the 0.1.43 +release. The reference CLI stable release was `0.153.3` (`rust-v0.153.3`). +OpenAI's current model pages identify the flagship model as `gpt-6-astra` and +the 5.6 family as `gpt-5.6`/Sol, Terra, and Luna. + +## Ported in 0.1.43 + +- Add `gpt-6-astra`, `gpt-5.6`, `gpt-5.6-sol`, `gpt-5.6-terra`, and + `gpt-5.6-luna` to the OpenAI catalog fallback. A stale cache or bundled + `models.dev` snapshot no longer hides these models from the picker. +- Preserve live `models.dev` entries when they exist, so the fallback cannot + replace newer provider metadata. +- Add API and ChatGPT OAuth presets with documented context limits and API + prices. GPT-6 Astra keeps its 1.05M context/922K input limit; ChatGPT OAuth + Sol, Terra, and Luna keep the existing 256K effective limit imposed by the + Responses Lite adapter. +- Recognize Astra and its documented `fast` variant in the ChatGPT OAuth + catalog. Astra stays on the standard Codex Responses transport; the current + MendCode Responses Lite compatibility contract is limited to the 5.6 tiers. +- Keep the compact Commands history in the Ctrl+T horizontal widget tray, + including its direct session-open action. + +## Reviewed and intentionally not ported + +- Upstream's asynchronous model-picker refresh (`5fc7840cf6d0`) is a larger + TUI/server contract change. MendCode already refreshes `models.dev` in the + provider service; this release adds the static safety net without changing + picker lifecycle or selection state. +- Luna Reserve usage fallback, app-server collaboration-mode discovery, live + context-compaction status, rate-limit banners, permission-profile discovery, + startup warning condensation, automatic thread naming, and submission-ID + steer acknowledgement were reviewed from the upstream history. Each needs a + separate MendCode contract and regression surface, so none is represented as + shipped behavior here. +- The upstream CLI version is not copied into MendCode's `version` request + header. The existing `0.144.0` value belongs to MendCode's internal + Responses Lite compatibility contract, and the upstream source contains no + equivalent current header contract to justify changing it. + +## Sources + +- [OpenAI GPT-6 Astra model documentation](https://developers.openai.com/api/docs/models/gpt-6-astra) +- [OpenAI GPT-5.6 Sol model documentation](https://developers.openai.com/api/docs/models/gpt-5.6-sol) +- [OpenAI Codex CLI releases](https://github.com/openai/codex/releases) diff --git a/docs/features.md b/docs/features.md index 163507fa..88d3e822 100644 --- a/docs/features.md +++ b/docs/features.md @@ -433,7 +433,9 @@ MendCode normalizes supported model aliases, preserves session affinity while th effective instructions stay the same, and rotates that affinity when instructions change. GPT-5.6 OAuth models expose a 256K effective input/context limit and a provider-aware compaction threshold so long sessions compact before the provider -rejects the request. +rejects the request. GPT-6 Astra is also available when the OpenAI catalog or +ChatGPT account exposes it, with its documented 1.05M context and 922K input +limits. ## Model Roles diff --git a/src/mendcode/packages/extensions/zed/extension.toml b/src/mendcode/packages/extensions/zed/extension.toml index 4339d9dd..12195a9d 100644 --- a/src/mendcode/packages/extensions/zed/extension.toml +++ b/src/mendcode/packages/extensions/zed/extension.toml @@ -1,7 +1,7 @@ id = "mendcode" name = "MendCode" description = "The open source coding agent." -version = "0.1.42" +version = "0.1.43" schema_version = 1 authors = ["MendCode"] repository = "https://github.com/MendCode/MendCode" @@ -11,26 +11,26 @@ name = "MendCode" icon = "./icons/opencode.svg" [agent_servers.mendcode.targets.darwin-aarch64] -archive = "https://github.com/MendCode/MendCode/releases/download/v0.1.42/mendcode-darwin-arm64.zip" +archive = "https://github.com/MendCode/MendCode/releases/download/v0.1.43/mendcode-darwin-arm64.zip" cmd = "./mendcode" args = ["acp"] [agent_servers.mendcode.targets.darwin-x86_64] -archive = "https://github.com/MendCode/MendCode/releases/download/v0.1.42/mendcode-darwin-x64.zip" +archive = "https://github.com/MendCode/MendCode/releases/download/v0.1.43/mendcode-darwin-x64.zip" cmd = "./mendcode" args = ["acp"] [agent_servers.mendcode.targets.linux-aarch64] -archive = "https://github.com/MendCode/MendCode/releases/download/v0.1.42/mendcode-linux-arm64.tar.gz" +archive = "https://github.com/MendCode/MendCode/releases/download/v0.1.43/mendcode-linux-arm64.tar.gz" cmd = "./mendcode" args = ["acp"] [agent_servers.mendcode.targets.linux-x86_64] -archive = "https://github.com/MendCode/MendCode/releases/download/v0.1.42/mendcode-linux-x64.tar.gz" +archive = "https://github.com/MendCode/MendCode/releases/download/v0.1.43/mendcode-linux-x64.tar.gz" cmd = "./mendcode" args = ["acp"] [agent_servers.mendcode.targets.windows-x86_64] -archive = "https://github.com/MendCode/MendCode/releases/download/v0.1.42/mendcode-windows-x64.zip" +archive = "https://github.com/MendCode/MendCode/releases/download/v0.1.43/mendcode-windows-x64.zip" cmd = "./mendcode.exe" args = ["acp"] diff --git a/src/mendcode/packages/opencode/package.json b/src/mendcode/packages/opencode/package.json index cd992d84..4c70e975 100644 --- a/src/mendcode/packages/opencode/package.json +++ b/src/mendcode/packages/opencode/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "0.1.42", + "version": "0.1.43", "name": "mendcode", "type": "module", "license": "MIT", diff --git a/src/mendcode/packages/opencode/script/dev-rearm-smoke.ts b/src/mendcode/packages/opencode/script/dev-rearm-smoke.ts index 543721d7..65142f79 100644 --- a/src/mendcode/packages/opencode/script/dev-rearm-smoke.ts +++ b/src/mendcode/packages/opencode/script/dev-rearm-smoke.ts @@ -2,7 +2,7 @@ import { createServer, type IncomingMessage, type ServerResponse } from "node:ht import { once } from "node:events" import { probeRuntime } from "./dev-rearm" -const VERSION = "0.1.42" +const VERSION = "0.1.43" const PROJECT = "/tmp/mendcode-dev-rearm-smoke-project" function assert(condition: unknown, message: string): asserts condition { diff --git a/src/mendcode/packages/opencode/src/cli/cmd/tui/component/agent-command-panel.tsx b/src/mendcode/packages/opencode/src/cli/cmd/tui/component/agent-command-panel.tsx new file mode 100644 index 00000000..9c59630e --- /dev/null +++ b/src/mendcode/packages/opencode/src/cli/cmd/tui/component/agent-command-panel.tsx @@ -0,0 +1,141 @@ +import { createMemo, createSignal, For, Show } from "solid-js" +import type { TuiThemeCurrent } from "@mendcode/plugin/tui" +import { Locale } from "@/util/locale" +import { + agentViewCommandStateRank, + formatAgentViewCommandSummary, + isAgentViewCommandActionable, + type AgentViewCommand, +} from "../util/agent-view" + +export function AgentCommandPanel(props: { + commands: readonly AgentViewCommand[] + sessionID: string + width: number + height?: number + busyID?: string + theme: Pick + onUpdate: (command: AgentViewCommand, state: "accepted" | "rejected") => void + onOpenSession?: (sessionID: string) => void +}) { + const [expanded, setExpanded] = createSignal(false) + const active = createMemo(() => + props.commands + .filter((command) => agentViewCommandStateRank(command) < 2) + .toSorted( + (a, b) => + Number(b.targetSessionID === props.sessionID && isAgentViewCommandActionable(b)) - + Number(a.targetSessionID === props.sessionID && isAgentViewCommandActionable(a)) || + agentViewCommandStateRank(a) - agentViewCommandStateRank(b) || + b.time.updated - a.time.updated, + ), + ) + const history = createMemo(() => + props.commands + .filter((command) => agentViewCommandStateRank(command) >= 2) + .toSorted((a, b) => b.time.updated - a.time.updated), + ) + const visible = createMemo(() => [...active().slice(0, 4), ...(expanded() ? history().slice(0, 4) : [])]) + const rows = () => ( + <> + + {(command) => { + const incoming = () => command.targetSessionID === props.sessionID + const actionable = () => incoming() && isAgentViewCommandActionable(command) + const busy = () => props.busyID === command.id + const sender = () => + incoming() ? command.payload?.sourceTitle || command.sourceSessionID : command.targetSessionID + const relatedSessionID = () => (incoming() ? command.sourceSessionID : command.targetSessionID) + const canOpen = () => + Boolean(props.onOpenSession && relatedSessionID() && relatedSessionID() !== props.sessionID) + const trailingWidth = () => (canOpen() ? 7 : 0) + (actionable() ? 19 : 0) + return ( + + + + {Locale.truncate( + `${incoming() ? "←" : "→"} ${Locale.truncate(sender().replace(/\s+/g, " "), 24)} · ${formatAgentViewCommandSummary(command)}`, + Math.max(1, props.width - trailingWidth()), + )} + + + { + const sessionID = relatedSessionID() + if (sessionID) props.onOpenSession?.(sessionID) + }} + > + [open] + + + + { + if (!busy()) props.onUpdate(command, "accepted") + }} + > + [accept] + + { + if (!busy()) props.onUpdate(command, "rejected") + }} + > + [reject] + + + + + + {Locale.truncate(command.error!.replace(/\s+/g, " "), props.width)} + + + + ) + }} + + 4 || (expanded() && history().length > 4)}> + + More commands in Agent View + + + + ) + + return ( + 0}> + + setExpanded((value) => !value)} + > + {history().length > 0 ? (expanded() ? "▾ " : "▸ ") : ""}Commands + {active().length > 0 ? ` · ${active().length} active` : ""} + {history().length > 0 ? ` · ${history().length} past` : ""} + + + + {rows()} + + + + + ) +} diff --git a/src/mendcode/packages/opencode/src/cli/cmd/tui/component/dialog-command.tsx b/src/mendcode/packages/opencode/src/cli/cmd/tui/component/dialog-command.tsx index be135267..54855aee 100644 --- a/src/mendcode/packages/opencode/src/cli/cmd/tui/component/dialog-command.tsx +++ b/src/mendcode/packages/opencode/src/cli/cmd/tui/component/dialog-command.tsx @@ -231,7 +231,7 @@ const titleOverrides: Record = { "session.context": "View Context Usage", "session.permission.status": "Change Approval Mode", "session.toggle.sticky_user_header": "Toggle Sticky User Header", - "session.toggle.todos": "Toggle Todos", + "session.toggle.todos": "Toggle Session Widgets", "session.toggle.thinking": "Toggle Thinking", "session.toggle.timestamps": "Toggle Timestamps", "session.toggle.actions": "Toggle Tool Details", diff --git a/src/mendcode/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/src/mendcode/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index 15b7ec0a..3db17978 100644 --- a/src/mendcode/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/src/mendcode/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -191,6 +191,7 @@ import { sessionBottomDockLayout, sessionTodoIcon, sessionTodoPanelWidth, + sessionWidgetTrayContentWidth, type SessionTodo, } from "../../util/session-bottom-dock" import { renderSessionExitSummary } from "../../util/session-exit-summary" @@ -220,12 +221,10 @@ import { unavailableReasoningLabel, } from "@/mend/tui/presentation" import { blurCompactionArcade, CompactionPanel, isCompactionArcadeFocused } from "../../component/compaction-panel" +import { AgentCommandPanel } from "../../component/agent-command-panel" import { agentViewCommandStateRank, agentViewCommandTouchesSession, - formatAgentViewCommandSummary, - formatAgentViewCommandState, - formatAgentViewCommandType, isAgentViewCommandActionable, type AgentViewCommand, } from "../../util/agent-view" @@ -1006,7 +1005,9 @@ export function Session() { const [headerTitleOffset, setHeaderTitleOffset] = createSignal(0) const [showGenericToolOutput, setShowGenericToolOutput] = kv.signal("generic_tool_output_visibility", false) const [showCompactedToolCalls, setShowCompactedToolCalls] = kv.signal(COMPACTED_TOOL_CALLS_KV_KEY, false) - const [showTodos, setShowTodos] = kv.signal("session_todos_visible", false) + // Keep the existing KV key so users who had the dock visible keep that preference. + const [showSessionWidgets, setShowSessionWidgets] = kv.signal("session_todos_visible", false) + const [focusSessionWidgets, setFocusSessionWidgets] = createSignal(false) const [followSessionOutput, setFollowSessionOutput] = createSignal(true) let submitFollowSyncTimer: ReturnType | undefined let submitFollowSyncToken = 0 @@ -1355,7 +1356,7 @@ export function Session() { const name = workflow?.name ? `${workflow.name} · ` : "" return Locale.truncate(`${name}${state}${progress ? ` · ${progress}` : ""}`, Math.max(12, contentWidth() - 16)) }) - const showSessionBottomDock = createMemo(() => showTodos() && !disabled()) + const showSessionBottomDock = createMemo(() => showSessionWidgets() && !disabled()) const promptDisabled = createMemo(() => disabled()) const agentCommandURL = (path: string, query?: Record) => { @@ -1428,19 +1429,6 @@ export function Session() { } } - const incomingAgentCommands = createMemo(() => - agentCommands().filter((command) => command.targetSessionID === route.sessionID), - ) - const sentAgentCommands = createMemo(() => - agentCommands().filter( - (command) => command.sourceSessionID === route.sessionID && command.targetSessionID !== route.sessionID, - ), - ) - const visibleAgentCommands = createMemo(() => [ - ...incomingAgentCommands().slice(0, 4), - ...sentAgentCommands().slice(0, 2), - ]) - createEffect( on( () => route.sessionID, @@ -3617,6 +3605,16 @@ export function Session() { const command = useCommandDialog() + function toggleSessionWidgets(dialog?: ReturnType) { + setShowSessionWidgets((visible) => { + const next = !visible + setFocusSessionWidgets(next) + if (!next) setTimeout(() => prompt?.focus(), 0) + return next + }) + dialog?.clear() + } + // Esc must cancel the active turn even when a transcript, widget, or an // autocomplete renderable owns focus. Pending approval/question UIs retain // their own Esc semantics (reject/close) and are deliberately excluded. @@ -3700,16 +3698,15 @@ export function Session() { }, }, { - title: showTodos() ? "Hide todos" : "Show todos", + title: showSessionWidgets() ? "Hide session widgets" : "Show session widgets", value: "session.toggle.todos", keybind: "todo_toggle", category: "Session", description: todos().length - ? `${todos().filter((todo) => todo.status !== "completed").length} open todos` - : "No todos in this session.", + ? `${todos().filter((todo) => todo.status !== "completed").length} open todos · horizontal widget tray` + : "Open the horizontal widget tray for this session.", onSelect: (dialog) => { - setShowTodos((prev) => !prev) - dialog.clear() + toggleSessionWidgets(dialog) }, }, { @@ -4395,92 +4392,6 @@ export function Session() { } }) - const agentCommandPanel = () => ( - 0}> - - - ⟡ Commands - - - {(command) => { - const incoming = command.targetSessionID === route.sessionID - const actionable = incoming && isAgentViewCommandActionable(command) - const busy = agentCommandBusyID() === command.id - const compact = insetRowWidth() < 76 - const statusLine = () => - actionable ? ( - - - mouse:{" "} - - void updateAgentCommand(command, "accepted")} - > - [accept] - - - {" "} - - void updateAgentCommand(command, "rejected")} - > - [reject] - - - ) : ( - - {formatAgentViewCommandState(command)} - - ) - return ( - - - - {incoming ? "in" : "sent"} · {formatAgentViewCommandSummary(command)} - - - - - - {formatAgentViewCommandType(command)} ·{" "} - {incoming - ? `from ${Locale.truncate(command.sourceSessionID, compact ? 14 : 18)}` - : `to ${Locale.truncate(command.targetSessionID, compact ? 14 : 18)}`} - - - - - - {statusLine()} - - - ) - }} - - - - ) - return ( )} - {agentCommandPanel()} {() => ( @@ -4850,8 +4760,14 @@ export function Session() { setFocusSessionWidgets(false)} + onUpdateCommand={(command, state) => void updateAgentCommand(command, state)} + onOpenSession={(sessionID) => navigate({ type: "session", sessionID })} onOpenSubagent={(sessionID) => navigate({ type: "session", sessionID })} info={{ branch: topBranchLabel(), @@ -5339,6 +5255,10 @@ function dockWidgetWidth(item: MendWidgetEntry) { return undefined } +function dockWidgetMinWidth(item: MendWidgetEntry) { + return Math.max(1, typeof item.width === "number" ? item.width : (item.minWidth ?? 18)) +} + function dockWidgetHeight(item: MendWidgetEntry, fallback: number) { if (typeof item.height === "number") return Math.max(1, Math.min(fallback, item.height)) return fallback @@ -5347,8 +5267,7 @@ function dockWidgetHeight(item: MendWidgetEntry, fallback: number) { function dockWidgetsMinWidth(items: MendWidgetEntry[]) { if (!items.length) return 0 return items.reduce((total, item, index) => { - const width = typeof item.width === "number" ? item.width : (item.minWidth ?? 18) - return total + Math.max(1, width) + (index > 0 ? 1 : 0) + return total + dockWidgetMinWidth(item) + (index > 0 ? 1 : 0) }, 0) } @@ -5483,14 +5402,23 @@ function RenderMendWidget(props: { item: MendWidgetEntry }) { function SessionBottomDock(props: { todos: SessionTodo[] subagents: SessionSubagentInfo[] + commands: readonly AgentViewCommand[] + commandBusyID?: string width: number sessionID: string info: SessionBottomInfo + autoFocus?: boolean + onAutoFocus?: () => void + onUpdateCommand: (command: AgentViewCommand, state: "accepted" | "rejected") => void + onOpenSession: (sessionID: string) => void onOpenSubagent: (sessionID: string) => void }) { const { theme } = useTheme() const mend = useMendTuiProfile() const dockWidgets = createMemo(() => listMendWidgets("sessionBottomDock")) + const commandWidth = createMemo(() => + props.commands.length > 0 ? Math.max(42, Math.min(72, Math.floor(props.width * 0.55))) : 0, + ) const builtinDockWidgetIDs = ["todo", "notes", "subagents", "info"] const profileControlsBuiltins = createMemo(() => { const widgets = mend.profile.widgets @@ -5518,48 +5446,112 @@ function SessionBottomDock(props: { }, }), ) + const trayContentWidth = createMemo(() => + sessionWidgetTrayContentWidth({ + dockWidth: layout().dockWidth, + widgetWidths: [ + commandWidth(), + layout().todoWidth, + layout().showNotes ? layout().notesWidth : 0, + layout().showSubagents ? layout().subagentsWidth : 0, + layout().showInfo ? layout().infoWidth : 0, + ...dockWidgets().map(dockWidgetMinWidth), + ], + }), + ) + const trayOverflow = createMemo(() => trayContentWidth() > layout().dockWidth) + const trayHeight = createMemo(() => layout().dockHeight + (trayOverflow() ? 1 : 0)) + let tray: ScrollBoxRenderable | undefined + + onMount(() => { + if (!props.autoFocus) return + tray?.focus() + props.onAutoFocus?.() + }) return ( - { + tray = value + }} width={layout().dockWidth} - height={layout().dockHeight} - flexDirection="row" - gap={1} - alignItems="stretch" - overflow="hidden" + height={trayHeight()} + scrollX + scrollY={false} + contentOptions={{ width: trayContentWidth(), minWidth: trayContentWidth() }} + horizontalScrollbarOptions={{ + visible: trayOverflow(), + trackOptions: { + backgroundColor: theme.backgroundElement, + foregroundColor: theme.border, + }, + }} + verticalScrollbarOptions={{ visible: false }} > - - - - - - - - - - - - {(item) => ( + + 0}> - + - )} - - + + + + + + + + + + + + + {(item) => ( + + + + )} + + + ) } @@ -5633,6 +5625,7 @@ function SessionNotesWidget(props: { sessionID: string; width: number; height: n props.compactSubagentPrompt && props.index === 0) @@ -6223,11 +6219,11 @@ function UserMessage(props: { - ●{" "} + {agentMessage() ? "◆" : "●"}{" "} {agentMessage() - ? `${agentMessage()!.kind === "response" ? "Reply from" : "From"} · ${ + ? `${agentMessage()!.kind === "response" ? "Agent reply from" : "Agent message from"} · ${ agentMessage()!.sourceTitle ?? agentMessage()!.sourceSessionID ?? "session" - }` + }${agentMessage()!.cancelled ? " · cancelled" : ""}` : subagentInitialPrompt() ? "Subagent prompt" : "You"} diff --git a/src/mendcode/packages/opencode/src/cli/cmd/tui/util/session-bottom-dock.test.ts b/src/mendcode/packages/opencode/src/cli/cmd/tui/util/session-bottom-dock.test.ts index f01d38eb..aca1bdd1 100644 --- a/src/mendcode/packages/opencode/src/cli/cmd/tui/util/session-bottom-dock.test.ts +++ b/src/mendcode/packages/opencode/src/cli/cmd/tui/util/session-bottom-dock.test.ts @@ -1,7 +1,15 @@ import { describe, expect, test } from "bun:test" -import { sessionBottomDockLayout, sessionTodoPanelWidth } from "./session-bottom-dock" +import { sessionBottomDockLayout, sessionTodoPanelWidth, sessionWidgetTrayContentWidth } from "./session-bottom-dock" describe("session bottom dock layout", () => { + test("keeps the tray viewport width when widgets fit", () => { + expect(sessionWidgetTrayContentWidth({ dockWidth: 100, widgetWidths: [28, 32] })).toBe(100) + }) + + test("keeps every stacked widget reachable when the tray overflows", () => { + expect(sessionWidgetTrayContentWidth({ dockWidth: 60, widgetWidths: [42, 28, 32] })).toBe(104) + }) + test("keeps room for notes beside todos when the dock has side space", () => { const layout = sessionBottomDockLayout({ width: 120, diff --git a/src/mendcode/packages/opencode/src/cli/cmd/tui/util/session-bottom-dock.ts b/src/mendcode/packages/opencode/src/cli/cmd/tui/util/session-bottom-dock.ts index 5afee8ab..15fd2877 100644 --- a/src/mendcode/packages/opencode/src/cli/cmd/tui/util/session-bottom-dock.ts +++ b/src/mendcode/packages/opencode/src/cli/cmd/tui/util/session-bottom-dock.ts @@ -25,6 +25,18 @@ const MAX_SUBAGENTS_WIDGET_WIDTH = 56 const MAX_INFO_WIDGET_WIDTH = 36 const SIDE_WIDGET_GAP = 1 +export function sessionWidgetTrayContentWidth(input: { + dockWidth: number + widgetWidths: readonly number[] + gap?: number +}) { + const gap = Math.max(0, input.gap ?? SIDE_WIDGET_GAP) + const contentWidth = input.widgetWidths + .filter((width) => Number.isFinite(width) && width > 0) + .reduce((total, width, index) => total + Math.max(1, width) + (index > 0 ? gap : 0), 0) + return Math.max(1, input.dockWidth, contentWidth) +} + function clampDockWidth(value: number, min: number, max: number) { return Math.max(min, Math.min(max, value)) } diff --git a/src/mendcode/packages/opencode/src/config/keybinds.ts b/src/mendcode/packages/opencode/src/config/keybinds.ts index 4521929a..c25b74fe 100644 --- a/src/mendcode/packages/opencode/src/config/keybinds.ts +++ b/src/mendcode/packages/opencode/src/config/keybinds.ts @@ -72,7 +72,7 @@ const KeybindsSchema = Schema.Struct({ agent_cycle_reverse: keybind("none", "Previous primary agent"), variant_cycle: keybind("f3", "Cycle model variants"), variant_list: keybind("shift+f3", "List model variants"), - todo_toggle: keybind("ctrl+t", "Toggle session todos"), + todo_toggle: keybind("ctrl+t", "Toggle session widgets"), input_clear: keybind("ctrl+c", "Clear input field"), input_paste: keybind("ctrl+v", "Paste from clipboard"), input_submit: keybind("return", "Submit input"), diff --git a/src/mendcode/packages/opencode/src/mend/cli/control-plane.ts b/src/mendcode/packages/opencode/src/mend/cli/control-plane.ts index 4be640cc..58432273 100644 --- a/src/mendcode/packages/opencode/src/mend/cli/control-plane.ts +++ b/src/mendcode/packages/opencode/src/mend/cli/control-plane.ts @@ -1621,7 +1621,7 @@ async function models(args: string[]) { if (sub === "presets") { console.log( JSON.stringify( - { presets: modelPresets, secretsIncluded: false, pricingSource: "official OpenAI docs captured 2026-05-06" }, + { presets: modelPresets, secretsIncluded: false, pricingSource: "official OpenAI docs verified 2026-09-04" }, null, 2, ), diff --git a/src/mendcode/packages/opencode/src/mend/config/models.ts b/src/mendcode/packages/opencode/src/mend/config/models.ts index f72811c9..337a1b03 100644 --- a/src/mendcode/packages/opencode/src/mend/config/models.ts +++ b/src/mendcode/packages/opencode/src/mend/config/models.ts @@ -167,6 +167,15 @@ function withoutRemovedModelRoles(config: ModelsConfig): ModelsConfig { } export const modelPresets = { + "openai-codex-subscription-gpt-6-astra": { + providerID: "openai", + modelID: "gpt-6-astra", + authMode: "chatgpt-subscription-oauth", + env: [], + pricingPer1MTokens: null, + source: ".agents/vendor/opencode/packages/opencode/src/plugin/codex.ts", + note: "Uses the Codex-supported GPT-6 Astra model with ChatGPT Plus/Pro OAuth when the account rollout includes Astra; not OpenAI API billing.", + }, "openai-codex-subscription-gpt-5.6-sol": { providerID: "openai", modelID: "gpt-5.6-sol", @@ -176,6 +185,24 @@ export const modelPresets = { source: ".agents/vendor/opencode/packages/opencode/src/plugin/codex.ts", note: "Uses the Codex-supported GPT-5.6 Sol model with ChatGPT Plus/Pro OAuth; not OpenAI API billing.", }, + "openai-codex-subscription-gpt-5.6-terra": { + providerID: "openai", + modelID: "gpt-5.6-terra", + authMode: "chatgpt-subscription-oauth", + env: [], + pricingPer1MTokens: null, + source: ".agents/vendor/opencode/packages/opencode/src/plugin/codex.ts", + note: "Uses the Codex-supported GPT-5.6 Terra model with ChatGPT Plus/Pro OAuth; not OpenAI API billing.", + }, + "openai-codex-subscription-gpt-5.6-luna": { + providerID: "openai", + modelID: "gpt-5.6-luna", + authMode: "chatgpt-subscription-oauth", + env: [], + pricingPer1MTokens: null, + source: ".agents/vendor/opencode/packages/opencode/src/plugin/codex.ts", + note: "Uses the Codex-supported GPT-5.6 Luna model with ChatGPT Plus/Pro OAuth; not OpenAI API billing.", + }, "openai-codex-subscription-gpt-5.2": { providerID: "openai", modelID: "gpt-5.2", @@ -217,9 +244,36 @@ export const modelPresets = { modelID: "gpt-5.6", authMode: "api-key", env: ["OPENAI_API_KEY"], - pricingPer1MTokens: { inputUsd: 5, cachedInputUsd: 0.5, outputUsd: 30 }, - source: "https://platform.openai.com/docs/pricing/", - note: "OpenAI API billing; newest GPT-5.6 default with max reasoning support.", + pricingPer1MTokens: { inputUsd: 4, cachedInputUsd: 0.4, outputUsd: 20 }, + source: "https://developers.openai.com/api/docs/models/gpt-5.6-sol", + note: "OpenAI API billing; GPT-5.6 alias for the Sol tier with max reasoning support.", + }, + "openai-api-gpt-6-astra": { + providerID: "openai", + modelID: "gpt-6-astra", + authMode: "api-key", + env: ["OPENAI_API_KEY"], + pricingPer1MTokens: { inputUsd: 10, cachedInputUsd: 1, outputUsd: 50 }, + source: "https://developers.openai.com/api/docs/models/gpt-6-astra", + note: "OpenAI API billing; flagship GPT-6 Astra for the hardest reasoning and coding work.", + }, + "openai-api-gpt-5.6-terra": { + providerID: "openai", + modelID: "gpt-5.6-terra", + authMode: "api-key", + env: ["OPENAI_API_KEY"], + pricingPer1MTokens: { inputUsd: 2, cachedInputUsd: 0.2, outputUsd: 12 }, + source: "https://developers.openai.com/api/docs/models/gpt-5.6-terra", + note: "OpenAI API billing; GPT-5.6 Terra for balanced everyday work.", + }, + "openai-api-gpt-5.6-luna": { + providerID: "openai", + modelID: "gpt-5.6-luna", + authMode: "api-key", + env: ["OPENAI_API_KEY"], + pricingPer1MTokens: { inputUsd: 0.2, cachedInputUsd: 0.02, outputUsd: 1.2 }, + source: "https://developers.openai.com/api/docs/models/gpt-5.6-luna", + note: "OpenAI API billing; GPT-5.6 Luna for cost-efficient high-volume work.", }, "openai-api-gpt-5-mini": { providerID: "openai", diff --git a/src/mendcode/packages/opencode/src/mend/prompt/compose.ts b/src/mendcode/packages/opencode/src/mend/prompt/compose.ts index ddb4ba78..de6e56a0 100644 --- a/src/mendcode/packages/opencode/src/mend/prompt/compose.ts +++ b/src/mendcode/packages/opencode/src/mend/prompt/compose.ts @@ -241,7 +241,7 @@ function marketplaceExtensionContract() { "- Custom Prompt Mode is one of `minimal`, `focus`, `full`, or `custom`; `custom` reads the bounded project prompt at `.mendcode/prompts/custom.md` while preserving the MendCode boundary. It is project instruction text, not an upstream hidden prompt.", "- Custom AI tools live in `.mendcode/tools/*.{ts,js}` and can invoke bounded scripts; expose only the smallest safe interface and keep secrets out of tools and packages.", "- Custom pages/routes render through the public plugin route/slot API. Custom widgets use `api.ui.runtime.setWidget` with `aboveEditor`, `belowEditor`, or `sessionBottomDock` placement and clean up with the plugin lifecycle.", - "- Ctrl+T toggles the current session's TODO view. Ctrl+P -> Customize TUI or `/customize` opens live customization; these controls do not replace the public plugin API.", + "- Ctrl+T toggles the current session's horizontal widget tray (todos included). Ctrl+P -> Customize TUI or `/customize` opens live customization; these controls do not replace the public plugin API.", "- Custom pages can build terminal-native ASCII/Solid UIs similar to built-in Usage, Memory Center, or Loop pages when the required state is available through the public API.", "- Shell-backed widgets use `api.shell.spawn()` for bounded stdout/stderr streams. This is not a PTY; do not implement full-screen terminal apps, cursor-addressing programs, alternate-screen apps, Doom, or real cava by piping stdout into the main TUI.", "- If a package needs private MendCode runtime data, add or request a public API first. Do not import private runtime internals from packages.", diff --git a/src/mendcode/packages/opencode/src/mend/prompt/sources.ts b/src/mendcode/packages/opencode/src/mend/prompt/sources.ts index a82218ea..1cbd354f 100644 --- a/src/mendcode/packages/opencode/src/mend/prompt/sources.ts +++ b/src/mendcode/packages/opencode/src/mend/prompt/sources.ts @@ -33,6 +33,17 @@ const modelBehaviorProfiles: Array = "Treat aliases, transport, reasoning settings, caching, and advanced features as runtime configuration; use only capabilities exposed by this session.", ], }, + { + id: "gpt-6-astra", + focusID: "codex", + label: "GPT-6 Astra compatibility", + sourcePolicy: "mendcode-compatibility", + match: /(^|[^a-z0-9])gpt[-_.:/]?6[-_.:/]?astra([^a-z0-9]|$)/i, + behavior: [ + "Use the actual GPT-6 Astra runtime contract and exposed tools instead of assuming that every Codex CLI feature is available through MendCode.", + "Treat reasoning modes, transport, caching, context limits, and advanced features as runtime configuration; verify capabilities before promising them.", + ], + }, { id: "claude-sonnet-5", focusID: "claude", diff --git a/src/mendcode/packages/opencode/src/plugin/codex.ts b/src/mendcode/packages/opencode/src/plugin/codex.ts index 954a6398..748a0d55 100644 --- a/src/mendcode/packages/opencode/src/plugin/codex.ts +++ b/src/mendcode/packages/opencode/src/plugin/codex.ts @@ -27,6 +27,7 @@ const ALLOWED_MODELS = new Set([ "gpt-5.3-codex-spark", "gpt-5.4", "gpt-5.4-mini", + "gpt-6-astra", ]) const CODEX_CHATGPT_MODEL_ALIASES: Record = { @@ -40,6 +41,12 @@ const CODEX_CHATGPT_5_6_LIMIT = { output: 128_000, } +const CODEX_CHATGPT_6_ASTRA_LIMIT = { + context: 1_050_000, + input: 922_000, + output: 128_000, +} + const CODEX_CHATGPT_FAST_MODE_MODELS = new Set([ "gpt-5.4", "gpt-5.4-mini", @@ -48,9 +55,15 @@ const CODEX_CHATGPT_FAST_MODE_MODELS = new Set([ "gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna", + "gpt-6-astra", ]) -const CODEX_CHATGPT_PRO_MODE_MODELS = new Set(["gpt-5.6", "gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna"]) +const CODEX_CHATGPT_PRO_MODE_MODELS = new Set([ + "gpt-5.6", + "gpt-5.6-sol", + "gpt-5.6-terra", + "gpt-5.6-luna", +]) export function normalizeCodexChatGPTModel(modelID: string) { const fastBase = modelID.endsWith("-fast") ? modelID.slice(0, -"-fast".length) : undefined @@ -68,6 +81,20 @@ export function normalizeCodexChatGPTModel(modelID: string) { } } +export function isCodexChatGPTModelSupported(modelID: string) { + const normalized = normalizeCodexChatGPTModel(modelID).modelID + if (ALLOWED_MODELS.has(normalized)) return true + const match = normalized.match(/^gpt-(\d+\.\d+)/) + return match ? parseFloat(match[1]) > 5.4 : false +} + +function codexChatGPTLimit(modelID: string) { + const normalized = normalizeCodexChatGPTModel(modelID).modelID + if (normalized === "gpt-6-astra") return CODEX_CHATGPT_6_ASTRA_LIMIT + if (normalized.startsWith("gpt-5.6")) return CODEX_CHATGPT_5_6_LIMIT + return undefined +} + export function normalizeCodexChatGPTRequestBody(body: BodyInit | null | undefined) { if (typeof body !== "string") return body let parsed: unknown @@ -569,12 +596,12 @@ export async function CodexAuthPlugin(input: PluginInput, options: CodexAuthPlug return Object.fromEntries( Object.entries(provider.models) .filter(([, model]) => { - if (ALLOWED_MODELS.has(model.api.id)) return true - const match = model.api.id.match(/^gpt-(\d+\.\d+)/) - return match ? parseFloat(match[1]) > 5.4 : false + return isCodexChatGPTModelSupported(model.api.id) }) .map(([modelID, model]) => { const modelOptions = isRecord(model.options) ? model.options : {} + const limit = codexChatGPTLimit(model.api.id) + const usesCompactionThreshold = limit !== undefined return [ modelID, { @@ -584,8 +611,8 @@ export async function CodexAuthPlugin(input: PluginInput, options: CodexAuthPlug output: 0, cache: { read: 0, write: 0 }, }, - limit: model.api.id.startsWith("gpt-5.6") - ? CODEX_CHATGPT_5_6_LIMIT + limit: limit + ? limit : model.id.includes("gpt-5.5") ? { context: 400_000, @@ -593,7 +620,7 @@ export async function CodexAuthPlugin(input: PluginInput, options: CodexAuthPlug output: 128_000, } : model.limit, - options: model.api.id.startsWith("gpt-5.6") + options: usesCompactionThreshold ? { ...modelOptions, compaction: { diff --git a/src/mendcode/packages/opencode/src/provider/models.ts b/src/mendcode/packages/opencode/src/provider/models.ts index 3b826f98..26ad753b 100644 --- a/src/mendcode/packages/opencode/src/provider/models.ts +++ b/src/mendcode/packages/opencode/src/provider/models.ts @@ -8,6 +8,7 @@ import { Flock } from "@mendcode/core/util/flock" import { Hash } from "@mendcode/core/util/hash" import { AppFileSystem } from "@mendcode/core/filesystem" import { withTransientReadRetry } from "@/util/effect-http-client" +import { withOpenAIModelFallbacks } from "./openai-models" const Cost = Schema.Struct({ input: Schema.Finite, @@ -166,7 +167,7 @@ export const layer: Layer.Layer - }).pipe(Effect.withSpan("ModelsDev.populate"), Effect.orDie) + }).pipe(Effect.map(withOpenAIModelFallbacks), Effect.withSpan("ModelsDev.populate"), Effect.orDie) const [cachedGet, invalidate] = yield* Effect.cachedInvalidateWithTTL(populate, Duration.infinity) diff --git a/src/mendcode/packages/opencode/src/provider/openai-models.ts b/src/mendcode/packages/opencode/src/provider/openai-models.ts new file mode 100644 index 00000000..f806aa57 --- /dev/null +++ b/src/mendcode/packages/opencode/src/provider/openai-models.ts @@ -0,0 +1,201 @@ +import type * as ModelsDev from "./models" + +const GPT_56_REASONING = ["none", "low", "medium", "high", "xhigh", "max"] +const GPT_6_ASTRA_REASONING = ["low", "medium", "high", "xhigh", "max"] + +const GPT_56_LIMIT = { + context: 1_050_000, + input: 922_000, + output: 128_000, +} + +const GPT_56_MODALITIES = { + input: ["text", "image", "pdf"] as Array<"text" | "image" | "pdf">, + output: ["text"] as Array<"text">, +} + +const GPT_56_BASE = { + attachment: true, + reasoning: true, + reasoning_options: [{ type: "effort", values: GPT_56_REASONING }], + temperature: false, + tool_call: true, + limit: GPT_56_LIMIT, + modalities: GPT_56_MODALITIES, + release_date: "2026-07-09", +} + +const GPT_56_PRO_MODE = { + provider: { body: { reasoning: { mode: "pro" } } }, +} + +function gpt56Modes(fastCost: ModelsDev.Model["cost"]) { + return { + fast: { + cost: fastCost, + provider: { body: { service_tier: "priority" } }, + }, + pro: GPT_56_PRO_MODE, + } +} + +const GPT_56_COSTS = { + sol: { + input: 4, + output: 20, + cache_read: 0.4, + cache_write: 5, + context_over_200k: { + input: 8, + output: 30, + cache_read: 0.8, + cache_write: 10, + }, + }, + terra: { + input: 2, + output: 12, + cache_read: 0.2, + cache_write: 2.5, + context_over_200k: { + input: 4, + output: 18, + cache_read: 0.4, + cache_write: 5, + }, + }, + luna: { + input: 0.2, + output: 1.2, + cache_read: 0.02, + cache_write: 0.25, + context_over_200k: { + input: 0.4, + output: 1.8, + cache_read: 0.04, + cache_write: 0.5, + }, + }, +} + +const GPT_56_FAST_COSTS = { + sol: { input: 8, output: 40, cache_read: 0.8, cache_write: 10 }, + terra: { input: 4, output: 24, cache_read: 0.4, cache_write: 5 }, + luna: { input: 0.4, output: 2.4, cache_read: 0.04, cache_write: 0.5 }, +} + +const GPT_6_ASTRA_COST = { + input: 10, + output: 50, + cache_read: 1, + cache_write: 12.5, + context_over_200k: { + input: 20, + output: 75, + cache_read: 2, + cache_write: 25, + }, +} + +const GPT_6_ASTRA_FAST_COST = { + input: 20, + output: 100, + cache_read: 2, + cache_write: 25, + context_over_200k: { + input: 40, + output: 150, + cache_read: 4, + cache_write: 50, + }, +} + +const GPT_6_ASTRA_MODES = { + fast: { + cost: GPT_6_ASTRA_FAST_COST, + provider: { body: { service_tier: "priority" } }, + }, +} + +function gpt56Model( + id: string, + name: string, + family: string, + cost: ModelsDev.Model["cost"], + fastCost: ModelsDev.Model["cost"], +): ModelsDev.Model { + return { + id, + name, + family, + ...GPT_56_BASE, + cost, + experimental: { modes: gpt56Modes(fastCost) }, + } +} + +/** + * Keep the picker usable when a cached or bundled models.dev catalog predates + * the current OpenAI model family. Live catalog entries always win. + */ +export const OPENAI_MODEL_FALLBACKS: Record = { + "gpt-5.6": gpt56Model("gpt-5.6", "GPT-5.6", "gpt-sol", GPT_56_COSTS.sol, GPT_56_FAST_COSTS.sol), + "gpt-5.6-sol": gpt56Model( + "gpt-5.6-sol", + "GPT-5.6 Sol", + "gpt-sol", + GPT_56_COSTS.sol, + GPT_56_FAST_COSTS.sol, + ), + "gpt-5.6-terra": gpt56Model( + "gpt-5.6-terra", + "GPT-5.6 Terra", + "gpt-terra", + GPT_56_COSTS.terra, + GPT_56_FAST_COSTS.terra, + ), + "gpt-5.6-luna": gpt56Model( + "gpt-5.6-luna", + "GPT-5.6 Luna", + "gpt-luna", + GPT_56_COSTS.luna, + GPT_56_FAST_COSTS.luna, + ), + "gpt-6-astra": { + id: "gpt-6-astra", + name: "GPT-6 Astra", + family: "gpt-astra", + attachment: true, + reasoning: true, + reasoning_options: [{ type: "effort", values: GPT_6_ASTRA_REASONING }], + temperature: false, + tool_call: true, + limit: { + context: 1_050_000, + input: 922_000, + output: 128_000, + }, + modalities: GPT_56_MODALITIES, + // OpenAI publishes a knowledge cutoff for Astra, but not a model release + // date on the model page. Keep this field empty until the catalog provides + // an authoritative value. + release_date: "", + cost: GPT_6_ASTRA_COST, + experimental: { modes: GPT_6_ASTRA_MODES }, + }, +} + +export function withOpenAIModelFallbacks(catalog: Record) { + const provider = catalog.openai + if (!provider) return catalog + return { + ...catalog, + openai: { + ...provider, + models: { + ...OPENAI_MODEL_FALLBACKS, + ...provider.models, + }, + }, + } +} diff --git a/src/mendcode/packages/opencode/src/session/prompt.ts b/src/mendcode/packages/opencode/src/session/prompt.ts index 9eb6b614..b7249a62 100644 --- a/src/mendcode/packages/opencode/src/session/prompt.ts +++ b/src/mendcode/packages/opencode/src/session/prompt.ts @@ -153,6 +153,7 @@ export function interruptedToolPromptText(messages: readonly MessageV2.WithParts } type RecoverablePromptMessage = { + parts?: readonly { type: string; metadata?: Record }[] info: { id: string role: "user" | "assistant" @@ -162,11 +163,24 @@ type RecoverablePromptMessage = { } } +function isCancelledPeerPrompt(message: Pick) { + return ( + message.parts?.some( + (part) => + part.type === "text" && + (part.metadata?.kind === "peer_message" || part.metadata?.kind === "peer_response") && + typeof part.metadata.cancelledAt === "number", + ) === true + ) +} + export function findRecoverableQueuedPrompt( messages: readonly T[], options?: { includeLegacy?: boolean }, ) { - const ordered = messages.toSorted((a, b) => a.info.time.created - b.info.time.created) + const ordered = messages + .filter((message) => !isCancelledPeerPrompt(message)) + .toSorted((a, b) => a.info.time.created - b.info.time.created) const latestAssistant = ordered.findLast((message) => message.info.role === "assistant") if (latestAssistant && latestAssistant.info.time.completed === undefined) return @@ -568,6 +582,44 @@ export const layer = Layer.effect( yield* backgroundTasks.dismissNotifications(sessionID) }) + const cancelPeerExchanges = Effect.fn("SessionPrompt.cancelPeerExchanges")(function* (sessionID: SessionID) { + const messages = yield* sessions.messages({ sessionID, view: "full" }) + const answered = new Set( + messages.flatMap((message) => (message.info.role === "assistant" ? [message.info.parentID] : [])), + ) + for (const message of messages) { + if (message.info.role !== "user" || message.info.queued !== true || answered.has(message.info.id)) continue + const parts = message.parts.filter( + (part): part is MessageV2.TextPart => + part.type === "text" && + (part.metadata?.kind === "peer_message" || part.metadata?.kind === "peer_response"), + ) + if (parts.length === 0) continue + for (const part of parts) { + yield* sessions.updatePart({ ...part, ignored: true, metadata: { ...part.metadata, cancelledAt: Date.now() } }) + } + yield* sessions.updateMessage({ ...message.info, queued: false }) + yield* state.cancelQueued(sessionID, message.info.id) + } + const commands = (yield* agentCommands.list()).filter( + (command) => + command.type === "peer_message" && + (command.targetSessionID === sessionID || command.sourceSessionID === sessionID) && + (command.state === "pending" || command.state === "accepted" || command.state === "running"), + ) + // Settle the durable exchange before idle is published, including replies + // still being produced elsewhere. Recovery must not replay a stopped run. + for (const command of commands) { + yield* agentCommands + .update({ + id: command.id, + state: command.state === "pending" ? "rejected" : "failed", + error: "Session stopped by the user before the message exchange completed.", + }) + .pipe(Effect.catchTag("AgentCommandInvalidStateTransitionError", () => Effect.void), Effect.orDie) + } + }) + const finishOrphanedAssistant = Effect.fn("SessionPrompt.finishOrphanedAssistant")(function* ( sessionID: SessionID, messageID: MessageID, @@ -622,6 +674,7 @@ export const layer = Layer.effect( // A background task can finish after the parent run is cancelled. Do not // let its late notification start a fresh assistant turn. yield* cancelOwnerWakeSession(sessionID) + yield* cancelPeerExchanges(sessionID) promptAbortReasons.set(sessionID, "user") const controller = promptAbortControllers.get(sessionID)?.controller let interruptedAssistantID: MessageID | undefined @@ -654,6 +707,9 @@ export const layer = Layer.effect( // the cancel request reached the runner. yield* cancelOwnerWakeSession(input.sessionID) const active = promptAbortControllers.get(input.sessionID) + if (active?.targetMessageID === input.targetMessageID || (!active && !(yield* state.isBusy(input.sessionID)))) { + yield* cancelPeerExchanges(input.sessionID) + } let interruptedAssistantID: MessageID | undefined let markedUserAbort = false const result = yield* state @@ -2439,6 +2495,9 @@ NOTE: At any point in time through this workflow you should feel free to ask the yield* slog.info("loop", { step }) let msgs = yield* MessageV2.filterCompactedEffect(sessionID) + const cancelled = msgs.find((message) => message.info.id === targetMessageID && isCancelledPeerPrompt(message)) + if (cancelled) return cancelled + msgs = msgs.filter((message) => !isCancelledPeerPrompt(message)) if (!initialMessageIDs) { const targetIndex = targetMessageID ? msgs.findIndex((message) => message.info.id === targetMessageID) : -1 @@ -3159,6 +3218,8 @@ NOTE: At any point in time through this workflow you should feel free to ask the return metadata?.kind === "peer_response" && metadata.deliveryID === command.id }), ) + const current = yield* agentCommands.get(command.id) + if (current.state !== "accepted" && current.state !== "running") return const responsePrompt = Option.isSome(existingResponse) ? existingResponse.value : yield* promptAsync({ @@ -3260,6 +3321,8 @@ NOTE: At any point in time through this workflow you should feel free to ask the return } + // A queued delivery may have been cancelled while its marker was read. + if ((yield* agentCommands.get(info.id)).state !== "running") return const message = yield* promptAsync({ sessionID: info.targetSessionID, parts: [ diff --git a/src/mendcode/packages/opencode/test/cli/tui/agent-command-panel.test.tsx b/src/mendcode/packages/opencode/test/cli/tui/agent-command-panel.test.tsx new file mode 100644 index 00000000..d9dd0b28 --- /dev/null +++ b/src/mendcode/packages/opencode/test/cli/tui/agent-command-panel.test.tsx @@ -0,0 +1,140 @@ +/** @jsxImportSource @opentui/solid */ +import { expect, test } from "bun:test" +import { RGBA } from "@opentui/core" +import { testRender } from "@opentui/solid" +import { createSignal } from "solid-js" +import { AgentCommandPanel } from "@/cli/cmd/tui/component/agent-command-panel" +import type { AgentViewCommand } from "@/cli/cmd/tui/util/agent-view" + +const theme = { + text: RGBA.fromHex("#f0f0f0"), + textMuted: RGBA.fromHex("#999999"), + accent: RGBA.fromHex("#66ccff"), + error: RGBA.fromHex("#ff6666"), +} + +function command(id: string, state: AgentViewCommand["state"]): AgentViewCommand { + return { + id, + state, + type: "peer_message", + sourceSessionID: "source", + targetSessionID: "target", + payload: { sourceTitle: "Cache worker", text: "Delayed progress" }, + time: { created: 1, updated: 1 }, + } +} + +async function settle(app: Awaited>) { + await app.renderOnce() + await app.renderOnce() +} + +test("collapses historical commands to one row and exposes failure details on demand", async () => { + const app = await testRender( + () => ( + { + throw new Error("Historical command must not be actionable") + }} + /> + ), + { width: 90, height: 12 }, + ) + try { + await settle(app) + expect(app.captureCharFrame().trim()).toBe("▸ Commands · 3 past") + const toggle = app.renderer.root.findDescendantById("agent-command-history-toggle")! + await app.mockMouse.click(toggle.x + 1, toggle.y) + await settle(app) + const frame = app.captureCharFrame() + expect(frame).toContain("▾ Commands · 3 past") + expect(frame).toContain("Cache worker") + expect(frame).toContain("Session stopped by the user.") + expect(frame.match(/failed/g)).toHaveLength(1) + expect(frame).not.toContain("[accept]") + await app.mockMouse.click(toggle.x + 1, toggle.y) + await settle(app) + expect(app.captureCharFrame().trim()).toBe("▸ Commands · 3 past") + } finally { + app.renderer.destroy() + } +}) + +test("offers a direct button to open the related session", async () => { + const opened: string[] = [] + const updates: string[] = [] + const app = await testRender( + () => ( + updates.push(`${item.id}:${state}`)} + onOpenSession={(sessionID) => opened.push(sessionID)} + /> + ), + { width: 60, height: 8 }, + ) + try { + await settle(app) + const open = app.renderer.root.findDescendantById("agent-command-open-pending")! + const accept = app.renderer.root.findDescendantById("agent-command-accept-pending")! + const reject = app.renderer.root.findDescendantById("agent-command-reject-pending")! + expect(open.x + open.width).toBeLessThanOrEqual(60) + expect(accept.x + accept.width).toBeLessThanOrEqual(60) + expect(reject.x + reject.width).toBeLessThanOrEqual(60) + await app.mockMouse.click(open.x + 1, open.y) + expect(opened).toEqual(["source"]) + await app.mockMouse.click(accept.x + 1, accept.y) + expect(updates).toEqual(["pending:accepted"]) + } finally { + app.renderer.destroy() + } +}) + +for (const width of [42, 100]) { + test(`keeps pending actions visible at ${width} columns and reacts to completion`, async () => { + const [commands, setCommands] = createSignal([command("pending", "pending"), command("old", "failed")]) + const updates: string[] = [] + const app = await testRender( + () => ( + { + updates.push(`${item.id}:${state}`) + setCommands((items) => + items.map((entry) => (entry.id === item.id ? { ...entry, state: "completed" } : entry)), + ) + }} + /> + ), + { width, height: 8 }, + ) + try { + await settle(app) + expect(app.captureCharFrame()).toContain("[accept] [reject]") + const accept = app.renderer.root.findDescendantById("agent-command-accept-pending")! + expect(accept.x + accept.width).toBeLessThanOrEqual(width) + await app.mockMouse.click(accept.x + 1, accept.y) + await settle(app) + expect(updates).toEqual(["pending:accepted"]) + expect(app.captureCharFrame().trim()).toBe("▸ Commands · 2 past") + } finally { + app.renderer.destroy() + } + }) +} diff --git a/src/mendcode/packages/opencode/test/mend/models-config.test.ts b/src/mendcode/packages/opencode/test/mend/models-config.test.ts index 5c8ddacd..562f7a1d 100644 --- a/src/mendcode/packages/opencode/test/mend/models-config.test.ts +++ b/src/mendcode/packages/opencode/test/mend/models-config.test.ts @@ -5,6 +5,7 @@ import { tmpdir } from "../fixture/fixture" import { initProject } from "../../src/mend/config/project" import { modelRoleProjection, + modelPresets, parsePromptModel, refreshGeneratedRuntimeModelConfig, resolveEffectivePromptSelection, @@ -17,6 +18,22 @@ async function writeText(file: string, value: string) { } describe("mend model roles", () => { + test("publishes the current OpenAI model family and pricing presets", () => { + expect(modelPresets["openai-codex-subscription-gpt-6-astra"].modelID).toBe("gpt-6-astra") + expect(modelPresets["openai-api-gpt-6-astra"].pricingPer1MTokens).toEqual({ + inputUsd: 10, + cachedInputUsd: 1, + outputUsd: 50, + }) + expect(modelPresets["openai-api-gpt-5.6"].pricingPer1MTokens).toEqual({ + inputUsd: 4, + cachedInputUsd: 0.4, + outputUsd: 20, + }) + expect(modelPresets["openai-api-gpt-5.6-terra"].modelID).toBe("gpt-5.6-terra") + expect(modelPresets["openai-api-gpt-5.6-luna"].modelID).toBe("gpt-5.6-luna") + }) + test("does not seed command packs that duplicate native TUI model/provider surfaces", async () => { await using dir = await tmpdir() diff --git a/src/mendcode/packages/opencode/test/mend/tui-prompt-chrome.test.ts b/src/mendcode/packages/opencode/test/mend/tui-prompt-chrome.test.ts index 2e82c256..8a8e4c9a 100644 --- a/src/mendcode/packages/opencode/test/mend/tui-prompt-chrome.test.ts +++ b/src/mendcode/packages/opencode/test/mend/tui-prompt-chrome.test.ts @@ -355,7 +355,7 @@ describe("mend tui prompt chrome", () => { expect(promptChromeUsesFullSessionWidth("ascii-box")).toBe(true) }) - test("ctrl-t is reserved for session todos and f3 cycles model variants", () => { + test("ctrl-t toggles session widgets and f3 cycles model variants", () => { const keybinds = ConfigKeybinds.Keybinds.parse({}) expect(keybinds.todo_toggle).toBe("ctrl+t") diff --git a/src/mendcode/packages/opencode/test/plugin/codex.test.ts b/src/mendcode/packages/opencode/test/plugin/codex.test.ts index 35f33128..9a51490b 100644 --- a/src/mendcode/packages/opencode/test/plugin/codex.test.ts +++ b/src/mendcode/packages/opencode/test/plugin/codex.test.ts @@ -5,6 +5,7 @@ import { extractAccountIdFromClaims, extractAccountId, normalizeCodexChatGPTRequestBody, + isCodexChatGPTModelSupported, prepareCodexChatGPTOAuthRequest, type IdTokenClaims, } from "../../src/plugin/codex" @@ -73,6 +74,19 @@ describe("plugin.codex", () => { ) as string, ), ).toEqual({ model: "gpt-5.6-terra", reasoning: { effort: "high", mode: "pro" } }) + expect( + JSON.parse( + normalizeCodexChatGPTRequestBody( + JSON.stringify({ model: "gpt-5.6-terra-pro", reasoning: { effort: "high" } }), + ) as string, + ), + ).toEqual({ model: "gpt-5.6-terra", reasoning: { effort: "high", mode: "pro" } }) + }) + + test("routes Astra fast mode to the priority service tier", () => { + expect( + JSON.parse(normalizeCodexChatGPTRequestBody(JSON.stringify({ model: "gpt-6-astra-fast" })) as string), + ).toEqual({ model: "gpt-6-astra", service_tier: "priority" }) }) test("preserves explicit GPT-5.6 tiers and non-JSON bodies", () => { @@ -89,8 +103,10 @@ describe("plugin.codex", () => { }) }) - test("keeps Sol, Terra, and Luna modes in the ChatGPT OAuth catalog", async () => { + test("keeps Astra, Sol, Terra, and Luna modes in the ChatGPT OAuth catalog", async () => { const ids = [ + "gpt-6-astra", + "gpt-6-astra-fast", "gpt-5.6-sol", "gpt-5.6-sol-fast", "gpt-5.6-terra", @@ -104,7 +120,7 @@ describe("plugin.codex", () => { id, { id, - api: { id: id.replace(/-fast$/, "") }, + api: { id: id.replace(/-(fast|pro)$/, "") }, cost: { input: 1, output: 1, cache: { read: 1, write: 1 } }, limit: { context: 1, input: 1, output: 1 }, }, @@ -116,11 +132,22 @@ describe("plugin.codex", () => { expect(Object.keys(models)).toEqual(ids) for (const id of ids) { - expect(models[id]?.limit).toEqual({ context: 256_000, input: 256_000, output: 128_000 }) + expect(models[id]?.limit).toEqual( + id.startsWith("gpt-6-astra") + ? { context: 1_050_000, input: 922_000, output: 128_000 } + : { context: 256_000, input: 256_000, output: 128_000 }, + ) expect(models[id]?.options).toMatchObject({ compaction: { threshold: 90 } }) } }) + test("accepts the official Astra model and rejects unknown GPT-6 IDs", () => { + expect(isCodexChatGPTModelSupported("gpt-6-astra")).toBe(true) + expect(isCodexChatGPTModelSupported("gpt-6-astra-fast")).toBe(true) + expect(isCodexChatGPTModelSupported("gpt-6-astra-pro")).toBe(false) + expect(isCodexChatGPTModelSupported("gpt-6-unknown")).toBe(false) + }) + test("rewrites GPT-5.6 OAuth requests for Responses Lite with honest client identity", async () => { const requests: Array<{ headers: Headers; body: Record }> = [] using server = Bun.serve({ diff --git a/src/mendcode/packages/opencode/test/provider/provider.test.ts b/src/mendcode/packages/opencode/test/provider/provider.test.ts index a6c9a05e..391a5811 100644 --- a/src/mendcode/packages/opencode/test/provider/provider.test.ts +++ b/src/mendcode/packages/opencode/test/provider/provider.test.ts @@ -8,6 +8,7 @@ import { Instance } from "../../src/project/instance" import { WithInstance } from "../../src/project/with-instance" import { Plugin } from "../../src/plugin/index" import { ModelsDev } from "@/provider/models" +import { OPENAI_MODEL_FALLBACKS, withOpenAIModelFallbacks } from "@/provider/openai-models" import { Provider } from "@/provider/provider" import { ProviderID, ModelID } from "../../src/provider/schema" import { Filesystem } from "@/util/filesystem" @@ -1984,6 +1985,34 @@ test("mode cost preserves over-200k pricing from base model", () => { }) }) +test("OpenAI catalog fallback keeps current GPT-5.6 and Astra models available", () => { + const catalog = withOpenAIModelFallbacks({ + openai: { + id: "openai", + name: "OpenAI", + env: ["OPENAI_API_KEY"], + models: { + "gpt-5.6": { + ...OPENAI_MODEL_FALLBACKS["gpt-5.6"], + name: "cached GPT-5.6", + }, + }, + }, + } as unknown as Record) + + expect(Object.keys(catalog.openai!.models)).toEqual( + expect.arrayContaining(["gpt-6-astra", "gpt-5.6", "gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna"]), + ) + expect(catalog.openai!.models["gpt-6-astra"]?.limit).toEqual({ + context: 1_050_000, + input: 922_000, + output: 128_000, + }) + expect(catalog.openai!.models["gpt-5.6"]?.name).toBe("cached GPT-5.6") + expect(catalog.openai!.models["gpt-5.6-luna"]?.experimental?.modes?.fast?.cost?.output).toBe(2.4) + expect(catalog.openai!.models["gpt-6-astra"]?.experimental?.modes?.fast?.cost?.output).toBe(100) +}) + test("models.dev normalization fills required response fields", () => { const provider = { id: "gateway", diff --git a/src/mendcode/packages/opencode/test/session/prompt.test.ts b/src/mendcode/packages/opencode/test/session/prompt.test.ts index bfbbacef..6fbd65a8 100644 --- a/src/mendcode/packages/opencode/test/session/prompt.test.ts +++ b/src/mendcode/packages/opencode/test/session/prompt.test.ts @@ -2487,6 +2487,145 @@ it.live("automatically delivers a same-workspace agent message and returns its r ), ) +for (const cancellation of ["session", "turn"] as const) { + it.live(`cancels queued peer messages when stopping a ${cancellation}`, () => + provideTmpdirServer( + Effect.fnUntraced(function* ({ llm }) { + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const status = yield* SessionStatus.Service + const commands = yield* AgentCommand.Service + const source = yield* sessions.create({ title: "Late subagent" }) + const target = yield* sessions.create({ title: "Stopped parent" }) + yield* llm.text("ready") + const finished = yield* prompt.prompt({ + sessionID: target.id, + agent: "build", + model: ref, + parts: [{ type: "text", text: "start" }], + }) + if (finished.info.role !== "assistant") throw new Error("assistant missing") + yield* status.set(target.id, { type: "busy", message: "active run" }) + const command = yield* commands.create({ + sourceSessionID: source.id, + targetSessionID: target.id, + type: "peer_message", + payload: { text: "delayed progress" }, + }) + if (cancellation === "session") yield* prompt.cancel(target.id) + else yield* prompt.cancelTurn({ sessionID: target.id, targetMessageID: finished.info.parentID }) + expect((yield* commands.get(command.id)).state).toBe("failed") + yield* status.set(target.id, { type: "idle" }) + yield* prompt.wakePeerDelivery(target.id) + yield* Effect.sleep("600 millis") + expect(yield* llm.calls).toBe(1) + expect( + (yield* sessions.messages({ sessionID: target.id, view: "full" })).some((message) => + message.parts.some((part) => part.type === "text" && part.metadata?.deliveryID === command.id), + ), + ).toBe(false) + }), + { git: true, config: (url) => ({ ...providerCfg(url), agent: { build: { model: "test/test-model" } } }) }, + ), + ) +} + +it.live("does not wake a stopped sender when a peer response arrives", () => + provideTmpdirServer( + Effect.fnUntraced(function* ({ llm }) { + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const commands = yield* AgentCommand.Service + const source = yield* sessions.create({ title: "Stopped sender" }) + const target = yield* sessions.create({ title: "Responding peer" }) + yield* prompt.wakePeerDelivery(source.id) + const gate = defer() + yield* llm.hold("late response", gate.promise) + const command = yield* commands.create({ + sourceSessionID: source.id, + targetSessionID: target.id, + type: "peer_message", + payload: { text: "question" }, + }) + try { + yield* Effect.gen(function* () { + while ((yield* llm.calls) === 0) yield* Effect.sleep("1 millis") + }).pipe(Effect.timeout("2 seconds")) + yield* prompt.cancel(source.id) + expect((yield* commands.get(command.id)).state).toBe("failed") + } finally { + gate.resolve() + } + yield* Effect.gen(function* () { + while ( + !(yield* sessions.messages({ sessionID: target.id })).some( + (message) => message.info.role === "assistant" && message.info.time.completed !== undefined, + ) + ) yield* Effect.sleep("1 millis") + }).pipe(Effect.timeout("2 seconds")) + yield* Effect.sleep("100 millis") + expect(yield* llm.calls).toBe(1) + expect(yield* sessions.messages({ sessionID: source.id, view: "full" })).toEqual([]) + }), + { git: true, config: (url) => ({ ...providerCfg(url), agent: { build: { model: "test/test-model" } } }) }, + ), +) + +it.live("stopping drops an already queued peer reply while preserving the queued human prompt", () => + provideTmpdirServer( + Effect.fnUntraced(function* ({ llm }) { + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const status = yield* SessionStatus.Service + const chat = yield* sessions.create({ title: "Mixed prompt queue" }) + yield* llm.hang + const current = yield* prompt.promptAsync({ + sessionID: chat.id, + agent: "build", + model: ref, + parts: [{ type: "text", text: "active work" }], + }) + yield* llm.wait(1) + const peer = yield* prompt.promptAsync({ + sessionID: chat.id, + agent: "build", + model: ref, + parts: [ + { type: "text", text: "late peer reply", metadata: { kind: "peer_response", sourceSessionID: "peer" } }, + ], + }) + const human = yield* prompt.promptAsync({ + sessionID: chat.id, + agent: "build", + model: ref, + parts: [{ type: "text", text: "keep my real message" }], + }) + yield* llm.text("human response") + expect(yield* prompt.cancelTurn({ sessionID: chat.id, targetMessageID: current.info.id })).toBe("cancelled") + yield* Effect.gen(function* () { + while ( + !(yield* sessions.messages({ sessionID: chat.id })).some( + (message) => + message.info.role === "assistant" && + message.info.parentID === human.info.id && + message.info.time.completed, + ) + ) yield* Effect.sleep("1 millis") + }).pipe(Effect.timeout("2 seconds")) + yield* Effect.sleep("50 millis") + const messages = yield* sessions.messages({ sessionID: chat.id, view: "full" }) + expect( + messages.some((message) => message.info.role === "assistant" && message.info.parentID === peer.info.id), + ).toBe(false) + expect(findRecoverableQueuedPrompt(messages, { includeLegacy: true })).toBeUndefined() + expect(yield* llm.calls).toBe(2) + expect(JSON.stringify((yield* llm.inputs).at(-1))).not.toContain("late peer reply") + expect((yield* status.get(chat.id)).type).toBe("idle") + }), + { git: true, config: (url) => ({ ...providerCfg(url), agent: { build: { model: "test/test-model" } } }) }, + ), +) + it.live("startup recovery finalizes stale foreground work without replaying it", () => provideTmpdirServer( Effect.fnUntraced(function* ({ llm }) {