Files
orca/src/main/runtime/runtime-worktree-structured-agent-rows-liveness.test.ts
T
Kelvin Amoaba 3347d47a7d fix(mobile): use desktop's PR lanes and status-dot rollup in the workspace list (#33966)
* fix(mobile): ask before running orca.yaml default-tab commands from the phone

The host's repo.hooks reply now carries desktop's setup config, so the phone
gates the Run/Skip choice and the trust prompt on defaultTabs commands too.

* fix(setup): keep the local script in run-both setup config without orca.yaml commands

* test(setup): drop the shared duplicate-host trust test desktop already covers

* refactor(setup): derive host setup trust from the shared setup config

* fix(mobile): count every host's repo rows before honouring repo-wide setup trust

* fix(mobile): show desktop's PR lanes and failed/interrupted status dots on the phone

The host builds worktree.ps linkedPR with the desktop's PR-lane cache reader
and sends the desktop sidebar dot as an optional sidebarStatus field.

* fix(mobile): keep uncached PR badges and let finished turns show through working titles

* fix(mobile): suppress agent panes' permission and parent titles in the host status dot

* test(runtime): expect sidebarStatus on the compact ps summary

* test(runtime): keep only host-specific sidebar status cases

* refactor(mobile): keep host title suppression and the uncached PR lane in one place each

* test(wire): pass the terminal title evidence map to new host builds

* fix(runtime): read each workspace's PR through its own host's repo
2026-10-27 03:20:58 +00:00

205 lines
7.2 KiB
TypeScript

