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
64 changes: 64 additions & 0 deletions apps/mobile/src/features/review/reviewCommentSelection.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,4 +78,68 @@ describe("review comment serialization", () => {
);
expect(segments[2]).toEqual(expect.objectContaining({ kind: "text", text: "\nAfter" }));
});

it("parses source-language review comments created by the web file viewer", () => {
const [segment] = parseReviewCommentMessageSegments(
[
'<review_comment sectionId="file:docs/plan.md" sectionTitle="File comment" filePath="docs/plan.md" startIndex="0" endIndex="1" rangeLabel="L1 to L2">',
"Clarify this.",
"```md",
"# Plan",
"- Step one",
"```",
"</review_comment>",
].join("\n"),
);

expect(segment).toEqual(
expect.objectContaining({
kind: "review-comment",
comment: expect.objectContaining({
filePath: "docs/plan.md",
fenceLanguage: "md",
diff: "# Plan\n- Step one",
}),
}),
);
});

it("keeps fenced examples in comment prose separate from the context fence", () => {
const [segment] = parseReviewCommentMessageSegments(
[
'<review_comment sectionId="section-1" sectionTitle="Working tree" filePath="src/app.ts" startIndex="0" endIndex="0" rangeLabel="+1">',
"Try this:",
"```ts",
"const value = 1;",
"```",
"Then retry.",
"```diff",
"@@ -0,0 +1,1 @@",
"+one",
"```",
"</review_comment>",
].join("\n"),
);

expect(segment).toEqual(
expect.objectContaining({
kind: "review-comment",
comment: expect.objectContaining({
text: ["Try this:", "```ts", "const value = 1;", "```", "Then retry."].join("\n"),
diff: "@@ -0,0 +1,1 @@\n+one",
}),
}),
);
});

it("round-trips greater-than signs in review attributes", () => {
const serialized = formatReviewCommentContext(
{ ...makeTarget(), sectionTitle: "Changes > 5" },
"Check this.",
);
const [comment] = parseReviewInlineComments(serialized);

expect(serialized).toContain('sectionTitle="Changes &gt; 5"');
expect(comment?.sectionTitle).toBe("Changes > 5");
});
});
49 changes: 34 additions & 15 deletions apps/mobile/src/features/review/reviewCommentSelection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export interface ReviewInlineComment {
readonly rangeLabel: string;
readonly text: string;
readonly diff: string;
readonly fenceLanguage?: string;
}

export type ReviewCommentMessageSegment =
Expand All @@ -38,6 +39,7 @@ let currentTarget: ReviewCommentTarget | null = null;
const listeners = new Set<() => void>();
const REVIEW_COMMENT_BLOCK_PATTERN = /<review_comment\b([^>]*)>\s*([\s\S]*?)<\/review_comment>/g;
const REVIEW_COMMENT_ATTRIBUTE_PATTERN = /([a-zA-Z][a-zA-Z0-9_-]*)="([^"]*)"/g;
const REVIEW_COMMENT_FENCE_PATTERN = /(`{3,})([^\s`]*)[^\n]*\n([\s\S]*?)\n\1/g;

