Files
orca/src/shared/native-chat-transcript-projection.ts
T
f199a20c3a Preserve OpenCode reasoning and recorded patches in native history (#24790)
* Use bounded OpenCode context for vault session continuation

OpenCode database and synthetic row paths are not text transcripts. Use the
vault preview or captured pane context, preserving actual transcript paths
containing a hash and supporting both OpenCode lanes and Windows paths.

Adapted the intent of #11859 and extended it to actual installed v2 vault rows.

Co-authored-by: mrcha033 <mrcha033@users.noreply.github.com>

* Read real OpenCode sessions in terminal-backed native Chat

Reuse the bounded AI Vault SQLite worker for v1 and v2 session pages and live updates. Keep terminal input as the real execution path and pace OpenCode Stop through its two-Escape interrupt.

Co-authored-by: xodmd45-ctrl <xodmd45-ctrl@users.noreply.github.com>

* fix(opencode): publish approval cards for permission requests

* Send OpenCode native approval through its Enter selector

* Resolve mobile Chat readability for folder workspaces

* Bound OpenCode part batches and preserve v2 image attachments

* Prefer live migrated OpenCode sessions over legacy copies

* Consolidate mobile Chat eligibility test imports

* Consolidate OpenCode SQLite protocol type imports

* fix(native-chat): preserve OpenCode reasoning and patch parts

Separate genuine reasoning from answer blocks in both native SQLite schemas and retain recorded patches as completed patch tools. Keep each database row together at page boundaries so the existing raw-row cursors cannot drop half of a mixed row.

Adapted from @akhan157's OpenCode native history work in #13287 at bb661d10d716764fb472d824cd434678875b1947; retains the current bounded reader and account discovery instead of restoring the older capture and cursor implementation.

Verified against genuine private installed 2.0.16 and official 1.18.30 CLI ingestion.

Co-authored-by: Adnan Khan <adnank11427@gmail.com>

* fix(native-chat): keep split OpenCode rows intact on desktop and mobile

Preserve the native reader's bounded OpenCode row groups in paired reads and snapshot/replacement frames so a second presentation-count slice cannot drop reasoning while advancing the database cursor. Sort derived reasoning before its answer under the same provider timestamp while retaining journal order.

These two boundaries were reproduced with genuine installed 2.0.16 and official 1.18.30 sessions in a hidden desktop renderer and the current mobile view over an actual authenticated encrypted pairing.

Completes the semantic presentation from @akhan157's #13287 without importing its older clipping or cursor implementation.

Co-authored-by: Adnan Khan <adnank11427@gmail.com>

* fix(native-chat): keep reasoning and answers together in live windows

* Bound OpenCode transcript RPC pages and present omission notices

* Bound OpenCode transcript RPC pages and present omission notices

* Bound OpenCode transcript RPC pages and present omission notices

* Update native worker oversized-history notice contract

---------

Co-authored-by: mrcha033 <mrcha033@users.noreply.github.com>
Co-authored-by: xodmd45-ctrl <xodmd45-ctrl@users.noreply.github.com>
Co-authored-by: nwparker <nwparker@users.noreply.github.com>
2026-10-03 22:04:52 -07:00

159 lines
6.1 KiB
TypeScript

// The transcript's projection of a conversation's messages into the rows it draws:
// ordered, tool runs folded into the turn that made them, harness turns dropped.
// Shared so the host's conversation outline asks "which user messages draw a row?"
// of exactly what the renderer's transcript runs, not a second reading of it.
//
// A session's subagents write into its journal, but their rows are not part of its
// conversation: each subagent's rows are that subagent's, kept apart and folded on
// their own, for the surface that shows them beside the spawn that ran it.
import { nativeChatSemanticRowId, type NativeChatMessage } from './native-chat-types'
import { compareAgentJournalPositions } from './agent-session-journal-position'
import { agentJournalItemSubagentId } from './agent-session-journal-producer'
import { stripNoiseMessages } from './native-chat-noise'
import { foldToolMessages } from './native-chat-tool-fold'
import { nativeChatTurnMembership, type NativeChatTurnJournal } from './native-chat-turn-membership'
/** Timestamp, then id. A null timestamp sorts first so a source that cannot supply
* one stays in place rather than jumping to the end. */
export function compareNativeChatMessagesByTime(
a: NativeChatMessage,
b: NativeChatMessage
): number {
const at = a.timestamp ?? Number.NEGATIVE_INFINITY
const bt = b.timestamp ?? Number.NEGATIVE_INFINITY
if (at !== bt) {
return at - bt
}
// Split reasoning shares its provider row's key, before that row's answer.
const aId = nativeChatSemanticRowId(a)
const bId = nativeChatSemanticRowId(b)
const aReasoning = aId !== a.id
const bReasoning = bId !== b.id
if (aId < bId) {
return -1
}
if (aId > bId) {
return 1
}
return Number(bReasoning) - Number(aReasoning)
}
/** Rows the journal holds read in the journal's own order, never its clock: a
* batch shares one timestamp, and a row recovered after a crash carries an
* earlier one. A row not in the journal yet — a send still in the outbox — was
* made after everything the journal holds, so it follows them; only such rows,
* and terminal-backed transcripts, which have no journal, order by time. */
export function compareNativeChatTranscriptMessages(
a: NativeChatMessage,
b: NativeChatMessage
): number {
if (a.journalPosition && b.journalPosition) {
return compareAgentJournalPositions(a.journalPosition, b.journalPosition)
}
if (a.journalPosition || b.journalPosition) {
return a.journalPosition ? -1 : 1
}
return compareNativeChatMessagesByTime(a, b)
}
type NativeChatMessageCompare = (a: NativeChatMessage, b: NativeChatMessage) => number
/** One of a subagent's rows, with the conversation turn it happened during. */
export type NativeChatSubagentRow = {
message: NativeChatMessage
turnKey: string | undefined
}
export type NativeChatTranscriptProjection = {
/** The session's own agent's rows. */
conversation: NativeChatMessage[]
/** Each subagent's own rows, in order, by the id they carry. */
subagentRows: ReadonlyMap<string, readonly NativeChatSubagentRow[]>
}
const NO_SUBAGENT_ROWS: ReadonlyMap<string, readonly NativeChatSubagentRow[]> = new Map()
function projectRows(messages: readonly NativeChatMessage[]): NativeChatMessage[] {
return stripNoiseMessages(foldToolMessages(messages))
}
function sortedCopy(
messages: readonly NativeChatMessage[],
compare: NativeChatMessageCompare
): NativeChatMessage[] {
// Not `toSorted`: mobile's Hermes lacks it, and src/shared must stay loadable there.
return Array.from(messages).sort(compare)
}
/** The conversation alone. `compare` lets the renderer order its own tail rows
* (streaming, optimistic sends), which never exist on the host. */
export function projectNativeChatTranscriptMessages(
messages: readonly NativeChatMessage[],
compare: NativeChatMessageCompare = compareNativeChatTranscriptMessages
): NativeChatMessage[] {
return projectNativeChatTranscript(messages, compare).conversation
}
/** `journal`: what places the conversation's rows in their turns
* (`nativeChatTurnMembership`), so a subagent's row sits in the turn a parent row there would. */
export function projectNativeChatTranscript(
messages: readonly NativeChatMessage[],
compare: NativeChatMessageCompare = compareNativeChatTranscriptMessages,
journal?: NativeChatTurnJournal | null
): NativeChatTranscriptProjection {
const sorted = sortedCopy(messages, compare)
const own: NativeChatMessage[] = []
const byAgent = new Map<string, NativeChatMessage[]>()
for (const message of sorted) {
const agentId = agentJournalItemSubagentId(message)
if (agentId === null) {
own.push(message)
} else {
const rows = byAgent.get(agentId)
if (rows) {
rows.push(message)
} else {
byAgent.set(agentId, [message])
}
}
}
const conversation = projectRows(own)
if (byAgent.size === 0) {
return { conversation, subagentRows: NO_SUBAGENT_ROWS }
}
// Only a user row the conversation draws opens a turn by position, never a subagent's.
const turnStarts = new Set(
conversation.filter((message) => message.role === 'user').map((message) => message.id)
)
const { turnKeys } = nativeChatTurnMembership(sorted, journal, (message) =>
turnStarts.has(message.id)
)
const turnOf = new Map(sorted.map((message, index) => [message, turnKeys[index]]))
const subagentRows = new Map<string, NativeChatSubagentRow[]>()
for (const [agentId, rows] of byAgent) {
// Folded one parent turn at a time: a run the subagent carries across a turn
// boundary splits there, so each of its edits counts in the turn it was made.
const projected: NativeChatSubagentRow[] = []
let chunk: NativeChatMessage[] = []
let chunkTurn: string | undefined
const flush = (): void => {
for (const message of projectRows(chunk)) {
projected.push({ message, turnKey: chunkTurn })
}
chunk = []
}
for (const message of rows) {
const turn = turnOf.get(message)
if (chunk.length > 0 && turn !== chunkTurn) {
flush()
}
chunkTurn = turn
chunk.push(message)
}
flush()
subagentRows.set(agentId, projected)
}
return { conversation, subagentRows }
}