mirror of
https://github.com/stablyai/orca.git
synced 2026-09-30 08:03:12 +00:00
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:
co-authored by
Merge Sim
parent
471a5f4aa7
commit
2513e21390
@@ -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 ──────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
|
||||
@@ -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('')
|
||||
|
||||
Reference in New Issue
Block a user