From ad75097c758362ecdb444682acbfe174b1640758 Mon Sep 17 00:00:00 2001 From: willytop8 Date: Sat, 29 Aug 2026 12:01:48 -0500 Subject: [PATCH] feat: add opt-in session-title status indicator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An unattended goal run has no continuous signal that it is alive: command hook output is not rendered in the TUI on current OpenCode builds, so the only feedback is whatever the model happens to say. Mirror live goal status into the OpenCode session title, which the TUI renders persistently: ▶ ship the release · 3/10 · 2m · 45k/200k Status icon, objective, auto-continues used/limit, elapsed, and context tokens/budget. Blocked outranks paused in the icon because it needs the user rather than a resume; a paused goal freezes its elapsed clock. Off by default, since it overwrites a user-visible field. When enabled the original title is captured before the first overwrite and restored by `/goal clear`. A render identical to the last one skips the API call, so read-only commands cost nothing, and update failures log at debug level without interrupting the goal loop. Two things this deliberately gets right: - The sync is skipped for `message.updated`, which streams many times per assistant turn. Syncing there would put an API round-trip in the response path for a cosmetic update; idle, compaction, and interruption events already cover every state the indicator renders. - Titles the plugin itself wrote are recognized and never captured as the user's "original". After a hard process kill the session still carries a status line, and capturing it would make `/goal clear` promote stale goal status to the permanent session title. Also documents OpenCode 2 as unsupported and untested — the peer/engine pin is `>=1.17.15 <2` — with a checklist of what a supported claim would require, and records that this plugin is server-only so its configuration lives entirely in opencode.json. Needs no TUI plugin entrypoint, no @opentui peer dependencies, and no build step, preserving the zero-dependency, no-build posture. Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 3 + README.md | 27 +++++ docs/compatibility.md | 50 +++++++++ index.d.ts | 12 +++ src/goal-plugin.js | 172 ++++++++++++++++++++++++++++++ test/goal-plugin.test.js | 223 +++++++++++++++++++++++++++++++++++++++ 6 files changed, 487 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index b2b7d16..77992d6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ ## Unreleased +- Document OpenCode 2 status explicitly: unsupported and untested, with the existing dual-shape session adapter noted and a concrete checklist of what a supported claim would require. Records that this plugin is server-only, so its configuration lives entirely in `opencode.json` on any OpenCode line. +- Add an opt-in session-title status indicator (`sessionTitleStatus`), mirroring live goal status — state icon, objective, turns, elapsed, and tokens — into the OpenCode session title so unattended runs show a continuous heartbeat. The original title is captured before the first overwrite and restored by `/goal clear`; unchanged renders skip the API call, and update failures are logged at debug level without interrupting the goal loop. The indicator refreshes on commands and on idle/compaction/interruption events but never on the `message.updated` events that stream during a turn, keeping API round-trips out of the response path. Status lines written by a previous process are recognized as the plugin's own, so a restart followed by `/goal clear` cannot promote stale goal status to the session's permanent title. + - Hold new goals created while a planning-only agent is active instead of starting them. Previously the idle guard caught a Plan-agent session only on the *next* idle, so `/goal ` in Plan mode created a live goal and the routed command text still told the model to start working. Such a goal is now recorded with stop reason `plan agent active`, keeps its budget, and is announced through a read-only control turn that explicitly tells the model not to begin work. - Add `restrictedAgents` (default `["plan"]`) and `allowGoalExecutionFromPlan` (default `false`) so the planning-only restriction can name other agents or be released entirely. The default-on behavior is pinned by the mutation contract. diff --git a/README.md b/README.md index c9b2171..5c3fee6 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,7 @@ This project is independently implemented for OpenCode. Product names used elsew | Operating systems | Filesystem-sensitive lifecycle tests run on Linux, macOS, and Windows | | Package entrypoint | Installed-tarball contracts verify both export paths, consumer TypeScript resolution, hooks, and all 11 tools | | Provider/backend quirks | Strict-template backends require the goal block to merge into the primary `system` message; covered by regression tests | +| OpenCode 2 | Not supported and not yet tested; the peer/engine pin is `>=1.17.15 <2`. See the [OpenCode 2 section](docs/compatibility.md#opencode-2) | See the [compatibility policy](docs/compatibility.md) for the supported public surface and versioning expectations. @@ -401,6 +402,32 @@ await GoalPlugin( `timeoutMs` caps how long the built-in child-session auditor waits for a verdict. `failurePolicy` defaults to `reject`: an unavailable API, missing child-session ID, provider error, or timeout rejects the audit and pauses the goal for review. Set it to `approve` only as an explicit compatibility escape hatch; an actual negative or malformed verifier verdict still rejects. `auditorOptions` is ignored when a custom `auditor` function is supplied. +## Status indicator + +Unattended runs are easier to trust when you can see the goal is still alive. Set `sessionTitleStatus: true` and the plugin mirrors live goal status into the OpenCode session title, which the TUI renders persistently: + +``` +▶ ship the release · 3/10 · 2m · 45k/200k +``` + +Status icon, objective, auto-continues used / limit, elapsed time, and context tokens / budget. The icon distinguishes running (`▶`), paused (`⏸`), and blocked (`⛔`) — blocked outranks paused because it needs you, not just a resume. A paused goal freezes its elapsed clock rather than running on. + +```json +{ + "plugin": [ + ["opencode-goal-plugin", { "sessionTitleStatus": true }] + ] +} +``` + +The option is **off by default** because it overwrites a user-visible field. When enabled, the session's original title is captured before the first overwrite and restored by `/goal clear`. A render identical to the last one skips the API call, so `/goal status` and other read-only commands cost nothing. Title updates are cosmetic: a failure is logged at debug level and never interrupts the goal loop. + +The indicator refreshes on goal commands and on idle, compaction, and interruption events — **not** on the `message.updated` events that stream during an assistant turn. Streaming refreshes would put an API round-trip in the response path for a cosmetic update, and idle is the cadence a human actually reads the indicator at. + +The captured original title lives in memory only, so a hard process kill leaves the last status line on the session. The plugin recognizes its own status lines and will not mistake one for your title, so `/goal clear` after a restart leaves the host's title alone rather than restoring stale goal status — but it cannot recover the title the session had before the goal started. Rename the session if you want it back. + +This needs no TUI plugin entrypoint, no `@opentui` dependencies, and no build step. + ## Plan-mode safety A planning-only agent is never driven into execution by the goal loop. OpenCode's built-in `plan` agent is restricted by default: diff --git a/docs/compatibility.md b/docs/compatibility.md index b01594d..01a5da9 100644 --- a/docs/compatibility.md +++ b/docs/compatibility.md @@ -81,6 +81,56 @@ comes from the rewritten turn's escaped reporting frame, fail-closed tool blocking, and parent-correlated lifecycle suppression. The system transform remains registered as additional protection for hosts that support it. +## OpenCode 2 + +**Status: not supported, and not yet tested.** + +The package declares `engines.opencode` and the `@opencode-ai/plugin` peer as +`>=1.17.15 <2`. That bound is deliberate: no claim in this repository is made +without a verified run behind it, and the project has not yet exercised the +plugin against an OpenCode 2 build. Treat OpenCode 2 as unverified rather than +as known-broken. + +### What already exists in this direction + +- `createOpenCodeSessionApi` speaks both the legacy generated-client shape + (`{ path, body, query }`) and the flattened shape (`{ sessionID, ... }`), + selected per operation and remembered after the first success. The + `sdkShape: "flat"` option pins the flattened shape for embedded clients. +- Only read-only operations are ever replayed against the alternate shape, so a + shape probe can never duplicate a mutating call. This invariant is pinned by + the mutation contract. + +### What a supported v2 claim would require + +Before the pin is widened, all of the following need to pass against a real +OpenCode 2 build, not a mock: + +1. Plugin load and hook registration through the v2 plugin entrypoint. +2. `command.execute.before`, `event`, `experimental.chat.system.transform`, + `experimental.session.compacting`, and `experimental.compaction.autocontinue` + firing with the shapes the plugin expects. +3. The execution-context signals (`chat.message`, `chat.params`, + `session.updated`) still reporting the active agent, which the planning-only + restriction depends on. +4. Session-API calls (`messages`, `promptAsync`, `create`, `get`, `update`, + `abort`) under whichever argument shape v2 ships. +5. Goal-specific compaction context and recovery of running child sessions after + a plugin restart, which are the areas most likely to differ. + +### Configuration + +This plugin is **server-only**: `package.json` exports the root and +`opencode-goal-plugin/server`, and there is no TUI plugin entrypoint. Its +configuration therefore lives entirely in `opencode.json` (the `plugin` and +`command` keys) on any OpenCode line. + +Plugins that *do* ship a TUI component are registered in a second file whose +location differs between OpenCode lines, and those formats must not be mixed. +That distinction does not apply here — including for the +[status indicator](../README.md#status-indicator), which reaches the TUI through +the session title rather than through a TUI plugin. + ## Versioning Semantic-versioning intent is: diff --git a/index.d.ts b/index.d.ts index c214fd5..2d502ca 100644 --- a/index.d.ts +++ b/index.d.ts @@ -311,6 +311,18 @@ export interface GoalPluginOptions { /** Register collision-safe native `goal` and `goal-verify` agents through OpenCode's config hook. */ registerAgents?: boolean + /** + * Mirror live goal status into the OpenCode session title, which the TUI + * renders persistently (e.g. `▶ ship the release · 3/10 · 2m · 45k/200k`), + * giving unattended runs a continuous heartbeat without a TUI plugin. + * + * The session's original title is captured before the first overwrite and + * restored by `/goal clear`. Title updates are cosmetic: a failure is logged + * at debug level and never interrupts the goal loop. + * @default false + */ + sessionTitleStatus?: boolean + /** * Agent names treated as planning-only. A goal created while one of these * agents is active is recorded but held paused instead of starting, and diff --git a/src/goal-plugin.js b/src/goal-plugin.js index d4658de..8675c96 100644 --- a/src/goal-plugin.js +++ b/src/goal-plugin.js @@ -111,6 +111,11 @@ function createRuntimeState() { seenIdleEventIDs: new Set(), sessionStatuses: new Map(), sessionExecutionContexts: new Map(), + // Session-title indicator: the user's own title, captured before the plugin + // first overwrites it, and the last title the plugin wrote (so an unchanged + // render skips the API call). + sessionTitles: new Map(), + appliedTitles: new Map(), pendingCommandTurns: new Map(), activeCommandTurns: new Map(), commandOutputs: new WeakMap(), @@ -422,6 +427,64 @@ function isPlanAgent(agent) { return isRestrictedAgent(agent, DEFAULT_RESTRICTED_AGENTS) } +// Session-title status indicator. OpenCode renders the session title +// persistently, so mirroring goal progress into it gives unattended runs a +// continuous heartbeat without a TUI plugin entrypoint. Opt-in, because it +// overwrites a user-visible field. +const SESSION_TITLE_OBJECTIVE_LIMIT = 48 +const SESSION_TITLE_ICONS = ["▶", "⏸", "⛔"] + +// The title sits in a narrow column, so every field is abbreviated hard. +function formatCompactDuration(ms) { + const totalSeconds = Math.max(0, Math.round(ms / 1000)) + if (totalSeconds < 60) return `${totalSeconds}s` + const totalMinutes = Math.floor(totalSeconds / 60) + if (totalMinutes < 60) return `${totalMinutes}m` + const hours = Math.floor(totalMinutes / 60) + const minutes = totalMinutes % 60 + return minutes ? `${hours}h${minutes}m` : `${hours}h` +} + +function formatCompactTokens(tokens) { + const value = toNonNegativeInteger(tokens) + if (value < 1000) return String(value) + if (value < 1_000_000) { + const thousands = value / 1000 + return `${thousands < 10 ? thousands.toFixed(1) : Math.round(thousands)}k` + } + const millions = value / 1_000_000 + return `${millions < 10 ? millions.toFixed(1) : Math.round(millions)}m` +} + +// Blocked and paused are distinct to a watching human: one needs input, the +// other just needs a resume. +function goalStatusIcon(goal) { + if (goal.blockedReason) return "⛔" + if (goal.stopped) return "⏸" + return "▶" +} + +// One-line goal status for the session title, e.g. +// "▶ ship the release · 3/10 · 2m · 45k/200k". +function buildSessionTitle(goal, now = Date.now()) { + const elapsedMs = Math.max(0, (goal.pausedAt || now) - goal.startedAt) + return [ + `${goalStatusIcon(goal)} ${summarizeText(goal.condition, SESSION_TITLE_OBJECTIVE_LIMIT)}`, + `${goal.turnCount}/${goal.options.maxTurns}`, + formatCompactDuration(elapsedMs), + `${formatCompactTokens(goal.totalTokens)}/${formatCompactTokens(goal.options.maxTokens)}`, + ].join(" · ") +} + +// Recognize a title this plugin wrote. The captured "original" is what +// `/goal clear` restores, so capturing one of our own status lines would make +// clear promote a stale status string to the permanent session title. That is +// exactly the state a hard process kill leaves behind. +function looksLikePluginSessionTitle(title) { + const text = typeof title === "string" ? title.trimStart() : "" + return SESSION_TITLE_ICONS.some((icon) => text.startsWith(`${icon} `)) +} + // Stop reason for a goal held because a planning-only agent is active. The // built-in `plan` case keeps its established wording so persisted state and // existing consumers stay stable. @@ -949,6 +1012,8 @@ function clearRuntimeState() { runtime.seenIdleEventIDs.clear() runtime.sessionStatuses.clear() runtime.sessionExecutionContexts.clear() + runtime.sessionTitles.clear() + runtime.appliedTitles.clear() runtime.pendingCommandTurns.clear() runtime.activeCommandTurns.clear() runtime.ownedPluginMessages.clear() @@ -2162,6 +2227,26 @@ async function logPluginWarning(client, message) { return logPluginMessage(client, "warn", message) } +// Cosmetic failures (session-title updates) log at debug and never fall back to +// the console: a title that failed to render must not look like a goal fault. +async function logPluginDebug(client, message, error) { + if (!client?.app?.log) return + try { + await client.app.log({ + body: { + service: "opencode-goal-plugin", + level: "debug", + message, + ...(error === undefined + ? {} + : { extra: { error: error?.message || error?.name || String(error) } }), + }, + }) + } catch { + // Diagnostics must never affect the goal loop. + } +} + function parseGoalArguments(args, defaults) { const parts = args.match(/"[^"]*"|'[^']*'|\S+/g) || [] const condition = [] @@ -3990,6 +4075,55 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {}) // agent. Defaults to false: unattended work must not escape Plan mode. const allowGoalExecutionFromPlan = pluginOptions.allowGoalExecutionFromPlan === true + // Opt-in: mirrors live goal status into the OpenCode session title, which the + // TUI renders persistently. Off by default because it overwrites a + // user-visible field. + const sessionTitleStatus = pluginOptions.sessionTitleStatus === true + + // Title updates are cosmetic: every path swallows errors after logging at + // debug level so a failure can never interrupt the goal loop. + const syncSessionTitle = async (sessionID) => { + if (!sessionTitleStatus || !sessionID) return + const goal = goalStates.get(sessionID) + if (!goal) return + const title = buildSessionTitle(goal) + if (currentRuntime().appliedTitles.get(sessionID) === title) return + try { + if (!currentRuntime().sessionTitles.has(sessionID)) { + const session = await sessionApi.get(sessionID) + const existing = typeof session?.title === "string" ? session.title : "" + // A status line left behind by a previous process is not the user's + // title; capture empty so clear leaves the host's title alone rather + // than restoring stale goal status. + currentRuntime().sessionTitles.set( + sessionID, + looksLikePluginSessionTitle(existing) ? "" : existing, + ) + } + await sessionApi.update(sessionID, { title }) + currentRuntime().appliedTitles.set(sessionID, title) + } catch (error) { + await logPluginDebug(client, "Failed to update session title", error) + } + } + + const restoreSessionTitle = async (sessionID) => { + if (!sessionTitleStatus || !sessionID) return + const runtime = currentRuntime() + if (!runtime.sessionTitles.has(sessionID)) return + const original = runtime.sessionTitles.get(sessionID) + runtime.sessionTitles.delete(sessionID) + runtime.appliedTitles.delete(sessionID) + // Empty means there was nothing genuine to restore (no title, or the + // session only carried a status line from a previous process). + if (!original) return + try { + await sessionApi.update(sessionID, { title: original }) + } catch (error) { + await logPluginDebug(client, "Failed to restore session title", error) + } + } + // The restricted agent currently driving this session, or "" when execution // is permitted. Reads the execution context the host reports through // `chat.message`, `chat.params`, and `session.updated`. @@ -4930,6 +5064,8 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {}) requireCurrent: false, }) } + // Hand the session title back to the user now that no goal owns it. + if (clearStillCurrent) await restoreSessionTitle(sessionID) replaceCommandOutputText( output, !clearStillCurrent @@ -6563,6 +6699,37 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {}) // register_command toggle: when disabled, the plugin does not own // a slash command and only the event/transform/compaction hooks remain. + // Session-title indicator: rather than threading a sync call through every + // state-mutating site (a missed one shows the user a stale status), wrap the + // two hooks that gate all state change. The sync no-ops when the rendered + // title is unchanged, and runs in `finally` so the displayed status matches + // the state actually reached even if a hook throws. + if (sessionTitleStatus) { + for (const hookName of ["command.execute.before", "event"]) { + const original = hooks[hookName] + if (typeof original !== "function") continue + hooks[hookName] = async (...args) => { + try { + return await original(...args) + } finally { + let titleSessionID = "" + if (hookName === "event") { + // `message.updated` streams many times per assistant turn. Awaiting + // a title sync on each would put an API round-trip in the streaming + // path for a cosmetic update; idle, compaction, and interruption + // events already cover every state the indicator renders. + if (args[0]?.event?.type !== "message.updated") { + titleSessionID = getSessionID(args[0]?.event) + } + } else { + titleSessionID = args[0]?.sessionID + } + await syncSessionTitle(titleSessionID) + } + } + } + } + if (!registerCommand) { delete hooks["command.execute.before"] } @@ -6700,6 +6867,11 @@ export const testInternals = { isPluginCommandMessage, isPluginContinuationMessage, isPlanAgent, + buildSessionTitle, + formatCompactDuration, + formatCompactTokens, + goalStatusIcon, + looksLikePluginSessionTitle, isRestrictedAgent, normalizeRestrictedAgents, isPluginGeneratedMessage, diff --git a/test/goal-plugin.test.js b/test/goal-plugin.test.js index 354efdf..4364515 100644 --- a/test/goal-plugin.test.js +++ b/test/goal-plugin.test.js @@ -37,6 +37,11 @@ const { isPluginCommandMessage, isPluginContinuationMessage, isPlanAgent, + buildSessionTitle, + formatCompactDuration, + formatCompactTokens, + goalStatusIcon, + looksLikePluginSessionTitle, isRestrictedAgent, normalizeRestrictedAgents, isPluginGeneratedMessage, @@ -9863,3 +9868,221 @@ test("restrictedAgents: [] releases the built-in Plan restriction", async () => }) assert.equal(calls.length, 1, "an empty restricted list is a deliberate opt-out") }) + +// --------------------------------------------------------------------------- +// Session-title status indicator +// --------------------------------------------------------------------------- + +async function createTitleHooks(overrides = {}) { + const updates = [] + const client = { + app: { log: async () => {} }, + session: { + messages: async () => ({ data: [message("still working")] }), + promptAsync: async () => ({}), + abort: async () => ({}), + get: overrides.get || (async () => ({ data: { title: "my original title" } })), + update: + overrides.update || + (async (input) => { + updates.push(input) + return {} + }), + }, + } + const hooks = await GoalPlugin( + { client }, + { persistState: false, minDelayMs: 1, sessionTitleStatus: true, ...(overrides.options || {}) }, + ) + return { hooks, updates } +} + +test("formatCompactDuration and formatCompactTokens abbreviate for a narrow title", () => { + assert.equal(formatCompactDuration(0), "0s") + assert.equal(formatCompactDuration(45_000), "45s") + assert.equal(formatCompactDuration(60_000), "1m") + // Elapsed time floors rather than rounds: 90s is "1m so far", not "2m". + assert.equal(formatCompactDuration(90_000), "1m") + assert.equal(formatCompactDuration(60 * 60_000), "1h") + assert.equal(formatCompactDuration(95 * 60_000), "1h35m") + // Clock skew must not render as garbage. + assert.equal(formatCompactDuration(-5000), "0s") + + assert.equal(formatCompactTokens(0), "0") + assert.equal(formatCompactTokens(999), "999") + assert.equal(formatCompactTokens(1500), "1.5k") + assert.equal(formatCompactTokens(45_000), "45k") + assert.equal(formatCompactTokens(1_500_000), "1.5m") +}) + +test("goalStatusIcon distinguishes running, paused, and blocked", () => { + assert.equal(goalStatusIcon({ stopped: false, blockedReason: "" }), "▶") + assert.equal(goalStatusIcon({ stopped: true, blockedReason: "" }), "⏸") + // Blocked outranks paused: it needs the user, not just a resume. + assert.equal(goalStatusIcon({ stopped: true, blockedReason: "needs a token" }), "⛔") +}) + +test("buildSessionTitle renders a compact one-line status", () => { + const now = Date.now() + const goal = { + condition: "ship the release", + stopped: false, + blockedReason: "", + turnCount: 3, + totalTokens: 45_000, + startedAt: now - 120_000, + pausedAt: 0, + options: { maxTurns: 10, maxTokens: 200_000 }, + } + assert.equal(buildSessionTitle(goal, now), "▶ ship the release · 3/10 · 2m · 45k/200k") + + // A paused goal freezes its elapsed clock instead of running on. + assert.equal( + buildSessionTitle({ ...goal, stopped: true, pausedAt: now - 60_000 }, now), + "⏸ ship the release · 3/10 · 1m · 45k/200k", + ) + + const title = buildSessionTitle({ ...goal, condition: "x".repeat(200) }, now) + assert.ok(title.includes("…"), "a long objective must be truncated") + assert.ok(title.length < 100, `title should stay compact, got ${title.length}`) +}) + +test("looksLikePluginSessionTitle recognizes titles this plugin wrote", () => { + assert.equal(looksLikePluginSessionTitle("▶ ship it · 3/10 · 2m · 45k/200k"), true) + assert.equal(looksLikePluginSessionTitle("⏸ ship it · 3/10 · 2m · 45k/200k"), true) + assert.equal(looksLikePluginSessionTitle("⛔ ship it · 3/10 · 2m · 45k/200k"), true) + assert.equal(looksLikePluginSessionTitle("my own session title"), false) + assert.equal(looksLikePluginSessionTitle(""), false) + assert.equal(looksLikePluginSessionTitle(undefined), false) + // A user title merely mentioning an icon, not in the leading marker form. + assert.equal(looksLikePluginSessionTitle("play ▶ button work"), false) +}) + +test("session-title status is off by default and never touches the title", async () => { + // The client here DOES expose session.update, so a call would be recorded; + // the assertion is meaningful only because the option is left unset. + const { hooks, updates } = await createTitleHooks({ + options: { sessionTitleStatus: undefined }, + }) + await hooks["command.execute.before"]( + { command: "goal", sessionID: "session-1", arguments: "ship it" }, + { parts: [] }, + ) + await hooks["command.execute.before"]( + { command: "goal", sessionID: "session-1", arguments: "pause" }, + { parts: [] }, + ) + assert.equal(updates.length, 0, "the default must not rewrite the session title") +}) + +test("sessionTitleStatus mirrors goal state into the title and restores it on clear", async () => { + const { hooks, updates } = await createTitleHooks() + + await hooks["command.execute.before"]( + { command: "goal", sessionID: "session-1", arguments: "ship it" }, + { parts: [] }, + ) + assert.ok(updates.length >= 1, "setting a goal must publish a title") + assert.match(updates[0].body.title, /^▶ ship it · 0\/\d+ · /) + + await hooks["command.execute.before"]( + { command: "goal", sessionID: "session-1", arguments: "pause" }, + { parts: [] }, + ) + assert.match(updates.at(-1).body.title, /^⏸ ship it/, "pausing re-renders the indicator") + + await hooks["command.execute.before"]( + { command: "goal", sessionID: "session-1", arguments: "clear" }, + { parts: [] }, + ) + assert.equal(updates.at(-1).body.title, "my original title", "clear must restore the original") +}) + +test("an unchanged session title is not rewritten", async () => { + const { hooks, updates } = await createTitleHooks() + await hooks["command.execute.before"]( + { command: "goal", sessionID: "session-1", arguments: "ship it" }, + { parts: [] }, + ) + const afterSet = updates.length + + await hooks["command.execute.before"]( + { command: "goal", sessionID: "session-1", arguments: "status" }, + { parts: [] }, + ) + assert.equal(updates.length, afterSet, "an unchanged render must skip the API call") +}) + +test("streaming message.updated events do not trigger session-title writes", async () => { + const { hooks, updates } = await createTitleHooks() + await hooks["command.execute.before"]( + { command: "goal", sessionID: "session-1", arguments: "ship it" }, + { parts: [] }, + ) + const afterSet = updates.length + + for (let i = 1; i <= 25; i += 1) { + await hooks.event({ + event: { + type: "message.updated", + properties: { + info: { + id: `msg-stream-${i}`, + role: "assistant", + sessionID: "session-1", + tokens: { input: i * 1000, output: i * 100, reasoning: 0 }, + }, + }, + }, + }) + } + + assert.equal( + updates.length, + afterSet, + `streaming must not write titles, got ${updates.length - afterSet} extra writes`, + ) +}) + +test("a status title left by a killed process is not restored as the user's title", async () => { + // After a hard kill the session still carries the plugin's own status line. + // Capturing that as the "original" would make /goal clear promote a stale + // status string to the permanent session title. + const stale = "▶ previous goal · 7/10 · 5m · 90k/200k" + const { hooks, updates } = await createTitleHooks({ + get: async () => ({ data: { title: stale } }), + }) + await hooks["command.execute.before"]( + { command: "goal", sessionID: "session-1", arguments: "ship it" }, + { parts: [] }, + ) + await hooks["command.execute.before"]( + { command: "goal", sessionID: "session-1", arguments: "clear" }, + { parts: [] }, + ) + + assert.ok( + !updates.some((u) => u.body.title === stale), + `clear must never write the stale status line back, got ${JSON.stringify(updates.map((u) => u.body.title))}`, + ) + assert.ok( + updates.every((u) => u.body.title.startsWith("▶ ship it") || u.body.title.startsWith("⏸ ship it")), + `only the live goal's own status should be written, got ${JSON.stringify(updates.map((u) => u.body.title))}`, + ) +}) + +test("a failing session-title update never breaks the goal loop", async () => { + const { hooks } = await createTitleHooks({ + update: async () => { + throw new Error("host refused the title update") + }, + }) + const output = { parts: [] } + await hooks["command.execute.before"]( + { command: "goal", sessionID: "session-1", arguments: "ship it" }, + output, + ) + + assert.equal(currentGoal("session-1").condition, "ship it", "the goal must still be created") + assert.ok(output.parts.length > 0, "the command must still produce output") +})