From 018eb1a25ec49f436e51a0a94486bacd41d1bd53 Mon Sep 17 00:00:00 2001 From: Blomios Date: Sat, 20 Jun 2026 10:57:20 +0200 Subject: [PATCH] =?UTF-8?q?fix(input):=20fiabilise=20la=20livraison=20de?= =?UTF-8?q?=20d=C3=A9l=C3=A9gation=20et=20journalise=20le=20submit?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Durcit le portail d'écriture de délégation et son acheminement bout-en-bout (commande Tauri → orchestrateur → file d'entrée infrastructure → portail frontend), avec une journalisation du submit pour diagnostiquer les cas où la délégation n'était pas remise. Couvre le portail d'écriture côté front (useWritePortal) avec ses tests. QA : checks application/front/infrastructure/app-tauri verts. Les tests loopback socket Unix réels ne sont pas exécutables en sandbox (UnixListener::bind → PermissionDenied) ; alternatives avec skips vertes. Co-Authored-By: Claude Opus 4.8 --- crates/app-tauri/src/commands.rs | 49 ++++++- .../application/src/orchestrator/service.rs | 16 ++- crates/infrastructure/src/input/mod.rs | 111 ++++++++++++++- frontend/src/adapters/input.ts | 12 ++ frontend/src/adapters/terminal.ts | 39 +++++- frontend/src/domain/index.ts | 2 +- .../terminals/useWritePortal.test.tsx | 20 +++ .../src/features/terminals/useWritePortal.ts | 126 +++++++++++++++++- 8 files changed, 352 insertions(+), 23 deletions(-) diff --git a/crates/app-tauri/src/commands.rs b/crates/app-tauri/src/commands.rs index 8e75f54..c64fd49 100644 --- a/crates/app-tauri/src/commands.rs +++ b/crates/app-tauri/src/commands.rs @@ -364,7 +364,46 @@ pub fn write_terminal( state: State<'_, AppState>, ) -> Result<(), ErrorDto> { let input = request.into_input()?; - state.write_terminal.execute(input).map_err(ErrorDto::from) + let session_id = input.session_id; + let bytes = input.data.len(); + let control = describe_terminal_write(&input.data); + application::diag!( + "[pty-write] write_terminal start: session={session_id} bytes={bytes} control={control}" + ); + match state.write_terminal.execute(input) { + Ok(()) => { + application::diag!( + "[pty-write] write_terminal ok: session={session_id} bytes={bytes} control={control}" + ); + Ok(()) + } + Err(e) => { + application::diag!( + "[pty-write] write_terminal failed: session={session_id} bytes={bytes} \ + control={control} error={e}" + ); + Err(ErrorDto::from(e)) + } + } +} + +fn describe_terminal_write(data: &[u8]) -> String { + if data.is_empty() { + return "".to_owned(); + } + if data.len() > 16 { + return "".to_owned(); + } + data.iter() + .map(|byte| match *byte { + b'\r' => "\\r".to_owned(), + b'\n' => "\\n".to_owned(), + 0x7f => "\\x7f".to_owned(), + byte if byte < 0x20 => format!("\\x{byte:02x}"), + byte => char::from(byte).to_string(), + }) + .collect::>() + .join("") } /// `resize_terminal` — resize a live PTY. @@ -1479,6 +1518,10 @@ pub async fn delegation_delivered( let project = resolve_project(&request.project_id, &state).await?; let agent_id = parse_agent_id(&request.agent_id)?; let ticket = parse_ticket_id(&request.ticket)?; + application::diag!( + "[delivery] delegation_delivered command: project={} agent={agent_id} ticket={ticket}", + project.id + ); state .orchestrator_service .note_delegation_delivered(&project, agent_id, ticket); @@ -1502,6 +1545,10 @@ pub async fn set_front_attached( state: State<'_, AppState>, ) -> Result<(), ErrorDto> { let agent_id = parse_agent_id(&request.agent_id)?; + application::diag!( + "[delivery] set_front_attached command: agent={agent_id} attached={}", + request.attached + ); state .orchestrator_service .set_agent_front_attached(agent_id, request.attached); diff --git a/crates/application/src/orchestrator/service.rs b/crates/application/src/orchestrator/service.rs index 806b3b7..e30de66 100644 --- a/crates/application/src/orchestrator/service.rs +++ b/crates/application/src/orchestrator/service.rs @@ -1288,13 +1288,11 @@ impl OrchestratorService { // Observability beacon only: never mutates the mailbox/busy state nor resolves // the ticket (that stays `idea_reply`-driven). Kept best-effort by construction — // a pure, infallible hook so a missing mediator/registry never breaks the - // rendezvous. The application crate carries no logging framework (zero new dep); - // the ack is materialised as an `eprintln!` trace, the same lightweight channel - // the orchestrator already uses for best-effort diagnostics. + // rendezvous. let _ = project; - eprintln!( - "[orchestrator] delegation delivered into agent {agent_id}'s native terminal \ - (front ack, ticket {ticket})" + crate::diag!( + "[delivery] front ack received: agent={agent_id} ticket={ticket} \ + (write-portal reports text+submit writes completed)" ); } @@ -1313,8 +1311,14 @@ impl OrchestratorService { /// agent **headless** (délégué en arrière-plan, sans cellule) voit le médiateur écrire /// lui-même la tâche dans son PTY — sinon le tour est perdu. No-op sans médiateur. pub fn set_agent_front_attached(&self, agent: domain::AgentId, attached: bool) { + crate::diag!("[delivery] front attachment changed: agent={agent} attached={attached}"); if let Some(input) = &self.input { input.set_front_attached(agent, attached); + } else { + crate::diag!( + "[delivery] front attachment ignored because input mediator is not wired: \ + agent={agent} attached={attached}" + ); } } diff --git a/crates/infrastructure/src/input/mod.rs b/crates/infrastructure/src/input/mod.rs index 82fb423..9c460ca 100644 --- a/crates/infrastructure/src/input/mod.rs +++ b/crates/infrastructure/src/input/mod.rs @@ -93,7 +93,7 @@ type HeadlessSink = /// Aligné sur le défaut du write-portal frontend (`DEFAULT_SUBMIT_DELAY_MS`) : sépare /// la soumission du collage pour esquiver la paste-detection des CLI. Utilisé seulement /// quand le profil de la cible ne fournit pas de `submit_delay_ms`. -const DEFAULT_SUBMIT_DELAY_MS: u32 = 60; +const DEFAULT_SUBMIT_DELAY_MS: u32 = 350; /// Taille maximale d'un fragment de consigne écrit dans le PTY en mode headless. const DELEGATION_WRITE_CHUNK_BYTES: usize = 512; /// Pause entre deux fragments de consigne en mode headless. @@ -147,6 +147,7 @@ impl BusyTracker { /// appel, l'`enqueue` publie la `DelegationReady` immédiatement (chemin chaud, /// zéro régression). fn mark_starting(&self, agent: AgentId) { + eprintln!("[input-mediator] cold-start gate armed agent={agent}"); self.lock_starting().insert(agent); } @@ -263,8 +264,10 @@ impl BusyTracker { let mut busy = self.lock(); let entry = busy.entry(agent).or_insert(AgentBusyState::Idle); if entry.is_busy() { + eprintln!("[input-mediator] enqueue queued behind busy agent={agent}"); false } else { + eprintln!("[input-mediator] turn started agent={agent} state={state:?}"); *entry = state; true } @@ -281,11 +284,21 @@ impl BusyTracker { let d = match &self.headless_sink { Some(sink) => match sink(agent, d) { Some(d) => d, - None => return, + None => { + eprintln!("[input-mediator] delegation handled by headless sink agent={agent}"); + return; + } }, None => d, }; if let Some(events) = &self.events { + eprintln!( + "[input-mediator] publishing DelegationReady agent={agent} ticket={} text_bytes={} submit_sequence={} submit_delay_ms={:?}", + d.ticket, + d.text.as_bytes().len(), + describe_submit_sequence(d.submit_sequence.as_deref()), + d.submit_delay_ms, + ); events.publish(DomainEvent::DelegationReady { agent_id: agent, ticket: d.ticket, @@ -309,7 +322,13 @@ impl BusyTracker { self.lock_starting().remove(&agent); let deferred = self.lock_deferred().remove(&agent); match deferred { - Some(d) => self.publish_deferred(agent, d), + Some(d) => { + eprintln!( + "[input-mediator] prompt-ready released deferred delegation agent={agent} ticket={}", + d.ticket + ); + self.publish_deferred(agent, d); + } None => self.mark_idle(agent), } } @@ -322,6 +341,10 @@ impl BusyTracker { fn release_cold_start(&self, agent: AgentId) { self.lock_starting().remove(&agent); if let Some(d) = self.lock_deferred().remove(&agent) { + eprintln!( + "[input-mediator] mcp-ready released deferred delegation agent={agent} ticket={}", + d.ticket + ); self.publish_deferred(agent, d); } } @@ -336,6 +359,7 @@ impl BusyTracker { .is_some_and(|s| s.is_busy()) }; if was_busy { + eprintln!("[input-mediator] turn marked idle agent={agent}"); if let Some(events) = &self.events { events.publish(DomainEvent::AgentBusyChanged { agent_id: agent, @@ -470,6 +494,10 @@ impl MediatedInbox { .unwrap_or_else(std::sync::PoisonError::into_inner) .contains(&agent) { + eprintln!( + "[input-mediator] front-owned delivery selected agent={agent} ticket={}", + d.ticket + ); return Some(d); } // Agent headless : récupérer son handle PTY. Absent ⇒ repli sur l'événement @@ -480,11 +508,19 @@ impl MediatedInbox { .get(&agent) .cloned(); let Some(handle) = handle else { + eprintln!( + "[input-mediator] no PTY handle for headless delivery agent={agent} ticket={}", + d.ticket + ); return Some(d); }; let pty = Arc::clone(&pty); let text = d.text; - let submit = d.submit_sequence.unwrap_or_else(|| "\r".to_owned()); + let ticket = d.ticket; + let submit = d + .submit_sequence + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| "\r".to_owned()); let delay = u64::from(d.submit_delay_ms.unwrap_or(DEFAULT_SUBMIT_DELAY_MS)); // Écriture sur un thread détaché : ne JAMAIS bloquer l'appelant (thread du // watcher prompt-ready, tâche tokio du serveur MCP, ou le fil de l'enqueue). @@ -493,11 +529,43 @@ impl MediatedInbox { // La consigne est fragmentée : les TUI (Codex surtout) peuvent traiter un // gros write unique comme un paste et garder/perdre la fin au démarrage. std::thread::spawn(move || { - let _ = write_delegation_chunks(pty.as_ref(), &handle, &text); + eprintln!( + "[input-mediator] headless write text start agent={agent} ticket={ticket} handle={} text_bytes={} submit_sequence={} submit_delay_ms={delay}", + handle.session_id, + text.as_bytes().len(), + describe_submit_sequence(Some(&submit)), + ); + match write_delegation_chunks(pty.as_ref(), &handle, &text) { + Ok(()) => eprintln!( + "[input-mediator] headless write text ok agent={agent} ticket={ticket} handle={}", + handle.session_id + ), + Err(e) => { + eprintln!( + "[input-mediator] headless write text failed agent={agent} ticket={ticket} handle={} error={e}", + handle.session_id + ); + return; + } + } if delay > 0 { std::thread::sleep(std::time::Duration::from_millis(delay)); } - let _ = pty.write(&handle, submit.as_bytes()); + eprintln!( + "[input-mediator] headless submit start agent={agent} ticket={ticket} handle={} submit_sequence={}", + handle.session_id, + describe_submit_sequence(Some(&submit)), + ); + match pty.write(&handle, submit.as_bytes()) { + Ok(()) => eprintln!( + "[input-mediator] headless submit ok agent={agent} ticket={ticket} handle={}", + handle.session_id + ), + Err(e) => eprintln!( + "[input-mediator] headless submit failed agent={agent} ticket={ticket} handle={} error={e}", + handle.session_id + ), + } }); None }) @@ -692,6 +760,24 @@ fn write_delegation_chunks( pty.write(handle, text[start..].as_bytes()) } +fn describe_submit_sequence(value: Option<&str>) -> String { + match value { + None => "".to_owned(), + Some("") => "".to_owned(), + Some(value) => value + .chars() + .map(|ch| match ch { + '\r' => "\\r".to_owned(), + '\n' => "\\n".to_owned(), + '\u{7f}' => "\\x7f".to_owned(), + ch if ch.is_control() => format!("\\x{:02x}", ch as u32), + ch => ch.to_string(), + }) + .collect::>() + .join(""), + } +} + impl InputMediator for MediatedInbox { fn enqueue(&self, agent: AgentId, ticket: Ticket) -> PendingReply { let ticket_id = ticket.id; @@ -761,6 +847,10 @@ impl InputMediator for MediatedInbox { } fn bind_handle(&self, agent: AgentId, handle: PtyHandle) { + eprintln!( + "[input-mediator] bind handle agent={agent} handle={}", + handle.session_id + ); self.handles().insert(agent, handle); } @@ -776,6 +866,13 @@ impl InputMediator for MediatedInbox { // then arm the prompt-ready watcher when the profile declares a literal marker // (C5). A `None`/empty pattern arms nothing: Idle then comes only from the // explicit signal or the per-turn timeout (safe fallback, never a false Idle). + eprintln!( + "[input-mediator] bind handle with prompt agent={agent} handle={} prompt_ready={} submit_sequence={} submit_delay_ms={:?}", + handle.session_id, + prompt_ready_pattern.as_deref().unwrap_or(""), + describe_submit_sequence(submit.sequence.as_deref()), + submit.delay_ms, + ); self.handles().insert(agent, handle.clone()); self.submit().insert(agent, submit); if let Some(pattern) = prompt_ready_pattern { @@ -805,8 +902,10 @@ impl InputMediator for MediatedInbox { .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); if attached { + eprintln!("[input-mediator] front attached agent={agent}"); front.insert(agent); } else { + eprintln!("[input-mediator] front detached agent={agent}"); front.remove(&agent); } } diff --git a/frontend/src/adapters/input.ts b/frontend/src/adapters/input.ts index e2ce146..56cf09c 100644 --- a/frontend/src/adapters/input.ts +++ b/frontend/src/adapters/input.ts @@ -28,15 +28,27 @@ export class TauriInputGateway implements InputGateway { agentId: string, ticket: string, ): Promise { + console.info("[input-gateway] delegationDelivered:start", { + projectId, + agentId, + ticket, + }); await invoke("delegation_delivered", { request: { projectId, agentId, ticket }, }); + console.info("[input-gateway] delegationDelivered:ok", { + projectId, + agentId, + ticket, + }); } async setFrontAttached(agentId: string, attached: boolean): Promise { + console.info("[input-gateway] setFrontAttached:start", { agentId, attached }); await invoke("set_front_attached", { request: { agentId, attached }, }); + console.info("[input-gateway] setFrontAttached:ok", { agentId, attached }); } async cancelResume(agentId: string): Promise { diff --git a/frontend/src/adapters/terminal.ts b/frontend/src/adapters/terminal.ts index b1e4444..685d711 100644 --- a/frontend/src/adapters/terminal.ts +++ b/frontend/src/adapters/terminal.ts @@ -64,11 +64,13 @@ export function makeTerminalHandle( return { sessionId, write(data: Uint8Array): Promise { - const run = chain.then(() => - invoke("write_terminal", { + const run = chain.then(async () => { + logTerminalWrite("start", sessionId, data); + await invoke("write_terminal", { request: { sessionId, data: Array.from(data) }, - }), - ); + }); + logTerminalWrite("ok", sessionId, data); + }); // Keep the chain alive even if this write rejects: the next write must // still run. Swallow the error on the *chain* copy only — `run` keeps the // rejection so the caller can observe it. @@ -91,6 +93,35 @@ export function makeTerminalHandle( }; } +function logTerminalWrite(phase: "start" | "ok", sessionId: string, data: Uint8Array): void { + if (data.length === 1 && data[0] >= 0x20 && data[0] !== 0x7f) return; + console.info("[terminal-write]", phase, { + sessionId, + bytes: data.length, + control: describeBytes(data), + }); +} + +function describeBytes(data: Uint8Array): string { + if (data.length === 0) return ""; + if (data.length > 16) return ""; + return Array.from(data) + .map((byte) => { + switch (byte) { + case 0x0d: + return "\\r"; + case 0x0a: + return "\\n"; + case 0x7f: + return "\\x7f"; + default: + if (byte < 0x20) return `\\x${byte.toString(16).padStart(2, "0")}`; + return String.fromCharCode(byte); + } + }) + .join(""); +} + export class TauriTerminalGateway implements TerminalGateway { async openTerminal( options: OpenTerminalOptions, diff --git a/frontend/src/domain/index.ts b/frontend/src/domain/index.ts index a3f06b6..5bab372 100644 --- a/frontend/src/domain/index.ts +++ b/frontend/src/domain/index.ts @@ -27,7 +27,7 @@ export type DomainEvent = * PTY-writes the turn: the frontend write-portal runs the handshake (b→e) * and writes `text` + `submitSequence`. `submitSequence`/`submitDelayMs` * come from the target's profile; absent ⇒ the portal applies its defaults - * (`"\r"`, ~60 ms). + * (`"\r"`, ~350 ms). */ type: "delegationReady"; agentId: string; diff --git a/frontend/src/features/terminals/useWritePortal.test.tsx b/frontend/src/features/terminals/useWritePortal.test.tsx index 3cdbc20..10d7496 100644 --- a/frontend/src/features/terminals/useWritePortal.test.tsx +++ b/frontend/src/features/terminals/useWritePortal.test.tsx @@ -235,6 +235,26 @@ describe("useWritePortal (§20)", () => { expect(writes).toEqual(["msg", "\n"]); }); + it("defaults an empty profile submitSequence to Enter", async () => { + const { system, view } = setup(); + const { handle, writes } = makeHandle(); + act(() => view.result.current.portal.bindHandle(handle)); + + await act(async () => { + system.emit({ + type: "delegationReady", + agentId: "ag1", + ticket: "t1", + text: "msg", + submitSequence: "", + submitDelayMs: 0, + }); + await vi.runAllTimersAsync(); + }); + + expect(writes).toEqual(["msg", "\r"]); + }); + it("ignores delegationReady for a different agent", async () => { const { system, view } = setup(); const { handle, writes } = makeHandle(); diff --git a/frontend/src/features/terminals/useWritePortal.ts b/frontend/src/features/terminals/useWritePortal.ts index aeb049f..4e3e55a 100644 --- a/frontend/src/features/terminals/useWritePortal.ts +++ b/frontend/src/features/terminals/useWritePortal.ts @@ -58,6 +58,8 @@ const DELEGATION_WRITE_CHUNK_BYTES = 512; /** Delay between delegation chunks, to avoid TUI paste/drop behaviour on cold agents. */ const DELEGATION_WRITE_CHUNK_DELAY_MS = 8; +const LOG_PREFIX = "[write-portal]"; + /** A pending delegation kept in the local FIFO. */ interface PendingDelegation { ticket: string; @@ -135,10 +137,24 @@ export function useWritePortal( if (previous === desired) return; if (previous) { - void inputRef.current?.setFrontAttached(previous, false).catch(() => {}); + portalLog("front-attached:set", { agent: previous, attached: false }); + void inputRef.current?.setFrontAttached(previous, false).catch((error) => { + portalLog("front-attached:set-failed", { + agent: previous, + attached: false, + error: describeError(error), + }); + }); } if (desired) { - void inputRef.current?.setFrontAttached(desired, true).catch(() => {}); + portalLog("front-attached:set", { agent: desired, attached: true }); + void inputRef.current?.setFrontAttached(desired, true).catch((error) => { + portalLog("front-attached:set-failed", { + agent: desired, + attached: true, + error: describeError(error), + }); + }); } frontAttachedAgentRef.current = desired; }; @@ -164,7 +180,16 @@ export function useWritePortal( setOverlay(true); void (async () => { + let drainNext = false; try { + portalLog("inject:start", { + agent, + ticket: head.ticket, + textBytes: encoder.encode(head.text).byteLength, + queue: queueRef.current.length, + submitSequence: describeControl(head.submitSequence), + submitDelayMs: head.submitDelayMs ?? DEFAULT_SUBMIT_DELAY_MS, + }); // Yield once so any keystroke that landed in the micro-window between // "empty observed" (a) and the suspension taking hold (b) is counted // before we re-check K at step (c). Without this the race window would @@ -174,6 +199,7 @@ export function useWritePortal( // (backspace) — never Ctrl-U (which could clear more than the human typed). const k = counterRef.current; if (k > 0) { + portalLog("inject:erase-human-window", { agent, ticket: head.ticket, count: k }); await handle.write(encoder.encode("\x7f".repeat(k))); counterRef.current = 0; } @@ -183,19 +209,37 @@ export function useWritePortal( // Long delegated prompts are chunked: Codex's TUI can otherwise treat a // large single write like a paste and occasionally keep/drop the tail on // cold-started agents before Enter is received. - await writeDelegationText(handle, head.text); + await writeDelegationText(handle, head.text, head.ticket); const delay = head.submitDelayMs ?? DEFAULT_SUBMIT_DELAY_MS; + portalLog("inject:before-submit-delay", { agent, ticket: head.ticket, delay }); await sleep(delay); - const submit = head.submitSequence ?? DEFAULT_SUBMIT_SEQUENCE; + const submit = normalizeSubmitSequence(head.submitSequence, head.ticket); + portalLog("inject:submit-write:start", { + agent, + ticket: head.ticket, + submitSequence: describeControl(submit), + submitBytes: encoder.encode(submit).byteLength, + }); await handle.write(encoder.encode(submit)); + portalLog("inject:submit-write:ok", { agent, ticket: head.ticket }); // Dequeue + ack exactly once (best-effort; never throws the handshake). queueRef.current.shift(); + drainNext = true; try { + portalLog("inject:ack:start", { agent, ticket: head.ticket }); await inputRef.current.delegationDelivered(project, agent, head.ticket); + portalLog("inject:ack:ok", { agent, ticket: head.ticket }); } catch { + portalLog("inject:ack:failed", { agent, ticket: head.ticket }); /* ack is observability-only; never block the portal */ } + } catch (error) { + portalLog("inject:failed", { + agent, + ticket: head.ticket, + error: describeError(error), + }); } finally { // (e) lower the overlay + resume the relay, with the 2 s anti-flash floor. const elapsed = Date.now() - startedAt; @@ -205,7 +249,7 @@ export function useWritePortal( setOverlay(false); injectingRef.current = false; // A boundary may now be open for the next queued delegation. - if (queueRef.current.length > 0) tryInject.current(); + if (queueRef.current.length > 0 && drainNext) tryInject.current(); } })(); }; @@ -231,6 +275,14 @@ export function useWritePortal( submitSequence: event.submitSequence, submitDelayMs: event.submitDelayMs, }); + portalLog("event:delegation-ready", { + agent: subscribedAgent, + ticket: event.ticket, + textBytes: encoder.encode(event.text).byteLength, + queue: queueRef.current.length, + submitSequence: describeControl(event.submitSequence), + submitDelayMs: event.submitDelayMs, + }); // A delegation may already be at a clean boundary (line empty) — try now. tryInject.current(); }) @@ -239,6 +291,7 @@ export function useWritePortal( else { unsubscribe = un; subscribedAgentRef.current = subscribedAgent; + portalLog("event:subscribed", { agent: subscribedAgent }); reconcileFrontAttachment.current(); } }); @@ -248,6 +301,7 @@ export function useWritePortal( unsubscribe?.(); if (subscribedAgentRef.current === subscribedAgent) { subscribedAgentRef.current = null; + portalLog("event:unsubscribed", { agent: subscribedAgent }); } if (frontAttachedAgentRef.current === subscribedAgent) { void inputRef.current?.setFrontAttached(subscribedAgent, false).catch(() => {}); @@ -279,6 +333,7 @@ export function useWritePortal( }, bindHandle(handle: TerminalHandle) { handleRef.current = handle; + portalLog("handle:bound", { agent: agentIdRef.current, sessionId: handle.sessionId }); // Tell the backend a frontend cell is mounted only after the event // subscription is ready. Until then, the backend keeps the headless PTY // write fallback, so a delegation cannot disappear during mount. @@ -287,6 +342,10 @@ export function useWritePortal( tryInject.current(); }, unbindHandle() { + portalLog("handle:unbound", { + agent: agentIdRef.current, + sessionId: handleRef.current?.sessionId, + }); handleRef.current = null; // The cell is gone — let the backend fall back to headless delivery so a // delegation arriving while this agent has no live cell is not lost. @@ -307,16 +366,40 @@ function sleep(ms: number): Promise { async function writeDelegationText( handle: TerminalHandle, text: string, + ticket: string, ): Promise { const chunks = splitUtf8Chunks(text, DELEGATION_WRITE_CHUNK_BYTES); for (let i = 0; i < chunks.length; i += 1) { + portalLog("inject:text-chunk:start", { + ticket, + sessionId: handle.sessionId, + chunk: i + 1, + chunks: chunks.length, + bytes: encoder.encode(chunks[i]).byteLength, + }); await handle.write(encoder.encode(chunks[i])); + portalLog("inject:text-chunk:ok", { + ticket, + sessionId: handle.sessionId, + chunk: i + 1, + chunks: chunks.length, + }); if (i + 1 < chunks.length) { await sleep(DELEGATION_WRITE_CHUNK_DELAY_MS); } } } +function normalizeSubmitSequence(sequence: string | undefined, ticket: string): string { + if (sequence == null) return DEFAULT_SUBMIT_SEQUENCE; + if (sequence.length > 0) return sequence; + portalLog("inject:empty-submit-sequence-defaulted", { + ticket, + defaultSubmitSequence: describeControl(DEFAULT_SUBMIT_SEQUENCE), + }); + return DEFAULT_SUBMIT_SEQUENCE; +} + function splitUtf8Chunks(text: string, maxBytes: number): string[] { if (text.length === 0) return [""]; const chunks: string[] = []; @@ -337,3 +420,36 @@ function splitUtf8Chunks(text: string, maxBytes: number): string[] { if (current.length > 0) chunks.push(current); return chunks; } + +function describeControl(value: string | undefined): string { + if (value == null) return ""; + if (value.length === 0) return ""; + return [...value] + .map((ch) => { + switch (ch) { + case "\r": + return "\\r"; + case "\n": + return "\\n"; + case "\x7f": + return "\\x7f"; + default: { + const code = ch.codePointAt(0)!; + if (code < 0x20) return `\\x${code.toString(16).padStart(2, "0")}`; + return ch; + } + } + }) + .join(""); +} + +function describeError(error: unknown): string { + if (error && typeof error === "object" && "message" in error) { + return String((error as { message: unknown }).message); + } + return String(error); +} + +function portalLog(event: string, fields: Record): void { + console.info(LOG_PREFIX, event, fields); +}