diff --git a/apps/server/test/services/threads/conversation-outline-parity.test.ts b/apps/server/test/services/threads/conversation-outline-parity.test.ts new file mode 100644 index 00000000000..328ca718527 --- /dev/null +++ b/apps/server/test/services/threads/conversation-outline-parity.test.ts @@ -0,0 +1,194 @@ +import { describe, expect, it } from "vitest"; +import { threadScope, turnScope } from "@bb/domain"; +import { + createConnection, + createProject, + createThread, + insertEvents, + migrate, + noopNotifier, + upsertHost, +} from "@bb/db"; +import { + buildThreadConversationOutline, + buildThreadTimeline, +} from "../../../src/services/threads/timeline.js"; + +type EventInput = Parameters[2][number]; + +function setup() { + const db = createConnection(":memory:"); + migrate(db); + const host = upsertHost(db, noopNotifier, { + name: "test-host", + type: "persistent", + }); + const { project } = createProject(db, noopNotifier, { + name: "test-project", + source: { type: "local_path", hostId: host.id, path: "/tmp/test" }, + }); + const thread = createThread(db, noopNotifier, { + projectId: project.id, + providerId: "claude-code", + }); + return { db, thread }; +} + +function agentMessage( + threadId: string, + sequence: number, + text: string, +): EventInput { + return { + threadId, + sequence, + type: "item/completed", + scope: turnScope("turn-1"), + providerThreadId: "provider-thread-1", + itemId: `message-${sequence}`, + itemKind: "agentMessage", + data: JSON.stringify({ + item: { id: `message-${sequence}`, type: "agentMessage", text }, + }), + }; +} + +function userQuestionLifecycle( + threadId: string, + sequence: number, + status: "pending" | "resolved", +): EventInput { + return { + threadId, + sequence, + type: "system/userQuestion/lifecycle", + scope: turnScope("turn-1"), + itemId: null, + itemKind: null, + data: JSON.stringify({ + interactionId: "pi-user-question", + providerId: "claude-code", + providerRequestId: "request-user-question", + status, + resolution: + status === "resolved" + ? { + kind: "user_answer", + answers: { "question-1": { selected: ["staging"] } }, + } + : null, + statusReason: null, + payload: { + kind: "user_question", + questions: [ + { + id: "question-1", + prompt: "Which deployment target should I use?", + shortLabel: "Target", + multiSelect: false, + options: [ + { value: "staging", label: "Staging" }, + { value: "production", label: "Production" }, + ], + allowFreeText: false, + }, + ], + }, + }), + }; +} + +describe("thread conversation outline parity", () => { + it("exposes the same conversation rows as the timeline after an answered question", () => { + const { db, thread } = setup(); + const requestId = "creq_23456789ab"; + insertEvents(db, noopNotifier, [ + { + threadId: thread.id, + sequence: 1, + type: "client/turn/requested", + scope: threadScope(), + itemId: null, + itemKind: null, + data: JSON.stringify({ + direction: "outbound", + requestId, + source: "tell", + initiator: "user", + input: [{ type: "text", text: "Audit the router", mentions: [] }], + target: { kind: "new-turn" }, + request: { method: "turn/start", params: {} }, + execution: { + model: "claude-opus-4", + reasoningLevel: "medium", + permissionMode: "workspace-write", + source: "client/turn/requested", + serviceTier: "default", + }, + }), + }, + { + threadId: thread.id, + sequence: 2, + type: "turn/started", + scope: turnScope("turn-1"), + providerThreadId: "provider-thread-1", + itemId: null, + itemKind: null, + data: JSON.stringify({}), + }, + { + threadId: thread.id, + sequence: 3, + type: "turn/input/accepted", + scope: turnScope("turn-1"), + providerThreadId: "provider-thread-1", + itemId: null, + itemKind: null, + data: JSON.stringify({ clientRequestId: requestId }), + }, + userQuestionLifecycle(thread.id, 4, "pending"), + userQuestionLifecycle(thread.id, 5, "resolved"), + agentMessage(thread.id, 6, "Staging it is."), + agentMessage(thread.id, 7, "Login works."), + agentMessage(thread.id, 8, "Audit complete."), + agentMessage(thread.id, 9, "Final runbook."), + { + threadId: thread.id, + sequence: 10, + type: "turn/completed", + scope: turnScope("turn-1"), + providerThreadId: "provider-thread-1", + itemId: null, + itemKind: null, + data: JSON.stringify({ status: "completed" }), + }, + ]); + + const timeline = buildThreadTimeline(db, thread, { + eventBudget: 1_000_000, + includeProviderUnhandledOperations: false, + includeNestedRows: false, + maxInlineOutputChars: null, + maxSeq: 10, + page: { kind: "latest", segmentLimit: 20 }, + }); + const timelineConversation = timeline.rows.flatMap((row) => + row.kind === "conversation" ? [{ id: row.id, text: row.text }] : [], + ); + // The answered question splits the turn: the direct reply and the + // segment-final message are visible, the interim one stays folded. + expect(timelineConversation.map((row) => row.text)).toEqual([ + "Audit the router", + "Staging it is.", + "Audit complete.", + "Final runbook.", + ]); + + const outline = buildThreadConversationOutline(db, thread, { maxSeq: 10 }); + expect( + outline.items.map((item) => ({ id: item.id, text: item.preview })), + ).toEqual(timelineConversation); + db.$client.close(); + }); +}); diff --git a/packages/db/src/data/events.ts b/packages/db/src/data/events.ts index 91ca37ec3ee..f58ab84e03a 100644 --- a/packages/db/src/data/events.ts +++ b/packages/db/src/data/events.ts @@ -2529,6 +2529,10 @@ export function listStoredConversationOutlineEventRows( "system/thread/interrupted", "system/error", "provider/error", + // An answered question splits a completed turn's folded summary exactly + // like accepted user input does; the outline must see it to keep the + // assistant rows it exposes in step with the main timeline. + "system/userQuestion/lifecycle", "item/agentMessage/delta", "item/plan/delta", ] satisfies ThreadEventType[]; diff --git a/packages/db/test/query-plans.test.ts b/packages/db/test/query-plans.test.ts index 68ea017465b..2bc0cb9f964 100644 --- a/packages/db/test/query-plans.test.ts +++ b/packages/db/test/query-plans.test.ts @@ -402,6 +402,7 @@ describe("slow query index plans", () => { "system/thread/interrupted", "system/error", "provider/error", + "system/userQuestion/lifecycle", "item/agentMessage/delta", "item/plan/delta", thread.id, diff --git a/packages/thread-view/src/completed-turn-grouping.ts b/packages/thread-view/src/completed-turn-grouping.ts index df6a8e6a2d8..a6d585f2b85 100644 --- a/packages/thread-view/src/completed-turn-grouping.ts +++ b/packages/thread-view/src/completed-turn-grouping.ts @@ -7,6 +7,8 @@ import { getMessageStartedAt } from "./format-helpers.js"; import { findLastTerminalTimelineMessage, isSingletonContextManagementOperation, + isTimelineTerminalMessage, + isMidTurnUserInputBoundaryMessage, isTimelineUngroupableMessage, } from "./timeline-message-helpers.js"; @@ -167,6 +169,9 @@ function groupCompletedTurnSummaryMessages( let groupedMessages: EventProjectionMessage[] = []; let segmentIndex = 0; let externalBoundaryIndex = 0; + let preserveNextTerminalMessage = false; + let sawMidTurnUserInput = false; + const phase = { sawTurnOutput: false }; function appendSummaryGroup(sourceMessages: EventProjectionMessage[]): void { if (sourceMessages.length === 0) { @@ -190,9 +195,14 @@ function groupCompletedTurnSummaryMessages( return; } - // Human follow-ups split one provider turn into multiple visible exchange - // segments. Keep each segment's last assistant/error message beside the - // user row instead of burying it inside that segment's collapsed summary. + // Mid-turn human follow-ups split one provider turn into multiple visible + // exchange segments. Keep each segment's last assistant/error message + // beside the user row instead of burying it inside that segment's + // collapsed summary. The first assistant/error message after mid-turn user + // input is kept too — it is the direct reply the user already read while + // the turn was streaming. Initial user rows (before any output) must not + // trigger any of this, or every ordinary turn would surface its interim + // messages. const sourceMessages = groupedMessages; groupedMessages = []; const terminalMessage = preserveLastTerminalMessage @@ -222,19 +232,40 @@ function groupCompletedTurnSummaryMessages( ) { flushGroupedMessages(true); externalBoundaryIndex += 1; + preserveNextTerminalMessage = true; + sawMidTurnUserInput = true; } } for (const message of summaryMessages) { flushExternalBoundariesBefore(message); if (isTimelineUngroupableMessage(message)) { - flushGroupedMessages( - message.kind === "user" && message.initiator === "user", + const isMidTurnUserInput = isMidTurnUserInputBoundaryMessage( + message, + phase, ); + flushGroupedMessages(isMidTurnUserInput); + items.push({ + kind: "ungrouped-message", + message, + }); + if (isMidTurnUserInput) { + preserveNextTerminalMessage = true; + sawMidTurnUserInput = true; + // An answered question is provider output too; a user request that + // follows it is a mid-turn follow-up even with no assistant text yet. + phase.sawTurnOutput = true; + } + continue; + } + phase.sawTurnOutput = true; + if (preserveNextTerminalMessage && isTimelineTerminalMessage(message)) { + flushGroupedMessages(); items.push({ kind: "ungrouped-message", message, }); + preserveNextTerminalMessage = false; continue; } groupedMessages.push(message); @@ -244,7 +275,7 @@ function groupCompletedTurnSummaryMessages( flushGroupedMessages(true); externalBoundaryIndex += 1; } - flushGroupedMessages(); + flushGroupedMessages(sawMidTurnUserInput); return applySingleSummaryTurnBounds(turn, items); } diff --git a/packages/thread-view/src/timeline-message-helpers.ts b/packages/thread-view/src/timeline-message-helpers.ts index 8e3d2c989a7..22e1384dc9b 100644 --- a/packages/thread-view/src/timeline-message-helpers.ts +++ b/packages/thread-view/src/timeline-message-helpers.ts @@ -25,9 +25,75 @@ export function isTimelineUngroupableMessage( if (message.kind === "assistant-text") { return message.isLegacyUserMessage === true; } + if (message.kind === "user-question-lifecycle") { + return message.lifecycle === "answered"; + } return message.kind === "debug/raw-event"; } +/** + * Human input that the provider actually received during a turn. Only this + * input can split a completed turn into visible exchange segments. + * + * - `accepted-user-request`: a human-sent row the provider accepted. Pending + * or rejected rows never reached the provider, so the assistant output that + * follows them answers nothing the user said. + * - `answered-question`: a provider-initiated question the human answered. + * - `none`: everything else, including agent/system steers. + */ +export type TimelineUserInputSource = + | "accepted-user-request" + | "answered-question" + | "none"; + +export function getTimelineUserInputSource( + message: EventProjectionMessage, +): TimelineUserInputSource { + if (message.kind === "user") { + return message.initiator === "user" && + message.turnRequest.status === "accepted" + ? "accepted-user-request" + : "none"; + } + if ( + message.kind === "user-question-lifecycle" && + message.lifecycle === "answered" + ) { + return "answered-question"; + } + return "none"; +} + +export interface TimelineTurnPhase { + /** + * True once the turn has produced assistant/tool output. User input that + * precedes any output (the turn-starting row, or several initial rows from + * a grouped `inputGroups` request) is initial input, not a mid-turn + * follow-up. + */ + sawTurnOutput: boolean; +} + +/** + * Whether `message` is mid-turn human input that starts a new exchange + * segment inside a completed turn. An accepted user request is a boundary + * only after the turn has produced output. An answered question is always a + * boundary: the provider asked it, so it is itself turn output. + */ +export function isMidTurnUserInputBoundaryMessage( + message: EventProjectionMessage, + phase: TimelineTurnPhase, +): boolean { + switch (getTimelineUserInputSource(message)) { + case "accepted-user-request": + return phase.sawTurnOutput; + case "answered-question": + return true; + case "none": + return false; + } +} + export function isTimelineSummaryCountedMessage( message: EventProjectionMessage, ): boolean { diff --git a/packages/thread-view/test/build-thread-timeline.test.ts b/packages/thread-view/test/build-thread-timeline.test.ts index b14059ad8d3..671c9917d4a 100644 --- a/packages/thread-view/test/build-thread-timeline.test.ts +++ b/packages/thread-view/test/build-thread-timeline.test.ts @@ -623,47 +623,20 @@ function permissionGrantLifecycleEvent({ } function userQuestionLifecycleEvent({ - interactionId = "pi-user-question", - questionPrompt, - resolution = null, seq, - status = "pending", - statusReason = null, + ...args }: UserQuestionLifecycleEventArgs): ThreadEventWithMeta { - return { - event: { - type: "system/userQuestion/lifecycle", - threadId: "thread-1", - scope: turnScope("turn-1"), - interactionId, - providerId: "claude-code", - providerRequestId: "request-user-question", - status, - resolution, - statusReason, - payload: { - kind: "user_question", - questions: [ - { - id: "question-1", - prompt: questionPrompt ?? "Which deployment target should I use?", - shortLabel: "Target", - multiSelect: false, - options: [ - { value: "staging", label: "Staging" }, - { value: "production", label: "Production" }, - ], - allowFreeText: true, - }, - ], - }, - }, - meta: { + const [event] = fromRows([ + createTimelineEventFactory({ threadId: "thread-1" }).userQuestionLifecycle({ + ...args, id: `event-${seq}`, seq, - createdAt: seq, - }, - }; + }), + ]); + if (!event) { + throw new Error("Expected a decoded user-question lifecycle event"); + } + return event; } function buildContextWindowUsage( diff --git a/packages/thread-view/test/completed-turn-user-input-visibility.test.ts b/packages/thread-view/test/completed-turn-user-input-visibility.test.ts new file mode 100644 index 00000000000..94ec8fab8ec --- /dev/null +++ b/packages/thread-view/test/completed-turn-user-input-visibility.test.ts @@ -0,0 +1,264 @@ +import type { ThreadEventRow } from "@bb/domain"; +import { describe, expect, it } from "vitest"; +import { + createTimelineEventFactory, + renderTimelineFixture, +} from "./timeline-test-harness.js"; +import type { TimelineEventFactory } from "./timeline-test-harness.js"; + +function topLevelAssistantTexts( + rows: ReturnType["rows"], +): string[] { + return rows.flatMap((row) => + row.kind === "conversation" && row.role === "assistant" ? [row.text] : [], + ); +} + +function renderTimeline( + events: ThreadEventRow[], + threadStatus: "active" | "idle", +) { + return renderTimelineFixture({ + events, + projectionOptions: { + threadStatus, + turnMessageDetail: "summary", + }, + }); +} + +describe("completed turn user input visibility", () => { + function buildSteerEvents(event: TimelineEventFactory, completed: boolean) { + const request = event.clientTurnRequested({ + target: { kind: "new-turn" }, + text: "Check my router setup", + }); + const steer = event.clientTurnRequested({ + target: { kind: "steer", expectedTurnId: "turn-1" }, + source: "tell", + text: "explore but do not apply changes", + }); + const events: ThreadEventRow[] = [ + request, + event.turnStarted(), + event.inputAccepted({ clientRequestId: request.data.requestId }), + event.assistantCompleted({ itemId: "a1", text: "Starting the audit." }), + steer, + event.inputAccepted({ clientRequestId: steer.data.requestId }), + event.assistantCompleted({ + itemId: "a2", + text: "Understood - read-only only.", + }), + event.commandCompleted({ itemId: "tool-1", command: "ssh router" }), + event.assistantCompleted({ itemId: "a3", text: "Login works." }), + event.assistantCompleted({ + itemId: "a4", + text: "Audit complete. Nothing was changed.", + }), + event.assistantCompleted({ itemId: "a5", text: "Final runbook." }), + ]; + if (completed) { + events.push(event.turnCompleted()); + } + return events; + } + + it("keeps the direct reply and the segment-final message visible after a steered turn completes", () => { + const event = createTimelineEventFactory({ threadId: "thread-1" }); + const runningTexts = topLevelAssistantTexts( + renderTimeline(buildSteerEvents(event, false), "active").rows, + ); + expect(runningTexts).toContain("Understood - read-only only."); + expect(runningTexts).toContain("Audit complete. Nothing was changed."); + + const completedEvent = createTimelineEventFactory({ threadId: "thread-1" }); + const completedTexts = topLevelAssistantTexts( + renderTimeline(buildSteerEvents(completedEvent, true), "idle").rows, + ); + expect(completedTexts).toContain("Understood - read-only only."); + expect(completedTexts).toContain("Audit complete. Nothing was changed."); + expect(completedTexts).toContain("Final runbook."); + expect(completedTexts).not.toContain("Login works."); + }); + + it("keeps interim messages of an unsteered turn folded after completion", () => { + const event = createTimelineEventFactory({ threadId: "thread-1" }); + const request = event.clientTurnRequested({ + target: { kind: "new-turn" }, + text: "Fix the bug", + }); + const timeline = renderTimeline( + [ + request, + event.turnStarted(), + event.inputAccepted({ clientRequestId: request.data.requestId }), + event.assistantCompleted({ itemId: "a1", text: "Looking into it." }), + event.commandCompleted({ itemId: "tool-1", command: "grep bug" }), + event.assistantCompleted({ itemId: "a2", text: "Found the cause." }), + event.assistantCompleted({ itemId: "a3", text: "Fixed and verified." }), + event.turnCompleted(), + ], + "idle", + ); + + expect(topLevelAssistantTexts(timeline.rows)).toEqual([ + "Fixed and verified.", + ]); + }); + + it("keeps interim messages folded when the turn starts from several grouped input rows", () => { + const event = createTimelineEventFactory({ threadId: "thread-1" }); + const request = event.clientTurnRequested({ + target: { kind: "new-turn" }, + text: "Fix the bug\n\nThen add a regression test", + input: [ + { + type: "text", + text: "Fix the bug\n\nThen add a regression test", + mentions: [], + }, + ], + inputGroups: [ + [{ type: "text", text: "Fix the bug", mentions: [] }], + [{ type: "text", text: "Then add a regression test", mentions: [] }], + ], + }); + const timeline = renderTimeline( + [ + request, + event.turnStarted(), + event.inputAccepted({ clientRequestId: request.data.requestId }), + event.assistantCompleted({ itemId: "a1", text: "Looking into it." }), + event.commandCompleted({ itemId: "tool-1", command: "grep bug" }), + event.assistantCompleted({ itemId: "a2", text: "Found the cause." }), + event.assistantCompleted({ itemId: "a3", text: "Fixed and verified." }), + event.turnCompleted(), + ], + "idle", + ); + + const userTexts = timeline.rows.flatMap((row) => + row.kind === "conversation" && row.role === "user" ? [row.text] : [], + ); + expect(userTexts).toEqual(["Fix the bug", "Then add a regression test"]); + expect(topLevelAssistantTexts(timeline.rows)).toEqual([ + "Fixed and verified.", + ]); + }); + + it("keeps the direct reply after an answered question that is the turn's first output", () => { + const event = createTimelineEventFactory({ threadId: "thread-1" }); + const request = event.clientTurnRequested({ + target: { kind: "new-turn" }, + text: "Audit the router", + }); + const timeline = renderTimeline( + [ + request, + event.turnStarted(), + event.inputAccepted({ clientRequestId: request.data.requestId }), + event.userQuestionLifecycle(), + event.userQuestionLifecycle({ + status: "resolved", + resolution: { + kind: "user_answer", + answers: { "question-1": { selected: ["staging"] } }, + }, + }), + event.assistantCompleted({ itemId: "a1", text: "Staging it is." }), + event.commandCompleted({ itemId: "tool-1", command: "ssh router" }), + event.assistantCompleted({ itemId: "a2", text: "Login works." }), + event.assistantCompleted({ itemId: "a3", text: "Audit complete." }), + event.assistantCompleted({ itemId: "a4", text: "Final runbook." }), + event.turnCompleted(), + ], + "idle", + ); + + const texts = topLevelAssistantTexts(timeline.rows); + expect(texts).toContain("Staging it is."); + expect(texts).toContain("Audit complete."); + expect(texts).toContain("Final runbook."); + expect(texts).not.toContain("Login works."); + }); + + it("keeps interim messages folded around a rejected steer", () => { + const event = createTimelineEventFactory({ threadId: "thread-1" }); + const request = event.clientTurnRequested({ + target: { kind: "new-turn" }, + text: "Check my router setup", + }); + const steer = event.clientTurnRequested({ + target: { kind: "steer", expectedTurnId: "turn-1" }, + source: "tell", + text: "explore but do not apply changes", + }); + const timeline = renderTimeline( + [ + request, + event.turnStarted(), + event.inputAccepted({ clientRequestId: request.data.requestId }), + event.assistantCompleted({ itemId: "a1", text: "Starting the audit." }), + steer, + event.clientTurnRejected({ requestId: steer.data.requestId }), + event.assistantCompleted({ itemId: "a2", text: "Checking config." }), + event.commandCompleted({ itemId: "tool-1", command: "ssh router" }), + event.assistantCompleted({ itemId: "a3", text: "Login works." }), + event.assistantCompleted({ itemId: "a4", text: "Audit complete." }), + event.turnCompleted(), + ], + "idle", + ); + + const steerRow = timeline.rows.find( + (row) => + row.kind === "conversation" && + row.role === "user" && + row.text === "explore but do not apply changes", + ); + expect(steerRow).toMatchObject({ + turnRequest: { kind: "steer", status: "rejected" }, + }); + expect(topLevelAssistantTexts(timeline.rows)).toEqual(["Audit complete."]); + }); + + it("keeps an answered question and the report preceding it visible after the turn completes", () => { + const event = createTimelineEventFactory({ threadId: "thread-1" }); + const request = event.clientTurnRequested({ + target: { kind: "new-turn" }, + text: "Audit the router", + }); + const timeline = renderTimeline( + [ + request, + event.turnStarted(), + event.inputAccepted({ clientRequestId: request.data.requestId }), + event.commandCompleted({ itemId: "tool-1", command: "ssh router" }), + event.assistantCompleted({ + itemId: "a1", + text: "Audit complete. Which changes should I include?", + }), + event.userQuestionLifecycle(), + event.userQuestionLifecycle({ + status: "resolved", + resolution: { + kind: "user_answer", + answers: { "question-1": { selected: ["all"] } }, + }, + }), + event.assistantCompleted({ itemId: "a2", text: "Final runbook." }), + event.turnCompleted(), + ], + "idle", + ); + + const texts = topLevelAssistantTexts(timeline.rows); + expect(texts).toContain("Audit complete. Which changes should I include?"); + expect(texts).toContain("Final runbook."); + expect( + timeline.rows.some( + (row) => row.kind === "work" && row.workKind === "question", + ), + ).toBe(true); + }); +}); diff --git a/packages/thread-view/test/timeline-test-harness.ts b/packages/thread-view/test/timeline-test-harness.ts index b06789126fa..5fdf9012c27 100644 --- a/packages/thread-view/test/timeline-test-harness.ts +++ b/packages/thread-view/test/timeline-test-harness.ts @@ -19,6 +19,7 @@ import type { ThreadEventWarningCategory, ThreadTurnInitiator, TurnRequestTarget, + UserQuestionPendingInteractionResolution, } from "@bb/domain"; import type { TimelineRow } from "@bb/server-contract"; import type { @@ -257,6 +258,16 @@ interface PermissionGrantLifecycleArgs extends DefaultTurnEventOptions { toolName?: string; } +interface UserQuestionLifecycleArgs extends DefaultTurnEventOptions { + interactionId?: string; + providerId?: string; + providerRequestId?: string; + questionPrompt?: string; + resolution?: UserQuestionPendingInteractionResolution | null; + status?: "pending" | "resolving" | "resolved" | "interrupted"; + statusReason?: string | null; +} + interface LegacyUserMessageArgs extends EventFactoryRowOptions { text: string; turnId?: string; @@ -326,6 +337,9 @@ export interface TimelineEventFactory { providerError( args: ProviderErrorArgs, ): ThreadEventRowOfType<"provider/error">; + userQuestionLifecycle( + args?: UserQuestionLifecycleArgs, + ): ThreadEventRowOfType<"system/userQuestion/lifecycle">; providerUnhandled( args?: ProviderUnhandledArgs, ): ThreadEventRowOfType<"provider/unhandled">; @@ -809,6 +823,42 @@ export function createTimelineEventFactory( }, }; }, + userQuestionLifecycle(args = {}) { + const base = nextDefaultTurnScopedRowBase( + "user-question-lifecycle", + args, + ); + return { + ...base, + type: "system/userQuestion/lifecycle", + data: { + interactionId: args.interactionId ?? "pi-user-question", + providerId: args.providerId ?? "claude-code", + providerRequestId: args.providerRequestId ?? "request-user-question", + status: args.status ?? "pending", + resolution: args.resolution ?? null, + statusReason: args.statusReason ?? null, + payload: { + kind: "user_question", + questions: [ + { + id: "question-1", + prompt: + args.questionPrompt ?? + "Which deployment target should I use?", + shortLabel: "Target", + multiSelect: false, + options: [ + { value: "staging", label: "Staging" }, + { value: "production", label: "Production" }, + ], + allowFreeText: true, + }, + ], + }, + }, + }; + }, providerError(args) { const base = nextProviderTurnScopedRowBase("provider-error", args); return {