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
27 changes: 23 additions & 4 deletions src/trpc/hooks-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,17 +23,36 @@ export interface CreateHooksApiDependencies {
deleteTaskTurnCheckpointRef?: (input: { cwd: string; ref: string }) => Promise<void>;
}

const COMPLETION_HOOK_EVENT_NAMES = new Set(["TaskComplete", "stop", "subagentstop", "afteragent"]);

function canTransitionTaskForHookEvent(summary: RuntimeTaskSessionSummary, event: RuntimeHookEvent): boolean {
if (event === "activity") {
return false;
}
if (event === "to_review") {
return summary.state === "running";
}
return (
summary.state === "awaiting_review" &&
(summary.reviewReason === "attention" || summary.reviewReason === "hook" || summary.reviewReason === "error")
);
if (summary.state !== "awaiting_review") {
return false;
}

// attention/error reviews can always return to running.
if (summary.reviewReason === "attention" || summary.reviewReason === "error") {
return true;
}

// Hook-triggered reviews: disallow to_in_progress when the hook that
// triggered review was a completion event (TaskComplete, stop, etc.).
// This prevents auto-commit PreToolUse from flipping completed tasks back to in_progress.
if (summary.reviewReason === "hook") {
const hookEventName = summary.latestHookActivity?.hookEventName ?? null;
if (hookEventName && COMPLETION_HOOK_EVENT_NAMES.has(hookEventName)) {
return false;
}
return true;
}
Comment on lines +44 to +53

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Guard relies on mutable latestHookActivity

The canTransitionTaskForHookEvent guard reads latestHookActivity.hookEventName to distinguish completion hooks from PreToolUse hooks, but applyHookActivity merges any string-valued hookEventName from subsequent events into that same field (line 842–843 of session-manager.ts). Any blocked to_in_progress or activity event that arrives with metadata containing a hookEventName outside COMPLETION_HOOK_EVENT_NAMES will silently overwrite the field, causing the next to_in_progress to pass the guard even on a completed task. A safer approach is to store the hook event name that triggered the review as a dedicated, write-once field (e.g. reviewHookEventName) so it cannot be clobbered by later activity updates.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/trpc/hooks-api.ts
Line: 44-53

Comment:
**Guard relies on mutable `latestHookActivity`**

The `canTransitionTaskForHookEvent` guard reads `latestHookActivity.hookEventName` to distinguish completion hooks from PreToolUse hooks, but `applyHookActivity` merges any string-valued `hookEventName` from subsequent events into that same field (line 842–843 of `session-manager.ts`). Any blocked `to_in_progress` or `activity` event that arrives with metadata containing a `hookEventName` outside `COMPLETION_HOOK_EVENT_NAMES` will silently overwrite the field, causing the next `to_in_progress` to pass the guard even on a completed task. A safer approach is to store the hook event name that triggered the review as a dedicated, write-once field (e.g. `reviewHookEventName`) so it cannot be clobbered by later activity updates.

How can I resolve this? If you propose a fix, please make it concise.


return false;
}

export function createHooksApi(deps: CreateHooksApiDependencies): RuntimeTrpcContext["hooksApi"] {
Expand Down
111 changes: 110 additions & 1 deletion test/runtime/trpc/hooks-api.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { describe, expect, it, vi } from "vitest";

import type { RuntimeTaskSessionSummary } from "../../../src/core/api-contract";
import type { RuntimeTaskHookActivity, RuntimeTaskSessionSummary } from "../../../src/core/api-contract";
import type { TerminalSessionManager } from "../../../src/terminal/session-manager";
import { createHooksApi } from "../../../src/trpc/hooks-api";

Expand All @@ -22,6 +22,30 @@ function createSummary(overrides: Partial<RuntimeTaskSessionSummary> = {}): Runt
};
}

function createHookActivity(overrides: Partial<RuntimeTaskHookActivity> = {}): RuntimeTaskHookActivity {
return {
activityText: null,
toolName: null,
toolInputSummary: null,
finalMessage: null,
hookEventName: null,
notificationType: null,
source: null,
...overrides,
};
}

function createAwaitingReviewSummary(
reviewReason: RuntimeTaskSessionSummary["reviewReason"],
hookEventName: string | null,
): RuntimeTaskSessionSummary {
return createSummary({
state: "awaiting_review",
reviewReason,
latestHookActivity: hookEventName !== null ? createHookActivity({ hookEventName }) : null,
});
}

