Keep Claude plugin UI out of chat and recover blocked replies (#37965)

Keep Claude plugin status and toast frames out of native chat, and give terminal-input requests an actionable failure with safe conversation recovery. Preserve genuine outside replies while hiding internal repair content.

Tracked in STA-13468. Built on #37480 by @agustin2606.

Co-authored-by: agustin2606 <agustinvarela2606@gmail.com>
This commit is contained in:
Brennan Benson
2026-11-05 22:34:16 -08:00
committed by GitHub
co-authored by agustin2606
parent ce7f68f7c0
commit e92552a413
55 changed files with 2475 additions and 345 deletions
+3
View File
@@ -287,6 +287,9 @@
"uncommittedFiles_one": "{{count}} uncommitted",
"uncommittedFiles_other": "{{count}} uncommitted"
},
"failure": {
"pluginInputRequired": "{{agent}} stopped because the \"{{plugin}}\" plugin is waiting for input in a terminal pane. Disable the plugin for chat sessions, or run the task in a {{agent}} terminal."
},
"fileReader": {
"reviewNotes_one": "{{count}} review note",
"reviewNotes_other": "{{count}} review notes"
+1
View File
@@ -2522,6 +2522,7 @@
"backgroundTaskNotStopped": "The background task wasn't stopped.",
"backgroundTasksNotStopped": "The background tasks weren't stopped.",
"answerUnconfirmed": "Your answer was recorded but the agent did not confirm it.",
"pluginInputRequired": "{{agent}} stopped because the \"{{plugin}}\" plugin is waiting for input in a terminal pane. Disable the plugin for chat sessions, or run the task in a {{agent}} terminal.",
"hostFault": "Orca ran into a problem, so this didn't go through.",
"hostFaultTryAgain": "Orca ran into a problem, so this didn't go through. Try again.",
"hostStopped": "{{agent}} never finished starting, so Orca stopped it.",
+1
View File
@@ -2050,6 +2050,7 @@
"agentStarting": "L'agent est encore en cours de démarrage.",
"agentStillWorking": "L'agent est encore en train de travailler.",
"answerUnconfirmed": "Votre réponse a été enregistrée, mais l'agent ne l'a pas confirmée.",
"pluginInputRequired": "{{agent}} s’est arrêté car le plugin « {{plugin}} » attend une saisie dans un volet de terminal. Désactivez le plugin pour les sessions de chat ou exécutez la tâche dans un terminal {{agent}}.",
"antigravityNotSignedIn": "Antigravity n’est pas connecté. Connectez-vous dans Paramètres → Comptes → Antigravity. Puis renvoyez votre message.",
"argumentsMissingValue": "Les arguments enregistrés nécessitent une valeur pour {{option}}.",
"argumentsMultipleValues": "Les arguments enregistrés donnent plusieurs valeurs à {{option}}.",
@@ -4,11 +4,18 @@ import type {
AgentSessionFailureSay
} from '../../../src/shared/agent-session/failures/agent-session-failure-copy'
import { translate } from '../i18n/mobile-i18n'
import { AGENT_SESSION_FAILURE_COPY } from '../../../src/shared/agent-session/failures/agent-session-failure-copy'
import { START_FAILURE_COPY } from './agent-session-start-failure-copy'
import { SEND_FAILURE_COPY } from './agent-session-send-failure-copy'
const COPY: Record<AgentSessionFailureCopyId, (values: AgentSessionFailureCopyValues) => string> = {
...START_FAILURE_COPY,
pluginInputRequired: (values) =>
translate(
'session.failure.pluginInputRequired',
AGENT_SESSION_FAILURE_COPY.pluginInputRequired,
values
),
...SEND_FAILURE_COPY,
backgroundTasksRunning: (values) =>
translate(
@@ -72,7 +72,7 @@ function noticeLineText(
// Only reword text fully described by its fact; other host text stays as written.
const context = { agentName: names.agentName }
const surface =
failure?.kind === 'savedConversationMissing'
failure?.kind === 'savedConversationMissing' || failure?.kind === 'pluginInputRequired'
? (['row', 'rejection'] as const).find(
(candidate) => notice.text === agentSessionFailureSentence(failure, candidate, context)
)
@@ -0,0 +1,294 @@
[
{
"type": "user",
"uuid": "8411e758-8907-436a-8a4c-7a01f42984ad",
"parentUuid": "507d71ac-fdf3-44e7-ad88-c57735849dd9",
"sessionId": "81552947-5912-49d0-88c7-4d70d448e93b",
"timestamp": "2026-11-04T01:17:14.727Z",
"isSidechain": false,
"origin": {
"kind": "task-notification",
"producer": "session-task",
"runId": "0mw1feznm-03b887b1"
},
"message": {
"role": "user",
"content": "<task-notification>\n<task-id>bobbnxies</task-id>\n<tool-use-id>toolu_01XkAUQnwQm75zCmVi4ogwpJ</tool-use-id>\n<output-file>/private/tmp/claude-501/-Users-m4air-orca-qa-claude-chat-render-1103-project/81552947-5912-49d0-88c7-4d70d448e93b/tasks/bobbnxies.output</output-file>\n<status>completed</status>\n<summary>Background command \"Wait 30 seconds in the background\" completed (exit code 0)</summary>\n</task-notification>"
},
"turnOrigin": "task_notification",
"turnPosition": {
"promptIndex": 0,
"turnIndex": 5
},
"promptSource": "system"
},
{
"type": "assistant",
"uuid": "3d22fda3-dd5b-4e27-a4e7-81b616c3ab42",
"parentUuid": "8411e758-8907-436a-8a4c-7a01f42984ad",
"sessionId": "81552947-5912-49d0-88c7-4d70d448e93b",
"timestamp": "2026-11-04T01:17:16.348Z",
"isSidechain": false,
"message": {
"model": "claude-opus-5-5",
"id": "msg_011Cgh1GvVL3Yq63nSKvSczU",
"type": "message",
"role": "assistant",
"content": [
{
"type": "tool_use",
"id": "toolu_01XJdyNeqcGHcoz11jWqJR3x",
"name": "Bash",
"input": {
"command": "echo QA_HOLD_f3",
"description": "Print the hold marker"
},
"caller": {
"type": "direct"
}
}
],
"container": null,
"stop_reason": "tool_use",
"stop_sequence": null,
"stop_details": null,
"usage": {
"input_tokens": 4,
"cache_creation_input_tokens": 387,
"cache_read_input_tokens": 19000,
"output_tokens": 85,
"output_tokens_details": {
"thinking_tokens": 0
},
"server_tool_use": {
"web_search_requests": 0,
"web_fetch_requests": 0
},
"service_tier": "standard",
"cache_creation": {
"ephemeral_1h_input_tokens": 0,
"ephemeral_5m_input_tokens": 387
},
"inference_geo": "global",
"iterations": [],
"speed": "standard",
"fallback_credit": null
},
"diagnostics": null,
"context_management": null
},
"apiBlockIndex": 0
},
{
"type": "user",
"uuid": "dc9a63ea-93cc-4f46-9898-3abf6d4e3c2c",
"parentUuid": "3d22fda3-dd5b-4e27-a4e7-81b616c3ab42",
"sessionId": "81552947-5912-49d0-88c7-4d70d448e93b",
"timestamp": "2026-11-04T01:20:47.511Z",
"isSidechain": false,
"message": {
"role": "user",
"content": [
{
"type": "tool_result",
"content": "[Tool call interrupted: the session ended before this call's result was recorded, so its outcome is unknown. Check whether it took effect before relying on it or running it again.]",
"is_error": true,
"tool_use_id": "toolu_01XJdyNeqcGHcoz11jWqJR3x"
}
]
}
},
{
"type": "assistant",
"uuid": "9f59f2e7-5724-42fa-8d4e-e250c80decca",
"parentUuid": "dc9a63ea-93cc-4f46-9898-3abf6d4e3c2c",
"sessionId": "81552947-5912-49d0-88c7-4d70d448e93b",
"timestamp": "2026-11-04T01:20:47.511Z",
"isSidechain": false,
"isApiErrorMessage": false,
"message": {
"diagnostics": null,
"id": "806f8832-9051-4c0d-bba2-24c4f957b106",
"container": null,
"model": "<synthetic>",
"role": "assistant",
"stop_details": null,
"stop_reason": "stop_sequence",
"stop_sequence": "",
"type": "message",
"usage": {
"output_tokens_details": null,
"input_tokens": 0,
"output_tokens": 0,
"cache_creation_input_tokens": 0,
"cache_read_input_tokens": 0,
"server_tool_use": {
"web_search_requests": 0,
"web_fetch_requests": 0
},
"service_tier": null,
"cache_creation": {
"ephemeral_1h_input_tokens": 0,
"ephemeral_5m_input_tokens": 0
},
"inference_geo": null,
"iterations": null,
"speed": null,
"fallback_credit": null
},
"content": [
{
"type": "text",
"text": "No response requested."
}
],
"context_management": null
}
},
{
"type": "user",
"uuid": "e4f96990-4837-4674-b0b9-6bb3265dbfaa",
"parentUuid": "9f59f2e7-5724-42fa-8d4e-e250c80decca",
"sessionId": "81552947-5912-49d0-88c7-4d70d448e93b",
"timestamp": "2026-11-04T01:20:47.578Z",
"isSidechain": false,
"message": {
"role": "user",
"content": [
{
"type": "text",
"text": "Run this Bash command: echo QA_HOLD_DELAY_q . Then reply DONE."
}
]
},
"turnOrigin": "sdk",
"turnPosition": {
"promptIndex": 0,
"turnIndex": 6
},
"promptSource": "sdk"
},
{
"type": "assistant",
"uuid": "49ff141e-f3ca-43c4-8473-f6d4f6845923",
"parentUuid": "e4f96990-4837-4674-b0b9-6bb3265dbfaa",
"sessionId": "81552947-5912-49d0-88c7-4d70d448e93b",
"timestamp": "2026-11-04T01:20:50.310Z",
"isSidechain": false,
"message": {
"model": "claude-opus-5-5",
"id": "msg_011Cgh1YcoeMNmKLAdtVXKP7",
"type": "message",
"role": "assistant",
"content": [
{
"type": "tool_use",
"id": "toolu_01EZLMkxCmt9Jctp7ijQG63W",
"name": "Bash",
"input": {
"command": "echo QA_HOLD_DELAY_q",
"description": "Print the delay marker"
},
"caller": {
"type": "direct"
}
}
],
"container": null,
"stop_reason": "tool_use",
"stop_sequence": null,
"stop_details": null,
"usage": {
"input_tokens": 4,
"cache_creation_input_tokens": 193,
"cache_read_input_tokens": 19387,
"output_tokens": 89,
"output_tokens_details": {
"thinking_tokens": 0
},
"server_tool_use": {
"web_search_requests": 0,
"web_fetch_requests": 0
},
"service_tier": "standard",
"cache_creation": {
"ephemeral_1h_input_tokens": 0,
"ephemeral_5m_input_tokens": 193
},
"inference_geo": "global",
"iterations": [],
"speed": "standard",
"fallback_credit": null
},
"diagnostics": null,
"context_management": null
},
"apiBlockIndex": 0
},
{
"type": "user",
"uuid": "4a4ee40b-4ba0-4e8d-9df4-0555d7456022",
"parentUuid": "49ff141e-f3ca-43c4-8473-f6d4f6845923",
"sessionId": "81552947-5912-49d0-88c7-4d70d448e93b",
"timestamp": "2026-11-04T01:21:23.810Z",
"isSidechain": false,
"message": {
"role": "user",
"content": [
{
"type": "tool_result",
"content": "[Tool call interrupted: the session ended before this call's result was recorded, so its outcome is unknown. Check whether it took effect before relying on it or running it again.]",
"is_error": true,
"tool_use_id": "toolu_01EZLMkxCmt9Jctp7ijQG63W"
}
]
}
},
{
"type": "assistant",
"uuid": "1a1ad65a-d8f7-46b5-9ca0-e8b1ef4be8e1",
"parentUuid": "4a4ee40b-4ba0-4e8d-9df4-0555d7456022",
"sessionId": "81552947-5912-49d0-88c7-4d70d448e93b",
"timestamp": "2026-11-04T01:21:23.810Z",
"isSidechain": false,
"isApiErrorMessage": false,
"message": {
"diagnostics": null,
"id": "15cf63cd-0660-44f8-8ad2-bd7a2bd7ce9e",
"container": null,
"model": "<synthetic>",
"role": "assistant",
"stop_details": null,
"stop_reason": "stop_sequence",
"stop_sequence": "",
"type": "message",
"usage": {
"output_tokens_details": null,
"input_tokens": 0,
"output_tokens": 0,
"cache_creation_input_tokens": 0,
"cache_read_input_tokens": 0,
"server_tool_use": {
"web_search_requests": 0,
"web_fetch_requests": 0
},
"service_tier": null,
"cache_creation": {
"ephemeral_1h_input_tokens": 0,
"ephemeral_5m_input_tokens": 0
},
"inference_geo": null,
"iterations": null,
"speed": null,
"fallback_credit": null
},
"content": [
{
"type": "text",
"text": "No response requested."
}
],
"context_management": null
}
}
]
@@ -87,4 +87,16 @@ describe('a Claude api_retry frame', () => {
expect(row).not.toHaveProperty('providerFrame')
expect(row.failure?.detail?.text).toContain('"attempt":3')
})
it('hides unmodeled system frames even when their payload names an error', async () => {
const unknown = {
type: 'system',
subtype: 'future_notice',
error: 'rate_limit',
session_id: 'provider-1',
uuid: 'future-1'
}
const rows = await statusRowsFor([unknown])
expect(rows).toEqual([])
})
})
@@ -0,0 +1,70 @@
import { asRecord } from '../ai-vault/session-scanner-values'
import type { ClaudeJournalTranslator } from './claude-journal-translator-contract'
export function withClaudeConversationContent(
handle: ClaudeJournalTranslator['handle']
): ClaudeJournalTranslator['handle'] {
return (event) => {
if (event.type === 'message') {
const message = claudeMessageWithoutSyntheticText(event.message)
if (!message) {
return
}
event = { ...event, message }
}
handle(event)
}
}
export function isClaudeInjectedUserMessage(frame: Record<string, unknown>): boolean {
return (
frame.type === 'user' &&
(frame.isMeta === true ||
frame.isSynthetic === true ||
frame.isCompactSummary === true ||
asRecord(frame.origin)?.kind === 'task-notification')
)
}
/** Resume repair text is not conversation; semantic siblings still take their normal paths. */
export function claudeMessageWithoutSyntheticText(
frame: Record<string, unknown>
): Record<string, unknown> | null {
const message = asRecord(frame.message)
const usage = asRecord(message?.usage)
const content = message?.content
if (
frame.type !== 'assistant' ||
message?.role !== 'assistant' ||
message.model !== '<synthetic>' ||
message.stop_reason !== 'stop_sequence' ||
message.stop_sequence !== '' ||
frame.error !== undefined ||
frame.isApiErrorMessage === true ||
frame.apiErrorStatus !== undefined ||
frame.is_api_error_message === true ||
frame.api_error !== undefined ||
!usage ||
![
'input_tokens',
'cache_creation_input_tokens',
'cache_read_input_tokens',
'output_tokens'
].every((key) => usage[key] === 0) ||
!Array.isArray(content)
) {
return frame
}
const text = content
.flatMap((part) => {
const block = asRecord(part)
return block?.type === 'text' && typeof block.text === 'string' ? [block.text] : []
})
.join('\n')
.trim()
if (text !== 'No response requested.') {
return frame
}
const remaining = content.filter((part) => asRecord(part)?.type !== 'text')
return remaining.length ? { ...frame, message: { ...message, content: remaining } } : null
}
+23 -1
View File
@@ -29,7 +29,12 @@ import type { createClaudeStreamedTextCheckpoints } from './claude-streamed-text
import type { ClaudeStreamedThinking } from './claude-streamed-thinking'
import type { ClaudeProvisionalRowCorrections } from './claude-provisional-row-corrections'
import type { ClaudeSubagentIdentities } from './claude-subagent-identities'
import { claudeTurnOpenedBySendEcho, type ClaudeTurnSource } from './claude-turn-opening'
import {
claudeStreamTurnSource,
claudeStreamTurnStartSource,
claudeTurnOpenedBySendEcho,
type ClaudeTurnSource
} from './claude-turn-opening'
import type { ClaudeOpenTurn } from './claude-open-turn'
import type { ClaudeAssistantFailures } from './claude-assistant-failures'
import type { ClaudeUsageLimits } from './claude-usage-limits'
@@ -53,6 +58,23 @@ export type ClaudeMessageJournalContext = {
threadGoal: ClaudeThreadGoal
}
export function journalClaudeStream(
ctx: ClaudeMessageJournalContext,
message: Record<string, unknown>,
observedAt: number
): boolean {
const delta = ctx.streamedBlocks.observe(message, observedAt)
const thinking = ctx.streamedThinking.observe(message, observedAt)
// Prefer message_start; use the first content delta for older streams that omit it.
const source =
delta || thinking ? claudeStreamTurnSource(message) : claudeStreamTurnStartSource(message)
ctx.turn.ensureOpen(message, source, observedAt)
if (delta) {
ctx.streamedText.append(delta.identity, delta.text, delta.parentToolUseId)
}
return delta !== null || thinking
}
export function journalClaudeMessage(
ctx: ClaudeMessageJournalContext,
message: Record<string, unknown>,
@@ -0,0 +1,69 @@
import { agentJournalSubmissionKey } from '../../shared/agent-session/journal/agent-session-journal-item-key'
import { PROVIDER_EXIT_ROW_PREFIX } from '../../shared/agent-session/lifecycle/agent-session-stop-row-identity'
import { agentSessionFailureWords } from '../../shared/agent-session/failures/agent-session-failure-words'
import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink'
import type { ClaudeOpenTurn } from './claude-open-turn'
import type { ClaudeStructuredSessionEvent } from './structured/claude-structured-session-state'
export function journalClaudePluginInputFailure({
event,
turn,
sink,
finish
}: {
event: Extract<ClaudeStructuredSessionEvent, { type: 'retiring' }>
turn: ClaudeOpenTurn
sink: StructuredAgentSessionEventSink
finish: (observedAt: number, failed: boolean) => void
}): void {
if (!turn.isOpen && event.unopenedSend) {
// A failed dispatched action needs a turn outcome without claiming a provider echo or acceptance.
turn.open(
{
sessionId: event.providerSessionId,
turnId: `plugin-input:${event.unopenedSend.clientMessageId}`,
userItemId: agentJournalSubmissionKey(event.unopenedSend.clientMessageId),
startedAt: event.unopenedSend.requestedAt ?? event.observedAt,
...(event.unopenedSend.requestedAt === undefined
? {}
: {
requestedAt: event.unopenedSend.requestedAt
})
},
event.observedAt
)
}
const scope = turn.turnScope
const stopped = turn.id !== null && sink.journalStopDecidesTurn?.(turn.id, event.observedAt)
finish(event.observedAt, !stopped)
if (stopped) {
return
}
// Physical-exit settlement revises this same row after proof; it must not add another notice.
sink.appendItem(
{
provider: 'orca',
clientMessageId: `${PROVIDER_EXIT_ROW_PREFIX}${event.sessionId}:${event.fence}:${event.acquisitionGeneration}`
},
{
kind: 'status',
tone: 'error',
...agentSessionFailureWords(event.failure, { surface: 'row', agentName: 'Claude' })
},
{ turnScope: scope, lifecycle: true, observedAt: event.observedAt }
)
sink.publish({ lifecycle: true })
}
export function settleClaudeInterruptedTurn(
turn: ClaudeOpenTurn,
observedAt: number,
failed: boolean
): void {
turn.settle({
state: 'interrupted',
...(failed ? { outcome: 'failure' as const } : {}),
completedAt: observedAt
})
turn.suppressReopen()
}
@@ -0,0 +1,187 @@
import { describe, expect, it, vi } from 'vitest'
import type { AgentJournalItemBody } from '../../shared/agent-session/journal/agent-session-journal-types'
import type { AgentJournalItemIdentity } from '../../shared/agent-session/journal/agent-session-journal-identity'
import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink'
import { createClaudeJournalTranslator } from './structured/journal/claude-structured-journal-translation'
function sinkState() {
const items: { identity: AgentJournalItemIdentity; body: AgentJournalItemBody }[] = []
const sink: StructuredAgentSessionEventSink = {
appendItem: (identity, body) => items.push({ identity, body }),
appendTombstone: () => {},
publish: vi.fn()
}
return { sink, items }
}
function providerFrameKinds(items: { body: AgentJournalItemBody }[]): string[] {
return items.flatMap((item) =>
item.body.kind === 'status' && item.body.providerFrame ? [item.body.providerFrame.kind] : []
)
}
/** Shape reported from Claude Code 2.1.316; UUIDs are test identities. */
function pluginUi(subtype: string | undefined, uuid: string, fields: Record<string, unknown>) {
return {
type: 'message' as const,
sessionId: 'orca-session',
message: {
type: 'system',
subtype,
plugin: 'fos-skills',
...fields,
uuid,
session_id: 'claude-session'
}
}
}
function userTurn(uuid: string, text: string) {
return {
type: 'message' as const,
sessionId: 'orca-session',
startsTurn: true as const,
message: {
type: 'user',
uuid,
session_id: 'claude-session',
parent_tool_use_id: null,
isReplay: true,
message: { role: 'user', content: text }
}
}
}
function assistantReply(uuid: string, text: string) {
return {
type: 'message' as const,
sessionId: 'orca-session',
message: {
type: 'assistant',
uuid,
session_id: 'claude-session',
parent_tool_use_id: null,
message: { role: 'assistant', content: [{ type: 'text', text }] }
}
}
}
describe('Claude plugin UI frames', () => {
it('keeps plugin status lines and toasts off the transcript for a whole turn', () => {
const state = sinkState()
const translator = createClaudeJournalTranslator({ sink: state.sink })
translator.handle(userTurn('user-1', 'Reply with exactly PROBE_OK and nothing else.'))
translator.handle(pluginUi('ui_status', 'ui-1', { text: 'PR 6 · 진행 13 · 대기 6 /board' }))
translator.handle(assistantReply('assistant-1', 'PROBE_OK'))
// Captured from Claude Code 2.1.316 with an isolated test plugin.
translator.handle(
pluginUi('ui_toast', 'ui-2', {
plugin: 'qa-plugin-ui',
text: 'QA plugin toast: prompt seen',
timeout_ms: 4000
})
)
translator.handle(pluginUi('ui_status', 'ui-3', { text: null }))
translator.handle({
type: 'message',
sessionId: 'orca-session',
message: {
type: 'result',
subtype: 'success',
uuid: 'result-1',
session_id: 'claude-session',
is_error: false,
result: 'PROBE_OK',
terminal_reason: 'completed'
}
})
expect(providerFrameKinds(state.items)).toEqual([])
expect(
state.items.flatMap((item) =>
item.body.kind === 'message' && item.body.role === 'assistant' ? [item.body.blocks] : []
)
).toEqual([[{ type: 'text', text: 'PROBE_OK' }]])
})
it('keeps the other plugin UI subtypes off the transcript too', () => {
const state = sinkState()
const translator = createClaudeJournalTranslator({ sink: state.sink })
for (const [index, subtype] of [
'ui_invalidate',
'ui_log',
'ui_panes',
'ui_placed',
'ui_scroll',
'ui_focus',
'ui_future',
'future_notice'
].entries()) {
translator.handle(pluginUi(subtype, `ui-${index}`, {}))
}
translator.handle(
pluginUi('ui_panes', 'historical-focus', {
panes: [{ id: 'blast-radius', plugin: 'blast-radius' }],
focus_requested_id: 'blast-radius'
})
)
expect(providerFrameKinds(state.items)).toEqual([])
})
it.each(['ui_status', 'future_notice', undefined])(
'does not flush streamed text for N or 10N %s diagnostics',
(subtype) => {
const writes = (count: number) => {
const state = sinkState()
const translator = createClaudeJournalTranslator({
sink: state.sink,
schedule: () => () => {}
})
translator.handle(userTurn('user-stream', 'Reply'))
let sequence = 0
const stream = (event: Record<string, unknown>) =>
translator.handle({
type: 'message',
sessionId: 'orca-session',
message: {
type: 'stream_event',
uuid: `stream-${sequence++}`,
session_id: 'claude-session',
event
}
})
stream({
type: 'message_start',
message: { id: 'msg-stream', role: 'assistant', content: [] }
})
stream({ type: 'content_block_start', index: 0, content_block: { type: 'text', text: '' } })
for (let i = 0; i < count; i++) {
stream({
type: 'content_block_delta',
index: 0,
delta: { type: 'text_delta', text: 'a' }
})
const frame = pluginUi(subtype, `status-${i}`, {
text: 'PR 6 · 진행 13 · 대기 6 /board'
})
translator.handle(frame)
}
const assistantWrites = () =>
state.items.filter(({ body }) => body.kind === 'message' && body.role === 'assistant')
expect(assistantWrites()).toHaveLength(0)
expect(providerFrameKinds(state.items)).toEqual([])
translator.flush()
expect(assistantWrites()).toHaveLength(1)
expect(assistantWrites()[0].body).toMatchObject({
blocks: [{ type: 'text', text: 'a'.repeat(count) }]
})
translator.dispose()
return assistantWrites().length
}
expect(writes(20)).toBe(writes(200))
}
)
})
+37
View File
@@ -0,0 +1,37 @@
import {
agentSessionFailureFact,
providerDiagnostic,
type SubmissionRejectionFact
} from '../../shared/agent-session/failures/agent-session-failure'
import { agentSessionFailureSentence } from '../../shared/agent-session/failures/agent-session-failure-words'
import { isProviderRecord } from '../provider-process/provider-json-record'
function nonemptyString(value: unknown): string | null {
return typeof value === 'string' && value.trim() ? value.trim() : null
}
export function claudeFocusedPlugin(message: Record<string, unknown>): string | null {
if (message.type !== 'system' || message.subtype !== 'ui_panes') {
return null
}
const paneId = nonemptyString(message.focus_requested_id)
if (!paneId) {
return null
}
const pane = Array.isArray(message.panes)
? message.panes.find((entry) => isProviderRecord(entry) && entry.id === paneId)
: undefined
return (isProviderRecord(pane) && nonemptyString(pane.plugin)) || paneId
}
export class ClaudePluginInputError extends Error {
readonly failure: SubmissionRejectionFact
constructor(plugin: string) {
const failure = agentSessionFailureFact('pluginInputRequired', {
detail: providerDiagnostic(plugin, 'person')
})
super(agentSessionFailureSentence(failure, 'row', { agentName: 'Claude' }))
this.failure = failure
}
}
@@ -216,3 +216,32 @@ function recoverLateIdentity(
})
}
}
/** Retain send ownership until publication and its delivery receipts have both settled. */
export function withClaudeReplaySettlements(
session: ClaudeSession | null,
settlements: ClaudeLateDispatchOutcome[],
publish: () => void,
report: (settlement: ClaudeLateDispatchOutcome) => void
): void {
const reported = session?.unreportedSettlements
if (session && settlements.length > 0) {
session.unreportedSettlements = new Set([
...(reported ?? []),
...settlements.map((settlement) => settlement.clientMessageId)
])
}
try {
publish()
} finally {
try {
for (const settlement of settlements) {
report(settlement)
}
} finally {
if (session) {
session.unreportedSettlements = reported
}
}
}
}
@@ -1,4 +1,3 @@
import { isClaudeToolResultContent } from '../../shared/claude-tool-content'
import { isDeepStrictEqual } from 'node:util'
import {
agentJournalItemKey,
@@ -27,7 +26,6 @@ import type {
StructuredAgentSessionItemAppendOptions
} from '../native-chat/agent-session-wire/structured-agent-session-event-sink'
import type { StructuredAgentSessionTransitionJournal } from '../native-chat/agent-session-wire/structured-agent-session-transition'
import { decodeClaudeTranscriptLine } from '../native-chat/transcript-line-decoders-claude'
import { claudeSourceBlockId } from './claude-streamed-block-identity'
import {
claudeMessageIdentity,
@@ -40,11 +38,14 @@ import {
readClaudeMessageEnvelope
} from './structured/journal/claude-structured-item-translation'
import { createClaudeJournalTranslator } from './structured/journal/claude-structured-journal-translation'
import { createClaudeTranscriptItemReader } from './claude-transcript-item-reader'
import {
createClaudeTranscriptItemReader,
claudeRestoredItemBody
} from './claude-transcript-item-reader'
import { idleClaudeJournalTurn } from './claude-session-state-turn-over'
import { claudeEnvelopeStopHookFeedbackRow } from './claude-stop-hook-feedback'
import { claudeMessageWithoutSyntheticText } from './claude-internal-message-content'
import { claudeTranscriptPrompt } from './claude-transcript-prompt'
/** An isolated projection of final source items, compared with saved historical content. */
export function createClaudeTranscriptImportPlan(input: {
sessionId: string
providerSessionId: string
@@ -109,20 +110,7 @@ export function createClaudeTranscriptImportPlan(input: {
return { accepted: true } as const
}
turnScope = scopeOf(existing)
if (body.kind === 'tool-call' && existing.body.kind === 'tool-call') {
// A call alone cannot undo its saved result; result-only suffixes keep the call metadata.
body =
body.state === 'running'
? existing.body
: { ...existing.body, ...body, name: existing.body.name, input: existing.body.input }
}
if (
body.kind === 'message' &&
body.role === 'reasoning' &&
existing.body.kind === 'message'
) {
body = { ...existing.body, ...body }
}
body = claudeRestoredItemBody(body, existing.body)
if (isDeepStrictEqual(existing.body, body)) {
return { accepted: true } as const
}
@@ -160,7 +148,14 @@ export function createClaudeTranscriptImportPlan(input: {
return {
restore: (row: Record<string, unknown>) => {
input.check()
const frame = { ...row, session_id: row.sessionId, parent_tool_use_id: null }
const frame = claudeMessageWithoutSyntheticText({
...row,
session_id: row.sessionId,
parent_tool_use_id: null
})
if (!frame) {
return
}
const envelope = readClaudeMessageEnvelope(frame)
const queued = row.type === 'attachment'
// The tail reader keeps only rows that carry one.
@@ -196,29 +191,31 @@ export function createClaudeTranscriptImportPlan(input: {
matchSource([claudeToolIdentity(envelope.sessionId, result.toolUseId)])
}
}
const prompt =
queued ||
(envelope?.role === 'user' &&
!claudeEnvelopeStopHookFeedbackRow(envelope) &&
envelope.content.some((part) => !isClaudeToolResultContent(part)))
const decoded = prompt
? decodeClaudeTranscriptLine(JSON.stringify(row), `${input.providerSessionId}:${uuid}`)
: null
// A stop marker restates the chat's own interrupted turn, which its turn row already reads.
if (prompt && (!decoded || decoded.role === 'system')) {
const { decoded, ignored, internalTurnStart, hasWork } = claudeTranscriptPrompt(
row,
envelope,
input.providerSessionId
)
if (ignored) {
return
}
uuids.push(uuid)
if (!decoded) {
if (envelope?.content.length && sourceObservedAt !== undefined) {
if (hasWork && sourceObservedAt !== undefined) {
workAt = Math.max(workAt ?? sourceObservedAt, sourceObservedAt)
}
translator.handle({
type: 'message',
sessionId: input.sessionId,
message: frame,
...(sourceObservedAt === undefined ? {} : { observedAt: sourceObservedAt })
})
if (hasWork || envelope?.role === 'assistant') {
translator.handle({
type: 'message',
sessionId: input.sessionId,
message: frame,
...(sourceObservedAt === undefined ? {} : { observedAt: sourceObservedAt })
})
}
// A hidden physical prompt still separates turns; caller-owned results settle first.
if (internalTurnStart) {
idle()
}
return
}
// Folded input stays in the ongoing turn; a physical opener also settles its predecessor.
@@ -258,7 +255,7 @@ export function createClaudeTranscriptImportPlan(input: {
input.check()
idle()
translator.flush()
// A row that renders nothing (a thinking block saved without its text) adds no divider either.
// A row that renders nothing adds no divider either.
const rows = [...mutations.values()]
const batch = createHistoryCatchUpBatch()
const firstCreated = rows.find(
@@ -0,0 +1,421 @@
import { describe, expect, it } from 'vitest'
import capture from './__fixtures__/claude-plugin-recovery.json'
import { asRecord } from '../ai-vault/session-scanner-values'
import { createClaudeJournalTranslator } from './structured/journal/claude-structured-journal-translation'
import { agentSessionFailureFact } from '../../shared/agent-session/failures/agent-session-failure'
import { agentJournalItemKey } from '../../shared/agent-session/journal/agent-session-journal-item-key'
import { resolveClaudeReplayTurn } from './claude-replay-turn-resolution'
import { dispatchClaudeTurn } from './structured/claude-structured-dispatch'
import {
sessionFor,
userMessage,
userReplayFrame
} from './structured/claude-structured-dispatch-test-support'
import {
SESSION,
SID,
at,
chat,
chatTurn,
prompt,
reply
} from './claude-transcript-catch-up-test-support'
// Claude 2.1.316: background wake and two resumed, interrupted tool calls.
const rows: Record<string, unknown>[] = capture.map((row, index) => ({
...row,
sessionId: SID,
...(index === 0 ? { parentUuid: 'a1' } : {})
}))
const frame = (row: Record<string, unknown>) => ({
...row,
session_id: SID,
parent_tool_use_id: null
})
const synthetic = rows[3]!
const task = rows[0]!
const message = (row: Record<string, unknown>) => asRecord(row.message)!
const contentParts = (row: Record<string, unknown>) => {
const content = message(row).content
if (!Array.isArray(content)) {
throw new Error('recorded content parts missing')
}
return content
}
it('imports recorded repair results without inventing outside work or duplicate failed turns', async () => {
const c = await chat()
await c.live()
const translator = createClaudeJournalTranslator({ sink: c.deferred.sink })
const live = (row: Record<string, unknown>, startsTurn = false) =>
translator.handle({
type: 'message',
sessionId: SESSION,
message: frame(row),
...(startsTurn ? { startsTurn: true } : {}),
observedAt: 4_000
})
live({
type: 'system',
subtype: 'task_started',
task_id: 'bobbnxies',
task_type: 'local_bash',
description: 'background work'
})
live({ type: 'system', subtype: 'task_notification', task_id: 'bobbnxies', status: 'completed' })
live(task)
live(rows[1]!)
const end = () =>
translator.handle({
type: 'ended',
sessionId: SESSION,
reason: 'crash',
failure: agentSessionFailureFact('pluginInputRequired'),
observedAt: 5_000
})
end()
const localPrompt = rows[4]!
await c.journal.appendSubmission({
clientMessageId: 'retry',
body: userMessage([{ type: 'text', text: 'retry' }]),
payloadFingerprint: 'retry',
fence: 1
})
await c.journal.resolveDispatch({
clientMessageId: 'retry',
state: 'accepted',
providerIdentity: { provider: 'claude', sessionId: SID, uuid: String(localPrompt.uuid) },
fence: 1
})
live(localPrompt, true)
live(rows[5]!)
end()
translator.dispose()
await c.deferred.drained()
const before = c.journal.snapshot()
const turns = before.items.filter((item) => item.body.kind === 'turn')
await c.write([...chatTurn, ...rows])
expect(await c.open('a1')).toBe(true)
const after = c.journal.snapshot()
expect(after.items.filter((item) => item.body.kind === 'turn')).toEqual(turns)
for (const row of [task, synthetic, rows[7]!]) {
expect(
c.journal.item(
agentJournalItemKey({ provider: 'claude', sessionId: SID, uuid: String(row.uuid) })
)
).toBeNull()
}
const tools = after.items.filter((item) => item.body.kind === 'tool-call')
expect(tools).toHaveLength(2)
for (const tool of tools) {
expect(tool.body).toMatchObject({ name: 'Bash', state: 'failed' })
const original = before.items.find((item) => item.itemId === tool.itemId)!
expect(tool.turnScope).toEqual(original.turnScope)
expect(tool.body.kind === 'tool-call' && tool.body.input).toEqual(
original.body.kind === 'tool-call' && original.body.input
)
}
expect(c.rows().some((row) => row.startsWith('(continued-outside'))).toBe(false)
expect(await c.open('a1')).toBe(true)
expect(c.journal.snapshot()).toEqual(after)
expect((await c.replay()).snapshot()).toEqual(after)
const outsideTask = { ...task, uuid: 'outside-task', parentUuid: rows[7]!.uuid }
await c.write([
outsideTask,
reply(
'outside-answer',
'outside-task',
'msg_outside',
{ type: 'text', text: 'real outside answer' },
10
)
])
expect(await c.open('a1')).toBe(true)
expect(c.rows()).toContain('assistant:real outside answer')
expect(c.rows().filter((row) => row === '(continued-outside)')).toHaveLength(1)
const outside = c.journal
.snapshot()
.items.find(
(item) =>
item.body.kind === 'message' &&
item.body.role === 'assistant' &&
item.body.blocks.some(
(block) => block.type === 'text' && block.text === 'real outside answer'
)
)!
const outsideScope = outside.turnScope
expect(outsideScope?.kind).toBe('turn')
if (outsideScope?.kind !== 'turn') {
throw new Error('outside turn missing')
}
expect(c.journal.item(outsideScope.turnItemId)?.body).toMatchObject({
kind: 'turn',
state: 'completed'
})
expect(turns.some((turn) => turn.itemId === outsideScope.turnItemId)).toBe(false)
expect(
c.journal.snapshot().items.filter((item) => turns.some((turn) => turn.itemId === item.itemId))
).toEqual(turns)
const caughtUp = c.journal.snapshot()
expect(await c.open('a1')).toBe(true)
expect(c.journal.snapshot()).toEqual(caughtUp)
expect((await c.replay()).snapshot()).toEqual(caughtUp)
})
it.each([false, true])('ignores quiet caveat time with a later prompt: %s', async (laterPrompt) => {
const c = await chat()
await c.live()
await c.write([
...chatTurn,
prompt('outside', 'a1', 'outside work', 10),
reply('answer', 'outside', 'msg_answer', { type: 'text', text: 'done' }, 11),
{
...prompt('caveat', 'answer', '<local-command-caveat>internal</local-command-caveat>', 60),
isMeta: true
},
...(laterPrompt ? [prompt('next', 'caveat', 'next request', 70)] : [])
])
expect(await c.open('a1')).toBe(true)
const turn = c.journal
.snapshot()
.items.find((item) => item.body.kind === 'turn' && item.body.turnId === 'outside')
expect(turn?.body).toMatchObject({
startedAt: Date.parse(at(10)),
completedAt: Date.parse(at(11))
})
})
it('preserves a task boundary without inventing a successor until real output arrives', async () => {
const c = await chat()
await c.live()
const wake = { ...task, ...prompt('wake', 'answer-again', String(message(task).content), 60) }
await c.write([
...chatTurn,
prompt('outside', 'a1', 'outside work', 10),
reply('answer', 'outside', 'msg_answer', { type: 'text', text: 'first answer' }, 11),
{ ...prompt('quiet', 'answer', 'quiet metadata', 12), isMeta: true },
reply('answer-again', 'quiet', 'msg_again', { type: 'text', text: 'continued answer' }, 13),
wake
])
expect(await c.open('a1')).toBe(true)
const before = c.journal.snapshot()
const turn = before.items.find(
(item) => item.body.kind === 'turn' && item.body.turnId === 'outside'
)!
expect(turn.body).toMatchObject({
state: 'completed',
startedAt: Date.parse(at(10)),
completedAt: Date.parse(at(13))
})
expect(before.items.filter((item) => item.body.kind === 'turn')).toHaveLength(2)
expect(c.rows().filter((row) => row === '(continued-outside)')).toHaveLength(1)
const continued = before.items.find(
(item) =>
item.body.kind === 'message' &&
item.body.role === 'assistant' &&
item.body.blocks.some((block) => block.type === 'text' && block.text === 'continued answer')
)!
expect(continued.turnScope).toEqual({ kind: 'turn', turnItemId: turn.itemId })
expect(await c.open('a1')).toBe(true)
expect(c.journal.snapshot()).toEqual(before)
await c.write([
reply('wake-answer', 'wake', 'msg_wake', { type: 'text', text: 'wake answer' }, 61)
])
expect(await c.open('a1')).toBe(true)
const after = c.journal.snapshot()
const answer = after.items.find(
(item) =>
item.body.kind === 'message' &&
item.body.role === 'assistant' &&
item.body.blocks.some((block) => block.type === 'text' && block.text === 'wake answer')
)!
const scope = answer.turnScope
expect(scope?.kind).toBe('turn')
if (scope?.kind !== 'turn') {
throw new Error('wake turn missing')
}
expect(scope.turnItemId).not.toBe(turn.itemId)
expect(c.journal.item(scope.turnItemId)?.body).toMatchObject({ kind: 'turn', state: 'completed' })
expect(c.journal.item(turn.itemId)).toEqual(turn)
expect(after.items.filter((item) => item.body.kind === 'turn')).toHaveLength(3)
// Each separate catch-up appends one genuine outside stretch.
expect(c.rows().filter((row) => row === '(continued-outside)')).toHaveLength(2)
expect(await c.open('a1')).toBe(true)
expect(c.journal.snapshot()).toEqual(after)
expect((await c.replay()).snapshot()).toEqual(after)
})
it('keeps mixed task results with their saved caller before an independent reply', async () => {
const c = await chat()
await c.live({ tool: true, interrupted: true })
const saved = c.journal.snapshot()
const tool = saved.items.find((item) => item.body.kind === 'tool-call')!
const failed = saved.items.filter((item) => item.body.kind === 'turn')
await c.write([
...chatTurn,
{
...task,
...prompt('mixed-task', 'a1', '', 60),
message: {
role: 'user',
content: [
{ type: 'text', text: String(message(task).content) },
{ type: 'tool_result', tool_use_id: 'toolu_1', content: 'real result' }
]
}
},
reply(
'mixed-answer',
'mixed-task',
'msg_mixed',
{ type: 'text', text: 'independent answer' },
61
)
])
expect(await c.open('a1')).toBe(true)
expect(c.journal.item(tool.itemId)).toMatchObject({
turnScope: tool.turnScope,
body: { name: 'Read', input: { path: 'original' }, state: 'completed' }
})
expect(
c.journal.snapshot().items.filter((item) => failed.some((old) => old.itemId === item.itemId))
).toEqual(failed)
const answer = c.journal
.snapshot()
.items.find(
(item) =>
item.body.kind === 'message' &&
item.body.role === 'assistant' &&
item.body.blocks.some(
(block) => block.type === 'text' && block.text === 'independent answer'
)
)!
expect(answer.turnScope).not.toEqual(tool.turnScope)
const scope = answer.turnScope
if (scope?.kind !== 'turn') {
throw new Error('independent turn missing')
}
expect(c.journal.item(scope.turnItemId)?.body).toMatchObject({ kind: 'turn', state: 'completed' })
})
describe('internal content keeps live settlement and semantic siblings', () => {
it('does not open a turn or acknowledge a pending send, then accepts the real echo/result', async () => {
const c = await chat()
const translator = createClaudeJournalTranslator({ sink: c.deferred.sink })
const live = (row: Record<string, unknown>, startsTurn = false) =>
translator.handle({
type: 'message',
sessionId: SESSION,
message: frame(row),
...(startsTurn ? { startsTurn: true } : {})
})
live(synthetic)
live(task, true)
expect(translator.currentTurnId).toBeNull()
const session = sessionFor()
session.providerSessionId = SID
const sent = await dispatchClaudeTurn(session, {
sessionId: SESSION,
clientMessageId: 'real-send',
body: userMessage([{ type: 'text', text: String(message(task).content) }])
})
const waiter = session.dispatchWaiters[0]!
const internalEcho = { ...frame(task), uuid: waiter.sentUuid }
expect(resolveClaudeReplayTurn(session, internalEcho)).toBeNull()
expect(session.dispatchWaiters).toHaveLength(1)
const realEcho = {
...userReplayFrame(waiter.sentUuid, String(message(task).content)),
session_id: SID
}
expect(resolveClaudeReplayTurn(session, realEcho)).toMatchObject({
clientMessageId: 'real-send'
})
expect(sent).toEqual({ state: 'admitted' })
live(realEcho, true)
live(synthetic)
expect(translator.currentTurnId).toBe(waiter.sentUuid)
const result = { type: 'result', subtype: 'success', session_id: SID, uuid: 'real-result' }
live(result)
expect(session.dispatchWaiters).toHaveLength(0)
expect(translator.currentTurnId).toBeNull()
translator.dispose()
await c.deferred.drained()
expect(c.rows()).not.toContain('assistant:No response requested.')
})
it('keeps mixed injected tool results and synthetic tool siblings', async () => {
const c = await chat()
const translator = createClaudeJournalTranslator({ sink: c.deferred.sink })
const live = (row: Record<string, unknown>) =>
translator.handle({ type: 'message', sessionId: SESSION, message: frame(row) })
const call = rows[1]!
live({
...synthetic,
message: {
...message(synthetic),
content: [...contentParts(synthetic), ...contentParts(call)]
}
})
const result = rows[2]!
live({
...task,
message: {
...message(task),
content: [{ type: 'text', text: String(message(task).content) }, ...contentParts(result)]
}
})
live({ type: 'system', subtype: 'session_state_changed', state: 'idle' })
translator.dispose()
await c.deferred.drained()
expect(c.rows()).toContain('tool:Bash:failed')
expect(c.rows()).not.toContain('assistant:No response requested.')
})
})
it.each(['real-model', 'missing-usage', 'nonzero-usage', 'error', 'empty-error'] as const)(
'retains %s assistant semantics',
async (kind) => {
const c = await chat()
await c.live()
const nested = message(synthetic)
const variant: Record<string, unknown> = {
...synthetic,
parentUuid: 'a1',
...(kind === 'error' ? { isApiErrorMessage: true } : {}),
...(kind === 'empty-error' ? { error: 'authentication_failed' } : {}),
message: {
...nested,
...(kind === 'empty-error' ? { content: [] } : {}),
...(kind === 'real-model' ? { model: 'claude-opus-5-5' } : {}),
...(kind === 'missing-usage' ? { usage: undefined } : {}),
...(kind === 'nonzero-usage'
? { usage: { ...asRecord(nested.usage), output_tokens: 1 } }
: {})
}
}
await c.write([...chatTurn, variant])
expect(await c.open('a1')).toBe(true)
expect(
c.journal.item(
agentJournalItemKey({ provider: 'claude', sessionId: SID, uuid: String(variant.uuid) })
)
).not.toBeNull()
}
)
it.each([undefined, { kind: 'human' }, { kind: 'future-origin' }])(
'retains canonical human text with origin %j',
async (origin) => {
const c = await chat()
await c.live()
const human = { ...prompt('external-human', 'a1', String(message(task).content), 10), origin }
await c.write([...chatTurn, human])
expect(await c.open('a1')).toBe(true)
expect(
c.journal.item(
agentJournalItemKey({ provider: 'claude', sessionId: SID, uuid: 'external-human' })
)?.body
).toMatchObject({ kind: 'message', role: 'user' })
}
)
@@ -1,5 +1,6 @@
import {
AGENT_JOURNAL_THREAD_SCOPE,
type AgentJournalItemBody,
type AgentJournalRenderItem
} from '../../shared/agent-session/journal/agent-session-journal-types'
import type { AgentJournalTurnScope } from '../../shared/agent-session/journal/agent-session-journal-turn-scope'
@@ -90,3 +91,19 @@ export function createClaudeTranscriptItemReader(
}
return { saved, localPrompt, scopeOf, promptScope, dependencies }
}
/** A restored call cannot undo its saved result or discard its original call metadata. */
export function claudeRestoredItemBody(
body: AgentJournalItemBody,
existing: AgentJournalItemBody
): AgentJournalItemBody {
if (body.kind === 'tool-call' && existing.kind === 'tool-call') {
return body.state === 'running'
? existing
: { ...existing, ...body, name: existing.name, input: existing.input }
}
if (body.kind === 'message' && body.role === 'reasoning' && existing.kind === 'message') {
return { ...existing, ...body }
}
return body
}
@@ -0,0 +1,42 @@
import { isClaudeToolResultContent } from '../../shared/claude-tool-content'
import { decodeClaudeTranscriptLine } from '../native-chat/transcript-line-decoders-claude'
import { claudeEnvelopeStopHookFeedbackRow } from './claude-stop-hook-feedback'
import {
claudeRecord,
claudeOutputEnvelope,
type ClaudeMessageEnvelope
} from './structured/journal/claude-structured-item-translation'
export function claudeTranscriptPrompt(
row: Record<string, unknown>,
envelope: ClaudeMessageEnvelope | null,
providerSessionId: string
) {
const feedback = envelope && claudeEnvelopeStopHookFeedbackRow(envelope)
const candidate =
row.type === 'attachment' ||
(envelope?.role === 'user' &&
!feedback &&
envelope.content.some((part) => !isClaudeToolResultContent(part)))
const decodedPrompt = candidate
? decodeClaudeTranscriptLine(JSON.stringify(row), `${providerSessionId}:${String(row.uuid)}`)
: null
const prompt =
candidate &&
(!envelope?.isInjectedUserTurn ||
decodedPrompt?.blocks.some((block) => block.type !== 'tool-result'))
const decoded = prompt ? decodedPrompt : null
// A stop marker restates the chat's own interrupted turn, which its turn row already reads.
return {
decoded,
ignored: prompt && (!decoded || decoded.role === 'system'),
internalTurnStart:
candidate &&
row.type === 'user' &&
claudeRecord(row.origin)?.kind === 'task-notification' &&
row.isMeta !== true &&
row.isSynthetic !== true &&
row.isCompactSummary !== true,
hasWork: Boolean(envelope && (claudeOutputEnvelope(envelope).content.length || feedback))
}
}
@@ -1,5 +1,8 @@
import { ClaudePluginInputError } from '../claude-plugin-ui'
import { claudePromptCardWritten } from '../claude-child-work-evidence'
import type {
ClaudeAcquisitionAttempt,
ClaudeSessionExit,
ClaudeSession,
ClaudeStructuredSessionAdapterDeps,
ClaudeStructuredSessionEvent
@@ -22,7 +25,11 @@ export function emitClaudeStructuredSessionEvent({
deps,
publishChildWork
}: ClaudeEventDelivery): void {
if (event.type === 'ended') {
if (event.type === 'retiring') {
// Retire host input before publishing the logical failure to subscribers.
deps.onEvent?.(event)
}
if (event.type === 'ended' || event.type === 'retiring') {
session?.childWork.clear()
session?.backgroundTasks.clear()
} else if (event.type === 'message') {
@@ -36,8 +43,32 @@ export function emitClaudeStructuredSessionEvent({
session.events?.publish()
}
session?.translator?.handle(event)
deps.onEvent?.(event)
if (event.type !== 'retiring') {
deps.onEvent?.(event)
}
publishChildWork(event.sessionId, session, event.type === 'message' ? event.message : null)
// Child waiting requires the card to be durable.
void claudePromptCardWritten(session, event)?.then(() => publishChildWork(event.sessionId))
}
export function deliverClaudeConnectionEvent(
sessions: ReadonlyMap<string, ClaudeSession>,
exits: ReadonlyMap<string, ClaudeSessionExit>,
attempt: ClaudeAcquisitionAttempt,
sessionId: string,
event: () => void
): void {
if (!attempt.published) {
attempt.buffered.push(event)
return
}
if (exits.get(sessionId)?.error instanceof ClaudePluginInputError) {
return
}
if (
sessions.get(sessionId)?.connection === attempt.connection ||
exits.get(sessionId)?.connection === attempt.connection
) {
event()
}
}
@@ -0,0 +1,54 @@
import { claudeFocusedPlugin, ClaudePluginInputError } from '../claude-plugin-ui'
import type {
ClaudeAcquisitionAttempt,
ClaudeAcquireCallbacks,
ClaudeSession,
ClaudeSessionExit
} from './claude-structured-session-state'
export function claudePluginUiRecovery(
{
attempt,
callbacks,
sessionId,
sessions
}: {
attempt: ClaudeAcquisitionAttempt
callbacks: ClaudeAcquireCallbacks
sessionId: string
sessions: ReadonlyMap<string, ClaudeSession>
},
current: () => ClaudeSession | null
): (message: Record<string, unknown>) => boolean {
return (message) => {
const plugin = claudeFocusedPlugin(message)
if (plugin === null) {
return false
}
callbacks.deliver(attempt, sessionId, () => {
const session = current()
if (
session &&
sessions.get(sessionId) === session &&
(session.translator?.openTurnInLiveProviderCycle === true ||
session.dispatchWaiters.some((waiter) => waiter.clientMessageId !== null))
) {
// A plugin-held tool may never acknowledge interrupt; the exit owner closes the child.
callbacks.handleExit(sessionId, attempt, new ClaudePluginInputError(plugin))
}
})
return true
}
}
export function ignoresClaudeRetiredConnection(
liveSession: ClaudeSession | null,
current: ClaudeSession | undefined,
exit: ClaudeSessionExit | undefined
): boolean {
return Boolean(
liveSession &&
current !== liveSession &&
(exit?.session !== liveSession || exit?.error instanceof ClaudePluginInputError)
)
}
@@ -1,17 +1,14 @@
import type { AgentSessionAcquisition } from '../../native-chat/agent-session-wire/structured-agent-session-adapter'
import { openClaudeStreamJsonConnection } from '../claude-stream-json-connection'
import { claudeLaunchPermissions } from './claude-structured-inbound-control'
import { resolveClaudeReplayTurn } from '../claude-replay-turn-resolution'
import {
resolveClaudeReplayTurn,
withClaudeReplaySettlements
} from '../claude-replay-turn-resolution'
import { claudeStartedUnechoedSend } from '../claude-command-lifecycle'
import { claudeSessionStateEndsTurn } from '../claude-session-state-turn-over'
import { settleClaudeTurnEndWaiters } from '../claude-request-end-wait'
import {
createClaudeInitProof,
readClaudeCapabilities,
observeClaudeReportedPermissionMode,
readClaudeFrameString,
readClaudeInit
} from './claude-structured-init-proof'
import * as proof from './claude-structured-init-proof'
import { claudeConfigDirEnvPatch } from '../claude-config-dir-pin'
import { claudeProcessIdentity } from './claude-structured-owner-identity'
import { ClaudePromptRegistry } from '../claude-prompt-registry'
@@ -20,7 +17,8 @@ import { observeClaudeFrameModels } from './claude-structured-execution-observat
import { observeClaudeFastModeFacts } from './launch/claude-structured-session-options'
import {
createClaudeSessionPublication,
reportClaudeOptions
reportClaudeOptions,
publishClaudeSessionAttempt
} from './claude-structured-session-publication'
import type * as sessionState from './claude-structured-session-state'
import { resolveClaudeAcquisitionError } from './claude-structured-session-close'
@@ -36,6 +34,10 @@ import {
import { isRootClaudeFrame } from '../claude-turn-opening'
import { createClaudeStartupOutcome, endsClaudeStartup } from './claude-structured-startup-outcome'
import { claudeStartupResultError } from '../claude-stream-json-exit-error'
import {
claudePluginUiRecovery,
ignoresClaudeRetiredConnection
} from './claude-structured-plugin-ui-recovery'
export async function acquireClaudeSession({
input,
@@ -59,7 +61,7 @@ export async function acquireClaudeSession({
expectedProviderSessionId: string | null = null
// Preserve the CLI diagnostic when SDK cleanup reports a generic error.
let childEnded: Error | null = null
const initProof = createClaudeInitProof()
const initProof = proof.createClaudeInitProof()
// Owned by this acquisition alone, so a late frame cannot fail a replacement's open.
const startup = createClaudeStartupOutcome()
const childFailed = (error: Error, ended?: Promise<unknown>): void => {
@@ -67,42 +69,52 @@ export async function acquireClaudeSession({
void (ended ? ended.then(startup.fail) : endsClaudeStartup(error) && startup.fail(error))
initProof.reject(error)
}
const delivery = { attempt, initProof, callbacks, sessionId, sessions }
const translator = createClaudeSessionJournalTranslator(
input.events,
String(input.fence),
{
attempt,
initProof,
callbacks,
sessionId
},
delivery,
() => (liveSession && sessions.get(sessionId) === liveSession ? liveSession : null),
(message) => startup.accept(claudeStartupResultError(message))
)
const contextActivity = translator?.contextActivity ?? 0
const recordTurnEnd = claudeTurnResumePointWriter(sessionId, sessions, deps)
const observedOptions: sessionState.ClaudeSession['reportedOptions'] = {}
const handlePluginUi = claudePluginUiRecovery(delivery, () => liveSession)
const onMessage = (message: Record<string, unknown>): void => {
const init = readClaudeInit(message)
if (readClaudeFrameString(message, 'session_id') !== expectedProviderSessionId) {
if (
ignoresClaudeRetiredConnection(liveSession, sessions.get(sessionId), exits.get(sessionId))
) {
return
}
const init = proof.readClaudeInit(message)
if (proof.readClaudeFrameString(message, 'session_id') !== expectedProviderSessionId) {
// Foreign init frames refuse startup; ordinary foreign frames are ignored.
if (init || (message.type === 'system' && message.subtype === 'init')) {
initProof.refuse()
}
return
}
if (handlePluginUi(message)) {
return
}
if (message.type === 'system' && message.subtype === 'init' && isRootClaudeFrame(message)) {
firstRootInit ??= { permissionMode: readClaudeFrameString(message, 'permissionMode') }
firstRootInit ??= {
permissionMode: proof.readClaudeFrameString(message, 'permissionMode')
}
if (liveSession) {
liveSession.firstRootInit = firstRootInit
}
}
const permissionChanged = observeClaudeReportedPermissionMode(observedOptions, message)
const permissionChanged = proof.observeClaudeReportedPermissionMode(observedOptions, message)
const modelReport = observeClaudeFrameModels(liveSession, message, init?.model)
if (init) {
initProof.resolve(init)
if (liveSession) {
liveSession.capabilities = readClaudeCapabilities(liveSession.capabilities, init.message)
liveSession.capabilities = proof.readClaudeCapabilities(
liveSession.capabilities,
init.message
)
}
}
observedLeafUuid = readClaudeTranscriptEntryUuid(message) ?? observedLeafUuid
@@ -128,54 +140,39 @@ export async function acquireClaudeSession({
startsTurn || message.type === 'result' ? { observedAt: deps.now?.() ?? Date.now() } : {}
const requestedAt = turnOrigin?.requestedAt
const startedSend = !startsTurn && liveSession ? claudeStartedUnechoedSend(liveSession) : null
// Retain send ownership during publication until the host receives its settlement.
const session = liveSession
const reported = session?.unreportedSettlements
if (session && settlements.length > 0) {
session.unreportedSettlements = new Set([
...(reported ?? []),
...settlements.map((settlement) => settlement.clientMessageId)
])
}
try {
callbacks.deliver(attempt, sessionId, () => {
if (liveSession && (modelReport || permissionChanged)) {
withClaudeReplaySettlements(
liveSession,
settlements,
() =>
callbacks.deliver(attempt, sessionId, () => {
if (liveSession && (modelReport || permissionChanged)) {
callbacks.emit(liveSession, input.events, {
type: 'options-reported',
sessionId,
fence: liveSession.fence,
acquisitionGeneration: liveSession.acquisitionGeneration,
reportedOptions: {
...modelReport,
reportedModel: liveSession.reportedOptions.reportedModel,
reportedPermissionMode: observedOptions.reportedPermissionMode
}
})
}
callbacks.emit(liveSession, input.events, {
type: 'options-reported',
type: 'message',
sessionId,
fence: liveSession.fence,
acquisitionGeneration: liveSession.acquisitionGeneration,
reportedOptions: {
...modelReport,
reportedModel: liveSession.reportedOptions.reportedModel,
reportedPermissionMode: observedOptions.reportedPermissionMode
}
message,
...(startsTurn ? { startsTurn: true } : {}),
...(requestedAt === null || requestedAt === undefined ? {} : { requestedAt }),
...(turnOrigin?.clientMessageId ? { clientMessageId: turnOrigin.clientMessageId } : {}),
...(startedSend ? { startedSend } : {}),
...observedAt
})
}
callbacks.emit(liveSession, input.events, {
type: 'message',
sessionId,
message,
...(startsTurn ? { startsTurn: true } : {}),
...(requestedAt === null || requestedAt === undefined ? {} : { requestedAt }),
...(turnOrigin?.clientMessageId ? { clientMessageId: turnOrigin.clientMessageId } : {}),
...(startedSend ? { startedSend } : {}),
...observedAt
})
// Final snapshots and the turn settlement must reach the journal before its leaf can move.
recordTurnEnd(liveSession, message, leafAtFrame)
})
} finally {
try {
for (const settlement of settlements) {
deps.onDispatchSettledLate?.({ sessionId, ...settlement })
}
} finally {
if (session) {
session.unreportedSettlements = reported
}
}
}
// Final snapshots and the turn settlement must reach the journal before its leaf can move.
recordTurnEnd(liveSession, message, leafAtFrame)
}),
(settlement) => deps.onDispatchSettledLate?.({ sessionId, ...settlement })
)
}
const emit = (event: Parameters<typeof callbacks.emit>[2]): void =>
callbacks.deliver(attempt, sessionId, () => callbacks.emit(liveSession, input.events, event))
@@ -197,9 +194,8 @@ export async function acquireClaudeSession({
naming?.launch(launch)
attempt.account = launch.account
observedLeafUuid = launch.resumeLeafUuid
const open = deps.openConnection ?? openClaudeStreamJsonConnection
const connection = await withAgentSessionCreatePhase('spawn', input.recordPhase, () =>
open(
(deps.openConnection ?? openClaudeStreamJsonConnection)(
{
pathToClaudeCodeExecutable: launch.pathToClaudeCodeExecutable,
prefixArgs: launch.prefixArgs,
@@ -223,7 +219,11 @@ export async function acquireClaudeSession({
return
}
childFailed(...claudeStartupExit(error, attempt.connection, input.events))
callbacks.handleExit(sessionId, attempt, error)
if (exits.get(sessionId)?.connection === attempt.connection) {
callbacks.finishClose(sessionId, attempt)
} else {
callbacks.handleExit(sessionId, attempt, error)
}
})
}
)
@@ -282,19 +282,15 @@ export async function acquireClaudeSession({
sdkOptions: launch.options
})
acquisitions.deleteIfCurrent(sessionId, attempt)
await withAgentSessionCreatePhase('publish', input.recordPhase, async () => {
callbacks.publish(session)
attempt.published = true
for (const event of attempt.buffered.splice(0)) {
event()
}
await publishClaudeSessionAttempt({
sessionId,
session,
attempt,
callbacks,
sessions,
exits,
recordPhase: input.recordPhase
})
// Buffered exit delivery can remove the session during publication.
if (sessions.get(sessionId) !== session) {
throw (
exits.get(sessionId)?.error ?? new Error('claude session ended before acquisition returned')
)
}
return acquisition
} catch (error) {
unbindReadingControl?.()
@@ -17,18 +17,15 @@ import { dispatchClaudeMessage } from './launch/claude-structured-message-option
import { readClaudeStructuredSessionOptions } from './launch/claude-structured-session-options'
import {
ClaudeAcquisitionRegistry,
type ClaudeAcquisitionAttempt,
type ClaudeSession,
type ClaudeSessionExit,
type ClaudeStructuredSessionAdapterDeps,
type ClaudeStructuredSessionEvent
} from './claude-structured-session-state'
import {
closeAllClaudeSessions,
finishClaudeCloseAfterExit
} from './claude-structured-session-close'
import { closeAllClaudeSessions } from './claude-structured-session-close'
import { claudeStoppedRequestEndWait } from '../claude-request-end-wait'
import {
finishClaudeCloseAfterExit,
drainClaudeObservedExits,
observeClaudeSessionExit,
settleClaudeUnexpectedExit,
@@ -36,7 +33,10 @@ import {
} from './claude-structured-session-exit-lifecycle'
import type { AgentSessionBackgroundTaskState } from '../../../shared/agent-session/agent-session-background-task-wire'
import { drainClaudeChildWork } from '../claude-child-work-evidence'
import { emitClaudeStructuredSessionEvent } from './claude-structured-event-delivery'
import {
deliverClaudeConnectionEvent,
emitClaudeStructuredSessionEvent
} from './claude-structured-event-delivery'
import {
answerClaudeStructuredPrompt,
cancelClaudeStructuredTurn,
@@ -87,13 +87,16 @@ export class ClaudeStructuredSessionAdapter implements StructuredAgentSessionAda
...(naming ? { naming } : {}),
callbacks: {
publish: start.publish,
deliver: (attempt, sessionId, event) => this.deliver(attempt, sessionId, event),
deliver: (attempt, sessionId, event) =>
deliverClaudeConnectionEvent(this.sessions, this.exits, attempt, sessionId, event),
emit: (session, _events, event) => this.emit(session, event),
handleExit: (sessionId, attempt, error) =>
observeClaudeSessionExit(this.exitLifecycle, sessionId, attempt, error),
finishClose: (sessionId, attempt) =>
finishClaudeCloseAfterExit({
sessions: this.sessions,
exits: this.exits,
closeExit: this.releaseProviderSession,
sessionId,
connection: attempt.connection,
deps: { ...this.deps, settledExitErrors: this.settledExitErrors },
@@ -109,19 +112,6 @@ export class ClaudeStructuredSessionAdapter implements StructuredAgentSessionAda
})
}
private deliver(attempt: ClaudeAcquisitionAttempt, sessionId: string, event: () => void): void {
if (!attempt.published) {
attempt.buffered.push(event)
return
}
if (
this.sessions.get(sessionId)?.connection === attempt.connection ||
this.exits.get(sessionId)?.connection === attempt.connection
) {
event()
}
}
/** Await exit publication after cleanup/cursor writes; unproven exits stay indexed for retry. */
drainObservedExits = (): Promise<void> => drainClaudeObservedExits(this.exits)
@@ -244,29 +244,6 @@ export function closeClaudePublishedSessionForDeps(
return closeClaudePublishedSession({ sessions, sessionId, ...deps })
}
/** Complete the pending close for this exact connection after observing its root exit. */
export function finishClaudeCloseAfterExit(input: {
sessions: Map<string, ClaudeSession>
sessionId: string
connection: ClaudeStreamJsonConnection | null
deps: Parameters<typeof closeClaudePublishedSessionForDeps>[2]
afterClose: (close: () => Promise<boolean>) => Promise<boolean>
}): void {
const { sessions, sessionId, deps } = input
if (sessions.get(sessionId)?.connection !== input.connection) {
return
}
void input
.afterClose(() => closeClaudePublishedSessionForDeps(sessions, sessionId, deps))
.catch((error: unknown) =>
deps.logger?.warn('finishing a Claude close after its process exited reported', {
scope: 'claude-close-after-exit',
sessionId,
error
})
)
}
export async function closeClaudeSession(
input: CloseClaudePublishedSessionInput & {
acquisitions: ClaudeAcquisitionRegistry
@@ -0,0 +1,59 @@
import type { SubmissionRejectionFact } from '../../../shared/agent-session/failures/agent-session-failure'
import type * as SessionAdapter from '../../native-chat/agent-session-wire/structured-agent-session-adapter'
import type { ClaudePendingPrompt } from '../claude-prompt-registry'
import type { ClaudeStartedSend } from './claude-structured-session-state'
export type ClaudeAuthDiagnostic = {
apiKeySourceConfigured: boolean
baseUrlConfigured: boolean
authTokenConfigured: boolean
apiKeyConfigured: boolean
settingSources: readonly string[]
}
export type ClaudeStructuredSessionEvent =
| {
type: 'message'
sessionId: string
message: Record<string, unknown>
/** Present when an acknowledged replay opens a turn; folded replays only settle delivery. */
startsTurn?: true
/** Submission time for the acknowledged dispatch, absent when its send is unknown. */
requestedAt?: number
/** The submission this replay acknowledged, which opens the turn. */
clientMessageId?: string
/** On any other frame: the send the CLI started and has not echoed, whose turn output opens. */
startedSend?: ClaudeStartedSend
/** Host clock at receipt; stamped on turn boundaries only. */
observedAt?: number
}
| { type: 'provider-frame'; sessionId: string; kind: string; payload: unknown }
| { type: 'prompt'; sessionId: string; prompt: ClaudePendingPrompt }
| { type: 'prompt-cancelled'; sessionId: string; promptKey: string }
| { type: 'options'; sessionId: string; models: unknown[] }
| {
type: 'handle'
sessionId: string
providerSessionId: string
leafUuid: string | null
fence: number
}
| { type: 'auth-diagnostic'; sessionId: string; diagnostic: ClaudeAuthDiagnostic }
| SessionAdapter.StructuredAgentSessionOptionsReportedEvent
| (SessionAdapter.StructuredAgentSessionRetiringEvent & {
providerSessionId: string
unopenedSend?: ClaudeStartedSend
})
| {
type: 'ended'
sessionId: string
reason: string
failure?: SubmissionRejectionFact
deathEvidenceKind?: 'kill-delivered'
/** Present for first-hand child exits so the host can fence recovery. */
cause?: 'unexpected-exit' | 'requested-close'
fence?: number
acquisitionGeneration?: string
/** Host clock when the end was observed. */
observedAt?: number
}
@@ -1,3 +1,4 @@
import type { ClaudeStreamJsonConnection } from '../claude-stream-json-connection'
import { withTimeout } from '../../../shared/promise-timeout-fallback'
import {
agentSessionFailureFact,
@@ -14,9 +15,15 @@ import {
settledClaudeTurnEndLeaf
} from './claude-structured-resume-point'
import { claudeHeldSends } from '../claude-command-lifecycle'
import { ClaudePluginInputError } from '../claude-plugin-ui'
import type { AgentSessionStartEndings } from '../../native-chat/agent-session-wire/structured-agent-session-start-failure'
import { claudeExitEnding } from './claude-structured-start-ending'
import { claudeCloseReport, settleClaudeExitedSession } from './claude-structured-session-close'
import {
closeClaudePublishedSessionForDeps,
claudeCloseReport,
settleClaudeExitedSession
} from './claude-structured-session-close'
import { retireClaudeDispatchWaiters } from './claude-structured-dispatch'
import {
AgentSessionAcquisitionExitUnprovenError,
AgentSessionAcquisitionRootExitObservedError
@@ -54,8 +61,10 @@ export function observeClaudeSessionExit(
session.events?.observeExit?.(claudeHeldSends(session), observedAt)
lifecycle.endings.record(sessionId, session.acquisitionGeneration, claudeExitEnding(error))
lifecycle.sessions.delete(sessionId)
// Run tree cleanup before recovery; an exit callback proves only root exit.
const cleanup = session.connection.close().catch(() => false)
// Register cleanup ownership before semantic publication can wake another provider write.
const cleanup = Promise.resolve()
.then(() => session.connection.close())
.catch(() => false)
const closePromise =
claudeStartDied(session) && providerExitObserved(error)
? withTimeout(cleanup, 2_000, false)
@@ -68,18 +77,44 @@ export function observeClaudeSessionExit(
closePromise
}
lifecycle.exits.set(sessionId, exit)
exit.publication = closePromise
.then((proven) => {
// Observed root exit releases the lease even if tree cleanup remains unverified.
if (
claudeCloseReport(session.connection, proven, error) instanceof
AgentSessionAcquisitionExitUnprovenError
) {
return undefined
}
return settleClaudeUnexpectedExit(lifecycle, sessionId, exit)
})
.catch(() => undefined)
try {
if (error instanceof ClaudePluginInputError) {
const waiter = session.dispatchWaiters.find((entry) => entry.clientMessageId !== null)
// Delivery remains uncertain, but this connection no longer owes a running action.
retireClaudeDispatchWaiters(session)
lifecycle.emit(session, {
type: 'retiring',
sessionId,
providerSessionId: session.providerSessionId,
reason: error.message,
failure: error.failure,
fence: session.fence,
acquisitionGeneration: session.acquisitionGeneration,
observedAt,
...(waiter?.clientMessageId
? {
unopenedSend: {
clientMessageId: waiter.clientMessageId,
...(waiter.requestedAt === null ? {} : { requestedAt: waiter.requestedAt })
}
}
: {})
})
}
} finally {
exit.publication = closePromise
.then((proven) => {
// Observed root exit releases the lease even if tree cleanup remains unverified.
if (
claudeCloseReport(session.connection, proven, error) instanceof
AgentSessionAcquisitionExitUnprovenError
) {
return undefined
}
return settleClaudeUnexpectedExit(lifecycle, sessionId, exit)
})
.catch(() => undefined)
}
}
/** Exited before the host-awaited open returned and before any turn; turns can settle without an Orca dispatch. */
@@ -122,15 +157,17 @@ export function settleClaudeUnexpectedExit(
reason: exit.error.message,
// A typed startup refusal or a spawn ENOENT keeps its next step; else provider exit vs host fault.
failure:
startupAcquisitionRefusal(exit.error) || providerSpawnAbsent(exit.error)
? providerStartupFailureFact(exit.error)
: startupFailure
? providerStartupFailureFact(startupFailure)
: providerExitObserved(exit.error)
? agentSessionFailureFact('providerExited', {
detail: providerDiagnosticOf(exit.error)
})
: agentSessionFailureFact('hostFault'),
exit.error instanceof ClaudePluginInputError
? exit.error.failure
: startupAcquisitionRefusal(exit.error) || providerSpawnAbsent(exit.error)
? providerStartupFailureFact(exit.error)
: startupFailure
? providerStartupFailureFact(startupFailure)
: providerExitObserved(exit.error)
? agentSessionFailureFact('providerExited', {
detail: providerDiagnosticOf(exit.error)
})
: agentSessionFailureFact('hostFault'),
cause: 'unexpected-exit',
...(report instanceof AgentSessionAcquisitionRootExitObservedError &&
report.deathEvidenceKind === 'kill-delivered'
@@ -184,3 +221,36 @@ export async function persistClaudeSessionHandle(
fence: session.fence
})
}
/** Complete the pending close for this exact connection after observing its root exit. */
export function finishClaudeCloseAfterExit(input: {
sessions: Map<string, ClaudeSession>
exits: Map<string, ClaudeSessionExit>
closeExit: (sessionId: string) => Promise<boolean>
sessionId: string
connection: ClaudeStreamJsonConnection | null
deps: Parameters<typeof closeClaudePublishedSessionForDeps>[2]
afterClose: (close: () => Promise<boolean>) => Promise<boolean>
}): void {
const { sessions, sessionId, deps } = input
const exit = input.exits.get(sessionId)
if (
sessions.get(sessionId)?.connection !== input.connection &&
exit?.connection !== input.connection
) {
return
}
void input
.afterClose(() =>
exit
? input.closeExit(sessionId)
: closeClaudePublishedSessionForDeps(sessions, sessionId, deps)
)
.catch((error: unknown) =>
deps.logger?.warn('finishing a Claude close after its process exited reported', {
scope: 'claude-close-after-exit',
sessionId,
error
})
)
}
@@ -0,0 +1,155 @@
import { describe, expect, it, vi } from 'vitest'
import { readAgentJournalTurn } from '../../../shared/agent-session/lifecycle/agent-session-turn-record'
import type { AgentJournalItemBody } from '../../../shared/agent-session/journal/agent-session-journal-types'
import type { ClaudeStructuredSessionEvent } from './claude-structured-session-state'
import {
adapterFor,
claudeFrame,
fakeClaude,
identityFor,
recordingJournalSink,
tick,
USER_MESSAGE
} from './claude-structured-session-test-support'
import { claudeFocusedPlugin } from '../claude-plugin-ui'
// Claude Code 2.1.303 recorded shape; UUID and session ID are test identities.
const FOCUSED_PANE = {
type: 'system',
subtype: 'ui_panes',
panes: [{ id: 'blast-radius', title: 'Blast Radius', plugin: 'blast-radius', rows: 10 }],
shown_id: 'blast-radius',
focused_id: null,
focus_requested_id: 'blast-radius',
uuid: '00000000-0000-4000-8000-000000000795'
}
async function setup() {
const claude = fakeClaude()
const events: ClaudeStructuredSessionEvent[] = []
const persisted: unknown[] = []
const items: AgentJournalItemBody[] = []
const sink = {
...recordingJournalSink(),
appendItem: (_identity: unknown, body: AgentJournalItemBody) => {
items.push(body)
},
observeExit: vi.fn()
}
const adapter = adapterFor(claude, {}, events, persisted)
const acquire = (fence = 7) =>
adapter.acquire({
identity: identityFor(),
fence,
spawnToken: `spawn-${fence}`,
events: sink
})
const dispatch = (id = 'client-1', fence = 7) =>
adapter.dispatch({
sessionId: 'session-1',
clientMessageId: id,
body: USER_MESSAGE,
fence
})
await acquire()
return { claude, events, persisted, sink, items, adapter, acquire, dispatch }
}
describe('focused Claude plugin pane recovery', () => {
it.each([null, 'child-tool'])(
'closes the held child once without interrupt for parent %s',
async (parent) => {
const state = await setup()
const first = state.claude.connections[0]
await state.dispatch()
claudeFrame(first, { ...FOCUSED_PANE, parent_tool_use_id: parent })
claudeFrame(first, FOCUSED_PANE)
await vi.waitFor(() =>
expect(state.events.filter((event) => event.type === 'ended')).toHaveLength(1)
)
expect(first.closeCount).toBe(1)
expect(first.calls.some((call) => call.subtype === 'interrupt')).toBe(false)
expect(state.events.find((event) => event.type === 'ended')).toMatchObject({
failure: {
kind: 'pluginInputRequired',
detail: { text: 'blast-radius', audience: 'person' }
},
reason:
'Claude stopped because the "blast-radius" plugin is waiting for input in a terminal pane. Disable the plugin for chat sessions, or run the task in a Claude terminal.'
})
expect(state.sink.observeExit).toHaveBeenCalledOnce()
expect(state.persisted).toHaveLength(1)
expect(state.items.flatMap((body) => readAgentJournalTurn(body) ?? []).at(-1)).toMatchObject({
state: 'interrupted',
outcome: 'failure'
})
await state.adapter.closeAll()
}
)
it('keeps passive panes running and ignores foreign, idle and settled focus frames', async () => {
const state = await setup()
const first = state.claude.connections[0]
claudeFrame(first, FOCUSED_PANE)
await state.dispatch()
claudeFrame(first, { ...FOCUSED_PANE, focus_requested_id: null })
claudeFrame(first, { type: 'system', subtype: 'ui_invalidate' })
claudeFrame(first, { type: 'system', subtype: 'ui_status', text: null })
claudeFrame(first, { ...FOCUSED_PANE, session_id: 'foreign-session' })
await tick()
expect(first.closeCount).toBe(0)
claudeFrame(first, { type: 'result', subtype: 'success', is_error: false, result: 'OK' })
claudeFrame(first, FOCUSED_PANE)
await tick()
expect(first.closeCount).toBe(0)
expect(state.events.some((event) => event.type === 'ended')).toBe(false)
await state.adapter.closeAll()
})
it('waits for close then sends on a fresh connection, ignoring late frames from the old child', async () => {
const state = await setup()
const first = state.claude.connections[0]
const closed = Promise.withResolvers<boolean>()
const close = first.close
first.close = async () => {
await close()
return closed.promise
}
await state.dispatch()
claudeFrame(first, FOCUSED_PANE)
const next = state.acquire(8)
await tick()
expect(state.claude.connections).toHaveLength(1)
closed.resolve(true)
await next
await state.dispatch('client-next', 8)
const second = state.claude.connections[1]
claudeFrame(first, FOCUSED_PANE)
expect(second.closeCount).toBe(0)
claudeFrame(second, {
type: 'assistant',
uuid: 'fresh-reply',
message: { role: 'assistant', content: [{ type: 'text', text: 'FOLLOW_UP_OK' }] }
})
claudeFrame(second, {
type: 'result',
subtype: 'success',
is_error: false,
result: 'FOLLOW_UP_OK'
})
expect(first.sent.filter((message) => message.type === 'user')).toHaveLength(1)
expect(second.sent.filter((message) => message.type === 'user')).toHaveLength(1)
expect(state.events.filter((event) => event.type === 'ended')).toHaveLength(1)
await state.adapter.closeAll()
})
it('uses the focused pane plugin name, falling back to its trimmed ID', () => {
expect(claudeFocusedPlugin({ ...FOCUSED_PANE, focus_requested_id: ' blast-radius ' })).toBe(
'blast-radius'
)
expect(
claudeFocusedPlugin({ ...FOCUSED_PANE, panes: [], focus_requested_id: ' pane-2 ' })
).toBe('pane-2')
expect(claudeFocusedPlugin({ ...FOCUSED_PANE, focus_requested_id: ' ' })).toBeNull()
})
})
@@ -1,3 +1,4 @@
import { withAgentSessionCreatePhase } from '../../observability/agent-session-instrumentation'
import { agentSessionAccountHome } from '../../../shared/agent-session/lifecycle/agent-session-account-home'
import { CLAUDE_STRUCTURED_AGENT } from './claude-structured-agent-definition'
import { randomUUID } from 'node:crypto'
@@ -11,6 +12,7 @@ import type { ClaudePromptRegistry } from '../claude-prompt-registry'
import type { ClaudeJournalTranslator } from '../claude-journal-translator-contract'
import type {
ClaudeSession,
ClaudeSessionExit,
ClaudeAcquisitionAttempt,
ClaudeAcquireCallbacks,
ClaudeStructuredSessionEvent,
@@ -185,3 +187,35 @@ export function reportClaudeOptions(
)
).catch(() => {})
}
export async function publishClaudeSessionAttempt({
sessionId,
session,
attempt,
callbacks,
sessions,
exits,
recordPhase
}: {
sessionId: string
session: ClaudeSession
attempt: ClaudeAcquisitionAttempt
callbacks: ClaudeAcquireCallbacks
sessions: ReadonlyMap<string, ClaudeSession>
exits: ReadonlyMap<string, ClaudeSessionExit>
recordPhase: StructuredAgentSessionAcquireInput['recordPhase']
}): Promise<void> {
await withAgentSessionCreatePhase('publish', recordPhase, async () => {
callbacks.publish(session)
attempt.published = true
for (const event of attempt.buffered.splice(0)) {
event()
}
})
// Buffered exit delivery can remove the session during publication.
if (sessions.get(sessionId) !== session) {
throw (
exits.get(sessionId)?.error ?? new Error('claude session ended before acquisition returned')
)
}
}
@@ -3,10 +3,8 @@ import type { ClaudeStartupOutcome } from './claude-structured-startup-outcome'
import type { ClaudeCliFlagCheck, ClaudeCliFlagFact } from '../claude-cli-flag-support'
import type { StructuredAgentSessionLogger } from '../../native-chat/agent-session-wire/structured-agent-session-logger'
import type { AgentJournalDispatchRejection } from '../../../shared/agent-session/failures/agent-session-failure-words'
import type { SubmissionRejectionFact } from '../../../shared/agent-session/failures/agent-session-failure'
import type { AgentJournalItemIdentity } from '../../../shared/agent-session/journal/agent-session-journal-identity'
import type { StructuredAgentSessionEventSink } from '../../native-chat/agent-session-wire/structured-agent-session-event-sink'
import type * as SessionAdapter from '../../native-chat/agent-session-wire/structured-agent-session-adapter'
import type {
ClaudeStreamJsonConnection,
openClaudeStreamJsonConnection
@@ -14,7 +12,7 @@ import type {
import type { ClaudeStructuredLaunch } from './launch/claude-structured-launch-resolution'
import type { StructuredLaunchRequest } from '../../runtime/agent-session/launch/structured-agent-launch-composition'
import type { ClaudeJournalTranslator } from '../claude-journal-translator-contract'
import type { ClaudePendingPrompt, ClaudePromptRegistry } from '../claude-prompt-registry'
import type { ClaudePromptRegistry } from '../claude-prompt-registry'
import { cancelProcessAcquisition } from '@orca/process-host/cancel-process-acquisition'
import type { AgentSessionFastModeState } from '../../../shared/agent-session/agent-session-wire'
import type { AgentChildWorkEvidence } from '../../../shared/agent-status/child-work/agent-status-child-work-evidence'
@@ -28,56 +26,11 @@ import type { AgentSessionRecord } from '../../../shared/agent-session/lifecycle
import type { ClaudeNamingInputs } from '../claude-naming-invocation'
import type { ClaudeInitializeFacts } from './claude-structured-session-startup'
export type ClaudeAuthDiagnostic = {
apiKeySourceConfigured: boolean
baseUrlConfigured: boolean
authTokenConfigured: boolean
apiKeyConfigured: boolean
settingSources: readonly string[]
}
export type ClaudeStructuredSessionEvent =
| {
type: 'message'
sessionId: string
message: Record<string, unknown>
/** Present when an acknowledged replay opens a turn; folded replays only settle delivery. */
startsTurn?: true
/** Submission time for the acknowledged dispatch, absent when its send is unknown. */
requestedAt?: number
/** The submission this replay acknowledged, which opens the turn. */
clientMessageId?: string
/** On any other frame: the send the CLI started and has not echoed, whose turn output opens. */
startedSend?: ClaudeStartedSend
/** Host clock at receipt; stamped on turn boundaries only. */
observedAt?: number
}
| { type: 'provider-frame'; sessionId: string; kind: string; payload: unknown }
| { type: 'prompt'; sessionId: string; prompt: ClaudePendingPrompt }
| { type: 'prompt-cancelled'; sessionId: string; promptKey: string }
| { type: 'options'; sessionId: string; models: unknown[] }
| {
type: 'handle'
sessionId: string
providerSessionId: string
leafUuid: string | null
fence: number
}
| { type: 'auth-diagnostic'; sessionId: string; diagnostic: ClaudeAuthDiagnostic }
| SessionAdapter.StructuredAgentSessionOptionsReportedEvent
| {
type: 'ended'
sessionId: string
reason: string
failure?: SubmissionRejectionFact
deathEvidenceKind?: 'kill-delivered'
/** Present for first-hand child exits so the host can fence recovery. */
cause?: 'unexpected-exit' | 'requested-close'
fence?: number
acquisitionGeneration?: string
/** Host clock when the end was observed. */
observedAt?: number
}
export type {
ClaudeAuthDiagnostic,
ClaudeStructuredSessionEvent
} from './claude-structured-session-event'
import type { ClaudeStructuredSessionEvent } from './claude-structured-session-event'
export type ClaudeStartedSend = { clientMessageId: string; requestedAt?: number }
@@ -5,6 +5,7 @@ import type {
import type { AgentJournalItemIdentity } from '../../../../shared/agent-session/journal/agent-session-journal-identity'
import type { NativeChatBlock } from '../../../../shared/native-chat/native-chat-types'
import { journalPayload } from '../../../native-chat/agent-session-journal/journal-payload-bounds'
import { isClaudeInjectedUserMessage } from '../../claude-internal-message-content'
import {
isClaudeToolUseContent,
isClaudeToolResultContent,
@@ -46,9 +47,7 @@ export function readClaudeMessageEnvelope(
const sessionId = claudeText(frame.session_id)
const uuid = claudeText(frame.uuid)
const role = message?.role
const isInjectedUserTurn =
frame.type === 'user' &&
(frame.isMeta === true || frame.isSynthetic === true || frame.isCompactSummary === true)
const isInjectedUserTurn = isClaudeInjectedUserMessage(frame)
return sessionId && uuid && (role === 'assistant' || role === 'user')
? {
sessionId,
@@ -22,7 +22,6 @@ import { createClaudeStreamedBlockRegistry } from '../../claude-streamed-block-i
import { createClaudeStreamedTextCheckpoints } from '../../claude-streamed-text-checkpoints'
import {
claudeFrameParentRef,
claudeStreamTurnStartSource,
claudeStreamTurnSource,
isRootClaudeFrame
} from '../../claude-turn-opening'
@@ -32,7 +31,7 @@ import { ClaudeContextFacts } from '../../claude-context-facts'
import { claudeSessionStateEndsTurn } from '../../claude-session-state-turn-over'
import { ClaudeJournalPrompts } from './claude-structured-journal-prompts'
import { claudeChildToolQueries } from '../../claude-child-tool-queries'
import { journalClaudeMessage } from '../../claude-message-journaling'
import { journalClaudeMessage, journalClaudeStream } from '../../claude-message-journaling'
import type { ClaudeMessageJournalContext } from '../../claude-message-journaling'
import { journalClaudeResult } from '../../claude-result-journaling'
import type { ClaudeResultJournalContext } from '../../claude-result-journaling'
@@ -40,6 +39,11 @@ import { ClaudeAssistantFailures } from '../../claude-assistant-failures'
import { ClaudeUsageLimits } from '../../claude-usage-limits'
import { ClaudeThreadGoal } from '../../claude-thread-goal'
import { createClaudeFinalOutputCoverage } from '../../claude-final-output-coverage'
import { withClaudeConversationContent } from '../../claude-internal-message-content'
import {
journalClaudePluginInputFailure,
settleClaudeInterruptedTurn
} from '../../claude-plugin-input-failure-journal'
export function createClaudeJournalTranslator(
deps: ClaudeJournalTranslatorDeps
@@ -47,7 +51,6 @@ export function createClaudeJournalTranslator(
const coverage = createClaudeFinalOutputCoverage(deps.sink)
deps = { ...deps, sink: coverage.sink }
const tools = new Map<string, ClaudeToolUse>()
// Every row joins the root turn open when it is written, whoever produced it.
const turnScope = () => turn.turnScope
const prompts = new ClaudeJournalPrompts({
...deps,
@@ -123,19 +126,6 @@ export function createClaudeJournalTranslator(
streamedThinking.flush()
}
const handleStream = (message: Record<string, unknown>, observedAt: number): boolean => {
const delta = streamedBlocks.observe(message, observedAt)
const thinking = streamedThinking.observe(message, observedAt)
// Prefer message_start; use the first content delta for older streams that omit it.
const source =
delta || thinking ? claudeStreamTurnSource(message) : claudeStreamTurnStartSource(message)
turn.ensureOpen(message, source, observedAt)
if (delta) {
streamedText.append(delta.identity, delta.text, delta.parentToolUseId)
}
return delta !== null || thinking
}
const messageContext: ClaudeMessageJournalContext = {
sink: deps.sink,
tools,
@@ -161,17 +151,21 @@ export function createClaudeJournalTranslator(
acceptStartupFailure: deps.acceptStartupFailure
}
const finish = (observedAt: number, failed: boolean): void => {
prompts.retryPendingCancellations()
flush()
corrections.abandon()
backgroundTasks.settleSession()
settleClaudeInterruptedTurn(turn, observedAt, failed)
}
return {
handle: (event) => {
handle: withClaudeConversationContent((event) => {
if (event.type === 'retiring') {
journalClaudePluginInputFailure({ event, turn, sink: deps.sink, finish })
return
}
if (event.type === 'ended') {
prompts.retryPendingCancellations()
flush()
corrections.abandon()
backgroundTasks.settleSession()
// Child exit proves the turn ended; the journal's Stop event determines whether a person ended it.
turn.settle({ state: 'interrupted', completedAt: event.observedAt ?? Date.now() })
// Prevent late frames from opening a turn after the child can no longer close it.
turn.suppressReopen()
finish(event.observedAt ?? Date.now(), event.failure?.kind === 'pluginInputRequired')
return
}
turn.observeStartedSend(event.type === 'message' ? (event.startedSend ?? null) : null)
@@ -197,7 +191,10 @@ export function createClaudeJournalTranslator(
if (event.type === 'message' && absorbClaudeCommandFrame(messageContext, event.message)) {
return
}
if (event.type === 'message' && handleStream(event.message, event.observedAt ?? Date.now())) {
if (
event.type === 'message' &&
journalClaudeStream(messageContext, event.message, event.observedAt ?? Date.now())
) {
return
}
// Observe announcements before flushing so buffered rows receive their resolved producer stamp.
@@ -253,7 +250,7 @@ export function createClaudeJournalTranslator(
providerFallback.append(event.kind, event.payload)
publishClaudeTurnActivity(turn, deps.sink, event)
}
},
}),
journalPrompts: prompts,
get currentTurnId() {
return turn.id
@@ -23,6 +23,7 @@ import {
type ClaudeMessageEnvelope
} from '../journal/claude-structured-item-translation'
import { claudeResultOutcome } from '../../claude-result-outcome'
import { isUnmodeledClaudeSystemFrame } from '../../../native-chat/agent-session-wire/provider-frame-disposition'
import {
isClaudeToolUseContent,
isClaudeToolResultContent
@@ -54,7 +55,8 @@ const PROGRESS_FRAME_KINDS: ReadonlySet<string> = new Set([
/** No row to write, so buffered output need not flush ahead of this frame. */
export function isClaudeProgressFrame(message: Record<string, unknown>): boolean {
return PROGRESS_FRAME_KINDS.has(claudeProviderFrameKind(message))
const kind = claudeProviderFrameKind(message)
return PROGRESS_FRAME_KINDS.has(kind) || isUnmodeledClaudeSystemFrame(kind)
}
const SETTLED_RESULT_KINDS: ReadonlySet<string> = new Set(
@@ -88,6 +88,15 @@ describe('provider frame classification catalog', () => {
).toBe('status-chrome')
})
it('suppresses unmodeled Claude system diagnostics, including terminal UI', () => {
for (const subtype of ['ui_status', 'ui_toast', 'ui_future_surface', 'uinotify']) {
expect(
classifyProviderFrame('claude', `message:system:${subtype}`, { plugin: 'p', text: 'x' })
).toBe('suppressed-benign')
}
expect(classifyProviderFrame('claude', 'message:system', {})).toBe('suppressed-benign')
})
it('keeps unknown future frames on the substantive bounded fallback path', () => {
expect(classifyProviderFrame('codex', 'notification:future/event', {})).toBe(
'timeline-substantive'
@@ -182,9 +191,7 @@ describe('failure is decided by kind, never by searching the payload', () => {
error: 'auth expired'
})
).toBeNull()
const unknown = unhandledProviderFrameJournalItem('claude', 'message:system:future', failed)
expect(unknown?.classification).toBe('timeline-substantive')
expect(unknown?.body.tone).toBeUndefined()
expect(unhandledProviderFrameJournalItem('claude', 'message:system:future', failed)).toBeNull()
})
})
@@ -168,6 +168,13 @@ function itemKind(kind: string): string | null {
return kind.startsWith('item:') ? kind.slice('item:'.length) : null
}
export function isUnmodeledClaudeSystemFrame(kind: string): boolean {
return (
(kind === 'message:system' || kind.startsWith('message:system:')) &&
!Object.hasOwn(PROVIDER_FRAME_CLASSIFICATIONS.claude, kind)
)
}
export function isDeltaProviderFrameKind(kind: string): boolean {
return notificationKind(kind).toLowerCase().endsWith('delta')
}
@@ -178,7 +185,7 @@ const CLAUDE_FRAME_CLASSIFICATIONS: Readonly<Record<string, ProviderFrameClassif
PROVIDER_FRAME_CLASSIFICATIONS.claude
/** Classify failures only from declared kinds/outcome fields; nested statuses can describe unrelated work.
* Unlisted frames retain visible fallback rows. */
* Unlisted non-system frames retain visible fallback rows. */
const PROVIDER_FRAME_CATALOGS: ReadonlyMap<
string,
(kind: string, payload: unknown) => ProviderFrameClassification | undefined
@@ -213,6 +220,9 @@ const PROVIDER_FRAME_CATALOGS: ReadonlyMap<
? 'error-surface'
: 'suppressed-benign'
}
if (isUnmodeledClaudeSystemFrame(kind)) {
return 'suppressed-benign'
}
if (kind === 'message:auth_status') {
const error = isProviderRecord(payload) ? payload.error : undefined
return typeof error === 'string' && error.trim().length > 0
@@ -157,6 +157,18 @@ export type StructuredAgentSessionOptionsReportedEvent =
export type StructuredAgentSessionLifecycleEvent =
| StructuredAgentSessionEndedEvent
| StructuredAgentSessionOptionsReportedEvent
| StructuredAgentSessionRetiringEvent
/** A logical failure stops input immediately; only a later ended event proves writer death. */
export type StructuredAgentSessionRetiringEvent = {
type: 'retiring'
sessionId: string
reason: string
failure: SubmissionRejectionFact
fence: number
acquisitionGeneration: string
observedAt: number
}
export type StructuredAgentSessionProviderChildPhase = 'starting' | 'ready'
@@ -143,8 +143,11 @@ export async function endExitedStructuredAgentSessionChildUnderSerialize<
return
}
const close = child.close
const expected = exit.expected && close?.failure?.kind !== 'hostFault'
const failure = close?.failure?.kind === 'hostFault' ? close.failure : exit.failure
const failedClose =
close?.failure?.kind === 'hostFault' || close?.failure?.kind === 'pluginInputRequired'
const stoppedPlugin = exit.failure?.kind === 'pluginInputRequired' && close?.cause === 'user-stop'
const expected = (exit.expected || stoppedPlugin) && !failedClose
const failure = failedClose ? close?.failure : exit.failure
// Use the first death observation when settling the running turn.
const observation = child.exitObservation?.()
const observedAt = observation?.observedAt ?? exit.observedAt ?? context.now()
@@ -236,12 +239,11 @@ export async function endExitedStructuredAgentSessionChildUnderSerialize<
(!expected || (startClose === 'host-stop' && unfinishedWork.hadUnsettledSubmissions))
// Fold Stop before fallback turn-end classification.
await close?.recorded
const generation = child.generation ?? 'unknown'
// Proven exit can refine earlier unverifiable rows.
const watched = { ownerFence: child.fence, observedAt }
settlement = {
ownerFence: child.fence,
settlementId: `${expected ? 'expected-close:' : PROVIDER_EXIT_ROW_PREFIX}${sessionId}:${child.fence}:${generation}`,
settlementId: `${expected ? 'expected-close:' : PROVIDER_EXIT_ROW_PREFIX}${sessionId}:${child.fence}:${child.generation ?? 'unknown'}`,
pendingSubmissionReason: expected
? 'provider_closed_before_acknowledgement'
: 'provider_exited_before_acknowledgement',
@@ -0,0 +1,328 @@
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session/lifecycle/agent-session-mutation-envelope'
import { ClaudeStructuredSessionAdapter } from '../../claude/structured/claude-structured-session-adapter'
import {
claudeFrame,
fakeClaude,
PROVIDER_SESSION_ID,
type FakeConnection
} from '../../claude/structured/claude-structured-session-test-support'
import { openTestAgentSessionRecordStore } from '../../runtime/agent-session/records/agent-session-record-store-test-harness'
import { structuredClaudeLifecycleEvent } from '../../runtime/agent-session/installation/structured-claude-runtime-adapter'
import {
closeTestJournalHostDatabases,
openTestJournalHostDatabase
} from '../agent-session-journal/journal-host-database-test-support'
import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support'
import { StructuredAgentSessionHost } from './structured-agent-session-host'
import {
HOST_TEST_NOW,
HOST_TEST_SESSION as SESSION,
hostTestAttachParams,
hostTestMessage,
hostTestOperationId,
resetHostTestOperationIds
} from './structured-agent-session-host-test-data'
import { createStructuredAgentSessionLogger } from './structured-agent-session-logger'
// Recorded pane content; ordering before the user echo is a constructed protocol contract.
const FOCUS = {
type: 'system',
subtype: 'ui_panes',
panes: [{ id: 'blast-radius', title: 'Blast Radius', plugin: 'blast-radius', rows: 10 }],
shown_id: 'blast-radius',
focused_id: null,
focus_requested_id: 'blast-radius'
}
const CALLER = { callerKey: 'client-1' }
let root: string
let host: StructuredAgentSessionHost
let adapter: ClaudeStructuredSessionAdapter
let claude: ReturnType<typeof fakeClaude>
let observedAt: number
let restoreConnections: (() => void)[]
beforeEach(async () => {
root = await mkdtemp(join(tmpdir(), 'orca-claude-plugin-input-'))
resetHostTestOperationIds()
observedAt = HOST_TEST_NOW
restoreConnections = []
claude = fakeClaude({ replayUuid: null })
adapter = new ClaudeStructuredSessionAdapter({
resolveLaunch: async () => ({
pathToClaudeCodeExecutable: 'claude',
options: {},
cwd: root,
claudeConfigDir: join(root, 'claude-home'),
providerSessionId: PROVIDER_SESSION_ID,
resumeLeafUuid: null,
resumesTranscript: false,
cliFlag: async () => 'supported' as const,
continuesChain: claude.connections.length > 0
}),
onEvent: (event) => {
const mapped = structuredClaudeLifecycleEvent(event)
if (mapped) {
void host.handleAdapterEvent(mapped)
}
},
onDispatchSettledLate: (event) => void host.settleLateDispatch(event),
openConnection: claude.openConnection,
readProcessStartTime: async () => 1_700_000_000_000,
now: () => ++observedAt
})
host = new StructuredAgentSessionHost({
agents: NO_STRUCTURED_AGENTS,
logger: createStructuredAgentSessionLogger(),
store: await openTestAgentSessionRecordStore(root),
adapter: Object.assign(adapter, { supportsCreate: () => true }),
journalDatabase: openTestJournalHostDatabase(root),
claimKeyId: 'key-1',
mintSpawnToken: () => hostTestOperationId(),
now: () => ++observedAt
})
expect(
await host.attach(
CALLER,
hostTestAttachParams(null, {
provider: 'claude',
agent: 'claude',
accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: join(root, 'claude-home') },
providerHandle: { kind: 'claude', sessionId: PROVIDER_SESSION_ID, leafUuid: null }
})
)
).toMatchObject({ ok: true })
await vi.waitFor(() => expect(child()?.phase).toBe('ready'))
})
afterEach(async () => {
for (const restore of restoreConnections) {
restore()
}
await adapter.closeAll()
await host.flushAllStreamedEvents()
await closeTestJournalHostDatabases()
claude.dispose()
await rm(root, { recursive: true, force: true })
})
function child() {
return host.collaboratorsForTests().sessions.get(SESSION)?.child
}
function envelope(method: string, fields: Record<string, unknown>, id = hostTestOperationId()) {
return {
sessionId: SESSION,
clientOperationId: id,
expectedRuntimeFence: null,
payloadFingerprint: computeAgentSessionPayloadFingerprint({
method,
sessionId: SESSION,
fields
})
}
}
async function send(text: string, queued = false) {
const fields = {
body: hostTestMessage(text),
...(queued ? { delivery: 'queue-if-active' as const } : {})
}
const request = envelope('agentSession.send', fields)
const result = await host.send(CALLER, { envelope: request, ...fields, userSend: true })
expect(result).toMatchObject({ ok: true })
return { id: request.clientOperationId, result }
}
async function snapshot() {
await host.flushStreamedEvents(SESSION)
return host.journalSnapshot(SESSION)
}
async function notices() {
return (await snapshot()).items.filter(
(item) => item.body.kind === 'status' && item.body.failure?.kind === 'pluginInputRequired'
)
}
async function expectReply(connection: FakeConnection, text: string) {
claudeFrame(connection, { ...connection.sent.at(-1)!, uuid: `echo-${text}` })
claudeFrame(connection, {
type: 'assistant',
uuid: `reply-${text}`,
message: { role: 'assistant', content: [{ type: 'text', text }] }
})
claudeFrame(connection, { type: 'result', subtype: 'success', is_error: false, result: text })
await vi.waitFor(async () => expect(JSON.stringify((await snapshot()).items)).toContain(text))
expect(
(await snapshot()).items.findLast((item) => item.body.kind === 'turn')?.body
).toMatchObject({ state: 'completed', outcome: 'success' })
}
async function startHeld() {
const first = claude.connections[0]!
const initial = await send('plugin probe')
await vi.waitFor(() => expect(first.sent).toHaveLength(1))
return { first, initial }
}
function holdClose(first: FakeConnection) {
const proof = Promise.withResolvers<boolean>()
const close = first.close
first.close = async () => {
await close()
return proof.promise
}
restoreConnections.push(() => {
proof.resolve(true)
first.close = close
})
return { proof, close }
}
it('fails an unechoed action before close proof; the quick next send joins close and gets a fresh reply', async () => {
const { first, initial } = await startHeld()
const { proof } = holdClose(first)
const originalChild = child()
claudeFrame(first, FOCUS)
expect(child()).toBe(originalChild)
expect(child()?.close?.failure?.kind).toBe('pluginInputRequired')
await vi.waitFor(async () => expect(await notices()).toHaveLength(1))
const failed = await snapshot()
expect(failed.items.findLast((item) => item.body.kind === 'turn')?.body).toMatchObject({
state: 'interrupted',
outcome: 'failure'
})
expect(failed.submissions.find((s) => s.clientMessageId === initial.id)?.dispatchState).not.toBe(
'accepted'
)
expect(host.readStatusSummary(SESSION)?.status).not.toBe('working')
expect(host.collaboratorsForTests().sessions.get(SESSION)?.lastEndedChild).toBeUndefined()
claudeFrame(first, { ...first.sent[0]!, uuid: 'late-failed-echo' })
claudeFrame(first, {
type: 'result',
subtype: 'success',
is_error: false,
result: 'late failure result'
})
expect(
(await snapshot()).submissions.find((s) => s.clientMessageId === initial.id)?.dispatchState
).not.toBe('accepted')
await send('fresh follow-up')
await new Promise((resolve) => setImmediate(resolve))
expect(claude.connections).toHaveLength(1)
expect(first.sent).toHaveLength(1)
proof.resolve(true)
await vi.waitFor(() => expect(claude.connections[1]?.sent).toHaveLength(1))
const second = claude.connections[1]!
claudeFrame(first, {
type: 'assistant',
message: { content: [{ type: 'text', text: 'LATE_OLD_OUTPUT' }] }
})
claudeFrame(first, { type: 'result', subtype: 'success', is_error: false, result: 'late result' })
claudeFrame(first, FOCUS)
await expectReply(second, 'FRESH_REPLY')
expect(await notices()).toHaveLength(1)
expect(JSON.stringify((await snapshot()).items)).not.toContain('LATE_OLD_OUTPUT')
expect(second.closeCount).toBe(0)
expect(first.calls.some((call) => call.subtype === 'interrupt')).toBe(false)
})
it('retains unproven ownership, then a legitimate next-send wake retries proof and completes delivery', async () => {
const { first } = await startHeld()
const { proof, close } = holdClose(first)
claudeFrame(first, FOCUS)
await vi.waitFor(async () => expect(await notices()).toHaveLength(1))
proof.resolve(false)
await new Promise((resolve) => setImmediate(resolve))
expect(child()?.close?.failure?.kind).toBe('pluginInputRequired')
expect(claude.connections).toHaveLength(1)
first.close = close
await send('retry after proof failure')
await vi.waitFor(() => expect(claude.connections[1]?.sent).toHaveLength(1))
await expectReply(claude.connections[1]!, 'RETRY_REPLY')
expect(first.closeCount).toBe(2)
expect(await notices()).toHaveLength(1)
})
it('holds queued cards through semantic and physical failure until explicit Resume dispatches and replies', async () => {
const { first } = await startHeld()
const queued = await send('queued follow-up', true)
expect(queued.result).toMatchObject({
ok: true,
value: { queued: { messageId: expect.any(String) } }
})
claudeFrame(first, FOCUS)
await vi.waitFor(() => expect(child()).toBeNull())
const journal = host.collaboratorsForTests().sessions.get(SESSION)!.journal
expect(journal.queuedMessages.pauses()).toContainEqual(
expect.objectContaining({ reason: 'failed' })
)
await host.flushStreamedEvents(SESSION)
await new Promise((resolve) => setImmediate(resolve))
expect(claude.connections).toHaveLength(1)
expect(first.sent).toHaveLength(1)
expect(
await host.queuedMessagesResume(CALLER, {
envelope: envelope('agentSession.queuedMessagesResume', {})
})
).toMatchObject({ ok: true, value: { resumed: true } })
await vi.waitFor(() => expect(claude.connections[1]?.sent).toHaveLength(1))
await expectReply(claude.connections[1]!, 'QUEUED_REPLY')
expect(await notices()).toHaveLength(1)
})
it.each([false, true])(
'keeps personal Stop precedence when focus arrives during interrupt (echoed=%s)',
async (echoed) => {
const { first } = await startHeld()
if (echoed) {
claudeFrame(first, { ...first.sent[0]!, uuid: 'stopped-user-echo' })
await vi.waitFor(async () =>
expect(
(await snapshot()).items.some(
(item) => item.body.kind === 'turn' && item.body.state === 'running'
)
).toBe(true)
)
}
claude.routes.interrupt = () => {
claudeFrame(first, FOCUS)
}
expect(
await host.cancel(CALLER, {
envelope: envelope('agentSession.cancel', {})
})
).toMatchObject({ ok: true })
await vi.waitFor(() => expect(child()).toBeNull())
expect(await notices()).toHaveLength(0)
expect(
(await snapshot()).items.findLast((item) => item.body.kind === 'turn')?.body
).toMatchObject({ state: 'interrupted', outcome: 'cancellation' })
}
)
it('finishes a retained unproven close when its exact process later reports exit', async () => {
const { first } = await startHeld()
const close = first.close
restoreConnections.push(() => {
first.close = close
})
first.close = async () => {
await close()
return false
}
claudeFrame(first, FOCUS)
await vi.waitFor(async () => expect(await notices()).toHaveLength(1))
expect(child()?.close).toBeDefined()
first.exitVerdict = { root: 'exited', tree: 'unverifiable' }
first.handlers.onExit?.(new Error('late real exit'), { expected: true })
await vi.waitFor(() => expect(child()).toBeNull())
expect(await notices()).toHaveLength(1)
await send('after late exit')
await vi.waitFor(() => expect(claude.connections[1]?.sent).toHaveLength(1))
await expectReply(claude.connections[1]!, 'LATE_EXIT_REPLY')
})
@@ -138,6 +138,31 @@ export class StructuredAgentSessionEventRecovery {
/** Settle/report exits without automatic restart; next-send delivery owns queued work and startup. */
async handle(event: StructuredAgentSessionLifecycleEvent): Promise<void> {
if (event.type === 'retiring') {
const session = this.context.sessions.get(event.sessionId)
const child = session?.child
if (
session &&
child &&
child.fence === event.fence &&
child.generation === event.acquisitionGeneration &&
!child.close
) {
const stopped = session.journal.stopMarks.personStopDecides(
session.journal.runningTurn()?.turnId ?? null,
event.observedAt
)
// Input joins the existing proof barrier; this records neither Stop nor root death.
child.close = {
cause: stopped ? 'user-stop' : 'host-stop',
reason: stopped ? null : event.reason,
...(stopped ? {} : { failure: event.failure }),
recorded: Promise.resolve(null),
requestedAt: session.journal.cursor()
}
}
return
}
if (event.type === 'options-reported') {
return settleStructuredAgentSessionOptionsReported(this.context, event)
}
@@ -15,6 +15,7 @@ import {
hostTestMessage,
hostTestOperationId
} from './structured-agent-session-host-test-data'
import { ClaudePluginInputError } from '../../claude/claude-plugin-ui'
import { sendAgentTurn } from '../../runtime/orchestration/send-agent-turn'
import { structuredSessionPointerCallerKey } from '../../runtime/orchestration/structured-mailbox-pointer-host'
import { createStructuredSessionObservationClock } from './structured-agent-session-observation-clock.test-fixture'
@@ -180,6 +181,51 @@ describe('queued follow-ups after an accepted turn fails', () => {
}
)
it('holds queued cards after a plugin ends the live Claude turn, until explicit resume', async () => {
const id = await rig.workingSend()
const sink = rig.providerEvents()
const translator = createClaudeJournalTranslator({ sink })
translator.handle({
type: 'message',
sessionId: SESSION,
startsTurn: true,
message: {
type: 'user',
uuid: 'plugin-user',
session_id: 'claude-session',
isReplay: true,
parent_tool_use_id: null,
message: { role: 'user', content: 'plugin probe' }
}
})
await sink.written?.()
await rig.settleAccepted(id, id)
const first = await queue('first plugin follow-up')
const second = await queue('second plugin follow-up')
const error = new ClaudePluginInputError('blast-radius')
translator.handle({
type: 'ended',
sessionId: SESSION,
cause: 'unexpected-exit',
reason: error.message,
failure: error.failure
})
await sink.written?.()
await rig.host.flushStreamedEvents(SESSION)
await held(first, second)
sink.publish()
await rig.host.flushStreamedEvents(SESSION)
await held(first, second)
expect(await rig.resume()).toMatchObject({ ok: true, value: { resumed: true } })
const handoff = await dispatched(first)
expect(await rig.handoff(second)).toBeUndefined()
await openRigTurnFor(rig, handoff)
await rig.settleAccepted(handoff, 'first')
await openRigTurnFor(rig, handoff, 'completed', 'success')
await dispatched(second)
translator.dispose()
})
it('holds every mid-turn card across repeated wakes, then resumes one turn at a time', async () => {
const working = await start()
const first = await queue('first')
@@ -18,6 +18,10 @@ import { unwrapClaudeBashInputBlock } from '../../shared/claude-bash-input'
import { claudeInterruptedMessageId } from './transcript-turn-markers'
import { claudeStopHookFeedbackRow } from '../claude/claude-stop-hook-feedback'
import { structuredAgentSessionStatusBlock } from '../../shared/agent-session/view/structured-agent-session-status-block'
import {
claudeMessageWithoutSyntheticText,
isClaudeInjectedUserMessage
} from '../claude/claude-internal-message-content'
const MAX_EDIT_PATCH_HUNKS = 40
const MAX_EDIT_PATCH_HUNK_LINES = 400
@@ -76,7 +80,10 @@ export function decodeClaudeTranscriptLine(
if (!parsed) {
return null
}
const record = claudeQueuedPromptAsUserRecord(parsed) ?? parsed
const record = claudeMessageWithoutSyntheticText(claudeQueuedPromptAsUserRecord(parsed) ?? parsed)
if (!record) {
return null
}
const role = record.type
if (role !== 'user' && role !== 'assistant') {
return null
@@ -105,9 +112,7 @@ export function decodeClaudeTranscriptLine(
return null
}
// Meta user turns may still contain genuine tool output.
const isInjectedUserTurn =
role === 'user' &&
(record.isMeta === true || record.isSynthetic === true || record.isCompactSummary === true)
const isInjectedUserTurn = isClaudeInjectedUserMessage(record)
const stopHookFeedback = isInjectedUserTurn ? claudeStopHookFeedbackMessage(decodedBlocks) : null
if (stopHookFeedback) {
return { id: recordMessageId, timestamp, source: 'transcript', ...stopHookFeedback, ...parent }
@@ -50,7 +50,7 @@ export type StructuredClaudeRuntimeAdapterDeps = {
export function structuredClaudeLifecycleEvent(
event: ClaudeStructuredSessionEvent
): StructuredAgentSessionLifecycleEvent | null {
if (event.type === 'options-reported') {
if (event.type === 'options-reported' || event.type === 'retiring') {
return event
}
// Every exit of a child with an identity, expected or not: the host ends that child's record.
@@ -0,0 +1,47 @@
// @vitest-environment happy-dom
import '@testing-library/jest-dom/vitest'
import { cleanup, render, screen } from '@testing-library/react'
import { afterEach, expect, it } from 'vitest'
import { TooltipProvider } from '@/components/ui/tooltip'
import { i18n } from '@/i18n/i18n'
import { agentSessionFailureFact } from '../../../../shared/agent-session/failures/agent-session-failure'
import { NativeChatNoticeRow } from './NativeChatNoticeRow'
function renderFailure(failure: ReturnType<typeof agentSessionFailureFact>, text: string) {
render(
<TooltipProvider>
<NativeChatNoticeRow
agentName="Claude"
block={{ type: 'text', tone: 'error', text, failure }}
/>
</TooltipProvider>
)
}
afterEach(async () => {
cleanup()
await i18n.changeLanguage('en')
})
it('renders a plugin input failure with its recovery in English and French', async () => {
const failure = agentSessionFailureFact('pluginInputRequired', {
detail: { text: 'blast-radius', audience: 'person' }
})
const text =
'Claude stopped because the "blast-radius" plugin is waiting for input in a terminal pane. Disable the plugin for chat sessions, or run the task in a Claude terminal.'
renderFailure(failure, text)
expect(screen.getByText(text)).toBeInTheDocument()
expect(screen.queryByText('Details')).not.toBeInTheDocument()
cleanup()
await i18n.changeLanguage('fr')
renderFailure(failure, text)
expect(
screen.getByText(
'Claude s’est arrêté car le plugin « blast-radius » attend une saisie dans un volet de terminal. Désactivez le plugin pour les sessions de chat ou exécutez la tâche dans un terminal Claude.'
)
).toBeInTheDocument()
expect(screen.queryByText('Détails')).not.toBeInTheDocument()
cleanup()
renderFailure(failure, 'Custom host recovery advice.')
expect(screen.getByText('Custom host recovery advice.')).toBeInTheDocument()
})
@@ -194,9 +194,11 @@ export function NativeChatNoticeRow({
const usageLimitContinue =
block.usageLimitTurnItemId !== undefined &&
block.usageLimitTurnItemId === orcaStopView.usageLimitTurnItemId
// Only reword auth and missing-conversation text fully described by its fact; host text may also carry command advice.
// Reword only text fully described by its fact; other host text may carry command advice.
const factSurface =
failure?.kind === 'notSignedIn' || failure?.kind === 'savedConversationMissing'
failure?.kind === 'notSignedIn' ||
failure?.kind === 'savedConversationMissing' ||
failure?.kind === 'pluginInputRequired'
? (['row', 'rejection'] as const).find(
(surface) => block.text === agentSessionFailureSentence(failure, surface, { agentName })
)
@@ -19,6 +19,12 @@ import { START_FAILURE_PIECES } from './agent-session-start-failure-words-text'
const PIECES: Record<AgentSessionFailureCopyId, (values: AgentSessionFailureCopyValues) => string> =
{
theAgent: () => translate('components.native-chat.failureWords.theAgent', COPY.theAgent),
pluginInputRequired: (values) =>
translate(
'components.native-chat.failureWords.pluginInputRequired',
COPY.pluginInputRequired,
values
),
...START_FAILURE_PIECES,
runCommandAgain: (values) =>
translate(
+1
View File
@@ -1587,6 +1587,7 @@
"fullAccessAsRoot": "{{agent}} won't use Full access while it runs as root. Choose another permission for {{agent}} in Settings → Agents for the computer running this chat, then start a new chat.",
"historyInOtherAccount": "This chat's history is in another Claude account. Switch back to that account to continue it.",
"historyTooLarge": "This conversation's history is too large to restore here.",
"pluginInputRequired": "{{agent}} stopped because the \"{{plugin}}\" plugin is waiting for input in a terminal pane. Disable the plugin for chat sessions, or run the task in a {{agent}} terminal.",
"hostFault": "Orca ran into a problem, so this didn't go through.",
"hostFaultTryAgain": "Orca ran into a problem, so this didn't go through. Try again.",
"hostRestarted": "Orca restarted before this message was sent.",
+1
View File
@@ -19713,6 +19713,7 @@
"couldNotStopTheAgent": "Couldn't stop the agent.",
"noTurnToStop": "{{agent}} had no turn running to stop.",
"answerUnconfirmed": "Your answer was recorded but the agent did not confirm it.",
"pluginInputRequired": "{{agent}} stopped because the \"{{plugin}}\" plugin is waiting for input in a terminal pane. Disable the plugin for chat sessions, or run the task in a {{agent}} terminal.",
"hostFault": "Orca ran into a problem, so this didn't go through.",
"hostFaultTryAgain": "Orca ran into a problem, so this didn't go through. Try again.",
"hostStopped": "{{agent}} never finished starting, so Orca stopped it.",
+1
View File
@@ -19176,6 +19176,7 @@
"argumentsMultipleValues": "Les arguments enregistrés donnent plusieurs valeurs à {{option}}.",
"argumentsPositionalPrompt": "Les arguments enregistrés contiennent un message.",
"argumentsUnsupportedOption": "Les arguments enregistrés contiennent une option non prise en charge ({{option}}).",
"pluginInputRequired": "{{agent}} s’est arrêté car le plugin « {{plugin}} » attend une saisie dans un volet de terminal. Désactivez le plugin pour les sessions de chat ou exécutez la tâche dans un terminal {{agent}}.",
"hostFault": "Orca a rencontré un problème, l'opération n'a donc pas abouti.",
"hostFaultTryAgain": "Orca a rencontré un problème, l'opération n'a donc pas abouti. Réessayez.",
"hostStopped": "{{agent}} n'a jamais fini de démarrer, Orca l'a donc arrêté.",
@@ -103,6 +103,8 @@ export const AGENT_SESSION_FAILURE_COPY = {
'Claude effort is invalid; choose low, medium, high, xhigh, or max and send again.',
modelUnavailable: "{{agent}} couldn't use {{detail}}. Pick another model and send again.",
providerRejected: "{{agent}} didn't accept this message.",
pluginInputRequired:
'{{agent}} stopped because the "{{plugin}}" plugin is waiting for input in a terminal pane. Disable the plugin for chat sessions, or run the task in a {{agent}} terminal.',
providerRejectedQuoted: "{{agent}} didn't accept this message: {{detail}}.",
attachmentEmpty: 'An image on this message is empty, so the message was not sent.',
attachmentTooLarge: 'An image on this message is too large, so the message was not sent.',
@@ -182,6 +184,7 @@ export type AgentSessionFailureCopyValues = {
installedVersion?: string
minimumVersion?: string
agent?: string
plugin?: string
command?: string
loginCommand?: string
slashCommand?: string
@@ -1,4 +1,4 @@
import type { ProviderDiagnostic } from './agent-session-failure'
import type { AgentSessionFailureFact, ProviderDiagnostic } from './agent-session-failure'
import type {
AgentSessionFailureCopyId,
AgentSessionFailureCopyValues,
@@ -27,3 +27,16 @@ export function quotingPersonDetail(
const quoted = detail?.audience === 'person' ? quotedProviderDetail(detail.text) : ''
return quoted ? say(quotedLead, { ...values, detail: quoted }) : say(lead, values)
}
/** The detail behind a failure's Details: provider words, or a log tail such as a CLI's startup stderr.
* A retry's log is the raw frame its row already shows. */
export function agentSessionFailureDetailText(
fact: AgentSessionFailureFact | undefined
): string | undefined {
const detail = fact?.detail
return detail &&
fact.kind !== 'pluginInputRequired' &&
!(detail.audience === 'log' && fact.kind === 'providerRetrying')
? detail.text
: undefined
}
@@ -62,3 +62,18 @@ describe('the words written beside a failure fact', () => {
expect(retrying({})).toBe('Codex hit a temporary problem and is retrying.')
})
})
it('names the terminal-input plugin literally and supplies both recovery paths', () => {
const plugin = 'plugin-{{agent}}-$&'
const fact = {
kind: 'pluginInputRequired',
detail: { text: plugin, audience: 'person' }
} as const
expect(agentSessionFailureSentence(fact, 'row', { agentName: 'Claude' })).toBe(
'Claude stopped because the "plugin-{{agent}}-$&" plugin is waiting for input in a terminal pane. Disable the plugin for chat sessions, or run the task in a Claude terminal.'
)
expect(classifyDispatchRejection({ reason: 'not delivered', rejection: fact })).toMatchObject({
category: 'undelivered',
verdict: 'failure'
})
})
@@ -206,6 +206,11 @@ const FAILURE_SENTENCES = {
fact.detail,
agent(say, context)
),
pluginInputRequired: (context, fact, _surface, say) =>
say('pluginInputRequired', {
...agent(say, context),
plugin: fact.detail?.text ?? 'unnamed'
}),
attachmentInvalid: (context, fact, _surface, say) =>
fact.attachment
? attachmentProblemSentence(say, context, fact.attachment)
@@ -47,6 +47,7 @@ export const AGENT_SESSION_FAILURE_KINDS = [
'providerExited',
'restartFailed',
'providerRejected',
'pluginInputRequired',
'modelUnavailable',
'claudeEffortInvalid',
'attachmentInvalid',
@@ -167,7 +168,7 @@ export type AgentSessionProviderRetry = {
export type AgentSessionFailureFact = {
kind: AgentSessionFailureKind
account?: AgentSessionAccountKind
/** Provider diagnostic, the rejected model ID on `modelUnavailable`, or the host error on `launchPreparationFailed`; never Orca's sentence. */
/** Provider diagnostic, rejected model/plugin identity, or the host error on `launchPreparationFailed`; never Orca's sentence. */
detail?: ProviderDiagnostic
/** On `restartFailed` and `startFailed`: the refusal that kept the agent from starting. On
* `commandRefused`: the refusal that kept the command from running. */
@@ -369,13 +370,4 @@ export function withProviderDiagnostic<TError extends Error>(
return diagnostic ? Object.assign(error, { providerDiagnostic: diagnostic }) : error
}
/** The detail behind a failure's Details: provider words, or a log tail such as a CLI's startup stderr.
* A retry's log is the raw frame its row already shows. */
export function agentSessionFailureDetailText(
fact: AgentSessionFailureFact | undefined
): string | undefined {
const detail = fact?.detail
return detail && !(detail.audience === 'log' && fact.kind === 'providerRetrying')
? detail.text
: undefined
}
export { agentSessionFailureDetailText } from './agent-session-failure-quoted-detail'
@@ -65,6 +65,7 @@ const KIND_CATEGORY = {
notDelivered: 'undelivered',
unconfirmed: 'undelivered',
providerExited: 'undelivered',
pluginInputRequired: 'undelivered',
hostStopped: 'undelivered',
providerStartFailed: 'startFailed',
savedConversationMissing: 'startFailed',
@@ -107,6 +108,7 @@ const KIND_VERDICT = {
notDelivered: null,
unconfirmed: null,
providerExited: 'failure',
pluginInputRequired: 'failure',
providerStartFailed: 'failure',
savedConversationMissing: 'failure',
startFailed: 'failure',
+34 -2
View File
@@ -29,7 +29,8 @@ const initializeResponse = {
available_output_styles: ['default']
}
// A prompt containing "slow" runs until it is interrupted.
// A prompt containing "slow" runs until it is interrupted; one containing "plugin-ui" first gets the
// frames a plugin's `$.ui.status` and `$.ui.toast` calls produce in stream-json mode.
const TURN_MS = 500
const SLOW_TURN_MS = 60_000
let turnTimer = null
@@ -37,10 +38,41 @@ let turnTimer = null
function startTurn(frame) {
// The CLI's --replay-user-messages echo, which opens the turn.
write({ ...frame, isReplay: true })
const slow = JSON.stringify(frame.message ?? '').includes('slow')
const prompt = JSON.stringify(frame.message ?? '')
const slow = prompt.includes('slow')
const pluginUi = prompt.includes('plugin-ui')
if (prompt.includes('plugin-focus')) {
write({
type: 'system',
subtype: 'ui_panes',
session_id: sessionId,
uuid: randomUUID(),
panes: [{ id: 'blast-radius', title: 'Blast Radius', plugin: 'blast-radius', rows: 10 }],
shown_id: 'blast-radius',
focused_id: null,
focus_requested_id: 'blast-radius'
})
return
}
turnTimer = setTimeout(
() => {
turnTimer = null
if (pluginUi) {
const ui = { type: 'system', plugin: 'e2e-board', session_id: sessionId }
write({
...ui,
subtype: 'ui_status',
text: 'PR 6 · 진행 13 · 대기 6 /board',
uuid: randomUUID()
})
write({
...ui,
subtype: 'ui_toast',
text: 'Board synced',
timeout_ms: 3000,
uuid: randomUUID()
})
}
write({
type: 'assistant',
uuid: randomUUID(),
@@ -13,6 +13,8 @@ import os from 'node:os'
import path from 'node:path'
import type { Page } from '@stablyai/playwright-test'
import { test as base, expect } from './orca-app'
import { waitForActiveWorktree, waitForSessionReady } from './store'
import { buildFakeAgentCommandOverride } from './fake-agent-command-override'
import { resolveCliCommand } from '../../../src/shared/node-cli-command-resolution'
import { createStructuredAgentSessionId } from '../../../src/shared/agent-session/structured-agent-session-create'
import {
@@ -107,3 +109,38 @@ export async function createChatOnHost(page: Page, worktreeId: string): Promise<
expect(reply, JSON.stringify(reply)).toMatchObject({ ok: true })
return sessionId
}
/** Open a chat tab driven by the Claude stub and focus its composer. */
export async function openStubChat(page: Page, claudeCommand: string) {
await waitForSessionReady(page)
const worktreeId = await waitForActiveWorktree(page)
await page.evaluate(async (command) => {
await window.__store?.getState().updateSettings({
nativeChatProviders: { claude: true, codex: true },
defaultTuiAgent: 'claude',
agentCmdOverrides: { claude: command }
})
}, buildFakeAgentCommandOverride(claudeCommand))
const sessionId = await createChatOnHost(page, worktreeId)
const readTabId = () =>
page.evaluate(
({ worktreeId, sessionId }) =>
window.__store
?.getState()
.unifiedTabsByWorktree[worktreeId]?.find((tab) => tab.entityId === sessionId)?.id,
{ worktreeId, sessionId }
)
await expect.poll(readTabId, { timeout: 30_000 }).toBeTruthy()
const tab = page.locator(`[data-testid="sortable-tab"][data-tab-id="${await readTabId()}"]`)
await tab.click()
const chat = page.locator('[data-native-chat-root="true"]')
const composer = chat.getByRole('textbox')
await composer.click()
return {
tab,
composer,
send: chat.getByRole('button', { name: 'Send', exact: true }),
stop: chat.getByRole('button', { name: 'Stop the agent' }),
reply: chat.getByText('Stub reply.')
}
}
@@ -0,0 +1,48 @@
// A Claude plugin's status line and toasts never reach the chat transcript as raw provider rows.
import { readFileSync } from 'node:fs'
import { expect } from './helpers/orca-app'
import { SUPPORTED, openStubChat, test } from './helpers/structured-claude-stub'
test.describe('native chat plugin UI frames', () => {
test.skip(!SUPPORTED, 'the Claude stub wrapper is POSIX-only')
test('a turn with plugin status and toast frames shows only the reply', async ({
orcaPage: page,
claudeStub
}) => {
const { reply } = await openStubChat(page, claudeStub.command)
await page.keyboard.type('plugin-ui probe')
await page.keyboard.press('Enter')
await expect(reply).toBeVisible({ timeout: 30_000 })
const chat = page.locator('[data-native-chat-root="true"]')
// Unknown frames land inside the turn's work section, so open it before looking.
await chat.getByText(/Worked for/).click()
await expect(chat.getByText('PR 6 · 진행 13 · 대기 6', { exact: false })).toHaveCount(0)
await expect(chat.getByText('Board synced')).toHaveCount(0)
await expect(reply).toBeVisible()
})
test('a focused plugin pane fails once and a follow-up completes on a fresh process', async ({
orcaPage: page,
claudeStub
}) => {
const { reply, composer } = await openStubChat(page, claudeStub.command)
await page.keyboard.type('plugin-focus probe')
await page.keyboard.press('Enter')
const chat = page.locator('[data-native-chat-root="true"]')
await expect(chat.getByText(/Claude stopped because the "blast-radius" plugin/)).toHaveCount(
1,
{ timeout: 30_000 }
)
await composer.fill('recovery probe')
await composer.press('Enter')
await expect(reply).toBeVisible({ timeout: 30_000 })
const requests = readFileSync(`${claudeStub.launchLog}.requests.jsonl`, 'utf8')
.trim()
.split('\n')
.map((line) => JSON.parse(line))
const sends = requests.filter((request) => request.type === 'user')
expect(new Set(sends.map((request) => request.pid)).size).toBe(2)
})
})
+6 -42
View File
@@ -1,49 +1,13 @@
// Completed tab and composer actions return keyboard input to the chat draft.
import type { Locator, Page } from '@stablyai/playwright-test'
import type { Locator } from '@stablyai/playwright-test'
import { expect } from './helpers/orca-app'
import { waitForActiveWorktree, waitForSessionReady } from './helpers/store'
import { buildFakeAgentCommandOverride } from './helpers/fake-agent-command-override'
import { SUPPORTED, createChatOnHost, test } from './helpers/structured-claude-stub'
import { SUPPORTED, openStubChat, test } from './helpers/structured-claude-stub'
function focusIsIn(composer: Locator): Promise<boolean> {
return composer.evaluate((element) => element.contains(element.ownerDocument.activeElement))
}
async function openChat(page: Page, claudeCommand: string) {
await waitForSessionReady(page)
const worktreeId = await waitForActiveWorktree(page)
await page.evaluate(async (command) => {
await window.__store?.getState().updateSettings({
nativeChatProviders: { claude: true, codex: true },
defaultTuiAgent: 'claude',
agentCmdOverrides: { claude: command }
})
}, buildFakeAgentCommandOverride(claudeCommand))
const sessionId = await createChatOnHost(page, worktreeId)
const readTabId = () =>
page.evaluate(
({ worktreeId, sessionId }) =>
window.__store
?.getState()
.unifiedTabsByWorktree[worktreeId]?.find((tab) => tab.entityId === sessionId)?.id,
{ worktreeId, sessionId }
)
await expect.poll(readTabId, { timeout: 30_000 }).toBeTruthy()
const tab = page.locator(`[data-testid="sortable-tab"][data-tab-id="${await readTabId()}"]`)
await tab.click()
const chat = page.locator('[data-native-chat-root="true"]')
const composer = chat.getByRole('textbox')
await composer.click()
return {
tab,
composer,
send: chat.getByRole('button', { name: 'Send', exact: true }),
stop: chat.getByRole('button', { name: 'Stop the agent' }),
reply: chat.getByText('Stub reply.')
}
}
test.describe('native chat composer focus', () => {
test.skip(!SUPPORTED, 'the Claude stub wrapper is POSIX-only')
@@ -53,7 +17,7 @@ test.describe('native chat composer focus', () => {
claudeStub
}, testInfo) => {
await page.setViewportSize({ width: 1280, height: 900 })
const { composer, send, reply } = await openChat(page, claudeStub.command)
const { composer, send, reply } = await openStubChat(page, claudeStub.command)
await page.keyboard.type('hello')
await send.click()
await expect(reply).toBeVisible({ timeout: 30_000 })
@@ -101,7 +65,7 @@ test.describe('native chat composer focus', () => {
orcaPage: page,
claudeStub
}) => {
const { tab, composer } = await openChat(page, claudeStub.command)
const { tab, composer } = await openStubChat(page, claudeStub.command)
await tab.dblclick()
const rename = tab.locator('[data-tab-rename-input="true"]')
await expect(rename).toBeFocused()
@@ -116,7 +80,7 @@ test.describe('native chat composer focus', () => {
})
test('when Send disables at the end of the turn', async ({ orcaPage: page, claudeStub }) => {
const { composer, send, stop, reply } = await openChat(page, claudeStub.command)
const { composer, send, stop, reply } = await openStubChat(page, claudeStub.command)
await page.keyboard.type('hello')
await send.click()
await expect(reply).toBeVisible({ timeout: 30_000 })
@@ -125,7 +89,7 @@ test.describe('native chat composer focus', () => {
})
test('when Stop disables while the stop is in flight', async ({ orcaPage: page, claudeStub }) => {
const { composer, send, stop } = await openChat(page, claudeStub.command)
const { composer, send, stop } = await openStubChat(page, claudeStub.command)
// The stub runs a prompt containing "slow" until it is interrupted.
await page.keyboard.type('slow')
await send.click()