diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts index 5c45013f8d..126a3f68b3 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts @@ -966,6 +966,89 @@ describe("ClaudeAdapterLive", () => { ); }); + it.effect("tracks server tool blocks as runtime tool items", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + + const runtimeEventsFiber = yield* Stream.take(adapter.streamEvents, 8).pipe( + Stream.runCollect, + Effect.forkChild, + ); + + const session = yield* adapter.startSession({ + threadId: THREAD_ID, + provider: "claudeAgent", + runtimeMode: "full-access", + }); + + const turn = yield* adapter.sendTurn({ + threadId: session.threadId, + input: "hello", + attachments: [], + }); + + harness.query.emit({ + type: "stream_event", + session_id: "sdk-session-server-tool", + uuid: "stream-server-tool-start", + parent_tool_use_id: null, + event: { + type: "content_block_start", + index: 0, + content_block: { + type: "server_tool_use", + id: "server-tool-1", + name: "Bash", + input: { + command: "pwd", + }, + }, + }, + } as unknown as SDKMessage); + + harness.query.emit({ + type: "stream_event", + session_id: "sdk-session-server-tool", + uuid: "stream-server-tool-stop", + parent_tool_use_id: null, + event: { + type: "content_block_stop", + index: 0, + }, + } as unknown as SDKMessage); + + harness.query.emit({ + type: "result", + subtype: "success", + is_error: false, + errors: [], + session_id: "sdk-session-server-tool", + uuid: "result-server-tool", + } as unknown as SDKMessage); + + const runtimeEvents = Array.from(yield* Fiber.join(runtimeEventsFiber)); + const toolStarted = runtimeEvents.find((event) => event.type === "item.started"); + assert.equal(toolStarted?.type, "item.started"); + if (toolStarted?.type === "item.started") { + assert.equal(toolStarted.payload.itemType, "command_execution"); + assert.equal(String(toolStarted.turnId), String(turn.turnId)); + } + + const toolCompleted = runtimeEvents.find( + (event) => + event.type === "item.completed" && event.payload.itemType === "command_execution", + ); + assert.equal(toolCompleted?.type, "item.completed"); + if (toolCompleted?.type === "item.completed") { + assert.equal(String(toolCompleted.turnId), String(turn.turnId)); + } + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + it.effect("classifies Claude Task tool invocations as collaboration agent work", () => { const harness = makeHarness(); return Effect.gen(function* () { diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index fb32da78c5..4d0c0256ca 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -909,6 +909,38 @@ function sdkNativeItemId(message: SDKMessage): string | undefined { return undefined; } +type ClaudeToolStartBlock = { + readonly type: "tool_use" | "server_tool_use" | "mcp_tool_use"; + readonly id: string; + readonly name: string; + readonly input?: unknown; +}; + +function getClaudeToolStartBlock(block: unknown): ClaudeToolStartBlock | undefined { + if (typeof block !== "object" || block === null) { + return undefined; + } + + const candidate = block as Record; + if ( + candidate.type !== "tool_use" && + candidate.type !== "server_tool_use" && + candidate.type !== "mcp_tool_use" + ) { + return undefined; + } + if (typeof candidate.id !== "string" || typeof candidate.name !== "string") { + return undefined; + } + + return { + type: candidate.type, + id: candidate.id, + name: candidate.name, + ...(Object.hasOwn(candidate, "input") ? { input: candidate.input } : {}), + }; +} + const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( options?: ClaudeAdapterLiveOptions, ) { @@ -1629,21 +1661,18 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( }); return; } - if ( - block.type !== "tool_use" && - block.type !== "server_tool_use" && - block.type !== "mcp_tool_use" - ) { + const toolBlock = getClaudeToolStartBlock(block); + if (!toolBlock) { return; } - const toolName = block.name; + const toolName = toolBlock.name; const itemType = classifyToolItemType(toolName); const toolInput = - typeof block.input === "object" && block.input !== null - ? (block.input as Record) + typeof toolBlock.input === "object" && toolBlock.input !== null + ? (toolBlock.input as Record) : {}; - const itemId = block.id; + const itemId = toolBlock.id; const detail = summarizeToolRequest(toolName, toolInput); const inputFingerprint = Object.keys(toolInput).length > 0 ? toolInputFingerprint(toolInput) : undefined; diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index 282d7f9c4d..2ecc22a4fd 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { createThreadJumpHintVisibilityController, + getSidebarThreadsByIds, getVisibleSidebarThreadIds, resolveAdjacentThreadId, getFallbackThreadIdAfterDelete, @@ -618,6 +619,55 @@ describe("getVisibleThreadsForProject", () => { }); }); +describe("getSidebarThreadsByIds", () => { + it("filters out archived and missing threads by default", () => { + const visibleThread = makeThread({ + id: ThreadId.make("thread-visible"), + archivedAt: null, + }); + const archivedThread = makeThread({ + id: ThreadId.make("thread-archived"), + archivedAt: "2026-03-09T10:11:00.000Z", + }); + + const result = getSidebarThreadsByIds({ + threadIds: [ + ThreadId.make("thread-visible"), + ThreadId.make("thread-missing"), + ThreadId.make("thread-archived"), + ], + threadsById: { + [visibleThread.id]: visibleThread, + [archivedThread.id]: archivedThread, + }, + }); + + expect(result).toEqual([visibleThread]); + }); + + it("can include archived threads for callers that need the full project set", () => { + const visibleThread = makeThread({ + id: ThreadId.make("thread-visible"), + archivedAt: null, + }); + const archivedThread = makeThread({ + id: ThreadId.make("thread-archived"), + archivedAt: "2026-03-09T10:11:00.000Z", + }); + + const result = getSidebarThreadsByIds({ + threadIds: [visibleThread.id, archivedThread.id], + threadsById: { + [visibleThread.id]: visibleThread, + [archivedThread.id]: archivedThread, + }, + includeArchived: true, + }); + + expect(result).toEqual([visibleThread, archivedThread]); + }); +}); + function makeProject(overrides: Partial = {}): Project { const { defaultModelSelection, ...rest } = overrides; return { diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index ed151c6da8..b91cec7172 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -241,6 +241,26 @@ export function getVisibleSidebarThreadIds( ); } +export function getSidebarThreadsByIds< + TThreadId extends PropertyKey, + TThread extends { archivedAt: string | null }, +>(input: { + threadIds: readonly TThreadId[]; + threadsById: Partial>; + includeArchived?: boolean; +}): TThread[] { + const threads = input.threadIds.flatMap((threadId) => { + const thread = input.threadsById[threadId]; + return thread === undefined ? [] : [thread]; + }); + + if (input.includeArchived) { + return threads; + } + + return threads.filter((thread) => thread.archivedAt === null); +} + export function resolveAdjacentThreadId(input: { threadIds: readonly T[]; currentThreadId: T | null; diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index bb63db6fc0..afea589629 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -1318,7 +1318,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec } if (clicked !== "delete") return; - if (projectThreads.length > 0) { + if (visibleProjectThreads.length > 0) { toastManager.add({ type: "warning", title: "Project is not empty", @@ -1368,7 +1368,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec project.environmentId, project.id, project.name, - projectThreads.length, + visibleProjectThreads.length, suppressProjectClickForContextMenuRef, ], );