mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 08:02:28 +00:00
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:
co-authored by
Merge Sim
parent
28214e1ea1
commit
7314ada3fb
@@ -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
|
||||
|
||||
+1
-1
@@ -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' })
|
||||
|
||||
Reference in New Issue
Block a user