mirror of
https://github.com/stablyai/orca.git
synced 2026-11-04 19:17:28 +00:00
The phone's worktree list now shows the running subagents the desktop sidebar shows, under their parent agent with their own ages, read from the host's existing child records. Tapping one opens its parent's existing chat or terminal. The feature was reviewed in #26423 and merged into this branch. Links from the phone to native chats whose saved tab ID differs from the session-derived one (restored, reserved and headless chats) now resolve to the chat's real tab instead of missing it.
181 lines
7.3 KiB
TypeScript
181 lines
7.3 KiB
TypeScript
import { makeStructuredAgentStatusSubject } from '../../shared/agent-status-subject'
|
|
import { collectRuntimeWorktreeAgentSources } from './runtime-worktree-agent-sources'
|
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
import { statusFeedChildView } from '../native-chat/agent-session-wire/structured-agent-session-status-feed-test-session'
|
|
import { normalizeAgentStatusPayload } from '../../shared/agent-status-types'
|
|
import { resolveWorktreeCatalogSnapshot } from './rpc/worktree-catalog-snapshot'
|
|
import { attachRuntimeWorktreeAgentRows } from './runtime-worktree-agent-rows'
|
|
import {
|
|
structuredAgentSessionPaneKey,
|
|
structuredAgentSessionTabId
|
|
} from '../../shared/structured-agent-session-projection'
|
|
import type { AgentSessionStatusSummary } from '../../shared/agent-session-wire'
|
|
import type { RuntimeWorktreePsSummary } from '../../shared/runtime-types'
|
|
import { AgentHookServer, _internals } from '../agent-hooks/server'
|
|
|
|
vi.mock('../telemetry/client', () => ({ track: vi.fn() }))
|
|
vi.mock('../telemetry/cohort-classifier', () => ({
|
|
getCohortAtEmit: vi.fn(() => ({ nth_repo_added: 2 }))
|
|
}))
|
|
|
|
/**
|
|
* A structured session has no PTY and no hook script, so the host publishes its projection into
|
|
* the agent-status store itself. This walks that store into `worktree ps` rows: before it, the CLI
|
|
* reported a worktree running one as idle while the sidebar showed it working.
|
|
*/
|
|
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
|
|
)
|
|
|
|
function summary(over: Partial<AgentSessionStatusSummary> = {}): AgentSessionStatusSummary {
|
|
return {
|
|
sessionId: SESSION,
|
|
workspaceId: WORKTREE_ID,
|
|
agent: 'claude',
|
|
status: 'working',
|
|
latestPrompt: 'ship the thing',
|
|
updatedAt: 1_757_030_400_000,
|
|
hostExecutionOwned: true,
|
|
...over
|
|
} as AgentSessionStatusSummary
|
|
}
|
|
|
|
function attach(summaries: AgentSessionStatusSummary[]): RuntimeWorktreePsSummary {
|
|
const store = new AgentHookServer()
|
|
for (const entry of summaries) {
|
|
store.ingestStructuredStatus(entry, SUBJECT)
|
|
}
|
|
const row = {
|
|
worktreeId: WORKTREE_ID,
|
|
status: 'inactive',
|
|
hasHostSidebarActivity: false,
|
|
agents: []
|
|
} as unknown as RuntimeWorktreePsSummary
|
|
const summariesById = new Map<string, RuntimeWorktreePsSummary>([[WORKTREE_ID, row]])
|
|
attachRuntimeWorktreeAgentRows({
|
|
summaries: summariesById,
|
|
workingTerminalEvidenceByWorktreeId: new Map(),
|
|
rowSources: collectRuntimeWorktreeAgentSources({
|
|
mirroredWorktreeIdByTabId: new Map(),
|
|
connectedPtyEvidence: {
|
|
tabIds: new Set(),
|
|
paneKeys: new Set(),
|
|
ptyIdByTerminalHandle: new Map()
|
|
},
|
|
hookSnapshots: store.getStatusSnapshot(),
|
|
readStructuredSummary: (sessionId) =>
|
|
summaries.findLast((entry) => entry.sessionId === sessionId)
|
|
}),
|
|
orchestrationByPaneKey: null,
|
|
getSummary: (id) => summariesById.get(id) ?? null
|
|
})
|
|
return row
|
|
}
|
|
|
|
beforeEach(() => {
|
|
_internals.resetCachesForTests()
|
|
})
|
|
|
|
describe('worktree ps reports structured sessions', () => {
|
|
it('carries canonical summary children, invalidates child-only changes, and clears an omitted roster', () => {
|
|
const child = statusFeedChildView()
|
|
const catalog = (children: (typeof child)[] | undefined) => ({
|
|
worktrees: [attach([summary({ children })])],
|
|
totalCount: 1,
|
|
truncated: false
|
|
})
|
|
const first = catalog([child])
|
|
expect(first.worktrees[0]?.agents[0]).toMatchObject({
|
|
children: [child],
|
|
subagentObservation: 'live'
|
|
})
|
|
const initial = resolveWorktreeCatalogSnapshot(first, null)
|
|
expect(resolveWorktreeCatalogSnapshot(catalog([child]), initial.snapshotId)).toEqual({
|
|
unchanged: true,
|
|
snapshotId: initial.snapshotId
|
|
})
|
|
expect(
|
|
resolveWorktreeCatalogSnapshot(catalog([{ ...child, state: 'waiting' }]), initial.snapshotId)
|
|
).not.toHaveProperty('unchanged')
|
|
expect(catalog(undefined).worktrees[0]?.agents[0]?.children).toEqual([])
|
|
// Old readers keep the parent fields; hook payloads cannot become a child producer.
|
|
expect(normalizeAgentStatusPayload({ state: 'working', children: [child] })).not.toHaveProperty(
|
|
'children'
|
|
)
|
|
})
|
|
|
|
it("lists a person's Stop still ending the turn, and drops it once the host does", () => {
|
|
expect(attach([summary({ stopping: true })]).agents[0]).toMatchObject({
|
|
state: 'working',
|
|
mainAgent: { state: 'working', stopping: true }
|
|
})
|
|
const ended = attach([summary({ stopping: true }), summary({ updatedAt: 1_757_030_401_000 })])
|
|
expect(ended.agents[0]?.state).toBe('working')
|
|
expect(ended.agents[0]?.mainAgent).not.toHaveProperty('stopping')
|
|
})
|
|
|
|
it('a busy structured session is not reported idle', () => {
|
|
const row = attach([summary()])
|
|
expect(row.agents).toHaveLength(1)
|
|
expect(row.agents[0]?.state).toBe('working')
|
|
expect(row.agents[0]?.agentType).toBe('claude')
|
|
expect(row.agents[0]?.prompt).toBe('ship the thing')
|
|
expect(row.status).toBe('working')
|
|
})
|
|
|
|
it('does not turn a completed host-held session into permission', () => {
|
|
const row = attach([summary({ status: 'idle' })])
|
|
expect(row.status).toBe('inactive')
|
|
expect(row.hasHostSidebarActivity).toBe(false)
|
|
})
|
|
|
|
it('reports the DERIVED pane key, never an orchestration credential', () => {
|
|
const row = attach([summary()])
|
|
expect(row.agents[0]?.paneKey).toBe(
|
|
structuredAgentSessionPaneKey(structuredAgentSessionTabId(SESSION), SESSION)
|
|
)
|
|
})
|
|
|
|
// Null status means no turn has been persisted; the chat itself shows nothing, so neither does this.
|
|
it('omits a session with no projected status', () => {
|
|
expect(attach([summary({ status: null })]).agents).toHaveLength(0)
|
|
})
|
|
|
|
it('keeps the journal clock on the row, so a restart republish is not new activity', () => {
|
|
const row = attach([summary()])
|
|
expect(row.agents[0]?.updatedAt).toBe(1_757_030_400_000)
|
|
expect(row.agents[0]?.stateStartedAt).toBe(1_757_030_400_000)
|
|
})
|
|
})
|
|
|
|
/**
|
|
* The deliberate non-goal. Adding structured rows to `terminal list` was investigated and rejected:
|
|
* mobile mounts a terminal WebView per row that can never receive a frame, a `connected`-keyed
|
|
* refresh check goes permanently true and pins shipped clients to a fast cadence with no exit, and
|
|
* the plugin projection has no field that can carry `writable: false`. Every SAFE consumer of a
|
|
* terminal summary checks `ptyId`; the breaking ones key off `connected` or mere row presence,
|
|
* which no added field can qualify. This pins that the listing never reads the status store.
|
|
*/
|
|
describe('terminal listing is deliberately left alone', () => {
|
|
it('never reads the agent-status store that now carries structured rows', async () => {
|
|
const { readFile } = await import('node:fs/promises')
|
|
// terminal/layout/terminal-resize-listeners.ts owns listTerminals.
|
|
const listing = await readFile(
|
|
new URL('./terminal/layout/terminal-resize-listeners.ts', import.meta.url),
|
|
'utf8'
|
|
)
|
|
// Guard the guard: an empty read would make every assertion below vacuously true.
|
|
expect(listing).toContain('async listTerminals(')
|
|
expect(listing).not.toContain('getAgentStatusSnapshotFn')
|
|
expect(listing).not.toContain('structuredHost')
|
|
})
|
|
})
|