diff --git a/src/adapters/cli/grok.ts b/src/adapters/cli/grok.ts index 6cb58056d..c2e8cb960 100644 --- a/src/adapters/cli/grok.ts +++ b/src/adapters/cli/grok.ts @@ -313,7 +313,10 @@ export function createGrokAdapter(pathOverride?: string): CliAdapter { // Busy markers verified on 0.2.93: "⠧ Waiting for response…" spinner // during the model phase, and the "Ctrl+c:cancel" shortcut-bar entry // through model AND tool phases (idle bar shows Shift+Tab:mode / - // Ctrl+x:shortcuts only). + // Ctrl+x:shortcuts only). Kept for reattach / first-prompt-timeout + // probes — post-submit busy-absent probing is disabled for + // reliableTurnTerminal CLIs (worker) because these markers often lag + // several seconds after submit and were flipping card-off DONE early. busyPattern: /Waiting for response|Ctrl\+c:\s*cancel/i, // Routing/identity ride on --rules (injectsSessionContext); no inline hints. systemHints: [], diff --git a/src/core/worker-pool.ts b/src/core/worker-pool.ts index 6d5e68f3f..53671957e 100644 --- a/src/core/worker-pool.ts +++ b/src/core/worker-pool.ts @@ -3125,12 +3125,18 @@ function setupWorkerHandlers( source: 'screen_update', content: msg.content, }); - // Usage ledger + turn reactions: idle/limited edges are turn - // boundaries. Append the token delta, and flip this turn's pending ✋ - // reactions to ✅ (best-effort, never blocks the status pipeline). + // Usage ledger: any settle-to-idle/limited edge records the delta. + // Turn reactions are stricter — only flip ✋→✅ after a real busy + // period (working/analyzing). Cold-start starting→idle (or the first + // prompt-ready before the turn has gone working) must NOT DONE a + // message that is still about to be / just being processed. Grok + // card-off sessions hit this when the ready-gate settle fired idle + // ~seconds after GoGoGo while the CLI was still running the prompt. if (ds.lastScreenStatus === 'idle' || ds.lastScreenStatus === 'limited') { recordUsageForDaemonSession(ds); - void finishTurnReactions(ds); + if (prevStatus === 'working' || prevStatus === 'analyzing') { + void finishTurnReactions(ds); + } } if ( ds.lastScreenStatus === 'idle' @@ -4082,7 +4088,8 @@ function shouldDropMismatchedHermesFinalOutput( /** * Turn-end half of the two-phase turn reactions (auto-on for card-off sessions, * i.e. streaming card disabled). The 冲! "received" reactions are added per-message at the daemon - * acceptance point (`noteTurnReceived`); when the worker next returns to idle we + * acceptance point (`noteTurnReceived`); the screen_update handler calls this + * only on working|analyzing → idle|limited (not cold-start starting→idle), to * flip every pending ✋ on this session to ✅ DONE and clear the list. When * silentTurnReactions is enabled after a ✋ has already landed, we only remove * that received reaction and do not add DONE. Binding the start to the message diff --git a/src/utils/pending-input-queue.ts b/src/utils/pending-input-queue.ts index 574767381..b15b47c75 100644 --- a/src/utils/pending-input-queue.ts +++ b/src/utils/pending-input-queue.ts @@ -106,6 +106,56 @@ export function resolveInitialPromptDelivery(opts: { }; } +/** + * Whether this spawn baked a non-empty first prompt into argv (not the write + * queue). Shared base for both Grok pre-exec busy arming and the card-off + * "seed working before first idle" path for quiescence argv adapters. + * + * Riff has passesInitialPromptViaArgs=false → false (queue-after-spawn). + */ +export function shouldTrackArgvBakedFirstPrompt(opts: { + passesInitialPromptViaArgs: boolean; + preparedInitialPrompt?: string | null; + queuedInitialPrompt?: string | null; +}): boolean { + if (!opts.passesInitialPromptViaArgs) return false; + if (!opts.preparedInitialPrompt?.trim()) return false; + if (opts.queuedInitialPrompt) return false; + return true; +} + +/** + * Whether markPromptReady must treat the first post-spawn ready as + * "pre-execution SessionStart" (report working, keep busy) rather than true + * end-of-turn idle. + * + * Strict conditions (PR #633 review): + * - adapter actually bakes the first prompt into argv (`passesInitialPromptViaArgs`) + * - an argv prompt exists and was NOT deferred to the write queue + * - SessionStart ready exists (`injectsReadyHook`) so first ready ≠ completion + * - turn terminal is authoritative (`reliableTurnTerminal`) so a later + * assistant_final/fireIdle will produce a real idle edge + * + * Riff (and any queue-after-spawn adapter) has passesInitialPromptViaArgs=false + * — must return false, or the first markPromptReady would clear isPromptReady + * and leave the post-spawn queue flush never firing. + * Gemini/Pi/MTR/OpenCode pass prompt via argv but use quiescence as the sole + * idle signal — first ready IS completion; must return false or they stick + * (use {@link shouldTrackArgvBakedFirstPrompt} + seed working→idle instead). + */ +export function shouldArmSpawnArgvInitialPromptBusy(opts: { + passesInitialPromptViaArgs: boolean; + preparedInitialPrompt?: string | null; + queuedInitialPrompt?: string | null; + injectsReadyHook: boolean; + reliableTurnTerminal: boolean; +}): boolean { + if (!shouldTrackArgvBakedFirstPrompt(opts)) return false; + if (!opts.injectsReadyHook) return false; + if (!opts.reliableTurnTerminal) return false; + return true; +} + /** Once either side of a queue boundary is durable, stop this batch and wait * for the next reliable idle edge before writing the following turn. */ export function shouldStopPendingBatch( diff --git a/src/worker.ts b/src/worker.ts index 63244999c..90bed72aa 100644 --- a/src/worker.ts +++ b/src/worker.ts @@ -61,6 +61,8 @@ import { pendingInputMayFlush, pendingInputAllowsTypeAhead, resolveInitialPromptDelivery, + shouldArmSpawnArgvInitialPromptBusy, + shouldTrackArgvBakedFirstPrompt, shouldDeferArgsBakedDurablePrompt, shouldDeferInitialPromptForArgLimit, shouldStopPendingBatch, @@ -1095,6 +1097,19 @@ let lastSpawnEffectiveCliSessionId: string | undefined; let lastSpawnDeferInitialPrompt = false; let lastSpawnQueuedInitialPrompt: string | undefined; let lastSpawnQueuedInitialPromptLogicalContent: string | undefined; +/** + * True only when {@link shouldArmSpawnArgvInitialPromptBusy} says so: argv- + * baked first prompt + SessionStart ready (Grok-class). First markPromptReady + * then reports working (not idle). Cleared on first consume. Must stay false + * for Riff/queue-after-spawn and for quiescence-only argv adapters. + */ +let spawnArgvInitialPromptBusy = false; +/** + * True when spawn baked first prompt into argv (any such adapter, incl. Pi / + * Gemini). Card-off reactions need a working→idle edge: Grok uses the busy + * arm above; quiescence argv adapters seed working then idle at first ready. + */ +let spawnArgvNeedsWorkingSeed = false; let idleDetector: IdleDetector | null = null; let isTmuxMode = false; /** True once a crash diagnostic tmux shell (bmx-diag-) is live. */ @@ -5170,6 +5185,20 @@ function markPromptReadyFromPty(): void { } } +/** Push a coarse screen status to the daemon without waiting for the 2s sampler. + * Used so card-off reactions see working→idle even on short turns. */ +function publishScreenStatus(status: 'working' | 'idle'): void { + if (!renderer) return; + const { content } = renderer.snapshot(); + send({ + type: 'screen_update', + content, + ...usageLimitTracker.classify(content, status), + turnId: currentBotmuxTurnId, + dispatchAttempt: currentBotmuxDispatchAttempt, + }); +} + function markPromptReady(): void { if (isPromptReady) return; // guard against duplicate calls if (cliRestartInProgress && !replacementSpawnInProgress) { @@ -5269,14 +5298,43 @@ function markPromptReady(): void { log('prompt-ready with no backend installed — deferring restart success receipt'); } } - // Send immediate idle snapshot so Lark card reflects idle status. - // BUT: skip when messages are pending — flushPending() will immediately - // make the CLI busy, so the idle state is transient and shouldn't appear - // in the card. This avoids a false "就绪" flash on daemon restart - // (where the initial prompt is queued before the CLI becomes idle). - if (renderer && pendingMessages.length === 0 && pendingRawInputs.length === 0 && pendingSessionRename === null && !isFlushing) { - const { content } = renderer.snapshot(); - send({ type: 'screen_update', content, ...usageLimitTracker.classify(content, 'idle'), turnId: currentBotmuxTurnId, dispatchAttempt: currentBotmuxDispatchAttempt }); + // Send an immediate status snapshot so Lark card / card-off reactions track + // real work. Skip pure idle when: + // - messages are pending — flushPending() will immediately make the CLI + // busy (avoids a false "就绪" flash on daemon restart); + // - Grok-class argv+SessionStart arming: first ready is pre-execution, so + // park as working until assistant_final/fireIdle; + // - quiescence argv CLIs (Pi/Gemini/MTR/OpenCode): first ready IS turn end + // — seed working then idle so card-off gets working→idle (review P2). + const hasPendingWork = + pendingMessages.length > 0 + || pendingRawInputs.length > 0 + || pendingSessionRename !== null + || isFlushing; + if (renderer && !hasPendingWork) { + if (spawnArgvInitialPromptBusy) { + spawnArgvInitialPromptBusy = false; + spawnArgvNeedsWorkingSeed = false; + // Stay non-ready so the next genuine end-of-turn idle is a real edge. + isPromptReady = false; + idleDetector?.reset(); + publishScreenStatus('working'); + log('Spawn argv initial prompt still in flight — reporting working (not idle) so turn reactions can settle later'); + } else if (spawnArgvNeedsWorkingSeed) { + // First ready = true completion for quiescence argv adapters. Seed a + // working edge before idle so daemon finishTurnReactions (gated on + // working→idle) still flips card-off GoGoGo on cold-start one-shots. + spawnArgvNeedsWorkingSeed = false; + publishScreenStatus('working'); + publishScreenStatus('idle'); + log('Argv-baked first prompt completed — seeded working→idle for card-off reactions'); + } else { + publishScreenStatus('idle'); + } + } else if (hasPendingWork) { + // Queued path will flip busy via flushPending; drop argv seed flags. + spawnArgvInitialPromptBusy = false; + spawnArgvNeedsWorkingSeed = false; } // barrier 注入必须先于本次 pending 用户消息落地(现存发送方均 barrier=false, // 该分支目前不触发;机制保留见 pendingInjections 声明处注释)。跳过本次 @@ -5677,6 +5735,10 @@ async function flushPending(): Promise { if (isPromptReady) { isPromptReady = false; idleDetector?.reset(); + // Immediate working for card-off reaction settle (PR #633 P2): the screen + // sampler is 2s — without this a short turn can complete before any + // working status is observed, leaving idle→idle and GoGoGo uncleared. + publishScreenStatus('working'); } try { @@ -5841,7 +5903,16 @@ async function flushPending(): Promise { } else { result = await cliAdapter.writeInput(backend, msg, { turnId: item.turnId }); } - scheduleBusyPatternIdleProbe(`${cliName()} post-submit`); + // Transcript-backed CLIs (Grok/Codex/… reliableTurnTerminal) own idle via + // assistant_final → fireIdle. Their busyPattern is often missing for + // several seconds after submit (or never matches the current TUI chrome), + // so a post-submit "busy marker absent" probe falsely marks prompt ready + // and the card-off DONE reaction lands while the turn is still running + // (seen live on Grok: GoGoGo → +7s post-submit probe → DONE, then + // deferred recheck still saw active PTY output). + if (cliAdapter.reliableTurnTerminal !== true) { + scheduleBusyPatternIdleProbe(`${cliName()} post-submit`); + } } catch (err: any) { log(`writeInput threw: ${err?.message ?? err}`); if (durableWrite && item.turnId) { @@ -6002,7 +6073,11 @@ function sendToPty( flushPending(); // fire-and-forget async; no-op if already flushing } else { if (!mergedQueued) log(`Queued message (${pendingMessages.length} pending): "${content.substring(0, 80)}" — ${cliName()} ${awaitingFirstPrompt ? 'still booting' : 'is busy'}`); - scheduleBusyPatternIdleProbe(`${cliName()} queued-message`); + // Same false-idle trap as post-submit (see flushPending): do not let + // "busy marker absent" declare ready for transcript-backed CLIs. + if (cliAdapter?.reliableTurnTerminal !== true) { + scheduleBusyPatternIdleProbe(`${cliName()} queued-message`); + } } return true; } @@ -7120,6 +7195,21 @@ async function spawnCli( preparedInitialPrompt = initialPromptDelivery.argvPrompt; lastSpawnQueuedInitialPrompt = initialPromptDelivery.queuedContent; lastSpawnQueuedInitialPromptLogicalContent = initialPromptDelivery.logicalContent; + // Argv-baked first prompt tracking (PR #633 P2 / second-round review): + // - needsWorkingSeed: any argv CLI (Pi/Gemini/MTR/OpenCode/Grok) so card-off + // reactions can form working→idle (seeded at first ready or Grok arm). + // - busy arm: only Grok-class SessionStart (first ready ≠ turn end). + const argvBakedOpts = { + passesInitialPromptViaArgs: cliAdapter.passesInitialPromptViaArgs === true, + preparedInitialPrompt, + queuedInitialPrompt: lastSpawnQueuedInitialPrompt, + }; + spawnArgvNeedsWorkingSeed = shouldTrackArgvBakedFirstPrompt(argvBakedOpts); + spawnArgvInitialPromptBusy = shouldArmSpawnArgvInitialPromptBusy({ + ...argvBakedOpts, + injectsReadyHook: cliAdapter.injectsReadyHook === true, + reliableTurnTerminal: cliAdapter.reliableTurnTerminal === true, + }); if (deferInitialPrompt && preparedDeferredInput) { piInitialPromptAdditionalArgs = [...(preparedDeferredInput.additionalArgs ?? [])]; piInitialPromptEnv = { ...(preparedDeferredInput.env ?? {}) }; diff --git a/test/initial-prompt-arg-limit.test.ts b/test/initial-prompt-arg-limit.test.ts index 159dd7664..a92b7d1a0 100644 --- a/test/initial-prompt-arg-limit.test.ts +++ b/test/initial-prompt-arg-limit.test.ts @@ -3,15 +3,90 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { describe, expect, it } from 'vitest'; import { createPiAdapter } from '../src/adapters/cli/pi.js'; +import { createGrokAdapter } from '../src/adapters/cli/grok.js'; +import { createRiffAdapter } from '../src/adapters/cli/riff.js'; +import { createGeminiAdapter } from '../src/adapters/cli/gemini.js'; import { shouldQueueInitialPrompt } from '../src/codex-rpc-lifecycle.js'; import { resolveInitialPromptDelivery, + shouldArmSpawnArgvInitialPromptBusy, + shouldTrackArgvBakedFirstPrompt, shouldDeferInitialPromptForArgLimit, } from '../src/utils/pending-input-queue.js'; import { PI_INITIAL_PROMPT_COMMAND } from '../src/adapters/cli/pi-initial-prompt-extension.js'; process.env.BOTMUX_TIME_SCALE ??= '0.01'; +describe('shouldArmSpawnArgvInitialPromptBusy (PR #633 CR)', () => { + it('arms only for Grok-class argv + SessionStart + reliable terminal', () => { + const grok = createGrokAdapter('/bin/grok'); + expect(shouldArmSpawnArgvInitialPromptBusy({ + passesInitialPromptViaArgs: grok.passesInitialPromptViaArgs === true, + preparedInitialPrompt: 'review this MR', + queuedInitialPrompt: undefined, + injectsReadyHook: grok.injectsReadyHook === true, + reliableTurnTerminal: grok.reliableTurnTerminal === true, + })).toBe(true); + }); + + it('does not arm for Riff (prompt is queue-after-spawn, not argv)', () => { + const riff = createRiffAdapter(); + // Reviewer regression: preparedInitialPrompt non-empty alone must NOT arm — + // Riff ignores prompt in buildArgs and queues after spawnCli returns. + expect(riff.passesInitialPromptViaArgs).toBeFalsy(); + expect(shouldArmSpawnArgvInitialPromptBusy({ + passesInitialPromptViaArgs: riff.passesInitialPromptViaArgs === true, + preparedInitialPrompt: 'hello from feishu', + queuedInitialPrompt: undefined, + injectsReadyHook: riff.injectsReadyHook === true, + reliableTurnTerminal: riff.reliableTurnTerminal === true, + })).toBe(false); + }); + + it('does not arm for quiescence-only argv adapters (Pi / Gemini) but still tracks argv seed', () => { + for (const adapter of [createPiAdapter('/bin/pi'), createGeminiAdapter('/bin/gemini')]) { + expect(adapter.passesInitialPromptViaArgs).toBe(true); + expect(adapter.injectsReadyHook).toBeFalsy(); + const base = { + passesInitialPromptViaArgs: adapter.passesInitialPromptViaArgs === true, + preparedInitialPrompt: 'do something', + queuedInitialPrompt: undefined as string | undefined, + }; + // Track seed so markPromptReady can publish working→idle for card-off. + expect(shouldTrackArgvBakedFirstPrompt(base)).toBe(true); + // Must NOT hold busy across first ready (first ready IS turn end). + expect(shouldArmSpawnArgvInitialPromptBusy({ + ...base, + injectsReadyHook: adapter.injectsReadyHook === true, + reliableTurnTerminal: adapter.reliableTurnTerminal === true, + })).toBe(false); + } + }); + + it('does not arm when the first prompt was deferred to the write queue', () => { + expect(shouldArmSpawnArgvInitialPromptBusy({ + passesInitialPromptViaArgs: true, + preparedInitialPrompt: 'argv-form', + queuedInitialPrompt: 'queued command', + injectsReadyHook: true, + reliableTurnTerminal: true, + })).toBe(false); + }); + + it('Riff post-spawn queue path: shouldQueueInitialPrompt is true when prompt exists', () => { + // Behavioral pin: riff does not bake prompt into argv, so the worker must + // queue + flush once after spawn (isPromptReady stays true for that flush). + const riff = createRiffAdapter(); + expect(shouldQueueInitialPrompt({ + hasPrompt: true, + rpcEngineActive: false, + queuePrompt: false, + passesInitialPromptViaArgs: riff.passesInitialPromptViaArgs === true, + deferInitialPrompt: false, + })).toBe(true); + }); +}); + describe('initial prompt argv byte-limit fallback', () => { it('does not defer when the adapter does not pass initial prompts via args', () => { expect(shouldDeferInitialPromptForArgLimit({ diff --git a/test/turn-reactions.test.ts b/test/turn-reactions.test.ts index d5e026971..974f6840c 100644 --- a/test/turn-reactions.test.ts +++ b/test/turn-reactions.test.ts @@ -11,8 +11,9 @@ * * Run: pnpm vitest run test/turn-reactions.test.ts */ +import { EventEmitter } from 'node:events'; import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { mkdtempSync } from 'fs'; +import { mkdtempSync, readFileSync } from 'fs'; import { tmpdir } from 'os'; import { join } from 'path'; @@ -43,7 +44,11 @@ vi.mock('../src/im/lark/client.js', async () => { import { registerBot } from '../src/bot-registry.js'; import { noteTurnReceived } from '../src/daemon.js'; -import { __testOnly_finishTurnReactions as finishTurnReactions } from '../src/core/worker-pool.js'; +import { + initWorkerPool, + __testOnly_finishTurnReactions as finishTurnReactions, + __testOnly_setupWorkerHandlers, +} from '../src/core/worker-pool.js'; import type { DaemonSession } from '../src/core/types.js'; const APP = 'reaction_app'; @@ -324,3 +329,112 @@ describe('two-phase turn reactions', () => { expect(mocks.addReaction).not.toHaveBeenCalledWith(APP, 'om_a', 'DONE'); }); }); + +/** + * Source-level pin: the screen_update handler must only flip ✋→✅ after a real + * busy period (working/analyzing → idle|limited). Cold-start starting→idle + * (ready-gate settle before the turn has gone working) must leave GoGoGo alone + * — otherwise card-off Grok sessions DONE a message while the CLI is still + * chewing the first prompt. + */ +describe('turn reaction idle edge gate (source)', () => { + it('finishTurnReactions is gated on prevStatus working|analyzing', () => { + const source = readFileSync( + join(process.cwd(), 'src/core/worker-pool.ts'), + 'utf8', + ); + // Anchor on the screen_update reaction comment — "// Usage ledger" alone + // also appears on the kill/close path and would pick the wrong block. + const blockStart = source.indexOf('// Usage ledger + turn reactions') >= 0 + ? source.indexOf('// Usage ledger + turn reactions') + : source.indexOf('// Usage ledger: any settle-to-idle'); + expect(blockStart).toBeGreaterThan(-1); + const block = source.slice(blockStart, blockStart + 900); + expect(block).toContain("prevStatus === 'working' || prevStatus === 'analyzing'"); + expect(block).toContain('void finishTurnReactions(ds)'); + // Must NOT call finishTurnReactions on every idle/limited edge unconditionally. + expect(block).not.toMatch( + /if \(ds\.lastScreenStatus === 'idle' \|\| ds\.lastScreenStatus === 'limited'\) \{\s*recordUsageForDaemonSession\(ds\);\s*void finishTurnReactions\(ds\);/, + ); + }); +}); + +/** + * Behavioral pin for PR #633 review P2: card-off finishTurnReactions requires a + * working→idle edge. Argv cold-start (Pi/Gemini) must seed working before idle + * (worker side); daemon-side, bare undefined→idle must NOT flip, while the + * seeded working→idle sequence must. + */ +describe('turn reaction screen_update behavioral gate', () => { + function makeFakeWorker() { + const worker = new EventEmitter() as any; + worker.killed = false; + worker.send = vi.fn(); + worker.kill = vi.fn(); + worker.pid = 4242; + worker.stdout = new EventEmitter(); + worker.stderr = new EventEmitter(); + return worker; + } + + async function flush(): Promise { + await new Promise(resolve => setTimeout(resolve, 0)); + // finishTurnReactions is fire-and-forget void; wait a tick for awaits + await new Promise(resolve => setTimeout(resolve, 0)); + } + + beforeEach(() => { + vi.clearAllMocks(); + process.env.SESSION_DATA_DIR = mkdtempSync(join(tmpdir(), 'botmux-react-behav-')); + mocks.addReaction.mockImplementation(async (_app: string, msgId: string) => `rid_${msgId}`); + mocks.removeReaction.mockResolvedValue(undefined); + registerWith(true); + initWorkerPool({ + sessionReply: vi.fn(async () => 'om_reply'), + getSessionWorkingDir: () => '/repo', + getActiveCount: () => 1, + closeSession: vi.fn(), + }); + }); + + it('undefined→idle alone does not DONE pending GoGoGo (gate)', async () => { + const worker = makeFakeWorker(); + const ds = makeDs({ + worker, + workerPort: 9999, + lastScreenStatus: undefined, + pendingAckReactions: [{ messageId: 'om_a', reactionId: 'rid_om_a' }], + }); + __testOnly_setupWorkerHandlers(ds, worker); + + worker.emit('message', { type: 'screen_update', content: 'done?', status: 'idle' }); + await flush(); + + expect(mocks.removeReaction).not.toHaveBeenCalled(); + expect(mocks.addReaction).not.toHaveBeenCalledWith(APP, 'om_a', 'DONE'); + expect(ds.pendingAckReactions?.map(a => a.messageId)).toEqual(['om_a']); + }); + + it('working→idle flips pending GoGoGo to DONE (argv seed / flushPending path)', async () => { + const worker = makeFakeWorker(); + const ds = makeDs({ + worker, + workerPort: 9999, + lastScreenStatus: undefined, + pendingAckReactions: [{ messageId: 'om_a', reactionId: 'rid_om_a' }], + }); + __testOnly_setupWorkerHandlers(ds, worker); + + // Mirrors markPromptReady seeding working then idle for quiescence argv CLIs. + worker.emit('message', { type: 'screen_update', content: 'busy', status: 'working' }); + await flush(); + worker.emit('message', { type: 'screen_update', content: 'ready', status: 'idle' }); + await flush(); + + expect(mocks.removeReaction).toHaveBeenCalledWith(APP, 'om_a', 'rid_om_a'); + expect(mocks.addReaction).toHaveBeenCalledWith(APP, 'om_a', 'DONE'); + expect(ds.pendingAckReactions).toEqual([]); + }); +}); + + diff --git a/test/worker-pipe-initial-screen-order.test.ts b/test/worker-pipe-initial-screen-order.test.ts index 4ab2de602..0d90ded16 100644 --- a/test/worker-pipe-initial-screen-order.test.ts +++ b/test/worker-pipe-initial-screen-order.test.ts @@ -34,26 +34,49 @@ describe('worker pipe initial screen ordering', () => { expect(captureIdx).toBeGreaterThan(idleIdx); }); - it('runs a busy-pattern idle probe after each submitted input', () => { + it('cold-start argv prompts seed working for card-off; Grok holds busy, quiescence seeds idle', () => { + // Grok: argv + SessionStart → hold working until assistant_final. + // Pi/Gemini: argv but first ready IS turn end → seed working then idle. + // Arming is centralized (not bare preparedInitialPrompt) so Riff is safe. const source = readFileSync(join(process.cwd(), 'src/worker.ts'), 'utf8'); + expect(source).toContain('shouldArmSpawnArgvInitialPromptBusy'); + expect(source).toContain('shouldTrackArgvBakedFirstPrompt'); + expect(source).toContain('spawnArgvNeedsWorkingSeed'); + expect(source).toContain('Spawn argv initial prompt still in flight — reporting working'); + expect(source).toContain('Argv-baked first prompt completed — seeded working→idle'); + expect(source).toContain("publishScreenStatus('working')"); + // Short-turn fix: flushPending publishes working immediately on submit. + expect(source).toContain("// Immediate working for card-off reaction settle"); + }); + + it('runs a busy-pattern idle probe after each submitted input — except reliableTurnTerminal CLIs', () => { + const source = readFileSync(join(process.cwd(), 'src/worker.ts'), 'utf8'); + // Match multiline writeStructuredInput( call (master expanded the args). const writeIdx = source.indexOf('result = await cliAdapter.writeStructuredInput('); - const probeIdx = source.indexOf('scheduleBusyPatternIdleProbe(`${cliName()} post-submit`);'); + const gateIdx = source.indexOf('if (cliAdapter.reliableTurnTerminal !== true) {', writeIdx); + const probeIdx = source.indexOf('scheduleBusyPatternIdleProbe(`${cliName()} post-submit`);', writeIdx); const helperIdx = source.indexOf('function scheduleBusyPatternIdleProbe(source: string): void'); expect(helperIdx).toBeGreaterThan(-1); expect(writeIdx).toBeGreaterThan(-1); - expect(probeIdx).toBeGreaterThan(writeIdx); + // Grok/Codex/… own idle via assistant_final; a post-submit "busy absent" + // probe was flipping card-off DONE mid-turn (busy markers lag after submit). + expect(gateIdx).toBeGreaterThan(writeIdx); + expect(probeIdx).toBeGreaterThan(gateIdx); + expect(probeIdx).toBeLessThan(gateIdx + 250); }); - it('rechecks busy-pattern adapters when a Lark message is queued while busy', () => { + it('rechecks busy-pattern adapters when a Lark message is queued while busy — except reliableTurnTerminal', () => { const source = readFileSync(join(process.cwd(), 'src/worker.ts'), 'utf8'); const queueLogIdx = source.indexOf('Queued message (${pendingMessages.length} pending)'); - const queuedProbeIdx = source.indexOf('scheduleBusyPatternIdleProbe(`${cliName()} queued-message`);'); + const gateIdx = source.indexOf('if (cliAdapter?.reliableTurnTerminal !== true) {', queueLogIdx); + const queuedProbeIdx = source.indexOf('scheduleBusyPatternIdleProbe(`${cliName()} queued-message`);', queueLogIdx); const helperIdx = source.indexOf('function scheduleBusyPatternIdleProbe(source: string): void'); expect(helperIdx).toBeGreaterThan(-1); expect(queueLogIdx).toBeGreaterThan(-1); - expect(queuedProbeIdx).toBeGreaterThan(queueLogIdx); + expect(gateIdx).toBeGreaterThan(queueLogIdx); + expect(queuedProbeIdx).toBeGreaterThan(gateIdx); }); it('rechecks busy-pattern adapters after first prompt timeout fallback unlocks startup', () => {