Skip to content
Closed
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
3 changes: 2 additions & 1 deletion mobile/src/worktree/agent-row-display.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,8 +121,9 @@ describe('agentDotState', () => {
expect(agentRowVerdict(agentRow), JSON.stringify(agentRow)).toBe(
agentMainAgentVerdict(agentRow)
)
// The phone has no acknowledgement record: it reads every turn as desktop reads one unseen.
expect(agentRowVerdictMark(agentRow), JSON.stringify(agentRow)).toBe(
agentVerdictDisplayMark(agentRow)
agentVerdictDisplayMark({ ...agentRow, stateStartedAt: 1, acknowledgedAt: undefined })
)
}
}
Expand Down
8 changes: 5 additions & 3 deletions mobile/src/worktree/agent-row-display.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,10 @@ const AGENT_TURN_OUTCOMES: readonly AgentTurnOutcome[] = [
]

// Mirrors desktop agentMainAgentVerdict and agentVerdictDisplayMark
// (src/shared/agent-main-agent-verdict.ts); a parity test runs both over one table. `mainAgent` is
// the main agent's own status, sent also while subagents hold the row working; an old host sends none.
// (src/shared/agent-main-agent-verdict.ts) as desktop reads a turn the user has not seen; a parity
// test runs both over one table. Desktop drops the failed mark from a turn cut short once the user
// acknowledges it; the phone has no acknowledgement record, so it keeps the unseen reading. `mainAgent`
// is the main agent's own status, sent also while subagents hold the row working; an old host sends none.
export function agentRowVerdict(row: AgentRowVerdictSource): AgentTurnOutcome | null {
if (row.mainAgent && row.mainAgent.state !== 'done') {
return null
Expand All @@ -49,7 +51,7 @@ export function agentRowVerdict(row: AgentRowVerdictSource): AgentTurnOutcome |
return row.state === 'done' && row.interrupted ? 'cancellation' : null
}

// A fault (a failure, or a turn cut short by something other than the user) reads failed and
// A failure, or a turn cut short when the agent stopped without anyone asking, reads failed and
// outranks every state; a user's Stop, a turn a newer request replaced, and an unproven end mark
// only a row that is itself done.
export function agentRowVerdictMark(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -176,7 +176,7 @@ describe('a turn recovery settled after its host went away', () => {
// Nobody stopped it: the flag older readers take as a user's stop stays down.
expect(row?.interrupted ?? false).toBe(false)
// The sidebar and tab read the published row, with no user action in between.
expect(row && agentVerdictDisplayMark(row)).toBe(mark)
expect(row && agentVerdictDisplayMark({ ...row, acknowledgedAt: undefined })).toBe(mark)
expect(row && agentTurnEndedOnPurpose(row)).toBe(false)
// The dot and the OS notification come only from a completion event, and none is sent.
expect(session.completionEvents).toEqual([])
Expand Down Expand Up @@ -216,7 +216,11 @@ describe('a turn recovery settled after its host went away', () => {
session.publish()

const [row] = session.server.getStatusSnapshot()
expect(row && agentVerdictDisplayMark(row)).toBe('failed')
expect(row && agentVerdictDisplayMark({ ...row, acknowledgedAt: undefined })).toBe('failed')
// Once the user has seen it, the chat itself is what still says the turn was cut.
expect(
row && agentVerdictDisplayMark({ ...row, acknowledgedAt: row.stateStartedAt })
).toBeNull()
const [settled] = [
...selectStructuredAgentSettledTurns(session.journal.snapshot().items).values()
]
Expand Down Expand Up @@ -250,7 +254,9 @@ describe('a turn recovery settled after its host went away', () => {
stateStartedAt: RECOVERED,
mainAgent: { state: 'done', outcome: 'unconfirmed' }
})
expect(row && agentVerdictDisplayMark(row)).toBe('unconfirmed')
expect(row && agentVerdictDisplayMark({ ...row, acknowledgedAt: undefined })).toBe(
'unconfirmed'
)
expect(session.completionEvents).toEqual([])
})

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,12 @@ function settled(turnId?: string) {
turn,
label: timing ? formatNativeChatTurnStatusLabel({ elapsedSeconds: 0, ...timing }) : null,
mark: verdict
? agentVerdictDisplayMark({ state: 'done', mainAgent: { state: 'done', outcome: verdict } })
? agentVerdictDisplayMark({
state: 'done',
mainAgent: { state: 'done', outcome: verdict },
stateStartedAt: 1,
acknowledgedAt: undefined
})
: null,
errorRows: items.flatMap((item) =>
item.body.kind === 'status' && item.body.tone === 'error' ? [item.body.text] : []
Expand Down
41 changes: 41 additions & 0 deletions src/renderer/src/attention/agent-attention-acknowledgement.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { describe, expect, it } from 'vitest'
import {
computeAgentAcknowledgementTargets,
type AgentAttentionTurnRecords
} from './agent-attention-acknowledgement'

const KEY = 'tab-1:leaf-1'

describe('computeAgentAcknowledgementTargets', () => {
// The row has read `working` since 1_000 while a subagent runs; its main agent was cut at 3_000.
// An acknowledgement from before the cut must not keep an on-screen chat from clearing it.
const heldOpen = {
stateStartedAt: 1_000,
mainAgent: { stateStartedAt: 3_000 }
}

it("acknowledges a viewed chat whose main agent was cut after the user's last look", () => {
const live: Omit<AgentAttentionTurnRecords, 'acknowledgedTurnStartedAt'> = {
liveTurns: { [KEY]: heldOpen },
retainedTurns: {}
}
const retained: Omit<AgentAttentionTurnRecords, 'acknowledgedTurnStartedAt'> = {
liveTurns: {},
retainedTurns: { [KEY]: { entry: heldOpen } }
}
for (const records of [live, retained]) {
expect(
computeAgentAcknowledgementTargets(
{ ...records, acknowledgedTurnStartedAt: { [KEY]: 2_000 } },
KEY
)
).toEqual([KEY])
expect(
computeAgentAcknowledgementTargets(
{ ...records, acknowledgedTurnStartedAt: { [KEY]: 3_000 } },
KEY
)
).toEqual([])
}
})
})
30 changes: 23 additions & 7 deletions src/renderer/src/attention/agent-attention-acknowledgement.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,19 @@ import {
type AgentAttentionRemainder,
type ReadableAgentAttentionUnread
} from './agent-attention-contract'
import {
agentAttentionStartedAt,
isAgentTurnAcknowledged
} from '../../../shared/agent-turn-acknowledgement'

/** A turn's clocks: the row's, and the main agent's own when child work holds the row open. */
type AgentAttentionTurn = { stateStartedAt: number; mainAgent?: { stateStartedAt?: number } }

/** Subject-keyed turn bookkeeping the acknowledgement policy reads; no surface shape here. */
export type AgentAttentionTurnRecords = {
liveTurns: Record<string, { stateStartedAt: number }>
liveTurns: Record<string, AgentAttentionTurn>
/** Turns kept after their session ended, so a finished agent can still be acknowledged. */
retainedTurns: Record<string, { entry: { stateStartedAt: number } }>
retainedTurns: Record<string, { entry: AgentAttentionTurn }>
acknowledgedTurnStartedAt: Record<string, number>
}

Expand All @@ -33,8 +40,8 @@ export function readAgentAttentionTurnStartedAt(
/**
* Subjects on the viewed surface whose current turn has not been acknowledged yet.
*
* Why compare stateStartedAt (not updatedAt): same-state pings must not re-trigger an ack,
* matching the is-unvisited rule the workspace card uses.
* Judged on the attention clock the failed mark reads (`agentAttentionStartedAt`), so viewing a chat
* whose main agent was cut while child work held its row open clears the mark.
*/
export function computeAgentAcknowledgementTargets(
records: AgentAttentionTurnRecords,
Expand All @@ -44,13 +51,22 @@ export function computeAgentAcknowledgementTargets(
return []
}
const targets: string[] = []
const acknowledgedAt = records.acknowledgedTurnStartedAt[subjectKey] ?? 0
const acknowledgedAt = records.acknowledgedTurnStartedAt[subjectKey]
const liveTurn = records.liveTurns[subjectKey]
if (liveTurn && acknowledgedAt < liveTurn.stateStartedAt) {
if (
liveTurn &&
!isAgentTurnAcknowledged({ stateStartedAt: agentAttentionStartedAt(liveTurn), acknowledgedAt })
) {
targets.push(subjectKey)
}
const retainedTurn = records.retainedTurns[subjectKey]
if (retainedTurn && acknowledgedAt < retainedTurn.entry.stateStartedAt) {
if (
retainedTurn &&
!isAgentTurnAcknowledged({
stateStartedAt: agentAttentionStartedAt(retainedTurn.entry),
acknowledgedAt
})
) {
targets.push(subjectKey)
}
return targets
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,7 @@ export function makeThreads(result: ReturnType<typeof buildActivityEvents>) {
return buildAgentPaneThreads({
events: result.events,
liveAgentByPaneKey: result.liveAgentByPaneKey,
paneEntryByPaneKey: result.paneEntryByPaneKey
paneEntryByPaneKey: result.paneEntryByPaneKey,
acknowledgedAgentsByPaneKey: {}
})
}
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,11 @@ describe('buildActivityEvents', () => {
acknowledgedAgentsByPaneKey: {},
now: 100_000
})
const threads = buildAgentPaneThreads({ events, liveAgentByPaneKey })
const threads = buildAgentPaneThreads({
events,
liveAgentByPaneKey,
acknowledgedAgentsByPaneKey: {}
})

expect(events).toHaveLength(80)
expect(threads).toHaveLength(18)
Expand Down Expand Up @@ -445,7 +449,8 @@ describe('buildActivityEvents', () => {
const groups = buildActivityThreadGroups(
buildAgentPaneThreads({
events: result.events,
liveAgentByPaneKey: result.liveAgentByPaneKey
liveAgentByPaneKey: result.liveAgentByPaneKey,
acknowledgedAgentsByPaneKey: {}
}),
'status'
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,11 @@ describe('activity thread grouping', () => {
acknowledgedAgentsByPaneKey: {},
now: 3_000
})
const threads = buildAgentPaneThreads({ events, liveAgentByPaneKey })
const threads = buildAgentPaneThreads({
events,
liveAgentByPaneKey,
acknowledgedAgentsByPaneKey: {}
})
const groups = buildActivityThreadGroups(threads, 'status')

expect(groups).toHaveLength(2)
Expand Down Expand Up @@ -154,7 +158,11 @@ describe('activity thread grouping', () => {
acknowledgedAgentsByPaneKey: {},
now: 1_000
})
const threads = buildAgentPaneThreads({ events, liveAgentByPaneKey })
const threads = buildAgentPaneThreads({
events,
liveAgentByPaneKey,
acknowledgedAgentsByPaneKey: {}
})
const group = getActivityThreadGroup(threads[0], 'project')

expect(group).toEqual({ key: 'project:unknown', label: 'Unknown project' })
Expand Down Expand Up @@ -228,7 +236,11 @@ describe('activity thread grouping', () => {
acknowledgedAgentsByPaneKey: {},
now: 3_000
})
const threads = buildAgentPaneThreads({ events, liveAgentByPaneKey })
const threads = buildAgentPaneThreads({
events,
liveAgentByPaneKey,
acknowledgedAgentsByPaneKey: {}
})
const groups = buildActivityThreadGroups(threads, 'worktree')

expect(groups.map((group) => group.key)).toEqual(['worktree:wt-a', 'worktree:wt-b'])
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ import {

function makeThread(paneKey: string, overrides: Partial<AgentPaneThread> = {}): AgentPaneThread {
return {
acknowledgedAt: undefined,
paneKey,
tab: makeTab(),
worktree: makeWorktree(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ function clearWithOpenDraft(clear: boolean = true) {
const retained = makeRetainedDoneEntry(tab)
const paneKey = retained.entry.paneKey
const thread: AgentPaneThread = {
acknowledgedAt: undefined,
paneKey,
tab,
worktree: makeWorktree(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ import { activityThreadStatusId } from './activity-thread-presentation'

function makeThread(paneKey: string, overrides: Partial<AgentPaneThread> = {}): AgentPaneThread {
return {
acknowledgedAt: undefined,
paneKey,
tab: makeTab(),
worktree: makeWorktree(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,11 @@ function buildBoth(
) {
const result = buildActivityEvents(args, eventCache)
const threads = buildAgentPaneThreads(
{ events: result.events, liveAgentByPaneKey: result.liveAgentByPaneKey },
{
events: result.events,
liveAgentByPaneKey: result.liveAgentByPaneKey,
acknowledgedAgentsByPaneKey: {}
},
threadCache
)
return { ...result, threads }
Expand Down Expand Up @@ -240,7 +244,8 @@ describe('activity build identity reuse', () => {
const cold = buildActivityEvents(scenario)
const coldThreads = buildAgentPaneThreads({
events: cold.events,
liveAgentByPaneKey: cold.liveAgentByPaneKey
liveAgentByPaneKey: cold.liveAgentByPaneKey,
acknowledgedAgentsByPaneKey: {}
})
expect(cached.events).toEqual(cold.events)
expect(cached.liveAgentByPaneKey).toEqual(cold.liveAgentByPaneKey)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ describe('live activity capacity', () => {
acknowledgedAgentsByPaneKey: {},
now: 3_000
})
const threads = buildAgentPaneThreads(result)
const threads = buildAgentPaneThreads({ ...result, acknowledgedAgentsByPaneKey: {} })

expect(threads).toHaveLength(82)
expect(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ const SSH_HOST = 'ssh:devbox' as ExecutionHostId
function makeThread(overrides: Partial<AgentPaneThread> = {}): AgentPaneThread {
const worktree = makeWorktree()
return {
acknowledgedAt: undefined,
paneKey: PANE_KEY,
paneTitle: 'Test Agent',
agentType: 'claude',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,9 @@ import {
PANE_KEY,
PANE_KEY_2
} from './ActivityPrototypePage-test-fixtures'
import { activityThreadStatusId } from './activity-thread-presentation'

type Ending = 'cancellation' | 'interruption'
type Ending = 'cancellation' | 'interruption' | 'failure'

function doneEntry(paneKey: string, outcome: Ending, at: number): AgentStatusEntry {
return {
Expand All @@ -30,8 +31,12 @@ function doneEntry(paneKey: string, outcome: Ending, at: number): AgentStatusEnt
}
}

/** Status groups for two ended panes, `newer` the most recent. */
function statusGroups(newer: Ending, older: Ending) {
/** The threads for two ended panes, `newer` the most recent. */
function endedThreads(
newer: Ending,
older: Ending,
acknowledgedAgentsByPaneKey: Record<string, number> = {}
) {
const repo = makeRepo()
const worktree = makeWorktree()
const { events, liveAgentByPaneKey } = buildActivityEvents({
Expand All @@ -45,10 +50,15 @@ function statusGroups(newer: Ending, older: Ending) {
},
worktreeMap: new Map([[worktree.id, worktree]]),
repoMap: new Map([[repo.id, repo]]),
acknowledgedAgentsByPaneKey: {},
acknowledgedAgentsByPaneKey,
now: 3_000
})
return buildActivityThreadGroups(buildAgentPaneThreads({ events, liveAgentByPaneKey }), 'status')
return buildAgentPaneThreads({ events, liveAgentByPaneKey, acknowledgedAgentsByPaneKey })
}

/** Status groups for two ended panes, `newer` the most recent. */
function statusGroups(newer: Ending, older: Ending) {
return buildActivityThreadGroups(endedThreads(newer, older), 'status')
}

describe('the status group headers', () => {
Expand All @@ -75,3 +85,20 @@ describe('the status group headers', () => {
expect(groups[0]).toMatchObject({ key: 'interrupted', state: 'interrupted' })
})
})

describe('a thread cut short with nobody asking', () => {
// The same acknowledgement as the sidebar: news until seen, then it reads like a finished turn.
it('leaves Failed, and its "Failed" line, once the user has seen it; a failure stays', () => {
const unseen = endedThreads('interruption', 'failure')
const seen = endedThreads('interruption', 'failure', { [PANE_KEY]: 3_000, [PANE_KEY_2]: 2_000 })
const cut = (threads: typeof seen) => threads.find((thread) => thread.paneKey === PANE_KEY)
const failed = (threads: typeof seen) => threads.find((thread) => thread.paneKey === PANE_KEY_2)

expect(activityThreadStatusId(cut(unseen)!)).toBe('failed')
expect(cut(unseen)?.responsePreview).toBe('Failed')
expect(activityThreadStatusId(cut(seen)!)).toBe('done')
expect(cut(seen)?.responsePreview).not.toBe('Failed')
expect(activityThreadStatusId(failed(seen)!)).toBe('failed')
expect(failed(seen)?.responsePreview).toBe('Failed')
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ const REMOTE_HOST = 'ssh:devbox' as const
function makeRemoteThread(): AgentPaneThread {
const worktree = { ...makeWorktree(), hostId: REMOTE_HOST }
return {
acknowledgedAt: undefined,
paneKey: 'tab-1:11111111-1111-4111-8111-111111111111',
paneTitle: 'Remote agent',
agentType: 'claude',
Expand Down
Loading
Loading