fix(native-chat): ignore stale restored working status (#17995)

* fix(native-chat): ignore stale restored working status

* fix(native-chat): sample status freshness per epoch

* Revert "fix(native-chat): sample status freshness per epoch"

This reverts commit 8e49b1badd.

* test(native-chat): include hook status timestamps

* test(native-chat): include visibility hook timestamp

---------

Co-authored-by: Merge Sim <sim@local>
This commit is contained in:
Brennan Benson
2026-09-01 14:31:23 -07:00
committed by GitHub
co-authored by Merge Sim
parent 28214e1ea1
commit 7314ada3fb
4 changed files with 120 additions and 14 deletions
@@ -0,0 +1,62 @@
import { describe, expect, it } from 'vitest'
import { resolveNativeChatHookState } from './use-native-chat-hook-status'
describe('resolveNativeChatHookState', () => {
const now = 1_000_000
it('does not treat a restored working row as live activity', () => {
expect(
resolveNativeChatHookState(
{
state: 'working',
workingMode: undefined,
updatedAt: now,
restoredUnconfirmed: true
},
now
)
).toBeNull()
})
it('keeps confirmed working activity live', () => {
expect(
resolveNativeChatHookState(
{
state: 'working',
workingMode: undefined,
updatedAt: now,
restoredUnconfirmed: false
},
now
)
).toBe('working')
})
it('continues to suppress monitoring rows', () => {
expect(
resolveNativeChatHookState(
{
state: 'working',
workingMode: 'monitoring',
updatedAt: now,
restoredUnconfirmed: false
},
now
)
).toBeNull()
})
it('does not keep an expired working row live', () => {
expect(
resolveNativeChatHookState(
{
state: 'working',
workingMode: undefined,
updatedAt: now - 30 * 60 * 1000 - 1,
restoredUnconfirmed: false
},
now
)
).toBeNull()
})
})
@@ -1,16 +1,39 @@
import { useAppStore } from '../../store'
import type { AgentStatusState } from '../../../../shared/agent-status-types'
import { isExplicitAgentStatusFresh } from '@/lib/agent-status'
import {
AGENT_STATUS_STALE_AFTER_MS,
type AgentStatusEntry,
type AgentStatusState
} from '../../../../shared/agent-status-types'
/**
* Hydrated nonterminal rows are only recovery evidence until a live hook event
* confirms the turn. They must not make Native Chat look permanently busy.
*/
export function resolveNativeChatHookState(
entry:
| Pick<AgentStatusEntry, 'state' | 'workingMode' | 'updatedAt' | 'restoredUnconfirmed'>
| undefined,
now = Date.now()
): AgentStatusState | null {
if (!entry || !isExplicitAgentStatusFresh(entry, now, AGENT_STATUS_STALE_AFTER_MS)) {
return null
}
return entry.state === 'working' && entry.workingMode === 'monitoring' ? null : entry.state
}
export function useNativeChatHookStatus(
paneKey: string
): readonly [AgentStatusState | null, number | null, boolean] {
// Freshness is time-based; subscribe to the scheduler epoch so a silent
// working row stops driving Native Chat when its TTL expires.
const agentStatusEpoch = useAppStore((store) => store.agentStatusEpoch)
void agentStatusEpoch
// Why: primitive selectors keep unrelated pane/status updates from rerendering
// native chat while still exposing the three fields used for reconciliation.
const state = useAppStore((store) => {
const entry = store.agentStatusByPaneKey[paneKey]
return entry?.state === 'working' && entry.workingMode === 'monitoring'
? null
: (entry?.state ?? null)
return resolveNativeChatHookState(entry)
})
const stateStartedAt = useAppStore(
(store) => store.agentStatusByPaneKey[paneKey]?.stateStartedAt ?? null
@@ -122,7 +122,7 @@ describe('useNativeChatLiveSession visibility', () => {
it('unsubscribes on hide, retains committed messages, and rejects hidden work', async () => {
useAppStore.setState({
agentStatusByPaneKey: {
[BASE_ARGS.paneKey]: { state: 'working', stateStartedAt: 100 }
[BASE_ARGS.paneKey]: { state: 'working', stateStartedAt: 100, updatedAt: Date.now() }
}
} as never)
await render(BASE_ARGS)
@@ -385,7 +385,9 @@ describe('useNativeChatLiveSession — transport routing', () => {
it("self-heals a stale 'working' hook once the turn-complete marker lands", async () => {
useAppStore.setState({
agentStatusByPaneKey: { [PANE]: { state: 'working', stateStartedAt: 1 } as never }
agentStatusByPaneKey: {
[PANE]: { state: 'working', stateStartedAt: 1, updatedAt: Date.now() } as never
}
})
const transport = getMockTransport('env-1')
await render({ paneKey: PANE, agent: AGENT, sessionId: SESSION, runtimeEnvironmentId: 'env-1' })
@@ -403,7 +405,9 @@ describe('useNativeChatLiveSession — transport routing', () => {
it('stops foreground working UI when Claude enters monitoring', async () => {
useAppStore.setState({
agentStatusByPaneKey: { [PANE]: { state: 'working', stateStartedAt: 1 } as never }
agentStatusByPaneKey: {
[PANE]: { state: 'working', stateStartedAt: 1, updatedAt: Date.now() } as never
}
})
const transport = getMockTransport('env-1')
await render({ paneKey: PANE, agent: AGENT, sessionId: SESSION, runtimeEnvironmentId: 'env-1' })
@@ -419,7 +423,12 @@ describe('useNativeChatLiveSession — transport routing', () => {
await act(async () => {
useAppStore.setState({
agentStatusByPaneKey: {
[PANE]: { state: 'working', workingMode: 'monitoring', stateStartedAt: 1 } as never
[PANE]: {
state: 'working',
workingMode: 'monitoring',
stateStartedAt: 1,
updatedAt: Date.now()
} as never
}
})
})
@@ -429,7 +438,9 @@ describe('useNativeChatLiveSession — transport routing', () => {
it('applies a lifecycle-only append after the final message frame', async () => {
useAppStore.setState({
agentStatusByPaneKey: { [PANE]: { state: 'working', stateStartedAt: 1 } as never }
agentStatusByPaneKey: {
[PANE]: { state: 'working', stateStartedAt: 1, updatedAt: Date.now() } as never
}
})
const transport = getMockTransport('env-1')
await render({ paneKey: PANE, agent: AGENT, sessionId: SESSION, runtimeEnvironmentId: 'env-1' })
@@ -456,7 +467,9 @@ describe('useNativeChatLiveSession — transport routing', () => {
it('applies a terminal-side interruption frame without a local Stop action', async () => {
useAppStore.setState({
agentStatusByPaneKey: { [PANE]: { state: 'working', stateStartedAt: 1 } as never }
agentStatusByPaneKey: {
[PANE]: { state: 'working', stateStartedAt: 1, updatedAt: Date.now() } as never
}
})
const transport = getMockTransport('env-1')
await render({ paneKey: PANE, agent: AGENT, sessionId: SESSION, runtimeEnvironmentId: 'env-1' })
@@ -483,7 +496,9 @@ describe('useNativeChatLiveSession — transport routing', () => {
it('does not let an older pagination read rewind a live completion', async () => {
useAppStore.setState({
agentStatusByPaneKey: { [PANE]: { state: 'working', stateStartedAt: 1 } as never }
agentStatusByPaneKey: {
[PANE]: { state: 'working', stateStartedAt: 1, updatedAt: Date.now() } as never
}
})
const transport = getMockTransport('env-1')
const many = Array.from({ length: NATIVE_CHAT_INITIAL_LIMIT }, (_unused, index) =>
@@ -529,7 +544,9 @@ describe('useNativeChatLiveSession — transport routing', () => {
it('reconciles completion from a reconnect snapshot', async () => {
useAppStore.setState({
agentStatusByPaneKey: { [PANE]: { state: 'working', stateStartedAt: 10 } as never }
agentStatusByPaneKey: {
[PANE]: { state: 'working', stateStartedAt: 10, updatedAt: Date.now() } as never
}
})
const transport = getMockTransport('env-1')
await render({ paneKey: PANE, agent: AGENT, sessionId: SESSION, runtimeEnvironmentId: 'env-1' })
@@ -557,7 +574,9 @@ describe('useNativeChatLiveSession — transport routing', () => {
it('reconciles interruption from a reconnect snapshot', async () => {
useAppStore.setState({
agentStatusByPaneKey: { [PANE]: { state: 'working', stateStartedAt: 10 } as never }
agentStatusByPaneKey: {
[PANE]: { state: 'working', stateStartedAt: 10, updatedAt: Date.now() } as never
}
})
const transport = getMockTransport('env-1')
await render({ paneKey: PANE, agent: AGENT, sessionId: SESSION, runtimeEnvironmentId: 'env-1' })
@@ -708,7 +727,9 @@ describe('useNativeChatLiveSession — notFound retry (#8401)', () => {
const transport = getMockTransport('env-1', { autoSnapshot: false })
transport.readSession.mockResolvedValue({ error: 'No transcript found', notFound: true })
useAppStore.setState({
agentStatusByPaneKey: { [PANE]: { state: 'working', stateStartedAt: 1 } }
agentStatusByPaneKey: {
[PANE]: { state: 'working', stateStartedAt: 1, updatedAt: Date.now() }
}
} as never)
await render({ paneKey: PANE, agent: AGENT, sessionId: SESSION, runtimeEnvironmentId: 'env-1' })