mirror of
https://github.com/stablyai/orca.git
synced 2026-10-22 08:03:37 +00:00
fix(native-chat): recover Claude outbox and resume state
This commit is contained in:
@@ -3,7 +3,7 @@ import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { AgentJournalMessageItem } from '../../shared/agent-session-journal-types'
|
||||
import { dispatchClaudeTurn } from './claude-structured-dispatch'
|
||||
import { dispatchClaudeTurn, resolveClaudeReplayWaiter } from './claude-structured-dispatch'
|
||||
import type { ClaudeSession } from './claude-structured-session-state'
|
||||
|
||||
function sessionFor(send = vi.fn().mockResolvedValue(undefined)): ClaudeSession {
|
||||
@@ -26,6 +26,60 @@ function userMessage(blocks: AgentJournalMessageItem['blocks']): AgentJournalMes
|
||||
}
|
||||
|
||||
describe('Claude structured dispatch image limits', () => {
|
||||
it('accepts a slash command from its result receipt when Claude omits the user replay', async () => {
|
||||
const session = sessionFor()
|
||||
const dispatched = dispatchClaudeTurn(
|
||||
session,
|
||||
{ clientMessageId: 'client-1', body: userMessage([{ type: 'text', text: '/permissions' }]) },
|
||||
100
|
||||
)
|
||||
await vi.waitFor(() => expect(session.dispatchWaiters).toHaveLength(1))
|
||||
|
||||
resolveClaudeReplayWaiter(session, {
|
||||
type: 'result',
|
||||
subtype: 'success',
|
||||
session_id: 'provider-session',
|
||||
uuid: 'command-result-uuid'
|
||||
})
|
||||
|
||||
await expect(dispatched).resolves.toEqual({
|
||||
state: 'accepted',
|
||||
providerIdentity: {
|
||||
provider: 'claude',
|
||||
sessionId: 'provider-session',
|
||||
uuid: 'command-result-uuid'
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('does not mistake a normal turn result for its missing user replay', async () => {
|
||||
const session = sessionFor()
|
||||
const dispatched = dispatchClaudeTurn(
|
||||
session,
|
||||
{ clientMessageId: 'client-1', body: userMessage([{ type: 'text', text: 'hello' }]) },
|
||||
100
|
||||
)
|
||||
await vi.waitFor(() => expect(session.dispatchWaiters).toHaveLength(1))
|
||||
|
||||
resolveClaudeReplayWaiter(session, {
|
||||
type: 'result',
|
||||
session_id: 'provider-session',
|
||||
uuid: 'unrelated-result-uuid'
|
||||
})
|
||||
expect(session.dispatchWaiters).toHaveLength(1)
|
||||
resolveClaudeReplayWaiter(session, {
|
||||
type: 'user',
|
||||
parent_tool_use_id: null,
|
||||
session_id: 'provider-session',
|
||||
uuid: 'user-replay-uuid'
|
||||
})
|
||||
|
||||
await expect(dispatched).resolves.toMatchObject({
|
||||
state: 'accepted',
|
||||
providerIdentity: { uuid: 'user-replay-uuid' }
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects more than twenty URL images before sending', async () => {
|
||||
const session = sessionFor()
|
||||
const body = userMessage(
|
||||
|
||||
@@ -54,14 +54,19 @@ export function resolveClaudeReplayWaiter(
|
||||
session: ClaudeSession,
|
||||
message: Record<string, unknown>
|
||||
): void {
|
||||
const isUserReplay = message.type === 'user' && message.parent_tool_use_id === null
|
||||
const isCompletedCommand = message.type === 'result'
|
||||
if (
|
||||
message.type !== 'user' ||
|
||||
message.parent_tool_use_id !== null ||
|
||||
(!isUserReplay && !isCompletedCommand) ||
|
||||
readClaudeFrameString(message, 'session_id') !== session.providerSessionId
|
||||
) {
|
||||
return
|
||||
}
|
||||
const uuid = readClaudeFrameString(message, 'uuid')
|
||||
const current = session.dispatchWaiters[0]
|
||||
if (isCompletedCommand && !current?.acceptsResult) {
|
||||
return
|
||||
}
|
||||
const waiter = uuid ? session.dispatchWaiters.shift() : undefined
|
||||
if (waiter && uuid) {
|
||||
clearTimeout(waiter.timer)
|
||||
@@ -121,9 +126,14 @@ async function messageContent(body: AgentJournalMessageItem): Promise<unknown[]>
|
||||
return content
|
||||
}
|
||||
|
||||
function waitForReplay(session: ClaudeSession, timeoutMs: number): Promise<string | null> {
|
||||
function waitForReplay(
|
||||
session: ClaudeSession,
|
||||
timeoutMs: number,
|
||||
acceptsResult: boolean
|
||||
): Promise<string | null> {
|
||||
return new Promise((resolve) => {
|
||||
const waiter = {
|
||||
acceptsResult,
|
||||
resolve,
|
||||
timer: setTimeout(() => {
|
||||
const index = session.dispatchWaiters.indexOf(waiter)
|
||||
@@ -149,7 +159,10 @@ export async function dispatchClaudeTurn(
|
||||
} catch (error) {
|
||||
return { state: 'rejected', reason: (error as Error).message }
|
||||
}
|
||||
const replayed = waitForReplay(session, timeoutMs)
|
||||
const acceptsResult = input.body.blocks.some(
|
||||
(block) => block.type === 'text' && block.text.trimStart().startsWith('/')
|
||||
)
|
||||
const replayed = waitForReplay(session, timeoutMs, acceptsResult)
|
||||
try {
|
||||
await session.connection.send({
|
||||
type: 'user',
|
||||
|
||||
@@ -55,6 +55,7 @@ export type ClaudeStructuredSessionAdapterDeps = {
|
||||
export type ClaudeDispatchWaiter = {
|
||||
resolve: (uuid: string | null) => void
|
||||
timer: ReturnType<typeof setTimeout>
|
||||
acceptsResult: boolean
|
||||
}
|
||||
|
||||
export type ClaudeSession = {
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { AgentSessionRecord } from '../../../shared/agent-session-record'
|
||||
import type { AgentProviderSessionMetadata } from '../../../shared/agent-session-resume'
|
||||
import { agentSessionProviderHandleChainHead } from '../../../shared/agent-session-provider-handle'
|
||||
import type {
|
||||
AgentSessionHistoryRequest,
|
||||
AgentSessionHistoryResult
|
||||
} from '../../../shared/agent-session-wire'
|
||||
import type { AgentSessionJournal } from '../agent-session-journal/journal-store'
|
||||
import { readAgentSessionHistory } from './agent-session-history-page'
|
||||
|
||||
function providerSessionMetadata(
|
||||
record: AgentSessionRecord | null
|
||||
): AgentProviderSessionMetadata | undefined {
|
||||
const head = record ? agentSessionProviderHandleChainHead(record.providerHandleChain) : null
|
||||
return head
|
||||
? {
|
||||
key: 'session_id',
|
||||
id: head.handle.provider === 'claude' ? head.handle.sessionId : head.handle.threadId
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
|
||||
export function readStructuredAgentSessionHistoryResult(input: {
|
||||
journal: AgentSessionJournal
|
||||
record: AgentSessionRecord | null
|
||||
request: AgentSessionHistoryRequest
|
||||
}): AgentSessionHistoryResult {
|
||||
const result = readAgentSessionHistory(input.journal, input.request)
|
||||
const fence = input.record?.lease.runtimeFence
|
||||
const providerSession = providerSessionMetadata(input.record)
|
||||
if (fence === undefined) {
|
||||
return providerSession ? { ...result, providerSession } : result
|
||||
}
|
||||
return result.ok
|
||||
? {
|
||||
...result,
|
||||
page: { ...result.page, fence },
|
||||
...(providerSession ? { providerSession } : {})
|
||||
}
|
||||
: { ...result, fence, ...(providerSession ? { providerSession } : {}) }
|
||||
}
|
||||
@@ -18,7 +18,6 @@ import type {
|
||||
AgentSessionSendResult,
|
||||
AgentSessionWireRefusal
|
||||
} from '../../../shared/agent-session-wire'
|
||||
import { readAgentSessionHistory } from './agent-session-history-page'
|
||||
import type { AgentSessionAttachParams } from './structured-agent-session-attach'
|
||||
import { performAttach } from './structured-agent-session-attach-flow'
|
||||
import {
|
||||
@@ -54,6 +53,7 @@ import type {
|
||||
StructuredAgentSessionHostDeps,
|
||||
StructuredAgentSessionHostSession
|
||||
} from './structured-agent-session-host-types'
|
||||
import { readStructuredAgentSessionHistoryResult } from './structured-agent-session-history-result'
|
||||
export type {
|
||||
StructuredAgentSessionCaller,
|
||||
StructuredAgentSessionHostDeps
|
||||
@@ -292,12 +292,12 @@ export class StructuredAgentSessionHost {
|
||||
}
|
||||
|
||||
history(request: AgentSessionHistoryRequest): AgentSessionHistoryResult {
|
||||
const result = readAgentSessionHistory(this.requireSession(request.sessionId).journal, request)
|
||||
const fence = this.deps.store.getRecord(request.sessionId)?.lease.runtimeFence
|
||||
if (fence === undefined) {
|
||||
return result
|
||||
}
|
||||
return result.ok ? { ...result, page: { ...result.page, fence } } : { ...result, fence }
|
||||
const record = this.deps.store.getRecord(request.sessionId)
|
||||
return readStructuredAgentSessionHistoryResult({
|
||||
journal: this.requireSession(request.sessionId).journal,
|
||||
record,
|
||||
request
|
||||
})
|
||||
}
|
||||
|
||||
subscribe(input: AgentSessionSubscribeInput): () => void {
|
||||
|
||||
@@ -382,6 +382,15 @@ describe('a structured Claude session over agentSession.*', () => {
|
||||
CLAUDE_CONFIG_DIR: join(root, 'claude-home'),
|
||||
[CLAUDE_SPAWN_TOKEN_ENV]: expect.any(String)
|
||||
})
|
||||
const history = await call('agentSession.history', {
|
||||
sessionId: SESSION,
|
||||
direction: 'tail',
|
||||
limit: 1
|
||||
})
|
||||
expect(history).toMatchObject({
|
||||
ok: true,
|
||||
result: { providerSession: { key: 'session_id', id: PROVIDER_SESSION } }
|
||||
})
|
||||
const stream = await subscribe()
|
||||
|
||||
const body = { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'List files' }] }
|
||||
|
||||
@@ -32,6 +32,7 @@ vi.mock('./use-structured-agent-session', () => ({
|
||||
blockedClientMessageId: null,
|
||||
send: vi.fn(),
|
||||
retry: vi.fn(),
|
||||
discard: vi.fn(),
|
||||
isWorking: false,
|
||||
turnId: null,
|
||||
cancel: vi.fn(),
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { RotateCcw } from 'lucide-react'
|
||||
import { RotateCcw, Trash2 } from 'lucide-react'
|
||||
import type { AgentType } from '../../../../shared/agent-status-types'
|
||||
import { dispatchStructuredAgentSessionComposerCommand } from '../../../../shared/structured-agent-session-composer'
|
||||
import { structuredAgentSessionPaneKey } from '../../../../shared/structured-agent-session-projection'
|
||||
@@ -221,18 +221,29 @@ export function NativeChatStructuredSession(props: {
|
||||
'Message was not sent.'
|
||||
)}
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
onClick={() => controller.retry(retryableOutboxEntry.clientMessageId)}
|
||||
>
|
||||
<RotateCcw className="size-3" />
|
||||
{translate(
|
||||
'auto.components.native.chat.NativeChatStructuredSession.a5e7f14068',
|
||||
'Retry'
|
||||
)}
|
||||
</Button>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
onClick={() => controller.discard(retryableOutboxEntry.clientMessageId)}
|
||||
>
|
||||
<Trash2 className="size-3" />
|
||||
{translate('components.native-chat.structuredSession.discardOutboxEntry', 'Discard')}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
onClick={() => controller.retry(retryableOutboxEntry.clientMessageId)}
|
||||
>
|
||||
<RotateCcw className="size-3" />
|
||||
{translate(
|
||||
'auto.components.native.chat.NativeChatStructuredSession.a5e7f14068',
|
||||
'Retry'
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{controller.error || composerError ? (
|
||||
|
||||
+24
-1
@@ -9,6 +9,7 @@ const mocks = vi.hoisted(() => ({
|
||||
store: null as null | {
|
||||
setState: (state: Record<string, unknown>) => void
|
||||
},
|
||||
setAgentStatus: vi.fn(),
|
||||
subscribe: vi.fn(),
|
||||
unsubscribe: vi.fn()
|
||||
}))
|
||||
@@ -18,7 +19,7 @@ vi.mock('@/store', async () => {
|
||||
const useAppStore = create(() => ({
|
||||
unifiedTabsByWorktree: {},
|
||||
testRuntimeOwner: null,
|
||||
setAgentStatus: vi.fn(),
|
||||
setAgentStatus: mocks.setAgentStatus,
|
||||
removeAgentStatus: vi.fn()
|
||||
}))
|
||||
mocks.store = useAppStore
|
||||
@@ -54,6 +55,7 @@ const structuredTab = {
|
||||
|
||||
const historyResult = {
|
||||
ok: true,
|
||||
providerSession: { key: 'session_id', id: 'provider-session-uuid' },
|
||||
page: {
|
||||
sessionId: 'session-1',
|
||||
epoch: 'epoch-1',
|
||||
@@ -111,4 +113,25 @@ describe('StructuredAgentSessionStatusBridge', () => {
|
||||
|
||||
await waitFor(() => expect(mocks.subscribe).toHaveBeenCalledTimes(2), { timeout: 1_500 })
|
||||
})
|
||||
|
||||
it('publishes the provider id without making native chat terminal-resumable', async () => {
|
||||
render(<StructuredAgentSessionStatusBridge />)
|
||||
|
||||
await waitFor(() => expect(mocks.setAgentStatus).toHaveBeenCalled())
|
||||
expect(mocks.setAgentStatus.mock.calls.at(-1)?.[5]).toEqual({
|
||||
providerSession: { key: 'session_id', id: 'provider-session-uuid' },
|
||||
terminalResumeEligible: false
|
||||
})
|
||||
expect(mocks.setAgentStatus.mock.calls.at(-1)?.[5]?.providerSession?.id).not.toBe('session-1')
|
||||
})
|
||||
|
||||
it('omits resume identity instead of substituting the desktop id on an older host', async () => {
|
||||
mocks.call.mockResolvedValueOnce({ ...historyResult, providerSession: undefined })
|
||||
render(<StructuredAgentSessionStatusBridge />)
|
||||
|
||||
await waitFor(() => expect(mocks.setAgentStatus).toHaveBeenCalled())
|
||||
expect(mocks.setAgentStatus.mock.calls.at(-1)?.[5]).toEqual({
|
||||
terminalResumeEligible: false
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -4,6 +4,7 @@ import type {
|
||||
AgentSessionHandoffStatus,
|
||||
AgentSessionHistoryResult
|
||||
} from '../../../../shared/agent-session-wire'
|
||||
import type { AgentProviderSessionMetadata } from '../../../../shared/agent-session-resume'
|
||||
import {
|
||||
projectStructuredAgentSessionStatus,
|
||||
structuredAgentSessionPaneKey
|
||||
@@ -39,7 +40,11 @@ function latestPrompt(state: StructuredAgentSessionState): string {
|
||||
return ''
|
||||
}
|
||||
|
||||
function projectStatus(tab: StructuredTab, state: StructuredAgentSessionState): void {
|
||||
function projectStatus(
|
||||
tab: StructuredTab,
|
||||
state: StructuredAgentSessionState,
|
||||
providerSession: AgentProviderSessionMetadata | undefined
|
||||
): void {
|
||||
const projection = projectStructuredAgentSessionStatus(state.items)
|
||||
const store = useAppStore.getState()
|
||||
store.setAgentStatus(
|
||||
@@ -53,7 +58,10 @@ function projectStatus(tab: StructuredTab, state: StructuredAgentSessionState):
|
||||
tab.label,
|
||||
undefined,
|
||||
{ tabId: tab.id, worktreeId: tab.worktreeId },
|
||||
{ providerSession: { key: 'session_id', id: tab.entityId } }
|
||||
{
|
||||
...(providerSession ? { providerSession } : {}),
|
||||
terminalResumeEligible: false
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -64,9 +72,10 @@ function startStatusProjection(tab: StructuredTab, target: RuntimeClientTarget):
|
||||
let opening = false
|
||||
let unsubscribe = (): void => {}
|
||||
let reconnectTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let providerSession: AgentProviderSessionMetadata | undefined
|
||||
const apply = (action: Parameters<typeof reduceStructuredAgentSession>[1]): void => {
|
||||
state = reduceStructuredAgentSession(state, action)
|
||||
projectStatus(tab, state)
|
||||
projectStatus(tab, state, providerSession)
|
||||
}
|
||||
const scheduleReconnect = (): void => {
|
||||
if (stopped || connected || reconnectTimer) {
|
||||
@@ -155,6 +164,7 @@ function startStatusProjection(tab: StructuredTab, target: RuntimeClientTarget):
|
||||
if (stopped) {
|
||||
return
|
||||
}
|
||||
providerSession = result.providerSession
|
||||
if (result.ok) {
|
||||
apply({ type: 'tail-page', page: result.page })
|
||||
} else {
|
||||
|
||||
@@ -50,6 +50,28 @@ function refusedResult(code: AgentSessionWireRefusalCode) {
|
||||
return { ok: false, refusal: { code, message: code } }
|
||||
}
|
||||
|
||||
function unknownResult(clientMessageId: string) {
|
||||
return {
|
||||
ok: true,
|
||||
replayed: false,
|
||||
fence: 1,
|
||||
cursor: { epoch: 'epoch-1', sequence: 1 },
|
||||
value: {
|
||||
clientMessageId,
|
||||
submission: {
|
||||
clientMessageId,
|
||||
fence: 1,
|
||||
payloadFingerprint: 'fingerprint',
|
||||
dispatchState: 'unknown',
|
||||
providerItemId: null,
|
||||
reason: 'provider receipt missing',
|
||||
submittedAt: 1,
|
||||
resolvedAt: 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe('useStructuredAgentSessionOutbox', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
@@ -148,4 +170,76 @@ describe('useStructuredAgentSessionOutbox', () => {
|
||||
.clientOperationId
|
||||
).toBe(firstId)
|
||||
})
|
||||
|
||||
it('discards an unconfirmed head after restart and dispatches the queued message', async () => {
|
||||
vi.mocked(globalThis.crypto.randomUUID)
|
||||
.mockReturnValueOnce('11111111-1111-4111-8111-111111111111')
|
||||
.mockReturnValueOnce('22222222-2222-4222-8222-222222222222')
|
||||
mocks.call
|
||||
.mockResolvedValueOnce(unknownResult('11111111-1111-4111-8111-111111111111'))
|
||||
.mockResolvedValueOnce(acceptedResult(1))
|
||||
const first = renderHook(() =>
|
||||
useStructuredAgentSessionOutbox({
|
||||
sessionId: 'session-1',
|
||||
target: LOCAL_TARGET,
|
||||
fence: 1,
|
||||
submissions: []
|
||||
})
|
||||
)
|
||||
|
||||
act(() => expect(first.result.current.send('/permissions prompt')).toBe(true))
|
||||
await waitFor(() => expect(first.result.current.outbox[0]?.state).toBe('unconfirmed'))
|
||||
act(() => expect(first.result.current.send('send this next')).toBe(true))
|
||||
const staleId = first.result.current.outbox[0]!.clientMessageId
|
||||
first.unmount()
|
||||
|
||||
const restarted = renderHook(() =>
|
||||
useStructuredAgentSessionOutbox({
|
||||
sessionId: 'session-1',
|
||||
target: LOCAL_TARGET,
|
||||
fence: 1,
|
||||
submissions: []
|
||||
})
|
||||
)
|
||||
expect(restarted.result.current.outbox).toHaveLength(2)
|
||||
act(() => restarted.result.current.discard(staleId))
|
||||
|
||||
await waitFor(() => expect(mocks.call).toHaveBeenCalledTimes(2))
|
||||
expect(mocks.call.mock.calls[1]?.[2]).toMatchObject({
|
||||
body: { blocks: [{ type: 'text', text: 'send this next' }] }
|
||||
})
|
||||
await waitFor(() => expect(restarted.result.current.outbox).toHaveLength(0))
|
||||
})
|
||||
|
||||
it('retries an unconfirmed slash command after restart without leaving a durable head', async () => {
|
||||
mocks.call
|
||||
.mockResolvedValueOnce(unknownResult('11111111-1111-4111-8111-111111111111'))
|
||||
.mockResolvedValueOnce(acceptedResult(1))
|
||||
const first = renderHook(() =>
|
||||
useStructuredAgentSessionOutbox({
|
||||
sessionId: 'session-1',
|
||||
target: LOCAL_TARGET,
|
||||
fence: 1,
|
||||
submissions: []
|
||||
})
|
||||
)
|
||||
|
||||
act(() => expect(first.result.current.send('/permissions prompt')).toBe(true))
|
||||
await waitFor(() => expect(first.result.current.outbox[0]?.state).toBe('unconfirmed'))
|
||||
const staleId = first.result.current.outbox[0]!.clientMessageId
|
||||
first.unmount()
|
||||
|
||||
const restarted = renderHook(() =>
|
||||
useStructuredAgentSessionOutbox({
|
||||
sessionId: 'session-1',
|
||||
target: LOCAL_TARGET,
|
||||
fence: 1,
|
||||
submissions: []
|
||||
})
|
||||
)
|
||||
act(() => restarted.result.current.retry(staleId))
|
||||
|
||||
await waitFor(() => expect(mocks.call).toHaveBeenCalledTimes(2))
|
||||
await waitFor(() => expect(restarted.result.current.outbox).toHaveLength(0))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -270,5 +270,30 @@ export function useStructuredAgentSessionOutbox(args: {
|
||||
outboxRef.current = next
|
||||
setOutbox(next)
|
||||
}
|
||||
return { outbox, error, blockedClientMessageId: blockedIdRef.current, send, retry }
|
||||
const discard = (clientMessageId: string): void => {
|
||||
const discarded = outboxRef.current.find((entry) => entry.clientMessageId === clientMessageId)
|
||||
const next = outboxRef.current.filter((entry) => entry.clientMessageId !== clientMessageId)
|
||||
if (!writeOutbox(sessionId, next)) {
|
||||
setError('Message could not be removed from the outbox')
|
||||
return
|
||||
}
|
||||
const clearsError =
|
||||
discarded?.state === 'unconfirmed' || blockedIdRef.current === clientMessageId
|
||||
if (blockedIdRef.current === clientMessageId) {
|
||||
blockedIdRef.current = null
|
||||
}
|
||||
if (clearsError) {
|
||||
setError(null)
|
||||
}
|
||||
outboxRef.current = next
|
||||
setOutbox(next)
|
||||
}
|
||||
return {
|
||||
outbox,
|
||||
error,
|
||||
blockedClientMessageId: blockedIdRef.current,
|
||||
send,
|
||||
retry,
|
||||
discard
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,7 +33,8 @@ vi.mock('./use-structured-agent-session-outbox', () => ({
|
||||
blockedClientMessageId: null,
|
||||
error: null,
|
||||
send: vi.fn(),
|
||||
retry: vi.fn()
|
||||
retry: vi.fn(),
|
||||
discard: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
|
||||
@@ -232,6 +232,7 @@ export function useStructuredAgentSession(args: {
|
||||
blockedClientMessageId: outboxController.blockedClientMessageId,
|
||||
send: outboxController.send,
|
||||
retry: outboxController.retry,
|
||||
discard: outboxController.discard,
|
||||
isWorking: turnId !== null,
|
||||
turnId,
|
||||
cancel: (turnId: string) => mutate('agentSession.cancel', 'agentSession.cancel', { turnId }),
|
||||
|
||||
@@ -15202,7 +15202,10 @@
|
||||
"allow": "Allow",
|
||||
"deny": "Deny"
|
||||
},
|
||||
"launchPromptNotDelivered": "Not delivered — check the terminal"
|
||||
"launchPromptNotDelivered": "Not delivered — check the terminal",
|
||||
"structuredSession": {
|
||||
"discardOutboxEntry": "Discard"
|
||||
}
|
||||
},
|
||||
"tab": {
|
||||
"bar": {
|
||||
|
||||
@@ -16,6 +16,27 @@ function makePiCompatibleProviderSession(agent: 'pi' | 'omp' | 'prime-agent') {
|
||||
}
|
||||
|
||||
describe('recordAgentProviderSession', () => {
|
||||
it('does not capture a structured native owner for terminal resume on restart', () => {
|
||||
const store = createTestStore()
|
||||
const paneKey = 'structured-tab:leaf-1'
|
||||
const providerSession = { key: 'session_id' as const, id: 'provider-session-uuid' }
|
||||
|
||||
store
|
||||
.getState()
|
||||
.setAgentStatus(
|
||||
paneKey,
|
||||
{ state: 'working', prompt: 'keep going', agentType: 'claude' },
|
||||
'Claude Chat',
|
||||
undefined,
|
||||
{ tabId: 'structured-tab', worktreeId: 'wt-1' },
|
||||
{ providerSession, terminalResumeEligible: false }
|
||||
)
|
||||
store.getState().captureAllSleepingAgentSessions('quit')
|
||||
|
||||
expect(store.getState().agentStatusByPaneKey[paneKey]?.providerSession).toEqual(providerSession)
|
||||
expect(store.getState().sleepingAgentSessionsByPaneKey[paneKey]).toBeUndefined()
|
||||
})
|
||||
|
||||
it('preserves the root session while a child permission hook moves Codex to waiting', () => {
|
||||
const store = createTestStore()
|
||||
const providerSession = { key: 'session_id' as const, id: 'root-session' }
|
||||
|
||||
@@ -169,6 +169,7 @@ export type AgentStatusSlice = {
|
||||
providerSession?: AgentProviderSessionMetadata
|
||||
launchConfig?: SleepingAgentLaunchConfig
|
||||
launchToken?: string
|
||||
terminalResumeEligible?: false
|
||||
}
|
||||
) => void
|
||||
|
||||
@@ -534,7 +535,11 @@ function sleepingRecordFromEntry(args: {
|
||||
origin?: SleepingAgentSessionRecord['origin']
|
||||
}): SleepingAgentSessionRecord | null {
|
||||
const agent = args.entry.agentType
|
||||
if (!isResumableTuiAgent(agent) || !args.entry.providerSession) {
|
||||
if (
|
||||
args.entry.terminalResumeEligible === false ||
|
||||
!isResumableTuiAgent(agent) ||
|
||||
!args.entry.providerSession
|
||||
) {
|
||||
return null
|
||||
}
|
||||
if (!getAgentResumeArgv(agent, args.entry.providerSession)) {
|
||||
@@ -1941,6 +1946,9 @@ export const createAgentStatusSlice: StateCreator<AppState, [], [], AgentStatusS
|
||||
? existing?.subagents
|
||||
: payload.subagents,
|
||||
...(providerSession ? { providerSession } : {}),
|
||||
...(metadata?.terminalResumeEligible === false
|
||||
? { terminalResumeEligible: false as const }
|
||||
: {}),
|
||||
...(promptInteractionKey ? { promptInteractionKey } : {}),
|
||||
...(payload.restoredUnconfirmed ? { restoredUnconfirmed: true } : {}),
|
||||
// Why: `interrupted` is done-only; parseAgentStatusPayload already clamps it for non-done states, so write it through directly.
|
||||
|
||||
@@ -13,6 +13,7 @@ import type {
|
||||
AgentJournalSubmission
|
||||
} from './agent-session-journal-types'
|
||||
import type { AgentSessionHandoffStage, AgentSessionOwnerRuntimeKind } from './agent-session-record'
|
||||
import type { AgentProviderSessionMetadata } from './agent-session-resume'
|
||||
|
||||
export type AgentSessionHandoffDirection = 'to-tui' | 'to-native'
|
||||
export type AgentSessionHandoffMode = 'now' | 'after-turn' | 'stop-turn'
|
||||
@@ -93,7 +94,7 @@ export type AgentSessionHistoryPage = {
|
||||
}
|
||||
|
||||
export type AgentSessionHistoryResult =
|
||||
| { ok: true; page: AgentSessionHistoryPage }
|
||||
| { ok: true; page: AgentSessionHistoryPage; providerSession?: AgentProviderSessionMetadata }
|
||||
/** Every reset forces a clean reload; the snapshot is inlined so the client
|
||||
* never has to make a second call to recover. */
|
||||
| {
|
||||
@@ -101,6 +102,7 @@ export type AgentSessionHistoryResult =
|
||||
reset: AgentJournalResetReason
|
||||
snapshot: AgentJournalSnapshot
|
||||
fence?: number
|
||||
providerSession?: AgentProviderSessionMetadata
|
||||
}
|
||||
|
||||
/** Cursor-qualified incremental publication. Items and submissions carry their
|
||||
|
||||
@@ -138,6 +138,8 @@ export type AgentStatusEntry = {
|
||||
/** Provider-owned conversation/session id captured from hook payloads.
|
||||
* Used only for exact CLI resume; Orca terminal ids are not agent-session ids. */
|
||||
providerSession?: AgentProviderSessionMetadata
|
||||
/** False when the status belongs to a non-terminal owner that restores itself. */
|
||||
terminalResumeEligible?: false
|
||||
/** Live-only Command Code turn boundary key; not persisted to last-status.json. */
|
||||
promptInteractionKey?: string
|
||||
/** True for a nonterminal state hydrated from last-status.json with no live hook since:
|
||||
|
||||
Reference in New Issue
Block a user