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
7 changes: 6 additions & 1 deletion src/mcp/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
createTestSessionReviewFile,
createTestSessionSnapshot,
} from "../../test/helpers/mcp-fixtures";
import { HUNK_SESSION_API_VERSION, HUNK_SESSION_DAEMON_VERSION } from "../session/protocol";
import { HunkHostClient } from "./client";

const originalHost = process.env.HUNK_MCP_HOST;
Expand Down Expand Up @@ -155,7 +156,11 @@ describe("Hunk MCP client", () => {
}

if (url.pathname === "/session-api/capabilities") {
return Response.json({ version: 1, actions: ["list"] });
return Response.json({
version: HUNK_SESSION_API_VERSION,
daemonVersion: HUNK_SESSION_DAEMON_VERSION,
actions: ["list"],
});
}

if (url.pathname === "/session") {
Expand Down
1 change: 1 addition & 0 deletions src/mcp/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,7 @@ describe("Hunk session daemon server", () => {
expect(capabilities.status).toBe(200);
await expect(capabilities.json()).resolves.toMatchObject({
version: 1,
daemonVersion: 1,
actions: [
"list",
"get",
Expand Down
2 changes: 2 additions & 0 deletions src/mcp/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
HUNK_SESSION_API_PATH,
HUNK_SESSION_API_VERSION,
HUNK_SESSION_CAPABILITIES_PATH,
HUNK_SESSION_DAEMON_VERSION,
type SessionDaemonAction,
type SessionDaemonCapabilities,
type SessionDaemonRequest,
Expand Down Expand Up @@ -59,6 +60,7 @@ function formatDaemonServeError(error: unknown, host: string, port: number) {
function sessionCapabilities(): SessionDaemonCapabilities {
return {
version: HUNK_SESSION_API_VERSION,
daemonVersion: HUNK_SESSION_DAEMON_VERSION,
actions: SUPPORTED_SESSION_ACTIONS,
};
}
Expand Down
29 changes: 29 additions & 0 deletions src/session/capabilities.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { afterEach, describe, expect, test } from "bun:test";
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
import { readHunkSessionDaemonCapabilities } from "./capabilities";
import { HUNK_SESSION_API_VERSION, HUNK_SESSION_DAEMON_VERSION } from "./protocol";

const servers = new Set<ReturnType<typeof createServer>>();

Expand Down Expand Up @@ -49,4 +50,32 @@ describe("readHunkSessionDaemonCapabilities", () => {

await expect(readHunkSessionDaemonCapabilities(config)).resolves.toBeNull();
});

test("returns null when the daemon omits the compatibility version field", async () => {
const { config } = await listen((_request: IncomingMessage, response: ServerResponse) => {
response.writeHead(200, { "content-type": "application/json" });
response.end(JSON.stringify({ version: HUNK_SESSION_API_VERSION, actions: ["list"] }));
});

await expect(readHunkSessionDaemonCapabilities(config)).resolves.toBeNull();
});

test("accepts capabilities only when both API and daemon compatibility versions match", async () => {
const { config } = await listen((_request: IncomingMessage, response: ServerResponse) => {
response.writeHead(200, { "content-type": "application/json" });
response.end(
JSON.stringify({
version: HUNK_SESSION_API_VERSION,
daemonVersion: HUNK_SESSION_DAEMON_VERSION,
actions: ["list", "get"],
}),
);
});

await expect(readHunkSessionDaemonCapabilities(config)).resolves.toEqual({
version: HUNK_SESSION_API_VERSION,
daemonVersion: HUNK_SESSION_DAEMON_VERSION,
actions: ["list", "get"],
});
});
});
7 changes: 6 additions & 1 deletion src/session/capabilities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { resolveHunkMcpConfig, type ResolvedHunkMcpConfig } from "../mcp/config"
import {
HUNK_SESSION_API_VERSION,
HUNK_SESSION_CAPABILITIES_PATH,
HUNK_SESSION_DAEMON_VERSION,
type SessionDaemonCapabilities,
} from "./protocol";

Expand All @@ -13,7 +14,10 @@ export function reportHunkDaemonUpgradeRestart(log: (message: string) => void =
log(HUNK_DAEMON_UPGRADE_RESTART_NOTICE);
}

/** Read the live daemon's session API capabilities, returning null for incompatible daemons. */
/**
* Read the live daemon's advertised compatibility, returning null when the daemon is too old for
* this Hunk build even if it still answers the same HTTP action list.
*/
export async function readHunkSessionDaemonCapabilities(
config: ResolvedHunkMcpConfig = resolveHunkMcpConfig(),
): Promise<SessionDaemonCapabilities | null> {
Expand All @@ -37,6 +41,7 @@ export async function readHunkSessionDaemonCapabilities(
!capabilities ||
typeof capabilities !== "object" ||
(capabilities as { version?: unknown }).version !== HUNK_SESSION_API_VERSION ||
(capabilities as { daemonVersion?: unknown }).daemonVersion !== HUNK_SESSION_DAEMON_VERSION ||
!Array.isArray((capabilities as { actions?: unknown }).actions)
) {
return null;
Expand Down
96 changes: 94 additions & 2 deletions src/session/commands.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import {
type HunkDaemonCliClient,
} from "./commands";
import { HUNK_DAEMON_UPGRADE_RESTART_NOTICE } from "./capabilities";
import { HUNK_SESSION_API_VERSION } from "./protocol";
import { HUNK_SESSION_API_VERSION, HUNK_SESSION_DAEMON_VERSION } from "./protocol";

function createTestListedSession(sessionId: string) {
return buildTestListedSession({
Expand Down Expand Up @@ -58,6 +58,7 @@ function createClient(overrides: Partial<HunkDaemonCliClient>): HunkDaemonCliCli
return {
getCapabilities: async () => ({
version: HUNK_SESSION_API_VERSION,
daemonVersion: HUNK_SESSION_DAEMON_VERSION,
actions: [
"list",
"get",
Expand Down Expand Up @@ -215,7 +216,11 @@ describe("session command compatibility checks", () => {
createClient({
getCapabilities: async () => {
createdClients.push("stale-capabilities");
return { version: HUNK_SESSION_API_VERSION - 1, actions: ["list"] };
return {
version: HUNK_SESSION_API_VERSION - 1,
daemonVersion: HUNK_SESSION_DAEMON_VERSION,
actions: ["list"],
};
},
}),
createClient({
Expand Down Expand Up @@ -268,6 +273,92 @@ describe("session command compatibility checks", () => {
}
});

test("refreshes a stale daemon before running comment-add", async () => {
const selector: SessionSelectorInput = { sessionId: "session-1" };
const restartCalls: Array<{ action: string; selector?: SessionSelectorInput }> = [];
const createdClients: string[] = [];
const notices: string[] = [];
const originalConsoleError = console.error;
console.error = (...args: unknown[]) => {
notices.push(args.map((value) => String(value)).join(" "));
};

const clients = [
createClient({
getCapabilities: async () => {
createdClients.push("stale-capabilities");
return null;
},
}),
createClient({
addComment: async (input) => {
createdClients.push("fresh-comment-add");
expect(input.selector).toEqual(selector);
expect(input.filePath).toBe("README.md");
expect(input.side).toBe("new");
expect(input.line).toBe(2);
expect(input.summary).toBe("Review note");
return {
commentId: "comment-1",
fileId: "file-1",
filePath: "README.md",
hunkIndex: 0,
side: "new",
line: 2,
};
},
}),
];

try {
setSessionCommandTestHooks({
createClient: () => {
const client = clients.shift();
if (!client) {
throw new Error("No fake session client remaining.");
}

return client;
},
resolveDaemonAvailability: async () => true,
restartDaemonForMissingAction: async (action, receivedSelector) => {
restartCalls.push({ action, selector: receivedSelector });
},
});

const output = await runSessionCommand({
kind: "session",
action: "comment-add",
selector,
filePath: "README.md",
side: "new",
line: 2,
summary: "Review note",
reveal: false,
output: "json",
} satisfies SessionCommandInput);

expect(JSON.parse(output)).toMatchObject({
result: {
commentId: "comment-1",
filePath: "README.md",
side: "new",
line: 2,
},
});
expect(restartCalls).toEqual([
{
action: "comment-add",
selector,
},
]);
expect(createdClients).toEqual(["stale-capabilities", "fresh-comment-add"]);
expect(notices).toContain(HUNK_DAEMON_UPGRADE_RESTART_NOTICE);
} finally {
console.error = originalConsoleError;
}
});

test("runs review commands through the daemon without raw patch text by default", async () => {
setSessionCommandTestHooks({
createClient: () =>
Expand Down Expand Up @@ -674,6 +765,7 @@ describe("session command compatibility checks", () => {
createClient({
getCapabilities: async () => ({
version: HUNK_SESSION_API_VERSION,
daemonVersion: HUNK_SESSION_DAEMON_VERSION,
actions: [
"list",
"get",
Expand Down
7 changes: 7 additions & 0 deletions src/session/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,12 @@ export const HUNK_SESSION_API_PATH = "/session-api";
export const HUNK_SESSION_CAPABILITIES_PATH = `${HUNK_SESSION_API_PATH}/capabilities`;
export const HUNK_SESSION_API_VERSION = 1;

/**
* Version daemon/session compatibility separately from the HTTP action surface so newer Hunk
* builds can refresh an older daemon even when it still exposes the same API endpoints.
*/
export const HUNK_SESSION_DAEMON_VERSION = 1;

export type SessionDaemonAction =
| "list"
| "get"
Expand All @@ -41,6 +47,7 @@ export type SessionDaemonAction =

export interface SessionDaemonCapabilities {
version: number;
daemonVersion: number;
actions: SessionDaemonAction[];
}

Expand Down
4 changes: 3 additions & 1 deletion src/ui/AppHost.interactions.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1821,9 +1821,11 @@ describe("App interactions", () => {
}
}

// Page-sized scrolling should move selection ownership into the later file. The exact hunk
// can vary with viewport handoff timing because the page jump may land near either visible
// hunk in second.ts on slower CI machines.
expect(snapshot).toMatchObject({
selectedFilePath: "second.ts",
selectedHunkIndex: 0,
});

for (let index = 0; index < 8; index += 1) {
Expand Down
Loading