From 82e0fd8316f1f0b6e263386b16671ba0e484e61f Mon Sep 17 00:00:00 2001 From: iceteaSA <171169159+iceteaSA@users.noreply.github.com> Date: Sat, 29 Aug 2026 08:48:22 +0200 Subject: [PATCH] fix(session): mint message ids and timestamps from one clock read Freshly persisted messages now read Date.now() once, mint the ascending message ID from that value via Identifier.create("msg", "ascending", created), and persist the same value as time.created. The captured inversion was msg_04b012001003... with time_created 1787964760065 appearing after msg_04b012001001... with time_created 1787964760066 despite the later ID. Deriving time.created from the ID was tried and rejected because the ID's embedded timestamp can predate a legacy row's separately-read Date.now(), mis-ordering new messages against existing data. --- .../src/cli/cmd/debug/agent.handler.ts | 7 +++-- .../opencode/src/cli/cmd/github.handler.ts | 4 +-- packages/opencode/src/session/compaction.ts | 23 ++++++++------ packages/opencode/src/session/prompt.ts | 31 ++++++++++++------- packages/opencode/src/tool/plan.ts | 6 ++-- packages/opencode/src/tool/task.ts | 3 +- packages/opencode/test/session/prompt.test.ts | 29 +++++++++++++++++ 7 files changed, 71 insertions(+), 32 deletions(-) diff --git a/packages/opencode/src/cli/cmd/debug/agent.handler.ts b/packages/opencode/src/cli/cmd/debug/agent.handler.ts index b9d9ff49c8e4..2706a868d9bc 100644 --- a/packages/opencode/src/cli/cmd/debug/agent.handler.ts +++ b/packages/opencode/src/cli/cmd/debug/agent.handler.ts @@ -8,6 +8,7 @@ import { Provider } from "@/provider/provider" import { Session } from "@/session/session" import type { MessageV2 } from "../../../session/message-v2" import { MessageID, PartID } from "../../../session/schema" +import { Identifier } from "@/id/id" import { ToolRegistry } from "@/tool/registry" import { Permission } from "../../../permission" import { iife } from "../../../util/iife" @@ -129,7 +130,6 @@ const createToolContext = Effect.fn("Cli.debug.agent.createToolContext")(functio ) { const sessionSvc = yield* Session.Service const session = yield* sessionSvc.create({ title: `Debug tool run (${agent.name})` }) - const messageID = MessageID.ascending() const model = agent.model ? agent.model : yield* Effect.gen(function* () { @@ -149,12 +149,13 @@ const createToolContext = Effect.fn("Cli.debug.agent.createToolContext")(functio }), ) }) - const now = Date.now() + const created = Date.now() + const messageID = MessageID.ascending(Identifier.create("msg", "ascending", created)) const message: SessionV1.Assistant = { id: messageID, sessionID: session.id, role: "assistant", - time: { created: now }, + time: { created }, parentID: messageID, modelID: model.modelID, providerID: model.providerID, diff --git a/packages/opencode/src/cli/cmd/github.handler.ts b/packages/opencode/src/cli/cmd/github.handler.ts index fcf44279ce7f..2266468e289b 100644 --- a/packages/opencode/src/cli/cmd/github.handler.ts +++ b/packages/opencode/src/cli/cmd/github.handler.ts @@ -22,7 +22,7 @@ import { InstanceRef } from "@/effect/instance-ref" import { SessionShare } from "@/share/session" import { Session } from "@/session/session" import type { SessionID } from "../../session/schema" -import { MessageID, PartID } from "../../session/schema" +import { PartID } from "../../session/schema" import { Provider } from "@/provider/provider" import { MessageV2 } from "../../session/message-v2" import { EventV2Bridge } from "@/event-v2-bridge" @@ -902,7 +902,6 @@ export const githubRun = Effect.fn("Cli.github.run")(function* (args: { event?: const prompt = sessionPrompt const result = yield* prompt.prompt({ sessionID: session.id, - messageID: MessageID.ascending(), variant, model: { providerID, @@ -950,7 +949,6 @@ export const githubRun = Effect.fn("Cli.github.run")(function* (args: { event?: console.log("Requesting summary from agent...") const summary = yield* prompt.prompt({ sessionID: session.id, - messageID: MessageID.ascending(), variant, model: { providerID, diff --git a/packages/opencode/src/session/compaction.ts b/packages/opencode/src/session/compaction.ts index 75d6374bfa54..187310ffa3c0 100644 --- a/packages/opencode/src/session/compaction.ts +++ b/packages/opencode/src/session/compaction.ts @@ -3,6 +3,7 @@ import { SessionV1 } from "@opencode-ai/core/v1/session" import { ConfigV1 } from "@opencode-ai/core/v1/config/config" import { Session } from "./session" import { SessionID, MessageID, PartID } from "./schema" +import { Identifier } from "@/id/id" import { Provider } from "@/provider/provider" import { MessageV2 } from "./message-v2" import { Token } from "@/util/token" @@ -390,8 +391,9 @@ const layer = Layer.effect( .filter(Boolean) .join("\n\n") const ctx = yield* InstanceState.context + const created = Date.now() const msg: SessionV1.Assistant = { - id: MessageID.ascending(), + id: MessageID.ascending(Identifier.create("msg", "ascending", created)), role: "assistant", parentID: input.parentID, sessionID: input.sessionID, @@ -412,9 +414,7 @@ const layer = Layer.effect( }, modelID: model.id, providerID: model.providerID, - time: { - created: Date.now(), - }, + time: { created }, } yield* session.updateMessage(msg) const processor = yield* processors.create({ @@ -468,11 +468,12 @@ const layer = Layer.effect( if (result === "continue" && input.auto) { if (replay) { const original = replay.info + const created = Date.now() const replayMsg = yield* session.updateMessage({ - id: MessageID.ascending(), + id: MessageID.ascending(Identifier.create("msg", "ascending", created)), role: "user", sessionID: input.sessionID, - time: { created: Date.now() }, + time: { created }, agent: original.agent, model: original.model, format: original.format, @@ -516,11 +517,12 @@ const layer = Layer.effect( { enabled: true }, )).enabled ) { + const created = Date.now() const continueMsg = yield* session.updateMessage({ - id: MessageID.ascending(), + id: MessageID.ascending(Identifier.create("msg", "ascending", created)), role: "user", sessionID: input.sessionID, - time: { created: Date.now() }, + time: { created }, agent: userMessage.agent, model: userMessage.model, }) @@ -563,13 +565,14 @@ const layer = Layer.effect( auto: boolean overflow?: boolean }) { + const created = Date.now() const msg = yield* session.updateMessage({ - id: MessageID.ascending(), + id: MessageID.ascending(Identifier.create("msg", "ascending", created)), role: "user", model: input.model, sessionID: input.sessionID, agent: input.agent, - time: { created: Date.now() }, + time: { created }, }) yield* session.updatePart({ id: PartID.ascending(), diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 0f85d44f209b..5fd599e09f5a 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -4,6 +4,7 @@ import path from "path" import { SessionV1 } from "@opencode-ai/core/v1/session" import os from "os" import { SessionID, MessageID, PartID } from "./schema" +import { Identifier } from "@/id/id" import { MessageV2 } from "./message-v2" import { SessionRevert } from "./revert" import { Session } from "./session" @@ -265,8 +266,9 @@ const layer = Layer.effect( const promptOps = yield* ops() const { task: taskTool } = yield* registry.named() const taskModel = task.model ? yield* getModel(task.model.providerID, task.model.modelID, sessionID) : model + const created = Date.now() const assistantMessage: SessionV1.Assistant = yield* sessions.updateMessage({ - id: MessageID.ascending(), + id: MessageID.ascending(Identifier.create("msg", "ascending", created)), role: "assistant", parentID: lastUser.id, sessionID, @@ -278,7 +280,7 @@ const layer = Layer.effect( tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, modelID: taskModel.id, providerID: taskModel.providerID, - time: { created: Date.now() }, + time: { created }, }) let part: SessionV1.ToolPart = yield* sessions.updatePart({ id: PartID.ascending(), @@ -429,11 +431,12 @@ const layer = Layer.effect( if (!task.command) return + const summaryCreated = Date.now() const summaryUserMsg: SessionV1.User = { - id: MessageID.ascending(), + id: MessageID.ascending(Identifier.create("msg", "ascending", summaryCreated)), sessionID, role: "user", - time: { created: Date.now() }, + time: { created: summaryCreated }, agent: lastUser.agent, model: lastUser.model, } @@ -467,10 +470,11 @@ const layer = Layer.effect( throw error } const model = input.model ?? agent.model ?? (yield* currentModel(input.sessionID)) + const userCreated = Date.now() const userMsg: SessionV1.User = { - id: input.messageID ?? MessageID.ascending(), + id: input.messageID ?? MessageID.ascending(Identifier.create("msg", "ascending", userCreated)), sessionID: input.sessionID, - time: { created: Date.now() }, + time: { created: userCreated }, role: "user", agent: input.agent, model: { providerID: model.providerID, modelID: model.modelID }, @@ -486,15 +490,16 @@ const layer = Layer.effect( } yield* sessions.updatePart(userPart) + const assistantCreated = Date.now() const msg: SessionV1.Assistant = { - id: MessageID.ascending(), + id: MessageID.ascending(Identifier.create("msg", "ascending", assistantCreated)), sessionID: input.sessionID, parentID: userMsg.id, mode: input.agent, agent: input.agent, cost: 0, path: { cwd: ctx.directory, root: ctx.worktree }, - time: { created: Date.now() }, + time: { created: assistantCreated }, role: "assistant", tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, modelID: model.modelID, @@ -653,11 +658,12 @@ const layer = Layer.effect( : undefined const variant = input.variant ?? (ag.variant && full?.variants?.[ag.variant] ? ag.variant : undefined) + const created = Date.now() const info: SessionV1.User = { - id: input.messageID ?? MessageID.ascending(), + id: input.messageID ?? MessageID.ascending(Identifier.create("msg", "ascending", created)), role: "user", sessionID: input.sessionID, - time: { created: Date.now() }, + time: { created }, tools: input.tools, agent: ag.name, model: { @@ -1183,8 +1189,9 @@ const layer = Layer.effect( Effect.provideService(Session.Service, sessions), ) + const created = Date.now() const msg: SessionV1.Assistant = { - id: MessageID.ascending(), + id: MessageID.ascending(Identifier.create("msg", "ascending", created)), parentID: lastUser.id, role: "assistant", mode: agent.name, @@ -1195,7 +1202,7 @@ const layer = Layer.effect( tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, modelID: model.id, providerID: model.providerID, - time: { created: Date.now() }, + time: { created }, sessionID, } yield* sessions.updateMessage(msg) diff --git a/packages/opencode/src/tool/plan.ts b/packages/opencode/src/tool/plan.ts index 3b5ed978545a..397d213176e8 100644 --- a/packages/opencode/src/tool/plan.ts +++ b/packages/opencode/src/tool/plan.ts @@ -7,6 +7,7 @@ import { Session } from "@/session/session" import { MessageV2 } from "../session/message-v2" import { Provider } from "@/provider/provider" import { InstanceState } from "@/effect/instance-state" +import { Identifier } from "@/id/id" import { MessageID, PartID } from "../session/schema" import EXIT_DESCRIPTION from "./plan-exit.txt" @@ -50,11 +51,12 @@ export const PlanExitTool = Tool.define( const model = lastUser?.info.role === "user" && lastUser.info.model ? lastUser.info.model : yield* provider.defaultModel() + const created = Date.now() const msg: SessionV1.User = { - id: MessageID.ascending(), + id: MessageID.ascending(Identifier.create("msg", "ascending", created)), sessionID: ctx.sessionID, role: "user", - time: { created: Date.now() }, + time: { created }, agent: "build", model, } diff --git a/packages/opencode/src/tool/task.ts b/packages/opencode/src/tool/task.ts index d8ca640cfba9..83b7e0616122 100644 --- a/packages/opencode/src/tool/task.ts +++ b/packages/opencode/src/tool/task.ts @@ -4,7 +4,7 @@ import { ToolJsonSchema } from "./json-schema" import { SessionV1 } from "@opencode-ai/core/v1/session" import { BackgroundJob } from "@/background/job" import { Session } from "@/session/session" -import { SessionID, MessageID } from "../session/schema" +import { SessionID } from "../session/schema" import { MessageV2 } from "../session/message-v2" import { Agent } from "../agent/agent" import { deriveSubagentSessionPermission } from "../agent/subagent-permissions" @@ -200,7 +200,6 @@ export const TaskTool = Tool.define( const runTask = Effect.fn("TaskTool.runTask")(function* () { const parts = yield* ops.resolvePromptParts(params.prompt) const result = yield* ops.prompt({ - messageID: MessageID.ascending(), sessionID: nextSession.id, model: { modelID: model.modelID, diff --git a/packages/opencode/test/session/prompt.test.ts b/packages/opencode/test/session/prompt.test.ts index da6e0f8d036f..fbc405363d66 100644 --- a/packages/opencode/test/session/prompt.test.ts +++ b/packages/opencode/test/session/prompt.test.ts @@ -5,6 +5,7 @@ import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { SessionProjector } from "@opencode-ai/core/session/projector" import { eq } from "drizzle-orm" import { EventV2Bridge } from "@/event-v2-bridge" +import { Identifier } from "@/id/id" import { expect } from "bun:test" import { Cause, Deferred, Duration, Effect, Exit, Fiber, Layer } from "effect" import path from "path" @@ -442,6 +443,34 @@ const boot = Effect.fn("test.boot")(function* (input?: { title?: string }) { return { prompt, run, sessions, chat } }) +it.instance("prompt persists the timestamp embedded in its freshly minted message ID", () => + Effect.acquireUseRelease( + Effect.sync(() => { + const original = Date.now + let reads = 0 + Date.now = () => 1_000 + reads++ + return original + }), + () => + Effect.gen(function* () { + const { prompt, chat } = yield* boot() + const message = yield* prompt.prompt({ + sessionID: chat.id, + agent: "build", + model: ref, + noReply: true, + parts: [{ type: "text", text: "timestamp invariant" }], + }) + + expect(Identifier.timestamp(message.info.id)).toBe(message.info.time.created) + }), + (original) => + Effect.sync(() => { + Date.now = original + }), + ), +) + // Loop semantics noLLMServer.instance(