Skip to content
Merged
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
38 changes: 38 additions & 0 deletions apps/server/src/mcp/toolkits/core.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,21 @@ import * as McpToolAccessTestkit from "../McpToolAccess.testkit.ts";
import { dispatchFailure } from "../threadAccess.ts";
import * as EnvironmentHandlers from "./environment/handlers.ts";
import * as AttachmentHandlers from "./attachment/handlers.ts";
import { ApprovalToolkit } from "./approval/tools.ts";
import { AttachmentToolkit } from "./attachment/tools.ts";
import { DeviceToolkit } from "./device/tools.ts";
import { EnvironmentToolkit } from "./environment/tools.ts";
import { HtmlToolkit } from "./html/tools.ts";
import { IssueLinksToolkit } from "./issueLinks/tools.ts";
import { MessageDraftsToolkit } from "./messageDrafts/tools.ts";
import { OrchestratorToolkit } from "./orchestrator/tools.ts";
import { PreviewToolkit } from "./preview/tools.ts";
import { PreviewControlsToolkit } from "./previewControls/tools.ts";
import { ProjectToolkit } from "./project/tools.ts";
import { PullRequestsToolkit } from "./pullRequests/tools.ts";
import { ThreadToolkit } from "./thread/tools.ts";
import { ThreadDeleteToolkit } from "./threadDelete/tools.ts";
import { WorktreeToolkit } from "./worktree/tools.ts";

