fix(time-context): preserve empty pre-step decisions
This commit is contained in:
@@ -16,13 +16,13 @@ Opt-in durable context with the current zoned time, immutable Session zone, requ
|
||||
|
||||
When a Session has `SessionHeader.timeZone`, that immutable IANA zone formats its readings. A headerless Session instead uses the configured fallback; when `timeZone` is omitted, the plugin resolves the Node process's system zone once at plugin load. Node honors `TZ`; without that override, the host or container supplies the fallback. An explicit `timeZone` is validated at plugin load but does not override a Session-owned zone.
|
||||
|
||||
`refreshIntervalMs` must be a non-negative safe integer. Omission or `0` adds context to every entered request step whose signal is not already aborted. A positive value adds it only when the session has no earlier time-context injection, wall time moved backward, or at least that many milliseconds have elapsed since the latest injection.
|
||||
`refreshIntervalMs` must be a non-negative safe integer. Omission or `0` adds context to every non-empty entered request batch whose signal is not already aborted. A positive value adds it only when the session has no earlier time-context injection, wall time moved backward, or at least that many milliseconds have elapsed since the latest injection.
|
||||
|
||||
## Timing semantics
|
||||
|
||||
The plugin prepends an `agent/pre-step` listener and delegates first. When the downstream decision enters a request step, time-context derives client zones from the decision's final messages plus user-rpc messages already entered in the open turn, then appends one reading to that decision. Schedule later derives the same facts directly from the immutable Session header and those durable user-rpc sources; the reading is not a second machine authority.
|
||||
The plugin prepends an `agent/pre-step` listener and delegates first. When the downstream decision enters a non-empty message batch, time-context derives client zones from those final messages plus user-rpc messages already entered in the open turn, then appends one reading to that decision. Schedule later derives the same facts directly from the immutable Session header and those durable user-rpc sources; the reading is not a second machine authority.
|
||||
|
||||
An entering step records its downstream messages followed by exactly one time-context `UserMessage` after `step/start`. Its source is the simple marker `{ kind: 'plugin', plugin: 'time-context' }`; the Session header and original user-rpc sources remain the only machine-readable zone owners. A first-step decision rewritten to empty opens no step and adds no reading. An empty tool continuation can still enter a later step and receives a reading.
|
||||
An entering non-empty batch records its downstream messages followed by exactly one time-context `UserMessage` after `step/start`. Its source is the simple marker `{ kind: 'plugin', plugin: 'time-context' }`; the Session header and original user-rpc sources remain the only machine-readable zone owners. A decision rewritten to empty never gains a reading: it opens no initial step, and an empty tool continuation may still enter a later step using existing history.
|
||||
|
||||
Reject, cancellation, and listener failure before `step/start` add no reading. A plugin disposal that wins while the listener awaits downstream work also prevents the in-flight listener from contributing. Steering inserted after AgentLoop has claimed the current batch retains ordinary next-step ownership and receives fresh context when that later step enters; time-context adds no inbox state or AgentLoop lifecycle path.
|
||||
|
||||
@@ -34,7 +34,7 @@ A time reading records an entered request step, not a completed or successfully
|
||||
|
||||
The separately published `./invariant` companion checks the simple plugin source, open turn and step, elapsed baseline, and durable event time. It also re-derives Session and client zones from the Session header and current turn's original user-rpc messages, so duplicated source authority or mismatched rendered policy fails. The rendered timestamp must parse and cannot postdate the event; process suspension between sampling and append does not invalidate the reading.
|
||||
|
||||
The time reading stays in derived conversation history until a later compaction shadows it. Request headers contain no time-context state. Request reconstruction uses the complete durable surface prefix after each `step/start`, so transmitted requests need not map one-to-one to readings: request preparation can fail after step entry, while interval suppression can let a request reuse existing history without adding one.
|
||||
The time reading stays in derived conversation history until a later compaction shadows it. Request headers contain no time-context state. Request reconstruction uses the complete durable surface prefix after each `step/start`, so transmitted requests need not map one-to-one to readings: request preparation can fail after step entry, while an empty continuation or interval suppression can let a request reuse existing history without adding one.
|
||||
|
||||
## Model Experience
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
} from './request-zone.ts'
|
||||
|
||||
export type { ClientTimeZoneContext } from './request-zone.ts'
|
||||
export { deriveClientTimeZoneContext, renderTimeZoneContext } from './request-zone.ts'
|
||||
export { deriveClientTimeZoneContext } from './request-zone.ts'
|
||||
|
||||
/** Cordis plugin name used by loader diagnostics. */
|
||||
export const name = 'time-context'
|
||||
@@ -241,7 +241,7 @@ export function apply(ctx: Context, config: Config): () => void {
|
||||
if (wasDisposed()) return next()
|
||||
const decision = await next()
|
||||
if (wasDisposed() || wasAborted() || decision.kind === 'reject'
|
||||
|| (step === 1 && decision.messages.length === 0)) {
|
||||
|| decision.messages.length === 0) {
|
||||
return decision
|
||||
}
|
||||
const now = Date.now()
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { createUserMessage } from '@deepseek-ai/dsh-llm'
|
||||
import * as timeContext from '@deepseek-ai/dsh-time-context'
|
||||
import {
|
||||
deriveClientTimeZoneContext,
|
||||
renderTimeZoneContext,
|
||||
} from '@deepseek-ai/dsh-time-context'
|
||||
import { renderTimeZoneContext } from '../src/request-zone.ts'
|
||||
|
||||
function request(clientTimeZone?: unknown) {
|
||||
return createUserMessage({
|
||||
@@ -15,6 +16,11 @@ function request(clientTimeZone?: unknown) {
|
||||
}
|
||||
|
||||
describe('request-zone derivation', () => {
|
||||
it('publishes derivation without exposing the internal renderer', () => {
|
||||
expect(timeContext.deriveClientTimeZoneContext).toBe(deriveClientTimeZoneContext)
|
||||
expect('renderTimeZoneContext' in timeContext).toBe(false)
|
||||
})
|
||||
|
||||
it('derives missing, one resolved zone, and sorted unique mixed zones', () => {
|
||||
const plugin = createUserMessage({
|
||||
content: [],
|
||||
|
||||
@@ -547,6 +547,36 @@ describe('real agent-loop request history', () => {
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('does not revive an empty continuation after a completed step', async () => {
|
||||
const adapter = new ScriptedAdapter([textResponse('done')])
|
||||
const ctx = await loopHarness(adapter)
|
||||
ctx.on('agent/turn-stopping', (subject) => {
|
||||
subject.inject(createUserMessage({
|
||||
content: [{ type: 'text', text: 'pending context' }],
|
||||
source: { kind: 'plugin', plugin: 'test' },
|
||||
}))
|
||||
})
|
||||
ctx.on('agent/pre-step', async (_agent, _messages, context, next) => {
|
||||
const decision = await next()
|
||||
return context.step === 1 || decision.kind === 'reject'
|
||||
? decision
|
||||
: { kind: 'enter', messages: [] }
|
||||
})
|
||||
const agent = ctx.agentLoop.create(SessionId('empty-completed-continuation'), {
|
||||
provider: 'mock',
|
||||
model: 'mock',
|
||||
})
|
||||
|
||||
agent.followup(rpcMessage('finish once', 'Asia/Shanghai'))
|
||||
await agent.whenIdle()
|
||||
|
||||
expect(adapter.requests).toHaveLength(1)
|
||||
expect(agent.session.events.filter(event => event.type === 'step/start')).toHaveLength(1)
|
||||
expect(contextTexts(agent.session)).toHaveLength(1)
|
||||
expect(agent.inbox.hasPending).toBe(false)
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('preserves post-claim steering without persisting failed-turn context', async () => {
|
||||
const adapter = new ScriptedAdapter([textResponse('resumed')])
|
||||
const ctx = await loopHarness(adapter)
|
||||
@@ -617,7 +647,7 @@ describe('real agent-loop request history', () => {
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('persists one ordered context per request, accumulates readings, and leaves system headers unchanged', async () => {
|
||||
it('does not add a reading to an empty tool continuation and leaves system headers unchanged', async () => {
|
||||
const adapter = new ScriptedAdapter([toolCallResponse(), textResponse('done')])
|
||||
const ctx = await loopHarness(adapter)
|
||||
ctx.tools.register(defineContentToolFixture({
|
||||
@@ -638,11 +668,9 @@ describe('real agent-loop request history', () => {
|
||||
const contexts = agent.session.events.filter(
|
||||
(event): event is SessionEvent<'user/message'> => event.type === 'user/message' && event.data.source.kind === 'plugin')
|
||||
const starts = agent.session.events.filter(event => event.type === 'step/start')
|
||||
expect(contexts).toHaveLength(adapter.requests.length)
|
||||
expect(contexts).toHaveLength(1)
|
||||
expect(starts).toHaveLength(adapter.requests.length)
|
||||
for (let index = 0; index < contexts.length; index += 1) {
|
||||
expect(contexts[index]!.seq).toBeGreaterThan(starts[index]!.seq)
|
||||
}
|
||||
expect(contexts[0]!.seq).toBeGreaterThan(starts[0]!.seq)
|
||||
expect(contexts.every(event => event.data.source.kind === 'plugin'
|
||||
&& event.data.source.plugin === 'time-context'
|
||||
&& event.surfaceOp === 'append')).toBe(true)
|
||||
@@ -653,8 +681,7 @@ describe('real agent-loop request history', () => {
|
||||
expect(firstRequestText).toContain('Elapsed since the preceding model-visible message: unavailable.')
|
||||
expect(firstRequestText).not.toContain('Time sampled while preparing turn 1, step 2:')
|
||||
expect(secondRequestText).toContain('Time sampled while preparing turn 1, step 1:')
|
||||
expect(secondRequestText).toContain('Time sampled while preparing turn 1, step 2:')
|
||||
expect(secondRequestText).toContain('Elapsed since the preceding step context: 1m 1s.')
|
||||
expect(secondRequestText).not.toContain('Time sampled while preparing turn 1, step 2:')
|
||||
|
||||
for (const request of adapter.requests) expect(request.system).not.toContain('Time sampled while preparing')
|
||||
const headers = agent.session.events.filter(event => event.type === 'request/header')
|
||||
|
||||
@@ -35,7 +35,7 @@ Each `session/event` copies its event into the session controller. The first pen
|
||||
|
||||
A live controller retains no seed copy. If first initialization rejects, the next flush borrows the current append-only Session log, rechecks the backend's actual cursor, and appends only the missing suffix before draining retained events. Concurrent retries share one initialization attempt; a committed-but-rejected write therefore neither duplicates the prefix nor permanently poisons the Session.
|
||||
|
||||
Crash repair is cold-only. For a live id, `load(id)` snapshots the authoritative in-memory log, waits for that snapshot to become durable, and returns it with the coordinator's stored header only when balanced; an open live turn rejects instead of receiving synthetic interruption closers. For a cold id, inspection reads, validates, freezes, and constructs one unpublished Session; repeated inspection reuses that object graph only while its source revision remains current. `prepare(id)` performs the same check before repair, reserves the exact Session, commits any pending torn-tail/interrupted-turn repair, and returns it for publication. HMR adoption reads through `loadStored`, compares cwd and any stored `timeZone`, and never closes the active turn. A stored header without `timeZone` is the compatibility exception: a zoned live object may adopt it, but the stored header remains headerless and is never backfilled.
|
||||
Crash repair is cold-only. For a live id, `load(id)` snapshots the authoritative in-memory log, waits for that snapshot to become durable, and returns it with the coordinator's stored header only when balanced; an open live turn rejects instead of receiving synthetic interruption closers. For a cold id, inspection reads, validates, freezes, and constructs one unpublished Session; repeated inspection reuses that object graph only while its source revision remains current. `prepare(id)` performs the same check before repair, reserves the exact Session across backend reads and repair writes, commits any pending torn-tail/interrupted-turn repair, and returns it for publication. HMR adoption reads through `loadStored`, requires exact stored/live cwd and optional-`timeZone` identity, and never closes the active turn. Normal resume reconstructs a headerless live Session from its stored header, so it remains zone-unavailable and is never backfilled; a zoned live object cannot adopt that prefix.
|
||||
|
||||
Backend reads normalize the exact supported same-version shapes before current-shape validation. Pre-identity messages receive the deterministic id `legacy-message:<session-id>:<event-seq>`; a tool-result content replacement inherits its target's imported id. A pre-react-loop `turn/start` loses its obsolete trigger, a removed `steering/message` becomes the same identified `user/message`, and an older `turn/end` maps its terminal reason without inventing a caller that the old record did not name. The coordinator uses the same normalized view for `load`, `inspect`, `readFrom`, ownerless-state claims, and HMR prefix adoption. Storage remains append-only: reads do not rewrite old records, and later appends use the current shape. These are narrow import exceptions from the [pre-identity message](../../../.agents/notes/implemented/bug-fix/2026-07-28-load-pre-identity-session-messages.md) and [pre-react-loop session](../../../.agents/notes/implemented/bug-fix/2026-08-04-load-pre-react-loop-sessions.md) decisions, not a general v0 migration promise.
|
||||
|
||||
@@ -56,7 +56,7 @@ The `PersistenceBackend<TornMarker>` hooks (the only contract between the coordi
|
||||
| `list(signal?)` | List all stored metadata, observing optional cancellation. |
|
||||
| `close?()` | Optional lifecycle teardown (e.g. close a db handle), awaited after the dispose drain. |
|
||||
|
||||
The coordinator asserts the stored id and validates the optional stored `timeZone` as a string before repair or publication. Live adoption compares stored/live cwd and requires an exact live match when the stored header has a zone; an absent stored zone remains absent. Its `inspect()` path takes ownership of fresh backend values, validates and freezes them once, and retains at most the configured number of unpublished Sessions without calling `commitRepair`. A retained source is reused or repaired only when its revision still equals `readStoredRevision`; otherwise the coordinator reloads it. This freshness check does not add cross-process writer exclusion. Revision retries converge when the durable log remains unchanged for one read/check round trip; continuous external writers can delay `load`, `inspect`, or `prepare`. The `tornMarker` is fully OPAQUE: the coordinator only tests `!== undefined` and round-trips it to `commitRepair`, never inspecting its value (the JSONL backend uses the byte offset to truncate to, the SQLite backend the seq to delete from). A third-party backend MAY implement the abstract service directly without the coordinator, but it must provide the same non-mutating inspection and trustworthy lightweight snapshot revisions. See [the write-coordinator Agent Note](../../../.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md).
|
||||
The coordinator asserts the stored id and validates the optional stored `timeZone` as a string before repair or publication. Live adoption requires exact stored/live cwd and optional-zone equality, including headerless-to-headerless identity. Its `inspect()` path takes ownership of fresh backend values, validates and freezes them once, and retains at most the configured number of unpublished Sessions without calling `commitRepair`. A retained source is reused or repaired only when its revision still equals `readStoredRevision`; otherwise the coordinator reloads it. This freshness check does not add cross-process writer exclusion. Revision retries converge when the durable log remains unchanged for one read/check round trip; continuous external writers can delay `load`, `inspect`, or `prepare`. The `tornMarker` is fully OPAQUE: the coordinator only tests `!== undefined` and round-trips it to `commitRepair`, never inspecting its value (the JSONL backend uses the byte offset to truncate to, the SQLite backend the seq to delete from). A third-party backend MAY implement the abstract service directly without the coordinator, but it must provide the same non-mutating inspection and trustworthy lightweight snapshot revisions. See [the write-coordinator Agent Note](../../../.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md).
|
||||
|
||||
## Metadata and location types
|
||||
|
||||
|
||||
Reference in New Issue
Block a user