From a591320409c6c966820bcc3363dac4970709dcf7 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Wed, 7 Oct 2026 16:38:38 +0200 Subject: [PATCH 1/5] docs(changelog): announce the plugin registry in 0.11.0 --- CHANGELOG.md | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6b60e3d0ba..fc8c485ebe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## 0.11.0 - 2026-10-07 +Paseo 0.11 adds support for the [plugin registry](https://paseo.sh/plugins). Browse community plugins there, install one with `paseo plugin add owner/slug`, or [submit your own](https://paseo.sh/docs/plugins/publishing#plugin-registry). + +**Before upgrading:** `paseo plugin add owner/repo` now installs from the registry, so use `github:owner/repo` to install straight from GitHub and `./path` for a local directory. + ### Added - Added Muse Code as a provider, with model, approval mode, and reasoning effort controls ([#5719](https://github.com/getpaseo/paseo/pull/5719), [#5775](https://github.com/getpaseo/paseo/pull/5775) by [@millerben95](https://github.com/millerben95)) @@ -19,18 +23,21 @@ - Added syntax highlighting for Vue files ([#6205](https://github.com/getpaseo/paseo/pull/6205) by [@Davnn1](https://github.com/Davnn1)) - Added `agents.providers..options` in `config.json` as default options for every provider, with per-agent overrides ([#5780](https://github.com/getpaseo/paseo/pull/5780)) - Added the terminal key bar and Paste to terminal panes on iPad and Android tablets ([#5712](https://github.com/getpaseo/paseo/pull/5712) by [@lardissone](https://github.com/lardissone)) -- Added screens and sidebar header and footer items to the plugin SDK ([#5685](https://github.com/getpaseo/paseo/pull/5685)) -- Added a display name, icon, screenshots, and videos to the plugin manifest ([#6263](https://github.com/getpaseo/paseo/pull/6263)) -- Added an `OVERVIEW.md` template to `paseo plugin init` for plugin registry listings ([#6226](https://github.com/getpaseo/paseo/pull/6226)) + +### Plugins + +- Changed `paseo plugin add owner/slug` to install the reviewed plugin from the plugin registry, so local directories need a path such as `./slug` and direct GitHub installs need `github:owner/repo` ([#6224](https://github.com/getpaseo/paseo/pull/6224)) +- Added [screens and sidebar header and footer items](https://paseo.sh/docs/plugins/reference#screens-and-sidebar-items) to the plugin SDK ([#5685](https://github.com/getpaseo/paseo/pull/5685)) - Added usage sources to the plugin SDK ([#5465](https://github.com/getpaseo/paseo/pull/5465), [#5876](https://github.com/getpaseo/paseo/pull/5876)) - Added `spawnProcess()`, `execCommand()`, and `terminateProcess()` to the plugin SDK for launching CLIs, including Windows `.cmd` and `.bat` scripts ([#6151](https://github.com/getpaseo/paseo/pull/6151)) - Added `client.playAudio()` to the plugin SDK for playing audio on every client ([#5976](https://github.com/getpaseo/paseo/pull/5976)) - Added an `agent.closed` plugin lifecycle event ([#5971](https://github.com/getpaseo/paseo/pull/5971) by [@ilteoood](https://github.com/ilteoood)) - Added command and environment overrides and availability diagnostics for plugin providers ([#5707](https://github.com/getpaseo/paseo/pull/5707)) +- Added a display name, icon, screenshots, and videos to the plugin manifest ([#6263](https://github.com/getpaseo/paseo/pull/6263)) +- Added an `OVERVIEW.md` template to `paseo plugin init` for plugin registry listings ([#6226](https://github.com/getpaseo/paseo/pull/6226)) ### Changed -- Changed `paseo plugin add owner/slug` to install the reviewed plugin from the plugin registry, so local directories need a path such as `./slug` and direct GitHub installs need `github:owner/repo` ([#6224](https://github.com/getpaseo/paseo/pull/6224)) - Changed the context window details to show usage for the account the agent runs under, including custom `CLAUDE_CONFIG_DIR` and `CODEX_HOME` homes ([#5465](https://github.com/getpaseo/paseo/pull/5465)) - Changed the context window details on desktop to a hover card with Refresh ([#5975](https://github.com/getpaseo/paseo/pull/5975), [#6185](https://github.com/getpaseo/paseo/pull/6185)) - Changed the context window meter to show before an agent's first turn ([#6089](https://github.com/getpaseo/paseo/pull/6089)) From d46164ce0d45fb6fc579313b68c462ac0d97d1fe Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Wed, 7 Oct 2026 17:17:17 +0200 Subject: [PATCH 2/5] fix(claude): keep long-running tool calls out of the subagents track (#6308) Claude Code sends a tool_progress heartbeat every 30 s while a tool is still running, with that tool's own id as parent_tool_use_id. The Claude adapter routed any frame with a parent id to the sidechain tracker, so a main-thread call that ran past 30 s (MCP, WebFetch, WebSearch) became a "Claude subagent" row with a child action per heartbeat that never completes, and its card was relabeled Task. A heartbeat now names no sidechain, so it never reaches subagent routing. Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com> --- .../claude/agent.sub-agent-sidechain.test.ts | 71 +++++++++++++++++++ .../server/agent/providers/claude/agent.ts | 5 ++ 2 files changed, 76 insertions(+) diff --git a/packages/server/src/server/agent/providers/claude/agent.sub-agent-sidechain.test.ts b/packages/server/src/server/agent/providers/claude/agent.sub-agent-sidechain.test.ts index efb452e9bf..84293afa6a 100644 --- a/packages/server/src/server/agent/providers/claude/agent.sub-agent-sidechain.test.ts +++ b/packages/server/src/server/agent/providers/claude/agent.sub-agent-sidechain.test.ts @@ -587,4 +587,75 @@ describe("ClaudeAgentSession sub-agent sidechain updates", () => { expect(latest.detail.log).toContain("[Read] file-6.md"); expect(latest.detail.log).toContain("[Read] file-205.md"); }); + + test("keeps a long main-thread tool call out of the subagents track", async () => { + // Claude Code sends a heartbeat every 30 s while a tool runs, with the tool's own id as + // parent_tool_use_id. Frames captured from Claude Code 2.1.291 for a 40 s MCP call. + queryFactory.mockImplementation(() => + buildQueryMock([ + { + type: "system", + subtype: "init", + session_id: "heartbeat-session", + permissionMode: "bypassPermissions", + model: "opus", + }, + { + type: "assistant", + parent_tool_use_id: null, + message: { + id: "msg-heartbeat-1", + role: "assistant", + content: [ + { type: "tool_use", id: "toolu_slow", name: "mcp__slow__slow_check", input: {} }, + ], + }, + }, + { + type: "tool_progress", + tool_use_id: "toolu_slow-heartbeat-0", + tool_name: "mcp__slow__slow_check", + parent_tool_use_id: "toolu_slow", + elapsed_time_seconds: 30, + heartbeat: true, + }, + { + type: "user", + parent_tool_use_id: null, + message: { + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: "toolu_slow", + content: [{ type: "text", text: "SLOW_CHECK_OK" }], + is_error: false, + }, + ], + }, + }, + { + type: "result", + subtype: "success", + usage: { input_tokens: 1, cache_read_input_tokens: 0, output_tokens: 1 }, + total_cost_usd: 0, + }, + ]), + ); + const session = await new ClaudeAgentClient({ + logger, + queryFactory, + resolveBinary: async () => "/test/claude/bin", + }).createSession({ provider: "claude", cwd: process.cwd() }); + + const events = await collectUntilTerminal(streamSession(session, "run the slow check")); + await session.close(); + + expect(events.filter((event) => event.type === "provider_subagent")).toEqual([]); + const toolCallNames = events.flatMap((event) => + event.type === "timeline" && event.item.type === "tool_call" ? [event.item.name] : [], + ); + expect(toolCallNames).toContain("mcp__slow__slow_check"); + expect(toolCallNames).not.toContain("Task"); + }); }); diff --git a/packages/server/src/server/agent/providers/claude/agent.ts b/packages/server/src/server/agent/providers/claude/agent.ts index 08640f472f..b797ce2c94 100644 --- a/packages/server/src/server/agent/providers/claude/agent.ts +++ b/packages/server/src/server/agent/providers/claude/agent.ts @@ -1916,6 +1916,11 @@ function readClaudeParentToolUseId(message: SDKMessage): string | null { if (!("parent_tool_use_id" in message)) { return null; } + // Claude Code's liveness tick for a tool still running after 30 s names that tool as its + // parent, so a long main-thread call would otherwise read as a subagent. + if (message.type === "tool_progress" && message.heartbeat === true) { + return null; + } const parentToolUseId = (message as { parent_tool_use_id?: unknown }).parent_tool_use_id; return typeof parentToolUseId === "string" && parentToolUseId.length > 0 ? parentToolUseId : null; } From 1ae979d3d04042f67c2e95921a07e910588dcd00 Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Wed, 7 Oct 2026 18:30:20 +0200 Subject: [PATCH 3/5] Subagents that finish while the app is disconnected no longer stay "working" (#6316) * fix(app): catch up provider subagents after the update feed reconnects Subagent updates are not replayed across a reconnect, so a child that finished while the app was disconnected stayed "working" until one subagent was opened and its parent was listed again. Re-list every parent the app holds once the update feed resubscribes. Closes #6109 * fix(app): resync a subagent parent even when its first list failed A parent's subagent track lists the parent once on mount. When that list failed because the app was disconnected, the parent was never tracked, so the reconnect resync skipped it and children that later arrived via live updates stayed stale after the next drop. Track the parent when its list is requested instead of when it succeeds. * test(website): expect the plugin directory links now that they are on 1b88f1612 turned PLUGINS_LINKED on, but the e2e test still asserted the directory was unlinked, failing the typecheck job on main and every PR. Assert the navigation, footer, and Browse plugins links point at /plugins. The header check now targets the navigation landmark the homepage renders; the banner locator matched nothing, so it only passed while asserting zero. --------- Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com> --- packages/app/src/contexts/session-context.tsx | 13 ++- .../app/src/subagents/provider-store.test.ts | 97 +++++++++++++++++++ packages/app/src/subagents/provider-store.ts | 33 +++++++ packages/website/e2e/plugins.spec.ts | 11 +-- 4 files changed, 144 insertions(+), 10 deletions(-) diff --git a/packages/app/src/contexts/session-context.tsx b/packages/app/src/contexts/session-context.tsx index 3e8165146c..430e4ade74 100644 --- a/packages/app/src/contexts/session-context.tsx +++ b/packages/app/src/contexts/session-context.tsx @@ -50,7 +50,7 @@ import { useToast } from "@/contexts/toast-context"; import { toErrorMessage } from "@/utils/error-messages"; import { showProviderNoticeToast } from "@/utils/provider-notice-toast"; import { applyCheckoutStatusUpdateFromEvent } from "@/git/checkout-status-cache"; -import { useProviderSubagentStore } from "@/subagents/provider-store"; +import { resyncProviderSubagents, useProviderSubagentStore } from "@/subagents/provider-store"; // Re-export types from session-store and draft-store for backward compatibility export type { DraftInput } from "@/stores/draft-store"; @@ -532,9 +532,14 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider } }); - const unsubProviderSubagentUpdate = onFeed("agent.provider_subagents.update", (message) => { - if (message.type !== "agent.provider_subagents.update") return; - useProviderSubagentStore.getState().applyUpdate(serverId, message.payload); + const unsubProviderSubagentUpdate = feeds.subscribe({ + snapshot: () => { + void resyncProviderSubagents(client, serverId); + }, + update: (message) => { + if (message.type !== "agent.provider_subagents.update") return; + useProviderSubagentStore.getState().applyUpdate(serverId, message.payload); + }, }); const unsubCheckoutStatusUpdate = onFeed("checkout_status_update", (message) => { diff --git a/packages/app/src/subagents/provider-store.test.ts b/packages/app/src/subagents/provider-store.test.ts index 96badfd354..01d6880d5b 100644 --- a/packages/app/src/subagents/provider-store.test.ts +++ b/packages/app/src/subagents/provider-store.test.ts @@ -1,8 +1,11 @@ import { DaemonConnectionError } from "@getpaseo/client/internal/daemon-client"; import { afterEach, describe, expect, test } from "vitest"; +import type { ProviderSubagentDescriptorPayload } from "@getpaseo/protocol/messages"; import { observeProviderSubagentTimeline, providerSubagentKey, + refreshProviderSubagents, + resyncProviderSubagents, useProviderSubagentStore, } from "./provider-store"; @@ -15,6 +18,7 @@ afterEach(() => { descriptors: new Map(), timelines: new Map(), hiddenFromTrack: new Set(), + trackedParents: new Set(), }); }); @@ -727,3 +731,96 @@ describe("projected child history", () => { expect(current().lastSeq).toBe(2000); }); }); + +describe("resyncing after the update feed resubscribes", () => { + function child( + id: string, + parentAgentId: string, + status: ProviderSubagentDescriptorPayload["status"], + ): ProviderSubagentDescriptorPayload { + return { + id, + parentAgentId, + provider: "claude", + title: "Explore", + description: null, + status, + createdAt: "2026-10-07T10:00:00.000Z", + updatedAt: "2026-10-07T10:00:00.000Z", + toolCallId: id, + }; + } + + function fakeDaemon(lists: Record) { + const requested: string[] = []; + return { + requested, + lists, + client: { + listProviderSubagents: async (parentAgentId: string) => { + requested.push(parentAgentId); + return { + requestId: "list", + parentAgentId, + subagents: lists[parentAgentId] ?? [], + error: null, + }; + }, + }, + }; + } + + const status = (parentAgentId: string, subagentId: string) => + useProviderSubagentStore + .getState() + .descriptors.get(providerSubagentKey(SERVER_ID, parentAgentId, subagentId))?.status; + + test("catches up on children that finished or started while updates were not delivered", async () => { + const daemon = fakeDaemon({ [PARENT_ID]: [child("child-1", PARENT_ID, "running")] }); + await refreshProviderSubagents(daemon.client, SERVER_ID, PARENT_ID); + expect(status(PARENT_ID, "child-1")).toBe("running"); + + // The connection dropped: the daemon finished child-1 and ran child-2 without telling us. + daemon.lists[PARENT_ID] = [ + child("child-1", PARENT_ID, "completed"), + child("child-2", PARENT_ID, "completed"), + ]; + await resyncProviderSubagents(daemon.client, SERVER_ID); + + expect(status(PARENT_ID, "child-1")).toBe("completed"); + expect(status(PARENT_ID, "child-2")).toBe("completed"); + }); + + test("catches up on a parent whose first list failed while disconnected", async () => { + const daemon = fakeDaemon({}); + const disconnected = { + listProviderSubagents: async () => { + throw new DaemonConnectionError("Transport not connected"); + }, + }; + await expect( + refreshProviderSubagents(disconnected, SERVER_ID, PARENT_ID), + ).rejects.toBeInstanceOf(DaemonConnectionError); + useProviderSubagentStore.getState().applyUpdate(SERVER_ID, { + kind: "upsert", + subagent: child("child-1", PARENT_ID, "running"), + }); + + // The connection dropped again: the daemon finished child-1 without telling us. + daemon.lists[PARENT_ID] = [child("child-1", PARENT_ID, "completed")]; + await resyncProviderSubagents(daemon.client, SERVER_ID); + + expect(status(PARENT_ID, "child-1")).toBe("completed"); + }); + + test("lists again only the parents this server was showing", async () => { + const daemon = fakeDaemon({}); + await refreshProviderSubagents(daemon.client, SERVER_ID, PARENT_ID); + await refreshProviderSubagents(daemon.client, "server-2", "other-parent"); + daemon.requested.length = 0; + + await resyncProviderSubagents(daemon.client, SERVER_ID); + + expect(daemon.requested).toEqual([PARENT_ID]); + }); +}); diff --git a/packages/app/src/subagents/provider-store.ts b/packages/app/src/subagents/provider-store.ts index 86e857d774..fd44dae8d2 100644 --- a/packages/app/src/subagents/provider-store.ts +++ b/packages/app/src/subagents/provider-store.ts @@ -28,6 +28,9 @@ interface ProviderSubagentState { descriptors: Map; timelines: Map; hiddenFromTrack: Set; + /** Parents whose list this client asked for, so a resubscribed update feed can catch them up. */ + trackedParents: Set; + trackParent(serverId: string, parentAgentId: string): void; hideFromTrack(serverId: string, parentAgentId: string, subagentIds: readonly string[]): void; replaceList( serverId: string, @@ -84,6 +87,7 @@ export function refreshProviderSubagents( const pending = clientRequests.get(requestKey); if (pending) return pending; + useProviderSubagentStore.getState().trackParent(serverId, parentAgentId); const request = client .listProviderSubagents(parentAgentId) .then((payload) => { @@ -101,6 +105,27 @@ function parentPrefix(serverId: string, parentAgentId: string): string { return `${serverId}\0${parentAgentId}\0`; } +/** + * Updates are not replayed across a reconnect, so a child that finished or started while the + * update feed was down stays stale until its parent is listed again. Call this once the feed has + * resubscribed, so every update after the list is delivered. + */ +export async function resyncProviderSubagents( + client: ProviderSubagentListClient, + serverId: string, +): Promise { + const serverPrefix = `${serverId}\0`; + const parentAgentIds = [...useProviderSubagentStore.getState().trackedParents] + .filter((key) => key.startsWith(serverPrefix)) + .map((key) => key.slice(serverPrefix.length, -1)); + await Promise.all( + parentAgentIds.map((parentAgentId) => + // A parent that was archived meanwhile has nothing left to catch up. + refreshProviderSubagents(client, serverId, parentAgentId).catch(() => undefined), + ), + ); +} + const EMPTY_TIMELINE: ProviderSubagentTimelineState = { tail: [], head: [], @@ -147,6 +172,14 @@ export const useProviderSubagentStore = create((set) => ( descriptors: new Map(), timelines: new Map(), hiddenFromTrack: new Set(), + trackedParents: new Set(), + trackParent(serverId, parentAgentId) { + set((state) => { + const prefix = parentPrefix(serverId, parentAgentId); + if (state.trackedParents.has(prefix)) return state; + return { trackedParents: new Set(state.trackedParents).add(prefix) }; + }); + }, hideFromTrack(serverId, parentAgentId, subagentIds) { set((state) => { const hiddenFromTrack = new Set(state.hiddenFromTrack); diff --git a/packages/website/e2e/plugins.spec.ts b/packages/website/e2e/plugins.spec.ts index 015d362338..26c0d9619a 100644 --- a/packages/website/e2e/plugins.spec.ts +++ b/packages/website/e2e/plugins.spec.ts @@ -388,19 +388,18 @@ async function expectPageMetadata(page: Page, title: string, path: string) { ); } -test("keeps the directory unlinked until the coordinated announcement", async ({ page }) => { +test("links the directory from the site navigation", async ({ page }) => { await page.goto("/"); await expect( - page.getByRole("banner").getByRole("link", { name: "Plugins", exact: true }), - ).toHaveCount(0); + page.getByRole("navigation").getByRole("link", { name: "Plugins", exact: true }), + ).toHaveAttribute("href", "/plugins"); await expect( page.getByRole("contentinfo").getByRole("link", { name: "Plugins", exact: true }), - ).toHaveCount(0); + ).toHaveAttribute("href", "/plugins"); await expect(page.getByRole("link", { name: "Browse plugins" })).toHaveAttribute( "href", - "https://paseo.cafe", + "/plugins", ); - await expect(page.locator('a[href="/plugins"]')).toHaveCount(0); const response = await page.goto("/plugins"); expect(response?.status()).toBe(200); await expect(page.getByRole("heading", { level: 1, name: /^Plugins/ })).toBeVisible(); From b352dad2e302cb2f98c5b3739148011df5cf43c1 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Wed, 7 Oct 2026 21:27:17 +0200 Subject: [PATCH 4/5] fix(website): put Plugins first in the header --- packages/website/src/components/site-header.tsx | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/packages/website/src/components/site-header.tsx b/packages/website/src/components/site-header.tsx index c691761b56..fc70e11f39 100644 --- a/packages/website/src/components/site-header.tsx +++ b/packages/website/src/components/site-header.tsx @@ -12,6 +12,14 @@ export function SiteHeader() { Paseo
+ {PLUGINS_LINKED && ( + + Plugins + + )} Docs - {PLUGINS_LINKED && ( - - Plugins - - )} Date: Wed, 7 Oct 2026 21:00:11 +0100 Subject: [PATCH 5/5] feat(claude): add Haiku 5.5 with its model capabilities (#6332) --- packages/cli/tests/15-provider.test.ts | 11 +++- .../agent/providers/claude/agent.test.ts | 12 +++- .../agent/providers/claude/model-manifest.ts | 12 +++- .../agent/providers/claude/models.test.ts | 57 ++++++++++++++++++- 4 files changed, 86 insertions(+), 6 deletions(-) diff --git a/packages/cli/tests/15-provider.test.ts b/packages/cli/tests/15-provider.test.ts index bc1183de81..3081ee3584 100644 --- a/packages/cli/tests/15-provider.test.ts +++ b/packages/cli/tests/15-provider.test.ts @@ -122,10 +122,15 @@ const EXPECTED_CLAUDE_MODELS = [ model: "Opus 4.6", descriptionFragment: "Most capable", }, + { + id: "claude-haiku-5-5", + model: "Haiku 5.5", + descriptionFragment: "Fastest", + }, { id: "claude-haiku-4-5", model: "Haiku 4.5", - descriptionFragment: "Fastest", + descriptionFragment: "Previous release", }, ] as const; @@ -435,8 +440,8 @@ try { "--quiet should print the same ordered model IDs returned by --json", ); assert( - claudeModelsFromJson.some((m) => m.id === "claude-sonnet-5-5"), - "captured --json output should include the current Claude everyday model id", + claudeModelsFromJson.some((m) => m.id === "claude-haiku-5-5"), + "captured --json output should include the current Claude quick-answer model id", ); console.log("✓ provider models --quiet outputs model IDs only\n"); } diff --git a/packages/server/src/server/agent/providers/claude/agent.test.ts b/packages/server/src/server/agent/providers/claude/agent.test.ts index 2d970c49de..9f7430b103 100644 --- a/packages/server/src/server/agent/providers/claude/agent.test.ts +++ b/packages/server/src/server/agent/providers/claude/agent.test.ts @@ -496,7 +496,7 @@ describe("ClaudeAgentClient.fetchCatalog", () => { const client = new ClaudeAgentClient({ logger, resolveBinary: async () => "/test/claude/bin", - resolveVersion: async () => "2.1.284", + resolveVersion: async () => "2.1.293", runtimeSettings: { env: { CLAUDE_CONFIG_DIR: emptyConfigDir } }, }); const { models } = await client.fetchCatalog({ @@ -515,6 +515,15 @@ describe("ClaudeAgentClient.fetchCatalog", () => { expect(getThinkingIds("claude-opus-4-8")).toContain("ultracode"); expect(getThinkingIds("claude-sonnet-5")).toContain("xhigh"); expect(getThinkingIds("claude-sonnet-5")).toContain("ultracode"); + expect(getThinkingIds("claude-haiku-5-5")).toEqual([ + "off", + "low", + "medium", + "high", + "xhigh", + "max", + "ultracode", + ]); expect(getThinkingIds("claude-sonnet-5-5")).toContain("xhigh"); expect(getThinkingIds("claude-sonnet-5-5")).not.toContain("off"); expect(getThinkingIds("claude-opus-4-7[1m]")).toContain("ultracode"); @@ -1223,6 +1232,7 @@ describe("ClaudeAgentSession features", () => { test.each([ ["supported model", "claude-opus-4-8", { type: "disabled" }, undefined], + ["Haiku 5.5", "claude-haiku-5-5", { type: "disabled" }, undefined], ["unsupported model", "claude-fable-5", { type: "adaptive", display: "summarized" }, "high"], ["custom model", "openrouter/anthropic/claude-opus-4-8", undefined, undefined], ["provider default", null, undefined, undefined], diff --git a/packages/server/src/server/agent/providers/claude/model-manifest.ts b/packages/server/src/server/agent/providers/claude/model-manifest.ts index e2e64f28fd..398fcb1ccd 100644 --- a/packages/server/src/server/agent/providers/claude/model-manifest.ts +++ b/packages/server/src/server/agent/providers/claude/model-manifest.ts @@ -170,10 +170,20 @@ export const CLAUDE_MODEL_MANIFEST = [ effortLevels: CLAUDE_EFFORT_LEVELS.standard, supportsThinkingDisabled: true, }, + { + id: "claude-haiku-5-5", + label: "Haiku 5.5", + description: "Haiku 5.5 · Fastest for quick answers", + minimumClaudeCodeVersion: "2.1.293", + defaultThinkingOptionId: "medium", + contextWindowMaxTokens: 1_000_000, + effortLevels: CLAUDE_EFFORT_LEVELS.xhigh, + supportsThinkingDisabled: true, + }, { id: "claude-haiku-4-5", label: "Haiku 4.5", - description: "Haiku 4.5 · Fastest for quick answers", + description: "Haiku 4.5 · Previous release", contextWindowMaxTokens: 200_000, }, ] as const satisfies readonly ClaudeModelManifestEntry[]; diff --git a/packages/server/src/server/agent/providers/claude/models.test.ts b/packages/server/src/server/agent/providers/claude/models.test.ts index 0e187734b8..ed7db8841c 100644 --- a/packages/server/src/server/agent/providers/claude/models.test.ts +++ b/packages/server/src/server/agent/providers/claude/models.test.ts @@ -39,7 +39,7 @@ async function createClaudeConfigDirWithRawSettings(settings: string): Promise claudeCodeVersion, @@ -66,6 +66,7 @@ describe("getClaudeModels", () => { "claude-opus-4-6", "claude-sonnet-4-6[1m]", "claude-sonnet-4-6", + "claude-haiku-5-5", "claude-haiku-4-5", ]); }); @@ -100,6 +101,7 @@ describe("getClaudeModels", () => { ["claude-opus-4-6", 200_000], ["claude-sonnet-4-6[1m]", 1_000_000], ["claude-sonnet-4-6", 200_000], + ["claude-haiku-5-5", 1_000_000], ["claude-haiku-4-5", 200_000], ]), ); @@ -123,6 +125,12 @@ describe("getClaudeModels", () => { expect(getClaudeModels("2.1.284").map((model) => model.id)).toContain("claude-sonnet-5-5"); }); + it("gates Haiku 5.5 on Claude Code 2.1.293 without changing the default", () => { + expect(getClaudeModels("2.1.292").map((model) => model.id)).not.toContain("claude-haiku-5-5"); + expect(getClaudeModels("2.1.293").map((model) => model.id)).toContain("claude-haiku-5-5"); + expect(getClaudeModels("2.1.293").find((model) => model.isDefault)?.id).toBe("claude-opus-5-5"); + }); + it("derives thinking options from model effort capabilities", () => { const models = new Map(getClaudeModels().map((model) => [model.id, model])); @@ -192,6 +200,8 @@ describe("getClaudeModels", () => { ["claude-sonnet-5-20260101", true, "high"], ["claude-fable-5", false, "high"], ["claude-fable-5-1", false, "high"], + ["claude-haiku-5-5", true, "medium"], + ["claude-haiku-5-5-20261007", true, "medium"], ["claude-haiku-4-5", false, undefined], ["openrouter/anthropic/claude-opus-4-8", false, undefined], [null, false, undefined], @@ -653,6 +663,51 @@ describe("Claude Sonnet 5.5 catalog", () => { }); }); +describe("Claude Haiku 5.5 catalog", () => { + it("offers one Haiku 5.5 entry with a 1M context window", () => { + const haiku55Models = getClaudeModels() + .filter((model) => model.id.startsWith("claude-haiku-5-5")) + .map(({ id, label, contextWindowMaxTokens }) => ({ id, label, contextWindowMaxTokens })); + + expect(haiku55Models).toEqual([ + { id: "claude-haiku-5-5", label: "Haiku 5.5", contextWindowMaxTokens: 1_000_000 }, + ]); + }); + + it("offers Off and every effort level with medium as the default", () => { + const haiku55 = getClaudeModels().find((model) => model.id === "claude-haiku-5-5"); + + expect(haiku55?.thinkingOptions?.map((option) => option.id)).toEqual([ + CLAUDE_DISABLED_THINKING_OPTION_ID, + "low", + "medium", + "high", + "xhigh", + "max", + CLAUDE_ULTRACODE_THINKING_OPTION_ID, + ]); + expect(haiku55?.defaultThinkingOptionId).toBe("medium"); + expect( + haiku55?.thinkingOptions?.filter((option) => option.isDefault).map((option) => option.id), + ).toEqual(["medium"]); + }); + + it("normalizes provider-prefixed runtime IDs", () => { + expect(normalizeClaudeRuntimeModelId("anthropic/claude-haiku-5-5")).toBe("claude-haiku-5-5"); + expect(normalizeClaudeRuntimeModelId("us.anthropic.claude-haiku-5-5-v1:0")).toBe( + "claude-haiku-5-5", + ); + expect(claudeManifestModelSupportsFastMode("claude-haiku-5-5")).toBe(false); + }); + + it("resolves suffixed and dated Haiku 5.5 IDs to the single catalog entry", () => { + expect(findClaudeModel("claude-haiku-5-5[1m]")?.id).toBe("claude-haiku-5-5"); + expect(findClaudeModel("claude-haiku-5-5-20261007")?.id).toBe("claude-haiku-5-5"); + expect(findClaudeModel("claude-haiku-5-5-20261007[1m]")?.id).toBe("claude-haiku-5-5"); + expect(findClaudeModel("claude-haiku-5-5[1m]")?.contextWindowMaxTokens).toBe(1_000_000); + }); +}); + describe("claudeManifestModelSupportsFastMode", () => { it("keeps fast mode strict to first-party manifest model IDs", () => { expect(normalizeClaudeManifestModelId("openrouter/anthropic/claude-opus-4-8")).toBeNull();