From 1ef29f3ccfbdabfacb125ce1a3db5fa78308cd3f Mon Sep 17 00:00:00 2001 From: Obed0101 Date: Tue, 25 Aug 2026 22:47:47 -0500 Subject: [PATCH 1/4] release: prepare v0.1.39 --- CHANGELOG.md | 21 ++ docs/mermaid-ascii-rendering.md | 2 +- docs/releasing.md | 17 ++ src/mendcode/install.ps1 | 56 +++- .../packages/extensions/zed/extension.toml | 12 +- src/mendcode/packages/opencode/package.json | 2 +- .../opencode/script/dev-rearm-smoke.ts | 2 +- .../cli/cmd/tui/component/dialog-command.tsx | 4 +- .../cli/cmd/tui/component/prompt/index.tsx | 102 +++++-- .../cli/cmd/tui/context/session-control.tsx | 67 ++++- .../src/cli/cmd/tui/routes/session/index.tsx | 56 +++- .../packages/opencode/src/effect/runner.ts | 250 +++++++++-------- .../opencode/src/installation/index.ts | 96 +++++-- .../packages/opencode/src/storage/db.ts | 10 +- .../opencode/src/storage/legacy-migration.ts | 254 ++++++++++++++++++ .../test/cli/tui/prompt-working-start.test.ts | 21 +- .../test/cli/tui/session-control.test.ts | 34 +++ .../opencode/test/effect/runner.test.ts | 42 ++- .../test/installation/installation.test.ts | 22 +- .../test/storage/legacy-migration.test.ts | 82 ++++++ 20 files changed, 956 insertions(+), 196 deletions(-) create mode 100644 src/mendcode/packages/opencode/src/storage/legacy-migration.ts create mode 100644 src/mendcode/packages/opencode/test/storage/legacy-migration.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 6f28e7b7..cf090887 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,26 @@ # Changelog +## 0.1.39 - 2026-08-26 + +MendCode v0.1.39 makes interruption reliable across folders and recovers legacy installs without disrupting active sessions. + +### Changed + +- Route session controls through the owning workspace or directory instead of the currently selected project. +- Keep the global `Esc` interrupt available when the editor, transcript, or another TUI widget owns focus. +- Preserve explicit backend and provider retry state while their live heartbeat is still fresh. + +### Fixed + +- Fall back from a stale targeted cancellation to a bounded `session.abort` without leaving large or old sessions running indefinitely. +- Prevent cancellation outbox entries captured under an old route from replaying into another backend. +- Update Windows installations through the native PowerShell path instead of assuming WSL `/bin/bash` exists. +- Reconcile legacy SQLite migration journals transactionally when the existing schema is already present. + +### Tests + +- Run focused session-control, prompt, runner, installation, database-migration, workspace-routing, build, and startup smoke checks. + ## 0.1.38 - 2026-08-25 MendCode v0.1.38 hardens Smart Approval and keeps long-running sessions truthful during retries, reconnects, and large TUI turns. diff --git a/docs/mermaid-ascii-rendering.md b/docs/mermaid-ascii-rendering.md index 497c51bb..cc20a29d 100644 --- a/docs/mermaid-ascii-rendering.md +++ b/docs/mermaid-ascii-rendering.md @@ -1,6 +1,6 @@ # Mermaid ASCII Rendering -MendCode renders Mermaid fences as local, terminal-native ASCII diagrams in Markdown and rich chat presentation modes. The renderer keeps diagram semantics visible without requiring a browser, SVG canvas, or network request. This page reflects the current v0.1.38 behavior; it is documentation-only and does not require a version bump. +MendCode renders Mermaid fences as local, terminal-native ASCII diagrams in Markdown and rich chat presentation modes. The renderer keeps diagram semantics visible without requiring a browser, SVG canvas, or network request. This page reflects the current v0.1.39 behavior. ## Chat Canvas Behavior diff --git a/docs/releasing.md b/docs/releasing.md index 08ca9675..9e7ec18a 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -20,6 +20,23 @@ Windows users who cannot run PowerShell can use `src/mendcode/install.cmd`. It uses the Windows-provided `curl.exe` and `tar.exe` tools and installs the baseline x64 asset (or the ARM64 asset) without requiring a PowerShell command. +On Windows, the supported installer and in-app updater use native PowerShell; +they never invoke the WSL `bash.exe` launcher. This matters on machines where +WSL is installed incompletely or has no Linux distribution. To recover an old +installation whose updater still reports `WSL ... execvpe(/bin/bash)`, run the +native installer once from PowerShell: + +```powershell +$script = Join-Path $env:TEMP "mendcode-install.ps1" +Invoke-WebRequest "https://raw.githubusercontent.com/MendCode/MendCode/main/src/mendcode/install.ps1" -OutFile $script +powershell.exe -NoProfile -ExecutionPolicy Bypass -File $script -SkipSetup +``` + +The installer updates only `~\.mendcode\bin\mendcode.exe`; it does not delete +the MendCode/OpenCode data directory or existing sessions. Newer binaries +schedule the replacement after the running process exits, avoiding Windows +executable file locks. + The release must include these assets: ```text diff --git a/src/mendcode/install.ps1 b/src/mendcode/install.ps1 index 64224138..a34ef67b 100644 --- a/src/mendcode/install.ps1 +++ b/src/mendcode/install.ps1 @@ -2,7 +2,8 @@ param( [string]$Version = $env:VERSION, [switch]$NoModifyPath, [switch]$Setup, - [switch]$SkipSetup + [switch]$SkipSetup, + [int]$WaitForExitPid = 0 ) $ErrorActionPreference = "Stop" @@ -76,6 +77,52 @@ function Write-SetupCommand { Write-Host ('Setup later: $env:OPENCODE_ROUTE=''{"type":"setup"}''; & "' + $binary + '"') -ForegroundColor DarkGray } +function Wait-MendCodeProcessExit { + param([int]$ProcessId) + + if ($ProcessId -le 0) { return } + while (Get-Process -Id $ProcessId -ErrorAction SilentlyContinue) { + Start-Sleep -Milliseconds 250 + } +} + +function Start-DeferredUpdate { + if ($env:MENDCODE_UPDATE_WORKER -eq "1") { return $false } + if (-not $env:MENDCODE_UPDATE_PARENT_PID) { return $false } + + [int]$parentPid = 0 + if (-not [int]::TryParse($env:MENDCODE_UPDATE_PARENT_PID, [ref]$parentPid) -or $parentPid -le 0) { + throw "MendCode could not schedule the Windows updater: invalid parent process id." + } + + $shell = Get-Command powershell.exe -ErrorAction SilentlyContinue + if (-not $shell) { $shell = Get-Command pwsh.exe -ErrorAction SilentlyContinue } + if (-not $shell) { + throw "MendCode could not schedule the Windows updater because PowerShell is unavailable." + } + + $workerArguments = [System.Collections.Generic.List[string]]::new() + $workerArguments.Add("-NoProfile") + $workerArguments.Add("-NonInteractive") + $workerArguments.Add("-ExecutionPolicy") + $workerArguments.Add("Bypass") + $workerArguments.Add("-File") + $workerArguments.Add('"' + $PSCommandPath + '"') + if ($Version) { + $workerArguments.Add("-Version") + $workerArguments.Add($Version) + } + if ($NoModifyPath) { $workerArguments.Add("-NoModifyPath") } + $workerArguments.Add("-SkipSetup") + $workerArguments.Add("-WaitForExitPid") + $workerArguments.Add($parentPid.ToString()) + + $env:MENDCODE_UPDATE_WORKER = "1" + Start-Process -FilePath $shell.Source -ArgumentList $workerArguments -WindowStyle Hidden | Out-Null + Write-Host "Update scheduled; it will finish after MendCode closes." -ForegroundColor DarkGreen + return $true +} + function Maybe-LaunchSetup { if ($SetupMode -eq "skip") { Write-SetupCommand @@ -180,6 +227,9 @@ if (-not [System.Runtime.InteropServices.RuntimeInformation]::IsOSPlatform([Syst throw "install.ps1 is for Windows. On macOS or Linux, run: curl -fsSL https://raw.githubusercontent.com/MendCode/MendCode/main/src/mendcode/install | bash" } +if (Start-DeferredUpdate) { exit 0 } +Wait-MendCodeProcessExit -ProcessId $WaitForExitPid + New-Item -ItemType Directory -Force $InstallDir | Out-Null $target = Get-MendCodeTarget @@ -234,3 +284,7 @@ Write-Host " $(Join-Path $InstallDir "mendcode.exe") # run now in this termina Write-Host "" Write-Host "Open a new terminal to use: mendcode" Maybe-LaunchSetup + +if ($env:MENDCODE_UPDATE_SCRIPT_PATH -and (Test-Path $env:MENDCODE_UPDATE_SCRIPT_PATH)) { + Remove-Item -Force $env:MENDCODE_UPDATE_SCRIPT_PATH -ErrorAction SilentlyContinue +} diff --git a/src/mendcode/packages/extensions/zed/extension.toml b/src/mendcode/packages/extensions/zed/extension.toml index bfb92b53..31bbe998 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.38" +version = "0.1.39" 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.38/mendcode-darwin-arm64.zip" +archive = "https://github.com/MendCode/MendCode/releases/download/v0.1.39/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.38/mendcode-darwin-x64.zip" +archive = "https://github.com/MendCode/MendCode/releases/download/v0.1.39/mendcode-darwin-x64.zip" cmd = "./mendcode" args = ["acp"] [agent_servers.mendcode.targets.linux-aarch64] -archive = "https://github.com/MendCode/MendCode/releases/download/v0.1.38/mendcode-linux-arm64.tar.gz" +archive = "https://github.com/MendCode/MendCode/releases/download/v0.1.39/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.38/mendcode-linux-x64.tar.gz" +archive = "https://github.com/MendCode/MendCode/releases/download/v0.1.39/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.38/mendcode-windows-x64.zip" +archive = "https://github.com/MendCode/MendCode/releases/download/v0.1.39/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 a583cfca..c0acdbf4 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.38", + "version": "0.1.39", "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 d993fcfb..b69264ce 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.38" +const VERSION = "0.1.39" 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/dialog-command.tsx b/src/mendcode/packages/opencode/src/cli/cmd/tui/component/dialog-command.tsx index ee8574e9..be135267 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 @@ -71,10 +71,10 @@ function init() { }) const result = { - trigger(name: string) { + trigger(name: string, options?: { bypassEnabled?: boolean }) { for (const option of entries()) { if (option.value === name) { - if (!isEnabled(option)) return + if (!options?.bypassEnabled && !isEnabled(option)) return option.onSelect?.(dialog) return } diff --git a/src/mendcode/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx b/src/mendcode/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx index 42cf145b..3d8915f6 100644 --- a/src/mendcode/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/src/mendcode/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -32,7 +32,12 @@ import { useSDK, type SDKConnectionStatus } from "@tui/context/sdk" import { useRoute } from "@tui/context/route" import { useProject } from "@tui/context/project" import { useSync } from "@tui/context/sync" -import { sessionControlAllowsPrompt, useSessionControl } from "@tui/context/session-control" +import { + sessionCancelResultNeedsHardAbort, + sessionControlAllowsPrompt, + resolveSessionControlRouting, + useSessionControl, +} from "@tui/context/session-control" import { useEvent } from "@tui/context/event" import { editorSelectionKey, useEditorContext, type EditorSelection } from "@tui/context/editor" import { MessageID, PartID } from "@/session/schema" @@ -122,6 +127,7 @@ import { const NATIVE_COMPACTION_SLASHES = new Set(["compact", "summarize"]) const ACTIVE_LOOP_STATES = new Set(["active", "sleeping", "working", "needs_input", "blocked"]) +const SESSION_INTERRUPT_FALLBACK_TARGET = "__mendcode_session_interrupt__" const trace = Log.create({ service: "tui.prompt" }) export type LoopWorkflowInfo = { @@ -157,6 +163,7 @@ export type PromptProps = { historyScope?: PromptHistoryScope historyItems?: () => readonly PromptInfo[] workspaceID?: string + directory?: string permissionMode?: string permissionModeLabel?: string permissionPending?: number @@ -553,8 +560,16 @@ function endPendingPromptDelivery(request: PromptAsyncInput) { } function cancelPendingPromptDeliveryForInterrupt(sessionID: string, targetMessageID?: string) { - if (!targetMessageID) return - cancelPendingPromptDelivery(sessionID, targetMessageID) + if (targetMessageID) { + cancelPendingPromptDelivery(sessionID, targetMessageID) + return + } + // A stale session may not have a target message in the current sync + // window. Cancel every local delivery before the hard session abort so a + // reconnect cannot replay a prompt the user just stopped. + for (const delivery of pendingPromptDeliveriesForSession(sessionID)) { + if (delivery.request.messageID) cancelPendingPromptDelivery(sessionID, delivery.request.messageID) + } } export function latestPendingAssistantID( @@ -718,9 +733,12 @@ export function hasInterruptibleSessionTurn(input: { export function sessionInterruptConfirmationAction(input: { activeTargetMessageID?: string armedTargetMessageID?: string + hasActiveTurn?: boolean }): "ignore" | "arm" | "interrupt" { - if (!input.activeTargetMessageID) return "ignore" - return input.armedTargetMessageID === input.activeTargetMessageID ? "interrupt" : "arm" + const activeTargetMessageID = + input.activeTargetMessageID ?? (input.hasActiveTurn ? SESSION_INTERRUPT_FALLBACK_TARGET : undefined) + if (!activeTargetMessageID) return "ignore" + return input.armedTargetMessageID === activeTargetMessageID ? "interrupt" : "arm" } export function sessionInterruptHint(input: { enabled: boolean; working: boolean; armed: boolean }) { @@ -961,7 +979,9 @@ export function Prompt(props: PromptProps) { const [interruptRequested, setInterruptRequested] = createSignal(false) const [interruptTargetMessageID, setInterruptTargetMessageID] = createSignal() const updateInterruptRequested = (interrupted: boolean, targetMessageID?: string) => { - setInterruptTargetMessageID(interrupted ? (targetMessageID ?? interruptTargetMessageID()) : undefined) + setInterruptTargetMessageID( + interrupted ? (targetMessageID ?? interruptTargetMessageID() ?? SESSION_INTERRUPT_FALLBACK_TARGET) : undefined, + ) setInterruptRequested(interrupted) props.onInterruptChange?.(interrupted) } @@ -1084,6 +1104,10 @@ export function Prompt(props: PromptProps) { return latestAssistant } const hasActiveWorkingAssistant = createMemo(() => Boolean(findActiveWorkingAssistant())) + // A stale/incomplete assistant receipt can outlive the runner in an older + // session. It is still an interruptible turn for Esc, even though it must + // not keep the normal Generating indicator alive forever. + const hasOrphanedAssistant = createMemo(() => Boolean(findOrphanedAssistant())) const hasPendingPromptDelivery = createMemo(() => { pendingPromptDeliveryRevision() return Boolean(props.sessionID && pendingPromptDeliveryIsActive(props.sessionID)) @@ -1650,8 +1674,15 @@ export function Prompt(props: PromptProps) { } createEffect(() => { if (store.interrupt === 0) return - const activeTargetMessageID = workingStatusActive() ? activeTurnTargetMessageID() : undefined - if (activeTargetMessageID === interruptArmedTargetMessageID) return + const activeTargetMessageID = + workingStatusActive() || hasOrphanedAssistant() || hasPendingPromptDelivery() + ? activeTurnTargetMessageID() + : undefined + if ( + activeTargetMessageID === interruptArmedTargetMessageID || + (!activeTargetMessageID && interruptArmedTargetMessageID === SESSION_INTERRUPT_FALLBACK_TARGET) + ) + return resetInterruptConfirmation() }) const currentMessageHistoryPrompt = createMemo(() => @@ -1766,7 +1797,7 @@ export function Prompt(props: PromptProps) { enabled: () => shouldEnableSessionInterrupt({ statusType: status().type, - hasActiveWorkingAssistant: hasActiveWorkingAssistant(), + hasActiveWorkingAssistant: hasActiveWorkingAssistant() || hasOrphanedAssistant(), hasPendingPromptDelivery: Boolean(props.sessionID && pendingPromptDeliveryIsActive(props.sessionID)), autocompleteVisible: Boolean(autocomplete?.visible), compactionActive: compactionActive(), @@ -1781,7 +1812,7 @@ export function Prompt(props: PromptProps) { const hasActiveTurn = hasInterruptibleSessionTurn({ statusType: status().type, hasDraft: store.prompt.input.trim().length > 0 || store.prompt.parts.length > 0, - hasActiveWorkingAssistant: hasActiveWorkingAssistant(), + hasActiveWorkingAssistant: hasActiveWorkingAssistant() || hasOrphanedAssistant(), hasPendingPromptDelivery: Boolean(props.sessionID && pendingPromptDeliveryIsActive(props.sessionID)), compactionActive: compactionActive(), }) @@ -1805,6 +1836,7 @@ export function Prompt(props: PromptProps) { const action = sessionInterruptConfirmationAction({ activeTargetMessageID: targetMessageID, armedTargetMessageID: interruptArmedTargetMessageID, + hasActiveTurn, }) if (action === "ignore") return if (action === "interrupt") { @@ -1815,7 +1847,7 @@ export function Prompt(props: PromptProps) { return } - interruptArmedTargetMessageID = targetMessageID + interruptArmedTargetMessageID = targetMessageID ?? SESSION_INTERRUPT_FALLBACK_TARGET setStore("interrupt", 1) if (interruptResetTimer) clearTimeout(interruptResetTimer) interruptResetTimer = setTimeout(() => { @@ -2301,11 +2333,45 @@ export function Prompt(props: PromptProps) { } function abortSession(sessionID: string, targetMessageID?: string) { - if (!targetMessageID) return - sessionControl.request({ sessionID, targetMessageID }) + if (targetMessageID) + sessionControl.request({ + sessionID, + targetMessageID, + workspaceID: props.workspaceID, + directory: props.directory, + }) updateInterruptRequested(true, targetMessageID) + cancelPendingPromptDeliveryForInterrupt(sessionID, targetMessageID) if (interruptRequest) return - interruptRequest = withTimeout(sessionControl.drain(), 2000, "Session interrupt timed out") + const hardAbort = async () => { + const routing = resolveSessionControlRouting({ + sessionWorkspaceID: props.workspaceID, + sessionDirectory: props.directory, + currentWorkspaceID: project.workspace.current(), + currentDirectory: sdk.directory, + }) + await withTimeout( + sdk.client.session.abort( + { + sessionID, + ...routing, + }, + { throwOnError: true }, + ), + 2000, + "Session abort timed out", + ) + } + const cancel = targetMessageID + ? withTimeout(sessionControl.drain(), 2000, "Session interrupt timed out") + .catch(() => false) + .then(async (delivered) => { + const control = sessionControl.status(sessionID) + const result = control.state === "stop_confirmed" ? control.result : undefined + if (sessionCancelResultNeedsHardAbort({ delivered, result })) await hardAbort() + }) + : hardAbort() + interruptRequest = cancel .catch(() => undefined) .finally(() => { interruptRequest = undefined @@ -3703,7 +3769,11 @@ export function Prompt(props: PromptProps) { const interruptHint = createMemo(() => { return sessionInterruptHint({ enabled: workingIndicatorConfig().showInterruptHint, - working: workingStatusActive(), + // Legacy sessions can retain an unfinished assistant after the backend + // lost its busy status. Keep the two-Esc hint visible for that emergency + // stop path even though we deliberately suppress the normal Generating + // indicator for stale activity. + working: workingStatusActive() || hasOrphanedAssistant() || hasPendingPromptDelivery(), armed: store.interrupt > 0, }) }) @@ -3875,7 +3945,7 @@ export function Prompt(props: PromptProps) { zIndex={1000} overflow="visible" > - + normalizeSessionCancelOutbox(next)) } - function request(input: { sessionID: string; targetMessageID: string }) { - const workspace = project.workspace.current() - const directory = workspace === undefined ? sdk.directory : undefined + function request(input: { sessionID: string; targetMessageID: string; workspaceID?: string; directory?: string }) { + const routing = resolveSessionControlRouting({ + sessionWorkspaceID: input.workspaceID, + sessionDirectory: input.directory, + currentWorkspaceID: project.workspace.current(), + currentDirectory: sdk.directory, + }) + const workspace = routing.workspace + const directory = routing.directory const id = `${workspace ?? directory ?? ""}\u0000${input.sessionID}\u0000${input.targetMessageID}` const current = entries() - if ( - sessionCancelRequestIsDuplicate({ - entries: current, - confirmedTargetMessageID: confirmed()[input.sessionID]?.targetMessageID, - sessionID: input.sessionID, - targetMessageID: input.targetMessageID, - }) + if (confirmed()[input.sessionID]?.targetMessageID === input.targetMessageID) return + const sameTarget = current.find( + (entry) => entry.sessionID === input.sessionID && entry.targetMessageID === input.targetMessageID, ) - return + if (sameTarget?.id === id) return + // Replace a legacy entry for the same turn when its route was captured + // before the session owner was known. Otherwise the stale entry would + // win the dedupe check and Esc would keep hitting the wrong folder. + const withoutSameTarget = sameTarget ? current.filter((entry) => entry.id !== sameTarget.id) : current persist([ - ...current, + ...withoutSameTarget, { id, ...(directory === undefined ? {} : { directory }), 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 a982e686..3a7165b2 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 @@ -49,6 +49,7 @@ import type { Part, PermissionRequest, Provider, + Session as SessionInfo, Message, ToolPart, UserMessage, @@ -57,6 +58,7 @@ import type { } from "@mendcode/sdk/v2" import type { OpencodeClient } from "@mendcode/sdk/v2" import { useLocal } from "@tui/context/local" +import { resolveSessionControlRouting } from "@tui/context/session-control" import * as Log from "@mendcode/core/util/log" import { Locale } from "@/util/locale" import { tuiText } from "@tui/util/text" @@ -580,6 +582,22 @@ export function shouldHandleGlobalSessionInterrupt(input: { return input.eventName === "escape" && !input.defaultPrevented && input.activeTurn && !input.pendingInput } +/** + * Keep the emergency Esc path available when a legacy/shared backend lost its + * busy status event but still left an unfinished assistant receipt in sync. + */ +export function sessionHasUnfinishedAssistant( + messages: ReadonlyArray<{ + role: string + error?: unknown + finish?: string + time: { created?: number; completed?: number } + }>, +) { + const latestAssistant = messages.findLast((message) => message.role === "assistant") + return Boolean(latestAssistant && latestAssistant.time.completed === undefined && !latestAssistant.error) +} + const context = createContext<{ width: number sessionID: string @@ -749,10 +767,18 @@ export function Session() { const sdk = useSDK() const [now, setNow] = createSignal(Date.now()) const [sessionInterruptRequested, setSessionInterruptRequested] = createSignal(false) + // Keep the owner route available before the session is inserted into the + // sync cache. This closes the small window where Esc could otherwise fall + // back to the previously selected folder while a shared/legacy session is + // still loading. + const [routeSessionInfo, setRouteSessionInfo] = createSignal() createEffect( on( () => route.sessionID, - () => setSessionInterruptRequested(false), + () => { + setSessionInterruptRequested(false) + setRouteSessionInfo(undefined) + }, { defer: true }, ), ) @@ -761,6 +787,7 @@ export function Session() { }) const promptRef = usePromptRef() const session = createMemo(() => sync.session.get(route.sessionID)) + const sessionOwner = createMemo(() => session() ?? routeSessionInfo()) const [workflowTaskRuns, { refetch: refetchWorkflowTaskRuns }] = createResource( () => (session()?.parentID ? route.sessionID : undefined), async () => { @@ -861,6 +888,7 @@ export function Session() { const status = sync.data.session_status?.[route.sessionID]?.type return status === "busy" || status === "retry" }) + const hasUnfinishedAssistant = createMemo(() => sessionHasUnfinishedAssistant(activityMessages())) const activeTurnAssistantID = createMemo(() => { const unfinished = pending() if (unfinished) return unfinished @@ -1924,6 +1952,8 @@ export function Session() { return } + setRouteSessionInfo(result.data) + if (result.data.workspaceID !== previousWorkspace) { project.workspace.set(result.data.workspaceID) @@ -3584,14 +3614,17 @@ export function Session() { !shouldHandleGlobalSessionInterrupt({ eventName: evt.name, defaultPrevented: evt.defaultPrevented, - activeTurn: Boolean(pending() || sessionWorking() || hasLocalActiveTurn()), + activeTurn: Boolean(pending() || sessionWorking() || hasLocalActiveTurn() || hasUnfinishedAssistant()), pendingInput: disabled(), }) ) return evt.preventDefault() evt.stopPropagation() - command.trigger("session.interrupt") + // The prompt command normally requires prompt focus. The global Esc path + // is deliberately allowed to bypass that UI guard: a transcript, editor, + // widget, or stale legacy session must not make the emergency stop inert. + command.trigger("session.interrupt", { bypassEnabled: true }) }) function fillSessionPrompt(value: string) { @@ -3834,7 +3867,15 @@ export function Session() { }, onSelect: async (dialog) => { const status = sync.data.session_status?.[route.sessionID] - if (status?.type !== "idle") await sdk.client.session.abort({ sessionID: route.sessionID }).catch(() => {}) + if (status?.type !== "idle") { + const routing = resolveSessionControlRouting({ + sessionWorkspaceID: sessionOwner()?.workspaceID, + sessionDirectory: sessionOwner()?.directory, + currentWorkspaceID: project.workspace.current(), + currentDirectory: sdk.directory, + }) + await sdk.client.session.abort({ sessionID: route.sessionID, ...routing }).catch(() => {}) + } const revert = session()?.revert?.messageID const message = messages().findLast((x) => (!revert || x.id < revert) && x.role === "user") if (!message) return @@ -4785,9 +4826,7 @@ export function Session() { selected={ permissionSelection()?.requestID === request().id ? permissionSelection()?.option : undefined } - onSelectionChange={(option) => - setPermissionSelection({ requestID: request().id, option }) - } + onSelectionChange={(option) => setPermissionSelection({ requestID: request().id, option })} /> )} @@ -4850,7 +4889,8 @@ export function Session() { disabled={promptDisabled()} historyScope={`session:${route.sessionID}`} historyItems={sessionPromptHistory} - workspaceID={project.workspace.current()} + workspaceID={sessionOwner()?.workspaceID} + directory={sessionOwner()?.directory} sessionID={route.sessionID} permissionMode={permissionMode()} permissionModeLabel={permissionModeLabel()} diff --git a/src/mendcode/packages/opencode/src/effect/runner.ts b/src/mendcode/packages/opencode/src/effect/runner.ts index 0fa49a77..5d9bcde2 100644 --- a/src/mendcode/packages/opencode/src/effect/runner.ts +++ b/src/mendcode/packages/opencode/src/effect/runner.ts @@ -2,14 +2,12 @@ import { Cause, Deferred, Effect, Exit, Fiber, Latch, Schema, Scope, Synchronize import * as Log from "@mendcode/core/util/log" const trace = Log.create({ service: "runner" }) +const TARGET_CANCEL_FINALIZER_TIMEOUT = "500 millis" export interface Runner { readonly state: State readonly busy: boolean - readonly ensureRunning: ( - work: Effect.Effect, - options?: EnsureRunningOptions, - ) => Effect.Effect + readonly ensureRunning: (work: Effect.Effect, options?: EnsureRunningOptions) => Effect.Effect readonly startShell: (work: Effect.Effect, ready?: Latch.Latch) => Effect.Effect readonly interruptCurrent: (options?: NonNullable) => Effect.Effect readonly interruptQueued: (options?: NonNullable) => Effect.Effect @@ -98,10 +96,13 @@ export const make = ( const idleIfCurrent = () => SynchronizedRef.modify(ref, (st) => [st._tag === "Idle" ? idle : Effect.void, st] as const).pipe(Effect.flatten) - const finishRun = (id: number, done: Deferred.Deferred, exit: Exit.Exit): Effect.Effect => - SynchronizedRef.modifyEffect( - ref, - (st) => Effect.gen(function* () { + const finishRun = ( + id: number, + done: Deferred.Deferred, + exit: Exit.Exit, + ): Effect.Effect => + SynchronizedRef.modifyEffect(ref, (st) => + Effect.gen(function* () { trace.trace("finish", { id, state: st._tag, success: Exit.isSuccess(exit) }) if (st._tag === "Running" && st.run.id === id) { return [ @@ -140,7 +141,7 @@ export const make = ( ): Effect.Effect> => Effect.gen(function* () { const id = next() - const fiber = yield* (Effect.yieldNow.pipe(Effect.andThen(work))).pipe( + const fiber = yield* Effect.yieldNow.pipe(Effect.andThen(work)).pipe( Effect.onExit((exit) => finishRun(id, done, exit)), Effect.forkIn(scope), ) @@ -148,9 +149,8 @@ export const make = ( }) as Effect.Effect> const finishShell = (id: number): Effect.Effect => - SynchronizedRef.modifyEffect( - ref, - (st) => Effect.gen(function* () { + SynchronizedRef.modifyEffect(ref, (st) => + Effect.gen(function* () { if (st._tag === "Shell" && st.shell.id === id) { return [idle, { _tag: "Idle" }] as const } @@ -158,7 +158,10 @@ export const make = ( const [next, ...remaining] = st.run if (!next) return [idle, { _tag: "Idle" }] as const const run = yield* startRun(next.work, next.done, next.key) - return [Effect.void, remaining.length ? { _tag: "RunningThenRun", run, next: remaining } : { _tag: "Running", run }] as const + return [ + Effect.void, + remaining.length ? { _tag: "RunningThenRun", run, next: remaining } : { _tag: "Running", run }, + ] as const } return [Effect.void, st] as const }), @@ -199,10 +202,13 @@ export const make = ( }) const ensureRunning = (work: Effect.Effect, options?: EnsureRunningOptions): Effect.Effect => - SynchronizedRef.modifyEffect( - ref, - (st) => Effect.gen(function* () { - trace.trace("ensure", { state: st._tag, queue: options?.queue ?? false, interrupt: Boolean(options?.interrupt) }) + SynchronizedRef.modifyEffect(ref, (st) => + Effect.gen(function* () { + trace.trace("ensure", { + state: st._tag, + queue: options?.queue ?? false, + interrupt: Boolean(options?.interrupt), + }) switch (st._tag) { case "Running": if (options?.queue) { @@ -293,91 +299,100 @@ export const make = ( }), ).pipe(Effect.flatten) as Effect.Effect - const cancelCurrent = (options?: { before?: Effect.Effect; cancelPending?: boolean }) => SynchronizedRef.modify(ref, (st) => { - switch (st._tag) { - case "Idle": - return [Effect.void, st] as const - case "Running": - return [ - Effect.gen(function* () { - yield* options?.before ?? Effect.void - yield* Fiber.interrupt(st.run.fiber) - // The interrupted work normally completes this in finishRun. Close - // it here as well so callers cannot hang if cancellation wins the - // brief race before the forked work begins its on-exit path. - yield* Deferred.fail(st.run.done, new Cancelled()).pipe(Effect.asVoid) - yield* idleIfCurrent() - }), - { _tag: "Idle" } as const, - ] as const - case "RunningThenRun": - return [ - Effect.gen(function* () { - if (options?.cancelPending) yield* cancelPendingHandles(st.next) - yield* options?.before ?? Effect.void - yield* Fiber.interrupt(st.run.fiber) - yield* Deferred.fail(st.run.done, new Cancelled()).pipe(Effect.asVoid) - }), - options?.cancelPending ? { _tag: "Running", run: st.run } as const : st, - ] as const - case "Shell": - return [ - Effect.gen(function* () { - yield* options?.before ?? Effect.void - yield* stopShell(st.shell) - yield* idleIfCurrent() - }), - { _tag: "Idle" } as const, - ] as const - case "ShellThenRun": - return [ - Effect.gen(function* () { - yield* options?.before ?? Effect.void - yield* stopShell(st.shell) - yield* cancelPendingHandles(st.run) - yield* idleIfCurrent() - }), - { _tag: "Idle" } as const, - ] as const - } - }).pipe(Effect.flatten) - - const cancelCurrentIf = ( - key: string, - options?: { before?: Effect.Effect; cancelPending?: boolean; ignoreInterruptible?: boolean }, - ): Effect.Effect => - SynchronizedRef.modify( - ref, - (st): readonly [Effect.Effect, State] => { - if (st._tag !== "Running" && st._tag !== "RunningThenRun") { - return [Effect.succeed("not_running" as const), st] as const - } - if (st.run.key !== key) return [Effect.succeed("target_mismatch" as const), st] as const - if (!st.run.interruptible && options?.ignoreInterruptible !== true) - return [Effect.succeed("not_interruptible" as const), st] as const - if (st._tag === "Running") { + const cancelCurrent = (options?: { before?: Effect.Effect; cancelPending?: boolean }) => + SynchronizedRef.modify(ref, (st) => { + switch (st._tag) { + case "Idle": + return [Effect.void, st] as const + case "Running": return [ Effect.gen(function* () { yield* options?.before ?? Effect.void yield* Fiber.interrupt(st.run.fiber) - yield* Deferred.await(st.run.done).pipe(Effect.exit, Effect.asVoid) - return "cancelled" as const + // The interrupted work normally completes this in finishRun. Close + // it here as well so callers cannot hang if cancellation wins the + // brief race before the forked work begins its on-exit path. + yield* Deferred.fail(st.run.done, new Cancelled()).pipe(Effect.asVoid) + yield* idleIfCurrent() }), - st, + { _tag: "Idle" } as const, ] as const - } + case "RunningThenRun": + return [ + Effect.gen(function* () { + if (options?.cancelPending) yield* cancelPendingHandles(st.next) + yield* options?.before ?? Effect.void + yield* Fiber.interrupt(st.run.fiber) + yield* Deferred.fail(st.run.done, new Cancelled()).pipe(Effect.asVoid) + }), + options?.cancelPending ? ({ _tag: "Running", run: st.run } as const) : st, + ] as const + case "Shell": + return [ + Effect.gen(function* () { + yield* options?.before ?? Effect.void + yield* stopShell(st.shell) + yield* idleIfCurrent() + }), + { _tag: "Idle" } as const, + ] as const + case "ShellThenRun": + return [ + Effect.gen(function* () { + yield* options?.before ?? Effect.void + yield* stopShell(st.shell) + yield* cancelPendingHandles(st.run) + yield* idleIfCurrent() + }), + { _tag: "Idle" } as const, + ] as const + } + }).pipe(Effect.flatten) + + const cancelCurrentIf = ( + key: string, + options?: { before?: Effect.Effect; cancelPending?: boolean; ignoreInterruptible?: boolean }, + ): Effect.Effect => + SynchronizedRef.modify(ref, (st): readonly [Effect.Effect, State] => { + if (st._tag !== "Running" && st._tag !== "RunningThenRun") { + return [Effect.succeed("not_running" as const), st] as const + } + if (st.run.key !== key) return [Effect.succeed("target_mismatch" as const), st] as const + if (!st.run.interruptible && options?.ignoreInterruptible !== true) + return [Effect.succeed("not_interruptible" as const), st] as const + if (st._tag === "Running") { return [ Effect.gen(function* () { - if (options?.cancelPending) yield* cancelPendingHandles(st.next) yield* options?.before ?? Effect.void - yield* Fiber.interrupt(st.run.fiber) - yield* Deferred.await(st.run.done).pipe(Effect.exit, Effect.asVoid) + // Targeted cancellation is an HTTP control-plane request. Bound + // finalizer waiting so a stale or huge session cannot make Esc + // appear dead, while still giving ordinary tool cleanup time to + // persist the aborted assistant receipt. + yield* Fiber.interrupt(st.run.fiber).pipe(Effect.timeout(TARGET_CANCEL_FINALIZER_TIMEOUT), Effect.ignore) + // Cancellation is a control-plane operation. Do not wait for a + // provider/tool finalizer to resolve the run receipt: an old or + // huge session must be stoppable even when that cleanup is slow. + yield* Deferred.fail(st.run.done, new Cancelled()).pipe(Effect.asVoid) + yield* idleIfCurrent() return "cancelled" as const }), - options?.cancelPending ? { _tag: "Running", run: st.run } as const : st, + { _tag: "Idle" } as const, ] as const - }, - ).pipe(Effect.flatten) + } + return [ + Effect.gen(function* () { + if (options?.cancelPending) yield* cancelPendingHandles(st.next) + yield* options?.before ?? Effect.void + yield* Fiber.interrupt(st.run.fiber).pipe(Effect.timeout(TARGET_CANCEL_FINALIZER_TIMEOUT), Effect.ignore) + // Resolve the caller immediately; the interrupted fiber's exit + // hook may otherwise keep the HTTP cancel request hanging. + yield* Deferred.fail(st.run.done, new Cancelled()).pipe(Effect.asVoid) + yield* idleIfCurrent() + return "cancelled" as const + }), + options?.cancelPending ? ({ _tag: "Running", run: st.run } as const) : st, + ] as const + }).pipe(Effect.flatten) const cancel = cancelCurrent() @@ -391,7 +406,9 @@ export const make = ( yield* cancelPendingHandles(removed) return [ true, - remaining.length > 0 ? { _tag: "RunningThenRun", run: st.run, next: remaining } : { _tag: "Running", run: st.run }, + remaining.length > 0 + ? { _tag: "RunningThenRun", run: st.run, next: remaining } + : { _tag: "Running", run: st.run }, ] as const } if (st._tag === "ShellThenRun") { @@ -401,7 +418,9 @@ export const make = ( yield* cancelPendingHandles(removed) return [ true, - remaining.length > 0 ? { _tag: "ShellThenRun", shell: st.shell, run: remaining } : { _tag: "Shell", shell: st.shell }, + remaining.length > 0 + ? { _tag: "ShellThenRun", shell: st.shell, run: remaining } + : { _tag: "Shell", shell: st.shell }, ] as const } return [false, st] as const @@ -416,31 +435,32 @@ export const make = ( }) const interruptRun = (run: RunHandle, options?: NonNullable) => - Effect.gen(function* () { - yield* options?.before ?? Effect.void - yield* Fiber.interrupt(run.fiber).pipe(Effect.ensuring(options?.after ?? Effect.void)) - yield* Deferred.await(run.done).pipe(Effect.exit, Effect.asVoid) - }) + Effect.gen(function* () { + yield* options?.before ?? Effect.void + yield* Fiber.interrupt(run.fiber).pipe(Effect.ensuring(options?.after ?? Effect.void)) + yield* Deferred.await(run.done).pipe(Effect.exit, Effect.asVoid) + }) const interruptShell = (shell: ShellHandle, options?: NonNullable) => - Effect.gen(function* () { - yield* options?.before ?? Effect.void - yield* stopShell(shell).pipe(Effect.ensuring(options?.after ?? Effect.void)) - }) + Effect.gen(function* () { + yield* options?.before ?? Effect.void + yield* stopShell(shell).pipe(Effect.ensuring(options?.after ?? Effect.void)) + }) - const interruptCurrent = (options?: NonNullable) => SynchronizedRef.modify(ref, (st) => { - switch (st._tag) { - case "Idle": - return [Effect.void, st] as const - case "Running": - return [interruptRun(st.run, options), st] as const - case "RunningThenRun": - return [interruptRun(st.run, options), st] as const - case "Shell": - return [interruptShell(st.shell, options), st] as const - case "ShellThenRun": - return [interruptShell(st.shell, options), st] as const - } - }).pipe(Effect.flatten) + const interruptCurrent = (options?: NonNullable) => + SynchronizedRef.modify(ref, (st) => { + switch (st._tag) { + case "Idle": + return [Effect.void, st] as const + case "Running": + return [interruptRun(st.run, options), st] as const + case "RunningThenRun": + return [interruptRun(st.run, options), st] as const + case "Shell": + return [interruptShell(st.shell, options), st] as const + case "ShellThenRun": + return [interruptShell(st.shell, options), st] as const + } + }).pipe(Effect.flatten) const interruptQueued = (options?: NonNullable) => SynchronizedRef.modify(ref, (st) => { diff --git a/src/mendcode/packages/opencode/src/installation/index.ts b/src/mendcode/packages/opencode/src/installation/index.ts index 6f0e23a9..fecc67f1 100644 --- a/src/mendcode/packages/opencode/src/installation/index.ts +++ b/src/mendcode/packages/opencode/src/installation/index.ts @@ -4,8 +4,9 @@ import { CrossSpawnSpawner } from "@mendcode/core/cross-spawn-spawner" import { withTransientReadRetry } from "@/util/effect-http-client" import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process" import path from "path" -import { existsSync, readFileSync } from "fs" +import { existsSync, readFileSync, unlinkSync, writeFileSync } from "fs" import { fileURLToPath } from "url" +import { tmpdir } from "os" import z from "zod" import { BusEvent } from "@/bus/bus-event" import { Flag } from "@mendcode/core/flag/flag" @@ -19,6 +20,9 @@ const log = Log.create({ service: "installation" }) const GITHUB_REPO = process.env.MENDCODE_GITHUB_REPO ?? "MendCode/MendCode" const GITHUB_RAW_INSTALL_URL = process.env.MENDCODE_INSTALL_URL ?? `https://raw.githubusercontent.com/${GITHUB_REPO}/main/src/mendcode/install` +const GITHUB_RAW_INSTALL_PS1_URL = + process.env.MENDCODE_INSTALL_PS1_URL ?? + `https://raw.githubusercontent.com/${GITHUB_REPO}/main/src/mendcode/install.ps1` const GITHUB_LATEST_RELEASE_URL = `https://api.github.com/repos/${GITHUB_REPO}/releases/latest` const PackageVersion = (() => { try { @@ -114,7 +118,7 @@ function installerShell() { const configured = process.env.MENDCODE_BASH_PATH?.trim() if (configured) return configured - const candidates = process.platform === "win32" ? ["bash.exe", "bash"] : ["/bin/bash", "/usr/bin/bash", "bash"] + const candidates = ["/bin/bash", "/usr/bin/bash", "bash"] for (const candidate of candidates) { if (candidate.startsWith("/") && existsSync(candidate)) return candidate const resolved = which(candidate) @@ -123,6 +127,36 @@ function installerShell() { return undefined } +function installerPowerShell() { + const configured = process.env.MENDCODE_POWERSHELL_PATH?.trim() + if (configured) return configured + + for (const candidate of ["pwsh.exe", "powershell.exe", "pwsh", "powershell"]) { + const resolved = which(candidate) + if (resolved) return resolved + } + return undefined +} + +export function usesNativeWindowsUpdater(platform: NodeJS.Platform = process.platform) { + return platform === "win32" +} + +export function windowsUpdaterArgs(scriptPath: string, target: string) { + return [ + "-NoProfile", + "-NonInteractive", + "-ExecutionPolicy", + "Bypass", + "-File", + scriptPath, + "-Version", + target, + "-NoModifyPath", + "-SkipSetup", + ] +} + // Response schemas for external version APIs const GitHubRelease = Schema.Struct({ tag_name: Schema.String }) @@ -180,6 +214,38 @@ export const layer: Layer.Layer Effect.fail(new UpgradeFailedError({ stderr: describeUpgradeFailure(defect) }))), - Effect.mapError( - (error) => - error instanceof UpgradeFailedError - ? error - : new UpgradeFailedError({ stderr: describeUpgradeFailure(error) }), + Effect.mapError((error) => + error instanceof UpgradeFailedError + ? error + : new UpgradeFailedError({ stderr: describeUpgradeFailure(error) }), ), ) @@ -263,7 +324,9 @@ export const layer: Layer.Layer { count: entries.length, mode: typeof OPENCODE_MIGRATIONS !== "undefined" ? "bundled" : "dev", }) - if (Flag.OPENCODE_SKIP_MIGRATIONS) { + if (!Flag.OPENCODE_SKIP_MIGRATIONS) { + const reconciled = reconcileLegacyMigrationJournal(db, entries) + if (reconciled) { + log.info("reconciled legacy sqlite migration journal", { + count: entries.length, + }) + } + } else { for (const item of entries) { item.sql = "select 1;" } diff --git a/src/mendcode/packages/opencode/src/storage/legacy-migration.ts b/src/mendcode/packages/opencode/src/storage/legacy-migration.ts new file mode 100644 index 00000000..4663d478 --- /dev/null +++ b/src/mendcode/packages/opencode/src/storage/legacy-migration.ts @@ -0,0 +1,254 @@ +import type { SQLiteBunDatabase } from "drizzle-orm/bun-sqlite" +import { sql } from "drizzle-orm" + +export type LegacyMigrationEntry = { + sql: string + timestamp: number + name: string +} + +const JOURNAL_TABLE = "__drizzle_migrations" +const SAVEPOINT = "mendcode_legacy_migration_statement" +const BREAKPOINT = "--> statement-breakpoint" + +type Database = Pick +type TableInfoRow = { name: string } +type JournalRow = { name: string | null } + +function quoteIdentifier(value: string) { + return `"${value.replaceAll('"', '""')}"` +} + +/** + * Drizzle executes migration chunks separated by its breakpoint marker. A few + * older bundled migrations predate that marker and contain several statements, + * so split those chunks without cutting quoted semicolons. + */ +function splitStatements(sql: string) { + const statements: string[] = [] + let start = 0 + let quote: "'" | '"' | "`" | undefined + let depth = 0 + + for (let index = 0; index < sql.length; index += 1) { + const character = sql[index] + const next = sql[index + 1] + + if (quote) { + if (character === quote) { + if (next === quote && quote !== "`") { + index += 1 + continue + } + quote = undefined + } + continue + } + + if (character === "'" || character === '"' || character === "`") { + quote = character + continue + } + if (character === "(") { + depth += 1 + continue + } + if (character === ")") { + depth = Math.max(0, depth - 1) + continue + } + if (character === ";" && depth === 0) { + const statement = sql.slice(start, index).trim() + if (statement) statements.push(statement) + start = index + 1 + } + } + + const remainder = sql.slice(start).trim() + if (remainder) statements.push(remainder) + return statements +} + +function migrationStatements(sql: string) { + return sql + .split(BREAKPOINT) + .flatMap((chunk) => splitStatements(chunk)) + .map((statement) => statement.trim()) + .filter(Boolean) +} + +function tableInfo(db: Database, table: string) { + return db.all(`PRAGMA table_info(${quoteIdentifier(table)})`) +} + +function hasTable(db: Database, table: string) { + const rows = db.all<{ name: string }>( + `SELECT name FROM sqlite_master WHERE type = 'table' AND name = ${JSON.stringify(table)} LIMIT 1`, + ) + return rows.length > 0 +} + +function declaredTable(statement: string) { + const match = /^CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?[`"]?([A-Za-z_][A-Za-z0-9_]*)[`"]?\s*\((.*)\)$/is.exec( + statement, + ) + if (!match) return + + const columns = [ + ...match[2].matchAll( + /(?:^|,)\s*(?!(?:PRIMARY|UNIQUE|CONSTRAINT|FOREIGN|CHECK)\b)[`"]?([A-Za-z_][A-Za-z0-9_]*)[`"]?\s+/gi, + ), + ].map((entry) => entry[1]) + return { table: match[1], columns } +} + +function declaredAddedColumn(statement: string) { + const match = + /^ALTER\s+TABLE\s+[`"]?([A-Za-z_][A-Za-z0-9_]*)[`"]?\s+ADD\s+(?:COLUMN\s+)?[`"]?([A-Za-z_][A-Za-z0-9_]*)[`"]?/i.exec( + statement, + ) + return match ? { table: match[1], column: match[2] } : undefined +} + +function declaredDroppedColumn(statement: string) { + const match = + /^ALTER\s+TABLE\s+[`"]?([A-Za-z_][A-Za-z0-9_]*)[`"]?\s+DROP\s+COLUMN\s+[`"]?([A-Za-z_][A-Za-z0-9_]*)[`"]?/i.exec( + statement, + ) + return match ? { table: match[1], column: match[2] } : undefined +} + +function declaredUpdatedTable(statement: string) { + const match = /^UPDATE\s+[`"]?([A-Za-z_][A-Za-z0-9_]*)[`"]?\s+SET\s+[`"]?([A-Za-z_][A-Za-z0-9_]*)[`"]?/i.exec( + statement, + ) + return match ? { table: match[1], column: match[2] } : undefined +} + +function errorText(error: unknown) { + const messages: string[] = [] + const seen = new Set() + let current: unknown = error + while (current && !seen.has(current)) { + seen.add(current) + if (current instanceof Error) messages.push(current.message) + else if (typeof current === "object" && current !== null && "message" in current) { + const message = (current as { message?: unknown }).message + if (typeof message === "string") messages.push(message) + } + current = + typeof current === "object" && current !== null && "cause" in current + ? (current as { cause?: unknown }).cause + : undefined + } + return messages.join("\n") || String(error) +} + +function isAlreadyApplied(db: Database, statement: string, error: unknown) { + const message = errorText(error).toLowerCase() + const normalized = statement.trim() + + if (/^create\s+table\b/i.test(normalized) && message.includes("already exists")) { + const declaration = declaredTable(normalized) + if (!declaration || !hasTable(db, declaration.table)) return false + const actual = new Set(tableInfo(db, declaration.table).map((row) => row.name)) + return declaration.columns.every((column) => actual.has(column)) + } + + if (/^create\s+(?:unique\s+)?index\b/i.test(normalized) && message.includes("already exists")) { + return true + } + + if (/^alter\s+table\b/i.test(normalized) && message.includes("duplicate column name")) { + const declaration = declaredAddedColumn(normalized) + return declaration ? tableInfo(db, declaration.table).some((row) => row.name === declaration.column) : false + } + + if (/^alter\s+table\b/i.test(normalized) && message.includes("no such column")) { + const declaration = declaredDroppedColumn(normalized) + return declaration ? !tableInfo(db, declaration.table).some((row) => row.name === declaration.column) : false + } + + if (/^update\s+/i.test(normalized) && message.includes("no such column")) { + // A legacy database may already have completed a data-move migration and + // dropped the source column. Only skip when the destination column exists. + const declaration = declaredUpdatedTable(normalized) + return declaration ? tableInfo(db, declaration.table).some((row) => row.name === declaration.column) : false + } + + if (/^drop\s+table\b/i.test(normalized) && message.includes("no such table")) return true + if (/^drop\s+index\b/i.test(normalized) && message.includes("no such index")) return true + return false +} + +function runStatement(db: Database, statement: string) { + db.run(`SAVEPOINT ${SAVEPOINT}`) + try { + db.run(statement) + db.run(`RELEASE SAVEPOINT ${SAVEPOINT}`) + } catch (error) { + try { + db.run(`ROLLBACK TO SAVEPOINT ${SAVEPOINT}`) + } finally { + db.run(`RELEASE SAVEPOINT ${SAVEPOINT}`) + } + if (!isAlreadyApplied(db, statement, error)) throw error + } +} + +function createJournal(db: Database) { + db.run( + `CREATE TABLE IF NOT EXISTS ${quoteIdentifier(JOURNAL_TABLE)} (\n` + + ` id INTEGER PRIMARY KEY AUTOINCREMENT,\n` + + ` hash text NOT NULL,\n` + + ` created_at numeric,\n` + + ` name text NOT NULL,\n` + + ` applied_at text\n` + + `)`, + ) +} + +/** + * Repairs databases created before MendCode's Drizzle journal was persisted. + * It is deliberately opt-in: a database must already contain `project`, and + * unknown journal rows abort reconciliation rather than hiding schema drift. + */ +export function reconcileLegacyMigrationJournal(db: Database, entries: LegacyMigrationEntry[]) { + if (!hasTable(db, "project")) return false + + const journalExists = hasTable(db, JOURNAL_TABLE) + const journalRows = journalExists ? db.all(`SELECT name FROM ${quoteIdentifier(JOURNAL_TABLE)}`) : [] + if (journalRows.some((row) => !row.name)) return false + + const knownNames = new Set(entries.map((entry) => entry.name)) + if (journalRows.some((row) => row.name && !knownNames.has(row.name))) return false + + const applied = new Set(journalRows.flatMap((row) => (row.name ? [row.name] : []))) + const missing = entries.filter((entry) => !applied.has(entry.name)) + if (missing.length === 0) return false + + db.run("BEGIN") + try { + createJournal(db) + for (const entry of missing) { + for (const statement of migrationStatements(entry.sql)) runStatement(db, statement) + db.run( + sql`INSERT INTO ${sql.identifier(JOURNAL_TABLE)} (hash, created_at, name, applied_at) VALUES (${""}, ${entry.timestamp}, ${entry.name}, ${new Date().toISOString()})`, + ) + } + db.run("COMMIT") + return true + } catch (error) { + try { + db.run("ROLLBACK") + } catch { + // Preserve the original migration error. + } + throw error + } +} + +export const _test = { + migrationStatements, + splitStatements, +} diff --git a/src/mendcode/packages/opencode/test/cli/tui/prompt-working-start.test.ts b/src/mendcode/packages/opencode/test/cli/tui/prompt-working-start.test.ts index b0ca7e2b..bd42e5a4 100644 --- a/src/mendcode/packages/opencode/test/cli/tui/prompt-working-start.test.ts +++ b/src/mendcode/packages/opencode/test/cli/tui/prompt-working-start.test.ts @@ -73,6 +73,7 @@ import { SESSION_BOTTOM_FOLLOW_REFLOW_DELAYS_MS, shouldKeepSessionBottomFollow, shouldHandleGlobalSessionInterrupt, + sessionHasUnfinishedAssistant, shouldReleaseSessionPagingBoundarySuppression, shouldRestoreSessionScrollAnchor, shouldUseSimpleSessionHistory, @@ -997,6 +998,22 @@ describe("resolveWorkingStartedAt", () => { expect(sessionInterruptHint({ enabled: true, working: true, armed: true })).toBe("[esc again to interrupt]") expect(sessionInterruptHint({ enabled: false, working: true, armed: true })).toBeUndefined() expect(sessionInterruptHint({ enabled: true, working: false, armed: true })).toBeUndefined() + expect(sessionInterruptConfirmationAction({ hasActiveTurn: true })).toBe("arm") + expect( + sessionInterruptConfirmationAction({ + hasActiveTurn: true, + armedTargetMessageID: "__mendcode_session_interrupt__", + }), + ).toBe("interrupt") + }) + + test("keeps the emergency Esc path for an unfinished legacy assistant receipt", () => { + expect(sessionHasUnfinishedAssistant([])).toBe(false) + expect(sessionHasUnfinishedAssistant([{ role: "assistant", time: {} }])).toBe(true) + expect( + sessionHasUnfinishedAssistant([{ role: "assistant", error: { name: "MessageAbortedError" }, time: {} }]), + ).toBe(false) + expect(sessionHasUnfinishedAssistant([{ role: "assistant", time: { completed: 1 } }])).toBe(false) }) test("accepts prompt interrupt only when the prompt textarea owns focus", () => { @@ -1487,9 +1504,7 @@ describe("resolveWorkingStartedAt", () => { const partsByMessage = { user_1: [{ type: "text", text: "inspect this workspace" }] } expect(sessionUserPromptForPermissionRequest({ messages, partsByMessage })).toBeUndefined() - expect( - sessionUserPromptForPermissionRequest({ messages, partsByMessage, messageID: "missing" }), - ).toBeUndefined() + expect(sessionUserPromptForPermissionRequest({ messages, partsByMessage, messageID: "missing" })).toBeUndefined() }) test("keys global loop cache entries by the requested page contract", () => { diff --git a/src/mendcode/packages/opencode/test/cli/tui/session-control.test.ts b/src/mendcode/packages/opencode/test/cli/tui/session-control.test.ts index 1b171d21..c2f9edfd 100644 --- a/src/mendcode/packages/opencode/test/cli/tui/session-control.test.ts +++ b/src/mendcode/packages/opencode/test/cli/tui/session-control.test.ts @@ -5,6 +5,8 @@ import { sessionCancelRetryDelay, sessionControlAllowsPrompt, sessionCancelRequestIsDuplicate, + sessionCancelResultNeedsHardAbort, + resolveSessionControlRouting, SESSION_CANCEL_AUTO_RETRY_MAX_ATTEMPTS, SESSION_CANCEL_AUTO_RETRY_WINDOW_MS, type SessionCancelOutboxEntry, @@ -155,4 +157,36 @@ describe("session control outbox", () => { }), ).toBe(false) }) + + test("falls back to a session abort when the targeted stop cannot prove the active turn", () => { + expect(sessionCancelResultNeedsHardAbort({ delivered: false })).toBe(true) + expect(sessionCancelResultNeedsHardAbort({ delivered: true, result: "target_mismatch" })).toBe(true) + expect(sessionCancelResultNeedsHardAbort({ delivered: true, result: "not_running" })).toBe(true) + expect(sessionCancelResultNeedsHardAbort({ delivered: true, result: "cancelled" })).toBe(false) + expect(sessionCancelResultNeedsHardAbort({ delivered: true, result: "already_terminal" })).toBe(false) + }) + + test("routes cancellation to the session owner instead of the currently selected folder", () => { + expect( + resolveSessionControlRouting({ + sessionDirectory: "/work/legacy-session", + currentWorkspaceID: "workspace-selected-now", + currentDirectory: "/work/current-folder", + }), + ).toEqual({ directory: "/work/legacy-session" }) + expect( + resolveSessionControlRouting({ + sessionWorkspaceID: "workspace-session-owner", + sessionDirectory: "/work/ignored-when-warped", + currentWorkspaceID: "workspace-selected-now", + currentDirectory: "/work/current-folder", + }), + ).toEqual({ workspace: "workspace-session-owner" }) + expect( + resolveSessionControlRouting({ + currentWorkspaceID: "workspace-selected-now", + currentDirectory: "/work/current-folder", + }), + ).toEqual({ workspace: "workspace-selected-now" }) + }) }) diff --git a/src/mendcode/packages/opencode/test/effect/runner.test.ts b/src/mendcode/packages/opencode/test/effect/runner.test.ts index d0a3ec7e..db10221c 100644 --- a/src/mendcode/packages/opencode/test/effect/runner.test.ts +++ b/src/mendcode/packages/opencode/test/effect/runner.test.ts @@ -125,10 +125,9 @@ describe("Runner", () => { return "second-result" }) - const [a, b] = yield* Effect.all( - [runner.ensureRunning(first), runner.ensureRunning(second, { queue: true })], - { concurrency: "unbounded" }, - ) + const [a, b] = yield* Effect.all([runner.ensureRunning(first), runner.ensureRunning(second, { queue: true })], { + concurrency: "unbounded", + }) expect(a).toBe("first-result") expect(b).toBe("second-result") @@ -166,10 +165,7 @@ describe("Runner", () => { ) .pipe(Effect.forkChild) const duplicateQueued = yield* runner - .ensureRunning( - Effect.fail("duplicate should not run"), - { queue: true, queueKey: "message" }, - ) + .ensureRunning(Effect.fail("duplicate should not run"), { queue: true, queueKey: "message" }) .pipe(Effect.forkChild) yield* Deferred.succeed(activeGate, undefined) @@ -640,6 +636,36 @@ describe("Runner", () => { }), ) + it.live( + "cancelCurrentIf returns while a run finalizer is still unwinding", + Effect.gen(function* () { + const s = yield* Scope.Scope + const started = yield* Deferred.make() + const release = yield* Deferred.make() + const runner = Runner.make(s) + const active = yield* runner + .ensureRunning( + Effect.gen(function* () { + yield* Deferred.succeed(started, undefined) + return yield* Effect.never.pipe(Effect.ensuring(Deferred.await(release)), Effect.as("active")) + }), + { queueKey: "turn-finalizer" }, + ) + .pipe(Effect.forkChild) + + try { + yield* Deferred.await(started) + expect(yield* runner.cancelCurrentIf("turn-finalizer").pipe(Effect.timeout("250 millis"))).toBe("cancelled") + expect(runner.busy).toBe(false) + + yield* Deferred.succeed(release, undefined) + expect(Exit.isFailure(yield* Fiber.await(active).pipe(Effect.timeout("250 millis")))).toBe(true) + } finally { + yield* Deferred.succeed(release, undefined).pipe(Effect.ignore) + } + }), + ) + it.live( "work can be started after cancel", Effect.gen(function* () { diff --git a/src/mendcode/packages/opencode/test/installation/installation.test.ts b/src/mendcode/packages/opencode/test/installation/installation.test.ts index d21ed721..b849d33a 100644 --- a/src/mendcode/packages/opencode/test/installation/installation.test.ts +++ b/src/mendcode/packages/opencode/test/installation/installation.test.ts @@ -11,10 +11,7 @@ function mockHttpClient(handler: (request: HttpClientRequest.HttpClientRequest) return Layer.succeed(HttpClient.HttpClient, client) } -function mockSpawner( - handler: (cmd: string, args: readonly string[]) => string = () => "", - spawnError?: Error, -) { +function mockSpawner(handler: (cmd: string, args: readonly string[]) => string = () => "", spawnError?: Error) { const spawner = ChildProcessSpawner.make((command) => { if (spawnError) { return Effect.fail( @@ -63,6 +60,23 @@ function testLayer( } describe("installation", () => { + test("selects the native updater on Windows instead of the WSL bash launcher", () => { + expect(Installation.usesNativeWindowsUpdater("win32")).toBe(true) + expect(Installation.usesNativeWindowsUpdater("darwin")).toBe(false) + expect(Installation.windowsUpdaterArgs("C:\\Temp\\mendcode-upgrade.ps1", "0.1.39")).toEqual([ + "-NoProfile", + "-NonInteractive", + "-ExecutionPolicy", + "Bypass", + "-File", + "C:\\Temp\\mendcode-upgrade.ps1", + "-Version", + "0.1.39", + "-NoModifyPath", + "-SkipSetup", + ]) + }) + test("uses package version for local display labels", () => { expect(Installation.displayVersion()).toMatch(/^\d+\.\d+\.\d+$/) expect(Installation.labelVersion()).toMatch(/^v\d+\.\d+\.\d+$/) diff --git a/src/mendcode/packages/opencode/test/storage/legacy-migration.test.ts b/src/mendcode/packages/opencode/test/storage/legacy-migration.test.ts new file mode 100644 index 00000000..71746be2 --- /dev/null +++ b/src/mendcode/packages/opencode/test/storage/legacy-migration.test.ts @@ -0,0 +1,82 @@ +import { Database } from "bun:sqlite" +import { describe, expect, test } from "bun:test" +import { drizzle } from "drizzle-orm/bun-sqlite" +import { reconcileLegacyMigrationJournal } from "../../src/storage/legacy-migration" + +const projectTable = ` + CREATE TABLE "project" ( + "id" text PRIMARY KEY, + "worktree" text NOT NULL, + "vcs" text, + "name" text, + "icon_url" text, + "icon_color" text, + "time_created" integer NOT NULL, + "time_updated" integer NOT NULL, + "time_initialized" integer, + "sandboxes" text NOT NULL + ) +` + +const initial = { + name: "20260127222353_familiar_lady_ursula", + timestamp: 1769552633000, + sql: `${projectTable}; + --> statement-breakpoint + CREATE TABLE "message" ("id" text PRIMARY KEY NOT NULL, "session_id" text NOT NULL); + --> statement-breakpoint + CREATE INDEX "message_session_idx" ON "message" ("session_id"); + `, +} + +describe("legacy sqlite migration reconciliation", () => { + test("rebuilds the journal and preserves already-created tables", () => { + const sqlite = new Database(":memory:") + const db = drizzle({ client: sqlite }) + db.run(projectTable) + + const changed = { + name: "20260128000000_add_workspace", + timestamp: 1769558400000, + sql: `ALTER TABLE "project" ADD COLUMN "workspace" text;`, + } + + expect(reconcileLegacyMigrationJournal(db, [initial, changed])).toBe(true) + expect( + sqlite + .query(`SELECT name FROM "__drizzle_migrations" ORDER BY id`) + .all() + .map((row) => (row as { name: string }).name), + ).toEqual([initial.name, changed.name]) + expect(sqlite.query(`SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'message'`).all()).toHaveLength( + 1, + ) + expect( + sqlite + .query(`PRAGMA table_info("project")`) + .all() + .some((row) => (row as { name: string }).name === "workspace"), + ).toBe(true) + + sqlite.close() + }) + + test("does not mask an incompatible existing table", () => { + const sqlite = new Database(":memory:") + const db = drizzle({ client: sqlite }) + db.run(`CREATE TABLE "project" ("id" text PRIMARY KEY, "worktree" text NOT NULL)`) + + expect(() => reconcileLegacyMigrationJournal(db, [initial])).toThrow(/CREATE TABLE/) + expect(sqlite.query(`SELECT name FROM sqlite_master WHERE name = '__drizzle_migrations'`).all()).toHaveLength(0) + + sqlite.close() + }) + + test("leaves a fresh database on the normal Drizzle migration path", () => { + const sqlite = new Database(":memory:") + const db = drizzle({ client: sqlite }) + expect(reconcileLegacyMigrationJournal(db, [initial])).toBe(false) + expect(sqlite.query(`SELECT name FROM sqlite_master WHERE name = '__drizzle_migrations'`).all()).toHaveLength(0) + sqlite.close() + }) +}) From 19cb48d366bd8a953a27f04dfb8876cf9cffb47d Mon Sep 17 00:00:00 2001 From: Obed0101 Date: Thu, 3 Sep 2026 00:56:33 -0500 Subject: [PATCH 2/4] fix: harden session delivery and runtime reliability --- src/mendcode/infra/app.ts | 4 + src/mendcode/packages/core/src/filesystem.ts | 27 +- .../core/test/filesystem/filesystem.test.ts | 26 ++ src/mendcode/packages/function/src/api.ts | 67 +++- .../packages/function/src/feishu-security.ts | 37 ++ src/mendcode/packages/function/sst-env.d.ts | 10 +- .../function/test/feishu-security.test.ts | 43 +++ src/mendcode/packages/opencode/bin/opencode | 12 +- .../packages/opencode/script/postinstall.mjs | 6 +- .../opencode/script/queue-compaction-smoke.ts | 4 +- .../packages/opencode/src/agent/agent.ts | 23 +- .../packages/opencode/src/auth/index.ts | 53 ++- .../packages/opencode/src/cli/cmd/db.ts | 15 +- .../cli/cmd/tui/component/prompt/index.tsx | 149 ++++++-- .../src/cli/cmd/tui/routes/session/index.tsx | 52 ++- .../opencode/src/cli/cmd/tui/thread.ts | 1 + .../src/cli/cmd/tui/util/agent-view.ts | 12 +- .../packages/opencode/src/config/config.ts | 4 +- .../opencode/src/control-plane/workspace.ts | 33 +- .../packages/opencode/src/effect/runner.ts | 62 ++-- src/mendcode/packages/opencode/src/index.ts | 13 +- .../opencode/src/mend/memory/store.ts | 5 +- .../opencode/src/mend/runtime/loop-service.ts | 31 +- .../opencode/src/provider/provider.ts | 11 +- .../routes/instance/httpapi/groups/control.ts | 2 + .../routes/instance/httpapi/groups/global.ts | 2 + .../instance/httpapi/handlers/session.ts | 9 +- .../server/routes/instance/httpapi/server.ts | 5 +- .../src/server/routes/instance/session.ts | 10 +- .../src/session/agent-command-policy.ts | 11 +- .../opencode/src/session/agent-command.ts | 23 +- .../packages/opencode/src/session/loop.ts | 2 +- .../opencode/src/session/message-v2.ts | 34 +- .../opencode/src/session/processor.ts | 5 +- .../packages/opencode/src/session/prompt.ts | 333 +++++++++++++++--- .../packages/opencode/src/session/retry.ts | 7 +- .../opencode/src/session/run-state.ts | 6 +- .../packages/opencode/src/session/session.ts | 23 +- .../opencode/src/session/workflow-runner.ts | 40 ++- .../src/session/workflow-scheduler.ts | 8 + .../packages/opencode/src/share/share-next.ts | 44 ++- .../packages/opencode/src/skill/discovery.ts | 26 +- .../opencode/src/storage/json-migration.ts | 4 + .../packages/opencode/src/tool/peers.ts | 44 +-- .../packages/opencode/src/tool/peers.txt | 2 +- .../packages/opencode/src/tool/registry.ts | 8 +- .../packages/opencode/src/tool/tell.ts | 8 +- .../packages/opencode/src/tool/tell.txt | 2 +- .../packages/opencode/src/util/rpc.ts | 74 +++- .../packages/opencode/src/v2/session.ts | 113 +++++- .../opencode/test/agent/agent.test.ts | 22 ++ .../packages/opencode/test/auth/auth.test.ts | 23 ++ .../opencode/test/cli/tui/agent-view.test.ts | 11 +- .../test/cli/tui/prompt-working-start.test.ts | 76 +++- .../cli/tui/session-initialization.test.ts | 8 +- .../opencode/test/config/config.test.ts | 18 + .../test/control-plane/workspace.test.ts | 66 ++++ .../opencode/test/effect/runner.test.ts | 129 +++++++ .../installation/launcher-package.test.ts | 33 ++ .../opencode/test/mend/loop-service.test.ts | 16 + .../opencode/test/mend/memory.test.ts | 10 + .../opencode/test/sdk/sse-fetch.test.ts | 23 ++ .../test/server/httpapi-session.test.ts | 2 +- .../test/server/session-actions.test.ts | 2 +- .../test/session/agent-command.test.ts | 14 +- .../session/loop-workflow-adapter.test.ts | 2 +- .../opencode/test/session/loop.test.ts | 7 +- .../opencode/test/session/prompt.test.ts | 205 ++++++++++- .../opencode/test/session/retry.test.ts | 62 ++-- .../opencode/test/session/session.test.ts | 57 +++ .../test/session/workflow-scheduler.test.ts | 21 ++ .../session/workflow-task-executor.test.ts | 2 +- .../opencode/test/share/share-next.test.ts | 55 ++- .../opencode/test/skill/discovery.test.ts | 16 + .../test/storage/json-migration.test.ts | 5 + .../packages/opencode/test/tool/peers.test.ts | 8 +- .../opencode/test/tool/registry.test.ts | 3 +- .../packages/opencode/test/util/rpc.test.ts | 59 ++++ .../js/src/gen/core/serverSentEvents.gen.ts | 5 +- src/mendcode/sdks/vscode/images/icon.png | Bin 84 -> 330 bytes src/mendcode/sst-env.d.ts | 10 +- 81 files changed, 2133 insertions(+), 382 deletions(-) create mode 100644 src/mendcode/packages/function/src/feishu-security.ts create mode 100644 src/mendcode/packages/function/test/feishu-security.test.ts create mode 100644 src/mendcode/packages/opencode/test/installation/launcher-package.test.ts create mode 100644 src/mendcode/packages/opencode/test/sdk/sse-fetch.test.ts create mode 100644 src/mendcode/packages/opencode/test/util/rpc.test.ts mode change 120000 => 100644 src/mendcode/sdks/vscode/images/icon.png diff --git a/src/mendcode/infra/app.ts b/src/mendcode/infra/app.ts index bb627f51..fabc0b61 100644 --- a/src/mendcode/infra/app.ts +++ b/src/mendcode/infra/app.ts @@ -8,6 +8,8 @@ const DISCORD_SUPPORT_BOT_TOKEN = new sst.Secret("DISCORD_SUPPORT_BOT_TOKEN") const DISCORD_SUPPORT_CHANNEL_ID = new sst.Secret("DISCORD_SUPPORT_CHANNEL_ID") const FEISHU_APP_ID = new sst.Secret("FEISHU_APP_ID") const FEISHU_APP_SECRET = new sst.Secret("FEISHU_APP_SECRET") +const FEISHU_VERIFICATION_TOKEN = new sst.Secret("FEISHU_VERIFICATION_TOKEN") +const FEISHU_ENCRYPT_KEY = new sst.Secret("FEISHU_ENCRYPT_KEY") const bucket = new sst.cloudflare.Bucket("Bucket") export const api = new sst.cloudflare.Worker("Api", { @@ -26,6 +28,8 @@ export const api = new sst.cloudflare.Worker("Api", { DISCORD_SUPPORT_CHANNEL_ID, FEISHU_APP_ID, FEISHU_APP_SECRET, + FEISHU_VERIFICATION_TOKEN, + FEISHU_ENCRYPT_KEY, ], transform: { worker: (args) => { diff --git a/src/mendcode/packages/core/src/filesystem.ts b/src/mendcode/packages/core/src/filesystem.ts index 8a1cc3a0..552cad52 100644 --- a/src/mendcode/packages/core/src/filesystem.ts +++ b/src/mendcode/packages/core/src/filesystem.ts @@ -1,5 +1,5 @@ import { NodeFileSystem } from "@effect/platform-node" -import { dirname, join, relative, resolve as pathResolve } from "path" +import { basename, dirname, isAbsolute, join, relative, resolve as pathResolve, sep } from "path" import { realpathSync } from "fs" import * as NFS from "fs/promises" import { lookup } from "mime-types" @@ -215,11 +215,18 @@ export namespace AppFileSystem { export function resolve(p: string): string { const resolved = pathResolve(windowsPath(p)) - try { - return normalizePath(realpathSync(resolved)) - } catch (e: any) { - if (e?.code === "ENOENT") return normalizePath(resolved) - throw e + const suffix: string[] = [] + let current = resolved + while (true) { + try { + return normalizePath(join(realpathSync.native(current), ...suffix.toReversed())) + } catch (e: any) { + if (e?.code !== "ENOENT") throw e + const parent = dirname(current) + if (parent === current) return normalizePath(resolved) + suffix.push(basename(current)) + current = parent + } } } @@ -235,10 +242,14 @@ export namespace AppFileSystem { export function overlaps(a: string, b: string) { const relA = relative(a, b) const relB = relative(b, a) - return !relA || !relA.startsWith("..") || !relB || !relB.startsWith("..") + return internalRelative(relA) || internalRelative(relB) } export function contains(parent: string, child: string) { - return !relative(parent, child).startsWith("..") + return internalRelative(relative(parent, child)) + } + + function internalRelative(value: string) { + return value === "" || (value !== ".." && !value.startsWith(`..${sep}`) && !isAbsolute(value)) } } diff --git a/src/mendcode/packages/core/test/filesystem/filesystem.test.ts b/src/mendcode/packages/core/test/filesystem/filesystem.test.ts index 340ffc0e..648c4f22 100644 --- a/src/mendcode/packages/core/test/filesystem/filesystem.test.ts +++ b/src/mendcode/packages/core/test/filesystem/filesystem.test.ts @@ -4,6 +4,7 @@ import { NodeFileSystem } from "@effect/platform-node" import { AppFileSystem } from "@mendcode/core/filesystem" import { testEffect } from "../lib/effect" import path from "path" +import { symlink } from "node:fs/promises" const live = AppFileSystem.layer.pipe(Layer.provideMerge(NodeFileSystem.layer)) const { effect: it } = testEffect(live) @@ -357,6 +358,31 @@ describe("AppFileSystem", () => { expect(AppFileSystem.contains("/a/b", "/a/c")).toBe(false) }) + it( + "resolves missing paths through the nearest existing symlink parent", + Effect.gen(function* () { + const filesys = yield* FileSystem.FileSystem + const tmp = yield* filesys.makeTempDirectoryScoped() + const project = path.join(tmp, "project") + const outside = path.join(tmp, "outside") + const link = path.join(project, "linked") + yield* filesys.makeDirectory(project) + yield* filesys.makeDirectory(outside) + yield* Effect.promise(() => symlink(outside, link, "dir")) + + expect(AppFileSystem.resolve(path.join(link, "missing.txt"))).toBe( + path.join(AppFileSystem.resolve(outside), "missing.txt"), + ) + expect(AppFileSystem.contains(AppFileSystem.resolve(project), AppFileSystem.resolve(path.join(link, "missing.txt")))).toBe(false) + }), + ) + + if (process.platform === "win32") { + test("contains rejects a child on another drive", () => { + expect(AppFileSystem.contains("C:\\project", "D:\\project\\file.txt")).toBe(false) + }) + } + test("overlaps detects overlapping paths", () => { expect(AppFileSystem.overlaps("/a/b", "/a/b/c")).toBe(true) expect(AppFileSystem.overlaps("/a/b/c", "/a/b")).toBe(true) diff --git a/src/mendcode/packages/function/src/api.ts b/src/mendcode/packages/function/src/api.ts index 58c74fe3..898f6871 100644 --- a/src/mendcode/packages/function/src/api.ts +++ b/src/mendcode/packages/function/src/api.ts @@ -5,6 +5,7 @@ import { jwtVerify, createRemoteJWKSet } from "jose" import { createAppAuth } from "@octokit/auth-app" import { Octokit } from "@octokit/rest" import { Resource } from "sst" +import { verifyFeishuRequest, verifyFeishuToken, FEISHU_REQUEST_MAX_AGE_SECONDS } from "./feishu-security" type Env = { SYNC_SERVER: DurableObjectNamespace @@ -67,7 +68,10 @@ export class SyncServer extends DurableObject { public async share(sessionID: string) { let secret = await this.getSecret() - if (secret) return secret + if (secret) { + if ((await this.getSessionID()) !== sessionID) throw new Error("Share identifier collision") + return secret + } secret = randomUUID() await this.ctx.storage.put("secret", secret) @@ -87,6 +91,27 @@ export class SyncServer extends DurableObject { if (secret !== (await this.getSecret())) throw new Error("Invalid secret") } + public async assertAccess(sessionID: string, secret: string) { + if (sessionID !== (await this.getSessionID())) throw new Error("Invalid session") + await this.assertSecret(secret) + } + + public async claimReplay(key: string, now: number, expiresAt: number) { + const storageKey = `feishu/replay/${key}` + const accepted = await this.ctx.storage.transaction(async (txn) => { + const existing = await txn.get(storageKey) + if (existing && existing >= now) return false + await txn.put(storageKey, expiresAt) + return true + }) + if (!accepted) return false + + const entries = await this.ctx.storage.list({ prefix: "feishu/replay/", limit: 128 }) + const expired = Array.from(entries).flatMap(([entry, expiry]) => (expiry < now ? [entry] : [])) + if (expired.length) await this.ctx.storage.delete(expired) + return true + } + private async getSecret() { return this.ctx.storage.get("secret") } @@ -133,7 +158,7 @@ export default new Hono<{ Bindings: Env }>() const secret = body.secret const id = c.env.SYNC_SERVER.idFromName(SyncServer.shortName(sessionID)) const stub = c.env.SYNC_SERVER.get(id) - await stub.assertSecret(secret) + await stub.assertAccess(sessionID, secret) await stub.clear() return c.json({}) }) @@ -157,7 +182,7 @@ export default new Hono<{ Bindings: Env }>() const name = SyncServer.shortName(body.sessionID) const id = c.env.SYNC_SERVER.idFromName(name) const stub = c.env.SYNC_SERVER.get(id) - await stub.assertSecret(body.secret) + await stub.assertAccess(body.sessionID, body.secret) await stub.publish(body.key, body.content) return c.json({}) }) @@ -202,7 +227,10 @@ export default new Hono<{ Bindings: Env }>() return c.json({ info, messages }) }) .post("/feishu", async (c) => { - const body = (await c.req.json()) as { + const rawBody = await c.req.text() + let body: { + token?: string + header?: { token?: string } challenge?: string event?: { message?: { @@ -214,7 +242,34 @@ export default new Hono<{ Bindings: Env }>() } } } - console.log(JSON.stringify(body, null, 2)) + try { + body = JSON.parse(rawBody) + } catch { + return c.json({ error: "Invalid JSON" }, { status: 400 }) + } + + const timestamp = c.req.header("x-lark-request-timestamp") + const nonce = c.req.header("x-lark-request-nonce") + const signature = c.req.header("x-lark-signature") + if ( + !verifyFeishuRequest({ + body: rawBody, + timestamp, + nonce, + signature, + encryptKey: Resource.FEISHU_ENCRYPT_KEY.value, + }) || + !verifyFeishuToken(body, Resource.FEISHU_VERIFICATION_TOKEN.value) + ) { + return c.json({ error: "Invalid Feishu signature" }, { status: 401 }) + } + + const replay = c.env.SYNC_SERVER.get(c.env.SYNC_SERVER.idFromName("feishu-webhook-replay-v1")) + const now = Date.now() + if (!(await replay.claimReplay(signature!, now, now + FEISHU_REQUEST_MAX_AGE_SECONDS * 1_000))) { + return c.json({ error: "Replayed Feishu request" }, { status: 409 }) + } + const challenge = body.challenge if (challenge) return c.json({ challenge }) @@ -302,6 +357,7 @@ export default new Hono<{ Bindings: Env }>() const installationAuth = await auth({ type: "installation", installationId: installation.id, + repositoryNames: [repo], }) return c.json({ token: installationAuth.token }) @@ -344,6 +400,7 @@ export default new Hono<{ Bindings: Env }>() const installationAuth = await auth({ type: "installation", installationId: installation.id, + repositoryIds: [repoData.id], }) return c.json({ token: installationAuth.token }) diff --git a/src/mendcode/packages/function/src/feishu-security.ts b/src/mendcode/packages/function/src/feishu-security.ts new file mode 100644 index 00000000..9ebd8ef3 --- /dev/null +++ b/src/mendcode/packages/function/src/feishu-security.ts @@ -0,0 +1,37 @@ +import { createHash, timingSafeEqual } from "node:crypto" + +export const FEISHU_REQUEST_MAX_AGE_SECONDS = 300 + +function constantTimeEqual(left: string, right: string) { + const a = Buffer.from(left) + const b = Buffer.from(right) + return a.length === b.length && timingSafeEqual(a, b) +} + +export function verifyFeishuRequest(input: { + body: string + timestamp?: string + nonce?: string + signature?: string + encryptKey: string + now?: number +}) { + if (!input.timestamp || !input.nonce || !input.signature || !input.encryptKey) return false + if (!/^\d+$/.test(input.timestamp) || !/^[a-f0-9]{64}$/i.test(input.signature)) return false + + const now = Math.floor((input.now ?? Date.now()) / 1_000) + const timestamp = Number(input.timestamp) + if (!Number.isSafeInteger(timestamp) || Math.abs(now - timestamp) > FEISHU_REQUEST_MAX_AGE_SECONDS) return false + + const expected = createHash("sha256") + .update(input.timestamp + input.nonce + input.encryptKey + input.body) + .digest("hex") + return constantTimeEqual(expected, input.signature.toLowerCase()) +} + +export function verifyFeishuToken(body: unknown, verificationToken: string) { + if (!verificationToken || !body || typeof body !== "object") return false + const record = body as { token?: unknown; header?: { token?: unknown } } + const token = typeof record.header?.token === "string" ? record.header.token : record.token + return typeof token === "string" && constantTimeEqual(token, verificationToken) +} diff --git a/src/mendcode/packages/function/sst-env.d.ts b/src/mendcode/packages/function/sst-env.d.ts index bc56bd78..6e5f08f8 100644 --- a/src/mendcode/packages/function/sst-env.d.ts +++ b/src/mendcode/packages/function/sst-env.d.ts @@ -67,6 +67,14 @@ declare module "sst" { "type": "sst.sst.Secret" "value": string } + "FEISHU_ENCRYPT_KEY": { + "type": "sst.sst.Secret" + "value": string + } + "FEISHU_VERIFICATION_TOKEN": { + "type": "sst.sst.Secret" + "value": string + } "GITHUB_APP_ID": { "type": "sst.sst.Secret" "value": string @@ -304,4 +312,4 @@ declare module "sst" { } import "sst" -export {} \ No newline at end of file +export {} diff --git a/src/mendcode/packages/function/test/feishu-security.test.ts b/src/mendcode/packages/function/test/feishu-security.test.ts new file mode 100644 index 00000000..b667239a --- /dev/null +++ b/src/mendcode/packages/function/test/feishu-security.test.ts @@ -0,0 +1,43 @@ +import { createHash } from "node:crypto" +import { describe, expect, test } from "bun:test" +import { FEISHU_REQUEST_MAX_AGE_SECONDS, verifyFeishuRequest, verifyFeishuToken } from "../src/feishu-security" + +describe("Feishu webhook security", () => { + test("accepts a fresh request with the official SHA-256 signature shape", () => { + const now = 1_800_000_000_000 + const timestamp = String(now / 1_000) + const nonce = "nonce" + const encryptKey = "encrypt-key" + const body = JSON.stringify({ schema: "2.0", header: { token: "verify" } }) + const signature = createHash("sha256").update(timestamp + nonce + encryptKey + body).digest("hex") + + expect(verifyFeishuRequest({ body, timestamp, nonce, signature, encryptKey, now })).toBe(true) + expect(verifyFeishuToken(JSON.parse(body), "verify")).toBe(true) + }) + + test("rejects stale, malformed, and tampered requests", () => { + const now = 1_800_000_000_000 + const timestamp = String(now / 1_000 - FEISHU_REQUEST_MAX_AGE_SECONDS - 1) + expect( + verifyFeishuRequest({ + body: "{}", + timestamp, + nonce: "nonce", + signature: "0".repeat(64), + encryptKey: "encrypt-key", + now, + }), + ).toBe(false) + expect( + verifyFeishuRequest({ + body: "{}", + timestamp: String(now / 1_000), + nonce: "nonce", + signature: "not-hex", + encryptKey: "encrypt-key", + now, + }), + ).toBe(false) + expect(verifyFeishuToken({ header: { token: "wrong" } }, "verify")).toBe(false) + }) +}) diff --git a/src/mendcode/packages/opencode/bin/opencode b/src/mendcode/packages/opencode/bin/opencode index e688e189..0b07630c 100755 --- a/src/mendcode/packages/opencode/bin/opencode +++ b/src/mendcode/packages/opencode/bin/opencode @@ -17,7 +17,7 @@ function run(target) { process.exit(code) } -const envPath = process.env.OPENCODE_BIN_PATH +const envPath = process.env.MENDCODE_BIN_PATH || process.env.OPENCODE_BIN_PATH if (envPath) { run(envPath) } @@ -26,9 +26,9 @@ const scriptPath = fs.realpathSync(__filename) const scriptDir = path.dirname(scriptPath) // -const cached = path.join(scriptDir, ".opencode") -if (fs.existsSync(cached)) { - run(cached) +for (const name of [".mendcode", ".opencode"]) { + const cached = path.join(scriptDir, name) + if (fs.existsSync(cached)) run(cached) } const platformMap = { @@ -50,8 +50,8 @@ let arch = archMap[os.arch()] if (!arch) { arch = os.arch() } -const base = "opencode-" + platform + "-" + arch -const binary = platform === "windows" ? "opencode.exe" : "opencode" +const base = "mendcode-" + platform + "-" + arch +const binary = platform === "windows" ? "mendcode.exe" : "mendcode" function supportsAvx2() { if (arch !== "x64") return false diff --git a/src/mendcode/packages/opencode/script/postinstall.mjs b/src/mendcode/packages/opencode/script/postinstall.mjs index e1789aa0..0067805b 100644 --- a/src/mendcode/packages/opencode/script/postinstall.mjs +++ b/src/mendcode/packages/opencode/script/postinstall.mjs @@ -49,8 +49,8 @@ function detectPlatformAndArch() { function findBinary() { const { platform, arch } = detectPlatformAndArch() - const packageName = `opencode-${platform}-${arch}` - const binaryName = platform === "windows" ? "opencode.exe" : "opencode" + const packageName = `mendcode-${platform}-${arch}` + const binaryName = platform === "windows" ? "mendcode.exe" : "mendcode" try { // Use require.resolve to find the package @@ -89,7 +89,7 @@ async function main() { } fs.chmodSync(target, 0o755) } catch (error) { - console.error("Failed to setup opencode binary:", error.message) + console.error("Failed to setup MendCode binary:", error.message) process.exit(1) } } diff --git a/src/mendcode/packages/opencode/script/queue-compaction-smoke.ts b/src/mendcode/packages/opencode/script/queue-compaction-smoke.ts index e989fdbc..3a810147 100644 --- a/src/mendcode/packages/opencode/script/queue-compaction-smoke.ts +++ b/src/mendcode/packages/opencode/script/queue-compaction-smoke.ts @@ -350,10 +350,10 @@ async function main() { "MendCode queue/compaction smoke is ready (the provider is local and never calls an API).", `Model context limit: ${CONTEXT_LIMIT.toLocaleString()} tokens; auto-compaction: ${COMPACTION_LIMIT.toLocaleString()} tokens.`, "The TUI opens with a synthetic trigger prompt already loaded.", - "Press Enter to submit it, press Esc once while compaction is active to cancel it, then submit two short prompts.", + "Press Enter to submit it, press Esc once to arm interruption, then press Esc again within five seconds to cancel it.", `The local model holds that request for ${Math.round(HOLD_MS / 1_000)} seconds so the queued state is visible.`, "Only one smoke can run at a time. Exit with /exit or Ctrl+C; child processes and the lock are cleaned automatically.", - "Expected result: one Esc issues one cancellation; the compaction summary terminates once, Snake disappears, the panel collapses, and queued messages remain paired with their queued/send state.", + "Expected result: the second Esc issues one cancellation; the compaction summary terminates once, Snake disappears, the panel collapses, and queued messages remain paired with their queued/send state.", "", ].join("\n"), ) diff --git a/src/mendcode/packages/opencode/src/agent/agent.ts b/src/mendcode/packages/opencode/src/agent/agent.ts index d3bbbaeb..59b3ea0f 100644 --- a/src/mendcode/packages/opencode/src/agent/agent.ts +++ b/src/mendcode/packages/opencode/src/agent/agent.ts @@ -13,7 +13,7 @@ import PROMPT_EXPLORE from "./prompt/explore.txt" import PROMPT_SUMMARY from "./prompt/summary.txt" import PROMPT_TITLE from "./prompt/title.txt" import { Permission } from "@/permission" -import { mergeDeep, pipe, sortBy, values } from "remeda" +import { mergeDeep } from "remeda" import { Global } from "@mendcode/core/global" import { AppFileSystem } from "@mendcode/core/filesystem" import path from "path" @@ -290,14 +290,13 @@ export const layer = Layer.effect( const list = Effect.fnUntraced(function* () { const cfg = yield* config.get() - return pipe( - agents, - values(), - sortBy( - [(x) => (cfg.default_agent ? x.name === cfg.default_agent : x.name === "build"), "desc"], - [(x) => x.name, "asc"], - ), - ) + const defaultKey = cfg.default_agent ?? "build" + return Object.entries(agents) + .toSorted( + ([leftKey, left], [rightKey, right]) => + Number(rightKey === defaultKey) - Number(leftKey === defaultKey) || left.name.localeCompare(right.name), + ) + .map(([, agent]) => agent) }) const defaultAgent = Effect.fnUntraced(function* () { @@ -307,14 +306,14 @@ export const layer = Layer.effect( if (!agent) throw new Error(`default agent "${c.default_agent}" not found`) if (agent.mode === "subagent") throw new Error(`default agent "${c.default_agent}" is a subagent`) if (agent.hidden === true) throw new Error(`default agent "${c.default_agent}" is hidden`) - return agent.name + return c.default_agent } - const visible = Object.values(agents).find((a) => a.mode !== "subagent" && a.hidden !== true) + const visible = Object.entries(agents).find(([, agent]) => agent.mode !== "subagent" && agent.hidden !== true) if (!visible) throw new Error( 'no primary visible agent found; configure a primary custom agent with "default_agent" or re-enable agent.build/agent.plan', ) - return visible.name + return visible[0] }) return { diff --git a/src/mendcode/packages/opencode/src/auth/index.ts b/src/mendcode/packages/opencode/src/auth/index.ts index 4248b94e..f2af6099 100644 --- a/src/mendcode/packages/opencode/src/auth/index.ts +++ b/src/mendcode/packages/opencode/src/auth/index.ts @@ -4,6 +4,7 @@ import { zod } from "@/util/effect-zod" import { NonNegativeInt } from "@/util/schema" import { Global } from "@mendcode/core/global" import { AppFileSystem } from "@mendcode/core/filesystem" +import { EffectFlock } from "@mendcode/core/util/effect-flock" export const OAUTH_DUMMY_KEY = "opencode-oauth-dummy-key" @@ -54,7 +55,12 @@ export const layer = Layer.effect( Service, Effect.gen(function* () { const fsys = yield* AppFileSystem.Service + const flock = yield* EffectFlock.Service const decode = Schema.decodeUnknownOption(Info) + const withMutationLock = (body: Effect.Effect) => + flock.withLock(body, `auth:${file}`).pipe( + Effect.mapError((error) => (error._tag === "AuthError" ? error : fail("Failed to lock auth data")(error))), + ) const all = Effect.fn("Auth.all")(function* () { if (process.env.OPENCODE_AUTH_CONTENT) { @@ -71,28 +77,39 @@ export const layer = Layer.effect( return (yield* all())[providerID] }) - const set = Effect.fn("Auth.set")(function* (key: string, info: Info) { - const norm = key.replace(/\/+$/, "") - const data = yield* all() - if (norm !== key) delete data[key] - delete data[norm + "/"] - yield* fsys - .writeJson(file, { ...data, [norm]: info }, 0o600) - .pipe(Effect.mapError(fail("Failed to write auth data"))) - }) - - const remove = Effect.fn("Auth.remove")(function* (key: string) { - const norm = key.replace(/\/+$/, "") - const data = yield* all() - delete data[key] - delete data[norm] - yield* fsys.writeJson(file, data, 0o600).pipe(Effect.mapError(fail("Failed to write auth data"))) - }) + const set = Effect.fn("Auth.set")((key: string, info: Info) => + withMutationLock( + Effect.gen(function* () { + const norm = key.replace(/\/+$/, "") + const data = yield* all() + if (norm !== key) delete data[key] + delete data[norm + "/"] + yield* fsys + .writeJson(file, { ...data, [norm]: info }, 0o600) + .pipe(Effect.mapError(fail("Failed to write auth data"))) + }), + ), + ) + + const remove = Effect.fn("Auth.remove")((key: string) => + withMutationLock( + Effect.gen(function* () { + const norm = key.replace(/\/+$/, "") + const data = yield* all() + delete data[key] + delete data[norm] + yield* fsys.writeJson(file, data, 0o600).pipe(Effect.mapError(fail("Failed to write auth data"))) + }), + ), + ) return Service.of({ get, all, set, remove }) }), ) -export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer)) +export const defaultLayer = layer.pipe( + Layer.provide(AppFileSystem.defaultLayer), + Layer.provide(EffectFlock.defaultLayer), +) export * as Auth from "." diff --git a/src/mendcode/packages/opencode/src/cli/cmd/db.ts b/src/mendcode/packages/opencode/src/cli/cmd/db.ts index 23bbeb7e..d0ccf187 100644 --- a/src/mendcode/packages/opencode/src/cli/cmd/db.ts +++ b/src/mendcode/packages/opencode/src/cli/cmd/db.ts @@ -94,12 +94,15 @@ const MigrateCommand = cmd({ if (tty) process.stderr.write("\n") if (tty) process.stderr.write("\x1b[?25h") else process.stderr.write(`sqlite-migration:done${EOL}`) - UI.println( - `Migration complete: ${stats.projects} projects, ${stats.sessions} sessions, ${stats.messages} messages`, - ) - await JsonMigration.writeJsonStorageMigrationDoneMarker() - if (stats.errors.length > 0) { - UI.println(`${stats.errors.length} errors occurred during migration`) + if (JsonMigration.jsonStorageMigrationSucceeded(stats)) { + UI.println( + `Migration complete: ${stats.projects} projects, ${stats.sessions} sessions, ${stats.messages} messages`, + ) + await JsonMigration.writeJsonStorageMigrationDoneMarker() + } else { + UI.error(`Migration incomplete: ${stats.errors.length} errors occurred; the next run will retry`) + for (const error of stats.errors.slice(0, 10)) UI.error(error) + process.exitCode = 1 } } catch (err) { if (tty) process.stderr.write("\x1b[?25h") diff --git a/src/mendcode/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx b/src/mendcode/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx index 3d8915f6..26559f1b 100644 --- a/src/mendcode/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/src/mendcode/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -309,6 +309,8 @@ type PendingPromptDelivery = { const pendingPromptDeliveries = new Map() // In-flight cancellation must not make a newly submitted prompt render as queued. const pendingPromptDeliveryInFlightBySession = new Map>() +const pendingPromptDeliveryDispatchKeys = new Set() +const promptDeliveryTailBySession = new Map>() const latestPromptDeliveryKeyBySession = new Map() const cancelledPromptDeliveryKeys = new Set() const pendingPromptDeliveryListeners = new Set<() => void>() @@ -321,6 +323,53 @@ function notifyPendingPromptDeliveryListeners() { for (const listener of pendingPromptDeliveryListeners) listener() } +export function runPromptDeliveryInSessionOrder(sessionID: string, task: () => Promise): Promise { + const previous = promptDeliveryTailBySession.get(sessionID) ?? Promise.resolve() + const current = previous.then(task, task) + const tail = current.then( + () => undefined, + () => undefined, + ) + promptDeliveryTailBySession.set(sessionID, tail) + void tail.finally(() => { + if (promptDeliveryTailBySession.get(sessionID) === tail) promptDeliveryTailBySession.delete(sessionID) + }) + return current +} + +function pendingPromptDeliveryAcceptanceState(sessionID: string, messageID: string) { + const key = `${sessionID}:${messageID}` + if (pendingPromptDeliveries.get(key)?.state === "accepted") return "accepted" as const + if ( + pendingPromptDeliveryDispatchKeys.has(key) || + pendingPromptDeliveryInFlightBySession.get(sessionID)?.has(key) || + pendingPromptDeliveries.has(key) + ) + return "waiting" as const + return "missing" as const +} + +export function waitForPendingPromptDeliveryReady(sessionID: string, messageID: string, timeoutMs = 5_000) { + return new Promise((resolve) => { + let settled = false + let unsubscribe = () => {} + const finish = (ready: boolean) => { + if (settled) return + settled = true + clearTimeout(timer) + unsubscribe() + resolve(ready) + } + const inspect = () => { + const state = pendingPromptDeliveryAcceptanceState(sessionID, messageID) + if (state === "accepted" || state === "missing") finish(true) + } + const timer = setTimeout(() => finish(false), timeoutMs) + unsubscribe = subscribePendingPromptDeliveries(inspect) + inspect() + }) +} + export function promptDeliveryIsQueued(state: "pending" | "accepted", queuedBehindActiveTurn = false) { return state === "pending" || queuedBehindActiveTurn } @@ -520,11 +569,12 @@ function settlePendingPromptDelivery(request: PromptAsyncInput) { function cancelPendingPromptDeliveryKey(sessionID: string, key: string) { const inFlight = pendingPromptDeliveryInFlightBySession.get(sessionID) const wasInFlight = inFlight?.delete(key) ?? false + const wasQueuedForDispatch = pendingPromptDeliveryDispatchKeys.delete(key) if (inFlight && inFlight.size === 0) pendingPromptDeliveryInFlightBySession.delete(sessionID) const removed = pendingPromptDeliveries.delete(key) if (latestPromptDeliveryKeyBySession.get(sessionID) === key) latestPromptDeliveryKeyBySession.delete(sessionID) - if (wasInFlight) cancelledPromptDeliveryKeys.add(key) - if (removed || wasInFlight) notifyPendingPromptDeliveryListeners() + if (wasInFlight || wasQueuedForDispatch) cancelledPromptDeliveryKeys.add(key) + if (removed || wasInFlight || wasQueuedForDispatch) notifyPendingPromptDeliveryListeners() } export function cancelPendingPromptDelivery(sessionID: string, messageID: string) { @@ -717,6 +767,22 @@ export function shouldEnableSessionInterrupt(input: { return false } +export function shouldClearSessionInterruptRequest(input: { + requestTargetMessageID: string + activeTargetMessageID?: string + targetTerminal: boolean + sessionSettled: boolean + controlState: string + controlResult?: string +}) { + const confirmed = + input.controlState === "stop_confirmed" && + (input.controlResult === "cancelled" || input.controlResult === "already_terminal") + const targetSuperseded = + input.activeTargetMessageID !== undefined && input.activeTargetMessageID !== input.requestTargetMessageID + return input.targetTerminal || input.sessionSettled || confirmed || targetSuperseded +} + export function hasInterruptibleSessionTurn(input: { statusType: string hasDraft?: boolean @@ -731,14 +797,12 @@ export function hasInterruptibleSessionTurn(input: { } export function sessionInterruptConfirmationAction(input: { - activeTargetMessageID?: string - armedTargetMessageID?: string + sessionID?: string + armedSessionID?: string hasActiveTurn?: boolean }): "ignore" | "arm" | "interrupt" { - const activeTargetMessageID = - input.activeTargetMessageID ?? (input.hasActiveTurn ? SESSION_INTERRUPT_FALLBACK_TARGET : undefined) - if (!activeTargetMessageID) return "ignore" - return input.armedTargetMessageID === activeTargetMessageID ? "interrupt" : "arm" + if (!input.sessionID || !input.hasActiveTurn) return "ignore" + return input.armedSessionID === input.sessionID ? "interrupt" : "arm" } export function sessionInterruptHint(input: { enabled: boolean; working: boolean; armed: boolean }) { @@ -932,8 +996,9 @@ export function Prompt(props: PromptProps) { let pendingPromptRetryInFlight = false let promptDisposed = false let interruptRequest: Promise | undefined + let queuedInterruptRequest: { sessionID: string; targetMessageID?: string } | undefined let interruptResetTimer: ReturnType | undefined - let interruptArmedTargetMessageID: string | undefined + let interruptArmedSessionID: string | undefined let autocomplete: AutocompleteRef let lastPastedContentClick: { time: number; offset: number } | undefined @@ -1171,13 +1236,23 @@ export function Prompt(props: PromptProps) { const targetMessageID = interruptTargetMessageID() if (!sessionID || !interruptRequested() || !targetMessageID) return const control = sessionControl.status(sessionID) + const activeTargetMessageID = activeTurnTargetMessageID() const targetTerminal = messagesForActivity().some( (message) => message.role === "assistant" && message.parentID === targetMessageID && message.time.completed !== undefined, ) - const rejected = control.state === "stop_confirmed" && control.result !== "cancelled" const sessionSettled = status().type === "idle" && !hasActiveWorkingAssistant() && !hasPendingPromptDelivery() - if (targetTerminal || rejected || sessionSettled) updateInterruptRequested(false) + if ( + shouldClearSessionInterruptRequest({ + requestTargetMessageID: targetMessageID, + activeTargetMessageID, + targetTerminal, + sessionSettled, + controlState: control.state, + controlResult: control.state === "stop_confirmed" ? control.result : undefined, + }) + ) + updateInterruptRequested(false) }) onCleanup(() => { if (clearWorkingStartTimer) clearTimeout(clearWorkingStartTimer) @@ -1666,25 +1741,12 @@ export function Prompt(props: PromptProps) { interrupt: 0, }) function resetInterruptConfirmation() { - interruptArmedTargetMessageID = undefined + interruptArmedSessionID = undefined setStore("interrupt", 0) if (!interruptResetTimer) return clearTimeout(interruptResetTimer) interruptResetTimer = undefined } - createEffect(() => { - if (store.interrupt === 0) return - const activeTargetMessageID = - workingStatusActive() || hasOrphanedAssistant() || hasPendingPromptDelivery() - ? activeTurnTargetMessageID() - : undefined - if ( - activeTargetMessageID === interruptArmedTargetMessageID || - (!activeTargetMessageID && interruptArmedTargetMessageID === SESSION_INTERRUPT_FALLBACK_TARGET) - ) - return - resetInterruptConfirmation() - }) const currentMessageHistoryPrompt = createMemo(() => messageHistoryIndex() === 0 ? undefined : messageHistoryItems().at(messageHistoryIndex()), ) @@ -1834,8 +1896,8 @@ export function Prompt(props: PromptProps) { if (!props.sessionID) return const targetMessageID = activeTurnTargetMessageID() const action = sessionInterruptConfirmationAction({ - activeTargetMessageID: targetMessageID, - armedTargetMessageID: interruptArmedTargetMessageID, + sessionID: props.sessionID, + armedSessionID: interruptArmedSessionID, hasActiveTurn, }) if (action === "ignore") return @@ -1847,7 +1909,7 @@ export function Prompt(props: PromptProps) { return } - interruptArmedTargetMessageID = targetMessageID ?? SESSION_INTERRUPT_FALLBACK_TARGET + interruptArmedSessionID = props.sessionID setStore("interrupt", 1) if (interruptResetTimer) clearTimeout(interruptResetTimer) interruptResetTimer = setTimeout(() => { @@ -2342,7 +2404,10 @@ export function Prompt(props: PromptProps) { }) updateInterruptRequested(true, targetMessageID) cancelPendingPromptDeliveryForInterrupt(sessionID, targetMessageID) - if (interruptRequest) return + if (interruptRequest) { + queuedInterruptRequest = { sessionID, targetMessageID } + return + } const hardAbort = async () => { const routing = resolveSessionControlRouting({ sessionWorkspaceID: props.workspaceID, @@ -2375,6 +2440,9 @@ export function Prompt(props: PromptProps) { .catch(() => undefined) .finally(() => { interruptRequest = undefined + const replay = queuedInterruptRequest + queuedInterruptRequest = undefined + if (!promptDisposed && replay) abortSession(replay.sessionID, replay.targetMessageID) }) } @@ -2455,17 +2523,13 @@ export function Prompt(props: PromptProps) { }) } - async function deliverPrompt( + async function deliverPromptNow( request: PromptAsyncInput, options?: { retry?: boolean; notify?: boolean; forceAccepted?: boolean; queuedBehindActiveTurn?: boolean }, ) { const retry = options?.retry === true const forceAccepted = options?.forceAccepted === true const deliveryKey = pendingPromptDeliveryKey(request) - if (!retry) { - cancelledPromptDeliveryKeys.delete(deliveryKey) - latestPromptDeliveryKeyBySession.set(request.sessionID, deliveryKey) - } beginPendingPromptDelivery(request) try { if (cancelledPromptDeliveryKeys.has(deliveryKey)) return @@ -2530,6 +2594,22 @@ export function Prompt(props: PromptProps) { } } + function deliverPrompt( + request: PromptAsyncInput, + options?: { retry?: boolean; notify?: boolean; forceAccepted?: boolean; queuedBehindActiveTurn?: boolean }, + ) { + const deliveryKey = pendingPromptDeliveryKey(request) + if (options?.retry !== true) { + cancelledPromptDeliveryKeys.delete(deliveryKey) + latestPromptDeliveryKeyBySession.set(request.sessionID, deliveryKey) + } + pendingPromptDeliveryDispatchKeys.add(deliveryKey) + notifyPendingPromptDeliveryListeners() + return runPromptDeliveryInSessionOrder(request.sessionID, () => deliverPromptNow(request, options)).finally(() => { + if (pendingPromptDeliveryDispatchKeys.delete(deliveryKey)) notifyPendingPromptDeliveryListeners() + }) + } + async function retryPendingPromptDeliveries(input?: { forceAccepted?: boolean }) { if ( pendingPromptRetryInFlight || @@ -2712,6 +2792,7 @@ export function Prompt(props: PromptProps) { const submittedPrompt = promptSubmitParts(promptSnapshot) const queuedBehindActiveTurn = workingStatusActive() const submissionStartedAt = Date.now() + resetInterruptConfirmation() submitPending = true updateInterruptRequested(false) const modelConfig = await readModelsConfig(mend.root).catch(() => undefined) 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 3a7165b2..d74e8eb0 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 @@ -41,6 +41,7 @@ import { Prompt, sessionActivityMessages, subscribePendingPromptDeliveries, + waitForPendingPromptDeliveryReady, type PromptRef, type PromptSubmitInfo, } from "@tui/component/prompt" @@ -62,6 +63,7 @@ import { resolveSessionControlRouting } from "@tui/context/session-control" import * as Log from "@mendcode/core/util/log" import { Locale } from "@/util/locale" import { tuiText } from "@tui/util/text" +import { compareSessionMessages } from "@tui/util/session-message-order" import { Process } from "@/util/process" import type { Tool } from "@/tool/tool" import type { ReadTool } from "@/tool/read" @@ -221,6 +223,7 @@ import { agentViewCommandStateRank, agentViewCommandTouchesSession, formatAgentViewCommandSummary, + formatAgentViewCommandState, formatAgentViewCommandType, isAgentViewCommandActionable, type AgentViewCommand, @@ -942,8 +945,11 @@ export function Session() { }) const queuedMessages = createMemo(() => { const queuedIDs = queuedMessageIDs() - return messages().filter((message): message is UserMessage => message.role === "user" && queuedIDs.has(message.id)) + return messages() + .filter((message): message is UserMessage => message.role === "user" && queuedIDs.has(message.id)) + .toSorted(compareSessionMessages) }) + const nextQueuedMessageID = createMemo(() => queuedMessages()[0]?.id) const messageByID = createMemo(() => new Map(messages().map((message) => [message.id, message] as const))) const pinnedTurnUserMessageID = createMemo(() => sessionPinnedUserMessageID({ @@ -1315,7 +1321,17 @@ export function Session() { if (sendNowMessageID()) return setSendNowMessageID(messageID) try { - await sdk.client.session.interrupt({ sessionID: route.sessionID }, { throwOnError: true }) + const ready = await waitForPendingPromptDeliveryReady(route.sessionID, messageID) + if (!ready) throw new Error("The queued prompt is still being delivered. Try again after the connection settles.") + const interrupted = await sdk.client.session.interrupt( + { sessionID: route.sessionID }, + { throwOnError: true }, + ) + if (interrupted.data !== true) { + void sync.session.sync(route.sessionID, { force: true }).catch(() => undefined) + toast.show({ message: "Queued prompt already started or is no longer waiting", variant: "info", duration: 3000 }) + return + } toast.show({ message: "Queued prompt sent now", variant: "success", duration: 2500 }) } catch (error) { toast.show({ @@ -4429,7 +4445,7 @@ export function Session() { ) : ( - {command.state} + {formatAgentViewCommandState(command)} ) return ( @@ -4720,7 +4736,11 @@ export function Session() { queued sticky anchorID={`queued-${message().id}`} - onSendNow={() => void sendQueuedNow(message().id)} + onSendNow={ + nextQueuedMessageID() === message().id + ? () => void sendQueuedNow(message().id) + : undefined + } sendNowPending={sendNowMessageID() === message().id} simpleHistory={false} compactSubagentPrompt={Boolean(session()?.parentID)} @@ -4753,8 +4773,6 @@ export function Session() { }} message={message() as UserMessage} parts={sync.data.part[message().id] ?? []} - onSendNow={() => void sendQueuedNow(message().id)} - sendNowPending={sendNowMessageID() === message().id} simpleHistory={ !showCompactedToolCalls() && shouldUseSimpleSessionHistory({ @@ -5987,6 +6005,13 @@ function UserMessage(props: { const texts = props.parts .map((x) => { if (x.type === "text" && !x.synthetic) { + const metadata = x.metadata as Record | undefined + if ( + (metadata?.kind === "peer_message" || metadata?.kind === "peer_response") && + typeof metadata.displayText === "string" + ) { + return metadata.displayText + } return x.text } return null @@ -6015,13 +6040,18 @@ function UserMessage(props: { const queuedFg = createMemo(() => selectedForeground(theme, color())) const sendNowBackground = createMemo(() => (props.sendNowPending ? theme.backgroundElement : theme.accent)) const sendNowForeground = createMemo(() => selectedForeground(theme, sendNowBackground())) - const peerMessage = createMemo(() => { + const agentMessage = createMemo(() => { const part = props.parts.find( - (item) => item.type === "text" && (item.metadata as Record | undefined)?.kind === "peer_message", + (item) => + item.type === "text" && + ["peer_message", "peer_response"].includes( + String((item.metadata as Record | undefined)?.kind), + ), ) if (!part || part.type !== "text") return undefined const metadata = part.metadata as Record | undefined return { + kind: metadata?.kind === "peer_response" ? ("response" as const) : ("message" as const), sourceTitle: typeof metadata?.sourceTitle === "string" ? metadata.sourceTitle : undefined, sourceSessionID: typeof metadata?.sourceSessionID === "string" ? metadata.sourceSessionID : undefined, } @@ -6198,8 +6228,10 @@ function UserMessage(props: { ●{" "} - {peerMessage() - ? `Peer · ${peerMessage()!.sourceTitle ?? peerMessage()!.sourceSessionID ?? "session"}` + {agentMessage() + ? `${agentMessage()!.kind === "response" ? "Reply from" : "From"} · ${ + agentMessage()!.sourceTitle ?? agentMessage()!.sourceSessionID ?? "session" + }` : subagentInitialPrompt() ? "Subagent prompt" : "You"} diff --git a/src/mendcode/packages/opencode/src/cli/cmd/tui/thread.ts b/src/mendcode/packages/opencode/src/cli/cmd/tui/thread.ts index 51350de0..667c692f 100644 --- a/src/mendcode/packages/opencode/src/cli/cmd/tui/thread.ts +++ b/src/mendcode/packages/opencode/src/cli/cmd/tui/thread.ts @@ -620,6 +620,7 @@ export const TuiThreadCommand = cmd({ error: errorMessage(error), }) }) + client.close() worker.terminate() } diff --git a/src/mendcode/packages/opencode/src/cli/cmd/tui/util/agent-view.ts b/src/mendcode/packages/opencode/src/cli/cmd/tui/util/agent-view.ts index a89467f6..8faa40ee 100644 --- a/src/mendcode/packages/opencode/src/cli/cmd/tui/util/agent-view.ts +++ b/src/mendcode/packages/opencode/src/cli/cmd/tui/util/agent-view.ts @@ -402,10 +402,18 @@ export function formatAgentViewCommandType(command: Pick) { + if (command.type !== "peer_message") return command.state + if (command.state === "pending" || command.state === "accepted") return "queued" + if (command.state === "running") return "awaiting response" + if (command.state === "completed") return "responded" + return command.state +} + export function formatAgentViewCommandSummary(command: Pick) { const action = formatAgentViewCommandType(command) const detail = @@ -416,6 +424,6 @@ export function formatAgentViewCommandSummary(command: Pick { const base = path.basename(dir) - return base === ".mendcode" || base === ".mendcode" || dir === Flag.OPENCODE_CONFIG_DIR + return base === ".mendcode" || base === ".opencode" || dir === Flag.OPENCODE_CONFIG_DIR } const installPluginDependency = Effect.fnUntraced(function* (dir: string) { @@ -700,7 +700,7 @@ export const layer = Layer.effect( for (const dir of directories) { if (isProjectConfigDir(dir)) { - for (const file of ["mendcode.json", "mendcode.jsonc", "mendcode.json", "mendcode.jsonc"]) { + for (const file of ["mendcode.json", "mendcode.jsonc", "opencode.json", "opencode.jsonc"]) { const source = path.join(dir, file) log.debug(`loading config from ${source}`) yield* merge(source, yield* loadFile(source)) diff --git a/src/mendcode/packages/opencode/src/control-plane/workspace.ts b/src/mendcode/packages/opencode/src/control-plane/workspace.ts index 2311d8ac..ff7286c4 100644 --- a/src/mendcode/packages/opencode/src/control-plane/workspace.ts +++ b/src/mendcode/packages/opencode/src/control-plane/workspace.ts @@ -529,6 +529,22 @@ export const layer = Layer.effect( .get(), ) + const destination = + input.workspaceID === null + ? undefined + : yield* Effect.gen(function* () { + const workspaceID = input.workspaceID! + const space = yield* get(workspaceID) + if (!space) + return yield* new WorkspaceNotFoundError({ + message: `Workspace not found: ${workspaceID}`, + workspaceID, + }) + const adapter = getAdapter(space.projectID, space.type) + const target = yield* EffectBridge.fromPromise(() => adapter.target(space)) + return { space, target } + }) + if (current?.workspaceID) { const previous = yield* get(current.workspaceID) if (previous) { @@ -551,9 +567,6 @@ export const layer = Layer.effect( yield* prompt.cancel(input.sessionID) } - // "claim" this session so any future events coming from - // the old workspace are ignored - SyncEvent.claim(input.sessionID, input.workspaceID ?? Instance.project.id) } } @@ -566,6 +579,7 @@ export const layer = Layer.effect( }, }), ) + SyncEvent.claim(input.sessionID, Instance.project.id) log.info("session warp complete", { workspaceID: input.workspaceID, @@ -576,15 +590,7 @@ export const layer = Layer.effect( } const workspaceID = input.workspaceID - const space = yield* get(workspaceID) - if (!space) - return yield* new WorkspaceNotFoundError({ - message: `Workspace not found: ${workspaceID}`, - workspaceID, - }) - - const adapter = getAdapter(space.projectID, space.type) - const target = yield* EffectBridge.fromPromise(() => adapter.target(space)) + const { space, target } = destination! if (target.type === "local") { yield* sync.run(Session.Event.Updated, { @@ -593,6 +599,7 @@ export const layer = Layer.effect( workspaceID: input.workspaceID, }, }) + SyncEvent.claim(input.sessionID, workspaceID) log.info("session warp complete", { workspaceID: input.workspaceID, @@ -702,6 +709,8 @@ export const layer = Layer.effect( }) } + SyncEvent.claim(input.sessionID, workspaceID) + log.info("session warp complete", { workspaceID: input.workspaceID, sessionID: input.sessionID, diff --git a/src/mendcode/packages/opencode/src/effect/runner.ts b/src/mendcode/packages/opencode/src/effect/runner.ts index 5d9bcde2..8ffe8770 100644 --- a/src/mendcode/packages/opencode/src/effect/runner.ts +++ b/src/mendcode/packages/opencode/src/effect/runner.ts @@ -190,14 +190,39 @@ export const make = ( discard: true, }) + const promoteAfterInterrupt = (id: number): Effect.Effect => + SynchronizedRef.modifyEffect(ref, (st) => + Effect.gen(function* () { + if (st._tag === "Running" && st.run.id === id) return [idle, { _tag: "Idle" } as const] as const + if (st._tag !== "RunningThenRun" || st.run.id !== id) return [Effect.void, st] as const + + const [next, ...remaining] = st.next + if (!next) return [idle, { _tag: "Idle" } as const] as const + const run = yield* startRun(next.work, next.done, next.key) + return [ + Effect.void, + remaining.length ? { _tag: "RunningThenRun", run, next: remaining } : { _tag: "Running", run }, + ] as const + }), + ).pipe(Effect.flatten) as Effect.Effect + + const releaseInterruptedRun = (run: RunHandle) => + Effect.gen(function* () { + yield* Fiber.interrupt(run.fiber).pipe(Effect.timeout(TARGET_CANCEL_FINALIZER_TIMEOUT), Effect.ignore) + yield* Deferred.fail(run.done, new Cancelled()).pipe(Effect.asVoid) + // If the interrupted fiber is still unwinding, its on-exit hook has not + // promoted the queue. Detach that stale run and advance it here. + yield* promoteAfterInterrupt(run.id) + }) + const interruptThenAwait = ( - fiber: Fiber.Fiber, + run: RunHandle, done: Deferred.Deferred, options: NonNullable, ) => Effect.gen(function* () { yield* options.before ?? Effect.void - yield* Fiber.interrupt(fiber).pipe(Effect.ensuring(options.after ?? Effect.void)) + yield* releaseInterruptedRun(run).pipe(Effect.ensuring(options.after ?? Effect.void)) return yield* awaitDone(done) }) @@ -218,7 +243,7 @@ export const make = ( const next = yield* queueRun(work, options?.queueKey) return [ st.run.interruptible && options.interrupt - ? interruptThenAwait(st.run.fiber, next.done, options.interrupt) + ? interruptThenAwait(st.run, next.done, options.interrupt) : awaitDone(next.done), { _tag: "RunningThenRun", run: st.run, next: [next] }, ] as const @@ -234,7 +259,7 @@ export const make = ( const next = yield* queueRun(work, options?.queueKey) return [ st.run.interruptible && options.interrupt - ? interruptThenAwait(st.run.fiber, next.done, options.interrupt) + ? interruptThenAwait(st.run, next.done, options.interrupt) : awaitDone(next.done), { ...st, next: [...st.next, next] }, ] as const @@ -308,11 +333,10 @@ export const make = ( return [ Effect.gen(function* () { yield* options?.before ?? Effect.void - yield* Fiber.interrupt(st.run.fiber) - // The interrupted work normally completes this in finishRun. Close - // it here as well so callers cannot hang if cancellation wins the - // brief race before the forked work begins its on-exit path. - yield* Deferred.fail(st.run.done, new Cancelled()).pipe(Effect.asVoid) + // Session abort is a control-plane request. A provider or tool + // finalizer may keep unwinding, but it must not hold Esc or the + // HTTP abort response indefinitely. + yield* releaseInterruptedRun(st.run) yield* idleIfCurrent() }), { _tag: "Idle" } as const, @@ -322,10 +346,10 @@ export const make = ( Effect.gen(function* () { if (options?.cancelPending) yield* cancelPendingHandles(st.next) yield* options?.before ?? Effect.void - yield* Fiber.interrupt(st.run.fiber) - yield* Deferred.fail(st.run.done, new Cancelled()).pipe(Effect.asVoid) + yield* releaseInterruptedRun(st.run) + if (options?.cancelPending) yield* idleIfCurrent() }), - options?.cancelPending ? ({ _tag: "Running", run: st.run } as const) : st, + options?.cancelPending ? ({ _tag: "Idle" } as const) : st, ] as const case "Shell": return [ @@ -368,11 +392,7 @@ export const make = ( // finalizer waiting so a stale or huge session cannot make Esc // appear dead, while still giving ordinary tool cleanup time to // persist the aborted assistant receipt. - yield* Fiber.interrupt(st.run.fiber).pipe(Effect.timeout(TARGET_CANCEL_FINALIZER_TIMEOUT), Effect.ignore) - // Cancellation is a control-plane operation. Do not wait for a - // provider/tool finalizer to resolve the run receipt: an old or - // huge session must be stoppable even when that cleanup is slow. - yield* Deferred.fail(st.run.done, new Cancelled()).pipe(Effect.asVoid) + yield* releaseInterruptedRun(st.run) yield* idleIfCurrent() return "cancelled" as const }), @@ -383,11 +403,7 @@ export const make = ( Effect.gen(function* () { if (options?.cancelPending) yield* cancelPendingHandles(st.next) yield* options?.before ?? Effect.void - yield* Fiber.interrupt(st.run.fiber).pipe(Effect.timeout(TARGET_CANCEL_FINALIZER_TIMEOUT), Effect.ignore) - // Resolve the caller immediately; the interrupted fiber's exit - // hook may otherwise keep the HTTP cancel request hanging. - yield* Deferred.fail(st.run.done, new Cancelled()).pipe(Effect.asVoid) - yield* idleIfCurrent() + yield* releaseInterruptedRun(st.run) return "cancelled" as const }), options?.cancelPending ? ({ _tag: "Running", run: st.run } as const) : st, @@ -437,7 +453,7 @@ export const make = ( const interruptRun = (run: RunHandle, options?: NonNullable) => Effect.gen(function* () { yield* options?.before ?? Effect.void - yield* Fiber.interrupt(run.fiber).pipe(Effect.ensuring(options?.after ?? Effect.void)) + yield* releaseInterruptedRun(run).pipe(Effect.ensuring(options?.after ?? Effect.void)) yield* Deferred.await(run.done).pipe(Effect.exit, Effect.asVoid) }) const interruptShell = (shell: ShellHandle, options?: NonNullable) => diff --git a/src/mendcode/packages/opencode/src/index.ts b/src/mendcode/packages/opencode/src/index.ts index f343a90c..7ce5a9c1 100644 --- a/src/mendcode/packages/opencode/src/index.ts +++ b/src/mendcode/packages/opencode/src/index.ts @@ -175,7 +175,7 @@ const cli = yargs(args) let last = -1 if (tty) process.stderr.write("\x1b[?25l") try { - await JsonMigration.run(drizzle({ client: Database.Client().$client }), { + const stats = await JsonMigration.run(drizzle({ client: Database.Client().$client }), { progress: (event) => { const percent = Math.floor((event.current / event.total) * 100) if (percent === last && event.current !== event.total) return @@ -192,8 +192,15 @@ const cli = yargs(args) } }, }) - await JsonMigration.writeJsonStorageMigrationDoneMarker() - process.stderr.write("Database migration complete." + EOL) + if (JsonMigration.jsonStorageMigrationSucceeded(stats)) { + await JsonMigration.writeJsonStorageMigrationDoneMarker() + process.stderr.write("Database migration complete." + EOL) + } else { + process.stderr.write( + `Database migration incomplete (${stats.errors.length} errors); it will retry on the next start.${EOL}`, + ) + for (const error of stats.errors.slice(0, 10)) process.stderr.write(`- ${error}${EOL}`) + } } finally { if (tty) process.stderr.write("\x1b[?25h") else { diff --git a/src/mendcode/packages/opencode/src/mend/memory/store.ts b/src/mendcode/packages/opencode/src/mend/memory/store.ts index bae8fc03..ca7e8a3e 100644 --- a/src/mendcode/packages/opencode/src/mend/memory/store.ts +++ b/src/mendcode/packages/opencode/src/mend/memory/store.ts @@ -1,5 +1,5 @@ import { existsSync } from "fs" -import { mkdir, readFile, readdir, writeFile } from "fs/promises" +import { appendFile, mkdir, readFile, readdir, writeFile } from "fs/promises" import path from "path" import { memoryPaths, readMemoryConfig, type MemoryScope } from "./config" import { inferMemoryCategoryIDs } from "./categories" @@ -150,8 +150,7 @@ export async function appendMemoryEntry(input: Partial & { text: st const entry = normalizeMemoryEntry(input) const file = entry.scope === "global" ? paths.globalEntries : paths.projectEntries await mkdir(path.dirname(file), { recursive: true }) - const previous = await readTextIfExists(file) - await writeFile(file, `${previous}${JSON.stringify(entry)}\n`) + await appendFile(file, `${JSON.stringify(entry)}\n`) await refreshMemoryIndex(root) return entry } diff --git a/src/mendcode/packages/opencode/src/mend/runtime/loop-service.ts b/src/mendcode/packages/opencode/src/mend/runtime/loop-service.ts index 7d77d7d2..7c9a6484 100644 --- a/src/mendcode/packages/opencode/src/mend/runtime/loop-service.ts +++ b/src/mendcode/packages/opencode/src/mend/runtime/loop-service.ts @@ -269,12 +269,17 @@ export function loopServicePlan(args: LoopServiceArgs): LoopServicePlan { const command = args.command ?? defaultCommand() const serviceDir = path.resolve(args.serviceDir || defaultServiceDir(platformValue)) const logDir = path.resolve(args.logDir || defaultLogDir(platformValue)) - const programArguments = [ - "/usr/bin/env", - `PATH=${servicePath()}`, - command, - ...loopDaemonArgs({ intervalMs, limit, execute: args.execute, reportOnly: args.reportOnly, quiet: args.quiet }), - ] + const daemonArguments = loopDaemonArgs({ + intervalMs, + limit, + execute: args.execute, + reportOnly: args.reportOnly, + quiet: args.quiet, + }) + const programArguments = + platformValue === "win32" + ? [command, ...daemonArguments] + : ["/usr/bin/env", `PATH=${servicePath()}`, command, ...daemonArguments] const scheduledProgramArguments = platformValue === "darwin" ? [ "/usr/bin/env", @@ -297,7 +302,7 @@ export function loopServicePlan(args: LoopServiceArgs): LoopServicePlan { platformValue === "darwin" ? ["/bin/sh", "-c", loopServicePreflightCommand({ daemonArguments: scheduledProgramArguments, databasePath, projectRoot })] : programArguments - const programLine = shellQuote(programArguments) + const programLine = platformValue === "win32" ? windowsCommandLine(programArguments) : shellQuote(programArguments) return { label, backend, @@ -467,6 +472,16 @@ function shellQuote(args: string[]) { .join(" ") } +function windowsQuote(value: string) { + if (/^[^\s"]+$/.test(value)) return value + const escaped = value.replace(/(\\*)"/g, "$1$1\\\"").replace(/(\\+)$/, "$1$1") + return `"${escaped}"` +} + +function windowsCommandLine(args: string[]) { + return args.map(windowsQuote).join(" ") +} + export function loopServicePlist(plan: LoopServicePlan) { return ` @@ -537,7 +552,7 @@ export function loopServiceWindowsCommand(plan: LoopServicePlan) { const projectRoot = plan.projectRoot.replaceAll('"', '""') const serviceDir = path.dirname(plan.definitionPath).replaceAll('"', '""') const logDir = path.dirname(plan.healthPath).replaceAll('"', '""') - return `set "MENDCODE_SHELL_CWD=${projectRoot}" && set "MENDCODE_LOOP_SERVICE=1" && set "MENDCODE_LOOP_SERVICE_DIR=${serviceDir}" && set "MENDCODE_LOOP_LOG_DIR=${logDir}" && ${shellQuote(plan.programArguments)} >> "${plan.stdoutPath}" 2>> "${plan.stderrPath}"` + return `set "MENDCODE_SHELL_CWD=${projectRoot}" && set "MENDCODE_LOOP_SERVICE=1" && set "MENDCODE_LOOP_SERVICE_DIR=${serviceDir}" && set "MENDCODE_LOOP_LOG_DIR=${logDir}" && ${windowsCommandLine(plan.programArguments)} >> "${plan.stdoutPath}" 2>> "${plan.stderrPath}"` } export function loopServiceDefinition(plan: LoopServicePlan) { diff --git a/src/mendcode/packages/opencode/src/provider/provider.ts b/src/mendcode/packages/opencode/src/provider/provider.ts index 7212d7df..3c201377 100644 --- a/src/mendcode/packages/opencode/src/provider/provider.ts +++ b/src/mendcode/packages/opencode/src/provider/provider.ts @@ -31,8 +31,17 @@ import { ModelID, ProviderID } from "./schema" import { ClaudeCode } from "./claude-code" const log = Log.create({ service: "provider" }) +// Compaction and long-reasoning requests can legitimately stay silent for more +// than a few seconds. Keep the watchdog conservative so ordinary model think +// time is not reported as a network outage. Explicit provider values win. const DEFAULT_CHUNK_TIMEOUT = 60_000 +function defaultChunkTimeout() { + const value = Number(process.env.MENDCODE_LLM_STREAM_IDLE_TIMEOUT_MS) + if (Number.isFinite(value) && value > 0) return value + return DEFAULT_CHUNK_TIMEOUT +} + function shouldUseCopilotResponsesApi(modelID: string): boolean { const match = /^gpt-(\d+)/.exec(modelID) if (!match) return false @@ -1505,7 +1514,7 @@ const layer: Layer.Layer< const customFetch = options["fetch"] const chunkTimeout = - options["chunkTimeout"] === false ? undefined : (options["chunkTimeout"] ?? DEFAULT_CHUNK_TIMEOUT) + options["chunkTimeout"] === false ? undefined : (options["chunkTimeout"] ?? defaultChunkTimeout()) delete options["chunkTimeout"] options["fetch"] = async (input: any, init?: BunFetchRequestInit) => { diff --git a/src/mendcode/packages/opencode/src/server/routes/instance/httpapi/groups/control.ts b/src/mendcode/packages/opencode/src/server/routes/instance/httpapi/groups/control.ts index 33e6a8e4..78560efa 100644 --- a/src/mendcode/packages/opencode/src/server/routes/instance/httpapi/groups/control.ts +++ b/src/mendcode/packages/opencode/src/server/routes/instance/httpapi/groups/control.ts @@ -3,6 +3,7 @@ import { ProviderID } from "@/provider/schema" import { Schema } from "effect" import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" import { described } from "./metadata" +import { Authorization } from "../middleware/authorization" const AuthParams = Schema.Struct({ providerID: ProviderID, @@ -71,5 +72,6 @@ export const ControlApi = HttpApi.make("control").add( }), ), ) + .middleware(Authorization) .annotateMerge(OpenApi.annotations({ title: "control", description: "Control plane routes." })), ) diff --git a/src/mendcode/packages/opencode/src/server/routes/instance/httpapi/groups/global.ts b/src/mendcode/packages/opencode/src/server/routes/instance/httpapi/groups/global.ts index 475580ed..0b876d05 100644 --- a/src/mendcode/packages/opencode/src/server/routes/instance/httpapi/groups/global.ts +++ b/src/mendcode/packages/opencode/src/server/routes/instance/httpapi/groups/global.ts @@ -5,6 +5,7 @@ import "@/server/event" import { Schema } from "effect" import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" import { described } from "./metadata" +import { Authorization } from "../middleware/authorization" const GlobalHealth = Schema.Struct({ healthy: Schema.Literal(true), @@ -137,5 +138,6 @@ export const GlobalApi = HttpApi.make("global").add( }), ), ) + .middleware(Authorization) .annotateMerge(OpenApi.annotations({ title: "global", description: "Global server routes." })), ) diff --git a/src/mendcode/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts b/src/mendcode/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts index d445aeef..072a33ef 100644 --- a/src/mendcode/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts +++ b/src/mendcode/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts @@ -178,10 +178,14 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session", }) { yield* SessionError.mapStorageNotFound(session.get(ctx.payload.sourceSessionID)) yield* SessionError.mapStorageNotFound(session.get(ctx.params.sessionID)) - return yield* agentCommandSvc.create({ targetSessionID: ctx.params.sessionID, ...ctx.payload }).pipe( + const created = yield* agentCommandSvc.create({ targetSessionID: ctx.params.sessionID, ...ctx.payload }).pipe( Effect.catchIf(NotFoundError.isInstance, (error) => Effect.fail(ApiError.notFound(error.data.message))), Effect.catchTag("AgentCommandInvalidTargetError", () => Effect.fail(new HttpApiError.BadRequest({}))), ) + if (created.type === "peer_message" && created.state === "accepted") { + yield* promptSvc.wakePeerDelivery(created.targetSessionID) + } + return created }) const agentCommandPatch = Effect.fn("SessionHttpApi.agentCommandPatch")(function* (ctx: { @@ -413,8 +417,7 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session", }) const interrupt = Effect.fn("SessionHttpApi.interrupt")(function* (ctx: { params: { sessionID: SessionID } }) { - yield* promptSvc.interrupt(ctx.params.sessionID) - return true + return yield* promptSvc.interrupt(ctx.params.sessionID) }) const init = Effect.fn("SessionHttpApi.init")(function* (ctx: { diff --git a/src/mendcode/packages/opencode/src/server/routes/instance/httpapi/server.ts b/src/mendcode/packages/opencode/src/server/routes/instance/httpapi/server.ts index f304cb5e..a3e0660c 100644 --- a/src/mendcode/packages/opencode/src/server/routes/instance/httpapi/server.ts +++ b/src/mendcode/packages/opencode/src/server/routes/instance/httpapi/server.ts @@ -109,7 +109,10 @@ const cors = (corsOptions?: CorsOptions) => { global: true }, ) -const rootApiRoutes = HttpApiBuilder.layer(RootHttpApi).pipe(Layer.provide([controlHandlers, globalHandlers])) +const rootApiRoutes = HttpApiBuilder.layer(RootHttpApi).pipe( + Layer.provide([controlHandlers, globalHandlers]), + Layer.provide(authorizationLayer.pipe(Layer.provide(ServerAuth.Config.defaultLayer))), +) const instanceRouterLayer = authorizationRouterMiddleware .combine(instanceRouterMiddleware) .combine(workspaceRouterMiddleware) diff --git a/src/mendcode/packages/opencode/src/server/routes/instance/session.ts b/src/mendcode/packages/opencode/src/server/routes/instance/session.ts index 770d5423..73b14108 100644 --- a/src/mendcode/packages/opencode/src/server/routes/instance/session.ts +++ b/src/mendcode/packages/opencode/src/server/routes/instance/session.ts @@ -437,9 +437,14 @@ export const SessionRoutes = lazy(() => const body = c.req.valid("json") const session = yield* Session.Service const command = yield* AgentCommand.Service + const prompt = yield* SessionPrompt.Service yield* session.get(body.sourceSessionID) yield* session.get(targetSessionID) - return yield* command.create({ targetSessionID, ...body }) + const created = yield* command.create({ targetSessionID, ...body }) + if (created.type === "peer_message" && created.state === "accepted") { + yield* prompt.wakePeerDelivery(created.targetSessionID) + } + return created }) } catch (error) { if (error instanceof AgentCommand.InvalidTargetError) { @@ -1064,8 +1069,7 @@ export const SessionRoutes = lazy(() => async (c) => jsonRequest("SessionRoutes.interrupt", c, function* () { const svc = yield* SessionPrompt.Service - yield* svc.interrupt(c.req.valid("param").sessionID) - return true + return yield* svc.interrupt(c.req.valid("param").sessionID) }), ) .post( diff --git a/src/mendcode/packages/opencode/src/session/agent-command-policy.ts b/src/mendcode/packages/opencode/src/session/agent-command-policy.ts index 86562a1c..b749f855 100644 --- a/src/mendcode/packages/opencode/src/session/agent-command-policy.ts +++ b/src/mendcode/packages/opencode/src/session/agent-command-policy.ts @@ -100,15 +100,16 @@ export function evaluate(input: { if (input.type === "peer_message") { if (sameWorkspace(input)) { return { - decision: "same_workspace", + decision: "safe_auto", permissions: [], - reason: "Peer text is non-authorizing; the target must explicitly accept it before delivery.", + reason: + "Session message text is non-authorizing and same-workspace; it is delivered automatically at the target idle boundary.", } } return { decision: "approval_required", permissions: [], - reason: "Peer text lacks same-workspace context, so the target must explicitly accept it before delivery.", + reason: "Session message text lacks same-workspace context, so automatic delivery is disabled.", } } if (sameWorkspace(input)) { @@ -160,9 +161,9 @@ export function matrix(): MatrixItem[] { }, { type: "peer_message", - decision: "same_workspace", + decision: "safe_auto", permissions: [], - reason: "Non-authorizing peer text is held until the target explicitly accepts delivery.", + reason: "Non-authorizing session message text is delivered automatically at the target idle boundary.", }, ] } diff --git a/src/mendcode/packages/opencode/src/session/agent-command.ts b/src/mendcode/packages/opencode/src/session/agent-command.ts index 671506f2..199f0cbe 100644 --- a/src/mendcode/packages/opencode/src/session/agent-command.ts +++ b/src/mendcode/packages/opencode/src/session/agent-command.ts @@ -190,7 +190,7 @@ export class InvalidTargetError extends Schema.TaggedErrorClass) { return `${input.type}:${JSON.stringify(input.payload)}` } @@ -522,27 +527,27 @@ export const layer = Layer.effect( .all() .find( (row) => - row.state === "pending" && + commandOccupiesQueue(row, now) && row.source_session_id === input.sourceSessionID && - !commandExpired(row, now) && commandPayloadKey(row.data) === commandPayloadKey({ type: input.type, payload }), ) if (duplicate) return { info: fromRow(duplicate, { source, target, targetBackground }), created: false } - const pendingForTarget = db + const queuedForTarget = db .select() .from(AgentCommandTable) .where(eq(AgentCommandTable.target_session_id, input.targetSessionID)) .all() - .filter((row) => row.state === "pending" && !commandExpired(row, now)).length + .filter((row) => commandOccupiesQueue(row, now)).length const requestedExpiresAt = input.expiresAt ?? now + defaultCommandTTL const expiresAt = input.type === "peer_message" ? Math.min(requestedExpiresAt, now + maxPeerMessageTTL) : requestedExpiresAt - const overLimit = pendingForTarget >= maxPendingPerTarget + const overLimit = queuedForTarget >= maxPendingPerTarget + const autoAccept = input.type === "peer_message" && policy.decision === "safe_auto" const row = { id: AgentCommandID.ascending(), source_session_id: input.sourceSessionID, target_session_id: input.targetSessionID, - state: (expiresAt <= now ? "expired" : overLimit ? "rejected" : "pending") as State, + state: (expiresAt <= now ? "expired" : overLimit ? "rejected" : autoAccept ? "accepted" : "pending") as State, time_created: now, time_updated: now, data: { @@ -553,7 +558,7 @@ export const layer = Layer.effect( expiresAt, ...(overLimit ? { - error: `Target already has ${maxPendingPerTarget} pending commands; wait for the worker to accept or reject one first.`, + error: `Target already has ${maxPendingPerTarget} queued commands; wait for one to finish before sending another.`, } : {}), ...(expiresAt <= now ? { error: "Command expired before it could be queued." } : {}), diff --git a/src/mendcode/packages/opencode/src/session/loop.ts b/src/mendcode/packages/opencode/src/session/loop.ts index ee5eeb5d..d9617550 100644 --- a/src/mendcode/packages/opencode/src/session/loop.ts +++ b/src/mendcode/packages/opencode/src/session/loop.ts @@ -1386,7 +1386,7 @@ function redactArtifactString(value: string) { .replace(/\bBearer\s+[A-Za-z0-9._~+/=-]{8,}/gi, "Bearer [REDACTED]") .replace(/\bsk-[A-Za-z0-9_-]{8,}\b/g, "[REDACTED_SECRET]") .replace(/\bgh[pousr]_[A-Za-z0-9_]{20,}\b/g, "[REDACTED_SECRET]") - .replace(/(["'])(api[_-]?key|access[_-]?token|auth[_-]?token|token|secret|password)\1\s*:\s*"[^"\n]*"/gi, '$1$2$1:"[REDACTED]"') + .replace(/(["'])(api[_-]?key|access[_-]?token|auth[_-]?token|authorization|token|secret|password)\1\s*:\s*"[^"\n]*"/gi, '$1$2$1:"[REDACTED]"') .replace(/\b(api[_-]?key|access[_-]?token|auth[_-]?token|token|secret|password)\s*([:=])\s*([^\s,;]+)/gi, "$1$2[REDACTED]") .replace(/\b(authorization)\s*([:=])\s*([^\n]+)/gi, "$1$2[REDACTED]") } diff --git a/src/mendcode/packages/opencode/src/session/message-v2.ts b/src/mendcode/packages/opencode/src/session/message-v2.ts index 41a00c1a..69f76a99 100644 --- a/src/mendcode/packages/opencode/src/session/message-v2.ts +++ b/src/mendcode/packages/opencode/src/session/message-v2.ts @@ -2351,6 +2351,21 @@ export function fromError( ).toObject() } + const streamStall = retryableStreamStallError(e) + if (streamStall) { + return new APIError( + { + message: "AI backend stream stalled", + isRetryable: true, + metadata: { + kind: "stream_timeout", + message: streamStall, + }, + }, + { cause: e }, + ).toObject() + } + switch (true) { case e instanceof DOMException && e.name === "AbortError": if (ctx.aborted) { @@ -2557,9 +2572,6 @@ function retryableNetworkError(e: unknown): RetryableNetworkError | undefined { lower.includes("operation was aborted") || lower.includes("request aborted") || lower.includes("stream aborted") || - lower.includes("read timed out") || - lower.includes("stream timed out") || - lower.includes("sse read timed out") || lower.includes("failed to fetch") ) { return { @@ -2575,6 +2587,22 @@ function retryableNetworkError(e: unknown): RetryableNetworkError | undefined { return cause } +function retryableStreamStallError(e: unknown): string | undefined { + if (typeof e !== "object" || e === null) return undefined + const err = e as { cause?: unknown; errors?: unknown } + const message = errorMessage(e) + const lower = message.toLowerCase() + if (lower.includes("read timed out") || lower.includes("stream timed out") || lower.includes("sse read timed out")) + return message + if (Array.isArray(err.errors)) { + for (const nested of err.errors) { + const match = retryableStreamStallError(nested) + if (match) return match + } + } + return retryableStreamStallError(err.cause) +} + export function isNetworkError(error: unknown) { if (!APIError.isInstance(error)) return false const metadata = error.data.metadata diff --git a/src/mendcode/packages/opencode/src/session/processor.ts b/src/mendcode/packages/opencode/src/session/processor.ts index 2001070d..f577007a 100644 --- a/src/mendcode/packages/opencode/src/session/processor.ts +++ b/src/mendcode/packages/opencode/src/session/processor.ts @@ -39,8 +39,9 @@ import * as DateTime from "effect/DateTime" const DOOM_LOOP_THRESHOLD = 3 const log = Log.create({ service: "session.processor" }) -// Abort silent provider streams quickly after sleep/network changes. -// Slow providers can override this with the environment variable. +// Compaction and long-reasoning requests can legitimately stay silent. Keep a +// watchdog for dead streams without turning normal provider think time into a +// false network outage. Slow providers can override this value. const DEFAULT_LLM_STREAM_IDLE_TIMEOUT_MS = 60_000 const DEFAULT_MEMORY_EXTRACTION_TIMEOUT_MS = 45_000 const RETRY_STATUS_EVENT_INTERVAL_MS = 5_000 diff --git a/src/mendcode/packages/opencode/src/session/prompt.ts b/src/mendcode/packages/opencode/src/session/prompt.ts index c14f74a0..661cea65 100644 --- a/src/mendcode/packages/opencode/src/session/prompt.ts +++ b/src/mendcode/packages/opencode/src/session/prompt.ts @@ -52,6 +52,7 @@ import { zod } from "@/util/effect-zod" import { withStatics } from "@/util/schema" import * as EffectLogger from "@mendcode/core/effect/logger" import { InstanceState } from "@/effect/instance-state" +import { InstanceRef } from "@/effect/instance-ref" import { TaskTool, type TaskPromptOps } from "@/tool/task" import { BackgroundTask } from "./background-task" import { WorkflowService } from "./workflow-service" @@ -83,6 +84,8 @@ const OWNER_WAKE_MAX_EVENTS = 16 const OWNER_WAKE_PREVIEW_CHARS = 320 const SHELL_ABORT_FORCE_KILL_AFTER = "1 second" const CANCEL_TOOL_SETTLE_MS = 1_500 +const PEER_DELIVERY_RETRY_INTERVAL_MS = 500 +const PEER_DELIVERY_RETRY_MAX_ATTEMPTS = 20 export type OwnerWakeNotification = BackgroundTask.Notification | WorkflowService.Notification @@ -93,6 +96,13 @@ type OwnerWakeState = { running: Set } +type PeerDeliveryState = { + targets: Set + pendingTargets: Set + retryingTargets: Set + scope: Scope.Scope +} + function ownerWakePreview(value: string | undefined) { const normalized = value?.trim().replace(/\s+/g, " ") if (!normalized) return @@ -460,9 +470,14 @@ export function shouldContinueAfterCompactionStop(input: { export function resolveCancelTurnResult(input: { runtime: "cancelled" | "target_mismatch" | "not_running" | "not_interruptible" terminalAssistant: boolean + sessionBusy: boolean }): CancelTurnResult { if (input.runtime === "cancelled") return input.runtime if (input.runtime === "not_interruptible") return "not_running" + // A compaction or synthetic resume changes the runner key while the original + // user turn already has a terminal assistant. That old receipt must not hide + // the newer active run from the TUI's hard-abort fallback. + if (input.runtime === "target_mismatch" && input.sessionBusy) return "target_mismatch" if (input.terminalAssistant) return "already_terminal" return input.runtime } @@ -471,7 +486,7 @@ export interface Interface { readonly cancel: (sessionID: SessionID) => Effect.Effect readonly cancelTurn: (input: CancelTurnInput) => Effect.Effect readonly cancelQueued: (input: { sessionID: SessionID; messageID: MessageID }) => Effect.Effect - readonly interrupt: (sessionID: SessionID) => Effect.Effect + readonly interrupt: (sessionID: SessionID) => Effect.Effect readonly prompt: (input: PromptInput) => Effect.Effect readonly promptAsync: (input: PromptInput) => Effect.Effect readonly loop: (input: LoopInput) => Effect.Effect @@ -680,7 +695,12 @@ export const layer = Layer.effect( msg.info.parentID === input.targetMessageID && Boolean(msg.info.time.completed), ) - return resolveCancelTurnResult({ runtime: result, terminalAssistant: Option.isSome(terminal) }) + const sessionBusy = yield* state.isBusy(input.sessionID) + return resolveCancelTurnResult({ + runtime: result, + terminalAssistant: Option.isSome(terminal), + sessionBusy, + }) }) const syncSessionSelection = Effect.fn("SessionPrompt.syncSessionSelection")(function* (input: { @@ -1812,7 +1832,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the const interrupt = Effect.fn("SessionPrompt.interrupt")(function* (sessionID: SessionID) { yield* elog.info("interrupt", { sessionID }) let interruptedAssistantID: MessageID | undefined - yield* state + return yield* state .interruptQueued(sessionID, { before: Effect.gen(function* () { promptAbortReasons.set(sessionID, "user") @@ -2946,6 +2966,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the const loop: (input: LoopInput) => Effect.Effect = Effect.fn("SessionPrompt.loop")(function* ( input: LoopInput, ) { + yield* ensurePeerDeliveryState yield* resumeWorkflowTaskSession(input.sessionID) let interruptedAssistantID: MessageID | undefined const queueMode = (yield* config.get()).queue?.mode ?? "after-response" @@ -3054,18 +3075,136 @@ NOTE: At any point in time through this workflow you should feel free to ask the // claimed per target only after the target is idle, then persisted through // the normal prompt boundary so a restart can detect the delivery marker // and avoid inserting the same prompt twice. - const peerDeliveryTargets = new Set() - const peerDeliveryPendingTargets = new Set() - const deliverPeerMessage = (info: AgentCommand.Info): Effect.Effect => + const peerMessageRuntimeContext = (info: Extract) => + [ + '', + `source_session: ${JSON.stringify({ + id: info.sourceSessionID, + title: info.payload.sourceTitle ?? info.sourceSessionID, + })}`, + "This is a non-authorizing session message from another MendCode session in the same workspace.", + "Reply normally in this session. Your final response text is returned automatically to the source session once it finishes.", + "Do not use tell only to acknowledge this message; automatic response delivery does not create an acknowledgement loop.", + "", + ].join("\n") + + const peerResponseRuntimeContext = (input: { + deliveryID: string + sourceSessionID: SessionID + sourceTitle: string + targetAssistantID: MessageID + }) => + [ + '', + `responding_session: ${JSON.stringify({ id: input.sourceSessionID, title: input.sourceTitle })}`, + `delivery_id: ${JSON.stringify(input.deliveryID)}`, + `assistant_message_id: ${JSON.stringify(input.targetAssistantID)}`, + "This is the automatic response to an earlier same-workspace session message.", + "Treat it as coordination context. Do not send an acknowledgement unless further work genuinely requires it.", + "", + ].join("\n") + + const completePeerResponse = ( + assistant: MessageV2.Assistant, + peerState: PeerDeliveryState, + ): Effect.Effect => + Effect.gen(function* () { + if (!assistant.parentID || assistant.time.completed === undefined) return + if (assistant.finish === "tool-calls" || assistant.finish === "unknown") return + const parent = yield* sessions.findMessage( + assistant.sessionID, + (message) => message.info.id === assistant.parentID && message.info.role === "user", + ) + if (Option.isNone(parent)) return + let deliveryID: string | undefined + for (const part of parent.value.parts) { + if (part.type !== "text") continue + const metadata = part.metadata as Record | undefined + if (metadata?.kind !== "peer_message" || typeof metadata.deliveryID !== "string") continue + deliveryID = metadata.deliveryID + break + } + if (!deliveryID) return + const command = (yield* agentCommands.list({ targetSessionID: assistant.sessionID })).find( + (item) => + item.id === deliveryID && + item.type === "peer_message" && + (item.state === "accepted" || item.state === "running"), + ) + if (!command) return + if (assistant.error) { + yield* agentCommands.update({ + id: command.id, + targetSessionID: command.targetSessionID, + state: "failed", + error: `Agent response failed in assistant ${assistant.id}.`, + }) + } else { + const assistantMessage = yield* sessions.findMessage( + assistant.sessionID, + (message) => message.info.id === assistant.id, + ) + const responseText = Option.isSome(assistantMessage) + ? assistantMessage.value.parts + .flatMap((part) => (part.type === "text" && !part.ignored ? [part.text] : [])) + .join("\n\n") + .trim() + : "" + const displayText = responseText || "The agent completed its response without a text summary." + const targetSession = yield* sessions.get(command.targetSessionID) + const existingResponse = yield* sessions.findMessage(command.sourceSessionID, (message) => + message.parts.some((part) => { + if (part.type !== "text") return false + const metadata = part.metadata as Record | undefined + return metadata?.kind === "peer_response" && metadata.deliveryID === command.id + }), + ) + const responsePrompt = Option.isSome(existingResponse) + ? existingResponse.value + : yield* promptAsync({ + sessionID: command.sourceSessionID, + parts: [ + { + type: "text", + text: `${peerResponseRuntimeContext({ + deliveryID: command.id, + sourceSessionID: command.targetSessionID, + sourceTitle: targetSession.title, + targetAssistantID: assistant.id, + })}\n\n${displayText}`, + metadata: { + kind: "peer_response", + deliveryID: command.id, + sourceSessionID: command.targetSessionID, + sourceTitle: targetSession.title, + targetAssistantID: assistant.id, + displayText, + receivedAt: Date.now(), + }, + }, + ], + }) + yield* agentCommands.update({ + id: command.id, + targetSessionID: command.targetSessionID, + state: "completed", + result: `Agent response returned to source in prompt ${responsePrompt.info.id}.`, + }) + } + peerState.pendingTargets.add(command.targetSessionID) + yield* scheduleNextPeerDelivery(command.targetSessionID, peerState) + }).pipe(Effect.orDie) + + const deliverPeerMessage = (info: AgentCommand.Info, peerState: PeerDeliveryState): Effect.Effect => Effect.gen(function* () { if (info.type !== "peer_message" || (info.state !== "accepted" && info.state !== "running")) return - if (peerDeliveryTargets.has(info.targetSessionID)) return - peerDeliveryTargets.add(info.targetSessionID) + if (peerState.targets.has(info.targetSessionID)) return + peerState.targets.add(info.targetSessionID) let settled = false try { const statusInfo = yield* status.get(info.targetSessionID) if (statusInfo.type !== "idle") return - if (info.policy.decision !== "same_workspace") { + if (info.policy.decision !== "safe_auto" && info.policy.decision !== "same_workspace") { yield* agentCommands.update({ id: info.id, targetSessionID: info.targetSessionID, @@ -3099,13 +3238,25 @@ NOTE: At any point in time through this workflow you should feel free to ask the ), ) if (marker) { + const response = existing.findLast( + (message) => + message.info.role === "assistant" && + message.info.parentID === marker.info.id && + message.info.time.completed !== undefined && + message.info.finish !== "tool-calls" && + message.info.finish !== "unknown", + ) + if (response?.info.role === "assistant") { + yield* completePeerResponse(response.info, peerState) + settled = true + return + } yield* agentCommands.update({ id: info.id, targetSessionID: info.targetSessionID, - state: "completed", - result: `Peer message already persisted in prompt ${marker.info.id}.`, + state: "running", + result: `Session message delivered in prompt ${marker.info.id}; awaiting response.`, }) - settled = true return } @@ -3114,12 +3265,13 @@ NOTE: At any point in time through this workflow you should feel free to ask the parts: [ { type: "text", - text: info.payload.text, + text: `${peerMessageRuntimeContext(info)}\n\n${info.payload.text}`, metadata: { kind: "peer_message", deliveryID: info.id, sourceSessionID: info.sourceSessionID, sourceTitle: info.payload.sourceTitle ?? info.sourceSessionID, + displayText: info.payload.text, receivedAt: Date.now(), }, }, @@ -3128,10 +3280,9 @@ NOTE: At any point in time through this workflow you should feel free to ask the yield* agentCommands.update({ id: info.id, targetSessionID: info.targetSessionID, - state: "completed", - result: `Peer message persisted in prompt ${message.info.id}.`, + state: "running", + result: `Session message delivered in prompt ${message.info.id}; awaiting response.`, }) - settled = true } catch (error) { yield* agentCommands.update({ id: info.id, @@ -3141,20 +3292,20 @@ NOTE: At any point in time through this workflow you should feel free to ask the }) settled = true } finally { - peerDeliveryTargets.delete(info.targetSessionID) - if (settled) yield* scheduleNextPeerDelivery(info.targetSessionID) + peerState.targets.delete(info.targetSessionID) + if (settled) yield* scheduleNextPeerDelivery(info.targetSessionID, peerState) } }).pipe(Effect.orDie) - const schedulePeerDelivery = (info: AgentCommand.Info): Effect.Effect => + const schedulePeerDelivery = (info: AgentCommand.Info, peerState: PeerDeliveryState): Effect.Effect => Effect.gen(function* () { if (info.type !== "peer_message" || (info.state !== "accepted" && info.state !== "running")) return - yield* deliverPeerMessage(info).pipe(Effect.forkIn(scope, { startImmediately: true })) + yield* deliverPeerMessage(info, peerState).pipe(Effect.forkIn(peerState.scope, { startImmediately: true })) }) - const scheduleNextPeerDelivery = (targetSessionID: SessionID): Effect.Effect => + const scheduleNextPeerDelivery = (targetSessionID: SessionID, peerState: PeerDeliveryState): Effect.Effect => Effect.gen(function* () { - if (!peerDeliveryPendingTargets.has(targetSessionID)) return + if (!peerState.pendingTargets.has(targetSessionID)) return const next = (yield* agentCommands.list({ targetSessionID })) .filter( (item): item is Extract => @@ -3162,27 +3313,128 @@ NOTE: At any point in time through this workflow you should feel free to ask the ) .toSorted((a, b) => a.time.created - b.time.created || a.id.localeCompare(b.id))[0] if (next) { - yield* schedulePeerDelivery(next) + yield* schedulePeerDelivery(next, peerState) return } - peerDeliveryPendingTargets.delete(targetSessionID) + peerState.pendingTargets.delete(targetSessionID) }) - const enqueuePeerDelivery = (info: AgentCommand.Info): Effect.Effect => + const schedulePeerDeliveryRetry = (targetSessionID: SessionID, peerState: PeerDeliveryState): Effect.Effect => + Effect.gen(function* () { + if (peerState.retryingTargets.has(targetSessionID)) return + peerState.retryingTargets.add(targetSessionID) + yield* Effect.gen(function* () { + for ( + let attempt = 0; + attempt < PEER_DELIVERY_RETRY_MAX_ATTEMPTS && peerState.pendingTargets.has(targetSessionID); + attempt += 1 + ) { + yield* scheduleNextPeerDelivery(targetSessionID, peerState) + if (!peerState.pendingTargets.has(targetSessionID)) break + yield* Effect.sleep(`${PEER_DELIVERY_RETRY_INTERVAL_MS} millis`) + } + }).pipe( + Effect.ensuring(Effect.sync(() => peerState.retryingTargets.delete(targetSessionID))), + Effect.forkIn(peerState.scope, { startImmediately: true }), + ) + }) + + const enqueuePeerDelivery = (info: AgentCommand.Info, peerState: PeerDeliveryState): Effect.Effect => Effect.gen(function* () { if (info.type !== "peer_message" || info.state !== "accepted") return - peerDeliveryPendingTargets.add(info.targetSessionID) - yield* scheduleNextPeerDelivery(info.targetSessionID) + peerState.pendingTargets.add(info.targetSessionID) + yield* scheduleNextPeerDelivery(info.targetSessionID, peerState) + yield* schedulePeerDeliveryRetry(info.targetSessionID, peerState) }) + const enqueueAcceptedPeerCommand = + (peerState: PeerDeliveryState) => (event: { properties: { info: AgentCommand.Info } }) => { + if (event.properties.info.type !== "peer_message" || event.properties.info.state !== "accepted") { + return Effect.void + } + return enqueuePeerDelivery(event.properties.info, peerState) + } + + const peerDeliveryState = yield* InstanceState.make( + Effect.fn("SessionPrompt.peerDeliveryState")(function* (instance) { + const instanceScope = yield* Scope.Scope + const peerState: PeerDeliveryState = { + targets: new Set(), + pendingTargets: new Set(), + retryingTargets: new Set(), + scope: instanceScope, + } + const withInstance = (effect: Effect.Effect) => + effect.pipe(Effect.provideService(InstanceRef, instance)) + + yield* bus + .subscribe(AgentCommand.Event.Created) + .pipe( + Stream.runForEach(enqueueAcceptedPeerCommand(peerState)), + withInstance, + Effect.forkIn(instanceScope, { startImmediately: true }), + ) + yield* bus.subscribe(AgentCommand.Event.Updated).pipe( + Stream.filter((event) => event.properties.info.type === "peer_message"), + Stream.runForEach(enqueueAcceptedPeerCommand(peerState)), + withInstance, + Effect.forkIn(instanceScope, { startImmediately: true }), + ) + yield* bus.subscribe(MessageV2.Event.Updated).pipe( + Stream.filter( + (event) => + event.properties.info.role === "assistant" && event.properties.info.time.completed !== undefined, + ), + Stream.runForEach((event) => + event.properties.info.role === "assistant" + ? completePeerResponse(event.properties.info, peerState) + : Effect.void, + ), + withInstance, + Effect.forkIn(instanceScope, { startImmediately: true }), + ) + yield* bus.subscribe(SessionStatus.Event.Status).pipe( + Stream.filter((event) => event.properties.status.type === "idle"), + Stream.runForEach((event) => + // SessionStatus publishes idle before removing its durable busy row. + Effect.sleep("1 millis").pipe( + Effect.andThen(scheduleNextPeerDelivery(event.properties.sessionID, peerState)), + Effect.andThen(schedulePeerDeliveryRetry(event.properties.sessionID, peerState)), + ), + ), + withInstance, + Effect.forkIn(instanceScope, { startImmediately: true }), + ) + yield* Effect.gen(function* () { + yield* Effect.sleep("1 millis") + const targets = new Set( + (yield* agentCommands.list()) + .filter((info) => info.type === "peer_message" && (info.state === "accepted" || info.state === "running")) + .map((info) => info.targetSessionID), + ) + for (const targetSessionID of targets) peerState.pendingTargets.add(targetSessionID) + yield* Effect.forEach(targets, (targetSessionID) => scheduleNextPeerDelivery(targetSessionID, peerState), { + discard: true, + }) + yield* Effect.forEach(targets, (targetSessionID) => schedulePeerDeliveryRetry(targetSessionID, peerState), { + discard: true, + }) + }).pipe(withInstance, Effect.forkIn(instanceScope, { startImmediately: false })) + return peerState + }), + ) + + const ensurePeerDeliveryState = InstanceState.get(peerDeliveryState).pipe(Effect.asVoid) const wakePeerDelivery = Effect.fn("SessionPrompt.wakePeerDelivery")(function* (targetSessionID: SessionID) { + const peerState = yield* InstanceState.get(peerDeliveryState) const pending = (yield* agentCommands.list({ targetSessionID })).filter( (info): info is Extract => info.type === "peer_message" && (info.state === "accepted" || info.state === "running"), ) if (pending.length === 0) return - peerDeliveryPendingTargets.add(targetSessionID) - yield* scheduleNextPeerDelivery(targetSessionID) + peerState.pendingTargets.add(targetSessionID) + yield* scheduleNextPeerDelivery(targetSessionID, peerState) + yield* schedulePeerDeliveryRetry(targetSessionID, peerState) }) const recoverQueuedPrompt = Effect.fn("SessionPrompt.recoverQueuedPrompt")(function* ( @@ -3541,29 +3793,6 @@ NOTE: At any point in time through this workflow you should feel free to ask the }), ) - yield* bus.subscribe(AgentCommand.Event.Updated).pipe( - Stream.filter((event) => event.properties.info.type === "peer_message"), - Stream.runForEach((event) => { - if (event.properties.info.state !== "accepted") return Effect.void - return enqueuePeerDelivery(event.properties.info) - }), - Effect.forkScoped({ startImmediately: true }), - ) - yield* bus.subscribe(SessionStatus.Event.Status).pipe( - Stream.filter((event) => event.properties.status.type === "idle"), - Stream.runForEach((event) => scheduleNextPeerDelivery(event.properties.sessionID)), - Effect.forkScoped({ startImmediately: true }), - ) - yield* Effect.gen(function* () { - yield* Effect.sleep("1 millis") - const targets = new Set( - (yield* agentCommands.list()) - .filter((info) => info.type === "peer_message" && (info.state === "accepted" || info.state === "running")) - .map((info) => info.targetSessionID), - ) - for (const targetSessionID of targets) peerDeliveryPendingTargets.add(targetSessionID) - yield* Effect.forEach(targets, (targetSessionID) => scheduleNextPeerDelivery(targetSessionID), { discard: true }) - }).pipe(Effect.forkScoped({ startImmediately: false })) return Service.of({ cancel, cancelTurn, diff --git a/src/mendcode/packages/opencode/src/session/retry.ts b/src/mendcode/packages/opencode/src/session/retry.ts index e3216c62..d2855b38 100644 --- a/src/mendcode/packages/opencode/src/session/retry.ts +++ b/src/mendcode/packages/opencode/src/session/retry.ts @@ -20,6 +20,10 @@ function cap(ms: number) { } export function delay(attempt: number, error?: MessageV2.APIError) { + // Network recovery must not inherit a provider's rate-limit hint. The + // request is already known to be transport-bound, so probe again quickly. + if (error && MessageV2.isNetworkError(error)) return RETRY_NETWORK_INTERVAL + if (error) { const headers = error.data.responseHeaders if (headers) { @@ -45,12 +49,9 @@ export function delay(attempt: number, error?: MessageV2.APIError) { } } - if (MessageV2.isNetworkError(error)) return RETRY_NETWORK_INTERVAL return cap(RETRY_INITIAL_DELAY * Math.pow(RETRY_BACKOFF_FACTOR, attempt - 1)) } } - - if (error && MessageV2.isNetworkError(error)) return RETRY_NETWORK_INTERVAL return cap(Math.min(RETRY_INITIAL_DELAY * Math.pow(RETRY_BACKOFF_FACTOR, attempt - 1), RETRY_MAX_DELAY_NO_HEADERS)) } diff --git a/src/mendcode/packages/opencode/src/session/run-state.ts b/src/mendcode/packages/opencode/src/session/run-state.ts index ad416db3..be647f3f 100644 --- a/src/mendcode/packages/opencode/src/session/run-state.ts +++ b/src/mendcode/packages/opencode/src/session/run-state.ts @@ -30,7 +30,7 @@ export interface Interface { readonly interruptQueued: ( sessionID: SessionID, options?: NonNullable, - ) => Effect.Effect + ) => Effect.Effect readonly startShell: ( sessionID: SessionID, onInterrupt: Effect.Effect, @@ -180,8 +180,8 @@ export const layer = Layer.effect( ) { const data = yield* InstanceState.get(state) const existing = data.runners.get(sessionID) - if (!existing) return - yield* existing.interruptQueued(options) + if (!existing) return false + return yield* existing.interruptQueued(options) }) const ensureRunning = Effect.fn("SessionRunState.ensureRunning")(function* ( diff --git a/src/mendcode/packages/opencode/src/session/session.ts b/src/mendcode/packages/opencode/src/session/session.ts index abb5d5c6..b1dbffea 100644 --- a/src/mendcode/packages/opencode/src/session/session.ts +++ b/src/mendcode/packages/opencode/src/session/session.ts @@ -954,8 +954,27 @@ export const layer: Layer.Layer stale.flush!) + if (pendingPartDeltas.get(oldest) !== stale) continue + if (stale.timer) { + clearTimeout(stale.timer) + stale.timer = undefined + } + const part = structuredClone(stale.part) + yield* sync.run(MessageV2.Event.PartUpdated, { + sessionID: part.sessionID, + part, + time: Date.now(), + }) + if (pendingPartDeltas.get(oldest) === stale) pendingPartDeltas.delete(oldest) } pending = { part: structuredClone(part), diff --git a/src/mendcode/packages/opencode/src/session/workflow-runner.ts b/src/mendcode/packages/opencode/src/session/workflow-runner.ts index 335f51d2..7f80a6fa 100644 --- a/src/mendcode/packages/opencode/src/session/workflow-runner.ts +++ b/src/mendcode/packages/opencode/src/session/workflow-runner.ts @@ -62,6 +62,16 @@ export function shouldAuditWorkflowCompletion(state: WorkflowRunState) { return state === "queued" || state === "working" } +export function workflowClaimCanExecute( + snapshot: WorkflowService.WorkflowSnapshot, + claim: Pick, + sessionID: SessionID, +) { + if (!shouldRecoverWorkflowRun(snapshot.run.state)) return false + const task = snapshot.tasks.find((candidate) => candidate.id === claim.taskID) + return task?.state === "working" && task.attempt === claim.attempt && task.sessionID === sessionID +} + const errorText = (error: unknown) => { if (error instanceof Error) return error.message if (typeof error === "object" && error !== null && "message" in error && typeof error.message === "string") @@ -468,13 +478,29 @@ export const layer = Layer.effect( depth: 1, maxChildren: input.claim.maxChildren, }) - yield* scheduler.markStarted({ - runID: input.runID as never, - taskID: input.claim.taskID, - attemptID: input.claim.attemptID, - backgroundTaskID: attempt.sessionID, - backgroundGeneration: attempt.generation, - }) + yield* scheduler + .markStarted({ + runID: input.runID as never, + taskID: input.claim.taskID, + attemptID: input.claim.attemptID, + backgroundTaskID: attempt.sessionID, + backgroundGeneration: attempt.generation, + }) + .pipe( + Effect.tapCause(() => + background + .cancelAttempt({ sessionID: attempt.sessionID, generation: attempt.generation }) + .pipe(Effect.catchCause(() => Effect.void)), + ), + ) + + const latest = yield* workflow.show(input.runID as never) + if (!workflowClaimCanExecute(latest, input.claim, attempt.sessionID)) { + yield* background + .cancelAttempt({ sessionID: attempt.sessionID, generation: attempt.generation }) + .pipe(Effect.catchCause(() => Effect.void)) + return + } const result: ExecutionResult = yield* executor .execute({ diff --git a/src/mendcode/packages/opencode/src/session/workflow-scheduler.ts b/src/mendcode/packages/opencode/src/session/workflow-scheduler.ts index 2ad332c8..c37d3589 100644 --- a/src/mendcode/packages/opencode/src/session/workflow-scheduler.ts +++ b/src/mendcode/packages/opencode/src/session/workflow-scheduler.ts @@ -520,10 +520,18 @@ export const layer = Layer.effect( readonly backgroundGeneration: number }) { const current = yield* workflow.show(input.runID) + if (current.run.state !== "queued" && current.run.state !== "working") + return yield* Effect.fail( + new WorkflowStateError(input.runID, `Workflow cannot start tasks while ${current.run.state}`), + ) const now = Date.now() const changed = Database.transaction((db) => { const task = db.select().from(WorkflowTaskTable).where(and(eq(WorkflowTaskTable.run_id, input.runID), eq(WorkflowTaskTable.id, input.taskID))).get() const attempt = db.select().from(WorkflowTaskAttemptTable).where(eq(WorkflowTaskAttemptTable.id, input.attemptID)).get() + const run = db.select().from(WorkflowRunTable).where(eq(WorkflowRunTable.id, input.runID)).get() + if (!run) throw new WorkflowNotFoundError(input.runID) + if (run.state !== "queued" && run.state !== "working") + throw new WorkflowStateError(input.runID, `Workflow cannot start tasks while ${run.state}`) if (!task || !attempt) throw new WorkflowStateError(input.runID, `Task attempt ${input.attemptID} no longer exists`) if (attempt.state !== "queued") return false db.update(WorkflowTaskTable).set({ state: "working", time_started: task.time_started ?? now, time_updated: now }).where(eq(WorkflowTaskTable.id, input.taskID)).run() diff --git a/src/mendcode/packages/opencode/src/share/share-next.ts b/src/mendcode/packages/opencode/src/share/share-next.ts index 990922d8..a17e8ef1 100644 --- a/src/mendcode/packages/opencode/src/share/share-next.ts +++ b/src/mendcode/packages/opencode/src/share/share-next.ts @@ -7,6 +7,7 @@ import { InstanceState } from "@/effect/instance-state" import { Provider } from "@/provider/provider" import { ModelID, ProviderID } from "@/provider/schema" import { Session } from "@/session/session" +import { SessionTable } from "@/session/session.sql" import { MessageV2 } from "@/session/message-v2" import type { SessionID } from "@/session/schema" import { Database } from "@/storage/db" @@ -14,6 +15,7 @@ import { eq } from "drizzle-orm" import { Config } from "@/config/config" import * as Log from "@mendcode/core/util/log" import { SessionShareTable } from "./share.sql" +import { SyncEvent } from "@/sync" const log = Log.create({ service: "share-next" }) const disabled = process.env["OPENCODE_DISABLE_SHARE"] === "true" || process.env["OPENCODE_DISABLE_SHARE"] === "1" @@ -42,6 +44,7 @@ type State = { queue: Map> scope: Scope.Closeable shared: Map + refreshing: Set } type Data = @@ -116,6 +119,41 @@ export const layer = Layer.effect( const httpOk = HttpClient.filterStatusOk(http) const provider = yield* Provider.Service const session = yield* Session.Service + const syncEvents = Option.getOrUndefined(yield* Effect.serviceOption(SyncEvent.Service)) + + const setShareUrl = (sessionID: SessionID, url: string | null) => + syncEvents + ? syncEvents.run(Session.Event.Updated, { sessionID, info: { share: { url } } }) + : db((database) => + database + .update(SessionTable) + .set({ share_url: url, time_updated: Date.now() }) + .where(eq(SessionTable.id, sessionID)) + .run(), + ).pipe(Effect.asVoid) + + function refreshAfterRemoval(sessionID: SessionID) { + return Effect.gen(function* () { + const s = yield* InstanceState.get(state) + if (s.refreshing.has(sessionID)) return + s.refreshing.add(sessionID) + yield* Effect.gen(function* () { + yield* Effect.sleep(100) + const existing = yield* getCached(sessionID) + if (!existing) return + yield* remove(sessionID) + yield* setShareUrl(sessionID, null) + const replacement = yield* create(sessionID) + yield* setShareUrl(sessionID, replacement.url) + }).pipe( + Effect.catchCause((cause) => + Effect.sync(() => log.error("share refresh after content removal failed", { sessionID, cause })), + ), + Effect.ensuring(Effect.sync(() => s.refreshing.delete(sessionID))), + Effect.forkIn(s.scope), + ) + }) + } function sync(sessionID: SessionID, data: Data[]): Effect.Effect { return Effect.gen(function* () { @@ -148,7 +186,7 @@ export const layer = Layer.effect( const state: InstanceState.InstanceState = yield* InstanceState.make( Effect.fn("ShareNext.state")(function* (_ctx) { - const cache: State = { queue: new Map(), scope: yield* Scope.make(), shared: new Map() } + const cache: State = { queue: new Map(), scope: yield* Scope.make(), shared: new Map(), refreshing: new Set() } yield* Effect.addFinalizer(() => Scope.close(cache.scope, Exit.void).pipe( @@ -156,6 +194,7 @@ export const layer = Layer.effect( Effect.sync(() => { cache.queue.clear() cache.shared.clear() + cache.refreshing.clear() }), ), ), @@ -198,6 +237,8 @@ export const layer = Layer.effect( yield* watch(MessageV2.Event.PartUpdated, (evt) => sync(evt.properties.part.sessionID, [{ type: "part", data: evt.properties.part }]), ) + yield* watch(MessageV2.Event.Removed, (evt) => refreshAfterRemoval(evt.properties.sessionID)) + yield* watch(MessageV2.Event.PartRemoved, (evt) => refreshAfterRemoval(evt.properties.sessionID)) yield* watch(Session.Event.Diff, (evt) => sync(evt.properties.sessionID, [{ type: "session_diff", data: evt.properties.diff }]), ) @@ -371,6 +412,7 @@ export const defaultLayer = layer.pipe( Layer.provide(FetchHttpClient.layer), Layer.provide(Provider.defaultLayer), Layer.provide(Session.defaultLayer), + Layer.provide(SyncEvent.defaultLayer), ) export * as ShareNext from "./share-next" diff --git a/src/mendcode/packages/opencode/src/skill/discovery.ts b/src/mendcode/packages/opencode/src/skill/discovery.ts index 2baf3d38..7af4ae69 100644 --- a/src/mendcode/packages/opencode/src/skill/discovery.ts +++ b/src/mendcode/packages/opencode/src/skill/discovery.ts @@ -34,6 +34,22 @@ export const layer: Layer.Layer + name.length > 0 && + name !== "." && + name !== ".." && + !name.includes("/") && + !name.includes("\\") && + !/^[a-zA-Z]:/.test(name) + + const safeSkillFile = (file: string) => { + const normalized = file.replaceAll("\\", "/") + if (!normalized || normalized.startsWith("/") || /^[a-zA-Z]:/.test(normalized)) return false + return normalized.split("/").every((part) => part.length > 0 && part !== "." && part !== "..") + } + + const remotePath = (...segments: string[]) => segments.map(encodeURIComponent).join("/") + const download = Effect.fn("Discovery.download")(function* (url: string, dest: string) { if (yield* fs.exists(dest).pipe(Effect.orDie)) return true @@ -73,6 +89,10 @@ export const layer: Layer.Layer { + if (!safeSkillName(skill.name) || skill.files.some((file) => !safeSkillFile(file))) { + log.warn("skill entry contains an unsafe path", { url: index, skill: skill.name }) + return false + } if (!skill.files.includes("SKILL.md")) { log.warn("skill entry missing SKILL.md", { url: index, skill: skill.name }) return false @@ -88,7 +108,11 @@ export const layer: Layer.Layer download(new URL(file, `${host}/${skill.name}/`).href, path.join(root, file)), + (file) => + download( + new URL(remotePath(skill.name, ...file.replaceAll("\\", "/").split("/")), `${host}/`).href, + path.join(root, file), + ), { concurrency: fileConcurrency, }, diff --git a/src/mendcode/packages/opencode/src/storage/json-migration.ts b/src/mendcode/packages/opencode/src/storage/json-migration.ts index 3e7b9b39..74a7fa7a 100644 --- a/src/mendcode/packages/opencode/src/storage/json-migration.ts +++ b/src/mendcode/packages/opencode/src/storage/json-migration.ts @@ -24,6 +24,10 @@ export async function writeJsonStorageMigrationDoneMarker() { await Filesystem.write(jsonStorageMigrationDonePath(), "") } +export function jsonStorageMigrationSucceeded(stats: { readonly errors: readonly string[] }) { + return stats.errors.length === 0 +} + export type Progress = { current: number total: number diff --git a/src/mendcode/packages/opencode/src/tool/peers.ts b/src/mendcode/packages/opencode/src/tool/peers.ts index b6324abf..72a6b69d 100644 --- a/src/mendcode/packages/opencode/src/tool/peers.ts +++ b/src/mendcode/packages/opencode/src/tool/peers.ts @@ -5,16 +5,16 @@ import { Session } from "@/session/session" import { SessionStatus } from "@/session/status" import { NonNegativeInt } from "@/util/schema" -const MAX_PEERS = 32 +const MAX_SESSIONS = 32 const MAX_ACTIVITY_LENGTH = 120 export const Parameters = Schema.Struct({ limit: Schema.optional(NonNegativeInt).annotate({ - description: "Maximum number of peer descriptors to return. The server caps this at 32.", + description: "Maximum number of session descriptors to return. The server caps this at 32.", }), }) -export type PeerDescriptor = { +export type SessionDescriptor = { sessionID: string title: string state: string @@ -38,13 +38,13 @@ function statusDescriptor(status: SessionStatus.Info) { } } -export function peerDescriptors(input: { +export function sessionDescriptors(input: { sessions: readonly Session.Info[] statuses: ReadonlyMap currentSessionID: string limit?: number -}): PeerDescriptor[] { - const limit = Math.min(MAX_PEERS, Math.max(1, Math.floor(input.limit ?? MAX_PEERS))) +}): SessionDescriptor[] { + const limit = Math.min(MAX_SESSIONS, Math.max(1, Math.floor(input.limit ?? MAX_SESSIONS))) return input.sessions .filter((session) => session.id !== input.currentSessionID) .toSorted((a, b) => b.time.updated - a.time.updated || a.id.localeCompare(b.id)) @@ -61,8 +61,8 @@ export function peerDescriptors(input: { }) } -export const PeersTool = Tool.define( - "peers", +export const SessionsTool = Tool.define( + "sessions", Effect.gen(function* () { const sessions = yield* Session.Service const statuses = yield* SessionStatus.Service @@ -71,33 +71,33 @@ export const PeersTool = Tool.define, ctx: Tool.Context) => Effect.gen(function* () { - const [items, statusMap] = yield* Effect.all([sessions.list({ limit: MAX_PEERS + 1 }), statuses.list()]) - const peers = peerDescriptors({ + const [items, statusMap] = yield* Effect.all([sessions.list({ limit: MAX_SESSIONS + 1 }), statuses.list()]) + const otherSessions = sessionDescriptors({ sessions: items, statuses: statusMap, currentSessionID: ctx.sessionID, limit: params.limit, }) - const output = peers.length - ? peers - .map((peer) => + const output = otherSessions.length + ? otherSessions + .map((session) => [ - `sessionID=${peer.sessionID}`, - `title=${JSON.stringify(peer.title)}`, - `state=${peer.state}`, - peer.activity ? `activity=${JSON.stringify(peer.activity)}` : undefined, - peer.workspaceID ? `workspaceID=${peer.workspaceID}` : undefined, - `updatedAt=${peer.updatedAt}`, + `sessionID=${session.sessionID}`, + `title=${JSON.stringify(session.title)}`, + `state=${session.state}`, + session.activity ? `activity=${JSON.stringify(session.activity)}` : undefined, + session.workspaceID ? `workspaceID=${session.workspaceID}` : undefined, + `updatedAt=${session.updatedAt}`, ] .filter(Boolean) .join(" "), ) .join("\n") - : "No other peer sessions are available in this project." + : "No other sessions are available in this project." return { - title: `Peers (${peers.length})`, + title: `Sessions (${otherSessions.length})`, output, - metadata: { count: peers.length }, + metadata: { count: otherSessions.length }, } }), } diff --git a/src/mendcode/packages/opencode/src/tool/peers.txt b/src/mendcode/packages/opencode/src/tool/peers.txt index 7690a86c..3ad9a85f 100644 --- a/src/mendcode/packages/opencode/src/tool/peers.txt +++ b/src/mendcode/packages/opencode/src/tool/peers.txt @@ -1,3 +1,3 @@ -List the other active MendCode sessions in the current project as bounded peer descriptors. +List the other active MendCode sessions in the current project as bounded session descriptors. The result contains only explicit session IDs, titles, lifecycle state, short activity, workspace identity, and update time. It never reads or exposes transcript text, files, reasoning, permissions, secrets, or provider context. Use the exact session ID from this list with the tell tool. diff --git a/src/mendcode/packages/opencode/src/tool/registry.ts b/src/mendcode/packages/opencode/src/tool/registry.ts index 8f7ab772..efdce857 100644 --- a/src/mendcode/packages/opencode/src/tool/registry.ts +++ b/src/mendcode/packages/opencode/src/tool/registry.ts @@ -64,7 +64,7 @@ import { BackgroundTask } from "@/session/background-task" import { Auth } from "@/auth" import { AgentCommand } from "@/session/agent-command" import { TellTool } from "./tell" -import { PeersTool } from "./peers" +import { SessionsTool } from "./peers" const log = Log.create({ service: "tool.registry" }) @@ -148,7 +148,7 @@ export const layer: Layer.Layer< const workflowtool = yield* WorkflowTool const reviewtool = yield* ReviewTool const telltool = yield* TellTool - const peerstool = yield* PeersTool + const sessionstool = yield* SessionsTool const memorytool = yield* MemoryTool const memorygraphtool = yield* MemoryGraphTool @@ -250,7 +250,7 @@ export const layer: Layer.Layer< loop: Tool.init(looptool), workflow: Tool.init(workflowtool), review: Tool.init(reviewtool), - peers: Tool.init(peerstool), + sessions: Tool.init(sessionstool), tell: Tool.init(telltool), memory: Tool.init(memorytool), memoryGraph: Tool.init(memorygraphtool), @@ -282,7 +282,7 @@ export const layer: Layer.Layer< tool.loop, tool.workflow, tool.review, - tool.peers, + tool.sessions, tool.tell, tool.memory, tool.memoryGraph, diff --git a/src/mendcode/packages/opencode/src/tool/tell.ts b/src/mendcode/packages/opencode/src/tool/tell.ts index b7bb9d9e..f9419bf6 100644 --- a/src/mendcode/packages/opencode/src/tool/tell.ts +++ b/src/mendcode/packages/opencode/src/tool/tell.ts @@ -42,13 +42,15 @@ export const TellTool = Tool.define { - const parsed = JSON.parse(evt.data) - if (parsed.type === "rpc.request") { - const result = await rpc[parsed.method](parsed.input) + let parsed: { type?: string; method?: string; input?: unknown; id?: number } + try { + parsed = JSON.parse(evt.data) + } catch { + return + } + if (parsed.type !== "rpc.request" || typeof parsed.id !== "number") return + try { + const method = parsed.method && rpc[parsed.method] + if (!method) throw new Error(`Unknown RPC method: ${parsed.method ?? ""}`) + const result = await method(parsed.input) postMessage(JSON.stringify({ type: "rpc.result", result, id: parsed.id })) + } catch (error) { + postMessage( + JSON.stringify({ + type: "rpc.error", + error: error instanceof Error ? error.message : String(error), + id: parsed.id, + }), + ) } } } @@ -19,21 +35,39 @@ export function emit(event: string, data: unknown) { export function client(target: { postMessage: (data: string) => void | null onmessage: ((this: Worker, ev: MessageEvent) => any) | null + addEventListener?: (type: string, listener: (event: Event | ErrorEvent) => void) => void }) { - const pending = new Map void>() + const pending = new Map void; reject: (error: Error) => void }>() const listeners = new Map void>>() let id = 0 + const rejectAll = (error: Error) => { + for (const request of pending.values()) request.reject(error) + pending.clear() + } target.onmessage = async (evt) => { - const parsed = JSON.parse(evt.data) + let parsed: { type?: string; result?: unknown; error?: unknown; id?: number; event?: string; data?: unknown } + try { + parsed = JSON.parse(evt.data) + } catch { + rejectAll(new Error("RPC worker returned a malformed response")) + return + } if (parsed.type === "rpc.result") { - const resolve = pending.get(parsed.id) - if (resolve) { - resolve(parsed.result) - pending.delete(parsed.id) + const request = pending.get(parsed.id!) + if (request) { + request.resolve(parsed.result) + pending.delete(parsed.id!) + } + } + if (parsed.type === "rpc.error") { + const request = pending.get(parsed.id!) + if (request) { + request.reject(new Error(typeof parsed.error === "string" ? parsed.error : "RPC worker request failed")) + pending.delete(parsed.id!) } } if (parsed.type === "rpc.event") { - const handlers = listeners.get(parsed.event) + const handlers = listeners.get(parsed.event!) if (handlers) { for (const handler of handlers) { handler(parsed.data) @@ -41,12 +75,22 @@ export function client(target: { } } } + target.addEventListener?.("error", (event) => { + const message = event instanceof ErrorEvent && event.message ? event.message : "RPC worker failed" + rejectAll(new Error(message)) + }) + target.addEventListener?.("messageerror", () => rejectAll(new Error("RPC worker message could not be decoded"))) return { call(method: Method, input: Parameters[0]): Promise> { const requestId = id++ - return new Promise((resolve) => { - pending.set(requestId, resolve) - target.postMessage(JSON.stringify({ type: "rpc.request", method, input, id: requestId })) + return new Promise((resolve, reject) => { + pending.set(requestId, { resolve, reject }) + try { + target.postMessage(JSON.stringify({ type: "rpc.request", method, input, id: requestId })) + } catch (error) { + pending.delete(requestId) + reject(error) + } }) }, on(event: string, handler: (data: Data) => void) { @@ -60,6 +104,10 @@ export function client(target: { handlers!.delete(handler) } }, + close(error = new Error("RPC worker terminated")) { + rejectAll(error) + listeners.clear() + }, } } diff --git a/src/mendcode/packages/opencode/src/v2/session.ts b/src/mendcode/packages/opencode/src/v2/session.ts index 102c353e..05c7f73a 100644 --- a/src/mendcode/packages/opencode/src/v2/session.ts +++ b/src/mendcode/packages/opencode/src/v2/session.ts @@ -12,6 +12,14 @@ import { SessionEvent } from "./session-event" import { V2Schema } from "./schema" import { optionalOmitUndefined } from "@/util/schema" import { Modelv2 } from "./model" +import { Session as LegacySession } from "@/session/session" +import { SessionPrompt } from "@/session/prompt" +import { SessionCompaction } from "@/session/compaction" +import { SessionStatus } from "@/session/status" +import { MessageV2 } from "@/session/message-v2" +import { Agent } from "@/agent/agent" +import { Provider } from "@/provider/provider" +import { ModelID, ProviderID } from "@/provider/schema" export const Delivery = Schema.Literals(["immediate", "deferred"]).annotate({ identifier: "Session.Delivery", @@ -119,6 +127,12 @@ export class Service extends Context.Service()("@opencode/v2 export const layer = Layer.effect( Service, Effect.gen(function* () { + const legacySession = yield* LegacySession.Service + const legacyPrompt = yield* SessionPrompt.Service + const legacyCompaction = yield* SessionCompaction.Service + const legacyStatus = yield* SessionStatus.Service + const agents = yield* Agent.Service + const providers = yield* Provider.Service const decodeMessage = Schema.decodeUnknownSync(SessionMessage.Message) const decode = (row: typeof SessionMessageTable.$inferSelect) => @@ -321,8 +335,20 @@ export const layer = Layer.effect( } const result: Interface = { - create: Effect.fn("V2Session.create")(function* (_input) { - return {} as any + create: Effect.fn("V2Session.create")(function* (input) { + const created = yield* legacySession.create({ + agent: input?.agent, + parentID: input?.parentID, + workspaceID: input?.workspaceID, + model: input?.model + ? { + providerID: ProviderID.make(input.model.providerID), + id: ModelID.make(input.model.id), + variant: input.model.variant, + } + : undefined, + }) + return yield* result.get(created.id).pipe(Effect.orDie) }), get: Effect.fn("V2Session.get")(function* (sessionID) { const row = Database.use((db) => db.select().from(SessionTable).where(eq(SessionTable.id, sessionID)).get()) @@ -441,8 +467,61 @@ export const layer = Layer.effect( }) return rows.map((row) => decode(row)) }), - prompt: Effect.fn("V2Session.prompt")(function* (_input) { - return {} as any + prompt: Effect.fn("V2Session.prompt")(function* (input) { + const current = yield* legacySession.get(input.sessionID).pipe(Effect.orDie) + const agent = current.agent ?? (yield* agents.defaultAgent()) + const model = current.model + ? { providerID: current.model.providerID, modelID: current.model.id } + : yield* providers.defaultModel() + const request = { + sessionID: input.sessionID, + agent, + model, + parts: [ + { type: "text" as const, text: input.prompt.text }, + ...(input.prompt.files ?? []).map((file) => ({ + type: "file" as const, + mime: file.mime, + filename: file.name, + url: file.uri, + })), + ...(input.prompt.agents ?? []).map((attachment) => ({ + type: "agent" as const, + name: attachment.name, + source: attachment.source + ? { + value: attachment.source.text, + start: attachment.source.start, + end: attachment.source.end, + } + : undefined, + })), + ], + } + const message = yield* ((input.delivery ?? DefaultDelivery) === "deferred" + ? legacyPrompt.promptAsync(request) + : legacyPrompt.prompt(request)).pipe(Effect.orDie) + const responseInfo = message.info + const legacyUser = + responseInfo.role === "user" + ? message + : Option.getOrUndefined( + yield* legacySession.findMessage( + input.sessionID, + (candidate) => candidate.info.role === "user" && candidate.info.id === responseInfo.parentID, + ), + ) + if (!legacyUser || legacyUser.info.role !== "user") + return yield* Effect.die(new Error("V2 prompt completed without its corresponding user turn")) + const projected = yield* result.messages({ sessionID: input.sessionID, order: "desc" }) + const user = projected.find( + (candidate): candidate is SessionMessage.User => + candidate.type === "user" && + DateTime.toEpochMillis(candidate.time.created) === legacyUser.info.time.created && + candidate.text === input.prompt.text, + ) + if (!user) return yield* Effect.die(new Error("V2 prompt was accepted but its user event was not projected")) + return user }), shell: Effect.fn("V2Session.shell")(function* (_input) {}), skill: Effect.fn("V2Session.skill")(function* (_input) {}), @@ -481,14 +560,34 @@ export const layer = Layer.effect( if (!text) return }).pipe(Effect.forkChild()) }), - compact: Effect.fn("V2Session.compact")(function* (_sessionID) {}), - wait: Effect.fn("V2Session.wait")(function* (_sessionID) {}), + compact: Effect.fn("V2Session.compact")(function* (sessionID) { + const current = yield* legacySession.get(sessionID).pipe(Effect.orDie) + const defaultAgent = yield* agents.defaultAgent() + const agent = MessageV2.latestUserInfo(sessionID)?.agent ?? current.agent ?? defaultAgent + const model = current.model + ? { providerID: current.model.providerID, modelID: current.model.id } + : yield* providers.defaultModel() + yield* legacyCompaction.create({ sessionID, agent, model, auto: false }).pipe(Effect.orDie) + yield* legacyPrompt.loop({ sessionID }) + }), + wait: Effect.fn("V2Session.wait")(function* (sessionID) { + while ((yield* legacyStatus.get(sessionID)).type !== "idle") yield* Effect.sleep(50) + }), } return Service.of(result) }), ) -export const defaultLayer = layer +export const defaultLayer = Layer.suspend(() => + layer.pipe( + Layer.provide(SessionPrompt.defaultLayer), + Layer.provide(SessionCompaction.defaultLayer), + Layer.provide(SessionStatus.defaultLayer), + Layer.provide(LegacySession.defaultLayer), + Layer.provide(Agent.defaultLayer), + Layer.provide(Provider.defaultLayer), + ), +) export * as SessionV2 from "./session" diff --git a/src/mendcode/packages/opencode/test/agent/agent.test.ts b/src/mendcode/packages/opencode/test/agent/agent.test.ts index 35da56c7..5c584f82 100644 --- a/src/mendcode/packages/opencode/test/agent/agent.test.ts +++ b/src/mendcode/packages/opencode/test/agent/agent.test.ts @@ -697,6 +697,28 @@ test("defaultAgent respects default_agent config set to plan", async () => { }) }) +test("defaultAgent returns the configured key when its display name is customized", async () => { + await using tmp = await tmpdir({ + config: { + default_agent: "build", + agent: { + build: { name: "Coder" }, + }, + }, + }) + await WithInstance.provide({ + directory: tmp.path, + fn: async () => { + const key = await load(tmp.path, (svc) => svc.defaultAgent()) + const agent = await load(tmp.path, (svc) => svc.get(key)) + const list = await load(tmp.path, (svc) => svc.list()) + expect(key).toBe("build") + expect(agent?.name).toBe("Coder") + expect(list[0]?.name).toBe("Coder") + }, + }) +}) + test("defaultAgent respects default_agent config set to custom agent with mode all", async () => { await using tmp = await tmpdir({ config: { diff --git a/src/mendcode/packages/opencode/test/auth/auth.test.ts b/src/mendcode/packages/opencode/test/auth/auth.test.ts index f24dfc91..0ba56817 100644 --- a/src/mendcode/packages/opencode/test/auth/auth.test.ts +++ b/src/mendcode/packages/opencode/test/auth/auth.test.ts @@ -83,4 +83,27 @@ describe("Auth", () => { }), ), ) + + it.live("serializes concurrent credential updates without losing entries", () => + provideTmpdirInstance(() => + Effect.gen(function* () { + const auth = yield* Auth.Service + yield* Effect.all( + Array.from({ length: 24 }, (_, index) => + auth.set(`provider-${index}`, { + type: "api", + key: `key-${index}`, + }), + ), + { concurrency: "unbounded" }, + ) + const data = yield* auth.all() + for (let index = 0; index < 24; index++) { + const entry = data[`provider-${index}`] + expect(entry?.type).toBe("api") + if (entry?.type === "api") expect(entry.key).toBe(`key-${index}`) + } + }), + ), + ) }) diff --git a/src/mendcode/packages/opencode/test/cli/tui/agent-view.test.ts b/src/mendcode/packages/opencode/test/cli/tui/agent-view.test.ts index e86acbcf..b872e718 100644 --- a/src/mendcode/packages/opencode/test/cli/tui/agent-view.test.ts +++ b/src/mendcode/packages/opencode/test/cli/tui/agent-view.test.ts @@ -2,6 +2,8 @@ import { describe, expect, test } from "bun:test" import { formatAgentViewDetailLabel, formatAgentViewCommandSummary, + formatAgentViewCommandState, + formatAgentViewCommandType, formatAgentViewPathLabel, formatAgentViewSessionTime, countAgentViewCommands, @@ -343,7 +345,14 @@ describe("Agent View visibility", () => { ).toBe("pending · send message · Ship next safe step") expect( formatAgentViewCommandSummary(command({ type: "peer_message", payload: { text: "Check the queued run" } })), - ).toBe("pending · peer message · Check the queued run") + ).toBe("queued · session message · Check the queued run") + expect(formatAgentViewCommandType(command({ type: "peer_message" }))).toBe("session message") + expect(formatAgentViewCommandState(command({ type: "peer_message", state: "pending" }))).toBe("queued") + expect(formatAgentViewCommandState(command({ type: "peer_message", state: "accepted" }))).toBe("queued") + expect(formatAgentViewCommandState(command({ type: "peer_message", state: "running" }))).toBe( + "awaiting response", + ) + expect(formatAgentViewCommandState(command({ type: "peer_message", state: "completed" }))).toBe("responded") expect(formatAgentViewCommandSummary(command({ type: "stop", payload: { reason: "handoff" } }))).toBe( "pending · stop worker · handoff", ) diff --git a/src/mendcode/packages/opencode/test/cli/tui/prompt-working-start.test.ts b/src/mendcode/packages/opencode/test/cli/tui/prompt-working-start.test.ts index bd42e5a4..e9642485 100644 --- a/src/mendcode/packages/opencode/test/cli/tui/prompt-working-start.test.ts +++ b/src/mendcode/packages/opencode/test/cli/tui/prompt-working-start.test.ts @@ -20,6 +20,7 @@ import { promptDeliveryRetryAction, promptDeliveryRetryDelay, promptRecoveryReady, + runPromptDeliveryInSessionOrder, shouldRetryConnectionForPrompt, shouldRecoverAcceptedPromptDeliveries, acceptedPromptRecoveryDecision, @@ -38,6 +39,7 @@ import { shouldClearWorkingStartedAt, shouldKeepWorkingStatus, shouldEnableSessionInterrupt, + shouldClearSessionInterruptRequest, shouldHandlePromptCursorArrow, hasInterruptibleSessionTurn, sessionInterruptConfirmationAction, @@ -256,6 +258,29 @@ describe("prompt delivery recovery", () => { expect(promptDeliveryErrorMessage(undefined)).toBe("The server rejected this prompt.") }) + test("dispatches prompt requests for one session in submission order", async () => { + const order: string[] = [] + let releaseFirst = () => {} + let markFirstStarted = () => {} + const firstStarted = new Promise((resolve) => (markFirstStarted = resolve)) + const firstGate = new Promise((resolve) => (releaseFirst = resolve)) + const first = runPromptDeliveryInSessionOrder("session-ordered-delivery", async () => { + order.push("first:start") + markFirstStarted() + await firstGate + order.push("first:end") + }) + const second = runPromptDeliveryInSessionOrder("session-ordered-delivery", async () => { + order.push("second:start") + }) + + await firstStarted + expect(order).toEqual(["first:start"]) + releaseFirst() + await Promise.all([first, second]) + expect(order).toEqual(["first:start", "first:end", "second:start"]) + }) + test("keeps an accepted delivery queued only when it was submitted behind an active turn", () => { expect(promptDeliveryIsQueued("pending")).toBe(true) expect(promptDeliveryIsQueued("accepted")).toBe(false) @@ -979,32 +1004,59 @@ describe("resolveWorkingStartedAt", () => { ) }) - test("requires two Esc presses for the same active turn", () => { - expect(sessionInterruptConfirmationAction({ activeTargetMessageID: "message-1" })).toBe("arm") + test("keeps double-Esc armed across internal target changes in the same active session", () => { + expect(sessionInterruptConfirmationAction({ sessionID: "session-1", hasActiveTurn: true })).toBe("arm") expect( sessionInterruptConfirmationAction({ - activeTargetMessageID: "message-1", - armedTargetMessageID: "message-1", + sessionID: "session-1", + armedSessionID: "session-1", + hasActiveTurn: true, }), ).toBe("interrupt") expect( sessionInterruptConfirmationAction({ - activeTargetMessageID: "message-2", - armedTargetMessageID: "message-1", + sessionID: "session-2", + armedSessionID: "session-1", + hasActiveTurn: true, }), ).toBe("arm") - expect(sessionInterruptConfirmationAction({ armedTargetMessageID: "message-1" })).toBe("ignore") + expect(sessionInterruptConfirmationAction({ armedSessionID: "session-1", hasActiveTurn: true })).toBe("ignore") + expect(sessionInterruptConfirmationAction({ sessionID: "session-1", hasActiveTurn: false })).toBe("ignore") expect(sessionInterruptHint({ enabled: true, working: true, armed: false })).toBeUndefined() expect(sessionInterruptHint({ enabled: true, working: true, armed: true })).toBe("[esc again to interrupt]") expect(sessionInterruptHint({ enabled: false, working: true, armed: true })).toBeUndefined() expect(sessionInterruptHint({ enabled: true, working: false, armed: true })).toBeUndefined() - expect(sessionInterruptConfirmationAction({ hasActiveTurn: true })).toBe("arm") + }) + + test("re-arms Esc after cancellation confirmation or turn promotion", () => { + const base = { + requestTargetMessageID: "message-1", + activeTargetMessageID: "message-1", + targetTerminal: false, + sessionSettled: false, + controlState: "stop_requested", + } + expect(shouldClearSessionInterruptRequest(base)).toBe(false) expect( - sessionInterruptConfirmationAction({ - hasActiveTurn: true, - armedTargetMessageID: "__mendcode_session_interrupt__", + shouldClearSessionInterruptRequest({ + ...base, + controlState: "stop_confirmed", + controlResult: "cancelled", }), - ).toBe("interrupt") + ).toBe(true) + expect( + shouldClearSessionInterruptRequest({ + ...base, + activeTargetMessageID: "message-2", + }), + ).toBe(true) + expect( + shouldClearSessionInterruptRequest({ + ...base, + controlState: "stop_confirmed", + controlResult: "target_mismatch", + }), + ).toBe(false) }) test("keeps the emergency Esc path for an unfinished legacy assistant receipt", () => { diff --git a/src/mendcode/packages/opencode/test/cli/tui/session-initialization.test.ts b/src/mendcode/packages/opencode/test/cli/tui/session-initialization.test.ts index c5f6cf83..1938109c 100644 --- a/src/mendcode/packages/opencode/test/cli/tui/session-initialization.test.ts +++ b/src/mendcode/packages/opencode/test/cli/tui/session-initialization.test.ts @@ -141,7 +141,9 @@ describe("session route initialization", () => { expect(frontend).not.toContain('return "turn already finished"') expect(frontend).not.toContain('return "stop reconciled;') expect(frontend).toContain("const control = sessionControl.status(sessionID)") - expect(frontend).toContain('control.state === "stop_confirmed" && control.result !== "cancelled"') + expect(frontend).toContain("const confirmed =") + expect(frontend).toContain('input.controlState === "stop_confirmed"') + expect(frontend).toContain('input.controlResult === "cancelled"') expect(backend).toContain('const cancelTurn = Effect.fn("SessionPrompt.cancelTurn")') expect(backend).toContain('yield* elog.info("cancel-turn", { ...input, important: true })') }) @@ -308,7 +310,9 @@ describe("session route initialization", () => { expect(source).toContain('return "Waiting for the current response to finish"') expect(source).toContain("const transcriptRows = createMemo") expect(source).toContain("return sessionTranscriptRows(messages(), queuedMessageIDs(), {") - expect(source).toContain('return messages().filter((message): message is UserMessage => message.role === "user"') + expect(source).toContain( + 'filter((message): message is UserMessage => message.role === "user" && queuedIDs.has(message.id))', + ) expect(source).toContain("tailIDs: pendingDeliveryTailIDs()") expect(source).toContain("const pendingDeliveryQueuedIDs = createMemo") expect(source).toContain("pendingPromptDeliveryMessageIDs(route.sessionID)") diff --git a/src/mendcode/packages/opencode/test/config/config.test.ts b/src/mendcode/packages/opencode/test/config/config.test.ts index 7eb7b0d1..ab3642d8 100644 --- a/src/mendcode/packages/opencode/test/config/config.test.ts +++ b/src/mendcode/packages/opencode/test/config/config.test.ts @@ -867,6 +867,24 @@ Test agent prompt`, }) }) +test("loads legacy opencode.json from the .opencode directory", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + const legacyDir = path.join(dir, ".opencode") + await fs.mkdir(legacyDir, { recursive: true }) + await Filesystem.write(path.join(legacyDir, "opencode.json"), JSON.stringify({ snapshot: true })) + }, + }) + await WithInstance.provide({ + directory: tmp.path, + fn: async () => { + const config = await load() + expect(config.snapshot).toBe(true) + expect(await listDirs()).toContain(path.join(tmp.path, ".opencode")) + }, + }) +}) + test("agent markdown permission config preserves user key order", async () => { await using tmp = await tmpdir({ init: async (dir) => { diff --git a/src/mendcode/packages/opencode/test/control-plane/workspace.test.ts b/src/mendcode/packages/opencode/test/control-plane/workspace.test.ts index 71bea58c..0fa01220 100644 --- a/src/mendcode/packages/opencode/test/control-plane/workspace.test.ts +++ b/src/mendcode/packages/opencode/test/control-plane/workspace.test.ts @@ -668,6 +668,72 @@ describe("workspace-old CRUD", () => { }) }) + test("sessionWarp keeps the current workspace and owner when the destination is missing", async () => { + await withInstance(async (dir) => { + const previousType = unique("warp-missing-prev") + const previous = workspaceInfo(Instance.project.id, previousType) + insertWorkspace(previous) + registerAdapter(Instance.project.id, previousType, localAdapter(path.join(dir, "warp-missing-prev")).adapter) + const session = await AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.create({}))) + attachSessionToWorkspace(session.id, previous.id) + SyncEvent.claim(session.id, previous.id) + + await expect( + warpWorkspaceSession({ workspaceID: WorkspaceID.ascending("wrk_missing_destination"), sessionID: session.id }), + ).rejects.toThrow("Workspace not found") + + expect( + Database.use((db) => + db + .select({ workspaceID: SessionTable.workspace_id }) + .from(SessionTable) + .where(eq(SessionTable.id, session.id)) + .get(), + )?.workspaceID, + ).toBe(previous.id) + expect(sessionSequenceOwner(session.id)).toBe(previous.id) + }) + }) + + it.live("sessionWarp keeps the current owner when remote replay fails", () => + Effect.gen(function* () { + yield* HttpServer.serveEffect()( + Effect.gen(function* () { + const req = yield* HttpServerRequest.HttpServerRequest + if (new URL(req.url, "http://localhost").pathname === "/warp-fail/sync/replay") + return HttpServerResponse.text("replay failed", { status: 503 }) + return HttpServerResponse.text("unexpected", { status: 500 }) + }), + ) + const url = yield* serverUrl() + yield* provideTmpdirInstance( + (dir) => + Effect.gen(function* () { + const workspace = yield* WorkspaceOld.Service + const sessions = yield* SessionNs.Service + const previousType = unique("warp-fail-prev") + const targetType = unique("warp-fail-target") + const previous = workspaceInfo(Instance.project.id, previousType) + const target = workspaceInfo(Instance.project.id, targetType, { directory: "remote-target" }) + insertWorkspace(previous) + insertWorkspace(target) + registerAdapter(Instance.project.id, previousType, localAdapter(path.join(dir, "warp-fail-prev")).adapter) + registerAdapter(Instance.project.id, targetType, remoteAdapter(`${url}/warp-fail`).adapter) + const session = yield* sessions.create({}) + attachSessionToWorkspace(session.id, previous.id) + SyncEvent.claim(session.id, previous.id) + + const result = yield* Effect.exit(workspace.sessionWarp({ workspaceID: target.id, sessionID: session.id })) + + expect(result._tag).toBe("Failure") + expect(sessionSequenceOwner(session.id)).toBe(previous.id) + expect((yield* sessions.get(session.id)).workspaceID).toBe(previous.id) + }), + { git: true }, + ) + }), + ) + it.live("sessionWarp syncs previous remote history, replays it, steals, and claims the sequence", () => { const calls: FetchCall[] = [] let historySessionID: SessionID | undefined diff --git a/src/mendcode/packages/opencode/test/effect/runner.test.ts b/src/mendcode/packages/opencode/test/effect/runner.test.ts index db10221c..5212d158 100644 --- a/src/mendcode/packages/opencode/test/effect/runner.test.ts +++ b/src/mendcode/packages/opencode/test/effect/runner.test.ts @@ -445,6 +445,53 @@ describe("Runner", () => { }), ) + it.live( + "interrupt promotes queued work while the old finalizer is still unwinding", + Effect.gen(function* () { + const s = yield* Scope.Scope + const started = yield* Deferred.make() + const release = yield* Deferred.make() + const queuedStarted = yield* Deferred.make() + const runner = Runner.make(s, { onInterrupt: Effect.succeed("fallback") }) + + const active = yield* runner + .ensureRunning( + Effect.gen(function* () { + yield* Deferred.succeed(started, undefined) + return yield* Effect.never.pipe(Effect.ensuring(Deferred.await(release)), Effect.as("active")) + }), + ) + .pipe(Effect.forkChild) + try { + yield* Deferred.await(started) + const queued = yield* runner + .ensureRunning( + Effect.gen(function* () { + yield* Deferred.succeed(queuedStarted, undefined) + return "queued" + }), + { queue: true }, + ) + .pipe(Effect.forkChild) + yield* Effect.gen(function* () { + while (runner.state._tag !== "RunningThenRun") yield* Effect.yieldNow + }).pipe(Effect.timeout("250 millis")) + + expect(yield* runner.interruptQueued().pipe(Effect.timeout("1 second"))).toBe(true) + yield* Deferred.await(queuedStarted).pipe(Effect.timeout("250 millis")) + expect(yield* Fiber.join(active)).toBe("fallback") + expect(yield* Fiber.join(queued)).toBe("queued") + expect(runner.busy).toBe(false) + + yield* Deferred.succeed(release, undefined) + yield* Effect.sleep("10 millis") + expect(runner.busy).toBe(false) + } finally { + yield* Deferred.succeed(release, undefined).pipe(Effect.ignore) + } + }), + ) + it.live( "duplicate interrupts remain idempotent after promotion", Effect.gen(function* () { @@ -666,6 +713,88 @@ describe("Runner", () => { }), ) + it.live( + "cancelCurrentIf promotes queued work while the old finalizer is still unwinding", + Effect.gen(function* () { + const s = yield* Scope.Scope + const started = yield* Deferred.make() + const release = yield* Deferred.make() + const queuedStarted = yield* Deferred.make() + const runner = Runner.make(s, { onInterrupt: Effect.succeed("cancelled") }) + const active = yield* runner + .ensureRunning( + Effect.gen(function* () { + yield* Deferred.succeed(started, undefined) + return yield* Effect.never.pipe(Effect.ensuring(Deferred.await(release)), Effect.as("active")) + }), + { queueKey: "turn-active" }, + ) + .pipe(Effect.forkChild) + + try { + yield* Deferred.await(started) + const queued = yield* runner + .ensureRunning( + Effect.gen(function* () { + yield* Deferred.succeed(queuedStarted, undefined) + return "queued" + }), + { queue: true, queueKey: "turn-queued" }, + ) + .pipe(Effect.forkChild) + yield* Effect.gen(function* () { + while (runner.state._tag !== "RunningThenRun") yield* Effect.yieldNow + }).pipe(Effect.timeout("250 millis")) + + expect(yield* runner.cancelCurrentIf("turn-active").pipe(Effect.timeout("1 second"))).toBe("cancelled") + yield* Deferred.await(queuedStarted).pipe(Effect.timeout("250 millis")) + expect(yield* Fiber.join(active)).toBe("cancelled") + expect(yield* Fiber.join(queued)).toBe("queued") + expect(runner.busy).toBe(false) + } finally { + yield* Deferred.succeed(release, undefined).pipe(Effect.ignore) + } + }), + ) + + it.live( + "hard cancel returns while finalizers unwind and discards queued work", + Effect.gen(function* () { + const s = yield* Scope.Scope + const started = yield* Deferred.make() + const release = yield* Deferred.make() + const runner = Runner.make(s) + const active = yield* runner + .ensureRunning( + Effect.gen(function* () { + yield* Deferred.succeed(started, undefined) + return yield* Effect.never.pipe(Effect.ensuring(Deferred.await(release)), Effect.as("active")) + }), + { queueKey: "turn-active" }, + ) + .pipe(Effect.forkChild) + + try { + yield* Deferred.await(started) + const queued = yield* runner + .ensureRunning(Effect.succeed("queued"), { queue: true, queueKey: "turn-queued" }) + .pipe(Effect.exit, Effect.forkChild) + yield* Effect.gen(function* () { + while (runner.state._tag !== "RunningThenRun") yield* Effect.yieldNow + }).pipe(Effect.timeout("250 millis")) + + yield* runner.cancelCurrent({ cancelPending: true }).pipe(Effect.timeout("1 second")) + expect(runner.busy).toBe(false) + expect(Exit.isFailure(yield* Fiber.join(queued).pipe(Effect.timeout("250 millis")))).toBe(true) + + yield* Deferred.succeed(release, undefined) + expect(Exit.isFailure(yield* Fiber.await(active).pipe(Effect.timeout("250 millis")))).toBe(true) + } finally { + yield* Deferred.succeed(release, undefined).pipe(Effect.ignore) + } + }), + ) + it.live( "work can be started after cancel", Effect.gen(function* () { diff --git a/src/mendcode/packages/opencode/test/installation/launcher-package.test.ts b/src/mendcode/packages/opencode/test/installation/launcher-package.test.ts new file mode 100644 index 00000000..e51eaedd --- /dev/null +++ b/src/mendcode/packages/opencode/test/installation/launcher-package.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, test } from "bun:test" +import { readFile } from "fs/promises" +import path from "path" + +const root = path.join(import.meta.dirname, "../..") + +describe("published MendCode launcher", () => { + test("uses the MendCode platform package and binary names emitted by the build", async () => { + const [launcher, postinstall, build] = await Promise.all([ + readFile(path.join(root, "bin/opencode"), "utf8"), + readFile(path.join(root, "script/postinstall.mjs"), "utf8"), + readFile(path.join(root, "script/build.ts"), "utf8"), + ]) + + expect(build).toContain('const binaryName = "mendcode"') + expect(launcher).toContain('const base = "mendcode-" + platform + "-" + arch') + expect(launcher).toContain('const binary = platform === "windows" ? "mendcode.exe" : "mendcode"') + expect(postinstall).toContain("const packageName = `mendcode-${platform}-${arch}`") + expect(postinstall).toContain('const binaryName = platform === "windows" ? "mendcode.exe" : "mendcode"') + }) + + test("honors the MendCode binary override", () => { + const result = Bun.spawnSync({ + cmd: [process.execPath, path.join(root, "bin/opencode"), "-e", 'process.stdout.write("override-ok")'], + env: { ...process.env, MENDCODE_BIN_PATH: process.execPath }, + stdout: "pipe", + stderr: "pipe", + }) + + expect(result.exitCode).toBe(0) + expect(result.stdout.toString()).toBe("override-ok") + }) +}) diff --git a/src/mendcode/packages/opencode/test/mend/loop-service.test.ts b/src/mendcode/packages/opencode/test/mend/loop-service.test.ts index c8be0ac5..411d0645 100644 --- a/src/mendcode/packages/opencode/test/mend/loop-service.test.ts +++ b/src/mendcode/packages/opencode/test/mend/loop-service.test.ts @@ -240,6 +240,8 @@ describe("loop service plans", () => { }) expect(plan.backend).toBe("scheduled-task") + expect(plan.programArguments[0]).toBe("mendcode.exe") + expect(plan.programArguments).not.toContain("/usr/bin/env") expect(plan.definitionPath).toContain("com.mendcode.loops.") expect(plan.installCommand[0]).toBe("schtasks.exe") expect(loopServiceWindowsCommand(plan)).toContain("mendcode.exe loops daemon") @@ -249,6 +251,20 @@ describe("loop service plans", () => { expect(loopServiceWindowsCommand(plan)).toContain("--quiet") }) + test("quotes a Windows executable path without Unix shell helpers", () => { + const plan = loopServicePlan({ + projectRoot: "C:\\work\\repo", + command: "C:\\Program Files\\MendCode\\mendcode.exe", + platform: "win32", + serviceDir: "C:\\MendCode\\Loops", + logDir: "C:\\MendCode\\Logs", + }) + + expect(plan.programArguments[0]).toBe("C:\\Program Files\\MendCode\\mendcode.exe") + expect(loopServiceWindowsCommand(plan)).toContain('&& "C:\\Program Files\\MendCode\\mendcode.exe" loops daemon') + expect(loopServiceWindowsCommand(plan)).not.toContain("/usr/bin/env") + }) + test("persists bounded scheduler health and reports service drift", async () => { await using dir = await tmpdir() const args = { diff --git a/src/mendcode/packages/opencode/test/mend/memory.test.ts b/src/mendcode/packages/opencode/test/mend/memory.test.ts index 1e274b18..01db61b2 100644 --- a/src/mendcode/packages/opencode/test/mend/memory.test.ts +++ b/src/mendcode/packages/opencode/test/mend/memory.test.ts @@ -65,6 +65,16 @@ describe("mend memory", () => { expect(status.readsSecrets).toBe(false) }) + test("concurrent memory appends preserve every complete JSONL entry", async () => { + await using dir = await tmpdir() + const texts = Array.from({ length: 32 }, (_, index) => `Concurrent memory ${index}`) + + await Promise.all(texts.map((text) => appendMemoryEntry({ scope: "project", text }, dir.path))) + + const entries = await readMemoryEntries("project", dir.path) + expect(entries.map((entry) => entry.text).sort()).toEqual(texts.sort()) + }) + test("normalizes Dream write policy settings", async () => { await using dir = await tmpdir() diff --git a/src/mendcode/packages/opencode/test/sdk/sse-fetch.test.ts b/src/mendcode/packages/opencode/test/sdk/sse-fetch.test.ts new file mode 100644 index 00000000..c2d9890f --- /dev/null +++ b/src/mendcode/packages/opencode/test/sdk/sse-fetch.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, test } from "bun:test" +import { createClient } from "../../../sdk/js/src/gen/client/client.gen" + +describe("generated V1 SDK SSE transport", () => { + test("uses the client-configured fetch implementation", async () => { + const requests: Request[] = [] + const customFetch = async (request: Request) => { + requests.push(request) + return new Response('data: {"ok":true}\n\n', { + status: 200, + headers: { "content-type": "text/event-stream" }, + }) + } + const client = createClient({ baseUrl: "https://sdk.example.test", fetch: customFetch }) + + const result = await client.get.sse({ url: "/events" }) + const next = await result.stream.next() + + expect(next).toEqual({ value: { ok: true }, done: false }) + expect(requests).toHaveLength(1) + expect(requests[0].url).toBe("https://sdk.example.test/events") + }) +}) diff --git a/src/mendcode/packages/opencode/test/server/httpapi-session.test.ts b/src/mendcode/packages/opencode/test/server/httpapi-session.test.ts index 66f4f0a7..ab563028 100644 --- a/src/mendcode/packages/opencode/test/server/httpapi-session.test.ts +++ b/src/mendcode/packages/opencode/test/server/httpapi-session.test.ts @@ -703,7 +703,7 @@ describe("session HttpApi", () => { method: "POST", headers, }), - ).toBe(true) + ).toBe(false) expect( yield* requestJson(pathFor(SessionPaths.remove, { sessionID: created.id }), { diff --git a/src/mendcode/packages/opencode/test/server/session-actions.test.ts b/src/mendcode/packages/opencode/test/server/session-actions.test.ts index b6c6e313..f4cde086 100644 --- a/src/mendcode/packages/opencode/test/server/session-actions.test.ts +++ b/src/mendcode/packages/opencode/test/server/session-actions.test.ts @@ -48,7 +48,7 @@ describe("session action routes", () => { const interrupt = await app.request(`/session/${session.id}/interrupt`, { method: "POST" }) expect(interrupt.status).toBe(200) - expect(await interrupt.json()).toBe(true) + expect(await interrupt.json()).toBe(false) await svc.remove(session.id) }, diff --git a/src/mendcode/packages/opencode/test/session/agent-command.test.ts b/src/mendcode/packages/opencode/test/session/agent-command.test.ts index 91ed2d7d..0fbd2e49 100644 --- a/src/mendcode/packages/opencode/test/session/agent-command.test.ts +++ b/src/mendcode/packages/opencode/test/session/agent-command.test.ts @@ -118,7 +118,7 @@ describe("Agent Command inbox", () => { }) }) - test("queues same-workspace peer text without granting tool permissions", async () => { + test("auto-queues same-workspace peer text without granting tool permissions", async () => { await using tmp = await tmpdir({ git: true }) await WithInstance.provide({ directory: tmp.path, @@ -137,10 +137,10 @@ describe("Agent Command inbox", () => { sourceSessionID: source.id, targetSessionID: target.id, type: "peer_message", - state: "pending", + state: "accepted", permissions: [], policy: { - decision: "same_workspace", + decision: "safe_auto", permissions: [], }, payload: { @@ -156,7 +156,7 @@ describe("Agent Command inbox", () => { type: "peer_message", payload: { text: " " }, }), - ).rejects.toThrow("Peer message text cannot be empty.") + ).rejects.toThrow("Session message text cannot be empty.") }, }) }) @@ -175,7 +175,7 @@ describe("Agent Command inbox", () => { type: "peer_message", payload: { text: "loop back" }, }), - ).rejects.toThrow("Peer messages require a different target session.") + ).rejects.toThrow("Session messages require a different target session.") }, }) }) @@ -370,7 +370,7 @@ describe("Agent Command inbox", () => { expect(duplicate.id).toBe(first.id) expect(overflow).toMatchObject({ state: "rejected", - error: "Target already has 3 pending commands; wait for the worker to accept or reject one first.", + error: "Target already has 3 queued commands; wait for one to finish before sending another.", }) }, }) @@ -730,7 +730,7 @@ describe("Agent Command inbox", () => { decision: "approval_required", permissions: ["session.message.send"], }), - expect.objectContaining({ type: "peer_message", decision: "same_workspace", permissions: [] }), + expect.objectContaining({ type: "peer_message", decision: "safe_auto", permissions: [] }), ]), ) }) diff --git a/src/mendcode/packages/opencode/test/session/loop-workflow-adapter.test.ts b/src/mendcode/packages/opencode/test/session/loop-workflow-adapter.test.ts index 8aaf0625..a9205792 100644 --- a/src/mendcode/packages/opencode/test/session/loop-workflow-adapter.test.ts +++ b/src/mendcode/packages/opencode/test/session/loop-workflow-adapter.test.ts @@ -110,7 +110,7 @@ function promptLayer() { cancel: () => Effect.void, cancelTurn: () => Effect.succeed("not_running" as const), cancelQueued: () => Effect.succeed(false), - interrupt: () => Effect.void, + interrupt: () => Effect.succeed(false), prompt: (_input: PromptInput) => Effect.succeed(message), promptAsync: (_input: PromptInput) => Effect.succeed(message), loop: () => Effect.succeed(message), diff --git a/src/mendcode/packages/opencode/test/session/loop.test.ts b/src/mendcode/packages/opencode/test/session/loop.test.ts index 4b7bdcad..1e1071da 100644 --- a/src/mendcode/packages/opencode/test/session/loop.test.ts +++ b/src/mendcode/packages/opencode/test/session/loop.test.ts @@ -118,7 +118,7 @@ function runRunner( cancel: () => Effect.void, cancelTurn: () => Effect.succeed("not_running" as const), cancelQueued: () => Effect.succeed(false), - interrupt: () => Effect.void, + interrupt: () => Effect.succeed(false), prompt: (input: PromptInput) => Effect.gen(function* () { prompts++ @@ -2940,7 +2940,7 @@ describe("loop workflow service", () => { cancel: () => Effect.void, cancelTurn: () => Effect.succeed("not_running" as const), cancelQueued: () => Effect.succeed(false), - interrupt: () => Effect.void, + interrupt: () => Effect.succeed(false), prompt: () => Effect.promise(async () => { prompts++ @@ -3273,6 +3273,7 @@ describe("loop workflow service", () => { `token=secret-token-value ${"x".repeat(1_500)}`, 'payload={"token":"json-secret-value"}', "validated with sk-abcdefghijklmnopqrstuvwxyz123456", + 'headers={"authorization":"Bearer quoted-secret-value"}', ], nextAction: 'Retry after setting payload={"password":"hunter2"}', }, @@ -3289,6 +3290,8 @@ describe("loop workflow service", () => { expect(checkpoint?.evidence?.[1]).toContain("[REDACTED]") expect(checkpoint?.evidence?.[1]).not.toContain("json-secret-value") expect(checkpoint?.evidence?.[2]).toContain("[REDACTED_SECRET]") + expect(checkpoint?.evidence?.[3]).toContain('"authorization":"[REDACTED]"') + expect(checkpoint?.evidence?.[3]).not.toContain("quoted-secret-value") expect(JSON.stringify(checkpoint?.metadata)).toContain("[REDACTED]") expect(JSON.stringify(checkpoint?.metadata)).not.toContain("hunter2") expect(snapshot.runs[0]?.checkpoint?.summary).toContain("Authorization:[REDACTED]") diff --git a/src/mendcode/packages/opencode/test/session/prompt.test.ts b/src/mendcode/packages/opencode/test/session/prompt.test.ts index ac41faa5..5eded8d4 100644 --- a/src/mendcode/packages/opencode/test/session/prompt.test.ts +++ b/src/mendcode/packages/opencode/test/session/prompt.test.ts @@ -57,6 +57,7 @@ import { SessionStatus } from "../../src/session/status" import { recoverStaleSessionStatuses, STALE_SESSION_RECOVERY_MS } from "../../src/session/recovery" import { BackgroundTask } from "../../src/session/background-task" import { SessionV2 } from "../../src/v2/session" +import { Modelv2 } from "../../src/v2/model" import { Skill } from "../../src/skill" import { LoopWorkflow } from "../../src/session/loop" import { LoopRunner } from "../../src/session/loop-runner" @@ -81,11 +82,24 @@ import { reply, TestLLMServer } from "../lib/llm-server" void Log.init({ print: false }) test("resolves conditional turn cancellation without affecting a newer run", () => { - expect(resolveCancelTurnResult({ runtime: "cancelled", terminalAssistant: false })).toBe("cancelled") - expect(resolveCancelTurnResult({ runtime: "target_mismatch", terminalAssistant: true })).toBe("already_terminal") - expect(resolveCancelTurnResult({ runtime: "target_mismatch", terminalAssistant: false })).toBe("target_mismatch") - expect(resolveCancelTurnResult({ runtime: "not_running", terminalAssistant: false })).toBe("not_running") - expect(resolveCancelTurnResult({ runtime: "not_interruptible", terminalAssistant: false })).toBe("not_running") + expect(resolveCancelTurnResult({ runtime: "cancelled", terminalAssistant: false, sessionBusy: false })).toBe( + "cancelled", + ) + expect(resolveCancelTurnResult({ runtime: "target_mismatch", terminalAssistant: true, sessionBusy: false })).toBe( + "already_terminal", + ) + expect(resolveCancelTurnResult({ runtime: "target_mismatch", terminalAssistant: true, sessionBusy: true })).toBe( + "target_mismatch", + ) + expect(resolveCancelTurnResult({ runtime: "target_mismatch", terminalAssistant: false, sessionBusy: true })).toBe( + "target_mismatch", + ) + expect(resolveCancelTurnResult({ runtime: "not_running", terminalAssistant: false, sessionBusy: false })).toBe( + "not_running", + ) + expect(resolveCancelTurnResult({ runtime: "not_interruptible", terminalAssistant: false, sessionBusy: true })).toBe( + "not_running", + ) }) test("maps tool execution to bounded activity labels", () => { @@ -2314,18 +2328,24 @@ it.live("promptAsync never replays a delivered prompt whose assistant is still i ), ) -it.live("delivers an accepted peer message once with visible provenance", () => +it.live("automatically delivers a same-workspace agent message and returns its response once", () => provideTmpdirServer( Effect.fnUntraced(function* ({ llm }) { const prompt = yield* SessionPrompt.Service + yield* Effect.sleep("20 millis") const sessions = yield* Session.Service + const status = yield* SessionStatus.Service const commands = yield* AgentCommand.Service const source = yield* sessions.create({ title: "Peer sender" }) const target = yield* sessions.create({ title: "Peer receiver", permission: [{ permission: "*", pattern: "*", action: "allow" }], }) - yield* llm.text("peer response") + yield* prompt.wakePeerDelivery(source.id) + yield* status.set(target.id, { type: "busy", message: "active run" }) + const responseGate = defer() + yield* llm.hold("peer response", responseGate.promise) + yield* llm.text("source observed peer response") const command = yield* commands.create({ sourceSessionID: source.id, @@ -2333,10 +2353,43 @@ it.live("delivers an accepted peer message once with visible provenance", () => type: "peer_message", payload: { text: "hello from the sender" }, }) - expect(command.state).toBe("pending") - yield* commands.update({ id: command.id, targetSessionID: target.id, state: "accepted" }) - yield* prompt.wakePeerDelivery(target.id) - yield* prompt.wakePeerDelivery(target.id) + expect(command.state).toBe("accepted") + expect(command.policy.decision).toBe("safe_auto") + yield* Effect.sleep("50 millis") + expect((yield* commands.get(command.id)).state).toBe("accepted") + expect( + (yield* sessions.messages({ sessionID: target.id, view: "full" })).some((message) => + message.parts.some( + (part) => + part.type === "text" && (part.metadata as Record | undefined)?.deliveryID === command.id, + ), + ), + ).toBe(false) + expect(yield* llm.calls).toBe(0) + yield* status.set(target.id, { type: "idle" }, { notify: false }) + + const awaitingResponse = yield* Effect.gen(function* () { + while (true) { + const messages = yield* sessions.messages({ sessionID: target.id, view: "full" }) + const received = messages.find((message) => + message.parts.some( + (part) => + part.type === "text" && + (part.metadata as Record | undefined)?.deliveryID === command.id, + ), + ) + const state = yield* commands.get(command.id) + if (received && state.state === "running" && (yield* llm.calls) === 1) return true + yield* Effect.sleep("1 millis") + } + }).pipe( + Effect.timeout("2 seconds"), + Effect.catch(() => Effect.succeed(false)), + ) + expect(awaitingResponse).toBe(true) + expect(JSON.stringify(yield* llm.inputs)).toContain("agent_message") + expect(JSON.stringify(yield* llm.inputs)).toContain(source.id) + responseGate.resolve() const delivered = yield* Effect.gen(function* () { while (true) { @@ -2355,7 +2408,22 @@ it.live("delivers an accepted peer message once with visible provenance", () => message.info.parentID === received?.info.id && message.info.time.completed !== undefined, ) - if (received && state.state === "completed" && assistantCompleted) return true + const sourceMessages = yield* sessions.messages({ sessionID: source.id, view: "full" }) + const returned = sourceMessages.find((message) => + message.parts.some( + (part) => + part.type === "text" && + (part.metadata as Record | undefined)?.kind === "peer_response" && + (part.metadata as Record | undefined)?.deliveryID === command.id, + ), + ) + const sourceResponded = sourceMessages.some( + (message) => + message.info.role === "assistant" && + message.info.parentID === returned?.info.id && + message.info.time.completed !== undefined, + ) + if (received && returned && state.state === "completed" && assistantCompleted && sourceResponded) return true yield* Effect.sleep("1 millis") } }).pipe( @@ -2376,14 +2444,38 @@ it.live("delivers an accepted peer message once with visible provenance", () => (part) => part.type === "text" && (part.metadata as Record | undefined)?.deliveryID === command.id, ) - expect(receivedPart && receivedPart.type === "text" ? receivedPart.text : undefined).toBe("hello from the sender") + expect(receivedPart && receivedPart.type === "text" ? receivedPart.text : undefined).toContain( + '', + ) + expect( + receivedPart && receivedPart.type === "text" + ? (receivedPart.metadata as Record | undefined)?.displayText + : undefined, + ).toBe("hello from the sender") expect( receivedPart && receivedPart.type === "text" ? (receivedPart.metadata as Record | undefined)?.sourceSessionID : undefined, ).toBe(source.id) expect(yield* commands.get(command.id).pipe(Effect.map((info) => info.state))).toBe("completed") - expect(yield* llm.calls).toBe(1) + const sourceMessages = yield* sessions.messages({ sessionID: source.id, view: "full" }) + const returnedPart = sourceMessages + .flatMap((message) => message.parts) + .find( + (part) => + part.type === "text" && + (part.metadata as Record | undefined)?.kind === "peer_response" && + (part.metadata as Record | undefined)?.deliveryID === command.id, + ) + expect(returnedPart && returnedPart.type === "text" ? returnedPart.text : undefined).toContain("agent_response") + expect( + returnedPart && returnedPart.type === "text" + ? (returnedPart.metadata as Record | undefined)?.displayText + : undefined, + ).toBe("peer response") + expect(JSON.stringify(yield* llm.inputs)).toContain("peer response") + expect(yield* llm.calls).toBe(2) + expect((yield* commands.list()).filter((item) => item.type === "peer_message")).toHaveLength(1) }), { git: true, @@ -2601,6 +2693,44 @@ it.live("cancel-turn aborts an active compaction and finalizes its summary", () ), ) +it.live("reports a stale terminal target while a newer compaction run is active", () => + provideTmpdirServer( + Effect.fnUntraced(function* ({ llm }) { + const prompt = yield* SessionPrompt.Service + const compaction = yield* SessionCompaction.Service + const sessions = yield* Session.Service + const chat = yield* sessions.create({ title: "Stale target during compaction" }) + yield* llm.text("seed response") + yield* prompt.prompt({ + sessionID: chat.id, + agent: "build", + model: ref, + parts: [{ type: "text", text: "seed request" }], + }) + const original = (yield* sessions.messages({ sessionID: chat.id })).find( + (message) => + message.info.role === "user" && + message.parts.some((part) => part.type === "text" && part.text === "seed request"), + ) + if (!original) throw new Error("seed user message missing") + + const gate = defer() + yield* llm.hold("compaction response", gate.promise) + yield* compaction.create({ sessionID: chat.id, agent: "build", model: ref, auto: true }) + const compacting = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild) + yield* llm.wait(2) + + expect(yield* prompt.cancelTurn({ sessionID: chat.id, targetMessageID: original.info.id })).toBe( + "target_mismatch", + ) + yield* prompt.cancel(chat.id).pipe(Effect.timeout("1 second")) + gate.resolve() + yield* Fiber.join(compacting).pipe(Effect.timeout("1 second")) + }), + { git: true, config: providerCfg }, + ), +) + it.live("publishes bounded tool activity until the prompt returns to idle", () => provideTmpdirServer( Effect.fnUntraced(function* ({ llm }) { @@ -3052,7 +3182,7 @@ it.live("manual interrupt stops only the active turn and preserves its queued pr .pipe(Effect.forkChild) yield* Effect.sleep("20 millis") - yield* prompt.interrupt(chat.id).pipe(Effect.timeout("1 second")) + expect(yield* prompt.interrupt(chat.id).pipe(Effect.timeout("1 second"))).toBe(true) const [interrupted, queuedResult] = yield* Effect.all([Fiber.join(active), Fiber.join(queued)]) gate.resolve() const messages = yield* sessions.messages({ sessionID: chat.id }) @@ -3382,6 +3512,51 @@ it.live("prompt emits v2 prompted and synthetic events", () => ), ) +it.live("V2 create, prompt, compact, and wait bridge to the working session runtime", () => + provideTmpdirServer( + Effect.fnUntraced(function* ({ llm }) { + yield* Effect.gen(function* () { + const v2 = yield* SessionV2.Service + const session = yield* v2.create({ + agent: "build", + model: { + providerID: Modelv2.ProviderID.make("test"), + id: Modelv2.ID.make("test-model"), + variant: Modelv2.VariantID.make("default"), + }, + }) + yield* llm.text("v2 response") + + const user = yield* v2.prompt({ + sessionID: session.id, + delivery: "immediate", + prompt: { text: "hello through v2" }, + }) + yield* v2.wait(session.id) + yield* llm.text("v2 compacted summary") + yield* v2.compact(session.id) + yield* v2.wait(session.id) + const messages = yield* v2.messages({ sessionID: session.id, order: "asc" }) + + expect(session.agent).toBe("build") + expect(session.model).toMatchObject({ providerID: "test", id: "test-model" }) + expect(user).toMatchObject({ type: "user", text: "hello through v2" }) + expect(messages).toEqual( + expect.arrayContaining([ + expect.objectContaining({ type: "user", text: "hello through v2" }), + expect.objectContaining({ + type: "assistant", + content: expect.arrayContaining([expect.objectContaining({ type: "text", text: "v2 response" })]), + }), + expect.objectContaining({ type: "compaction", summary: "v2 compacted summary" }), + ]), + ) + }).pipe(Effect.provide(SessionV2.layer)) + }), + { git: true, config: providerCfg }, + ), +) + it.live("static loop returns assistant text through local provider", () => provideTmpdirServer( Effect.fnUntraced(function* ({ llm }) { diff --git a/src/mendcode/packages/opencode/test/session/retry.test.ts b/src/mendcode/packages/opencode/test/session/retry.test.ts index b3cd8992..55f9709a 100644 --- a/src/mendcode/packages/opencode/test/session/retry.test.ts +++ b/src/mendcode/packages/opencode/test/session/retry.test.ts @@ -2,7 +2,8 @@ import { describe, expect, test } from "bun:test" import type { NamedError } from "@mendcode/core/util/error" import { APICallError } from "ai" import { setTimeout as sleep } from "node:timers/promises" -import { Effect, Layer, Schedule } from "effect" +import { Effect, Fiber, Layer, Schedule } from "effect" +import * as TestClock from "effect/testing/TestClock" import { CrossSpawnSpawner } from "@mendcode/core/cross-spawn-spawner" import { SessionRetry } from "../../src/session/retry" import { MessageV2 } from "../../src/session/message-v2" @@ -46,6 +47,11 @@ describe("session.retry.delay", () => { expect(SessionRetry.delay(99, error)).toBe(SessionRetry.RETRY_NETWORK_INTERVAL) }) + test("does not delay network recovery because of a provider retry hint", () => { + const error = apiError({ "retry-after-ms": "60000" }, "Network connection lost") + expect(SessionRetry.delay(1, error)).toBe(SessionRetry.RETRY_NETWORK_INTERVAL) + }) + test("prefers retry-after-ms when shorter than exponential", () => { const error = apiError({ "retry-after-ms": "1500" }) expect(SessionRetry.delay(4, error)).toBe(1500) @@ -159,29 +165,32 @@ describe("session.retry.delay", () => { }), ) - it.live("keeps transient network retries alive past the normal retry cap", () => - provideTmpdirInstance(() => - Effect.gen(function* () { - const error = MessageV2.APIError.Schema.parse( - new MessageV2.APIError({ - message: "Network connection lost", - isRetryable: true, - responseHeaders: { "retry-after-ms": "0" }, - metadata: { code: "ENETDOWN" }, - }).toObject(), - ) - const step = yield* Schedule.toStepWithMetadata( - SessionRetry.policy({ - parse: (err) => MessageV2.APIError.Schema.parse(err), - set: () => Effect.void, - }), - ) - - for (let attempt = 0; attempt < SessionRetry.RETRY_MAX_ATTEMPTS + 2; attempt++) { + it.effect("keeps transient network retries alive past the normal retry cap", () => + Effect.gen(function* () { + const error = MessageV2.APIError.Schema.parse( + new MessageV2.APIError({ + message: "Network connection lost", + isRetryable: true, + responseHeaders: { "retry-after-ms": "0" }, + metadata: { code: "ENETDOWN" }, + }).toObject(), + ) + const step = yield* Schedule.toStepWithMetadata( + SessionRetry.policy({ + parse: (err) => MessageV2.APIError.Schema.parse(err), + set: () => Effect.void, + }), + ) + const attempts = SessionRetry.RETRY_MAX_ATTEMPTS + 2 + const fiber = yield* Effect.gen(function* () { + for (let attempt = 0; attempt < attempts; attempt++) { yield* step(error) } - }), - ), + }).pipe(Effect.forkChild) + + yield* TestClock.adjust(`${attempts} seconds`) + yield* Fiber.join(fiber) + }), ) }) @@ -457,14 +466,17 @@ describe("session.message-v2.fromError", () => { expect(SessionRetry.retryable(result)).toBeUndefined() }) - test("converts silent stream timeouts to retryable APIError", () => { + test("keeps silent stream stalls distinct from physical network failures", () => { const result = MessageV2.fromError(new Error("SSE read timed out"), { providerID }) expect(MessageV2.APIError.isInstance(result)).toBe(true) if (!MessageV2.APIError.isInstance(result)) throw new Error("expected APIError") expect(result.data.isRetryable).toBe(true) - expect(result.data.message).toBe("Network connection lost") - expect(SessionRetry.retryable(result)).toBe("Network connection lost") + expect(result.data.message).toBe("AI backend stream stalled") + expect(result.data.metadata).toMatchObject({ kind: "stream_timeout", message: "SSE read timed out" }) + expect(MessageV2.isNetworkError(result)).toBe(false) + expect(SessionRetry.retryable(result)).toBe("AI backend stream stalled") + expect(SessionRetry.delay(1, result)).toBe(SessionRetry.RETRY_INITIAL_DELAY) }) test("retries processor idle stream watchdog timeouts", () => { diff --git a/src/mendcode/packages/opencode/test/session/session.test.ts b/src/mendcode/packages/opencode/test/session/session.test.ts index f6953d78..1bceaf8b 100644 --- a/src/mendcode/packages/opencode/test/session/session.test.ts +++ b/src/mendcode/packages/opencode/test/session/session.test.ts @@ -420,6 +420,63 @@ describe("live part delta persistence", () => { }, { timeout: 30000 }, ) + + test( + "persists the oldest live part snapshot before capacity eviction", + async () => { + await WithInstance.provide({ + directory: projectRoot, + fn: async () => { + const info = await create({}) + try { + const messageID = MessageID.ascending() + await updateMessage({ + id: messageID, + sessionID: info.id, + role: "assistant", + time: { created: Date.now() }, + agent: "build", + model: { providerID: "test", modelID: "test" }, + path: { cwd: projectRoot, root: projectRoot }, + summary: false, + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + } as unknown as MessageV2.Info) + + const parts = [] as MessageV2.TextPart[] + for (let index = 0; index < 65; index++) { + parts.push( + (await updatePart({ + id: PartID.ascending(), + messageID, + sessionID: info.id, + type: "text", + text: "", + time: { start: Date.now() }, + })) as MessageV2.TextPart, + ) + } + for (let index = 0; index < parts.length; index++) { + await updatePartDelta({ + sessionID: info.id, + messageID, + partID: parts[index].id, + field: "text", + delta: `delta-${index}`, + }) + } + + const persisted = await getPart({ sessionID: info.id, messageID, partID: parts[0].id }) + if (!persisted || persisted.type !== "text") throw new Error("Expected persisted oldest text part") + expect(persisted.text).toBe("delta-0") + } finally { + await remove(info.id) + } + }, + }) + }, + { timeout: 30000 }, + ) }) describe("Session", () => { diff --git a/src/mendcode/packages/opencode/test/session/workflow-scheduler.test.ts b/src/mendcode/packages/opencode/test/session/workflow-scheduler.test.ts index 1b44774e..c1cf504d 100644 --- a/src/mendcode/packages/opencode/test/session/workflow-scheduler.test.ts +++ b/src/mendcode/packages/opencode/test/session/workflow-scheduler.test.ts @@ -145,6 +145,27 @@ describe("workflow scheduler readiness", () => { expect(WorkflowRunner.shouldRecoverWorkflowRun("stopped")).toBe(false) }) + test("revalidates a claimed task immediately before execution", () => { + const sessionID = SessionID.make("ses_workflow_claim") + const snapshot = { + run: { state: "working" }, + tasks: [{ id: "task", state: "working", attempt: 2, sessionID }], + } as unknown as WorkflowService.WorkflowSnapshot + const claim = { taskID: "task", attempt: 2 } as never + + expect(WorkflowRunner.workflowClaimCanExecute(snapshot, claim, sessionID)).toBe(true) + expect( + WorkflowRunner.workflowClaimCanExecute( + { ...snapshot, run: { ...snapshot.run, state: "stopped" } } as WorkflowService.WorkflowSnapshot, + claim, + sessionID, + ), + ).toBe(false) + expect(WorkflowRunner.workflowClaimCanExecute(snapshot, { taskID: "task", attempt: 1 } as never, sessionID)).toBe( + false, + ) + }) + test("keeps dependencies and phase barriers strict while honoring concurrency", () => { const first = WorkflowScheduler.readyTaskIDs({ phases: [ diff --git a/src/mendcode/packages/opencode/test/session/workflow-task-executor.test.ts b/src/mendcode/packages/opencode/test/session/workflow-task-executor.test.ts index aec8ebe8..4e9fd87e 100644 --- a/src/mendcode/packages/opencode/test/session/workflow-task-executor.test.ts +++ b/src/mendcode/packages/opencode/test/session/workflow-task-executor.test.ts @@ -71,7 +71,7 @@ function runExecutor(input: { }), cancelTurn: () => Effect.succeed("not_running" as const), cancelQueued: () => Effect.succeed(false), - interrupt: () => Effect.void, + interrupt: () => Effect.succeed(false), prompt: (prompt: PromptInput) => { input.calls.push(prompt) return input.stall ? Effect.never : Effect.succeed(input.message ?? promptMessage(input.promptText)) diff --git a/src/mendcode/packages/opencode/test/share/share-next.test.ts b/src/mendcode/packages/opencode/test/share/share-next.test.ts index 909e5f38..49e2346b 100644 --- a/src/mendcode/packages/opencode/test/share/share-next.test.ts +++ b/src/mendcode/packages/opencode/test/share/share-next.test.ts @@ -11,7 +11,8 @@ import { Bus } from "../../src/bus" import { Config } from "@/config/config" import { Provider } from "@/provider/provider" import { Session } from "@/session/session" -import type { SessionID } from "../../src/session/schema" +import { MessageV2 } from "@/session/message-v2" +import { MessageID, type SessionID } from "../../src/session/schema" import { ShareNext } from "@/share/share-next" import { SessionShareTable } from "../../src/share/share.sql" import { Database } from "@/storage/db" @@ -330,4 +331,56 @@ describe("ShareNext", () => { { config: { enterprise: { url: "https://legacy-share.example.com" } } }, ), ) + + it.live("rotates the public share when a message is removed", () => + provideTmpdirInstance( + () => { + const seen: Array<[string, string]> = [] + let creates = 0 + const client = HttpClient.make((req) => { + seen.push([req.method, req.url]) + if (req.method === "POST" && req.url.endsWith("/api/share")) { + creates++ + return Effect.succeed( + json(req, { + id: creates === 1 ? "shr_old" : "shr_new", + url: `https://legacy-share.example.com/share/${creates === 1 ? "old" : "new"}`, + secret: creates === 1 ? "sec_old" : "sec_new", + }), + ) + } + return Effect.succeed(json(req, { ok: true })) + }) + + return Effect.gen(function* () { + const bus = yield* Bus.Service + const shares = yield* ShareNext.Service + const sessions = yield* Session.Service + const info = yield* sessions.create({ title: "shared" }) + yield* shares.init() + yield* shares.create(info.id) + + yield* bus.publish(MessageV2.Event.Removed, { + sessionID: info.id, + messageID: MessageID.ascending("msg_removed"), + reason: "revert", + }) + yield* Effect.sleep(350) + + expect(share(info.id)).toMatchObject({ + id: "shr_new", + url: "https://legacy-share.example.com/share/new", + secret: "sec_new", + }) + expect((yield* sessions.get(info.id)).share?.url).toBe("https://legacy-share.example.com/share/new") + expect(seen.filter(([, url]) => !url.endsWith("/sync"))).toEqual([ + ["POST", "https://legacy-share.example.com/api/share"], + ["DELETE", "https://legacy-share.example.com/api/share/shr_old"], + ["POST", "https://legacy-share.example.com/api/share"], + ]) + }).pipe(Effect.provide(wired(client))) + }, + { config: { enterprise: { url: "https://legacy-share.example.com" } } }, + ), + ) }) diff --git a/src/mendcode/packages/opencode/test/skill/discovery.test.ts b/src/mendcode/packages/opencode/test/skill/discovery.test.ts index 2d88e064..b0026efb 100644 --- a/src/mendcode/packages/opencode/test/skill/discovery.test.ts +++ b/src/mendcode/packages/opencode/test/skill/discovery.test.ts @@ -21,6 +21,15 @@ beforeAll(async () => { async fetch(req) { const url = new URL(req.url) + if (url.pathname === "/malicious/index.json") { + return Response.json({ + skills: [ + { name: "../escaped-skill", files: ["SKILL.md"] }, + { name: "safe", files: ["SKILL.md", "../../escaped.md"] }, + ], + }) + } + // route /.well-known/skills/* to the fixture directory if (url.pathname.startsWith("/.well-known/skills/")) { const filePath = url.pathname.replace("/.well-known/skills/", "") @@ -80,6 +89,13 @@ describe("Discovery.pull", () => { expect(dirs).toEqual([]) }) + test("rejects skill names and files that escape the cache", async () => { + const dirs = await pull(`http://localhost:${server.port}/malicious/`) + expect(dirs).toEqual([]) + expect(await Filesystem.exists(path.join(Global.Path.cache, "escaped-skill"))).toBe(false) + expect(await Filesystem.exists(path.join(Global.Path.cache, "escaped.md"))).toBe(false) + }) + test("downloads reference files alongside SKILL.md", async () => { const dirs = await pull(CLOUDFLARE_SKILLS_URL) // find a skill dir that should have reference files (e.g. agents-sdk) diff --git a/src/mendcode/packages/opencode/test/storage/json-migration.test.ts b/src/mendcode/packages/opencode/test/storage/json-migration.test.ts index 48f88dcb..eb1334dc 100644 --- a/src/mendcode/packages/opencode/test/storage/json-migration.test.ts +++ b/src/mendcode/packages/opencode/test/storage/json-migration.test.ts @@ -111,6 +111,11 @@ describe("JSON to SQLite migration", () => { await fs.rm(storageDir, { recursive: true, force: true }) }) + test("only considers a migration complete when every record migrated cleanly", () => { + expect(JsonMigration.jsonStorageMigrationSucceeded({ errors: [] })).toBe(true) + expect(JsonMigration.jsonStorageMigrationSucceeded({ errors: ["failed record"] })).toBe(false) + }) + test("migrates project", async () => { await writeProject(storageDir, { id: "proj_test123abc", diff --git a/src/mendcode/packages/opencode/test/tool/peers.test.ts b/src/mendcode/packages/opencode/test/tool/peers.test.ts index b3ff576b..16eb3560 100644 --- a/src/mendcode/packages/opencode/test/tool/peers.test.ts +++ b/src/mendcode/packages/opencode/test/tool/peers.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test" -import { peerDescriptors } from "../../src/tool/peers" +import { sessionDescriptors } from "../../src/tool/peers" import type { Session } from "../../src/session/session" import type { SessionStatus } from "../../src/session/status" @@ -12,7 +12,7 @@ function session(id: string, updated: number, title = id) { } as Session.Info } -describe("peer directory", () => { +describe("session directory", () => { test("returns bounded same-project descriptors without the current session", () => { const sessions = [session("current", 10), session("older", 20, "Older worker"), session("newer", 30, "New worker")] const statuses = new Map([ @@ -20,7 +20,7 @@ describe("peer directory", () => { ["newer", { type: "idle" }], ]) - expect(peerDescriptors({ sessions, statuses, currentSessionID: "current", limit: 10 })).toEqual([ + expect(sessionDescriptors({ sessions, statuses, currentSessionID: "current", limit: 10 })).toEqual([ { sessionID: "newer", title: "New worker", @@ -41,7 +41,7 @@ describe("peer directory", () => { test("caps the requested limit and activity text", () => { const longActivity = "x".repeat(300) - const descriptors = peerDescriptors({ + const descriptors = sessionDescriptors({ sessions: Array.from({ length: 40 }, (_, index) => session(`peer-${index}`, index)), statuses: new Map([["peer-39", { type: "busy", message: longActivity } as SessionStatus.Info]]), currentSessionID: "none", diff --git a/src/mendcode/packages/opencode/test/tool/registry.test.ts b/src/mendcode/packages/opencode/test/tool/registry.test.ts index 76c9d007..22d1a64d 100644 --- a/src/mendcode/packages/opencode/test/tool/registry.test.ts +++ b/src/mendcode/packages/opencode/test/tool/registry.test.ts @@ -192,7 +192,8 @@ describe("tool.registry", () => { expect(ids).toContain("memory_graph") expect(ids).toContain("review") expect(ids).toContain("tell") - expect(ids).toContain("peers") + expect(ids).toContain("sessions") + expect(ids).not.toContain("peers") expect(ids).toContain("task_status") expect(ids).toContain("write") expect(ids).not.toContain("image_gen") diff --git a/src/mendcode/packages/opencode/test/util/rpc.test.ts b/src/mendcode/packages/opencode/test/util/rpc.test.ts new file mode 100644 index 00000000..f37ec7e5 --- /dev/null +++ b/src/mendcode/packages/opencode/test/util/rpc.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, test } from "bun:test" +import { Rpc } from "../../src/util/rpc" + +type Endpoint = { + postMessage: (data: string) => void + onmessage: ((this: Worker, event: MessageEvent) => unknown) | null + addEventListener: (type: string, listener: (event: Event | ErrorEvent) => void) => void +} + +function endpoint(reply: (request: { id: number; method: string }) => string): Endpoint { + const target: Endpoint = { + onmessage: null, + addEventListener() {}, + postMessage(data) { + const request = JSON.parse(data) + queueMicrotask(() => target.onmessage?.call({} as Worker, { data: reply(request) } as MessageEvent)) + }, + } + return target +} + +describe("RPC client", () => { + test("rejects a request when the worker reports an execution error", async () => { + const target = endpoint((request) => + JSON.stringify({ type: "rpc.error", id: request.id, error: `failed ${request.method}` }), + ) + const client = Rpc.client<{ explode: (input: null) => Promise }>(target) + + await expect(client.call("explode", null)).rejects.toThrow("failed explode") + }) + + test("rejects pending requests when the worker emits malformed JSON", async () => { + const target = endpoint(() => "not-json") + const client = Rpc.client<{ broken: (input: null) => Promise }>(target) + + await expect(client.call("broken", null)).rejects.toThrow("malformed response") + }) + + test("rejects synchronously when posting to the worker fails", async () => { + const target = endpoint(() => "") + target.postMessage = () => { + throw new Error("worker is closed") + } + const client = Rpc.client<{ closed: (input: null) => Promise }>(target) + + await expect(client.call("closed", null)).rejects.toThrow("worker is closed") + }) + + test("rejects pending requests when the worker is terminated", async () => { + const target = endpoint(() => "") + target.postMessage = () => {} + const client = Rpc.client<{ pending: (input: null) => Promise }>(target) + const pending = client.call("pending", null) + + client.close() + + await expect(pending).rejects.toThrow("worker terminated") + }) +}) diff --git a/src/mendcode/packages/sdk/js/src/gen/core/serverSentEvents.gen.ts b/src/mendcode/packages/sdk/js/src/gen/core/serverSentEvents.gen.ts index ffc4f16d..ff5f9756 100644 --- a/src/mendcode/packages/sdk/js/src/gen/core/serverSentEvents.gen.ts +++ b/src/mendcode/packages/sdk/js/src/gen/core/serverSentEvents.gen.ts @@ -49,6 +49,8 @@ export type ServerSentEventsOptions = Omit Promise + /** Custom fetch implementation inherited from the SDK client configuration. */ + fetch?: (request: Request) => ReturnType url: string } @@ -72,6 +74,7 @@ export const createSseClient = ({ sseMaxRetryAttempts, sseMaxRetryDelay, sseSleepFn, + fetch: fetchFn = globalThis.fetch, url, ...options }: ServerSentEventsOptions): ServerSentEventsResult => { @@ -99,7 +102,7 @@ export const createSseClient = ({ } try { - const response = await fetch(url, { ...options, headers, signal }) + const response = await fetchFn(new Request(url, { ...options, headers, signal })) if (!response.ok) throw new Error(`SSE failed: ${response.status} ${response.statusText}`) diff --git a/src/mendcode/sdks/vscode/images/icon.png b/src/mendcode/sdks/vscode/images/icon.png deleted file mode 120000 index a43fdd21..00000000 --- a/src/mendcode/sdks/vscode/images/icon.png +++ /dev/null @@ -1 +0,0 @@ -/Users/obed/code/MendCode/.agents/vendor/opencode/packages/identity/mark-512x512.png \ No newline at end of file diff --git a/src/mendcode/sdks/vscode/images/icon.png b/src/mendcode/sdks/vscode/images/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..48f38fc8c2be73e63990e01503af30b070c5be31 GIT binary patch literal 330 zcmeAS@N?(olHy`uVBq!ia0y~yU;;9k7?_xWRHv9tCXnI`@Ck7h77+OV|9@0OM7Q|Y zJ3zrdo-U3d6}R5pHRNSb5OH?&NfMgsk->4;e^zyId+`NT&q*q%Xv(emj6i*Oz!%0# za(JYHQVZk@oban4DEqhJBQq<0RSoG42)Do7#?B535{9NfYuzIjfSGRhc3N(Ekga|L OB<|_z=d#Wzp$P!xU^p}Y literal 0 HcmV?d00001 diff --git a/src/mendcode/sst-env.d.ts b/src/mendcode/sst-env.d.ts index 52702acd..8645c8ec 100644 --- a/src/mendcode/sst-env.d.ts +++ b/src/mendcode/sst-env.d.ts @@ -86,6 +86,14 @@ declare module "sst" { "type": "sst.sst.Secret" "value": string } + "FEISHU_ENCRYPT_KEY": { + "type": "sst.sst.Secret" + "value": string + } + "FEISHU_VERIFICATION_TOKEN": { + "type": "sst.sst.Secret" + "value": string + } "GITHUB_APP_ID": { "type": "sst.sst.Secret" "value": string @@ -324,4 +332,4 @@ declare module "sst" { /// import "sst" -export {} \ No newline at end of file +export {} From b5646990dbc48d3326049e299f5cb9355a9212f8 Mon Sep 17 00:00:00 2001 From: Obed0101 Date: Thu, 3 Sep 2026 01:17:02 -0500 Subject: [PATCH 3/4] fix: preserve queued prompt delivery during interrupt --- .../cli/cmd/tui/component/prompt/index.tsx | 99 ++++++++++++++++++- .../test/cli/tui/prompt-working-start.test.ts | 15 ++- 2 files changed, 101 insertions(+), 13 deletions(-) diff --git a/src/mendcode/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx b/src/mendcode/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx index 753893f9..50fcd4ba 100644 --- a/src/mendcode/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/src/mendcode/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -310,6 +310,8 @@ type PendingPromptDelivery = { const pendingPromptDeliveries = new Map() // In-flight cancellation must not make a newly submitted prompt render as queued. const pendingPromptDeliveryInFlightBySession = new Map>() +const pendingPromptDeliveryDispatchKeys = new Set() +const promptDeliveryTailBySession = new Map>() const latestPromptDeliveryKeyBySession = new Map() const cancelledPromptDeliveryKeys = new Set() const pendingPromptDeliveryListeners = new Set<() => void>() @@ -322,6 +324,53 @@ function notifyPendingPromptDeliveryListeners() { for (const listener of pendingPromptDeliveryListeners) listener() } +export function runPromptDeliveryInSessionOrder(sessionID: string, task: () => Promise): Promise { + const previous = promptDeliveryTailBySession.get(sessionID) ?? Promise.resolve() + const current = previous.then(task, task) + const tail = current.then( + () => undefined, + () => undefined, + ) + promptDeliveryTailBySession.set(sessionID, tail) + void tail.finally(() => { + if (promptDeliveryTailBySession.get(sessionID) === tail) promptDeliveryTailBySession.delete(sessionID) + }) + return current +} + +function pendingPromptDeliveryAcceptanceState(sessionID: string, messageID: string) { + const key = `${sessionID}:${messageID}` + if (pendingPromptDeliveries.get(key)?.state === "accepted") return "accepted" as const + if ( + pendingPromptDeliveryDispatchKeys.has(key) || + pendingPromptDeliveryInFlightBySession.get(sessionID)?.has(key) || + pendingPromptDeliveries.has(key) + ) + return "waiting" as const + return "missing" as const +} + +export function waitForPendingPromptDeliveryReady(sessionID: string, messageID: string, timeoutMs = 5_000) { + return new Promise((resolve) => { + let settled = false + let unsubscribe = () => {} + const finish = (ready: boolean) => { + if (settled) return + settled = true + clearTimeout(timer) + unsubscribe() + resolve(ready) + } + const inspect = () => { + const state = pendingPromptDeliveryAcceptanceState(sessionID, messageID) + if (state === "accepted" || state === "missing") finish(true) + } + const timer = setTimeout(() => finish(false), timeoutMs) + unsubscribe = subscribePendingPromptDeliveries(inspect) + inspect() + }) +} + export function promptDeliveryIsQueued(state: "pending" | "accepted", queuedBehindActiveTurn = false) { return state === "pending" || queuedBehindActiveTurn } @@ -521,11 +570,12 @@ function settlePendingPromptDelivery(request: PromptAsyncInput) { function cancelPendingPromptDeliveryKey(sessionID: string, key: string) { const inFlight = pendingPromptDeliveryInFlightBySession.get(sessionID) const wasInFlight = inFlight?.delete(key) ?? false + const wasQueuedForDispatch = pendingPromptDeliveryDispatchKeys.delete(key) if (inFlight && inFlight.size === 0) pendingPromptDeliveryInFlightBySession.delete(sessionID) const removed = pendingPromptDeliveries.delete(key) if (latestPromptDeliveryKeyBySession.get(sessionID) === key) latestPromptDeliveryKeyBySession.delete(sessionID) - if (wasInFlight) cancelledPromptDeliveryKeys.add(key) - if (removed || wasInFlight) notifyPendingPromptDeliveryListeners() + if (wasInFlight || wasQueuedForDispatch) cancelledPromptDeliveryKeys.add(key) + if (removed || wasInFlight || wasQueuedForDispatch) notifyPendingPromptDeliveryListeners() } export function cancelPendingPromptDelivery(sessionID: string, messageID: string) { @@ -735,6 +785,22 @@ export function shouldEnableSessionInterrupt(input: { return false } +export function shouldClearSessionInterruptRequest(input: { + requestTargetMessageID: string + activeTargetMessageID?: string + targetTerminal: boolean + sessionSettled: boolean + controlState: string + controlResult?: string +}) { + const confirmed = + input.controlState === "stop_confirmed" && + (input.controlResult === "cancelled" || input.controlResult === "already_terminal") + const targetSuperseded = + input.activeTargetMessageID !== undefined && input.activeTargetMessageID !== input.requestTargetMessageID + return input.targetTerminal || input.sessionSettled || confirmed || targetSuperseded +} + export function hasInterruptibleSessionTurn(input: { statusType: string hasDraft?: boolean @@ -1223,13 +1289,24 @@ export function Prompt(props: PromptProps) { const targetMessageID = interruptTargetMessageID() if (!sessionID || !interruptRequested() || !targetMessageID) return const control = sessionControl.status(sessionID) + const activeTargetMessageID = activeTurnTargetMessageID() + const controlResult = control.state === "stop_confirmed" ? control.result : undefined const targetTerminal = messagesForActivity().some( (message) => message.role === "assistant" && message.parentID === targetMessageID && message.time.completed !== undefined, ) - const rejected = control.state === "stop_confirmed" && control.result !== "cancelled" const sessionSettled = status().type === "idle" && !hasActiveWorkingAssistant() && !hasPendingPromptDelivery() - if (targetTerminal || rejected || sessionSettled) updateInterruptRequested(false) + if ( + shouldClearSessionInterruptRequest({ + requestTargetMessageID: targetMessageID, + activeTargetMessageID, + targetTerminal, + sessionSettled, + controlState: control.state, + controlResult, + }) + ) + updateInterruptRequested(false) }) onCleanup(() => { if (clearWorkingStartTimer) clearTimeout(clearWorkingStartTimer) @@ -2514,7 +2591,7 @@ export function Prompt(props: PromptProps) { }) } - async function deliverPrompt( + async function deliverPromptNow( request: PromptAsyncInput, options?: { retry?: boolean; notify?: boolean; forceAccepted?: boolean; queuedBehindActiveTurn?: boolean }, ) { @@ -2589,6 +2666,18 @@ export function Prompt(props: PromptProps) { } } + function deliverPrompt( + request: PromptAsyncInput, + options?: { retry?: boolean; notify?: boolean; forceAccepted?: boolean; queuedBehindActiveTurn?: boolean }, + ) { + const deliveryKey = pendingPromptDeliveryKey(request) + pendingPromptDeliveryDispatchKeys.add(deliveryKey) + notifyPendingPromptDeliveryListeners() + return runPromptDeliveryInSessionOrder(request.sessionID, () => deliverPromptNow(request, options)).finally(() => { + if (pendingPromptDeliveryDispatchKeys.delete(deliveryKey)) notifyPendingPromptDeliveryListeners() + }) + } + async function retryPendingPromptDeliveries(input?: { forceAccepted?: boolean }) { if ( pendingPromptRetryInFlight || diff --git a/src/mendcode/packages/opencode/test/cli/tui/prompt-working-start.test.ts b/src/mendcode/packages/opencode/test/cli/tui/prompt-working-start.test.ts index a7d77e56..6fdf4f22 100644 --- a/src/mendcode/packages/opencode/test/cli/tui/prompt-working-start.test.ts +++ b/src/mendcode/packages/opencode/test/cli/tui/prompt-working-start.test.ts @@ -1069,24 +1069,23 @@ describe("resolveWorkingStartedAt", () => { ) }) - test("keeps double-Esc armed across internal target changes in the same active session", () => { - expect(sessionInterruptConfirmationAction({ sessionID: "session-1", hasActiveTurn: true })).toBe("arm") + test("keeps double-Esc armed only for the active message target", () => { + expect(sessionInterruptConfirmationAction({ activeTargetMessageID: "message-1", hasActiveTurn: true })).toBe("arm") expect( sessionInterruptConfirmationAction({ - sessionID: "session-1", - armedSessionID: "session-1", + activeTargetMessageID: "message-1", + armedTargetMessageID: "message-1", hasActiveTurn: true, }), ).toBe("interrupt") expect( sessionInterruptConfirmationAction({ - sessionID: "session-2", - armedSessionID: "session-1", + activeTargetMessageID: "message-2", + armedTargetMessageID: "message-1", hasActiveTurn: true, }), ).toBe("arm") - expect(sessionInterruptConfirmationAction({ armedSessionID: "session-1", hasActiveTurn: true })).toBe("ignore") - expect(sessionInterruptConfirmationAction({ sessionID: "session-1", hasActiveTurn: false })).toBe("ignore") + expect(sessionInterruptConfirmationAction({ armedTargetMessageID: "message-1", hasActiveTurn: false })).toBe("ignore") expect(sessionInterruptHint({ enabled: true, working: true, armed: false })).toBeUndefined() expect(sessionInterruptHint({ enabled: true, working: true, armed: true })).toBe("[esc again to interrupt]") expect(sessionInterruptHint({ enabled: false, working: true, armed: true })).toBeUndefined() From 924963e01308df5b58bbade3f82560eb9ab0ca3e Mon Sep 17 00:00:00 2001 From: Obed0101 Date: Fri, 4 Sep 2026 16:03:31 -0500 Subject: [PATCH 4/4] release: prepare v0.1.43 --- CHANGELOG.md | 30 ++ docs/README.md | 1 + .../codex-cli-0.153.3-gpt56-2026-09-04.md | 46 +++ docs/features.md | 4 +- .../packages/extensions/zed/extension.toml | 12 +- src/mendcode/packages/opencode/package.json | 2 +- .../opencode/script/dev-rearm-smoke.ts | 2 +- .../cmd/tui/component/agent-command-panel.tsx | 141 +++++++++ .../cli/cmd/tui/component/dialog-command.tsx | 2 +- .../src/cli/cmd/tui/routes/session/index.tsx | 290 +++++++++--------- .../cmd/tui/util/session-bottom-dock.test.ts | 10 +- .../cli/cmd/tui/util/session-bottom-dock.ts | 12 + .../packages/opencode/src/config/keybinds.ts | 2 +- .../opencode/src/mend/cli/control-plane.ts | 2 +- .../opencode/src/mend/config/models.ts | 60 +++- .../opencode/src/mend/prompt/compose.ts | 2 +- .../opencode/src/mend/prompt/sources.ts | 11 + .../packages/opencode/src/plugin/codex.ts | 41 ++- .../packages/opencode/src/provider/models.ts | 3 +- .../opencode/src/provider/openai-models.ts | 201 ++++++++++++ .../packages/opencode/src/session/prompt.ts | 65 +++- .../test/cli/tui/agent-command-panel.test.tsx | 140 +++++++++ .../opencode/test/mend/models-config.test.ts | 17 + .../test/mend/tui-prompt-chrome.test.ts | 2 +- .../opencode/test/plugin/codex.test.ts | 33 +- .../opencode/test/provider/provider.test.ts | 29 ++ .../opencode/test/session/prompt.test.ts | 139 +++++++++ 27 files changed, 1122 insertions(+), 177 deletions(-) create mode 100644 docs/audits/codex-cli-0.153.3-gpt56-2026-09-04.md create mode 100644 src/mendcode/packages/opencode/src/cli/cmd/tui/component/agent-command-panel.tsx create mode 100644 src/mendcode/packages/opencode/src/provider/openai-models.ts create mode 100644 src/mendcode/packages/opencode/test/cli/tui/agent-command-panel.test.tsx 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 }) {