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
8 changes: 4 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
"core:unlink": "yalc remove @flamingo-stack/openframe-frontend-core"
},
"dependencies": {
"@flamingo-stack/openframe-frontend-core": "^0.0.446",
"@flamingo-stack/openframe-frontend-core": "^0.0.448",
"@hookform/resolvers": "^5.2.2",
"@monaco-editor/react": "^4.7.0",
"@tanstack/react-query": "^5.90.16",
Expand Down
218 changes: 116 additions & 102 deletions schema.graphql

Large diffs are not rendered by default.

8 changes: 8 additions & 0 deletions src/app/(app)/mingo/hooks/use-mingo-dialog-selection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -344,6 +344,9 @@ export function useMingoDialogSelection() {
createdAt: msg.createdAt,
owner: msg.owner,
messageData: msg.messageData,
// Feed the merge's per-role seq coverage so a post-reload live synthetic
// isn't dropped by an unrelated row's seq (message-loss on stop/reload).
lastChunkStreamSeq: msg.lastChunkStreamSeq,
}));

const assistantConfig = ASSISTANT_CONFIG.MINGO;
Expand All @@ -355,6 +358,11 @@ export function useMingoDialogSelection() {
onReject: handleRejectRef.current,
approvalStatuses: Object.fromEntries(Object.entries(approvalStatusesRef.current).map(([k, v]) => [k, v as any])),
batchApprovalsEnabled: featureFlags.batchApproval.enabled(),
// Must match the realtime processor (use-mingo-realtime-subscription):
// an omitted option is not guaranteed to include ADMIN, and a history
// reprocess (reopen, reconnect invalidation, pagination) would silently
// drop pending ADMIN approval cards that rendered live.
displayApprovalTypes: ['CLIENT', 'ADMIN'],
});
const allProcessedMessages = foldPendingApprovalsEnvelope(rawProcessedMessages as Message[]);

Expand Down
37 changes: 35 additions & 2 deletions src/app/(app)/mingo/hooks/use-mingo-realtime-subscription.ts
Original file line number Diff line number Diff line change
Expand Up @@ -354,8 +354,16 @@ function useDialogChunkProcessor(dialogId: string, options: UseDialogChunkProces
},

onToolExecuted: (segment: ToolExecutionSegment) => {
// Merge into an existing card first; when NOTHING matches — the
// common case for the FIRST post-MESSAGE_END EXECUTING chunk of an
// approved command, which has no prior segment to merge into —
// append the segment to the last assistant bubble instead of
// dropping it, or the whole tool run stays invisible.
const execId = segment.data.toolExecutionRequestId;
if (execId) updateToolExecutionInMessages(dialogId, execId, segment.data);
const matched = updateToolExecutionInMessages(dialogId, execId, segment.data);
if (!matched) {
appendSegmentsToLastAssistant(dialogId, [segment]);
}
},

