Merge remote-tracking branch 'origin/worktree/session-format-03-v0-v1-migration' into worktree/session-format-04-live-assistant-stream

# Conflicts:
#	docs/event-producer-consumer.i18n.yaml
#	docs/event-producer-consumer.md
#	docs/event-producer-consumer.zh.md
#	packages/core/agent/src/runtime-types.ts
This commit is contained in:
Tianyi Cui
2026-09-02 20:58:27 +08:00
208 changed files with 4067 additions and 355 deletions
@@ -86,6 +86,20 @@ export interface ISession {
signal?: AbortSignal,
requestId?: SessionRequestId,
): Promise<RemoteResult<{ accepted: true }>>
/**
* Replace the latest current turn-opening human message and rerun from it.
* @param messageSeq - selected durable user-message event.
* @param expectedLastUserSeq - optimistic conversation revision captured by the editor.
* @param text - replacement text; retained non-text blocks remain Host-owned.
* @param signal - optional caller cancellation before admission.
* @returns the committed replacement event seq, or a business error.
*/
edit(
messageSeq: number,
expectedLastUserSeq: number,
text: string,
signal?: AbortSignal,
): Promise<RemoteResult<{ accepted: true; messageSeq: number }>>
/**
* Resolve one durable image referenced by this session.
* @param attachmentId - opaque id found in the folded session log.
@@ -52,12 +52,26 @@ export interface PendingSubmission {
readonly images: readonly PendingSubmissionImage[]
}
/** One local same-session edit awaiting its durable replacement message. */
export interface PendingEdit {
/** RPC identity echoed by the replacement `user/message`. */
readonly requestId: SessionRequestId
/** Existing user-message event being replaced. */
readonly targetSeq: number
/** Latest human message observed when edit mode opened. */
readonly expectedLastUserSeq: number
/** Replacement text submitted by the inline editor. */
readonly text: string
/** Client wall-clock ms used by the optimistic replacement bubble. */
readonly time: number
}
/** History-open lifecycle of a Session event window. */
export type OpenState = 'cold' | 'loading' | 'open' | 'error'
/** Send/stop failure surfaced by Session consumers. */
export interface PromptError {
readonly op: 'send' | 'stop'
readonly op: 'send' | 'stop' | 'edit'
readonly error: RemoteFailure
}
@@ -67,6 +81,8 @@ export interface SessionSnapshot {
readonly queue: readonly QueuedMessage[]
/** Local prompt-submission echoes not yet observed as durable events or queue occurrences. */
readonly pendingSubmissions: readonly PendingSubmission[]
/** Same-session edit waiting for its replacement user message. */
readonly pendingEdit: PendingEdit | null
readonly running: boolean
readonly subagent: {
readonly address: SubagentAddress
@@ -22,7 +22,7 @@ import type {
BeginSubmissionInput, PendingSubmissionRetirement, SessionFace, SubmissionHandle,
} from '../contract/session.ts'
import type {
OpenState, PendingSubmission, PromptError, SessionSnapshot,
OpenState, PendingEdit, PendingSubmission, PromptError, SessionSnapshot,
} from '../contract/snapshot.ts'
import { MutableSessionEventSource } from '../contract/events.ts'
import type {
@@ -119,6 +119,7 @@ export class Session implements SessionFace {
private lastAgentError: string | null = null
/** Local submission echoes, insertion-ordered (see SessionSnapshot.pendingSubmissions). */
private pendingSubmissions: readonly PendingSubmission[] = []
private pendingEdit: PendingEdit | null = null
/** Per-echo settlement state; `retiring` latches the first observation so a
* queue frame and its durable event cannot both retire one echo. */
private readonly submissionSettlements = new Map<SessionRequestId, {
@@ -285,6 +286,45 @@ export class Session implements SessionFace {
return result
}
/** Submit one same-session edit with an immediate optimistic replacement. */
async edit(
messageSeq: number,
expectedLastUserSeq: number,
text: string,
signal?: AbortSignal,
): Promise<RemoteResult<{ accepted: true; messageSeq: number }>> {
const requestId = randomUUID() as SessionRequestId
this.pendingEdit = { requestId, targetSeq: messageSeq, expectedLastUserSeq, text, time: Date.now() }
this.promptError = null
this.lastAgentError = null
this.notifier.markDirty()
let result: RemoteResult<{ accepted: true; messageSeq: number }>
try {
result = await this.remote.session.edit({
requestId,
sessionId: this.sessionId,
messageSeq,
expectedLastUserSeq,
text,
clientTimeZone: resolvedClientTimeZone(),
}, signal)
} catch (error: unknown) {
const cleared = this.clearPendingEdit(requestId)
if (isRemoteFailure(error)) {
this.promptError = { op: 'edit', error }
this.notifier.markDirty()
return { ok: false, error }
}
if (cleared) this.notifier.markDirty()
throw error
}
if (!result.ok && this.clearPendingEdit(requestId)) {
this.promptError = { op: 'edit', error: result.error }
this.notifier.markDirty()
}
return result
}
/**
* Resolve one image referenced by this session into browser-consumable bytes.
* @param attachmentId - opaque id found in the folded session log.
@@ -588,6 +628,12 @@ export class Session implements SessionFace {
// ---- Private ----
private clearPendingEdit(requestId: SessionRequestId): boolean {
if (this.pendingEdit === null || this.pendingEdit.requestId !== requestId) return false
this.pendingEdit = null
return true
}
/** @param generation - openGeneration at launch; stale passes cannot publish after replacement. */
private async doOpen(generation: number): Promise<void> {
this.openState = 'loading'
@@ -696,6 +742,17 @@ export class Session implements SessionFace {
/** Retire the matching echo when a durable browser-prompt `user/message` becomes visible. */
private observeSubmissionEvent(event: { readonly type: string; readonly data?: unknown }): void {
if (event.type === 'user/message' && this.pendingEdit !== null) {
const source = (event.data as { readonly source?: { readonly rpcId?: unknown } } | undefined)?.source
if (source?.rpcId === this.pendingEdit.requestId) {
const requestId = this.pendingEdit.requestId
scheduleFrame(() => {
if (this.pendingEdit?.requestId !== requestId) return
this.pendingEdit = null
this.notifier.markDirty()
})
}
}
if (this.submissionSettlements.size === 0 || event.type !== 'user/message') return
// Structural read: window entries may be compact history records, so the
// fields are narrowed rather than trusted (same posture as Conversation
@@ -769,6 +826,7 @@ export class Session implements SessionFace {
sessionId: this.sessionId,
queue: this.queueMirror.snapshot(),
pendingSubmissions: this.pendingSubmissions,
pendingEdit: this.pendingEdit,
running: this.running,
subagent: this.address === undefined
? null