mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-08-29 04:26:38 +00:00
feat(session-controller): 客户端本地提交回显与 rpcId 关联
beginSubmission 在 prompt 之前同步把本地提交回显写入 SessionSnapshot.pendingSubmissions; durable user/message(source.rpcId)或队列投影(SessionQueuedItem.rpcId)到达后延迟一帧退休, prompt 失败与放弃立即退休并回调 onRetire。fixture 的 prompt 同步回显 requestId。
This commit is contained in:
@@ -12,9 +12,37 @@ import type { MessageId } from '@deepseek-ai/dsh-llm/brand'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
import type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol'
|
||||
import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store'
|
||||
import type { PromptContentPart, QueueAction } from '../../types.ts'
|
||||
import type { PromptContentPart, QueueAction, SessionRequestId } from '../../types.ts'
|
||||
import type { ClientResult } from './result.ts'
|
||||
import type { SessionSnapshot } from './snapshot.ts'
|
||||
import type { PendingSubmissionImage, SessionSnapshot } from './snapshot.ts'
|
||||
|
||||
/**
|
||||
* Why a local submission echo left the snapshot: `observed` when its durable
|
||||
* `user/message` event or host queue occurrence arrived (with the admitted
|
||||
* image references in prompt order), `failed` when the prompt was rejected,
|
||||
* threw, or was aborted before acceptance.
|
||||
*/
|
||||
export type PendingSubmissionRetirement =
|
||||
| { readonly reason: 'observed'; readonly attachments: readonly ImageAttachmentRef[] }
|
||||
| { readonly reason: 'failed' }
|
||||
|
||||
/** Input registering one local submission echo ahead of its prompt call. */
|
||||
export interface BeginSubmissionInput {
|
||||
/** Prompt text exactly as the upcoming prompt will send it. */
|
||||
readonly text: string
|
||||
/** Ordered image previews matching the upcoming prompt's image parts. */
|
||||
readonly images: readonly PendingSubmissionImage[]
|
||||
/** Settlement callback fired exactly once when the echo retires. */
|
||||
readonly onRetire?: (retirement: PendingSubmissionRetirement) => void
|
||||
}
|
||||
|
||||
/** One registered submission echo: the identity its prompt must carry, and the pre-prompt escape hatch. */
|
||||
export interface SubmissionHandle {
|
||||
/** The prompt RPC identity; pass it to {@link ISession.prompt}. */
|
||||
readonly requestId: SessionRequestId
|
||||
/** Retire the echo as failed when the caller cannot reach prompt() (serialization failure); no-op after any other settlement. */
|
||||
abandon(): void
|
||||
}
|
||||
|
||||
/** Key-addressed projection read face (the useProjection resolution path; see ProjectionValueStore). */
|
||||
export interface ProjectionsFace {
|
||||
@@ -33,16 +61,29 @@ export interface ISession {
|
||||
readonly sessionId: SessionId
|
||||
/** Host-computed projection values by key (the useProjection seat). */
|
||||
readonly projections: ProjectionsFace
|
||||
/**
|
||||
* Register one local submission echo in `snapshot.pendingSubmissions`,
|
||||
* synchronously, before the caller serializes and sends the prompt. The
|
||||
* echo retires when a durable `user/message` event or queue occurrence
|
||||
* carrying the returned identity arrives, or when the identified prompt
|
||||
* call fails.
|
||||
* @param input - echo content and the optional settlement callback.
|
||||
* @returns the minted identity for {@link prompt} plus the pre-prompt abandon path.
|
||||
*/
|
||||
beginSubmission(input: BeginSubmissionInput): SubmissionHandle
|
||||
/**
|
||||
* Send a prompt into the session.
|
||||
* @param content - text plus browser-owned temporary image uploads.
|
||||
* @param mode - 'queue' appends a turn; 'steer' interrupts the running one.
|
||||
* @param signal - optional caller cancellation for the complete admission round-trip.
|
||||
* @param requestId - identity from {@link beginSubmission}; a failed identified prompt retires its echo.
|
||||
* @returns acceptance, or the business error (also mirrored into snapshot.promptError).
|
||||
*/
|
||||
prompt(
|
||||
content: PromptContentPart[],
|
||||
mode: 'queue' | 'steer',
|
||||
signal?: AbortSignal,
|
||||
requestId?: SessionRequestId,
|
||||
): Promise<ClientResult<{ accepted: true }>>
|
||||
/**
|
||||
* Resolve one durable image referenced by this session.
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
|
||||
import type { MessageId } from '@deepseek-ai/dsh-llm/brand'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
import type { SubagentAddress } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { SessionRequestId } from '../../types.ts'
|
||||
import type { ClientFailure } from './result.ts'
|
||||
|
||||
/** One transient inbox occurrence from the authoritative queue snapshot. */
|
||||
@@ -10,11 +11,42 @@ export interface QueuedMessage {
|
||||
readonly id: MessageId
|
||||
readonly messageId: MessageId
|
||||
readonly placement: 'queued' | 'steering' | 'context'
|
||||
/** Prompt-RPC identity of a browser-submitted occurrence; correlates the local submission echo. */
|
||||
readonly rpcId?: SessionRequestId
|
||||
readonly content: readonly ContentBlock[]
|
||||
readonly preview: string
|
||||
readonly text: string | null
|
||||
}
|
||||
|
||||
/** One image displayed by a local submission echo before durable admission. */
|
||||
export interface PendingSubmissionImage {
|
||||
/** Browser-owned preview URL; its lifecycle belongs to the submitter, never this snapshot. */
|
||||
readonly previewUrl: string
|
||||
/** Browser file name, when the file had one. */
|
||||
readonly name?: string
|
||||
/** Intrinsic pixel width, when the submitter has probed it. */
|
||||
readonly width?: number
|
||||
/** Intrinsic pixel height, when the submitter has probed it. */
|
||||
readonly height?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* One local prompt-submission echo: inserted synchronously when a submission
|
||||
* begins, so the conversation can show the message before serialization,
|
||||
* transport, and durable admission complete. Client-memory only — reload and
|
||||
* reconnect rebuild the conversation from durable events alone.
|
||||
*/
|
||||
export interface PendingSubmission {
|
||||
/** The prompt RPC identity; the durable `user/message` source echoes it as `rpcId`. */
|
||||
readonly requestId: SessionRequestId
|
||||
/** Client wall-clock ms when the submission began. */
|
||||
readonly time: number
|
||||
/** Prompt text exactly as it will be sent (one text block). */
|
||||
readonly text: string
|
||||
/** Ordered image previews matching the prompt's image parts. */
|
||||
readonly images: readonly PendingSubmissionImage[]
|
||||
}
|
||||
|
||||
/** History-open lifecycle of a Session event window. */
|
||||
export type OpenState = 'cold' | 'loading' | 'open' | 'error'
|
||||
|
||||
@@ -28,6 +60,8 @@ export interface PromptError {
|
||||
export interface SessionSnapshot {
|
||||
readonly sessionId: SessionId
|
||||
readonly queue: readonly QueuedMessage[]
|
||||
/** Local prompt-submission echoes not yet observed as durable events or queue occurrences. */
|
||||
readonly pendingSubmissions: readonly PendingSubmission[]
|
||||
readonly running: boolean
|
||||
readonly subagent: {
|
||||
readonly address: SubagentAddress
|
||||
|
||||
@@ -40,7 +40,14 @@ export type {
|
||||
SessionProjectionMap,
|
||||
UseProjection,
|
||||
} from './sessions/projection-store.ts'
|
||||
export type { ISession, ProjectionsFace, SessionFace } from './contract/session.ts'
|
||||
export type {
|
||||
BeginSubmissionInput,
|
||||
ISession,
|
||||
PendingSubmissionRetirement,
|
||||
ProjectionsFace,
|
||||
SessionFace,
|
||||
SubmissionHandle,
|
||||
} from './contract/session.ts'
|
||||
export type { ISessions } from './contract/sessions.ts'
|
||||
export { MutableSessionEventSource } from './contract/events.ts'
|
||||
export type {
|
||||
@@ -53,6 +60,8 @@ export type {
|
||||
} from './contract/events.ts'
|
||||
export type {
|
||||
OpenState,
|
||||
PendingSubmission,
|
||||
PendingSubmissionImage,
|
||||
PromptError,
|
||||
QueuedMessage,
|
||||
SessionSnapshot,
|
||||
|
||||
@@ -43,6 +43,7 @@ export class SessionQueueMirror {
|
||||
id: item.id,
|
||||
messageId: item.message.id,
|
||||
placement: item.placement,
|
||||
...(item.rpcId === undefined ? {} : { rpcId: item.rpcId }),
|
||||
content,
|
||||
preview: previewOf(content),
|
||||
text: textOf(content),
|
||||
|
||||
@@ -24,9 +24,11 @@ import type {
|
||||
} from '../../types.ts'
|
||||
import type { ClientFailure, ClientResult } from '../contract/result.ts'
|
||||
import { transportResult } from '../contract/result.ts'
|
||||
import type { SessionFace } from '../contract/session.ts'
|
||||
import type {
|
||||
OpenState, PromptError, SessionSnapshot,
|
||||
BeginSubmissionInput, PendingSubmissionRetirement, SessionFace, SubmissionHandle,
|
||||
} from '../contract/session.ts'
|
||||
import type {
|
||||
OpenState, PendingSubmission, PromptError, SessionSnapshot,
|
||||
} from '../contract/snapshot.ts'
|
||||
import { MutableSessionEventSource } from '../contract/events.ts'
|
||||
import type {
|
||||
@@ -101,6 +103,14 @@ export class Session implements SessionFace {
|
||||
private removed = false
|
||||
private promptError: PromptError | null = null
|
||||
private lastAgentError: string | null = null
|
||||
/** Local submission echoes, insertion-ordered (see SessionSnapshot.pendingSubmissions). */
|
||||
private pendingSubmissions: readonly PendingSubmission[] = []
|
||||
/** 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, {
|
||||
readonly onRetire?: ((retirement: PendingSubmissionRetirement) => void) | undefined
|
||||
retiring: boolean
|
||||
}>()
|
||||
/** Owns the addressed page/follow lifecycle while this Session is open. */
|
||||
private events: SessionEventStream | undefined
|
||||
|
||||
@@ -172,16 +182,42 @@ export class Session implements SessionFace {
|
||||
|
||||
// ---- Operations ----
|
||||
|
||||
/**
|
||||
* Register one local submission echo (see the ISession declaration).
|
||||
* Synchronous through markDirty: the echo is in the very next snapshot, so
|
||||
* the conversation can paint it before the caller starts serializing.
|
||||
* @param input - echo content and the optional settlement callback.
|
||||
* @returns the minted identity for {@link prompt} plus the pre-prompt abandon path.
|
||||
*/
|
||||
beginSubmission(input: BeginSubmissionInput): SubmissionHandle {
|
||||
const requestId = randomUUID() as SessionRequestId
|
||||
this.pendingSubmissions = [...this.pendingSubmissions, {
|
||||
requestId,
|
||||
time: Date.now(),
|
||||
text: input.text,
|
||||
images: input.images,
|
||||
}]
|
||||
this.submissionSettlements.set(requestId, { onRetire: input.onRetire, retiring: false })
|
||||
// The blank → engaging edge flips here, ahead of prompt(): the composer
|
||||
// docks and the echo renders on the click's own frame.
|
||||
this.promptAttempted = true
|
||||
this.notifier.markDirty()
|
||||
return { requestId, abandon: () => { this.retireFailedSubmission(requestId) } }
|
||||
}
|
||||
|
||||
/**
|
||||
* Send (queue/steer passed through 1:1); failures land in the snapshot's promptError.
|
||||
* @param content - text plus browser-owned temporary image uploads.
|
||||
* @param mode - queue appends after the current turn; steer interrupts it.
|
||||
* @param signal - optional caller cancellation for the complete admission round-trip.
|
||||
* @param requestId - identity from {@link beginSubmission}; a failed identified prompt retires its echo.
|
||||
* @returns the prompt result (also mirrored into promptError on failure).
|
||||
*/
|
||||
async prompt(
|
||||
content: PromptContentPart[],
|
||||
mode: 'queue' | 'steer',
|
||||
signal?: AbortSignal,
|
||||
requestId?: SessionRequestId,
|
||||
): Promise<ClientResult<{ accepted: true }>> {
|
||||
this.promptError = null
|
||||
this.lastAgentError = null
|
||||
@@ -196,7 +232,7 @@ export class Session implements SessionFace {
|
||||
if (this.address === undefined) {
|
||||
const clientTimeZone = resolvedClientTimeZone()
|
||||
result = toSessionResult(await this.remote.session.prompt({
|
||||
requestId: randomUUID() as SessionRequestId,
|
||||
requestId: requestId ?? randomUUID() as SessionRequestId,
|
||||
sessionId: this.sessionId,
|
||||
mode,
|
||||
content,
|
||||
@@ -236,6 +272,7 @@ export class Session implements SessionFace {
|
||||
result = transportResult(error)
|
||||
}
|
||||
if (!result.ok) {
|
||||
if (requestId !== undefined) this.retireFailedSubmission(requestId)
|
||||
this.promptError = { op: 'send', error: result.error }
|
||||
this.notifier.markDirty()
|
||||
return result
|
||||
@@ -434,6 +471,7 @@ export class Session implements SessionFace {
|
||||
*/
|
||||
replaceControl(queue: readonly SessionQueuedItem[]): void {
|
||||
this.queueMirror.replace(queue)
|
||||
this.observeSubmissionQueue(queue)
|
||||
this.notifier.markDirty()
|
||||
}
|
||||
|
||||
@@ -443,6 +481,7 @@ export class Session implements SessionFace {
|
||||
*/
|
||||
handleControlFrame(frame: Extract<SessionControlFrame, { type: 'queue' }>): void {
|
||||
this.queueMirror.replace(frame.items)
|
||||
this.observeSubmissionQueue(frame.items)
|
||||
this.notifier.markDirty()
|
||||
}
|
||||
|
||||
@@ -523,6 +562,12 @@ export class Session implements SessionFace {
|
||||
* @returns when the Remote iterator has completed teardown.
|
||||
*/
|
||||
async dispose(): Promise<void> {
|
||||
// Unsettled echoes retire as failed so their owners can restore or
|
||||
// release browser resources; echoes already scheduled as observed keep
|
||||
// that settlement.
|
||||
for (const requestId of [...this.submissionSettlements.keys()]) {
|
||||
this.retireFailedSubmission(requestId)
|
||||
}
|
||||
this.openGeneration++
|
||||
const events = this.events
|
||||
this.events = undefined
|
||||
@@ -581,6 +626,7 @@ export class Session implements SessionFace {
|
||||
if (entries.some(entry => entry.event.type === 'turn/start')) this.firstPromptPendingTurn = false
|
||||
if (projections !== undefined) this.projections.seed(projections)
|
||||
this.eventSource.replace(entries, hasMore)
|
||||
for (const entry of entries) this.observeSubmissionEvent(entry.event)
|
||||
this.notifier.markDirty()
|
||||
}
|
||||
|
||||
@@ -598,9 +644,70 @@ export class Session implements SessionFace {
|
||||
if (event.type === 'turn/start') this.firstPromptPendingTurn = false
|
||||
const queueChanged = this.queueMirror.acceptDurable(event)
|
||||
this.eventSource.append(entry)
|
||||
// After the feed append: the conversation assembly's animation frame is
|
||||
// registered by the feed subscribers above, so the echo-retirement frame
|
||||
// scheduled here always runs after the durable node became renderable.
|
||||
this.observeSubmissionEvent(event)
|
||||
return queueChanged || awaitingFirstTurn !== this.firstPromptPendingTurn
|
||||
}
|
||||
|
||||
/** Retire the matching echo when a durable browser-prompt `user/message` becomes visible. */
|
||||
private observeSubmissionEvent(event: { readonly type: string; readonly data?: unknown }): void {
|
||||
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
|
||||
// assembly matchers).
|
||||
const data = event.data as { readonly source?: unknown; readonly content?: unknown } | undefined
|
||||
const source = data?.source as { readonly kind?: unknown; readonly rpcId?: unknown } | undefined
|
||||
if (source?.kind !== 'user' || typeof source.rpcId !== 'string') return
|
||||
this.scheduleObservedRetirement(source.rpcId as SessionRequestId, imageRefsIn(data?.content))
|
||||
}
|
||||
|
||||
/** Retire echoes whose prompts landed in the host inbox instead of the log (running-turn submissions). */
|
||||
private observeSubmissionQueue(items: readonly SessionQueuedItem[]): void {
|
||||
if (this.submissionSettlements.size === 0) return
|
||||
for (const item of items) {
|
||||
if (item.rpcId !== undefined) {
|
||||
this.scheduleObservedRetirement(item.rpcId, imageRefsIn(item.message.content))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Latch one observed settlement and remove the echo an animation frame
|
||||
* later. The delay keeps the echo in the snapshot until the frame in which
|
||||
* the durable node (whose assembly frame was registered first) is
|
||||
* renderable; the render-time rpcId dedupe hides the one-frame overlap.
|
||||
*/
|
||||
private scheduleObservedRetirement(
|
||||
requestId: SessionRequestId,
|
||||
attachments: readonly ImageAttachmentRef[],
|
||||
): void {
|
||||
const settlement = this.submissionSettlements.get(requestId)
|
||||
if (settlement === undefined || settlement.retiring) return
|
||||
settlement.retiring = true
|
||||
scheduleFrame(() => { this.finishSubmission(requestId, { reason: 'observed', attachments }) })
|
||||
}
|
||||
|
||||
/** Remove one unsettled echo immediately (prompt rejection, abort, or disposal). */
|
||||
private retireFailedSubmission(requestId: SessionRequestId): void {
|
||||
const settlement = this.submissionSettlements.get(requestId)
|
||||
if (settlement === undefined || settlement.retiring) return
|
||||
settlement.retiring = true
|
||||
this.finishSubmission(requestId, { reason: 'failed' })
|
||||
}
|
||||
|
||||
/** Single removal point: drop the echo, publish, then notify the owner. */
|
||||
private finishSubmission(requestId: SessionRequestId, retirement: PendingSubmissionRetirement): void {
|
||||
const settlement = this.submissionSettlements.get(requestId)
|
||||
/* v8 ignore next -- retiring latches before every schedule, so one settlement never finishes twice. */
|
||||
if (settlement === undefined) return
|
||||
this.submissionSettlements.delete(requestId)
|
||||
this.pendingSubmissions = this.pendingSubmissions.filter(echo => echo.requestId !== requestId)
|
||||
this.notifier.markDirty()
|
||||
settlement.onRetire?.(retirement)
|
||||
}
|
||||
|
||||
/** Publish a terminal background failure only while this stream still owns the Session. */
|
||||
private failEventStream(events: SessionEventStream, generation: number, error: unknown): void {
|
||||
if (generation !== this.openGeneration || this.events !== events) return
|
||||
@@ -617,6 +724,7 @@ export class Session implements SessionFace {
|
||||
return {
|
||||
sessionId: this.sessionId,
|
||||
queue: this.queueMirror.snapshot(),
|
||||
pendingSubmissions: this.pendingSubmissions,
|
||||
running: this.running,
|
||||
subagent: this.address === undefined
|
||||
? null
|
||||
@@ -644,6 +752,26 @@ export class Session implements SessionFace {
|
||||
}
|
||||
}
|
||||
|
||||
/** Run one callback on the next animation frame, or a macrotask where no frame clock exists. */
|
||||
function scheduleFrame(fn: () => void): void {
|
||||
if (typeof requestAnimationFrame === 'function') requestAnimationFrame(() => { fn() })
|
||||
else setTimeout(fn, 0)
|
||||
}
|
||||
|
||||
/** Image attachment references in one structurally-read content block list, in block order. */
|
||||
function imageRefsIn(content: unknown): readonly ImageAttachmentRef[] {
|
||||
if (!Array.isArray(content)) return []
|
||||
const refs: ImageAttachmentRef[] = []
|
||||
for (const block of content) {
|
||||
if (typeof block !== 'object' || block === null) continue
|
||||
const candidate = block as { readonly type?: unknown; readonly attachment?: unknown }
|
||||
if (candidate.type === 'image' && typeof candidate.attachment === 'object' && candidate.attachment !== null) {
|
||||
refs.push(candidate.attachment as ImageAttachmentRef)
|
||||
}
|
||||
}
|
||||
return refs
|
||||
}
|
||||
|
||||
/** Convert a terminal Session stream failure to the Client error vocabulary. */
|
||||
function openFailure(error: unknown): ClientFailure {
|
||||
const failure = sessionStreamFailure(error)
|
||||
|
||||
@@ -188,16 +188,24 @@ function queueItems(
|
||||
...project('next-turn').map(message => ({
|
||||
id: message.id,
|
||||
placement: 'queued' as const,
|
||||
...promptRpcId(message),
|
||||
message: { id: message.id, content: message.content as unknown as JsonValue[] },
|
||||
})),
|
||||
...project('next-step').map(message => ({
|
||||
id: message.id,
|
||||
placement: message.source.kind === 'user' ? 'steering' as const : 'context' as const,
|
||||
...promptRpcId(message),
|
||||
message: { id: message.id, content: message.content as unknown as JsonValue[] },
|
||||
})),
|
||||
]
|
||||
}
|
||||
|
||||
/** Prompt-RPC identity carried by a browser-submitted message's user source. */
|
||||
function promptRpcId(message: UserMessage): Pick<SessionQueuedItem, 'rpcId'> {
|
||||
const source = message.source
|
||||
return source.kind === 'user' && 'rpcId' in source ? { rpcId: source.rpcId } : {}
|
||||
}
|
||||
|
||||
function jobView(job: JobSnapshot): SessionJob {
|
||||
return {
|
||||
id: job.id,
|
||||
|
||||
@@ -432,6 +432,8 @@ export type SessionFollowFrame =
|
||||
export interface SessionQueuedItem {
|
||||
readonly id: MessageId
|
||||
readonly placement: 'queued' | 'steering' | 'context'
|
||||
/** Prompt-RPC identity from the queued message's user source; clients retire the matching local submission echo on it. */
|
||||
readonly rpcId?: SessionRequestId
|
||||
/** JSON-safe message fields consumed by pending-queue presentation. */
|
||||
readonly message: {
|
||||
readonly id: MessageId
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
/** Local submission echoes: synchronous insertion, observed/failed retirement, and settlement callbacks. */
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createUserMessage } from '@deepseek-ai/dsh-llm'
|
||||
import type { MessageSource } from '@deepseek-ai/dsh-llm'
|
||||
import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
|
||||
import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
import { Session } from '../src/client/sessions/session.ts'
|
||||
import type { PendingSubmissionRetirement } from '../src/client/contract/session.ts'
|
||||
import type { SessionQueuedItem, SessionRequestId } from '../src/types.ts'
|
||||
import { FakeApiClient, err, fakeRemote, ok } from './fake-api.client.ts'
|
||||
import { historyValue } from './event-script.client.ts'
|
||||
|
||||
const SID = 'fk-s1' as SessionId
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
function makeSession(api = new FakeApiClient()): { api: FakeApiClient; session: Session } {
|
||||
return { api, session: new Session(SID, api, fakeRemote(api)) }
|
||||
}
|
||||
|
||||
function imageRef(id: string): ImageAttachmentRef {
|
||||
return {
|
||||
attachmentId: id,
|
||||
mediaType: 'image/png',
|
||||
bytes: 1,
|
||||
width: 2,
|
||||
height: 2,
|
||||
} as unknown as ImageAttachmentRef
|
||||
}
|
||||
|
||||
/** A durable browser-prompt user/message whose source echoes `rpcId`. */
|
||||
function promptEvent(seq: number, rpcId: SessionRequestId, refs: readonly ImageAttachmentRef[] = []): SessionEvent {
|
||||
return {
|
||||
seq,
|
||||
time: 1_700_000_000_000 + seq,
|
||||
type: 'user/message',
|
||||
surfaceOp: 'append',
|
||||
data: createUserMessage({
|
||||
content: [
|
||||
...refs.map(attachment => ({ type: 'image' as const, attachment })),
|
||||
{ type: 'text' as const, text: '发送' },
|
||||
],
|
||||
source: { kind: 'user', rpcId } as MessageSource,
|
||||
}),
|
||||
} as unknown as SessionEvent
|
||||
}
|
||||
|
||||
function queuedItem(rpcId: SessionRequestId, refs: readonly ImageAttachmentRef[] = []): SessionQueuedItem {
|
||||
return {
|
||||
id: 'm-queued' as SessionQueuedItem['id'],
|
||||
placement: 'queued',
|
||||
rpcId,
|
||||
message: {
|
||||
id: 'm-queued' as SessionQueuedItem['id'],
|
||||
content: refs.map(attachment => ({ type: 'image', attachment })) as unknown as SessionQueuedItem['message']['content'],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/** Let the frame-delayed retirement (setTimeout fallback in this node environment) run. */
|
||||
function settleFrames(): Promise<void> {
|
||||
return new Promise(resolve => setTimeout(resolve, 0))
|
||||
}
|
||||
|
||||
describe('beginSubmission', () => {
|
||||
it('inserts the echo synchronously and flips the engaging edge before any prompt call', () => {
|
||||
const { session } = makeSession()
|
||||
expect(session.getSnapshot()).toMatchObject({ pendingSubmissions: [], promptAttempted: false })
|
||||
const handle = session.beginSubmission({
|
||||
text: '你好',
|
||||
images: [{ previewUrl: 'blob:p1', name: 'a.png', width: 4, height: 3 }],
|
||||
})
|
||||
expect(session.getSnapshot().promptAttempted).toBe(true)
|
||||
expect(session.getSnapshot().pendingSubmissions).toMatchObject([{
|
||||
requestId: handle.requestId,
|
||||
text: '你好',
|
||||
images: [{ previewUrl: 'blob:p1', name: 'a.png', width: 4, height: 3 }],
|
||||
}])
|
||||
})
|
||||
|
||||
it('abandon retires the echo as failed exactly once', () => {
|
||||
const { session } = makeSession()
|
||||
const retirements: PendingSubmissionRetirement[] = []
|
||||
const handle = session.beginSubmission({
|
||||
text: '放弃',
|
||||
images: [],
|
||||
onRetire: retirement => retirements.push(retirement),
|
||||
})
|
||||
handle.abandon()
|
||||
handle.abandon()
|
||||
expect(session.getSnapshot().pendingSubmissions).toEqual([])
|
||||
expect(retirements).toEqual([{ reason: 'failed' }])
|
||||
})
|
||||
})
|
||||
|
||||
describe('prompt-coupled retirement', () => {
|
||||
it('a rejected identified prompt retires its echo immediately alongside promptError', async () => {
|
||||
const { api, session } = makeSession()
|
||||
api.onPrompt = () => Promise.resolve(err({ code: 'agent-busy', message: '忙', details: {} }))
|
||||
const retirements: PendingSubmissionRetirement[] = []
|
||||
const handle = session.beginSubmission({
|
||||
text: '失败的',
|
||||
images: [],
|
||||
onRetire: retirement => retirements.push(retirement),
|
||||
})
|
||||
const result = await session.prompt([{ type: 'text', text: '失败的' }], 'queue', undefined, handle.requestId)
|
||||
expect(result.ok).toBe(false)
|
||||
expect(session.getSnapshot().pendingSubmissions).toEqual([])
|
||||
expect(session.getSnapshot().promptError).toMatchObject({ op: 'send' })
|
||||
expect(retirements).toEqual([{ reason: 'failed' }])
|
||||
})
|
||||
|
||||
it('sends the echo identity as the prompt requestId', async () => {
|
||||
const { api, session } = makeSession()
|
||||
const handle = session.beginSubmission({ text: '带 id', images: [] })
|
||||
await session.prompt([{ type: 'text', text: '带 id' }], 'queue', undefined, handle.requestId)
|
||||
expect(api.callsOf('session.prompt')).toMatchObject([{ requestId: handle.requestId }])
|
||||
})
|
||||
|
||||
it('an unidentified prompt failure leaves registered echoes alone', async () => {
|
||||
const { api, session } = makeSession()
|
||||
api.onPrompt = () => Promise.resolve(err({ code: 'agent-busy', message: '忙', details: {} }))
|
||||
session.beginSubmission({ text: '还在', images: [] })
|
||||
await session.prompt([{ type: 'text', text: '另一个' }], 'queue')
|
||||
expect(session.getSnapshot().pendingSubmissions).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('observed retirement', () => {
|
||||
it('a live durable event carrying the rpcId retires the echo one frame later with the admitted refs', async () => {
|
||||
const { api, session } = makeSession()
|
||||
api.onHistory = () => Promise.resolve(ok(historyValue([])))
|
||||
await session.open()
|
||||
const retirements: PendingSubmissionRetirement[] = []
|
||||
const handle = session.beginSubmission({
|
||||
text: '发送',
|
||||
images: [{ previewUrl: 'blob:p1' }],
|
||||
onRetire: retirement => retirements.push(retirement),
|
||||
})
|
||||
const refs = [imageRef('att-1')]
|
||||
await api.pushFollow(SID, { type: 'event', event: promptEvent(0, handle.requestId, refs) as never })
|
||||
// Synchronously after the append the echo is still in the snapshot; the
|
||||
// render-time dedupe owns the overlap frame.
|
||||
expect(session.getSnapshot().pendingSubmissions).toHaveLength(1)
|
||||
await settleFrames()
|
||||
expect(session.getSnapshot().pendingSubmissions).toEqual([])
|
||||
expect(retirements).toEqual([{ reason: 'observed', attachments: refs }])
|
||||
})
|
||||
|
||||
it('a queue occurrence carrying the rpcId retires the echo (running-turn submissions)', async () => {
|
||||
const { session } = makeSession()
|
||||
const retirements: PendingSubmissionRetirement[] = []
|
||||
const handle = session.beginSubmission({
|
||||
text: '排队',
|
||||
images: [{ previewUrl: 'blob:p1' }],
|
||||
onRetire: retirement => retirements.push(retirement),
|
||||
})
|
||||
const refs = [imageRef('att-q')]
|
||||
session.handleControlFrame({ type: 'queue', sessionId: SID, items: [queuedItem(handle.requestId, refs)] })
|
||||
await settleFrames()
|
||||
expect(session.getSnapshot().pendingSubmissions).toEqual([])
|
||||
expect(retirements).toEqual([{ reason: 'observed', attachments: refs }])
|
||||
// The queue projection keeps the correlation id for render-time dedupe.
|
||||
expect(session.getSnapshot().queue).toMatchObject([{ rpcId: handle.requestId }])
|
||||
})
|
||||
|
||||
it('a full-window install (reconnect resync) retires echoes observed in the window', async () => {
|
||||
const { api, session } = makeSession()
|
||||
const handle = session.beginSubmission({ text: '重连', images: [] })
|
||||
api.onHistory = () => Promise.resolve(ok(historyValue([promptEvent(12, handle.requestId)])))
|
||||
await session.open()
|
||||
await settleFrames()
|
||||
expect(session.getSnapshot().pendingSubmissions).toEqual([])
|
||||
})
|
||||
|
||||
it('the first observation wins: a later prompt failure cannot re-retire an observed echo', async () => {
|
||||
const { api, session } = makeSession()
|
||||
api.onHistory = () => Promise.resolve(ok(historyValue([])))
|
||||
await session.open()
|
||||
const retirements: PendingSubmissionRetirement[] = []
|
||||
const handle = session.beginSubmission({
|
||||
text: '先观察',
|
||||
images: [],
|
||||
onRetire: retirement => retirements.push(retirement),
|
||||
})
|
||||
await api.pushFollow(SID, { type: 'event', event: promptEvent(0, handle.requestId) as never })
|
||||
handle.abandon()
|
||||
await settleFrames()
|
||||
expect(retirements).toEqual([{ reason: 'observed', attachments: [] }])
|
||||
})
|
||||
|
||||
it('uses requestAnimationFrame for the retirement delay when the runtime provides one', async () => {
|
||||
const frames: FrameRequestCallback[] = []
|
||||
vi.stubGlobal('requestAnimationFrame', (fn: FrameRequestCallback) => {
|
||||
frames.push(fn)
|
||||
return frames.length
|
||||
})
|
||||
const { api, session } = makeSession()
|
||||
api.onHistory = () => Promise.resolve(ok(historyValue([])))
|
||||
await session.open()
|
||||
const handle = session.beginSubmission({ text: '帧', images: [] })
|
||||
await api.pushFollow(SID, { type: 'event', event: promptEvent(0, handle.requestId) as never })
|
||||
expect(session.getSnapshot().pendingSubmissions).toHaveLength(1)
|
||||
expect(frames).toHaveLength(1)
|
||||
frames[0]?.(0)
|
||||
expect(session.getSnapshot().pendingSubmissions).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('disposal', () => {
|
||||
it('retires unsettled echoes as failed and preserves an already-observed settlement', async () => {
|
||||
const { api, session } = makeSession()
|
||||
api.onHistory = () => Promise.resolve(ok(historyValue([])))
|
||||
await session.open()
|
||||
const retirements: { text: string; retirement: PendingSubmissionRetirement }[] = []
|
||||
const observed = session.beginSubmission({
|
||||
text: '已观察',
|
||||
images: [],
|
||||
onRetire: retirement => retirements.push({ text: '已观察', retirement }),
|
||||
})
|
||||
session.beginSubmission({
|
||||
text: '未settle',
|
||||
images: [],
|
||||
onRetire: retirement => retirements.push({ text: '未settle', retirement }),
|
||||
})
|
||||
await api.pushFollow(SID, { type: 'event', event: promptEvent(0, observed.requestId) as never })
|
||||
await session.dispose()
|
||||
await settleFrames()
|
||||
expect(retirements).toEqual([
|
||||
{ text: '未settle', retirement: { reason: 'failed' } },
|
||||
{ text: '已观察', retirement: { reason: 'observed', attachments: [] } },
|
||||
])
|
||||
expect(session.getSnapshot().pendingSubmissions).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -2757,9 +2757,14 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
|
||||
attachments.set(String(attachment.attachmentId), { attachment, data: block.data })
|
||||
return { type: 'image', attachment }
|
||||
})
|
||||
// The host echoes the prompt's requestId as the user source's rpcId;
|
||||
// the Session object retires its local submission echo on it. The
|
||||
// user-rpc source member is declared by dsh-api-session-controller,
|
||||
// which this standalone fixture does not import — hence the assertion.
|
||||
const promptSource = { kind: 'user', rpcId: request.requestId } as MessageSource
|
||||
if (mode === 'steer' && replays.has(id)) {
|
||||
// Steering: the durable user/message lands inside the current turn; the replay continues.
|
||||
append(id, { type: 'user/message', surfaceOp: 'append', data: userMessage(durable) })
|
||||
append(id, { type: 'user/message', surfaceOp: 'append', data: userMessage(durable, promptSource) })
|
||||
return sessionOk({ accepted: true as const })
|
||||
}
|
||||
const turn = nextTurn.get(id) ?? 0
|
||||
@@ -2772,7 +2777,7 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
|
||||
if (plan.wanted !== null && plan.wanted !== plan.active) {
|
||||
append(id, { type: 'plan/mode', data: { active: plan.wanted } })
|
||||
}
|
||||
append(id, { type: 'user/message', surfaceOp: 'append', data: userMessage(durable) })
|
||||
append(id, { type: 'user/message', surfaceOp: 'append', data: userMessage(durable, promptSource) })
|
||||
// Capacity parallel of the host token-meter's request/context record:
|
||||
// log-only, appended inside the open turn, and deduplicated against the
|
||||
// route already recorded (the fixture never varies contextWindow).
|
||||
|
||||
@@ -76,6 +76,7 @@ function createSessionsBench(_ctx: Context): SessionsBench {
|
||||
const snapshot = createSnapshotStore<SessionSnapshot>({
|
||||
sessionId: id,
|
||||
queue: [],
|
||||
pendingSubmissions: [],
|
||||
running: false,
|
||||
subagent: null,
|
||||
removed: false,
|
||||
|
||||
@@ -111,6 +111,7 @@ function sessionSnapshot(nodes: LegacyConversationSlice['nodes']): SessionSnapsh
|
||||
return {
|
||||
sessionId: SID,
|
||||
queue: [],
|
||||
pendingSubmissions: [],
|
||||
running: false,
|
||||
subagent: null,
|
||||
removed: false,
|
||||
|
||||
@@ -25,6 +25,7 @@ type AttentionState = Parameters<Parameters<QuestionComposerProps['useSessionPen
|
||||
const sessionState: SessionState = {
|
||||
sessionId: SID,
|
||||
queue: [],
|
||||
pendingSubmissions: [],
|
||||
running: false,
|
||||
subagent: null,
|
||||
removed: false,
|
||||
|
||||
@@ -66,6 +66,7 @@ export function sessionSnapshot(sessionId: SessionId): SessionSnapshot {
|
||||
return {
|
||||
sessionId,
|
||||
queue: [],
|
||||
pendingSubmissions: [],
|
||||
running: false,
|
||||
subagent: null,
|
||||
removed: false,
|
||||
|
||||
@@ -7,8 +7,9 @@ import {
|
||||
import type {
|
||||
AgentContext, ISessions, ProjectionsFace, SessionBinding, SessionFace, SessionListState,
|
||||
SessionEventLikeEntry, SessionLiveEventEntry, SessionSearchResultItem,
|
||||
SessionSnapshot, SessionSummary,
|
||||
SessionSnapshot, SessionSummary, SubmissionHandle,
|
||||
} from '@deepseek-ai/dsh-api-session-controller/client'
|
||||
import type { SessionRequestId } from '@deepseek-ai/dsh-api-session-controller/types'
|
||||
import type { SubagentAddress } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
|
||||
import type { ObservableSnapshot, SnapshotStore } from '@deepseek-ai/dsh-client-store'
|
||||
@@ -94,6 +95,22 @@ export class FixtureSession implements SessionFace {
|
||||
throw new Error(`test session "${this.sessionId}": prompt is not stubbed — supply it on the fixture's session face`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimal local-echo registration: mints an identity without touching the
|
||||
* fixture snapshot (submission echoes are client-only presentation state).
|
||||
* Supply `beginSubmission` on the fixture's session face to observe echoes.
|
||||
* @returns a handle whose abandon is a no-op.
|
||||
*/
|
||||
beginSubmission(): SubmissionHandle {
|
||||
this.submissionSeq += 1
|
||||
return {
|
||||
requestId: `test-submission-${this.submissionSeq}` as SessionRequestId,
|
||||
abandon: () => {},
|
||||
}
|
||||
}
|
||||
|
||||
private submissionSeq = 0
|
||||
|
||||
/**
|
||||
* Fail-loud stub; supply `readAttachment` on the fixture's session face to exercise it.
|
||||
* @param _attachmentId - opaque durable attachment id.
|
||||
|
||||
Reference in New Issue
Block a user