onUserMessage: (
Expand Down Expand Up @@ -476,6 +484,16 @@ export function DialogSubscription({
// Rejects out-of-order JetStream redeliveries.
const lastAppliedStreamSeqRef = useRef<number>(-1);

// Dispatch-level redelivery gate (mirrors the tickets subscription):
// JetStream is at-least-once, so a chunk with an equal-or-older streamSeq
// has already been processed — letting it through duplicates accumulator
// text / participant rows. The streamState guard above only protects the
// query-cache write, not the processor.
const lastDispatchedStreamSeqRef = useRef<number>(-1);
useEffect(() => {
lastDispatchedStreamSeqRef.current = -1;
}, [dialogId]);

const syncStreamStateFromChunk = useCallback(
(chunk: ChunkData) => {
if (typeof chunk.streamSeq === 'number') {
Expand All @@ -500,6 +518,10 @@ export function DialogSubscription({
if (featureFlags.debugNatsChunks.enabled()) {
console.log('[mingo-js] chunk received', { dialogId, streamSeq: chunk.streamSeq, chunk });
}
if (typeof chunk.streamSeq === 'number') {
if (chunk.streamSeq <= lastDispatchedStreamSeqRef.current) return;
lastDispatchedStreamSeqRef.current = chunk.streamSeq;
}
syncStreamStateFromChunk(chunk);
processorRef.current(chunk);
},
Expand All @@ -514,7 +536,7 @@ export function DialogSubscription({
onConnectionChange?.(dialogId, false);
}, [dialogId, onConnectionChange]);

useJetStreamDialogSubscription({
const { reconnectionCount } = useJetStreamDialogSubscription({
enabled: isInitialOptStartSeqReady,
dialogId,
streamName: CHAT_CHUNKS_STREAM,
Expand All @@ -527,5 +549,16 @@ export function DialogSubscription({
getNatsWsUrl: getWsUrl,
});

// NATS reconnect: JetStream replays only ~10 minutes of CHAT_CHUNKS, so an
// outage longer than that leaves a gap the resume-by-seq cannot fill.
// Refetch persisted history — the merge layer dedupes what replay covers.
const lastHandledReconnectRef = useRef(0);
useEffect(() => {
if (reconnectionCount <= lastHandledReconnectRef.current) return;
lastHandledReconnectRef.current = reconnectionCount;
void queryClient.invalidateQueries({ queryKey: ['mingo-dialog-messages', dialogId] });
void queryClient.invalidateQueries({ queryKey: ['mingo-dialog', dialogId] });
}, [reconnectionCount, queryClient, dialogId]);

return null;
}
176 changes: 118 additions & 58 deletions src/app/(app)/mingo/stores/mingo-messages-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,11 +65,16 @@ interface MingoMessagesStore {
status: 'approved' | 'rejected',
resolvedByName?: string | null,
) => void;
/** Merge an EXECUTING/EXECUTED event into the matching tool segment or
* batch `executions` slot. `executionRequestId` is optional — without it,
* an EXECUTED chunk pairs with the newest EXECUTING of the same
* (integratedToolType, toolFunction). Returns whether anything matched so
* the caller can append a fresh segment instead of dropping the event. */
updateToolExecutionInMessages: (
dialogId: string,
executionRequestId: string,
executionRequestId: string | undefined,
executedData: ToolExecutionSegment['data'],
) => void;
) => boolean;
getMessages: (dialogId: string) => Message[];

// Real-time State Management
Expand Down Expand Up @@ -289,67 +294,99 @@ export const useMingoMessagesStore = create<MingoMessagesStore>()(

updateToolExecutionInMessages: (
dialogId: string,
executionRequestId: string,
executionRequestId: string | undefined,
executedData: ToolExecutionSegment['data'],
) => {
let matched = false;
set(state => {
const newMap = new Map(state.messagesByDialog);
const currentMessages = newMap.get(dialogId) || [];

let matched = false;
const updatedMessages = currentMessages.map(message => {
if (matched) return message;
if (message.role !== 'assistant' || !Array.isArray(message.content)) return message;

let messageChanged = false;
const updatedContent = message.content.map(segment => {
if (matched) return segment;

if (
segment.type === 'tool_execution' &&
segment.data.type === 'EXECUTING_TOOL' &&
segment.data.toolExecutionRequestId === executionRequestId
) {
matched = true;
messageChanged = true;
const merged: ToolExecutionSegment = {
type: 'tool_execution',
data: {
...executedData,
toolTitle: executedData.toolTitle ?? segment.data.toolTitle,
parameters: executedData.parameters ?? segment.data.parameters,
},
};
return merged;
}

if (
// Scan NEWEST-first: an id-less EXECUTED must pair with the newest
// in-flight EXECUTING of the same tool (a stale interrupted card
// from an earlier turn must not swallow the current run); execId
// matches are unique so direction doesn't change them.
let msgIdx = -1;
let segIdx = -1;
for (let i = currentMessages.length - 1; i >= 0 && msgIdx === -1; i--) {
const message = currentMessages[i];
if (message.role !== 'assistant' || !Array.isArray(message.content)) continue;
const content = message.content as MessageSegment[];
for (let j = content.length - 1; j >= 0; j--) {
const segment = content[j];
if (segment.type === 'tool_execution') {
const idMatches = executionRequestId
? segment.data.toolExecutionRequestId === executionRequestId
: segment.data.type === 'EXECUTING_TOOL' &&
segment.data.integratedToolType === executedData.integratedToolType &&
segment.data.toolFunction === executedData.toolFunction;
if (idMatches) {
msgIdx = i;
segIdx = j;
break;
}
} else if (
executionRequestId &&
segment.type === 'approval_batch' &&
segment.data.toolCalls.some(c => c.toolExecutionRequestId === executionRequestId)
) {
matched = true;
messageChanged = true;
const prev: ApprovalBatchExecutionState | undefined = segment.data.executions?.[executionRequestId];
const next: ApprovalBatchExecutionState =
executedData.type === 'EXECUTED_TOOL'
? { status: 'done', result: executedData.result, success: executedData.success }
: { status: 'executing', result: prev?.result, success: prev?.success };
return {
...segment,
data: {
...segment.data,
executions: { ...(segment.data.executions ?? {}), [executionRequestId]: next },
},
} as ApprovalBatchSegment;
msgIdx = i;
segIdx = j;
break;
}
}
}

return segment;
});

return messageChanged ? { ...message, content: updatedContent } : message;
});
if (msgIdx === -1) return state;
matched = true;

const message = currentMessages[msgIdx];
const content = message.content as MessageSegment[];
const segment = content[segIdx];
let nextSegment: MessageSegment;

if (segment.type === 'tool_execution') {
// Never downgrade a completed run back to EXECUTING (JetStream
// redelivery of the EXECUTING chunk after EXECUTED landed).
// `matched` stays TRUE here on purpose: the run is already
// represented on screen, so the caller's append fallback must not
// fire — a `false` would duplicate the card.
if (executedData.type === 'EXECUTING_TOOL' && segment.data.type === 'EXECUTED_TOOL') {
return state;
}
nextSegment = {
type: 'tool_execution',
data: {
...executedData,
toolTitle: executedData.toolTitle ?? segment.data.toolTitle,
parameters: executedData.parameters ?? segment.data.parameters,
},
} satisfies ToolExecutionSegment;
} else {
const batch = segment as ApprovalBatchSegment;
const prev: ApprovalBatchExecutionState | undefined = batch.data.executions?.[executionRequestId as string];
// Same no-downgrade rule for a batch slot: a redelivered EXECUTING
// must not flip a 'done' entry back (matched stays true → no append).
if (executedData.type === 'EXECUTING_TOOL' && prev?.status === 'done') {
return state;
}
const next: ApprovalBatchExecutionState =
executedData.type === 'EXECUTED_TOOL'
? { status: 'done', result: executedData.result, success: executedData.success }
: { status: 'executing', result: prev?.result, success: prev?.success };
nextSegment = {
...batch,
data: {
...batch.data,
executions: { ...(batch.data.executions ?? {}), [executionRequestId as string]: next },
},
} as ApprovalBatchSegment;
}

if (!matched) return state;
const nextContent = [...content];
nextContent[segIdx] = nextSegment;
const updatedMessages = [...currentMessages];
updatedMessages[msgIdx] = { ...message, content: nextContent };

newMap.set(dialogId, updatedMessages);

Expand All @@ -362,6 +399,7 @@ export const useMingoMessagesStore = create<MingoMessagesStore>()(

return { messagesByDialog: newMap, streamingMessages: newStreamingMap };
});
return matched;
},

getMessages: (dialogId: string) => {
Expand Down Expand Up @@ -480,16 +518,25 @@ export const useMingoMessagesStore = create<MingoMessagesStore>()(
let nextContent: MessageSegment[];

if (incomingCompaction) {
const startedIdx = existing.findIndex(s => s.type === 'context_compaction' && s.status === 'started');
const hasAnyCompaction = existing.some(s => s.type === 'context_compaction');
// Mirror the lib's upsertTrailingCompaction: replace the LAST
// 'started' (earlier compactions in the bubble are already
// completed-in-place), else append. The previous first-match +
// any-compaction blanket silently dropped a second compaction
// landing in the same bubble.
let startedIdx = -1;
for (let k = existing.length - 1; k >= 0; k--) {
const seg = existing[k];
if (seg.type === 'context_compaction' && seg.status === 'started') {
startedIdx = k;
break;
}
}

if (incomingCompaction.status === 'completed' && startedIdx !== -1) {
if (startedIdx !== -1) {
nextContent = [...existing];
nextContent[startedIdx] = incomingCompaction;
} else if (!hasAnyCompaction) {
nextContent = [...existing, incomingCompaction];
} else {
nextContent = existing;
nextContent = [...existing, incomingCompaction];
}
} else {
const accumulator = state.segmentAccumulators.get(dialogId);
Expand All @@ -511,7 +558,20 @@ export const useMingoMessagesStore = create<MingoMessagesStore>()(
}
}

return state;
// No assistant message anywhere (thread cleared / history not yet
// hydrated): open a fresh bubble instead of dropping the segments —
// mirrors the lib's appendToTrailingAssistant. Silently dropping the
// first post-MESSAGE_END tool chunk here is exactly the
// invisible-tool-run bug this pipeline was fixed for.
const fallback: Message = {
id: `assistant-${Date.now()}-${Math.random().toString(16).slice(2)}`,
role: 'assistant',
content: incomingCompaction ? [incomingCompaction] : [...segments],
timestamp: new Date(),
...(streamSeq != null ? { streamSeq } : {}),
};
newMap.set(dialogId, [...currentMessages, fallback]);
return { messagesByDialog: newMap };
});
},

Expand Down
11 changes: 11 additions & 0 deletions src/app/(app)/tickets/components/ticket-details-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -425,6 +425,16 @@ export function TicketDetailsView({ ticketId }: TicketDetailsViewProps) {
const adminInitialOptStartSeq = useMemo(() => maxPersistedStreamSeq(adminChat.rawPages), [adminChat.rawPages]);
const isInitialOptStartSeqReady = clientChat.isFetched && (!isTechnicianChatEnabled || adminChat.isFetched);

// NATS reconnect: JetStream replays only ~10 minutes of CHAT_CHUNKS, so an
// outage longer than that leaves a gap the resume-by-seq cannot fill.
// Refetch persisted history — the merge layer dedupes what replay covers.
const refetchClientChat = clientChat.refetch;
const refetchAdminChat = adminChat.refetch;
const handleNatsReconnected = useCallback(() => {
void refetchClientChat();
if (isTechnicianChatEnabled) void refetchAdminChat();
}, [refetchClientChat, refetchAdminChat, isTechnicianChatEnabled]);

const applyStatus = useCallback(
(nextStatus: Dialog['status']) => {
queryClient.setQueryData<Dialog | null>(ticketsQueryKeys.detail(ticketId), prev =>
Expand Down Expand Up @@ -932,6 +942,7 @@ export function TicketDetailsView({ ticketId }: TicketDetailsViewProps) {
adminInitialOptStartSeq={adminInitialOptStartSeq}
isInitialOptStartSeqReady={isInitialOptStartSeqReady}
subscribeAdmin={isTechnicianChatEnabled}
onReconnected={handleNatsReconnected}
/>
{isSidebarLayout ? (
<PageLayout
Expand Down
Loading
Loading