feat(schedule): add durable after reminders
This commit is contained in:
@@ -26,6 +26,8 @@ Question responses are validated against their pending request before the first
|
||||
|
||||
`session.history` reads an attached Session in memory or inspects a cold log through persistence without resuming or publishing an Agent, then pages on append-origin message boundaries. `maxMessages` counts `user/message` and `assistant/message` events that entered the surface by appending, so a model-only replacement copy consumes no quota. Each page stays one contiguous raw event range, which keeps a compaction's log-only `compact/summary` record on the same page as the replacement that cites it.
|
||||
|
||||
An optional `SessionEventView` is a non-persistent presentation sidecar. Tool calls/results keep their existing Host presenters. A Schedule dispatch remains raw on append; after an acknowledged `session/flushed(session, throughSeq)`, the gateway advances an exact-Session `WeakMap` cursor with `max`, derives newly covered receipts through the Schedule package, and redelivers the identical event with `{ for: 'event', presentationKey: 'schedule/reminder', view }`. Reversed flush completion cannot move the cursor backward or duplicate a receipt. Attached history adds these views only within a persistence-inspected prefix whose header and every event match the live identity; unavailable, failed, or mismatched inspection serves raw history without the sidecar. Detached history is already a persisted prefix.
|
||||
|
||||
`session.history`'s tail page (`beforeSeq` absent) additionally carries an optional `projections` block — the watermark snapshot of every unit registered on `ctx.sessionProjections` (`@deepseek-ai/dsh-session-projection`), with `asOfSeq` = the last event seq the values reflect (`-1` on an empty log). The gateway also subscribes to the registry's change feed and mints a `session/projection` mux frame per changed unit (`{sessionId, key, value, seq}` — live push state, never logged; clients hold one generic per-session value store under higher-seq-wins). The carrier holds zero domain knowledge (each value passed its unit's own schema inside the registry; the wire schemas keep `values`/`value` wide); loadOlder pages never carry the block, and a composition without the registry serves histories without either surface.
|
||||
|
||||
Session titles ride the generic projection pair like every other domain — the history-tail `projections` block plus `session/projection` frames under the `title` key (the bespoke `session/title` frame is retired). Titles do not join `session.list`; cold sessions remain metadata-only there until opening or resuming attaches their logs. `session.rename` accepts an explicit user title (resuming a cold session first), delegating to `ctx.sessionTitle.rename` — the accepted `session/title` event pins the title against automatic regeneration — and returns the normalized title plus its event seq so a client settles its `title` projection cell ahead of the push frame; a title that normalizes to empty returns `title-invalid`.
|
||||
|
||||
@@ -26,7 +26,9 @@ Settings 分节中的 `reasoningEffort` 在 agent-default-model 插件配置中
|
||||
|
||||
`session.history` 会读取已附加 Session 的内存状态,或通过持久化检查冷日志,而不会恢复或发布 agent,然后按追加来源的消息边界分页:`maxMessages` 统计以追加方式进入 surface 的 `user/message` 和 `assistant/message` 事件,因此仅供模型使用的替换副本不占用配额。每一页仍是一段连续的原始事件区间,从而让压缩(compaction)的仅日志 `compact/summary` 记录与引用它的替换留在同一页。
|
||||
|
||||
`session.history` 的尾页(不带 `beforeSeq`)额外携带一个可选的 `projections` 块——`ctx.sessionProjections`(`@deepseek-ai/dsh-session-projection`)上每个已注册单元的水位线快照,`asOfSeq` = 这些值共同反映到的最后一个事件 seq(空日志为 `-1`)。网关还订阅注册表的变更流,为每个状态发生变化的单元生成一个 `session/projection` mux 帧(`{sessionId, key, value, seq}`——实时推送状态,绝不入日志;客户端按 seq 高者胜维护一个按会话的通用值仓)。载体不持有任何领域知识(每个值在注册表内部已过其单元自己的 schema;协议 schema 对 `values`/`value` 保持宽松);loadOlder 页永不携带该块,未装注册表的组合则两个面都不提供。
|
||||
可选的 `SessionEventView` 是非持久 presentation sidecar。工具 call/result 保留既有 Host presenter。Schedule dispatch 在 append 时保持 raw;收到获确认的 `session/flushed(session, throughSeq)` 后,网关才以 `max` 推进按 exact Session 键控的 `WeakMap` cursor,通过 Schedule package 派生新覆盖的回执,并用 `{ for: 'event', presentationKey: 'schedule/reminder', view }` 重投完全相同的事件。反序完成的 flush 不能让 cursor 后退或重复回执。已附加 history 只会在 persistence inspect 得到的前缀内添加这些 view,而且该前缀的 header 与每个 event 都必须和 live identity 一致;inspect 不可用、失败或不匹配时,仍会返回 raw history,只省略 sidecar。已分离 history 本身已经是持久前缀。
|
||||
|
||||
`session.history` 的尾页(不带 `beforeSeq`)额外携带一个可选的 `projections` 块——`ctx.sessionProjections`(`@deepseek-ai/dsh-session-projection`)上每个已注册单元的水位线快照,`asOfSeq` = 这些值共同反映到的最后一个事件 seq(空日志为 `-1`)。网关还订阅注册表的变更流,为每个状态发生变化的单元铸造一个 `session/projection` mux 帧(`{sessionId, key, value, seq}`——实时推送状态,绝不入日志;客户端按 seq 高者胜维护一个按会话的通用值仓)。载体不持有任何领域知识(每个值在注册表内部已过其单元自己的 schema;协议 schema 对 `values`/`value` 保持宽松);loadOlder 页永不携带该块,未装注册表的组合则两个面都不提供。
|
||||
|
||||
会话标题与其他所有领域一样搭乘这对通用投影机制——历史尾页的 `projections` 块外加 `title` 键下的 `session/projection` 帧(专设的 `session/title` 帧已下线)。标题不会加入 `session.list`;冷会话在其中仍只有元数据,直到打开或恢复操作附加其日志。`session.rename` 接受用户显式标题(冷会话先恢复),委托给 `ctx.sessionTitle.rename`——被接受的 `session/title` 事件将标题钉住、不再被自动生成覆盖——并返回规范化后的标题及其事件 seq,让 client 在推送帧到达前就结算自己的 `title` 投影格;规范化后为空的标题返回 `title-invalid`。
|
||||
|
||||
|
||||
@@ -55,6 +55,7 @@
|
||||
"@deepseek-ai/dsh-session-query": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-title": "workspace:^",
|
||||
"@deepseek-ai/dsh-settings": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-schedule": "workspace:^",
|
||||
"@deepseek-ai/dsh-skill": "workspace:^",
|
||||
"@deepseek-ai/dsh-subagent": "workspace:^",
|
||||
"@deepseek-ai/dsh-tools": "workspace:^",
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { mkdir, stat } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { isDeepStrictEqual } from 'node:util'
|
||||
import type { Context } from 'cordis'
|
||||
import { installModelSelection } from '@deepseek-ai/dsh-agent'
|
||||
import type { Agent, ModelSelection, ModelSelectionRef, AgentOptions, AgentStatus } from '@deepseek-ai/dsh-agent'
|
||||
@@ -30,8 +31,9 @@ import type {} from '@deepseek-ai/dsh-tools'
|
||||
import type {
|
||||
ApiProxy, ConfigurableProviderView, CredentialView, GoalRef, HistoryEntry, HostFrame,
|
||||
ModelCatalogFailure, ModelProviderGroup,
|
||||
ModelReasoning, MuxFrame, QuestionResponsePayload, SessionProjectionsBlock, SessionSearchItem,
|
||||
QueuedInboxItem, SessionSummary, SettingsNamespaceView, SubagentAddress, ToolEventView,
|
||||
ModelReasoning, MuxFrame, PresentedEventView, QuestionResponsePayload, SessionEventView,
|
||||
QueuedInboxItem, SessionProjectionsBlock, SessionSearchItem, SessionSummary, SettingsNamespaceView,
|
||||
SubagentAddress, ToolEventView,
|
||||
WorkspaceId, WorkspaceView,
|
||||
} from './api/index.ts'
|
||||
import {
|
||||
@@ -58,6 +60,10 @@ import { credentialRef } from '@deepseek-ai/dsh-credentials'
|
||||
// Value edge: the rename impl narrows the title service's validation failure; the import also resolves `ctx.get('sessionTitle')`.
|
||||
import { SessionTitleInvalidError } from '@deepseek-ai/dsh-session-title'
|
||||
import type { CallId } from '@deepseek-ai/dsh-llm/brand'
|
||||
import {
|
||||
SCHEDULE_REMINDER_PRESENTATION_KEY,
|
||||
scheduleReminderPresentation,
|
||||
} from '@deepseek-ai/dsh-tool-schedule'
|
||||
import type { ApprovalOutcome, ApprovalRequestId } from '@deepseek-ai/dsh-user-approval'
|
||||
// Side-effect type import: resolves the `approval/request` waterfall and
|
||||
// `ctx.get('approval')` without a value dependency on the seam (optional composition).
|
||||
@@ -465,6 +471,28 @@ function viewFor(ctx: Context, event: SessionEvent, argsFor: (callId: string) =>
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive one Schedule-owned event sidecar without allowing corrupt domain data
|
||||
* to break raw event delivery. `seedLength` selects the parent-prefix or
|
||||
* child-suffix ownership segment inside the package helper.
|
||||
*/
|
||||
function scheduleViewFor(
|
||||
ctx: Context,
|
||||
header: SessionHeader,
|
||||
events: readonly SessionEvent[],
|
||||
event: SessionEvent,
|
||||
): PresentedEventView | undefined {
|
||||
try {
|
||||
const view = scheduleReminderPresentation(events, event.seq, header.seedLength ?? 0)
|
||||
return view === undefined
|
||||
? undefined
|
||||
: { for: 'event', presentationKey: SCHEDULE_REMINDER_PRESENTATION_KEY, view }
|
||||
} catch (error: unknown) {
|
||||
ctx.logger.warn(`api-proxy: Schedule presentation failed at seq ${event.seq}; serving raw event: ${String(error)}`)
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a tool/result's call pairing by scanning a window of events backwards
|
||||
* for the matching tool/call. Used by the history path (the page is the
|
||||
@@ -493,17 +521,49 @@ function historyPage(
|
||||
events: readonly SessionEvent[],
|
||||
beforeSeq: number | undefined,
|
||||
maxMessages: number | undefined,
|
||||
presentation?: { header: SessionHeader; throughSeq: number },
|
||||
): { events: HistoryEntry[]; hasMore: boolean } {
|
||||
const page = paginate(events, beforeSeq, maxMessages ?? DEFAULT_MAX_MESSAGES)
|
||||
return {
|
||||
events: page.events.map((event) => {
|
||||
const view = viewFor(ctx, event, callId => backscanArgs(page.events, callId))
|
||||
const toolView = viewFor(ctx, event, callId => backscanArgs(page.events, callId))
|
||||
const eventView = presentation !== undefined && event.seq < presentation.throughSeq
|
||||
? scheduleViewFor(ctx, presentation.header, events, event)
|
||||
: undefined
|
||||
const view: SessionEventView | undefined = toolView ?? eventView
|
||||
return { event, ...view === undefined ? {} : { view } }
|
||||
}),
|
||||
hasMore: page.hasMore,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Prove the exclusive durable prefix of one attached Session against a
|
||||
* detached persistence inspection. The header and every stored event must
|
||||
* match the live identity; absent top-level `delegationDepth` is the persisted
|
||||
* format's canonical zero. A divergent or impossible suffix proves nothing
|
||||
* and therefore returns zero.
|
||||
*/
|
||||
function identityMatchingStoredPrefix(
|
||||
session: Pick<Session, 'header'>,
|
||||
liveEvents: readonly SessionEvent[],
|
||||
stored: { meta: SessionHeader; events: readonly SessionEvent[] },
|
||||
): number {
|
||||
const liveIdentity = {
|
||||
...session.header,
|
||||
delegationDepth: session.header.delegationDepth ?? 0,
|
||||
}
|
||||
const storedIdentity = {
|
||||
...stored.meta,
|
||||
delegationDepth: stored.meta.delegationDepth ?? 0,
|
||||
}
|
||||
if (!isDeepStrictEqual(storedIdentity, liveIdentity) || stored.events.length > liveEvents.length) return 0
|
||||
for (let index = 0; index < stored.events.length; index += 1) {
|
||||
if (!isDeepStrictEqual(stored.events[index], liveEvents[index])) return 0
|
||||
}
|
||||
return stored.events.length
|
||||
}
|
||||
|
||||
/**
|
||||
* The projection baseline for one history tail page: the registry's
|
||||
* watermark-cache snapshot — one fully synchronous read (no await between the
|
||||
@@ -745,6 +805,8 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
||||
const pendingQuestions = new Map<RpcId, PendingQuestion>()
|
||||
const pendingApprovals = new Map<RpcId, PendingApproval>()
|
||||
const muxQueues = new Set<FrameQueue<RpcRequest<MuxFrame>>>()
|
||||
/** Commit-aware event presentation cursor keyed by exact live Session identity. */
|
||||
const presentedThrough = new WeakMap<Session, number>()
|
||||
|
||||
/**
|
||||
* Install or return the session-local model selection that prompt assembly snapshots.
|
||||
@@ -810,6 +872,25 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
||||
for (const queue of muxQueues) queue.push(envelope)
|
||||
}
|
||||
|
||||
// Raw append delivery remains unchanged. A successful durability checkpoint
|
||||
// later replays only newly covered Schedule dispatches with their sidecar;
|
||||
// exact-Session identity and max advancement contain id reuse and reversed
|
||||
// concurrent flush completion without creating another durable state owner.
|
||||
ctx.on('session/flushed', (session, throughSeq) => {
|
||||
const previous = presentedThrough.get(session) ?? 0
|
||||
if (throughSeq <= previous) return
|
||||
presentedThrough.set(session, throughSeq)
|
||||
for (let seq = previous; seq < throughSeq; seq += 1) {
|
||||
const event = session.events[seq]
|
||||
if (event === undefined) {
|
||||
throw new Error(`api-proxy: flushed prefix for "${session.id}" is missing event seq ${seq}`)
|
||||
}
|
||||
const view = scheduleViewFor(ctx, session.header, session.events, event)
|
||||
if (view === undefined) continue
|
||||
broadcast({ type: 'session/event', sessionId: session.id, event, view })
|
||||
}
|
||||
})
|
||||
|
||||
// Projection change feed → session/projection push frames. The carrier
|
||||
// mints the wire frame (the Service Definition package holds no wire vocabulary); the
|
||||
// child activates only when a projection registry is composed, and the
|
||||
@@ -1023,17 +1104,42 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
||||
async function historyStateFor(
|
||||
sessionId: SessionId,
|
||||
includeProjections: boolean,
|
||||
): Promise<{ events: SessionEvent[]; projections?: SessionProjectionsBlock }> {
|
||||
): Promise<{
|
||||
header: SessionHeader
|
||||
events: SessionEvent[]
|
||||
presentedThroughSeq: number
|
||||
projections?: SessionProjectionsBlock
|
||||
}> {
|
||||
const attached = ctx.sessions.get(sessionId)
|
||||
if (attached !== undefined) {
|
||||
const events = [...attached.events]
|
||||
const projections = includeProjections ? projectionsFor(ctx, attached) : undefined
|
||||
return { events, ...projections === undefined ? {} : { projections } }
|
||||
let presentedThroughSeq = 0
|
||||
const persistence = ctx.get('sessionPersistence')
|
||||
if (persistence !== undefined) {
|
||||
try {
|
||||
const stored = await persistence.inspect(sessionId)
|
||||
presentedThroughSeq = identityMatchingStoredPrefix(attached, events, stored)
|
||||
} catch (error: unknown) {
|
||||
// Attached history remains available from the live Session. A
|
||||
// failed or not-yet-materialized inspection only withholds
|
||||
// commit-gated event presentation sidecars.
|
||||
ctx.logger.warn(`session.history: persistence inspection for attached "${sessionId}" failed; serving raw events: ${String(error)}`)
|
||||
}
|
||||
}
|
||||
return {
|
||||
header: attached.header,
|
||||
events,
|
||||
presentedThroughSeq,
|
||||
...projections === undefined ? {} : { projections },
|
||||
}
|
||||
}
|
||||
const inspected = await inspectServable(sessionId)
|
||||
const projections = includeProjections ? detachedProjectionsFor(ctx, inspected.events) : undefined
|
||||
return {
|
||||
header: inspected.meta,
|
||||
events: inspected.events,
|
||||
presentedThroughSeq: inspected.events.length,
|
||||
...projections === undefined ? {} : { projections },
|
||||
}
|
||||
}
|
||||
@@ -1611,7 +1717,12 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
||||
|
||||
async history(request) {
|
||||
const { sessionId, beforeSeq, maxMessages } = request.payload
|
||||
let state: { events: SessionEvent[]; projections?: SessionProjectionsBlock }
|
||||
let state: {
|
||||
header: SessionHeader
|
||||
events: SessionEvent[]
|
||||
presentedThroughSeq: number
|
||||
projections?: SessionProjectionsBlock
|
||||
}
|
||||
try {
|
||||
state = await historyStateFor(sessionId, beforeSeq === undefined)
|
||||
} catch (error: unknown) {
|
||||
@@ -1624,7 +1735,10 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
||||
details: {},
|
||||
})
|
||||
}
|
||||
const page = historyPage(ctx, state.events, beforeSeq, maxMessages)
|
||||
const page = historyPage(ctx, state.events, beforeSeq, maxMessages, {
|
||||
header: state.header,
|
||||
throughSeq: state.presentedThroughSeq,
|
||||
})
|
||||
return ok(request, {
|
||||
events: page.events,
|
||||
hasMore: page.hasMore,
|
||||
|
||||
@@ -11,7 +11,7 @@ import type { Wire } from './rpc.schema.ts'
|
||||
import { rpcErrorSchema, rpcIdSchema } from './rpc.schema.ts'
|
||||
import { approvalRequestIdSchema } from './approvals.schema.ts'
|
||||
import {
|
||||
contentBlockSchema, messageIdSchema, sessionEventSchema, sessionIdSchema, toolEventViewSchema,
|
||||
contentBlockSchema, messageIdSchema, sessionEventSchema, sessionEventViewSchema, sessionIdSchema,
|
||||
} from './sessions.schema.ts'
|
||||
import { workspaceIdSchema, workspaceViewSchema } from './workspace.schema.ts'
|
||||
|
||||
@@ -40,7 +40,7 @@ const messageSchema = z.object({
|
||||
|
||||
/** MuxFrame union (payload slot of a mux-stream ServerRequest). */
|
||||
export const muxFrameSchema = z.discriminatedUnion('type', [
|
||||
z.object({ type: z.literal('session/event'), sessionId: sessionIdSchema, event: sessionEventSchema, view: toolEventViewSchema.optional() }),
|
||||
z.object({ type: z.literal('session/event'), sessionId: sessionIdSchema, event: sessionEventSchema, view: sessionEventViewSchema.optional() }),
|
||||
z.object({ type: z.literal('session/subscribed'), sessionId: sessionIdSchema, lastSeq: z.number().int() }),
|
||||
z.object({ type: z.literal('approval/requested'), sessionId: sessionIdSchema, approvalId: approvalRequestIdSchema, toolName: z.string(), callId: z.string().optional(), reason: z.string().optional() }),
|
||||
z.object({ type: z.literal('approval/resolved'), sessionId: sessionIdSchema, approvalId: approvalRequestIdSchema, outcome: z.union([z.literal('allowed-once'), z.literal('rejected'), z.literal('cancelled'), z.literal('unavailable')]) }),
|
||||
|
||||
@@ -32,6 +32,21 @@ export type ToolEventView =
|
||||
| { for: 'call'; view: ToolCallView }
|
||||
| { for: 'result'; view: ToolResultView }
|
||||
|
||||
/**
|
||||
* Host-computed presentation for one non-surface Session event. The domain
|
||||
* owns the presentation key and JSON-compatible view shape; the carrier keeps
|
||||
* both generic so an opt-in client plugin can render the event without adding
|
||||
* domain vocabulary to the connection package.
|
||||
*/
|
||||
export interface PresentedEventView {
|
||||
for: 'event'
|
||||
presentationKey: string
|
||||
view: unknown
|
||||
}
|
||||
|
||||
/** Optional non-persistent presentation sidecar for one Session event. */
|
||||
export type SessionEventView = ToolEventView | PresentedEventView
|
||||
|
||||
/** One pending inbox occurrence in the authoritative `session/queue` snapshot. */
|
||||
export interface QueuedInboxItem {
|
||||
/** Message identity used by inbox mutations. */
|
||||
@@ -66,7 +81,7 @@ export interface EventsApi {
|
||||
* approval/question frames (requested = answerable server-request, the rest are pure pushes).
|
||||
*/
|
||||
export type MuxFrame =
|
||||
| { type: 'session/event'; sessionId: SessionId; event: SessionEvent; view?: ToolEventView }
|
||||
| { type: 'session/event'; sessionId: SessionId; event: SessionEvent; view?: SessionEventView }
|
||||
| { type: 'session/subscribed'; sessionId: SessionId; lastSeq: number }
|
||||
| { type: 'approval/requested'; sessionId: SessionId; approvalId: ApprovalRequestId; toolName: string; callId?: CallId; reason?: string }
|
||||
| { type: 'approval/resolved'; sessionId: SessionId; approvalId: ApprovalRequestId; outcome: ApprovalOutcome }
|
||||
|
||||
@@ -48,7 +48,10 @@ export type {
|
||||
export type { WorkspaceApi, WorkspaceId, WorkspaceView } from './workspace.ts'
|
||||
export type { CommandsApi, CommandDescriptor } from './commands.ts'
|
||||
export type { SkillsApi, SkillEntry } from './skills.ts'
|
||||
export type { EventsApi, MuxFrame, HostFrame, QueuedInboxItem, ToolCallView, ToolEventView, ToolResultView } from './events.ts'
|
||||
export type {
|
||||
EventsApi, HostFrame, MuxFrame, PresentedEventView, QueuedInboxItem,
|
||||
SessionEventView, ToolCallView, ToolEventView, ToolResultView,
|
||||
} from './events.ts'
|
||||
export type { GoalsApi, GoalId, GoalRef } from './goals.ts'
|
||||
export type { SettingsApi, SettingsNamespaceView, SettingsPathOpView, SettingsSecretView } from './settings.ts'
|
||||
export type { CredentialsApi, CredentialView } from './credentials.ts'
|
||||
|
||||
@@ -14,7 +14,7 @@ import type {
|
||||
HistoryEntry, ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning,
|
||||
ModelReasoningEffort, ModelSelection, SessionProjectionsBlock, SessionSearchItem, SessionSummary,
|
||||
} from './sessions.ts'
|
||||
import type { ToolEventView } from './events.ts'
|
||||
import type { SessionEventView, ToolEventView } from './events.ts'
|
||||
import type { WorkspaceId } from './workspace.ts'
|
||||
import {
|
||||
SESSION_SEARCH_RESULT_LIMIT,
|
||||
@@ -193,10 +193,26 @@ export const toolEventViewSchema = z.discriminatedUnion('for', [
|
||||
z.object({ for: z.literal('result'), view: z.looseObject({ card: z.string() }) }),
|
||||
]) as unknown as z.ZodType<ToolEventView>
|
||||
|
||||
/** One session.history item: the session event plus its optional host-computed tool view. */
|
||||
/** Domain-owned presented-event sidecar with a carrier-validated key and present payload. */
|
||||
const presentedEventViewSchema = z.object({
|
||||
for: z.literal('event'),
|
||||
presentationKey: z.string().min(1),
|
||||
view: z.unknown(),
|
||||
}).refine(value => Object.hasOwn(value, 'view'), {
|
||||
message: 'presented event view payload is required',
|
||||
path: ['view'],
|
||||
})
|
||||
|
||||
/** Any optional host-computed sidecar carried with a Session event. */
|
||||
export const sessionEventViewSchema = z.union([
|
||||
toolEventViewSchema,
|
||||
presentedEventViewSchema,
|
||||
]) as unknown as z.ZodType<SessionEventView>
|
||||
|
||||
/** One session.history item: the session event plus its optional host-computed view. */
|
||||
export const historyEntrySchema: z.ZodType<Wire<HistoryEntry>> = z.object({
|
||||
event: sessionEventSchema,
|
||||
view: toolEventViewSchema.optional(),
|
||||
view: sessionEventViewSchema.optional(),
|
||||
}) as unknown as z.ZodType<Wire<HistoryEntry>>
|
||||
|
||||
/**
|
||||
|
||||
@@ -11,7 +11,7 @@ import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
// cordis Context merge (via dsh-agent) must not enter client aggregates.
|
||||
import type { SessionProjectionMap } from '@deepseek-ai/dsh-session-projection/types'
|
||||
import type { RpcId, RpcRequest, RpcResponse } from './rpc.ts'
|
||||
import type { ToolEventView } from './events.ts'
|
||||
import type { SessionEventView } from './events.ts'
|
||||
import type { WorkspaceId } from './workspace.ts'
|
||||
|
||||
declare module '@deepseek-ai/dsh-llm' {
|
||||
@@ -33,7 +33,7 @@ declare module '@deepseek-ai/dsh-llm' {
|
||||
*/
|
||||
export interface HistoryEntry {
|
||||
event: SessionEvent
|
||||
view?: ToolEventView
|
||||
view?: SessionEventView
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
220
packages/host/apiproxy/tests/api-proxy-schedule-view.spec.ts
Normal file
220
packages/host/apiproxy/tests/api-proxy-schedule-view.spec.ts
Normal file
@@ -0,0 +1,220 @@
|
||||
/**
|
||||
* Schedule reminder views cross the Host only after persistence proves their
|
||||
* dispatch prefix. Live append sends raw events; session/flushed replays the
|
||||
* identical dispatch with a generic sidecar. History independently gates the
|
||||
* same projection on an identity-matching stored prefix.
|
||||
*/
|
||||
|
||||
import { Context } from 'cordis'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import AgentRegistry from '@deepseek-ai/dsh-agent'
|
||||
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
|
||||
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { Session, SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry from '@deepseek-ai/dsh-tools'
|
||||
import type { MuxFrame, RpcRequest } from '@deepseek-ai/dsh-host-apiproxy/api'
|
||||
import { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
|
||||
import { createApiProxy } from '@deepseek-ai/dsh-host-apiproxy'
|
||||
import { ScheduleId } from '@deepseek-ai/dsh-tool-schedule'
|
||||
|
||||
interface FlushControl {
|
||||
handler: () => true | Promise<true>
|
||||
}
|
||||
|
||||
async function harness(control?: FlushControl): Promise<Context> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(SystemPrompt, { persona: '' })
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(UserInteractionService)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
if (control !== undefined) ctx.on('session/flush', () => control.handler())
|
||||
return ctx
|
||||
}
|
||||
|
||||
function appendReminder(
|
||||
session: Session,
|
||||
id: string,
|
||||
prompt: string,
|
||||
): { create: SessionEvent; dispatch: SessionEvent } {
|
||||
const scheduleId = ScheduleId(id)
|
||||
const create = session.append('schedule/change', {
|
||||
version: 1,
|
||||
operation: 'create',
|
||||
schedule: {
|
||||
id: scheduleId,
|
||||
kind: 'after',
|
||||
prompt,
|
||||
afterSeconds: 1,
|
||||
scheduledAt: '2026-08-05T12:00:01.000Z',
|
||||
},
|
||||
})
|
||||
const dispatch = session.append('schedule/change', {
|
||||
version: 1,
|
||||
operation: 'dispatch',
|
||||
id: scheduleId,
|
||||
})
|
||||
return { create, dispatch }
|
||||
}
|
||||
|
||||
async function collectEvents(
|
||||
iterable: AsyncIterable<RpcRequest<MuxFrame>>,
|
||||
count: number,
|
||||
abort: AbortController,
|
||||
): Promise<Extract<MuxFrame, { type: 'session/event' }>[]> {
|
||||
const events: Extract<MuxFrame, { type: 'session/event' }>[] = []
|
||||
for await (const envelope of iterable) {
|
||||
if (envelope.payload.type !== 'session/event') continue
|
||||
events.push(envelope.payload)
|
||||
if (events.length >= count) abort.abort()
|
||||
}
|
||||
return events
|
||||
}
|
||||
|
||||
describe('commit-aware Schedule live views', () => {
|
||||
it('takes the max of reverse flush completion and replays each dispatch once', async () => {
|
||||
const first = Promise.withResolvers<true>()
|
||||
let calls = 0
|
||||
const ctx = await harness({
|
||||
handler: () => ++calls === 1 ? first.promise : true,
|
||||
})
|
||||
const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' })
|
||||
const abort = new AbortController()
|
||||
const collected = collectEvents(
|
||||
api.events.mux({ rpcId: RpcId('schedule-live'), payload: {} }, abort.signal),
|
||||
6,
|
||||
abort,
|
||||
)
|
||||
const session = ctx.sessions.create(SessionId('schedule-live'))
|
||||
const firstPair = appendReminder(session, 'schedule-1', 'first')
|
||||
const slow = ctx.sessions.flush(session)
|
||||
const secondPair = appendReminder(session, 'schedule-2', 'second')
|
||||
await expect(ctx.sessions.flush(session)).resolves.toBe(true)
|
||||
first.resolve(true)
|
||||
await expect(slow).resolves.toBe(true)
|
||||
|
||||
const frames = await collected
|
||||
const raw = frames.filter(frame => frame.view === undefined)
|
||||
const presented = frames.filter(frame => frame.view?.for === 'event')
|
||||
expect(raw.map(frame => frame.event.seq)).toEqual([0, 1, 2, 3])
|
||||
expect(presented.map(frame => frame.event.seq)).toEqual([1, 3])
|
||||
expect(presented[0]?.event).toBe(firstPair.dispatch)
|
||||
expect(presented[1]?.event).toBe(secondPair.dispatch)
|
||||
expect(presented.map(frame => frame.view)).toEqual([
|
||||
{
|
||||
for: 'event',
|
||||
presentationKey: 'schedule/reminder',
|
||||
view: {
|
||||
scheduleId: 'schedule-1', prompt: 'first',
|
||||
occurrenceAt: '2026-08-05T12:00:01.000Z', deliveryMode: 'session-local',
|
||||
},
|
||||
},
|
||||
{
|
||||
for: 'event',
|
||||
presentationKey: 'schedule/reminder',
|
||||
view: {
|
||||
scheduleId: 'schedule-2', prompt: 'second',
|
||||
occurrenceAt: '2026-08-05T12:00:01.000Z', deliveryMode: 'session-local',
|
||||
},
|
||||
},
|
||||
])
|
||||
expect(firstPair.create.seq).toBe(0)
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('withholds a view after rejection and publishes it on the next successful checkpoint', async () => {
|
||||
let calls = 0
|
||||
const ctx = await harness({
|
||||
handler: () => ++calls === 1 ? Promise.reject(new Error('disk unavailable')) : true,
|
||||
})
|
||||
const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' })
|
||||
const abort = new AbortController()
|
||||
const collected = collectEvents(
|
||||
api.events.mux({ rpcId: RpcId('schedule-retry'), payload: {} }, abort.signal),
|
||||
3,
|
||||
abort,
|
||||
)
|
||||
const session = ctx.sessions.create(SessionId('schedule-retry'))
|
||||
appendReminder(session, 'schedule-1', 'retry me')
|
||||
await expect(ctx.sessions.flush(session)).rejects.toThrow('disk unavailable')
|
||||
await expect(ctx.sessions.flush(session)).resolves.toBe(true)
|
||||
|
||||
const frames = await collected
|
||||
expect(frames.filter(frame => frame.view?.for === 'event')).toHaveLength(1)
|
||||
expect(frames.at(-1)?.view).toMatchObject({
|
||||
for: 'event', presentationKey: 'schedule/reminder',
|
||||
})
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Schedule history views', () => {
|
||||
it('uses only the attached identity-matching stored prefix and fails soft to raw history', async () => {
|
||||
const ctx = await harness()
|
||||
const parent = ctx.sessions.create(SessionId('schedule-parent'), { meta: { cwd: '/tmp' } })
|
||||
appendReminder(parent, 'parent-reminder', 'from parent')
|
||||
const session = ctx.sessions.create(SessionId('schedule-attached'), {
|
||||
seed: [...parent.events],
|
||||
meta: { cwd: '/tmp', parentSession: parent.id, seedLength: 2 },
|
||||
})
|
||||
let inspect = (): Promise<{ meta: SessionHeader; events: SessionEvent[] }> => Promise.resolve({
|
||||
meta: session.header,
|
||||
events: [...session.events.slice(0, 1)],
|
||||
})
|
||||
ctx.provide('sessionPersistence', {
|
||||
inspect: () => inspect(),
|
||||
} as never)
|
||||
const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' })
|
||||
const history = async () => {
|
||||
const response = await api.sessions.history({
|
||||
rpcId: RpcId('schedule-history'), payload: { sessionId: session.id },
|
||||
})
|
||||
if (!response.result.ok) throw new Error(response.result.error.message)
|
||||
return response.result.value.events
|
||||
}
|
||||
|
||||
expect((await history()).find(entry => entry.event.seq === 1)?.view).toBeUndefined()
|
||||
inspect = () => Promise.resolve({
|
||||
meta: { ...session.header, delegationDepth: 0 },
|
||||
events: [...session.events.slice(0, 2)],
|
||||
})
|
||||
expect((await history()).find(entry => entry.event.seq === 1)?.view).toMatchObject({
|
||||
for: 'event', presentationKey: 'schedule/reminder',
|
||||
})
|
||||
inspect = () => Promise.resolve({
|
||||
meta: { ...session.header, cwd: '/different', delegationDepth: 0 },
|
||||
events: [...session.events.slice(0, 2)],
|
||||
})
|
||||
expect((await history()).find(entry => entry.event.seq === 1)?.view).toBeUndefined()
|
||||
inspect = () => Promise.reject(new Error('inspect unavailable'))
|
||||
expect((await history()).find(entry => entry.event.seq === 1)?.view).toBeUndefined()
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('presents every dispatch in detached persisted history', async () => {
|
||||
const ctx = await harness()
|
||||
let source: Session | undefined
|
||||
const owner = await ctx.plugin(Object.assign((inner: Context) => {
|
||||
source = inner.sessions.create(SessionId('schedule-source'), { meta: { cwd: '/tmp' } })
|
||||
}, { inject: ['sessions'] }))
|
||||
if (source === undefined) throw new Error('session owner did not publish its session')
|
||||
appendReminder(source, 'schedule-1', 'cold reminder')
|
||||
const meta = source.header
|
||||
const events = [...source.events]
|
||||
await owner.dispose()
|
||||
ctx.provide('sessionPersistence', {
|
||||
list: () => Promise.resolve([meta]),
|
||||
inspect: () => Promise.resolve({ meta, events }),
|
||||
} as never)
|
||||
const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' })
|
||||
const response = await api.sessions.history({
|
||||
rpcId: RpcId('schedule-cold'), payload: { sessionId: meta.id },
|
||||
})
|
||||
if (!response.result.ok) throw new Error(response.result.error.message)
|
||||
expect(response.result.value.events.find(entry => entry.event.seq === 1)?.view).toMatchObject({
|
||||
for: 'event', presentationKey: 'schedule/reminder',
|
||||
})
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
})
|
||||
@@ -149,7 +149,8 @@ describe('mux live view computation', () => {
|
||||
|
||||
expect(byCall.get('tool/call:c-gen')?.view).toEqual({ for: 'call', view: { card: 'generic', title: 'gen call' } })
|
||||
expect(byCall.get('tool/call:c-term')?.view).toEqual({ for: 'call', view: { card: 'terminal', title: 'echo hi' } })
|
||||
expect(byCall.get('tool/call:c-diff')?.view?.view.card).toBe('diff')
|
||||
const diffView = byCall.get('tool/call:c-diff')?.view
|
||||
expect(diffView?.for === 'call' ? diffView.view.card : undefined).toBe('diff')
|
||||
expect(byCall.get('tool/call:c-call-only')?.view).toEqual({
|
||||
for: 'call',
|
||||
view: { card: 'generic', title: 'program', kind: 'execute', rawInput: 'return value' },
|
||||
|
||||
@@ -194,6 +194,25 @@ describe('sessions domain schemas', () => {
|
||||
hasMore: false,
|
||||
modelSelection: { provider: 'deepseek-official', model: 'deepseek-v4-flash' },
|
||||
}).hasMore).toBe(false)
|
||||
const presented = {
|
||||
event: { type: 'schedule/change', seq: 2, time: 3, data: { operation: 'dispatch' } },
|
||||
view: {
|
||||
for: 'event',
|
||||
presentationKey: 'schedule/reminder',
|
||||
view: { scheduleId: 'schedule-1' },
|
||||
},
|
||||
}
|
||||
const parsedHistory = sessionHistoryValueSchema.parse({ events: [presented], hasMore: false })
|
||||
expect(parsedHistory.events?.at(0)?.view).toEqual(presented.view)
|
||||
for (const view of [
|
||||
{ for: 'event', presentationKey: '', view: {} },
|
||||
{ for: 'event', presentationKey: 'schedule/reminder' },
|
||||
]) {
|
||||
expect(() => sessionHistoryValueSchema.parse({
|
||||
events: [{ event: presented.event, view }],
|
||||
hasMore: false,
|
||||
})).toThrow()
|
||||
}
|
||||
expect(sessionModelsRequestSchema.parse({ sessionId: 's1' }).sessionId).toBe('s1')
|
||||
expect(sessionModelsValueSchema.parse({
|
||||
current: { provider: 'deepseek-official', model: 'deepseek-v4-flash', reasoningEffort: 'max' },
|
||||
@@ -420,6 +439,11 @@ describe('events frame schemas', () => {
|
||||
it('accepts every mux frame branch', () => {
|
||||
const frames = [
|
||||
{ type: 'session/event', sessionId: 's', event: { type: 't', seq: 0, time: 1, data: null } },
|
||||
{
|
||||
type: 'session/event', sessionId: 's',
|
||||
event: { type: 'schedule/change', seq: 1, time: 2, data: { operation: 'dispatch' } },
|
||||
view: { for: 'event', presentationKey: 'schedule/reminder', view: null },
|
||||
},
|
||||
{ type: 'session/subscribed', sessionId: 's', lastSeq: -1 },
|
||||
{ type: 'approval/requested', sessionId: 's', approvalId: 'a', toolName: 'bash', callId: 'c', reason: 'r' },
|
||||
{ type: 'approval/resolved', sessionId: 's', approvalId: 'a', outcome: 'allowed-once' },
|
||||
|
||||
@@ -59,6 +59,9 @@
|
||||
{
|
||||
"path": "../../session/session-title"
|
||||
},
|
||||
{
|
||||
"path": "../../schedule/tool-schedule"
|
||||
},
|
||||
{
|
||||
"path": "../../session-query/session-query"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user