const layerOrchestratorToolkit = McpHttpServer.layerOrchestratorToolkit.pipe(
Layer.provide(Layer.mock(ThreadLaunchService.ThreadLaunchService)({})),
Expand Down Expand Up @@ -95,6 +110,29 @@ const layerThreadToolkitWith = (
);
const layerThreadToolkit = layerThreadToolkitWith(layerThreadMoveServices);

// All toolkits share one MCP server, so a duplicate name silently shadows a tool.
it("publishes unique tool names across every toolkit", () => {
const names = [
ApprovalToolkit,
AttachmentToolkit,
DeviceToolkit,
EnvironmentToolkit,
HtmlToolkit,
IssueLinksToolkit,
MessageDraftsToolkit,
OrchestratorToolkit,
PreviewToolkit,
PreviewControlsToolkit,
ProjectToolkit,
PullRequestsToolkit,
ThreadToolkit,
ThreadDeleteToolkit,
WorktreeToolkit,
].flatMap((toolkit) => Object.values(toolkit.tools).map((tool) => tool.name));
expect(names.filter((name, index) => names.indexOf(name) !== index)).toEqual([]);
expect(names).toEqual(expect.arrayContaining(["link_linear_issue", "t3_draft_create"]));
});

const threadId = ThreadId.make("mcp-core-thread");
const scope: McpInvocationContext.McpInvocationScope = {
environmentId: EnvironmentId.make("mcp-core-environment"),
Expand Down
51 changes: 51 additions & 0 deletions apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4224,6 +4224,57 @@ describe("ClaudeAdapterV2 background wake turns", () => {
),
);

it.effect("keeps usage warnings and provisioned overage silent", () =>
Effect.gen(function* () {
const harness = yield* makeWakeHarness;
const now = yield* DateTime.now;
yield* harness.runtime.startTurn(
makeClaudeTestTurnInput({
threadId: harness.threadId,
providerThread: harness.providerThread,
now,
attemptId: RunAttemptId.make("attempt-claude-overage"),
text: "Continue.",
attachments: [],
}),
);
for (const rate_limit_info of [
{ status: "allowed_warning" },
{ status: "rejected", overageStatus: "allowed" },
{ status: "rejected", overageStatus: "allowed_warning" },
{ status: "rejected", isUsingOverage: true },
{ status: "rejected", overageInUse: true },
]) {
yield* Queue.offer(
harness.sdkMessages,
claudeSdkFrame({
type: "rate_limit_event",
rate_limit_info,
session_id: WAKE_NATIVE_SESSION,
uuid: "00000000-0000-4000-8000-000000000604",
}),
);
}
yield* Queue.offer(
harness.sdkMessages,
makeResultFrame({
uuid: "00000000-0000-4000-8000-000000000605",
result: "Done.",
}),
);
yield* Queue.take(harness.terminalReceipts);
assert.isFalse(
harness.events.some(
(event) => event.type === "turn_item.updated" && event.turnItem.type === "system_notice",
),
);
}).pipe(
Effect.provide(
Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),
),
),
);

it.effect("keeps a later future-window event-supplied Claude reset over the embedded reset", () =>
Effect.gen(function* () {
const harness = yield* makeWakeHarness;
Expand Down
25 changes: 25 additions & 0 deletions apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1028,6 +1028,31 @@ effectIt.effect("does not mistake a failed state read for a superseded run", ()
}),
);

effectIt.effect("resumes an imported native session on its first T3 turn", () =>
Effect.gen(function* () {
const firstTurnFor = (marked: boolean) =>
Effect.gen(function* () {
const harness = makeLocalCommandHarness({
text: "Continue",
existingClaudeSession: { marked },
});
yield* harness.start;
expect(harness.startRootRun).toHaveBeenCalledOnce();
return harness.startRootRun.mock.calls[0]![0];
});

// Claude creates the session on a first turn without prior native turns,
// which the CLI rejects for an imported session id that already exists.
const imported = yield* firstTurnFor(true);
expect(imported.providerTurnOrdinal).toBe(1);
expect(imported.nativeThreadHasTurns).toBe(true);

const unmarked = yield* firstTurnFor(false);
expect(unmarked.providerTurnOrdinal).toBe(1);
expect(unmarked.nativeThreadHasTurns).toBe(false);
}),
);

effectIt.effect(
"signs out the existing native provider before opening the newly selected provider",
() =>
Expand Down
49 changes: 49 additions & 0 deletions apps/server/src/project/AgentSessionScanner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1156,6 +1156,55 @@ it.layer(NodeServices.layer)("AgentSessionScanner", (it) => {
}),
);

it.effect("imports sessions started in a subfolder unless a nested project owns it", () =>
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const nowMs = Date.parse("2026-08-24T12:00:00.000Z");
yield* TestClock.setTime(nowMs);
const claudeHomePath = yield* makeTempDir("t3code-claude-home-");
const codexHomePath = yield* makeTempDir("t3code-codex-home-");
const workspace = yield* makeTempDir("t3code-workspace-");
const subfolder = path.join(workspace, "functions", "billing");
const nestedProject = path.join(workspace, "nested");
yield* fileSystem.makeDirectory(subfolder, { recursive: true });
yield* fileSystem.makeDirectory(path.join(nestedProject, "src"), { recursive: true });

const writeSession = (cwd: string, sessionId: string, ageMs: number) =>
writeTranscript({
filePath: path.join(claudeHomePath, "projects", `-${sessionId}`, `${sessionId}.jsonl`),
contents: `${JSON.stringify({
type: "user",
cwd,
sessionId,
timestamp: "2026-08-23T12:00:00.000Z",
message: { role: "user", content: "Fix the project" },
})}\n`,
mtimeMs: nowMs - ageMs,
});
yield* writeSession(workspace, "at-root", 1_000);
yield* writeSession(subfolder, "in-subfolder", 2_000);
yield* writeSession(path.join(subfolder, "deleted"), "in-deleted-folder", 3_000);
yield* writeSession(path.join(nestedProject, "src"), "in-nested-project", 4_000);
yield* writeSession(path.dirname(workspace), "above-root", 5_000);

const sessionIds = (workspaceRoot: string) =>
runRecentThreads({
claudeHomePath,
codexHomePath,
workspaceRoot,
importedWorkspaceRoots: [workspace, nestedProject],
}).pipe(Effect.map((threads) => threads.map((thread) => thread.providerSessionId)));

expect(yield* sessionIds(workspace)).toEqual([
"at-root",
"in-subfolder",
"in-deleted-folder",
]);
expect(yield* sessionIds(nestedProject)).toEqual(["in-nested-project"]);
}),
);

