1import type { OrchestrationV2TurnItem, SecretRequestAnswerInput } from "@t3tools/contracts";
2
3export type SecretRequestItem = Extract<
4 OrchestrationV2TurnItem,
5 { readonly type: "secret_request" }
6>;
7
8/** Shown under the field: the one promise the card makes about the value. */
9export const SECRET_REQUEST_PRIVACY_NOTE = "Stored securely, never shown to the agent";
10export const SECRET_REQUEST_DEFAULT_PLACEHOLDER = "Paste the secret";
11
12/** What a secret request card shows: the form while pending, otherwise a one-line outcome. */
13export type SecretRequestDisplay =
14 | { readonly kind: "pending" }
15 | { readonly kind: "pending-elsewhere"; readonly label: string }
16 | {
17 readonly kind: "answered";
18 readonly outcome: "saved" | "declined" | "ended";
19 readonly label: string;
20 };
21
22const SAVED_DISPLAY: SecretRequestDisplay = {
23 kind: "answered",
24 outcome: "saved",
25 label: "Saved securely and kept private",
26};
27const DECLINED_DISPLAY: SecretRequestDisplay = {
28 kind: "answered",
29 outcome: "declined",
30 label: "Declined",
31};
32const ENDED_DISPLAY: SecretRequestDisplay = {
33 kind: "answered",
34 outcome: "ended",
35 label: "Request ended",
36};
37const PENDING_DISPLAY: SecretRequestDisplay = { kind: "pending" };
38const PENDING_ELSEWHERE_DISPLAY: SecretRequestDisplay = {
39 kind: "pending-elsewhere",
40 label: "Waiting for an answer in the original thread",
41};
42
43/**
44 * `visibility` is the projected row's: a request inherited from another
45 * thread (a fork) can only be answered where it was asked.
46 */
47export function secretRequestDisplay(
48 item: Pick<SecretRequestItem, "secretStatus">,
49 visibility: "local" | "inherited" | "synthetic",
50): SecretRequestDisplay {
51 switch (item.secretStatus) {
52 case "pending":
53 return visibility === "local" ? PENDING_DISPLAY : PENDING_ELSEWHERE_DISPLAY;
54 case "saved":
55 return SAVED_DISPLAY;
56 case "declined":
57 return DECLINED_DISPLAY;
58 case "cancelled":
59 return ENDED_DISPLAY;
60 }
61}
62
63/**
64 * Builds the RPC payload for an answer. A save with a blank value returns null,
65 * since the server rejects it; callers keep Save disabled instead.
66 */
67export function secretRequestAnswerInput(
68 item: Pick<SecretRequestItem, "id" | "threadId">,
69 answer: { readonly type: "save"; readonly secret: string } | { readonly type: "decline" },
70): SecretRequestAnswerInput | null {
71 if (answer.type === "decline") {
72 return { threadId: item.threadId, turnItemId: item.id, answer: { type: "decline" } };
73 }
74 const secret = answer.secret.trim();
75 if (secret.length === 0) return null;
76 return { threadId: item.threadId, turnItemId: item.id, answer: { type: "save", secret } };
77}
78
79/** Failures whose message is written for the user and never echoes the request payload. */
80const USER_FACING_FAILURE_TAGS = new Set(["SecretRequestError", "EnvironmentAuthorizationError"]);
81
82/**
83 * Inline error copy for a failed answer. Only known server errors pass their
84 * message through: anything else (transport or encoding failures) gets the
85 * generic copy, so the typed value can never surface in the UI.
86 */
87export function secretRequestFailureMessage(failure: unknown): string {
88 if (
89 typeof failure === "object" &&
90 failure !== null &&
91 "_tag" in failure &&
92 typeof failure._tag === "string" &&
93 USER_FACING_FAILURE_TAGS.has(failure._tag) &&
94 "message" in failure &&
95 typeof failure.message === "string" &&
96 failure.message.trim().length > 0
97 ) {
98 return failure.message;
99 }
100 return "Could not answer the request. Try again.";
101}