merge: update the session-log-v4 integration base from master

Merge a2acc7d3eb620bb52f09f7ea3b5a3a1c23353e18 after preserving the earlier
master checkpoint and its successful CI. Carry current provider input-history
handling, UI controls, agent targeting, and runner-concurrency changes into
the integration base without changing the Session migration feature layers.

Keep durable direct-child catalog semantics alongside the upstream running /
inactive vocabulary. Resolve the snapshot description conflicts by retaining
that upstream status text and the existing descendants-only diagnostic rule;
the source and all 53 JSON tool descriptions agree. Preserve parent-session
availability handling with the upstream stale-blank and batched-history fixes.

The merged application builds, all 41 documentation gates pass, and focused
provider, tool-control, and Session Controller tests pass (98, 29, and 125).
Recorded input changes come from master: one refreshed v3 list-agents sample
and three new v3 scenarios; no migration bumps an existing snapshot generation.
This commit is contained in:
Tianyi Cui committed 2026-09-18 15:52:56 +08:00
commit c3cfe83d2e
343 files changed
+8368 -2217

No files matched your search

@@ -127,6 +127,8 @@ export interface ISession {
* turn-jump loader. Repeated calls while a jump is paging lower its shared
* target and return the in-flight completion; `snapshot.loadingOlder` is
* the busy signal for the whole jump.
* Older pages publish together at completion, including successful pages
* before a later failure; live events remain independently visible.
* @param seq - durable event seq the window must reach (a turn's `turn/start` seq).
* @returns completion once covered, exhausted, superseded, or failed soft.
*/
@@ -19,7 +19,7 @@ export interface SessionListEntry {
title?: string
updatedAt: number
running: boolean
/** Empty-log bit mirrored from the summary; lists hide blank sessions (filtering stays with the consumer). */
/** Summary blankness reconciled with `sessionListMetadata`; lists hide blank sessions (filtering stays with the consumer). */
blank: boolean
parentSessionId?: SessionId
/** Coarse durable origin for navigation filtering; not a continuation capability. */
@@ -219,6 +219,7 @@ export class SessionManager {
/**
* Lazy build: return the existing instance or construct one (no auto-open —
* the reference allocator opens history after binding the scope).
* New instances reconcile retained metadata before returning.
* @param sessionId - the session to get.
* @returns the resident instance.
*/
@@ -242,6 +243,9 @@ export class SessionManager {
// durable history, even though child rows do not carry `blank`.
session.handleBlank(false)
session.handleRunning(false)
} else {
// Retained metadata may have notified before this Session existed.
session.handleBlank(true)
}
}
}
@@ -272,9 +276,14 @@ export class SessionManager {
let store = this.projectionStores.get(sessionId)
if (store === undefined) {
store = new ProjectionValueStore()
// List rows project off store keys (title); any-key changes re-enter
// the manager's own batched rebuild channel.
store.subscribeAny(() => { this.notifier.markDirty() })
const projections = store
store.subscribeAny(() => {
// Newer history or control metadata corrects a resident Session's stale list hint.
if (projections.values().sessionListMetadata?.blank === false) {
this.sessions.get(sessionId)?.handleBlank(false)
}
this.notifier.markDirty()
})
this.projectionStores.set(sessionId, store)
}
return store
@@ -359,7 +368,7 @@ export class SessionManager {
this.listState = 'idle'
this.listPhase = 'ready'
this.updateParentAvailability()
// Push running/blank bits down to instantiated Sessions (the list is the authoritative summary source).
// Sessions reconcile list blank hints with their current metadata projection.
for (const s of this.summaries) {
const session = this.sessions.get(s.sessionId)
if (session === undefined) continue
@@ -674,8 +683,12 @@ export class SessionManager {
const projectionStore = this.projectionStores.get(summary.sessionId)
const title = projectionStore?.get('title')
const projectionValues = projectionStore?.values()
const metadata = projectionValues?.sessionListMetadata
return {
...summary,
// Cached list hints can precede a history opening or control update.
blank: summary.blank && metadata?.blank !== false,
updatedAt: Math.max(summary.updatedAt, metadata?.lastPromptAt ?? 0),
...(typeof title === 'string' && title !== '' ? { title } : {}),
...(projectionValues === undefined ? {} : { projectionValues }),
}
@@ -39,10 +39,10 @@ export interface SessionSummary {
/** Local ownership counts; Host metadata refreshes cannot overwrite them. */
readonly retainedBy: SessionRetainInfo['retainedBy']
/**
* Empty-log bit (host summary derivation mirror). New Session reuses a blank
* one targeting the same workspace. Filtering stays with the consumer: the
* store carries every row, while the Workspace browser shows only the
* selected blank entry.
* Blankness from the Host summary reconciled with `sessionListMetadata`.
* New Session reuses a blank one targeting the same workspace. Filtering
* stays with the consumer: the store carries every row, while the Workspace
* browser shows only the selected blank entry.
*/
blank: boolean
updatedAt: number
@@ -53,6 +53,12 @@ export const PAGE_MESSAGES = 50
/** Messages requested per page while a turn jump loops backwards (fewer, larger round trips). */
export const JUMP_PAGE_MESSAGES = 200
interface PendingHistory {
beforeSeq: SessionLogOffset
hasMore: boolean
readonly pages: (readonly SessionEventLikeEntry[])[]
}
/** Manager-owned observers of a Session object's local state edges. */
export interface SessionOptions {
/** Catalog-discovered address selecting non-activating subagent transport. */
@@ -97,6 +103,7 @@ export class Session implements SessionFace {
private jumpTargetSeq: SessionSeq | null = null
/** The running jump loop's completion, shared by retargeting callers. */
private jumpPromise: Promise<void> | null = null
private pendingHistory: PendingHistory | null = null
private readonly stopObservingInbox: () => void
private readonly assistantStream = new ClientAssistantStream()
private running = false
@@ -419,6 +426,14 @@ export class Session implements SessionFace {
// target behind — only the loop's finally clears that field, and no
// loop starts here.
if (this.loadingOlder) return Promise.resolve()
const events = this.events
if (events === undefined) return Promise.resolve()
const pending: PendingHistory = {
beforeSeq: this.baseSeq,
hasMore: this.hasMore,
pages: [],
}
this.pendingHistory = pending
this.jumpTargetSeq = seq
this.loadingOlder = true
this.notifier.markDirty()
@@ -428,15 +443,13 @@ export class Session implements SessionFace {
const generation = this.openGeneration
this.jumpPromise = (async () => {
try {
while (this.hasMore && this.jumpTargetSeq !== null && this.baseSeq > this.jumpTargetSeq) {
while (pending.hasMore && this.jumpTargetSeq !== null && pending.beforeSeq > this.jumpTargetSeq) {
if (generation !== this.openGeneration) return
const events = this.events
if (events === undefined) return
const before = this.baseSeq
await events.prepend({ beforeSeq: this.baseSeq, maxMessages: JUMP_PAGE_MESSAGES })
const before = pending.beforeSeq
await events.prepend({ beforeSeq: before, maxMessages: JUMP_PAGE_MESSAGES })
// No-progress guard: an empty or dropped page that still claims more
// history must end the loop, not spin it.
if (this.baseSeq >= before) return
if (pending.beforeSeq >= before) return
}
} catch (error) {
if (!isRemoteFailure(error)) {
@@ -445,7 +458,11 @@ export class Session implements SessionFace {
} finally {
this.jumpTargetSeq = null
this.jumpPromise = null
this.pendingHistory = null
this.loadingOlder = false
if (generation === this.openGeneration && pending.pages.length > 0) {
this.prependWindow(pending.pages.reverse().flat(), pending.hasMore)
}
this.notifier.markDirty()
}
})()
@@ -535,13 +552,12 @@ export class Session implements SessionFace {
}
/**
* Blank-bit relay from the authoritative summary source (`session.list` and
* `api-session/added`). Monotone: once any signal (local first send,
* running flip, an earlier summary) cleared it, a stale true never
* re-blanks.
* @param blank - the summary's derived empty-log bit.
* Relay list blankness without overriding a started conversation established
* by the current projection, a local prompt, or running state.
* @param blank - whether the list or projection reports an unstarted conversation.
*/
handleBlank(blank: boolean): void {
blank = blank && this.projections.values().sessionListMetadata?.blank !== false
if (blank === this.blankBit) return
if (blank && (this.promptAttempted || this.running)) return
this.blankBit = blank
@@ -648,6 +664,11 @@ export class Session implements SessionFace {
const visible = this.assistantStream.replace(entries, assistantStream)
this.baseSeq = SessionLogOffset(entries[0]?.event.seq ?? 0)
this.hasMore = hasMore
if (this.pendingHistory !== null) {
this.pendingHistory.beforeSeq = this.baseSeq
this.pendingHistory.hasMore = hasMore
this.pendingHistory.pages.length = 0
}
if (visible.some(entry => entry.event.type === 'turn/start')) this.firstPromptPendingTurn = false
if (projections !== undefined) this.projections.seed(projections)
this.eventSource.replace(visible, hasMore)
@@ -684,6 +705,13 @@ export class Session implements SessionFace {
/** Prepend one stream-validated history page. */
private prependWindow(entries: readonly SessionEventLikeEntry[], hasMore: boolean): void {
if (this.pendingHistory !== null) {
const pending = this.pendingHistory
pending.beforeSeq = entries[0] === undefined ? pending.beforeSeq : SessionLogOffset(entries[0].event.seq)
pending.hasMore = hasMore
pending.pages.push(entries)
return
}
this.baseSeq = entries[0] === undefined ? this.baseSeq : SessionLogOffset(entries[0].event.seq)
this.hasMore = hasMore
this.eventSource.prepend(entries, hasMore)