From 06fdfbcc5f4f14049c1158aabd956de9e97c90fc Mon Sep 17 00:00:00 2001 From: Gunju Kim Date: Thu, 29 Oct 2026 21:52:31 +0000 Subject: [PATCH] Fix console turn finish timestamps --- docs/reference.md | 11 +- internal/cli/session_terminal.go | 3 + internal/cli/session_terminal_test.go | 11 +- internal/consoleserver/frontend/app.ts | 49 +++++++- .../testdata/session_history_test.js | 110 ++++++++++++++++-- internal/consoleserver/web/app.js | 53 ++++++++- internal/consoleserver/web/styles.css | 12 +- internal/sessionruntime/history.go | 4 +- internal/sessionruntime/history_test.go | 26 +++++ internal/sessionruntime/server_test.go | 12 +- 10 files changed, 270 insertions(+), 21 deletions(-) diff --git a/docs/reference.md b/docs/reference.md index a4c6c779b..8cf0601a2 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -510,8 +510,15 @@ accepts prompt drafts while a selected Session is still Pending and enables sending after the runtime connects. It shows connection status separately from working, waiting-for-input, and interrupting progress, including elapsed time for active work, and adds the -duration to its separator after a completed turn. Duration labels are omitted -from retained history that does not contain event timestamps and from turns +duration to its separator after a completed turn. The console shows the turn's +finish time before its duration in the browser's local time zone. Retained history +shows finish times without durations. Turns from earlier days also include the +date, with the year shown when it differs from the current year. Hover over, +focus, or tap the finish time for the full date, seconds, and time zone. Press +Escape to dismiss the details. +Failed and interrupted turns are labeled accordingly. Finish times are omitted +when the event has no timestamp or the turn was interrupted by runtime recovery. +Duration labels are also omitted from turns interrupted by runtime recovery. Both clients use the same event stream and provider conversation. Both clients can stream agent and tool activity, answer user-input requests, and interrupt active work without ending the provider diff --git a/internal/cli/session_terminal.go b/internal/cli/session_terminal.go index f391d840d..5cae18e19 100644 --- a/internal/cli/session_terminal.go +++ b/internal/cli/session_terminal.go @@ -403,6 +403,9 @@ func runSessionPlainTerminalWithWidth(ctx context.Context, input io.Reader, outp if event.Status == "interrupted" { write("%s\n", formatter.warning("Turn interrupted.")) } + if pageEvent { + continue + } if elapsed, ok := sessionTerminalTurnElapsed(activeTurnID, activeTurnStarted, event, time.Now(), replayingHistory, recoveredCompletion); ok { write("%s\n\n", formatter.turnSeparator(elapsed, terminalWidth())) activeTurnID = "" diff --git a/internal/cli/session_terminal_test.go b/internal/cli/session_terminal_test.go index 9b3a3e780..8e00628c7 100644 --- a/internal/cli/session_terminal_test.go +++ b/internal/cli/session_terminal_test.go @@ -149,18 +149,23 @@ func TestSessionPlainTerminalDoesNotRepeatStreamedToolOutput(t *testing.T) { } func TestSessionPlainTerminalIsolatesHistoryPagesFromLiveStream(t *testing.T) { + startedAt := time.Date(2026, time.August, 8, 12, 0, 0, 0, time.UTC) + earlierCompletedAt := startedAt.Add(-2 * time.Minute) + completedAt := startedAt.Add(5*time.Minute + 19*time.Second) var events bytes.Buffer encoder := json.NewEncoder(&events) for _, event := range []sessionruntime.Event{ {Type: sessionruntime.EventHistoryStart, HistoryLimited: true, HistoryCursor: "cursor-1"}, {Type: sessionruntime.EventAssistantDelta, Text: "projected response"}, - {Type: sessionruntime.EventHistoryEnd, HistoryState: &sessionruntime.HistoryState{ActiveTurnID: "turn-1"}}, + {Type: sessionruntime.EventHistoryEnd, HistoryState: &sessionruntime.HistoryState{ActiveTurnID: "turn-1", ActiveTurnStarted: &startedAt}}, {Type: sessionruntime.EventAssistantMessage, Text: "projected response"}, {Type: sessionruntime.EventAssistantDelta, Text: "live response"}, {Type: sessionruntime.EventHistoryStart, RequestID: "history-1", HistoryPage: true}, {Type: sessionruntime.EventAssistantMessage, Text: "earlier response"}, + {Type: sessionruntime.EventTurnCompleted, Status: "completed", Timestamp: &earlierCompletedAt}, {Type: sessionruntime.EventHistoryEnd, RequestID: "history-1", HistoryPage: true}, {Type: sessionruntime.EventAssistantMessage, Text: "live response"}, + {Type: sessionruntime.EventTurnCompleted, TurnID: "turn-1", Status: "completed", Timestamp: &completedAt}, } { if err := encoder.Encode(event); err != nil { t.Fatal(err) @@ -186,6 +191,10 @@ func TestSessionPlainTerminalIsolatesHistoryPagesFromLiveStream(t *testing.T) { if !strings.Contains(got, "Earlier Session history is available. Use /history to load the previous page.") { t.Fatalf("terminal output = %q, want limited history notice", got) } + separator := formatSessionTurnSeparator(completedAt.Sub(startedAt), sessionTUIDefaultWidth) + if strings.Count(got, "Worked for") != 1 || !strings.Contains(got, separator) { + t.Fatalf("terminal output = %q, want only the live turn separator %q", got, separator) + } } func TestSessionPlainTerminalIgnoresDuplicateHistoryRequests(t *testing.T) { diff --git a/internal/consoleserver/frontend/app.ts b/internal/consoleserver/frontend/app.ts index 186fe583c..57fa16894 100644 --- a/internal/consoleserver/frontend/app.ts +++ b/internal/consoleserver/frontend/app.ts @@ -5048,6 +5048,9 @@ function replayOlderHistoryPage(events) { elements.messages = page; state.assistantSegmentByTurn = new Map(); state.assistantTextByTurn = new Map(); + state.activeTurn = false; + state.activeTurnID = ''; + state.activeTurnStartedAt = 0; state.replayingHistory = true; state.pinHistoryToBottom = false; for (const event of events) handleEvent(event); @@ -6313,6 +6316,16 @@ function renderRecovery(event) { scrollToBottom(); } +function formatTurnFinishTime(date: Date, now = new Date(Date.now())) { + const time = new Intl.DateTimeFormat(undefined, {timeStyle: 'short'}).format(date); + if (date.toDateString() === now.toDateString()) return `at ${time}`; + const day = new Intl.DateTimeFormat(undefined, { + month: 'short', day: 'numeric', + ...(date.getFullYear() === now.getFullYear() ? {} : {year: 'numeric'}), + }).format(date); + return `${day} at ${time}`; +} + function renderTurnEnd(event, recoveredCompletion = false) { const completedAt = Date.parse(event.timestamp || ''); const matchingTurn = !event.turnId || !state.activeTurnID || event.turnId === state.activeTurnID; @@ -6332,7 +6345,41 @@ function renderTurnEnd(event, recoveredCompletion = false) { const divider = document.createElement('div'); divider.className = 'turn-divider'; if (event.notificationId) divider.dataset.notificationId = event.notificationId; - if (elapsed !== null) divider.textContent = `Worked for ${formatSessionProgressElapsed(elapsed)}`; + const label = document.createElement('span'); + label.className = 'turn-divider-label'; + if (!Number.isNaN(completedAt) && !recoveredCompletion) { + const date = new Date(completedAt); + const status = event.status === 'failed' ? 'Failed' : event.status === 'interrupted' ? 'Interrupted' : 'Finished'; + const exact = `${status} ${new Intl.DateTimeFormat(undefined, {dateStyle: 'full', timeStyle: 'long'}).format(date)}`; + const details = document.createElement('details'); + details.className = 'turn-timestamp'; + const summary = document.createElement('summary'); + summary.setAttribute('aria-label', exact); + const timestamp = document.createElement('time'); + timestamp.dateTime = date.toISOString(); + timestamp.textContent = `${status} ${formatTurnFinishTime(date)}`; + summary.append(timestamp); + const full = document.createElement('span'); + full.className = 'turn-timestamp-detail'; + full.textContent = exact; + details.append(summary, full); + details.addEventListener('keydown', event => { + if (event.key !== 'Escape') return; + details.open = false; + details.dataset.dismissed = 'true'; + }); + details.addEventListener('pointerleave', () => { delete details.dataset.dismissed; }); + details.addEventListener('focusout', () => { delete details.dataset.dismissed; }); + label.append(details); + } + if (elapsed !== null) { + if (label.hasChildNodes()) label.append(document.createTextNode(' · ')); + const duration = document.createElement('span'); + duration.className = 'turn-duration'; + duration.textContent = `Worked for ${formatSessionProgressElapsed(elapsed)}`; + label.append(duration); + } + if (label.hasChildNodes()) divider.append(label); elements.messages.append(divider); scrollToBottom(); scheduleCompletionRead(); diff --git a/internal/consoleserver/testdata/session_history_test.js b/internal/consoleserver/testdata/session_history_test.js index e50379532..d244ba3cc 100644 --- a/internal/consoleserver/testdata/session_history_test.js +++ b/internal/consoleserver/testdata/session_history_test.js @@ -1542,7 +1542,83 @@ function testTurnDividerShowsDuration() { handleEvent({type: 'turn.completed', turnId: 'turn-1', status: 'completed', timestamp: '2026-08-08T12:05:19Z'}); const divider = elements.messages.querySelector('.turn-divider'); - assert.equal(divider.textContent, 'Worked for 5m 19s'); + const finished = formatTurnFinishTime(new Date('2026-08-08T12:05:19Z')); + assert.equal(divider.querySelector('summary').textContent, `Finished ${finished}`); + assert.equal(divider.querySelector('.turn-duration').textContent, 'Worked for 5m 19s'); + const label = divider.querySelector('.turn-divider-label'); + assert.equal(label.firstChild.tag, 'details'); + assert.equal(label.children[1].textContent, ' · '); + assert.equal(label.lastChild.className, 'turn-duration'); +} + +function testTurnFinishTimeFormatting() { + const now = new Date(2026, 7, 8, 12, 6); + const today = new Date(2026, 7, 8, 12, 5, 19); + const yesterday = new Date(2026, 7, 7, 23, 59); + const lastYear = new Date(2025, 7, 8, 12, 5); + const shortTime = date => new Intl.DateTimeFormat(undefined, {timeStyle: 'short'}).format(date); + const shortDate = date => new Intl.DateTimeFormat(undefined, {month: 'short', day: 'numeric'}).format(date); + const fullDate = date => new Intl.DateTimeFormat(undefined, {month: 'short', day: 'numeric', year: 'numeric'}).format(date); + assert.equal(formatTurnFinishTime(today, now), `at ${shortTime(today)}`); + assert.equal(formatTurnFinishTime(yesterday, now), `${shortDate(yesterday)} at ${shortTime(yesterday)}`); + assert.equal(formatTurnFinishTime(lastYear, now), `${fullDate(lastYear)} at ${shortTime(lastYear)}`); + assert.equal(formatTurnFinishTime(yesterday, new Date(2026, 7, 8, 0, 0)), `${shortDate(yesterday)} at ${shortTime(yesterday)}`); +} + +function testTurnDividerShowsFinishTimestamp() { + const timestamp = '2026-08-08T12:05:19Z'; + const date = new Date(timestamp); + const visible = formatTurnFinishTime(date); + const exact = new Intl.DateTimeFormat(undefined, {dateStyle: 'full', timeStyle: 'long'}).format(date); + for (const replay of [false, true]) { + for (const [status, label] of [['completed', 'Finished'], ['failed', 'Failed'], ['interrupted', 'Interrupted']]) { + resetHarness(); + if (replay) handleEvent({type: 'history.start'}); + handleEvent({type: 'turn.completed', turnId: 'turn-1', status, timestamp, notificationId: 'completion-1'}); + + const divider = elements.messages.querySelector('.turn-divider'); + const time = divider.querySelector('time'); + assert.ok(time, `missing ${status} timestamp during ${replay ? 'replay' : 'live rendering'}`); + assert.equal(time.dateTime, date.toISOString()); + const summary = divider.querySelector('summary'); + assert.equal(summary.textContent, `${label} ${visible}`); + assert.equal(summary.attributes.get('aria-label'), `${label} ${exact}`); + assert.equal(divider.querySelector('.turn-timestamp-detail').textContent, `${label} ${exact}`); + assert.equal(divider.querySelector('.turn-duration'), null); + assert.equal(divider.dataset.notificationId, 'completion-1'); + } + } +} + +function testTurnTimestampDismissal() { + resetHarness(); + handleEvent({type: 'turn.completed', status: 'completed', timestamp: '2026-08-08T12:05:19Z'}); + const details = elements.messages.querySelector('.turn-timestamp'); + details.open = true; + details.listeners.get('keydown')({key: 'Enter'}); + assert.equal(details.open, true); + details.listeners.get('keydown')({key: 'Escape'}); + assert.equal(details.open, false); + assert.equal(details.dataset.dismissed, 'true'); + details.listeners.get('pointerleave')(); + assert.equal(details.dataset.dismissed, undefined); + details.listeners.get('keydown')({key: 'Escape'}); + details.listeners.get('focusout')(); + assert.equal(details.dataset.dismissed, undefined); +} + +function testTurnDividerOmitsUnknownFinishTimestamp() { + for (const replay of [false, true]) { + for (const timestamp of [undefined, 'invalid']) { + resetHarness(); + if (replay) handleEvent({type: 'history.start'}); + handleEvent({type: 'turn.completed', turnId: 'turn-1', status: 'completed', timestamp}); + + const divider = elements.messages.querySelector('.turn-divider'); + assert.equal(divider.querySelector('time'), null); + assert.equal(divider.textContent, ''); + } + } } function testUntimestampedHistoryDividerOmitsDuration() { @@ -1573,15 +1649,18 @@ function testUntimestampedLiveTurnUsesLocalDuration() { } function testRuntimeRecoveryDividerOmitsDuration() { - resetHarness(); - handleEvent({type: 'history.start'}); - handleEvent({type: 'turn.started', turnId: 'turn-1', timestamp: '2026-08-08T12:00:00Z'}); - handleEvent({type: 'runtime.recovered', text: 'Session runtime restarted'}); - handleEvent({type: 'input.resolved', turnId: 'turn-1', inputId: 'input-1', status: 'cancelled'}); - handleEvent({type: 'turn.completed', turnId: 'turn-1', status: 'interrupted', timestamp: '2026-08-08T13:00:00Z'}); - - const divider = elements.messages.querySelector('.turn-divider'); - assert.equal(divider.textContent, ''); + for (const replay of [false, true]) { + resetHarness(); + if (replay) handleEvent({type: 'history.start'}); + handleEvent({type: 'turn.started', turnId: 'turn-1', timestamp: '2026-08-08T12:00:00Z'}); + handleEvent({type: 'runtime.recovered', text: 'Session runtime restarted'}); + handleEvent({type: 'input.resolved', turnId: 'turn-1', inputId: 'input-1', status: 'cancelled'}); + handleEvent({type: 'turn.completed', turnId: 'turn-1', status: 'interrupted', timestamp: '2026-08-08T13:00:00Z'}); + + const divider = elements.messages.querySelector('.turn-divider'); + assert.equal(divider.textContent, ''); + assert.equal(divider.querySelector('time'), null); + } } function testSessionResetClearsPromptDraft() { @@ -1707,13 +1786,16 @@ function testOlderHistoryPageIsPrependedWithoutChangingLiveState() { const previousHeight = elements.messages.scrollHeight; handleEvent({type: 'user.message', id: 1, text: 'earlier request'}); handleEvent({type: 'assistant.message', id: 2, text: 'earlier response'}); - handleEvent({type: 'turn.completed', id: 3, status: 'interrupted'}); + handleEvent({type: 'turn.completed', id: 3, status: 'interrupted', timestamp: '2026-08-08T11:58:00Z'}); assert.doesNotMatch(elements.messages.textContent, /earlier response/); handleEvent({type: 'history.end', historyPage: true, requestId: sent[0].requestId}); const text = elements.messages.textContent; assert.ok(text.indexOf('earlier request') < text.indexOf('recent response')); assert.ok(text.indexOf('earlier response') < text.indexOf('recent response')); + const divider = elements.messages.querySelector('.turn-divider'); + assert.equal(divider.querySelector('time').dateTime, '2026-08-08T11:58:00.000Z'); + assert.equal(divider.querySelector('.turn-duration'), null); assert.equal(state.activeTurn, true); assert.equal(state.activeTurnID, 'turn-live'); assert.equal(state.activeTurnStartedAt, Date.parse('2026-08-08T12:00:00Z')); @@ -1729,6 +1811,8 @@ function testOlderHistoryPageIsPrependedWithoutChangingLiveState() { requestOlderHistory(); assert.equal(sent.length, 2); assert.equal(sent[1].historyCursor, 'cursor-2'); + handleEvent({type: 'turn.completed', turnId: 'turn-live', status: 'completed', timestamp: '2026-08-08T12:05:19Z'}); + assert.equal(elements.messages.querySelector('.turn-duration').textContent, 'Worked for 5m 19s'); } function testReselectRefreshesStatusPlaceholder() { @@ -2430,6 +2514,10 @@ testSessionProgressElapsedFormatting(); testRuntimeStatusLifecycle(); testRuntimeStatusTreatsOmittedContextTokensAsZero(); testTurnDividerShowsDuration(); +testTurnFinishTimeFormatting(); +testTurnDividerShowsFinishTimestamp(); +testTurnTimestampDismissal(); +testTurnDividerOmitsUnknownFinishTimestamp(); testUntimestampedHistoryDividerOmitsDuration(); testUntimestampedLiveTurnUsesLocalDuration(); testRuntimeRecoveryDividerOmitsDuration(); diff --git a/internal/consoleserver/web/app.js b/internal/consoleserver/web/app.js index 812aa1f77..aae0df40e 100644 --- a/internal/consoleserver/web/app.js +++ b/internal/consoleserver/web/app.js @@ -4757,6 +4757,9 @@ spec: elements.messages = page; state.assistantSegmentByTurn = new Map(); state.assistantTextByTurn = new Map(); + state.activeTurn = false; + state.activeTurnID = ''; + state.activeTurnStartedAt = 0; state.replayingHistory = true; state.pinHistoryToBottom = false; for (const event of events) @@ -6028,6 +6031,16 @@ spec: elements.messages.append(card); scrollToBottom(); } + function formatTurnFinishTime(date, now = new Date(Date.now())) { + const time = new Intl.DateTimeFormat(undefined, { timeStyle: 'short' }).format(date); + if (date.toDateString() === now.toDateString()) + return `at ${time}`; + const day = new Intl.DateTimeFormat(undefined, { + month: 'short', day: 'numeric', + ...(date.getFullYear() === now.getFullYear() ? {} : { year: 'numeric' }), + }).format(date); + return `${day} at ${time}`; + } function renderTurnEnd(event, recoveredCompletion = false) { const completedAt = Date.parse(event.timestamp || ''); const matchingTurn = !event.turnId || !state.activeTurnID || event.turnId === state.activeTurnID; @@ -6049,8 +6062,44 @@ spec: divider.className = 'turn-divider'; if (event.notificationId) divider.dataset.notificationId = event.notificationId; - if (elapsed !== null) - divider.textContent = `Worked for ${formatSessionProgressElapsed(elapsed)}`; + const label = document.createElement('span'); + label.className = 'turn-divider-label'; + if (!Number.isNaN(completedAt) && !recoveredCompletion) { + const date = new Date(completedAt); + const status = event.status === 'failed' ? 'Failed' : event.status === 'interrupted' ? 'Interrupted' : 'Finished'; + const exact = `${status} ${new Intl.DateTimeFormat(undefined, { dateStyle: 'full', timeStyle: 'long' }).format(date)}`; + const details = document.createElement('details'); + details.className = 'turn-timestamp'; + const summary = document.createElement('summary'); + summary.setAttribute('aria-label', exact); + const timestamp = document.createElement('time'); + timestamp.dateTime = date.toISOString(); + timestamp.textContent = `${status} ${formatTurnFinishTime(date)}`; + summary.append(timestamp); + const full = document.createElement('span'); + full.className = 'turn-timestamp-detail'; + full.textContent = exact; + details.append(summary, full); + details.addEventListener('keydown', event => { + if (event.key !== 'Escape') + return; + details.open = false; + details.dataset.dismissed = 'true'; + }); + details.addEventListener('pointerleave', () => { delete details.dataset.dismissed; }); + details.addEventListener('focusout', () => { delete details.dataset.dismissed; }); + label.append(details); + } + if (elapsed !== null) { + if (label.hasChildNodes()) + label.append(document.createTextNode(' · ')); + const duration = document.createElement('span'); + duration.className = 'turn-duration'; + duration.textContent = `Worked for ${formatSessionProgressElapsed(elapsed)}`; + label.append(duration); + } + if (label.hasChildNodes()) + divider.append(label); elements.messages.append(divider); scrollToBottom(); scheduleCompletionRead(); diff --git a/internal/consoleserver/web/styles.css b/internal/consoleserver/web/styles.css index c35bf2a02..da3821980 100644 --- a/internal/consoleserver/web/styles.css +++ b/internal/consoleserver/web/styles.css @@ -466,12 +466,22 @@ button { color: inherit; } .recovery-card { padding: 12px 14px; border-color: rgba(197,138,62,.3); background: #fff9ef; color: #8a5b1f; font-size: 12px; line-height: 1.5; } .goal-card { padding: 12px 14px; border-color: rgba(70,116,91,.25); background: #f3faf6; color: var(--ink); font-size: 12px; line-height: 1.5; } .goal-card strong { display: block; margin-bottom: 3px; color: var(--accent); } -.turn-divider { margin: 2px 0 22px 40px; color: var(--faint); font-size: 10px; line-height: 1; white-space: nowrap; } +.turn-divider { margin: 2px 0 22px 40px; color: var(--muted); font-size: 12px; line-height: 1.4; } .turn-divider:empty { height: 1px; background: linear-gradient(90deg,var(--line),transparent); } .turn-divider:not(:empty) { display: flex; align-items: center; gap: 8px; } .turn-divider:not(:empty)::before, .turn-divider:not(:empty)::after { height: 1px; background: var(--line); content: ""; } .turn-divider:not(:empty)::before { flex: 0 0 10px; } .turn-divider:not(:empty)::after { flex: 1; } +.turn-divider-label { min-width: 0; } +.turn-timestamp { position: relative; display: inline-block; max-width: 100%; } +.turn-timestamp > summary { display: inline-flex; align-items: center; min-height: 24px; list-style: none; cursor: pointer; text-decoration: underline dotted var(--faint); text-underline-offset: 3px; border-radius: 3px; } +.turn-timestamp > summary::-webkit-details-marker { display: none; } +.turn-timestamp > summary:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; } +.turn-timestamp-detail, .turn-timestamp > summary::after { position: absolute; z-index: 5; top: calc(100% + 4px); left: 0; width: max-content; max-width: min(320px, calc(100vw - 64px)); padding: 8px 10px; border: 1px solid var(--line); border-radius: 8px; background: var(--card); color: var(--ink); box-shadow: var(--shadow); font-size: 12px; line-height: 1.5; white-space: normal; text-decoration: none; } +.turn-timestamp:not([open]):not([data-dismissed]) > summary:focus-visible::after { content: attr(aria-label); } +@media (hover: hover) { + .turn-timestamp:not([open]):not([data-dismissed]) > summary:hover::after { content: attr(aria-label); } +} .composer-wrap { position: relative; z-index: 2; min-width: 0; grid-row: 3; padding: 12px max(24px, calc((100% - 960px) / 2)) 10px; background: linear-gradient(transparent, var(--canvas) 24%); } .composer-wrap.attachment-drop-target .composer { border-color: var(--accent); box-shadow: 0 0 0 3px rgba(0,0,0,.1); } .session-progress { display: flex; align-items: center; gap: 5px; min-height: 18px; margin: 0 4px 7px; color: var(--muted); font-size: 11px; font-weight: 650; font-variant-numeric: tabular-nums; } diff --git a/internal/sessionruntime/history.go b/internal/sessionruntime/history.go index cc9ce817e..83c937d55 100644 --- a/internal/sessionruntime/history.go +++ b/internal/sessionruntime/history.go @@ -418,7 +418,9 @@ func historyItemWireBytes(item historyItem) int { } func normalizedHistoryEvent(event Event) Event { - event.Timestamp = nil + if event.Type != EventTurnCompleted { + event.Timestamp = nil + } event.RequestID = "" event.TurnID = "" event.FirstEventID = 0 diff --git a/internal/sessionruntime/history_test.go b/internal/sessionruntime/history_test.go index b92dcc24b..ce63d44d7 100644 --- a/internal/sessionruntime/history_test.go +++ b/internal/sessionruntime/history_test.go @@ -179,6 +179,32 @@ func TestProjectHistoryBoundsCompletionNotices(t *testing.T) { } } +func TestProjectHistoryPreservesCompletionTimestamps(t *testing.T) { + completedAt := time.Date(2026, time.August, 8, 12, 5, 19, 0, time.UTC) + for _, status := range []string{"completed", "failed", "interrupted"} { + t.Run(status, func(t *testing.T) { + events := []Event{ + {ID: 1, Type: EventTurnCompleted, TurnID: "turn-1", Status: status, Timestamp: &completedAt}, + {ID: 2, Type: EventTurnCompleted, TurnID: "turn-2", Status: status}, + } + items, _, _ := projectHistory(events) + page, cursor := historyItemsPage(items, 0, 1, DefaultHistoryByteLimit) + assertHistoryEventIDs(t, page, 2) + if page[0].Timestamp != nil { + t.Fatalf("untimestamped completion has timestamp %v", page[0].Timestamp) + } + page, cursor = historyItemsPage(items, cursor, 1, DefaultHistoryByteLimit) + assertHistoryEventIDs(t, page, 1) + if cursor != 0 || page[0].Timestamp == nil || !page[0].Timestamp.Equal(completedAt) { + t.Fatalf("completion timestamp = %v, cursor = %d, want %v and end of history", page[0].Timestamp, cursor, completedAt) + } + if events[0].Timestamp == nil || !events[0].Timestamp.Equal(completedAt) { + t.Fatal("history projection modified the source timestamp") + } + }) + } +} + func TestProjectHistoryBoundsPendingMessage(t *testing.T) { _, state, _ := projectHistory([]Event{{ ID: 1, diff --git a/internal/sessionruntime/server_test.go b/internal/sessionruntime/server_test.go index bd37b30ae..3f70d6ce1 100644 --- a/internal/sessionruntime/server_test.go +++ b/internal/sessionruntime/server_test.go @@ -2807,17 +2807,19 @@ func TestServerCompletionNotificationIdentity(t *testing.T) { func TestServerPagesProjectedHistoryItems(t *testing.T) { journal := NewJournal() defer journal.Close() + firstCompletedAt := time.Date(2026, time.August, 8, 12, 5, 19, 0, time.UTC) + secondCompletedAt := firstCompletedAt.Add(time.Hour) for _, event := range []Event{ {Type: EventUserMessage, TurnID: "turn-1", Text: "first"}, {Type: EventTurnStarted, TurnID: "turn-1", Status: "running"}, {Type: EventAssistantDelta, TurnID: "turn-1", Text: "first "}, {Type: EventAssistantDelta, TurnID: "turn-1", Text: "answer"}, {Type: EventAssistantMessage, TurnID: "turn-1", Text: "first answer"}, - {Type: EventTurnCompleted, TurnID: "turn-1", Status: "completed"}, + {Type: EventTurnCompleted, TurnID: "turn-1", Status: "completed", Timestamp: &firstCompletedAt}, {Type: EventUserMessage, TurnID: "turn-2", Text: "second"}, {Type: EventTurnStarted, TurnID: "turn-2", Status: "running"}, {Type: EventAssistantMessage, TurnID: "turn-2", Text: "second answer"}, - {Type: EventTurnCompleted, TurnID: "turn-2", Status: "completed"}, + {Type: EventTurnCompleted, TurnID: "turn-2", Status: "completed", Timestamp: &secondCompletedAt}, } { if err := journal.Append(event); err != nil { t.Fatal(err) @@ -2875,6 +2877,9 @@ func TestServerPagesProjectedHistoryItems(t *testing.T) { if retained[2].NotificationID != journal.LatestNotification().ID { t.Fatalf("completion notification ID = %q, want %q", retained[2].NotificationID, journal.LatestNotification().ID) } + if retained[2].Timestamp == nil || !retained[2].Timestamp.Equal(secondCompletedAt) { + t.Fatalf("completion timestamp = %v, want %v", retained[2].Timestamp, secondCompletedAt) + } if end.HistoryState == nil { t.Fatalf("history end = %#v, want state snapshot", end) } @@ -2920,6 +2925,9 @@ func TestServerPagesProjectedHistoryItems(t *testing.T) { if retained[2].NotificationID != journal.notificationID(6) { t.Fatalf("older completion notification ID = %q, want %q", retained[2].NotificationID, journal.notificationID(6)) } + if retained[2].Timestamp == nil || !retained[2].Timestamp.Equal(firstCompletedAt) { + t.Fatalf("older completion timestamp = %v, want %v", retained[2].Timestamp, firstCompletedAt) + } } func TestServerPreservesStateOutsideProjectedHistoryPage(t *testing.T) {