-
Notifications
You must be signed in to change notification settings - Fork 246
fix(hooks): block to_in_progress for completed tasks (fixes #218) #491
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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"; | ||
|
|
||
|
|
@@ -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 = { | ||
|
|
@@ -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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The new test block covers Prompt To Fix With AIThis 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. |
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
latestHookActivityThe
canTransitionTaskForHookEventguard readslatestHookActivity.hookEventNameto distinguish completion hooks from PreToolUse hooks, butapplyHookActivitymerges any string-valuedhookEventNamefrom subsequent events into that same field (line 842–843 ofsession-manager.ts). Any blockedto_in_progressoractivityevent that arrives with metadata containing ahookEventNameoutsideCOMPLETION_HOOK_EVENT_NAMESwill silently overwrite the field, causing the nextto_in_progressto 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