@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.
- package/package.json +4 -4
- package/src/components/AgentSidebar.tsx +1 -0
- package/src/components/LocalLlmSettingsModal.tsx +40 -3
- package/src/components/chat/ApprovalModal.tsx +10 -0
- package/src/components/chat/AssistantMessage.tsx +252 -15
- package/src/components/chat/ExecutedActionsCard.tsx +39 -8
- package/src/components/chat/LocalChatPanel.tsx +10 -0
- package/src/components/chat/MessageList.tsx +21 -0
- package/src/components/chat/RetryStatus.tsx +77 -0
- package/src/components/chat/StreamingStatus.tsx +9 -0
- package/src/components/chat/TurnFailureCard.tsx +272 -0
- package/src/components/chat/executed-actions-model.ts +119 -1
- package/src/components/chat/turn-failure.ts +185 -0
- package/src/components/settings/DiagnoseSettingsPanel.tsx +113 -2
- package/src/components/settings/LlmSettingsPanel.tsx +18 -0
- package/src/layouts/AgentLayout.tsx +1 -1
- package/src/layouts/RightPanelTabBar.tsx +1 -1
- package/src/layouts/right-panel-tabs.ts +1 -1
- package/src/lib/local-api.ts +18 -0
- package/src/pages/AgentPage.tsx +100 -0
- package/src/pages/SettingsPage.tsx +2 -2
|
@@ -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
|
+
}
|