Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
160 changes: 111 additions & 49 deletions scripts/test-codex-optimistic-submit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,39 @@ import {
codexPromptsMatchForOwnership,
shouldQueueOptimisticCodexUserEntry,
} from '../src/renderer/src/workspace/hook/actions/streaming'
import { emptyRuntime } from '../src/renderer/src/workspace/workspaceState'
import { shouldClearIdleCodexQueuedMessages } from '../src/renderer/src/workspace/queueInvariants'
import { emptyRuntime, type SemanticLiveTurn } from '../src/renderer/src/workspace/workspaceState'

function semanticTurn(
turnId: string,
endedAt: number | null,
text = '',
): SemanticLiveTurn {
return {
turnId,
text,
source: 'proxy',
blocks: {},
blockOrder: [],
stopReason: null,
usage: null,
task: {
todos: [],
doneCount: 0,
totalCount: 0,
inProgressToolUseIds: [],
activeToolNames: [],
},
lookups: {
toolCallsById: {},
toolUseIdsInOrder: [],
resolvedToolUseIds: [],
erroredToolUseIds: [],
},
startedAt: 1,
endedAt,
}
}

// The bug this guards was brutally simple: submit calls
// setStreamingBaseline() first, which changes streamPhase to
Expand All @@ -41,30 +73,7 @@ import { emptyRuntime } from '../src/renderer/src/workspace/workspaceState'
{
const runtime = emptyRuntime()
runtime.streamPhase = 'idle'
runtime.semantic.currentTurn = {
turnId: 'resp_live',
text: '',
source: 'proxy',
blocks: {},
blockOrder: [],
stopReason: null,
usage: null,
task: {
todos: [],
doneCount: 0,
totalCount: 0,
inProgressToolUseIds: [],
activeToolNames: [],
},
lookups: {
toolCallsById: {},
toolUseIdsInOrder: [],
resolvedToolUseIds: [],
erroredToolUseIds: [],
},
startedAt: 1,
endedAt: null,
}
runtime.semantic.currentTurn = semanticTurn('resp_live', null)

assert.equal(
shouldQueueOptimisticCodexUserEntry(runtime),
Expand All @@ -76,30 +85,7 @@ import { emptyRuntime } from '../src/renderer/src/workspace/workspaceState'
{
const runtime = emptyRuntime()
runtime.streamPhase = 'tool_running'
runtime.semantic.currentTurn = {
turnId: 'resp_done',
text: '',
source: 'proxy',
blocks: {},
blockOrder: [],
stopReason: null,
usage: null,
task: {
todos: [],
doneCount: 0,
totalCount: 0,
inProgressToolUseIds: [],
activeToolNames: [],
},
lookups: {
toolCallsById: {},
toolUseIdsInOrder: [],
resolvedToolUseIds: [],
erroredToolUseIds: [],
},
startedAt: 1,
endedAt: 2,
}
runtime.semantic.currentTurn = semanticTurn('resp_done', 2)

assert.equal(
shouldQueueOptimisticCodexUserEntry(runtime),
Expand All @@ -108,6 +94,46 @@ import { emptyRuntime } from '../src/renderer/src/workspace/workspaceState'
)
}

{
const runtime = emptyRuntime()
runtime.streamPhase = 'submitting'
runtime.semantic.history = [
semanticTurn('resp_history_bridge', 2, 'previous turn still visible until JSONL catches up'),
]

assert.equal(
shouldQueueOptimisticCodexUserEntry(runtime),
true,
'a Codex prompt must queue while rendered semantic history still owns the feed tail',
)
}

{
const runtime = emptyRuntime()
runtime.streamPhase = 'submitting'
runtime.entries = [
{
type: 'assistant',
uuid: 'committed:entry',
parentUuid: null,
timestamp: '2026-05-20T12:00:00.000Z',
message: {
role: 'assistant',
content: [{ type: 'text', text: 'already committed' }],
},
},
]
runtime.semantic.history = [
semanticTurn('resp_history_committed', 2, 'already committed'),
]

assert.equal(
shouldQueueOptimisticCodexUserEntry(runtime),
false,
'committed-owned semantic history must not keep later Codex prompts stuck in QueueStrip',
)
}

{
assert.equal(
codexPromptsMatchForOwnership('Fix this\r\nnow ', 'Fix this\nnow'),
Expand All @@ -121,4 +147,40 @@ import { emptyRuntime } from '../src/renderer/src/workspace/workspaceState'
)
}

{
assert.equal(
shouldClearIdleCodexQueuedMessages({
awaitingAssistant: false,
processActive: false,
provider: 'codex',
queuedMessagesLength: 1,
streamPhase: 'idle',
}),
true,
'idle Codex panes must not retain local queued prompts after all live signals clear',
)
assert.equal(
shouldClearIdleCodexQueuedMessages({
awaitingAssistant: false,
processActive: true,
provider: 'codex',
queuedMessagesLength: 1,
streamPhase: 'idle',
}),
false,
'active Codex process state can still legitimately own queued prompts',
)
assert.equal(
shouldClearIdleCodexQueuedMessages({
awaitingAssistant: false,
processActive: false,
provider: 'claude',
queuedMessagesLength: 1,
streamPhase: 'idle',
}),
false,
'Claude queue rows are backed by provider queue-operation records and must not use the Codex fallback',
)
}

console.log('codex optimistic submit tests passed')
18 changes: 18 additions & 0 deletions scripts/test-feed-render-model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -314,6 +314,24 @@ function webSearchHistoryTurn(turnId: string, itemId: string, answerText: string
)
}

{
const model = deriveFeedRenderModel({
provider: 'codex',
entries: [userEntry('optimistic-codex-user:1', 'new submitted prompt')],
semanticHistory: [liveTurn('resp_history_bridge')],
semanticTurn: null,
streamPhase: 'submitting',
streamPhasePendingToolName: null,
streamPhasePendingToolUseId: null,
})

assert.deepEqual(
model.debugRows.map(row => row.slot),
['entry', 'semantic', 'work'],
'Feed still renders entry-plane optimistic prompts before semantic history/work, so submit ownership must avoid this plane when history is renderable',
)
}

{
const committed = assistantEntry(
'committed-rollout-copy',
Expand Down
33 changes: 31 additions & 2 deletions src/renderer/src/workspace/hook/actions/streaming.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@ import {
} from '@renderer/workspace/entries/utils'
import { isOptimisticCodexUserEntry } from '@renderer/workspace/codex/entries'
import { isSemanticTurnRunning } from '@renderer/workspace/semantic/helpers'
import {
buildCommittedAssistantText,
semanticTurnHasRenderableContent,
} from '@renderer/features/feed/ui/semantic/renderUnits'

import type { WorkspaceSetRuntimes } from '@renderer/workspace/hook/context'

Expand All @@ -35,7 +39,10 @@ import type { WorkspaceSetRuntimes } from '@renderer/workspace/hook/context'
// (see ipc/handleBulkJsonl.ts for the reconciliation side).

export function shouldQueueOptimisticCodexUserEntry(
current: Pick<SessionRuntime, 'semantic' | 'streamPhase'>,
current: Pick<
SessionRuntime,
'entries' | 'semantic' | 'streamPhase' | 'toolResultIndex' | 'toolUseIndex'
>,
): boolean {
// WHY this deliberately ignores `streamPhase`:
// TileLeaf calls setStreamingBaseline() and addOptimisticCodexUserEntry()
Expand All @@ -50,7 +57,29 @@ export function shouldQueueOptimisticCodexUserEntry(
// work indicator, but it is polluted by the current submit and cannot
// answer "is there older live feed content this prompt must not jump
// above?"
return isSemanticTurnRunning(current.semantic.currentTurn)
if (isSemanticTurnRunning(current.semantic.currentTurn)) return true

// WHY completed semantic history is part of this ownership test:
// Feed renders in planes: committed/optimistic entries first, then
// semantic history/current, then work. A Codex submit that becomes a
// normal optimistic Entry while a previous completed semantic turn is
// still renderable therefore lands above the previous turn's semantic
// bridge and the work row. The prompt is "present" in the DOM, but it
// is no longer the latest user action visually — exactly the #239
// failure. Raw history length is too broad because history can linger
// after committed rows already own its visible content, so mirror the
// Feed renderability predicate with the same committed text/tool
// ownership inputs.
const committedAssistantText = buildCommittedAssistantText(current.entries)
return current.semantic.history.some(turn =>
turn.turnId !== current.semantic.currentTurn?.turnId &&
semanticTurnHasRenderableContent(
turn,
current.toolUseIndex,
current.toolResultIndex,
committedAssistantText,
),
)
}

export function codexPromptOwnershipKey(text: string | null | undefined): string {
Expand Down
Loading