Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,10 @@ describe('live activity reconciler', () => {
it('ends the activity when the session turns terminal', async () => {
await openTurn('srv-1', session())
const handle = factory.start.mock.results[0].value
await reconcile('srv-1', session({ completedAt: '2026-07-25T11:00:00.000Z' }))
await reconcile(
'srv-1',
session({ status: 'idle', ptyAttached: false, lifecycle: 'completed' }),
)
expect(handle.end).toHaveBeenCalledWith('immediate')
})

Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
/**
* SessionScreen — ended-session redirect.
*
* When a session has ended (ptyAttached:false, status:'idle') the screen should
* open its conversation history if the session HAS a conversation. The trigger
* is boundConversationId ?? conversationId, NOT promptCount — promptCount counts
* only prompts sent through the app, so an adopted / externally-started session
* with real history reads promptCount 0 and must still redirect rather than
* strand on the read-only "Running in another terminal" placeholder.
* When a session has no live process (streamer `lifecycle` completed/failed/
* resumable, or the legacy idle+detached pair on older servers) the screen
* should open its conversation history if the session HAS a conversation.
* The trigger is boundConversationId ?? conversationId, NOT promptCount —
* promptCount counts only prompts sent through the app, so an adopted /
* externally-started session with real history reads promptCount 0 and must
* still redirect rather than strand on the read-only placeholder.
*/
import React from 'react'
import { render } from '@testing-library/react-native'
Expand Down Expand Up @@ -113,40 +114,82 @@ describe('SessionScreen — ended-session redirect', () => {
mockParams = { id: SESSION_UUID, server: 'srv1' }
})

it('redirects to the conversation when the ended session has a conversationId, even with promptCount 0', async () => {
mockSessionData = endedSession({ conversationId: SESSION_UUID, promptCount: 0 })
it('redirects when lifecycle is completed and a conversationId is present', async () => {
mockSessionData = endedSession({
conversationId: SESSION_UUID,
promptCount: 0,
lifecycle: 'completed',
})
await render(<SessionDetailScreen />, { wrapper: createWrapper() })
expect(mockReplace).toHaveBeenCalledWith(`/conversation/${SESSION_UUID}?server=srv1`)
})

it('redirects a held (resumable) session to conversation history for resume', async () => {
mockSessionData = endedSession({
conversationId: SESSION_UUID,
lifecycle: 'resumable',
completedAt: '2026-08-01T00:00:00Z',
})
await render(<SessionDetailScreen />, { wrapper: createWrapper() })
expect(mockReplace).toHaveBeenCalledWith(`/conversation/${SESSION_UUID}?server=srv1`)
})

it('redirects via boundConversationId (codex) even without conversationId', async () => {
mockSessionData = endedSession({ boundConversationId: SESSION_UUID, conversationId: null, promptCount: 0 })
mockSessionData = endedSession({
boundConversationId: SESSION_UUID,
conversationId: null,
promptCount: 0,
lifecycle: 'completed',
})
await render(<SessionDetailScreen />, { wrapper: createWrapper() })
expect(mockReplace).toHaveBeenCalledWith(`/conversation/${SESSION_UUID}?server=srv1`)
})

it('does NOT redirect when the ended session has no conversation at all', async () => {
mockSessionData = endedSession({ conversationId: null, boundConversationId: null, promptCount: 0 })
mockSessionData = endedSession({
conversationId: null,
boundConversationId: null,
promptCount: 0,
lifecycle: 'completed',
})
await render(<SessionDetailScreen />, { wrapper: createWrapper() })
expect(mockReplace).not.toHaveBeenCalled()
})

// A just-resumed session reads idle + detached until its PTY attaches, which
// looks identical to an ended one. Redirecting then bounced the user straight
// back to the conversation they had just tapped Resume on.
it('falls back to idle+detached redirect when lifecycle is absent', async () => {
mockSessionData = endedSession({ conversationId: SESSION_UUID, promptCount: 0 })
await render(<SessionDetailScreen />, { wrapper: createWrapper() })
expect(mockReplace).toHaveBeenCalledWith(`/conversation/${SESSION_UUID}?server=srv1`)
})

// A just-resumed session on an older server (no lifecycle) can read
// idle+detached until its PTY attaches. Redirecting then bounced the user
// straight back to the conversation they had just tapped Resume on.
it('does NOT redirect a starting session that has not attached its PTY yet', async () => {
mockParams = { id: SESSION_UUID, server: 'srv1', starting: '1' }
mockSessionData = endedSession({ conversationId: SESSION_UUID, promptCount: 0 })
await render(<SessionDetailScreen />, { wrapper: createWrapper() })
expect(mockReplace).not.toHaveBeenCalled()
})

it('does NOT redirect an attached live session', async () => {
mockSessionData = endedSession({
conversationId: SESSION_UUID,
ptyAttached: true,
status: 'waiting_input',
lifecycle: 'attached',
})
await render(<SessionDetailScreen />, { wrapper: createWrapper() })
expect(mockReplace).not.toHaveBeenCalled()
})

it('drops the starting screen once the PTY is attached', async () => {
mockParams = { id: SESSION_UUID, server: 'srv1', starting: '1' }
mockSessionData = endedSession({
conversationId: SESSION_UUID,
ptyAttached: true,
status: 'waiting_input',
lifecycle: 'attached',
})
const { queryByText } = await render(<SessionDetailScreen />, { wrapper: createWrapper() })
expect(queryByText('Starting session…')).toBeNull()
Expand Down
77 changes: 70 additions & 7 deletions __tests__/unit/lib/sessionPresentation.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import {
deriveConversationPresentation,
deriveSessionPresentation,
sessionOpensAsHistory,
sessionPhase,
} from '@/lib/sessionPresentation'

Expand Down Expand Up @@ -120,17 +121,47 @@ describe('deriveSessionPresentation', () => {
capabilities: { canResume: true, canSendInput: false, isObserveOnly: true },
})
})

it('prefers lifecycle completed/failed over status idle', () => {
expect(
deriveSessionPresentation(
base({ status: 'idle', ptyAttached: false, lifecycle: 'completed' }),
),
).toMatchObject({ kind: 'completed', live: false })
expect(
deriveSessionPresentation(
base({
status: 'idle',
ptyAttached: false,
lifecycle: 'failed',
failureReason: 'boom',
}),
).labelKey,
).toBe('status.failed')
})

it('treats lifecycle resumable as historical (resume, observe-only)', () => {
expect(
deriveSessionPresentation(
base({ status: 'idle', ptyAttached: false, lifecycle: 'resumable' }),
),
).toMatchObject({
kind: 'historical',
live: false,
capabilities: { canResume: true, isObserveOnly: true },
})
})
})

describe('sessionPhase', () => {
it('reads an idle, detached session with no completedAt as starting, not ended', () => {
it('reads an idle, detached session with no lifecycle as starting, not ended', () => {
expect(sessionPhase({ status: 'idle', ptyAttached: false })).toBe('starting')
})

it('reads an idle, detached session with completedAt as ended', () => {
it('does not treat completedAt alone as ended (holds stamp it too)', () => {
expect(
sessionPhase({ status: 'idle', ptyAttached: false, completedAt: '2026-08-01T00:00:00Z' }),
).toBe('ended')
).toBe('starting')
})

it('reads an idle, attached session as live', () => {
Expand All @@ -145,15 +176,47 @@ describe('sessionPhase', () => {
expect(sessionPhase({ status: 'running', ptyAttached: true })).toBe('live')
})

it('gives completedAt precedence over ptyAttached still reading true', () => {
// The server can report a stale ptyAttached: true on the same payload
// that finally sets completedAt; completedAt is the authoritative signal.
it('maps streamer lifecycle onto the coarse phase', () => {
expect(sessionPhase({ status: 'running', ptyAttached: true, lifecycle: 'attached' })).toBe(
'live',
)
expect(sessionPhase({ status: 'idle', ptyAttached: false, lifecycle: 'resumable' })).toBe(
'resumable',
)
expect(sessionPhase({ status: 'idle', ptyAttached: false, lifecycle: 'completed' })).toBe(
'ended',
)
expect(sessionPhase({ status: 'idle', ptyAttached: false, lifecycle: 'failed' })).toBe(
'ended',
)
})

it('gives lifecycle precedence over a stale ptyAttached: true', () => {
expect(
sessionPhase({ status: 'idle', ptyAttached: true, completedAt: '2026-08-01T00:00:00Z' }),
sessionPhase({ status: 'idle', ptyAttached: true, lifecycle: 'completed' }),
).toBe('ended')
})
})

describe('sessionOpensAsHistory', () => {
it('opens history for ended and resumable lifecycles', () => {
expect(
sessionOpensAsHistory({ status: 'idle', ptyAttached: false, lifecycle: 'completed' }),
).toBe(true)
expect(
sessionOpensAsHistory({ status: 'idle', ptyAttached: false, lifecycle: 'resumable' }),
).toBe(true)
expect(
sessionOpensAsHistory({ status: 'running', ptyAttached: true, lifecycle: 'attached' }),
).toBe(false)
})

it('falls back to idle+detached when lifecycle is absent', () => {
expect(sessionOpensAsHistory({ status: 'idle', ptyAttached: false })).toBe(true)
expect(sessionOpensAsHistory({ status: 'running', ptyAttached: false })).toBe(false)
})
})

describe('deriveConversationPresentation', () => {
it('returns unavailable when resume is blocked', () => {
expect(
Expand Down
19 changes: 17 additions & 2 deletions __tests__/unit/services/live-activity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,16 +40,26 @@ function makeState(overrides: Partial<LiveSessionState> = {}): LiveSessionState
describe('isTerminal', () => {
it('treats each end-of-life signal independently', () => {
expect(isTerminal(makeSession({ processLiveness: 'gone' }))).toBe(true)
expect(isTerminal(makeSession({ completedAt: '2026-07-25T11:00:00.000Z' }))).toBe(true)
expect(isTerminal(makeSession({ status: 'idle', ptyAttached: false, lifecycle: 'completed' }))).toBe(
true,
)
expect(isTerminal(makeSession({ status: 'idle', ptyAttached: false, lifecycle: 'resumable' }))).toBe(
true,
)
expect(isTerminal(makeSession({ status: 'idle', ptyAttached: false }))).toBe(true)
})

it('does not treat completedAt alone as terminal (holds stamp it too)', () => {
expect(isTerminal(makeSession({ completedAt: '2026-07-25T11:00:00.000Z' }))).toBe(false)
})

it('does not treat a live session as terminal', () => {
expect(isTerminal(makeSession())).toBe(false)
expect(isTerminal(makeSession({ status: 'waiting_input' }))).toBe(false)
// idle but still attached is a lull, not an ending
expect(isTerminal(makeSession({ status: 'idle', ptyAttached: true }))).toBe(false)
expect(isTerminal(makeSession({ processLiveness: 'unknown' }))).toBe(false)
expect(isTerminal(makeSession({ lifecycle: 'attached' }))).toBe(false)
})
})

Expand All @@ -63,7 +73,12 @@ describe('toLiveState', () => {

it('returns null for every terminal signal', () => {
expect(toLiveState(makeSession({ processLiveness: 'gone' }), 'srv-1')).toBeNull()
expect(toLiveState(makeSession({ completedAt: STARTED_AT }), 'srv-1')).toBeNull()
expect(
toLiveState(
makeSession({ status: 'idle', ptyAttached: false, lifecycle: 'completed' }),
'srv-1',
),
).toBeNull()
expect(toLiveState(makeSession({ status: 'idle', ptyAttached: false }), 'srv-1')).toBeNull()
})

Expand Down
8 changes: 6 additions & 2 deletions app/conversation/[id].tsx
Original file line number Diff line number Diff line change
Expand Up @@ -308,8 +308,12 @@ export default function ConversationDetailScreen() {

// Only redirect to the live session view when the session is actually
// attached. An idle, detached session bounces straight back here, so
// redirecting on mere existence is an infinite loop.
const isSessionLive = liveSession?.ptyAttached === true
// redirecting on mere existence is an infinite loop. Prefer streamer
// `lifecycle` when present; fall back to ptyAttached on older servers.
const isSessionLive =
liveSession?.lifecycle != null
? liveSession.lifecycle === 'attached'
: liveSession?.ptyAttached === true
useEffect(() => {
if (isConvNotFound && isSessionLive) {
router.replace(`/session/${id}?server=${serverId}`)
Expand Down
29 changes: 17 additions & 12 deletions app/session/[id].tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
import * as Clipboard from 'expo-clipboard'
import { CopySimple, InfoIcon, PencilSimple, Star, StopCircle, GitDiff, Warning } from 'phosphor-react-native'
import { SessionStatusBadge } from '@/components/sessions/SessionStatusBadge'
import { deriveSessionPresentation } from '@/lib/sessionPresentation'
import { deriveSessionPresentation, sessionOpensAsHistory } from '@/lib/sessionPresentation'
import { useSessionDetail } from '@/hooks/useSession'
import { useSessionActions } from '@/hooks/useSessionActions'
import { useTerminalStream } from '@/hooks/useTerminalStream'
Expand Down Expand Up @@ -555,7 +555,7 @@
})
})
return unsub
}, [

Check warning on line 558 in app/session/[id].tsx

View workflow job for this annotation

GitHub Actions / Lint

React Hook useEffect has a missing dependency: 'stopSession'. Either include it or remove the dependency array
navigation,
isPending,
id,
Expand Down Expand Up @@ -592,25 +592,30 @@
}

useEffect(() => {
// Redirect an ended session to its conversation history. Gate on "has a
// conversation" (boundConversationId ?? conversationId), NOT promptCount:
// promptCount counts only prompts sent through the app, so an adopted /
// externally-started session that has real history reads promptCount 0 and
// would otherwise strand on the read-only placeholder.
// Redirect when there is no live process to attach to and the session has
// conversation history. Prefer streamer `lifecycle` (via sessionOpensAsHistory)
// so a hold (`resumable`) is not conflated with a genuine end — both open
// history (resume lives there), but `completedAt` / idle+detached alone
// cannot tell them apart.
//
// Skip while starting: a session that is spawning also reads
// idle + detached, and redirecting then bounces the user back to the very
// conversation they just resumed from.
// Gate on "has a conversation" (boundConversationId ?? conversationId), NOT
// promptCount: promptCount counts only prompts sent through the app, so an
// adopted / externally-started session that has real history reads
// promptCount 0 and would otherwise strand on the read-only placeholder.
//
// Skip while starting: older servers omit `lifecycle`, and a spawning
// session can still read idle+detached on those builds.
if (isPending) return
const hasConversation = !!(session?.boundConversationId ?? session?.conversationId)
if (session?.ptyAttached === false &&
session?.status === 'idle' &&
if (
session != null &&
sessionOpensAsHistory(session) &&
hasConversation &&
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(id ?? '')
) {
router.replace(`/conversation/${id}?server=${serverId}`)
}
}, [isPending, session?.ptyAttached, session?.status, session?.boundConversationId, session?.conversationId, id, serverId, router])
}, [isPending, session, id, serverId, router])

// Codex bind race: before boundConversationId arrives, history may 404 on the
// placeholder id. When the streamer first publishes the rollout UUID, switch
Expand Down
1 change: 1 addition & 0 deletions e2e/fixtures/session-detail.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
"id": "session-abc123",
"status": "running",
"ptyAttached": true,
"lifecycle": "attached",
"projectPath": "/home/user/my-project",
"projectName": "my-project",
"branch": "main",
Expand Down
1 change: 1 addition & 0 deletions e2e/fixtures/session-first-run.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
"id": "session-first-run",
"status": "running",
"ptyAttached": true,
"lifecycle": "attached",
"projectPath": "/home/user/my-project",
"projectName": "my-project",
"branch": "main",
Expand Down
1 change: 1 addition & 0 deletions e2e/fixtures/session-missing-path.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
"id": "session-missing-path",
"status": "idle",
"ptyAttached": false,
"lifecycle": "failed",
"projectPath": "/Users/ronenmars/Desktop/dev/personal/new/ai-tools",
"projectName": "ai-tools",
"branch": "main",
Expand Down
1 change: 1 addition & 0 deletions e2e/fixtures/session-waiting-input.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
"id": "session-waiting",
"status": "waiting_input",
"ptyAttached": true,
"lifecycle": "attached",
"projectPath": "/home/user/my-project",
"projectName": "my-project",
"branch": "main",
Expand Down
Loading
Loading