Merge pull request #3997 from jeremymcs/fix/mcp-output-stream-order-secure-fallback
fix(interactive): keep MCP tool ordering and secure env fallback prompts
This commit is contained in:
commit
ad61c43907
13 changed files with 470 additions and 51 deletions
|
|
@ -150,3 +150,71 @@ test("chat-controller keeps tool output ahead of delayed assistant text for exte
|
|||
assert.equal(host.chatContainer.children[0]?.constructor?.name, "ToolExecutionComponent");
|
||||
assert.equal(host.chatContainer.children[1]?.constructor?.name, "AssistantMessageComponent");
|
||||
});
|
||||
|
||||
test("chat-controller keeps serverToolUse output ahead of assistant text when external results arrive", async () => {
|
||||
(globalThis as any)[Symbol.for("@gsd/pi-coding-agent:theme")] = {
|
||||
fg: (_key: string, text: string) => text,
|
||||
bg: (_key: string, text: string) => text,
|
||||
bold: (text: string) => text,
|
||||
italic: (text: string) => text,
|
||||
truncate: (text: string) => text,
|
||||
};
|
||||
|
||||
const host = createHost();
|
||||
const toolId = "mcp-secure-1";
|
||||
const serverToolUse = {
|
||||
type: "serverToolUse",
|
||||
id: toolId,
|
||||
name: "mcp__gsd-workflow__secure_env_collect",
|
||||
input: { projectDir: "/tmp/project", keys: [{ key: "SECURE_PASSWORD" }], destination: "dotenv" },
|
||||
};
|
||||
|
||||
await handleAgentEvent(host, { type: "message_start", message: makeAssistant([]) } as any);
|
||||
|
||||
await handleAgentEvent(
|
||||
host,
|
||||
{
|
||||
type: "message_update",
|
||||
message: makeAssistant([serverToolUse]),
|
||||
assistantMessageEvent: {
|
||||
type: "server_tool_use",
|
||||
contentIndex: 0,
|
||||
partial: makeAssistant([serverToolUse]),
|
||||
},
|
||||
} as any,
|
||||
);
|
||||
|
||||
assert.equal(host.streamingComponent, undefined, "assistant content should stay deferred while only tool content streams");
|
||||
assert.equal(host.chatContainer.children.length, 1, "server tool block should render immediately");
|
||||
assert.equal(host.chatContainer.children[0]?.constructor?.name, "ToolExecutionComponent");
|
||||
|
||||
host.getMarkdownThemeWithSettings = () => ({});
|
||||
const resultMessage = makeAssistant([
|
||||
{
|
||||
...serverToolUse,
|
||||
externalResult: {
|
||||
content: [{ type: "text", text: "secure_env_collect was cancelled by user." }],
|
||||
details: {},
|
||||
isError: true,
|
||||
},
|
||||
},
|
||||
{ type: "text", text: "The secure password collection was cancelled." },
|
||||
]);
|
||||
|
||||
await handleAgentEvent(
|
||||
host,
|
||||
{
|
||||
type: "message_update",
|
||||
message: resultMessage,
|
||||
assistantMessageEvent: {
|
||||
type: "server_tool_use",
|
||||
contentIndex: 0,
|
||||
partial: resultMessage,
|
||||
},
|
||||
} as any,
|
||||
);
|
||||
|
||||
assert.equal(host.chatContainer.children.length, 2, "assistant text should render after existing server tool output");
|
||||
assert.equal(host.chatContainer.children[0]?.constructor?.name, "ToolExecutionComponent");
|
||||
assert.equal(host.chatContainer.children[1]?.constructor?.name, "AssistantMessageComponent");
|
||||
});
|
||||
|
|
|
|||
|
|
@ -88,6 +88,8 @@ export interface ExtensionUIDialogOptions {
|
|||
timeout?: number;
|
||||
/** When true, the user can select multiple options. The return type becomes `string[]`. */
|
||||
allowMultiple?: boolean;
|
||||
/** When true, text input dialogs should hide typed characters if supported by the client surface. */
|
||||
secure?: boolean;
|
||||
}
|
||||
|
||||
/** Placement for extension widgets. */
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import { keyHint } from "./keybinding-hints.js";
|
|||
export interface ExtensionInputOptions {
|
||||
tui?: TUI;
|
||||
timeout?: number;
|
||||
secure?: boolean;
|
||||
}
|
||||
|
||||
export class ExtensionInputComponent extends Container implements Focusable {
|
||||
|
|
@ -61,6 +62,7 @@ export class ExtensionInputComponent extends Container implements Focusable {
|
|||
}
|
||||
|
||||
this.input = new Input();
|
||||
this.input.secure = opts?.secure === true;
|
||||
if (placeholder) {
|
||||
this.input.placeholder = placeholder;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -129,19 +129,6 @@ export async function handleAgentEvent(host: InteractiveModeStateHost & {
|
|||
host.streamingMessage = event.message;
|
||||
const innerEvent = event.assistantMessageEvent;
|
||||
|
||||
if (!host.streamingComponent && hasVisibleAssistantContent(host.streamingMessage)) {
|
||||
host.streamingComponent = new AssistantMessageComponent(
|
||||
undefined,
|
||||
host.hideThinkingBlock,
|
||||
host.getMarkdownThemeWithSettings(),
|
||||
host.settingsManager.getTimestampFormat(),
|
||||
);
|
||||
host.chatContainer.addChild(host.streamingComponent);
|
||||
}
|
||||
if (host.streamingComponent) {
|
||||
host.streamingComponent.updateContent(host.streamingMessage);
|
||||
}
|
||||
|
||||
let externalToolResult:
|
||||
| { toolCallId: string; content: Array<{ type: string; text?: string; data?: string; mimeType?: string }>; details: Record<string, unknown>; isError: boolean }
|
||||
| undefined;
|
||||
|
|
@ -156,6 +143,18 @@ export async function handleAgentEvent(host: InteractiveModeStateHost & {
|
|||
isError: ext.isError ?? false,
|
||||
};
|
||||
}
|
||||
} else if (innerEvent.type === "server_tool_use") {
|
||||
const idx = typeof innerEvent.contentIndex === "number" ? innerEvent.contentIndex : -1;
|
||||
const block = idx >= 0 ? (host.streamingMessage.content[idx] as any) : undefined;
|
||||
const ext = block?.externalResult;
|
||||
if (block?.id && ext) {
|
||||
externalToolResult = {
|
||||
toolCallId: block.id,
|
||||
content: ext.content ?? [{ type: "text", text: "" }],
|
||||
details: ext.details ?? {},
|
||||
isError: ext.isError ?? false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const contentBlocks = host.streamingMessage.content;
|
||||
|
|
@ -230,6 +229,26 @@ export async function handleAgentEvent(host: InteractiveModeStateHost & {
|
|||
}
|
||||
}
|
||||
|
||||
// Render assistant text/thinking after tool components so mixed
|
||||
// streams keep chronological ordering in the chat container.
|
||||
const hasToolBlocks = hasAssistantToolBlocks(host.streamingMessage);
|
||||
if (!host.streamingComponent && hasVisibleAssistantContent(host.streamingMessage)) {
|
||||
host.streamingComponent = new AssistantMessageComponent(
|
||||
undefined,
|
||||
host.hideThinkingBlock,
|
||||
host.getMarkdownThemeWithSettings(),
|
||||
host.settingsManager.getTimestampFormat(),
|
||||
);
|
||||
host.chatContainer.addChild(host.streamingComponent);
|
||||
}
|
||||
if (host.streamingComponent) {
|
||||
if (hasToolBlocks) {
|
||||
host.chatContainer.removeChild(host.streamingComponent);
|
||||
host.chatContainer.addChild(host.streamingComponent);
|
||||
}
|
||||
host.streamingComponent.updateContent(host.streamingMessage);
|
||||
}
|
||||
|
||||
// Update index: fully processed blocks won't need re-scanning.
|
||||
// Keep the last block's index (it may still be accumulating data),
|
||||
// so we re-check it next time but skip all earlier ones.
|
||||
|
|
|
|||
|
|
@ -1631,7 +1631,7 @@ export class InteractiveMode {
|
|||
this.hideExtensionInput();
|
||||
resolve(undefined);
|
||||
},
|
||||
{ tui: this.ui, timeout: opts?.timeout },
|
||||
{ tui: this.ui, timeout: opts?.timeout, secure: opts?.secure },
|
||||
);
|
||||
|
||||
this.editorContainer.clear();
|
||||
|
|
|
|||
|
|
@ -224,7 +224,7 @@ export async function runRpcMode(session: AgentSession): Promise<never> {
|
|||
),
|
||||
|
||||
input: (title, placeholder, opts) =>
|
||||
createDialogPromise(opts, undefined, { method: "input", title, placeholder, timeout: opts?.timeout }, (r) =>
|
||||
createDialogPromise(opts, undefined, { method: "input", title, placeholder, timeout: opts?.timeout, secure: opts?.secure }, (r) =>
|
||||
"cancelled" in r && r.cancelled ? undefined : "value" in r ? r.value : undefined,
|
||||
),
|
||||
|
||||
|
|
|
|||
|
|
@ -291,6 +291,7 @@ export type RpcExtensionUIRequest =
|
|||
title: string;
|
||||
placeholder?: string;
|
||||
timeout?: number;
|
||||
secure?: boolean;
|
||||
}
|
||||
| { type: "extension_ui_request"; id: string; method: "editor"; title: string; prefill?: string }
|
||||
| {
|
||||
|
|
|
|||
|
|
@ -32,4 +32,15 @@ describe("Input", () => {
|
|||
input.focused = false;
|
||||
assert.equal(input.focused, false);
|
||||
});
|
||||
|
||||
it("secure mode obscures typed characters in render output", () => {
|
||||
const input = new Input();
|
||||
input.secure = true;
|
||||
input.focused = true;
|
||||
input.handleInput("secret123");
|
||||
|
||||
const line = input.render(40)[0] ?? "";
|
||||
assert.ok(!line.includes("secret123"), "rendered line must not expose raw secret text");
|
||||
assert.ok(line.includes("*********"), "rendered line should include masked characters");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -21,6 +21,8 @@ export class Input implements Component, Focusable {
|
|||
public onSubmit?: (value: string) => void;
|
||||
public onEscape?: () => void;
|
||||
public placeholder: string = "";
|
||||
/** When true, render obscured characters instead of the actual value. */
|
||||
public secure: boolean = false;
|
||||
|
||||
/** Focusable interface - set by TUI when focus changes */
|
||||
private _focused: boolean = false;
|
||||
|
|
@ -446,6 +448,7 @@ export class Input implements Component, Focusable {
|
|||
// Calculate visible window
|
||||
const prompt = "> ";
|
||||
const availableWidth = width - prompt.length;
|
||||
const renderValue = this.secure ? "*".repeat(this.value.length) : this.value;
|
||||
|
||||
if (availableWidth <= 0) {
|
||||
return [prompt];
|
||||
|
|
@ -466,7 +469,7 @@ export class Input implements Component, Focusable {
|
|||
|
||||
if (this.value.length < availableWidth) {
|
||||
// Everything fits (leave room for cursor at end)
|
||||
visibleText = this.value;
|
||||
visibleText = renderValue;
|
||||
} else {
|
||||
// Need horizontal scrolling
|
||||
// Reserve one character for cursor if it's at the end
|
||||
|
|
@ -501,17 +504,17 @@ export class Input implements Component, Focusable {
|
|||
|
||||
if (this.cursor < halfWidth) {
|
||||
// Cursor near start
|
||||
visibleText = this.value.slice(0, findValidEnd(scrollWidth));
|
||||
visibleText = renderValue.slice(0, findValidEnd(scrollWidth));
|
||||
cursorDisplay = this.cursor;
|
||||
} else if (this.cursor > this.value.length - halfWidth) {
|
||||
// Cursor near end
|
||||
const start = findValidStart(this.value.length - scrollWidth);
|
||||
visibleText = this.value.slice(start);
|
||||
visibleText = renderValue.slice(start);
|
||||
cursorDisplay = this.cursor - start;
|
||||
} else {
|
||||
// Cursor in middle
|
||||
const start = findValidStart(this.cursor - halfWidth);
|
||||
visibleText = this.value.slice(start, findValidEnd(start + scrollWidth));
|
||||
visibleText = renderValue.slice(start, findValidEnd(start + scrollWidth));
|
||||
cursorDisplay = halfWidth;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -60,6 +60,8 @@ interface SdkElicitationFieldSchema {
|
|||
type?: string;
|
||||
title?: string;
|
||||
description?: string;
|
||||
format?: string;
|
||||
writeOnly?: boolean;
|
||||
oneOf?: SdkElicitationRequestOption[];
|
||||
items?: {
|
||||
anyOf?: SdkElicitationRequestOption[];
|
||||
|
|
@ -73,6 +75,7 @@ interface SdkElicitationRequest {
|
|||
requestedSchema?: {
|
||||
type?: string;
|
||||
properties?: Record<string, SdkElicitationFieldSchema>;
|
||||
required?: string[];
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -85,7 +88,16 @@ interface ParsedElicitationQuestion extends Question {
|
|||
noteFieldId?: string;
|
||||
}
|
||||
|
||||
interface ParsedTextInputField {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
required: boolean;
|
||||
secure: boolean;
|
||||
}
|
||||
|
||||
const OTHER_OPTION_LABEL = "None of the above";
|
||||
const SENSITIVE_FIELD_PATTERN = /(password|passphrase|secret|token|api[_\s-]*key|private[_\s-]*key|credential)/i;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Stream factory
|
||||
|
|
@ -274,6 +286,67 @@ export function parseAskUserQuestionsElicitation(
|
|||
return questions.length > 0 ? questions : null;
|
||||
}
|
||||
|
||||
function isSecureElicitationField(
|
||||
requestMessage: string,
|
||||
fieldId: string,
|
||||
field: SdkElicitationFieldSchema,
|
||||
): boolean {
|
||||
if (field.format === "password") return true;
|
||||
if (field.writeOnly === true) return true;
|
||||
|
||||
const rawField = field as Record<string, unknown>;
|
||||
if (rawField.sensitive === true || rawField["x-sensitive"] === true) return true;
|
||||
|
||||
const haystack = [
|
||||
requestMessage,
|
||||
fieldId.replace(/[_-]+/g, " "),
|
||||
typeof field.title === "string" ? field.title : "",
|
||||
typeof field.description === "string" ? field.description : "",
|
||||
]
|
||||
.join(" ")
|
||||
.toLowerCase();
|
||||
|
||||
return SENSITIVE_FIELD_PATTERN.test(haystack);
|
||||
}
|
||||
|
||||
export function parseTextInputElicitation(
|
||||
request: Pick<SdkElicitationRequest, "message" | "mode" | "requestedSchema">,
|
||||
): ParsedTextInputField[] | null {
|
||||
if (request.mode && request.mode !== "form") return null;
|
||||
const schema = request.requestedSchema as
|
||||
| ({ properties?: Record<string, SdkElicitationFieldSchema>; keys?: Record<string, SdkElicitationFieldSchema> } & Record<string, unknown>)
|
||||
| undefined;
|
||||
const fieldsSource = schema?.properties && typeof schema.properties === "object"
|
||||
? schema.properties
|
||||
: schema?.keys && typeof schema.keys === "object"
|
||||
? schema.keys
|
||||
: undefined;
|
||||
if (!fieldsSource) return null;
|
||||
|
||||
const requiredSet = new Set(
|
||||
Array.isArray(request.requestedSchema?.required)
|
||||
? request.requestedSchema.required.filter((value): value is string => typeof value === "string")
|
||||
: [],
|
||||
);
|
||||
|
||||
const fields: ParsedTextInputField[] = [];
|
||||
for (const [fieldId, field] of Object.entries(fieldsSource)) {
|
||||
if (!field || typeof field !== "object") continue;
|
||||
if (field.type !== "string") continue;
|
||||
if (Array.isArray(field.oneOf) && field.oneOf.length > 0) continue;
|
||||
|
||||
fields.push({
|
||||
id: fieldId,
|
||||
title: typeof field.title === "string" && field.title.length > 0 ? field.title : fieldId,
|
||||
description: typeof field.description === "string" ? field.description : "",
|
||||
required: requiredSet.has(fieldId),
|
||||
secure: isSecureElicitationField(request.message, fieldId, field),
|
||||
});
|
||||
}
|
||||
|
||||
return fields.length > 0 ? fields : null;
|
||||
}
|
||||
|
||||
export function roundResultToElicitationContent(
|
||||
questions: ParsedElicitationQuestion[],
|
||||
result: RoundResult,
|
||||
|
|
@ -355,6 +428,52 @@ async function promptElicitationWithDialogs(
|
|||
return { action: "accept", content };
|
||||
}
|
||||
|
||||
function buildTextInputPromptTitle(request: SdkElicitationRequest, field: ParsedTextInputField): string {
|
||||
const parts = [
|
||||
request.serverName ? `[${request.serverName}]` : "",
|
||||
field.title,
|
||||
field.description,
|
||||
].filter((part) => typeof part === "string" && part.trim().length > 0);
|
||||
return parts.join("\n\n");
|
||||
}
|
||||
|
||||
function buildTextInputPlaceholder(field: ParsedTextInputField): string | undefined {
|
||||
const desc = field.description.trim();
|
||||
if (!desc) return field.required ? "Required" : "Leave empty to skip";
|
||||
|
||||
const formatLine = desc
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.find((line) => /^format:/i.test(line));
|
||||
|
||||
if (!formatLine) return field.required ? "Required" : "Leave empty to skip";
|
||||
const hint = formatLine.replace(/^format:\s*/i, "").trim();
|
||||
return hint.length > 0 ? hint : field.required ? "Required" : "Leave empty to skip";
|
||||
}
|
||||
|
||||
async function promptTextInputElicitation(
|
||||
request: SdkElicitationRequest,
|
||||
fields: ParsedTextInputField[],
|
||||
ui: ExtensionUIContext,
|
||||
signal: AbortSignal,
|
||||
): Promise<SdkElicitationResult> {
|
||||
const content: Record<string, string | string[]> = {};
|
||||
|
||||
for (const field of fields) {
|
||||
const value = await ui.input(
|
||||
buildTextInputPromptTitle(request, field),
|
||||
buildTextInputPlaceholder(field),
|
||||
{ signal, ...(field.secure ? { secure: true } : {}) },
|
||||
);
|
||||
if (value === undefined) {
|
||||
return { action: "cancel" };
|
||||
}
|
||||
content[field.id] = value;
|
||||
}
|
||||
|
||||
return { action: "accept", content };
|
||||
}
|
||||
|
||||
export function createClaudeCodeElicitationHandler(
|
||||
ui: ExtensionUIContext | undefined,
|
||||
): ((request: SdkElicitationRequest, options: { signal: AbortSignal }) => Promise<SdkElicitationResult>) | undefined {
|
||||
|
|
@ -366,19 +485,24 @@ export function createClaudeCodeElicitationHandler(
|
|||
}
|
||||
|
||||
const questions = parseAskUserQuestionsElicitation(request);
|
||||
if (!questions) {
|
||||
return { action: "decline" };
|
||||
if (questions) {
|
||||
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);
|
||||
}
|
||||
|
||||
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),
|
||||
};
|
||||
const textFields = parseTextInputElicitation(request);
|
||||
if (textFields) {
|
||||
return promptTextInputElicitation(request, textFields, ui, signal);
|
||||
}
|
||||
|
||||
return promptElicitationWithDialogs(request, questions, ui, signal);
|
||||
return { action: "decline" };
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -508,15 +632,15 @@ export function extractToolResultsFromSdkUserMessage(message: SDKUserMessage): A
|
|||
return extracted;
|
||||
}
|
||||
|
||||
function attachExternalResultsToToolCalls(
|
||||
toolCalls: AssistantMessage["content"],
|
||||
function attachExternalResultsToToolBlocks(
|
||||
toolBlocks: AssistantMessage["content"],
|
||||
toolResultsById: ReadonlyMap<string, ExternalToolResultPayload>,
|
||||
): void {
|
||||
for (const block of toolCalls) {
|
||||
if (block.type !== "toolCall") continue;
|
||||
for (const block of toolBlocks) {
|
||||
if (block.type !== "toolCall" && block.type !== "serverToolUse") continue;
|
||||
const externalResult = toolResultsById.get(block.id);
|
||||
if (!externalResult) continue;
|
||||
(block as ToolCallWithExternalResult).externalResult = externalResult;
|
||||
(block as ToolCallWithExternalResult & { id: string }).externalResult = externalResult;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -554,8 +678,8 @@ async function pumpSdkMessages(
|
|||
/** Track the last text content seen across all assistant turns for the final message. */
|
||||
let lastTextContent = "";
|
||||
let lastThinkingContent = "";
|
||||
/** Collect tool calls from intermediate SDK turns for tool_execution events. */
|
||||
const intermediateToolCalls: AssistantMessage["content"] = [];
|
||||
/** Collect tool blocks from intermediate SDK turns for tool execution rendering. */
|
||||
const intermediateToolBlocks: AssistantMessage["content"] = [];
|
||||
/** Preserve real external tool results from Claude Code's synthetic user messages. */
|
||||
const toolResultsById = new Map<string, ExternalToolResultPayload>();
|
||||
|
||||
|
|
@ -666,9 +790,9 @@ async function pumpSdkMessages(
|
|||
lastTextContent = block.text;
|
||||
} else if (block.type === "thinking" && block.thinking) {
|
||||
lastThinkingContent = block.thinking;
|
||||
} else if (block.type === "toolCall") {
|
||||
// Collect tool calls for externalToolExecution rendering
|
||||
intermediateToolCalls.push(block);
|
||||
} else if (block.type === "toolCall" || block.type === "serverToolUse") {
|
||||
// Collect tool blocks for externalToolExecution rendering
|
||||
intermediateToolBlocks.push(block);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -678,24 +802,33 @@ async function pumpSdkMessages(
|
|||
for (const { toolUseId, result } of extractToolResultsFromSdkUserMessage(msg as SDKUserMessage)) {
|
||||
toolResultsById.set(toolUseId, result);
|
||||
}
|
||||
attachExternalResultsToToolCalls(intermediateToolCalls, toolResultsById);
|
||||
attachExternalResultsToToolBlocks(intermediateToolBlocks, toolResultsById);
|
||||
|
||||
// Push a synthetic toolcall_end for each tool call from this turn
|
||||
// so the TUI can render tool results in real-time during the SDK
|
||||
// session instead of waiting until the entire session completes.
|
||||
if (builder) {
|
||||
for (const block of builder.message.content) {
|
||||
if (block.type !== "toolCall") continue;
|
||||
const extResult = (block as ToolCallWithExternalResult).externalResult;
|
||||
if (!extResult) continue;
|
||||
// Push a toolcall_end with result attached so the chat-controller
|
||||
// can call updateResult on the pending ToolExecutionComponent.
|
||||
stream.push({
|
||||
type: "toolcall_end",
|
||||
contentIndex: builder.message.content.indexOf(block),
|
||||
toolCall: block,
|
||||
partial: builder.message,
|
||||
});
|
||||
const contentIndex = builder.message.content.indexOf(block);
|
||||
if (contentIndex < 0) continue;
|
||||
// Push synthetic completion events with result attached so the
|
||||
// chat-controller can update pending ToolExecutionComponents.
|
||||
if (block.type === "toolCall") {
|
||||
stream.push({
|
||||
type: "toolcall_end",
|
||||
contentIndex,
|
||||
toolCall: block,
|
||||
partial: builder.message,
|
||||
});
|
||||
} else if (block.type === "serverToolUse") {
|
||||
stream.push({
|
||||
type: "server_tool_use",
|
||||
contentIndex,
|
||||
partial: builder.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -713,8 +846,8 @@ async function pumpSdkMessages(
|
|||
const finalContent: AssistantMessage["content"] = [];
|
||||
|
||||
// Add tool calls from intermediate turns first (renders above text)
|
||||
attachExternalResultsToToolCalls(intermediateToolCalls, toolResultsById);
|
||||
finalContent.push(...intermediateToolCalls);
|
||||
attachExternalResultsToToolBlocks(intermediateToolBlocks, toolResultsById);
|
||||
finalContent.push(...intermediateToolBlocks);
|
||||
|
||||
// Add text/thinking from the last turn
|
||||
if (builder && builder.message.content.length > 0) {
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import {
|
|||
extractToolResultsFromSdkUserMessage,
|
||||
getClaudeLookupCommand,
|
||||
parseAskUserQuestionsElicitation,
|
||||
parseTextInputElicitation,
|
||||
parseClaudeLookupOutput,
|
||||
roundResultToElicitationContent,
|
||||
} from "../stream-adapter.ts";
|
||||
|
|
@ -514,6 +515,117 @@ describe("stream-adapter — MCP elicitation bridge", () => {
|
|||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("parseTextInputElicitation recognizes secure free-text MCP forms", () => {
|
||||
const request = {
|
||||
serverName: "gsd-workflow",
|
||||
message: "Enter values for environment variables.",
|
||||
mode: "form" as const,
|
||||
requestedSchema: {
|
||||
type: "object" as const,
|
||||
properties: {
|
||||
TEST_PASSWORD: {
|
||||
type: "string",
|
||||
title: "TEST_PASSWORD",
|
||||
description: "Format: min 8 characters\nLeave empty to skip.",
|
||||
},
|
||||
PROJECT_NAME: {
|
||||
type: "string",
|
||||
title: "PROJECT_NAME",
|
||||
description: "Human-readable project name.",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const parsed = parseTextInputElicitation(request as any);
|
||||
assert.deepEqual(parsed, [
|
||||
{
|
||||
id: "TEST_PASSWORD",
|
||||
title: "TEST_PASSWORD",
|
||||
description: "Format: min 8 characters\nLeave empty to skip.",
|
||||
required: false,
|
||||
secure: true,
|
||||
},
|
||||
{
|
||||
id: "PROJECT_NAME",
|
||||
title: "PROJECT_NAME",
|
||||
description: "Human-readable project name.",
|
||||
required: false,
|
||||
secure: false,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("parseTextInputElicitation accepts legacy keys schema and skips unsupported fields", () => {
|
||||
const request = {
|
||||
serverName: "gsd-workflow",
|
||||
message: "Enter secure values",
|
||||
mode: "form" as const,
|
||||
requestedSchema: {
|
||||
type: "object" as const,
|
||||
keys: {
|
||||
API_TOKEN: {
|
||||
type: "string",
|
||||
title: "API_TOKEN",
|
||||
description: "Leave empty to skip.",
|
||||
},
|
||||
META: {
|
||||
type: "object",
|
||||
title: "metadata",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const parsed = parseTextInputElicitation(request as any);
|
||||
assert.deepEqual(parsed, [
|
||||
{
|
||||
id: "API_TOKEN",
|
||||
title: "API_TOKEN",
|
||||
description: "Leave empty to skip.",
|
||||
required: false,
|
||||
secure: true,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("createClaudeCodeElicitationHandler collects secure_env_collect fields through input dialogs", async () => {
|
||||
const secureRequest = {
|
||||
serverName: "gsd-workflow",
|
||||
message: "Enter values for environment variables.",
|
||||
mode: "form" as const,
|
||||
requestedSchema: {
|
||||
type: "object" as const,
|
||||
properties: {
|
||||
TEST_PASSWORD: {
|
||||
type: "string",
|
||||
title: "TEST_PASSWORD",
|
||||
description: "Format: Your secure testing password\nLeave empty to skip.",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const inputCalls: Array<{ opts?: { secure?: boolean } }> = [];
|
||||
const handler = createClaudeCodeElicitationHandler({
|
||||
input: async (_title: string, _placeholder?: string, opts?: { secure?: boolean }) => {
|
||||
inputCalls.push({ opts });
|
||||
return "super-secret";
|
||||
},
|
||||
} as any);
|
||||
assert.ok(handler);
|
||||
|
||||
const result = await handler!(secureRequest as any, { signal: new AbortController().signal });
|
||||
assert.deepEqual(result, {
|
||||
action: "accept",
|
||||
content: {
|
||||
TEST_PASSWORD: "super-secret",
|
||||
},
|
||||
});
|
||||
assert.equal(inputCalls.length, 1);
|
||||
assert.equal(inputCalls[0]?.opts?.secure, true, "secure_env_collect fields should request secure input");
|
||||
});
|
||||
});
|
||||
|
||||
describe("stream-adapter — Windows Claude path lookup (#3770)", () => {
|
||||
|
|
|
|||
|
|
@ -126,7 +126,7 @@ async function collectOneSecret(
|
|||
): Promise<string | null> {
|
||||
if (!ctx.hasUI) return null;
|
||||
|
||||
return ctx.ui.custom((tui: any, theme: any, _kb: any, done: (r: string | null) => void) => {
|
||||
const customResult = await ctx.ui.custom((tui: any, theme: any, _kb: any, done: (r: string | null) => void) => {
|
||||
let value = "";
|
||||
let cachedLines: string[] | undefined;
|
||||
|
||||
|
|
@ -223,6 +223,29 @@ async function collectOneSecret(
|
|||
handleInput,
|
||||
};
|
||||
});
|
||||
|
||||
// RPC/web surfaces may not implement ctx.ui.custom(). Fall back to a
|
||||
// standard input prompt so users can still provide the secret.
|
||||
if (customResult !== undefined) {
|
||||
return customResult;
|
||||
}
|
||||
|
||||
if (typeof ctx.ui?.input !== "function") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const inputTitle = `Secure value for ${keyName} (${pageIndex + 1}/${totalPages})`;
|
||||
const inputPlaceholder = hint || "Enter secret value";
|
||||
const inputResult = await ctx.ui.input(
|
||||
inputTitle,
|
||||
inputPlaceholder,
|
||||
{ secure: true },
|
||||
);
|
||||
if (typeof inputResult !== "string") {
|
||||
return null;
|
||||
}
|
||||
const trimmed = inputResult.trim();
|
||||
return trimmed.length > 0 ? trimmed : null;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -317,3 +317,48 @@ test("secure_env_collect #2997: null from ctx.ui.custom() is still treated as sk
|
|||
"Key returning null must NOT be in applied list",
|
||||
);
|
||||
});
|
||||
|
||||
test("secure_env_collect: falls back to secure input prompt when custom UI is unavailable", async (t) => {
|
||||
const { collectSecretsFromManifest } = await loadOrchestrator();
|
||||
|
||||
const tmp = makeTempDir("sec-input-fallback-test");
|
||||
t.after(() => {
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const manifest = makeManifest([
|
||||
{ key: "SECRET_FROM_INPUT_FALLBACK", status: "pending", formatHint: "starts with sk-" },
|
||||
]);
|
||||
await writeManifestFile(tmp, manifest);
|
||||
|
||||
let callIndex = 0;
|
||||
const inputCalls: Array<{ title: string; placeholder?: string; opts?: { secure?: boolean } }> = [];
|
||||
const mockCtx = {
|
||||
cwd: tmp,
|
||||
hasUI: true,
|
||||
ui: {
|
||||
custom: async (_factory: any) => {
|
||||
callIndex++;
|
||||
if (callIndex <= 1) return null; // summary screen dismiss
|
||||
return undefined; // collect screen unavailable on this surface
|
||||
},
|
||||
input: async (title: string, placeholder?: string, opts?: { secure?: boolean }) => {
|
||||
inputCalls.push({ title, placeholder, opts });
|
||||
return " sk-test-fallback-value ";
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = await collectSecretsFromManifest(tmp, "M001", mockCtx as any);
|
||||
|
||||
assert.ok(
|
||||
result.applied.includes("SECRET_FROM_INPUT_FALLBACK"),
|
||||
"Fallback input should collect and apply the key",
|
||||
);
|
||||
assert.ok(
|
||||
!result.skipped.includes("SECRET_FROM_INPUT_FALLBACK"),
|
||||
"Fallback input should not mark the key as skipped",
|
||||
);
|
||||
assert.equal(inputCalls.length, 1, "Fallback input should be requested once");
|
||||
assert.equal(inputCalls[0]?.opts?.secure, true, "Fallback input should request secure entry when supported");
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue