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