Skip to content
Merged
4 changes: 2 additions & 2 deletions mobile/src/session/mobile-structured-agent-session-cancel.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { AgentSessionCancelResult } from '../../../src/shared/agent-session-wire'
import type { AgentJournalRenderItem } from '../../../src/shared/agent-session-journal-types'
import type { StructuredAgentSessionState } from '../../../src/shared/structured-agent-session-reducer'
import { activeStructuredAgentSessionTurnId } from '../../../src/shared/structured-agent-session-live-turn'
import { runningStructuredAgentSessionTurnId } from '../../../src/shared/structured-agent-session-live-turn'
import type { RpcClient } from '../transport/rpc-client'
import {
requestStructuredAgentSessionMutation,
Expand Down Expand Up @@ -37,7 +37,7 @@ export async function requestMobileStructuredAgentSessionCancel(args: {
}): Promise<boolean> {
const { client, enabled, inFlight, onSendError, sessionId, stateRef } = args
const current = stateRef.current
const turnId = activeStructuredAgentSessionTurnId(current.items)
const turnId = runningStructuredAgentSessionTurnId(current)
if (!client || !sessionId || !enabled || current.fence === null || !turnId) {
onSendError('Stop not sent')
return false
Expand Down
43 changes: 43 additions & 0 deletions mobile/src/session/use-mobile-native-chat-turn-disclosure.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -761,4 +761,47 @@ describe('useMobileNativeChatTurnDisclosure', () => {
expect(rows.map((row) => row.activeTurnIsWorking)).toEqual([false, false, true])
expect(rows[0].turnStatus?.workedSeconds).toBe(4)
})

it('draws the live bar on a turn whose record and opening message are not loaded', () => {
// The newest rows of a long turn; its record is above the page, named only by the host.
const items: AgentJournalRenderItem[] = ['a300', 'a301'].map((itemId, index) => ({
itemId,
revision: 0,
sequence: 300 + index,
observedAt: 300 + index,
body: { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: itemId }] },
turnScope: { kind: 'turn', turnItemId: 'turn-record' }
}))
const messages: NativeChatMessage[] = items.map((item) => ({
...userMessage(item.itemId),
role: 'assistant'
}))
const rowsWith = (latestTurn: NativeChatTurnJournal['latestTurn']) => {
act(() => {
renderer = create(
createElement(Harness, {
messages,
enabled: true,
workingStartedAt: 1_000,
turnJournal: { items, submissions: [], latestTurn }
})
)
})
const disclosure = renderer!.root.findByType('result').props.disclosure
const rows = messages.map((message, index) => disclosure.resolveRow(index, message))
act(() => renderer?.unmount())
return rows
}

const hosted = rowsWith({
itemId: 'turn-record',
observedAt: 1,
turn: { turnId: 'turn-1', state: 'running', startedAt: 1_000, userItemId: 'user-1' }
})
expect(hosted[0]?.turnStatus).toMatchObject({ startedAt: 1_000 })
expect(hosted.map((row) => row.activeTurnIsWorking)).toEqual([true, true])

// From the loaded rows alone, nothing names the turn, so no bar draws.
expect(rowsWith(undefined).map((row) => row.turnStatus)).toEqual([null, null])
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,49 @@ describe('mobile structured prompt cancellation', () => {
)
})

it("stops the host's running turn when its record is not loaded", async () => {
state = {
...state,
hasOlder: true,
items: [pendingApproval()],
latestTurn: {
itemId: 'turn-status',
observedAt: 1,
turn: { turnId: 'turn-1', state: 'running', startedAt: 1 }
}
}
act(() => {
renderer = create(createElement(Harness, { promptCancelSupported: true }))
})
expect(hook.isWorking).toBe(true)
await act(async () => {
expect(await hook.cancelPrompt()).toBe(true)
})
expect(mocks.sendRequest).toHaveBeenCalledWith(
'agentSession.cancel',
expect.objectContaining({
turnId: 'turn-1',
prompt: { itemId: 'approval-1', expectedRevision: 4 }
}),
expect.any(Object)
)
})

it('reads a turn the host ended as ended, though its running row is still loaded', () => {
state = {
...state,
latestTurn: {
itemId: 'turn-status',
observedAt: 1,
turn: { turnId: 'turn-1', state: 'completed', startedAt: 1, completedAt: 2 }
}
}
act(() => {
renderer = create(createElement(Harness, { promptCancelSupported: true }))
})
expect(hook.turnId).toBeNull()
})

it('uses the rendered prompt identity when the journal changes before tap', async () => {
act(() => {
renderer = create(createElement(Harness, { promptCancelSupported: true }))
Expand All @@ -243,4 +286,28 @@ describe('mobile structured prompt cancellation', () => {
expect.any(Object)
)
})

it("names the host's running turn for a plain Stop when its record is not loaded", async () => {
state = {
...state,
hasOlder: true,
items: [],
latestTurn: {
itemId: 'turn-status',
observedAt: 1,
turn: { turnId: 'turn-1', state: 'running' }
}
}
act(() => {
renderer = create(createElement(Harness, { promptCancelSupported: true }))
})
expect(hook.turnId).toBe('turn-1')
await act(async () => {
hook.cancel()
})
const call = mocks.sendRequest.mock.calls.find(([method]) => method === 'agentSession.cancel')
expect(call).toBeDefined()
expect(call?.[1]).toMatchObject({ turnId: 'turn-1' })
expect(call?.[1]).not.toHaveProperty('prompt')
})
})
8 changes: 4 additions & 4 deletions mobile/src/session/use-mobile-structured-agent-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@ import { TUI_AGENT_DISPLAY_NAMES } from '../../../src/shared/tui-agent-display-n
import { isStructuredAgentSessionMainAgentWorking } from '../../../src/shared/structured-agent-session-main-agent-working'
import { isFinalAgentSessionReadRefusal } from '../../../src/shared/structured-agent-session-read-refusal'
import {
activeStructuredAgentSessionTurnId,
isStructuredAgentSessionThinking
isStructuredAgentSessionThinking,
runningStructuredAgentSessionTurnId
} from '../../../src/shared/structured-agent-session-live-turn'
import { selectStructuredAgentTurnActivity } from '../../../src/shared/native-chat-turn-activity'
import {
Expand Down Expand Up @@ -177,14 +177,14 @@ export function useMobileStructuredAgentSession(args: {
}),
[transcriptItems, state.submissions]
)
const turnId = activeStructuredAgentSessionTurnId(state.items)
const turnId = runningStructuredAgentSessionTurnId(state)
const turnTiming = useMobileStructuredAgentTurnTiming(
{ ...state, items: transcriptItems },
turnId
)
const activityText =
selectStructuredAgentTurnActivity(state.items, turnId, state.activity)?.text ?? null
const thinking = isStructuredAgentSessionThinking(state.items)
const thinking = isStructuredAgentSessionThinking(state)
const turnIndicator = useMemo(() => ({ thinking, activityText }), [thinking, activityText])
const status = state.status === 'idle' ? 'idle' : state.status
const approvalPrompt = useMemo(
Expand Down
12 changes: 9 additions & 3 deletions mobile/src/session/use-mobile-structured-agent-turn-timing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type {
AgentJournalSubmission
} from '../../../src/shared/agent-session-journal-types'
import type { NativeChatSettledTurns } from '../../../src/shared/native-chat-turn-status'
import type { AgentSessionLatestTurn } from '../../../src/shared/agent-session-wire'
import type { NativeChatTurnJournal } from '../../../src/shared/native-chat-turn-membership'
import type { StructuredAgentHostClock } from '../../../src/shared/structured-agent-session-reducer'
import { selectStructuredAgentTurnBars } from '../../../src/shared/structured-agent-session-turn-timing'
Expand All @@ -19,10 +20,12 @@ export function useMobileStructuredAgentTurnTiming(
{
items,
submissions,
latestTurn,
hostClock
}: {
items: readonly AgentJournalRenderItem[]
submissions: readonly AgentJournalSubmission[]
latestTurn?: AgentSessionLatestTurn | null
hostClock?: StructuredAgentHostClock | null
},
turnId: string | null
Expand All @@ -33,10 +36,13 @@ export function useMobileStructuredAgentTurnTiming(
workingStartedAt: number | null
} {
const { settledTurns, runningTiming } = useMemo(
() => selectStructuredAgentTurnBars(items, submissions, turnId),
[items, submissions, turnId]
() => selectStructuredAgentTurnBars(items, submissions, turnId, latestTurn),
[items, submissions, turnId, latestTurn]
)
const turnJournal = useMemo(
() => ({ items, submissions, latestTurn }),
[items, submissions, latestTurn]
)
const turnJournal = useMemo(() => ({ items, submissions }), [items, submissions])
const [latch, setLatch] = useState<StructuredAgentTurnClockLatch | null>(null)
// Stamp during render (React's derive-from-props pattern) so the first paint of
// a new turn already counts from the right instant.
Expand Down
4 changes: 2 additions & 2 deletions mobile/src/session/use-mobile-structured-send-with-outcome.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
// host advertises the queue and no pending prompt is one this build cannot answer.

import { useCallback } from 'react'
import { activeStructuredAgentSessionTurnId } from '../../../src/shared/structured-agent-session-live-turn'
import { runningStructuredAgentSessionTurnId } from '../../../src/shared/structured-agent-session-live-turn'
import { pendingPromptsAllUnanswerableHere } from '../../../src/shared/agent-session-approval-subject'
import {
structuredAgentSessionSendBody,
Expand Down Expand Up @@ -95,7 +95,7 @@ export function useMobileStructuredSendWithOutcome(args: {
...controller
},
canRun: () =>
!activeStructuredAgentSessionTurnId(stateRef.current.items) &&
!runningStructuredAgentSessionTurnId(stateRef.current) &&
!stateRef.current.items.some(
(item) => pendingStructuredApproval(item) || pendingStructuredQuestion(item)
),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -163,7 +163,7 @@ describe("a Codex subagent's rows on the parent's surfaces", () => {
summary: ['Reading the diff']
})

expect(isStructuredAgentSessionThinking(await items())).toBe(false)
expect(isStructuredAgentSessionThinking({ items: await items() })).toBe(false)
})

it("does not show the child's compaction as the parent's activity line", async () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@

import { agentJournalSubmissionKey } from '../../../shared/agent-session-journal-item-key'
import { isRootAgentJournalItem } from '../../../shared/agent-session-journal-producer'
import { latestStructuredAgentSessionTurn } from '../../../shared/structured-agent-session-live-turn'
import type {
AgentJournalCursor,
AgentJournalRenderItem,
Expand All @@ -21,6 +22,7 @@ import {
type AgentSessionHistoryPage,
type AgentSessionHistoryRequest,
type AgentSessionHistoryResult,
type AgentSessionLatestTurn,
type AgentSessionSubagentRosterEntry
} from '../../../shared/agent-session-wire'
import type { AgentSessionJournal } from '../agent-session-journal/journal-store'
Expand Down Expand Up @@ -308,6 +310,20 @@ function buildPage(input: {
liveCursor: input.snapshot.cursor,
hasOlder: input.hasOlder,
hasNewer: input.hasNewer,
...(input.subagentRoster === undefined ? {} : { subagentRoster: input.subagentRoster })
...(input.subagentRoster === undefined ? {} : { subagentRoster: input.subagentRoster }),
// From the whole timeline, never the page: a long turn's record sits below any window.
latestTurn: snapshotLatestTurn(input.snapshot)
}
}

// A catch-up run pages one snapshot many times; its scan back to the turn record is paid once.
const latestTurnBySnapshot = new WeakMap<AgentJournalSnapshot, AgentSessionLatestTurn | null>()

function snapshotLatestTurn(snapshot: AgentJournalSnapshot): AgentSessionLatestTurn | null {
let latest = latestTurnBySnapshot.get(snapshot)
if (latest === undefined) {
latest = latestStructuredAgentSessionTurn(snapshot.items)
latestTurnBySnapshot.set(snapshot, latest)
}
return latest
}
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ export function deliverToSubscriber(
submissions: page.submissions
},
fence: subscriber.fence,
...(page.latestTurn !== undefined ? { latestTurn: page.latestTurn } : {}),
...shared
},
// On a multi-page catch-up the draft list rides only the final page, or a
Expand Down
Loading
Loading