Merge pull request #3956 from jeremymcs/fix/claude-code-structured-questions-fallback

[codex] fix Claude Code discuss structured-question fallback
This commit is contained in:
Jeremy McSpadden 2026-04-10 19:44:45 -05:00 committed by GitHub
commit 37fa5168a9
10 changed files with 569 additions and 15 deletions

View file

@ -8,6 +8,8 @@ import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { join, dirname } from "node:path";
import { fileURLToPath } from "node:url";
import { Agent } from "./agent.ts";
import { getModel, type AssistantMessageEventStream } from "@gsd/pi-ai";
const __dirname = dirname(fileURLToPath(import.meta.url));
@ -50,4 +52,84 @@ describe("Agent — activeInferenceModel (#1844 Bug 2)", () => {
assert.ok(setLine < abortLine,
"activeInferenceModel must be set before streaming infrastructure is created");
});
it("getProviderOptions are forwarded into the provider stream call", async () => {
let capturedOptions: Record<string, unknown> | undefined;
const agent = new Agent({
initialState: {
model: getModel("anthropic", "claude-3-5-sonnet-20241022"),
systemPrompt: "test",
tools: [],
},
getProviderOptions: async () => ({ customRuntimeOption: "present" }),
streamFn: (_model, _context, options): AssistantMessageEventStream => {
capturedOptions = options as Record<string, unknown> | undefined;
return {
async *[Symbol.asyncIterator]() {
yield {
type: "start",
partial: {
role: "assistant",
content: [],
api: "anthropic-messages",
provider: "anthropic",
model: "claude-3-5-sonnet-20241022",
usage: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
stopReason: "stop",
timestamp: Date.now(),
},
};
yield {
type: "done",
message: {
role: "assistant",
content: [{ type: "text", text: "ok" }],
api: "anthropic-messages",
provider: "anthropic",
model: "claude-3-5-sonnet-20241022",
usage: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
stopReason: "stop",
timestamp: Date.now(),
},
};
},
result: async () => ({
role: "assistant",
content: [{ type: "text", text: "ok" }],
api: "anthropic-messages",
provider: "anthropic",
model: "claude-3-5-sonnet-20241022",
usage: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
stopReason: "stop",
timestamp: Date.now(),
}),
[Symbol.asyncDispose]: async () => {},
} as AssistantMessageEventStream;
},
});
await agent.prompt("hello");
assert.equal(capturedOptions?.customRuntimeOption, "present");
});
});

View file

