refactor(time-context): restore browser zones with Projection (#6811)
* refactor(time-context): retain browser zones in Projection * fix(time-context): clarify reads and recovery checks
This commit is contained in:
1 parent
ab0116c629
commit
1696816d2c
9 files changed
+175
-34
No files matched your search
@@ -3742,7 +3742,7 @@ export type ShellDialect = 'bash' | 'pwsh'
|
||||
## `@deepseek-ai/dsh-time-context`
|
||||
|
||||
- `inject`: `agents` · `sessionProjections`
|
||||
- `source`: [`packages/context/time-context/src/index.ts:47`](../packages/context/time-context/src/index.ts)
|
||||
- `source`: [`packages/context/time-context/src/index.ts:52`](../packages/context/time-context/src/index.ts)
|
||||
|
||||
```ts config-catalog
|
||||
/** Request-preparation clock formatting and append scheduling. Invalid values fail plugin load. */
|
||||
|
||||
@@ -3744,7 +3744,7 @@ export type ShellDialect = 'bash' | 'pwsh'
|
||||
## `@deepseek-ai/dsh-time-context`
|
||||
|
||||
- `inject`: `agents` · `sessionProjections`
|
||||
- `source`: [`packages/context/time-context/src/index.ts:47`](../packages/context/time-context/src/index.ts)
|
||||
- `source`: [`packages/context/time-context/src/index.ts:52`](../packages/context/time-context/src/index.ts)
|
||||
|
||||
```ts config-catalog
|
||||
/** Request-preparation clock formatting and append scheduling. Invalid values fail plugin load. */
|
||||
|
||||
@@ -36,8 +36,8 @@
|
||||
en: 269d7aa1f64c4278
|
||||
zh: 086018a60cc3a351
|
||||
/deepseek-ai-dsh-time-context/understand-the-implementation/main-flow:
|
||||
en: f09994721526e830
|
||||
zh: 6f1a463355111513
|
||||
en: 9ec32c75627991bb
|
||||
zh: 7ee417dda0aa90a4
|
||||
/deepseek-ai-dsh-time-context/further-exploration:
|
||||
en: e1cd55f40edbb7ed
|
||||
zh: 8a2abf22ef859841
|
||||
@@ -63,8 +63,8 @@
|
||||
en: 2fbf2175168e8244
|
||||
zh: f681f89596f500f5
|
||||
/deepseek-ai-dsh-time-context/known-limitations-and-deferred-work:
|
||||
en: 8fcf131f1f00626b
|
||||
zh: a99b928aca0b1fab
|
||||
en: 825ebd4015f57a5c
|
||||
zh: 08defcdd0fa17b81
|
||||
/deepseek-ai-dsh-time-context/known-limitations-and-deferred-work/dev-note:
|
||||
en: e67f633dc6fe7773
|
||||
zh: 3bb0c20c93f16f98
|
||||
@@ -78,7 +78,7 @@ The plugin prepends an `agent/pre-step` listener that delegates first and append
|
||||
|
||||
The pre-step listener is installed before awaiting projection registration. Steps arriving during activation wait for the reader; registration failure or owner disposal stops the request, and cancellation while waiting produces no reading.
|
||||
|
||||
When an injection is due, the plugin samples the wall clock, derives the browser-zone policy from the open turn's `user-rpc` messages, resolves the display zone (request-local or fallback), and renders the three-line reading. Positive-interval scheduling scans raw durable session events for the latest plugin-attributed message — including one shadowed by compaction — so the schedule survives resume without a process-local cache. A reading records an entered step, not a completed or transmitted request; a later preparation failure can leave it in history.
|
||||
When an injection is due, the plugin samples the wall clock, derives the browser-zone policy, resolves the display zone (request-local or fallback), and renders the three-line reading. The private `timeContext` Projection stores message and injection times, the latest entered turn, and its distinct reported browser zones in encounter order. It retains zone facts from messages shadowed by compaction. Zone validation stays after rejection and refresh checks; replay does not invoke formatters or hooks. JSON checkpoints plus suffix replay match a full replay. Older-version cache rows rebuild from the full journal. A reading records an entered step, not a completed or transmitted request; a later preparation failure can leave it in history.
|
||||
|
||||
</details>
|
||||
|
||||
@@ -135,6 +135,7 @@ Append-only; newly visible content follows the reusable request prefix and does
|
||||
|
||||
These limits define when clock context is a poor fit. They are current package constraints.
|
||||
|
||||
- **State and replay cost** — timestamps and the current turn number have fixed size. Zone state grows with distinct reported strings in the latest entered turn, not all messages; each new distinct zone copies that list. Formatters remain discardable process caches. The standard AgentLoop requests its latest entered turn. If a typed pre-step caller requests another turn, the original full-prefix synchronous scan includes later entered messages. Retiring this fallback requires limiting pre-step requests to the latest entered turn and updating callers together.
|
||||
- **Prompt guidance only** — browser-zone context guides natural-language interpretation but does not silently supply another tool's required zone field.
|
||||
- **Mixed turns ask** — if one open turn contains prompts from different browser zones, the model is told to clarify rather than guess which one owns an unqualified time.
|
||||
- **Fallback is not user authority** — the configured or process zone formats the clock when current-turn browser zones are missing or mixed, but the model-facing policy still says to clarify.
|
||||
|
||||
@@ -78,7 +78,7 @@ kind: "package-reference"
|
||||
|
||||
pre-step 监听器在等待投影注册前安装。激活期间到达的步骤会等待读取句柄;注册失败或所属插件释放会终止请求,等待期间取消不会产生读数。
|
||||
|
||||
需要注入时,插件采样挂钟时间,从开放轮次的 `user-rpc` 消息派生浏览器时区策略,解析显示时区(请求本地或回退),并渲染三行读数。正数间隔调度会扫描原始持久会话事件,查找最新一条归因于插件的消息——包括被压缩(compaction)遮蔽的读数——因此调度无需进程本地缓存也能在恢复后存续。读数记录的是已进入的步骤,不是已完成或已传输的请求;后续准备失败时,该读数可能留在历史中。
|
||||
需要注入时,插件采样挂钟时间,确定浏览器时区策略,解析显示时区(请求本地或回退),并渲染三行读数。私有 `timeContext` Projection 保存消息及注入时间、最近进入的轮次,以及按出现顺序去重的浏览器时区值,也保留被压缩遮蔽消息的时区事实。时区校验仍在拒绝和刷新间隔检查之后执行;重放不调用格式化器或 hook。JSON 检查点加后缀重放与完整重放一致;旧版本缓存行通过完整日志重建。读数记录的是已进入的步骤,不是已完成或已传输的请求;后续准备失败时,该读数可能留在历史中。
|
||||
|
||||
</details>
|
||||
|
||||
@@ -135,6 +135,7 @@ Elapsed since the preceding step context: <duration-or-unavailable>.
|
||||
|
||||
这些限制说明时钟上下文何时不合适。它们是当前包约束。
|
||||
|
||||
- **状态与重放成本**:时间戳和当前轮次编号占固定空间。时区状态随最近进入轮次中不同的时区字符串数量增长,不随所有消息增长;每次增加新时区时复制该列表。格式化器仍是可丢弃的进程缓存。标准 AgentLoop 请求最近进入的轮次。若类型化 pre-step 调用方请求其他轮次,原同步完整前缀扫描会包含目标轮次之后已进入的消息。只有将 pre-step 请求明确限定为最近进入的轮次并同步更新调用方后,才可移除此回退。
|
||||
- **仅限提示词来源信息**:浏览器时区上下文用于指导自然语言解释,但不会悄然填入另一工具所要求的时区字段。
|
||||
- **混合轮次会询问**:如果同一个开放轮次包含来自不同浏览器时区的提示词,模型会收到要求澄清的指令,而不会猜测哪个时区拥有未限定的时间。
|
||||
- **回退值不代表用户权威**:浏览器来源信息缺失或混杂时,配置或进程时区用于格式化时钟,但面向模型的策略仍要求澄清。
|
||||
|
||||
@@ -23,6 +23,7 @@ import { Duration } from '@deepseek-ai/dsh-util-time'
|
||||
import type {} from '@deepseek-ai/dsh-session-projection'
|
||||
import {
|
||||
deriveBrowserTimeZoneContext,
|
||||
reportedBrowserTimeZone,
|
||||
renderBrowserTimeZoneContext,
|
||||
} from './request-zone.ts'
|
||||
import type { BrowserTimeZoneContext } from './request-zone.ts'
|
||||
@@ -38,6 +39,10 @@ const timeContextStateSchema = zod.object({
|
||||
lastInjectionTime: zod.number().nullable(),
|
||||
/** Latest injection time in the open turn, or null before that turn receives one. */
|
||||
lastTurnInjectionTime: zod.number().nullable(),
|
||||
/** Latest entered turn; null before any turn/start record. */
|
||||
browserTurn: zod.number().nullable(),
|
||||
/** Distinct reported zones in encounter order; validation remains request-time. */
|
||||
browserTimeZones: zod.array(zod.string()),
|
||||
})
|
||||
|
||||
/** The agent registry that owns pre-step processing. */
|
||||
@@ -76,7 +81,7 @@ function formatDuration(elapsedMs: number): string {
|
||||
return parts.join(' ')
|
||||
}
|
||||
|
||||
/** Collect already-entered and proposed user messages belonging to one open turn. */
|
||||
/** Preserve the original scan for typed pre-step callers requesting a nonlatest turn. */
|
||||
function requestMessages(agent: Agent, turn: number, proposed: readonly UserMessage[]): UserMessage[] {
|
||||
const entered: UserMessage[] = []
|
||||
for (let seq = agent.session.seq - 1; seq >= 0; seq -= 1) {
|
||||
@@ -152,18 +157,31 @@ export async function apply(ctx: Context, config: Config): Promise<void> {
|
||||
|
||||
const projectionReady = ctx.sessionProjections.register({
|
||||
key: 'timeContext',
|
||||
stateVersion: 2,
|
||||
stateVersion: 3,
|
||||
stateSchema: timeContextStateSchema,
|
||||
init: () => ({ lastMessageTime: null, lastInjectionTime: null, lastTurnInjectionTime: null }),
|
||||
init: () => ({
|
||||
lastMessageTime: null,
|
||||
lastInjectionTime: null,
|
||||
lastTurnInjectionTime: null,
|
||||
browserTurn: null,
|
||||
browserTimeZones: [],
|
||||
}),
|
||||
apply: (state, event) => {
|
||||
if (event.type === 'turn/start' || event.type === 'turn/end') {
|
||||
if (event.type === 'turn/start') {
|
||||
return { ...state, lastTurnInjectionTime: null, browserTurn: event.data.turn, browserTimeZones: [] }
|
||||
}
|
||||
if (event.type === 'turn/end') {
|
||||
return state.lastTurnInjectionTime === null ? state : { ...state, lastTurnInjectionTime: null }
|
||||
}
|
||||
if (event.type === 'user/message') {
|
||||
const injected = event.data.source.kind === name
|
||||
const withMessage = state.lastMessageTime === event.time
|
||||
let withMessage = state.lastMessageTime === event.time
|
||||
? state
|
||||
: { ...state, lastMessageTime: event.time }
|
||||
const timeZone = reportedBrowserTimeZone(event.data)
|
||||
if (state.browserTurn !== null && timeZone !== undefined && !state.browserTimeZones.includes(timeZone)) {
|
||||
withMessage = { ...withMessage, browserTimeZones: [...state.browserTimeZones, timeZone] }
|
||||
}
|
||||
if (!injected) return withMessage
|
||||
return {
|
||||
...withMessage,
|
||||
@@ -194,12 +212,13 @@ export async function apply(ctx: Context, config: Config): Promise<void> {
|
||||
&& now >= lastInjection
|
||||
&& now - lastInjection < refreshIntervalMs) return decision
|
||||
}
|
||||
/* v8 ignore next 6 -- every later step follows a recorded injection in the same turn */
|
||||
/* v8 ignore next 3 -- every later step follows a recorded injection in the same turn */
|
||||
const previous = step === 1
|
||||
? state.lastMessageTime ?? undefined
|
||||
: state.lastTurnInjectionTime ?? undefined
|
||||
const messages = requestMessages(agent, turn, decision.messages)
|
||||
const browser = deriveBrowserTimeZoneContext(messages)
|
||||
const browser = state.browserTurn === turn
|
||||
? deriveBrowserTimeZoneContext(decision.messages, state.browserTimeZones)
|
||||
: deriveBrowserTimeZoneContext(requestMessages(agent, turn, decision.messages))
|
||||
const selectedTimeZone = browser.kind === 'resolved' ? browser.timeZone : fallbackTimeZone
|
||||
const text = renderText(
|
||||
now,
|
||||
|
||||
@@ -1,32 +1,33 @@
|
||||
/** Browser-zone derivation and model-facing policy text for one open request turn. */
|
||||
/** Browser-zone facts from committed or proposed user-rpc messages and model-facing policy text. */
|
||||
|
||||
import { canonicalizeTimeZone } from '@deepseek-ai/dsh-util-time'
|
||||
import type { UserMessage } from '@deepseek-ai/dsh-llm'
|
||||
import { assertNever } from '@deepseek-ai/dsh-util-values'
|
||||
|
||||
/** Browser-zone facts derived from user-rpc messages in one open turn. */
|
||||
/** Browser-zone facts for the messages selected while preparing a request. */
|
||||
export type BrowserTimeZoneContext =
|
||||
| { readonly kind: 'resolved'; readonly timeZone: string }
|
||||
| { readonly kind: 'mixed'; readonly timeZones: readonly string[] }
|
||||
| { readonly kind: 'missing' }
|
||||
|
||||
/**
|
||||
* Read and validate a Host-canonicalized browser zone from one ordinary user-rpc message.
|
||||
*
|
||||
* A request may only name a zone another process resolves back to the same
|
||||
* identity, so the shared vocabulary's canonical answer must equal the reported
|
||||
* value; an alias is refused here rather than silently rewritten.
|
||||
* Read the raw zone reported by a user-rpc source; validation belongs to request preparation.
|
||||
* @param message - user message; only user sources with a string rpcId and clientTimeZone contribute.
|
||||
* @returns the reported zone, or undefined when no user-rpc zone was reported.
|
||||
*/
|
||||
function browserTimeZone(message: UserMessage): string | undefined {
|
||||
export function reportedBrowserTimeZone(message: UserMessage): string | undefined {
|
||||
const source = message.source
|
||||
const value = source.kind === 'user'
|
||||
return source.kind === 'user'
|
||||
&& 'rpcId' in source
|
||||
&& typeof source.rpcId === 'string'
|
||||
&& 'clientTimeZone' in source
|
||||
&& typeof source.clientTimeZone === 'string'
|
||||
? source.clientTimeZone
|
||||
: undefined
|
||||
if (value === undefined) return undefined
|
||||
}
|
||||
|
||||
/** Validate a reported zone at request preparation, preserving the platform refusal. */
|
||||
function browserTimeZone(value: string): string {
|
||||
const resolved = canonicalizeTimeZone(value)
|
||||
if (resolved.kind === 'accepted') {
|
||||
if (resolved.timeZone !== value) {
|
||||
@@ -51,18 +52,21 @@ function browserTimeZone(message: UserMessage): string | undefined {
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive the unique, mixed, or missing browser zone for one open turn.
|
||||
* @param messages - Entered and proposed user messages belonging to the turn.
|
||||
* Derive the unique, mixed, or missing browser zone for the selected request messages.
|
||||
* @param messages - proposed messages, or entered plus proposed messages selected by the legacy reader.
|
||||
* @param enteredTimeZones - reported zones from committed messages, before the proposed messages.
|
||||
* @returns Sorted, duplicate-free browser-zone facts.
|
||||
* @throws TypeError when a user-rpc source carries an invalid or noncanonical zone.
|
||||
*/
|
||||
export function deriveBrowserTimeZoneContext(
|
||||
messages: readonly UserMessage[],
|
||||
enteredTimeZones: readonly string[] = [],
|
||||
): BrowserTimeZoneContext {
|
||||
const timeZones = [...new Set(messages.flatMap((message) => {
|
||||
const timeZone = browserTimeZone(message)
|
||||
const reported = messages.flatMap((message) => {
|
||||
const timeZone = reportedBrowserTimeZone(message)
|
||||
return timeZone === undefined ? [] : [timeZone]
|
||||
}))].sort()
|
||||
})
|
||||
const timeZones = [...new Set([...enteredTimeZones, ...reported].map(browserTimeZone))].sort()
|
||||
const [timeZone, ...remaining] = timeZones
|
||||
if (timeZone === undefined) return { kind: 'missing' }
|
||||
if (remaining.length === 0) return { kind: 'resolved', timeZone }
|
||||
@@ -71,7 +75,7 @@ export function deriveBrowserTimeZoneContext(
|
||||
|
||||
/**
|
||||
* Render the model instruction for one browser-zone context.
|
||||
* @param context - Browser-zone facts for the open turn.
|
||||
* @param context - browser-zone facts selected for this request.
|
||||
* @returns One durable policy line.
|
||||
*/
|
||||
export function renderBrowserTimeZoneContext(context: BrowserTimeZoneContext): string {
|
||||
|
||||
@@ -53,6 +53,13 @@ describe('browser request-zone context', () => {
|
||||
])).toThrow(/browser time zone must be canonical/)
|
||||
})
|
||||
|
||||
it('combines committed reported zones with proposed messages before validation', () => {
|
||||
expect(deriveBrowserTimeZoneContext([browserMessage('America/New_York')], ['Asia/Shanghai']))
|
||||
.toEqual({ kind: 'mixed', timeZones: ['America/New_York', 'Asia/Shanghai'] })
|
||||
expect(() => deriveBrowserTimeZoneContext([browserMessage('Not/A_Real_Zone')], ['+08:00']))
|
||||
.toThrow('browser time zone must be canonical UTC or IANA Area/Location: "+08:00"')
|
||||
})
|
||||
|
||||
it('keeps the exact refusal and the platform cause for every unusable zone', () => {
|
||||
const refusal = (timeZone: string): Error => {
|
||||
try {
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { afterEach, beforeEach, describe, expect, it, onTestFinished, vi } from 'vitest'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import Loader from '@deepseek-ai/cordis-plugin-loader'
|
||||
import { createUserMessage, ToolCallId, LlmAdapter } from '@deepseek-ai/dsh-llm'
|
||||
import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm'
|
||||
import type { ContextFormed } from '@deepseek-ai/dsh-llm'
|
||||
import { Session, SessionId, type SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import { Session, SessionId, SessionLogOffset, type SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import AgentRegistry, { agentEvents, type Agent } from '@deepseek-ai/dsh-agent'
|
||||
import { defineContentToolFixture } from '@deepseek-ai/dsh-tool-testkit'
|
||||
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
|
||||
@@ -422,6 +422,115 @@ describe('configuration and lifecycle', () => {
|
||||
})
|
||||
|
||||
describe('time-context projection fold edges', () => {
|
||||
it('restores browser zones from a JSON checkpoint and suffix without reading the prefix', async () => {
|
||||
const { ctx } = await mount({ refreshIntervalMs: 0 })
|
||||
onTestFinished(() => ctx.fiber.dispose())
|
||||
const session = Session.create(SessionId('browser-checkpoint'))
|
||||
openMessageTurn(session, 1, 'Asia/Shanghai')
|
||||
const cut = session.seq
|
||||
const rows = structuredClone(ctx.sessionProjections.checkpoint(session))
|
||||
session.append('user/message', createUserMessage({
|
||||
content: [],
|
||||
source: { kind: 'user', rpcId: 'steer', clientTimeZone: 'America/New_York' } as never,
|
||||
}), { surfaceOp: 'append' })
|
||||
const events = session.snapshotEvents()
|
||||
const suffix = session.snapshotEvents(cut)
|
||||
const jsonRows = JSON.parse(JSON.stringify(rows)) as typeof rows
|
||||
const restored = ctx.sessionProjections.restore(jsonRows, suffix, cut, session.header, session.inheritedEventCount)
|
||||
expect(restored.checkpoint.timeContext).toEqual(ctx.sessionProjections.checkpoint(session).timeContext)
|
||||
|
||||
const cold = Session.create(SessionId('browser-cold'), events)
|
||||
const lookup = vi.spyOn(cold, 'eventAt')
|
||||
const snapshot = vi.spyOn(cold, 'snapshotEvents')
|
||||
const own = vi.spyOn(cold, 'ownEvents')
|
||||
ctx.sessionProjections.hydrate(cold, jsonRows, suffix, cut)
|
||||
await fire(ctx, sessionAgent(cold), 1, 1)
|
||||
// The supplied suffix is already hydrated; later lifecycle records may still be read.
|
||||
expect(lookup.mock.calls.every(([seq]) => seq >= events.length)).toBe(true)
|
||||
expect(snapshot).not.toHaveBeenCalled()
|
||||
expect(own).not.toHaveBeenCalled()
|
||||
lookup.mockRestore()
|
||||
snapshot.mockRestore()
|
||||
own.mockRestore()
|
||||
expect(contextTexts(cold).at(-1)).toContain('mixed ["America/New_York","Asia/Shanghai"]')
|
||||
})
|
||||
|
||||
it('rebuilds an old checkpoint from the full journal and removes the unit on unload', async () => {
|
||||
const { ctx, fiber } = await mount()
|
||||
onTestFinished(() => ctx.fiber.dispose())
|
||||
const session = Session.create(SessionId('browser-old-row'))
|
||||
openMessageTurn(session, 1, 'Asia/Shanghai')
|
||||
const rows = ctx.sessionProjections.checkpoint(session)
|
||||
const oldRows = {
|
||||
...rows,
|
||||
timeContext: {
|
||||
ver: 2,
|
||||
seq: rows.timeContext!.seq,
|
||||
val: { lastMessageTime: BASE, lastInjectionTime: null, lastTurnInjectionTime: null },
|
||||
},
|
||||
}
|
||||
const cold = Session.create(SessionId('browser-old-cold'), session.snapshotEvents())
|
||||
ctx.sessionProjections.hydrate(cold, oldRows, cold.snapshotEvents(), SessionLogOffset(0))
|
||||
expect(ctx.sessionProjections.checkpoint(cold).timeContext).toEqual({
|
||||
ver: 3, seq: cold.seq - 1, val: rows.timeContext!.val,
|
||||
})
|
||||
await fiber.dispose()
|
||||
expect(ctx.sessionProjections.checkpoint(cold).timeContext).toBeUndefined()
|
||||
})
|
||||
|
||||
it('retains reported invalid zones without validating rejected or throttled requests', async () => {
|
||||
const { ctx } = await mount({ refreshIntervalMs: 1000 })
|
||||
onTestFinished(() => ctx.fiber.dispose())
|
||||
const session = Session.create(SessionId('browser-refusal-timing'))
|
||||
const agent = sessionAgent(session)
|
||||
openMessageTurn(session, 1, 'UTC')
|
||||
await fire(ctx, agent, 1, 1)
|
||||
session.append('user/message', createUserMessage({
|
||||
content: [],
|
||||
source: { kind: 'user', rpcId: 'invalid', clientTimeZone: '+08:00' } as never,
|
||||
}), { surfaceOp: 'append' })
|
||||
expect(ctx.sessionProjections.checkpoint(session).timeContext?.val).toMatchObject({
|
||||
browserTimeZones: ['UTC', '+08:00'],
|
||||
})
|
||||
vi.setSystemTime(BASE + 500)
|
||||
await expect(fire(ctx, agent, 1, 2)).resolves.toBeUndefined()
|
||||
vi.setSystemTime(BASE + 1000)
|
||||
const reject = ctx.on('agent/pre-step', () => Promise.resolve({ kind: 'reject' as const }))
|
||||
await expect(fire(ctx, agent, 1, 2)).resolves.toBeUndefined()
|
||||
reject()
|
||||
await expect(fire(ctx, agent, 1, 2)).rejects.toThrow(
|
||||
'browser time zone must be canonical UTC or IANA Area/Location: "+08:00"',
|
||||
)
|
||||
expect(contextTexts(session)).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('keeps the historical requested-turn fallback and resets distinct zones on the next turn', async () => {
|
||||
const { ctx } = await mount({ refreshIntervalMs: 0 })
|
||||
onTestFinished(() => ctx.fiber.dispose())
|
||||
const session = Session.create(SessionId('browser-historical-turn'))
|
||||
session.append('user/message', createUserMessage({
|
||||
content: [],
|
||||
source: { kind: 'user', rpcId: 'before-turn', clientTimeZone: 'Europe/London' } as never,
|
||||
}), { surfaceOp: 'append' })
|
||||
expect(ctx.sessionProjections.checkpoint(session).timeContext?.val).toMatchObject({
|
||||
browserTurn: null, browserTimeZones: [],
|
||||
})
|
||||
openMessageTurn(session, 1, 'Asia/Shanghai')
|
||||
session.append('user/message', createUserMessage({
|
||||
content: [],
|
||||
source: { kind: 'user', rpcId: 'duplicate', clientTimeZone: 'Asia/Shanghai' } as never,
|
||||
}), { surfaceOp: 'append' })
|
||||
expect(ctx.sessionProjections.checkpoint(session).timeContext?.val).toMatchObject({
|
||||
browserTurn: 1, browserTimeZones: ['Asia/Shanghai'],
|
||||
})
|
||||
openMessageTurn(session, 2, 'America/New_York')
|
||||
expect(ctx.sessionProjections.checkpoint(session).timeContext?.val).toMatchObject({
|
||||
browserTurn: 2, browserTimeZones: ['America/New_York'],
|
||||
})
|
||||
await fire(ctx, sessionAgent(session), 1, 1)
|
||||
expect(contextTexts(session).at(-1)).toContain('mixed ["America/New_York","Asia/Shanghai"]')
|
||||
})
|
||||
|
||||
it('clears the open-turn injection time at the next turn start', async () => {
|
||||
const { ctx } = await mount()
|
||||
const session = Session.create(SessionId('same-turn'))
|
||||
|
||||
Reference in new issue
Block a user