From 5d5bcf2a22096c545d980489cb57da31ca083695 Mon Sep 17 00:00:00 2001 From: ChakraFusion <112588066+ChakraFusion@users.noreply.github.com> Date: Thu, 8 Oct 2026 10:12:41 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20stopPatterns=20option=20=E2=80=94=20exp?= =?UTF-8?q?licit=20stop=20lines=20count=20as=20awaiting=20the=20user?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Agent setups that end a turn with a protocol line such as "STOP: NEEDS_DECISION " or "STOP: BLOCKED " usually keep the todo for that decision open. The open-todos nudge then fires and the agent may answer its own question instead of waiting for the user. New opt-in option `stopPatterns` (regex strings, default none): when the newest assistant message's visible text matches one, hasPendingUserInput() returns true, so every idle nudge stands down exactly as for an open question tool. A newer user message clears it. Default behaviour unchanged. Tests: src/index.stop-patterns.test.ts (stop line not nudged, same turn nudged without the option, silent turn end still nudged, reasoning text ignored, invalid pattern skipped). Full suite: 599 pass. Co-Authored-By: Claude Opus 5.5 --- README.md | 4 +- src/index.stop-patterns.test.ts | 86 +++++++++++++++++++++++++++++++++ src/index.ts | 17 +++++++ 3 files changed, 106 insertions(+), 1 deletion(-) create mode 100644 src/index.stop-patterns.test.ts diff --git a/README.md b/README.md index d6b6c08..fff9978 100644 --- a/README.md +++ b/README.md @@ -247,10 +247,11 @@ If the assistant claims the task is done ("task done", "finished", "all complete ### User-input awareness -The session is not stalled while the ball is in the user's court. Two gates stand down idle nudges: +The session is not stalled while the ball is in the user's court. Three gates stand down idle nudges: - **Awaiting input**: the newest assistant message holds a `tool_use` part with `state.status: "pending"` (e.g. an open `question` tool call). All idle checks, the periodic recheck, delayed action-intent callbacks, and the tool-text scan skip prompting until a newer user message clears the gate. Completed tool calls never engage it. - **Recently active user**: any inbound user message within `activeUserWindowMs` (default 5 minutes) means the user is engaged — likely composing a reply, which leaves no pending tool call behind. Open-todos nudges (idle + periodic), the tool-text reminder fallback, and action-intent callbacks stand down until the window expires. +- **Explicit stop line** (opt-in): when the newest assistant message's text matches one of `stopPatterns`, the agent has deliberately handed the ball to the user — typical for agent setups whose prompts end a turn with a protocol line such as `STOP: NEEDS_DECISION ` or `STOP: BLOCKED `, while the todo for that decision is still open. It counts exactly like the awaiting-input gate. Default: no patterns (unchanged behaviour). --- @@ -422,6 +423,7 @@ With options: | `busyStallStrategy` | `"continue"` | Busy-stall response: `"continue"`, `"abort"` (abort-first), or `"off"` (disabled) | | `contextSaturationThreshold` | `0.85` | Ratio of used/usable context that routes a saturated parent to magic-context `ctx-wrapup` (only when magic-context is installed) | | `activeUserWindowMs` | `300000` | Inbound-user-message recency window (5 min) during which idle nudges stand down (user likely composing) | +| `stopPatterns` | `[]` | Array of regex strings (multiline). When the newest assistant message's text matches one, the session counts as awaiting the user and idle nudges stand down — e.g. `["STOP:\\s*(BLOCKED\|NEEDS_DECISION)\\b"]`. Invalid regexes are skipped. | | `subagentNativeCompactionEnabled` | `false` | Opt-in native `session.summarize()` for saturated subagent sessions (no magic-context detection required) | Message patterns are matched case-insensitively. Error names use exact match. diff --git a/src/index.stop-patterns.test.ts b/src/index.stop-patterns.test.ts new file mode 100644 index 0000000..1e773ef --- /dev/null +++ b/src/index.stop-patterns.test.ts @@ -0,0 +1,86 @@ +import { describe, test, expect, mock } from "bun:test" +import { AutoResumePlugin } from "./index" + +type PromptCall = { sid: string; body: string } + +function createMockContext(opts: { + sessions: Array<{ id: string; status: string }> + messages: Record> +}) { + const promptCalls: PromptCall[] = [] + const statusMap: Record = {} + for (const s of opts.sessions) statusMap[s.id] = { type: s.status } + const ctx = { + client: { + app: { log: mock(async () => {}) }, + session: { + list: mock(async () => ({ + data: opts.sessions.map(s => ({ + id: s.id, projectID: "proj-1", directory: "/test", title: s.id, version: "1.0.0", + time: { created: Date.now(), updated: Date.now() } + })) + })), + status: mock(async () => ({ data: statusMap })), + todo: mock(async () => ({ data: OPEN_TODOS })), + messages: mock(async (config: { path: { id: string } }) => ({ data: opts.messages[config.path.id] ?? [] })), + prompt: mock(async (config: { path: { id: string }; body: { parts: Array<{ type: string; text: string }> } }) => { + promptCalls.push({ sid: config.path.id, body: config.body.parts.map(p => p.text).join("") }) + return {} + }), + abort: mock(async () => ({})) + } + }, + ui: { toast: mock(async () => {}) } + } as any + return { ctx, promptCalls } +} + +const OPEN_TODOS = [ + { id: "t1", content: "pick the storage backend", status: "pending", priority: "high" }, + { id: "t2", content: "migrate the schema", status: "pending", priority: "high" } +] +const STOP = ["STOP:\\s*(BLOCKED|NEEDS_DECISION)\\b"] +const OPTIONS = { + enabled: true, baseBackoffMs: 1, checkIntervalMs: 10_000_000, maxRetries: 3, loopMaxContinues: 10, + minActivityGapMs: 0, toolTextCheckDelayMs: 10, warmupMs: 0, activeUserWindowMs: 0 +} +const event = (type: string, sid: string, properties: Record) => ({ event: { type, sessionID: sid, properties } }) +const wait = (ms: number) => new Promise(r => setTimeout(r, ms)) + +describe("stopPatterns", () => { + async function nudgesAfterIdle(lastText: string, stopPatterns?: string[], partType = "text") { + const sid = "ses_stop" + const { ctx, promptCalls } = createMockContext({ + sessions: [{ id: sid, status: "idle" }], + messages: { [sid]: [ + { role: "user", parts: [{ type: "text", text: "set up the storage layer" }] }, + { role: "assistant", parts: [{ type: partType, text: lastText }] } + ] } + }) + const hooks = await AutoResumePlugin(ctx, stopPatterns ? { ...OPTIONS, stopPatterns } : OPTIONS) + await hooks.event!(event("todo.updated", sid, { todos: OPEN_TODOS }) as any) + await hooks.event!(event("session.status", sid, { status: "idle" }) as any) + await wait(150) + return promptCalls.length + } + + test("a turn ending in a stop line with open todos is not nudged", async () => { + expect(await nudgesAfterIdle("Two options.\nSTOP: NEEDS_DECISION Postgres or SQLite?", STOP)).toBe(0) + }) + + test("without stopPatterns the same turn is nudged (unchanged default)", async () => { + expect(await nudgesAfterIdle("Two options.\nSTOP: NEEDS_DECISION Postgres or SQLite?")).toBeGreaterThanOrEqual(1) + }) + + test("a silent turn end is still nudged with stopPatterns set", async () => { + expect(await nudgesAfterIdle("Schema drafted.", STOP)).toBeGreaterThanOrEqual(1) + }) + + test("only visible text counts: a stop line in reasoning does not hold back the nudge", async () => { + expect(await nudgesAfterIdle("maybe STOP: BLOCKED here", STOP, "reasoning")).toBeGreaterThanOrEqual(1) + }) + + test("an invalid pattern is skipped instead of breaking the plugin", async () => { + expect(await nudgesAfterIdle("Two options.\nSTOP: NEEDS_DECISION Postgres or SQLite?", ["(", ...STOP])).toBe(0) + }) +}) diff --git a/src/index.ts b/src/index.ts index 926bade..562da69 100644 --- a/src/index.ts +++ b/src/index.ts @@ -479,6 +479,19 @@ function getOpenTodos(todos: Todo[]): Todo[] { return todos.filter(isOpenTodo) } +// `stopPatterns` option → RegExp list (invalid patterns are skipped). +function parseStopPatterns(raw: unknown): RegExp[] { + if (!Array.isArray(raw)) return [] + return raw.map(s => { try { return new RegExp(String(s), "m") } catch { return null } }).filter((r): r is RegExp => r !== null) +} + +// Does an assistant message's visible text match one of the stop patterns? +function endsWithStopLine(parts: Array>, patterns: RegExp[]): boolean { + if (!patterns.length) return false + const text = parts.filter(p => (p.type as string) === "text").map(p => String(p.text ?? "")).join("\n") + return patterns.some(re => re.test(text)) +} + function buildOpenTodosReminder(todos: Todo[]): string { if (!Array.isArray(todos)) return "continue" const open = todos.filter(t => t.status === "pending" || t.status === "in_progress") @@ -562,6 +575,8 @@ export const AutoResumePlugin: Plugin = async (ctx, options) => { if (!Array.isArray(raw) || raw.length === 0) return READY_TO_CONTINUE_PATTERNS return raw.map(s => { try { return new RegExp(s, "i") } catch { return null } }).filter((r): r is RegExp => r !== null) })() + // Explicit hand-off lines (e.g. "STOP: NEEDS_DECISION ...") that put the ball in the user's court. Default: none. + const stopPatterns: RegExp[] = parseStopPatterns(options?.stopPatterns) const dbg = (...args: unknown[]) => { if (debug) console.log("[debug]", ...args) } const sessions = new Map() @@ -1044,6 +1059,8 @@ export const AutoResumePlugin: Plugin = async (ctx, options) => { if (rawRole !== "assistant") continue const parts = msg.parts as Array> | undefined if (!parts) return false + // An explicit stop line (stopPatterns) in the newest assistant message also awaits the user. + if (endsWithStopLine(parts, stopPatterns)) return true for (const part of parts) { if ((part.type as string) !== "tool_use") continue const state = part.state as Record | undefined