import { AGENT_JOURNAL_THREAD_SCOPE } from '../../shared/agent-session-journal-types'
import { makeStructuredAgentStatusSubject } from '../../shared/agent-status-subject'
import { collectRuntimeWorktreeAgentSources } from './runtime-worktree-agent-sources'
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { StructuredAgentSessionStatusFeed } from '../native-chat/agent-session-wire/structured-agent-session-status-feed'
import { createTrackedJournalOpener } from '../native-chat/agent-session-journal/journal-host-database-test-support'
import type { AgentSessionStatusSummary } from '../../shared/agent-session-wire'
import type { RuntimeWorktreePsSummary } from '../../shared/runtime-types'
import { AgentHookServer, _internals } from '../agent-hooks/server'
import { attachRuntimeWorktreeAgentRows } from './runtime-worktree-agent-rows'
import { createStructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger'
import { codexProviderHandle } from '../../shared/agent-session-provider-handle-encoding'
vi.mock('../telemetry/client', () => ({ track: vi.fn() }))
vi.mock('../telemetry/cohort-classifier', () => ({
getCohortAtEmit: vi.fn(() => ({ nth_repo_added: 2 }))
}))
/** Exercise journal to worktree-status liveness: feed summaries never retract, but the store drops closed chats, including unanswered approvals whose retained status stays attention. */
const WORKTREE_ID = 'repo-1::/workspace/app'
const SESSION = 'a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d'
const SUBJECT = makeStructuredAgentStatusSubject(
{
executionHostId: 'local',
wslDistro: null,
workspaceId: WORKTREE_ID,
workspaceKind: 'git-worktree'
},
SESSION
)
const IDENTITY = {
provider: 'codex',
threadId: 'thread-1',
turnId: 'turn-1',
ordinal: 0
} as const
let root: string
const journals = createTrackedJournalOpener()
beforeEach(async () => {
_internals.resetCachesForTests()
root = await mkdtemp(join(tmpdir(), 'orca-structured-ps-liveness-'))
})
afterEach(async () => {
await journals.closeAll()
await rm(root, { recursive: true, force: true })
})
/** A session parked on an approval nobody answered — the state a deliberate close leaves behind. */
async function awaitingApproval() {
const journal = await journals.open({
identity: {
sessionId: SESSION,
workspaceId: WORKTREE_ID,
hostId: 'local',
agent: 'codex',
providerHandle: codexProviderHandle('thread-1')
},
stateDirectory: join(root, SESSION)
})
await journal.appendItem(
{ ...IDENTITY, ordinal: 1 },
{ kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'rm the branch' }] },
{ fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE }
)
await journal.appendItem(
{ ...IDENTITY, ordinal: 2 },
{
kind: 'approval',
title: 'Run the command?',
detail: null,
options: [{ id: 'allow', label: 'Allow' }],
resolution: { state: 'pending', selectedOptionId: null, resolvedBy: null, resolvedAt: null }
},
{ fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE }
)
const sessions = new Map([
[
SESSION,
{
journal,
child: { phase: 'ready' as const, generation: 'child-1', fence: 1 },
params: {
location: {
executionHostId: 'local' as const,
wslDistro: null,
workspaceId: WORKTREE_ID,
workspaceKind: 'git-worktree' as const
},
provider: 'codex' as const
}
}
]
])
const store = new AgentHookServer()
const published: AgentSessionStatusSummary[] = []
const feed = new StructuredAgentSessionStatusFeed({
logger: createStructuredAgentSessionLogger(),
sessions,
getConversation: () => null,
now: () => Date.now(),
statusSink: () => ({
publish: (summary, subject) => {
published.push(summary)
store.ingestStructuredStatus(summary, subject)
},
forget: (sessionId) => store.dropStructuredStatus(sessionId)
})
})
feed.publish(SESSION, journal)
return { feed, sessions, store, published }
}
function worktreeFor(store: AgentHookServer): RuntimeWorktreePsSummary {
const row = {
worktreeId: WORKTREE_ID,
status: 'inactive',
agents: []
} as unknown as RuntimeWorktreePsSummary
const summaries = new Map([[WORKTREE_ID, row]])
attachRuntimeWorktreeAgentRows({
summaries,
terminalTitleEvidenceByWorktreeId: new Map(),
rowSources: collectRuntimeWorktreeAgentSources({
mirroredWorktreeIdByTabId: new Map(),
connectedPtyEvidence: {
tabIds: new Set(),
paneKeys: new Set(),
ptyIdByTerminalHandle: new Map()
},
hookSnapshots: store.getStatusSnapshot()
}),
orchestrationByPaneKey: null,
getSummary: (id) => summaries.get(id) ?? null
})
return row
}
describe('worktree ps and a closed structured chat', () => {
it('reports the blocked row while the session is still held', async () => {
const { store } = await awaitingApproval()
const row = worktreeFor(store)
expect(row.agents).toHaveLength(1)
expect(row.agents[0]?.state).toBe('blocked')
expect(row.status).toBe('permission')
})
it('stops reporting it once close forgets the session', async () => {
const { feed, sessions, store } = await awaitingApproval()
// What the host does after eviction: the feed keeps its projection, the store drops the row.
sessions.delete(SESSION)
feed.close(SESSION)
const row = worktreeFor(store)
expect(row.agents).toHaveLength(0)
expect(row.status).toBe('inactive')
})
it('keeps an aged host-held working state authoritative', async () => {
const { store, published } = await awaitingApproval()
const aged = {
...published.at(-1)!,
hostExecutionOwned: true as const,
updatedAt: Date.now() - 30 * 60 * 1000 - 1,
status: 'working' as const
}
store.ingestStructuredStatus(aged, SUBJECT)
const row = worktreeFor(store)
expect(row.agents).toHaveLength(1)
expect(row.agents[0]?.state).toBe('working')
expect(row.status).toBe('working')
expect(row.agents[0]?.updatedAt).toBe(aged.updatedAt)
})
it('keeps an aged host-held approval state authoritative', async () => {
const { store, published } = await awaitingApproval()
const aged = {
...published.at(-1)!,
hostExecutionOwned: true as const,
updatedAt: Date.now() - 30 * 60 * 1000 - 1
}
store.ingestStructuredStatus(aged, SUBJECT)
const row = worktreeFor(store)
expect(row.agents).toHaveLength(1)
expect(row.agents[0]?.state).toBe('blocked')
expect(row.status).toBe('permission')
expect(row.agents[0]?.updatedAt).toBe(aged.updatedAt)
})
it('lets an aged approval decay once the host no longer owns the child', async () => {
const { store, published } = await awaitingApproval()
const { hostExecutionOwned: _owned, ...held } = published.at(-1)!
store.ingestStructuredStatus({ ...held, updatedAt: Date.now() - 30 * 60 * 1000 - 1 }, SUBJECT)
const row = worktreeFor(store)
expect(row.agents).toHaveLength(1)
expect(row.agents[0]?.state).toBe('blocked')
expect(row.status).toBe('inactive')
})
})