From 76df8028cbbd2b039b4a048dff875311ef30c31f Mon Sep 17 00:00:00 2001 From: Lex Date: Fri, 14 Aug 2026 22:54:10 +0800 Subject: [PATCH 1/7] feat(dag): add workflow draft action for schema-checked graph authoring MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Models writing workflow YAML by hand drift on field names and structure (live-observed: objective at top level, worker instead of worker_type) even with the reference template in context — YAML is an unvalidated text channel, so prompting alone cannot reach zero. This closes the gap at three layers: - workflow(action="draft"): pass the structured config through the tool parameter schema; the harness renders YAML to .opencode/workflow-drafts/, validates it, and returns the spec_path. Unknown fields are rejected by the provider-side schema before any file is written; YAML syntax errors disappear because rendering is code. spec_path contract unchanged. - routing guide now carries the minimal complete start-spec example inline (one hop) plus the field-ownership rules; description budget 5k -> 6.5k. - schemaDiagnostics maps high-frequency drift fields (worker/agent -> worker_type, prompt -> instruction, top-level objective -> config) to "Did you mean" hints, matching both message text and diagnostic path. Verified: test/dag 496/496 green incl. 5 new draft tests (render round-trip, bad-dependency diagnostics, unsafe-name rejection, schema drift rejection, drift hint); core command tests 21/21; tsgo clean in both packages. --- .../src/plugin/command/workflow-routing.md | 46 ++++-- packages/core/test/plugin/command.test.ts | 4 +- packages/opencode/src/dag/validation.ts | 32 +++- packages/opencode/src/tool/workflow.ts | 77 ++++++++- .../test/dag/workflow-authoring.test.ts | 25 +++ .../opencode/test/dag/workflow-tool.test.ts | 146 +++++++++++++++++- .../tool/workflow-provider-schema.test.ts | 10 +- .../tool/workflow-schema-contract.test.ts | 2 +- 8 files changed, 320 insertions(+), 22 deletions(-) diff --git a/packages/core/src/plugin/command/workflow-routing.md b/packages/core/src/plugin/command/workflow-routing.md index ad7c08dd91..b4059640a4 100644 --- a/packages/core/src/plugin/command/workflow-routing.md +++ b/packages/core/src/plugin/command/workflow-routing.md @@ -68,19 +68,45 @@ DAG; for a verdict, the matching audit is primary. Do not concatenate two comple Read the selected reference, retarget its objective and instructions, and remove phases current evidence already covers. Start its saved `spec_path` -directly only when target and acceptance evidence match. If none fits, load -`guide(topic="blocks")` and compose a task-local YAML. Load +directly only when target and acceptance evidence match. If none fits, compose +a task-local graph. Load `guide(topic="blocks")` for block contracts and `guide(topic="patterns")` only when domains overlap. Use low-level nodes only for fields blocks cannot express. -Write the graph to YAML and validate that `spec_path` before start. Fix every -diagnostic in the same file and revalidate; validation creates no workflow. A -successful start returns the exact workflow ID. The parent owns the graph, -controls, and final report; children own bounded work. End after start and let -the workflow wake the parent. Do not poll merely to wait or claim an unstarted -graph is running. - -## Progressive guidance +Prefer `workflow(action="draft")` over hand-writing YAML: pass the structured +`config` (same fields as the YAML below) and the tool renders and validates the +spec file, returning the `spec_path` to start. Field-name drift is impossible +because the parameter schema rejects unknown fields. Hand-write YAML only for +features draft does not carry (admission, custom bindings). The exact start +shape, for that fallback and for reading draft output: + +```yaml +title: Implement session recovery +config: + name: implement-session-recovery + objective: Implement session recovery with focused tests and review. + blocks: + - id: map + kind: explore + instruction: Locate the ownership and persistence seams. + - id: coding + kind: coding + depends_on: [map] + - id: review + kind: review + depends_on: [coding] +``` + +Top level is `title`/`mode`/`admission` (optional) and `config` (required); +`objective` lives INSIDE `config`; every block field is one of `id` (required), +`kind` (required), `depends_on`, `instruction`, `worker_type`, `required`, +`report_to_parent` — never `worker`, `prompt`, or `agent`. + +Validate that `spec_path` before start. Fix every diagnostic in the same file +and revalidate; validation creates no workflow. A successful start returns the +exact workflow ID. The parent owns the graph, controls, and final report; +children own bounded work. End after start and let the workflow wake the +parent. Do not poll merely to wait or claim an unstarted graph is running.## Progressive guidance `guide` without a topic is the index. Topics: `blocks` for block shape, `interface` for low-level fields, `policy` for recovery, and `patterns` for diff --git a/packages/core/test/plugin/command.test.ts b/packages/core/test/plugin/command.test.ts index 2ce6be2d73..e49d44aede 100644 --- a/packages/core/test/plugin/command.test.ts +++ b/packages/core/test/plugin/command.test.ts @@ -75,7 +75,9 @@ describe("CommandPlugin.Plugin", () => { it.effect("keeps always-on guidance small and loads detailed topics progressively", () => Effect.sync(() => { - expect(Buffer.byteLength(CommandPlugin.WorkflowContent)).toBeLessThan(5_000) + // Budget admits the inline start-spec example (one-hop field reference + // for hand-written YAML) while keeping per-action manuals progressive. + expect(Buffer.byteLength(CommandPlugin.WorkflowContent)).toBeLessThan(6_500) expect(CommandPlugin.WorkflowContent).toContain("project-level source or test changes") expect(CommandPlugin.WorkflowContent).toMatch(/even one project\s+file/) expect(CommandPlugin.WorkflowContent).toMatch(/isolated utility\s+scripts/) diff --git a/packages/opencode/src/dag/validation.ts b/packages/opencode/src/dag/validation.ts index c7bb864463..6180491843 100644 --- a/packages/opencode/src/dag/validation.ts +++ b/packages/opencode/src/dag/validation.ts @@ -346,6 +346,36 @@ function collectLeafIssues(issue: unknown, path: readonly string[], out: LeafIss if (message) out.push({ path: nextPath.join("") || "$", message }) } +// High-frequency authoring drift: fields the model reaches for from +// neighboring vocabularies, mapped to the field that exists. The decode leaf +// only carries the tag ("UnexpectedKey"); the offending field name lives in +// the diagnostic path, so both are matched. +const FIELD_DRIFT_HINTS: Record = { + worker: "worker_type", + workers: "worker_type", + agent: "worker_type", + prompt: "instruction", + task: "instruction", + objective: "config.objective", + graph: "config", + spec: "config", + nodes: "blocks (or vice versa — exactly one graph source)", + blocks: "nodes (or vice versa — exactly one graph source)", +} + +function driftHint(path: string, message: string) { + for (const [wrong, right] of Object.entries(FIELD_DRIFT_HINTS)) { + if ( + message.includes(`"${wrong}"`) || + message.includes(`'${wrong}'`) || + new RegExp(`[\\"\[]${wrong}[\\"\]]`).test(path) + ) { + return `Did you mean "${right}"? Every block field is one of id, kind, depends_on, instruction, worker_type, required, report_to_parent; objective lives inside config` + } + } + return "Fix the field shape; blocks graphs need name+objective+blocks, nodes graphs need name+nodes" +} + export function schemaDiagnostics(error: unknown, basePath = ""): Diagnostic[] { const leaves: LeafIssue[] = [] collectLeafIssues(isRecord(error) && error.issue !== undefined ? error.issue : error, basePath ? [basePath] : [], leaves) @@ -358,7 +388,7 @@ export function schemaDiagnostics(error: unknown, basePath = ""): Diagnostic[] { code: DIAGNOSTIC_CODES.schemaInvalid, path: leaf.path, message: leaf.message, - hint: "Fix the field shape; blocks graphs need name+objective+blocks, nodes graphs need name+nodes", + hint: driftHint(leaf.path, leaf.message), }), ), ) diff --git a/packages/opencode/src/tool/workflow.ts b/packages/opencode/src/tool/workflow.ts index 60b0345b82..314164d29a 100644 --- a/packages/opencode/src/tool/workflow.ts +++ b/packages/opencode/src/tool/workflow.ts @@ -18,6 +18,7 @@ import { SessionID } from "@/session/schema" import { createAdmissionRecord } from "@/dag/admission" import { TerminalViolationError } from "@opencode-ai/core/dag/core/types" import { FSUtil } from "@opencode-ai/core/fs-util" +import { stringify as yamlStringify } from "yaml" import { assertExternalDirectoryEffect } from "./external-directory" import path from "node:path" @@ -110,6 +111,17 @@ const Guide = Schema.Struct({ "blocks: composable block schema; interface: low-level workflow API; policy: gates/admission/recovery; patterns: domain playbooks. Omit for the compact index", }), }) +const Draft = Schema.Struct({ + action: Schema.Literal("draft").annotate({ + description: + 'Render a structured graph into a validated YAML spec file and return its spec_path — no workflow is created. Preferred over hand-writing YAML: field names are schema-checked here, eliminating serialization drift', + }), + title: Schema.optional(Schema.String).annotate({ description: "Optional workflow title" }), + config: DagValidation.WorkflowGraphSchema.annotate({ + description: + 'Exactly one graph shape: { name, objective, blocks: [{ id, kind, depends_on?, instruction?, worker_type?, required?, report_to_parent? }], node_defaults?, max_concurrency?, max_node_replan_attempts?, max_total_nodes? } or the low-level { name, nodes: [...] } form. Fields are exhaustive — no others exist', + }), +}) const ValidationProfile = Schema.optional(Schema.Literals(["portable", "environment"])).annotate({ description: "portable: distributable-template checks; environment: additionally resolves prompts, workers, and models in this project. Defaults: builtin specs portable, project/global/path specs environment", @@ -132,6 +144,7 @@ const ActionParams = Schema.Union([ List, Read, Guide, + Draft, ValidatePath, ]) @@ -251,7 +264,7 @@ export const WorkflowTool = Tool.define< formatValidationError: (error) => [ `Workflow call rejected by the action schema: ${error instanceof Error ? error.message : String(error)}`, - 'The call takes a single { params } object: params { action, ...action-owned fields } where each action owns only its own fields: start {spec_path}; extend {workflow_id, spec_path}; control(replan) {workflow_id, operation, spec_path}; other control operations {workflow_id, operation}; status {workflow_id}; result {workflow_id, node_id, cursor?, limit?}; list {}; read {spec_path}; guide {topic?}; validate {spec_path, profile?}. Put graph content in a .yaml/.yml file; session/project identity is never a parameter.', + 'The call takes a single { params } object: params { action, ...action-owned fields } where each action owns only its own fields: start {spec_path}; extend {workflow_id, spec_path}; control(replan) {workflow_id, operation, spec_path}; other control operations {workflow_id, operation}; status {workflow_id}; result {workflow_id, node_id, cursor?, limit?}; list {}; read {spec_path}; guide {topic?}; draft {title?, config}; validate {spec_path, profile?}. Put graph content in draft (structured, schema-checked) or a .yaml/.yml file; session/project identity is never a parameter.', ].join("\n"), execute: (call: Schema.Schema.Type, ctx: Tool.Context) => Effect.gen(function* () { @@ -300,6 +313,36 @@ export const WorkflowTool = Tool.define< metadata: {}, } } + case "draft": { + const specPath = yield* writeDraftSpec(params.config, params.title, callingSession.directory).pipe( + Effect.orDie, + ) + const result = yield* authoring.prepare({ + action: "start", + source: { kind: "yaml", source: specPath, content: yield* readDraftSpec(specPath).pipe(Effect.orDie) }, + profile: "portable", + }) + if (!result.valid) { + return { + title: `Workflow draft written with validation errors: ${params.config.name}`, + output: [ + `spec_path: ${specPath}`, + "The file is on disk; fix the errors by calling draft again with corrected fields. Diagnostics:", + ...result.errors.map((d) => `- [${d.code}] ${d.path}: ${d.message}${d.hint ? ` (${d.hint})` : ""}`), + ].join("\n"), + metadata: {}, + } + } + return { + title: `Workflow draft valid: ${params.config.name}`, + output: [ + `spec_path: ${specPath}`, + `nodes: ${result.nodes.length}`, + 'Next: workflow(action="start", spec_path) — or extend the file first for low-level fields, then start.', + ].join("\n"), + metadata: {}, + } + } case "list": { const entries = yield* DagWorkflows.list(callingSession.directory) if (entries.length === 0) { @@ -729,6 +772,38 @@ function validationOutput(result: DagValidation.ValidationResult) { } } +const DRAFT_DIRECTORY = path.join(".opencode", "workflow-drafts") +const DRAFT_NAME_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9-_]*$/ + +function writeDraftSpec( + config: DagValidation.StartGraph, + title: string | undefined, + directory: string, +): Effect.Effect { + return Effect.gen(function* () { + if (!DRAFT_NAME_PATTERN.test(config.name)) { + return yield* Effect.fail( + new Error( + `Workflow name must match ${DRAFT_NAME_PATTERN.source} (it becomes the spec filename): ${config.name}`, + ), + ) + } + const dir = path.join(directory, DRAFT_DIRECTORY) + yield* Effect.promise(() => Bun.write(Bun.file(path.join(dir, ".keep")), "")) + const specPath = path.join(dir, `${config.name}.yaml`) + const content = yamlStringify({ ...(title ? { title } : {}), config }) + yield* Effect.promise(() => Bun.write(specPath, content)) + return specPath + }) +} + +function readDraftSpec(specPath: string) { + return Effect.tryPromise({ + try: () => Bun.file(specPath).text(), + catch: (error) => new Error(`Failed to read draft spec ${specPath}: ${String(error)}`), + }) +} + function loadSpecFile(specPath: string, directory: string, ctx: Tool.Context) { return Effect.gen(function* () { const filepath = yield* resolveSpecPath(specPath, directory, ctx) diff --git a/packages/opencode/test/dag/workflow-authoring.test.ts b/packages/opencode/test/dag/workflow-authoring.test.ts index 0adb983a39..5c91c8676a 100644 --- a/packages/opencode/test/dag/workflow-authoring.test.ts +++ b/packages/opencode/test/dag/workflow-authoring.test.ts @@ -24,6 +24,31 @@ const start = { } describe("WorkflowAuthoring source-to-graph seam", () => { + it.effect("maps high-frequency field drift to the field that exists", () => + Effect.gen(function* () { + const authoring = WorkflowAuthoring.make() + const result = yield* authoring.prepare({ + action: "start", + source: { + kind: "yaml", + source: "drift.yaml", + content: [ + "config:", + " name: drift", + " objective: Field drift probe.", + " blocks:", + " - id: a", + " kind: coding", + " worker: general", + ].join("\n"), + }, + profile: "portable", + }) + expect(result.valid).toBe(false) + expect(result.errors.some((e) => e.hint.includes('Did you mean "worker_type"?'))).toBe(true) + }), + ) + it.effect("keeps every block-guide YAML envelope executable", () => Effect.gen(function* () { const guide = CommandPlugin.WorkflowBlocksContent diff --git a/packages/opencode/test/dag/workflow-tool.test.ts b/packages/opencode/test/dag/workflow-tool.test.ts index 178db7edf2..3277ac61b3 100644 --- a/packages/opencode/test/dag/workflow-tool.test.ts +++ b/packages/opencode/test/dag/workflow-tool.test.ts @@ -626,6 +626,47 @@ describe("workflow tool schema (negative tests)", () => { params: { action: "start", spec_path: ".opencode/workflows/deep.yaml" }, }) }) + + it("draft accepts a structured graph and rejects unknown fields", () => { + const decode = Schema.decodeUnknownSync(Parameters, { onExcessProperty: "error" }) + const draft = { + action: "draft", + title: "Structured draft", + config: { + name: "structured-draft", + objective: "Validate the draft rendering path.", + blocks: [ + { id: "map", kind: "explore", instruction: "Map the seams." }, + { id: "review", kind: "review", depends_on: ["map"] }, + ], + }, + } + expect(decode({ params: draft })).toMatchObject({ params: { action: "draft" } }) + // The high-frequency drift shapes die at the schema boundary. + expect(() => + decode({ params: { action: "draft", config: { ...draft.config, blocks: [{ id: "x", kind: "coding", worker: "general" }] } } }), + ).toThrow() + expect(() => decode({ params: { action: "draft", objective: "top level" } })).toThrow() + expect(() => decode({ params: { action: "draft" } })).toThrow() + }) + + it("draft passes mixed blocks+nodes through the schema; the authoring layer rejects them", () => { + const decode = Schema.decodeUnknownSync(Parameters) + // WorkflowGraphSchema is a permissive union at the parameter boundary — + // the compiled authoring check owns the blocks-xor-nodes rule, exercised + // in the execution tests below. + expect(() => + decode({ params: { + action: "draft", + config: { + name: "mixed", + objective: "Both sources at once.", + blocks: [{ id: "a", kind: "coding" }], + nodes: [], + }, + }}), + ).not.toThrow() + }) }) describe("workflow tool execution", () => { @@ -668,7 +709,10 @@ describe("workflow tool execution", () => { const index = yield* workflow.execute({ params: { action: "guide" }}, toolContext()) const blocks = yield* workflow.execute({ params: { action: "guide", topic: "blocks" }}, toolContext()) - expect(workflow.description.length).toBeLessThan(5_000) + // The budget admits the routing guide's inline start-spec example + // (one-hop field reference for hand-written YAML) while still keeping + // per-action manuals out of the always-on description. + expect(workflow.description.length).toBeLessThan(6_500) expect(index.output).toContain("blocks: compose") expect(index.output).not.toContain("# Composable Workflow Blocks") expect(blocks.output).toContain("# Composable Workflow Blocks") @@ -676,6 +720,100 @@ describe("workflow tool execution", () => { }), ) + runtime.effect("draft renders a structured graph into a validated spec file", () => + Effect.gen(function* () { + const info = yield* WorkflowTool + const workflow = yield* info.init() + const result = yield* workflow.execute( + { + params: { + action: "draft", + title: "Draft round trip", + config: { + name: "draft-round-trip", + objective: "Validate the draft rendering path.", + blocks: [ + { id: "map", kind: "explore", instruction: "Map the seams.\nSecond line with: colons and quotes." }, + { id: "coding", kind: "coding", depends_on: ["map"] }, + { id: "verify", kind: "verify", depends_on: ["coding"] }, + { id: "review", kind: "review", depends_on: ["verify"] }, + ], + }, + }, + }, + toolContext(), + ) + + expect(result.output).toContain(".opencode/workflow-drafts/draft-round-trip.yaml") + expect(result.output).toContain("nodes: ") + + // The rendered file round-trips through the same authoring path start + // uses, and read returns the authored document with draft content. + const read = yield* workflow.execute( + { params: { action: "read", spec_path: ".opencode/workflow-drafts/draft-round-trip.yaml" } }, + toolContext(), + ) + const parsed = JSON.parse(read.output) + expect(parsed.validation.valid).toBe(true) + expect(parsed.spec.config.name).toBe("draft-round-trip") + expect(parsed.spec.config.blocks).toHaveLength(4) + expect(parsed.spec.title).toBe("Draft round trip") + }), + ) + + runtime.effect("draft reports validation errors without creating a workflow", () => + Effect.gen(function* () { + const info = yield* WorkflowTool + const workflow = yield* info.init() + const decode = Schema.decodeUnknownSync(Parameters) + // Unknown dependency: valid at the parameter schema, rejected by the + // authoring validation the draft runs before returning. + const result = yield* workflow.execute( + decode({ + params: { + action: "draft", + config: { + name: "draft-bad-edge", + objective: "Broken dependency.", + blocks: [{ id: "a", kind: "coding", depends_on: ["ghost"] }], + }, + }, + }), + toolContext(), + ) + + expect(result.title).toContain("validation errors") + expect(result.output).toContain(".opencode/workflow-drafts/draft-bad-edge.yaml") + expect(result.output).toContain("ghost") + expect(published).toHaveLength(0) + }), + ) + + runtime.effect("draft refuses unsafe workflow names and stays outside the saved library", () => + Effect.gen(function* () { + const info = yield* WorkflowTool + const workflow = yield* info.init() + const decode = Schema.decodeUnknownSync(Parameters) + + const exit = yield* workflow + .execute( + decode({ + params: { + action: "draft", + config: { name: "../escape", objective: "x", blocks: [{ id: "a", kind: "coding" }] }, + }, + }), + toolContext(), + ) + .pipe(Effect.exit) + expect(Exit.isFailure(exit)).toBe(true) + + const list = yield* workflow.execute({ params: { action: "list" } }, toolContext()) + expect(list.output).not.toContain("draft-round-trip") + expect(list.output).not.toContain("workflow-drafts") + }), + ) + runtime.effect("status returns the durable workflow and node state", () => Effect.gen(function* () { const info = yield* WorkflowTool @@ -1099,10 +1237,12 @@ describe("workflow tool execution", () => { expect(published).toHaveLength(0) } - // Recovery guidance tells the model to move graph content into YAML. + // Recovery guidance tells the model to move graph content into draft + // or a YAML file — never an inline spec field. const guidance = workflow.formatValidationError?.(new Error("no branch matched")) ?? "" expect(guidance).toContain("start {spec_path}") - expect(guidance).toContain("Put graph content in a .yaml/.yml file") + expect(guidance).toContain("draft {title?, config}") + expect(guidance).toContain(".yaml/.yml file") }), ) diff --git a/packages/opencode/test/tool/workflow-provider-schema.test.ts b/packages/opencode/test/tool/workflow-provider-schema.test.ts index d85181b468..310d5ed68d 100644 --- a/packages/opencode/test/tool/workflow-provider-schema.test.ts +++ b/packages/opencode/test/tool/workflow-provider-schema.test.ts @@ -59,12 +59,12 @@ function record(node: JsonSchemaNode | undefined): Record { - test("base wire shape is a plain-object root carrying the 10-branch union in params", async () => { + test("base wire shape is a plain-object root carrying the 11-branch union in params", async () => { const schema = ToolJsonSchema.fromSchema(Parameters as never) as JsonSchemaNode expect(schema.type).toBe("object") expect(schema.anyOf).toBeUndefined() expect(schema.required).toEqual(["params"]) - expect(branches(schema).length).toBe(10) + expect(branches(schema).length).toBe(11) const flat = JSON.stringify(schema) expect(flat).not.toContain('"session_id"') expect(flat).not.toContain('"project_id"') @@ -117,7 +117,7 @@ describe("workflow provider-facing schema", () => { ToolJsonSchema.fromSchema(Parameters as never), ) as JsonSchemaNode expect(root(transformed).type).toBe("object") - expect(branches(transformed).length).toBe(10) + expect(branches(transformed).length).toBe(11) expect(branchByAction(transformed, "start", "spec_path").length).toBe(1) expect(branchByAction(transformed, "validate", "spec_path").length).toBeGreaterThan(0) }) @@ -127,7 +127,7 @@ describe("workflow provider-facing schema", () => { geminiModel, ToolJsonSchema.fromSchema(Parameters as never), ) as JsonSchemaNode - expect(branches(transformed).length).toBe(10) + expect(branches(transformed).length).toBe(11) expect(branchByAction(transformed, "start", "spec_path").length).toBe(1) expect(branchByAction(transformed, "start", "spec")).toEqual([]) const resultBranch = branchByAction(transformed, "result")[0] @@ -142,7 +142,7 @@ describe("workflow provider-facing schema", () => { ToolJsonSchema.fromSchema(Parameters as never), ) as JsonSchemaNode expect(root(transformed).type).toBe("object") - expect(branches(transformed)).toHaveLength(10) + expect(branches(transformed)).toHaveLength(11) expect(branchByAction(transformed, "start", "spec_path")).toHaveLength(1) } }) diff --git a/packages/opencode/test/tool/workflow-schema-contract.test.ts b/packages/opencode/test/tool/workflow-schema-contract.test.ts index 2f8a43073b..c53530bba4 100644 --- a/packages/opencode/test/tool/workflow-schema-contract.test.ts +++ b/packages/opencode/test/tool/workflow-schema-contract.test.ts @@ -50,7 +50,7 @@ describe("workflow tool schema contract", () => { test("the union survives intact inside the params property", () => { const transformed = ToolJsonSchema.fromSchema(Parameters as never) as JsonSchemaNode - expect(branches(transformed)).toHaveLength(10) + expect(branches(transformed)).toHaveLength(11) expect(transformed.properties?.params).toBeDefined() expect(transformed.required).toEqual(["params"]) }) From 1f606adab3f5bd3b3182b4c8bd4d5ac8734d43ed Mon Sep 17 00:00:00 2001 From: Lex Date: Fri, 14 Aug 2026 23:07:19 +0800 Subject: [PATCH 2/7] test(tool): regenerate workflow wire-shape snapshot for the draft action The draft action commit (76df8028c) added the 11th parameter-union branch but missed test/tool/__snapshots__/parameters.test.ts.snap; the stale snapshot failed 'tool parameters > JSON Schema (wire shape) > workflow' deterministically (surfaced as an external red gate during a concurrent verification run). Regenerated via bun test --update-snapshots; full test/tool + workflow-tool suites green (384 pass). --- .../__snapshots__/parameters.test.ts.snap | 306 ++++++++++++++++++ 1 file changed, 306 insertions(+) diff --git a/packages/opencode/test/tool/__snapshots__/parameters.test.ts.snap b/packages/opencode/test/tool/__snapshots__/parameters.test.ts.snap index 5e3418bff8..72463528fc 100644 --- a/packages/opencode/test/tool/__snapshots__/parameters.test.ts.snap +++ b/packages/opencode/test/tool/__snapshots__/parameters.test.ts.snap @@ -680,6 +680,312 @@ exports[`tool parameters JSON Schema (wire shape) workflow 1`] = ` ], "type": "object", }, + { + "properties": { + "action": { + "description": "Render a structured graph into a validated YAML spec file and return its spec_path — no workflow is created. Preferred over hand-writing YAML: field names are schema-checked here, eliminating serialization drift", + "enum": [ + "draft", + ], + "type": "string", + }, + "config": { + "anyOf": [ + { + "properties": { + "blocks": { + "description": "Composable blocks compiled into nodes by the runtime", + "items": { + "properties": { + "depends_on": { + "description": "Block IDs this block waits for. Defaults to []", + "items": { + "type": "string", + }, + "type": "array", + }, + "id": { + "description": "Unique block identifier; dependencies target block IDs", + "type": "string", + }, + "instruction": { + "description": "Task-specific instruction added to the block's built-in execution contract", + "type": "string", + }, + "kind": { + "description": "Composable workflow block; debug and review expand into evidence-gathering subgraphs", + "enum": [ + "explore", + "plan", + "prototype", + "debug", + "coding", + "verify", + "review", + "synthesize", + ], + "type": "string", + }, + "report_to_parent": { + "description": "Override wake behavior. Review decisions and synthesis report by default", + "type": "boolean", + }, + "required": { + "description": "Whether failure is terminal. Decision and verification blocks default to true; volume blocks to false", + "type": "boolean", + }, + "worker_type": { + "description": "Optional configured agent override; defaults from the block kind", + "type": "string", + }, + }, + "required": [ + "id", + "kind", + ], + "type": "object", + }, + "type": "array", + }, + "max_concurrency": { + "description": "Max parallel nodes. Default: 5", + "type": "number", + }, + "max_node_replan_attempts": { + "description": "Max replan restarts per node ID. Default: 5", + "type": "number", + }, + "max_total_nodes": { + "description": "Cumulative node cap across the workflow lifetime. Default: 100", + "type": "number", + }, + "name": { + "description": "Workflow name", + "type": "string", + }, + "node_defaults": { + "description": "Defaults inherited by nodes that omit required, worker_config, or report_to_parent", + "properties": { + "report_to_parent": { + "type": "boolean", + }, + "required": { + "type": "boolean", + }, + "worker_config": { + "properties": { + "timeout_ms": { + "type": "number", + }, + }, + "type": "object", + }, + }, + "type": "object", + }, + "objective": { + "description": "Injected into every generated child prompt; required for blocks", + "type": "string", + }, + }, + "required": [ + "name", + "objective", + "blocks", + ], + "type": "object", + }, + { + "properties": { + "max_concurrency": { + "description": "Max parallel nodes. Default: 5", + "type": "number", + }, + "max_node_replan_attempts": { + "description": "Max replan restarts per node ID. Default: 5", + "type": "number", + }, + "max_total_nodes": { + "description": "Cumulative node cap across the workflow lifetime. Default: 100", + "type": "number", + }, + "name": { + "description": "Workflow name", + "type": "string", + }, + "node_defaults": { + "description": "Defaults inherited by nodes that omit required, worker_config, or report_to_parent", + "properties": { + "report_to_parent": { + "type": "boolean", + }, + "required": { + "type": "boolean", + }, + "worker_config": { + "properties": { + "timeout_ms": { + "type": "number", + }, + }, + "type": "object", + }, + }, + "type": "object", + }, + "nodes": { + "description": "Low-level node declarations", + "items": { + "properties": { + "cancel": { + "description": "(replan only) Cancel this node", + "type": "boolean", + }, + "condition": { + "description": "Expression evaluated before spawn; node is skipped if false", + "type": "string", + }, + "depends_on": { + "description": "Node IDs this node waits for ([] for root)", + "items": { + "type": "string", + }, + "type": "array", + }, + "id": { + "description": "Unique node identifier, used in depends_on", + "type": "string", + }, + "input_mapping": { + "additionalProperties": { + "type": "string", + }, + "description": "Optional variable-to-source map, e.g. { resultA: "node-a", count: "node-b.output.count" }. Omit to expose each direct dependency under its node ID", + "type": "object", + }, + "name": { + "description": "Human-readable node name", + "type": "string", + }, + "output_schema": { + "description": "JSON Schema; child agent must call submit_result to submit structured output", + "type": "object", + }, + "prompt_template": { + "anyOf": [ + { + "properties": { + "inline": { + "description": "Inline prompt text; bind {{placeholders}} via input or input_mapping", + "type": "string", + }, + "input": { + "type": "object", + }, + }, + "required": [ + "inline", + ], + "type": "object", + }, + { + "properties": { + "id": { + "description": "Prompt asset id resolved from .opencode/dag-prompts (project, then global)", + "type": "string", + }, + "input": { + "type": "object", + }, + }, + "required": [ + "id", + ], + "type": "object", + }, + ], + "description": "Template: exactly one of { id: "..." } or { inline: "...", input: {...} }. Direct dependency outputs are available as {{node-id}} by default", + }, + "report_to_parent": { + "description": "If true, the parent agent is woken when this node completes or fails. Inherits config.node_defaults.report_to_parent", + "type": "boolean", + }, + "required": { + "description": "If true and this node fails, the workflow terminalizes as failed. Inherits config.node_defaults.required", + "type": "boolean", + }, + "restart": { + "description": "(replan only) Re-spawn this running node with new prompt. Running nodes only — terminal (completed/failed/skipped) nodes are immutable; to retry a failed node, add a replacement node under a new id", + "type": "boolean", + }, + "review": { + "description": "(deep review workers) design reviews pre-implementation artifacts; diff reviews require implementation_node_id and verification_node_id", + "properties": { + "implementation_node_id": { + "type": "string", + }, + "phase": { + "enum": [ + "design", + "diff", + ], + "type": "string", + }, + "verification_node_id": { + "type": "string", + }, + }, + "required": [ + "phase", + ], + "type": "object", + }, + "worker_config": { + "description": "{ timeout_ms } — bounds node execution. Inherits config.node_defaults.worker_config", + "properties": { + "timeout_ms": { + "type": "number", + }, + }, + "type": "object", + }, + "worker_type": { + "description": "Agent type (explore, build, general, plan, or custom)", + "type": "string", + }, + }, + "required": [ + "id", + "name", + "worker_type", + "depends_on", + "prompt_template", + ], + "type": "object", + }, + "type": "array", + }, + }, + "required": [ + "name", + "nodes", + ], + "type": "object", + }, + ], + "description": "Exactly one graph shape: { name, objective, blocks: [{ id, kind, depends_on?, instruction?, worker_type?, required?, report_to_parent? }], node_defaults?, max_concurrency?, max_node_replan_attempts?, max_total_nodes? } or the low-level { name, nodes: [...] } form. Fields are exhaustive — no others exist", + }, + "title": { + "description": "Optional workflow title", + "type": "string", + }, + }, + "required": [ + "action", + "config", + ], + "type": "object", + }, { "properties": { "action": { From 20b488c4a6f1afbd7061a8dac7ae8175a87cf7a7 Mon Sep 17 00:00:00 2001 From: Lex Date: Fri, 14 Aug 2026 23:40:36 +0800 Subject: [PATCH 3/7] docs(dag): restore the Progressive guidance heading swallowed by an edit The draft-action commit spliced the heading onto the preceding paragraph line, dropping it from rendered markdown structure. --- packages/core/src/plugin/command/workflow-routing.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/core/src/plugin/command/workflow-routing.md b/packages/core/src/plugin/command/workflow-routing.md index b4059640a4..bdd6fda2ad 100644 --- a/packages/core/src/plugin/command/workflow-routing.md +++ b/packages/core/src/plugin/command/workflow-routing.md @@ -106,7 +106,9 @@ Validate that `spec_path` before start. Fix every diagnostic in the same file and revalidate; validation creates no workflow. A successful start returns the exact workflow ID. The parent owns the graph, controls, and final report; children own bounded work. End after start and let the workflow wake the -parent. Do not poll merely to wait or claim an unstarted graph is running.## Progressive guidance +parent. Do not poll merely to wait or claim an unstarted graph is running. + +## Progressive guidance `guide` without a topic is the index. Topics: `blocks` for block shape, `interface` for low-level fields, `policy` for recovery, and `patterns` for From 3cd2d0d81f937cebb9e2931a68271e1e29263424 Mon Sep 17 00:00:00 2001 From: Lex Date: Fri, 14 Aug 2026 23:52:29 +0800 Subject: [PATCH 4/7] fix(dag): drop regex escapes in driftHint for the oxlint warning ratchet The RegExp escape forms tripped two unnecessary-escape warnings, pushing CI's oxlint count past the --max-warnings=4852 ratchet. Diagnostic paths are JSON.stringify-segmented, so a plain ["field"] substring check covers the same matches without the regex. --- packages/opencode/src/dag/validation.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/packages/opencode/src/dag/validation.ts b/packages/opencode/src/dag/validation.ts index 6180491843..d1126e41bf 100644 --- a/packages/opencode/src/dag/validation.ts +++ b/packages/opencode/src/dag/validation.ts @@ -365,11 +365,7 @@ const FIELD_DRIFT_HINTS: Record = { function driftHint(path: string, message: string) { for (const [wrong, right] of Object.entries(FIELD_DRIFT_HINTS)) { - if ( - message.includes(`"${wrong}"`) || - message.includes(`'${wrong}'`) || - new RegExp(`[\\"\[]${wrong}[\\"\]]`).test(path) - ) { + if (message.includes(`"${wrong}"`) || path.includes(`["${wrong}"]`)) { return `Did you mean "${right}"? Every block field is one of id, kind, depends_on, instruction, worker_type, required, report_to_parent; objective lives inside config` } } From 8e1240cb9b3a8be6b28cfb4aca363d68b2d792c5 Mon Sep 17 00:00:00 2001 From: Lex Date: Fri, 14 Aug 2026 23:10:06 +0800 Subject: [PATCH 5/7] fix(schema): stop lexicographic id ordering breakage from the 48-bit time-prefix wrap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause (proven from live logs): identifier create() encoded value = timestamp*0x1000 + counter into a 48-bit prefix. The shift eats 12 bits, so the prefix space covers only 2^36 ms (~795 days) and wrapped at epoch 1786706395136 = 2026-08-14 19:19:55.136 +08. Post-wrap ids (prefix 0009...) sort BELOW pre-wrap ids (fff...): observed msg_fffac212c001 at 09:48 UTC and msg_00090cb0400141 at 13:58 UTC match the computed prefixes. Sessions resumed across the boundary hit the runLoop exit condition (lastUser.id < lastAssistant.id) and never call the model ('loop step=0' followed 13ms later by 'exiting loop', no stream); the TUI binary-inserted new messages at the transcript top instead of the bottom. Two-layer repair: - Generator (schema/identifier + opencode id + core id): the prefix is now the raw 48-bit millisecond value behind a per-process monotonic latch (max(ts, last+1)) — no shift, 8925-year runway, same-ms bursts stay ascending, clock regression absorbed. 26-char format and the injected timestamp param are unchanged; descending() keeps the bitwise NOT. - Comparisons: every behavior-gating id ordering now uses time.created (id tiebreak for same-ms): runLoop exit (MessageV2.before), MessageV2 .latest() bindings and tasks filter, revert stage/cleanup ranges, session fork cutoff, TUI sync-store insertion/removal + session list order, TUI pending/queued/undo/revert-boundary filters, child-session ordering. This also revives already-corrupted cross-era sessions: a session whose last assistant message has a pre-wrap id accepts a new user message and runs the model again (pinned by the cross-era prompt test). Truncate cleanup no longer decodes ids at all (file mtime); the legacy timestamp() decoders keep the new encoding and have no remaining callers that read historical ids. Evidence: 4 deterministic red reproductions (identifier wrap boundary, cross-era runLoop, latest() cross-era, TUI store insert position) all green; mutation proof — reverting only the identifier encoding re-reddens the wrap tests and restore is byte-identical (sha256); typecheck clean in schema/core/opencode/tui; schema full 16/16, tui full 241/241, opencode test/session test/tool green. --- packages/core/src/id/id.ts | 3 +- packages/opencode/src/id/id.ts | 29 ++++---- packages/opencode/src/session/message-v2.ts | 24 ++++--- packages/opencode/src/session/prompt.ts | 2 +- packages/opencode/src/session/revert.ts | 10 ++- packages/opencode/src/session/session.ts | 3 +- packages/opencode/src/tool/truncate.ts | 13 ++-- .../opencode/test/session/message-v2.test.ts | 37 ++++++++++ packages/opencode/test/session/prompt.test.ts | 39 +++++++++++ .../opencode/test/tool/truncation.test.ts | 4 ++ packages/schema/src/identifier.ts | 24 ++++--- packages/schema/test/identifier.test.ts | 58 ++++++++++++++++ packages/tui/src/context/sync.tsx | 29 ++++++-- packages/tui/src/routes/session/index.tsx | 50 ++++++++++---- .../test/cli/cmd/tui/sync-msgwrap.test.tsx | 67 +++++++++++++++++++ 15 files changed, 325 insertions(+), 67 deletions(-) create mode 100644 packages/schema/test/identifier.test.ts create mode 100644 packages/tui/test/cli/cmd/tui/sync-msgwrap.test.tsx diff --git a/packages/core/src/id/id.ts b/packages/core/src/id/id.ts index be1efc446a..b06ab55a15 100644 --- a/packages/core/src/id/id.ts +++ b/packages/core/src/id/id.ts @@ -40,8 +40,7 @@ export function create(prefix: string, direction: "descending" | "ascending", ti export function timestamp(id: string): number { const prefix = id.split("_")[0] const hex = id.slice(prefix.length + 1, prefix.length + 13) - const encoded = BigInt("0x" + hex) - return Number(encoded / BigInt(0x1000)) + return Number(BigInt("0x" + hex)) } export * as Identifier from "./id" diff --git a/packages/opencode/src/id/id.ts b/packages/opencode/src/id/id.ts index 847a5c0329..8df9fbf38f 100644 --- a/packages/opencode/src/id/id.ts +++ b/packages/opencode/src/id/id.ts @@ -15,9 +15,14 @@ const prefixes = { const LENGTH = 26 -// State for monotonic ID generation -let lastTimestamp = 0 -let counter = 0 +// Latch over the raw millisecond value, shared by both directions. The prefix +// is the full 48-bit timestamp with no shift so it only wraps after 2^48 ms +// (~8925 years); without the latch, same-millisecond bursts would collide and +// clock regression would emit out-of-order ids. Historical ids (pre +// 2026-08-14) encoded (ts mod 2^36) << 12 and sort above new ids, so +// lexicographic id comparison across that boundary is invalid by design — +// ordering must always come from time.created. +let lastValue = 0n export function ascending(prefix: keyof typeof prefixes, given?: string) { return generateID(prefix, "ascending", given) @@ -49,17 +54,10 @@ function randomBase62(length: number): string { } export function create(prefix: string, direction: "descending" | "ascending", timestamp?: number): string { - const currentTimestamp = timestamp ?? Date.now() - - if (currentTimestamp !== lastTimestamp) { - lastTimestamp = currentTimestamp - counter = 0 - } - counter++ - - let now = BigInt(currentTimestamp) * BigInt(0x1000) + BigInt(counter) - - now = direction === "descending" ? ~now : now + const current = BigInt(timestamp ?? Date.now()) + const value = current > lastValue ? current : lastValue + 1n + lastValue = value + let now = direction === "descending" ? ~value : value const timeBytes = Buffer.alloc(6) for (let i = 0; i < 6; i++) { @@ -73,8 +71,7 @@ export function create(prefix: string, direction: "descending" | "ascending", ti export function timestamp(id: string): number { const prefix = id.split("_")[0] const hex = id.slice(prefix.length + 1, prefix.length + 13) - const encoded = BigInt("0x" + hex) - return Number(encoded / BigInt(0x1000)) + return Number(BigInt("0x" + hex)) } export * as Identifier from "./id" diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index 798518d0ba..4ae3957a25 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -579,29 +579,35 @@ export const filterCompactedEffect = Effect.fnUntraced(function* (sessionID: Ses // filterCompacted reorders messages for model consumption // ([compaction-user, summary, ...retained tail..., continue-user]), so array -// position is not chronological. Derive each binding by max id (MessageID -// is monotonic via MessageID.ascending) so a pre-compaction overflowing tail -// assistant doesn't get mistaken for the most recent turn. tasks are -// compaction/subtask parts attached to user messages newer than the latest -// finished assistant — i.e. unprocessed work. +// position is not chronological. Derive each binding by max (time.created, id) +// — time.created is the primary order because MessageIDs are not +// lexicographically ordered across the 2026-08 id-scheme boundary (historical +// pre-wrap ids sort above new ids); the id tiebreak only resolves +// same-millisecond bursts. tasks are compaction/subtask parts attached to user +// messages newer than the latest finished assistant — i.e. unprocessed work. export function latest(msgs: WithParts[]) { let user: User | undefined let assistant: Assistant | undefined let finished: Assistant | undefined for (const msg of msgs) { const info = msg.info - if (info.role === "user" && (!user || info.id > user.id)) user = info - if (info.role === "assistant" && (!assistant || info.id > assistant.id)) assistant = info - if (info.role === "assistant" && info.finish && (!finished || info.id > finished.id)) finished = info + if (info.role === "user" && (!user || before(user, info))) user = info + if (info.role === "assistant" && (!assistant || before(assistant, info))) assistant = info + if (info.role === "assistant" && info.finish && (!finished || before(finished, info))) finished = info } const tasks = msgs.flatMap((m) => - finished && m.info.id <= finished.id + finished && !before(finished, m.info) ? [] : m.parts.filter((p): p is CompactionPart | SubtaskPart => p.type === "compaction" || p.type === "subtask"), ) return { user, assistant, finished, tasks } } +export function before(a: { id: string; time: { created: number } }, b: { id: string; time: { created: number } }) { + if (a.time.created !== b.time.created) return a.time.created < b.time.created + return a.id < b.id +} + export function fromError( e: unknown, ctx: { providerID: ProviderV2.ID; aborted?: boolean }, diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index fcc8f5736e..11b6f95be8 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -1510,7 +1510,7 @@ export const layer = Layer.effect( (lastAssistant?.finish && !["tool-calls"].includes(lastAssistant.finish) && !hasToolCalls && - lastUser.id < lastAssistant.id) + MessageV2.before(lastUser, lastAssistant)) ) { const orphan = lastAssistantMsg?.parts.find( (part): part is SessionV1.ToolPart => part.type === "tool" && isOrphanedInterruptedTool(part), diff --git a/packages/opencode/src/session/revert.ts b/packages/opencode/src/session/revert.ts index 04631e4ec0..700363d482 100644 --- a/packages/opencode/src/session/revert.ts +++ b/packages/opencode/src/session/revert.ts @@ -71,7 +71,8 @@ export const layer = Layer.effect( if (session.revert?.snapshot) yield* snap.restore(session.revert.snapshot) yield* snap.revert(patches) if (rev.snapshot) rev.diff = yield* snap.diff(rev.snapshot) - const range = all.filter((msg) => msg.info.id >= rev.messageID) + const revMessage = all.find((msg) => msg.info.id === rev.messageID) + const range = revMessage ? all.filter((msg) => !MessageV2.before(msg.info, revMessage.info)) : [] const diffs = yield* summary.computeDiff({ messages: range }) yield* storage.write(["session_diff", input.sessionID], diffs).pipe(Effect.ignore) yield* events.publish(Session.Event.Diff, { sessionID: input.sessionID, diff: diffs }) @@ -102,11 +103,14 @@ export const layer = Layer.effect( const sessionID = session.id const msgs = yield* sessions.messages({ sessionID }).pipe(Effect.orDie) const messageID = session.revert.messageID + const revMessage = msgs.find((msg) => msg.info.id === messageID) + if (!revMessage) return + const revInfo = revMessage.info const remove = [] as SessionV1.WithParts[] let target: SessionV1.WithParts | undefined for (const msg of msgs) { - if (msg.info.id < messageID) continue - if (msg.info.id > messageID) { + if (MessageV2.before(msg.info, revInfo)) continue + if (MessageV2.before(revInfo, msg.info)) { remove.push(msg) continue } diff --git a/packages/opencode/src/session/session.ts b/packages/opencode/src/session/session.ts index 27789bc6bb..59fce4afce 100644 --- a/packages/opencode/src/session/session.ts +++ b/packages/opencode/src/session/session.ts @@ -796,6 +796,7 @@ export const layer: Layer.Layer< metadata: structuredClone(original.metadata), }) const msgs = yield* messages({ sessionID: input.sessionID }) + const cutoff = input.messageID ? msgs.find((msg) => msg.info.id === input.messageID) : undefined const idMap = new Map() // Every updateMessage/updatePart publishes a durable event, and each @@ -811,7 +812,7 @@ export const layer: Layer.Layer< () => Effect.gen(function* () { for (const msg of msgs) { - if (input.messageID && msg.info.id >= input.messageID) break + if (cutoff && !MessageV2.before(msg.info, cutoff.info)) break const newID = MessageID.ascending() idMap.set(msg.info.id, newID) diff --git a/packages/opencode/src/tool/truncate.ts b/packages/opencode/src/tool/truncate.ts index 1815643d03..7ff6e56513 100644 --- a/packages/opencode/src/tool/truncate.ts +++ b/packages/opencode/src/tool/truncate.ts @@ -6,7 +6,6 @@ import type { Agent } from "../agent/agent" import { FSUtil } from "@opencode-ai/core/fs-util" import { evaluate } from "@/permission/evaluate" import { Config } from "@/config/config" -import { Identifier } from "../id/id" import { ToolID } from "./schema" import { TRUNCATION_DIR } from "./truncation-dir" @@ -52,16 +51,18 @@ export const layer = Layer.effect( const fs = yield* FSUtil.Service const cleanup = Effect.fn("Truncate.cleanup")(function* () { - const cutoff = Identifier.timestamp( - Identifier.create("tool", "ascending", Date.now() - Duration.toMillis(RETENTION)), - ) + const cutoff = Date.now() - Duration.toMillis(RETENTION) const entries = yield* fs.readDirectory(TRUNCATION_DIR).pipe( Effect.map((all) => all.filter((name) => name.startsWith("tool_"))), Effect.catch(() => Effect.succeed([])), ) for (const entry of entries) { - if (Identifier.timestamp(entry) >= cutoff) continue - yield* fs.remove(path.join(TRUNCATION_DIR, entry)).pipe(Effect.catch(() => Effect.void)) + const file = path.join(TRUNCATION_DIR, entry) + const info = yield* fs.stat(file).pipe(Effect.catch(() => Effect.void)) + if (!info) continue + const mtime = Option.getOrElse(info.mtime, () => new Date(0)).getTime() + if (mtime >= cutoff) continue + yield* fs.remove(file).pipe(Effect.catch(() => Effect.void)) } }) diff --git a/packages/opencode/test/session/message-v2.test.ts b/packages/opencode/test/session/message-v2.test.ts index 1de84c9dd9..fb94fd37a5 100644 --- a/packages/opencode/test/session/message-v2.test.ts +++ b/packages/opencode/test/session/message-v2.test.ts @@ -1658,4 +1658,41 @@ describe("session.message-v2.latest", () => { expect(state.tasks).toHaveLength(1) expect(state.tasks[0]).toMatchObject({ type: "compaction", auto: true }) }) + + const PRE_WRAP_USER = MessageID.make("msg_fff0000000000000000001") + const POST_WRAP_USER = MessageID.make("msg_0009000000000000000001") + const PRE_WRAP_ASSISTANT = MessageID.make("msg_ffe0000000000000000001") + const POST_WRAP_ASSISTANT = MessageID.make("msg_0008000000000000000001") + + test("latest picks cross-era bindings by time.created, not by id order", () => { + const preWrapUser: SessionV1.WithParts = { + info: { ...userInfo(PRE_WRAP_USER), time: { created: 1786700000000 } }, + parts: [], + } + const postWrapUser: SessionV1.WithParts = { + info: { ...userInfo(POST_WRAP_USER), time: { created: 1786707000000 } }, + parts: [], + } + const preWrapAssistant: SessionV1.WithParts = { + info: { + ...assistantInfo(PRE_WRAP_ASSISTANT, PRE_WRAP_USER), + finish: "stop", + time: { created: 1786701000000 }, + }, + parts: [], + } + const postWrapAssistant: SessionV1.WithParts = { + info: { + ...assistantInfo(POST_WRAP_ASSISTANT, POST_WRAP_USER), + finish: "stop", + time: { created: 1786708000000 }, + }, + parts: [], + } + + const state = MessageV2.latest([preWrapUser, postWrapUser, preWrapAssistant, postWrapAssistant]) + expect(state.user?.id).toBe(POST_WRAP_USER) + expect(state.assistant?.id).toBe(POST_WRAP_ASSISTANT) + expect(state.finished?.id).toBe(POST_WRAP_ASSISTANT) + }) }) diff --git a/packages/opencode/test/session/prompt.test.ts b/packages/opencode/test/session/prompt.test.ts index 1d05d4b5a4..0139cc10c4 100644 --- a/packages/opencode/test/session/prompt.test.ts +++ b/packages/opencode/test/session/prompt.test.ts @@ -560,6 +560,45 @@ noLLMServer.instance( { config: cfg }, ) +it.instance("loop runs the model when the newest user message sorts below the pre-wrap assistant id (cross-era wrap)", () => + Effect.gen(function* () { + const { llm } = yield* useServerConfig(providerCfg) + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const chat = yield* sessions.create({ title: "Pinned" }) + const assistantID = MessageID.make("msg_fffac212c001") + yield* sessions.updateMessage({ + id: assistantID, + role: "assistant", + parentID: MessageID.make("msg_fffac212c000"), + sessionID: chat.id, + mode: "build", + agent: "build", + cost: 0, + path: { cwd: "/tmp", root: "/tmp" }, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + modelID: ref.modelID, + providerID: ref.providerID, + time: { created: 1786700000000 }, + finish: "stop", + } satisfies SessionV1.Assistant) + yield* sessions.updatePart({ + id: PartID.ascending(), + messageID: assistantID, + sessionID: chat.id, + type: "text", + text: "pre-wrap answer", + }) + yield* user(chat.id, "hello after the wrap") + yield* llm.text("world") + + const result = yield* prompt.loop({ sessionID: chat.id }) + expect(result.info.role).toBe("assistant") + expect(result.info.id).not.toBe(assistantID) + expect(yield* llm.hits).toHaveLength(1) + }), +) + it.instance("loop exits without an LLM request for interrupted orphan tool calls", () => Effect.gen(function* () { const { llm } = yield* useServerConfig(providerCfg) diff --git a/packages/opencode/test/tool/truncation.test.ts b/packages/opencode/test/tool/truncation.test.ts index 6e65b5f54c..119e41062c 100644 --- a/packages/opencode/test/tool/truncation.test.ts +++ b/packages/opencode/test/tool/truncation.test.ts @@ -256,6 +256,10 @@ describe("Truncate", () => { yield* writeFileStringScoped(old, "old content") yield* writeFileStringScoped(recent, "recent content") + const oldMtime = new Date(Date.now() - 10 * DAY_MS) + const recentMtime = new Date(Date.now() - 3 * DAY_MS) + yield* fs.utimes(old, oldMtime, oldMtime) + yield* fs.utimes(recent, recentMtime, recentMtime) yield* svc.cleanup() expect(yield* fs.exists(old)).toBe(false) diff --git a/packages/schema/src/identifier.ts b/packages/schema/src/identifier.ts index 9812a673fb..ee835192c8 100644 --- a/packages/schema/src/identifier.ts +++ b/packages/schema/src/identifier.ts @@ -1,7 +1,13 @@ const length = 26 const chars = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" -let lastTimestamp = 0 -let counter = 0 +// Latch over the raw millisecond value, shared by both directions. The prefix +// is the full 48-bit timestamp with no shift so it only wraps after 2^48 ms +// (~8925 years); without the latch, same-millisecond bursts would collide and +// clock regression would emit out-of-order ids. Historical ids (pre +// 2026-08-14) encoded (ts mod 2^36) << 12 and sort above new ids, so +// lexicographic id comparison across that boundary is invalid by design — +// ordering must always come from time.created. +let lastValue = 0n export function ascending() { return create(false) @@ -12,16 +18,12 @@ export function descending() { } export function create(descending: boolean, timestamp = Date.now()) { - if (timestamp !== lastTimestamp) { - lastTimestamp = timestamp - counter = 0 - } - counter++ - - const current = BigInt(timestamp) * 0x1000n + BigInt(counter) - const value = descending ? ~current : current + const current = BigInt(timestamp) + const value = current > lastValue ? current : lastValue + 1n + lastValue = value + const out = descending ? ~value : value const time = Array.from({ length: 6 }, (_, index) => - Number((value >> BigInt(40 - 8 * index)) & 0xffn) + Number((out >> BigInt(40 - 8 * index)) & 0xffn) .toString(16) .padStart(2, "0"), ).join("") diff --git a/packages/schema/test/identifier.test.ts b/packages/schema/test/identifier.test.ts new file mode 100644 index 0000000000..dc38075bc2 --- /dev/null +++ b/packages/schema/test/identifier.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, test } from "bun:test" +import { create } from "../src/identifier" + +const WRAP_BOUNDARY = 1786706395136 +const chars = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" + +describe("identifier 48-bit wrap", () => { + test("ascending ids stay lexicographically ascending across the wrap boundary", () => { + const preWrap = create(false, WRAP_BOUNDARY - 1) + const postWrap = create(false, WRAP_BOUNDARY + 1) + expect(preWrap < postWrap).toBe(true) + }) + + test("descending ids stay lexicographically descending across the wrap boundary", () => { + const preWrap = create(true, WRAP_BOUNDARY - 1) + const postWrap = create(true, WRAP_BOUNDARY + 1) + expect(postWrap < preWrap).toBe(true) + }) + + test("same-millisecond ids are strictly ascending and unique", () => { + const ids = Array.from({ length: 10 }, (_, index) => create(false, WRAP_BOUNDARY + 1000 + index)) + for (let i = 1; i < ids.length; i++) { + expect(ids[i - 1]! < ids[i]!).toBe(true) + } + expect(new Set(ids).size).toBe(ids.length) + }) + + test("same-millisecond descending ids are strictly descending and unique", () => { + const ids = Array.from({ length: 10 }, (_, index) => create(true, WRAP_BOUNDARY + 2000 + index)) + for (let i = 1; i < ids.length; i++) { + expect(ids[i - 1]! > ids[i]!).toBe(true) + } + expect(new Set(ids).size).toBe(ids.length) + }) + + test("id format is 12 hex chars plus 14 base62 chars", () => { + const id = create(false, WRAP_BOUNDARY + 1) + expect(id).toHaveLength(26) + expect(id.slice(0, 12)).toMatch(/^[0-9a-f]{12}$/) + for (const char of id.slice(12)) { + expect(chars).toContain(char) + } + }) + + test("ids stay ascending when the clock regresses (latch absorbs regression)", () => { + const first = create(false, WRAP_BOUNDARY + 3000) + const regressed = create(false, WRAP_BOUNDARY + 3000 - 50) + const later = create(false, WRAP_BOUNDARY + 3000 + 50) + expect(first < regressed).toBe(true) + expect(regressed < later).toBe(true) + }) + + test("new-scheme ids sort below historical pre-wrap ids (comparisons must be time-based)", () => { + const now = create(false, Date.now()) + const historical = create(false, WRAP_BOUNDARY - 1) + expect(now < historical).toBe(true) + }) +}) diff --git a/packages/tui/src/context/sync.tsx b/packages/tui/src/context/sync.tsx index 6772cfe683..38f5390c21 100644 --- a/packages/tui/src/context/sync.tsx +++ b/packages/tui/src/context/sync.tsx @@ -55,6 +55,24 @@ function search(items: T[], target: string, key: (item: T) => string) { return { found: false, index: left } } +function before(a: { id: string; time: { created: number } }, b: { id: string; time: { created: number } }) { + if (a.time.created !== b.time.created) return a.time.created < b.time.created + return a.id < b.id +} + +function searchMessages(messages: Message[], target: Message) { + let left = 0 + let right = messages.length - 1 + while (left <= right) { + const middle = Math.floor((left + right) / 2) + const value = messages[middle] + if (before(value, target)) left = middle + 1 + else if (before(target, value)) right = middle - 1 + else return { found: true, index: middle } + } + return { found: false, index: left } +} + export const { context: SyncContext, use: useSync, @@ -175,7 +193,7 @@ export const { function listSessions() { return sdk.client.session .list({ start: Date.now() - 30 * 24 * 60 * 60 * 1000, ...sessionListQuery() }) - .then((x) => (x.data ?? []).toSorted((a, b) => a.id.localeCompare(b.id))) + .then((x) => (x.data ?? []).toSorted((a, b) => b.time.updated - a.time.updated)) } event.subscribe((event, { workspace }) => { @@ -338,7 +356,7 @@ export const { setStore("message", event.properties.info.sessionID, [event.properties.info]) break } - const result = search(messages, event.properties.info.id, (m) => m.id) + const result = searchMessages(messages, event.properties.info) if (result.found) { setStore("message", event.properties.info.sessionID, result.index, reconcile(event.properties.info)) break @@ -374,13 +392,14 @@ export const { case "message.removed": { touchMessage(event.properties.sessionID, event.properties.messageID) const messages = store.message[event.properties.sessionID] - const result = search(messages, event.properties.messageID, (m) => m.id) - if (result.found) { + if (!messages) break + const index = messages.findIndex((m) => m.id === event.properties.messageID) + if (index >= 0) { setStore( "message", event.properties.sessionID, produce((draft) => { - draft.splice(result.index, 1) + draft.splice(index, 1) }), ) } diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index 47dc19dbdd..8bd5c25ced 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -29,6 +29,7 @@ import { BoxRenderable, ScrollBoxRenderable, addDefaultParsers, TextAttributes, import { Prompt, type PromptRef } from "../../component/prompt" import type { AssistantMessage, + Message, Part, Provider, ToolPart, @@ -96,6 +97,11 @@ export const alwaysSeparate = new WeakSet() type RetryAction = Extract["action"] +function orderedBefore(a: { id: string; time: { created: number } }, b: { id: string; time: { created: number } }) { + if (a.time.created !== b.time.created) return a.time.created < b.time.created + return a.id < b.id +} + function goUpsellKeys(action: RetryAction) { if (!action) return if (!GO_UPSELL_PROVIDERS.has(action.provider)) return @@ -208,7 +214,7 @@ export function Session() { const parentID = session()?.parentID ?? session()?.id return sync.data.session .filter((x) => x.parentID === parentID || x.id === parentID) - .toSorted((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)) + .toSorted((a, b) => b.time.created - a.time.created) }) const messages = createMemo(() => sync.data.message[route.sessionID] ?? []) const foregroundTasks = createMemo(() => @@ -236,9 +242,10 @@ export function Session() { const disabled = createMemo(() => permissions().length > 0 || questions().length > 0) const pending = createMemo(() => { - const completed = messages().findLast((x) => x.role === "assistant" && x.time.completed)?.id - return messages().findLast((x) => x.role === "assistant" && !x.time.completed && (!completed || x.id > completed)) - ?.id + const completed = messages().findLast((x) => x.role === "assistant" && x.time.completed) + return messages().findLast( + (x) => x.role === "assistant" && !x.time.completed && (!completed || !orderedBefore(x, completed)), + ) }) const lastAssistant = createMemo(() => { @@ -611,7 +618,10 @@ export function Session() { const status = sync.data.session_status?.[route.sessionID] if (status?.type !== "idle") await sdk.client.session.abort({ sessionID: route.sessionID }).catch(() => {}) const revert = session()?.revert?.messageID - const message = messages().findLast((x) => (!revert || x.id < revert) && x.role === "user") + const revertMessage = revert ? messages().find((m) => m.id === revert) : undefined + const message = messages().findLast( + (x) => x.role === "user" && (!revertMessage || orderedBefore(x, revertMessage)), + ) if (!message) return void sdk.client.session .revert({ @@ -649,7 +659,10 @@ export function Session() { dialog.clear() const messageID = session()?.revert?.messageID if (!messageID) return - const message = messages().find((x) => x.role === "user" && x.id > messageID) + const revertMessage = messages().find((m) => m.id === messageID) + const message = revertMessage + ? messages().find((x) => x.role === "user" && orderedBefore(revertMessage, x)) + : undefined if (!message) { void sdk.client.session.unrevert({ sessionID: route.sessionID, @@ -873,8 +886,9 @@ export function Session() { category: "Session", run: () => { const revertID = session()?.revert?.messageID + const revertMessage = revertID ? messages().find((m) => m.id === revertID) : undefined const lastAssistantMessage = messages().findLast( - (msg) => msg.role === "assistant" && (!revertID || msg.id < revertID), + (msg) => msg.role === "assistant" && (!revertMessage || orderedBefore(msg, revertMessage)), ) if (!lastAssistantMessage) { toast.show({ message: "No assistant messages found", variant: "error" }) @@ -1118,13 +1132,23 @@ export function Session() { const revertInfo = createMemo(() => session()?.revert) const revertMessageID = createMemo(() => revertInfo()?.messageID) + const revertBoundary = createMemo(() => { + const messageID = revertMessageID() + if (!messageID) return undefined + return messages().find((m) => m.id === messageID) + }) + + const atOrAfterRevert = (message: Message) => { + const boundary = revertBoundary() + return boundary !== undefined && !orderedBefore(message, boundary) + } const revertDiffFiles = createMemo(() => getRevertDiffFiles(revertInfo()?.diff ?? "")) const revertRevertedMessages = createMemo(() => { - const messageID = revertMessageID() - if (!messageID) return [] - return messages().filter((x) => x.id >= messageID && x.role === "user") + const boundary = revertBoundary() + if (!boundary) return [] + return messages().filter((x) => x.role === "user" && !orderedBefore(x, boundary)) }) const revert = createMemo(() => { @@ -1247,7 +1271,7 @@ export function Session() { ) })()} - = revert()!.messageID}> + <> @@ -1352,7 +1376,7 @@ function UserMessage(props: { parts: Part[] onMouseUp: () => void index: number - pending?: string + pending?: Message }) { const ctx = use() const local = useLocal() @@ -1370,7 +1394,7 @@ function UserMessage(props: { const files = createMemo(() => props.parts.flatMap((x) => (x.type === "file" ? [x] : []))) const { theme } = useTheme() const [hover, setHover] = createSignal(false) - const queued = createMemo(() => props.pending && props.message.id > props.pending) + const queued = createMemo(() => props.pending && props.message.time.created > props.pending.time.created) const color = createMemo(() => local.agent.color(props.message.agent)) const queuedFg = createMemo(() => selectedForeground(theme, color())) const metadataVisible = createMemo(() => queued() || ctx.showTimestamps()) diff --git a/packages/tui/test/cli/cmd/tui/sync-msgwrap.test.tsx b/packages/tui/test/cli/cmd/tui/sync-msgwrap.test.tsx new file mode 100644 index 0000000000..dc4d91f7f5 --- /dev/null +++ b/packages/tui/test/cli/cmd/tui/sync-msgwrap.test.tsx @@ -0,0 +1,67 @@ +/** @jsxImportSource @opentui/solid */ +import { expect, test } from "bun:test" +import type { GlobalEvent } from "@opencode-ai/sdk/v2" +import { tmpdir } from "../../../fixture/fixture" +import { json, mount, wait } from "./sync-fixture" + +const sessionID = "ses_msgwrap" + +const session = { + id: sessionID, + title: "wrap", + time: { created: 0, updated: 0 }, + version: "1.15.13", + directory: "/tmp/opencode/packages/opencode", +} + +const preWrapAssistant = { + id: "msg_fffac212c001", + sessionID, + role: "assistant" as const, + agent: "build", + modelID: "test-model", + providerID: "test", + mode: "build", + parentID: "msg_fffac212c000", + path: { cwd: session.directory, root: session.directory }, + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + time: { created: 1786700000000, completed: 1786700001000 }, +} + +const postWrapUser = { + id: "msg_00090cb04001", + sessionID, + role: "user" as const, + agent: "build", + model: { providerID: "test", modelID: "test-model" }, + time: { created: 1786707000000 }, +} + +function global(payload: GlobalEvent["payload"]): GlobalEvent { + return { directory: "/tmp/other", project: "proj_test", payload } +} + +test("message.updated with a cross-era id appends after pre-wrap messages", async () => { + await using tmp = await tmpdir() + await Bun.write(`${tmp.path}/kv.json`, "{}") + const { app, emit, sync } = await mount((url) => { + if (url.pathname === `/session/${sessionID}`) return json(session) + if (url.pathname === `/session/${sessionID}/message`) return json([{ info: preWrapAssistant, parts: [] }]) + if (url.pathname === `/session/${sessionID}/todo` || url.pathname === `/session/${sessionID}/diff`) return json([]) + return undefined + }, tmp.path) + + try { + await sync.session.sync(sessionID) + expect(sync.data.message[sessionID]?.map((message) => message.id)).toStrictEqual([preWrapAssistant.id]) + emit(global({ id: "evt_new_user", type: "message.updated", properties: { sessionID, info: postWrapUser } })) + await wait(() => sync.data.message[sessionID]?.length === 2) + expect(sync.data.message[sessionID]?.map((message) => message.id)).toStrictEqual([ + preWrapAssistant.id, + postWrapUser.id, + ]) + } finally { + app.renderer.destroy() + } +}) From feb3b331d11b00679fe9cbb14eda497bb001a807 Mon Sep 17 00:00:00 2001 From: Lex Date: Fri, 14 Aug 2026 23:40:03 +0800 Subject: [PATCH 6/7] fix(tui): keep the session store id-ordered; recency is a display concern MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Independent verification of the wrap fix caught a P1 regression it introduced: listSessions() was re-sorted by time.updated (descending), which broke the id-ascending invariant every session event handler's binary search relies on. Touching an old session (session.updated after a prompt) then duplicated its row in the store, session.deleted could miss and leave a ghost, and session.get returned undefined for existing sessions — reproduced with the sync fixture. The store now restores codepoint id order in listSessions(); the session-list dialog applies recency ordering at the display layer (search fallback re-sorts by time.updated, matching search results). Two sync-store regression tests pin the invariant: session.updated reconciles in place (no duplicate) and session.deleted removes when recency order diverges from id order. Both are red on the regressed ordering and green here. --- .../tui/src/component/dialog-session-list.tsx | 4 +- packages/tui/src/context/sync.tsx | 5 +- .../cli/cmd/tui/sync-session-store.test.tsx | 100 ++++++++++++++++++ 3 files changed, 107 insertions(+), 2 deletions(-) create mode 100644 packages/tui/test/cli/cmd/tui/sync-session-store.test.tsx diff --git a/packages/tui/src/component/dialog-session-list.tsx b/packages/tui/src/component/dialog-session-list.tsx index 2965b3692e..feeb417ec3 100644 --- a/packages/tui/src/component/dialog-session-list.tsx +++ b/packages/tui/src/component/dialog-session-list.tsx @@ -43,7 +43,9 @@ export function DialogSessionList() { ) const currentSessionID = createMemo(() => (route.data.type === "session" ? route.data.sessionID : undefined)) - const sessions = createMemo(() => searchResults() ?? sync.data.session) + // sync.data.session is id-ordered (binary-search invariant); the browse + // fallback re-sorts by recency for display, matching the search results. + const sessions = createMemo(() => searchResults() ?? [...sync.data.session].toSorted((a, b) => b.time.updated - a.time.updated)) function recover(session: NonNullable[number]>) { const workspace = project.workspace.get(session.workspaceID!) diff --git a/packages/tui/src/context/sync.tsx b/packages/tui/src/context/sync.tsx index 38f5390c21..952713cdec 100644 --- a/packages/tui/src/context/sync.tsx +++ b/packages/tui/src/context/sync.tsx @@ -191,9 +191,12 @@ export const { } function listSessions() { + // Store order must stay id-ascending: every session event handler + // binary-searches by id (search()). Recency ordering is a display + // concern and lives in the session-list dialog. return sdk.client.session .list({ start: Date.now() - 30 * 24 * 60 * 60 * 1000, ...sessionListQuery() }) - .then((x) => (x.data ?? []).toSorted((a, b) => b.time.updated - a.time.updated)) + .then((x) => (x.data ?? []).toSorted((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))) } event.subscribe((event, { workspace }) => { diff --git a/packages/tui/test/cli/cmd/tui/sync-session-store.test.tsx b/packages/tui/test/cli/cmd/tui/sync-session-store.test.tsx new file mode 100644 index 0000000000..7a29a94118 --- /dev/null +++ b/packages/tui/test/cli/cmd/tui/sync-session-store.test.tsx @@ -0,0 +1,100 @@ +/** @jsxImportSource @opentui/solid */ +import { expect, test } from "bun:test" +import type { GlobalEvent } from "@opencode-ai/sdk/v2" +import { tmpdir } from "../../../fixture/fixture" +import { json, mount, wait } from "./sync-fixture" + +const sessionOld = { + id: "ses_aaa111", + slug: "old", + projectID: "proj_test", + title: "old", + time: { created: 1, updated: 1 }, + version: "1.15.13", + directory: "/tmp/opencode/packages/opencode", +} + +const sessionMid = { + id: "ses_bbb222", + slug: "mid", + projectID: "proj_test", + title: "mid", + time: { created: 2, updated: 2 }, + version: "1.15.13", + directory: "/tmp/opencode/packages/opencode", +} + +const sessionNew = { + id: "ses_ccc333", + slug: "new", + projectID: "proj_test", + title: "new", + time: { created: 3, updated: 3 }, + version: "1.15.13", + directory: "/tmp/opencode/packages/opencode", +} + +function global(payload: GlobalEvent["payload"]): GlobalEvent { + return { directory: "/tmp/other", project: "proj_test", payload } +} + +test("session.updated keeps one entry per session id regardless of recency order", async () => { + await using tmp = await tmpdir() + await Bun.write(`${tmp.path}/kv.json`, "{}") + const sessions = [sessionNew, sessionMid, sessionOld] + const { app, emit, sync } = await mount((url) => { + if (url.pathname === "/session") return json(sessions) + return undefined + }, tmp.path) + + try { + await sync.session.refresh() + expect(sync.data.session.map((session) => session.id)).toStrictEqual([ + sessionOld.id, + sessionMid.id, + sessionNew.id, + ]) + // Touching the oldest session moves it to most-recent by time.updated; + // the id-keyed store must reconcile in place, never duplicate. + emit( + global({ + id: "evt_touch_old", + type: "session.updated", + properties: { sessionID: sessionOld.id, info: { ...sessionOld, time: { ...sessionOld.time, updated: 99 } } }, + }), + ) + await wait(() => sync.data.session.find((session) => session.id === sessionOld.id)?.time.updated === 99) + expect(sync.data.session.map((session) => session.id)).toStrictEqual([ + sessionOld.id, + sessionMid.id, + sessionNew.id, + ]) + } finally { + app.renderer.destroy() + } +}) + +test("session.deleted removes the session even when recency order diverges from id order", async () => { + await using tmp = await tmpdir() + await Bun.write(`${tmp.path}/kv.json`, "{}") + const sessions = [sessionNew, sessionOld, sessionMid] + const { app, emit, sync } = await mount((url) => { + if (url.pathname === "/session") return json(sessions) + return undefined + }, tmp.path) + + try { + await sync.session.refresh() + emit( + global({ + id: "evt_delete_mid", + type: "session.deleted", + properties: { sessionID: sessionMid.id, info: sessionMid }, + }), + ) + await wait(() => !sync.data.session.some((session) => session.id === sessionMid.id)) + expect(sync.data.session.map((session) => session.id)).toStrictEqual([sessionOld.id, sessionNew.id]) + } finally { + app.renderer.destroy() + } +}) From 65ac4ec0704376983f3bcbbdc1baa5635a826e7f Mon Sep 17 00:00:00 2001 From: Lex Date: Fri, 14 Aug 2026 23:58:06 +0800 Subject: [PATCH 7/7] test(schema): drop non-null assertions the oxlint ratchet flags The wrap-boundary tests used ! on already-string expressions, adding four no-unnecessary-type-assertion warnings that push CI past the --max-warnings=4852 ratchet. --- packages/schema/test/identifier.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/schema/test/identifier.test.ts b/packages/schema/test/identifier.test.ts index dc38075bc2..06e5055808 100644 --- a/packages/schema/test/identifier.test.ts +++ b/packages/schema/test/identifier.test.ts @@ -20,7 +20,7 @@ describe("identifier 48-bit wrap", () => { test("same-millisecond ids are strictly ascending and unique", () => { const ids = Array.from({ length: 10 }, (_, index) => create(false, WRAP_BOUNDARY + 1000 + index)) for (let i = 1; i < ids.length; i++) { - expect(ids[i - 1]! < ids[i]!).toBe(true) + expect(ids[i - 1] < ids[i]).toBe(true) } expect(new Set(ids).size).toBe(ids.length) }) @@ -28,7 +28,7 @@ describe("identifier 48-bit wrap", () => { test("same-millisecond descending ids are strictly descending and unique", () => { const ids = Array.from({ length: 10 }, (_, index) => create(true, WRAP_BOUNDARY + 2000 + index)) for (let i = 1; i < ids.length; i++) { - expect(ids[i - 1]! > ids[i]!).toBe(true) + expect(ids[i - 1] > ids[i]).toBe(true) } expect(new Set(ids).size).toBe(ids.length) })