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]
- Optimized session activity status handling.
## [1.3.6] - 2025-12-27
- Added the ability to manage (connect/disconnect) providers in settings.
+1 -1
View File
@@ -2847,7 +2847,7 @@ dependencies = [
[[package]]
name = "openchamber-desktop"
version = "1.3.5"
version = "1.3.6"
dependencies = [
"anyhow",
"axum",
@@ -329,8 +329,8 @@ async fn handle_event(
set_phase(app, &id, ActivityPhase::Busy, phases.clone(), cooldowns.clone()).await;
}
// Derive cooldown from "step-finish reason=stop" marker when present.
if is_stop_step_finish_part(&event.properties) {
// Derive cooldown from info.finish === 'stop' when present.
if has_finish_stop(info) {
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 {
let Some(part) = properties.get("part") else {
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")
fn has_finish_stop(info: &Value) -> bool {
info.get("finish").and_then(Value::as_str) == Some("stop")
}
async fn enter_cooldown_if_busy(
@@ -287,6 +287,11 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
return Boolean(messageCompletedAt && messageCompletedAt > 0);
}, [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(
() =>
filterVisibleParts(normalizedParts, {
@@ -382,23 +387,8 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
return { name, token: rawValue } satisfies AgentMentionInfo;
}, [isUser, message.parts]);
const stepState = React.useMemo(() => {
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;
// Message is considered to have an "open step" if info.finish is not yet present
const hasOpenStep = typeof messageFinish !== 'string';
const shouldCoordinateRendering = React.useMemo(() => {
if (isUser) {
@@ -756,6 +746,7 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
parts={visibleParts}
isUser={isUser}
isMessageCompleted={isMessageCompleted}
messageFinish={messageFinish}
syntaxTheme={syntaxTheme}
isMobile={isMobile}
hasTouchInput={hasTouchInput}
@@ -797,6 +788,7 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
parts={visibleParts}
isUser={isUser}
isMessageCompleted={isMessageCompleted}
messageFinish={messageFinish}
syntaxTheme={syntaxTheme}
isMobile={isMobile}
hasTouchInput={hasTouchInput}
@@ -108,22 +108,15 @@ export const detectTurns = (messages: ChatMessageEntry[]): Turn[] => {
const extractFinalAssistantText = (turn: Turn): string | undefined => {
for (const assistantMsg of turn.assistantMessages) {
const infoFinish = (assistantMsg.info as { finish?: string | null | undefined }).finish;
for (const part of assistantMsg.parts) {
if (part.type === 'step-finish') {
const finishReason = (part as { reason?: string | null | undefined }).reason;
const infoFinish = (assistantMsg.info as { finish?: string | null | undefined }).finish;
if (finishReason === 'stop' || infoFinish === 'stop') {
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;
}
}
if (infoFinish === 'stop') {
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) => {
const messageId = msg.info.id;
const infoFinish = (msg.info as { finish?: string | null | undefined }).finish;
const hasStopFinishInMessage = ENABLE_TEXT_JUSTIFICATION_ACTIVITY
? msg.parts.some((part) => {
if (part.type !== 'step-finish') return false;
const reason = (part as { reason?: string | null | undefined }).reason;
return reason === 'stop';
})
? infoFinish === 'stop'
: false;
msg.parts.forEach((part) => {
@@ -105,6 +105,7 @@ interface MessageBodyProps {
parts: Part[];
isUser: boolean;
isMessageCompleted: boolean;
messageFinish?: string;
syntaxTheme: { [key: string]: React.CSSProperties };
@@ -300,6 +301,7 @@ const AssistantMessageBody: React.FC<Omit<MessageBodyProps, 'isUser'>> = ({
messageId,
parts,
isMessageCompleted,
messageFinish,
syntaxTheme,
isMobile,
@@ -347,9 +349,7 @@ const AssistantMessageBody: React.FC<Omit<MessageBodyProps, 'isUser'>> = ({
const createSessionFromAssistantMessage = useSessionStore((state) => state.createSessionFromAssistantMessage);
const isLastAssistantInTurn = turnGroupingContext?.isLastAssistantInTurn ?? false;
const hasStopFinish = React.useMemo(() => {
return parts.some((part) => part.type === 'step-finish' && (part as { reason?: string | null | undefined }).reason === 'stop');
}, [parts]);
const hasStopFinish = messageFinish === 'stop';
const hasTools = toolParts.length > 0;
@@ -414,24 +414,8 @@ const AssistantMessageBody: React.FC<Omit<MessageBodyProps, 'isUser'>> = ({
});
}, [reasoningParts]);
const stepState = React.useMemo(() => {
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 {
stepStarts,
stepFinishes,
hasOpenStep: stepStarts > stepFinishes,
};
}, [visibleParts]);
const hasOpenStep = stepState.hasOpenStep;
// Message is considered to have an "open step" if info.finish is not yet present
const hasOpenStep = typeof messageFinish !== 'string';
const shouldHoldForReasoning =
reasoningParts.length > 0 &&
+9 -25
View File
@@ -698,9 +698,7 @@ export const useEventStream = () => {
const existingMessage = getMessageFromStore(sessionId, messageId);
const existingLen = computeTextLength(existingMessage?.parts || []);
const existingStopMarker = existingMessage?.parts?.some(
(part) => part?.type === 'step-finish' && (part as { reason?: string }).reason === 'stop'
) ?? false;
const existingStopMarker = (existingMessage?.info as { finish?: string } | undefined)?.finish === 'stop';
const serverParts = (props as { parts?: unknown }).parts || (messageExt as { parts?: unknown }).parts;
const partsArray = Array.isArray(serverParts) ? (serverParts as Part[]) : [];
@@ -710,12 +708,13 @@ export const useEventStream = () => {
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) {
const incomingLen = computeTextLength(partsArray);
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) {
trackMessage(messageId, 'skipped_shrinking_update', { incomingLen, existingLen });
@@ -772,16 +771,12 @@ export const useEventStream = () => {
const messageTime = (message as { time?: { completed?: unknown } }).time;
const completedCandidate = (messageTime as { completed?: unknown } | undefined)?.completed;
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(
(p) => p?.type === 'step-finish' && (p as { reason?: string }).reason === 'stop'
) || existingStopMarker;
const stopMarkerPresent = finish === 'stop' || existingStopMarker;
const shouldFinalizeAssistantMessage =
(message as { role?: string }).role === 'assistant' &&
(hasCompletedTimestamp || finish === 'stop' || stopMarkerPresent);
(hasCompletedTimestamp || stopMarkerPresent);
if (shouldFinalizeAssistantMessage && (message as { role?: string }).role === 'assistant') {
@@ -894,20 +889,9 @@ export const useEventStream = () => {
completeStreamingMessage(sessionId, messageId);
// 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) {
const finishCandidate = (message as { finish?: unknown }).finish;
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) {
if (finish === 'stop') {
const currentPhase = useSessionStore.getState().sessionActivityPhase?.get(sessionId);
if (currentPhase === 'busy') {
updateSessionActivityPhase(sessionId, 'cooldown');
+1 -1
View File
@@ -590,7 +590,7 @@ export const debugUtils = {
const lastMessage = assistantMessages[assistantMessages.length - 1];
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 completedAt = timeInfo?.completed;
+2 -3
View File
@@ -11,6 +11,7 @@ export interface MessageInfo {
};
status?: string;
streaming?: boolean;
finish?: string;
}
export interface MessageRecord {
@@ -27,9 +28,7 @@ export function isMessageComplete(messageInfo: MessageInfo, parts: Part[] = []):
const completedAt = typeof timeInfo?.completed === 'number' ? timeInfo.completed : undefined;
const messageStatus = messageInfo?.status;
const hasStopFinish = parts.some(part =>
part.type === 'step-finish' && (part as any).reason === 'stop'
);
const hasStopFinish = messageInfo.finish === 'stop';
const hasCompletedFlag = (typeof completedAt === 'number' && completedAt > 0) || messageStatus === 'completed';
if (!hasCompletedFlag || !hasStopFinish) {
+16 -19
View File
@@ -101,13 +101,8 @@ const computePartsTextLength = (parts: Part[] | undefined): number => {
}, 0);
};
const hasStopReasonStop = (parts: Part[] | undefined): boolean => {
if (!Array.isArray(parts)) {
return false;
}
return parts.some(
(part) => part?.type === "step-finish" && (part as Record<string, unknown>)?.reason === "stop"
);
const hasFinishStop = (info: { finish?: string } | undefined): boolean => {
return info?.finish === "stop";
};
const getPartKey = (part: Part | undefined): string | undefined => {
@@ -159,8 +154,8 @@ const mergeDuplicateMessage = (
const incomingParts = Array.isArray(incoming.parts) ? incoming.parts : [];
const existingLen = computePartsTextLength(existingParts);
const incomingLen = computePartsTextLength(incomingParts);
const existingStop = hasStopReasonStop(existingParts);
const incomingStop = hasStopReasonStop(incomingParts);
const existingStop = hasFinishStop(existing.info);
const incomingStop = hasFinishStop(incoming.info);
let parts = incomingParts;
if (existingStop && existingLen >= incomingLen) {
@@ -449,7 +444,7 @@ export const useMessageStore = create<MessageStore>()(
const existingParts = Array.isArray(existingEntry.parts) ? existingEntry.parts : [];
const existingLen = computePartsTextLength(existingParts);
const serverLen = computePartsTextLength(serverParts);
const storeHasStop = hasStopReasonStop(existingParts);
const storeHasStop = hasFinishStop(existingEntry.info);
if (storeHasStop && existingLen > serverLen) {
const mergedParts = mergePreferExistingParts(existingParts, serverParts);
@@ -1388,14 +1383,6 @@ export const useMessageStore = create<MessageStore>()(
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) => {
@@ -1887,6 +1874,16 @@ export const useMessageStore = create<MessageStore>()(
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) => {
@@ -2041,7 +2038,7 @@ export const useMessageStore = create<MessageStore>()(
const existingParts = Array.isArray(existingEntry.parts) ? existingEntry.parts : [];
const existingLen = computePartsTextLength(existingParts);
const serverLen = computePartsTextLength(serverParts);
const storeHasStop = hasStopReasonStop(existingParts);
const storeHasStop = hasFinishStop(existingEntry.info);
if (storeHasStop && existingLen > serverLen) {
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') {
const sessionId = payload.properties?.sessionID;
if (typeof sessionId === 'string' && sessionId.length > 0) {