Skip to content
45 changes: 45 additions & 0 deletions dato-agent/src/components/AgentSurface.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -644,6 +644,51 @@
line-height: 1.4;
}

.diagnosticsReview {
display: flex;
flex-direction: column;
gap: var(--spacing-xs);
margin: var(--spacing-s) 0 0;
padding: var(--spacing-s);
border: 1px solid var(--color--border);
border-radius: var(--border-radius-m, 6px);
font-size: var(--font-size-xs);
}

.diagnosticsReview legend {
padding: 0 var(--spacing-xs);
color: var(--color--ink);
font-weight: 600;
}

.diagnosticsReviewItem {
display: flex;
align-items: baseline;
gap: var(--spacing-xs);
color: var(--color--ink-subtle);
}

.diagnosticsReviewHint {
margin: 0;
color: var(--color--ink-subtle);
}

.diagnosticsReviewItem .diagnosticsReviewAction {
flex: none;
color: var(--color--ink);
font-weight: 600;
}

.diagnosticsReviewItem code {
flex: none;
white-space: nowrap;
}

.diagnosticsReviewItem span {
min-width: 0;
overflow-wrap: anywhere;
}

.activityDisclosure {
color: var(--color--ink-subtle);
font-size: var(--font-size-s);
Expand Down
77 changes: 76 additions & 1 deletion dato-agent/src/components/AgentSurface.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1384,6 +1384,78 @@ describe('AgentSurface', () => {
).not.toBeInTheDocument();
});

it('asks which secret-looking values to redact before copying diagnostics', async () => {
const onCopyFailureDiagnostics = vi
.fn()
.mockResolvedValueOnce({
status: 'review',
candidates: [
{
value: 'first-secret-value-0001',
preview: '…0001 · 23 chars',
path: 'conversation[0].text',
occurrences: 1,
},
{
value: 'second-secret-value-002',
preview: '…0002 · 23 chars',
path: 'turn.events[1].output',
occurrences: 2,
},
],
})
.mockResolvedValueOnce({ status: 'copied' });
const user = userEvent.setup();

render(
<AgentSurface
connection={connected}
entries={[
{
id: 'failed-response',
kind: 'message',
role: 'assistant',
content: '',
error: 'The request could not be completed.',
failure: { id: 'failure-1', retryable: false },
},
]}
onCopyFailureDiagnostics={onCopyFailureDiagnostics}
onSubmit={vi.fn()}
/>,
);

await user.click(screen.getByRole('button', { name: 'Copy diagnostics' }));
expect(
screen.getByText(/2 values in the diagnostics look like secrets/),
).toBeInTheDocument();
expect(screen.getByText('…0002 · 23 chars')).toBeInTheDocument();
expect(
screen.getByText(
'Ticked values are replaced with [redacted] in the copy.',
),
).toBeInTheDocument();
expect(screen.getAllByText('Redact')).toHaveLength(2);
expect(
screen.queryByText('second-secret-value-002'),
).not.toBeInTheDocument();

// Everything starts selected; untick one, then copy.
const checkboxes = screen.getAllByRole('checkbox');
expect(
checkboxes.every((checkbox) => (checkbox as HTMLInputElement).checked),
).toBe(true);
await user.click(checkboxes[1] as HTMLElement);
await user.click(
screen.getByRole('button', { name: 'Copy with 1 redacted' }),
);

expect(onCopyFailureDiagnostics).toHaveBeenLastCalledWith('failure-1', [
'first-secret-value-0001',
]);
expect(screen.queryByText(/look like secrets/)).not.toBeInTheDocument();
});

