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
16 changes: 11 additions & 5 deletions src/resources/extensions/shared/next-action-ui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,11 +118,9 @@ export async function showNextAction(
}
});

// Headless guard: when no UI is bound (noOpUIContext), ctx.ui.custom() resolves
// to undefined immediately, and ctx.ui.select() does the same. Skip both and
// return the safe default so callers don't await two no-op promises before
// reaching a deterministic "not_yet". Lockup #5125 root protection.
if (!ctx.hasUI) {
// Headless/non-interactive guard: avoid emitting interactive select requests
// in contexts where no human can answer (no UI, RPC/headless shims).
if (!isInteractiveUIContext(ctx)) {
return "not_yet";
}

Expand Down Expand Up @@ -218,3 +216,11 @@ export async function showNextAction(

return result;
}

function isInteractiveUIContext(ctx: ExtensionCommandContext): boolean {
if (!ctx.hasUI) return false;
if (process.env.GSD_HEADLESS === "1") return false;
const uiMode = (ctx.ui as { mode?: string } | undefined)?.mode;
if (uiMode === "rpc" || uiMode === "headless") return false;
return true;
}
32 changes: 32 additions & 0 deletions src/resources/extensions/shared/tests/next-action-ui-hasui.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,38 @@ describe("showNextAction ctx.hasUI guard (#5125 lockup root protection)", () =>
assert.equal(result, "alpha", "fallback should map the picked label back to the chosen action id");
});

it("returns 'not_yet' immediately when UI mode is rpc even if ctx.hasUI is true", async () => {
let customCalled = 0;
let selectCalled = 0;

const ctx = {
hasUI: true,
ui: {
mode: "rpc",
custom: async () => {
customCalled++;
return undefined as never;
},
select: async () => {
selectCalled++;
return undefined;
},
},
};

const result = await showNextAction(ctx as any, {
title: "GSD — test",
actions: [
{ id: "alpha", label: "Alpha", description: "first", recommended: true },
{ id: "beta", label: "Beta", description: "second" },
],
});

assert.equal(result, "not_yet", "rpc-backed UI is non-interactive for next-action");
assert.equal(customCalled, 0, "ctx.ui.custom must not be called in rpc mode");
assert.equal(selectCalled, 0, "ctx.ui.select must not be called in rpc mode");
});

it("returns the resolved id when ctx.ui.custom completes normally", async () => {
let selectCalled = 0;

Expand Down
Loading