it.effect("keeps the provider instance that owns a custom session home", () =>
Effect.gen(function* () {
const path = yield* Path.Path;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -189,7 +189,7 @@ it("removes host actions after revocation while keeping file preview and copying
using _setup = setup();
await using screen = await renderMarkdown("[Readme](docs/readme.md)");
await screen.openContextMenu();
expect(offeredActions()).toEqual(["open", "reveal", "copy-relative", "copy-full"]);
expect(offeredActions()).toEqual(["open", "open-with", "reveal", "copy-relative", "copy-full"]);
await setAllowed(false);
state.choose.mockResolvedValue("copy-full");
await screen.openContextMenu();
Expand Down
52 changes: 49 additions & 3 deletions apps/web/src/components/ChatMarkdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,9 @@ import {
import {
AssetWorkspaceAssetNotFoundError,
AuthPreviewOperateScope,
DEFAULT_SERVER_SETTINGS,
EDITORS,
resolveFileOpenTarget,
type EditorId,
type EnvironmentId,
type ServerConfig,
Expand Down Expand Up @@ -426,10 +429,23 @@ type MarkdownServerConfigFields = ReturnType<typeof markdownServerConfigFields>;

/** The server config fields markdown reads. */
function markdownServerConfigFields(config: ServerConfig | null) {
const settings = config?.settings ?? DEFAULT_SERVER_SETTINGS;
const availableEditors = config?.availableEditors ?? NO_MARKDOWN_EDITORS;
return {
pullRequestsEnabled: config?.environment.capabilities.pullRequests === true,
threadPullRequestsEnabled: config?.environment.capabilities.threadPullRequests === true,
availableEditors: config?.availableEditors ?? NO_MARKDOWN_EDITORS,
availableEditors,
// Fork: file links follow Settings → Open in (default target, extension rules, custom apps).
fileOpenSettings: {
fileOpenDefault: settings.fileOpenDefault,
fileOpenRules: settings.fileOpenRules,
},
openWithEditors: [
...EDITORS.filter(
(editor) => editor.id !== "file-manager" && availableEditors.includes(editor.id),
),
...settings.customEditors,
].map(({ id, label }) => ({ id, label })),
revealInFileManagerLabel:
config?.shellRevealInFileManager === true && config.availableEditors.includes("file-manager")
? config.shellRevealInFileManagerKind === undefined
Expand All @@ -448,7 +464,18 @@ function markdownServerConfigFieldsEqual(
previous.threadPullRequestsEnabled === next.threadPullRequestsEnabled &&
previous.revealInFileManagerLabel === next.revealInFileManagerLabel &&
previous.availableEditors.length === next.availableEditors.length &&
previous.availableEditors.every((editor, index) => editor === next.availableEditors[index])
previous.availableEditors.every((editor, index) => editor === next.availableEditors[index]) &&
previous.fileOpenSettings.fileOpenDefault === next.fileOpenSettings.fileOpenDefault &&
previous.fileOpenSettings.fileOpenRules.length === next.fileOpenSettings.fileOpenRules.length &&
previous.fileOpenSettings.fileOpenRules.every((rule, index) => {
const nextRule = next.fileOpenSettings.fileOpenRules[index];
return rule.extension === nextRule?.extension && rule.target === nextRule.target;
}) &&
previous.openWithEditors.length === next.openWithEditors.length &&
previous.openWithEditors.every((editor, index) => {
const nextEditor = next.openWithEditors[index];
return editor.id === nextEditor?.id && editor.label === nextEditor.label;
})
);
}

Expand Down Expand Up @@ -609,11 +636,17 @@ function useChatMarkdownState({
pullRequestsEnabled,
threadPullRequestsEnabled,
availableEditors,
fileOpenSettings,
openWithEditors,
revealInFileManagerLabel,
} = useAtomValue(markdownServerConfigFieldsAtom(environmentId));
const projects = useProjects();
const [preferredEditor] = usePreferredEditor(availableEditors);
const preferredEditorMenuLabel = openInEditorMenuLabel(preferredEditor);
const customEditorLabel = openWithEditors.find((editor) => editor.id === preferredEditor)?.label;
const preferredEditorMenuLabel =
customEditorLabel !== undefined && !EDITORS.some((editor) => editor.id === preferredEditor)
? `Open in ${customEditorLabel}`
: openInEditorMenuLabel(preferredEditor);
const openInPreferredEditor = useOpenInPreferredEditor(environmentId, availableEditors);
const openInEditor = useAtomCommand(shellEnvironment.openInEditor, {
reportFailure: false,
Expand Down Expand Up @@ -856,6 +889,7 @@ function useChatMarkdownState({
fileLinkMeta.workspaceRelativePath ??
(!canPreviewMedia && isHostPath(fileLinkMeta.filePath) ? fileLinkMeta.filePath : null);

const fileOpenTarget = resolveFileOpenTarget(fileLinkMeta.filePath, fileOpenSettings);
return (
<MarkdownFileLink
environmentId={environmentId}
Expand All @@ -881,6 +915,18 @@ function useChatMarkdownState({
? async () =>
openInPreferredEditor(
markdownFileEditorTarget(fileLinkMeta, await resolveHostPath()),
fileOpenTarget === "t3" ? undefined : fileOpenTarget,
)
: undefined
}
openExternally={fileOpenTarget !== "t3"}
openWithEditors={openWithEditors}
onOpenWith={
canUseShellActions
? async (editor: EditorId) =>
openInPreferredEditor(
markdownFileEditorTarget(fileLinkMeta, await resolveHostPath()),
editor,
)
: undefined
}
Expand Down
Loading