describe("createHooksApi", () => {
it("treats ineligible hook transitions as successful no-ops", async () => {
const manager = {
Expand Down Expand Up @@ -145,4 +169,89 @@ describe("createHooksApi", () => {
ref: "refs/kanban/checkpoints/task-1/turn/1",
});
});

describe("to_in_progress guard for hook-triggered reviews", () => {
function makeManager(summary: RuntimeTaskSessionSummary) {
return {
getSummary: vi.fn(() => summary),
transitionToReview: vi.fn(),
transitionToRunning: vi.fn(() => summary),
applyHookActivity: vi.fn(),
} as unknown as TerminalSessionManager;
}

async function ingestToInProgress(summary: RuntimeTaskSessionSummary) {
const manager = makeManager(summary);
const api = createHooksApi({
getWorkspacePathById: vi.fn(() => "/tmp/repo"),
ensureTerminalManagerForWorkspace: vi.fn(async () => manager),
broadcastRuntimeWorkspaceStateUpdated: vi.fn(),
broadcastTaskReadyForReview: vi.fn(),
});

const response = await api.ingest({
taskId: "task-1",
workspaceId: "workspace-1",
event: "to_in_progress",
});

return { response, manager };
}

it("blocks to_in_progress when reviewReason=hook and latestHookActivity.hookEventName=TaskComplete", async () => {
const summary = createAwaitingReviewSummary("hook", "TaskComplete");
const { response, manager } = await ingestToInProgress(summary);
expect(response).toEqual({ ok: true });
expect(manager.transitionToRunning).not.toHaveBeenCalled();
});

it("blocks to_in_progress when reviewReason=hook and latestHookActivity.hookEventName=stop", async () => {
const summary = createAwaitingReviewSummary("hook", "stop");
const { response, manager } = await ingestToInProgress(summary);
expect(response).toEqual({ ok: true });
expect(manager.transitionToRunning).not.toHaveBeenCalled();
});

it("blocks to_in_progress when reviewReason=hook and latestHookActivity.hookEventName=afteragent", async () => {
const summary = createAwaitingReviewSummary("hook", "afteragent");
const { response, manager } = await ingestToInProgress(summary);
expect(response).toEqual({ ok: true });
expect(manager.transitionToRunning).not.toHaveBeenCalled();
});

it("blocks to_in_progress when reviewReason=hook and latestHookActivity.hookEventName=subagentstop", async () => {
const summary = createAwaitingReviewSummary("hook", "subagentstop");
const { response, manager } = await ingestToInProgress(summary);
expect(response).toEqual({ ok: true });
expect(manager.transitionToRunning).not.toHaveBeenCalled();
});

it("allows to_in_progress when reviewReason=hook and latestHookActivity.hookEventName=PreToolUse (ask_followup_question)", async () => {
const summary = createAwaitingReviewSummary("hook", "PreToolUse");
const { response, manager } = await ingestToInProgress(summary);
expect(response).toEqual({ ok: true });
expect(manager.transitionToRunning).toHaveBeenCalled();
});

it("allows to_in_progress when reviewReason=hook and latestHookActivity=null (backward compat)", async () => {
const summary = createAwaitingReviewSummary("hook", null);
const { response, manager } = await ingestToInProgress(summary);
expect(response).toEqual({ ok: true });
expect(manager.transitionToRunning).toHaveBeenCalled();
});

it("allows to_in_progress when reviewReason=attention (user returned)", async () => {
const summary = createAwaitingReviewSummary("attention", null);
const { response, manager } = await ingestToInProgress(summary);
expect(response).toEqual({ ok: true });
expect(manager.transitionToRunning).toHaveBeenCalled();
});

it("allows to_in_progress when reviewReason=error (error recovery)", async () => {
const summary = createAwaitingReviewSummary("error", null);
const { response, manager } = await ingestToInProgress(summary);
expect(response).toEqual({ ok: true });
expect(manager.transitionToRunning).toHaveBeenCalled();
});
});
});
Comment on lines 169 to 257

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Missing tests for exit and interrupted review reasons

The new test block covers attention, error, and hook, but reviewReason can also be "exit" or "interrupted" (per runtimeTaskSessionReviewReasonSchema). Both fall through all the if branches in the new code and return false, matching the old behavior. Tests for these cases would lock in that expectation and prevent a future contributor from accidentally adding them to the allow-list.

Prompt To Fix With AI
This is a comment left during a code review.
Path: test/runtime/trpc/hooks-api.test.ts
Line: 169-257

Comment:
**Missing tests for `exit` and `interrupted` review reasons**

The new test block covers `attention`, `error`, and `hook`, but `reviewReason` can also be `"exit"` or `"interrupted"` (per `runtimeTaskSessionReviewReasonSchema`). Both fall through all the `if` branches in the new code and return `false`, matching the old behavior. Tests for these cases would lock in that expectation and prevent a future contributor from accidentally adding them to the allow-list.

How can I resolve this? If you propose a fix, please make it concise.