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 { SessionId } from '@deepseek-ai/dsh-session/types'
|
||||||
import type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol'
|
import type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol'
|
||||||
import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store'
|
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 { 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). */
|
/** Key-addressed projection read face (the useProjection resolution path; see ProjectionValueStore). */
|
||||||
export interface ProjectionsFace {
|
export interface ProjectionsFace {
|
||||||
@@ -33,16 +61,29 @@ export interface ISession {
|
|||||||
readonly sessionId: SessionId
|
readonly sessionId: SessionId
|
||||||
/** Host-computed projection values by key (the useProjection seat). */
|
/** Host-computed projection values by key (the useProjection seat). */
|
||||||
readonly projections: ProjectionsFace
|
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.
|
* Send a prompt into the session.
|
||||||
* @param content - text plus browser-owned temporary image uploads.
|
* @param content - text plus browser-owned temporary image uploads.
|
||||||
* @param mode - 'queue' appends a turn; 'steer' interrupts the running one.
|
* @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).
|
* @returns acceptance, or the business error (also mirrored into snapshot.promptError).
|
||||||
*/
|
*/
|
||||||
prompt(
|
prompt(
|
||||||
content: PromptContentPart[],
|
content: PromptContentPart[],
|
||||||
mode: 'queue' | 'steer',
|
mode: 'queue' | 'steer',
|
||||||
signal?: AbortSignal,
|
signal?: AbortSignal,
|
||||||
|
requestId?: SessionRequestId,
|
||||||
): Promise<ClientResult<{ accepted: true }>>
|
): Promise<ClientResult<{ accepted: true }>>
|
||||||
/**
|
/**
|
||||||
* Resolve one durable image referenced by this session.
|
* 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 { MessageId } from '@deepseek-ai/dsh-llm/brand'
|
||||||
import type { SessionId } from '@deepseek-ai/dsh-session/types'
|
import type { SessionId } from '@deepseek-ai/dsh-session/types'
|
||||||
import type { SubagentAddress } from '@deepseek-ai/dsh-client-connection/client'
|
import type { SubagentAddress } from '@deepseek-ai/dsh-client-connection/client'
|
||||||
|
import type { SessionRequestId } from '../../types.ts'
|
||||||
import type { ClientFailure } from './result.ts'
|
import type { ClientFailure } from './result.ts'
|
||||||
|
|
||||||
/** One transient inbox occurrence from the authoritative queue snapshot. */
|
/** One transient inbox occurrence from the authoritative queue snapshot. */
|
||||||
@@ -10,11 +11,42 @@ export interface QueuedMessage {
|
|||||||
readonly id: MessageId
|
readonly id: MessageId
|
||||||
readonly messageId: MessageId
|
readonly messageId: MessageId
|
||||||
readonly placement: 'queued' | 'steering' | 'context'
|
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 content: readonly ContentBlock[]
|
||||||
readonly preview: string
|
readonly preview: string
|
||||||
readonly text: string | null
|
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. */
|
/** History-open lifecycle of a Session event window. */
|
||||||
export type OpenState = 'cold' | 'loading' | 'open' | 'error'
|
export type OpenState = 'cold' | 'loading' | 'open' | 'error'
|
||||||
|
|
||||||
@@ -28,6 +60,8 @@ export interface PromptError {
|
|||||||
export interface SessionSnapshot {
|
export interface SessionSnapshot {
|
||||||
readonly sessionId: SessionId
|
readonly sessionId: SessionId
|
||||||
readonly queue: readonly QueuedMessage[]
|
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 running: boolean
|
||||||
readonly subagent: {
|
readonly subagent: {
|
||||||
readonly address: SubagentAddress
|
readonly address: SubagentAddress
|
||||||
|
|||||||
@@ -40,7 +40,14 @@ export type {
|
|||||||
SessionProjectionMap,
|
SessionProjectionMap,
|
||||||
UseProjection,
|
UseProjection,
|
||||||
} from './sessions/projection-store.ts'
|
} 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 type { ISessions } from './contract/sessions.ts'
|
||||||
export { MutableSessionEventSource } from './contract/events.ts'
|
export { MutableSessionEventSource } from './contract/events.ts'
|
||||||
export type {
|
export type {
|
||||||
@@ -53,6 +60,8 @@ export type {
|
|||||||
} from './contract/events.ts'
|
} from './contract/events.ts'
|
||||||
export type {
|
export type {
|
||||||
OpenState,
|
OpenState,
|
||||||
|
PendingSubmission,
|
||||||
|
PendingSubmissionImage,
|
||||||
PromptError,
|
PromptError,
|
||||||
QueuedMessage,
|
QueuedMessage,
|
||||||
SessionSnapshot,
|
SessionSnapshot,
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ export class SessionQueueMirror {
|
|||||||
id: item.id,
|
id: item.id,
|
||||||
messageId: item.message.id,
|
messageId: item.message.id,
|
||||||
placement: item.placement,
|
placement: item.placement,
|
||||||
|
...(item.rpcId === undefined ? {} : { rpcId: item.rpcId }),
|
||||||
content,
|
content,
|
||||||
preview: previewOf(content),
|
preview: previewOf(content),
|
||||||
text: textOf(content),
|
text: textOf(content),
|
||||||
|
|||||||
@@ -24,9 +24,11 @@ import type {
|
|||||||
} from '../../types.ts'
|
} from '../../types.ts'
|
||||||
import type { ClientFailure, ClientResult } from '../contract/result.ts'
|
import type { ClientFailure, ClientResult } from '../contract/result.ts'
|
||||||
import { transportResult } from '../contract/result.ts'
|
import { transportResult } from '../contract/result.ts'
|
||||||
import type { SessionFace } from '../contract/session.ts'
|
|
||||||
import type {
|
import type {
|
||||||
OpenState, PromptError, SessionSnapshot,
|
BeginSubmissionInput, PendingSubmissionRetirement, SessionFace, SubmissionHandle,
|
||||||
|
} from '../contract/session.ts'
|
||||||
|
import type {
|
||||||
|
OpenState, PendingSubmission, PromptError, SessionSnapshot,
|
||||||
} from '../contract/snapshot.ts'
|
} from '../contract/snapshot.ts'
|
||||||
import { MutableSessionEventSource } from '../contract/events.ts'
|
import { MutableSessionEventSource } from '../contract/events.ts'
|
||||||
import type {
|
import type {
|
||||||
@@ -101,6 +103,14 @@ export class Session implements SessionFace {
|
|||||||
private removed = false
|
private removed = false
|
||||||
private promptError: PromptError | null = null
|
private promptError: PromptError | null = null
|
||||||
private lastAgentError: string | 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. */
|
/** Owns the addressed page/follow lifecycle while this Session is open. */
|
||||||
private events: SessionEventStream | undefined
|
private events: SessionEventStream | undefined
|
||||||
|
|
||||||
@@ -172,16 +182,42 @@ export class Session implements SessionFace {
|
|||||||
|
|
||||||
// ---- Operations ----
|
// ---- 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.
|
* Send (queue/steer passed through 1:1); failures land in the snapshot's promptError.
|
||||||
* @param content - text plus browser-owned temporary image uploads.
|
* @param content - text plus browser-owned temporary image uploads.
|
||||||
* @param mode - queue appends after the current turn; steer interrupts it.
|
* @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).
|
* @returns the prompt result (also mirrored into promptError on failure).
|
||||||
*/
|
*/
|
||||||
async prompt(
|
async prompt(
|
||||||
content: PromptContentPart[],
|
content: PromptContentPart[],
|
||||||
mode: 'queue' | 'steer',
|
mode: 'queue' | 'steer',
|
||||||
signal?: AbortSignal,
|
signal?: AbortSignal,
|
||||||
|
requestId?: SessionRequestId,
|
||||||
): Promise<ClientResult<{ accepted: true }>> {
|
): Promise<ClientResult<{ accepted: true }>> {
|
||||||
this.promptError = null
|
this.promptError = null
|
||||||
this.lastAgentError = null
|
this.lastAgentError = null
|
||||||
@@ -196,7 +232,7 @@ export class Session implements SessionFace {
|
|||||||
if (this.address === undefined) {
|
if (this.address === undefined) {
|
||||||
const clientTimeZone = resolvedClientTimeZone()
|
const clientTimeZone = resolvedClientTimeZone()
|
||||||
result = toSessionResult(await this.remote.session.prompt({
|
result = toSessionResult(await this.remote.session.prompt({
|
||||||
requestId: randomUUID() as SessionRequestId,
|
requestId: requestId ?? randomUUID() as SessionRequestId,
|
||||||
sessionId: this.sessionId,
|
sessionId: this.sessionId,
|
||||||
mode,
|
mode,
|
||||||
content,
|
content,
|
||||||
@@ -236,6 +272,7 @@ export class Session implements SessionFace {
|
|||||||
result = transportResult(error)
|
result = transportResult(error)
|
||||||
}
|
}
|
||||||
if (!result.ok) {
|
if (!result.ok) {
|
||||||
|
if (requestId !== undefined) this.retireFailedSubmission(requestId)
|
||||||
this.promptError = { op: 'send', error: result.error }
|
this.promptError = { op: 'send', error: result.error }
|
||||||
this.notifier.markDirty()
|
this.notifier.markDirty()
|
||||||
return result
|
return result
|
||||||
@@ -434,6 +471,7 @@ export class Session implements SessionFace {
|
|||||||
*/
|
*/
|
||||||
replaceControl(queue: readonly SessionQueuedItem[]): void {
|
replaceControl(queue: readonly SessionQueuedItem[]): void {
|
||||||
this.queueMirror.replace(queue)
|
this.queueMirror.replace(queue)
|
||||||
|
this.observeSubmissionQueue(queue)
|
||||||
this.notifier.markDirty()
|
this.notifier.markDirty()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -443,6 +481,7 @@ export class Session implements SessionFace {
|
|||||||
*/
|
*/
|
||||||
handleControlFrame(frame: Extract<SessionControlFrame, { type: 'queue' }>): void {
|
handleControlFrame(frame: Extract<SessionControlFrame, { type: 'queue' }>): void {
|
||||||
this.queueMirror.replace(frame.items)
|
this.queueMirror.replace(frame.items)
|
||||||
|
this.observeSubmissionQueue(frame.items)
|
||||||
this.notifier.markDirty()
|
this.notifier.markDirty()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -523,6 +562,12 @@ export class Session implements SessionFace {
|
|||||||
* @returns when the Remote iterator has completed teardown.
|
* @returns when the Remote iterator has completed teardown.
|
||||||
*/
|
*/
|
||||||
async dispose(): Promise<void> {
|
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++
|
this.openGeneration++
|
||||||
const events = this.events
|
const events = this.events
|
||||||
this.events = undefined
|
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 (entries.some(entry => entry.event.type === 'turn/start')) this.firstPromptPendingTurn = false
|
||||||
if (projections !== undefined) this.projections.seed(projections)
|
if (projections !== undefined) this.projections.seed(projections)
|
||||||
this.eventSource.replace(entries, hasMore)
|
this.eventSource.replace(entries, hasMore)
|
||||||
|
for (const entry of entries) this.observeSubmissionEvent(entry.event)
|
||||||
this.notifier.markDirty()
|
this.notifier.markDirty()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -598,9 +644,70 @@ export class Session implements SessionFace {
|
|||||||
if (event.type === 'turn/start') this.firstPromptPendingTurn = false
|
if (event.type === 'turn/start') this.firstPromptPendingTurn = false
|
||||||
const queueChanged = this.queueMirror.acceptDurable(event)
|
const queueChanged = this.queueMirror.acceptDurable(event)
|
||||||
this.eventSource.append(entry)
|
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
|
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. */
|
/** Publish a terminal background failure only while this stream still owns the Session. */
|
||||||
private failEventStream(events: SessionEventStream, generation: number, error: unknown): void {
|
private failEventStream(events: SessionEventStream, generation: number, error: unknown): void {
|
||||||
if (generation !== this.openGeneration || this.events !== events) return
|
if (generation !== this.openGeneration || this.events !== events) return
|
||||||
@@ -617,6 +724,7 @@ export class Session implements SessionFace {
|
|||||||
return {
|
return {
|
||||||
sessionId: this.sessionId,
|
sessionId: this.sessionId,
|
||||||
queue: this.queueMirror.snapshot(),
|
queue: this.queueMirror.snapshot(),
|
||||||
|
pendingSubmissions: this.pendingSubmissions,
|
||||||
running: this.running,
|
running: this.running,
|
||||||
subagent: this.address === undefined
|
subagent: this.address === undefined
|
||||||
? null
|
? 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. */
|
/** Convert a terminal Session stream failure to the Client error vocabulary. */
|
||||||
function openFailure(error: unknown): ClientFailure {
|
function openFailure(error: unknown): ClientFailure {
|
||||||
const failure = sessionStreamFailure(error)
|
const failure = sessionStreamFailure(error)
|
||||||
|
|||||||
@@ -188,16 +188,24 @@ function queueItems(
|
|||||||
...project('next-turn').map(message => ({
|
...project('next-turn').map(message => ({
|
||||||
id: message.id,
|
id: message.id,
|
||||||
placement: 'queued' as const,
|
placement: 'queued' as const,
|
||||||
|
...promptRpcId(message),
|
||||||
message: { id: message.id, content: message.content as unknown as JsonValue[] },
|
message: { id: message.id, content: message.content as unknown as JsonValue[] },
|
||||||
})),
|
})),
|
||||||
...project('next-step').map(message => ({
|
...project('next-step').map(message => ({
|
||||||
id: message.id,
|
id: message.id,
|
||||||
placement: message.source.kind === 'user' ? 'steering' as const : 'context' as const,
|
placement: message.source.kind === 'user' ? 'steering' as const : 'context' as const,
|
||||||
|
...promptRpcId(message),
|
||||||
message: { id: message.id, content: message.content as unknown as JsonValue[] },
|
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 {
|
function jobView(job: JobSnapshot): SessionJob {
|
||||||
return {
|
return {
|
||||||
id: job.id,
|
id: job.id,
|
||||||
|
|||||||
@@ -432,6 +432,8 @@ export type SessionFollowFrame =
|
|||||||
export interface SessionQueuedItem {
|
export interface SessionQueuedItem {
|
||||||
readonly id: MessageId
|
readonly id: MessageId
|
||||||
readonly placement: 'queued' | 'steering' | 'context'
|
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. */
|
/** JSON-safe message fields consumed by pending-queue presentation. */
|
||||||
readonly message: {
|
readonly message: {
|
||||||
readonly id: MessageId
|
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 })
|
attachments.set(String(attachment.attachmentId), { attachment, data: block.data })
|
||||||
return { type: 'image', attachment }
|
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)) {
|
if (mode === 'steer' && replays.has(id)) {
|
||||||
// Steering: the durable user/message lands inside the current turn; the replay continues.
|
// 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 })
|
return sessionOk({ accepted: true as const })
|
||||||
}
|
}
|
||||||
const turn = nextTurn.get(id) ?? 0
|
const turn = nextTurn.get(id) ?? 0
|
||||||
@@ -2772,7 +2777,7 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
|
|||||||
if (plan.wanted !== null && plan.wanted !== plan.active) {
|
if (plan.wanted !== null && plan.wanted !== plan.active) {
|
||||||
append(id, { type: 'plan/mode', data: { active: plan.wanted } })
|
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:
|
// Capacity parallel of the host token-meter's request/context record:
|
||||||
// log-only, appended inside the open turn, and deduplicated against the
|
// log-only, appended inside the open turn, and deduplicated against the
|
||||||
// route already recorded (the fixture never varies contextWindow).
|
// route already recorded (the fixture never varies contextWindow).
|
||||||
|
|||||||
@@ -76,6 +76,7 @@ function createSessionsBench(_ctx: Context): SessionsBench {
|
|||||||
const snapshot = createSnapshotStore<SessionSnapshot>({
|
const snapshot = createSnapshotStore<SessionSnapshot>({
|
||||||
sessionId: id,
|
sessionId: id,
|
||||||
queue: [],
|
queue: [],
|
||||||
|
pendingSubmissions: [],
|
||||||
running: false,
|
running: false,
|
||||||
subagent: null,
|
subagent: null,
|
||||||
removed: false,
|
removed: false,
|
||||||
|
|||||||
@@ -111,6 +111,7 @@ function sessionSnapshot(nodes: LegacyConversationSlice['nodes']): SessionSnapsh
|
|||||||
return {
|
return {
|
||||||
sessionId: SID,
|
sessionId: SID,
|
||||||
queue: [],
|
queue: [],
|
||||||
|
pendingSubmissions: [],
|
||||||
running: false,
|
running: false,
|
||||||
subagent: null,
|
subagent: null,
|
||||||
removed: false,
|
removed: false,
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ type AttentionState = Parameters<Parameters<QuestionComposerProps['useSessionPen
|
|||||||
const sessionState: SessionState = {
|
const sessionState: SessionState = {
|
||||||
sessionId: SID,
|
sessionId: SID,
|
||||||
queue: [],
|
queue: [],
|
||||||
|
pendingSubmissions: [],
|
||||||
running: false,
|
running: false,
|
||||||
subagent: null,
|
subagent: null,
|
||||||
removed: false,
|
removed: false,
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ export function sessionSnapshot(sessionId: SessionId): SessionSnapshot {
|
|||||||
return {
|
return {
|
||||||
sessionId,
|
sessionId,
|
||||||
queue: [],
|
queue: [],
|
||||||
|
pendingSubmissions: [],
|
||||||
running: false,
|
running: false,
|
||||||
subagent: null,
|
subagent: null,
|
||||||
removed: false,
|
removed: false,
|
||||||
|
|||||||
@@ -7,8 +7,9 @@ import {
|
|||||||
import type {
|
import type {
|
||||||
AgentContext, ISessions, ProjectionsFace, SessionBinding, SessionFace, SessionListState,
|
AgentContext, ISessions, ProjectionsFace, SessionBinding, SessionFace, SessionListState,
|
||||||
SessionEventLikeEntry, SessionLiveEventEntry, SessionSearchResultItem,
|
SessionEventLikeEntry, SessionLiveEventEntry, SessionSearchResultItem,
|
||||||
SessionSnapshot, SessionSummary,
|
SessionSnapshot, SessionSummary, SubmissionHandle,
|
||||||
} from '@deepseek-ai/dsh-api-session-controller/client'
|
} 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 type { SubagentAddress } from '@deepseek-ai/dsh-client-connection/client'
|
||||||
import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
|
import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
|
||||||
import type { ObservableSnapshot, SnapshotStore } 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`)
|
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.
|
* Fail-loud stub; supply `readAttachment` on the fixture's session face to exercise it.
|
||||||
* @param _attachmentId - opaque durable attachment id.
|
* @param _attachmentId - opaque durable attachment id.
|
||||||
|
|||||||
Reference in New Issue
Block a user