Files
tutti/packages/agent/gui/agent-message-center/workspaceAgentMessageCenterModel.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

694 lines
21 KiB
TypeScript

import {
type AgentActivityMessage,
type AgentActivityNeedsAttentionItem,
type AgentActivitySession,
type WorkspaceAgentConsumerSession
} from "@tutti-os/agent-activity-core";
import type { AgentConversationPromptVM } from "../shared/agentConversation/contracts/agentConversationVM";
import { normalizeAskUserQuestions } from "../shared/agentConversation/askUserQuestions";
import { extractAgentMcpToolTarget } from "../shared/agentMcpToolTarget";
import { normalizeAgentApprovalPurpose } from "../shared/agentConversation/agentApprovalPurpose";
import type { WorkspaceAgentActivityStatus } from "../shared/workspaceAgentActivityListViewModel";
import { resolveWorkspaceAgentSessionSortTimeUnixMs } from "../shared/workspaceAgentSessionSortTime";
import {
buildWorkspaceAgentMessageCenterDigest,
resolveWorkspaceAgentMessageCenterDigestAgentMessageSummary,
type WorkspaceAgentMessageCenterDigest,
type WorkspaceAgentMessageCenterDigestAgentSummary
} from "./workspaceAgentMessageCenterDigest";
import {
extractExitPlanKeepPlanningOptionId,
extractExitPlanModeOptions,
isExitPlanSwitchModeInput
} from "../shared/agentConversation/exitPlanOptions";
export {
buildWorkspaceAgentMessageCenterModelFromItems,
isCompletedMessageCenterItem,
isInteractiveMessageCenterItem,
isWaitingMessageCenterItem,
selectMessageCenterAttentionDeckItems
} from "./workspaceAgentMessageCenterCollection";
export interface WorkspaceAgentMessageCenterModel {
waitingCount: number;
items: WorkspaceAgentMessageCenterItem[];
counts: WorkspaceAgentMessageCenterCounts;
}
export interface WorkspaceAgentMessageCenterCounts {
all: number;
working: number;
waiting: number;
completed: number;
failed: number;
}
export interface WorkspaceAgentMessageCenterItem {
id: string;
agentSessionId: string;
agentTargetId?: string | null;
agentName?: string | null;
agentAvatarUrl?: string | null;
provider: string;
userId: string | null;
title: string;
imported?: boolean;
identity: WorkspaceAgentMessageCenterIdentity | null;
cwd: string;
status: WorkspaceAgentActivityStatus;
digest: WorkspaceAgentMessageCenterDigest;
lastAgentMessageSummary: string;
lastAgentMessageAtUnixMs: number | null;
pendingInteractionTarget: WorkspaceAgentMessageCenterInteractionTarget | null;
pendingPrompt: AgentConversationPromptVM | null;
needsAttentionKind: AgentActivityNeedsAttentionItem["kind"] | null;
needsAttentionSummary: string | null;
latestTurnOutcome?: WorkspaceAgentMessageCenterTurnOutcome | null;
sortTimeUnixMs: number;
}
export interface WorkspaceAgentMessageCenterInteractionTarget {
agentSessionId: string;
requestId: string;
turnId: string;
}
export interface WorkspaceAgentMessageCenterTurnOutcome {
notificationKey: string;
status: "completed" | "failed";
turnId: string | null;
}
export interface BuildWorkspaceAgentMessageCenterOptions {
agentPresentations?: readonly WorkspaceAgentMessageCenterAgentPresentation[];
avoidGroupingEdits?: boolean;
identityBySessionId?: Record<string, WorkspaceAgentMessageCenterIdentity>;
/**
* Canonical Agent Session ids to keep in the model even when
* `session.visible === false`.
* Ambient surfaces (the global Message Center) never list hidden sessions;
* a surface whose subject IS a specific session (e.g. the Issue task card
* over a hidden delegate run) opts that exact session in so its pending
* prompts stay renderable and answerable.
*/
includeHiddenSessionIds?: readonly string[];
itemCutoffUnixMs?: number | null;
promptFallbackLabels?: WorkspaceAgentMessageCenterPromptFallbackLabels;
workspaceRoot?: string | null;
}
export interface WorkspaceAgentMessageCenterAgentPresentation {
agentTargetId: string;
iconUrl?: string | null;
name: string;
}
export interface WorkspaceAgentMessageCenterIdentity {
userName: string;
userAvatarUrl?: string;
agentName: string;
agentAvatarUrl?: string;
}
export interface WorkspaceAgentMessageCenterPromptFallbackLabels {
constraintHeader: string;
inputHeader: string;
question: string;
title: string;
}
export interface BuildWorkspaceAgentMessageCenterItemInput {
session: WorkspaceAgentMessageCenterSession;
latestTurn?: WorkspaceAgentConsumerSession["latestTurn"] | null;
messages: readonly AgentActivityMessage[];
status: WorkspaceAgentActivityStatus;
needsAttention: AgentActivityNeedsAttentionItem | null;
pendingInteractionTarget: WorkspaceAgentMessageCenterInteractionTarget | null;
pendingPrompt: AgentConversationPromptVM | null;
latestTurnOutcome: WorkspaceAgentMessageCenterTurnOutcome | null;
options?: BuildWorkspaceAgentMessageCenterOptions;
}
type WorkspaceAgentMessageCenterSession =
| AgentActivitySession
| WorkspaceAgentConsumerSession["session"];
export function buildWorkspaceAgentMessageCenterItem({
session,
latestTurn,
messages,
status,
needsAttention,
pendingInteractionTarget,
pendingPrompt,
latestTurnOutcome,
options = {}
}: BuildWorkspaceAgentMessageCenterItemInput): WorkspaceAgentMessageCenterItem {
const messageAnalysis = analyzeMessageCenterSessionMessages(
session.agentSessionId,
messages
);
const lastAgentMessage = messageAnalysis.latestAgentMessage;
const title = session.title.trim();
const digest = buildWorkspaceAgentMessageCenterDigest({
fallbackTitle: title,
latestAgentMessage: messageAnalysis.latestDigestAgentMessage,
needsAttention,
pendingPrompt,
status
});
const agentPresentation = resolveMessageCenterAgentPresentation(
session.agentTargetId,
options.agentPresentations
);
return {
id: `message-center-${session.agentSessionId}`,
agentSessionId: session.agentSessionId,
agentTargetId: session.agentTargetId?.trim() || null,
agentName: agentPresentation?.name ?? null,
agentAvatarUrl: agentPresentation?.iconUrl ?? null,
provider: session.provider,
userId: session.userId?.trim() || null,
title,
...(isImportedMessageCenterSession(session) ? { imported: true } : {}),
identity: resolveMessageCenterIdentity(
session.agentSessionId,
options.identityBySessionId
),
cwd: session.cwd,
status,
digest,
lastAgentMessageSummary:
lastAgentMessage?.summary ?? needsAttention?.summary ?? title,
lastAgentMessageAtUnixMs: lastAgentMessage?.occurredAtUnixMs ?? null,
pendingInteractionTarget,
pendingPrompt,
needsAttentionKind: needsAttention?.kind ?? null,
needsAttentionSummary: needsAttention?.summary ?? null,
latestTurnOutcome,
sortTimeUnixMs: resolveWorkspaceAgentSessionSortTimeUnixMs({
...session,
latestTurn
})
};
}
function resolveMessageCenterAgentPresentation(
agentTargetId: string | null | undefined,
presentations:
| readonly WorkspaceAgentMessageCenterAgentPresentation[]
| undefined
): { iconUrl: string | null; name: string } | null {
const targetId = agentTargetId?.trim() ?? "";
if (!targetId) {
return null;
}
const presentation = presentations?.find(
(candidate) => candidate.agentTargetId.trim() === targetId
);
const name = presentation?.name.trim() ?? "";
if (!presentation || !name) {
return null;
}
return {
iconUrl: presentation.iconUrl?.trim() || null,
name
};
}
function resolveMessageCenterIdentity(
agentSessionId: string,
identityBySessionId:
| Record<string, WorkspaceAgentMessageCenterIdentity>
| undefined
): WorkspaceAgentMessageCenterIdentity | null {
const identity = identityBySessionId?.[agentSessionId];
if (!identity) {
return null;
}
const userName = identity.userName.trim();
const agentName = identity.agentName.trim();
if (!userName || !agentName) {
return null;
}
const userAvatarUrl = identity.userAvatarUrl?.trim() ?? "";
const agentAvatarUrl = identity.agentAvatarUrl?.trim() ?? "";
return {
userName,
...(userAvatarUrl ? { userAvatarUrl } : {}),
agentName,
...(agentAvatarUrl ? { agentAvatarUrl } : {})
};
}
function isImportedMessageCenterSession(
session: WorkspaceAgentMessageCenterSession
): boolean {
return "imported" in session && session.imported === true;
}
interface MessageCenterSessionMessageAnalysis {
latestDigestAgentMessage: WorkspaceAgentMessageCenterDigestAgentSummary | null;
latestAgentMessage: WorkspaceAgentMessageCenterDigestAgentSummary | null;
latestTurnOutcome: WorkspaceAgentMessageCenterTurnOutcome | null;
pendingPrompt: AgentConversationPromptVM | null;
}
interface PendingPromptCandidate {
message: AgentActivityMessage;
prompt: AgentConversationPromptVM;
}
interface TurnOutcomeCandidate {
message: AgentActivityMessage;
outcome: WorkspaceAgentMessageCenterTurnOutcome;
}
function analyzeMessageCenterSessionMessages(
agentSessionId: string,
messages: readonly AgentActivityMessage[]
): MessageCenterSessionMessageAnalysis {
let latestAgentMessage: WorkspaceAgentMessageCenterDigestAgentSummary | null =
null;
let latestAgentMessageSource: AgentActivityMessage | null = null;
let latestDigestAgentMessage: WorkspaceAgentMessageCenterDigestAgentSummary | null =
null;
let latestDigestAgentMessageSource: AgentActivityMessage | null = null;
let latestPendingPrompt: PendingPromptCandidate | null = null;
let latestOutcome: TurnOutcomeCandidate | null = null;
for (const message of messages) {
// Realtime deltas are transient engine state (version 0, no durable
// sequence). Message Center summaries consume durable history so a
// streaming reply cannot remount rich preview content on every token.
if (message.version === 0 && message.sequence === undefined) {
continue;
}
if (
isAgentMessageRole(message.role) &&
!isReasoningMessageKind(message.kind)
) {
const summary = messageSummary(message);
if (summary) {
const occurredAtUnixMs = messageTimeUnixMs(message);
if (
!latestAgentMessageSource ||
compareMessagesByRecentTime(message, latestAgentMessageSource) < 0
) {
latestAgentMessage = { summary, occurredAtUnixMs };
latestAgentMessageSource = message;
}
}
const digestSummary =
resolveWorkspaceAgentMessageCenterDigestAgentMessageSummary(message);
if (digestSummary) {
const occurredAtUnixMs = messageTimeUnixMs(message);
if (
!latestDigestAgentMessageSource ||
compareMessagesByRecentTime(message, latestDigestAgentMessageSource) <
0
) {
latestDigestAgentMessage = {
summary: digestSummary,
occurredAtUnixMs
};
latestDigestAgentMessageSource = message;
}
}
const outcome = turnOutcomeFromMessage(agentSessionId, message);
if (
outcome &&
(!latestOutcome ||
compareMessagesByRecentTime(message, latestOutcome.message) < 0)
) {
latestOutcome = { message, outcome };
}
}
if (!isTerminalMessageStatus(message.status)) {
const prompt = promptFromMessage(message);
if (
prompt &&
(!latestPendingPrompt ||
compareMessagesByRecentTime(message, latestPendingPrompt.message) < 0)
) {
latestPendingPrompt = { message, prompt };
}
}
}
return {
latestDigestAgentMessage,
latestAgentMessage,
latestTurnOutcome: latestOutcome?.outcome ?? null,
pendingPrompt: latestPendingPrompt?.prompt ?? null
};
}
function promptFromMessage(
message: AgentActivityMessage
): AgentConversationPromptVM | null {
return (
exitPlanPromptFromMessage(message) ??
approvalPromptFromMessage(message) ??
askUserPromptFromMessage(message)
);
}
function approvalPromptFromMessage(
message: AgentActivityMessage
): AgentConversationPromptVM | null {
if (!isPermissionMessage(message)) {
return null;
}
const payload = recordValue(message.payload);
const rootInput = recordValue(payload.input);
const nestedInput = recordValue(recordValue(rootInput.toolCall).input);
const displayInput =
Object.keys(nestedInput).length > 0 ? nestedInput : rootInput;
if (isExitPlanMessage(message, rootInput)) {
return null;
}
const requestId =
stringValue(rootInput.requestId) ??
stringValue(payload.requestId) ??
stringValue(payload.approvalRequestId) ??
message.messageId;
const options = [
...arrayValue(rootInput.options),
...arrayValue(payload.options)
].flatMap((option) => {
const record = recordValue(option);
const id =
stringValue(record.optionId) ??
stringValue(record.id) ??
stringValue(record.kind);
if (!id) {
return [];
}
return [
{
id,
label:
stringValue(record.name) ??
stringValue(record.label) ??
stringValue(record.title) ??
id,
kind: stringValue(record.kind) ?? id,
...(stringValue(record.description)
? { description: stringValue(record.description) as string }
: {})
}
];
});
if (options.length === 0) {
return null;
}
const mcpTarget = extractAgentMcpToolTarget({ payload });
const approvalPurpose = normalizeAgentApprovalPurpose(
payload.approvalPurpose
);
return {
kind: "approval",
id: `approval:${requestId}`,
turnId: message.turnId ?? "turn:unknown",
requestId,
callId: stringValue(payload.callId) ?? message.messageId,
...(approvalPurpose ? { approvalPurpose } : {}),
title: firstNonEmptyString(
mcpTarget?.displayName ?? null,
stringValue(payload.summary),
stringValue(payload.title),
stringValue(displayInput.title),
stringValue(displayInput.command),
messageSummary(message),
message.kind
),
toolName:
stringValue(payload.toolName) ??
stringValue(payload.name) ??
stringValue(payload.tool),
status: message.status ?? stringValue(payload.status),
input: Object.keys(displayInput).length > 0 ? displayInput : payload,
options,
output: null,
occurredAtUnixMs: messageTimeUnixMs(message) || null
};
}
function askUserPromptFromMessage(
message: AgentActivityMessage
): AgentConversationPromptVM | null {
if (!isQuestionMessage(message)) {
return null;
}
const payload = recordValue(message.payload);
const input = recordValue(payload.input);
const questions = normalizeAskUserQuestions(input.questions);
if (questions.length === 0) {
return null;
}
return {
kind: "ask-user",
requestId:
stringValue(input.requestId) ??
stringValue(payload.requestId) ??
stringValue(payload.interactiveRequestId) ??
message.messageId,
title:
stringValue(payload.title) ??
stringValue(payload.summary) ??
messageSummary(message),
questions
};
}
function exitPlanPromptFromMessage(
message: AgentActivityMessage
): AgentConversationPromptVM | null {
const payload = recordValue(message.payload);
const input = recordValue(payload.input);
if (!isExitPlanMessage(message, input)) {
return null;
}
return {
kind: "exit-plan",
requestId:
stringValue(input.requestId) ??
stringValue(payload.requestId) ??
message.messageId,
title:
stringValue(input.title) ??
stringValue(recordValue(input.toolCall).title) ??
stringValue(payload.title) ??
stringValue(payload.summary) ??
messageSummary(message),
options: extractExitPlanModeOptions(input, payload),
...keepPlanningOption(extractExitPlanKeepPlanningOptionId(input, payload))
};
}
function keepPlanningOption(optionId: string | null): {
keepPlanningOptionId?: string;
} {
return optionId ? { keepPlanningOptionId: optionId } : {};
}
function isExitPlanMessage(
message: AgentActivityMessage,
input: Record<string, unknown> = recordValue(
recordValue(message.payload).input
)
): boolean {
// Some shapes only flag exit-plan via metadata ("exitplanmode"); the canonical
// Claude shape is a `switch_mode` approval carrying a `plan` option.
return (
includesAny(normalizedMetadataValues(message), ["exitplanmode"]) ||
isExitPlanSwitchModeInput(input)
);
}
function turnOutcomeFromMessage(
agentSessionId: string,
message: AgentActivityMessage
): WorkspaceAgentMessageCenterTurnOutcome | null {
const status = outcomeStatusFromMessage(message);
if (!status) {
return null;
}
const turnId = message.turnId?.trim() || null;
const messageId = message.messageId.trim();
const notificationSubject = turnId
? `turn:${turnId}`
: `message:${messageId}`;
return {
notificationKey: `${agentSessionId}:${notificationSubject}:${status}`,
status,
turnId
};
}
function outcomeStatusFromMessage(
message: AgentActivityMessage
): WorkspaceAgentMessageCenterTurnOutcome["status"] | null {
if (!isAgentMessageRole(message.role)) {
return null;
}
const payload = recordValue(message.payload);
const status = (message.status ?? stringValue(payload.status) ?? "")
.trim()
.toLowerCase();
switch (status) {
case "completed":
case "done":
case "success":
case "succeeded":
return "completed";
case "error":
case "failed":
return "failed";
default:
return null;
}
}
function isAgentMessageRole(role: string): boolean {
const normalized = role.trim().toLowerCase();
return normalized === "assistant" || normalized === "agent";
}
/**
* Reasoning/thinking messages are stored with an assistant-like `role` but a
* distinct `kind: "reasoning"` (see workspaceAgentMessageProjection.ts, which
* routes `kind === "reasoning"` to a separate "assistant_thinking" timeline
* item instead of a normal reply). The message center must not treat these as
* a normal agent reply when picking the latest message to preview, otherwise
* raw thinking content (occasionally still carrying literal tag markup) can
* surface verbatim in the message-center list instead of the actual reply.
*/
function isReasoningMessageKind(kind: string): boolean {
return kind.trim().toLowerCase() === "reasoning";
}
function isTerminalMessageStatus(status: string | null | undefined): boolean {
switch (status?.trim().toLowerCase()) {
case "answered":
case "canceled":
case "cancelled":
case "completed":
case "failed":
case "rejected":
case "resolved":
return true;
default:
return false;
}
}
function isPermissionMessage(message: AgentActivityMessage): boolean {
return includesAny(normalizedMetadataValues(message), [
"permission",
"approval"
]);
}
function isQuestionMessage(message: AgentActivityMessage): boolean {
return includesAny(normalizedMetadataValues(message), [
"ask_user",
"ask-user",
"askuserquestion",
"question"
]);
}
function normalizedMetadataValues(message: AgentActivityMessage): string {
const payload = recordValue(message.payload);
const input = recordValue(payload.input);
return [
message.kind,
message.status ?? "",
stringValue(payload.type) ?? "",
stringValue(payload.action) ?? "",
stringValue(payload.requestType) ?? "",
stringValue(payload.callType) ?? "",
stringValue(payload.toolName) ?? "",
stringValue(payload.name) ?? "",
stringValue(payload.status) ?? "",
stringValue(input.type) ?? "",
stringValue(input.action) ?? "",
stringValue(input.requestType) ?? "",
stringValue(input.callType) ?? "",
stringValue(input.toolName) ?? "",
stringValue(input.name) ?? "",
stringValue(input.status) ?? ""
]
.join(" ")
.replace(/[_\s-]+/g, "")
.toLowerCase();
}
function compareMessagesByRecentTime(
left: AgentActivityMessage,
right: AgentActivityMessage
): number {
return (
messageTimeUnixMs(right) - messageTimeUnixMs(left) ||
right.version - left.version ||
right.messageId.localeCompare(left.messageId)
);
}
function includesAny(value: string, needles: readonly string[]): boolean {
return needles.some((needle) =>
value.includes(needle.replace(/[_\s-]+/g, "").toLowerCase())
);
}
function messageSummary(message: AgentActivityMessage): string {
return firstNonEmptyString(
stringValue(message.payload.summary),
stringValue(message.payload.displayPrompt),
stringValue(message.payload.text),
stringValue(message.payload.content),
stringValue(message.payload.message),
stringValue(message.payload.body),
stringValue(message.payload.title)
);
}
function messageTimeUnixMs(message: AgentActivityMessage): number {
return (
positiveNumber(message.occurredAtUnixMs) ??
positiveNumber(message.completedAtUnixMs) ??
positiveNumber(message.startedAtUnixMs) ??
positiveNumber(message.version) ??
0
);
}
function firstNonEmptyString(...values: Array<string | null>): string {
return values.find((value) => value !== null && value.length > 0) ?? "";
}
function stringValue(value: unknown): string | null {
return typeof value === "string" && value.trim() ? value.trim() : null;
}
function recordValue(value: unknown): Record<string, unknown> {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: {};
}
function arrayValue(value: unknown): unknown[] {
return Array.isArray(value) ? value : [];
}
function positiveNumber(value: unknown): number | null {
return typeof value === "number" && Number.isFinite(value) && value > 0
? value
: null;
}