fix(native-chat): publish structured session status from the host so the sidebar never goes stale (#18776)

* fix(native-chat): publish structured session status from the host

The sidebar learned whether a structured chat was mid-turn by replaying
the session journal in the renderer, through a reader whose lifetime was
tied to the chat pane. Hiding the pane stopped the reader before the
turn's settlement arrived, so the row stayed on "working" until the chat
was reopened. The same coupling meant a tab never opened this session
showed no status at all, and a reloaded renderer lost every settled row.

The host owns the journal, so it now projects each session's status once
per journal publication and fans the changes out on one stream per client
(`agentSession.subscribeStatus`). The projection survives eviction of an
idle session's provider child and is republished when readable sessions
are restored. The renderer bridge subscribes to that feed per runtime
target and never opens a transcript reader; the observation hook is gone.

Additive wire surface behind the existing structured capability; old
hosts reject the method and the renderer retries, showing no status.

* fix(native-chat): negotiate the status feed and stop losing a change on subscribe

The status stream is additive to a surface that already shipped, so a host
advertising agent-session.structured.v1 can still answer subscribeStatus with
method_not_found. Every renderer error path reconnected, so a remote host one
release behind got a relay round-trip every 5s and no sidebar status at all.
Give the method its own capability and probe it before subscribing; a failed
probe still retries, an absent capability does not.

Re-projecting on subscribe also wrote straight into the shared cache, so a
second client could pin the first to a stale summary. Route those diffs
through publish() before the arriving subscriber is registered.

* fix(native-chat): bound the status prompt, merge snapshots, and prove the unread path

One status frame carries every retained session and a send admits 256 KB per
prompt, so ~16 large-prompt sessions could push the snapshot past the 4 MB
outbound guard and into the retry loop. Bound latestPrompt to the same
200-char single-line preview every other agent-status row already carries.

A snapshot also replaced the cached map wholesale, so the empty first frame
from a restarting host retracted every row before restore republished them.
Merge instead; the tab map, not this feed, decides which sessions are listed.

Tests: the hidden-pane claim now sits at the host, where a journal with no
transcript subscriber is driven from running to idle; the RPC test reads a
real projection instead of its own stub.

* fix(native-chat): merge the duplicated status-event type import

* test(native-chat): pin the restart status publication, and log the unsupported host

Startup restore indexes a readable session and publishes its status, which is
what puts a never-reopened tab back in the sidebar. Only an Electron screenshot
covered that wiring; a sitting status subscriber now pins it directly.

The terminal "host too old" branch was silent, so a mixed-version report showed
an empty sidebar with nothing in the log to explain it.

---------

Co-authored-by: Merge Sim <sim@local>
This commit is contained in:
Brennan Benson
2026-09-05 15:35:03 -07:00
committed by GitHub
co-authored by Merge Sim
parent 471a5f4aa7
commit 2513e21390
24 changed files with 1556 additions and 234 deletions
+29 -1
View File
@@ -12,8 +12,13 @@ import type {
AgentJournalResolution,
AgentJournalSubmission
} from './agent-session-journal-types'
import type { AgentSessionHandoffStage, AgentSessionOwnerRuntimeKind } from './agent-session-record'
import type {
AgentSessionHandoffStage,
AgentSessionOwnerRuntimeKind,
AgentSessionRecord
} from './agent-session-record'
import type { AgentProviderSessionMetadata } from './agent-session-resume'
import type { StructuredAgentSessionProjectedStatus } from './structured-agent-session-projection'
export type AgentSessionHandoffDirection = 'to-tui' | 'to-native'
export type AgentSessionHandoffMode = 'now' | 'after-turn' | 'stop-turn'
@@ -159,6 +164,29 @@ export type AgentSessionSubscribeEvent =
}
| { type: 'end' }
// ─── Status feed ────────────────────────────────────────────────────────────
/** What a session list needs to know about one session. The host projects it
* from the journal so no client has to replay a transcript to learn whether a
* turn is running. Additive surface: an older host has no such method. */
export type AgentSessionStatusSummary = {
sessionId: string
workspaceId: string
agent: AgentSessionRecord['provider']
/** Null until the journal holds a persisted user or assistant message. */
status: StructuredAgentSessionProjectedStatus | null
latestPrompt: string
providerSession?: AgentProviderSessionMetadata
updatedAt: number
}
/** A summary outlives its provider child: an evicted idle session is still idle, so the host
* keeps the last projection and never retracts one. Tabs, not this feed, decide what is listed. */
export type AgentSessionStatusEvent =
| { type: 'snapshot'; sessions: AgentSessionStatusSummary[] }
| { type: 'status'; session: AgentSessionStatusSummary }
| { type: 'end' }
// ─── Mutation envelope ──────────────────────────────────────────────────────
/**
+5
View File
@@ -133,6 +133,10 @@ export const CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY =
// to stop provider children after the last surface closes without tying lifetime to a transport.
export const STRUCTURED_AGENT_SESSION_HOLD_RUNTIME_CAPABILITY =
'agent-session.structured.hold.v1' as const
// Why: agentSession.subscribeStatus is additive to a surface that already shipped, so a host
// advertising agent-session.structured.v1 may still answer it with method_not_found. Clients must
// probe before subscribing or they reconnect forever and never show any status at all.
export const AGENT_SESSION_STATUS_FEED_RUNTIME_CAPABILITY = 'agent-session.status-feed.v1' as const
// Why: adding kimi to RESUMABLE_TUI_AGENTS grows terminal.ensureAgentSession's enum, and an
// older host answers the unknown member with invalid_argument — a code the launch fallback does
// not retry on — so clients must probe before taking the host-authority path.
@@ -229,6 +233,7 @@ export const RUNTIME_CAPABILITIES = [
AGENT_SESSION_OMP_RESUME_PATH_RUNTIME_CAPABILITY,
STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY,
STRUCTURED_AGENT_SESSION_HOLD_RUNTIME_CAPABILITY,
AGENT_SESSION_STATUS_FEED_RUNTIME_CAPABILITY,
AGENT_SESSION_KIMI_RESUME_RUNTIME_CAPABILITY,
FILE_MUTATION_OWNERSHIP_RUNTIME_CAPABILITY,
GITHUB_MARK_PR_READY_RUNTIME_CAPABILITY,
@@ -1,4 +1,5 @@
import { describe, expect, it } from 'vitest'
import { AGENT_STATUS_MAX_FIELD_LENGTH } from './agent-status-field-normalization'
import type { AgentJournalRenderItem } from './agent-session-journal-types'
import { parsePaneKey } from './stable-pane-id'
import {
@@ -6,6 +7,7 @@ import {
hasPersistedStructuredAgentSessionTurn,
projectStructuredItemToNativeChat,
projectStructuredAgentSessionStatus,
projectStructuredAgentSessionStatusSummary,
structuredAgentSessionPaneKey
} from './structured-agent-session-projection'
@@ -44,6 +46,52 @@ describe('structured agent session status projection', () => {
expect(projectStructuredAgentSessionStatus([running, completed])).toBe('idle')
})
it('summarizes status with the newest user prompt, and null before any persisted turn', () => {
const running = item('running', 3, {
kind: 'status',
text: 'Working',
turnLifecycle: { turnId: 'turn-1', state: 'running' }
})
const first = item('first', 1, {
kind: 'message',
role: 'user',
blocks: [{ type: 'text', text: 'first' }]
})
const second = item('second', 2, {
kind: 'message',
role: 'user',
blocks: [
{ type: 'text', text: 'second' },
{ type: 'text', text: 'line' }
]
})
expect(projectStructuredAgentSessionStatusSummary([running])).toEqual({
status: null,
latestPrompt: ''
})
expect(projectStructuredAgentSessionStatusSummary([first, second, running])).toEqual({
status: 'working',
latestPrompt: 'second line'
})
expect(projectStructuredAgentSessionStatusSummary([first, second])).toEqual({
status: 'idle',
latestPrompt: 'second line'
})
})
it('bounds the wire prompt at the shared agent-status preview cap', () => {
const pasted = item('pasted', 1, {
kind: 'message',
role: 'user',
blocks: [{ type: 'text', text: 'x'.repeat(AGENT_STATUS_MAX_FIELD_LENGTH * 40) }]
})
expect(projectStructuredAgentSessionStatusSummary([pasted]).latestPrompt).toHaveLength(
AGENT_STATUS_MAX_FIELD_LENGTH
)
})
it('creates a deterministic pane identity for status stores', () => {
const paneKey = structuredAgentSessionPaneKey('structured-agent-session-1', 'session-1')
@@ -1,3 +1,4 @@
import { normalizePromptField } from './agent-status-field-normalization'
import type { AgentJournalRenderItem } from './agent-session-journal-types'
import type { NativeChatBlock, NativeChatMessage } from './native-chat-types'
import { sha256 } from './sha256'
@@ -162,6 +163,34 @@ export function projectStructuredAgentSessionStatus(
return activeStructuredAgentSessionTurnId(items) ? 'working' : 'idle'
}
/** The newest user prompt, as the sidebar quotes it. */
export function latestStructuredAgentSessionPrompt(
items: readonly AgentJournalRenderItem[]
): string {
for (let index = items.length - 1; index >= 0; index -= 1) {
const body = items[index]?.body
if (body?.kind === 'message' && body.role === 'user') {
return body.blocks.flatMap((block) => (block.type === 'text' ? [block.text] : [])).join('\n')
}
}
return ''
}
/** One projection shared by host and client: null status means "no turn yet", not idle.
* The prompt is bounded to the same preview every other agent-status row carries — a send
* admits 256 KB, and one status frame carries every retained session at once. */
export function projectStructuredAgentSessionStatusSummary(
items: readonly AgentJournalRenderItem[]
): { status: StructuredAgentSessionProjectedStatus | null; latestPrompt: string } {
if (!hasPersistedStructuredAgentSessionTurn(items)) {
return { status: null, latestPrompt: '' }
}
return {
status: projectStructuredAgentSessionStatus(items),
latestPrompt: normalizePromptField(latestStructuredAgentSessionPrompt(items))
}
}
export function structuredAgentSessionPaneKey(tabId: string, sessionId: string): string {
const bytes = sha256(new TextEncoder().encode(sessionId))
const hex = Array.from(bytes.slice(0, 16), (byte) => byte.toString(16).padStart(2, '0')).join('')