feat(schedule): add durable after reminders
This commit is contained in:
@@ -26,7 +26,7 @@ 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.
|
||||
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', view }`. The durable event type selects the client renderer. 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.
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ Settings 分节中的 `reasoningEffort` 在 agent-default-model 插件配置中
|
||||
|
||||
`session.history` 会读取已附加 Session 的内存状态,或通过持久化检查冷日志,而不会恢复或发布 agent,然后按追加来源的消息边界分页:`maxMessages` 统计以追加方式进入 surface 的 `user/message` 和 `assistant/message` 事件,因此仅供模型使用的替换副本不占用配额。每一页仍是一段连续的原始事件区间,从而让压缩(compaction)的仅日志 `compact/summary` 记录与引用它的替换留在同一页。
|
||||
|
||||
可选的 `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 本身已经是持久前缀。
|
||||
可选的 `SessionEventView` 是非持久 presentation sidecar。工具 call/result 保留既有 Host presenter。Schedule dispatch 在 append 时保持 raw;收到获确认的 `session/flushed(session, throughSeq)` 后,网关才以 `max` 推进按 exact Session 键控的 `WeakMap` cursor,通过 Schedule package 派生新覆盖的回执,并用 `{ for: 'event', view }` 重投完全相同的事件。持久事件类型选择客户端 renderer。反序完成的 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 页永不携带该块,未装注册表的组合则两个面都不提供。
|
||||
|
||||
|
||||
@@ -60,10 +60,7 @@ 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 { 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).
|
||||
@@ -486,7 +483,7 @@ function scheduleViewFor(
|
||||
const view = scheduleReminderPresentation(events, event.seq, header.seedLength ?? 0)
|
||||
return view === undefined
|
||||
? undefined
|
||||
: { for: 'event', presentationKey: SCHEDULE_REMINDER_PRESENTATION_KEY, view }
|
||||
: { for: 'event', view }
|
||||
} catch (error: unknown) {
|
||||
ctx.logger.warn(`api-proxy: Schedule presentation failed at seq ${event.seq}; serving raw event: ${String(error)}`)
|
||||
return undefined
|
||||
|
||||
@@ -33,14 +33,12 @@ export type ToolEventView =
|
||||
| { 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.
|
||||
* Host-computed presentation for one non-surface Session event. The durable
|
||||
* event type selects an optional client renderer; the sidecar carries only the
|
||||
* JSON-compatible view so the connection package adds no domain vocabulary.
|
||||
*/
|
||||
export interface PresentedEventView {
|
||||
for: 'event'
|
||||
presentationKey: string
|
||||
view: unknown
|
||||
}
|
||||
|
||||
|
||||
@@ -193,10 +193,9 @@ export const toolEventViewSchema = z.discriminatedUnion('for', [
|
||||
z.object({ for: z.literal('result'), view: z.looseObject({ card: z.string() }) }),
|
||||
]) as unknown as z.ZodType<ToolEventView>
|
||||
|
||||
/** Domain-owned presented-event sidecar with a carrier-validated key and present payload. */
|
||||
/** Domain-owned presented-event sidecar whose durable event supplies the renderer key. */
|
||||
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',
|
||||
|
||||
@@ -104,7 +104,6 @@ describe('commit-aware Schedule live views', () => {
|
||||
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',
|
||||
@@ -112,7 +111,6 @@ describe('commit-aware Schedule live views', () => {
|
||||
},
|
||||
{
|
||||
for: 'event',
|
||||
presentationKey: 'schedule/reminder',
|
||||
view: {
|
||||
scheduleId: 'schedule-2', prompt: 'second',
|
||||
occurrenceAt: '2026-08-05T12:00:01.000Z', deliveryMode: 'session-local',
|
||||
@@ -143,7 +141,7 @@ describe('commit-aware Schedule live views', () => {
|
||||
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',
|
||||
for: 'event',
|
||||
})
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
@@ -180,7 +178,7 @@ describe('Schedule history views', () => {
|
||||
events: [...session.events.slice(0, 2)],
|
||||
})
|
||||
expect((await history()).find(entry => entry.event.seq === 1)?.view).toMatchObject({
|
||||
for: 'event', presentationKey: 'schedule/reminder',
|
||||
for: 'event',
|
||||
})
|
||||
inspect = () => Promise.resolve({
|
||||
meta: { ...session.header, cwd: '/different', delegationDepth: 0 },
|
||||
@@ -213,7 +211,7 @@ describe('Schedule history views', () => {
|
||||
})
|
||||
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',
|
||||
for: 'event',
|
||||
})
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
@@ -198,16 +198,12 @@ describe('sessions domain schemas', () => {
|
||||
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' },
|
||||
]) {
|
||||
for (const view of [{ for: 'event' }]) {
|
||||
expect(() => sessionHistoryValueSchema.parse({
|
||||
events: [{ event: presented.event, view }],
|
||||
hasMore: false,
|
||||
@@ -442,7 +438,7 @@ describe('events frame schemas', () => {
|
||||
{
|
||||
type: 'session/event', sessionId: 's',
|
||||
event: { type: 'schedule/change', seq: 1, time: 2, data: { operation: 'dispatch' } },
|
||||
view: { for: 'event', presentationKey: 'schedule/reminder', view: null },
|
||||
view: { for: 'event', view: null },
|
||||
},
|
||||
{ type: 'session/subscribed', sessionId: 's', lastSeq: -1 },
|
||||
{ type: 'approval/requested', sessionId: 's', approvalId: 'a', toolName: 'bash', callId: 'c', reason: 'r' },
|
||||
|
||||
Reference in New Issue
Block a user