Skip to content

fix: ensure time.completed is always set on finished assistant messages#113

Merged
realDuang merged 2 commits intomainfrom
fix/brokend_ai_answer_timer
Apr 14, 2026
Merged

fix: ensure time.completed is always set on finished assistant messages#113
realDuang merged 2 commits intomainfrom
fix/brokend_ai_answer_timer

Conversation

@realDuang
Copy link
Copy Markdown
Owner

Summary

Fix timer on inactive sessions: when switching to a chat with completed AI responses, the elapsed time counter was incorrectly ticking instead of showing a static value.

Supersedes #87 — incorporates the review feedback with a two-layer fix.

Root Cause

Some adapters (e.g. OpenCode) strip time.completed during multi-step agent loops and only restore it on session.idle. If that event is dropped (e.g. during a WebSocket reconnect), the field stays undefined, causing the frontend timer to tick indefinitely. Additionally, persistMessage() in engine-manager skips messages without time.completed, so they never hit disk.

Changes

1. Engine-manager root-cause fix (engine-manager.ts)

When sendMessage() returns an assistant message without time.completed, set it to Date.now(), persist and emit the finalized message.

2. Frontend safety net (SessionTurn.tsx)

Replace the time.created fallback (which showed ~0s duration) with a capturedEndTime signal that snapshots Date.now() once when the session is first observed as done without time.completed. This gives an approximately correct duration for any remaining edge cases.

Credit

Original bug report and initial fix by @OverCart345 in #87.

Test Plan

  • Type check passes (npm run typecheck)
  • Unit tests pass (bun run test:unit — 429 passed)

Co-authored-by: OverCart345 82957545+OverCart345@users.noreply.github.com

ris and others added 2 commits April 3, 2026 21:32
Root cause: some adapters (e.g. OpenCode) strip time.completed during
multi-step agent loops and only restore it on session.idle. If that
event is dropped (e.g. WebSocket reconnect), the field stays undefined,
causing the frontend timer to tick indefinitely on finished messages.

Two-layer fix:
1. engine-manager: set time.completed = Date.now() when sendMessage()
   returns an assistant message without it, then persist and emit.
2. SessionTurn: replace time.created fallback with a capturedEndTime
   signal that snapshots Date.now() once when the session is first
   observed as done without time.completed — gives approximately
   correct duration instead of ~0s.

Co-authored-by: OverCart345 <82957545+OverCart345@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot AI review requested due to automatic review settings April 14, 2026 06:32
@realDuang realDuang merged commit f8caeee into main Apr 14, 2026
9 checks passed
@realDuang realDuang deleted the fix/brokend_ai_answer_timer branch April 14, 2026 06:32
@github-actions
Copy link
Copy Markdown

Coverage Report

Status Category Percentage Covered / Total
🔵 Lines 56.54% (🎯 50%) 7348 / 12996
🔵 Statements 55.68% (🎯 50%) 7795 / 13999
🔵 Functions 57.69% (🎯 50%) 1290 / 2236
🔵 Branches 51.85% (🎯 50%) 4453 / 8588
File Coverage
File Stmts Branches Functions Lines Uncovered Lines
Changed Files
electron/main/gateway/engine-manager.ts 98.87% 94.4% 97.56% 98.99% 161-165, 488, 507
Generated in workflow #355 for commit 8acf953 by the Vitest Coverage Report Action

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Fixes an issue where completed assistant turns could lack time.completed, causing the UI duration timer to keep ticking after the session is actually finished (notably across reconnects / missed idle events), and preventing persistence of finalized assistant messages.

Changes:

  • Engine-side: backfill time.completed on assistant messages returned from sendMessage() when missing, then persist + emit an update.
  • Frontend: add a “captured end time” fallback so completed turns without time.completed render a static duration instead of ticking.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.

File Description
src/components/SessionTurn.tsx Adds a captured end-time fallback to stop the duration timer for completed turns missing time.completed.
electron/main/gateway/engine-manager.ts Backfills missing time.completed for assistant messages returned from adapters and persists/emits the finalized message.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +879 to 885
if (result.role === "assistant" && !result.time.completed) {
const finalized = { ...result, time: { ...result.time, completed: Date.now() } };
this.persistMessage(sessionId, finalized);
this.emit("message.updated", { sessionId, message: finalized });
}

return result;
Copy link

Copilot AI Apr 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This new fallback path emits message.updated with message: finalized but does not rewrite finalized.sessionId (or its parts) from the engine session id to the conversation id. The renderer indexes messages by msgInfo.sessionId (see src/pages/Chat.tsx:1383-1386), so this event can be stored under the wrong session key and effectively disappear. Fix by rewriting the payload the same way forwardEvents() does (e.g., call rewriteSessionId({ sessionId: engineSessionId, message: finalized }, engineSessionId, sessionId) or at least set finalized.sessionId = sessionId and rewrite part.sessionId). Also, consider returning the finalized/rewritten message instead of result so the message.send RPC response includes time.completed consistently, and add/adjust the existing EngineManager unit test to cover this branch.

Suggested change
if (result.role === "assistant" && !result.time.completed) {
const finalized = { ...result, time: { ...result.time, completed: Date.now() } };
this.persistMessage(sessionId, finalized);
this.emit("message.updated", { sessionId, message: finalized });
}
return result;
let response = result;
if (result.role === "assistant" && !result.time.completed) {
const finalized = { ...result, time: { ...result.time, completed: Date.now() } };
this.persistMessage(sessionId, finalized);
const rewritten = rewriteSessionId(
{ sessionId: engineSessionId, message: finalized },
engineSessionId,
sessionId,
).message as UnifiedMessage;
this.emit("message.updated", { sessionId, message: rewritten });
response = rewritten;
}
return response;

Copilot uses AI. Check for mistakes.
Comment on lines +575 to +593
// Safety-net end time: captured once when we first notice the session is done
// but time.completed is missing (e.g. session.idle event was dropped).
const [capturedEndTime, setCapturedEndTime] = createSignal<number | undefined>();

const finalEndTime = createMemo(() => {
// Only trust time.completed when isWorking is false — during multi-step
// tasks, intermediate messages may carry completed timestamps prematurely.
if (props.isWorking) return undefined;
const lastAssistant = props.assistantMessages.at(-1);
return lastAssistant?.time?.completed;
return lastAssistant?.time?.completed ?? capturedEndTime();
});

createEffect(() => {
if (!props.isWorking && !capturedEndTime()) {
const lastAssistant = props.assistantMessages.at(-1);
if (lastAssistant && !lastAssistant.time?.completed) {
setCapturedEndTime(Date.now());
}
}
Copy link

Copilot AI Apr 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

capturedEndTime is component-local state and never resets when this SessionTurn instance is reused for a different turn/session. MessageList renders turns with <Index each={turns()}> (src/components/MessageList.tsx:77), which preserves component instances by index across dataset changes (e.g., switching sessions), so a previously captured end time can leak into a different turn and produce incorrect/negative durations when time.completed is missing. Consider resetting capturedEndTime whenever props.userMessage.id (and/or props.sessionID) changes, or keying the captured value by the last assistant message id.

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants