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
117 changes: 117 additions & 0 deletions src/renderer/src/rendering/__tests__/fixtures.buriedPrompt239.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import { describe, expect, it } from 'vitest'

import { collectCommittedCandidates } from '@renderer/rendering/observations/committed'
import {
collectLifecycleCandidates,
collectOptimisticCandidates,
} from '@renderer/rendering/observations/local'
import { collectSemanticCandidates } from '@renderer/rendering/observations/semantic'
import { createSessionLedger } from '@renderer/rendering/model/ledger'

// ---------------------------------------------------------------------------
// FIXTURE: buried-prompt-239 — the first end-to-end pipeline run.
//
// What this protects: issue #239 / the "STILL NOT SHOWING FUCKING USER
// PROMPT" bundle. The legacy renderer painted fixed JSX planes (entries →
// semantic history → current → work), so a submitted prompt EXISTED in the
// DOM but sat above a stack of stale semantic-history rows — visually
// missing. #172 comment 5 reopened the umbrella issue over exactly this
// shape and rewrote the acceptance bar: one ordered list, tests assert
// FINAL ORDER, not row existence.
//
// Shape (times relative, producer clock):
// t+000 committed user prompt #1
// t+100 semantic history turn (assistant answer, ended t+150, committed
// row never arrived — bridge content, must stay visible)
// t+200 optimistic user prompt #2 (submitted, no committed row yet)
// work active (streamPhase non-idle)
//
// Expected order: prompt#1, history blocks, prompt#2 (optimistic), work.
// The old planes would have painted prompt#2 BEFORE the history rows.
// Then: the committed row for prompt#2 lands → optimistic candidate is
// rejected with 'optimistic-owned-by-committed' and the committed twin
// takes the exact same position (the queue/optimistic handoff invariant —
// no re-burying, #172 comment 6).
// ---------------------------------------------------------------------------

const T = 1_700_000_000_000
const iso = (ms: number) => new Date(ms).toISOString()

describe('fixture: buried-prompt-239 (end-to-end collectors → ledger → rows)', () => {
const committedEntries = [
{
uuid: 'p1',
type: 'user',
timestamp: iso(T),
permissionMode: 'default',
message: { role: 'user', content: 'first prompt' },
},
]
const history = [
{
turnId: 'resp_hist',
source: 'proxy',
startedAtMs: T + 100,
endedAtMs: T + 150,
blocks: [{ blockIndex: 0, kind: 'text', text: 'stale but uncommitted answer', finalized: true }],
},
]
const optimistic = [{ uuid: 'optimistic-codex-user:1', text: 'second prompt', submittedAtMs: T + 200 }]

function runPipeline(entries: typeof committedEntries) {
const committed = collectCommittedCandidates(entries, 'codex', 's1')
const semantic = collectSemanticCandidates(null, history, 'codex', 's1')
const opt = collectOptimisticCandidates(optimistic, 'codex', 's1')
const lifecycle = collectLifecycleCandidates({
provider: 'codex',
sessionId: 's1',
streamPhaseIdle: false,
hasContentCandidates: true,
})
return createSessionLedger()({
provider: 'codex',
committed: committed.candidates,
live: [...semantic.candidates, ...opt],
statics: lifecycle,
unknowns: [],
})
}

it('the prompt is the visual tail of content — history sorts BEFORE it, work last', () => {
const ledger = runPipeline(committedEntries)
expect(ledger.rows.map(r => r.candidate.id)).toEqual([
'entry:p1',
'sem:resp_hist:0',
'optimistic:optimistic-codex-user:1',
'work',
])
})

it('uncommitted history is REORDERED, never suppressed — it may be the only copy (D3 discriminator)', () => {
const ledger = runPipeline(committedEntries)
const hist = ledger.decisions.find(d => d.candidateId === 'sem:resp_hist:0')
expect(hist?.selected).toBe(true)
})

it('handoff: committed twin lands → optimistic rejected with reason, position preserved (no re-bury)', () => {
const withCommittedTwin = [
...committedEntries,
{
uuid: 'p2',
type: 'user',
timestamp: iso(T + 200),
permissionMode: 'default',
message: { role: 'user', content: 'second prompt' }, // CRLF/whitespace variance — normalized match
},
]
const ledger = runPipeline(withCommittedTwin)
expect(ledger.rows.map(r => r.candidate.id)).toEqual([
'entry:p1',
'sem:resp_hist:0',
'entry:p2',
'work',
])
const opt = ledger.decisions.find(d => d.candidateId === 'optimistic:optimistic-codex-user:1')
expect(opt).toMatchObject({ selected: false, reason: 'optimistic-owned-by-committed' })
})
})
28 changes: 27 additions & 1 deletion src/renderer/src/rendering/model/ownership.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ export type CommittedOwnership = {
toolUseIds: ReadonlySet<string>
toolResultIds: ReadonlySet<string>
itemIds: ReadonlySet<string>
/** Normalized committed USER text — optimistic reconciliation only. */
userTextKeys: ReadonlySet<string>
}

/**
Expand Down Expand Up @@ -69,7 +71,11 @@ export function buildCommittedOwnership(
const toolUseIds = new Set<string>()
const toolResultIds = new Set<string>()
const itemIds = new Set<string>()
const userTextKeys = new Set<string>()
for (const c of committed) {
if (c.contentKind === 'user-text' && c.normalizedTextKey) {
userTextKeys.add(c.normalizedTextKey)
}
if (c.contentKind === 'assistant-text') {
if (c.messageId) wholeTurnOwnerIds.add(c.messageId)
if (c.textKey) exactText.add(c.textKey)
Expand All @@ -85,7 +91,7 @@ export function buildCommittedOwnership(
if (c.callId) toolResultIds.add(c.callId)
}
}
return { wholeTurnOwnerIds, exactText, normalizedText, toolUseIds, toolResultIds, itemIds }
return { wholeTurnOwnerIds, exactText, normalizedText, toolUseIds, toolResultIds, itemIds, userTextKeys }
}

/**
Expand All @@ -100,6 +106,26 @@ export function decideLiveCandidate(
): OwnershipDecision {
const evidence: string[] = []

// Optimistic prompt reconciliation (plan D1 handoff): the committed user
// row owns its optimistic stand-in by normalized text. Bias is toward the
// optimistic row SURVIVING when no owner is provable — surviving too long
// is visible and diagnosable; vanishing early is the silent #339 class.
if (candidate.owner === 'optimistic-submit') {
if (
candidate.normalizedTextKey &&
ownership.userTextKeys.has(candidate.normalizedTextKey)
) {
evidence.push('committed user row with matching normalized text')
return {
candidateId: candidate.id,
selected: false,
reason: 'optimistic-owned-by-committed',
evidence,
}
}
return { candidateId: candidate.id, selected: true, reason: 'selected', evidence }
}

// Whole-turn suppression: policy-gated, only for history bridges (a LIVE
// current turn matching a committed id means committed caught up while
// still streaming — units below handle it; killing the whole live turn
Expand Down
7 changes: 6 additions & 1 deletion src/renderer/src/rendering/observations/committed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -156,7 +156,12 @@ export function collectCommittedCandidates(
timestampMs: entryTimestampMs(e),
sequence: index,
textKey: e.type === 'assistant' && text ? text : undefined,
normalizedTextKey: e.type === 'assistant' && text ? normalizeTextKey(text) : undefined,
// Assistant rows: both keys, for live-text suppression. User rows:
// normalized only — NOT for suppression (ownership sets are built from
// assistant-text candidates exclusively) but for optimistic-prompt
// reconciliation: the committed user row owns its optimistic stand-in
// by normalized text (marker+text, never tail position).
normalizedTextKey: text ? normalizeTextKey(text) : undefined,
})
decisions.push({ candidateId: id, selected: true, reason: 'selected', evidence: [] })
})
Expand Down
116 changes: 116 additions & 0 deletions src/renderer/src/rendering/observations/local.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import type { AgentProviderKind } from '@shared/types/providerKind'
import { normalizeTextKey } from '@renderer/rendering/observations/committed'
import type { RenderCandidate } from '@renderer/rendering/model/types'

// ---------------------------------------------------------------------------
// Local candidate collectors — optimistic submits + lifecycle (work/empty).
//
// Optimistic rows exist ONLY for echo providers (codex, opencode — the
// usesOptimisticUserEcho capability): their committed user row can lag or
// never arrive, so the submitted prompt needs a visible owner immediately.
// Claude never mints one (its queue-operation records are the provider-
// owned signal; a Claude optimistic row would double-render every prompt).
//
// The optimistic candidate carries the normalized prompt key because its
// whole lifecycle ends by RECONCILIATION: when the committed user row with
// matching normalized text lands, committed owns the prompt and the
// optimistic candidate is rejected with 'optimistic-owned-by-committed'.
// Matching is marker + normalized text, never tail position (a committed
// tool-result user row can land between the optimistic row and the real
// prompt — the documented two-pass lesson).
// ---------------------------------------------------------------------------

export type OptimisticPromptLike = {
/** The optimistic uuid (legacy prefix `optimistic-codex-user:` is the
* shared marker across echo providers — codex-named, provider-neutral). */
uuid: string
text: string
submittedAtMs: number | null
}

export function collectOptimisticCandidates(
prompts: readonly OptimisticPromptLike[],
provider: AgentProviderKind,
sessionId: string,
): RenderCandidate[] {
return prompts.map((p, i) => ({
id: `optimistic:${p.uuid}`,
owner: 'optimistic-submit' as const,
provider,
sourcePlane: 'local-submit' as const,
sessionId,
contentKind: 'user-text' as const,
// Submit wall-clock: the optimistic row stands in for its future
// committed twin, so it must sort exactly where the committed row will
// (same timestamp basis, and SOURCE_RANK ties it with committed rows).
timestampMs: p.submittedAtMs,
sequence: i,
textKey: p.text,
normalizedTextKey: normalizeTextKey(p.text),
}))
}

/**
* Lifecycle candidates. Work is a phase fact, never text (plan §7 rule 8):
* `streamPhase !== 'idle'` ALWAYS yields a work candidate even when every
* content candidate was suppressed as a duplicate — submit-in-flight,
* request wait, and tool wait are visible states with no text. Empty exists
* only when no content candidate survived; the ledger decides that AFTER
* ownership, so this collector takes the survivor count, not raw state.
*/
export function collectLifecycleCandidates(params: {
provider: AgentProviderKind
sessionId: string
streamPhaseIdle: boolean
hasContentCandidates: boolean
}): RenderCandidate[] {
const out: RenderCandidate[] = []
if (!params.hasContentCandidates) {
out.push({
id: 'empty',
owner: 'empty',
provider: params.provider,
sourcePlane: 'process',
sessionId: params.sessionId,
contentKind: 'empty',
timestampMs: null,
sequence: 0,
})
}
if (!params.streamPhaseIdle) {
out.push({
id: 'work',
owner: 'work',
provider: params.provider,
sourcePlane: 'process',
sessionId: params.sessionId,
contentKind: 'work',
timestampMs: null,
sequence: 1,
})
}
return out
}

/**
* Optimistic reconciliation, ledger-side: committed user rows own their
* optimistic stand-ins by normalized text. Exported for the ledger pass.
*/
export function committedUserTextKeys(
committed: readonly RenderCandidate[],
): ReadonlySet<string> {
const keys = new Set<string>()
for (const c of committed) {
if (c.contentKind === 'user-text' && c.owner === 'committed') {
// User rows don't carry textKey (only assistant rows do, for
// suppression) — reconciliation needs its own key derivation, so the
// committed collector is extended by callers via normalizedTextKey
// when present, else this set stays conservative and the optimistic
// row survives until an exact owner appears. Surviving too long is
// the visible-and-diagnosable failure; vanishing early is the silent
// one (#339) — we bias toward visible.
if (c.normalizedTextKey) keys.add(c.normalizedTextKey)
}
}
return keys
}