it('offers compact recovery actions only for a terminal assistant failure', async () => {
const onRetryFailedTurn = vi.fn();
const onCopyFailureDiagnostics = vi.fn().mockResolvedValue(undefined);
Expand Down Expand Up @@ -1442,7 +1514,10 @@ describe('AgentSurface', () => {

await user.click(copyButton);
expect(onCopyFailureDiagnostics).toHaveBeenCalledOnce();
expect(onCopyFailureDiagnostics).toHaveBeenCalledWith('failure-1');
expect(onCopyFailureDiagnostics).toHaveBeenCalledWith(
'failure-1',
undefined,
);
});

it('shows only diagnostics for a non-retryable terminal failure', () => {
Expand Down
90 changes: 87 additions & 3 deletions dato-agent/src/components/AgentSurface.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import type {
Mention,
} from '../lib/mentions';
import { fallbackAssetMention, fallbackRecordMention } from '../lib/mentions';
import type { SecretCandidate } from '../lib/secretCandidates';
import { MentionDisplay } from '../recordComments/entrypoints/components/shared/MentionDisplay';
import commentStyles from '../recordComments/entrypoints/styles/comment.module.css';
import {
Expand Down Expand Up @@ -313,7 +314,14 @@ export type AgentSurfaceProps = {
onRejectUnsafeAction?: (approval: UnsafeApprovalViewModel) => void;
onRepairUnsafeAction?: (approval: UnsafeApprovalViewModel) => void;
onRetryFailedTurn?: (failureId: string) => void | Promise<void>;
onCopyFailureDiagnostics?: (failureId: string) => Promise<void>;
/**
* Copies a failure's diagnostics. Without `redact`, it may return `review` with values that look
* like secrets instead of copying; calling again with the values to redact (possibly none) copies.
*/
onCopyFailureDiagnostics?: (
failureId: string,
redact?: readonly string[],
) => Promise<CopyDiagnosticsResult | undefined>;
autoApproveEnabled?: boolean;
autoApproveChanging?: boolean;
autoApproveError?: string;
Expand Down Expand Up @@ -695,6 +703,10 @@ function EmptyState() {
);
}

export type CopyDiagnosticsResult =
| { status: 'copied' }
| { status: 'review'; candidates: readonly SecretCandidate[] };

function FailureActions({
failure,
onRetry,
Expand All @@ -708,6 +720,9 @@ function FailureActions({
const [copyState, setCopyState] = useState<
'idle' | 'copying' | 'copied' | 'error'
>('idle');
// Values that look like secrets, awaiting the user's choice; all start selected.
const [review, setReview] = useState<readonly SecretCandidate[]>();
const [selected, setSelected] = useState<ReadonlySet<string>>(new Set());
const copiedTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);

useEffect(
Expand Down Expand Up @@ -735,7 +750,7 @@ function FailureActions({
}
};

const copyDiagnostics = async () => {
const copyDiagnostics = async (redact?: readonly string[]) => {
if (retryPending || copyState === 'copying' || !onCopyDiagnostics) {
return;
}
Expand All @@ -748,7 +763,16 @@ function FailureActions({
setCopyState('copying');

try {
await onCopyDiagnostics(failure.id);
const result = await onCopyDiagnostics(failure.id, redact);
if (result?.status === 'review') {
setReview(result.candidates);
setSelected(
new Set(result.candidates.map((candidate) => candidate.value)),
);
setCopyState('idle');
return;
}
setReview(undefined);
setCopyState('copied');
copiedTimerRef.current = setTimeout(() => {
copiedTimerRef.current = undefined;
Expand Down Expand Up @@ -797,6 +821,66 @@ function FailureActions({
Couldn’t copy diagnostics
</p>
)}
{review && (
<fieldset className={styles.diagnosticsReview}>
<legend>
{review.length === 1
? 'This value in the diagnostics looks like a secret.'
: `${review.length} values in the diagnostics look like secrets.`}
</legend>
<p className={styles.diagnosticsReviewHint}>
Ticked values are replaced with [redacted] in the copy.
</p>
{review.map((candidate) => (
<label
className={styles.diagnosticsReviewItem}
key={candidate.value}
>
<input
checked={selected.has(candidate.value)}
onChange={(event) => {
const next = new Set(selected);
if (event.target.checked) next.add(candidate.value);
else next.delete(candidate.value);
setSelected(next);
}}
type="checkbox"
/>
<span className={styles.diagnosticsReviewAction}>Redact</span>
<code>{candidate.preview}</code>
<span>
in {candidate.path || 'diagnostics'}
{candidate.occurrences > 1
? ` (${candidate.occurrences}×)`
: ''}
</span>
</label>
))}
<div className={styles.failureActions}>
<button
className={`${styles.failureAction} ${styles.failureActionPrimary}`}
onClick={() => {
void copyDiagnostics([...selected]);
}}
type="button"
>
<CopyIcon />
<span>
{selected.size > 0
? `Copy with ${selected.size} redacted`
: 'Copy without redacting'}
</span>
</button>
<button
className={styles.failureAction}
onClick={() => setReview(undefined)}
type="button"
>
Cancel
</button>
</div>
</fieldset>
)}
</>
);
}
Expand Down
Loading