diff --git a/.github/RELEASE_NOTES_TEMPLATE.md b/.github/RELEASE_NOTES_TEMPLATE.md index aa826820a..b9ba61702 100644 --- a/.github/RELEASE_NOTES_TEMPLATE.md +++ b/.github/RELEASE_NOTES_TEMPLATE.md @@ -57,4 +57,4 @@ typecheck: N/N packages green --- -**Full changelog:** `{previous_tag}...{current_tag}` +**Full changelog:** [`{previous_tag}`...`{current_tag}`](https://github.com/LeXwDeX/OpenCode-GraphAgent/compare/{previous_tag}...{current_tag}) diff --git a/.github/releases/README.md b/.github/releases/README.md new file mode 100644 index 000000000..a2ed7240a --- /dev/null +++ b/.github/releases/README.md @@ -0,0 +1,59 @@ +# Release Notes Series Files + +One markdown file per release series — `.github/releases/vX.Y.Z.md` — is the +source of truth for the GitHub Release body. The `dev` prereleases +(`X.Y.Z-dev.1 … dev.N`) and the `main` stable promotion (`X.Y.Z`) of a series +all render the **same** file; only the channel word differs. + +The release job (`.github/workflows/release-fork.yml`) renders and validates +the file **before** `gh release create` and fails closed on any violation — a +release can never ship with placeholder notes. + +## Lifecycle + +1. **Series starts** — when `X.Y.Z` becomes the next version, the release job + looks for `.github/releases/vX.Y.Z.md`. Until that file is committed, every + release attempt of the series fails; the validator error names the exact + expected path. This is intentional. +2. **Dev prereleases** — each `X.Y.Z-dev.N` build re-renders the current file + content. Update the file as the series evolves. +3. **Stable promotion** — the `main` release of `X.Y.Z` renders the same file; + `{Prerelease/Stable}` becomes `Stable`. The compare range always spans from + the last stable tag, not from the previous `-dev.N`. +4. **Series closes** — after the stable release ships, the file remains as the + historical record. The next series needs its own new `vX.Y.(Z+1).md`. + +## Placeholders + +Five tokens are machine-substituted at render time: + +| Token | Replaced with | +| -------------------- | --------------------------------------------------------- | +| `{VERSION}` | bare semver, e.g. `1.0.10` (no `v` prefix) | +| `{Prerelease/Stable}` | `Prerelease` on `dev`, `Stable` on `main` | +| `{branch}` | releasing branch name (`dev` or `main`) | +| `{previous_tag}` | latest existing stable tag, e.g. `graphagent-v1.0.9` | +| `{current_tag}` | the tag being released, e.g. `graphagent-v1.0.10` | + +The template also contains authoring-guidance braces (`{Feature name}`, +`{module}`, `{One-sentence summary …}`). These are **not** substituted — +replace every one of them with real content. The validator fails on any +residual `{` or `}` in the rendered notes. + +## Authoring rules (enforced fail-closed) + +- Start from `.github/RELEASE_NOTES_TEMPLATE.md` and keep the exact `### ` + emoji headings, their canonical order, and the `---` separators between + sections. Omit sections that have no content — do not leave empty headers. +- Copy the emoji headings verbatim from the template; never retype them. The + 🏗️ (Architecture / Refactor) and ⚙️ (CI / Engineering) headings end with an + invisible U+FE0F variation selector that editors and copy-paste can strip. +- Prose must be ASCII everywhere except the emoji headings themselves. +- `### 🧪 Test Summary` and `### 🔍 Verification` are mandatory in every + release; the Test Summary body needs at least one fenced code block. +- The final line is the full-changelog compare link with the repository slug + written out literally (`https://github.com/LeXwDeX/OpenCode-GraphAgent/compare/{previous_tag}...{current_tag}`). + A repository rename fails validation on purpose — update the series file. + +The grammar is implemented in `packages/opencode/script/release-notes.ts` +(rule errors are prefixed `[release-notes]`). diff --git a/.github/workflows/release-fork.yml b/.github/workflows/release-fork.yml index c6ad8e31f..06cf50e09 100644 --- a/.github/workflows/release-fork.yml +++ b/.github/workflows/release-fork.yml @@ -70,6 +70,8 @@ jobs: tag: ${{ steps.release-version.outputs.tag }} prerelease: ${{ steps.release-version.outputs.prerelease }} latest: ${{ steps.release-version.outputs.latest }} + previous_tag: ${{ steps.release-version.outputs.previous_tag }} + steps: - name: Checkout Repository uses: actions/checkout@v4 @@ -270,6 +272,29 @@ jobs: echo "--- SHA256SUMS ---" cat SHA256SUMS + - name: Setup Bun + uses: ./.github/actions/setup-bun + with: + save-cache: false + + # Render + validate the per-series notes file (.github/releases/vX.Y.Z.md) + # BEFORE creating the release. Fail closed: a missing or invalid series + # file stops the job here, so a release can never ship with placeholder + # notes. The script derives the series filename from --version; the + # workflow passes only primitives. Rendered notes go to RUNNER_TEMP and + # are never attached as a release asset. + - name: Render Release Notes (fail closed) + run: | + bun run ./packages/opencode/script/release-notes.ts \ + --notes-dir ".github/releases" \ + --version "${{ needs.version.outputs.version }}" \ + --channel "${{ needs.version.outputs.channel }}" \ + --branch "${{ github.ref_name }}" \ + --tag "${{ needs.version.outputs.tag }}" \ + --previous-tag "${{ needs.version.outputs.previous_tag }}" \ + --repo "${{ github.repository }}" \ + --out "$RUNNER_TEMP/RELEASE_NOTES.md" + - name: Create GitHub Release env: GH_TOKEN: ${{ github.token }} @@ -284,7 +309,7 @@ jobs: fi gh release create "${{ needs.version.outputs.tag }}" \ --title "OpenCode GraphAgent v${{ needs.version.outputs.version }}" \ - --notes "GraphAgent release from branch ${{ github.ref_name }}" \ + --notes-file "$RUNNER_TEMP/RELEASE_NOTES.md" \ --target "${{ github.sha }}" \ "${EXTRA_FLAGS[@]}" \ release-assets/* diff --git a/.github/workflows/specgit-accept.yml b/.github/workflows/specgit-accept.yml index ff9080efd..46dfb5b6e 100644 --- a/.github/workflows/specgit-accept.yml +++ b/.github/workflows/specgit-accept.yml @@ -2,12 +2,7 @@ name: SpecGit Acceptance on: pull_request: - # Delivery PRs target dev (fast-integration layer) and are promoted to - # main via the release PR — main's legacy branch protection also requires - # the SpecGit Acceptance check, so the verdict must run on both targets. - # dev→main promotion stays governed by the protect-main Ruleset's four - # required checks. - branches: [dev, main] + branches: [main] permissions: contents: read @@ -16,10 +11,7 @@ jobs: specgit-acceptance: name: SpecGit Acceptance runs-on: ubuntu-latest - # Must exceed the slowest required sibling (Unit Tests (linux) runs - # ~28min on PRs): the verdict waits for every policy check to reach a - # terminal state before evaluating. - timeout-minutes: 45 + timeout-minutes: 15 steps: - name: Checkout code uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -31,18 +23,20 @@ jobs: fetch-depth: 0 persist-credentials: false + - name: Setup pnpm + uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6 + - name: Setup Node.js uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: - node-version: '22' + node-version: '20.19.0' + cache: 'pnpm' + + - name: Install dependencies + run: pnpm install --frozen-lockfile - # This repo is a bun workspace and does not vendor the SpecGit CLI; - # install the published CLI instead of building from source. Pinned - # with a caret floor (#366): the CLI releases multiple times a day and - # an unpinned install would let an unnoticed upstream change flip CI - # acceptance verdicts repo-wide. - - name: Install specgit CLI - run: npm install -g specgit@^0.5.0 + - name: Build CLI + run: pnpm run build - name: Wait for sibling checks # The verdict must see the OTHER required checks in a terminal @@ -57,11 +51,9 @@ jobs: run: | node --input-type=module <<'EOF' import { readFileSync } from 'node:fs'; - // Minimal parse of policy.yaml's required_checks block list — - // avoids a yaml dependency in this bun-based repo. - const policy = readFileSync('spec_git/policy.yaml', 'utf8'); - const section = policy.slice(policy.indexOf('required_checks:')); - const required = [...section.matchAll(/^\s*-\s*(.+)$/gm)].map((m) => m[1].trim()); + import { parse } from 'yaml'; + const policy = parse(readFileSync('spec_git/policy.yaml', 'utf8')); + const required = policy.required_checks ?? []; const headers = { authorization: 'Bearer ' + process.env.GH_TOKEN, accept: 'application/vnd.github+json', @@ -74,9 +66,7 @@ jobs: const retried = [...byName.keys()].find((k) => k.startsWith(name + ' (')); return retried !== undefined && terminal.has(byName.get(retried)); }; - // Must outlast the slowest required sibling (Unit Tests (linux) - // runs ~28min on PRs); the job timeout above bounds this too. - const deadline = Date.now() + 40 * 60 * 1000; + const deadline = Date.now() + 15 * 60 * 1000; while (Date.now() < deadline) { const res = await fetch(url, { headers }); if (!res.ok) throw new Error('check-runs API ' + res.status); @@ -95,6 +85,6 @@ jobs: EOF - name: specgit finish - run: specgit finish --json + run: node bin/specgit.js finish --json env: GH_TOKEN: ${{ github.token }} diff --git a/.gitignore b/.gitignore index 9df3f7ff3..1ede19fc3 100644 --- a/.gitignore +++ b/.gitignore @@ -52,3 +52,4 @@ tsconfig.tsbuildinfo .opencode/commands/ .opencode/skills .qoder +.opencode/workflow-reports/ diff --git a/.specgit.yaml b/.specgit.yaml index 4d59cdf94..5c6d893ac 100644 --- a/.specgit.yaml +++ b/.specgit.yaml @@ -1,8 +1,8 @@ version: 1 -delivery: reduce-dag-auto +delivery: issue389 context: kind: branch - branch: refactor/392-reduce-dag-auto + branch: feat/todo-step-reminders issues: - - 392 -pr: 393 + - 389 +pr: 394 diff --git a/AGENTS.md b/AGENTS.md index 1467e4c43..ee8e1066f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -265,6 +265,19 @@ re-init; keep manual guidance outside them. origin. `specgit doctor` probes git, repository, origin, gh, and policy. +### Before creating an issue, check for duplicates + +- Before running `specgit issue` with a new title, search the tracker for + similar open work: `gh issue list` with keywords from the title + (state, labels, and search terms via `gh search issues`). +- Open and read every plausible candidate (`gh issue view `) — compare + the WHY, not just the wording. +- If a candidate covers the same WHY, continue that issue instead of + creating a new one; if it is close but different, say how they differ. +- When unsure, ask the requester to decide between continuing the existing + issue and creating a duplicate. The team ships one line of work per WHY, + never two. + ### Issue granularity One issue = one independently verifiable WHY. If a deliverable cannot be diff --git a/CLAUDE.md b/CLAUDE.md index 04434d780..27e995774 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -200,6 +200,19 @@ re-init; keep manual guidance outside them. origin. `specgit doctor` probes git, repository, origin, gh, and policy. +### Before creating an issue, check for duplicates + +- Before running `specgit issue` with a new title, search the tracker for + similar open work: `gh issue list` with keywords from the title + (state, labels, and search terms via `gh search issues`). +- Open and read every plausible candidate (`gh issue view `) — compare + the WHY, not just the wording. +- If a candidate covers the same WHY, continue that issue instead of + creating a new one; if it is close but different, say how they differ. +- When unsure, ask the requester to decide between continuing the existing + issue and creating a duplicate. The team ships one line of work per WHY, + never two. + ### Issue granularity One issue = one independently verifiable WHY. If a deliverable cannot be diff --git a/packages/opencode/script/release-notes.ts b/packages/opencode/script/release-notes.ts new file mode 100644 index 000000000..9f32aebeb --- /dev/null +++ b/packages/opencode/script/release-notes.ts @@ -0,0 +1,245 @@ +import { basename, join } from "node:path" + +const tagPrefix = "graphagent-v" +const versionPattern = /^(\d+\.\d+\.\d+)(?:-dev\.\d+)?$/ +const channelLinePattern = /^(Prerelease|Stable) release from `(dev|main)` branch\. \S/ +const asciiLinePattern = /^[\x00-\x7F]*$/ + +// Headings must equal .github/RELEASE_NOTES_TEMPLATE.md codepoint-for-codepoint: +// 🏗️ and ⚙️ carry a U+FE0F variation selector that plain-text editing strips. +export const canonicalHeadings: readonly string[] = [ + "### 🎯 Features", + "### 🐛 Bug Fixes", + "### 🏗️ Architecture / Refactor", + "### ⚙️ CI / Engineering", + "### 📦 Dependencies / Tooling", + "### 🧪 Test Summary", + "### 🔍 Verification", +] +const changeHeadings = canonicalHeadings.slice(0, 5) +const testSummaryHeading = canonicalHeadings[5] +const verificationHeading = canonicalHeadings[6] + +export type ReleaseNotesInput = { + version: string + channel: "main" | "dev" + branch: string + tag: string + previousTag: string + repo: string +} + +export class ReleaseNotesError extends Error { + constructor(message: string) { + super(`[release-notes] ${message}`) + this.name = "ReleaseNotesError" + } +} + +export function seriesFor(version: string): string { + const match = versionPattern.exec(version) + if (!match) throw new ReleaseNotesError(`malformed version "${version}" (expected X.Y.Z or X.Y.Z-dev.N)`) + return match[1] +} + +export function seriesFileFor(version: string): string { + return `.github/releases/v${seriesFor(version)}.md` +} + +function channelWord(channel: "main" | "dev") { + return channel === "main" ? "Stable" : "Prerelease" +} + +export function renderPlaceholders(source: string, input: ReleaseNotesInput): string { + return source + .replaceAll("{VERSION}", input.version) + .replaceAll("{Prerelease/Stable}", channelWord(input.channel)) + .replaceAll("{branch}", input.branch) + .replaceAll("{previous_tag}", input.previousTag) + .replaceAll("{current_tag}", input.tag) +} + +export function expectedFinalLine(input: ReleaseNotesInput): string { + return `**Full changelog:** [\`${input.previousTag}\`...\`${input.tag}\`](https://github.com/${input.repo}/compare/${input.previousTag}...${input.tag})` +} + +export function validateAndRender(source: string, input: ReleaseNotesInput): string { + const rendered = renderPlaceholders(source, input) + const lines = rendered.split(/\r?\n/) + const nonBlank = lines.filter((line) => line.trim().length > 0) + + if (nonBlank[0] !== `## opencode ${input.version}`) + throw new ReleaseNotesError(`first line must be "## opencode ${input.version}", found ${quote(nonBlank[0])}`) + + const channelMatch = channelLinePattern.exec(nonBlank[1] ?? "") + if (!channelMatch || channelMatch[1] !== channelWord(input.channel) || channelMatch[2] !== input.branch) + throw new ReleaseNotesError( + `second line must be "${channelWord(input.channel)} release from \`${input.branch}\` branch. ", found ${quote(nonBlank[1])}`, + ) + + const headings = lines.filter((line) => line.startsWith("### ")) + for (const heading of headings) { + if (!canonicalHeadings.includes(heading)) + throw new ReleaseNotesError(`unknown section heading ${quote(heading)} — must equal a template heading codepoint-for-codepoint`) + } + let previousIndex = -1 + for (const heading of headings) { + const index = canonicalHeadings.indexOf(heading) + if (index <= previousIndex) + throw new ReleaseNotesError(`section headings must follow template order without duplicates, violated at ${quote(heading)}`) + previousIndex = index + } + + if (!headings.includes(testSummaryHeading)) throw new ReleaseNotesError(`missing required section ${quote(testSummaryHeading)}`) + if (!headings.includes(verificationHeading)) throw new ReleaseNotesError(`missing required section ${quote(verificationHeading)}`) + + const blocks = splitBlocks(lines) + const sections = blocks.slice(1, -1).map((block) => { + const content = block.filter((line) => line.trim().length > 0) + return { heading: content[0], body: content.slice(1) } + }) + + if (!sections.some((section) => changeHeadings.includes(section.heading ?? "") && section.body.length > 0)) + throw new ReleaseNotesError( + "no change section with content — at least one of Features, Bug Fixes, Architecture / Refactor, CI / Engineering, Dependencies / Tooling must have a non-empty body", + ) + + for (const section of sections) { + if ((section.heading ?? "").startsWith("### ") && section.body.length === 0) + throw new ReleaseNotesError(`empty section ${quote(section.heading)}`) + } + + const testSection = sections.find((section) => section.heading === testSummaryHeading) + if (testSection && !hasNonEmptyFence(testSection.body)) + throw new ReleaseNotesError("Test Summary must contain at least one fenced code block with non-empty content") + + for (const section of sections) { + if (!section.heading?.startsWith("### ")) + throw new ReleaseNotesError('invalid "---" separator structure: every block between separators must start with a "### " heading') + } + const blockStarts = sections.filter((section) => section.heading?.startsWith("### ")).length + if (blockStarts !== headings.length) + throw new ReleaseNotesError( + `invalid "---" separator structure: ${headings.length} section headings but only ${blockStarts} start their own block — exactly one "---" is required between the intro, between consecutive sections, and before the final changelog line`, + ) + + if (input.tag !== `${tagPrefix}${input.version}`) + throw new ReleaseNotesError(`tag must be "${tagPrefix}${input.version}" for version ${input.version}, received "${input.tag}"`) + if (input.previousTag.length === 0) + throw new ReleaseNotesError("previousTag is empty — no stable graphagent-v* tag exists, so no changelog range can be rendered") + const expected = expectedFinalLine(input) + const finalLines = (blocks[blocks.length - 1] ?? []).filter((line) => line.trim().length > 0) + if (finalLines.length !== 1 || finalLines[0] !== expected) + throw new ReleaseNotesError(`final line must be ${quote(expected)}, alone after the last "---" separator`) + + if (rendered.includes("{") || rendered.includes("}")) { + const residual = nonBlank.filter((line) => line.includes("{") || line.includes("}")).slice(0, 3) + throw new ReleaseNotesError(`unresolved "{" or "}" placeholders remain: ${residual.map(quote).join(" ")}`) + } + + for (const line of lines) { + if (!asciiLinePattern.test(line) && !canonicalHeadings.includes(line)) + throw new ReleaseNotesError(`non-ASCII line outside the canonical emoji headings: ${quote(line)}`) + } + + return rendered +} + +function splitBlocks(lines: readonly string[]) { + const blocks: string[][] = [] + let current: string[] = [] + for (const line of lines) { + if (line.trim() === "---") { + blocks.push(current) + current = [] + } else { + current.push(line) + } + } + blocks.push(current) + return blocks +} + +function hasNonEmptyFence(body: readonly string[]) { + let open = false + let content = false + for (const line of body) { + if (line.trim().startsWith("```")) { + if (open && content) return true + open = !open + content = false + } else if (open && line.trim().length > 0) { + content = true + } + } + return false +} + +function quote(value: string | undefined) { + return JSON.stringify(value ?? "(empty)") +} + +function readArgs(argv: readonly string[]) { + const args: Record = {} + for (let i = 0; i < argv.length; i += 2) { + const key = argv[i] + if (key === undefined || !key.startsWith("--")) throw new ReleaseNotesError(`unexpected argument "${key}" (expected --key value pairs)`) + const value = argv[i + 1] + if (value === undefined) throw new ReleaseNotesError(`missing value for ${key}`) + args[key.slice(2)] = value + } + return args +} + +function requireArg(args: Record, name: string) { + const value = args[name] + if (value === undefined) throw new ReleaseNotesError(`--${name} is required`) + return value +} + +function resolveFile(args: Record, version: string) { + const fileName = `v${seriesFor(version)}.md` + if (args.file !== undefined && args["notes-dir"] !== undefined) + throw new ReleaseNotesError("pass either --file or --notes-dir, not both") + if (args.file !== undefined) { + if (basename(args.file) !== fileName) + throw new ReleaseNotesError(`series mismatch: --file ${args.file} must be named ${fileName} for version ${version}`) + return args.file + } + if (args["notes-dir"] !== undefined) return join(args["notes-dir"], fileName) + throw new ReleaseNotesError(`--file or --notes-dir is required (expected series file ${seriesFileFor(version)})`) +} + +async function main() { + const args = readArgs(process.argv.slice(2)) + const version = requireArg(args, "version") + const channel = requireArg(args, "channel") + if (channel !== "main" && channel !== "dev") + throw new ReleaseNotesError(`--channel must be "main" or "dev", received "${channel}"`) + const file = resolveFile(args, version) + if (!(await Bun.file(file).exists())) + throw new ReleaseNotesError( + `missing series file ${file} — create ${seriesFileFor(version)} (relative to the repo root) for this release series`, + ) + const input: ReleaseNotesInput = { + version, + channel, + branch: requireArg(args, "branch"), + tag: requireArg(args, "tag"), + previousTag: requireArg(args, "previous-tag"), + repo: requireArg(args, "repo"), + } + const rendered = validateAndRender(await Bun.file(file).text(), input) + const out = requireArg(args, "out") + await Bun.write(out, rendered) + console.log(`Release notes rendered for ${input.tag} -> ${out}`) +} + +if (import.meta.main) { + try { + await main() + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)) + process.exit(1) + } +} diff --git a/packages/opencode/script/release-version.ts b/packages/opencode/script/release-version.ts index 5f8cddecb..75bb17c3b 100644 --- a/packages/opencode/script/release-version.ts +++ b/packages/opencode/script/release-version.ts @@ -13,12 +13,13 @@ export function resolveReleaseVersion(input: { branch: string; tags: string[] }) const latest = input.tags .flatMap((tag) => { const version = parseStableTag(tag) - return version ? [version] : [] + return version ? [{ tag, version }] : [] }) - .toSorted(compareVersion) + .toSorted((left, right) => compareVersion(left.version, right.version)) .at(-1) - const target = nextVersion(latest) + const target = nextVersion(latest?.version) const base = target.join(".") + const previousTag = latest?.tag ?? "" if (channel === "main") { return { @@ -27,6 +28,7 @@ export function resolveReleaseVersion(input: { branch: string; tags: string[] }) tag: `${tagPrefix}${base}`, prerelease: false, latest: true, + previous_tag: previousTag, } } @@ -45,6 +47,7 @@ export function resolveReleaseVersion(input: { branch: string; tags: string[] }) tag: `${tagPrefix}${version}`, prerelease: true, latest: false, + previous_tag: previousTag, } } @@ -107,6 +110,7 @@ async function main() { `tag=${release.tag}`, `prerelease=${release.prerelease}`, `latest=${release.latest}`, + `previous_tag=${release.previous_tag}`, "", ].join("\n"), ) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index e6d30bfe0..2139e4412 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -58,6 +58,8 @@ import * as DateTime from "effect/DateTime" import { eq } from "drizzle-orm" import { SessionTable } from "@opencode-ai/core/session/sql" import { SessionReminders } from "./reminders" +import { Todo } from "./todo" +import { TodoReminders } from "./todo-reminders" import { SessionTools } from "./tools" import { LLMEvent } from "@opencode-ai/llm" import { SettingsHook, HOOK_REWAKE_SENTINEL, type TriggerResult } from "@/hook/settings" @@ -149,6 +151,7 @@ export const layer = Layer.effect( const registry = yield* ToolRegistry.Service const truncate = yield* Truncate.Service const image = yield* Image.Service + const todoSvc = yield* Todo.Service const spawner = yield* ChildProcessSpawner.ChildProcessSpawner const scope = yield* Scope.Scope const instruction = yield* Instruction.Service @@ -1684,6 +1687,12 @@ export const layer = Layer.effect( Effect.provideService(FSUtil.Service, fsys), Effect.provideService(Session.Service, sessions), ) + // Issue #389: re-surface uncompleted todos once per model step + // (in-memory synthetic part, skipped when all settled or when the + // turn just updated the list via todowrite). + msgs = yield* TodoReminders.apply({ messages: msgs, sessionID }).pipe( + Effect.provideService(Todo.Service, todoSvc), + ) const msg: SessionV1.Assistant = { id: MessageID.ascending(), @@ -2183,6 +2192,7 @@ export const defaultLayer = Layer.suspend(() => RuntimeFlags.defaultLayer, EventV2Bridge.defaultLayer, HookStartContext.defaultLayer, + Todo.defaultLayer, ), ), ), @@ -2338,6 +2348,7 @@ export const node = LayerNode.make(layer, [ RuntimeFlags.node, Database.node, Memory.node, + Todo.node, HookStartContext.node, SettingsHook.node, Goal.node, ]) diff --git a/packages/opencode/src/session/todo-reminders.ts b/packages/opencode/src/session/todo-reminders.ts new file mode 100644 index 000000000..926528813 --- /dev/null +++ b/packages/opencode/src/session/todo-reminders.ts @@ -0,0 +1,70 @@ +// SPDX-FileCopyrightText: 2026 LeXwDeX +// SPDX-License-Identifier: AGPL-3.0-or-later + +/** + * Per-step todo stale-state reminder (issue #389). + * + * Todo lists drift silently: nothing re-surfaces the list once written, so + * completed work stays pending and stale items linger. While a session holds + * uncompleted todos, every model step appends ONE synthetic text part with the + * current uncompleted items to the last user message — model-visible, never + * persisted (same in-memory convention as SessionReminders' non-plan-mode + * parts; its plan-mode branch persists instead, which is NOT the pattern + * here). + * + * Skip conditions: + * - no todos for the session, or nothing uncompleted (completed and + * cancelled both count as settled) + * - freshness guard: the session's last assistant message already contains + * a successful todowrite call — the model just updated the list itself, + * so this step's request does not nag about it + */ +import { Effect } from "effect" +import { SessionV1 } from "@opencode-ai/core/v1/session" +import type { SessionID } from "./schema" +import { PartID } from "./schema" +import { Todo } from "./todo" + +const TODO_WRITE_TOOL = "todowrite" + +function turnJustUpdatedTodos(messages: SessionV1.WithParts[]): boolean { + const lastAssistant = messages.findLast((msg) => msg.info.role === "assistant") + if (!lastAssistant) return false + return lastAssistant.parts.some( + (part): part is SessionV1.ToolPart => + part.type === "tool" && part.tool === TODO_WRITE_TOOL && part.state.status === "completed", + ) +} + +function renderReminder(uncompleted: Todo.Info[]): string { + const lines = uncompleted.map((todo) => `- ${todo.status}: ${todo.content}`) + return [ + `[todo reminder] ${uncompleted.length} uncompleted todo item${uncompleted.length === 1 ? "" : "s"}:`, + ...lines, + "Keep the list current: mark items completed when done, adjust stale entries, and set in_progress only for the item you are actively working on. Update via todowrite.", + ].join("\n") +} + +export const apply = Effect.fn("TodoReminders.apply")(function* (input: { + messages: SessionV1.WithParts[] + sessionID: SessionID +}) { + const todo = yield* Todo.Service + const todos = yield* todo.get(input.sessionID) + const uncompleted = todos.filter((item) => item.status !== "completed" && item.status !== "cancelled") + if (uncompleted.length === 0) return input.messages + const userMessage = input.messages.findLast((msg) => msg.info.role === "user") + if (!userMessage) return input.messages + if (turnJustUpdatedTodos(input.messages)) return input.messages + userMessage.parts.push({ + id: PartID.ascending(), + messageID: userMessage.info.id, + sessionID: input.sessionID, + type: "text", + text: renderReminder(uncompleted), + synthetic: true, + } satisfies SessionV1.TextPart) + return input.messages +}) + +export * as TodoReminders from "./todo-reminders" diff --git a/packages/opencode/test/release-notes.test.ts b/packages/opencode/test/release-notes.test.ts new file mode 100644 index 000000000..0af1da057 --- /dev/null +++ b/packages/opencode/test/release-notes.test.ts @@ -0,0 +1,305 @@ +import { describe, expect, test } from "bun:test" +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises" +import os from "node:os" +import path from "node:path" +import { seriesFileFor, validateAndRender } from "../script/release-notes" + +// Fixtures derive their headings from the live template so that editing a +// template emoji, order, or heading count changes test outcome instead of +// silently drifting away from the grammar the validator enforces. +const template = await Bun.file(new URL("../../../.github/RELEASE_NOTES_TEMPLATE.md", import.meta.url)).text() +const headings = template.split(/\r?\n/).filter((line) => line.startsWith("### ")) + +const input = { + version: "1.0.10", + channel: "main", + branch: "main", + tag: "graphagent-v1.0.10", + previousTag: "graphagent-v1.0.9", + repo: "LeXwDeX/OpenCode-GraphAgent", +} as const + +const devInput = { + version: "1.0.10-dev.3", + channel: "dev", + branch: "dev", + tag: "graphagent-v1.0.10-dev.3", + previousTag: "graphagent-v1.0.9", + repo: input.repo, +} as const + +type Section = { heading: string; body: string } + +const defaultIntro = "{Prerelease/Stable} release from `{branch}` branch. Ships the fail-closed release notes harness." + +function changelogLine(repo: string) { + return `**Full changelog:** [\`{previous_tag}\`...\`{current_tag}\`](https://github.com/${repo}/compare/{previous_tag}...{current_tag})` +} + +function expectedFinalLine(source: { previousTag: string; tag: string; repo: string }) { + return `**Full changelog:** [\`${source.previousTag}\`...\`${source.tag}\`](https://github.com/${source.repo}/compare/${source.previousTag}...${source.tag})` +} + +function defaultSections(): Section[] { + return [ + { heading: headings[0], body: "- **Notes harness**: Series files render through a validator that fails closed on every rule." }, + { heading: headings[1], body: "- **Placeholder notes**: Releases no longer publish a placeholder body." }, + { heading: headings[2], body: "- **Renderer**: One script renders and validates the series file before the release exists." }, + { heading: headings[3], body: "- The release job renders notes from the committed series file before creating the release." }, + { heading: headings[4], body: "- No dependency changes in this series." }, + { + heading: headings[5], + body: "```\nrelease-notes: 12 pass\ntotal: 12 tests, 0 failures\ntypecheck: 1/1 packages green\n```", + }, + { heading: headings[6], body: "Rendered with bun test from packages/opencode and mutation-checked rule by rule." }, + ] +} + +function buildSource(options: { sections?: Section[]; intro?: string; title?: string; finalLine?: string }) { + return [ + options.title ?? "## opencode {VERSION}", + "", + options.intro ?? defaultIntro, + "", + "---", + "", + ...(options.sections ?? defaultSections()).flatMap((section) => [section.heading, "", section.body, "", "---", ""]), + options.finalLine ?? changelogLine(input.repo), + ].join("\n") +} + +describe("release notes series resolution", () => { + test("maps both channels of one series to the same series file", () => { + expect(seriesFileFor("1.0.10")).toBe(".github/releases/v1.0.10.md") + expect(seriesFileFor("1.0.10-dev.3")).toBe(".github/releases/v1.0.10.md") + }) + + test("rejects malformed versions", () => { + expect(() => seriesFileFor("main")).toThrow() + expect(() => seriesFileFor("1.0")).toThrow() + expect(() => seriesFileFor("")).toThrow() + }) +}) + +describe("release notes rendering", () => { + test("renders a valid stable series file", () => { + const rendered = validateAndRender(buildSource({}), input) + + expect(rendered).toContain("## opencode 1.0.10") + expect(rendered).toContain("Stable release from `main` branch.") + expect(rendered).not.toContain("{") + expect(rendered).not.toContain("}") + expect(rendered.endsWith(expectedFinalLine(input))).toBe(true) + }) + + test("renders the same series file for a dev prerelease of that series", () => { + const rendered = validateAndRender(buildSource({}), devInput) + + expect(rendered).toContain("## opencode 1.0.10-dev.3") + expect(rendered).toContain("Prerelease release from `dev` branch.") + expect(rendered.endsWith(expectedFinalLine(devInput))).toBe(true) + expect(rendered).not.toContain("{") + }) +}) + +describe("release notes grammar (fail closed)", () => { + test("rejects a title that does not match the released version", () => { + expect(() => validateAndRender(buildSource({ title: "## opencode 9.9.9" }), input)).toThrow("[release-notes]") + }) + + test("rejects a hardcoded channel or branch word in the intro", () => { + const stableWord = "Stable release from `dev` branch. Ships the harness." + expect(() => validateAndRender(buildSource({ intro: stableWord }), devInput)).toThrow("[release-notes]") + const mainBranch = "Prerelease release from `main` branch. Ships the harness." + expect(() => validateAndRender(buildSource({ intro: mainBranch }), devInput)).toThrow("[release-notes]") + }) + + test("rejects unknown, de-variated, duplicated, or reordered headings", () => { + const sections = defaultSections() + const unknown = sections.toSpliced(0, 1, { heading: "### 🎁 Gifts", body: sections[0].body }) + expect(() => validateAndRender(buildSource({ sections: unknown }), input)).toThrow("[release-notes]") + + for (const index of [2, 3]) { + const devariated = sections.toSpliced(index, 1, { + heading: headings[index].replaceAll("\uFE0F", ""), + body: sections[index].body, + }) + expect(() => validateAndRender(buildSource({ sections: devariated }), input)).toThrow("[release-notes]") + } + + const duplicated = [sections[0], sections[0], ...sections.slice(1)] + expect(() => validateAndRender(buildSource({ sections: duplicated }), input)).toThrow("[release-notes]") + + const reordered = [sections[1], sections[0], ...sections.slice(2)] + expect(() => validateAndRender(buildSource({ sections: reordered }), input)).toThrow("[release-notes]") + }) + + test("rejects a missing Test Summary or Verification section", () => { + expect(() => validateAndRender(buildSource({ sections: defaultSections().toSpliced(5, 1) }), input)).toThrow( + "[release-notes]", + ) + expect(() => validateAndRender(buildSource({ sections: defaultSections().toSpliced(6, 1) }), input)).toThrow( + "[release-notes]", + ) + }) + + test("rejects a file with no change section at all", () => { + expect(() => validateAndRender(buildSource({ sections: defaultSections().slice(5) }), input)).toThrow( + "[release-notes]", + ) + }) + + test("rejects an empty section body", () => { + const emptied = defaultSections().toSpliced(0, 1, { heading: headings[0], body: "" }) + expect(() => validateAndRender(buildSource({ sections: emptied }), input)).toThrow("[release-notes]") + }) + + test("rejects a Test Summary without a non-empty fenced block", () => { + const plain = defaultSections().toSpliced(5, 1, { heading: headings[5], body: "All 12 tests passed." }) + expect(() => validateAndRender(buildSource({ sections: plain }), input)).toThrow("[release-notes]") + const emptyFence = defaultSections().toSpliced(5, 1, { heading: headings[5], body: "```\n```" }) + expect(() => validateAndRender(buildSource({ sections: emptyFence }), input)).toThrow("[release-notes]") + }) + + test("rejects doubled, missing, or stray --- separators", () => { + const source = buildSource({}) + const doubled = source.replace(`---\n\n${headings[0]}`, `---\n\n---\n\n${headings[0]}`) + expect(() => validateAndRender(doubled, input)).toThrow("[release-notes]") + const missing = source.replace(`\n\n---\n\n${headings[0]}`, `\n\n${headings[0]}`) + expect(() => validateAndRender(missing, input)).toThrow("[release-notes]") + const stray = defaultSections().toSpliced(1, 1, { heading: headings[1], body: "- One fix.\n---\n- Another fix." }) + expect(() => validateAndRender(buildSource({ sections: stray }), input)).toThrow("[release-notes]") + }) + + test("rejects a wrong final changelog line", () => { + const bareRange = "**Full changelog:** `{previous_tag}...{current_tag}`" + expect(() => validateAndRender(buildSource({ finalLine: bareRange }), input)).toThrow("[release-notes]") + const wrongRepo = changelogLine("some-other/repo") + expect(() => validateAndRender(buildSource({ finalLine: wrongRepo }), input)).toThrow("[release-notes]") + const wrongTag = expectedFinalLine(input).replaceAll(input.tag, "graphagent-v1.0.11") + expect(() => validateAndRender(buildSource({ finalLine: wrongTag }), input)).toThrow("[release-notes]") + expect(() => validateAndRender(buildSource({ finalLine: "That is all." }), input)).toThrow("[release-notes]") + }) + + test("rejects an empty previous tag or a tag that does not match the version", () => { + expect(() => validateAndRender(buildSource({}), { ...input, previousTag: "" })).toThrow("[release-notes]") + expect(() => validateAndRender(buildSource({}), { ...input, tag: "graphagent-v9.9.9" })).toThrow("[release-notes]") + }) + + test("rejects residual placeholders anywhere in the rendered notes", () => { + const residual = defaultSections().toSpliced(0, 1, { + heading: headings[0], + body: "- **Notes harness**: Uses {summary} to describe the change.", + }) + expect(() => validateAndRender(buildSource({ sections: residual }), input)).toThrow("[release-notes]") + }) + + test("rejects non-ASCII prose outside the emoji headings", () => { + const accented = defaultSections().toSpliced(0, 1, { heading: headings[0], body: "- Adds café support." }) + expect(() => validateAndRender(buildSource({ sections: accented }), input)).toThrow("[release-notes]") + const emDash = "Stable release from `main` branch. Adds rich text — with an em dash." + expect(() => validateAndRender(buildSource({ intro: emDash }), input)).toThrow("[release-notes]") + }) +}) + +const notesScript = path.resolve(import.meta.dir, "../script/release-notes.ts") + +async function runNotesScript(options: { dir: string; out: string; version?: string }) { + const version = options.version ?? input.version + const child = Bun.spawn( + [ + "bun", + "run", + notesScript, + "--notes-dir", + ".github/releases", + "--version", + version, + "--channel", + input.channel, + "--branch", + input.branch, + "--tag", + `graphagent-v${version}`, + "--previous-tag", + input.previousTag, + "--repo", + input.repo, + "--out", + options.out, + ], + { cwd: options.dir, stdout: "pipe", stderr: "pipe" }, + ) + const [stdout, stderr, exitCode] = await Promise.all([ + new Response(child.stdout).text(), + new Response(child.stderr).text(), + child.exited, + ]) + return { stdout, stderr, exitCode } +} + +describe("release notes CLI", () => { + test("fails closed when the series file is missing or from another series", async () => { + const dir = await mkdtemp(path.join(os.tmpdir(), "release-notes-")) + try { + const out = path.join(dir, "RELEASE_NOTES.md") + + const missing = await runNotesScript({ dir, out }) + expect(missing.exitCode).not.toBe(0) + expect(missing.stderr).toContain("[release-notes]") + expect(missing.stderr).toContain("v1.0.10.md") + + await mkdir(path.join(dir, ".github/releases"), { recursive: true }) + await writeFile(path.join(dir, ".github/releases/v1.0.10.md"), buildSource({})) + const wrongSeries = await runNotesScript({ dir, out, version: "1.0.11" }) + expect(wrongSeries.exitCode).not.toBe(0) + expect(wrongSeries.stderr).toContain("[release-notes]") + expect(wrongSeries.stderr).toContain("v1.0.11.md") + + expect(await Bun.file(out).exists()).toBe(false) + } finally { + await rm(dir, { recursive: true, force: true }) + } + }) + + test("renders the series file to --out only after validation succeeds", async () => { + const dir = await mkdtemp(path.join(os.tmpdir(), "release-notes-")) + try { + await mkdir(path.join(dir, ".github/releases"), { recursive: true }) + await writeFile(path.join(dir, ".github/releases/v1.0.10.md"), buildSource({})) + const out = path.join(dir, "RELEASE_NOTES.md") + + const result = await runNotesScript({ dir, out }) + + expect(result.exitCode).toBe(0) + expect((await Bun.file(out).text()).trim()).toBe(validateAndRender(buildSource({}), input).trim()) + } finally { + await rm(dir, { recursive: true, force: true }) + } + }) +}) + +describe("release notes template and workflow wiring", () => { + test("template stays the grammar authority the fixtures are derived from", () => { + expect(headings).toHaveLength(7) + expect(new Set(headings).size).toBe(7) + expect(template).toContain("**Full changelog:** [`{previous_tag}`...`{current_tag}`](https://github.com/") + expect(template).toContain("/compare/{previous_tag}...{current_tag})") + }) + + test("release job renders and validates notes before gh release create", async () => { + const workflow = await Bun.file(new URL("../../../.github/workflows/release-fork.yml", import.meta.url)).text() + const releaseJob = workflow.slice(workflow.indexOf("\n release:")) + + expect(workflow).not.toContain('--notes "GraphAgent release from branch') + expect(releaseJob).toContain("script/release-notes.ts") + expect(releaseJob).toContain('--notes-dir ".github/releases"') + expect(releaseJob).toContain("--previous-tag") + expect(releaseJob).toContain("needs.version.outputs.previous_tag") + expect(releaseJob).toContain('--out "$RUNNER_TEMP/RELEASE_NOTES.md"') + expect(releaseJob.indexOf("release-notes.ts")).toBeLessThan(releaseJob.indexOf("gh release create")) + expect(releaseJob).toContain('gh release create "${{ needs.version.outputs.tag }}"') + expect(releaseJob).toContain('--notes-file "$RUNNER_TEMP/RELEASE_NOTES.md"') + expect(releaseJob).toContain("./.github/actions/setup-bun") + }) +}) diff --git a/packages/opencode/test/release-version.test.ts b/packages/opencode/test/release-version.test.ts index e0542d9ad..1486b03f2 100644 --- a/packages/opencode/test/release-version.test.ts +++ b/packages/opencode/test/release-version.test.ts @@ -9,6 +9,7 @@ describe("GraphAgent release versions", () => { tag: "graphagent-v1.0.0", prerelease: false, latest: true, + previous_tag: "", }) }) @@ -24,6 +25,7 @@ describe("GraphAgent release versions", () => { tag: "graphagent-v1.0.0-dev.1", prerelease: true, latest: false, + previous_tag: "", }) }) @@ -53,6 +55,26 @@ describe("GraphAgent release versions", () => { ) }) + test("seeds previous_tag from the latest stable tag for both channels", () => { + expect(resolveReleaseVersion({ branch: "dev", tags: ["graphagent-v1.0.8", "graphagent-v1.0.9"] })).toEqual({ + channel: "dev", + version: "1.0.10-dev.1", + tag: "graphagent-v1.0.10-dev.1", + prerelease: true, + latest: false, + previous_tag: "graphagent-v1.0.9", + }) + expect( + resolveReleaseVersion({ branch: "main", tags: ["graphagent-v1.0.8", "graphagent-v1.0.9"] }).previous_tag, + ).toBe("graphagent-v1.0.9") + }) + + test("keeps previous_tag on the last stable across the dev series", () => { + expect( + resolveReleaseVersion({ branch: "dev", tags: ["graphagent-v1.0.9", "graphagent-v1.0.10-dev.2"] }).previous_tag, + ).toBe("graphagent-v1.0.9") + }) + test("wires one resolved version into both the build and GitHub Release", async () => { const workflow = await Bun.file(new URL("../../../.github/workflows/release-fork.yml", import.meta.url)).text() @@ -62,5 +84,7 @@ describe("GraphAgent release versions", () => { expect(workflow).toContain("OPENCODE_VERSION: ${{ needs.version.outputs.version }}") expect(workflow).toContain('gh release create "${{ needs.version.outputs.tag }}"') expect(workflow).toContain("--prerelease --latest=false") + expect(workflow).toContain("previous_tag: ${{ steps.release-version.outputs.previous_tag }}") + expect(workflow).toContain("needs.version.outputs.previous_tag") }) }) diff --git a/packages/opencode/test/session/todo-reminders.test.ts b/packages/opencode/test/session/todo-reminders.test.ts new file mode 100644 index 000000000..0d5c8fb9e --- /dev/null +++ b/packages/opencode/test/session/todo-reminders.test.ts @@ -0,0 +1,295 @@ +// oxlint-disable typescript-eslint/no-unsafe-type-assertion -- fixtures +// mirror dag-wake-integration.test.ts: message/part fixtures use `as never` +// shims implementing only the slice the scenario exercises. Type-only. +// SPDX-FileCopyrightText: 2026 LeXwDeX +// SPDX-License-Identifier: AGPL-3.0-or-later + +/** + * Issue #389 — per-step todo stale-state reminder. + * + * While a session holds uncompleted todos, every model step (including + * tool-free steps) re-surfaces the current list as ONE synthetic part on the + * last user message — model-visible, never persisted. Skip conditions: + * - no todos for the session + * - nothing uncompleted (completed and cancelled both count as settled) + * - freshness guard: the current turn's last assistant message already + * contains a successful todowrite call (the model just updated the list) + */ +import { describe, expect } from "bun:test" +import { Effect, Layer } from "effect" +import type { SessionV1 } from "@opencode-ai/core/v1/session" +import { SessionID, PartID, MessageID } from "@/session/schema" +import { Todo } from "@/session/todo" +import { TodoReminders } from "@/session/todo-reminders" +import { testEffect } from "../lib/effect" + +const runtime = testEffect(Layer.empty) + +function makeTodoLayer(todos: Todo.Info[]) { + return Layer.mock(Todo.Service, { + get: () => Effect.succeed(todos), + }) +} + +let clock = 0 + +function userMessage(text: string): SessionV1.WithParts { + const id = MessageID.ascending() + return { + info: { + id, + role: "user", + sessionID: SessionID.make("ses_1"), + time: { created: clock++ }, + agent: "build", + model: { providerID: "test" as never, modelID: "m" as never }, + }, + parts: [{ + id: PartID.ascending(), + messageID: id, + sessionID: SessionID.make("ses_1"), + type: "text", + text, + }] as never, + } +} + +function assistantMessage( + tools: { name: string; status: string }[] = [], + text?: string, +): SessionV1.WithParts { + const id = MessageID.ascending() + const parts: Record[] = tools.map((t) => ({ + id: PartID.ascending(), + messageID: id, + sessionID: SessionID.make("ses_1"), + type: "tool", + callID: `call-${clock++}`, + tool: t.name, + state: { + status: t.status, + input: {}, + ...(t.status === "completed" ? { output: "", title: "" } : t.status === "error" ? { error: "boom" } : {}), + }, + })) + if (text) { + parts.push({ + id: PartID.ascending(), + messageID: id, + sessionID: SessionID.make("ses_1"), + type: "text", + text, + }) + } + return { + info: { + id, + role: "assistant", + sessionID: SessionID.make("ses_1"), + parentID: MessageID.ascending(), + time: { created: clock++ }, + mode: "build", + agent: "build", + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + modelID: "m" as never, + providerID: "test" as never, + path: { cwd: "/tmp", root: "/tmp" }, + finish: "stop", + }, + parts: parts as never, + } +} + +function lastUser(messages: SessionV1.WithParts[]) { + return messages.findLast((m) => m.info.role === "user") +} + +describe("TodoReminders.apply (issue #389)", () => { + runtime.effect("injects nothing when the session has no todos", () => + Effect.gen(function* () { + const messages = [userMessage("work")] + const result = yield* TodoReminders.apply({ + messages, + sessionID: SessionID.make("ses_1"), + }).pipe(Effect.provide(makeTodoLayer([]))) + expect(result).toBe(messages) + expect(lastUser(result)?.parts).toHaveLength(1) + }), + ) + + runtime.effect("injects nothing when every todo is settled (completed or cancelled)", () => + Effect.gen(function* () { + const messages = [userMessage("work")] + const result = yield* TodoReminders.apply({ + messages, + sessionID: SessionID.make("ses_1"), + }).pipe( + Effect.provide(makeTodoLayer([ + { content: "a", status: "completed", priority: "high" }, + { content: "b", status: "cancelled", priority: "low" }, + ])), + ) + expect(lastUser(result)?.parts).toHaveLength(1) + }), + ) + + runtime.effect("injects exactly one synthetic reminder with uncompleted statuses", () => + Effect.gen(function* () { + const messages = [userMessage("work")] + const result = yield* TodoReminders.apply({ + messages, + sessionID: SessionID.make("ses_1"), + }).pipe( + Effect.provide(makeTodoLayer([ + { content: "implement reminder module", status: "in_progress", priority: "high" }, + { content: "add tests", status: "pending", priority: "high" }, + { content: "shipped", status: "completed", priority: "low" }, + ])), + ) + const last = lastUser(result) + expect(last?.parts).toHaveLength(2) + const reminder = last?.parts.at(-1) as never as { type: string; text: string; synthetic?: boolean } + expect(reminder.type).toBe("text") + expect(reminder.synthetic).toBe(true) + expect(reminder.text).toContain("implement reminder module") + expect(reminder.text).toContain("in_progress") + expect(reminder.text).toContain("add tests") + expect(reminder.text).toContain("pending") + expect(reminder.text).not.toContain("shipped") + expect(reminder.text).toContain("todowrite") + }), + ) + + runtime.effect("freshness guard: skips when the turn's last assistant message contains a successful todowrite", () => + Effect.gen(function* () { + const messages = [ + userMessage("work"), + assistantMessage([{ name: "todowrite", status: "completed" }], "updated"), + ] + const result = yield* TodoReminders.apply({ + messages, + sessionID: SessionID.make("ses_1"), + }).pipe( + Effect.provide(makeTodoLayer([ + { content: "a", status: "pending", priority: "high" }, + ])), + ) + expect(lastUser(result)?.parts).toHaveLength(1) + }), + ) + + runtime.effect("an older todowrite does not suppress the reminder once further steps followed", () => + Effect.gen(function* () { + const messages = [ + userMessage("work"), + assistantMessage([{ name: "todowrite", status: "completed" }]), + assistantMessage([{ name: "read", status: "completed" }], "read the file"), + ] + const result = yield* TodoReminders.apply({ + messages, + sessionID: SessionID.make("ses_1"), + }).pipe( + Effect.provide(makeTodoLayer([ + { content: "a", status: "pending", priority: "high" }, + ])), + ) + expect(lastUser(result)?.parts).toHaveLength(2) + }), + ) + + runtime.effect("a failed todowrite does not satisfy the freshness guard", () => + Effect.gen(function* () { + const messages = [ + userMessage("work"), + assistantMessage([{ name: "todowrite", status: "error" }]), + ] + const result = yield* TodoReminders.apply({ + messages, + sessionID: SessionID.make("ses_1"), + }).pipe( + Effect.provide(makeTodoLayer([ + { content: "a", status: "pending", priority: "high" }, + ])), + ) + expect(lastUser(result)?.parts).toHaveLength(2) + }), + ) +}) + +// The run-loop structural guarantees called out by the PR #391 review (O3): +// apply runs once per model step BEFORE tool resolution — never once per +// tool — and its mutation lives only in that step's in-memory request. +describe("TodoReminders run-loop guarantees (issue #389 review)", () => { + runtime.effect("parallel tools in one step still see exactly one reminder", () => + Effect.gen(function* () { + // The rejected PreToolUse design would have injected once per tool + // call; the run-loop seam injects once per step, so a fan-out of N + // completed tools (none a fresh todowrite) yields ONE reminder. + const messages = [ + userMessage("work"), + assistantMessage([ + { name: "read", status: "completed" }, + { name: "grep", status: "completed" }, + { name: "bash", status: "completed" }, + ], "ran three tools"), + ] + const result = yield* TodoReminders.apply({ + messages, + sessionID: SessionID.make("ses_1"), + }).pipe( + Effect.provide(makeTodoLayer([ + { content: "a", status: "pending", priority: "high" }, + ])), + ) + const last = lastUser(result) + expect(last?.parts).toHaveLength(2) + expect(last?.parts.filter((part) => (part as never as { text?: string }).text?.startsWith("[todo reminder]"))).toHaveLength(1) + }), + ) + + runtime.effect("consecutive steps with fresh per-step reads never accumulate reminders", () => + Effect.gen(function* () { + // The run loop re-derives msgs from the database each step, so the + // synthetic part never persists and never stacks across steps. + const todoLayer = makeTodoLayer([{ content: "a", status: "pending", priority: "high" }]) + const durableBase = [userMessage("work")] + + const step1 = structuredClone(durableBase) + const result1 = yield* TodoReminders.apply({ messages: step1, sessionID: SessionID.make("ses_1") }).pipe( + Effect.provide(todoLayer), + ) + expect(lastUser(result1)?.parts).toHaveLength(2) + + const step2 = structuredClone(durableBase) + const result2 = yield* TodoReminders.apply({ messages: step2, sessionID: SessionID.make("ses_1") }).pipe( + Effect.provide(todoLayer), + ) + expect(lastUser(result2)?.parts).toHaveLength(2) + + // The durable base stays pristine — the injection is per-request only. + expect(lastUser(durableBase)?.parts).toHaveLength(1) + }), + ) + + runtime.effect("a compacted transcript still receives the reminder", () => + Effect.gen(function* () { + // Compaction runs before apply on the per-step fresh read: the + // filtered transcript still ends in a user message, and with no + // assistant carrying a fresh todowrite the reminder must survive. + const messages = [userMessage("compacted summary: prior turns elided, work continues")] + const result = yield* TodoReminders.apply({ + messages, + sessionID: SessionID.make("ses_1"), + }).pipe( + Effect.provide(makeTodoLayer([ + { content: "a", status: "pending", priority: "high" }, + ])), + ) + expect(lastUser(result)?.parts).toHaveLength(2) + const reminder = lastUser(result)?.parts.at(-1) as never as { text: string; synthetic?: boolean } + expect(reminder.synthetic).toBe(true) + expect(reminder.text).toContain("[todo reminder]") + }), + ) +})