Skip to content
Merged
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
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'

import { isAgentSpawnToolName } from './agentSpawnTools'
import { isAgentSpawnToolName } from './registry.renderer.capabilities'

describe('isAgentSpawnToolName (P2c dispatch-name blind spot)', () => {
it('matches all three spawn families', () => {
Expand Down
29 changes: 28 additions & 1 deletion src/providers/registry.renderer.capabilities.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { ReactNode } from 'react'
import type { ConditionView } from '@shared/conditions-core/view'
import type { ToolResultBlock, ToolUseBlock } from '@shared/types/transcript'
import { type AgentProviderKind, isAgentProviderKind } from '@shared/types/providerKind'
import { AGENT_PROVIDER_KINDS, type AgentProviderKind, isAgentProviderKind } from '@shared/types/providerKind'
import { CLAUDE_VIEWS } from '@providers/claude/renderer/conditions/views'
import { CODEX_VIEWS } from '@providers/codex/renderer/conditions/views'
import {
Expand Down Expand Up @@ -266,3 +266,30 @@ export function getRendererProviderCapabilities(id: string): RendererProviderCap
if (!provider) throw new Error(`Unknown provider: ${id}`)
return provider
}

// Shared predicate: does this tool_use spawn a subagent the fleet row
// should own? (Residue plan P2c — the 2026-06-21 dispatch-name blind
// spot: a session spawning via the built-in MCP orchestration server
// painted ZERO Task cards while state-snapshot.subAgents held 73 tracked
// children, because the interception only knew 'Agent'/'spawn_agent'.)
//
// Lives HERE (not in features/feed/lib, its pre-#493 home) because it is a
// pure union over each provider's RendererProviderCapabilities.isSpawnTool —
// provider vocabulary, owned by this registry. Moving it also cut the last
// value-level rendering/ → features/feed edge (#493 PR-2): the committed
// collector's task-notification join and the feed's fleet rows now share it
// through the providers layer, which BOTH may legally import.
//
// WHY union across ALL providers instead of resolving the caller's provider:
// the call sites differ. Block.tsx has the ProviderContext, but
// ConversationRow.tsx's grouping walk (isAgentBlock) does not thread a
// provider. A provider-agnostic union preserves the previous behavior EXACTLY
// — the old Set matched any of the three names regardless of provider — while
// still sourcing the names from the registry. Because each provider's spawn
// names are disjoint in practice (no backend emits another's spawn verb), the
// union is identical to a per-provider check for any real transcript.
export function isAgentSpawnToolName(name: string): boolean {
return AGENT_PROVIDER_KINDS.some(kind =>
getRendererProviderCapabilities(kind).isSpawnTool(name),
)
}
11 changes: 11 additions & 0 deletions src/remote-client/src/transcript/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,13 @@ export type SessionTranscript = {
entries: Entry[]
semanticTurn: SemanticLiveTurn | null
semanticHistory: SemanticLiveTurn[]
/** The full fold state, mirrored on exactly the ticks that update
* semanticTurn/semanticHistory above. Exposed since #493 PR-2 so
* SessionView can hand the ledger a REAL SemanticRuntimeState (the
* RuntimeRenderInput contract) instead of fabricating a partial
* {currentTurn, history} object behind an `as unknown as` cast. The
* two scalar mirrors stay because non-ledger surfaces read them. */
semantic: SemanticRuntimeState
phase: StreamPhaseState
toolUseIndex: Map<string, ToolUseBlock>
toolResultIndex: Map<string, ToolResultBlock>
Expand Down Expand Up @@ -110,6 +117,7 @@ function emptyTranscript(): SessionTranscript {
entries: [],
semanticTurn: null,
semanticHistory: [],
semantic: emptySemanticRuntime(),
phase: emptyPhase(),
toolUseIndex: new Map(),
toolResultIndex: new Map(),
Expand Down Expand Up @@ -157,12 +165,14 @@ export class TranscriptStore {
// turn, no phase, no prompts (useIpcSubscriptions offExit).
const state = this.state(e.sessionId)
state.semantic = { ...state.semantic, currentTurn: null }
const clearedSemantic = state.semantic
this.mutate(e.sessionId, t => ({
...t,
exited: true,
workingStatus: null,
phase: emptyPhase(),
semanticTurn: null,
semantic: clearedSemantic,
conditions: null,
}))
}),
Expand Down Expand Up @@ -521,6 +531,7 @@ export class TranscriptStore {
...prev,
semanticTurn: nextSemantic.currentTurn,
semanticHistory: nextSemantic.history,
semantic: nextSemantic,
phase: nextPhase,
}))
}
Expand Down
45 changes: 24 additions & 21 deletions src/remote-client/src/ui/SessionView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@ import { useCallback, useEffect, useMemo, useState, useSyncExternalStore } from
import { Feed } from '@renderer/features/feed/ui/Feed'
import { SessionFeedProvider } from '@renderer/features/sessionFeed/SessionFeedContext'
import { useLedgerFeedItems } from '@renderer/rendering/view/useLedgerFeedItems'
import type { SessionRuntime } from '@renderer/session-runtime/state'
import type { Entry } from '@shared/types/transcript'
import type { RuntimeRenderInput } from '@renderer/session-runtime/state'
import type { GhostEntry } from 'agent-transcript-parser/ghost'
import {
conditionStateByKind,
type ClaudeAskUserQuestionState,
Expand All @@ -18,7 +18,7 @@ import type { TranscriptStore } from '../transcript/store'
// "deliberately skipped subsystems" note below). A single frozen empty map
// keeps the ledger's ghost plane cache stable across renders — a fresh map
// each render would defeat the adapter's by-reference plane memoization.
const NO_GHOSTS: ReadonlyMap<string, Entry> = new Map()
const NO_GHOSTS: ReadonlyMap<string, GhostEntry> = new Map()

// One session, desktop-grade: this mounts the REAL desktop Feed component
// (see the alias table in ../vite.config.ts — the phone renders the same
Expand Down Expand Up @@ -80,25 +80,28 @@ export function SessionView({
// the desktop does. It used to rely on Feed's legacy deriveFeedRenderModel
// path (the only remaining consumer of it after the desktop flip); that
// path is deleted, so the phone must produce renderItemsOverride too. The
// ledger reads a minimal SessionRuntime view — the seven fields
// useLedgerFeedItems/ledgerFeedContextFromRuntime touch — assembled from
// the wire transcript. Only entries + semantic + phase differ across
// renders; ghosts is the frozen empty map (no optimistic plane on the
// phone) and lastJsonlEntryAt is irrelevant with no ghosts to invalidate.
// ledger takes the DECLARED RuntimeRenderInput contract (#493 PR-2), so
// this view is honestly typed — the old `as unknown as SessionRuntime`
// cast over a fabricated partial object is gone; `semantic` is the store's
// REAL fold state. Only entries + semantic + phase differ across renders;
// ghosts is the frozen empty map (no optimistic plane on the phone) and
// lastJsonlEntryAt is irrelevant with no ghosts to invalidate.
//
// Memo deps stay the scalar mirrors (semanticTurn/semanticHistory), NOT
// transcript.semantic: the fold object also changes reference on
// flows/log-only updates, and re-firing on those would recompute the
// pipeline more often than the desktop does for the same stream.
const runtimeView = useMemo(
() =>
({
entries: transcript.entries,
semantic: {
currentTurn: transcript.semanticTurn,
history: transcript.semanticHistory,
},
ghosts: NO_GHOSTS,
streamPhase: transcript.phase.streamPhase,
streamPhasePendingToolName: transcript.phase.streamPhasePendingToolName,
streamPhasePendingToolUseId: transcript.phase.streamPhasePendingToolUseId,
lastJsonlEntryAt: 0,
}) as unknown as SessionRuntime,
(): RuntimeRenderInput => ({
entries: transcript.entries,
semantic: transcript.semantic,
ghosts: NO_GHOSTS,
streamPhase: transcript.phase.streamPhase,
streamPhasePendingToolName: transcript.phase.streamPhasePendingToolName,
streamPhasePendingToolUseId: transcript.phase.streamPhasePendingToolUseId,
lastJsonlEntryAt: 0,
}),
// eslint-disable-next-line react-hooks/exhaustive-deps -- see comment above
[
transcript.entries,
transcript.semanticTurn,
Expand Down
29 changes: 0 additions & 29 deletions src/renderer/src/features/feed/lib/agentSpawnTools.ts

This file was deleted.

2 changes: 1 addition & 1 deletion src/renderer/src/features/feed/ui/rows/Block.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ import { ImageBlockRow } from '@renderer/features/feed/ui/rows/ImageBlockRow'
import { UserBand } from '@renderer/features/feed/ui/rows/primitives'
import { ToolResultRow } from '@renderer/features/feed/ui/rows/ToolResultRow'
import { ToolUseRow } from '@renderer/features/feed/ui/rows/ToolUseRow'
import { isAgentSpawnToolName } from '@renderer/features/feed/lib/agentSpawnTools'
import { isAgentSpawnToolName } from '@providers/registry.renderer.capabilities'
import { JsonToolRow } from '@providers/shared/renderer/rows/JsonToolRow'
import { TaskSubagentRow } from '@renderer/features/feed/ui/rows/TaskSubagentRow'

Expand Down
2 changes: 1 addition & 1 deletion src/renderer/src/features/feed/ui/rows/ConversationRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import type {

import { MarkerRow } from '@renderer/features/feed/ui/MarkerRow'
import { TextProse } from '@renderer/features/feed/ui/markdown'
import { isAgentSpawnToolName } from '@renderer/features/feed/lib/agentSpawnTools'
import { isAgentSpawnToolName } from '@providers/registry.renderer.capabilities'

import { Block } from '@renderer/features/feed/ui/rows/Block'
import { SubagentGroupHeader } from '@renderer/features/feed/ui/rows/SubagentGroupHeader'
Expand Down
2 changes: 1 addition & 1 deletion src/renderer/src/rendering/observations/committed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import type {
// the join-suppression set (below) tied to the exact blocks that CONSUME a
// notification — if the two ever diverged, we'd either suppress a standalone
// row nobody re-renders (lost background result) or double-paint one.
import { isAgentSpawnToolName } from '@renderer/features/feed/lib/agentSpawnTools'
import { isAgentSpawnToolName } from '@providers/registry.renderer.capabilities'

// ---------------------------------------------------------------------------
// Committed candidate collector — the anti-corruption boundary between raw
Expand Down
87 changes: 0 additions & 87 deletions src/renderer/src/rendering/policy/foldPolicy.test.ts

This file was deleted.

Loading