function emitChange() {
listeners.forEach((listener) => listener());
Expand Down Expand Up @@ -170,12 +172,17 @@ function formatReviewSelectedDiff(target: ReviewCommentTarget): string {
}

function escapeReviewCommentAttribute(value: string): string {
return value.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;");
return value
.replace(/&/g, "&amp;")
.replace(/"/g, "&quot;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
}

function unescapeReviewCommentAttribute(value: string): string {
return value
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&quot;/g, '"')
.replace(/&amp;/g, "&");
}
Expand All @@ -195,15 +202,19 @@ function readNonNegativeInteger(value: string | undefined): number | null {
return Number(value);
}

function extractReviewCommentText(rawBody: string): string {
const fenceIndex = rawBody.indexOf("```diff");
const commentBody = fenceIndex >= 0 ? rawBody.slice(0, fenceIndex) : rawBody;
return commentBody.trim();
}

function extractReviewCommentDiff(rawBody: string): string {
const match = rawBody.match(/```diff\s*\n([\s\S]*?)\n```/);
return match?.[1]?.trim() ?? "";
function extractReviewCommentBody(rawBody: string): {
text: string;
language: string;
contents: string;
} {
const matches = Array.from(rawBody.matchAll(REVIEW_COMMENT_FENCE_PATTERN));
const match = matches.at(-1);
const fenceIndex = match?.index;
return {
text: rawBody.slice(0, fenceIndex ?? rawBody.length).trim(),
language: match?.[2]?.trim() || "diff",
contents: match?.[3] ?? "",
};
}

function parseReviewInlineComment(
Expand All @@ -219,6 +230,7 @@ function parseReviewInlineComment(
if (!filePath || !sectionId || startIndex === null || endIndex === null) {
return null;
}
const body = extractReviewCommentBody(rawBody);

return {
id: `review-comment:${index}:${sectionId}:${filePath}:${startIndex}:${endIndex}`,
Expand All @@ -228,13 +240,20 @@ function parseReviewInlineComment(
startIndex: Math.min(startIndex, endIndex),
endIndex: Math.max(startIndex, endIndex),
rangeLabel: attributes.rangeLabel?.trim() || "line",
text: extractReviewCommentText(rawBody),
diff: extractReviewCommentDiff(rawBody),
text: body.text,
diff: body.contents,
fenceLanguage: body.language,
};
}

export function formatReviewCommentContext(target: ReviewCommentTarget, comment: string): string {
const rangeLabel = formatReviewSelectedRangeLabel(target);
const diff = formatReviewSelectedDiff(target);
const longestBacktickRun = Math.max(
0,
...Array.from(diff.matchAll(/`+/g), (match) => match[0].length),
);
const fence = "`".repeat(Math.max(3, longestBacktickRun + 1));
return [
[
"<review_comment",
Expand All @@ -247,9 +266,9 @@ export function formatReviewCommentContext(target: ReviewCommentTarget, comment:
">",
].join(""),
comment.trim(),
"```diff",
formatReviewSelectedDiff(target),
"```",
`${fence}diff`,
diff,
fence,
"</review_comment>",
].join("\n");
}
Expand Down
3 changes: 3 additions & 0 deletions apps/mobile/src/features/threads/ThreadFeed.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1193,6 +1193,9 @@ const ReviewCommentCard = memo(function ReviewCommentCard(props: {
});

function buildReviewCommentPatch(comment: ReviewInlineComment): string {
if ((comment.fenceLanguage ?? "diff") !== "diff") {
return "";
}
const diff = comment.diff.trim();
if (!diff) {
return "";
Expand Down
4 changes: 3 additions & 1 deletion apps/server/src/git/GitManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1361,7 +1361,9 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () {
},
);
const status: GitManagerShape["status"] = Effect.fn("status")(function* (input) {
const [local, remote] = yield* Effect.all([localStatus(input), remoteStatus(input)]);
const [local, remote] = yield* Effect.all([localStatus(input), remoteStatus(input)], {
concurrency: "unbounded",
});
return mergeGitStatusParts(local, remote);
});
const invalidateLocalStatus: GitManagerShape["invalidateLocalStatus"] = Effect.fn(
Expand Down
56 changes: 55 additions & 1 deletion apps/server/src/mcp/McpHttpServer.test.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import { expect, it } from "@effect/vitest";
import { NodeHttpServer } from "@effect/platform-node";
import { EnvironmentId, PreviewTabId, ProviderInstanceId, ThreadId } from "@t3tools/contracts";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
import * as Stream from "effect/Stream";
import { McpSchema, McpServer } from "effect/unstable/ai";
import { HttpServerResponse } from "effect/unstable/http";
import { HttpBody, HttpClient, HttpRouter, HttpServerResponse } from "effect/unstable/http";

import * as McpHttpServer from "./McpHttpServer.ts";
import * as McpInvocationContext from "./McpInvocationContext.ts";
Expand Down Expand Up @@ -48,6 +49,59 @@ it("normalizes empty successful notification responses to accepted", () => {
expect(resultResponse.status).toBe(200);
});

it.effect("terminates HTTP MCP sessions with DELETE", () =>
Effect.scoped(
Effect.gen(function* () {
const serverLayer = McpServer.layerHttp({
name: "MCP termination test",
version: "1.0.0",
path: "/mcp",
});
yield* HttpRouter.serve(serverLayer, {
disableListenLog: true,
disableLogger: true,
}).pipe(Layer.build);
const httpClient = yield* HttpClient.HttpClient;

const initializeResponse = yield* httpClient.post("/mcp", {
headers: { accept: "application/json, text/event-stream" },
body: HttpBody.text(
`{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"mcp-test","version":"1.0.0"}}}`,
"application/json",
),
});
const sessionId = initializeResponse.headers["mcp-session-id"];
expect(initializeResponse.status).toBe(200);
expect(sessionId).not.toBeNull();

const missingSessionResponse = yield* httpClient.del("/mcp");
expect(missingSessionResponse.status).toBe(400);

const unknownSessionResponse = yield* httpClient.del("/mcp", {
headers: { "mcp-session-id": "unknown-session" },
});
expect(unknownSessionResponse.status).toBe(404);

const terminateResponse = yield* httpClient.del("/mcp", {
headers: { "mcp-session-id": sessionId! },
});
expect(terminateResponse.status).toBe(204);

const reusedSessionResponse = yield* httpClient.post("/mcp", {
headers: {
accept: "application/json, text/event-stream",
"mcp-session-id": sessionId!,
},
body: HttpBody.text(
`{"jsonrpc":"2.0","id":2,"method":"ping","params":{}}`,
"application/json",
),
});
expect(reusedSessionResponse.status).toBe(404);
}),
).pipe(Effect.provide(NodeHttpServer.layerTest)),
);

it.effect("registers annotated tools and preserves authenticated request context", () =>
Effect.scoped(
Effect.gen(function* () {
Expand Down
66 changes: 66 additions & 0 deletions apps/server/src/vcs/VcsStatusBroadcaster.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import type {
VcsStatusResult,
VcsStatusStreamEvent,
} from "@t3tools/contracts";
import { GitManagerError } from "@t3tools/contracts";

import * as VcsStatusBroadcaster from "./VcsStatusBroadcaster.ts";
import * as GitWorkflowService from "../git/GitWorkflowService.ts";
Expand Down Expand Up @@ -149,6 +150,71 @@ describe("VcsStatusBroadcaster", () => {
}).pipe(Effect.provide(makeTestLayer(state)));
});

it.effect("keeps the cached snapshot unchanged when a refresh branch fails", () => {
const state = {
currentLocalStatus: baseLocalStatus,
currentRemoteStatus: baseRemoteStatus,
localStatusCalls: 0,
remoteStatusCalls: 0,
localInvalidationCalls: 0,
remoteInvalidationCalls: 0,
failRemoteStatus: false,
};
const testLayer = VcsStatusBroadcaster.layer.pipe(
Layer.provideMerge(NodeServices.layer),
Layer.provide(
Layer.mock(GitWorkflowService.GitWorkflowService)({
localStatus: () =>
Effect.sync(() => {
state.localStatusCalls += 1;
return state.currentLocalStatus;
}),
remoteStatus: () =>
Effect.suspend(() => {
state.remoteStatusCalls += 1;
return state.failRemoteStatus
? Effect.fail(
new GitManagerError({
operation: "VcsStatusBroadcaster.test",
detail: "remote status failed",
}),
)
: Effect.succeed(state.currentRemoteStatus);
}),
invalidateLocalStatus: () =>
Effect.sync(() => {
state.localInvalidationCalls += 1;
}),
invalidateRemoteStatus: () =>
Effect.sync(() => {
state.remoteInvalidationCalls += 1;
}),
}),
),
);

return Effect.gen(function* () {
const broadcaster = yield* VcsStatusBroadcaster.VcsStatusBroadcaster;
yield* broadcaster.getStatus({ cwd: "/repo" });

state.currentLocalStatus = {
...baseLocalStatus,
refName: "feature/partial-refresh",
};
state.currentRemoteStatus = {
...baseRemoteStatus,
aheadCount: 3,
};
state.failRemoteStatus = true;

const refreshExit = yield* broadcaster.refreshStatus("/repo").pipe(Effect.exit);
const cached = yield* broadcaster.getStatus({ cwd: "/repo" });

assert.isTrue(Exit.isFailure(refreshExit));
assert.deepStrictEqual(cached, baseStatus);
}).pipe(Effect.provide(testLayer));
});

it.effect("refreshes only the cached local snapshot when requested", () => {
const state = {
currentLocalStatus: baseLocalStatus,
Expand Down
Loading
Loading