Skip to content
Closed
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 @@ -965,6 +965,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 @@ -621,6 +622,55 @@ describe("getVisibleThreadsForProject", () => {
});
});

describe("getSidebarThreadsByIds", () => {
it("filters out archived and missing threads by default", () => {
const visibleThread = makeThread({
id: ThreadId.makeUnsafe("thread-visible"),
archivedAt: null,
});
const archivedThread = makeThread({
id: ThreadId.makeUnsafe("thread-archived"),
archivedAt: "2026-03-09T10:11:00.000Z",
});

const result = getSidebarThreadsByIds({
threadIds: [
ThreadId.makeUnsafe("thread-visible"),
ThreadId.makeUnsafe("thread-missing"),
ThreadId.makeUnsafe("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.makeUnsafe("thread-visible"),
archivedAt: null,
});
const archivedThread = makeThread({
id: ThreadId.makeUnsafe("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);
}

export function resolveAdjacentThreadId<T>(input: {
threadIds: readonly T[];
currentThreadId: T | null;
Expand Down
25 changes: 15 additions & 10 deletions apps/web/src/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,7 @@ import {
import { useThreadSelectionStore } from "../threadSelectionStore";
import { isNonEmpty as isNonEmptyString } from "effect/String";
import {
getSidebarThreadsByIds,
getVisibleSidebarThreadIds,
getVisibleThreadsForProject,
resolveAdjacentThreadId,
Expand Down Expand Up @@ -850,10 +851,10 @@ export default function Sidebar() {
const focusMostRecentThreadForProject = useCallback(
(projectId: ProjectId) => {
const latestThread = sortThreadsForSidebar(
(threadIdsByProjectId[projectId] ?? [])
.map((threadId) => sidebarThreadsById[threadId])
.filter((thread): thread is NonNullable<typeof thread> => thread !== undefined)
.filter((thread) => thread.archivedAt === null),
getSidebarThreadsByIds({
threadIds: threadIdsByProjectId[projectId] ?? [],
threadsById: sidebarThreadsById,
}),
appSettings.sidebarThreadSortOrder,
)[0];
if (!latestThread) return;
Expand Down Expand Up @@ -1252,8 +1253,11 @@ export default function Sidebar() {
}
if (clicked !== "delete") return;

const projectThreadIds = threadIdsByProjectId[projectId] ?? [];
if (projectThreadIds.length > 0) {
const visibleProjectThreads = getSidebarThreadsByIds({
threadIds: threadIdsByProjectId[projectId] ?? [],
threadsById: sidebarThreadsById,
});
if (visibleProjectThreads.length > 0) {
toastManager.add({
type: "warning",
title: "Project is not empty",
Expand Down Expand Up @@ -1292,6 +1296,7 @@ export default function Sidebar() {
copyPathToClipboard,
getDraftThreadByProjectId,
projects,
sidebarThreadsById,
threadIdsByProjectId,
],
);
Expand Down Expand Up @@ -1400,10 +1405,10 @@ export default function Sidebar() {
},
});
const projectThreads = sortThreadsForSidebar(
(threadIdsByProjectId[project.id] ?? [])
.map((threadId) => sidebarThreadsById[threadId])
.filter((thread): thread is NonNullable<typeof thread> => thread !== undefined)
.filter((thread) => thread.archivedAt === null),
getSidebarThreadsByIds({
threadIds: threadIdsByProjectId[project.id] ?? [],
threadsById: sidebarThreadsById,
}),
appSettings.sidebarThreadSortOrder,
);
const projectStatus = resolveProjectStatusIndicator(
Expand Down
Loading