Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 83 additions & 0 deletions apps/server/src/provider/Layers/ClaudeAdapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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* () {
Expand Down
47 changes: 38 additions & 9 deletions apps/server/src/provider/Layers/ClaudeAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>;
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,
) {
Expand Down Expand Up @@ -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<string, unknown>)
typeof toolBlock.input === "object" && toolBlock.input !== null
? (toolBlock.input as Record<string, unknown>)
: {};
const itemId = block.id;
const itemId = toolBlock.id;
const detail = summarizeToolRequest(toolName, toolInput);
const inputFingerprint =
Object.keys(toolInput).length > 0 ? toolInputFingerprint(toolInput) : undefined;
Expand Down
50 changes: 50 additions & 0 deletions apps/web/src/components/Sidebar.logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

import {
createThreadJumpHintVisibilityController,
getSidebarThreadsByIds,
getVisibleSidebarThreadIds,
resolveAdjacentThreadId,
getFallbackThreadIdAfterDelete,
Expand Down Expand Up @@ -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> = {}): Project {
const { defaultModelSelection, ...rest } = overrides;
return {
Expand Down
20 changes: 20 additions & 0 deletions apps/web/src/components/Sidebar.logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,26 @@ export function getVisibleSidebarThreadIds<TThreadId>(
);
}

export function getSidebarThreadsByIds<
TThreadId extends PropertyKey,
TThread extends { archivedAt: string | null },
>(input: {
threadIds: readonly TThreadId[];
threadsById: Partial<Record<TThreadId, TThread | undefined>>;
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);
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

New exported function is unused in production code

Low Severity

getSidebarThreadsByIds is exported and tested but never imported or called in any production code. The PR description states "reuse shared sidebar thread filtering before the delete check," but Sidebar.tsx still uses inline projectThreads.filter((thread) => thread.archivedAt === null) at the existing call site. The function appears to have been intended as the shared utility but was never actually wired in.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit a70e8f3. Configure here.


export function resolveAdjacentThreadId<T>(input: {
threadIds: readonly T[];
currentThreadId: T | null;
Expand Down
4 changes: 2 additions & 2 deletions apps/web/src/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -1368,7 +1368,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
project.environmentId,
project.id,
project.name,
projectThreads.length,
visibleProjectThreads.length,
suppressProjectClickForContextMenuRef,
],
);
Expand Down
Loading