From e92552a4135c4dfe80a2fbded76ad01cde72f69d Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 5 Nov 2026 22:34:16 -0800 Subject: [PATCH] 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 --- mobile/src/i18n/en-runtime-required.json | 3 + mobile/src/i18n/locales/en.json | 1 + mobile/src/i18n/locales/fr.json | 1 + .../src/session/agent-session-failure-copy.ts | 7 + .../transcript/MobileNativeChatNoticeRow.tsx | 2 +- .../__fixtures__/claude-plugin-recovery.json | 294 ++++++++++++ src/main/claude/claude-api-retry-row.test.ts | 12 + .../claude/claude-internal-message-content.ts | 70 +++ src/main/claude/claude-message-journaling.ts | 24 +- .../claude-plugin-input-failure-journal.ts | 69 +++ .../claude/claude-plugin-ui-frames.test.ts | 187 ++++++++ src/main/claude/claude-plugin-ui.ts | 37 ++ .../claude/claude-replay-turn-resolution.ts | 29 ++ .../claude/claude-transcript-import-plan.ts | 73 ++- ...claude-transcript-internal-content.test.ts | 421 ++++++++++++++++++ .../claude/claude-transcript-item-reader.ts | 17 + src/main/claude/claude-transcript-prompt.ts | 42 ++ .../claude-structured-event-delivery.ts | 35 +- .../claude-structured-plugin-ui-recovery.ts | 54 +++ .../claude-structured-session-acquisition.ts | 158 ++++--- .../claude-structured-session-adapter.ts | 30 +- .../claude-structured-session-close.ts | 23 - .../claude-structured-session-event.ts | 59 +++ ...laude-structured-session-exit-lifecycle.ts | 118 ++++- ...laude-structured-session-plugin-ui.test.ts | 155 +++++++ .../claude-structured-session-publication.ts | 34 ++ .../claude-structured-session-state.ts | 59 +-- .../claude-structured-item-translation.ts | 5 +- .../claude-structured-journal-translation.ts | 51 +-- .../claude-structured-provider-fallback.ts | 4 +- .../provider-frame-disposition.test.ts | 13 +- .../provider-frame-disposition.ts | 12 +- .../structured-agent-session-adapter.ts | 12 + .../structured-agent-session-child-exit.ts | 10 +- ...-agent-session-claude-plugin-input.test.ts | 328 ++++++++++++++ ...structured-agent-session-event-recovery.ts | 25 ++ ...tured-agent-session-queued-failure.test.ts | 46 ++ .../transcript-line-decoders-claude.ts | 13 +- .../structured-claude-runtime-adapter.ts | 2 +- .../NativeChatNoticeRow.plugin-input.test.tsx | 47 ++ .../native-chat/NativeChatNoticeRow.tsx | 6 +- .../agent-session-failure-words-text.ts | 6 + .../src/i18n/en-runtime-required.json | 1 + src/renderer/src/i18n/locales/en.json | 1 + src/renderer/src/i18n/locales/fr.json | 1 + .../failures/agent-session-failure-copy.ts | 3 + .../agent-session-failure-quoted-detail.ts | 15 +- .../agent-session-failure-words.test.ts | 15 + .../failures/agent-session-failure-words.ts | 5 + .../failures/agent-session-failure.ts | 14 +- ...ctured-agent-session-dispatch-rejection.ts | 2 + tests/e2e/fixtures/structured-claude-stub.cjs | 36 +- tests/e2e/helpers/structured-claude-stub.ts | 37 ++ .../e2e/native-chat-plugin-ui-frames.spec.ts | 48 ++ tests/e2e/native-chat-send-focus.spec.ts | 48 +- 55 files changed, 2475 insertions(+), 345 deletions(-) create mode 100644 src/main/claude/__fixtures__/claude-plugin-recovery.json create mode 100644 src/main/claude/claude-internal-message-content.ts create mode 100644 src/main/claude/claude-plugin-input-failure-journal.ts create mode 100644 src/main/claude/claude-plugin-ui-frames.test.ts create mode 100644 src/main/claude/claude-plugin-ui.ts create mode 100644 src/main/claude/claude-transcript-internal-content.test.ts create mode 100644 src/main/claude/claude-transcript-prompt.ts create mode 100644 src/main/claude/structured/claude-structured-plugin-ui-recovery.ts create mode 100644 src/main/claude/structured/claude-structured-session-event.ts create mode 100644 src/main/claude/structured/claude-structured-session-plugin-ui.test.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-claude-plugin-input.test.ts create mode 100644 src/renderer/src/components/native-chat/NativeChatNoticeRow.plugin-input.test.tsx create mode 100644 tests/e2e/native-chat-plugin-ui-frames.spec.ts diff --git a/mobile/src/i18n/en-runtime-required.json b/mobile/src/i18n/en-runtime-required.json index e2d21aa3e7e..288d4d5a6b9 100644 --- a/mobile/src/i18n/en-runtime-required.json +++ b/mobile/src/i18n/en-runtime-required.json @@ -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" diff --git a/mobile/src/i18n/locales/en.json b/mobile/src/i18n/locales/en.json index 596dff4dd9a..59ec1020053 100644 --- a/mobile/src/i18n/locales/en.json +++ b/mobile/src/i18n/locales/en.json @@ -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.", diff --git a/mobile/src/i18n/locales/fr.json b/mobile/src/i18n/locales/fr.json index 9f7522c236c..e09d9ccb143 100644 --- a/mobile/src/i18n/locales/fr.json +++ b/mobile/src/i18n/locales/fr.json @@ -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}}.", diff --git a/mobile/src/session/agent-session-failure-copy.ts b/mobile/src/session/agent-session-failure-copy.ts index d1fafb3628a..22b0af20e34 100644 --- a/mobile/src/session/agent-session-failure-copy.ts +++ b/mobile/src/session/agent-session-failure-copy.ts @@ -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 string> = { ...START_FAILURE_COPY, + pluginInputRequired: (values) => + translate( + 'session.failure.pluginInputRequired', + AGENT_SESSION_FAILURE_COPY.pluginInputRequired, + values + ), ...SEND_FAILURE_COPY, backgroundTasksRunning: (values) => translate( diff --git a/mobile/src/session/native-chat/transcript/MobileNativeChatNoticeRow.tsx b/mobile/src/session/native-chat/transcript/MobileNativeChatNoticeRow.tsx index 43fdb4b1b48..eab1cad9c12 100644 --- a/mobile/src/session/native-chat/transcript/MobileNativeChatNoticeRow.tsx +++ b/mobile/src/session/native-chat/transcript/MobileNativeChatNoticeRow.tsx @@ -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) ) diff --git a/src/main/claude/__fixtures__/claude-plugin-recovery.json b/src/main/claude/__fixtures__/claude-plugin-recovery.json new file mode 100644 index 00000000000..66eda99b464 --- /dev/null +++ b/src/main/claude/__fixtures__/claude-plugin-recovery.json @@ -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": "\nbobbnxies\ntoolu_01XkAUQnwQm75zCmVi4ogwpJ\n/private/tmp/claude-501/-Users-m4air-orca-qa-claude-chat-render-1103-project/81552947-5912-49d0-88c7-4d70d448e93b/tasks/bobbnxies.output\ncompleted\nBackground command \"Wait 30 seconds in the background\" completed (exit code 0)\n" + }, + "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": "", + "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": "", + "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 + } + } +] diff --git a/src/main/claude/claude-api-retry-row.test.ts b/src/main/claude/claude-api-retry-row.test.ts index 0248672593e..7df5d20d44a 100644 --- a/src/main/claude/claude-api-retry-row.test.ts +++ b/src/main/claude/claude-api-retry-row.test.ts @@ -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([]) + }) }) diff --git a/src/main/claude/claude-internal-message-content.ts b/src/main/claude/claude-internal-message-content.ts new file mode 100644 index 00000000000..45377a2bf1a --- /dev/null +++ b/src/main/claude/claude-internal-message-content.ts @@ -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): 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 +): Record | null { + const message = asRecord(frame.message) + const usage = asRecord(message?.usage) + const content = message?.content + if ( + frame.type !== 'assistant' || + message?.role !== 'assistant' || + message.model !== '' || + 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 +} diff --git a/src/main/claude/claude-message-journaling.ts b/src/main/claude/claude-message-journaling.ts index 46d2de361e3..71c2a807ede 100644 --- a/src/main/claude/claude-message-journaling.ts +++ b/src/main/claude/claude-message-journaling.ts @@ -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, + 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, diff --git a/src/main/claude/claude-plugin-input-failure-journal.ts b/src/main/claude/claude-plugin-input-failure-journal.ts new file mode 100644 index 00000000000..c0d91ab7706 --- /dev/null +++ b/src/main/claude/claude-plugin-input-failure-journal.ts @@ -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 + 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() +} diff --git a/src/main/claude/claude-plugin-ui-frames.test.ts b/src/main/claude/claude-plugin-ui-frames.test.ts new file mode 100644 index 00000000000..3973f0ffda8 --- /dev/null +++ b/src/main/claude/claude-plugin-ui-frames.test.ts @@ -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) { + 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) => + 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)) + } + ) +}) diff --git a/src/main/claude/claude-plugin-ui.ts b/src/main/claude/claude-plugin-ui.ts new file mode 100644 index 00000000000..bbe4bd73474 --- /dev/null +++ b/src/main/claude/claude-plugin-ui.ts @@ -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 | 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 + } +} diff --git a/src/main/claude/claude-replay-turn-resolution.ts b/src/main/claude/claude-replay-turn-resolution.ts index 613963bc89d..50cb95df847 100644 --- a/src/main/claude/claude-replay-turn-resolution.ts +++ b/src/main/claude/claude-replay-turn-resolution.ts @@ -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 + } + } + } +} diff --git a/src/main/claude/claude-transcript-import-plan.ts b/src/main/claude/claude-transcript-import-plan.ts index 771a235a70c..12be16e8a97 100644 --- a/src/main/claude/claude-transcript-import-plan.ts +++ b/src/main/claude/claude-transcript-import-plan.ts @@ -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) => { 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( diff --git a/src/main/claude/claude-transcript-internal-content.test.ts b/src/main/claude/claude-transcript-internal-content.test.ts new file mode 100644 index 00000000000..10618e845ba --- /dev/null +++ b/src/main/claude/claude-transcript-internal-content.test.ts @@ -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[] = capture.map((row, index) => ({ + ...row, + sessionId: SID, + ...(index === 0 ? { parentUuid: 'a1' } : {}) +})) +const frame = (row: Record) => ({ + ...row, + session_id: SID, + parent_tool_use_id: null +}) +const synthetic = rows[3]! +const task = rows[0]! +const message = (row: Record) => asRecord(row.message)! +const contentParts = (row: Record) => { + 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, 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', 'internal', 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, 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) => + 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 = { + ...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' }) + } +) diff --git a/src/main/claude/claude-transcript-item-reader.ts b/src/main/claude/claude-transcript-item-reader.ts index 1073116084e..9d53b4375d0 100644 --- a/src/main/claude/claude-transcript-item-reader.ts +++ b/src/main/claude/claude-transcript-item-reader.ts @@ -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 +} diff --git a/src/main/claude/claude-transcript-prompt.ts b/src/main/claude/claude-transcript-prompt.ts new file mode 100644 index 00000000000..51cc4712797 --- /dev/null +++ b/src/main/claude/claude-transcript-prompt.ts @@ -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, + 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)) + } +} diff --git a/src/main/claude/structured/claude-structured-event-delivery.ts b/src/main/claude/structured/claude-structured-event-delivery.ts index 5e8c92719d9..bf79ece6d23 100644 --- a/src/main/claude/structured/claude-structured-event-delivery.ts +++ b/src/main/claude/structured/claude-structured-event-delivery.ts @@ -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, + exits: ReadonlyMap, + 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() + } +} diff --git a/src/main/claude/structured/claude-structured-plugin-ui-recovery.ts b/src/main/claude/structured/claude-structured-plugin-ui-recovery.ts new file mode 100644 index 00000000000..c57e3ef9d81 --- /dev/null +++ b/src/main/claude/structured/claude-structured-plugin-ui-recovery.ts @@ -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 + }, + current: () => ClaudeSession | null +): (message: Record) => 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) + ) +} diff --git a/src/main/claude/structured/claude-structured-session-acquisition.ts b/src/main/claude/structured/claude-structured-session-acquisition.ts index 0064e20e909..40f97b3ac11 100644 --- a/src/main/claude/structured/claude-structured-session-acquisition.ts +++ b/src/main/claude/structured/claude-structured-session-acquisition.ts @@ -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): 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): 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[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?.() diff --git a/src/main/claude/structured/claude-structured-session-adapter.ts b/src/main/claude/structured/claude-structured-session-adapter.ts index dd9db92aa34..ca4787b1193 100644 --- a/src/main/claude/structured/claude-structured-session-adapter.ts +++ b/src/main/claude/structured/claude-structured-session-adapter.ts @@ -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 => drainClaudeObservedExits(this.exits) diff --git a/src/main/claude/structured/claude-structured-session-close.ts b/src/main/claude/structured/claude-structured-session-close.ts index 6c1559142c2..05ceb106f24 100644 --- a/src/main/claude/structured/claude-structured-session-close.ts +++ b/src/main/claude/structured/claude-structured-session-close.ts @@ -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 - sessionId: string - connection: ClaudeStreamJsonConnection | null - deps: Parameters[2] - afterClose: (close: () => Promise) => Promise -}): 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 diff --git a/src/main/claude/structured/claude-structured-session-event.ts b/src/main/claude/structured/claude-structured-session-event.ts new file mode 100644 index 00000000000..b228f804939 --- /dev/null +++ b/src/main/claude/structured/claude-structured-session-event.ts @@ -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 + /** 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 + } diff --git a/src/main/claude/structured/claude-structured-session-exit-lifecycle.ts b/src/main/claude/structured/claude-structured-session-exit-lifecycle.ts index 77238390ba2..9309a4a15e6 100644 --- a/src/main/claude/structured/claude-structured-session-exit-lifecycle.ts +++ b/src/main/claude/structured/claude-structured-session-exit-lifecycle.ts @@ -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 + exits: Map + closeExit: (sessionId: string) => Promise + sessionId: string + connection: ClaudeStreamJsonConnection | null + deps: Parameters[2] + afterClose: (close: () => Promise) => Promise +}): 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 + }) + ) +} diff --git a/src/main/claude/structured/claude-structured-session-plugin-ui.test.ts b/src/main/claude/structured/claude-structured-session-plugin-ui.test.ts new file mode 100644 index 00000000000..a91ed37e5a7 --- /dev/null +++ b/src/main/claude/structured/claude-structured-session-plugin-ui.test.ts @@ -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() + 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() + }) +}) diff --git a/src/main/claude/structured/claude-structured-session-publication.ts b/src/main/claude/structured/claude-structured-session-publication.ts index c33e20d5d38..90a9a9784a7 100644 --- a/src/main/claude/structured/claude-structured-session-publication.ts +++ b/src/main/claude/structured/claude-structured-session-publication.ts @@ -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 + exits: ReadonlyMap + recordPhase: StructuredAgentSessionAcquireInput['recordPhase'] +}): Promise { + 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') + ) + } +} diff --git a/src/main/claude/structured/claude-structured-session-state.ts b/src/main/claude/structured/claude-structured-session-state.ts index c1cca9b588b..49ce0f938ad 100644 --- a/src/main/claude/structured/claude-structured-session-state.ts +++ b/src/main/claude/structured/claude-structured-session-state.ts @@ -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 - /** 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 } diff --git a/src/main/claude/structured/journal/claude-structured-item-translation.ts b/src/main/claude/structured/journal/claude-structured-item-translation.ts index f3017e68105..483c1bbeb28 100644 --- a/src/main/claude/structured/journal/claude-structured-item-translation.ts +++ b/src/main/claude/structured/journal/claude-structured-item-translation.ts @@ -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, diff --git a/src/main/claude/structured/journal/claude-structured-journal-translation.ts b/src/main/claude/structured/journal/claude-structured-journal-translation.ts index f0cd0a145de..5fa4f06cf27 100644 --- a/src/main/claude/structured/journal/claude-structured-journal-translation.ts +++ b/src/main/claude/structured/journal/claude-structured-journal-translation.ts @@ -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() - // 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, 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 diff --git a/src/main/claude/structured/launch/claude-structured-provider-fallback.ts b/src/main/claude/structured/launch/claude-structured-provider-fallback.ts index 5c8c2127ada..ace62918bbd 100644 --- a/src/main/claude/structured/launch/claude-structured-provider-fallback.ts +++ b/src/main/claude/structured/launch/claude-structured-provider-fallback.ts @@ -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 = new Set([ /** No row to write, so buffered output need not flush ahead of this frame. */ export function isClaudeProgressFrame(message: Record): 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 = new Set( diff --git a/src/main/native-chat/agent-session-wire/provider-frame-disposition.test.ts b/src/main/native-chat/agent-session-wire/provider-frame-disposition.test.ts index 8595cc03c84..da78dfe50f6 100644 --- a/src/main/native-chat/agent-session-wire/provider-frame-disposition.test.ts +++ b/src/main/native-chat/agent-session-wire/provider-frame-disposition.test.ts @@ -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() }) }) diff --git a/src/main/native-chat/agent-session-wire/provider-frame-disposition.ts b/src/main/native-chat/agent-session-wire/provider-frame-disposition.ts index 8a1a421a662..f45bfd4b990 100644 --- a/src/main/native-chat/agent-session-wire/provider-frame-disposition.ts +++ b/src/main/native-chat/agent-session-wire/provider-frame-disposition.ts @@ -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 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 diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts index d4b57567de0..887f0f6992b 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts @@ -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' diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-child-exit.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-child-exit.ts index 612300a8bca..e89da28766e 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-child-exit.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-child-exit.ts @@ -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', diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-plugin-input.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-plugin-input.test.ts new file mode 100644 index 00000000000..c678b5367e5 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-plugin-input.test.ts @@ -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 +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, 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() + 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') +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-event-recovery.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-event-recovery.ts index 62602a8e272..47dcabffda6 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-event-recovery.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-event-recovery.ts @@ -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 { + 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) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-failure.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-failure.test.ts index 7824204f5f1..628cc655292 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-failure.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-failure.test.ts @@ -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') diff --git a/src/main/native-chat/transcript-line-decoders-claude.ts b/src/main/native-chat/transcript-line-decoders-claude.ts index 8dc8ad2c9b9..00290c78a84 100644 --- a/src/main/native-chat/transcript-line-decoders-claude.ts +++ b/src/main/native-chat/transcript-line-decoders-claude.ts @@ -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 } diff --git a/src/main/runtime/agent-session/installation/structured-claude-runtime-adapter.ts b/src/main/runtime/agent-session/installation/structured-claude-runtime-adapter.ts index ac10edecaf5..7e159afc797 100644 --- a/src/main/runtime/agent-session/installation/structured-claude-runtime-adapter.ts +++ b/src/main/runtime/agent-session/installation/structured-claude-runtime-adapter.ts @@ -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. diff --git a/src/renderer/src/components/native-chat/NativeChatNoticeRow.plugin-input.test.tsx b/src/renderer/src/components/native-chat/NativeChatNoticeRow.plugin-input.test.tsx new file mode 100644 index 00000000000..3dfc098dc15 --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatNoticeRow.plugin-input.test.tsx @@ -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, text: string) { + render( + + + + ) +} + +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() +}) diff --git a/src/renderer/src/components/native-chat/NativeChatNoticeRow.tsx b/src/renderer/src/components/native-chat/NativeChatNoticeRow.tsx index caa7ab2e114..d7008e4fbf5 100644 --- a/src/renderer/src/components/native-chat/NativeChatNoticeRow.tsx +++ b/src/renderer/src/components/native-chat/NativeChatNoticeRow.tsx @@ -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 }) ) diff --git a/src/renderer/src/components/native-chat/agent-session-failure-words-text.ts b/src/renderer/src/components/native-chat/agent-session-failure-words-text.ts index 3c37c5b1ed1..fd349069bed 100644 --- a/src/renderer/src/components/native-chat/agent-session-failure-words-text.ts +++ b/src/renderer/src/components/native-chat/agent-session-failure-words-text.ts @@ -19,6 +19,12 @@ import { START_FAILURE_PIECES } from './agent-session-start-failure-words-text' const PIECES: Record 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( diff --git a/src/renderer/src/i18n/en-runtime-required.json b/src/renderer/src/i18n/en-runtime-required.json index 48d16d78c6d..8c78f4491b1 100644 --- a/src/renderer/src/i18n/en-runtime-required.json +++ b/src/renderer/src/i18n/en-runtime-required.json @@ -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.", diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index bff4b976ff7..25081035b0a 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -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.", diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index 81ec2ae603f..adffd5c27b4 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -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é.", diff --git a/src/shared/agent-session/failures/agent-session-failure-copy.ts b/src/shared/agent-session/failures/agent-session-failure-copy.ts index e19864d4a78..a1a8f1d6d78 100644 --- a/src/shared/agent-session/failures/agent-session-failure-copy.ts +++ b/src/shared/agent-session/failures/agent-session-failure-copy.ts @@ -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 diff --git a/src/shared/agent-session/failures/agent-session-failure-quoted-detail.ts b/src/shared/agent-session/failures/agent-session-failure-quoted-detail.ts index aacc86a4399..6497a7a2a1c 100644 --- a/src/shared/agent-session/failures/agent-session-failure-quoted-detail.ts +++ b/src/shared/agent-session/failures/agent-session-failure-quoted-detail.ts @@ -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 +} diff --git a/src/shared/agent-session/failures/agent-session-failure-words.test.ts b/src/shared/agent-session/failures/agent-session-failure-words.test.ts index 60fdc46d786..82ca662c076 100644 --- a/src/shared/agent-session/failures/agent-session-failure-words.test.ts +++ b/src/shared/agent-session/failures/agent-session-failure-words.test.ts @@ -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' + }) +}) diff --git a/src/shared/agent-session/failures/agent-session-failure-words.ts b/src/shared/agent-session/failures/agent-session-failure-words.ts index daa662f57e2..960209f50d0 100644 --- a/src/shared/agent-session/failures/agent-session-failure-words.ts +++ b/src/shared/agent-session/failures/agent-session-failure-words.ts @@ -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) diff --git a/src/shared/agent-session/failures/agent-session-failure.ts b/src/shared/agent-session/failures/agent-session-failure.ts index 1bada92f442..3cdce390fb8 100644 --- a/src/shared/agent-session/failures/agent-session-failure.ts +++ b/src/shared/agent-session/failures/agent-session-failure.ts @@ -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( 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' diff --git a/src/shared/agent-session/requests/structured-agent-session-dispatch-rejection.ts b/src/shared/agent-session/requests/structured-agent-session-dispatch-rejection.ts index e50926fcf34..cb4c4b58589 100644 --- a/src/shared/agent-session/requests/structured-agent-session-dispatch-rejection.ts +++ b/src/shared/agent-session/requests/structured-agent-session-dispatch-rejection.ts @@ -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', diff --git a/tests/e2e/fixtures/structured-claude-stub.cjs b/tests/e2e/fixtures/structured-claude-stub.cjs index 7aeb0f36b47..a175689d457 100644 --- a/tests/e2e/fixtures/structured-claude-stub.cjs +++ b/tests/e2e/fixtures/structured-claude-stub.cjs @@ -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(), diff --git a/tests/e2e/helpers/structured-claude-stub.ts b/tests/e2e/helpers/structured-claude-stub.ts index 88af1c10dee..0aaa6669c95 100644 --- a/tests/e2e/helpers/structured-claude-stub.ts +++ b/tests/e2e/helpers/structured-claude-stub.ts @@ -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.') + } +} diff --git a/tests/e2e/native-chat-plugin-ui-frames.spec.ts b/tests/e2e/native-chat-plugin-ui-frames.spec.ts new file mode 100644 index 00000000000..71e68b180df --- /dev/null +++ b/tests/e2e/native-chat-plugin-ui-frames.spec.ts @@ -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) + }) +}) diff --git a/tests/e2e/native-chat-send-focus.spec.ts b/tests/e2e/native-chat-send-focus.spec.ts index 07332a49e4d..ae1acc388e6 100644 --- a/tests/e2e/native-chat-send-focus.spec.ts +++ b/tests/e2e/native-chat-send-focus.spec.ts @@ -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 { 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()