feat(schedule): add absolute-time reminders
This commit is contained in:
@@ -11,7 +11,10 @@ import type { HostFrame, MuxFrame, RpcMessage, RpcRequest } from '../src/client/
|
||||
import { FixtureApiClient, createFixtureApi } from '../src/client/fixture.ts'
|
||||
|
||||
const sid = (id: string): SessionId => id as SessionId
|
||||
const req = <P>(payload: P): RpcRequest<P> => ({ rpcId: RpcId(`t-${Math.abs(Math.sin(reqCount++)).toString(36).slice(2, 10)}`), payload })
|
||||
const req = <P>(payload: P): RpcRequest<P> => ({
|
||||
rpcId: RpcId(`t-${Math.abs(Math.sin(reqCount++)).toString(36).slice(2, 10)}`),
|
||||
payload: { timeZone: 'UTC', clientTimeZone: 'UTC', ...payload },
|
||||
})
|
||||
let reqCount = 0
|
||||
|
||||
interface TimingHooks {
|
||||
@@ -755,6 +758,79 @@ describe('createFixtureApi', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('mirrors canonical Session and message-bound client zone handling', async () => {
|
||||
const api = createFixtureApi({ empty: true })
|
||||
const sessionId = sid('fx-zone')
|
||||
const alias = 'US/Eastern'
|
||||
const canonical = new Intl.DateTimeFormat('en-US', { timeZone: alias })
|
||||
.resolvedOptions().timeZone
|
||||
|
||||
await expect(api.sessions.create(req({ sessionId, timeZone: alias }))).resolves.toMatchObject({
|
||||
result: { ok: true, value: { sessionId } },
|
||||
})
|
||||
await expect(api.sessions.create(req({ sessionId, timeZone: canonical }))).resolves.toMatchObject({
|
||||
result: { ok: true, value: { sessionId } },
|
||||
})
|
||||
const conflict = await api.sessions.create(req({ sessionId, timeZone: 'Asia/Shanghai' }))
|
||||
expect(conflict.result).toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
code: 'session-conflict',
|
||||
details: {
|
||||
sessionId,
|
||||
requestedTimeZone: 'Asia/Shanghai',
|
||||
existingTimeZone: canonical,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const prompted = await api.sessions.prompt(req({
|
||||
sessionId,
|
||||
mode: 'queue',
|
||||
content: [{ type: 'text', text: 'zone-bound' }],
|
||||
clientTimeZone: alias,
|
||||
}))
|
||||
expect(prompted.result).toMatchObject({ ok: true })
|
||||
const history = await api.sessions.history(req({ sessionId }))
|
||||
if (!history.result.ok) throw new Error('fixture history failed')
|
||||
const user = history.result.value.events.find(entry => entry.event.type === 'user/message')
|
||||
expect(user?.event).toMatchObject({
|
||||
type: 'user/message',
|
||||
data: { source: { kind: 'user', clientTimeZone: canonical } },
|
||||
})
|
||||
})
|
||||
|
||||
it.each([
|
||||
['timeZone', undefined],
|
||||
['timeZone', 'CST'],
|
||||
['timeZone', 'Not/A_Real_Zone'],
|
||||
['clientTimeZone', undefined],
|
||||
['clientTimeZone', 'CST'],
|
||||
['clientTimeZone', 'Not/A_Real_Zone'],
|
||||
] as const)('rejects invalid fixture %s input %j', async (field, value) => {
|
||||
const api = createFixtureApi({ empty: true })
|
||||
if (field === 'timeZone') {
|
||||
const created = await api.sessions.create(req({ timeZone: value }))
|
||||
expect(created.result).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: 'invalid-time-zone', details: { field, value: value ?? null } },
|
||||
})
|
||||
return
|
||||
}
|
||||
const created = await api.sessions.create(req({ timeZone: 'UTC' }))
|
||||
if (!created.result.ok) throw new Error('fixture create failed')
|
||||
const prompted = await api.sessions.prompt(req({
|
||||
sessionId: created.result.value.sessionId,
|
||||
mode: 'queue',
|
||||
content: [{ type: 'text', text: 'rejected' }],
|
||||
clientTimeZone: value,
|
||||
}))
|
||||
expect(prompted.result).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: 'invalid-time-zone', details: { field, value: value ?? null } },
|
||||
})
|
||||
})
|
||||
|
||||
it('attaches an existing ungrouped Session to a matching Workspace', async () => {
|
||||
const api = createFixtureApi()
|
||||
const sessionId = sid('fx-existing-ungrouped')
|
||||
@@ -786,7 +862,12 @@ describe('createFixtureApi', () => {
|
||||
error: {
|
||||
code: 'session-conflict',
|
||||
message: `session ${existing.sessionId} already uses no cwd`,
|
||||
details: { sessionId: existing.sessionId, requestedCwd: '/tmp/fixture' },
|
||||
details: {
|
||||
sessionId: existing.sessionId,
|
||||
requestedCwd: '/tmp/fixture',
|
||||
requestedTimeZone: 'UTC',
|
||||
existingTimeZone: 'UTC',
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
@@ -983,11 +1064,16 @@ describe('FixtureApiClient (protocol-level fake carrier)', () => {
|
||||
{ query: 'fixture' },
|
||||
new AbortController().signal,
|
||||
)).result.ok).toBe(true)
|
||||
const created = await client.sessions.create({})
|
||||
const created = await client.sessions.create({ timeZone: 'UTC' })
|
||||
if (!created.result.ok) throw new Error('create failed')
|
||||
const id = created.result.value.sessionId
|
||||
expect((await client.sessions.history({ sessionId: id })).result.ok).toBe(true)
|
||||
expect((await client.sessions.prompt({ sessionId: id, mode: 'queue', content: [{ type: 'text', text: '嗨' }] })).result.ok).toBe(true)
|
||||
expect((await client.sessions.prompt({
|
||||
sessionId: id,
|
||||
mode: 'queue',
|
||||
content: [{ type: 'text', text: '嗨' }],
|
||||
clientTimeZone: 'UTC',
|
||||
})).result.ok).toBe(true)
|
||||
expect((await client.sessions.cancel({ sessionId: id })).result.ok).toBe(true)
|
||||
expect((await client.host.describe({})).result.ok).toBe(true)
|
||||
expect((await client.workspace.list({})).result.ok).toBe(true)
|
||||
@@ -998,7 +1084,7 @@ describe('FixtureApiClient (protocol-level fake carrier)', () => {
|
||||
const renamed = await client.workspace.rename({ workspaceId: wsid, title: 'via-client-2' })
|
||||
if (!renamed.result.ok) throw new Error('workspace rename failed')
|
||||
expect(renamed.result.value.workspace.title).toBe('via-client-2')
|
||||
const attached = await client.sessions.create({ workspaceId: wsid })
|
||||
const attached = await client.sessions.create({ workspaceId: wsid, timeZone: 'UTC' })
|
||||
if (!attached.result.ok) throw new Error('attached create failed')
|
||||
const moved = await client.workspace.insertSessionBefore({ workspaceId: wsid, sessionId: attached.result.value.sessionId })
|
||||
if (!moved.result.ok) throw new Error('workspace move failed')
|
||||
@@ -1058,6 +1144,7 @@ describe('FixtureApiClient (protocol-level fake carrier)', () => {
|
||||
const created = await client.sessions.create({
|
||||
workspaceId: made.result.value.workspace.workspaceId,
|
||||
sessionId,
|
||||
timeZone: 'UTC',
|
||||
})
|
||||
expect(created.result).toMatchObject({ ok: true, value: { sessionId } })
|
||||
const frames = await framesPromise
|
||||
@@ -1066,6 +1153,7 @@ describe('FixtureApiClient (protocol-level fake carrier)', () => {
|
||||
sessionId,
|
||||
mode: 'queue',
|
||||
content: [{ type: 'text', text: 'retain' }],
|
||||
clientTimeZone: 'UTC',
|
||||
})
|
||||
expect(rejected.result).toMatchObject({ ok: false, error: { code: 'agent-busy' } })
|
||||
})
|
||||
@@ -1076,6 +1164,7 @@ describe('FixtureApiClient (protocol-level fake carrier)', () => {
|
||||
const partialResult = await partial.sessions.create({
|
||||
workspaceId: 'fx-ws-fixture' as WorkspaceId,
|
||||
sessionId: sid('fx-query-partial'),
|
||||
timeZone: 'UTC',
|
||||
})
|
||||
expect(partialResult.result).toMatchObject({
|
||||
ok: false,
|
||||
@@ -1087,6 +1176,7 @@ describe('FixtureApiClient (protocol-level fake carrier)', () => {
|
||||
await expect(dropped.sessions.create({
|
||||
workspaceId: 'fx-ws-fixture' as WorkspaceId,
|
||||
sessionId: sid('fx-query-dropped'),
|
||||
timeZone: 'UTC',
|
||||
})).rejects.toThrow(/dropped session\.create response/)
|
||||
})
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import type {
|
||||
// plugin-to-plugin value imports are a bundle purity error.
|
||||
import { transportError } from '@deepseek-ai/dsh-host-apiproxy/api'
|
||||
import { mergeOrderedBaseline } from '../ordered-baseline.ts'
|
||||
import { resolvedClientTimeZone } from '../time-zone.ts'
|
||||
import type { SessionListEntry, TitledSessionSummary } from './lineage.ts'
|
||||
import { flattenLineage } from './lineage.ts'
|
||||
import type { PendingInteractionStatus } from './pending.ts'
|
||||
@@ -514,7 +515,10 @@ export class SessionManager {
|
||||
opts: { workspaceId?: WorkspaceId; cwd?: string; sessionId?: SessionId } = {},
|
||||
): Promise<RpcResult<{ sessionId: SessionId }>> {
|
||||
try {
|
||||
const shared = opts.sessionId === undefined ? {} : { sessionId: opts.sessionId }
|
||||
const shared = {
|
||||
timeZone: resolvedClientTimeZone(),
|
||||
...(opts.sessionId === undefined ? {} : { sessionId: opts.sessionId }),
|
||||
}
|
||||
const payload = opts.workspaceId !== undefined
|
||||
? { workspaceId: opts.workspaceId, ...shared }
|
||||
: { ...(opts.cwd === undefined ? {} : { cwd: opts.cwd }), ...shared }
|
||||
|
||||
@@ -25,6 +25,7 @@ import { isVisibleAssistantChunk, PartialAccumulator } from './partial.ts'
|
||||
import { ProjectionValueStore } from './projection-store.ts'
|
||||
import type { ProjectionsBaseline } from './projection-store.ts'
|
||||
import { ToolCallTree } from './tool-call-tree.ts'
|
||||
import { resolvedClientTimeZone } from '../time-zone.ts'
|
||||
|
||||
/** Messages requested per history page. */
|
||||
export const PAGE_MESSAGES = 50
|
||||
@@ -232,7 +233,12 @@ export class Session implements SessionFace {
|
||||
let result: RpcResult<{ accepted: true }>
|
||||
try {
|
||||
if (this.address === undefined) {
|
||||
result = (await this.api.sessions.prompt({ sessionId: this.sessionId, mode, content })).result
|
||||
result = (await this.api.sessions.prompt({
|
||||
sessionId: this.sessionId,
|
||||
mode,
|
||||
content,
|
||||
clientTimeZone: resolvedClientTimeZone(),
|
||||
})).result
|
||||
} else if (this.address.mode === 'one-shot') {
|
||||
result = {
|
||||
ok: false,
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
/** Browser-owned time-zone sampling for Session and prompt RPC provenance. */
|
||||
|
||||
/**
|
||||
* Resolve the current browser IANA zone for one outbound operation.
|
||||
* @returns The browser-provided canonical zone.
|
||||
* @throws when the runtime cannot provide a non-empty zone.
|
||||
*/
|
||||
export function resolvedClientTimeZone(): string {
|
||||
const timeZone = new Intl.DateTimeFormat().resolvedOptions().timeZone
|
||||
if (typeof timeZone !== 'string' || timeZone.length === 0) {
|
||||
throw new Error('browser time zone is unavailable')
|
||||
}
|
||||
return timeZone
|
||||
}
|
||||
@@ -12,8 +12,11 @@ import TypertRegistry from '@deepseek-ai/dsh-typert-registry'
|
||||
import * as RuntimeClient from '../src/client/index.ts'
|
||||
import type { SessionsService } from '../src/client/sessions/service.ts'
|
||||
import type { WorkspacesService } from '../src/client/workspaces/service.ts'
|
||||
import { resolvedClientTimeZone } from '../src/client/time-zone.ts'
|
||||
import { FakeApiClient, ok } from './fake-api.ts'
|
||||
|
||||
const CLIENT_TIME_ZONE = resolvedClientTimeZone()
|
||||
|
||||
interface Bench {
|
||||
ctx: Context
|
||||
api: FakeApiClient
|
||||
@@ -102,7 +105,10 @@ describe('runtime client apply', () => {
|
||||
|
||||
const sessions = bench.ctx.get('sessions') as SessionsService
|
||||
const workspaces = bench.ctx.get('workspaces') as WorkspacesService
|
||||
expect(bench.api.callsOf('session.create')).toEqual([{ workspaceId: 'w-recent' }])
|
||||
expect(bench.api.callsOf('session.create')).toEqual([{
|
||||
workspaceId: 'w-recent',
|
||||
timeZone: CLIENT_TIME_ZONE,
|
||||
}])
|
||||
expect(sessions.list.getSnapshot().current).toBe('fk-new')
|
||||
|
||||
sessions.clear()
|
||||
|
||||
@@ -6,11 +6,13 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import { SessionManager } from '../src/client/sessions/manager.ts'
|
||||
import { resolvedClientTimeZone } from '../src/client/time-zone.ts'
|
||||
import { FakeApiClient, deferred, err, ok } from './fake-api.ts'
|
||||
import { entries, plainTurn } from './event-script.ts'
|
||||
|
||||
const S1 = 'fk-m1' as SessionId
|
||||
const S2 = 'fk-m2' as SessionId
|
||||
const CLIENT_TIME_ZONE = resolvedClientTimeZone()
|
||||
|
||||
type SummaryOver = Partial<{
|
||||
updatedAt: number
|
||||
@@ -708,7 +710,11 @@ describe('remaining branches', () => {
|
||||
api.onCreate = () => Promise.resolve(ok({ sessionId: S1 }))
|
||||
const manager = new SessionManager(api)
|
||||
await manager.create({ cwd: '/tmp/w', sessionId: S1 })
|
||||
expect(api.callsOf('session.create')).toEqual([{ cwd: '/tmp/w', sessionId: S1 }])
|
||||
expect(api.callsOf('session.create')).toEqual([{
|
||||
cwd: '/tmp/w',
|
||||
sessionId: S1,
|
||||
timeZone: CLIENT_TIME_ZONE,
|
||||
}])
|
||||
expect(manager.getListSnapshot().items[0]).toMatchObject({ sessionId: S1, cwd: '/tmp/w' })
|
||||
await manager.create({ cwd: '/tmp/w' }) // same id returned: no duplicate row
|
||||
expect(manager.getListSnapshot().items).toHaveLength(1)
|
||||
|
||||
@@ -11,6 +11,7 @@ import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
|
||||
import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import { Session } from '../src/client/sessions/session.ts'
|
||||
import { resolvedClientTimeZone } from '../src/client/time-zone.ts'
|
||||
import { FakeApiClient, deferred, err, ok } from './fake-api.ts'
|
||||
import { entries, ev, plainTurn } from './event-script.ts'
|
||||
|
||||
@@ -19,6 +20,7 @@ const at = (seq: number, e: Record<string, unknown>): SessionEvent =>
|
||||
|
||||
const SID = 'fk-s1' as SessionId
|
||||
const PARENT = 'fk-parent' as SessionId
|
||||
const CLIENT_TIME_ZONE = resolvedClientTimeZone()
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
@@ -722,7 +724,12 @@ describe('prompt and cancel errors', () => {
|
||||
expect(result.ok).toBe(true)
|
||||
// Monotone: settlement alone does not step the phase anywhere.
|
||||
expect(session.getSnapshot().composerPhase).toBe('engaging')
|
||||
expect(api.callsOf('session.prompt')).toMatchObject([{ sessionId: SID, mode: 'queue', content: [{ type: 'text', text: '要发的' }] }])
|
||||
expect(api.callsOf('session.prompt')).toEqual([{
|
||||
sessionId: SID,
|
||||
mode: 'queue',
|
||||
content: [{ type: 'text', text: '要发的' }],
|
||||
clientTimeZone: CLIENT_TIME_ZONE,
|
||||
}])
|
||||
// First content lands (running turn): engaging → active.
|
||||
session.handleRunning(true)
|
||||
expect(session.getSnapshot().composerPhase).toBe('active')
|
||||
|
||||
@@ -10,9 +10,11 @@ import { Context } from 'cordis'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import { SessionCreateError, SessionsService, scopeOf } from '../src/client/sessions/service.ts'
|
||||
import { resolvedClientTimeZone } from '../src/client/time-zone.ts'
|
||||
import { FakeApiClient, deferred, err, ok } from './fake-api.ts'
|
||||
|
||||
const sid = (s: string): SessionId => s as SessionId
|
||||
const CLIENT_TIME_ZONE = resolvedClientTimeZone()
|
||||
|
||||
interface Bench {
|
||||
ctx: Context
|
||||
@@ -452,7 +454,11 @@ describe('create', () => {
|
||||
const b = bench()
|
||||
b.api.onCreate = () => Promise.resolve(ok({ sessionId: sid('fresh') }))
|
||||
await expect(b.svc.create({ cwd: '/w', sessionId: sid('fresh') })).resolves.toBe('fresh')
|
||||
expect(b.api.callsOf('session.create')).toEqual([{ cwd: '/w', sessionId: 'fresh' }])
|
||||
expect(b.api.callsOf('session.create')).toEqual([{
|
||||
cwd: '/w',
|
||||
sessionId: 'fresh',
|
||||
timeZone: CLIENT_TIME_ZONE,
|
||||
}])
|
||||
b.api.onCreate = () => Promise.resolve({
|
||||
rpcId: 'e' as never,
|
||||
result: { ok: false as const, error: { code: 'internal' as const, message: '爆了', details: {} } },
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { resolvedClientTimeZone } from '../src/client/time-zone.ts'
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('browser time zone', () => {
|
||||
it('returns the runtime-resolved zone', () => {
|
||||
expect(resolvedClientTimeZone()).toBe(
|
||||
new Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||
)
|
||||
})
|
||||
|
||||
it('fails loud when the runtime exposes no zone', () => {
|
||||
const options = new Intl.DateTimeFormat().resolvedOptions()
|
||||
vi.spyOn(Intl.DateTimeFormat.prototype, 'resolvedOptions').mockReturnValue({
|
||||
...options,
|
||||
timeZone: '',
|
||||
})
|
||||
|
||||
expect(() => resolvedClientTimeZone()).toThrow('browser time zone is unavailable')
|
||||
})
|
||||
})
|
||||
@@ -2,12 +2,14 @@ import { Context } from 'cordis'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { SessionId, WorkspaceId, WorkspaceView } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import { SessionsService } from '../src/client/sessions/service.ts'
|
||||
import { resolvedClientTimeZone } from '../src/client/time-zone.ts'
|
||||
import { WorkspaceManager } from '../src/client/workspaces/manager.ts'
|
||||
import { DirectoryBrowseError, WorkspaceCreateError, WorkspacesService } from '../src/client/workspaces/service.ts'
|
||||
import { FakeApiClient, deferred, err, ok } from './fake-api.ts'
|
||||
|
||||
const sid = (id: string): SessionId => id as SessionId
|
||||
const wid = (id: string): WorkspaceId => id as WorkspaceId
|
||||
const CLIENT_TIME_ZONE = resolvedClientTimeZone()
|
||||
|
||||
function workspace(id: string, sessionIds: SessionId[] = [], createdAt = '2026-01-01T00:00:00.000Z'): WorkspaceView {
|
||||
return {
|
||||
@@ -188,7 +190,10 @@ describe('WorkspacesService', () => {
|
||||
// Miss: beta has only a non-blank session → host create with workspaceId.
|
||||
api.onCreate = () => Promise.resolve(ok({ sessionId: sid('s-fresh') }))
|
||||
await expect(workspaces.connectWorkspace(wid('beta'))).resolves.toBe('s-fresh')
|
||||
expect(api.callsOf('session.create')).toEqual([{ workspaceId: 'beta' }])
|
||||
expect(api.callsOf('session.create')).toEqual([{
|
||||
workspaceId: 'beta',
|
||||
timeZone: CLIENT_TIME_ZONE,
|
||||
}])
|
||||
// Same guarantee on the create arm (draft hand-off writes the machine pre-open).
|
||||
expect(sessions.binding(sid('s-fresh'))).toBeDefined()
|
||||
|
||||
@@ -196,7 +201,10 @@ describe('WorkspacesService', () => {
|
||||
// never reused, a fresh accounted session is created instead.
|
||||
api.onCreate = () => Promise.resolve(ok({ sessionId: sid('s-fresh-3') }))
|
||||
await expect(workspaces.connectWorkspace(wid('gamma'))).resolves.toBe('s-fresh-3')
|
||||
expect(api.callsOf('session.create')).toEqual([{ workspaceId: 'beta' }, { workspaceId: 'gamma' }])
|
||||
expect(api.callsOf('session.create')).toEqual([
|
||||
{ workspaceId: 'beta', timeZone: CLIENT_TIME_ZONE },
|
||||
{ workspaceId: 'gamma', timeZone: CLIENT_TIME_ZONE },
|
||||
])
|
||||
|
||||
// Unknown workspace fails loud instead of silently creating in nowhere.
|
||||
await expect(workspaces.connectWorkspace(wid('ghost'))).rejects.toThrow(/unknown workspace ghost/)
|
||||
@@ -411,7 +419,10 @@ describe('startInitialSelection', () => {
|
||||
await b.sessions.refresh()
|
||||
// Store notifications and the connect round trip are microtask-batched.
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
expect(b.api.callsOf('session.create')).toEqual([{ workspaceId: 'recent' }])
|
||||
expect(b.api.callsOf('session.create')).toEqual([{
|
||||
workspaceId: 'recent',
|
||||
timeZone: CLIENT_TIME_ZONE,
|
||||
}])
|
||||
expect(b.sessions.list.getSnapshot().current).toBe('s-new')
|
||||
stop()
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user