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
15 changes: 12 additions & 3 deletions dist/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -4420,12 +4420,10 @@ async function setupV2(context) {
const pursue = command.action === "resume" || command.action === "goal" && args !== "" && !controlOnly;
const signal = execution?.signal ? AbortSignal.any([abortController.signal, execution.signal]) : abortController.signal;
let admitted = false;
let cancellation;
const cancel = () => {
if (!pursue || disposed)
return;
goalServices.stopAutonomy?.(input.sessionID);
cancellation = cancelActiveGoal(input.sessionID).catch((error) => v2ErrorLog("Failed to persist command cancellation", error));
};
execution?.signal?.addEventListener("abort", cancel, { once: true });
try {
Expand Down Expand Up @@ -4470,7 +4468,6 @@ async function setupV2(context) {
}
} finally {
execution?.signal?.removeEventListener("abort", cancel);
await cancellation;
}
}
});
Expand Down Expand Up @@ -4503,6 +4500,18 @@ async function setupV2(context) {
}
}));
}
try {
const hookInterrupt = context.session.hook;
registrations.push(await hookInterrupt("interrupt", async ({ sessionID }) => {
markSessionOwnership(sessionID, true);
goalServices.stopAutonomy?.(sessionID);
try {
await cancelActiveGoal(sessionID);
} catch (error) {
v2ErrorLog("Failed to persist explicit session cancellation", error);
}
}));
} catch {}
registrations.push(await context.tool.transform((draft) => {
for (const tool of goalToolsV2(goalServices))
draft.add(tool);
Expand Down
26 changes: 21 additions & 5 deletions src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2969,13 +2969,10 @@ async function setupV2(context: PluginV2.Plugin.Context): Promise<PluginV2.Plugi
? AbortSignal.any([abortController.signal, execution.signal])
: abortController.signal
let admitted = false
let cancellation: Promise<unknown> | undefined
const cancel = () => {
if (!pursue || disposed) return
goalServices.stopAutonomy?.(input.sessionID)
cancellation = cancelActiveGoal(input.sessionID).catch((error) =>
v2ErrorLog("Failed to persist command cancellation", error),
)
// Transport disposal stops local autonomy without closing the goal.
}
execution?.signal?.addEventListener("abort", cancel, { once: true })
try {
Expand Down Expand Up @@ -3024,7 +3021,6 @@ async function setupV2(context: PluginV2.Plugin.Context): Promise<PluginV2.Plugi
}
} finally {
execution?.signal?.removeEventListener("abort", cancel)
await cancellation
}
},
})
Expand Down Expand Up @@ -3069,6 +3065,26 @@ async function setupV2(context: PluginV2.Plugin.Context): Promise<PluginV2.Plugi
)
}

// New hosts notify explicit user interruption even between execution cycles.
// Older hosts retain their execution-interrupted event handling.
try {
const hookInterrupt = context.session.hook as (
name: "interrupt",
callback: (event: { sessionID: string }) => Promise<void>,
) => Promise<{ dispose(): Promise<void> }>
registrations.push(await hookInterrupt("interrupt", async ({ sessionID }) => {
markSessionOwnership(sessionID, true)
goalServices.stopAutonomy?.(sessionID)
try {
await cancelActiveGoal(sessionID)
} catch (error) {
v2ErrorLog("Failed to persist explicit session cancellation", error)
}
}))
} catch {
// Host predates the explicit session interruption hook.
}

registrations.push(
await context.tool.transform((draft) => {
for (const tool of goalToolsV2(goalServices)) draft.add(tool)
Expand Down
16 changes: 16 additions & 0 deletions test/server-v2.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1807,6 +1807,7 @@ test("V2 cleanup disposes registrations and stops the event consumer", async ()
expect.arrayContaining([
"command.transform",
"session.hook:prompt",
"session.hook:interrupt",
"tool.transform",
"tool.hook:execute.before",
"tool.hook:execute.after",
Expand Down Expand Up @@ -2465,6 +2466,7 @@ test("V2 command cancellation between idle cycles persists cancellation and stop
{ signal: controller.signal },
)
await waitFor(() => waits > 0)
await mock.hooks.interrupt!({ sessionID: "ses_v2" })
controller.abort()
await command
expect((await getGoal("ses_v2"))?.status).toBe("cancelled")
Expand Down Expand Up @@ -2524,3 +2526,17 @@ test("V2 a stopped event stream pauses a waiting goal rather than polling foreve
.execute({ sessionID: "ses_v2", prompt: { text: "Continue the goal" }, delivery: "steer" })
expect((await getGoal("ses_v2"))?.status).toBe("paused")
})

test("V2 a command transport abort preserves the active goal for reconnection", async () => {
const mock = makeMockContext()
let waits = 0
const context = { ...mock, session: { ...mock.session, wait: async () => { waits++ } } }
await setupPlugin(context as never)
await createGoalViaV2Tool(mock, "Preserve scope after disconnect")
const controller = new AbortController()
const command = mock.commands.find((command) => command.name === "goal")!.execute({ sessionID: "ses_v2", prompt: { text: "Preserve scope after disconnect" }, delivery: "steer" }, { signal: controller.signal })
await waitFor(() => waits > 0)
controller.abort()
await command
expect((await getGoal("ses_v2"))?.status).toBe("active")
})
Loading