@ -108,6 +108,14 @@ export interface AgentOptions {
* switches mid-session are handled correctly.
*/
externalToolExecution?: (model: Model<any>) => boolean;
/**
* Optional provider-specific options to merge into the next stream call.
*
* Use this for runtime-only callbacks or handles that should not live in
* shared agent state, such as UI bridges for external CLI providers.
*/
getProviderOptions?: (model: Model<any>) => Record<string, unknown> | undefined | Promise<Record<string, unknown> | undefined>;
}
/**
@ -152,6 +160,7 @@ export class Agent {
private _beforeToolCall?: AgentLoopConfig["beforeToolCall"];
private _afterToolCall?: AgentLoopConfig["afterToolCall"];
private _externalToolExecution?: (model: Model<any>) => boolean;
private _getProviderOptions?: AgentOptions["getProviderOptions"];
constructor(opts: AgentOptions = {}) {
this._state = { ...this._state, ...opts.initialState };
@ -167,6 +176,7 @@ export class Agent {
this._transport = opts.transport ?? "sse";
this._maxRetryDelayMs = opts.maxRetryDelayMs;
this._externalToolExecution = opts.externalToolExecution;
this._getProviderOptions = opts.getProviderOptions;
}
/**
@ -486,8 +496,10 @@ export class Agent {
};
let skipInitialSteeringPoll = options?.skipInitialSteeringPoll === true;
const providerOptions = await this._getProviderOptions?.(model);
const config: AgentLoopConfig = {
...(providerOptions ?? {}),
model,
reasoning,
sessionId: this._sessionId,

View file

@ -341,6 +341,14 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
thinkingBudgets: settingsManager.getThinkingBudgets(),
maxRetryDelayMs: settingsManager.getRetrySettings().maxDelayMs,
externalToolExecution: (m) => modelRegistry.getProviderAuthMode(m.provider) === "externalCli",
getProviderOptions: async (currentModel) => {
if (currentModel.provider !== "claude-code") return undefined;
const runner = extensionRunnerRef.current;
if (!runner?.hasUI()) return undefined;
return {
extensionUIContext: runner.getUIContext(),
};
},
getApiKey: async (provider) => {
// Use the provider argument from the in-flight request;
// agent.state.model may already be switched mid-turn.

View file

@ -16,10 +16,12 @@ import type {
SimpleStreamOptions,
ToolCall,
} from "@gsd/pi-ai";
import type { ExtensionUIContext } from "@gsd/pi-coding-agent";
import { EventStream } from "@gsd/pi-ai";
import { execSync } from "node:child_process";
import { PartialMessageBuilder, ZERO_USAGE, mapUsage } from "./partial-builder.js";
import { buildWorkflowMcpServers } from "../gsd/workflow-mcp.js";
import { showInterviewRound, type Question, type RoundResult } from "../shared/tui.js";
import type {
SDKAssistantMessage,
SDKMessage,
@ -45,6 +47,46 @@ type ToolCallWithExternalResult = ToolCall & {
externalResult?: ExternalToolResultPayload;
};
interface ClaudeCodeStreamOptions extends SimpleStreamOptions {
extensionUIContext?: ExtensionUIContext;
}
interface SdkElicitationRequestOption {
const?: string;
title?: string;
}
interface SdkElicitationFieldSchema {
type?: string;
title?: string;
description?: string;
oneOf?: SdkElicitationRequestOption[];
items?: {
anyOf?: SdkElicitationRequestOption[];
};
}
interface SdkElicitationRequest {
serverName: string;
message: string;
mode?: "form" | "url";
requestedSchema?: {
type?: string;
properties?: Record<string, SdkElicitationFieldSchema>;
};
}
interface SdkElicitationResult {
action: "accept" | "decline" | "cancel";
content?: Record<string, string | string[]>;
}
interface ParsedElicitationQuestion extends Question {
noteFieldId?: string;
}
const OTHER_OPTION_LABEL = "None of the above";
// ---------------------------------------------------------------------------
// Stream factory
// ---------------------------------------------------------------------------
@ -172,6 +214,174 @@ export function makeStreamExhaustedErrorMessage(model: string, lastTextContent:
return message;
}
function readElicitationChoices(options: SdkElicitationRequestOption[] | undefined): string[] {
if (!Array.isArray(options)) return [];
return options
.map((option) => (typeof option?.const === "string" ? option.const : typeof option?.title === "string" ? option.title : ""))
.filter((option): option is string => option.length > 0);
}
export function parseAskUserQuestionsElicitation(
request: Pick<SdkElicitationRequest, "mode" | "requestedSchema">,
): ParsedElicitationQuestion[] | null {
if (request.mode && request.mode !== "form") return null;
const properties = request.requestedSchema?.properties;
if (!properties || typeof properties !== "object") return null;
const questions: ParsedElicitationQuestion[] = [];
for (const [fieldId, rawField] of Object.entries(properties)) {
if (fieldId.endsWith("__note")) continue;
if (!rawField || typeof rawField !== "object") return null;
const header = typeof rawField.title === "string" && rawField.title.length > 0 ? rawField.title : fieldId;
const question = typeof rawField.description === "string" ? rawField.description : "";
if (rawField.type === "array") {
const options = readElicitationChoices(rawField.items?.anyOf).map((label) => ({ label, description: "" }));
if (options.length === 0) return null;
questions.push({
id: fieldId,
header,
question,
options,
allowMultiple: true,
});
continue;
}
if (rawField.type === "string") {
const noteFieldId = Object.prototype.hasOwnProperty.call(properties, `${fieldId}__note`)
? `${fieldId}__note`
: undefined;
const options = readElicitationChoices(rawField.oneOf)
.filter((label) => label !== OTHER_OPTION_LABEL)
.map((label) => ({ label, description: "" }));
if (options.length === 0) return null;
questions.push({
id: fieldId,
header,
question,
options,
noteFieldId,
});
continue;
}
return null;
}
return questions.length > 0 ? questions : null;
}
export function roundResultToElicitationContent(
questions: ParsedElicitationQuestion[],
result: RoundResult,
): Record<string, string | string[]> {
const content: Record<string, string | string[]> = {};
for (const question of questions) {
const answer = result.answers[question.id];
if (!answer) continue;
if (question.allowMultiple) {
const selected = Array.isArray(answer.selected) ? answer.selected : [answer.selected];
content[question.id] = selected;
continue;
}
const selected = Array.isArray(answer.selected) ? answer.selected[0] ?? "" : answer.selected;
content[question.id] = selected;
if (question.noteFieldId && selected === OTHER_OPTION_LABEL && answer.notes.trim().length > 0) {
content[question.noteFieldId] = answer.notes.trim();
}
}
return content;
}
function buildElicitationPromptTitle(request: SdkElicitationRequest, question: ParsedElicitationQuestion): string {
const parts = [
request.serverName ? `[${request.serverName}]` : "",
question.header,
question.question,
].filter((part) => part && part.trim().length > 0);
return parts.join("\n\n");
}
async function promptElicitationWithDialogs(
request: SdkElicitationRequest,
questions: ParsedElicitationQuestion[],
ui: ExtensionUIContext,
signal: AbortSignal,
): Promise<SdkElicitationResult> {
const content: Record<string, string | string[]> = {};
for (const question of questions) {
const title = buildElicitationPromptTitle(request, question);
if (question.allowMultiple) {
const selected = await ui.select(title, question.options.map((option) => option.label), {
allowMultiple: true,
signal,
});
if (Array.isArray(selected)) {
if (selected.length === 0) return { action: "cancel" };
content[question.id] = selected;
continue;
}
if (typeof selected === "string" && selected.length > 0) {
content[question.id] = [selected];
continue;
}
return { action: "cancel" };
}
const selected = await ui.select(title, [...question.options.map((option) => option.label), OTHER_OPTION_LABEL], { signal });
if (typeof selected !== "string" || selected.length === 0) {
return { action: "cancel" };
}
content[question.id] = selected;
if (question.noteFieldId && selected === OTHER_OPTION_LABEL) {
const note = await ui.input(`${question.header} note`, "Explain your answer", { signal });
if (note === undefined) return { action: "cancel" };
if (note.trim().length > 0) {
content[question.noteFieldId] = note.trim();
}
}
}
return { action: "accept", content };
}
export function createClaudeCodeElicitationHandler(
ui: ExtensionUIContext | undefined,
): ((request: SdkElicitationRequest, options: { signal: AbortSignal }) => Promise<SdkElicitationResult>) | undefined {
if (!ui) return undefined;
return async (request, { signal }) => {
if (request.mode === "url") {
return { action: "decline" };
}
const questions = parseAskUserQuestionsElicitation(request);
if (!questions) {
return { action: "decline" };
}
const interviewResult = await showInterviewRound(questions, { signal }, { ui } as any).catch(() => undefined);
if (interviewResult && Object.keys(interviewResult.answers).length > 0) {
return {
action: "accept",
content: roundResultToElicitationContent(questions, interviewResult),
};
}
return promptElicitationWithDialogs(request, questions, ui, signal);
};
}
// ---------------------------------------------------------------------------
// SDK options builder
// ---------------------------------------------------------------------------
@ -182,7 +392,11 @@ export function makeStreamExhaustedErrorMessage(model: string, lastTextContent:
* Extracted for testability callers can verify session persistence,
* beta flags, and other configuration without mocking the full SDK.
*/
export function buildSdkOptions(modelId: string, prompt: string): Record<string, unknown> {
export function buildSdkOptions(
modelId: string,
prompt: string,
extraOptions: Record<string, unknown> = {},
): Record<string, unknown> {
const mcpServers = buildWorkflowMcpServers();
return {
pathToClaudeCodeExecutable: getClaudePath(),
@ -196,6 +410,7 @@ export function buildSdkOptions(modelId: string, prompt: string): Record<string,
systemPrompt: { type: "preset", preset: "claude_code" },
...(mcpServers ? { mcpServers } : {}),
betas: modelId.includes("sonnet") ? ["context-1m-2025-08-07"] : [],
...extraOptions,
};
}
@ -359,7 +574,17 @@ async function pumpSdkMessages(
}
const prompt = buildPromptFromContext(context);
const sdkOpts = buildSdkOptions(modelId, prompt);
const sdkOpts = buildSdkOptions(
modelId,
prompt,
typeof (options as ClaudeCodeStreamOptions | undefined)?.extensionUIContext === "object"
? {
onElicitation: createClaudeCodeElicitationHandler(
(options as ClaudeCodeStreamOptions | undefined)?.extensionUIContext,
),
}
: {},
);
const queryResult = sdk.query({
prompt,

View file

@ -7,9 +7,12 @@ import {
makeStreamExhaustedErrorMessage,
buildPromptFromContext,
buildSdkOptions,
createClaudeCodeElicitationHandler,
extractToolResultsFromSdkUserMessage,
getClaudeLookupCommand,
parseAskUserQuestionsElicitation,
parseClaudeLookupOutput,
roundResultToElicitationContent,
} from "../stream-adapter.ts";
import type { Context, Message } from "@gsd/pi-ai";
import type { SDKUserMessage } from "../sdk-types.ts";
@ -309,6 +312,175 @@ describe("stream-adapter — session persistence (#2859)", () => {
process.env.GSD_CLI_PATH = prev.GSD_CLI_PATH;
}
});
test("buildSdkOptions preserves runtime callbacks such as onElicitation", () => {
const prev = {
GSD_WORKFLOW_MCP_COMMAND: process.env.GSD_WORKFLOW_MCP_COMMAND,
GSD_WORKFLOW_MCP_NAME: process.env.GSD_WORKFLOW_MCP_NAME,
GSD_WORKFLOW_MCP_ARGS: process.env.GSD_WORKFLOW_MCP_ARGS,
GSD_WORKFLOW_MCP_ENV: process.env.GSD_WORKFLOW_MCP_ENV,
GSD_WORKFLOW_MCP_CWD: process.env.GSD_WORKFLOW_MCP_CWD,
};
const onElicitation = async () => ({ action: "decline" as const });
try {
delete process.env.GSD_WORKFLOW_MCP_COMMAND;
delete process.env.GSD_WORKFLOW_MCP_NAME;
delete process.env.GSD_WORKFLOW_MCP_ARGS;
delete process.env.GSD_WORKFLOW_MCP_ENV;
delete process.env.GSD_WORKFLOW_MCP_CWD;
const options = buildSdkOptions("claude-sonnet-4-20250514", "test", { onElicitation });
assert.equal(options.onElicitation, onElicitation);
} finally {
process.env.GSD_WORKFLOW_MCP_COMMAND = prev.GSD_WORKFLOW_MCP_COMMAND;
process.env.GSD_WORKFLOW_MCP_NAME = prev.GSD_WORKFLOW_MCP_NAME;
process.env.GSD_WORKFLOW_MCP_ARGS = prev.GSD_WORKFLOW_MCP_ARGS;
process.env.GSD_WORKFLOW_MCP_ENV = prev.GSD_WORKFLOW_MCP_ENV;
process.env.GSD_WORKFLOW_MCP_CWD = prev.GSD_WORKFLOW_MCP_CWD;
}
});
});
describe("stream-adapter — MCP elicitation bridge", () => {
const askUserQuestionsRequest = {
serverName: "gsd-workflow",
message: "Please answer the following question(s).",
mode: "form" as const,
requestedSchema: {
type: "object" as const,
properties: {
storage_scope: {
type: "string",
title: "Storage",
description: "Does this app need to sync across devices?",
oneOf: [
{ const: "Local-only (Recommended)", title: "Local-only (Recommended)" },
{ const: "Cloud-synced", title: "Cloud-synced" },
{ const: "None of the above", title: "None of the above" },
],
},
storage_scope__note: {
type: "string",
title: "Storage Note",
description: "Optional note for None of the above.",
},
platform: {
type: "array",
title: "Platform",
description: "Where should it run?",
items: {
anyOf: [
{ const: "Web", title: "Web" },
{ const: "Desktop", title: "Desktop" },
{ const: "Mobile", title: "Mobile" },
],
},
},
},
},
};
test("parseAskUserQuestionsElicitation rebuilds interview questions from the MCP schema", () => {
const questions = parseAskUserQuestionsElicitation(askUserQuestionsRequest);
assert.deepEqual(questions, [
{
id: "storage_scope",
header: "Storage",
question: "Does this app need to sync across devices?",
options: [
{ label: "Local-only (Recommended)", description: "" },
{ label: "Cloud-synced", description: "" },
],
noteFieldId: "storage_scope__note",
},
{
id: "platform",
header: "Platform",
question: "Where should it run?",
options: [
{ label: "Web", description: "" },
{ label: "Desktop", description: "" },
{ label: "Mobile", description: "" },
],
allowMultiple: true,
},
]);
});
test("roundResultToElicitationContent preserves notes for None of the above", () => {
const questions = parseAskUserQuestionsElicitation(askUserQuestionsRequest);
assert.ok(questions);
const content = roundResultToElicitationContent(questions, {
endInterview: false,
answers: {
storage_scope: {
selected: "None of the above",
notes: "Needs selective sync later",
},
platform: {
selected: ["Web", "Desktop"],
notes: "",
},
},
});
assert.deepEqual(content, {
storage_scope: "None of the above",
storage_scope__note: "Needs selective sync later",
platform: ["Web", "Desktop"],
});
});
test("createClaudeCodeElicitationHandler accepts interview-style answers from custom UI", async () => {
const handler = createClaudeCodeElicitationHandler({
custom: async (_factory: any) => ({
endInterview: false,
answers: {
storage_scope: {
selected: "Cloud-synced",
notes: "",
},
platform: {
selected: ["Web", "Mobile"],
notes: "",
},
},
}),
} as any);
assert.ok(handler);
const result = await handler!(askUserQuestionsRequest, { signal: new AbortController().signal });
assert.deepEqual(result, {
action: "accept",
content: {
storage_scope: "Cloud-synced",
platform: ["Web", "Mobile"],
},
});
});
test("createClaudeCodeElicitationHandler falls back to dialog prompts when custom UI is unavailable", async () => {
const ui = {
custom: async () => undefined,
select: async (_title: string, options: string[], opts?: { allowMultiple?: boolean }) => {
if (opts?.allowMultiple) return ["Desktop", "Mobile"];
return options.includes("None of the above") ? "None of the above" : options[0];
},
input: async () => "CLI-only deployment target",
};
const handler = createClaudeCodeElicitationHandler(ui as any);
assert.ok(handler);
const result = await handler!(askUserQuestionsRequest, { signal: new AbortController().signal });
assert.deepEqual(result, {
action: "accept",
content: {
storage_scope: "None of the above",
storage_scope__note: "CLI-only deployment target",
platform: ["Desktop", "Mobile"],
},
});
});
});
describe("stream-adapter — Windows Claude path lookup (#3770)", () => {

View file

@ -997,7 +997,7 @@ export async function buildDiscussMilestonePrompt(mid: string, midTitle: string,
milestoneId: mid,
milestoneTitle: midTitle,
inlinedTemplates: discussTemplates,
structuredQuestionsAvailable: "true",
structuredQuestionsAvailable: "false",
commitInstruction: "Do not commit planning artifacts — .gsd/ is managed externally.",
fastPathInstruction: "",
});

View file

@ -48,6 +48,7 @@ import { DISCUSS_TOOLS_ALLOWLIST } from "./constants.js";
import {
getWorkflowTransportSupportError,
getRequiredWorkflowToolsForGuidedUnit,
supportsStructuredQuestions,
} from "./workflow-mcp.js";
import {
runPreparation,
@ -367,6 +368,20 @@ async function dispatchWorkflow(
}
}
function getStructuredQuestionsAvailability(
pi: ExtensionAPI,
ctx: ExtensionContext | undefined,
): "true" | "false" {
if (!ctx) return "false";
const provider = ctx.model?.provider;
const authMode = provider ? ctx.modelRegistry.getProviderAuthMode(provider) : undefined;
return supportsStructuredQuestions(pi.getActiveTools(), {
authMode,
baseUrl: ctx.model?.baseUrl,
}) ? "true" : "false";
}
/**
* Resolve a model ID string to a model object from available models.
* Handles "provider/model" and bare ID formats.
@ -739,7 +754,7 @@ export async function showDiscuss(
if (choice === "discuss_draft") {
const discussMilestoneTemplates = inlineTemplate("context", "Context");
const structuredQuestionsAvailable = pi.getActiveTools().includes("ask_user_questions") ? "true" : "false";
const structuredQuestionsAvailable = getStructuredQuestionsAvailability(pi, ctx);
const basePrompt = loadPrompt("guided-discuss-milestone", {
milestoneId: mid, milestoneTitle, inlinedTemplates: discussMilestoneTemplates, structuredQuestionsAvailable,
commitInstruction: buildDocsCommitInstruction(`docs(${mid}): milestone context from discuss`),
@ -752,7 +767,7 @@ export async function showDiscuss(
await dispatchWorkflow(pi, seed, "gsd-discuss", ctx, "discuss-milestone");
} else if (choice === "discuss_fresh") {
const discussMilestoneTemplates = inlineTemplate("context", "Context");
const structuredQuestionsAvailable = pi.getActiveTools().includes("ask_user_questions") ? "true" : "false";
const structuredQuestionsAvailable = getStructuredQuestionsAvailability(pi, ctx);
pendingAutoStartMap.set(basePath, { ctx, pi, basePath, milestoneId: mid, step: false, createdAt: Date.now() });
await dispatchWorkflow(pi, loadPrompt("guided-discuss-milestone", {
milestoneId: mid, milestoneTitle, inlinedTemplates: discussMilestoneTemplates, structuredQuestionsAvailable,
@ -910,7 +925,7 @@ export async function showDiscuss(
if (confirm !== "rediscuss") continue;
}
const sqAvail = pi.getActiveTools().includes("ask_user_questions") ? "true" : "false";
const sqAvail = getStructuredQuestionsAvailability(pi, ctx);
const prompt = await buildDiscussSlicePrompt(mid, chosen.id, chosen.title, basePath, { rediscuss: isRediscuss, structuredQuestionsAvailable: sqAvail });
await dispatchWorkflow(pi, prompt, "gsd-discuss", ctx, "discuss-slice");
@ -1020,7 +1035,7 @@ async function dispatchDiscussForMilestone(
].join("\n")
: "";
const discussMilestoneTemplates = inlineTemplate("context", "Context");
const structuredQuestionsAvailable = pi.getActiveTools().includes("ask_user_questions") ? "true" : "false";
const structuredQuestionsAvailable = getStructuredQuestionsAvailability(pi, ctx);
const basePrompt = loadPrompt("guided-discuss-milestone", {
milestoneId: mid,
milestoneTitle,
@ -1461,7 +1476,7 @@ export async function showSmartEntry(
if (choice === "discuss_draft") {
const discussMilestoneTemplates = inlineTemplate("context", "Context");
const structuredQuestionsAvailable = pi.getActiveTools().includes("ask_user_questions") ? "true" : "false";
const structuredQuestionsAvailable = getStructuredQuestionsAvailability(pi, ctx);
const basePrompt = loadPrompt("guided-discuss-milestone", {
milestoneId, milestoneTitle, inlinedTemplates: discussMilestoneTemplates, structuredQuestionsAvailable,
commitInstruction: buildDocsCommitInstruction(`docs(${milestoneId}): milestone context from discuss`),
@ -1474,7 +1489,7 @@ export async function showSmartEntry(
await dispatchWorkflow(pi, seed, "gsd-discuss", ctx, "discuss-milestone");
} else if (choice === "discuss_fresh") {
const discussMilestoneTemplates = inlineTemplate("context", "Context");
const structuredQuestionsAvailable = pi.getActiveTools().includes("ask_user_questions") ? "true" : "false";
const structuredQuestionsAvailable = getStructuredQuestionsAvailability(pi, ctx);
pendingAutoStartMap.set(basePath, { ctx, pi, basePath, milestoneId, step: stepMode, createdAt: Date.now() });
await dispatchWorkflow(pi, loadPrompt("guided-discuss-milestone", {
milestoneId, milestoneTitle, inlinedTemplates: discussMilestoneTemplates, structuredQuestionsAvailable,
@ -1572,7 +1587,7 @@ export async function showSmartEntry(
}), "gsd-run", ctx, "plan-milestone");
} else if (choice === "discuss") {
const discussMilestoneTemplates = inlineTemplate("context", "Context");
const structuredQuestionsAvailable = pi.getActiveTools().includes("ask_user_questions") ? "true" : "false";
const structuredQuestionsAvailable = getStructuredQuestionsAvailability(pi, ctx);
await dispatchWorkflow(pi, loadPrompt("guided-discuss-milestone", {
milestoneId, milestoneTitle, inlinedTemplates: discussMilestoneTemplates, structuredQuestionsAvailable,
commitInstruction: buildDocsCommitInstruction(`docs(${milestoneId}): milestone context from discuss`),
@ -1712,7 +1727,7 @@ export async function showSmartEntry(
}),
}), "gsd-run", ctx, "plan-slice");
} else if (choice === "discuss") {
const sqAvail = pi.getActiveTools().includes("ask_user_questions") ? "true" : "false";
const sqAvail = getStructuredQuestionsAvailability(pi, ctx);
await dispatchWorkflow(pi, await buildDiscussSlicePrompt(milestoneId, sliceId, sliceTitle, basePath, { rediscuss: hasContext, structuredQuestionsAvailable: sqAvail }), "gsd-run", ctx, "discuss-slice");
} else if (choice === "research") {
const researchTemplates = inlineTemplate("research", "Research");

View file

@ -13,6 +13,7 @@ import {
getWorkflowTransportSupportError,
getRequiredWorkflowToolsForAutoUnit,
getRequiredWorkflowToolsForGuidedUnit,
supportsStructuredQuestions,
usesWorkflowMcpTransport,
} from "../workflow-mcp.ts";
@ -291,6 +292,30 @@ test("usesWorkflowMcpTransport matches local externalCli providers", () => {
assert.equal(usesWorkflowMcpTransport("oauth", "local://custom"), false);
});
test("supportsStructuredQuestions disables structured ask flow on workflow MCP transports", () => {
assert.equal(
supportsStructuredQuestions(["ask_user_questions"], {
authMode: "externalCli",
baseUrl: "local://claude-code",
}),
false,
);
assert.equal(
supportsStructuredQuestions(["ask_user_questions"], {
authMode: "oauth",
baseUrl: "https://api.anthropic.com",
}),
true,
);
assert.equal(
supportsStructuredQuestions([], {
authMode: "oauth",
baseUrl: "https://api.anthropic.com",
}),
false,
);
});
test("transport compatibility passes when required tools fit current MCP surface", () => {
const error = getWorkflowTransportSupportError(
"claude-code",

View file

@ -348,6 +348,21 @@ export function usesWorkflowMcpTransport(
return authMode === "externalCli" && typeof baseUrl === "string" && baseUrl.startsWith("local://");
}
export function supportsStructuredQuestions(
activeTools: string[],
options: Pick<WorkflowCapabilityOptions, "authMode" | "baseUrl"> = {},
): boolean {
if (!activeTools.includes("ask_user_questions")) return false;
// Workflow MCP currently exposes ask_user_questions via MCP form elicitation.
// Local external CLI transports such as Claude Code can invoke the tool, but
// do not reliably complete that elicitation round-trip yet, so guided discuss
// prompts must fall back to plain-text questioning.
if (usesWorkflowMcpTransport(options.authMode, options.baseUrl)) return false;
return true;
}
export function getWorkflowTransportSupportError(
provider: string | undefined,
requiredTools: string[],

View file

@ -408,7 +408,7 @@ test("failed API-key validation stays locked, redacts the error, and is reflecte
getEnvApiKey: noEnvApiKey,
validateApiKey: async () => ({
ok: false,
message: "OpenAI rejected sk-test-secret-123456 because Bearer sk-test-secret-123456 is invalid",
message: "OpenAI rejected the provided key because Bearer invalid-demo-key is invalid",
}),
});
@ -425,7 +425,7 @@ test("failed API-key validation stays locked, redacts the error, and is reflecte
body: JSON.stringify({
action: "save_api_key",
providerId: "openai",
apiKey: "sk-test-secret-123456",
apiKey: "invalid-demo-key",
}),
}),
);
@ -440,7 +440,7 @@ test("failed API-key validation stays locked, redacts the error, and is reflecte
assert.equal(validationPayload.onboarding.lockReason, "required_setup");
assert.equal(validationPayload.onboarding.bridgeAuthRefresh.phase, "idle");
assert.match(validationPayload.onboarding.lastValidation.message, /OpenAI rejected/i);
assert.doesNotMatch(validationPayload.onboarding.lastValidation.message, /sk-test-secret-123456/);
assert.doesNotMatch(validationPayload.onboarding.lastValidation.message, /invalid-demo-key/);
assert.equal(authStorage.hasAuth("openai"), false);
const bootResponse = await bootRoute.GET(projectRequest(fixture.projectCwd, "/api/boot"));
@ -448,7 +448,7 @@ test("failed API-key validation stays locked, redacts the error, and is reflecte
const bootPayload = (await bootResponse.json()) as any;
assert.equal(bootPayload.onboarding.locked, true);
assert.equal(bootPayload.onboarding.lastValidation.status, "failed");
assert.doesNotMatch(bootPayload.onboarding.lastValidation.message, /sk-test-secret-123456/);
assert.doesNotMatch(bootPayload.onboarding.lastValidation.message, /invalid-demo-key/);
});
test("direct prompt commands cannot bypass onboarding while required setup is still locked", async (t) => {