Skip to content

Commit 09b9060

Browse files
committed
feat: mid-turn interaction Phase 05 — native resume, queue-based delivery tracking, code review fixes
- Native resume for Codex/OpenCode: interrupt + resume with session ID instead of lossy continuation prompt reconstruction. Agent loads full conversation history from its own session files. - Queue-based interjection delivery: native stdin interjections (Claude Code, Factory Droid) go to executionQueue first, move to chat history only on confirmed delivery (writeInterjection success) or failure. - Resume prompt wrapper: tells agent to continue interrupted task while incorporating user's interjection (buildResumePrompt). - Continuation prompt fallback preserved for agents without resume support. - displayText on QueuedItem: queue UI shows raw user message, not internal prompt wrappers (continuation prompt or resume prompt). - pendingInterjection flag: prevents onExit handler from re-spawning interjections that were already sent to stdin. - Orphaned interjection handling: process exit moves pending interjections to logs as failed delivery. - Code review fixes: input validation on writeInterjection IPC, immutable queue operations, extracted captureCurrentTurnOutput helper, deduplicated markInterjectionFailed, removed dead updateInterjectionDeliveryState.
1 parent c685369 commit 09b9060

19 files changed

Lines changed: 1095 additions & 94 deletions

File tree

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
# Codex Mid-Turn Interaction: Native Resume Approach
2+
3+
## Problem
4+
5+
Codex currently uses the **interrupt-and-continue fallback** for mid-turn interaction:
6+
7+
1. SIGINT kills the process
8+
2. New process spawned with a hand-crafted continuation prompt containing partial output
9+
3. Agent loses all conversation context except what Maestro manually captures and stuffs into the prompt
10+
11+
This is fragile, lossy, and architecturally different from Claude Code's native stdin-based mid-turn input.
12+
13+
## Research Findings (Codex CLI Reference)
14+
15+
- `codex exec` is explicitly **non-interactive** — designed for "scripted or CI-style runs that should finish without human interaction"
16+
- stdin with `-` only accepts the **initial prompt**, not continuous streaming input
17+
- No documented stdin streaming, IPC, socket, or signal-based mid-turn message injection
18+
- `--json` emits JSONL events **out** but there's no way to send events **back in**
19+
- **Conclusion: True mid-turn stdin injection (like Claude Code's `stream-json`) is impossible with Codex**
20+
21+
## Proposed Approach: Interrupt + Native Resume
22+
23+
Codex supports `codex exec resume <SESSION_ID> "follow-up prompt"`. Instead of reconstructing context manually, leverage Codex's own session persistence.
24+
25+
### Current Flow (Fallback)
26+
27+
```
28+
User interjects while Codex is working
29+
→ SIGINT sent to process
30+
→ Process exits
31+
→ Maestro collects partial stdout captured so far
32+
→ Maestro builds continuation prompt:
33+
buildContinuationPrompt(partialOutput, userMessage)
34+
wraps partial output in <partial_output> tags
35+
→ Spawn fresh: codex exec -- "giant continuation prompt"
36+
→ Agent sees ONLY what Maestro stuffed in the prompt
37+
→ Context loss: tool calls in progress, reasoning state, earlier turns
38+
```
39+
40+
### Proposed Flow (Native Resume)
41+
42+
```
43+
User interjects while Codex is working
44+
→ Grab thread_id (already stored as sessionId on the tab/process)
45+
→ SIGINT sent to process
46+
→ Process exits (Codex saves state to ~/.codex/sessions/ JSONL files)
47+
→ Spawn: codex exec resume <thread_id> -- "user's interjection message"
48+
→ Codex loads FULL conversation history from its own session files
49+
→ Agent has complete context of everything that happened
50+
```
51+
52+
### Comparison
53+
54+
| Aspect | Current (Fallback) | Proposed (Native Resume) |
55+
| -------------------------- | ------------------------------------------ | ---------------------------------------- |
56+
| Context preservation | Partial — only captured stdout | Full — Codex's own session files |
57+
| Continuation prompt | Hand-crafted with `<partial_output>` tags | Just the user's interjection |
58+
| Tool call history | Lost | Preserved |
59+
| Reasoning state | Lost | Preserved (in session JSONL) |
60+
| Earlier conversation turns | Lost | Preserved |
61+
| Complexity | High — prompt reconstruction logic | Low — use existing resume infrastructure |
62+
| Reliability | Fragile — depends on stdout capture timing | Robust — Codex manages its own state |
63+
64+
## Infrastructure Already in Place
65+
66+
| Component | File | Status |
67+
| --------------------------- | ----------------------------------------- | ----------------------------------------------- |
68+
| `thread_id` extraction | `src/main/parsers/codex-output-parser.ts` | Done — parsed from `thread.started` JSONL event |
69+
| `resumeArgs` definition | `src/main/agents/definitions.ts` | Done — `(sessionId) => ['resume', sessionId]` |
70+
| `supportsResume` capability | `src/main/agents/capabilities.ts` | Done — `true` |
71+
| Resume arg building | `src/main/utils/agent-args.ts` | Done — inserts `resume <id>` into CLI args |
72+
| Session ID storage | Tab/process state in agentStore | Done — stored when parser emits `init` event |
73+
74+
## Implementation Plan
75+
76+
### Primary Change: `src/renderer/hooks/input/useInputProcessing.ts`
77+
78+
In the interrupt-and-continue fallback path (~line 443-538), replace:
79+
80+
```typescript
81+
// BEFORE: Build continuation prompt with partial output
82+
const continuationPrompt = buildContinuationPrompt(partialOutput, userMessage);
83+
queueExecution({ prompt: continuationPrompt, sessionId: undefined });
84+
```
85+
86+
With:
87+
88+
```typescript
89+
// AFTER: Resume with native session continuation
90+
const threadId = getCurrentSessionId(); // already captured from thread.started
91+
queueExecution({ prompt: userMessage, sessionId: threadId });
92+
```
93+
94+
### Capability Gating
95+
96+
Gate this behavior on agents that support native resume:
97+
98+
```typescript
99+
if (capabilities.supportsResume && sessionId) {
100+
// Use native resume — full context preserved by agent
101+
queueExecution({ prompt: userMessage, sessionId });
102+
} else {
103+
// Fall back to continuation prompt reconstruction
104+
const continuationPrompt = buildContinuationPrompt(partialOutput, userMessage);
105+
queueExecution({ prompt: continuationPrompt });
106+
}
107+
```
108+
109+
### Secondary Changes
110+
111+
1. **`src/main/process-manager/spawners/ChildProcessSpawner.ts`** — Ensure resume args are passed through when `sessionId` is provided on a queued execution
112+
2. **`src/main/utils/agent-args.ts`** — Verify the resume + follow-up prompt combination produces correct CLI: `codex exec resume <id> -- "message"`
113+
114+
## Key Risk: Session State on SIGINT
115+
116+
**Does Codex save session state when interrupted with SIGINT (not just on clean exit)?**
117+
118+
Codex uses incrementally-written `.jsonl` rollout files at `~/.codex/sessions/YYYY/MM/DD/rollout-<timestamp>-<uuid>.jsonl`. Since JSONL files are append-only and typically flushed per-line, partial sessions should be persisted even on interrupt.
119+
120+
**Mitigation:** If SIGINT doesn't reliably save state, two fallback strategies:
121+
122+
1. **Graceful wait** — Let the current turn complete, then resume (queue-and-wait instead of interrupt)
123+
2. **Hybrid** — Try native resume first; if it fails (session not found), fall back to continuation prompt
124+
125+
## Agents This Applies To
126+
127+
| Agent | Supports Resume | Session Persistence | Candidate? |
128+
| ------------- | --------------- | ------------------------- | ------------------ |
129+
| Codex | Yes | JSONL rollout files | Yes |
130+
| Claude Code | N/A | Has native mid-turn stdin | No (already works) |
131+
| OpenCode | TBD | TBD | Investigate |
132+
| Factory Droid | TBD | TBD | Investigate |
133+
134+
## References
135+
136+
- Codex CLI reference: https://developers.openai.com/codex/cli/reference.md
137+
- Agent definitions: `src/main/agents/definitions.ts:143-190`
138+
- Agent capabilities: `src/main/agents/capabilities.ts:206-232`
139+
- Codex output parser: `src/main/parsers/codex-output-parser.ts`
140+
- Codex session storage: `src/main/storage/codex-session-storage.ts`
141+
- Interrupt fallback path: `src/renderer/hooks/input/useInputProcessing.ts:443-538`

src/__tests__/renderer/hooks/useAgentListeners.test.ts

Lines changed: 88 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,7 @@ let onCommandExitHandler: ListenerCallback | undefined;
9191
let onUsageHandler: ListenerCallback | undefined;
9292
let onAgentErrorHandler: ListenerCallback | undefined;
9393
let onThinkingChunkHandler: ListenerCallback | undefined;
94+
let onInterjectionAckHandler: ListenerCallback | undefined;
9495
let onSshRemoteHandler: ListenerCallback | undefined;
9596
let onToolExecutionHandler: ListenerCallback | undefined;
9697

@@ -103,6 +104,7 @@ const mockUnsubscribeCommandExit = vi.fn();
103104
const mockUnsubscribeUsage = vi.fn();
104105
const mockUnsubscribeAgentError = vi.fn();
105106
const mockUnsubscribeThinkingChunk = vi.fn();
107+
const mockUnsubscribeInterjectionAck = vi.fn();
106108
const mockUnsubscribeSshRemote = vi.fn();
107109
const mockUnsubscribeToolExecution = vi.fn();
108110

@@ -143,6 +145,10 @@ const mockProcess = {
143145
onThinkingChunkHandler = handler;
144146
return mockUnsubscribeThinkingChunk;
145147
}),
148+
onInterjectionAck: vi.fn((handler: ListenerCallback) => {
149+
onInterjectionAckHandler = handler;
150+
return mockUnsubscribeInterjectionAck;
151+
}),
146152
onSshRemote: vi.fn((handler: ListenerCallback) => {
147153
onSshRemoteHandler = handler;
148154
return mockUnsubscribeSshRemote;
@@ -205,6 +211,7 @@ beforeEach(() => {
205211
onUsageHandler = undefined;
206212
onAgentErrorHandler = undefined;
207213
onThinkingChunkHandler = undefined;
214+
onInterjectionAckHandler = undefined;
208215
onSshRemoteHandler = undefined;
209216
onToolExecutionHandler = undefined;
210217

@@ -273,7 +280,7 @@ describe('getErrorTitleForType', () => {
273280

274281
describe('useAgentListeners', () => {
275282
describe('listener registration', () => {
276-
it('registers all 11 IPC listeners on mount', () => {
283+
it('registers all 12 IPC listeners on mount', () => {
277284
const deps = createMockDeps();
278285
renderHook(() => useAgentListeners(deps));
279286

@@ -286,11 +293,12 @@ describe('useAgentListeners', () => {
286293
expect(mockProcess.onUsage).toHaveBeenCalledTimes(1);
287294
expect(mockProcess.onAgentError).toHaveBeenCalledTimes(1);
288295
expect(mockProcess.onThinkingChunk).toHaveBeenCalledTimes(1);
296+
expect(mockProcess.onInterjectionAck).toHaveBeenCalledTimes(1);
289297
expect(mockProcess.onSshRemote).toHaveBeenCalledTimes(1);
290298
expect(mockProcess.onToolExecution).toHaveBeenCalledTimes(1);
291299
});
292300

293-
it('unsubscribes all 11 listeners on unmount', () => {
301+
it('unsubscribes all 12 listeners on unmount', () => {
294302
const deps = createMockDeps();
295303
const { unmount } = renderHook(() => useAgentListeners(deps));
296304

@@ -305,6 +313,7 @@ describe('useAgentListeners', () => {
305313
expect(mockUnsubscribeUsage).toHaveBeenCalledTimes(1);
306314
expect(mockUnsubscribeAgentError).toHaveBeenCalledTimes(1);
307315
expect(mockUnsubscribeThinkingChunk).toHaveBeenCalledTimes(1);
316+
expect(mockUnsubscribeInterjectionAck).toHaveBeenCalledTimes(1);
308317
expect(mockUnsubscribeSshRemote).toHaveBeenCalledTimes(1);
309318
expect(mockUnsubscribeToolExecution).toHaveBeenCalledTimes(1);
310319
});
@@ -322,6 +331,83 @@ describe('useAgentListeners', () => {
322331
});
323332
});
324333

334+
describe('onInterjectionAck', () => {
335+
it('moves queued interjection from executionQueue to tab logs as delivered', () => {
336+
const deps = createMockDeps();
337+
const session = createMockSession({
338+
id: 'sess-1',
339+
aiTabs: [createMockTab({ id: 'tab-1', logs: [] })],
340+
activeTabId: 'tab-1',
341+
executionQueue: [
342+
{
343+
id: 'interjection-1',
344+
timestamp: 1700000000000,
345+
tabId: 'tab-1',
346+
type: 'message' as const,
347+
text: 'follow up question',
348+
images: undefined,
349+
},
350+
],
351+
});
352+
useSessionStore.setState({
353+
sessions: [session],
354+
activeSessionId: 'sess-1',
355+
});
356+
357+
renderHook(() => useAgentListeners(deps));
358+
359+
onInterjectionAckHandler?.('sess-1-ai-tab-1', 'interjection-1');
360+
361+
const updated = useSessionStore.getState().sessions.find((s) => s.id === 'sess-1');
362+
// Should be removed from queue
363+
expect(updated?.executionQueue.length).toBe(0);
364+
// Should now be in tab logs as delivered
365+
const deliveredLog = updated?.aiTabs[0].logs[0];
366+
expect(deliveredLog?.id).toBe('interjection-1');
367+
expect(deliveredLog?.text).toBe('follow up question');
368+
expect(deliveredLog?.interjection).toBe(true);
369+
expect(deliveredLog?.delivered).toBe(true);
370+
expect(deliveredLog?.deliveryFailed).toBe(false);
371+
});
372+
373+
it('falls back to marking existing log entry as delivered (interrupt-resume path)', () => {
374+
const deps = createMockDeps();
375+
const session = createMockSession({
376+
id: 'sess-1',
377+
aiTabs: [
378+
createMockTab({
379+
id: 'tab-1',
380+
logs: [
381+
{
382+
id: 'interjection-1',
383+
timestamp: Date.now(),
384+
source: 'user',
385+
text: 'follow up',
386+
interjection: true,
387+
delivered: false,
388+
deliveryFailed: true,
389+
},
390+
],
391+
}),
392+
],
393+
activeTabId: 'tab-1',
394+
});
395+
useSessionStore.setState({
396+
sessions: [session],
397+
activeSessionId: 'sess-1',
398+
});
399+
400+
renderHook(() => useAgentListeners(deps));
401+
402+
onInterjectionAckHandler?.('sess-1-ai-tab-1', 'interjection-1');
403+
404+
const updated = useSessionStore.getState().sessions.find((s) => s.id === 'sess-1');
405+
const updatedLog = updated?.aiTabs[0].logs[0];
406+
expect(updatedLog?.delivered).toBe(true);
407+
expect(updatedLog?.deliveryFailed).toBe(false);
408+
});
409+
});
410+
325411
// ========================================================================
326412
// onData handler
327413
// ========================================================================

0 commit comments

Comments
 (0)