Files
tutti/packages/agent/gui/shared/workspaceAgentTimelineMessageHelpers.ts
agent-radar 2ed3415fb7
Publish Claude Code Binaries / Stage and publish binaries (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-arm64 (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build linux-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build windows-amd64 (push) Canceled after 0s
Sync Tutti Agent Skills / sync (push) Canceled after 0s
Windows Agent Adapters / Test Windows agent adapters (push) Canceled after 0s
Windows Daemon Adapters / Test Windows daemon adapters (push) Canceled after 0s
Publish Tutti App Runtime / Publish runtime catalog (push) Canceled after 0s
源码快照 @ 2026-09-01 (source-only, 剔除非源码素材)
2026-09-01 20:17:27 +08:00

217 lines
6.0 KiB
TypeScript

import type { ToolCallStatusKind } from "./workspaceAgentToolCallDisplay";
import { isWorkspaceAgentSyntheticControlMessage } from "./workspaceAgentSyntheticMessages";
import type { WorkspaceAgentActivityTimelineItem } from "./workspaceAgentTimelineTypes";
export function messageRole(
item: WorkspaceAgentActivityTimelineItem
): "user" | "agent" | "thinking" | null {
const explicitRole = item.role?.trim().toLowerCase();
if (explicitRole === "user") {
return "user";
}
if (explicitRole === "assistant_thinking") {
return "thinking";
}
if (explicitRole === "assistant" || explicitRole === "agent") {
return "agent";
}
const itemType = item.itemType.trim().toLowerCase();
if (itemType === "message.user") {
return "user";
}
if (itemType === "message.agent" || itemType === "message.assistant") {
return "agent";
}
if (itemType === "message.assistant_thinking") {
return "thinking";
}
return null;
}
export function messageBody(item: WorkspaceAgentActivityTimelineItem): string {
const payloadContent = item.payload?.content;
if (typeof payloadContent === "string" && payloadContent.trim()) {
return isWorkspaceAgentSyntheticControlMessage(payloadContent)
? ""
: payloadContent;
}
const content = item.content;
if (content?.trim()) {
return isWorkspaceAgentSyntheticControlMessage(content) ? "" : content;
}
const payloadText = item.payload?.text;
if (typeof payloadText !== "string") {
return "";
}
return payloadText.trim() &&
!isWorkspaceAgentSyntheticControlMessage(payloadText)
? payloadText
: "";
}
export function thinkingStatusKind(
item: WorkspaceAgentActivityTimelineItem
): ToolCallStatusKind | null {
const status = firstPresentString(
item.status,
stringRecordValue(item.payload, "status"),
stringRecordValue(item.payload, "streamState"),
stringRecordValue(item.payload, "messageStreamState")
);
return messageStatusKind(status);
}
export function messageStatusKind(
status: string | null
): ToolCallStatusKind | null {
switch (normalizeStatusToken(status)) {
case "active":
case "running":
case "streaming":
case "working":
case "inprogress":
case "in_progress":
return "working";
case "completed":
case "complete":
case "done":
case "success":
case "succeeded":
return "completed";
case "failed":
case "error":
return "failed";
case "canceled":
return "canceled";
case "pending":
case "waiting":
return "waiting";
default:
return null;
}
}
export function isPlaceholderThinkingBody(body: string): boolean {
const normalized = body.trim();
return normalized === "..." || normalized === "…";
}
// Codex review reasoning summaries lead with a bold section title, e.g.
// "**Considering workspace registration order**\n\nI'm looking at...". Hide
// the title in /review process prose; keep the body paragraph.
const reviewProcessSummaryTitlePattern = /^\*\*(.+?)\*\*(?:\r?\n\s*)?/;
export function stripReviewProcessSummaryTitle(body: string): string {
const match = body.match(reviewProcessSummaryTitlePattern);
if (!match) {
return body;
}
return body.slice(match[0].length).trimStart();
}
export function normalizedMessageBody(body: string): string {
return body.trim().replace(/\s+/g, " ");
}
export function userMessageProjectionKey(
item: WorkspaceAgentActivityTimelineItem,
body: string
): string | null {
const normalizedBody = normalizedMessageBody(body);
if (normalizedBody) {
return `text:${normalizedBody}`;
}
if (!hasUserPromptContent(item.payload?.content)) {
return null;
}
const clientSubmitId = stringRecordValue(item.payload, "clientSubmitId");
return clientSubmitId
? `client-submit:${clientSubmitId}`
: `event:${item.eventId}`;
}
export function isRecentDuplicateUserMessage(
previous: WorkspaceAgentActivityTimelineItem | undefined,
current: WorkspaceAgentActivityTimelineItem
): boolean {
if (!previous) {
return false;
}
const previousTurnId = previous.turnId?.trim();
const currentTurnId = current.turnId?.trim();
if (previousTurnId && currentTurnId && previousTurnId === currentTurnId) {
return true;
}
if (previousTurnId && currentTurnId) {
return false;
}
const previousOccurredAt = previous.occurredAtUnixMs ?? 0;
const currentOccurredAt = current.occurredAtUnixMs ?? 0;
if (previousOccurredAt > 0 && currentOccurredAt > 0) {
return Math.abs(currentOccurredAt - previousOccurredAt) <= 60_000;
}
const previousSeq = previous.seq ?? 0;
const currentSeq = current.seq ?? 0;
if (previousSeq > 0 && currentSeq > 0) {
return Math.abs(currentSeq - previousSeq) <= 5;
}
return false;
}
function hasUserPromptContent(content: unknown): boolean {
if (!Array.isArray(content)) {
return false;
}
return content.some((candidate) => {
if (
!candidate ||
typeof candidate !== "object" ||
Array.isArray(candidate)
) {
return false;
}
const block = candidate as Record<string, unknown>;
if (block.type === "text") {
return typeof block.text === "string" && block.text.trim().length > 0;
}
// Keep the image block even when MIME/source metadata is incomplete. The
// renderer owns load failure presentation and must be able to show a
// retryable placeholder for an expired or malformed external URL.
return block.type === "image";
});
}
function stringRecordValue(record: unknown, key: string): string | null {
if (!record || typeof record !== "object") {
return null;
}
const value = (record as Record<string, unknown>)[key];
return typeof value === "string" && value.trim() ? value.trim() : null;
}
function firstPresentString(
...values: Array<string | null | undefined>
): string | null {
for (const value of values) {
const normalized = value?.trim();
if (normalized) {
return normalized;
}
}
return null;
}
function normalizeStatusToken(value: string | null): string {
return (value ?? "")
.trim()
.toLowerCase()
.replace(/[\s-]+/g, "_");
}