Skip to content
Draft
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
5 changes: 5 additions & 0 deletions .changeset/ask-user-mcp-ui-polish.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@truefoundry/trueforge-ui': patch
---

Polish MCP auth and Ask User pause chrome: lettered A/B/C options with Continue, outlined Connect, and screenshot-matching stack above the composer.
6 changes: 6 additions & 0 deletions .changeset/composer-pause-stack.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@truefoundry/trueforge-ui': patch
'@truefoundry/trueforge-assistant-ui-runtime': patch
---

Keep the composer interactive during running and paused turns: Cancel only when empty, Send supersedes the prior client stream without cancelSession (preserving superseded in-flight turns in the transcript), and stacked pause chrome is abandoned after a later user message. Hosts that override ComposerSendButton and still branch only on isRunning should update to use hasContent.
144 changes: 69 additions & 75 deletions packages/assistant-ui-runtime/src/collectPending.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type { McpAuthRequiredEvent } from './server/index.js';

import { ROOT_THREAD_ID } from './constants.js';
import { isMcpServerAuthInfoList, isUnknownRecord } from './messageCustomMetadata.js';
import { findCurrentPausedAssistantMessage } from './requiredActionInputs.js';
import { getToolApprovalThreadId, hasPendingToolApproval } from './toolApproval.js';
import {
getToolResponseThreadId,
Expand Down Expand Up @@ -55,97 +56,90 @@ function walkToolCallParts(

export function collectPendingApprovals(messages: readonly ThreadMessage[]): PendingApproval[] {
const pending: PendingApproval[] = [];

for (const message of messages) {
if (message.role !== 'assistant') {
continue;
}
const rootThreadId = getToolApprovalThreadId(message) ?? ROOT_THREAD_ID;
walkToolCallParts(
message.content,
(part, threadId) => {
const approval = part.approval;
if (approval == null || !hasPendingToolApproval(approval)) {
return;
}
pending.push({
approvalId: approval.id,
threadId,
toolName: part.toolName,
args: { ...part.args },
argsText: part.argsText,
});
},
rootThreadId,
);
const paused = findCurrentPausedAssistantMessage(messages);
if (paused == null) {
return pending;
}

const rootThreadId = getToolApprovalThreadId(paused) ?? ROOT_THREAD_ID;
walkToolCallParts(
paused.content,
(part, threadId) => {
const approval = part.approval;
if (approval == null || !hasPendingToolApproval(approval)) {
return;
}
pending.push({
approvalId: approval.id,
threadId,
toolName: part.toolName,
args: { ...part.args },
argsText: part.argsText,
});
},
rootThreadId,
);

return pending;
}

export function collectPendingToolResponses(messages: readonly ThreadMessage[]): PendingToolResponse[] {
const pending: PendingToolResponse[] = [];

for (const message of messages) {
if (message.role !== 'assistant') {
continue;
}
const rootThreadId = getToolResponseThreadId(message) ?? ROOT_THREAD_ID;
walkToolCallParts(
message.content,
(part, threadId) => {
if (!hasPendingToolResponse(part)) {
return;
}
const payload: AskUserQuestionInterruptPayload | undefined = isUnknownRecord(part.interrupt?.payload)
? {
...(typeof part.interrupt.payload['question'] === 'string'
? { question: part.interrupt.payload['question'] }
: {}),
...(Array.isArray(part.interrupt.payload['options']) &&
part.interrupt.payload['options'].every(option => typeof option === 'string')
? { options: part.interrupt.payload['options'] }
: {}),
}
: undefined;
pending.push({
toolCallId: part.toolCallId,
threadId,
toolName: part.toolName,
args: { ...part.args },
argsText: part.argsText,
...(payload?.question != null ? { question: payload.question } : {}),
...(payload?.options != null ? { options: payload.options } : {}),
});
},
rootThreadId,
);
const paused = findCurrentPausedAssistantMessage(messages);
if (paused == null) {
return pending;
}

const rootThreadId = getToolResponseThreadId(paused) ?? ROOT_THREAD_ID;
walkToolCallParts(
paused.content,
(part, threadId) => {
if (!hasPendingToolResponse(part)) {
return;
}
const payload: AskUserQuestionInterruptPayload | undefined = isUnknownRecord(part.interrupt?.payload)
? {
...(typeof part.interrupt.payload['question'] === 'string'
? { question: part.interrupt.payload['question'] }
: {}),
...(Array.isArray(part.interrupt.payload['options']) &&
part.interrupt.payload['options'].every(option => typeof option === 'string')
? { options: part.interrupt.payload['options'] }
: {}),
}
: undefined;
pending.push({
toolCallId: part.toolCallId,
threadId,
toolName: part.toolName,
args: { ...part.args },
argsText: part.argsText,
...(payload?.question != null ? { question: payload.question } : {}),
...(payload?.options != null ? { options: payload.options } : {}),
});
},
rootThreadId,
);

return pending;
}

export function derivePendingMcpAuth(
messages: readonly ThreadMessage[],
): { mcpServers: McpAuthRequiredEvent['mcpServers'] } | null {
for (const message of messages.toReversed()) {
if (message.role !== 'assistant') {
continue;
}
if (message.status.type !== 'requires-action') {
continue;
}
const custom = message.metadata.custom;
if (custom['pendingMcpAuth'] !== true) {
continue;
}
const servers = custom['mcpServers'];
if (!isMcpServerAuthInfoList(servers)) {
return { mcpServers: [] };
}
return { mcpServers: servers };
const paused = findCurrentPausedAssistantMessage(messages);
if (paused == null) {
return null;
}
const custom = paused.metadata.custom;
if (custom['pendingMcpAuth'] !== true) {
return null;
}
const servers = custom['mcpServers'];
if (!isMcpServerAuthInfoList(servers)) {
return { mcpServers: [] };
}
return null;
return { mcpServers: servers };
}

/**
Expand Down
92 changes: 91 additions & 1 deletion packages/assistant-ui-runtime/src/convertTurnMessages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@
applyApprovalDecisionsToContent,
collectApprovalDecisionsFromTurnInput,
collectSubsequentApprovalDecisions,
hasPendingToolApproval,
messageHasPendingApprovals,
TOOL_APPROVAL_THREAD_ID_CUSTOM_KEY,
toolApprovalMessageCustom,
Expand All @@ -58,6 +59,7 @@
applyUserToolResponsesToFold,
collectSubsequentToolResponses,
collectToolResponsesFromTurnInput,
hasPendingToolResponse,
messageHasPendingResponses,
TOOL_RESPONSE_THREAD_ID_CUSTOM_KEY,
toolResponseMessageCustom,
Expand Down Expand Up @@ -1047,6 +1049,94 @@
});
}

type ThreadAssistantMessagePart = Extract<ThreadMessage, { role: 'assistant' }>['content'][number];
type AssistantToolCallPart = Extract<ThreadAssistantMessagePart, { type: 'tool-call' }>;

function stripInteractivePendingFromContent(
content: readonly ThreadAssistantMessagePart[],
): ThreadAssistantMessagePart[] {
return content.map(part => {
if (part.type !== 'tool-call') {
return part;
}

let next: AssistantToolCallPart = part;
if (hasPendingToolResponse(part)) {
const { interrupt: _interrupt, ...rest } = part;

Check failure on line 1065 in packages/assistant-ui-runtime/src/convertTurnMessages.ts

View workflow job for this annotation

GitHub Actions / Format, Typecheck, Lint and Build

'_interrupt' is assigned a value but never used
next = rest;
}
const approval = next.approval;
if (approval != null && hasPendingToolApproval(approval)) {
next = {
...next,
approval: {
...approval,
approved: false,
reason: 'Superseded by a later message',
},
};
}
if (next.messages != null) {
const nested = next.messages.map(message => {
if (message.role !== 'assistant' || message.status.type !== 'requires-action') {
return message;
}
return stripInteractivePendingFromAssistant(message);
});
next = { ...next, messages: nested };
}
return next;
});
}

function stripInteractivePendingFromAssistant(
message: Extract<ThreadMessage, { role: 'assistant' }>,
): Extract<ThreadMessage, { role: 'assistant' }> {
const content = stripInteractivePendingFromContent(message.content);
const custom = Object.fromEntries(
Object.entries(message.metadata.custom).filter(
([key]) =>
key !== 'pendingMcpAuth' &&
key !== 'mcpServers' &&
key !== TOOL_APPROVAL_THREAD_ID_CUSTOM_KEY &&
key !== TOOL_RESPONSE_THREAD_ID_CUSTOM_KEY,
),
);
return {
...message,
content,
status: { type: 'incomplete', reason: 'cancelled' },
metadata: {
...message.metadata,
custom,
},
};
}

/** Clear interactive pause chrome on assistants that a later user message abandoned. */
function abandonSupersededPausedMessages(messages: readonly ThreadMessage[]): ThreadMessage[] {
let lastUserIndex = -1;
for (let i = messages.length - 1; i >= 0; i--) {
if (messages[i]?.role === 'user') {
lastUserIndex = i;
break;
}
}
if (lastUserIndex < 0) {
return [...messages];
}

return messages.map((message, index) => {
if (index >= lastUserIndex) {
return message;
}
if (message.role !== 'assistant' || message.status.type !== 'requires-action') {
return message;
}
return stripInteractivePendingFromAssistant(message);
});
}

function projectHistoryTurns(snapshot: SessionSnapshot, options?: ProjectSessionMessagesOptions): ThreadMessage[] {
const messages: ThreadMessage[] = [];
let lastAssistantIndex: number | undefined;
Expand Down Expand Up @@ -1200,7 +1290,7 @@
}
}

return applyRequiredActionsOverlayToMessages(messages, snapshot.requiredActions);
return abandonSupersededPausedMessages(applyRequiredActionsOverlayToMessages(messages, snapshot.requiredActions));
}

const DEFAULT_LIST_EVENTS_CONCURRENCY = 5;
Expand Down
1 change: 1 addition & 0 deletions packages/assistant-ui-runtime/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,8 @@
} from './messageCustomMetadata.js';
export {
collectRequiredActionInputs,
findCurrentPausedAssistantMessage,
findPausedAssistantMessage,

Check failure on line 53 in packages/assistant-ui-runtime/src/index.ts

View workflow job for this annotation

GitHub Actions / Format, Typecheck, Lint and Build

`findPausedAssistantMessage` is deprecated. Prefer {@link findCurrentPausedAssistantMessage}
messageHasPendingRequiredActions,
} from './requiredActionInputs.js';
export { getSession } from './sessions.js';
Expand Down
26 changes: 24 additions & 2 deletions packages/assistant-ui-runtime/src/requiredActionInputs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,14 +25,36 @@ export function isRequiredActionInput(item: TurnInputItem): item is RequiredActi
return item.type === 'user.tool_approval' || item.type === 'user.tool_response';
}

export function findPausedAssistantMessage(
/**
* Most recent assistant that is still awaiting action, ignoring a trailing
* non-paused assistant (optimistic/running bubble). Returns undefined when a
* later user message has abandoned the pause.
*/
export function findCurrentPausedAssistantMessage(
messages: readonly ThreadMessage[],
): Extract<ThreadMessage, { role: 'assistant' }> | undefined {
for (let i = messages.length - 1; i >= 0; i--) {
const candidate = messages[i];
if (candidate?.role === 'assistant' && candidate.status.type === 'requires-action') {
if (candidate == null) {
continue;
}
if (candidate.role === 'user') {
return undefined;
}
if (candidate.role !== 'assistant') {
continue;
}
if (candidate.status.type === 'requires-action') {
return candidate;
}
// Skip a trailing non-paused assistant (streaming/complete bubble).
}
return undefined;
}

/** @deprecated Prefer {@link findCurrentPausedAssistantMessage}. */
export function findPausedAssistantMessage(
messages: readonly ThreadMessage[],
): Extract<ThreadMessage, { role: 'assistant' }> | undefined {
return findCurrentPausedAssistantMessage(messages);
}
Loading
Loading