@steerable/agent-shell-web 0.7.2 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -18,6 +18,8 @@ import type { InspectTaskInput } from './executed-actions-model';
18
18
  import type { ChildInfo } from './OrchestrationChildrenCard';
19
19
  import type { TurnBlock } from './turn-timeline';
20
20
  import type { TurnFile } from './turn-files';
21
+ import type { RetryState } from './RetryStatus';
22
+ import type { TurnFailureCardProps } from './TurnFailureCard';
21
23
  import type { LlmSpeedSnapshot } from './process-status';
22
24
  import { ComposerStatusStack } from './composer-status-stack';
23
25
  import { t } from '@/i18n';
@@ -120,6 +122,10 @@ export interface LocalChatPanelProps {
120
122
  orchestrationChildrenByMessageId?: Record<string, ChildInfo[]>;
121
123
  /** Current round number for the in-flight turn (1-based). */
122
124
  currentRound?: number;
125
+ /** Live "reconnecting" state of the in-flight turn (see RetryStatus). */
126
+ retryState?: RetryState | null;
127
+ /** Resolves a failed turn's action buttons (see TurnFailureCard). */
128
+ failureAction?: TurnFailureCardProps['action'];
123
129
  /**
124
130
  * Settings gear callback for the input toolbar. Receiving this enables
125
131
  * the gear button on `ChatInput`; AgentPage wires it to opening
@@ -196,6 +202,8 @@ export function LocalChatPanel({
196
202
  currentTurnChildren,
197
203
  orchestrationChildrenByMessageId,
198
204
  currentRound,
205
+ retryState,
206
+ failureAction,
199
207
  onOpenSettings,
200
208
  inputToolbarExtras,
201
209
  inputLeadingChrome,
@@ -509,6 +517,8 @@ export function LocalChatPanel({
509
517
  currentTurnChildren={currentTurnChildren}
510
518
  orchestrationChildrenByMessageId={orchestrationChildrenByMessageId}
511
519
  currentRound={currentRound}
520
+ retryState={retryState}
521
+ failureAction={failureAction}
512
522
  emptyState={emptyState ?? <EmptyChat onSelectPrompt={handleSelectPrompt} />}
513
523
  mode={mode}
514
524
  onRegenerate={onRegenerate}
@@ -25,6 +25,8 @@ import { followedPreviewPath, type TurnFile } from './turn-files';
25
25
  import { publishConversationPreview } from '@/layouts/conversation-preview';
26
26
  import { inferDurationMs, readPersistedDurationMs } from './elapsed';
27
27
  import type { LlmSpeedSnapshot } from './process-status';
28
+ import type { RetryState } from './RetryStatus';
29
+ import type { TurnFailureCardProps } from './TurnFailureCard';
28
30
 
29
31
  /**
30
32
  * MessageList — scrolling viewport that renders user/assistant message
@@ -154,6 +156,13 @@ interface MessageListProps {
154
156
  * AssistantMessage so its `StreamingStatus` can show "Round 2 · 继续推理...".
155
157
  */
156
158
  currentRound?: number;
159
+ /**
160
+ * Live "reconnecting" state of the in-flight turn (backend `turn_retry`).
161
+ * Forwarded to the tail message so the status line replaces "thinking".
162
+ */
163
+ retryState?: RetryState | null;
164
+ /** Resolves a failed turn's action buttons (see TurnFailureCard). */
165
+ failureAction?: TurnFailureCardProps['action'];
157
166
  /** Current chat mode (Agent / Plan). */
158
167
  mode?: ChatMode;
159
168
  /** W1.2.1: regenerate an assistant turn (fork-preserving). */
@@ -207,6 +216,8 @@ export function MessageList({
207
216
  currentTurnChildren,
208
217
  orchestrationChildrenByMessageId,
209
218
  currentRound,
219
+ retryState,
220
+ failureAction,
210
221
  mode,
211
222
  onRegenerate,
212
223
  interrupted = false,
@@ -530,6 +541,16 @@ export function MessageList({
530
541
  orchestrationChildren={childList}
531
542
  previousUser={previousUser}
532
543
  currentRound={isStreamingTail ? currentRound : undefined}
544
+ retryState={isStreamingTail ? retryState : null}
545
+ // 「继续」只属于当前最后一个回合:用户已经发了新消息
546
+ // 时,继续旧回合会把旧任务的输出接在新消息之后,前后
547
+ // 顺序就乱了。判据是「这条是不是会话里最后一条消息」,
548
+ // 不是「最后一条助手消息」——后者在用户已追问后仍成立。
549
+ failureAction={
550
+ message.id === lastAssistantId && isLast
551
+ ? failureAction
552
+ : undefined
553
+ }
533
554
  isPlanMode={isPlanMode}
534
555
  onRegenerate={onRegenerate}
535
556
  startedAtMs={isStreamingTail ? currentTurnStartedAtMs : undefined}
@@ -0,0 +1,77 @@
1
+ import { useEffect, useState } from 'react';
2
+ import { motion } from 'framer-motion';
3
+ import { t } from '@/i18n';
4
+
5
+ /**
6
+ * RetryStatus — "reconnecting" line for a model request that is being retried.
7
+ *
8
+ * The backend retries connection failures on a seconds-scale backoff, so a
9
+ * turn that is working looks identical to one that is stuck. This renders the
10
+ * three facts a waiting user needs: it is retrying, since when, and that Stop
11
+ * still works. The elapsed label appears only after
12
+ * `RETRY_ELAPSED_VISIBLE_MS`, because a sub-10s wait reads as progress while a
13
+ * ticking timer on a short wait reads as a problem.
14
+ */
15
+
16
+ /** Elapsed seconds are shown from here on: 10s is where a wait needs progress. */
17
+ export const RETRY_ELAPSED_VISIBLE_MS = 10_000;
18
+
19
+ export interface RetryState {
20
+ /** Epoch ms when the first retry of this request started. */
21
+ startedAt: number;
22
+ /** Delay the loop announced for this attempt, for the next tick. */
23
+ delayMs?: number;
24
+ }
25
+
26
+ interface RetryStatusProps {
27
+ /** Live retry state, or undefined once the request is no longer retrying. */
28
+ retry: RetryState;
29
+ /** Injectable clock for tests; real time by default. */
30
+ now?: () => number;
31
+ }
32
+
33
+ /** Seconds waited so far; exported for the label's unit tests. */
34
+ export function waitedSeconds(startedAt: number, now: number): number {
35
+ return Math.max(0, Math.floor((now - startedAt) / 1000));
36
+ }
37
+
38
+ /** The line's text: bare "reconnecting", then the elapsed count. */
39
+ export function retryLabel(startedAt: number, now: number): string {
40
+ const waited = waitedSeconds(startedAt, now);
41
+ if (now - startedAt < RETRY_ELAPSED_VISIBLE_MS) {
42
+ return t('Network unstable, reconnecting…');
43
+ }
44
+ return t('Network unstable, reconnecting… ({seconds}s)', { seconds: waited });
45
+ }
46
+
47
+ export function RetryStatus({ retry, now = Date.now }: RetryStatusProps) {
48
+ const [tick, setTick] = useState(() => now());
49
+ useEffect(() => {
50
+ const id = window.setInterval(() => setTick(now()), 1000);
51
+ return () => window.clearInterval(id);
52
+ }, [now, retry.startedAt]);
53
+
54
+ const current = Math.max(tick, retry.startedAt);
55
+
56
+ return (
57
+ <motion.div
58
+ key="retry"
59
+ initial={{ opacity: 0, y: 2 }}
60
+ animate={{ opacity: 1, y: 0 }}
61
+ transition={{ duration: 0.2 }}
62
+ // `polite` announces the line once when it appears. The elapsed counter
63
+ // updates every second but is not announced again: it is informational,
64
+ // and a per-second announcement would interrupt screen-reader reading.
65
+ role="status"
66
+ aria-live="polite"
67
+ aria-atomic="true"
68
+ className="my-2 inline-flex items-center gap-2 text-xs text-agent-warning"
69
+ data-testid="retry-status"
70
+ >
71
+ <span className="h-3 w-3 animate-spin rounded-full border-2 border-current border-r-transparent" />
72
+ <span>{retryLabel(retry.startedAt, current)}</span>
73
+ </motion.div>
74
+ );
75
+ }
76
+
77
+ export default RetryStatus;
@@ -1,5 +1,6 @@
1
1
  import { motion } from 'framer-motion';
2
2
  import { t } from '@/i18n';
3
+ import { RetryStatus, type RetryState } from './RetryStatus';
3
4
 
4
5
  /**
5
6
  * StreamingStatus — informative placeholder for an assistant bubble that has
@@ -30,13 +31,21 @@ interface StreamingStatusProps {
30
31
  * nothing (the content + cursor blink is enough).
31
32
  */
32
33
  hasContent: boolean;
34
+ /**
35
+ * Set while the request is being retried. Outranks `hasContent`: a turn
36
+ * that already streamed text and then lost its connection is waiting, not
37
+ * writing, and the cursor alone would read as a stall.
38
+ */
39
+ retry?: RetryState;
33
40
  }
34
41
 
35
42
  export function StreamingStatus({
36
43
  round,
37
44
  actionCount,
38
45
  hasContent,
46
+ retry,
39
47
  }: StreamingStatusProps) {
48
+ if (retry) return <RetryStatus retry={retry} />;
40
49
  // Once tokens are flowing the parent already has plenty of visual feedback
41
50
  // (the streaming cursor); a separate status line would be noise.
42
51
  if (hasContent) return null;
@@ -0,0 +1,272 @@
1
+ import { useState } from 'react';
2
+ import { LuChevronDown, LuChevronUp, LuCopy, LuTriangleAlert } from 'react-icons/lu';
3
+ import { t } from '@/i18n';
4
+ import {
5
+ failurePresentation,
6
+ type FailureAction,
7
+ type TurnFailureDetail,
8
+ } from './turn-failure';
9
+
10
+ /**
11
+ * TurnFailureCard — what a failed turn shows in place of an answer.
12
+ *
13
+ * Two layers, chosen by who reads them. The headline and one sentence say
14
+ * what happened and what to do next; nobody needs `SSLEOFError` to press
15
+ * Continue. The details disclosure holds the raw cause, the hop the request
16
+ * took, and the trace id — the material a support reply or a bug report
17
+ * needs, which is exactly why it must not be in the way.
18
+ *
19
+ * The card never decides whether an action is available; it renders the ones
20
+ * its caller passed and shows the rest as disabled. That keeps the choice of
21
+ * "what can this build actually do" in one place (the message list) instead of
22
+ * duplicating capability checks per failure kind.
23
+ */
24
+
25
+ export interface TurnFailureCardProps {
26
+ /** Classified detail, when the backend recorded one. */
27
+ detail?: TurnFailureDetail;
28
+ /** Raw failure text, always present; the details layer's main content. */
29
+ reason: string;
30
+ /** Tool calls that already completed in this turn. */
31
+ completedSteps?: number;
32
+ /** ISO timestamp of the failed turn, for the details layer. */
33
+ happenedAt?: string;
34
+ /** Trace id, for the details layer and the copied report. */
35
+ traceId?: string;
36
+ /** Renders the action buttons this build can perform. */
37
+ action?: (action: FailureAction) => { onClick: () => void; disabled?: boolean; hint?: string } | null;
38
+ }
39
+
40
+ /** Details-layer field label/value pair. */
41
+ function DetailRow({ label, children }: { label: string; children: React.ReactNode }) {
42
+ return (
43
+ <>
44
+ <dt className="text-agent-muted-foreground">{label}</dt>
45
+ <dd className="min-w-0 break-words">{children}</dd>
46
+ </>
47
+ );
48
+ }
49
+
50
+ export function TurnFailureCard({
51
+ detail,
52
+ reason,
53
+ completedSteps = 0,
54
+ happenedAt,
55
+ traceId,
56
+ action,
57
+ }: TurnFailureCardProps) {
58
+ const [open, setOpen] = useState(false);
59
+ const presentation = failurePresentation(detail, { completedSteps });
60
+ const actions = [presentation.primary, ...presentation.secondary]
61
+ .map((entry) => ({ entry, handler: action?.(entry) ?? null }))
62
+ .filter((row): row is { entry: FailureAction; handler: NonNullable<typeof row.handler> } =>
63
+ row.handler !== null,
64
+ );
65
+
66
+ return (
67
+ <div
68
+ className="rounded-agent-lg border border-agent-destructive/40 bg-agent-destructive/5 px-2.5 py-2 text-xs leading-relaxed shadow-sm"
69
+ data-testid="turn-failure"
70
+ data-failure-kind={detail?.kind ?? 'unknown'}
71
+ >
72
+ <div className="flex items-start gap-2 font-medium text-agent-destructive">
73
+ <LuTriangleAlert className="mt-0.5 h-3.5 w-3.5 flex-shrink-0" />
74
+ <span>{presentation.title}</span>
75
+ </div>
76
+ <p className="mt-1 ml-5 text-agent-foreground/90">{presentation.body}</p>
77
+ <div className="mt-2 ml-5 flex flex-wrap items-center gap-2">
78
+ {actions.map(({ entry, handler }, index) => (
79
+ <button
80
+ key={entry.kind}
81
+ type="button"
82
+ disabled={handler.disabled}
83
+ title={handler.hint}
84
+ onClick={handler.onClick}
85
+ className={
86
+ index === 0
87
+ ? 'rounded-agent-md bg-agent-primary px-3 py-1 text-xs font-medium text-agent-primary-foreground transition hover:bg-agent-primary/90 disabled:opacity-50'
88
+ : 'rounded-agent-md border border-agent-border bg-agent-card px-3 py-1 text-xs text-agent-foreground transition hover:bg-agent-muted disabled:opacity-50'
89
+ }
90
+ data-action={entry.kind}
91
+ >
92
+ {actionLabel(entry)}
93
+ </button>
94
+ ))}
95
+ <button
96
+ type="button"
97
+ onClick={() => setOpen((value) => !value)}
98
+ className="inline-flex items-center gap-0.5 rounded px-1 text-agent-muted-foreground hover:text-agent-foreground"
99
+ aria-expanded={open}
100
+ data-action="details"
101
+ >
102
+ {open ? t('Hide details') : t('Details')}
103
+ {open ? <LuChevronUp className="h-3 w-3" /> : <LuChevronDown className="h-3 w-3" />}
104
+ </button>
105
+ </div>
106
+ {open && (
107
+ <div className="mt-2 ml-5 border-t border-agent-destructive/20 pt-2" data-testid="turn-failure-details">
108
+ <dl className="grid grid-cols-[5.5rem_1fr] gap-x-3 gap-y-1">
109
+ <DetailRow label={t('Category')}>
110
+ {presentation.classified ? categoryLabel(detail?.kind) : t('Not classified')}
111
+ </DetailRow>
112
+ <DetailRow label={t('Cause')}>
113
+ <span className="block">{causeSummary(detail, reason)}</span>
114
+ {presentation.classified && reason !== causeSummary(detail, reason) ? (
115
+ <code className="mt-0.5 block break-all font-mono text-[11px] text-agent-muted-foreground">
116
+ {reason}
117
+ </code>
118
+ ) : null}
119
+ </DetailRow>
120
+ {detail?.proxy || detail?.kind === 'transport' ? (
121
+ <DetailRow label={t('Connection')}>
122
+ {detail?.proxy
123
+ ? t('Through system proxy {proxy}', { proxy: detail.proxy })
124
+ : t('Direct')}
125
+ </DetailRow>
126
+ ) : null}
127
+ {detail?.attempts ? (
128
+ <DetailRow label={t('Retries')}>
129
+ {t('{count} attempts', { count: detail.attempts })}
130
+ </DetailRow>
131
+ ) : null}
132
+ {happenedAt ? (
133
+ <DetailRow label={t('Time')}>
134
+ <span className="font-mono text-[11px]">{happenedAt}</span>
135
+ </DetailRow>
136
+ ) : null}
137
+ {traceId ? (
138
+ <DetailRow label={t('Trace ID')}>
139
+ <span className="font-mono text-[11px]">{traceId}</span>
140
+ </DetailRow>
141
+ ) : null}
142
+ </dl>
143
+ {traceId ? <CopyDiagnostics detail={detail} reason={reason} traceId={traceId} /> : null}
144
+ </div>
145
+ )}
146
+ </div>
147
+ );
148
+ }
149
+
150
+ /** Button text per action, so the card does not repeat the copy three times. */
151
+ function actionLabel(action: FailureAction): string {
152
+ switch (action.kind) {
153
+ case 'retry':
154
+ return t('Continue');
155
+ case 'network-diagnostics':
156
+ return t('Network diagnostics');
157
+ case 'open-model-settings':
158
+ return t('Open model settings');
159
+ case 'new-chat':
160
+ return t('New chat');
161
+ }
162
+ }
163
+
164
+ /** The details layer's one-line Chinese cause, or the raw text when unknown. */
165
+ function causeSummary(detail: TurnFailureDetail | undefined, reason: string): string {
166
+ switch (detail?.kind) {
167
+ case 'transport':
168
+ return t('The connection to the model service dropped.');
169
+ case 'auth':
170
+ return t('The service rejected the credentials.');
171
+ case 'rate_limit':
172
+ return t('The service asked us to slow down.');
173
+ case 'server':
174
+ return t('The service reported its own failure.');
175
+ case 'context_overflow':
176
+ return t('The request exceeds the model window.');
177
+ case 'invalid_request':
178
+ return t('The service rejected the request.');
179
+ default:
180
+ return reason;
181
+ }
182
+ }
183
+
184
+ function categoryLabel(kind: string | undefined): string {
185
+ switch (kind) {
186
+ case 'transport':
187
+ return t('Network');
188
+ case 'auth':
189
+ return t('Credentials');
190
+ case 'rate_limit':
191
+ return t('Rate limit');
192
+ case 'server':
193
+ return t('Service error');
194
+ case 'context_overflow':
195
+ return t('Conversation too long');
196
+ case 'invalid_request':
197
+ return t('Rejected request');
198
+ default:
199
+ return t('Not classified');
200
+ }
201
+ }
202
+
203
+ /**
204
+ * One-click report text. Everything a reader needs to reproduce the failure,
205
+ * and nothing that identifies the user: the proxy address is already stripped
206
+ * of credentials by the backend, and the API key never enters a failure.
207
+ */
208
+ export function buildDiagnostics(input: {
209
+ detail?: TurnFailureDetail;
210
+ reason: string;
211
+ traceId: string;
212
+ version?: string;
213
+ }): string {
214
+ const lines = [
215
+ input.version ? t('Version: {version}', { version: input.version }) : null,
216
+ t('Category: {category}', {
217
+ category: input.detail ? categoryLabel(input.detail.kind) : t('Not classified'),
218
+ }),
219
+ t('Cause: {cause}', { cause: input.reason }),
220
+ input.detail?.proxy
221
+ ? t('Connection: through system proxy {proxy}', { proxy: input.detail.proxy })
222
+ : t('Connection: direct'),
223
+ input.detail?.attempts
224
+ ? t('Retries: {count}', { count: input.detail.attempts })
225
+ : null,
226
+ t('Trace ID: {traceId}', { traceId: input.traceId }),
227
+ ].filter((line): line is string => line !== null);
228
+ return lines.join('\n');
229
+ }
230
+
231
+ function CopyDiagnostics({
232
+ detail,
233
+ reason,
234
+ traceId,
235
+ }: {
236
+ detail?: TurnFailureDetail;
237
+ reason: string;
238
+ traceId: string;
239
+ }) {
240
+ const [copied, setCopied] = useState(false);
241
+ const copy = async () => {
242
+ const text = buildDiagnostics({ detail, reason, traceId });
243
+ try {
244
+ await navigator.clipboard.writeText(text);
245
+ setCopied(true);
246
+ window.setTimeout(() => setCopied(false), 2000);
247
+ } catch {
248
+ // Clipboard permission can be refused (browser, kiosk). The text is
249
+ // already on screen in the details layer, so reporting the failure is
250
+ // only a manual selection away; do not block on it.
251
+ setCopied(false);
252
+ }
253
+ };
254
+ return (
255
+ <div className="mt-2 flex items-center gap-2">
256
+ <button
257
+ type="button"
258
+ onClick={() => void copy()}
259
+ className="inline-flex items-center gap-1 rounded-agent-md border border-agent-border bg-agent-card px-2 py-0.5 text-[11px] hover:bg-agent-muted"
260
+ data-action="copy-diagnostics"
261
+ >
262
+ <LuCopy className="h-3 w-3" />
263
+ {copied ? t('Copied') : t('Copy diagnostic info')}
264
+ </button>
265
+ <span className="text-[11px] text-agent-muted-foreground">
266
+ {t("Doesn't include your API key")}
267
+ </span>
268
+ </div>
269
+ );
270
+ }
271
+
272
+ export default TurnFailureCard;
@@ -121,7 +121,7 @@ export interface ExecutedActionLike {
121
121
  arguments?: unknown;
122
122
  result?: unknown;
123
123
  sandbox?: { backend?: string; enforcement: string };
124
- approval?: { kind?: string; cached?: boolean };
124
+ approval?: { kind?: string; cached?: boolean; auto?: boolean };
125
125
  view?: { title?: string; declared?: boolean };
126
126
  durationMs?: number;
127
127
  }
@@ -618,6 +618,7 @@ function approvalFromNestedResult(result: unknown): ExecutedActionLike['approval
618
618
  return {
619
619
  kind: marker.kind,
620
620
  ...(marker.cached === true ? { cached: true } : {}),
621
+ ...(marker.auto === true ? { auto: true } : {}),
621
622
  };
622
623
  }
623
624
 
@@ -644,3 +645,120 @@ export function expandRunCodeActions<T extends ExecutedActionLike>(actions: T[])
644
645
  }
645
646
  return out;
646
647
  }
648
+
649
+ export interface AskReplay {
650
+ unattended: boolean;
651
+ lines: string[];
652
+ }
653
+
654
+ /** First question, used as the folded tool-row title. The intro is only a fallback. */
655
+ export function askRowSummary(action: { tool?: string; arguments?: unknown; result?: unknown }): string {
656
+ if (action.tool !== 'ask_user') return '';
657
+ return firstQuestionText(action.result) || firstQuestionText(action.arguments);
658
+ }
659
+
660
+ /** Host on a settled egress row. Ports 80 and 443 are omitted. */
661
+ export function egressRowSummary(action: { tool?: string; arguments?: unknown }): string {
662
+ if (action.tool !== 'network_egress') return '';
663
+ const value = action.arguments;
664
+ if (!value || typeof value !== 'object') return '';
665
+ const record = value as { host?: unknown; port?: unknown };
666
+ const host = typeof record.host === 'string' ? record.host : '';
667
+ if (!host) return '';
668
+ const port = typeof record.port === 'number' ? record.port : null;
669
+ if (port === null || port === 80 || port === 443) return host;
670
+ return `${host}:${port}`;
671
+ }
672
+
673
+ /** Questions and answers from an ask_user result, including a JSON preview. */
674
+ export function askReplay(result: unknown): AskReplay | null {
675
+ const record = unwrapAsk(result);
676
+ if (!record) return null;
677
+ const asked: string[] = [];
678
+ const rendered: string[] = [];
679
+ for (const question of record.questions) {
680
+ if (!question || typeof question !== 'object') continue;
681
+ const row = question as { id?: unknown; text?: unknown };
682
+ const text = typeof row.text === 'string' ? row.text.trim() : '';
683
+ if (!text) continue;
684
+ asked.push(text);
685
+ const id = typeof row.id === 'string' ? row.id : '';
686
+ rendered.push(id ? answerText(record.answers[id]) : '');
687
+ }
688
+ if (asked.length === 0) return null;
689
+ const filled = rendered.filter((value) => value.length > 0);
690
+ const first = filled[0];
691
+ const shared = first !== undefined && filled.length === asked.length && filled.every((value) => value === first);
692
+ const lines = shared
693
+ ? [...asked.map((text) => t('Question {text}', { text })), t('Answer {text}', { text: first })]
694
+ : asked.flatMap((text, index) => {
695
+ const answer = rendered[index] ?? '';
696
+ return answer
697
+ ? [t('Question {text}', { text }), t('Answer {text}', { text: answer })]
698
+ : [t('Question {text}', { text })];
699
+ });
700
+ return { unattended: record.unattended === true, lines };
701
+ }
702
+
703
+ function unwrapAsk(result: unknown): { questions: unknown[]; answers: Record<string, unknown>; unattended?: boolean } | null {
704
+ if (typeof result === 'string') {
705
+ const trimmed = result.trim();
706
+ if (!trimmed.startsWith('{')) return null;
707
+ try {
708
+ const parsed: unknown = JSON.parse(trimmed);
709
+ return unwrapAsk(parsed);
710
+ } catch {
711
+ return null;
712
+ }
713
+ }
714
+ if (!result || typeof result !== 'object' || Array.isArray(result)) return null;
715
+ const record = result as {
716
+ questions?: unknown;
717
+ answers?: unknown;
718
+ data?: unknown;
719
+ value?: unknown;
720
+ unattended?: unknown;
721
+ };
722
+ if (Array.isArray(record.questions) && record.answers && typeof record.answers === 'object' && !Array.isArray(record.answers)) {
723
+ return {
724
+ questions: record.questions,
725
+ answers: record.answers as Record<string, unknown>,
726
+ ...(record.unattended === true ? { unattended: true } : {}),
727
+ };
728
+ }
729
+ if (record.value && typeof record.value === 'object') {
730
+ const inner = unwrapAsk(record.value);
731
+ if (inner) return record.unattended === true ? { ...inner, unattended: true } : inner;
732
+ }
733
+ if (record.data && typeof record.data === 'object') {
734
+ const inner = unwrapAsk(record.data);
735
+ if (inner) return record.unattended === true ? { ...inner, unattended: true } : inner;
736
+ }
737
+ return null;
738
+ }
739
+
740
+ function firstQuestionText(value: unknown): string {
741
+ const unwrapped = unwrapAsk(value);
742
+ const questions = unwrapped?.questions ?? questionList(value);
743
+ for (const question of questions) {
744
+ if (!question || typeof question !== 'object') continue;
745
+ const text = (question as { text?: unknown }).text;
746
+ if (typeof text === 'string' && text.trim()) return text.trim();
747
+ }
748
+ return '';
749
+ }
750
+
751
+ function questionList(value: unknown): unknown[] {
752
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return [];
753
+ const questions = (value as { questions?: unknown }).questions;
754
+ return Array.isArray(questions) ? questions : [];
755
+ }
756
+
757
+ function answerText(value: unknown): string {
758
+ if (typeof value === 'string') return value.trim();
759
+ if (!Array.isArray(value)) return '';
760
+ return value
761
+ .filter((item): item is string => typeof item === 'string' && item.trim().length > 0)
762
+ .map((item) => item.trim())
763
+ .join(', ');
764
+ }