Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
212 changes: 9 additions & 203 deletions apps/mobile/src/lib/threadActivity.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import {
ApprovalRequestId,
isToolLifecycleItemType,
ProviderApprovalOption,
ProviderRequestKind,
} from "@t3tools/contracts";
derivePendingApprovals,
derivePendingUserInputs,
requestKindFromRequestType,
type PendingApproval,
type PendingUserInput,
} from "@t3tools/client-runtime/pending-requests";
import { isToolLifecycleItemType } from "@t3tools/contracts";
import type {
OrchestrationLatestTurn,
OrchestrationThread,
Expand All @@ -16,28 +18,11 @@ import { formatDuration } from "@t3tools/shared/orchestrationTiming";

import * as Arr from "effect/Array";
import * as Order from "effect/Order";
import * as Schema from "effect/Schema";

import { buildBotStepMeters, type BotStepMeterData } from "../features/threads/botStepUsage";

export interface PendingApproval {
readonly requestId: ApprovalRequestId;
readonly requestKind: ProviderRequestKind;
readonly createdAt: string;
readonly detail?: string;
readonly appName?: string;
readonly toolName?: string;
readonly options?: ReadonlyArray<ProviderApprovalOption>;
}

const isProviderRequestKind = Schema.is(ProviderRequestKind);
const isProviderApprovalOption = Schema.is(ProviderApprovalOption);

export interface PendingUserInput {
readonly requestId: ApprovalRequestId;
readonly createdAt: string;
readonly questions: ReadonlyArray<UserInputQuestion>;
}
export type { PendingApproval, PendingUserInput };
export { derivePendingApprovals, derivePendingUserInputs };

export interface PendingUserInputDraftAnswer {
readonly selectedOptionLabels?: ReadonlyArray<string>;
Expand Down Expand Up @@ -152,91 +137,6 @@ export type ThreadFeedLatestTurn = Pick<
"turnId" | "state" | "startedAt" | "completedAt"
>;

function requestKindFromRequestType(requestType: unknown): PendingApproval["requestKind"] | null {
switch (requestType) {
case "command_execution_approval":
case "exec_command_approval":
case "dynamic_tool_call":
return "command";
case "file_read_approval":
return "file-read";
case "file_change_approval":
case "apply_patch_approval":
return "file-change";
case "mcp_elicitation_approval":
return "mcp-elicitation";
default:
return null;
}
}

function isStalePendingRequestFailureDetail(detail: string | undefined): boolean {
const normalized = detail?.toLowerCase();
if (!normalized) {
return false;
}
return (
normalized.includes("stale pending approval request") ||
normalized.includes("stale pending user-input request") ||
normalized.includes("unknown pending approval request") ||
normalized.includes("unknown pending permission request") ||
normalized.includes("unknown pending user-input request")
);
}

function parseApprovalRequestId(value: unknown): ApprovalRequestId | null {
return typeof value === "string" && value.length > 0 ? ApprovalRequestId.make(value) : null;
}

function parseUserInputQuestions(
payload: Record<string, unknown> | null,
): ReadonlyArray<UserInputQuestion> | null {
const questions = payload?.questions;
if (!Array.isArray(questions)) {
return null;
}

const parsed = questions
.map<UserInputQuestion | null>((entry) => {
if (!entry || typeof entry !== "object") return null;
const question = entry as Record<string, unknown>;
if (
typeof question.id !== "string" ||
typeof question.header !== "string" ||
typeof question.question !== "string" ||
!Array.isArray(question.options)
) {
return null;
}
const options = question.options
.map<UserInputQuestion["options"][number] | null>((option) => {
if (!option || typeof option !== "object") return null;
const record = option as Record<string, unknown>;
if (typeof record.label !== "string" || typeof record.description !== "string") {
return null;
}
return {
label: record.label,
description: record.description,
};
})
.filter((option): option is UserInputQuestion["options"][number] => option !== null);
if (options.length === 0) {
return null;
}
return {
id: question.id,
header: question.header,
question: question.question,
options,
multiSelect: question.multiSelect === true,
};
})
.filter((question): question is UserInputQuestion => question !== null);

return parsed.length > 0 ? parsed : null;
}

function normalizeDraftAnswer(value: string | undefined): string | null {
if (typeof value !== "string") {
return null;
Expand Down Expand Up @@ -1384,100 +1284,6 @@ export function sortThreadActivities(
return Arr.sort(activities, activityOrder);
}

export function derivePendingApprovals(
sortedActivities: ReadonlyArray<OrchestrationThreadActivity>,
): PendingApproval[] {
const openByRequestId = new Map<ApprovalRequestId, PendingApproval>();

for (const activity of sortedActivities) {
const payload =
activity.payload && typeof activity.payload === "object"
? (activity.payload as Record<string, unknown>)
: null;
const requestId = parseApprovalRequestId(payload?.requestId);
const requestKind = isProviderRequestKind(payload?.requestKind)
? payload.requestKind
: requestKindFromRequestType(payload?.requestType);
const detail = typeof payload?.detail === "string" ? payload.detail : undefined;
const appName = typeof payload?.appName === "string" ? payload.appName : undefined;
const toolName = typeof payload?.toolName === "string" ? payload.toolName : undefined;
const options = Array.isArray(payload?.options)
? payload.options.filter(isProviderApprovalOption)
: undefined;

if (activity.kind === "approval.requested" && requestId) {
openByRequestId.set(requestId, {
requestId,
requestKind: requestKind ?? "command",
createdAt: activity.createdAt,
...(detail ? { detail } : {}),
...(appName ? { appName } : {}),
...(toolName ? { toolName } : {}),
...(options && options.length > 0 ? { options } : {}),
});
continue;
}

if (activity.kind === "approval.resolved" && requestId) {
openByRequestId.delete(requestId);
continue;
}

if (
activity.kind === "provider.approval.respond.failed" &&
requestId &&
isStalePendingRequestFailureDetail(detail)
) {
openByRequestId.delete(requestId);
}
}

return Arr.sortWith([...openByRequestId.values()], (s) => new Date(s.createdAt), Order.Date);
}

export function derivePendingUserInputs(
sortedActivities: ReadonlyArray<OrchestrationThreadActivity>,
): PendingUserInput[] {
const openByRequestId = new Map<ApprovalRequestId, PendingUserInput>();

for (const activity of sortedActivities) {
const payload =
activity.payload && typeof activity.payload === "object"
? (activity.payload as Record<string, unknown>)
: null;
const requestId = parseApprovalRequestId(payload?.requestId);
const detail = typeof payload?.detail === "string" ? payload.detail : undefined;

if (activity.kind === "user-input.requested" && requestId) {
const questions = parseUserInputQuestions(payload);
if (!questions) {
continue;
}
openByRequestId.set(requestId, {
requestId,
createdAt: activity.createdAt,
questions,
});
continue;
}

if (activity.kind === "user-input.resolved" && requestId) {
openByRequestId.delete(requestId);
continue;
}

if (
activity.kind === "provider.user-input.respond.failed" &&
requestId &&
isStalePendingRequestFailureDetail(detail)
) {
openByRequestId.delete(requestId);
}
}

return Arr.sortWith(openByRequestId.values(), (s) => new Date(s.createdAt), Order.Date);
}

export function setPendingUserInputCustomAnswer(
draft: PendingUserInputDraftAnswer | undefined,
customAnswer: string,
Expand Down
Loading
Loading