Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <question>` or `STOP: BLOCKED <reason>`, while the todo for that decision is still open. It counts exactly like the awaiting-input gate. Default: no patterns (unchanged behaviour).

---

Expand Down Expand Up @@ -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.
Expand Down
86 changes: 86 additions & 0 deletions src/index.stop-patterns.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, Array<any>>
}) {
const promptCalls: PromptCall[] = []
const statusMap: Record<string, { type: string }> = {}
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<string, unknown>) => ({ 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)
})
})
17 changes: 17 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Record<string, unknown>>, 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")
Expand Down Expand Up @@ -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<string, SessionWatch>()
Expand Down Expand Up @@ -1044,6 +1059,8 @@ export const AutoResumePlugin: Plugin = async (ctx, options) => {
if (rawRole !== "assistant") continue
const parts = msg.parts as Array<Record<string, unknown>> | 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<string, unknown> | undefined
Expand Down
Loading