feat: update message handling to use 'finish' property for step completion logic

This commit is contained in:
Bohdan Triapitsyn
2025-12-27 15:51:28 +02:00
parent 88e0cedcd3
commit 4d7340bf49
11 changed files with 60 additions and 134 deletions
+3
View File
@@ -4,6 +4,9 @@ All notable changes to this project will be documented in this file.
## [Unreleased] ## [Unreleased]
- Optimized session activity status handling.
## [1.3.6] - 2025-12-27 ## [1.3.6] - 2025-12-27
- Added the ability to manage (connect/disconnect) providers in settings. - Added the ability to manage (connect/disconnect) providers in settings.
+1 -1
View File
@@ -2847,7 +2847,7 @@ dependencies = [
[[package]] [[package]]
name = "openchamber-desktop" name = "openchamber-desktop"
version = "1.3.5" version = "1.3.6"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"axum", "axum",
@@ -329,8 +329,8 @@ async fn handle_event(
set_phase(app, &id, ActivityPhase::Busy, phases.clone(), cooldowns.clone()).await; set_phase(app, &id, ActivityPhase::Busy, phases.clone(), cooldowns.clone()).await;
} }
// Derive cooldown from "step-finish reason=stop" marker when present. // Derive cooldown from info.finish === 'stop' when present.
if is_stop_step_finish_part(&event.properties) { if has_finish_stop(info) {
enter_cooldown_if_busy(app, &id, phases.clone(), cooldowns.clone()).await; enter_cooldown_if_busy(app, &id, phases.clone(), cooldowns.clone()).await;
} }
} }
@@ -349,13 +349,8 @@ fn is_streaming_assistant_part(properties: &Value) -> bool {
) )
} }
fn is_stop_step_finish_part(properties: &Value) -> bool { fn has_finish_stop(info: &Value) -> bool {
let Some(part) = properties.get("part") else { info.get("finish").and_then(Value::as_str) == Some("stop")
return false;
};
let part_type = part.get("type").and_then(Value::as_str);
let reason = part.get("reason").and_then(Value::as_str);
part_type == Some("step-finish") && reason == Some("stop")
} }
async fn enter_cooldown_if_busy( async fn enter_cooldown_if_busy(
@@ -287,6 +287,11 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
return Boolean(messageCompletedAt && messageCompletedAt > 0); return Boolean(messageCompletedAt && messageCompletedAt > 0);
}, [isUser, messageCompletedAt]); }, [isUser, messageCompletedAt]);
const messageFinish = React.useMemo(() => {
const finish = (message.info as { finish?: string }).finish;
return typeof finish === 'string' ? finish : undefined;
}, [message.info]);
const visibleParts = React.useMemo( const visibleParts = React.useMemo(
() => () =>
filterVisibleParts(normalizedParts, { filterVisibleParts(normalizedParts, {
@@ -382,23 +387,8 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
return { name, token: rawValue } satisfies AgentMentionInfo; return { name, token: rawValue } satisfies AgentMentionInfo;
}, [isUser, message.parts]); }, [isUser, message.parts]);
const stepState = React.useMemo(() => { // Message is considered to have an "open step" if info.finish is not yet present
const hasOpenStep = typeof messageFinish !== 'string';
let stepStarts = 0;
let stepFinishes = 0;
visibleParts.forEach((part) => {
if (part.type === 'step-start') {
stepStarts += 1;
} else if (part.type === 'step-finish') {
stepFinishes += 1;
}
});
return {
hasOpenStep: stepStarts > stepFinishes,
};
}, [visibleParts]);
const hasOpenStep = stepState.hasOpenStep;
const shouldCoordinateRendering = React.useMemo(() => { const shouldCoordinateRendering = React.useMemo(() => {
if (isUser) { if (isUser) {
@@ -756,6 +746,7 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
parts={visibleParts} parts={visibleParts}
isUser={isUser} isUser={isUser}
isMessageCompleted={isMessageCompleted} isMessageCompleted={isMessageCompleted}
messageFinish={messageFinish}
syntaxTheme={syntaxTheme} syntaxTheme={syntaxTheme}
isMobile={isMobile} isMobile={isMobile}
hasTouchInput={hasTouchInput} hasTouchInput={hasTouchInput}
@@ -797,6 +788,7 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
parts={visibleParts} parts={visibleParts}
isUser={isUser} isUser={isUser}
isMessageCompleted={isMessageCompleted} isMessageCompleted={isMessageCompleted}
messageFinish={messageFinish}
syntaxTheme={syntaxTheme} syntaxTheme={syntaxTheme}
isMobile={isMobile} isMobile={isMobile}
hasTouchInput={hasTouchInput} hasTouchInput={hasTouchInput}
@@ -108,22 +108,15 @@ export const detectTurns = (messages: ChatMessageEntry[]): Turn[] => {
const extractFinalAssistantText = (turn: Turn): string | undefined => { const extractFinalAssistantText = (turn: Turn): string | undefined => {
for (const assistantMsg of turn.assistantMessages) { for (const assistantMsg of turn.assistantMessages) {
const infoFinish = (assistantMsg.info as { finish?: string | null | undefined }).finish;
for (const part of assistantMsg.parts) { if (infoFinish === 'stop') {
if (part.type === 'step-finish') { const textPart = assistantMsg.parts.find(p => p.type === 'text');
const finishReason = (part as { reason?: string | null | undefined }).reason; if (textPart) {
const infoFinish = (assistantMsg.info as { finish?: string | null | undefined }).finish; const textContent = (textPart as { text?: string | null | undefined }).text ??
(textPart as { content?: string | null | undefined }).content;
if (finishReason === 'stop' || infoFinish === 'stop') { if (typeof textContent === 'string' && textContent.trim().length > 0) {
return textContent;
const textPart = assistantMsg.parts.find(p => p.type === 'text');
if (textPart) {
const textContent = (textPart as { text?: string | null | undefined }).text ??
(textPart as { content?: string | null | undefined }).content;
if (typeof textContent === 'string' && textContent.trim().length > 0) {
return textContent;
}
}
} }
} }
} }
@@ -194,12 +187,9 @@ const getTurnActivityInfo = (turn: Turn): TurnActivityInfo => {
turn.assistantMessages.forEach((msg) => { turn.assistantMessages.forEach((msg) => {
const messageId = msg.info.id; const messageId = msg.info.id;
const infoFinish = (msg.info as { finish?: string | null | undefined }).finish;
const hasStopFinishInMessage = ENABLE_TEXT_JUSTIFICATION_ACTIVITY const hasStopFinishInMessage = ENABLE_TEXT_JUSTIFICATION_ACTIVITY
? msg.parts.some((part) => { ? infoFinish === 'stop'
if (part.type !== 'step-finish') return false;
const reason = (part as { reason?: string | null | undefined }).reason;
return reason === 'stop';
})
: false; : false;
msg.parts.forEach((part) => { msg.parts.forEach((part) => {
@@ -105,6 +105,7 @@ interface MessageBodyProps {
parts: Part[]; parts: Part[];
isUser: boolean; isUser: boolean;
isMessageCompleted: boolean; isMessageCompleted: boolean;
messageFinish?: string;
syntaxTheme: { [key: string]: React.CSSProperties }; syntaxTheme: { [key: string]: React.CSSProperties };
@@ -300,6 +301,7 @@ const AssistantMessageBody: React.FC<Omit<MessageBodyProps, 'isUser'>> = ({
messageId, messageId,
parts, parts,
isMessageCompleted, isMessageCompleted,
messageFinish,
syntaxTheme, syntaxTheme,
isMobile, isMobile,
@@ -347,9 +349,7 @@ const AssistantMessageBody: React.FC<Omit<MessageBodyProps, 'isUser'>> = ({
const createSessionFromAssistantMessage = useSessionStore((state) => state.createSessionFromAssistantMessage); const createSessionFromAssistantMessage = useSessionStore((state) => state.createSessionFromAssistantMessage);
const isLastAssistantInTurn = turnGroupingContext?.isLastAssistantInTurn ?? false; const isLastAssistantInTurn = turnGroupingContext?.isLastAssistantInTurn ?? false;
const hasStopFinish = React.useMemo(() => { const hasStopFinish = messageFinish === 'stop';
return parts.some((part) => part.type === 'step-finish' && (part as { reason?: string | null | undefined }).reason === 'stop');
}, [parts]);
const hasTools = toolParts.length > 0; const hasTools = toolParts.length > 0;
@@ -414,24 +414,8 @@ const AssistantMessageBody: React.FC<Omit<MessageBodyProps, 'isUser'>> = ({
}); });
}, [reasoningParts]); }, [reasoningParts]);
const stepState = React.useMemo(() => { // Message is considered to have an "open step" if info.finish is not yet present
let stepStarts = 0; const hasOpenStep = typeof messageFinish !== 'string';
let stepFinishes = 0;
visibleParts.forEach((part) => {
if (part.type === 'step-start') {
stepStarts += 1;
} else if (part.type === 'step-finish') {
stepFinishes += 1;
}
});
return {
stepStarts,
stepFinishes,
hasOpenStep: stepStarts > stepFinishes,
};
}, [visibleParts]);
const hasOpenStep = stepState.hasOpenStep;
const shouldHoldForReasoning = const shouldHoldForReasoning =
reasoningParts.length > 0 && reasoningParts.length > 0 &&
+9 -25
View File
@@ -698,9 +698,7 @@ export const useEventStream = () => {
const existingMessage = getMessageFromStore(sessionId, messageId); const existingMessage = getMessageFromStore(sessionId, messageId);
const existingLen = computeTextLength(existingMessage?.parts || []); const existingLen = computeTextLength(existingMessage?.parts || []);
const existingStopMarker = existingMessage?.parts?.some( const existingStopMarker = (existingMessage?.info as { finish?: string } | undefined)?.finish === 'stop';
(part) => part?.type === 'step-finish' && (part as { reason?: string }).reason === 'stop'
) ?? false;
const serverParts = (props as { parts?: unknown }).parts || (messageExt as { parts?: unknown }).parts; const serverParts = (props as { parts?: unknown }).parts || (messageExt as { parts?: unknown }).parts;
const partsArray = Array.isArray(serverParts) ? (serverParts as Part[]) : []; const partsArray = Array.isArray(serverParts) ? (serverParts as Part[]) : [];
@@ -710,12 +708,13 @@ export const useEventStream = () => {
if (!hasParts && !completedFromServer) break; if (!hasParts && !completedFromServer) break;
const finishCandidate = (message as { finish?: unknown }).finish;
const finish = typeof finishCandidate === 'string' ? finishCandidate : null;
const eventHasStopFinish = finish === 'stop';
if ((messageExt as { role?: unknown }).role === 'assistant' && hasParts) { if ((messageExt as { role?: unknown }).role === 'assistant' && hasParts) {
const incomingLen = computeTextLength(partsArray); const incomingLen = computeTextLength(partsArray);
const wouldShrink = existingLen > 0 && incomingLen + TEXT_SHRINK_TOLERANCE < existingLen; const wouldShrink = existingLen > 0 && incomingLen + TEXT_SHRINK_TOLERANCE < existingLen;
const eventHasStopFinish = partsArray.some(
(p) => p?.type === 'step-finish' && (p as { reason?: string }).reason === 'stop'
);
if (wouldShrink && !eventHasStopFinish) { if (wouldShrink && !eventHasStopFinish) {
trackMessage(messageId, 'skipped_shrinking_update', { incomingLen, existingLen }); trackMessage(messageId, 'skipped_shrinking_update', { incomingLen, existingLen });
@@ -772,16 +771,12 @@ export const useEventStream = () => {
const messageTime = (message as { time?: { completed?: unknown } }).time; const messageTime = (message as { time?: { completed?: unknown } }).time;
const completedCandidate = (messageTime as { completed?: unknown } | undefined)?.completed; const completedCandidate = (messageTime as { completed?: unknown } | undefined)?.completed;
const hasCompletedTimestamp = typeof completedCandidate === 'number' && Number.isFinite(completedCandidate); const hasCompletedTimestamp = typeof completedCandidate === 'number' && Number.isFinite(completedCandidate);
const finishCandidate = (message as { finish?: unknown }).finish;
const finish = typeof finishCandidate === 'string' ? finishCandidate : null;
const stopMarkerPresent = partsArray.some( const stopMarkerPresent = finish === 'stop' || existingStopMarker;
(p) => p?.type === 'step-finish' && (p as { reason?: string }).reason === 'stop'
) || existingStopMarker;
const shouldFinalizeAssistantMessage = const shouldFinalizeAssistantMessage =
(message as { role?: string }).role === 'assistant' && (message as { role?: string }).role === 'assistant' &&
(hasCompletedTimestamp || finish === 'stop' || stopMarkerPresent); (hasCompletedTimestamp || stopMarkerPresent);
if (shouldFinalizeAssistantMessage && (message as { role?: string }).role === 'assistant') { if (shouldFinalizeAssistantMessage && (message as { role?: string }).role === 'assistant') {
@@ -894,20 +889,9 @@ export const useEventStream = () => {
completeStreamingMessage(sessionId, messageId); completeStreamingMessage(sessionId, messageId);
// For web/vscode: trigger cooldown only when assistant message has finish === "stop" // For web/vscode: trigger cooldown only when assistant message has finish === "stop"
// (or we can infer a stop marker) to match desktop backend semantics. // to match desktop backend semantics.
if (!isDesktopRuntimeRef.current) { if (!isDesktopRuntimeRef.current) {
const finishCandidate = (message as { finish?: unknown }).finish; if (finish === 'stop') {
const finish = typeof finishCandidate === 'string' ? finishCandidate : null;
const inferredStopMarkerPresent =
Array.isArray(partsArray) &&
partsArray.some((part) => {
if (!part || typeof part !== 'object') return false;
const partAny = part as { type?: string; reason?: string };
return partAny.type === 'step-finish' && partAny.reason === 'stop';
});
if (finish === 'stop' || inferredStopMarkerPresent) {
const currentPhase = useSessionStore.getState().sessionActivityPhase?.get(sessionId); const currentPhase = useSessionStore.getState().sessionActivityPhase?.get(sessionId);
if (currentPhase === 'busy') { if (currentPhase === 'busy') {
updateSessionActivityPhase(sessionId, 'cooldown'); updateSessionActivityPhase(sessionId, 'cooldown');
+1 -1
View File
@@ -590,7 +590,7 @@ export const debugUtils = {
const lastMessage = assistantMessages[assistantMessages.length - 1]; const lastMessage = assistantMessages[assistantMessages.length - 1];
const stepFinishParts = lastMessage.parts.filter((p: any) => p.type === 'step-finish'); const stepFinishParts = lastMessage.parts.filter((p: any) => p.type === 'step-finish');
const hasStopReason = lastMessage.parts.some((p: any) => p.type === 'step-finish' && p.reason === 'stop'); const hasStopReason = (lastMessage.info as { finish?: string }).finish === 'stop';
const timeInfo = lastMessage.info.time as any; const timeInfo = lastMessage.info.time as any;
const completedAt = timeInfo?.completed; const completedAt = timeInfo?.completed;
+2 -3
View File
@@ -11,6 +11,7 @@ export interface MessageInfo {
}; };
status?: string; status?: string;
streaming?: boolean; streaming?: boolean;
finish?: string;
} }
export interface MessageRecord { export interface MessageRecord {
@@ -27,9 +28,7 @@ export function isMessageComplete(messageInfo: MessageInfo, parts: Part[] = []):
const completedAt = typeof timeInfo?.completed === 'number' ? timeInfo.completed : undefined; const completedAt = typeof timeInfo?.completed === 'number' ? timeInfo.completed : undefined;
const messageStatus = messageInfo?.status; const messageStatus = messageInfo?.status;
const hasStopFinish = parts.some(part => const hasStopFinish = messageInfo.finish === 'stop';
part.type === 'step-finish' && (part as any).reason === 'stop'
);
const hasCompletedFlag = (typeof completedAt === 'number' && completedAt > 0) || messageStatus === 'completed'; const hasCompletedFlag = (typeof completedAt === 'number' && completedAt > 0) || messageStatus === 'completed';
if (!hasCompletedFlag || !hasStopFinish) { if (!hasCompletedFlag || !hasStopFinish) {
+16 -19
View File
@@ -101,13 +101,8 @@ const computePartsTextLength = (parts: Part[] | undefined): number => {
}, 0); }, 0);
}; };
const hasStopReasonStop = (parts: Part[] | undefined): boolean => { const hasFinishStop = (info: { finish?: string } | undefined): boolean => {
if (!Array.isArray(parts)) { return info?.finish === "stop";
return false;
}
return parts.some(
(part) => part?.type === "step-finish" && (part as Record<string, unknown>)?.reason === "stop"
);
}; };
const getPartKey = (part: Part | undefined): string | undefined => { const getPartKey = (part: Part | undefined): string | undefined => {
@@ -159,8 +154,8 @@ const mergeDuplicateMessage = (
const incomingParts = Array.isArray(incoming.parts) ? incoming.parts : []; const incomingParts = Array.isArray(incoming.parts) ? incoming.parts : [];
const existingLen = computePartsTextLength(existingParts); const existingLen = computePartsTextLength(existingParts);
const incomingLen = computePartsTextLength(incomingParts); const incomingLen = computePartsTextLength(incomingParts);
const existingStop = hasStopReasonStop(existingParts); const existingStop = hasFinishStop(existing.info);
const incomingStop = hasStopReasonStop(incomingParts); const incomingStop = hasFinishStop(incoming.info);
let parts = incomingParts; let parts = incomingParts;
if (existingStop && existingLen >= incomingLen) { if (existingStop && existingLen >= incomingLen) {
@@ -449,7 +444,7 @@ export const useMessageStore = create<MessageStore>()(
const existingParts = Array.isArray(existingEntry.parts) ? existingEntry.parts : []; const existingParts = Array.isArray(existingEntry.parts) ? existingEntry.parts : [];
const existingLen = computePartsTextLength(existingParts); const existingLen = computePartsTextLength(existingParts);
const serverLen = computePartsTextLength(serverParts); const serverLen = computePartsTextLength(serverParts);
const storeHasStop = hasStopReasonStop(existingParts); const storeHasStop = hasFinishStop(existingEntry.info);
if (storeHasStop && existingLen > serverLen) { if (storeHasStop && existingLen > serverLen) {
const mergedParts = mergePreferExistingParts(existingParts, serverParts); const mergedParts = mergePreferExistingParts(existingParts, serverParts);
@@ -1388,14 +1383,6 @@ export const useMessageStore = create<MessageStore>()(
return finalizeAbortState({ messages: newMessages, ...updates }); return finalizeAbortState({ messages: newMessages, ...updates });
} }
}); });
const partType = (part as any)?.type;
if (partType === 'step-finish' && actualRole !== 'user') {
setTimeout(() => {
const store = get();
store.completeStreamingMessage(sessionId, messageId);
}, 0);
}
}, },
addStreamingPart: (sessionId: string, messageId: string, part: Part, role?: string, currentSessionId?: string) => { addStreamingPart: (sessionId: string, messageId: string, part: Part, role?: string, currentSessionId?: string) => {
@@ -1887,6 +1874,16 @@ export const useMessageStore = create<MessageStore>()(
return updates; return updates;
}); });
// Trigger completion when info.finish is present for assistant messages
const infoFinish = (messageInfo as { finish?: string })?.finish;
const messageRole = (messageInfo as { role?: string })?.role;
if (typeof infoFinish === 'string' && messageRole !== 'user') {
setTimeout(() => {
const store = get();
store.completeStreamingMessage(sessionId, messageId);
}, 0);
}
}, },
completeStreamingMessage: (sessionId: string, messageId: string) => { completeStreamingMessage: (sessionId: string, messageId: string) => {
@@ -2041,7 +2038,7 @@ export const useMessageStore = create<MessageStore>()(
const existingParts = Array.isArray(existingEntry.parts) ? existingEntry.parts : []; const existingParts = Array.isArray(existingEntry.parts) ? existingEntry.parts : [];
const existingLen = computePartsTextLength(existingParts); const existingLen = computePartsTextLength(existingParts);
const serverLen = computePartsTextLength(serverParts); const serverLen = computePartsTextLength(serverParts);
const storeHasStop = hasStopReasonStop(existingParts); const storeHasStop = hasFinishStop(existingEntry.info);
if (storeHasStop && existingLen > serverLen) { if (storeHasStop && existingLen > serverLen) {
const mergedParts = mergePreferExistingParts(existingParts, serverParts); const mergedParts = mergePreferExistingParts(existingParts, serverParts);
-18
View File
@@ -864,24 +864,6 @@ function deriveSessionActivity(payload) {
} }
} }
if (payload.type === 'message.part.updated') {
const info = payload.properties?.info;
const part = payload.properties?.part;
const sessionId = info?.sessionID ?? part?.sessionID ?? payload.properties?.sessionID;
const role = info?.role;
const partType = part?.type;
const reason = part?.reason;
if (
typeof sessionId === 'string' &&
sessionId.length > 0 &&
role === 'assistant' &&
partType === 'step-finish' &&
reason === 'stop'
) {
return { sessionId, phase: 'cooldown' };
}
}
if (payload.type === 'session.idle') { if (payload.type === 'session.idle') {
const sessionId = payload.properties?.sessionID; const sessionId = payload.properties?.sessionID;
if (typeof sessionId === 'string' && sessionId.length > 0) { if (typeof sessionId === 'string' && sessionId.length > 0) {