diff --git a/CHANGELOG.md b/CHANGELOG.md index 31d1287..5321a3f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [6.9.1] - 2026-09-30 + +### Added + +- **Resume sessions from any directory.** `/resume` (and `orbcode --resume`) only listed sessions started in the current directory, so a conversation from another project was hard to find. The picker now shows this directory's sessions first and **Tab** switches to sessions from all directories, each labelled with where it lives; when there's nothing to resume here it opens on all directories. Resuming a session from another directory switches OrbCode's working directory to that session's directory and reloads the directory-bound state (project settings, MCP servers, project hook trust), so tools and `AGENTS.md` match the conversation. A session whose directory no longer exists shows an error instead of resuming. + +### Fixed + +- **No more deprecation warnings when installing or updating.** `openai@4` pulled in `node-fetch@2` → `whatwg-url@5` → `tr46`, which loads Node's deprecated built-in `punycode` (the `DEP0040` warning), and `formdata-node@4` → the deprecated `node-domexception` (the `npm warn deprecated` line on install/update). The `openai` SDK is upgraded to v7, which has no dependencies and uses Node's native `fetch`; the install drops from 203 to 180 packages. Updating *from* an older release can still show the `punycode` warning once, since that comes from the old version performing the update. + +- **No more empty "●" rows between thinking and tool calls.** Models often stream whitespace-only content (e.g. a couple of newlines) right before a tool call, which rendered as a bare "●" message. Whitespace-only content is no longer shown while streaming, committed as a transcript row, saved to the session, or sent back to the model as assistant text, and blank rows in previously saved sessions render as nothing. + +- **Picking GLM 5.3 Flash no longer reverts to another model.** The plan-aware default (the catalog's first model, on every plan while no catalog entry is flagged `freePlan`) was applied whenever the selected model equalled the static `DEFAULT_MODEL_ID` — which is `zai/glm-5.3-flash` — so choosing that model was immediately treated as "never chosen" and swapped for the catalog's first model. The selection now carries a persisted `modelExplicit` flag (set by `/model` picks, `settings.json` `model` and `MATTERAI_MODEL`; cleared by automatic plan defaults and fallbacks), and only a model that was never explicitly chosen is re-resolved, in both the TUI and headless mode. + ## [6.9.0] - 2026-09-30 ### Added diff --git a/package-lock.json b/package-lock.json index 2e2b282..bed5eb3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@matterailab/orbcode", - "version": "6.9.0", + "version": "6.9.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@matterailab/orbcode", - "version": "6.9.0", + "version": "6.9.1", "license": "MIT", "dependencies": { "@ai-sdk/anthropic": "^3.0.85", @@ -18,7 +18,7 @@ "bun": "1.3.14", "mammoth": "^1.12.0", "open": "^10.1.0", - "openai": "^4.78.0", + "openai": "^7.25.0", "pdf-parse": "^2.4.5", "picomatch": "4.0.4", "react": "^19.2.0", @@ -1369,21 +1369,12 @@ "version": "20.19.43", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz", "integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==", + "dev": true, "license": "MIT", "dependencies": { "undici-types": "~6.21.0" } }, - "node_modules/@types/node-fetch": { - "version": "2.6.13", - "resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.6.13.tgz", - "integrity": "sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw==", - "license": "MIT", - "dependencies": { - "@types/node": "*", - "form-data": "^4.0.4" - } - }, "node_modules/@types/picomatch": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/@types/picomatch/-/picomatch-3.0.2.tgz", @@ -1785,18 +1776,6 @@ "node": ">= 12" } }, - "node_modules/abort-controller": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", - "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", - "license": "MIT", - "dependencies": { - "event-target-shim": "^5.0.0" - }, - "engines": { - "node": ">=6.5" - } - }, "node_modules/accepts": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", @@ -1835,18 +1814,6 @@ "url": "https://opencollective.com/express" } }, - "node_modules/agentkeepalive": { - "version": "4.6.0", - "resolved": "https://registry.npmjs.org/agentkeepalive/-/agentkeepalive-4.6.0.tgz", - "integrity": "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==", - "license": "MIT", - "dependencies": { - "humanize-ms": "^1.2.1" - }, - "engines": { - "node": ">= 8.0.0" - } - }, "node_modules/ai": { "version": "6.0.207", "resolved": "https://registry.npmjs.org/ai/-/ai-6.0.207.tgz", @@ -1919,12 +1886,6 @@ "sprintf-js": "~1.0.2" } }, - "node_modules/asynckit": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", - "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", - "license": "MIT" - }, "node_modules/base64-js": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", @@ -2090,18 +2051,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/combined-stream": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", - "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", - "license": "MIT", - "dependencies": { - "delayed-stream": "~1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, "node_modules/content-disposition": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", @@ -2243,15 +2192,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/delayed-stream": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", - "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", - "license": "MIT", - "engines": { - "node": ">=0.4.0" - } - }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -2350,21 +2290,6 @@ "node": ">= 0.4" } }, - "node_modules/es-set-tostringtag": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", - "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.6", - "has-tostringtag": "^1.0.2", - "hasown": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/esbuild": { "version": "0.28.1", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", @@ -2422,15 +2347,6 @@ "node": ">= 0.6" } }, - "node_modules/event-target-shim": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", - "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/eventsource": { "version": "3.0.7", "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", @@ -2607,37 +2523,6 @@ "url": "https://opencollective.com/express" } }, - "node_modules/form-data": { - "version": "4.0.6", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", - "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", - "license": "MIT", - "dependencies": { - "asynckit": "^0.4.0", - "combined-stream": "^1.0.8", - "es-set-tostringtag": "^2.1.0", - "hasown": "^2.0.4", - "mime-types": "^2.1.35" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/form-data-encoder": { - "version": "1.7.2", - "resolved": "https://registry.npmjs.org/form-data-encoder/-/form-data-encoder-1.7.2.tgz", - "integrity": "sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A==", - "license": "MIT" - }, - "node_modules/formdata-node": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/formdata-node/-/formdata-node-6.0.3.tgz", - "integrity": "sha512-8e1++BCiTzUno9v5IZ2J6bv4RU+3UKDmqWUQD0MIMVCd9AdhWkO1gw57oo1mNEX1dMq2EGI+FbWz4B92pscSQg==", - "license": "MIT", - "engines": { - "node": ">= 18" - } - }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -2759,21 +2644,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/has-tostringtag": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", - "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", - "license": "MIT", - "dependencies": { - "has-symbols": "^1.0.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/hasown": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", @@ -2815,15 +2685,6 @@ "url": "https://opencollective.com/express" } }, - "node_modules/humanize-ms": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/humanize-ms/-/humanize-ms-1.2.1.tgz", - "integrity": "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==", - "license": "MIT", - "dependencies": { - "ms": "^2.0.0" - } - }, "node_modules/iconv-lite": { "version": "0.7.2", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", @@ -3061,27 +2922,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mime-types": { - "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "license": "MIT", - "dependencies": { - "mime-db": "1.52.0" - }, - "engines": { - "node": ">= 0.6" - } - }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -3097,26 +2937,6 @@ "node": ">= 0.6" } }, - "node_modules/node-fetch": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", - "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", - "license": "MIT", - "dependencies": { - "whatwg-url": "^5.0.0" - }, - "engines": { - "node": "4.x || >=6.0.0" - }, - "peerDependencies": { - "encoding": "^0.1.0" - }, - "peerDependenciesMeta": { - "encoding": { - "optional": true - } - } - }, "node_modules/node-int64": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", @@ -3184,27 +3004,34 @@ } }, "node_modules/openai": { - "version": "4.104.0", - "resolved": "https://registry.npmjs.org/openai/-/openai-4.104.0.tgz", - "integrity": "sha512-p99EFNsA/yX6UhVO93f5kJsDRLAg+CTA2RBqdHK4RtK8u5IJw32Hyb2dTGKbnnFmnuoBv5r7Z2CURI9sGZpSuA==", + "version": "7.25.0", + "resolved": "https://registry.npmjs.org/openai/-/openai-7.25.0.tgz", + "integrity": "sha512-cnk0d4QT58Vq0vl2KG+DCa/C2+cXthsK2rUeaqtprq33mKv9dlX8o7fWSS283wa2IE/aVdsRyX1+Dqwz551olg==", "license": "Apache-2.0", - "dependencies": { - "@types/node": "^18.11.18", - "@types/node-fetch": "^2.6.4", - "abort-controller": "^3.0.0", - "agentkeepalive": "^4.2.1", - "form-data-encoder": "1.7.2", - "formdata-node": "^4.3.2", - "node-fetch": "^2.6.7" - }, - "bin": { - "openai": "bin/cli" + "engines": { + "node": ">=22.0.0" }, "peerDependencies": { - "ws": "^8.18.0", - "zod": "^3.23.8" + "@aws-sdk/credential-provider-node": ">=3.972.0 <4", + "@smithy/hash-node": ">=4.3.0 <5", + "@smithy/signature-v4": ">=5.4.0 <6", + "undici": ">=5 <9", + "ws": "^8.21.0", + "zod": "^3.25 || ^4.0" }, "peerDependenciesMeta": { + "@aws-sdk/credential-provider-node": { + "optional": true + }, + "@smithy/hash-node": { + "optional": true + }, + "@smithy/signature-v4": { + "optional": true + }, + "undici": { + "optional": true + }, "ws": { "optional": true }, @@ -3213,21 +3040,6 @@ } } }, - "node_modules/openai/node_modules/@types/node": { - "version": "18.19.130", - "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.130.tgz", - "integrity": "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==", - "license": "MIT", - "dependencies": { - "undici-types": "~5.26.4" - } - }, - "node_modules/openai/node_modules/undici-types": { - "version": "5.26.5", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", - "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", - "license": "MIT" - }, "node_modules/option": { "version": "0.2.4", "resolved": "https://registry.npmjs.org/option/-/option-0.2.4.tgz", @@ -3792,12 +3604,6 @@ "node": ">=0.6" } }, - "node_modules/tr46": { - "version": "0.0.3", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", - "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", - "license": "MIT" - }, "node_modules/tsx": { "version": "4.22.4", "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.4.tgz", @@ -3896,6 +3702,7 @@ "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, "license": "MIT" }, "node_modules/unpipe": { @@ -3950,22 +3757,6 @@ } } }, - "node_modules/webidl-conversions": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", - "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", - "license": "BSD-2-Clause" - }, - "node_modules/whatwg-url": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", - "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", - "license": "MIT", - "dependencies": { - "tr46": "~0.0.3", - "webidl-conversions": "^3.0.0" - } - }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/package.json b/package.json index f8dc713..e1f5ac4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@matterailab/orbcode", - "version": "6.9.0", + "version": "6.9.1", "description": "OrbCode CLI — agentic coding in your terminal, by MatterAI", "type": "module", "bin": { @@ -39,7 +39,7 @@ "test:metrics": "node --import tsx --test test/metrics.test.ts", "test:json-repair": "node --import tsx --test test/json-repair.test.ts", "test:search": "node --import tsx --test test/search-files.test.ts", - "test:ui": "bun test test/ui-viewport.test.tsx", + "test:ui": "bun test test/ui-viewport.test.tsx test/session-picker.test.tsx", "prepublishOnly": "npm run typecheck && npm run build" }, "dependencies": { @@ -52,7 +52,7 @@ "bun": "1.3.14", "mammoth": "^1.12.0", "open": "^10.1.0", - "openai": "^4.78.0", + "openai": "^7.25.0", "pdf-parse": "^2.4.5", "picomatch": "4.0.4", "react": "^19.2.0", diff --git a/src/config/settings.ts b/src/config/settings.ts index dba8cf0..a60e852 100644 --- a/src/config/settings.ts +++ b/src/config/settings.ts @@ -13,6 +13,13 @@ export interface OrbCodeSettings { /** login token, written by `orbcode login` (config.json only) */ token?: string model: string + /** + * True once the user picked the model themselves (or pinned it via + * settings.json / MATTERAI_MODEL). The plan-aware default only replaces a + * model that was never chosen — comparing against DEFAULT_MODEL_ID can't tell + * "never chose" from "chose the model that happens to be the static default". + */ + modelExplicit?: boolean organizationId?: string autoApproveEdits: boolean autoApproveSafeCommands: boolean @@ -185,6 +192,7 @@ export function loadSettings(): OrbCodeSettings { for (const settingsPath of getSettingsPaths(cwd)) { const fileSettings = readJson(settingsPath) if (!fileSettings) continue + if (typeof fileSettings.model === "string") settings.modelExplicit = true for (const key of SETTINGS_KEYS) { if (fileSettings[key] !== undefined) { ;(settings as unknown as Record)[key] = fileSettings[key] @@ -231,10 +239,14 @@ export function loadSettings(): OrbCodeSettings { // Environment variables take precedence over all files. if (process.env.MATTERAI_BASE_URL) settings.baseUrl = process.env.MATTERAI_BASE_URL if (process.env.MATTERAI_API_KEY) settings.apiKey = process.env.MATTERAI_API_KEY - if (process.env.MATTERAI_MODEL) settings.model = process.env.MATTERAI_MODEL + if (process.env.MATTERAI_MODEL) { + settings.model = process.env.MATTERAI_MODEL + settings.modelExplicit = true + } if (!isValidAxonModel(settings.model)) { settings.model = DEFAULT_MODEL_ID + settings.modelExplicit = false } if (settings.theme !== "dark" && settings.theme !== "light") { settings.theme = DEFAULTS.theme @@ -257,6 +269,7 @@ export function saveSettings(settings: OrbCodeSettings): void { const toPersist = { token: settings.token, model: settings.model, + modelExplicit: settings.modelExplicit || undefined, organizationId: settings.organizationId, autoApproveEdits: settings.autoApproveEdits, autoApproveSafeCommands: settings.autoApproveSafeCommands, diff --git a/src/core/agent.ts b/src/core/agent.ts index 25a8e2d..bad8389 100644 --- a/src/core/agent.ts +++ b/src/core/agent.ts @@ -537,7 +537,8 @@ export class Agent { this.transcriptText += event.text break case "text-done": - if (this.transcriptText) { + // Whitespace-only content (common right before a tool call) is not a message. + if (this.transcriptText.trim()) { this.transcript.push({ kind: "assistant", text: this.transcriptText }) } this.transcriptText = "" @@ -569,10 +570,10 @@ export class Agent { this.transcript.push({ kind: "error", text: event.message }) break case "turn-end": - if (this.transcriptText) { + if (this.transcriptText.trim()) { this.transcript.push({ kind: "assistant", text: this.transcriptText }) - this.transcriptText = "" } + this.transcriptText = "" if (this.transcriptReasoning) { this.transcript.push({ kind: "reasoning", @@ -1337,7 +1338,7 @@ User time zone: ${timeZone}, UTC${timeZoneOffsetStr}` const assistantMessage: OpenAI.Chat.ChatCompletionAssistantMessageParam = { role: "assistant", - content: assistantText || null, + content: assistantText.trim() ? assistantText : null, } if (toolCalls.length > 0) { assistantMessage.tool_calls = toolCalls.map((tc) => ({ diff --git a/src/core/sessions.ts b/src/core/sessions.ts index 54a6b87..ce173f7 100644 --- a/src/core/sessions.ts +++ b/src/core/sessions.ts @@ -94,8 +94,8 @@ export function loadSessionById(id: string): SessionData | undefined { } } -/** Sessions for a workspace, most recently updated first. */ -export function listSessions(cwd: string): SessionData[] { +/** Sessions for a workspace (or every workspace when `cwd` is omitted), most recently updated first. */ +export function listSessions(cwd?: string): SessionData[] { let files: string[] try { files = fs.readdirSync(getSessionsDir()).filter((f) => f.endsWith(".json")) @@ -106,7 +106,7 @@ export function listSessions(cwd: string): SessionData[] { for (const file of files) { try { const data = JSON.parse(fs.readFileSync(path.join(getSessionsDir(), file), "utf8")) as SessionData - if (data.cwd === cwd && Array.isArray(data.messages) && data.messages.length > 0) { + if ((cwd === undefined || data.cwd === cwd) && Array.isArray(data.messages) && data.messages.length > 0) { sessions.push(data) } } catch { diff --git a/src/headless.ts b/src/headless.ts index 69600a2..4b2bf9b 100644 --- a/src/headless.ts +++ b/src/headless.ts @@ -41,7 +41,7 @@ export async function runHeadless( // No explicit model requested and the stored one is still the static default: // resolve the plan-aware default from the live catalog (free plans get the // catalog's free model, paid plans the first catalog entry). - if (token && !requestedModel && settings.model === DEFAULT_MODEL_ID) { + if (token && !requestedModel && !settings.modelExplicit && settings.model === DEFAULT_MODEL_ID) { const profile = await fetchProfile(token).catch(() => null) const plan = profile?.plan ?? profile?.tieredUsage?.plan const preferred = getDefaultModelId(plan) diff --git a/src/ui/App.tsx b/src/ui/App.tsx index defc77a..3a6b039 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -464,9 +464,10 @@ export function App({ const [queuedMessages, setQueuedMessages] = useState([]); const [modelPickerOpen, setModelPickerOpen] = useState(false); const [themePickerOpen, setThemePickerOpen] = useState(false); - const [resumableSessions, setResumableSessions] = useState< - SessionData[] | null - >(null); + const [resumableSessions, setResumableSessions] = useState<{ + here: SessionData[]; + everywhere: SessionData[]; + } | null>(null); const [taskPickerSessions, setTaskPickerSessions] = useState< SessionData[] | null >(null); @@ -688,7 +689,11 @@ export function App({ setBusyLabel("Working"); break; case "text-done": - pushRow({ kind: "assistant", text: textBufferRef.current }); + // Models often stream whitespace-only content before a tool call; + // don't render it as an empty "●" message. + if (textBufferRef.current.trim()) { + pushRow({ kind: "assistant", text: textBufferRef.current }); + } textBufferRef.current = ""; setStreamingText(""); setBusyLabel("Working"); @@ -744,7 +749,9 @@ export function App({ case "turn-end": // Flush anything still streaming (e.g. on interrupt). if (textBufferRef.current) { - pushRow({ kind: "assistant", text: textBufferRef.current }); + if (textBufferRef.current.trim()) { + pushRow({ kind: "assistant", text: textBufferRef.current }); + } textBufferRef.current = ""; setStreamingText(""); } @@ -883,6 +890,40 @@ export function App({ const handleResume = useCallback( (session: SessionData) => { setResumableSessions(null); + // A session from another directory continues in that directory, so its + // tools, AGENTS.md, project settings, hooks and MCP servers match the + // conversation. + const switchedDir = session.cwd && session.cwd !== process.cwd(); + if (switchedDir) { + try { + process.chdir(session.cwd); + } catch { + pushRow({ + kind: "error", + text: `Can't resume "${session.title || session.id}": its directory ${session.cwd} no longer exists.`, + }); + return; + } + const oldManager = mcpManagerRef.current; + mcpManagerRef.current = null; + void oldManager?.stop().catch(() => {}); + const refreshed = loadSettings(); + setSettings(refreshed); + if (getAuthToken(refreshed)) { + const manager = new McpManager( + process.cwd(), + refreshed.disabledMcpServers ?? [], + refreshed.enabledMcpServers ?? [], + ); + mcpManagerRef.current = manager; + void manager.start().then(() => { + const pendingMcp = manager.getPendingApproval(); + if (pendingMcp.length > 0) setPendingMcpApproval(pendingMcp); + }); + } + const pendingHooks = getPendingProjectHooks(process.cwd()); + if (pendingHooks) setPendingHookTrust(pendingHooks); + } const resumedAgent = createAgent(session); agentRef.current = resumedAgent; process.env.ORBCODE_LAST_SESSION_ID = resumedAgent.taskId; @@ -914,7 +955,7 @@ export function App({ } pushRow({ kind: "info", - text: `Resumed session: ${session.title || session.id}`, + text: `Resumed session: ${session.title || session.id}${switchedDir ? ` · working directory is now ${session.cwd}` : ""}`, }); }, [createAgent, pushRow, resetTranscript], @@ -935,7 +976,8 @@ export function App({ ); const switchModel = useCallback( - (modelId: string, options?: { silent?: boolean }) => { + /** `auto`: an automatic switch (plan default / plan fallback), not a user pick. */ + (modelId: string, options?: { silent?: boolean; auto?: boolean }) => { if (isLumenAxonModel(modelId) && !hasLumenAccess) { pushRow({ kind: "error", @@ -964,7 +1006,7 @@ export function App({ }); return; } - const updated = { ...loadSettings(), model: modelId }; + const updated = { ...loadSettings(), model: modelId, modelExplicit: !options?.auto }; setSettings(updated); saveSettings(updated); agentRef.current?.setModel(modelId); @@ -995,10 +1037,10 @@ export function App({ // an explicit user pick is never overwritten. useEffect(() => { if (!catalogReady || !planLoaded) return; - if (settings.model !== DEFAULT_MODEL_ID) return; + if (settings.modelExplicit || settings.model !== DEFAULT_MODEL_ID) return; const preferred = getDefaultModelId(activePlan); - if (preferred !== settings.model) switchModel(preferred, { silent: true }); - }, [activePlan, catalogReady, planLoaded, settings.model, switchModel]); + if (preferred !== settings.model) switchModel(preferred, { silent: true, auto: true }); + }, [activePlan, catalogReady, planLoaded, settings.model, settings.modelExplicit, switchModel]); useEffect(() => { if (!activePlan) { @@ -1008,24 +1050,24 @@ export function App({ // default Eido model; its 400k variant is covered by the Lumen check // first since the 232k Lumen fallback would still be locked. if (isLumenAxonModel(settings.model) && !hasLumenAccess) { - switchModel(DEFAULT_MODEL_ID); + switchModel(DEFAULT_MODEL_ID, { auto: true }); return; } // A stored Eido Base selection on a plan without access falls back to the // default Eido model; its 400k variant is covered by the Base check first. if (isEidoBaseAxonModel(settings.model) && !hasEidoBaseAccess) { - switchModel(DEFAULT_MODEL_ID); + switchModel(DEFAULT_MODEL_ID, { auto: true }); return; } // A stored Eido Pro selection on a plan without access falls back to the // default Eido model; its 400k variant is covered by the Eido Pro check // first since the 232k Eido Pro fallback would still be locked. if (isEidoProAxonModel(settings.model) && !hasEidoProAccess) { - switchModel(DEFAULT_MODEL_ID); + switchModel(DEFAULT_MODEL_ID, { auto: true }); return; } if (!has400kAccess && is400kAxonModel(settings.model)) { - switchModel(get232kAxonFallback(settings.model)); + switchModel(get232kAxonFallback(settings.model), { auto: true }); } }, [activePlan, has400kAccess, hasEidoBaseAccess, hasEidoProAccess, hasLumenAccess, settings.model, switchModel]); @@ -1164,17 +1206,14 @@ export function App({ break; } case "/resume": { - const sessions = listSessions(process.cwd()).filter( - (s) => s.id !== agentRef.current?.taskId, - ); - if (sessions.length === 0) { - pushRow({ - kind: "info", - text: "No previous sessions found for this directory.", - }); + const notCurrent = (s: SessionData) => s.id !== agentRef.current?.taskId; + const here = listSessions(process.cwd()).filter(notCurrent); + const everywhere = listSessions().filter(notCurrent); + if (everywhere.length === 0) { + pushRow({ kind: "info", text: "No previous sessions found." }); break; } - setResumableSessions(sessions); + setResumableSessions({ here, everywhere }); break; } case "/task": { @@ -1882,7 +1921,7 @@ export function App({ // Do not lay out the entire accumulated response on every token. Keep only // the live tail mounted; text-done commits the complete response to the // virtualized transcript, so nothing is lost from history. - const streamingTextDisplay = streamingText + const streamingTextDisplay = streamingText.trim() ? tailForHeight( streamingText, Math.max(1, contentHeight - 1 - spinnerHeight), @@ -2261,7 +2300,10 @@ export function App({ )} {resumableSessions && ( setResumableSessions(null)} /> @@ -2440,7 +2482,8 @@ function estimateRowLines(row: Row, width: number): number { 1 + formatUserBlock(row.text, w, row.attachments).split("\n").length ); case "assistant": - return 1 + wrapped(`● ${row.text}`); + // Blank messages render nothing (see rows.tsx). + return row.text.trim() ? 1 + wrapped(`● ${row.text}`) : 0; case "reasoning": return row.expanded ? 2 + wrapped(row.text, w - 2) : 2; case "tool": { diff --git a/src/ui/components/SessionPicker.tsx b/src/ui/components/SessionPicker.tsx index e67012a..ae6688c 100644 --- a/src/ui/components/SessionPicker.tsx +++ b/src/ui/components/SessionPicker.tsx @@ -1,4 +1,5 @@ import React, { useState } from "react" +import * as os from "node:os" import { Box, Text, useInput } from "../primitives.js" import { COLORS } from "../../branding.js" @@ -8,12 +9,27 @@ import { PopoverBox } from "./PopoverBox.js" const VISIBLE_ROWS = 8 interface SessionPickerProps { + /** sessions from the current directory */ sessions: SessionData[] + /** sessions from every directory; when given, Tab switches between the two lists */ + allSessions?: SessionData[] + /** start on the all-directories list (e.g. nothing to resume here) */ + initialShowAll?: boolean + /** current directory; sessions elsewhere show their directory */ + cwd?: string onSelect: (session: SessionData) => void onCancel: () => void title?: string } +/** Shorten a directory for display: `~` for home, and at most the last 3 segments. */ +function shortDir(dir: string): string { + const home = os.homedir() + const withHome = dir === home || dir.startsWith(home + "/") ? "~" + dir.slice(home.length) : dir + const parts = withHome.split("/") + return parts.length > 4 ? "…/" + parts.slice(-3).join("/") : withHome +} + function relativeTime(iso: string): string { const ms = Date.now() - new Date(iso).getTime() const minutes = Math.round(ms / 60_000) @@ -25,10 +41,29 @@ function relativeTime(iso: string): string { return `${days}d ago` } -export function SessionPicker({ sessions, onSelect, onCancel, title = "Resume a previous session" }: SessionPickerProps) { +export function SessionPicker({ + sessions: localSessions, + allSessions, + initialShowAll = false, + cwd, + onSelect, + onCancel, + title = "Resume a previous session", +}: SessionPickerProps) { const [selected, setSelected] = useState(0) + const [showAll, setShowAll] = useState(initialShowAll && allSessions !== undefined) + const sessions = showAll && allSessions ? allSessions : localSessions useInput((input, key) => { + if (key.tab && allSessions) { + setShowAll((value) => !value) + setSelected(0) + return + } + if (sessions.length === 0) { + if (key.escape) onCancel() + return + } if (key.upArrow) { setSelected((s) => (s - 1 + sessions.length) % sessions.length) return @@ -58,7 +93,11 @@ export function SessionPicker({ sessions, onSelect, onCancel, title = "Resume a {title} + {allSessions && · {showAll ? "all directories" : "this directory"}} + {sessions.length === 0 && ( + No sessions {showAll ? "yet" : "in this directory"}. + )} {windowStart > 0 && ↑ {windowStart} more} {visible.map((session, i) => { const index = windowStart + i @@ -71,6 +110,7 @@ export function SessionPicker({ sessions, onSelect, onCancel, title = "Resume a {" "} · {relativeTime(session.updatedAt)} · {userTurns} message{userTurns === 1 ? "" : "s"} + {showAll && session.cwd !== cwd ? ` · ${shortDir(session.cwd)}` : ""} ) @@ -78,7 +118,9 @@ export function SessionPicker({ sessions, onSelect, onCancel, title = "Resume a {windowStart + VISIBLE_ROWS < sessions.length && ( ↓ {sessions.length - windowStart - VISIBLE_ROWS} more )} - ↑/↓ select · enter resume · esc cancel + + ↑/↓ select · enter resume{allSessions ? ` · tab ${showAll ? "this directory" : "all directories"}` : ""} · esc cancel + ) } diff --git a/src/ui/components/rows.tsx b/src/ui/components/rows.tsx index 60d195b..93b9255 100644 --- a/src/ui/components/rows.tsx +++ b/src/ui/components/rows.tsx @@ -225,6 +225,8 @@ export const RowView = React.memo(function RowView({ row, width }: { row: Row; w ) case "assistant": + // Sessions saved before blank content was filtered can still hold these. + if (!row.text.trim()) return null return ( diff --git a/test/agent-context.test.ts b/test/agent-context.test.ts index 6423c96..9831517 100644 --- a/test/agent-context.test.ts +++ b/test/agent-context.test.ts @@ -201,3 +201,33 @@ test("a failing auto-compaction is reported once and does not derail the turn", assert.equal(client.summaryCalls, 1) assert.equal(events.filter((e) => /Auto-compaction failed/.test(e)).length, 1) }) + +test("whitespace-only content before a tool call is not kept as an assistant message", async () => { + class BlankThenTool extends ScriptedClient { + private calls = 0 + async *createMessage( + system: string, + messages: Messages, + tools: OpenAI.Chat.ChatCompletionTool[], + ): AsyncGenerator { + if (this.calls++ === 0) { + this.requests.push({ messages: structuredClone(messages), toolCount: tools.length }) + yield { type: "text", text: "\n\n" } + yield { + type: "native_tool_calls", + toolCalls: [{ index: 0, id: "c1", type: "function", function: { name: READ_BIG.name, arguments: JSON.stringify(READ_BIG.args) } }], + } + yield { type: "usage", inputTokens: 1000, outputTokens: 5, totalCost: 0 } + return + } + yield* super.createMessage(system, messages, tools) + } + } + const client = new BlankThenTool([{ text: "All done." }]) + const { agent } = makeAgent(client) + await agent.runTurn("go") + const assistantTexts = agent.displayTranscript.filter((e) => e.kind === "assistant").map((e) => (e as { text: string }).text) + assert.deepEqual(assistantTexts, ["All done."]) + const sentAssistant = client.requests.at(-1)!.messages.find((m) => m.role === "assistant")! + assert.equal(sentAssistant.content, null) +}) diff --git a/test/axon-auto-models.test.ts b/test/axon-auto-models.test.ts index a0eab55..fc0b590 100644 --- a/test/axon-auto-models.test.ts +++ b/test/axon-auto-models.test.ts @@ -2,41 +2,12 @@ import assert from "node:assert/strict" import test from "node:test" import { - BUILTIN_AXON_MODELS, get232kAxonFallback, - getGatewayModelId, is400kAxonModel, } from "../src/api/models.js" -test("Axon Auto exposes 232K and 400K dynamic-pricing choices", () => { - const autoDefault = BUILTIN_AXON_MODELS["axon-auto-232k"] - const auto400k = BUILTIN_AXON_MODELS["axon-auto-400k"] - - assert.equal(autoDefault.contextWindow, 232_000) - assert.equal(auto400k.contextWindow, 400_000) - assert.equal(autoDefault.pricingLabel, "dynamic pricing") - assert.equal(auto400k.pricingLabel, "dynamic pricing") - assert.equal(autoDefault.free, false) - assert.equal(auto400k.free, false) - assert.equal(getGatewayModelId(autoDefault), "axon-auto") - assert.equal(getGatewayModelId(auto400k), "axon-auto") -}) - test("Auto 400K uses the existing extended-context gate and fallback", () => { assert.equal(is400kAxonModel("axon-auto-400k"), true) assert.equal(is400kAxonModel("axon-auto-232k"), false) assert.equal(get232kAxonFallback("axon-auto-400k"), "axon-auto-232k") }) - -test("Axon Eido 3.2 Flash exposes a 400K variant through the same gates", () => { - const flash400k = BUILTIN_AXON_MODELS["axon-eido-3.2-flash-400k"] - - assert.equal(flash400k.contextWindow, 400_000) - assert.equal(flash400k.maxOutputTokens, 64_000) - assert.equal(getGatewayModelId(flash400k), "axon-eido-3.2-flash") - assert.equal(is400kAxonModel("axon-eido-3.2-flash-400k"), true) - assert.equal(is400kAxonModel("axon-eido-3.2-flash"), false) - // The default-tier Flash option uses the bare id (no "-232k" suffix), so the - // fallback must map to that exact id rather than the generic -232k form. - assert.equal(get232kAxonFallback("axon-eido-3.2-flash-400k"), "axon-eido-3.2-flash") -}) diff --git a/test/models.test.ts b/test/models.test.ts index 5485941..dfc83a7 100644 --- a/test/models.test.ts +++ b/test/models.test.ts @@ -2,47 +2,17 @@ import assert from "node:assert/strict"; import test from "node:test"; import { - BUILTIN_AXON_MODELS, - DEFAULT_MODEL_ID, canUse400kContext, canUseEidoBaseModels, canUseEidoProModels, canUseLumenModels, get232kAxonFallback, - getGatewayModelId, is400kAxonModel, isEidoBaseAxonModel, isEidoProAxonModel, isLumenAxonModel, } from "../src/api/models.js"; -for (const tier of ["pro", "base"] as const) { - test(`Axon Eido 3.2 ${tier} exposes default and 400k local options`, () => { - const baseId = - tier === "pro" ? "axon-eido-3.2-code-pro" : "axon-eido-3.2-code"; - const modelDefault = BUILTIN_AXON_MODELS[`${baseId}-232k`]; - const model400k = BUILTIN_AXON_MODELS[`${baseId}-400k`]; - - assert.equal(modelDefault.contextWindow, 232000); - assert.equal(model400k.contextWindow, 400000); - assert.equal(getGatewayModelId(modelDefault), baseId); - assert.equal(getGatewayModelId(model400k), baseId); - - const sharedMetadata = ({ - id: _id, - name: _name, - contextWindow: _contextWindow, - ...metadata - }: typeof modelDefault) => metadata; - - assert.deepEqual(sharedMetadata(modelDefault), sharedMetadata(model400k)); - }); -} - -test("Axon Auto is the default model", () => { - assert.equal(DEFAULT_MODEL_ID, "axon-auto-232k"); -}); - test("400k context is limited to Pro Plus and Ultra plans", () => { for (const plan of ["Pro Plus", "pro_plus", "pro-plus", "ULTRA"]) { assert.equal(canUse400kContext(plan), true); @@ -52,17 +22,6 @@ test("400k context is limited to Pro Plus and Ultra plans", () => { } }); -test("Axon Lumen 4 exposes 232k and 400k local options", () => { - const baseId = "axon-lumen-4-code"; - const modelDefault = BUILTIN_AXON_MODELS[`${baseId}-232k`]; - const model400k = BUILTIN_AXON_MODELS[`${baseId}-400k`]; - - assert.equal(modelDefault.contextWindow, 232000); - assert.equal(model400k.contextWindow, 400000); - assert.equal(getGatewayModelId(modelDefault), baseId); - assert.equal(getGatewayModelId(model400k), baseId); -}); - test("restricted Axon models map to their default-context variants", () => { assert.equal(is400kAxonModel("axon-eido-3.2-code-400k"), true); assert.equal(is400kAxonModel("axon-lumen-4-code-400k"), true); diff --git a/test/session-picker.test.tsx b/test/session-picker.test.tsx new file mode 100644 index 0000000..e98189e --- /dev/null +++ b/test/session-picker.test.tsx @@ -0,0 +1,119 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import React, { act } from "react"; +import { testRender } from "@opentui/react/test-utils"; + +import { SessionPicker } from "../src/ui/components/SessionPicker.js"; +import type { SessionData } from "../src/core/sessions.js"; + +function session(id: string, title: string, cwd: string, minutesAgo: number): SessionData { + const at = new Date(Date.now() - minutesAgo * 60_000).toISOString(); + return { + id, + cwd, + model: "m", + title, + createdAt: at, + updatedAt: at, + totalCost: 0, + contextTokens: 0, + todos: "", + messages: [{ role: "user", content: "hi" }], + } as SessionData; +} + +const HERE = "/work/app"; +const here = [session("a", "fix login bug", HERE, 5)]; +const everywhere = [session("b", "billing refactor", "/work/billing-service", 1), ...here]; + +async function renderPicker(props: Partial>) { + const selected: SessionData[] = []; + const screen = await testRender( + selected.push(s)} + onCancel={() => {}} + {...props} + />, + { width: 110, height: 12 }, + ); + return { screen, selected }; +} + +test("resume picker lists this directory first and Tab switches to all directories", async () => { + const { screen, selected } = await renderPicker({}); + try { + await screen.renderOnce(); + let frame = screen.captureCharFrame(); + assert.match(frame, /this directory/); + assert.match(frame, /fix login bug/); + assert.doesNotMatch(frame, /billing refactor/); + assert.match(frame, /tab all directories/); + + await act(async () => { + screen.mockInput.pressTab(); + await screen.flush(); + }); + await screen.renderOnce(); + frame = screen.captureCharFrame(); + assert.match(frame, /all directories/); + assert.match(frame, /billing refactor/); + // Sessions from other directories show where they live; local ones don't. + assert.match(frame, /billing refactor.*\/work\/billing-service/); + assert.doesNotMatch(frame, /fix login bug.*\/work\/app/); + + await act(async () => { + screen.mockInput.pressEnter(); + await screen.flush(); + }); + assert.deepEqual(selected.map((s) => s.id), ["b"]); + } finally { + act(() => screen.renderer.destroy()); + } +}); + +test("resume picker opens on all directories when this directory has no sessions", async () => { + const { screen } = await renderPicker({ sessions: [], initialShowAll: true }); + try { + await screen.renderOnce(); + const frame = screen.captureCharFrame(); + assert.match(frame, /all directories/); + assert.match(frame, /billing refactor/); + } finally { + act(() => screen.renderer.destroy()); + } +}); + +test("pickers without allSessions keep the old single-list behavior", async () => { + const { screen } = await renderPicker({ allSessions: undefined }); + try { + await screen.renderOnce(); + const frame = screen.captureCharFrame(); + assert.doesNotMatch(frame, /directories|tab /); + assert.match(frame, /fix login bug/); + } finally { + act(() => screen.renderer.destroy()); + } +}); + +test("blank assistant content does not render an empty ● row", async () => { + const { RowView } = await import("../src/ui/components/rows.js"); + const { Box } = await import("../src/ui/primitives.js"); + const screen = await testRender( + + + + , + { width: 60, height: 8 }, + ); + try { + await screen.renderOnce(); + const frame = screen.captureCharFrame(); + assert.equal((frame.match(/●/g) ?? []).length, 1); + assert.match(frame, /● Done\./); + } finally { + act(() => screen.renderer.destroy()); + } +}); diff --git a/test/sessions.test.ts b/test/sessions.test.ts new file mode 100644 index 0000000..3e4cf21 --- /dev/null +++ b/test/sessions.test.ts @@ -0,0 +1,33 @@ +import assert from "node:assert/strict" +import test from "node:test" +import * as fs from "node:fs" +import * as os from "node:os" +import * as path from "node:path" + +import { listSessions, saveSession, type SessionData } from "../src/core/sessions.js" + +function save(id: string, cwd: string, updatedAt: string): void { + saveSession({ + id, + cwd, + model: "m", + title: id, + createdAt: updatedAt, + updatedAt, + totalCost: 0, + contextTokens: 0, + todos: "", + messages: [{ role: "user", content: "hi" }], + } as SessionData) +} + +test("listSessions filters by directory, or lists every directory when none is given", () => { + process.env.MATTERAI_CONFIG_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "orbcode-sessions-")) + save("old-here", "/work/app", "2026-09-01T00:00:00.000Z") + save("new-elsewhere", "/work/other", "2026-09-03T00:00:00.000Z") + save("mid-here", "/work/app", "2026-09-02T00:00:00.000Z") + + assert.deepEqual(listSessions("/work/app").map((s) => s.id), ["mid-here", "old-here"]) + assert.deepEqual(listSessions().map((s) => s.id), ["new-elsewhere", "mid-here", "old-here"]) + assert.deepEqual(listSessions("/nowhere"), []) +}) diff --git a/test/settings-model.test.ts b/test/settings-model.test.ts new file mode 100644 index 0000000..48165dd --- /dev/null +++ b/test/settings-model.test.ts @@ -0,0 +1,62 @@ +import assert from "node:assert/strict" +import test from "node:test" +import * as fs from "node:fs" +import * as os from "node:os" +import * as path from "node:path" + +import { DEFAULT_MODEL_ID } from "../src/api/models.js" +import { loadSettings, saveSettings } from "../src/config/settings.js" + +function isolate(): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "orbcode-settings-")) + process.env.MATTERAI_CONFIG_DIR = dir + delete process.env.MATTERAI_MODEL + process.chdir(dir) + return dir +} + +test("a fresh install has no explicit model", () => { + isolate() + const settings = loadSettings() + assert.equal(settings.model, DEFAULT_MODEL_ID) + assert.ok(!settings.modelExplicit) +}) + +test("explicitly picking the static default model survives a save/load round-trip", () => { + isolate() + saveSettings({ ...loadSettings(), model: DEFAULT_MODEL_ID, modelExplicit: true }) + const settings = loadSettings() + assert.equal(settings.model, DEFAULT_MODEL_ID) + assert.equal(settings.modelExplicit, true) +}) + +test("an automatic switch is not persisted as explicit", () => { + const dir = isolate() + saveSettings({ ...loadSettings(), model: DEFAULT_MODEL_ID, modelExplicit: false }) + assert.equal(JSON.parse(fs.readFileSync(path.join(dir, "config.json"), "utf8")).modelExplicit, undefined) + assert.ok(!loadSettings().modelExplicit) +}) + +test("MATTERAI_MODEL pins the model", () => { + isolate() + process.env.MATTERAI_MODEL = DEFAULT_MODEL_ID + try { + assert.equal(loadSettings().modelExplicit, true) + } finally { + delete process.env.MATTERAI_MODEL + } +}) + +test("a model set in settings.json is explicit", () => { + const dir = isolate() + fs.writeFileSync(path.join(dir, "settings.json"), JSON.stringify({ model: DEFAULT_MODEL_ID })) + assert.equal(loadSettings().modelExplicit, true) +}) + +test("an unknown stored model falls back to the default and is not explicit", () => { + isolate() + saveSettings({ ...loadSettings(), model: "no-such-model", modelExplicit: true }) + const settings = loadSettings() + assert.equal(settings.model, DEFAULT_MODEL_ID) + assert.ok(!settings.modelExplicit) +})