Skip to content

Commit e3e15f2

Browse files
authored
feat(assistant): surface confirmed tool results via a host renderer (fix lost API key) (#179)
* feat(assistant): surface confirmed tool results via a host renderer, fix lost API key A confirmed create_api_key returns its one-time secret in output.key, but the client dropped it: describeOutcome read only the prefix and told the user to "copy it from the API Keys page" — impossible, since a key's secret is shown only once at creation. Retain a confirmed tool's output on the resolving status message and render it prominently via a new host `renderConfirmedResult` slot (threaded through AssistantDock/Panel/transcript, mirroring toolRenderers), so a host can show a one-time reveal card. Correct the create_api_key status text. The secret lives only in the in-memory transcript (never cached, never sent back to the model), so it is shown once and gone on reload — matching the dedicated create-key page. Unlike toolRenderers' collapsed detail, the result card is expanded so a one-time secret is visible without a click. * fix(assistant): don't wrap a null confirmed-result in a margin box renderConfirmedResult returns null for a tool the host doesn't render a card for; the transcript still wrapped that null in `<div class="mt-3">`, leaving an empty spacer under every other confirmed tool's status line. Compute the node first and only wrap when it is non-null.
1 parent 12a40a4 commit e3e15f2

9 files changed

Lines changed: 290 additions & 7 deletions

src/assistant/AssistantDock.tsx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import { useAssistantLauncher } from "./launcher";
2323
import { ResizeHandle } from "./ResizeHandle";
2424
import type {
2525
AssistantTranscriptView,
26+
ConfirmedResult,
2627
ConnectionRequirement,
2728
ConnectRequirementResult,
2829
} from "./types";
@@ -52,6 +53,11 @@ export interface AssistantDockProps {
5253
renderMarkdown?: (content: string) => ReactNode;
5354
/** Per-tool custom detail renderers for expanded tool cards in the transcript. */
5455
toolRenderers?: ToolDetailRenderers;
56+
/** Render a prominent card for a CONFIRMED tool's result (e.g. a one-time
57+
* API-key reveal for `create_api_key`), shown inline after the action's status
58+
* line. The result's `output` may carry a one-time secret — see
59+
* {@link ConfirmedResult} for the never-persist/never-to-model contract. */
60+
renderConfirmedResult?: (result: ConfirmedResult) => ReactNode;
5561
/** Swap the conversation rendering for a host-supplied renderer (see
5662
* {@link AssistantPanelProps.renderTranscript}); the dock chrome, composer,
5763
* transport, and proposal flow stay owned by the panel. */
@@ -79,6 +85,7 @@ export function AssistantDock({
7985
onConnectRequirement,
8086
renderMarkdown,
8187
toolRenderers,
88+
renderConfirmedResult,
8289
renderTranscript,
8390
}: AssistantDockProps) {
8491
const { open, openAssistant, closeAssistant } = useAssistantLauncher();
@@ -199,6 +206,7 @@ export function AssistantDock({
199206
renderGraph={renderGraph}
200207
renderMarkdown={renderMarkdown}
201208
toolRenderers={toolRenderers}
209+
renderConfirmedResult={renderConfirmedResult}
202210
renderTranscript={renderTranscript}
203211
/>
204212
{isDesktop && (

src/assistant/AssistantPanel.tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ import type { AssistantModels } from "./client";
1919
import { isLowBalance, presentError } from "./presentation";
2020
import { ProposalCard } from "./ProposalCard";
2121
import { AssistantTranscript, assistantIsThinking } from "./transcript";
22-
import type { AssistantTranscriptView } from "./types";
22+
import type { AssistantTranscriptView, ConfirmedResult } from "./types";
2323
import type { AssistantChat } from "./useAssistantChat";
2424
import { useAssistantModels } from "./useAssistantModels";
2525
import { useAssistantThreads } from "./useAssistantThreads";
@@ -44,6 +44,10 @@ export interface AssistantPanelProps {
4444
renderMarkdown?: (content: string) => ReactNode;
4545
/** Per-tool custom detail renderers for expanded tool cards in the transcript. */
4646
toolRenderers?: ToolDetailRenderers;
47+
/** Render a prominent card for a CONFIRMED tool's result (e.g. a one-time
48+
* API-key reveal for `create_api_key`), shown inline after the action's status
49+
* line. Ignored when a host swaps the whole transcript via `renderTranscript`. */
50+
renderConfirmedResult?: (result: ConfirmedResult) => ReactNode;
4751
/** Swap ONLY the conversation rendering for a host-supplied renderer (e.g. a
4852
* different chat-message component), while the panel keeps owning the header,
4953
* composer, model picker, history, transport, and proposal orchestration.
@@ -149,6 +153,7 @@ export function AssistantPanel({
149153
renderGraph,
150154
renderMarkdown,
151155
toolRenderers,
156+
renderConfirmedResult,
152157
renderTranscript,
153158
}: AssistantPanelProps) {
154159
const models = useAssistantModels();
@@ -480,6 +485,7 @@ export function AssistantPanel({
480485
view={transcriptView}
481486
renderMarkdown={renderMarkdown}
482487
toolRenderers={toolRenderers}
488+
renderConfirmedResult={renderConfirmedResult}
483489
emptyState={
484490
<p className="px-4 py-8 text-center text-muted-foreground text-sm">
485491
{EMPTY_STATE}

src/assistant/presentation.test.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,20 @@ describe("describeOutcome", () => {
175175
it("has a sensible default for unknown tools", () => {
176176
expect(describeOutcome("mystery", {})).toBe("Action completed.");
177177
});
178+
179+
it("records a created API key by prefix without pointing at the (impossible) keys page", () => {
180+
// The secret is shown ONCE at creation and is unreadable from the API Keys
181+
// page afterwards, so the status line must not tell the user to copy it from
182+
// there — the reveal card carries the secret instead.
183+
const text = describeOutcome("create_api_key", {
184+
prefix: "sk-tan-abc",
185+
key: "sk-tan-abc-THE-SECRET",
186+
});
187+
expect(text).toBe("Created API key (sk-tan-abc…).");
188+
expect(text).not.toContain("API Keys page");
189+
// The one-time secret must never leak into the transcript status line.
190+
expect(text).not.toContain("THE-SECRET");
191+
});
178192
});
179193

180194
describe("describeFailure", () => {

src/assistant/presentation.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -301,9 +301,11 @@ export function describeOutcome(name: string, output: unknown): string {
301301
return wf.enabled ? "Workflow enabled." : "Workflow disabled.";
302302
}
303303
case "create_api_key":
304-
return o.prefix
305-
? `Created API key (${str(o.prefix)}…). Copy it from the API Keys page.`
306-
: "API key created.";
304+
// No "copy it from the API Keys page" instruction: a key's secret is shown
305+
// ONLY once, at creation, and can't be retrieved from that page later. The
306+
// secret rides `output.key` on the confirmed result and is surfaced by the
307+
// host's reveal card (see ConfirmedResult); this line is just the record.
308+
return o.prefix ? `Created API key (${str(o.prefix)}…).` : "API key created.";
307309
case "revoke_api_key":
308310
return "API key revoked.";
309311
case "invoke_integration":

src/assistant/transcript.test.tsx

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
// @vitest-environment jsdom
2+
import { render, screen } from "@testing-library/react";
3+
import { describe, expect, it } from "vitest";
4+
import { adaptTranscript, AssistantTranscript } from "./transcript";
5+
import type {
6+
AssistantTranscriptView,
7+
ChatMessage,
8+
ConfirmedResult,
9+
} from "./types";
10+
11+
/** A transcript view over a fixed message list — the fields a non-streaming,
12+
* proposal-free transcript needs; `renderProposal` is never reached here. */
13+
function viewOf(messages: ChatMessage[]): AssistantTranscriptView {
14+
return {
15+
messages,
16+
reasoning: null,
17+
streamingId: null,
18+
model: null,
19+
isStreaming: false,
20+
isThinking: false,
21+
pendingProposals: [],
22+
usage: null,
23+
renderProposal: () => null,
24+
};
25+
}
26+
27+
describe("adaptTranscript — confirmed results", () => {
28+
it("surfaces a status message's retained result, keyed by that message's id", () => {
29+
const result: ConfirmedResult = {
30+
name: "create_api_key",
31+
output: { key: "sk-tan-SECRET", prefix: "sk-tan" },
32+
args: { name: "ci" },
33+
};
34+
const { confirmedResults } = adaptTranscript(
35+
viewOf([
36+
{ id: "u1", role: "user", text: "make a key" },
37+
{ id: "s1", role: "status", text: "Created API key (sk-tan…).", result },
38+
]),
39+
);
40+
expect(confirmedResults.get("s1")).toBe(result);
41+
});
42+
43+
it("carries nothing for a plain status message (no result)", () => {
44+
const { confirmedResults } = adaptTranscript(
45+
viewOf([{ id: "s1", role: "status", text: "Action cancelled." }]),
46+
);
47+
expect(confirmedResults.size).toBe(0);
48+
});
49+
});
50+
51+
describe("AssistantTranscript — confirmed-result card", () => {
52+
it("renders the host card for a confirmed result while the secret stays out of the status text", () => {
53+
const result: ConfirmedResult = {
54+
name: "create_api_key",
55+
output: { key: "sk-tan-SECRET", prefix: "sk-tan" },
56+
args: { name: "ci" },
57+
};
58+
render(
59+
<AssistantTranscript
60+
view={viewOf([
61+
{ id: "u1", role: "user", text: "make a key" },
62+
{
63+
id: "s1",
64+
role: "status",
65+
text: "Created API key (sk-tan…).",
66+
result,
67+
},
68+
])}
69+
renderConfirmedResult={(r) => (
70+
<div data-testid="reveal">{String((r.output as { key: string }).key)}</div>
71+
)}
72+
/>,
73+
);
74+
// The card renders the secret (a host reveal card would mask it; here we just
75+
// prove the output reaches the renderer)…
76+
expect(screen.getByTestId("reveal").textContent).toBe("sk-tan-SECRET");
77+
// …and the status line is present and free of the secret.
78+
expect(screen.getByText("Created API key (sk-tan…).")).toBeTruthy();
79+
});
80+
81+
it("shows only the status line when no host renderer is supplied", () => {
82+
render(
83+
<AssistantTranscript
84+
view={viewOf([
85+
{
86+
id: "s1",
87+
role: "status",
88+
text: "Created API key (sk-tan…).",
89+
result: {
90+
name: "create_api_key",
91+
output: { key: "sk-tan-SECRET" },
92+
},
93+
},
94+
])}
95+
/>,
96+
);
97+
// With no `renderConfirmedResult`, the result is retained but never shown, so
98+
// the transcript degrades to just the status line — no secret anywhere.
99+
expect(screen.getByText("Created API key (sk-tan…).")).toBeTruthy();
100+
expect(screen.queryByText(/sk-tan-SECRET/)).toBeNull();
101+
});
102+
103+
it("adds no wrapper (no empty spacer) when the renderer returns null for a result", () => {
104+
// A confirmed tool the host renderer doesn't handle (returns null) must not
105+
// leave an empty margin box under its status line.
106+
const { container } = render(
107+
<AssistantTranscript
108+
view={viewOf([
109+
{
110+
id: "s1",
111+
role: "status",
112+
text: "Workflow created.",
113+
result: { name: "create_workflow", output: { ok: true } },
114+
},
115+
])}
116+
renderConfirmedResult={() => null}
117+
/>,
118+
);
119+
expect(screen.getByText("Workflow created.")).toBeTruthy();
120+
expect(container.querySelector(".mt-3")).toBeNull();
121+
});
122+
});

src/assistant/transcript.tsx

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import {
2222
import type { AssistantState } from "./reducer";
2323
import type {
2424
AssistantTranscriptView,
25+
ConfirmedResult,
2526
PendingProposal,
2627
ToolOutcome,
2728
} from "./types";
@@ -57,6 +58,10 @@ export interface AdaptedTranscript {
5758
/** The current/most-recent turn's assistant message — where the turn cost line
5859
* renders (it carries the turn's metrics), or null when there is none. */
5960
metricsHostId: string | null;
61+
/** Confirmed-tool results to render under their (system) message, keyed by that
62+
* message's id — carried from a `status` message's retained `result` so a host
63+
* card (e.g. a one-time API-key reveal) renders inline right after the action. */
64+
confirmedResults: Map<string, ConfirmedResult>;
6065
}
6166

6267
/**
@@ -87,6 +92,7 @@ type TurnMessage = ChatUiMessage & { segments: ChatMessageSegment[] };
8792
*/
8893
export function adaptTranscript(view: AssistantTranscriptView): AdaptedTranscript {
8994
const messages: ChatUiMessage[] = [];
95+
const confirmedResults = new Map<string, ConfirmedResult>();
9096
let turn: TurnMessage | null = null;
9197
// The assistant message of the CURRENT turn — the one opened since the most
9298
// recent user message — or null when the live turn has produced no assistant
@@ -144,6 +150,10 @@ export function adaptTranscript(view: AssistantTranscriptView): AdaptedTranscrip
144150
} else {
145151
// `status` — an informational system note that ends the assistant turn.
146152
messages.push({ id: msg.id, role: "system", content: msg.text });
153+
// A confirmed mutating tool that returned a renderable result attaches it
154+
// to its status message; carry it out keyed by this system message's id so
155+
// the host card renders inline right under the status line.
156+
if (msg.result) confirmedResults.set(msg.id, msg.result);
147157
turn = null;
148158
}
149159
}
@@ -198,6 +208,7 @@ export function adaptTranscript(view: AssistantTranscriptView): AdaptedTranscrip
198208
currentTurnAssistant && !isEmptyShell(currentTurnAssistant)
199209
? currentTurnAssistant.id
200210
: null,
211+
confirmedResults,
201212
};
202213
}
203214

@@ -224,6 +235,12 @@ export interface AssistantTranscriptProps {
224235
renderMarkdown?: (content: string) => ReactNode;
225236
/** Per-tool custom detail renderers for expanded tool cards. */
226237
toolRenderers?: ToolDetailRenderers;
238+
/** Render a prominent card for a CONFIRMED tool's result, inline after its
239+
* status line (e.g. a one-time API-key reveal for `create_api_key`). Return
240+
* null to fall back to just the status line. Unlike `toolRenderers` (collapsed
241+
* detail for a read-only tool chip), this is shown expanded, so a one-time
242+
* secret is visible without a click. See {@link ConfirmedResult}. */
243+
renderConfirmedResult?: (result: ConfirmedResult) => ReactNode;
227244
/** Zero-state shown for a fresh, non-streaming thread. */
228245
emptyState?: ReactNode;
229246
}
@@ -238,9 +255,10 @@ export function AssistantTranscript({
238255
view,
239256
renderMarkdown,
240257
toolRenderers,
258+
renderConfirmedResult,
241259
emptyState,
242260
}: AssistantTranscriptProps) {
243-
const { messages, proposalHostId, metricsHostId } = useMemo(
261+
const { messages, proposalHostId, metricsHostId, confirmedResults } = useMemo(
244262
() => adaptTranscript(view),
245263
[view],
246264
);
@@ -280,6 +298,19 @@ export function AssistantTranscript({
280298
))}
281299
</div>
282300
) : null;
301+
// A confirmed tool's host card, rendered inline under its status line
302+
// (e.g. the one-time API-key reveal). Only when the host supplied a
303+
// renderer AND it returns a node for this result — a renderer that
304+
// returns null (a tool it doesn't handle) must add no wrapper, or every
305+
// other confirmed tool would get an empty `mt-3` spacer under its status.
306+
const confirmed = confirmedResults.get(message.id);
307+
const confirmedNode =
308+
confirmed && renderConfirmedResult
309+
? renderConfirmedResult(confirmed)
310+
: null;
311+
const confirmedCard = confirmedNode ? (
312+
<div className="mt-3">{confirmedNode}</div>
313+
) : null;
283314
// The settled turn's at-cost figure, shown once under its assistant
284315
// bubble. Hidden while streaming and for a replayed (uncharged) turn.
285316
const cost =
@@ -291,9 +322,10 @@ export function AssistantTranscript({
291322
{formatTurnCost(view.usage.costUsd)} this turn
292323
</p>
293324
) : null;
294-
if (!proposals && !cost) return null;
325+
if (!proposals && !cost && !confirmedCard) return null;
295326
return (
296327
<>
328+
{confirmedCard}
297329
{proposals}
298330
{cost}
299331
</>

src/assistant/types.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,28 @@ export type ToolOutcome =
162162
| { ok: true; result?: unknown }
163163
| { ok: false; error?: { code: string; message: string } };
164164

165+
/**
166+
* The result of a CONFIRMED mutating tool, retained on the resolving `status`
167+
* message so a host can render it prominently (e.g. a one-time API-key reveal).
168+
*
169+
* SECURITY: `output` may carry a one-time secret (e.g. a freshly minted API
170+
* key). It lives ONLY in the in-memory transcript for the session — the message
171+
* transcript is deliberately never cached (see `persistence.ts`) and is never
172+
* sent back to the model (a turn request carries only the new user text, see
173+
* {@link ChatRequest}). So the secret is shown once and is gone on reload,
174+
* matching how a dedicated create-key page reveals a key exactly once. A host
175+
* renderer must uphold the same contract: reveal-on-demand, never persist it.
176+
*/
177+
export interface ConfirmedResult {
178+
/** The confirmed tool's name (e.g. "create_api_key"). */
179+
name: string;
180+
/** The tool's raw output. May contain a one-time secret — see the type doc. */
181+
output: unknown;
182+
/** The arguments the action was confirmed with, for a renderer that needs them
183+
* (e.g. the key's name). */
184+
args?: unknown;
185+
}
186+
165187
export interface ChatMessage {
166188
id: string;
167189
role: ChatRole;
@@ -174,6 +196,11 @@ export interface ChatMessage {
174196
args?: Record<string, unknown>;
175197
outcome?: ToolOutcome;
176198
};
199+
/** Present only on a `status` message that resolved a confirmed mutating tool
200+
* which returned a renderable result — carries that result so the transcript
201+
* can render a host-supplied card (e.g. a one-time key reveal) next to the
202+
* status line. See {@link ConfirmedResult} for the one-time-secret contract. */
203+
result?: ConfirmedResult;
177204
}
178205

179206
/** A mutating action the assistant proposed, awaiting the user's confirmation. */

0 commit comments

Comments
 (0)