Skip to content
Closed
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
36 changes: 36 additions & 0 deletions extensions/realtime-voice/src/bridge/abort-race.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { describe, expect, it, vi } from "vitest";
import { raceWithAbort } from "./abort-race.js";

describe("raceWithAbort", () => {
it("removes the abort listener when work completes normally", async () => {
const controller = new AbortController();
const removeEventListener = vi.spyOn(
controller.signal,
"removeEventListener",
);

await expect(
raceWithAbort(
controller.signal,
Promise.resolve("done"),
() => "aborted",
),
).resolves.toBe("done");

expect(removeEventListener).toHaveBeenCalledTimes(1);
expect(removeEventListener).toHaveBeenCalledWith(
"abort",
expect.any(Function),
);
});

it("returns the abort result when the signal aborts first", async () => {
const controller = new AbortController();
const work = new Promise<string>(() => {});
const result = raceWithAbort(controller.signal, work, () => "aborted");

controller.abort();

await expect(result).resolves.toBe("aborted");
});
});
21 changes: 21 additions & 0 deletions extensions/realtime-voice/src/bridge/abort-race.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
export function raceWithAbort<T>(
signal: AbortSignal,
work: Promise<T>,
onAbort: () => T,
): Promise<T> {
let removeListener: () => void = () => {};
const aborted = new Promise<T>((resolve) => {
const listener = () => {
removeListener();
resolve(onAbort());
};
if (signal.aborted) {
listener();
return;
}
signal.addEventListener("abort", listener, { once: true });
removeListener = () => signal.removeEventListener("abort", listener);
});

return Promise.race([work, aborted]).finally(removeListener);
}
20 changes: 6 additions & 14 deletions extensions/realtime-voice/src/bridge/coding-bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
type TaskFinalSummary,
type TaskSnapshot,
} from "@step-cli/realtime";
import { raceWithAbort } from "./abort-race.js";

const log = logger.child({ component: "coding-bridge" });

Expand Down Expand Up @@ -129,21 +130,12 @@ export class CodingBridge {
// throw on abort is not reliable (the subprocess can linger), and a
// run() that never settles would leave currentTask stuck forever →
// cancel appears to do nothing and no new task can be started.
const aborted = new Promise<TaskFinalSummary>((resolve) => {
const onAbort = () =>
resolve({
status: "interrupted",
summary: "任务已取消",
detail: makeDetail(progress) as unknown as Record<
string,
unknown
>,
});
if (ac.signal.aborted) onAbort();
else ac.signal.addEventListener("abort", onAbort, { once: true });
});
const work = this.runAgent(task, shouldResume, ac, progress, emit);
return Promise.race([work, aborted]);
return raceWithAbort(ac.signal, work, () => ({
status: "interrupted",
summary: "任务已取消",
detail: makeDetail(progress) as unknown as Record<string, unknown>,
}));
},
});
}
Expand Down
Loading