0}
runtimeContext={context.runtimeContext}
rewind={
- nativeChatRowOffersRewind(message, slot, deliveryNotice !== undefined)
+ nativeChatRowOffersRewind(message, slot, deliveryNotice !== undefined) &&
+ (!rewind?.eligibleItemIds || rewind.eligibleItemIds.has(message.id))
? rewind
: undefined
}
diff --git a/src/renderer/src/components/native-chat/structured-agent-session-message-projection.ts b/src/renderer/src/components/native-chat/structured-agent-session-message-projection.ts
index e4814640de47..25ff332ceb87 100644
--- a/src/renderer/src/components/native-chat/structured-agent-session-message-projection.ts
+++ b/src/renderer/src/components/native-chat/structured-agent-session-message-projection.ts
@@ -1,3 +1,4 @@
+import { agentSessionCurrentContextRows } from '../../../../shared/agent-session-context-clear'
import type {
AgentJournalRenderItem,
AgentJournalSubmission
@@ -29,7 +30,7 @@ export type StructuredPromptItem = AgentJournalRenderItem & {
export function pendingStructuredSessionPrompts(
items: AgentJournalRenderItem[]
): StructuredPromptItem[] {
- return items.filter(
+ return agentSessionCurrentContextRows(items).items.filter(
(item): item is StructuredPromptItem =>
(item.body.kind === 'approval' || item.body.kind === 'question') &&
item.body.resolution.state === 'pending'
diff --git a/src/renderer/src/components/native-chat/use-native-chat-rewind.test.tsx b/src/renderer/src/components/native-chat/use-native-chat-rewind.test.tsx
index b30730b2056d..ab6a8366de3c 100644
--- a/src/renderer/src/components/native-chat/use-native-chat-rewind.test.tsx
+++ b/src/renderer/src/components/native-chat/use-native-chat-rewind.test.tsx
@@ -101,6 +101,43 @@ describe('structured chat rewind', () => {
expect(toastError).not.toHaveBeenCalled()
})
+ it('offers rewind only after the clear, including when its divider is off the loaded page', async () => {
+ const props = input()
+ const clear = {
+ ...item('clear', 11),
+ body: {
+ kind: 'status' as const,
+ text: 'Context cleared',
+ contextClear: { operationId: 'clear', afterFence: 1, clearedAt: 1 }
+ }
+ }
+ const view = render({
+ ...props,
+ state: { ...props.state, items: [item('user', 10), clear, item('later', 12)] }
+ })
+ expect([...(view.result.current.surface?.eligibleItemIds ?? [])]).toEqual(['later'])
+ const confirm = vi.fn().mockResolvedValue(true)
+ await act(() => view.result.current.request('user', confirm))
+ expect(confirm).not.toHaveBeenCalled()
+ view.rerender({ ...props, contextFloor: { epoch: 'old', sequence: 11 } })
+ expect([...(view.result.current.surface?.eligibleItemIds ?? [])]).toEqual(['later'])
+ })
+
+ it('awaits the suffix sequence in the same epoch before unblocking', async () => {
+ const props = input()
+ props.send.mockResolvedValue({
+ kind: 'done',
+ value: { itemId: 'user', epoch: 'old', sequence: 15 }
+ })
+ const view = render(props)
+ await act(() => view.result.current.request('user', vi.fn().mockResolvedValue(true)))
+ expect(view.result.current.pending).toBe(true)
+ expect(view.result.current.blockedRef.current).toBe(true)
+ view.rerender({ ...props, state: { ...props.state, cursor: { epoch: 'old', sequence: 15 } } })
+ expect(view.result.current.pending).toBe(false)
+ expect(view.result.current.blockedRef.current).toBe(false)
+ })
+
it('offers nothing for a row the journal does not hold as a user message', async () => {
const props = input()
const confirm = vi.fn()
diff --git a/src/renderer/src/components/native-chat/use-native-chat-rewind.ts b/src/renderer/src/components/native-chat/use-native-chat-rewind.ts
index bd71d991fe3e..e547dec2e70b 100644
--- a/src/renderer/src/components/native-chat/use-native-chat-rewind.ts
+++ b/src/renderer/src/components/native-chat/use-native-chat-rewind.ts
@@ -16,24 +16,22 @@ import {
nativeChatRewindTimeoutCopy,
nativeChatRewindUnavailableCopy
} from './native-chat-rewind-copy'
-import type {
- StructuredAgentSessionWrite,
- StructuredAgentSessionWriteOutcome
-} from './use-structured-agent-session-mutate'
+import type { StructuredAgentSessionWriteOutcome } from './use-structured-agent-session-mutate'
import { returnMessageToComposer } from './structured-agent-session-message-hand-back'
import { nativeChatRewindOffered } from './native-chat-rewind-eligibility'
-import type { RuntimeClientTarget } from '@/runtime/runtime-client-target'
-import { useStructuredAgentSessionHostRecoversRewindOnSend } from '@/runtime/structured-agent-session-host-capability'
+import { latestAgentSessionContextClearSequence } from '../../../../shared/agent-session-context-clear'
+import type { AgentJournalCursor } from '../../../../shared/agent-session-journal-types'
export type NativeChatRewindSurface = {
disabledReason: string | null
+ eligibleItemIds?: ReadonlySet
request: (itemId: string, confirm: ConfirmationDialogContextValue) => Promise
}
/** How long a confirmed rewind waits for its new conversation before letting go. */
export const NATIVE_CHAT_REWIND_RESET_TIMEOUT_MS = 120_000
-type RewindInput = {
+export type RewindInput = {
sessionId: string
/** The composer the discarded message returns to, after whatever is typed there. */
composerScopeKey?: string
@@ -42,6 +40,7 @@ type RewindInput = {
/** Whether the pane is shown, and so reading; absent means shown. */
isVisible?: boolean
state: StructuredAgentSessionState
+ contextFloor?: AgentJournalCursor
/** Undefined until the host has answered for the current runtime. */
support: AgentSessionRewindSupport | undefined
/** The host's in-doubt latch: disables the action, never sending. */
@@ -72,9 +71,23 @@ function rewindFailureReason(failure: AgentSessionWriteFailure): string | undefi
: undefined
}
-function isRewindTarget(state: StructuredAgentSessionState, itemId: string): boolean {
- return state.items.some(
- (item) => item.itemId === itemId && item.body.kind === 'message' && item.body.role === 'user'
+function isRewindTarget(input: RewindInput, itemId: string): boolean {
+ const floor = rewindContextFloor(input)
+ return input.state.items.some(
+ (item) =>
+ item.itemId === itemId &&
+ item.sequence > floor &&
+ item.body.kind === 'message' &&
+ item.body.role === 'user'
+ )
+}
+
+function rewindContextFloor(input: RewindInput): number {
+ return Math.max(
+ latestAgentSessionContextClearSequence(input.state.items),
+ input.contextFloor && input.contextFloor.epoch === input.state.epoch
+ ? input.contextFloor.sequence
+ : 0
)
}
@@ -118,9 +131,15 @@ export function useNativeChatRewind(input: RewindInput) {
const inFlight = useRef(false)
const [sending, setSending] = useState(false)
// A confirmed rewind whose new conversation has not arrived yet.
- const [awaiting, setAwaiting] = useState<{ sessionId: string; epoch: string } | null>(null)
+ const [awaiting, setAwaiting] = useState<{
+ sessionId: string
+ epoch: string
+ sequence?: number
+ } | null>(null)
const awaitingReset =
- awaiting?.sessionId === input.sessionId && awaiting.epoch === input.state.epoch
+ awaiting?.sessionId === input.sessionId &&
+ awaiting.epoch === input.state.epoch &&
+ (awaiting.sequence === undefined || (input.state.cursor?.sequence ?? 0) < awaiting.sequence)
// A hidden pane stops reading, so its new conversation cannot arrive; that is no failure to report.
const hiddenWhileAwaiting = useRef(false)
const visible = input.isVisible !== false
@@ -155,7 +174,7 @@ export function useNativeChatRewind(input: RewindInput) {
inFlight.current ||
blockedRef.current ||
blockedReason(captured) ||
- !isRewindTarget(captured.state, itemId)
+ !isRewindTarget(captured, itemId)
) {
return
}
@@ -204,8 +223,22 @@ export function useNativeChatRewind(input: RewindInput) {
}
if (outcome.kind === 'done') {
giveBack()
- keepBlocked = !reset
- setAwaiting(reset ? null : { sessionId: captured.sessionId, epoch: expectedEpoch })
+ const caughtUp =
+ reset ||
+ (outcome.value.sequence !== undefined &&
+ (latest.current.state.cursor?.sequence ?? 0) >= outcome.value.sequence)
+ keepBlocked = !caughtUp
+ setAwaiting(
+ caughtUp
+ ? null
+ : {
+ sessionId: captured.sessionId,
+ epoch: expectedEpoch,
+ ...(outcome.value.sequence !== undefined
+ ? { sequence: outcome.value.sequence }
+ : {})
+ }
+ )
return
}
if (outcome.kind === 'dropped') {
@@ -248,10 +281,20 @@ export function useNativeChatRewind(input: RewindInput) {
[admitsSend]
)
const offered = nativeChatRewindOffered(input.support)
+ const floor = rewindContextFloor(input)
+ const eligibleItemIds = useMemo(
+ () =>
+ floor > 0
+ ? new Set(
+ input.state.items.filter((item) => item.sequence > floor).map((item) => item.itemId)
+ )
+ : undefined,
+ [floor, input.state.items]
+ )
// Rows re-render only when this changes; none get the action where the provider can never rewind.
const surface = useMemo(
- () => (offered ? { disabledReason, request } : undefined),
- [offered, disabledReason, request]
+ () => (offered ? { disabledReason, request, eligibleItemIds } : undefined),
+ [offered, disabledReason, request, eligibleItemIds]
)
return { request, admitsSend, unlessBlocked, surface, disabledReason, pending, blockedRef }
}
@@ -263,52 +306,3 @@ export type NativeChatRewindHost = {
onMessageReturned?: () => void
isVisible?: boolean
}
-
-/** The rewind a structured session's user rows offer, sent through the session's own writes. */
-export function useStructuredAgentSessionRewind(
- args: Omit &
- NativeChatRewindHost & { target: RuntimeClientTarget; write: StructuredAgentSessionWrite }
-) {
- const {
- blocked,
- composerScopeKey,
- hostBlockedReason,
- isVisible,
- onMessageReturned,
- sessionId,
- state,
- target,
- write
- } = args
- // An in-doubt rewind returns the prompt for the next send to settle; only a host that settles it
- // on a send may offer one. Unknown hides it, as unresolved support does.
- const support = useStructuredAgentSessionHostRecoversRewindOnSend(target)
- ? args.support
- : undefined
- const input = useMemo(
- () => ({
- sessionId,
- composerScopeKey,
- onMessageReturned,
- isVisible,
- hostBlockedReason: hostBlockedReason ?? undefined,
- state,
- support,
- blocked,
- send: (fields) =>
- write('agentSession.rewind', 'agentSession.rewind', fields)
- }),
- [
- blocked,
- composerScopeKey,
- hostBlockedReason,
- isVisible,
- onMessageReturned,
- sessionId,
- state,
- support,
- write
- ]
- )
- return useNativeChatRewind(input)
-}
diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-command-write.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-command-write.ts
index 36d798414a89..a0970544f227 100644
--- a/src/renderer/src/components/native-chat/use-structured-agent-session-command-write.ts
+++ b/src/renderer/src/components/native-chat/use-structured-agent-session-command-write.ts
@@ -10,12 +10,7 @@ import type {
} from './use-structured-agent-session-mutate'
import { holdStructuredAgentSessionSends } from './structured-agent-session-pending-sends'
-/**
- * Sends a conversation command. A /clear keeps the chat's sends out while it runs, as its host
- * refuses them, so text typed meanwhile stays in the box. Once it moves the chat to a new
- * conversation, the old one, which its host also refuses, takes nothing until this view leaves it.
- * Either way the hold ends by the command's deadline: the local call has none of its own.
- */
+/** Holds sends during clear; an older host's replacement keeps the hold until the view leaves. */
export function useStructuredAgentSessionCommandWrite(
sessionId: string,
write: StructuredAgentSessionWrite
diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-context-usage.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-context-usage.ts
index 23f6166a7087..80db32368162 100644
--- a/src/renderer/src/components/native-chat/use-structured-agent-session-context-usage.ts
+++ b/src/renderer/src/components/native-chat/use-structured-agent-session-context-usage.ts
@@ -10,10 +10,18 @@ import {
* filling a part the window lacks. A host without that answer leaves the window's alone. */
export function useStructuredAgentSessionContextUsage(
journalItems: readonly AgentJournalRenderItem[],
- support: AgentSessionOptionsResult['contextUsage']
+ support: AgentSessionOptionsResult['contextUsage'],
+ journalEpoch?: string | null
): StructuredAgentContextUsage | null {
return useMemo(
- () => selectStructuredAgentContextUsage(journalItems, support?.current),
- [journalItems, support]
+ () =>
+ selectStructuredAgentContextUsage(
+ journalItems,
+ support?.current,
+ support?.contextFloor && (!journalEpoch || support.contextFloor.epoch === journalEpoch)
+ ? support.contextFloor.sequence
+ : 0
+ ),
+ [journalItems, support, journalEpoch]
)
}
diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-rewind.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-rewind.ts
new file mode 100644
index 000000000000..9f86b1e0b12a
--- /dev/null
+++ b/src/renderer/src/components/native-chat/use-structured-agent-session-rewind.ts
@@ -0,0 +1,62 @@
+import { useMemo } from 'react'
+import type { AgentSessionRewindResult } from '../../../../shared/agent-session-rewind'
+import type { RuntimeClientTarget } from '@/runtime/runtime-client-target'
+import { useStructuredAgentSessionHostRecoversRewindOnSend } from '@/runtime/structured-agent-session-host-capability'
+import type { StructuredAgentSessionWrite } from './use-structured-agent-session-mutate'
+import {
+ useNativeChatRewind,
+ type NativeChatRewindHost,
+ type RewindInput
+} from './use-native-chat-rewind'
+
+/** The rewind a structured session's user rows offer, sent through the session's own writes. */
+export function useStructuredAgentSessionRewind(
+ args: Omit &
+ NativeChatRewindHost & { target: RuntimeClientTarget; write: StructuredAgentSessionWrite }
+) {
+ const {
+ blocked,
+ composerScopeKey,
+ contextFloor,
+ hostBlockedReason,
+ isVisible,
+ onMessageReturned,
+ sessionId,
+ state,
+ target,
+ write
+ } = args
+ // An in-doubt rewind returns the prompt for the next send to settle; only a host that settles it
+ // on a send may offer one. Unknown hides it, as unresolved support does.
+ const support = useStructuredAgentSessionHostRecoversRewindOnSend(target)
+ ? args.support
+ : undefined
+ const input = useMemo(
+ () => ({
+ sessionId,
+ composerScopeKey,
+ contextFloor,
+ onMessageReturned,
+ isVisible,
+ hostBlockedReason: hostBlockedReason ?? undefined,
+ state,
+ support,
+ blocked,
+ send: (fields) =>
+ write('agentSession.rewind', 'agentSession.rewind', fields)
+ }),
+ [
+ blocked,
+ composerScopeKey,
+ contextFloor,
+ hostBlockedReason,
+ isVisible,
+ onMessageReturned,
+ sessionId,
+ state,
+ support,
+ write
+ ]
+ )
+ return useNativeChatRewind(input)
+}
diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-thread-goal.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-thread-goal.ts
index a963a3b9c9ae..d3dc5bd00098 100644
--- a/src/renderer/src/components/native-chat/use-structured-agent-session-thread-goal.ts
+++ b/src/renderer/src/components/native-chat/use-structured-agent-session-thread-goal.ts
@@ -22,14 +22,22 @@ export type StructuredAgentSessionThreadGoal = {
export function useStructuredAgentSessionThreadGoal(args: {
journalItems: readonly AgentJournalRenderItem[]
support: AgentSessionOptionsResult['threadGoal']
+ journalEpoch?: string | null
mutate: StructuredAgentSessionMutate
}): StructuredAgentSessionThreadGoal | null {
- const { journalItems, mutate, support } = args
+ const { journalItems, mutate, support, journalEpoch } = args
const [pending, setPending] = useState(false)
const pendingRef = useRef(false)
// The loaded window reaches the live head, so a goal row in it is newer than the
// host's whole-journal answer; that answer covers only rows older than the window.
- const loaded = useMemo(() => currentAgentSessionThreadGoal(journalItems), [journalItems])
+ const floor =
+ support?.contextFloor && (!journalEpoch || support.contextFloor.epoch === journalEpoch)
+ ? support.contextFloor.sequence
+ : 0
+ const loaded = useMemo(
+ () => currentAgentSessionThreadGoal(journalItems, floor),
+ [journalItems, floor]
+ )
const goal = loaded === undefined ? (support?.current ?? null) : loaded
const change = useCallback(
async (next: AgentSessionThreadGoalChange): Promise => {
diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-transport-state.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-transport-state.ts
index fbc3a3674e23..f9e80d1b242f 100644
--- a/src/renderer/src/components/native-chat/use-structured-agent-session-transport-state.ts
+++ b/src/renderer/src/components/native-chat/use-structured-agent-session-transport-state.ts
@@ -6,6 +6,7 @@ import type { StructuredAgentSubagentRoster } from '../../../../shared/structure
import { selectStructuredAgentTurnActivity } from '../../../../shared/native-chat-turn-activity'
import { structuredSessionBackgroundTasksView } from '../../../../shared/structured-session-background-tasks-view'
import { useStructuredAgentTurnTiming } from './use-structured-agent-turn-timing'
+import { agentSessionCurrentContextRows } from '../../../../shared/agent-session-context-clear'
const NO_JOURNAL_ITEMS: StructuredAgentSessionState['items'] = []
const NO_SUBMISSIONS: StructuredAgentSessionState['submissions'] = []
@@ -20,10 +21,14 @@ export function useStructuredAgentSessionTransportState(
const subagentRoster = (enabled ? state.subagentRoster : undefined) ?? NO_SUBAGENT_ROSTER
const fence = enabled ? state.fence : null
const latestTurn = enabled ? state.latestTurn : undefined
+ const current = useMemo(
+ () => agentSessionCurrentContextRows(journalItems, submissions),
+ [journalItems, submissions]
+ )
// The host's whole-journal turn, never the loaded rows': a long turn's record is off the page.
const turnId = runningStructuredAgentSessionTurnId({ items: journalItems, latestTurn })
// The rule the host projects every session list's Working from, so this chat cannot disagree.
- const isWorking = isStructuredAgentSessionMainAgentWorking(turnId, submissions, fence)
+ const isWorking = isStructuredAgentSessionMainAgentWorking(turnId, current.submissions, fence)
const nextQueuedMessageId = (enabled ? state.nextQueuedMessageId : null) ?? null
// The host names the card its queue sends next. That send lands in a later update than a turn's
// end or a Resume, so until then the chat still reads as working and nothing flips in between.
@@ -41,6 +46,10 @@ export function useStructuredAgentSessionTransportState(
},
turnId
)
+ const backgroundTasks = structuredSessionBackgroundTasksView(
+ enabled ? state.backgroundTasks : null,
+ turnId
+ )
return {
journalItems,
latestTurn,
@@ -56,9 +65,7 @@ export function useStructuredAgentSessionTransportState(
queuePause: (enabled ? state.queuePause : null) ?? null,
/** Working only because the queue is about to send: nothing is in flight to stop yet. */
queueSendsNext,
- backgroundTasks: structuredSessionBackgroundTasksView(
- enabled ? state.backgroundTasks : null,
- turnId
- )
+ backgroundTasks,
+ conversationBusy: Boolean(turnId || backgroundTasks.isMonitoring)
}
}
diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session.ts b/src/renderer/src/components/native-chat/use-structured-agent-session.ts
index 062de2b6356c..09931ce7bbd8 100644
--- a/src/renderer/src/components/native-chat/use-structured-agent-session.ts
+++ b/src/renderer/src/components/native-chat/use-structured-agent-session.ts
@@ -38,7 +38,7 @@ import { structuredAgentSessionStartFailureFacts } from './structured-agent-sess
import { hostStatesTurnScopes } from '../../../../shared/native-chat-turn-membership'
import { pendingPromptsAllUnanswerableHere } from '../../../../shared/agent-session-approval-subject'
import { withNativeChatCutTurnNotices } from '../../../../shared/native-chat-cut-turn-notice'
-import { useStructuredAgentSessionRewind } from './use-native-chat-rewind'
+import { useStructuredAgentSessionRewind } from './use-structured-agent-session-rewind'
import type { NativeChatRewindHost } from './use-native-chat-rewind'
export type { StructuredPromptItem } from './structured-agent-session-message-projection'
@@ -152,12 +152,14 @@ export function useStructuredAgentSession(args: {
const threadGoal = useStructuredAgentSessionThreadGoal({
journalItems: transportState.journalItems,
+ journalEpoch: state.epoch,
support: threadGoalSupport,
mutate
})
const contextUsage = useStructuredAgentSessionContextUsage(
transportState.journalItems,
- contextUsageSupport
+ contextUsageSupport,
+ state.epoch
)
const railOutline = useStructuredAgentSessionRailOutline({
@@ -172,12 +174,7 @@ export function useStructuredAgentSession(args: {
// Out and unsettled: the chat takes no other send, and Stop stops it.
const sending = pending.some((entry) => entry.phase === 'sending')
// What the host refuses a conversation command or a rewind behind.
- const conversationBusy = Boolean(
- transportState.turnId ||
- prompts.length ||
- transportState.backgroundTasks.isMonitoring ||
- sending
- )
+ const conversationBusy = transportState.conversationBusy || prompts.length > 0 || sending
const rewind = useStructuredAgentSessionRewind({
sessionId,
target,
@@ -185,6 +182,7 @@ export function useStructuredAgentSession(args: {
...args.rewind,
state,
support: transportEnabled ? rewindSupport : undefined,
+ contextFloor: contextUsageSupport?.contextFloor ?? threadGoalSupport?.contextFloor,
// The host also refuses a rewind behind its queued cards, paused ones included.
blocked: conversationBusy || commandPending.current || queuedMessageIds.length > 0,
write
diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json
index 81cf2f502962..8188ef714b11 100644
--- a/src/renderer/src/i18n/locales/en.json
+++ b/src/renderer/src/i18n/locales/en.json
@@ -18182,6 +18182,7 @@
"cancelledBy": "Cancelled on {{device}}"
},
"notices": {
+ "contextCleared": "Context cleared",
"compaction": "Context compacted",
"details": "Details",
"plan": "Plan",
@@ -18214,6 +18215,7 @@
"skills": "Skills",
"loadingSkills": "Loading skills...",
"skillsLoaded": "Skills loaded",
+ "clearDescription": "Clear conversation context",
"skillsUnavailableHost": "Skills can't be listed in SSH chats",
"skillsLoadFailed": "Couldn't load skills",
"retrySkills": "Retry",
diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json
index 76bbfc173fc2..e8cea2c9d93a 100644
--- a/src/renderer/src/i18n/locales/es.json
+++ b/src/renderer/src/i18n/locales/es.json
@@ -17981,6 +17981,7 @@
"commands": "Comandos",
"skills": "Habilidades",
"loadingSkills": "Cargando habilidades…",
+ "clearDescription": "Borrar el contexto de la conversación",
"skillsUnavailableHost": "Las habilidades no se pueden listar en los chats SSH",
"skillsLoadFailed": "No se pudieron cargar las habilidades",
"retrySkills": "Reintentar",
@@ -18320,6 +18321,7 @@
"recorded": "Totales de las ediciones registradas en este turno."
},
"notices": {
+ "contextCleared": "Contexto borrado",
"compaction": "Contexto compactado",
"details": "Detalles",
"plan": "Plan",
diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json
index 99821d665151..120fe110e624 100644
--- a/src/renderer/src/i18n/locales/fr.json
+++ b/src/renderer/src/i18n/locales/fr.json
@@ -18038,6 +18038,7 @@
"skills": "Skills",
"loadingSkills": "Chargement des skills...",
"skillsLoaded": "Skills chargées",
+ "clearDescription": "Effacer le contexte de la conversation",
"skillsUnavailableHost": "Les skills ne peuvent pas être listées dans les chats SSH",
"skillsLoadFailed": "Impossible de charger les skills",
"retrySkills": "Réessayer",
@@ -18305,6 +18306,7 @@
"cancelledBy": "Annulé le {{device}}"
},
"notices": {
+ "contextCleared": "Contexte effacé",
"compaction": "Contexte compacté",
"details": "Détails",
"plan": "Plan",
diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json
index 830d40bd3aef..ce9c6449fa73 100644
--- a/src/renderer/src/i18n/locales/ja.json
+++ b/src/renderer/src/i18n/locales/ja.json
@@ -18012,6 +18012,7 @@
"commands": "コマンド",
"skills": "スキル",
"loadingSkills": "スキルを読み込み中…",
+ "clearDescription": "会話のコンテキストをクリア",
"skillsUnavailableHost": "SSH チャットではスキルを一覧表示できません",
"skillsLoadFailed": "スキルを読み込めませんでした",
"retrySkills": "再試行",
@@ -18239,6 +18240,7 @@
"cancelledBy": "{{device}}にキャンセルされました"
},
"notices": {
+ "contextCleared": "コンテキストをクリアしました",
"compaction": "圧縮されたコンテキスト",
"details": "詳細",
"plan": "プラン",
diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json
index 355225c9e462..9c67578cf1aa 100644
--- a/src/renderer/src/i18n/locales/ko.json
+++ b/src/renderer/src/i18n/locales/ko.json
@@ -18012,6 +18012,7 @@
"commands": "명령어",
"skills": "스킬",
"loadingSkills": "스킬 로딩 중…",
+ "clearDescription": "대화 컨텍스트 지우기",
"skillsUnavailableHost": "SSH 채팅에서는 스킬 목록을 표시할 수 없습니다",
"skillsLoadFailed": "스킬을 불러올 수 없습니다",
"retrySkills": "재시도",
@@ -18239,6 +18240,7 @@
"cancelledBy": "{{device}}에 취소됨"
},
"notices": {
+ "contextCleared": "컨텍스트 지워짐",
"compaction": "컨텍스트가 압축됨",
"details": "세부",
"plan": "계획",
diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json
index ecadc83a4c58..ff04fe475c71 100644
--- a/src/renderer/src/i18n/locales/zh.json
+++ b/src/renderer/src/i18n/locales/zh.json
@@ -17977,6 +17977,7 @@
"commands": "命令",
"skills": "技能",
"loadingSkills": "正在加载技能…",
+ "clearDescription": "清除对话上下文",
"skillsUnavailableHost": "SSH 聊天中无法列出技能",
"skillsLoadFailed": "无法加载技能",
"retrySkills": "重试",
@@ -18204,6 +18205,7 @@
"cancelledBy": "于 {{device}} 取消"
},
"notices": {
+ "contextCleared": "上下文已清除",
"compaction": "上下文压缩",
"details": "细节",
"plan": "计划",
diff --git a/src/shared/agent-session-context-clear.test.ts b/src/shared/agent-session-context-clear.test.ts
new file mode 100644
index 000000000000..b115c44b61c0
--- /dev/null
+++ b/src/shared/agent-session-context-clear.test.ts
@@ -0,0 +1,172 @@
+import { describe, expect, it } from 'vitest'
+import type {
+ AgentJournalItemBody,
+ AgentJournalRenderItem,
+ AgentJournalSubmission,
+ AgentJournalThreadGoal
+} from './agent-session-journal-types'
+import {
+ agentSessionCurrentContextRows,
+ isAgentSessionContextClear,
+ isNativeChatContextClear,
+ latestAgentSessionContextClearSequence
+} from './agent-session-context-clear'
+import {
+ currentAgentSessionThreadGoal,
+ currentAgentSessionThreadGoalBySequence
+} from './agent-session-thread-goal'
+import {
+ activeStructuredAgentSessionTurnId,
+ newestStructuredAgentSessionTurn
+} from './structured-agent-session-live-turn'
+import {
+ projectStructuredAgentSessionStatusState,
+ projectStructuredItemToNativeChat
+} from './structured-agent-session-projection'
+import {
+ latestStructuredAgentContextFacts,
+ selectStructuredAgentContextUsage
+} from './structured-agent-session-context-usage'
+import { owesStructuredAgentSessionWork } from './structured-agent-session-owed-work'
+
+const boundary = { operationId: 'clear', afterFence: 3, clearedAt: 100 }
+const row = (sequence: number, body: AgentJournalItemBody): AgentJournalRenderItem => ({
+ itemId: `row-${sequence}`,
+ sequence,
+ revision: 1,
+ observedAt: sequence,
+ body
+})
+const clear = row(5, {
+ kind: 'status',
+ text: 'Localized fallback',
+ presentation: 'context-cleared',
+ contextClear: boundary
+})
+const oldSend: AgentJournalSubmission = {
+ clientMessageId: 'old-send',
+ fence: 3,
+ payloadFingerprint: 'same',
+ dispatchState: 'unknown',
+ providerItemId: null,
+ reason: null,
+ submittedAt: 1,
+ resolvedAt: null,
+ acceptedSequence: 2
+}
+const user = row(1, {
+ kind: 'message',
+ role: 'user',
+ blocks: [{ type: 'text', text: 'Prior prompt' }]
+})
+const running = row(3, { kind: 'turn', turnId: 'old-turn', state: 'running' })
+const oldPrompt = row(4, {
+ kind: 'question',
+ question: 'Old question',
+ options: [],
+ resolution: { state: 'pending', selectedOptionId: null, resolvedBy: null, resolvedAt: null }
+})
+const goal: AgentJournalThreadGoal = {
+ objective: 'Old goal',
+ status: 'active',
+ tokenBudget: null,
+ tokensUsed: 1,
+ timeUsedSeconds: 1,
+ createdAt: 1,
+ updatedAt: 1
+}
+const goalRow = row(2, { kind: 'status', text: 'Goal set', threadGoal: { state: 'set', goal } })
+const usageRow = row(3, {
+ kind: 'turn',
+ turnId: 'old-turn',
+ state: 'completed',
+ contextUsage: {
+ window: { tokens: 200_000, capturedAt: 1 },
+ used: {
+ kind: 'estimate',
+ capturedAt: 1,
+ usage: {
+ inputTokens: 100,
+ cacheCreationInputTokens: 0,
+ cacheReadInputTokens: 0,
+ outputTokens: 0
+ }
+ }
+ }
+})
+
+describe('completed context clear projections', () => {
+ it('uses typed metadata and carries it through the shared desktop and mobile row', () => {
+ expect(isAgentSessionContextClear(clear.body)).toBe(true)
+ expect(
+ isAgentSessionContextClear({
+ kind: 'status',
+ text: 'Context cleared',
+ presentation: 'context-cleared'
+ })
+ ).toBe(false)
+ const message = projectStructuredItemToNativeChat(clear)!
+ expect(message).toMatchObject({
+ role: 'system',
+ blocks: [{ text: 'Localized fallback', contextClear: boundary }]
+ })
+ expect(isNativeChatContextClear(message)).toBe(true)
+ })
+
+ it('leaves earlier rows and uncertainty visible while clearing current work and prompts', () => {
+ const rows = [user, running, oldPrompt, clear]
+ const state = projectStructuredAgentSessionStatusState(rows, [oldSend], 3)
+ expect(state.summary).toEqual({ status: 'idle', latestPrompt: 'Prior prompt' })
+ expect(state.latestRequest).toBeNull()
+ expect(state.pendingPromptIds).toEqual([])
+ expect(state.owesWork).toBe(false)
+ expect(owesStructuredAgentSessionWork(rows, [oldSend], 3)).toBe(false)
+ expect(activeStructuredAgentSessionTurnId(rows)).toBeNull()
+ expect(newestStructuredAgentSessionTurn(rows)).toBeNull()
+ expect(rows).toHaveLength(4)
+ expect(oldSend.dispatchState).toBe('unknown')
+ })
+
+ it('includes new sends but excludes older unknown sends even at the current fence', () => {
+ const currentSend = { ...oldSend, clientMessageId: 'new-send', acceptedSequence: 6 }
+ const rows = [user, clear, row(7, { kind: 'turn', turnId: 'new-turn', state: 'running' })]
+ expect(agentSessionCurrentContextRows(rows, [oldSend, currentSend])).toEqual({
+ items: [rows[2]],
+ submissions: [currentSend]
+ })
+ expect(activeStructuredAgentSessionTurnId(rows)).toBe('new-turn')
+ expect(owesStructuredAgentSessionWork(rows, [oldSend, currentSend], 3)).toBe(true)
+ })
+
+ it('resets the current goal in sorted snapshots and unordered host maps', () => {
+ expect(currentAgentSessionThreadGoal([goalRow, clear])).toBeNull()
+ expect(currentAgentSessionThreadGoalBySequence([clear, goalRow])).toBeNull()
+ const newGoal = row(6, {
+ kind: 'status',
+ text: 'Goal set',
+ threadGoal: { state: 'set', goal: { ...goal, objective: 'New goal' } }
+ })
+ expect(currentAgentSessionThreadGoalBySequence([newGoal, goalRow, clear])?.objective).toBe(
+ 'New goal'
+ )
+ expect(currentAgentSessionThreadGoal([goalRow], clear.sequence)).toBeUndefined()
+ })
+
+ it('does not use earlier usage or stale whole-journal facts after a loaded clear', () => {
+ const old = latestStructuredAgentContextFacts([usageRow])
+ expect(selectStructuredAgentContextUsage([usageRow, clear], old)).toBeNull()
+ expect(latestStructuredAgentContextFacts([clear, usageRow])).toEqual({})
+ const newUsage = { ...usageRow, itemId: 'new-usage', sequence: 6 }
+ expect(latestStructuredAgentContextFacts([newUsage, clear, usageRow])).toEqual(old)
+ expect(selectStructuredAgentContextUsage([usageRow], undefined, clear.sequence)).toBeNull()
+ })
+
+ it('chooses the newest clear independent of map order and scopes off-page rows', () => {
+ const again = { ...clear, itemId: 'clear-again', sequence: 8 }
+ expect(latestAgentSessionContextClearSequence([again, user, clear])).toBe(8)
+ expect(agentSessionCurrentContextRows([user], [oldSend], 5)).toEqual({
+ items: [],
+ submissions: []
+ })
+ })
+})
diff --git a/src/shared/agent-session-context-clear.ts b/src/shared/agent-session-context-clear.ts
new file mode 100644
index 000000000000..1564054d66ef
--- /dev/null
+++ b/src/shared/agent-session-context-clear.ts
@@ -0,0 +1,65 @@
+import type {
+ AgentJournalItemBody,
+ AgentJournalRenderItem,
+ AgentJournalSubmission
+} from './agent-session-journal-types'
+import { isAgentSessionProviderContextBoundary } from './agent-session-provider-context'
+import type { NativeChatMessage } from './native-chat-types'
+
+export function isAgentSessionContextClear(body: AgentJournalItemBody | undefined): boolean {
+ return body?.kind === 'status' && isAgentSessionProviderContextBoundary(body.contextClear)
+}
+
+export function isNativeChatContextClear(message: NativeChatMessage): boolean {
+ return (
+ message.role === 'system' &&
+ message.blocks.some(
+ (block) => block.type === 'text' && isAgentSessionProviderContextBoundary(block.contextClear)
+ )
+ )
+}
+
+export function agentSessionContextSequenceFor(
+ sequence: number,
+ boundaries: readonly number[]
+): number {
+ let left = 0
+ let right = boundaries.length
+ while (left < right) {
+ const middle = Math.floor((left + right) / 2)
+ if (boundaries[middle] < sequence) {
+ left = middle + 1
+ } else {
+ right = middle
+ }
+ }
+ return left > 0 ? boundaries[left - 1] : 0
+}
+
+export function latestAgentSessionContextClearSequence(
+ items: Iterable>
+): number {
+ let sequence = 0
+ for (const item of items) {
+ if (item.sequence > sequence && isAgentSessionContextClear(item.body)) {
+ sequence = item.sequence
+ }
+ }
+ return sequence
+}
+
+export function agentSessionCurrentContextRows(
+ items: readonly AgentJournalRenderItem[],
+ submissions: readonly AgentJournalSubmission[] = [],
+ minimumSequence = 0
+) {
+ const sequence = Math.max(minimumSequence, latestAgentSessionContextClearSequence(items))
+ return sequence > 0
+ ? {
+ items: items.filter((item) => item.sequence > sequence),
+ submissions: submissions.filter(
+ (submission) => (submission.acceptedSequence ?? 0) > sequence
+ )
+ }
+ : { items, submissions }
+}
diff --git a/src/shared/agent-session-conversation-command.ts b/src/shared/agent-session-conversation-command.ts
index d1e5a2671fad..0035a2e11aad 100644
--- a/src/shared/agent-session-conversation-command.ts
+++ b/src/shared/agent-session-conversation-command.ts
@@ -60,7 +60,7 @@ export function isAgentSessionConversationCommandRecord(
return (
(row.phase === 'prepared' || row.phase === 'committed') &&
(row.runtimeFence === undefined ||
- (Number.isSafeInteger(row.runtimeFence) && row.runtimeFence > 0)) &&
+ (Number.isSafeInteger(row.runtimeFence) && row.runtimeFence >= 0)) &&
typeof row.operationId === 'string' &&
row.operationId.length > 0 &&
row.operationId.length <= 512 &&
diff --git a/src/shared/agent-session-journal-schemas.ts b/src/shared/agent-session-journal-schemas.ts
index e7dd3c2a8b14..9e5262693514 100644
--- a/src/shared/agent-session-journal-schemas.ts
+++ b/src/shared/agent-session-journal-schemas.ts
@@ -251,6 +251,13 @@ const KnownItemBody = z.discriminatedUnion('kind', [
kind: z.literal('status'),
text: z.string(),
presentation: z.string().optional(),
+ contextClear: z
+ .object({
+ operationId: z.string().min(1).max(512),
+ afterFence: z.number().int().nonnegative(),
+ clearedAt: z.number().int().nonnegative()
+ })
+ .optional(),
tone: z.string().optional(),
turnLifecycle: z.object(TurnLifecycleFields).optional(),
providerFrame: ProviderFrame.optional(),
diff --git a/src/shared/agent-session-journal-types.ts b/src/shared/agent-session-journal-types.ts
index 2ad0cc921acb..2ff04f88ad4f 100644
--- a/src/shared/agent-session-journal-types.ts
+++ b/src/shared/agent-session-journal-types.ts
@@ -1,3 +1,4 @@
+import type { AgentSessionProviderContextBoundary } from './agent-session-provider-context'
// ─── Canonical agent-session journal: cross-process wire shapes ─────────────
// The host-owned timeline for a structured agent session. Everything here must
// be plain JSON: rows are persisted verbatim and later republished to clients,
@@ -321,6 +322,7 @@ type AgentJournalStatusItemFields = {
kind: 'status'
/** Optional display hints; unknown values retain the ordinary text fallback. */
presentation?: string
+ contextClear?: AgentSessionProviderContextBoundary
tone?: string
/** Legacy carrier of a turn record: written by hosts before v3, and published
* to clients that predate the `turn` item. New code reads turns through
diff --git a/src/shared/agent-session-provider-context.test.ts b/src/shared/agent-session-provider-context.test.ts
new file mode 100644
index 000000000000..8378394c2b1d
--- /dev/null
+++ b/src/shared/agent-session-provider-context.test.ts
@@ -0,0 +1,53 @@
+import { describe, expect, it } from 'vitest'
+import {
+ activeProviderContext,
+ isAgentSessionProviderContextBoundary
+} from './agent-session-provider-context'
+import { agentSessionRecordFixture } from './agent-session-record.test-fixture'
+import { isPersistedAgentSessionRecord } from './agent-session-record'
+import { encodeAgentSessionRecord } from './agent-session-record-stored-form'
+
+const boundary = { operationId: 'clear-operation', afterFence: 3, clearedAt: 5000 }
+
+describe('provider context after clear', () => {
+ it('has no provider handle while the fresh context has not started', () => {
+ expect(
+ activeProviderContext({ providerHandleChain: [], providerContextBoundary: boundary })
+ ).toEqual({
+ head: null,
+ handleChain: [],
+ pendingClear: true
+ })
+ })
+
+ it('uses the chain head after fresh creation without filtering or replacing proof', () => {
+ const record = { ...agentSessionRecordFixture(), providerContextBoundary: boundary }
+ expect(activeProviderContext(record)).toEqual({
+ head: record.providerHandleChain.at(-1),
+ handleChain: record.providerHandleChain,
+ pendingClear: false
+ })
+ expect(isPersistedAgentSessionRecord(encodeAgentSessionRecord(record))).toBe(true)
+ })
+
+ it('rejects a boundary beyond the record fence', () => {
+ const record = agentSessionRecordFixture()
+ expect(
+ isPersistedAgentSessionRecord(
+ encodeAgentSessionRecord({
+ ...record,
+ providerContextBoundary: { ...boundary, afterFence: record.lease.runtimeFence + 1 }
+ })
+ )
+ ).toBe(false)
+ })
+
+ it.each([
+ {},
+ { ...boundary, afterFence: -1 },
+ { ...boundary, clearedAt: Number.NaN },
+ { ...boundary, operationId: '' }
+ ])('rejects malformed boundary metadata', (value) =>
+ expect(isAgentSessionProviderContextBoundary(value)).toBe(false)
+ )
+})
diff --git a/src/shared/agent-session-provider-context.ts b/src/shared/agent-session-provider-context.ts
new file mode 100644
index 000000000000..267988359a36
--- /dev/null
+++ b/src/shared/agent-session-provider-context.ts
@@ -0,0 +1,41 @@
+import type { AgentSessionRecord } from './agent-session-record'
+
+export type AgentSessionProviderContextBoundary = {
+ operationId: string
+ afterFence: number
+ clearedAt: number
+}
+
+export function isAgentSessionProviderContextBoundary(
+ value: unknown
+): value is AgentSessionProviderContextBoundary {
+ return (
+ typeof value === 'object' &&
+ value !== null &&
+ 'operationId' in value &&
+ typeof value.operationId === 'string' &&
+ value.operationId.length > 0 &&
+ value.operationId.length <= 512 &&
+ 'afterFence' in value &&
+ typeof value.afterFence === 'number' &&
+ Number.isSafeInteger(value.afterFence) &&
+ value.afterFence >= 0 &&
+ 'clearedAt' in value &&
+ typeof value.clearedAt === 'number' &&
+ Number.isSafeInteger(value.clearedAt) &&
+ value.clearedAt >= 0
+ )
+}
+
+/** Clear resets the chain; the boundary identifies a fresh launch before its first proof. */
+export function activeProviderContext(
+ record: Pick
+) {
+ const handleChain = record.providerHandleChain
+ const head = handleChain.at(-1) ?? null
+ return {
+ handleChain,
+ head,
+ pendingClear: record.providerContextBoundary !== undefined && head === null
+ }
+}
diff --git a/src/shared/agent-session-record.ts b/src/shared/agent-session-record.ts
index c228335a6264..185c62c8333e 100644
--- a/src/shared/agent-session-record.ts
+++ b/src/shared/agent-session-record.ts
@@ -25,9 +25,10 @@ import {
type AgentSessionProviderHandleLink
} from './agent-session-provider-handle'
import {
- isAgentSessionProviderHandleInNamespace,
- isStructuredAgentId
-} from './agent-session-provider-handle-encoding'
+ isAgentSessionProviderContextBoundary,
+ type AgentSessionProviderContextBoundary
+} from './agent-session-provider-context'
+import { isStructuredAgentId } from './agent-session-provider-handle-encoding'
import {
isAgentSessionAccountHome,
MAX_PATH_LENGTH,
@@ -149,6 +150,7 @@ export type AgentSessionRecord = {
/** The agent this session names, whether this build can run it or not. */
provider: string
providerHandleChain: AgentSessionProviderHandleLink[]
+ providerContextBoundary?: AgentSessionProviderContextBoundary
accountHome: AgentSessionAccountHome
/** The directory the provider first launched in, in the execution host's path syntax. Floating
* sessions resume here; worktree and folder ids still resolve by id to their durable place. */
@@ -359,6 +361,8 @@ export function isPersistedAgentSessionRecord(
isBoundedString(record.launchDirectory, MAX_PATH_LENGTH)) &&
(record.options === undefined || isAgentSessionOptions(record.options)) &&
(record.rewind === undefined || isAgentSessionRewindRecord(record.rewind)) &&
+ (record.providerContextBoundary === undefined ||
+ isAgentSessionProviderContextBoundary(record.providerContextBoundary)) &&
(record.conversationCommand === undefined ||
isAgentSessionConversationCommandRecord(record.conversationCommand)) &&
(record.conversationName === undefined ||
@@ -376,15 +380,12 @@ export function isPersistedAgentSessionRecord(
// The row holds stored handles; validate the chain they decode to.
const chain = decodePersistedAgentSessionProviderHandleChain(validated.providerHandleChain)
const head = chain?.at(-1)
- // One namespace, owned by the record's own agent; which transport is the chain's own fact.
- const transport = chain?.[0]?.handle.transport
- const namespace = transport === undefined ? null : { transport, agent: validated.provider }
return (
chain !== null &&
- chain.every(
- (link) =>
- namespace !== null && isAgentSessionProviderHandleInNamespace(link.handle, namespace)
- ) &&
+ // Chain validation already enforces one namespace.
+ (!head || head.handle.agent === validated.provider) &&
+ (validated.providerContextBoundary === undefined ||
+ validated.providerContextBoundary.afterFence <= validated.lease.runtimeFence) &&
(validated.lease.claimStatus !== 'live' ||
(validated.lease.ownerProcess !== null &&
head?.linkId === validated.lease.provenHandleLinkId &&
diff --git a/src/shared/agent-session-resume-marker.ts b/src/shared/agent-session-resume-marker.ts
index 3890bd8f81d3..5fee1084379e 100644
--- a/src/shared/agent-session-resume-marker.ts
+++ b/src/shared/agent-session-resume-marker.ts
@@ -74,6 +74,7 @@ export type AgentSessionResumeMarker = {
* record it.
*/
journalCursor?: AgentJournalCursor
+ contextClearOperationId?: string
/**
* What the session was doing, captured at the same stop-time snapshot that decided the offer.
* The dialog row, the status bar and the wire candidate read ONLY this; nothing re-reads the
@@ -128,6 +129,7 @@ const agentSessionResumeMarkerSchema = z.object({
trigger: z.enum(AGENT_SESSION_RESUME_TRIGGERS),
providerHandleRoot: markerField,
teardownId: markerField,
+ contextClearOperationId: markerField.optional().catch(undefined),
journalCursor: z
.object({ epoch: markerField, sequence: z.number().int().nonnegative() })
.optional()
diff --git a/src/shared/agent-session-rewind.ts b/src/shared/agent-session-rewind.ts
index 805d74b8843a..e7d864390cca 100644
--- a/src/shared/agent-session-rewind.ts
+++ b/src/shared/agent-session-rewind.ts
@@ -27,7 +27,11 @@ export type AgentSessionRewindParams = {
itemId: string
expectedEpoch: string
}
-export type AgentSessionRewindResult = { itemId: string; epoch: string }
+export type AgentSessionRewindResult = {
+ itemId: string
+ epoch: string
+ sequence?: number
+}
const Key = z.string().min(1).max(4096)
export const AgentSessionRewindRecordSchema = z.object({
@@ -36,8 +40,11 @@ export const AgentSessionRewindRecordSchema = z.object({
itemId: Key,
providerItemId: Key.optional(),
expectedEpoch: Key,
+ contextClearOperationId: Key.optional(),
+ contextClearSequence: z.number().int().positive().optional(),
phase: z.enum(['prepared', 'provider-succeeded', 'completed', 'refused']),
epoch: Key.optional(),
+ sequence: z.number().int().nonnegative().optional(),
hydrationVerified: z.boolean().optional(),
reason: z.string().min(1).max(512).optional(),
retained: z
diff --git a/src/shared/agent-session-thread-goal.ts b/src/shared/agent-session-thread-goal.ts
index fc0b3ffbc9a2..5f15bb3b5fdb 100644
--- a/src/shared/agent-session-thread-goal.ts
+++ b/src/shared/agent-session-thread-goal.ts
@@ -2,6 +2,7 @@
// beside it. The latest goal transition in journal order is the whole answer.
import { isRootAgentJournalItem } from './agent-session-journal-producer'
+import { isAgentSessionContextClear } from './agent-session-context-clear'
import {
AGENT_JOURNAL_THREAD_GOAL_STATUSES,
type AgentJournalItemBody,
@@ -48,7 +49,10 @@ function goalFromRow(body: AgentJournalItemBody): AgentJournalThreadGoal | null
}
function isGoalTransition(item: GoalCandidate): boolean {
- return isRootAgentJournalItem(item) && isAgentJournalThreadGoalRow(item.body)
+ return (
+ isAgentSessionContextClear(item.body) ||
+ (isRootAgentJournalItem(item) && isAgentJournalThreadGoalRow(item.body))
+ )
}
/**
@@ -59,11 +63,12 @@ function isGoalTransition(item: GoalCandidate): boolean {
* row's sequence, so the last goal row is the answer.
*/
export function currentAgentSessionThreadGoal(
- items: readonly GoalCandidate[]
+ items: readonly GoalCandidate[],
+ minimumSequence = 0
): AgentJournalThreadGoal | null | undefined {
for (let index = items.length - 1; index >= 0; index -= 1) {
const item = items[index]
- if (item && isGoalTransition(item)) {
+ if (item && item.sequence > minimumSequence && isGoalTransition(item)) {
return goalFromRow(item.body)
}
}
diff --git a/src/shared/agent-session-wire.ts b/src/shared/agent-session-wire.ts
index bfcd45c7cf34..236b15c66de3 100644
--- a/src/shared/agent-session-wire.ts
+++ b/src/shared/agent-session-wire.ts
@@ -474,11 +474,11 @@ export type AgentSessionOptionsResult = {
* `agentSession.threadGoal` never offers the controls. `current` is the
* latest goal the whole journal records, for a client whose loaded page
* starts after it. */
- threadGoal?: { current: AgentJournalThreadGoal | null }
+ threadGoal?: { current: AgentJournalThreadGoal | null; contextFloor?: AgentJournalCursor }
/** Present only where this session writes context facts to its turn rows.
* `current` is the newest of each part the whole journal records, for a
* client whose loaded page starts after the row that carries it. */
- contextUsage?: { current: AgentSessionContextUsage }
+ contextUsage?: { current: AgentSessionContextUsage; contextFloor?: AgentJournalCursor }
models: AgentSessionModelOption[]
/** Session/account/transport support. Absent means unknown, never unsupported. */
fastModeSupport?: AgentSessionFastModeSupport
diff --git a/src/shared/native-chat-context-usage.ts b/src/shared/native-chat-context-usage.ts
index 1bb23582b838..bbb41a8c9dfb 100644
--- a/src/shared/native-chat-context-usage.ts
+++ b/src/shared/native-chat-context-usage.ts
@@ -4,6 +4,7 @@
import { contextTokensFromUsage } from './agent-session-context-usage'
import type { NativeChatMessage } from './native-chat-types'
+import { isNativeChatContextClear } from './agent-session-context-clear'
export type NativeChatContextUsage = {
usedTokens: number
@@ -32,7 +33,7 @@ export function deriveNativeChatContextUsage(
): NativeChatContextUsage | null {
for (let index = messages.length - 1; index >= 0; index -= 1) {
const message = messages[index]!
- if (isNativeChatCompactionBoundary(message)) {
+ if (isNativeChatCompactionBoundary(message) || isNativeChatContextClear(message)) {
return null
}
if (message.role !== 'assistant' || !message.usage) {
diff --git a/src/shared/native-chat-provider-retry-runs.ts b/src/shared/native-chat-provider-retry-runs.ts
index 54ce2deee15e..347b95d56a3b 100644
--- a/src/shared/native-chat-provider-retry-runs.ts
+++ b/src/shared/native-chat-provider-retry-runs.ts
@@ -3,6 +3,7 @@
import { agentJournalItemSubagentId } from './agent-session-journal-producer'
import type { NativeChatMessage } from './native-chat-types'
+import { isNativeChatContextClear } from './agent-session-context-clear'
function isProviderRetryRow(message: NativeChatMessage): boolean {
const block = message.blocks.length === 1 ? message.blocks[0] : undefined
@@ -25,6 +26,9 @@ export function collapseProviderRetryRuns(
const openRuns = new Map()
const drawn: (NativeChatMessage | null)[] = []
for (const message of messages) {
+ if (isNativeChatContextClear(message)) {
+ openRuns.clear()
+ }
if (!isProviderRetryRow(message)) {
if (openRuns.size > 0) {
openRuns.delete(agentJournalItemSubagentId(message))
diff --git a/src/shared/native-chat-types.ts b/src/shared/native-chat-types.ts
index 40487d018133..730a6af0032d 100644
--- a/src/shared/native-chat-types.ts
+++ b/src/shared/native-chat-types.ts
@@ -1,3 +1,4 @@
+import type { AgentSessionProviderContextBoundary } from './agent-session-provider-context'
// ─── Native chat conversation model (cross-process, IPC-serializable) ────────
// The single renderer-facing conversation contract for the native chat view.
// Assembled from layered sources in priority order: on-disk JSONL transcripts,
@@ -50,6 +51,7 @@ export type NativeChatTextBlock = {
text: string
/** Optional journal display hints; readers narrow only the values they know. */
presentation?: string
+ contextClear?: AgentSessionProviderContextBoundary
tone?: string
/** Optional structured detail for an otherwise ordinary fallback line. */
providerFrame?: {
diff --git a/src/shared/structured-agent-session-composer.ts b/src/shared/structured-agent-session-composer.ts
index 0673d62dd26b..b14e8eb68294 100644
--- a/src/shared/structured-agent-session-composer.ts
+++ b/src/shared/structured-agent-session-composer.ts
@@ -18,7 +18,7 @@ const EFFORT_COMMAND: SlashCommandSuggestion = {
}
const CONVERSATION_COMMANDS: readonly SlashCommandSuggestion[] = [
- { name: 'clear', description: 'Start a fresh conversation' },
+ { name: 'clear', description: 'Clear conversation context' },
{ name: 'compact', description: 'Compact conversation context' }
]
diff --git a/src/shared/structured-agent-session-context-usage.ts b/src/shared/structured-agent-session-context-usage.ts
index f18771242d03..6e7bb391ae6d 100644
--- a/src/shared/structured-agent-session-context-usage.ts
+++ b/src/shared/structured-agent-session-context-usage.ts
@@ -12,6 +12,10 @@ import {
} from './agent-session-context-usage'
import type { AgentJournalRenderItem } from './agent-session-journal-types'
import { readAgentJournalTurn } from './agent-session-turn-record'
+import {
+ isAgentSessionContextClear,
+ latestAgentSessionContextClearSequence
+} from './agent-session-context-clear'
export type StructuredAgentContextUsage = {
usedTokens: number
@@ -28,11 +32,24 @@ type ContextFactSource = Pick
/** The newest of each part these rows carry, in any order; a part none of them carries is absent. */
export function latestStructuredAgentContextFacts(
- items: Iterable
+ items: Iterable,
+ minimumSequence = 0
): AgentSessionContextUsage {
let used: { sequence: number; fact: AgentSessionContextUsed } | null = null
let window: { sequence: number; fact: AgentSessionContextWindow } | null = null
for (const item of items) {
+ if (isAgentSessionContextClear(item.body) && item.sequence > minimumSequence) {
+ minimumSequence = item.sequence
+ if (used && used.sequence <= minimumSequence) {
+ used = null
+ }
+ if (window && window.sequence <= minimumSequence) {
+ window = null
+ }
+ }
+ if (item.sequence <= minimumSequence) {
+ continue
+ }
const facts = readAgentJournalTurn(item.body)?.contextUsage
if (facts?.used && (used === null || item.sequence >= used.sequence)) {
used = { sequence: item.sequence, fact: facts.used }
@@ -52,10 +69,16 @@ export function latestStructuredAgentContextFacts(
*/
export function selectStructuredAgentContextUsage(
items: readonly AgentJournalRenderItem[],
- wholeJournal?: AgentSessionContextUsage
+ wholeJournal?: AgentSessionContextUsage,
+ minimumSequence = 0
): StructuredAgentContextUsage | null {
- const loaded = latestStructuredAgentContextFacts(items)
- return summarizeStructuredAgentContextFacts({ ...wholeJournal, ...loaded })
+ const clearedSequence = latestAgentSessionContextClearSequence(items)
+ const loaded = latestStructuredAgentContextFacts(
+ items,
+ Math.max(minimumSequence, clearedSequence)
+ )
+ const fallback = clearedSequence > minimumSequence ? undefined : wholeJournal
+ return summarizeStructuredAgentContextFacts({ ...fallback, ...loaded })
}
function summarizeStructuredAgentContextFacts({
diff --git a/src/shared/structured-agent-session-live-turn.ts b/src/shared/structured-agent-session-live-turn.ts
index 44bc6be69bca..a716b8f4a131 100644
--- a/src/shared/structured-agent-session-live-turn.ts
+++ b/src/shared/structured-agent-session-live-turn.ts
@@ -26,6 +26,7 @@ import {
import { isRootAgentJournalItem } from './agent-session-journal-producer'
import type { AgentSessionLatestTurn, AgentSessionSubscribeEvent } from './agent-session-wire'
import { readAgentJournalTurn } from './agent-session-turn-record'
+import { isAgentSessionContextClear } from './agent-session-context-clear'
import type { NativeChatToolCallBlock } from './native-chat-types'
import {
isRunningStructuredAgentSessionToolAction,
@@ -38,6 +39,9 @@ export function activeStructuredAgentSessionTurnId(
items: readonly AgentJournalRenderItem[]
): string | null {
for (let index = items.length - 1; index >= 0; index -= 1) {
+ if (isAgentSessionContextClear(items[index]?.body)) {
+ return null
+ }
const turn = readAgentJournalTurn(items[index]?.body)
if (turn) {
return turn.state === 'running' ? turn.turnId : null
@@ -58,6 +62,11 @@ export function newestStructuredAgentSessionTurnBySequence(
if (item.sequence < newestSequence) {
continue
}
+ if (isAgentSessionContextClear(item.body)) {
+ newestSequence = item.sequence
+ newest = null
+ continue
+ }
const turn = readAgentJournalTurn(item.body)
if (turn) {
newestSequence = item.sequence
@@ -74,7 +83,10 @@ export function liveStructuredAgentSessionTurnScope(
): AgentJournalTurnScope {
let newest: AgentJournalRenderItem | null = null
for (const item of items) {
- if ((newest === null || item.sequence >= newest.sequence) && readAgentJournalTurn(item.body)) {
+ if (
+ (newest === null || item.sequence >= newest.sequence) &&
+ (readAgentJournalTurn(item.body) || isAgentSessionContextClear(item.body))
+ ) {
newest = item
}
}
@@ -111,6 +123,9 @@ export function latestStructuredAgentSessionTurn(
): AgentSessionLatestTurn | null {
for (let index = items.length - 1; index >= 0; index -= 1) {
const item = items[index]
+ if (isAgentSessionContextClear(item?.body)) {
+ return null
+ }
const turn = readAgentJournalTurn(item?.body)
if (item && turn) {
return { itemId: item.itemId, observedAt: item.observedAt, turn }
@@ -172,6 +187,9 @@ export function isStructuredAgentSessionThinking({ items, latestTurn }: HostTurn
for (let index = items.length - 1; index >= 0; index -= 1) {
const item = items[index]
const body = item?.body
+ if (isAgentSessionContextClear(body)) {
+ return false
+ }
const turn = readAgentJournalTurn(body)
if (turn) {
return (hostRunning ?? turn.state === 'running') && newestContentIsReasoning === true
@@ -212,6 +230,9 @@ export function statusStructuredAgentSessionToolCall(
continue
}
const body = item.body
+ if (isAgentSessionContextClear(body)) {
+ return null
+ }
const turn = readAgentJournalTurn(body)
if (turn) {
const named = turn.state === 'running' ? (running ?? newest) : null
diff --git a/src/shared/structured-agent-session-message-projection.ts b/src/shared/structured-agent-session-message-projection.ts
index 041d18e57110..43c3cf1ffee5 100644
--- a/src/shared/structured-agent-session-message-projection.ts
+++ b/src/shared/structured-agent-session-message-projection.ts
@@ -15,6 +15,10 @@ import { compareNativeChatTranscriptMessages } from './native-chat-transcript-pr
import type { NativeChatMessage } from './native-chat-types'
import { dispatchWasWithdrawn } from './structured-agent-session-dispatch-rejection'
import { projectStructuredItemsToNativeChat } from './structured-agent-session-projection'
+import {
+ agentSessionContextSequenceFor,
+ isAgentSessionContextClear
+} from './agent-session-context-clear'
/** A message this client sent that the host has not drawn yet: its bubble until the row lands. */
export type StructuredAgentSessionOptimisticMessage = {
@@ -50,19 +54,25 @@ export function structuredAgentSessionCommandItemIds(
*/
export function structuredAgentSessionRejectedShownInPlace(
submissions: readonly AgentJournalSubmission[],
- commandItemIds: ReadonlySet
+ commandItemIds: ReadonlySet,
+ clearSequences: readonly number[] = []
): Set {
// Each body's copies, as positions in submission order. A withdrawn one is no failed copy, so it
// supersedes nothing.
const copies = new Map()
+ const scopeKey = (submission: AgentJournalSubmission) =>
+ JSON.stringify([
+ agentSessionContextSequenceFor(submission.acceptedSequence ?? 0, clearSequences),
+ submission.payloadFingerprint
+ ])
for (const [index, submission] of submissions.entries()) {
if (!dispatchWasWithdrawn(submission)) {
const copy = { index, submittedAt: submission.submittedAt }
- const same = copies.get(submission.payloadFingerprint)
+ const same = copies.get(scopeKey(submission))
if (same) {
same.push(copy)
} else {
- copies.set(submission.payloadFingerprint, [copy])
+ copies.set(scopeKey(submission), [copy])
}
}
}
@@ -80,7 +90,7 @@ export function structuredAgentSessionRejectedShownInPlace(
// host re-delivers its own messages under new ids. Only a later copy sent once the rejection
// was known counts, so a repeat sent before it is kept.
(resolvedAt !== null &&
- (copies.get(submission.payloadFingerprint) ?? []).some(
+ (copies.get(scopeKey(submission)) ?? []).some(
(copy) => copy.index > index && copy.submittedAt >= resolvedAt
))
) {
@@ -121,7 +131,8 @@ export function projectStructuredAgentSessionMessages(
const inPlace = options.rejectedInPlace
? structuredAgentSessionRejectedShownInPlace(
submissions,
- structuredAgentSessionCommandItemIds(items)
+ structuredAgentSessionCommandItemIds(items),
+ items.filter((item) => isAgentSessionContextClear(item.body)).map((item) => item.sequence)
)
: new Set()
const visibleItems: AgentJournalRenderItem[] = []
diff --git a/src/shared/structured-agent-session-owed-work.ts b/src/shared/structured-agent-session-owed-work.ts
index 324c5160ed0e..fe7c9695ae73 100644
--- a/src/shared/structured-agent-session-owed-work.ts
+++ b/src/shared/structured-agent-session-owed-work.ts
@@ -1,6 +1,7 @@
import type { AgentJournalRenderItem, AgentJournalSubmission } from './agent-session-journal-types'
import { activeStructuredAgentSessionTurnId } from './structured-agent-session-live-turn'
import { isStructuredAgentSessionMainAgentWorking } from './structured-agent-session-main-agent-working'
+import { agentSessionCurrentContextRows } from './agent-session-context-clear'
/** A running turn or an unanswered send: what a `working` status means, and what an `attention`
* status hides beneath its pending prompt. The main-agent rule, read from the journal's items. */
@@ -9,9 +10,10 @@ export function owesStructuredAgentSessionWork(
submissions: readonly AgentJournalSubmission[],
currentFence?: number | null
): boolean {
+ const current = agentSessionCurrentContextRows(items, submissions)
return isStructuredAgentSessionMainAgentWorking(
- activeStructuredAgentSessionTurnId(items),
- submissions,
+ activeStructuredAgentSessionTurnId(current.items),
+ current.submissions,
currentFence
)
}
diff --git a/src/shared/structured-agent-session-projection.ts b/src/shared/structured-agent-session-projection.ts
index dd5737d1c54e..d5b08c833665 100644
--- a/src/shared/structured-agent-session-projection.ts
+++ b/src/shared/structured-agent-session-projection.ts
@@ -39,6 +39,7 @@ import { sha256 } from './sha256'
import { readAgentMessageSource } from './agent-session-message-source'
import { structuredAgentSessionStatusStartedAt } from './structured-agent-session-status-started-at'
import { owesStructuredAgentSessionWork } from './structured-agent-session-owed-work'
+import { agentSessionCurrentContextRows } from './agent-session-context-clear'
// Re-exported so the live-turn readers' and the unanswered-send rule's existing consumers keep one
// import site.
@@ -222,6 +223,7 @@ export function projectStructuredAgentSessionStatus(
submissions: readonly AgentJournalSubmission[] = [],
currentFence?: number | null
): StructuredAgentSessionProjectedStatus {
+ ;({ items, submissions } = agentSessionCurrentContextRows(items, submissions))
if (items.some(isPendingStructuredAgentSessionPrompt)) {
return 'attention'
}
@@ -273,6 +275,7 @@ export function projectStructuredAgentSessionStatusState(
/** Item ids of the approvals and questions waiting on the user: what makes the status `attention`. */
pendingPromptIds: string[]
} {
+ const latestPrompt = latestStructuredAgentSessionPrompt(items)
if (!hasStructuredAgentSessionRequest(items, submissions, currentFence)) {
return {
summary: { status: null, latestPrompt: '' },
@@ -281,6 +284,7 @@ export function projectStructuredAgentSessionStatusState(
pendingPromptIds: []
}
}
+ ;({ items, submissions } = agentSessionCurrentContextRows(items, submissions))
const status = projectStructuredAgentSessionStatus(items, submissions, currentFence)
const statusToolCall = status === 'working' ? statusStructuredAgentSessionToolCall(items) : null
const toolName = statusToolCall
@@ -318,7 +322,7 @@ export function projectStructuredAgentSessionStatusState(
: [],
summary: {
status,
- latestPrompt: normalizePromptField(latestStructuredAgentSessionPrompt(items)),
+ latestPrompt: normalizePromptField(latestPrompt),
...(toolName ? { toolName } : {}),
...(toolInput ? { toolInput } : {}),
...(lastAssistantMessage ? { lastAssistantMessage } : {}),
diff --git a/src/shared/structured-agent-session-status-block.ts b/src/shared/structured-agent-session-status-block.ts
index 080f3c5de53b..abeadd7f229a 100644
--- a/src/shared/structured-agent-session-status-block.ts
+++ b/src/shared/structured-agent-session-status-block.ts
@@ -14,6 +14,7 @@ export function structuredAgentSessionStatusBlock(
type: 'text',
text: body.text,
...(body.presentation !== undefined ? { presentation: body.presentation } : {}),
+ ...(body.contextClear !== undefined ? { contextClear: body.contextClear } : {}),
...(body.tone !== undefined ? { tone: body.tone } : {}),
...(body.providerFrame ? { providerFrame: body.providerFrame } : {}),
...(failure ? { failure } : {}),
diff --git a/tests/e2e/cross-version-wire/agent-session-clear-old-client.unit.test.ts b/tests/e2e/cross-version-wire/agent-session-clear-old-client.unit.test.ts
new file mode 100644
index 000000000000..8d13b384cec0
--- /dev/null
+++ b/tests/e2e/cross-version-wire/agent-session-clear-old-client.unit.test.ts
@@ -0,0 +1,67 @@
+// @vitest-environment happy-dom
+import { act, cleanup, renderHook } from '@testing-library/react'
+import { expect, test } from 'vitest'
+import { importReleaseCheckoutModule, materializeReleaseCheckout } from './release-checkout'
+import {
+ createQueuedMessageTestRig,
+ QUEUED_RIG_CALLER as CALLER
+} from '../../../src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig.test-fixture'
+import {
+ HOST_TEST_SESSION as SESSION,
+ hostTestOperationId
+} from '../../../src/main/native-chat/agent-session-wire/structured-agent-session-host-test-data'
+test('an older desktop releases its clear hold from the actual same-conversation result while its pane stays mounted', async () => {
+ const checkout = await materializeReleaseCheckout('b6b4d68cd844c921c7c5191cef72d5adafc0dafa')
+ const [commands, pending] = await Promise.all([
+ importReleaseCheckoutModule(
+ checkout,
+ 'src/renderer/src/components/native-chat/use-structured-agent-session-command-write.ts'
+ ),
+ importReleaseCheckoutModule(
+ checkout,
+ 'src/renderer/src/components/native-chat/structured-agent-session-pending-sends.ts'
+ )
+ ])
+ const useCommand = commands.useStructuredAgentSessionCommandWrite
+ const held = pending.structuredAgentSessionSendsHeld
+ if (typeof useCommand !== 'function' || typeof held !== 'function') {
+ throw new Error('historical clear hold missing')
+ }
+ const rig = await createQueuedMessageTestRig({ restartable: true })
+ try {
+ const before = await rig.host.journalSnapshot(SESSION)
+ const view = renderHook(() =>
+ useCommand(SESSION, async () => {
+ const fields = { command: 'clear' as const }
+ const result = await rig.host.conversationCommand(CALLER, {
+ ...fields,
+ envelope: rig.envelope(fields, 'agentSession.conversationCommand', hostTestOperationId())
+ })
+ if (!result.ok) {
+ throw new Error('clear failed')
+ }
+ expect(result.value.replacementSessionId).toBeUndefined()
+ return { kind: 'done', value: result.value }
+ })
+ )
+ let request: Promise | undefined
+ act(() => {
+ request = view.result.current('clear')
+ })
+ expect(held(SESSION)).toBe(true)
+ await act(async () => {
+ expect(await request).toMatchObject({
+ kind: 'done',
+ value: { command: 'clear', state: 'completed' }
+ })
+ })
+ expect(held(SESSION)).toBe(false)
+ const after = await rig.host.journalSnapshot(SESSION)
+ expect(after.cursor.epoch).toBe(before.cursor.epoch)
+ expect(after.items.slice(0, before.items.length)).toEqual(before.items)
+ expect(after.items.at(-1)?.body).toMatchObject({ presentation: 'context-cleared' })
+ } finally {
+ cleanup()
+ await rig.dispose()
+ }
+}, 300_000)
diff --git a/tests/e2e/cross-version-wire/agent-session-clear-old-tab-mirror.unit.test.ts b/tests/e2e/cross-version-wire/agent-session-clear-old-tab-mirror.unit.test.ts
new file mode 100644
index 000000000000..7151b9bcefee
--- /dev/null
+++ b/tests/e2e/cross-version-wire/agent-session-clear-old-tab-mirror.unit.test.ts
@@ -0,0 +1,72 @@
+import { describe, expect, it, vi } from 'vitest'
+import { importReleaseCheckoutModule, materializeReleaseCheckout } from './release-checkout'
+import {
+ HOST_TEST_SESSION,
+ HOST_TEST_NOW
+} from '../../../src/main/native-chat/agent-session-wire/structured-agent-session-host-test-data'
+import {
+ WORKTREE,
+ createChat,
+ clear,
+ snapshot
+} from '../../../src/main/runtime/rpc/methods/structured-chat-tab-table.test-fixture'
+
+describe('historical clients after a clear', () => {
+ it.each(['v1.4.222', 'b6b4d68cd844c921c7c5191cef72d5adafc0dafa'])(
+ '%s mirrors the actual published tab without changing pane or conversation identity',
+ async (ref) => {
+ const checkout = await materializeReleaseCheckout(ref)
+ vi.doMock(`${checkout.root}/src/renderer/src/runtime/web-runtime-session.ts`, () => ({
+ HOST_TERMINAL_SURFACE_SEPARATOR: '::',
+ WEB_TERMINAL_SURFACE_TAB_PREFIX: 'web-terminal:',
+ toWebTerminalSurfaceTabId: (id: string) => id
+ }))
+ vi.doMock(`${checkout.root}/src/renderer/src/runtime/runtime-terminal-stream.ts`, () => ({
+ getRemoteRuntimePtyEnvironmentId: () => null
+ }))
+ const module = await importReleaseCheckoutModule(
+ checkout,
+ 'src/renderer/src/runtime/web-session-tabs-sync/terminal-surfaces.ts'
+ )
+ const build = module.buildMirroredAgentTabs
+ if (typeof build !== 'function') {
+ throw new Error('historical agent tab mirror missing')
+ }
+ expect(await createChat(HOST_TEST_SESSION)).toMatchObject({ ok: true })
+ const localTabs = [
+ {
+ id: 'existing-local-pane',
+ entityId: HOST_TEST_SESSION,
+ contentType: 'agent-session',
+ agentSessionAgent: 'codex',
+ worktreeId: WORKTREE,
+ groupId: 'existing-local-group',
+ label: 'Codex Chat',
+ customLabel: 'My conversation',
+ color: null,
+ createdAt: HOST_TEST_NOW,
+ sortOrder: 0,
+ isPinned: true
+ }
+ ]
+ const mirror = async () =>
+ build(await snapshot(), 'remote-host', new Map(), 'fallback', 0, localTabs, HOST_TEST_NOW)
+ const before = await mirror()
+ await clear(HOST_TEST_SESSION)
+ expect(await mirror()).toEqual(before)
+ expect(await mirror()).toMatchObject([
+ {
+ hostTabId: `agent-session:${HOST_TEST_SESSION}`,
+ unifiedTab: {
+ id: 'existing-local-pane',
+ entityId: HOST_TEST_SESSION,
+ groupId: 'existing-local-group',
+ customLabel: 'My conversation',
+ isPinned: true
+ }
+ }
+ ])
+ },
+ 300_000
+ )
+})
From 7dd761541911bdf1261cb2d399c2b00769e9de3b Mon Sep 17 00:00:00 2001
From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
Date: Thu, 8 Oct 2026 12:17:14 -0700
Subject: [PATCH 2/4] Derive clear pauses only from journal membership and
cover older rewind clients
---
.../queued-message-context-clear.test.ts | 19 +--
.../queued-message-pause.test.ts | 25 ++--
.../queued-message-pause.ts | 23 +--
.../queued-message-table.ts | 3 +-
...gent-session-clear-old-client.unit.test.ts | 131 +++++++++++++++++-
5 files changed, 151 insertions(+), 50 deletions(-)
diff --git a/src/main/native-chat/agent-session-journal/queued-message-context-clear.test.ts b/src/main/native-chat/agent-session-journal/queued-message-context-clear.test.ts
index 623e77f110da..04d113950d71 100644
--- a/src/main/native-chat/agent-session-journal/queued-message-context-clear.test.ts
+++ b/src/main/native-chat/agent-session-journal/queued-message-context-clear.test.ts
@@ -7,11 +7,10 @@ import {
nextSendableQueuedCard
} from './queued-message-pause'
-const card = (messageId: string, carriedFrom: string | null = null) => ({
+const card = (messageId: string) => ({
messageId,
state: 'waiting',
holdReason: null,
- carriedFrom,
queuedAt: { epoch: 'epoch', sequence: 2 }
})
const marks = () => ({
@@ -29,7 +28,7 @@ const pauses = (cards = [card('before-one'), card('before-two')], m = marks(), a
describe('exact waiting-card membership after context clear', () => {
it('holds only recorded waiting IDs and intersects them with cards still waiting', () => {
- const after = card('after', 'old-legacy-source')
+ const after = card('after')
const current = [card('before-two'), after]
const held = pauses(current)
expect(held).toEqual([
@@ -77,18 +76,4 @@ describe('exact waiting-card membership after context clear', () => {
messageIds: ['before-two']
})
})
-
- it('uses carried-from only for old-host cards without a new clear mark', () => {
- const cards = [card('legacy', 'old-source')]
- expect(
- deriveQueuePauses({
- epoch: 'epoch',
- marks: createJournalQueuePauseMarks(),
- latestAcceptedTurnSequence: 0,
- cards,
- reopenFloor: null
- })
- ).toEqual([{ reason: 'cleared', since: null }])
- expect(pauses(cards)).toEqual([])
- })
})
diff --git a/src/main/native-chat/agent-session-journal/queued-message-pause.test.ts b/src/main/native-chat/agent-session-journal/queued-message-pause.test.ts
index 916dc78f65ef..288062688548 100644
--- a/src/main/native-chat/agent-session-journal/queued-message-pause.test.ts
+++ b/src/main/native-chat/agent-session-journal/queued-message-pause.test.ts
@@ -648,19 +648,22 @@ describe('which cards the pauses in force hold', () => {
function card(messageId: string, queuedAfter: number, fields: Partial = {}): Card {
const queuedAt = { epoch: 'epoch-1', sequence: queuedAfter + 1 }
- const base = { state: 'waiting', holdReason: null, carriedFrom: null }
+ const base = { state: 'waiting', holdReason: null }
return { messageId, ...base, queuedAt, ...fields }
}
/** A Stop at sequence 5 unless `stopped` is 0; this handle opened at `opened` and could not mark
* it (0: nothing to mark); no turn, Resume or reopen mark since. */
- function pausesOver(cards: readonly Card[], stopped = 5, opened = 0) {
+ function pausesOver(cards: readonly Card[], stopped = 5, opened = 0, clearedIds?: string[]) {
return deriveQueuePauses({
epoch: 'epoch-1',
marks: {
latestStop: stopped ? { sequence: stopped, event: { reason: 'user-stop', at: 0 } } : null,
resumedSequence: 0,
- reopenedSequence: 0
+ reopenedSequence: 0,
+ ...(clearedIds
+ ? { cleared: { sequence: 4, operationId: 'clear', messageIds: clearedIds } }
+ : {})
},
latestAcceptedTurnSequence: 0,
cards,
@@ -727,15 +730,15 @@ describe('which cards the pauses in force hold', () => {
})
it("a /clear's pause that holds nothing never hides a reopen's", () => {
- const cards = [
- card('carried', 1, { carriedFrom: 'source-session', holdReason: 'send_failed' }),
- card('typed', 2)
- ]
- expect(pausesOver(cards, 0, 4).map((pause) => pause.reason)).toEqual(['cleared', 'restarted'])
- expect(holding(cards, 0, 4)).toEqual([
- ['carried', null],
+ const cards = [card('cleared', 1, { holdReason: 'send_failed' }), card('typed', 2)]
+ const pauses = pausesOver(cards, 0, 4, ['cleared'])
+ expect(pauses.map((pause) => pause.reason)).toEqual(['cleared', 'restarted'])
+ expect(
+ cards.map((each) => [each.messageId, queuePauseHolding(pauses, each)?.reason ?? null])
+ ).toEqual([
+ ['cleared', null],
['typed', 'restarted']
])
- expect(nextSendableQueuedCard(pausesOver(cards, 0, 4), cards)).toBeNull()
+ expect(nextSendableQueuedCard(pauses, cards)).toBeNull()
})
})
diff --git a/src/main/native-chat/agent-session-journal/queued-message-pause.ts b/src/main/native-chat/agent-session-journal/queued-message-pause.ts
index d087bf3ea64c..688265f7c278 100644
--- a/src/main/native-chat/agent-session-journal/queued-message-pause.ts
+++ b/src/main/native-chat/agent-session-journal/queued-message-pause.ts
@@ -3,8 +3,7 @@
// - 'stopped': the latest Stop event is a person's (reason `user-stop`), with no later Resume row
// and no turn sent after it and accepted. A later Stop of any reason supersedes it; only a
// person's pauses.
-// - 'cleared': a card /clear carried into this conversation waits, and no turn or Resume has
-// happened here since.
+// - 'cleared': a card named by the latest clear mark waits, with no later accepted turn or Resume.
// - 'restarted': a card queued before this conversation last opened waits — Orca quit or
// crashed, or the chat closed, while it waited — and no turn or Resume has happened since the
// open. The open marks itself with a row when it finds waiting cards (`queueReopen`), so a card
@@ -48,8 +47,7 @@ export type JournalQueuePauseMarks = {
export type DerivedQueuePause = {
reason: QueuePauseReason
- /** Where a Stop's or a reopen's pause began: a card queued at or after it is newer. Null for
- * /clear's, which holds the cards it carried. */
+ /** Where the pause began; clear holds its exact recorded message IDs. */
since: AgentJournalCursor | null
messageIds?: readonly string[]
}
@@ -58,7 +56,6 @@ type QueueCard = {
messageId?: string
state: string
holdReason: string | null
- carriedFrom: string | null
queuedAt: AgentJournalCursor | null
}
@@ -184,7 +181,6 @@ export function deriveQueuePauses(input: {
pauses.push({ reason: 'stopped', since: { epoch, sequence: stop.sequence } })
}
const waiting = input.cards.filter((card) => card.state === 'waiting')
- const carried = waiting.filter((card) => card.carriedFrom !== null)
const cleared = marks.cleared
if (
cleared &&
@@ -198,13 +194,6 @@ export function deriveQueuePauses(input: {
if (messageIds.length > 0) {
pauses.push({ reason: 'cleared', since: { epoch, sequence: cleared.sequence }, messageIds })
}
- } else if (
- !cleared &&
- carried.length > 0 &&
- latestAcceptedTurnSequence === 0 &&
- marks.resumedSequence === 0
- ) {
- pauses.push({ reason: 'cleared', since: null })
}
const reopened = reopenPause(input)
if (
@@ -238,14 +227,10 @@ function reopenPause(input: {
return { reason: 'restarted', since: { epoch, sequence } }
}
-/** Queued before the pause began: for /clear, a card it carried. For a Stop or a reopen, a card
- * queued before its row; one from another epoch (before a rewind) or from a build that recorded
- * no position counts as before. A withdrawn steer keeps its position, so is held. */
+/** Clear holds recorded IDs; other pauses compare queued positions within the journal epoch. */
function queuedBefore(pause: DerivedQueuePause, card: QueueCard): boolean {
if (pause.reason === 'cleared') {
- return pause.messageIds
- ? pause.messageIds.includes(card.messageId ?? '')
- : card.carriedFrom !== null
+ return pause.messageIds?.includes(card.messageId ?? '') ?? false
}
const { since } = pause
return (
diff --git a/src/main/native-chat/agent-session-journal/queued-message-table.ts b/src/main/native-chat/agent-session-journal/queued-message-table.ts
index ea4f36aa7397..558f17ffc51c 100644
--- a/src/main/native-chat/agent-session-journal/queued-message-table.ts
+++ b/src/main/native-chat/agent-session-journal/queued-message-table.ts
@@ -55,8 +55,7 @@ export type QueuedMessageRow = {
* card, cleared when a withdrawal sends it back to waiting. Host-only; the published link is
* the submission's `queuedMessageId`. */
consumedAs: string | null
- /** The conversation /clear carried this card from; null for a card written here. What the
- * replacement's 'cleared' pause is derived from. */
+ /** Inert historical column; current inserts write null. */
carriedFrom: string | null
/** Where the journal stood when it was queued: a Stop's pause holds only cards queued before
* it. Null on rows from builds before it was recorded, which read as queued before any Stop. */
diff --git a/tests/e2e/cross-version-wire/agent-session-clear-old-client.unit.test.ts b/tests/e2e/cross-version-wire/agent-session-clear-old-client.unit.test.ts
index 8d13b384cec0..d43214047c6b 100644
--- a/tests/e2e/cross-version-wire/agent-session-clear-old-client.unit.test.ts
+++ b/tests/e2e/cross-version-wire/agent-session-clear-old-client.unit.test.ts
@@ -1,15 +1,37 @@
// @vitest-environment happy-dom
import { act, cleanup, renderHook } from '@testing-library/react'
-import { expect, test } from 'vitest'
+import { afterEach, expect, test, vi } from 'vitest'
import { importReleaseCheckoutModule, materializeReleaseCheckout } from './release-checkout'
import {
createQueuedMessageTestRig,
+ eventually,
QUEUED_RIG_CALLER as CALLER
} from '../../../src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig.test-fixture'
import {
HOST_TEST_SESSION as SESSION,
hostTestOperationId
} from '../../../src/main/native-chat/agent-session-wire/structured-agent-session-host-test-data'
+import { agentJournalSubmissionKey } from '../../../src/shared/agent-session-journal-item-key'
+import { EMPTY_STRUCTURED_AGENT_SESSION } from '../../../src/shared/structured-agent-session-reducer'
+import type { useNativeChatRewind } from '../../../src/renderer/src/components/native-chat/use-native-chat-rewind'
+
+const toastError = vi.hoisted(() => vi.fn())
+vi.mock('sonner', () => ({ toast: { error: toastError } }))
+
+type RewindInput = Parameters[0]
+type OldRewindModule = Record & {
+ useNativeChatRewind: typeof useNativeChatRewind
+}
+function isOldRewindModule(module: Record): module is OldRewindModule {
+ return typeof module.useNativeChatRewind === 'function'
+}
+
+afterEach(() => {
+ cleanup()
+ vi.useRealTimers()
+ toastError.mockReset()
+})
+
test('an older desktop releases its clear hold from the actual same-conversation result while its pane stays mounted', async () => {
const checkout = await materializeReleaseCheckout('b6b4d68cd844c921c7c5191cef72d5adafc0dafa')
const [commands, pending] = await Promise.all([
@@ -65,3 +87,110 @@ test('an older desktop releases its clear hold from the actual same-conversation
await rig.dispose()
}
}, 300_000)
+
+test('an older rewind-capable desktop releases its temporary same-epoch hold without losing history or its returned draft', async () => {
+ const checkout = await materializeReleaseCheckout('b6b4d68cd844c921c7c5191cef72d5adafc0dafa')
+ vi.doMock(
+ `${checkout.root}/src/renderer/src/runtime/structured-agent-session-host-capability.ts`,
+ () => ({
+ useStructuredAgentSessionHostRecoversRewindOnSend: () => true
+ })
+ )
+ const [module, drafts] = await Promise.all([
+ importReleaseCheckoutModule(
+ checkout,
+ 'src/renderer/src/components/native-chat/use-native-chat-rewind.ts'
+ ),
+ importReleaseCheckoutModule(
+ checkout,
+ 'src/renderer/src/components/native-chat/native-chat-draft-cache.ts'
+ )
+ ])
+ if (!isOldRewindModule(module) || typeof drafts.readNativeChatDraftCache !== 'function') {
+ throw new Error('historical rewind hook or draft reader missing')
+ }
+ const rig = await createQueuedMessageTestRig({
+ restartable: true,
+ rewind: async () => ({ ok: true })
+ })
+ try {
+ async function send(text: string, turn: string) {
+ const sent = rig.send(text)
+ expect(await sent.result).toMatchObject({ ok: true })
+ await eventually(async () =>
+ expect((await rig.submission(sent.id))?.handedOverAt).toBeDefined()
+ )
+ await rig.settleAccepted(sent.id, turn)
+ return agentJournalSubmissionKey(sent.id)
+ }
+ await send('earlier context remains visible', 'old')
+ const clear = { command: 'clear' as const }
+ expect(
+ await rig.host.conversationCommand(CALLER, {
+ ...clear,
+ envelope: rig.envelope(clear, 'agentSession.conversationCommand', hostTestOperationId())
+ })
+ ).toMatchObject({ ok: true })
+ await send('keep this current-context message', 'kept')
+ const target = await send('return this message to the draft', 'target')
+ const before = await rig.host.journalSnapshot(SESSION)
+ const fields = { itemId: target, expectedEpoch: before.cursor.epoch }
+ const result = await rig.host.rewind(CALLER, {
+ ...fields,
+ envelope: rig.envelope(fields, 'agentSession.rewind', hostTestOperationId())
+ })
+ expect(result).toMatchObject({ ok: true })
+ if (!result.ok) {
+ throw new Error('suffix rewind failed')
+ }
+ const after = await rig.host.journalSnapshot(SESSION)
+ expect(after.cursor.epoch).toBe(before.cursor.epoch)
+ expect(result.value.sequence).toBe(after.cursor.sequence)
+ expect(after.items.map((item) => item.body)).toEqual(
+ before.items.filter((item) => item.itemId !== target).map((item) => item.body)
+ )
+ const floor = rig.host.collaboratorsForTests().sessions.get(SESSION)!.journal.context.floor()!
+ expect(after.items.filter((item) => item.sequence <= floor.sequence)).toEqual(
+ before.items.filter((item) => item.sequence <= floor.sequence)
+ )
+
+ vi.useFakeTimers()
+ const input: RewindInput = {
+ sessionId: SESSION,
+ composerScopeKey: 'old-rewind-clear-compat',
+ state: {
+ ...EMPTY_STRUCTURED_AGENT_SESSION,
+ status: 'ready',
+ epoch: before.cursor.epoch,
+ cursor: before.cursor,
+ fence: result.fence,
+ items: before.items,
+ submissions: before.submissions
+ },
+ support: { supported: true },
+ blocked: false,
+ send: async () => ({ kind: 'done', value: result.value })
+ }
+ const view = renderHook((props: RewindInput) => module.useNativeChatRewind(props), {
+ initialProps: input
+ })
+ await act(() => view.result.current.request(target, async () => true))
+ view.rerender({ ...input, state: { ...input.state, cursor: after.cursor, items: after.items } })
+ expect(view.result.current.pending).toBe(true)
+ expect(view.result.current.blockedRef.current).toBe(true)
+ expect(drafts.readNativeChatDraftCache(input.composerScopeKey)).toContain(
+ 'return this message to the draft'
+ )
+ act(() => vi.advanceTimersByTime(119_999))
+ expect(view.result.current.blockedRef.current).toBe(true)
+ act(() => vi.advanceTimersByTime(1))
+ expect(view.result.current.pending).toBe(false)
+ expect(view.result.current.blockedRef.current).toBe(false)
+ expect(toastError).toHaveBeenCalledOnce()
+ expect(await rig.host.journalSnapshot(SESSION)).toEqual(after)
+ } finally {
+ cleanup()
+ vi.useRealTimers()
+ await rig.dispose()
+ }
+}, 300_000)
From fe8c75b810f1e58d1469df397901bd780b3c8881 Mon Sep 17 00:00:00 2001
From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
Date: Thu, 8 Oct 2026 15:01:35 -0700
Subject: [PATCH 3/4] Point queued command rewind test at the structured hook
---
.../use-structured-agent-session.queued-gating.test.tsx | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session.queued-gating.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session.queued-gating.test.tsx
index 52736ea98470..84976d1d4659 100644
--- a/src/renderer/src/components/native-chat/use-structured-agent-session.queued-gating.test.tsx
+++ b/src/renderer/src/components/native-chat/use-structured-agent-session.queued-gating.test.tsx
@@ -17,7 +17,7 @@ import type { AgentSessionQueuedMessage } from '../../../../shared/agent-session
import type { StructuredAgentSessionPendingSend } from './structured-agent-session-pending-sends'
import { agentJournalSubmissionKey } from '../../../../shared/agent-session-journal-item-key'
import { structuredAgentSessionDeliveryNotices } from './structured-agent-session-delivery-notices'
-import type * as RewindModule from './use-native-chat-rewind'
+import type * as RewindModule from './use-structured-agent-session-rewind'
const mocks = vi.hoisted(() => ({
call: vi.fn(),
@@ -53,7 +53,7 @@ vi.mock('./use-structured-agent-session-read', () => ({
}))
// The real rewind hook, with only its in-flight latch forced when a test says so.
-vi.mock('./use-native-chat-rewind', async (importOriginal) => {
+vi.mock('./use-structured-agent-session-rewind', async (importOriginal) => {
const actual = await importOriginal()
return {
...actual,
From 5f2c39efb62d2644e7ea4567d7f4ded6ef818242 Mon Sep 17 00:00:00 2001
From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
Date: Thu, 8 Oct 2026 15:18:52 -0700
Subject: [PATCH 4/4] Group queue consume errors and rewind replay assertions
---
.../journal-queued-messages.ts | 12 +---
.../queued-message-consume-error.ts | 9 +++
...gent-session-rewind-replay.test-fixture.ts | 68 +++++++++++++++++++
.../structured-agent-session-rewind.test.ts | 46 +++----------
4 files changed, 87 insertions(+), 48 deletions(-)
create mode 100644 src/main/native-chat/agent-session-journal/queued-message-consume-error.ts
create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-rewind-replay.test-fixture.ts
diff --git a/src/main/native-chat/agent-session-journal/journal-queued-messages.ts b/src/main/native-chat/agent-session-journal/journal-queued-messages.ts
index 0da11ea0c647..3337727689cd 100644
--- a/src/main/native-chat/agent-session-journal/journal-queued-messages.ts
+++ b/src/main/native-chat/agent-session-journal/journal-queued-messages.ts
@@ -47,6 +47,8 @@ import {
import { AgentSessionJournalError, assertJournalWritable } from './journal-write-guards'
import type { JournalAttachmentClaim } from './journal-submission-hook'
import type { JournalWriteBody, JournalWriteResult } from './journal-write-queue'
+import { QueuedMessageNotConsumableError } from './queued-message-consume-error'
+export { QueuedMessageNotConsumableError } from './queued-message-consume-error'
/** Tombstones must outlive the window in which their operation id could still be admitted as new. */
export const QUEUED_MESSAGE_REPLAY_WINDOW_MS =
@@ -379,13 +381,3 @@ export class JournalQueuedMessages {
).then(() => undefined)
}
}
-
-export class QueuedMessageNotConsumableError extends Error {
- constructor(
- readonly messageId: string,
- readonly expected: 'waiting' | 'returned'
- ) {
- super(`queued message ${messageId} is no longer ${expected}`)
- this.name = 'QueuedMessageNotConsumableError'
- }
-}
diff --git a/src/main/native-chat/agent-session-journal/queued-message-consume-error.ts b/src/main/native-chat/agent-session-journal/queued-message-consume-error.ts
new file mode 100644
index 000000000000..2c05b0a1d5b4
--- /dev/null
+++ b/src/main/native-chat/agent-session-journal/queued-message-consume-error.ts
@@ -0,0 +1,9 @@
+export class QueuedMessageNotConsumableError extends Error {
+ constructor(
+ readonly messageId: string,
+ readonly expected: 'waiting' | 'returned'
+ ) {
+ super(`queued message ${messageId} is no longer ${expected}`)
+ this.name = 'QueuedMessageNotConsumableError'
+ }
+}
diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-rewind-replay.test-fixture.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-rewind-replay.test-fixture.ts
new file mode 100644
index 000000000000..dedb69eedae9
--- /dev/null
+++ b/src/main/native-chat/agent-session-wire/structured-agent-session-rewind-replay.test-fixture.ts
@@ -0,0 +1,68 @@
+import { expect, type Mock } from 'vitest'
+import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key'
+import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store'
+import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter'
+import type { StructuredAgentSessionHost } from './structured-agent-session-host'
+import { HOST_TEST_SESSION, HOST_TEST_THREAD } from './structured-agent-session-host-test-data'
+
+type RewindReplayFixture = {
+ host: StructuredAgentSessionHost
+ store: AgentSessionRecordStore
+ rewind: Mock>
+ recoverRewind: Mock>
+ seed: () => Promise
+ params: (itemId: string) => Promise[1]>
+}
+const caller = { callerKey: 'desktop' }
+
+export async function expectSupersededRewindReplay({
+ host,
+ store,
+ rewind,
+ recoverRewind,
+ seed,
+ params
+}: RewindReplayFixture) {
+ const target = await seed()
+ const firstRequest = await params(target)
+ const first = await host.rewind(caller, firstRequest)
+ expect(first).toMatchObject({ ok: true })
+ const secondRequest = await params(
+ agentJournalItemKey({
+ provider: 'codex',
+ threadId: HOST_TEST_THREAD,
+ turnId: 'kept',
+ ordinal: 0
+ })
+ )
+ recoverRewind.mockResolvedValueOnce({ ok: true, items: [] })
+ expect(await host.rewind(caller, secondRequest)).toMatchObject({ ok: true })
+ expect(store.getRecord(HOST_TEST_SESSION)?.rewind?.operationId).toBe(
+ secondRequest.envelope.clientOperationId
+ )
+ const effects = rewind.mock.calls.length
+ const replay = await host.rewind(caller, firstRequest)
+ expect(replay).toMatchObject({ ok: true, replayed: true })
+ if (first.ok && replay.ok) {
+ expect(replay.value).toEqual(first.value)
+ }
+ expect(rewind).toHaveBeenCalledTimes(effects)
+}
+
+export async function expectRetainedPrefixRewindReplay({
+ host,
+ rewind,
+ seed,
+ params
+}: Pick) {
+ const target = await seed()
+ const request = await params(target)
+ const result = await host.rewind(caller, request)
+ expect(result).toMatchObject({ ok: true })
+ expect((await host.journalSnapshot(HOST_TEST_SESSION)).items).toHaveLength(1)
+ expect((await host.journalSnapshot(HOST_TEST_SESSION)).cursor.epoch).not.toBe(
+ request.expectedEpoch
+ )
+ expect(await host.rewind(caller, request)).toMatchObject({ ok: true, replayed: true })
+ expect(rewind).toHaveBeenCalledTimes(1)
+}
diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.test.ts
index c7668956fa00..1bbb02777243 100644
--- a/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.test.ts
+++ b/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.test.ts
@@ -1,3 +1,7 @@
+import {
+ expectSupersededRewindReplay,
+ expectRetainedPrefixRewindReplay
+} from './structured-agent-session-rewind-replay.test-fixture'
import { expectRawStopNoteRewindRecovery } from './structured-agent-session-rewind-stop-note.test-fixture'
import {
AGENT_JOURNAL_THREAD_SCOPE,
@@ -190,32 +194,8 @@ async function params(itemId: string, epoch?: string) {
const LATE = { provider: 'codex' as const, threadId: HOST_TEST_THREAD, turnId: 'late', ordinal: 0 }
describe('host rewind', () => {
- it('replays the original ordinary rewind after another rewind replaces its record', async () => {
- const target = await seed()
- const firstRequest = await params(target)
- const first = await host.rewind(caller, firstRequest)
- expect(first).toMatchObject({ ok: true })
- const secondRequest = await params(
- agentJournalItemKey({
- provider: 'codex',
- threadId: HOST_TEST_THREAD,
- turnId: 'kept',
- ordinal: 0
- })
- )
- recoverRewind.mockResolvedValueOnce({ ok: true, items: [] })
- expect(await host.rewind(caller, secondRequest)).toMatchObject({ ok: true })
- expect(store.getRecord(HOST_TEST_SESSION)?.rewind?.operationId).toBe(
- secondRequest.envelope.clientOperationId
- )
- const effects = rewind.mock.calls.length
- const replay = await host.rewind(caller, firstRequest)
- expect(replay).toMatchObject({ ok: true, replayed: true })
- if (first.ok && replay.ok) {
- expect(replay.value).toEqual(first.value)
- }
- expect(rewind).toHaveBeenCalledTimes(effects)
- })
+ it('replays the original ordinary rewind after another rewind replaces its record', () =>
+ expectSupersededRewindReplay({ host, store, rewind, recoverRewind, seed, params }))
it('retains the raw Stop failure and recovers a committed rewind against raw bodies', async () => {
await expectRawStopNoteRewindRecovery({ host, store, rewind })
@@ -293,18 +273,8 @@ describe('host rewind', () => {
expect(await second).toMatchObject({ ok: false, refusal: { rewindReason: 'stale-epoch' } })
expect(rewind).toHaveBeenCalledTimes(1)
})
- it('replaces the epoch with the retained prefix and replays without another provider call', async () => {
- const target = await seed()
- const request = await params(target)
- const result = await host.rewind(caller, request)
- expect(result).toMatchObject({ ok: true })
- expect((await host.journalSnapshot(HOST_TEST_SESSION)).items).toHaveLength(1)
- expect((await host.journalSnapshot(HOST_TEST_SESSION)).cursor.epoch).not.toBe(
- request.expectedEpoch
- )
- expect(await host.rewind(caller, request)).toMatchObject({ ok: true, replayed: true })
- expect(rewind).toHaveBeenCalledTimes(1)
- })
+ it('replaces the epoch with the retained prefix and replays without another provider call', () =>
+ expectRetainedPrefixRewindReplay({ host, rewind, seed, params }))
it('keeps when each kept message was first seen, not when the rewind re-read it', async () => {
const target = await seed()
const kept = (await host.journalSnapshot(HOST_TEST_SESSION)).items[0]!