diff --git a/packages/opencode/src/goal/goal.ts b/packages/opencode/src/goal/goal.ts index 29d56c7707..106d0be3cc 100644 --- a/packages/opencode/src/goal/goal.ts +++ b/packages/opencode/src/goal/goal.ts @@ -138,6 +138,34 @@ export interface Interface { * fiber AND publishes the paused event. */ readonly pauseAndPublish: (sessionID: SessionID, reason: string) => Effect.Effect + /** + * Goal-turn provenance + step ceiling (GOAL-TURN-SCOPE). Marks the session's + * CURRENT turn as goal-driven (kick / judge continuation / resume-kick) so + * (a) the prompt loop can cap its steps via goalTurnMaxSteps, and + * (b) SessionPrompt.cancel can map a user ESC on a goal turn into a goal + * pause instead of letting the idle event auto-resurrect the goal. + * The mark is process-local InstanceState: cleared when the turn ends + * (afterIdle entry, pause, clear, markDone) and safe to overwrite. + */ + readonly markTurnDriven: (sessionID: SessionID) => Effect.Effect + /** Clears the goal-turn mark. No-op when not marked. */ + readonly clearTurnDriven: (sessionID: SessionID) => Effect.Effect + /** + * ESC-on-goal-turn transition: pauses the goal (durable row + event) AND + * releases the automation registration AND clears the turn mark — one + * seam so SessionPrompt.cancel stays free of lease plumbing. No-op (returns + * undefined) when the goal is not active. Never fails: pause failures are + * logged and swallowed so a cancel path can always proceed. + */ + readonly pauseForUserCancel: (sessionID: SessionID, reason: string) => Effect.Effect + /** True when the session's current turn is goal-driven. */ + readonly isTurnDriven: (sessionID: SessionID) => Effect.Effect + /** + * Step ceiling for a goal-driven turn: min of GOAL_TURN_MAX_STEPS and the + * current goal's identity. Returns undefined when the turn is NOT + * goal-driven (the prompt loop then falls back to agent.steps unchanged). + */ + readonly goalTurnMaxSteps: (sessionID: SessionID) => Effect.Effect } export class Service extends Context.Service()("@opencode/Goal") {} @@ -167,6 +195,58 @@ const serviceLayer = Layer.effect( const fibers = new Map>() + // GOAL-TURN-SCOPE: process-local provenance of the CURRENT goal-driven + // turn. Keyed by session; set at every goal dispatch (kick in prompt.ts, + // continuation in loop.ts), cleared at turn end (afterIdle entry) and at + // every terminal transition (pause/clear/markDone) plus ESC-cancel. A stale + // mark is harmless: goalTurnMaxSteps re-validates against the durable goal + // row before reporting a ceiling. + const turnDriven = new Set() + + const markTurnDriven = Effect.fnUntraced(function* (sessionID: SessionID) { + turnDriven.add(sessionID) + }) + + const clearTurnDriven = Effect.fnUntraced(function* (sessionID: SessionID) { + turnDriven.delete(sessionID) + }) + + const isTurnDriven = Effect.fnUntraced(function* (sessionID: SessionID) { + return turnDriven.has(sessionID) + }) + + const goalTurnMaxSteps = Effect.fnUntraced(function* (sessionID: SessionID) { + if (!turnDriven.has(sessionID)) return undefined + // Re-validate against the durable row: a mark left over from a turn that + // ended with the goal cleared/paused must not cap an unrelated turn. + const state = yield* loadState(sessionID) + if (!state || state.status !== "active") { + turnDriven.delete(sessionID) + return undefined + } + return GoalPrompts.GOAL_TURN_MAX_STEPS + }) + + // ESC-on-goal-turn: durable pause + lease release + mark clear, failure- + // absorbed. Called from SessionPrompt.cancel so a user ESC on a goal turn + // pauses the goal instead of letting the post-cancel idle event resurrect + // it with an unwanted continuation. + const pauseForUserCancel = Effect.fnUntraced(function* (sessionID: SessionID, reason: string) { + const paused = yield* pauseAndPublish(sessionID, reason).pipe( + Effect.catchCause((cause) => + Effect.logWarning("goal pause on cancel failed", { sessionID, cause: String(cause) }).pipe( + Effect.as(undefined), + ), + ), + ) + if (paused) + yield* automation.unregister(sessionID, { kind: "goal", id: paused.goal_id ?? "legacy" }).pipe( + Effect.ignore, + ) + turnDriven.delete(sessionID) + return paused + }) + const registerFiber = Effect.fnUntraced(function* ( sessionID: SessionID, fiber: Fiber.Fiber, @@ -431,6 +511,7 @@ const serviceLayer = Layer.effect( if (!updated) return undefined yield* automation.unregister(sessionID, { kind: "goal", id: updated.goal_id ?? "legacy" }) yield* clearFiber(sessionID) + turnDriven.delete(sessionID) return updated }) @@ -476,6 +557,7 @@ const serviceLayer = Layer.effect( if (cleared) yield* automation.unregister(sessionID, { kind: "goal", id: cleared.goal_id ?? "legacy" }) yield* clearFiber(sessionID) + turnDriven.delete(sessionID) }) // GOAL-FP-01-05/-16: session-deletion cleanup. `clear` keeps the @@ -491,6 +573,7 @@ const serviceLayer = Layer.effect( if (cleared) yield* automation.unregister(sessionID, { kind: "goal", id: cleared.goal_id ?? "legacy" }) yield* clearFiber(sessionID) + turnDriven.delete(sessionID) }) const markDone = Effect.fn("Goal.markDone")(function* (sessionID: SessionID, reason: string) { @@ -505,6 +588,7 @@ const serviceLayer = Layer.effect( const completed = yield* deleteAndPublishDone(sessionID, reason) if (completed) yield* automation.unregister(sessionID, { kind: "goal", id: completed.goal_id ?? "legacy" }) + turnDriven.delete(sessionID) return completed }) @@ -551,8 +635,17 @@ const serviceLayer = Layer.effect( if (!state) return undefined const subgoals = state.subgoals ?? [] const sub = subgoals.length > 0 ? `,${subgoals.length} 个子目标` : "" - if (state.status === "active") + if (state.status === "active") { + // GOAL-TURN-SCOPE observability: a goal-driven turn in flight reads as + // "执行中" so a long first turn (turns_used still 0/N) does not look + // like a dead 0/N counter. + if (turnDriven.has(sessionID)) { + const busy = yield* sessionStatus.get(sessionID) + if (busy.type === "busy") + return `⊙ 目标(执行中,${state.turns_used}/${state.max_turns} 轮${sub}):${state.goal}` + } return `⊙ 目标(进行中,${state.turns_used}/${state.max_turns} 轮${sub}):${state.goal}` + } if (state.status === "paused") { const reason = state.paused_reason ? ` — ${state.paused_reason}` : "" return `⏸ 目标(已暂停,${state.turns_used}/${state.max_turns} 轮${reason}):${state.goal}` @@ -619,7 +712,7 @@ const serviceLayer = Layer.effect( newParseFailures >= GoalPrompts.MAX_CONSECUTIVE_PARSE_FAILURES ? "judge 模型未返回有效 JSON 判定。请检查模型配置或换用更可靠的模型,然后 /goal resume。" : turnsUsed >= state.max_turns - ? `已用 ${turnsUsed}/${state.max_turns} 轮。使用 /goal resume 继续,或 /goal clear 停止。` + ? `已用 ${turnsUsed}/${state.max_turns} 轮预算。/goal clear 停止;/goal resume 会重启一整轮执行(本轮内完成才会计为达成,否则仍会被再次暂停)。` : undefined const updated = GoalState.advance(state, { status: pauseReason ? "paused" : "active", @@ -708,7 +801,7 @@ const serviceLayer = Layer.effect( // text a second later, which looks like resume didn't work. const announceMsg = result.turns_used >= result.max_turns - ? `⚠ 目标已恢复,但预算已耗尽(${result.turns_used}/${result.max_turns} 轮)。下一轮 judge 会立刻再次判定超预算暂停。建议 /goal clear 后重新 /goal ,或在 /goal set 时传更大的 maxTurns。` + ? `⚠ 目标已恢复,但轮预算已耗尽(${result.turns_used}/${result.max_turns} 轮)。resume 会重启一整轮执行:本轮内任务完成才会计为达成,否则 judge 会再次暂停。建议 /goal clear 后用更大的 maxTurns 重新设定。` : undefined return { type: "kick" as const, @@ -834,6 +927,11 @@ const serviceLayer = Layer.effect( clearLoopFiberIf: clearFiberIf, deleteAndPublishDone, pauseAndPublish, + markTurnDriven, + clearTurnDriven, + isTurnDriven, + goalTurnMaxSteps, + pauseForUserCancel, }) }), ) diff --git a/packages/opencode/src/goal/loop.ts b/packages/opencode/src/goal/loop.ts index bb72356df5..8349856ff4 100644 --- a/packages/opencode/src/goal/loop.ts +++ b/packages/opencode/src/goal/loop.ts @@ -225,6 +225,11 @@ const serviceLayer = Layer.effect( const evaluatedRevisions = new Map() const afterIdle = Effect.fn("GoalLoop.afterIdle")(function* (sessionID: SessionID, scanResume?: boolean) { + // GOAL-TURN-SCOPE: the goal-driven turn that produced this idle has + // ended. Clear the mark up front; the continuation branch below re-marks + // when it dispatches the next turn. This also retires a stale mark when + // the idle came from an unrelated (non-goal) turn. + yield* goal.clearTurnDriven(sessionID) const goalState = yield* goal.load(sessionID) if (!goalState || goalState.status !== "active") return // D-4 entry gate (scan path only): the boot snapshot may have gone @@ -451,34 +456,44 @@ const serviceLayer = Layer.effect( const continuationLease = Option.getOrUndefined(yield* automation.claim(sessionID, goalOwner)) if (!continuationLease) return yield* Effect.gen(function* () { + // GOAL-TURN-SCOPE: mark BEFORE admission so the goal-turn provenance is + // already visible the instant the continuation can start — no window + // where an httpapi cancel slips between admit and mark. If admission + // is refused (session not idle), clear the speculative mark. + yield* goal.markTurnDriven(sessionID) const admitted = yield* SessionPrompt.admitIfIdle(promptSvc, automation, continuationLease, { sessionID, parts: [{ type: "text", text: continuationText }], }) - if (Option.isNone(admitted)) return + if (Option.isNone(admitted)) { + yield* goal.clearTurnDriven(sessionID) + return + } yield* admitted.value }).pipe( Effect.catchCause((cause) => Effect.gen(function* () { // F1: Only pause for non-interrupt causes. An interrupt (user - // pressed ESC during continuation) is safe to drop because the - // session ALWAYS re-emits idle afterwards, which re-drives this - // loop: SessionRunState.cancel (run-state.ts) and the runner's - // onIdle callback both call status.set(idle), and - // SessionStatus.set (status.ts) publishes the Status+Idle event - // pair unconditionally — even when the session was already idle. - // That fresh idle event forks a new afterIdle fiber whose - // shouldPreempt guard detects the user's newer message and pauses - // there if needed. Pausing HERE would race that replacement - // afterIdle fiber and emit a spurious pause. Real dispatch - // failures (provider fault, session write error) still get the - // recoverable pause below. + // pressed ESC during continuation) is safe to drop because + // SessionPrompt.cancel pauses goal-driven turns SYNCHRONOUSLY via + // goal.pauseForUserCancel (prompt.ts) BEFORE state.cancel lets the + // interrupt propagate — by the time this catchCause observes the + // cause, the goal is already paused, and pausing again HERE would + // double-publish. The session still ALWAYS re-emits idle + // afterwards, which re-drives this loop: SessionRunState.cancel + // (run-state.ts) and the runner's onIdle callback both call + // status.set(idle), and SessionStatus.set (status.ts) publishes + // the Status+Idle event pair unconditionally — even when the + // session was already idle. On that next cycle shouldPreempt is + // only the DB-failure fallback for a pauseForUserCancel that could + // not persist. Real dispatch failures (provider fault, session + // write error) still get the recoverable pause below. // F1: hasInterrupts is a structural check; Cause.interruptors only // collects DEFINED fiber ids and silently ignores interrupts // carrying none (e.g. Cause.interrupt()), which would otherwise be // misclassified as a dispatch failure and spuriously paused here. if (Cause.hasInterrupts(cause)) { - yield* Effect.logInfo("goal continuation interrupted (likely user ESC) — not pausing; shouldPreempt handles next cycle") + yield* Effect.logInfo("goal continuation interrupted (likely user ESC) — not pausing; cancel path already paused the goal") return Option.none() } const errMsg = `continuation dispatch failed: ${Cause.pretty(cause)}` diff --git a/packages/opencode/src/goal/prompts.ts b/packages/opencode/src/goal/prompts.ts index 188d44b977..f40949fb32 100644 --- a/packages/opencode/src/goal/prompts.ts +++ b/packages/opencode/src/goal/prompts.ts @@ -18,6 +18,15 @@ export const JUDGE_RESPONSE_SNIPPET_CHARS = 4000 // message, is treated as orphaned and auto-paused so the user can recover via // /goal resume instead of the goal sitting silently "active" forever. export const FRESHNESS_THRESHOLD = 120_000 +// Step ceiling applied to every goal-driven turn (kick, judge continuation, +// resume-kick). Without it a goal turn has NO step bound (agent.steps defaults +// to Infinity for build), so a long-running task keeps the session busy +// forever — the judge only runs on idle, and turns_used stays frozen at 0/20 +// with the goal permanently "active". This ceiling guarantees every goal turn +// reaches an idle boundary where the judge and the turn budget can engage. +// Applied as min(agent.steps ?? Infinity, GOAL_TURN_MAX_STEPS) so a stricter +// user-configured agent.steps is always respected. +export const GOAL_TURN_MAX_STEPS = 50 export const JUDGE_SYSTEM_PROMPT = `You are an autonomous-goal completion judge. You will receive: diff --git a/packages/opencode/src/hook/settings.ts b/packages/opencode/src/hook/settings.ts index 9f61f6aafc..44b67c0983 100644 --- a/packages/opencode/src/hook/settings.ts +++ b/packages/opencode/src/hook/settings.ts @@ -1432,10 +1432,11 @@ const mcpHandler: HookHandler = { /** * `type: "http"` handler. Per CC protocol, `entry.command` is the endpoint URL; - * the envelope is POSTed as JSON. 2xx → body parsed via the same parseStdout path - * as command stdout. Non-2xx → synthetic `exitBlock` so the trigger aggregator - * surfaces it as a block. Network errors / timeouts → log.warn + silent allow, - * mirroring commandHandler's spawnError behavior (hooks must never crash the host). + * the envelope is POSTed as JSON with `entry.headers` applied verbatim (auth + * tokens etc.). 2xx → body parsed via the same parseStdout path as command + * stdout. Non-2xx → synthetic `exitBlock` so the trigger aggregator surfaces it + * as a block. Network errors / timeouts → log.warn + silent allow, mirroring + * commandHandler's spawnError behavior (hooks must never crash the host). * * Factory takes the resolved HttpClient so the HookHandler.run signature stays * `R = never` (the WP-4A interface contract). Captures `http` in closure scope — @@ -1454,6 +1455,7 @@ const httpHandler: HookHandler = { const url = httpUrl(entry) const exit = yield* HttpClientRequest.post(url).pipe( + HttpClientRequest.setHeaders(entry.headers ?? {}), HttpClientRequest.bodyJson(envelope), Effect.flatMap((req) => httpRead.execute(req)), Effect.flatMap((res) => diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts index c950b632b0..1112688901 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts @@ -440,6 +440,16 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session", // Non-empty hooks[] guard → 4xx. event/type membership is already enforced // by the payload's literal schemas; this completes the validation contract. if (ctx.payload.hooks.length === 0) return yield* new HttpApiError.BadRequest({}) + // Per-entry guards: a command hook must carry a runnable command line + // (blank-only strings would spawn nothing), and timeout is a positive + // seconds multiplier — negative values time the request out immediately, + // and 0 silently falls back to the default instead of meaning "no timeout". + const invalidEntry = ctx.payload.hooks.some( + (hook) => + (hook.type === "command" && !(hook.command ?? "").trim()) || + (hook.timeout !== undefined && hook.timeout <= 0), + ) + if (invalidEntry) return yield* new HttpApiError.BadRequest({}) const id = yield* sessionHooks.add(ctx.params.sessionID, { event: ctx.payload.event as HookEvent, matcher: ctx.payload.matcher, diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 11b6f95be8..e6d30bfe03 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -175,6 +175,15 @@ export const layer = Layer.effect( const cancel = Effect.fn("SessionPrompt.cancel")(function* (sessionID: SessionID) { yield* Effect.logInfo("cancel", { "session.id": sessionID }) + // GOAL-TURN-SCOPE (ESC semantics): ESC on a goal-driven turn means "stop + // working on the goal", not just "stop this turn". Without this, the idle + // event after cancel re-enters afterIdle, shouldPreempt returns false (the + // last user message is the /goal command itself, necessarily older than + // this turn's assistant output), and the judge auto-resurrects the goal + // with a continuation the user just tried to abort. Pause instead. + if (goal && (yield* goal.isTurnDriven(sessionID))) { + yield* goal.pauseForUserCancel(sessionID, "用户中断(ESC)— /goal resume 继续").pipe(Effect.ignore) + } yield* state.cancel(sessionID) }) @@ -1663,7 +1672,12 @@ export const layer = Layer.effect( yield* events.publish(Session.Event.Error, { sessionID, error: error.toObject() }) throw error } - const maxSteps = agent.steps ?? Infinity + // GOAL-TURN-SCOPE: cap goal-driven turns (kick / continuation / + // resume-kick) so every goal turn reaches an idle boundary where the + // judge and the turn budget can engage. min() keeps a stricter + // user-configured agent.steps authoritative. + const goalMax = goal ? yield* goal.goalTurnMaxSteps(sessionID) : undefined + const maxSteps = Math.min(agent.steps ?? Infinity, goalMax ?? Infinity) const isLastStep = step >= maxSteps msgs = yield* SessionReminders.apply({ messages: msgs, agent, session }).pipe( Effect.provideService(RuntimeFlags.Service, flags), @@ -1996,6 +2010,10 @@ export const layer = Layer.effect( yield* sessions.updatePart(responsePart) yield* sessions.touch(input.sessionID) if (dispatchResult.type === "kick" && input.command === "goal") { + // GOAL-TURN-SCOPE: this loop() is a goal-driven turn (kick or + // resume-kick) — mark it so the step ceiling applies and ESC maps to + // a goal pause. + yield* goal?.markTurnDriven(input.sessionID) // Drain SessionStart hook contexts before loop if (startContext) { const contexts = yield* startContext.consume(input.sessionID) diff --git a/packages/opencode/test/goal/turn-scope.test.ts b/packages/opencode/test/goal/turn-scope.test.ts new file mode 100644 index 0000000000..99dde397d5 --- /dev/null +++ b/packages/opencode/test/goal/turn-scope.test.ts @@ -0,0 +1,155 @@ +import { describe, expect } from "bun:test" +import { Effect, Layer } from "effect" +import { Goal } from "@/goal/goal" +import { GoalPrompts } from "@/goal/prompts" +import { EventV2Bridge } from "@/event-v2-bridge" +import { SessionStatus } from "@/session/status" +import { Database } from "@opencode-ai/core/database/database" +import { SessionID } from "@/session/schema" +import { testEffect } from "../lib/effect" + +// GOAL-TURN-SCOPE regression tests: the turn-provenance mark (kick / +// continuation / resume-kick) drives (a) the goal-turn step ceiling surfaced by +// goalTurnMaxSteps, (b) ESC-on-goal-turn mapping to a durable pause, and (c) +// mark lifecycle across terminal transitions. Uses the real Goal layer (same +// shape as goal.test.ts) so the durable row, the event bus, and the +// process-local mark are all exercised. + +const testLayer = Goal.layer.pipe( + // provideMerge (not provide): the statusLine test body yields + // SessionStatus.Service to set busy/idle — it must see the SAME instance the + // Goal service reads. + Layer.provideMerge(EventV2Bridge.defaultLayer), + Layer.provideMerge(SessionStatus.defaultLayer), + Layer.provide(Database.defaultLayer), +) + +const it = testEffect(testLayer) + +describe("Goal turn-scope — markTurnDriven / goalTurnMaxSteps", () => { + it.live("unmarked session reports no ceiling", () => + Effect.gen(function* () { + const goal = yield* Goal.Service + const sid = SessionID.descending() + yield* goal.set(sid, "test goal", 5) + expect(yield* goal.goalTurnMaxSteps(sid)).toBeUndefined() + }), + ) + + it.live("marked + active goal reports GOAL_TURN_MAX_STEPS", () => + Effect.gen(function* () { + const goal = yield* Goal.Service + const sid = SessionID.descending() + yield* goal.set(sid, "test goal", 5) + yield* goal.markTurnDriven(sid) + expect(yield* goal.isTurnDriven(sid)).toBe(true) + expect(yield* goal.goalTurnMaxSteps(sid)).toBe(GoalPrompts.GOAL_TURN_MAX_STEPS) + }), + ) + + it.live("stale mark (goal cleared) self-retires and reports no ceiling", () => + Effect.gen(function* () { + const goal = yield* Goal.Service + const sid = SessionID.descending() + yield* goal.set(sid, "test goal", 5) + yield* goal.markTurnDriven(sid) + yield* goal.clear(sid) + // The durable row is gone: the next goalTurnMaxSteps probe must drop the + // mark instead of capping an unrelated turn. + expect(yield* goal.goalTurnMaxSteps(sid)).toBeUndefined() + expect(yield* goal.isTurnDriven(sid)).toBe(false) + }), + ) + + it.live("stale mark (goal paused) self-retires and reports no ceiling", () => + Effect.gen(function* () { + const goal = yield* Goal.Service + const sid = SessionID.descending() + yield* goal.set(sid, "test goal", 5) + yield* goal.markTurnDriven(sid) + yield* goal.pause(sid, "user-paused") + expect(yield* goal.goalTurnMaxSteps(sid)).toBeUndefined() + }), + ) +}) + +describe("Goal turn-scope — pauseForUserCancel (ESC semantics)", () => { + it.live("ESC on a marked turn pauses the goal durably and clears the mark", () => + Effect.gen(function* () { + const goal = yield* Goal.Service + const sid = SessionID.descending() + yield* goal.set(sid, "test goal", 5) + yield* goal.markTurnDriven(sid) + + const paused = yield* goal.pauseForUserCancel(sid, "用户中断(ESC)— /goal resume 继续") + expect(paused?.status).toBe("paused") + + const state = yield* goal.load(sid) + expect(state?.status).toBe("paused") + expect(state?.paused_reason).toBe("用户中断(ESC)— /goal resume 继续") + expect(yield* goal.isTurnDriven(sid)).toBe(false) + // A paused goal reports no step ceiling even if the mark somehow leaked. + expect(yield* goal.goalTurnMaxSteps(sid)).toBeUndefined() + }), + ) + + it.live("ESC-like cancel without an active goal is a no-op", () => + Effect.gen(function* () { + const goal = yield* Goal.Service + const sid = SessionID.descending() + const paused = yield* goal.pauseForUserCancel(sid, "ESC") + expect(paused).toBeUndefined() + }), + ) + + it.live("terminal transitions clear the mark (markDone)", () => + Effect.gen(function* () { + const goal = yield* Goal.Service + const sid = SessionID.descending() + yield* goal.set(sid, "test goal", 5) + yield* goal.markTurnDriven(sid) + yield* goal.markDone(sid, "/goal done") + expect(yield* goal.isTurnDriven(sid)).toBe(false) + expect(yield* goal.load(sid)).toBeUndefined() + }), + ) + + it.live("terminal transitions clear the mark (purgeSession)", () => + Effect.gen(function* () { + const goal = yield* Goal.Service + const sid = SessionID.descending() + yield* goal.set(sid, "test goal", 5) + yield* goal.markTurnDriven(sid) + yield* goal.purgeSession(sid) + expect(yield* goal.isTurnDriven(sid)).toBe(false) + }), + ) +}) + +describe("Goal turn-scope — statusLine executing indicator", () => { + // status.set needs an instance context (InstanceRef) — use it.instance so the + // test runs with a scoped temp instance. + it.instance("marked + busy session shows 执行中; idle shows 进行中", () => + Effect.gen(function* () { + const goal = yield* Goal.Service + const status = yield* SessionStatus.Service + const sid = SessionID.descending() + yield* goal.set(sid, "ship it", 5) + + const idleLine = yield* goal.statusLine(sid) + expect(idleLine).toContain("进行中") + expect(idleLine).not.toContain("执行中") + + yield* goal.markTurnDriven(sid) + yield* status.set(sid, { type: "busy" }) + const busyLine = yield* goal.statusLine(sid) + expect(busyLine).toContain("执行中") + expect(busyLine).toContain("0/5") + + // Marked but idle (turn ended, mark not yet retired) falls back to 进行中. + yield* status.set(sid, { type: "idle" }) + const backIdle = yield* goal.statusLine(sid) + expect(backIdle).toContain("进行中") + }), + ) +}) diff --git a/packages/opencode/test/hook/http-handler.test.ts b/packages/opencode/test/hook/http-handler.test.ts new file mode 100644 index 0000000000..ade67bdcba --- /dev/null +++ b/packages/opencode/test/hook/http-handler.test.ts @@ -0,0 +1,94 @@ +import { describe, expect } from "bun:test" +import { Effect, Layer } from "effect" +import { FetchHttpClient } from "effect/unstable/http" +import { SettingsHook } from "@/hook/settings" +import { SessionHooks } from "@/hook/session-hooks" +import { EventV2Bridge } from "@/event-v2-bridge" +import { Database } from "@opencode-ai/core/database/database" +import { SessionID } from "@/session/schema" +import { testEffect } from "../lib/effect" + +// httpHandler runtime contract against a real local server: configured +// entry.headers MUST reach the wire (auth tokens were silently dropped before +// this fix), and non-2xx responses MUST surface as the synthetic exitBlock so +// the trigger aggregator reports a block. + +const testLayer = SettingsHook.layer.pipe( + Layer.provide(EventV2Bridge.defaultLayer), + Layer.provide(Database.defaultLayer), + Layer.provideMerge(SessionHooks.defaultLayer), + Layer.provideMerge(FetchHttpClient.layer), +) +const it = testEffect(testLayer) + +const withFetch = ( + fetch: (req: Request) => Response | Promise, + fn: (url: string) => Effect.Effect, +) => + Effect.acquireUseRelease( + Effect.sync(() => Bun.serve({ port: 0, fetch })), + (server) => fn(server.url.toString()), + (server) => Effect.sync(() => server.stop(true)), + ) + +describe("SettingsHook http handler", () => { + it.instance("applies configured entry.headers to the outbound POST", () => + Effect.gen(function* () { + const sessionHooks = yield* SessionHooks.Service + const hook = yield* SettingsHook.Service + const sessionID = SessionID.descending() + let seen: Headers | undefined + yield* withFetch( + (req) => { + seen = req.headers + return new Response("{}", { status: 200, headers: { "content-type": "application/json" } }) + }, + (url) => + Effect.gen(function* () { + yield* sessionHooks.add(sessionID, { + event: "UserPromptSubmit", + hooks: [ + { + type: "http", + url, + headers: { authorization: "Bearer hook-secret", "x-hook-test": "present" }, + }, + ], + }) + const r = yield* hook.trigger( + { event: "UserPromptSubmit", prompt: "hi" }, + { sessionID, transcriptPath: "" }, + ) + expect(r.blocked).toBeUndefined() + expect(seen).toBeDefined() + expect(seen?.get("authorization")).toBe("Bearer hook-secret") + expect(seen?.get("x-hook-test")).toBe("present") + }), + ) + }), + ) + + it.instance("non-2xx response surfaces as exitBlock", () => + Effect.gen(function* () { + const sessionHooks = yield* SessionHooks.Service + const hook = yield* SettingsHook.Service + const sessionID = SessionID.descending() + yield* withFetch( + () => new Response("nope", { status: 500 }), + (url) => + Effect.gen(function* () { + yield* sessionHooks.add(sessionID, { + event: "UserPromptSubmit", + hooks: [{ type: "http", url }], + }) + const r = yield* hook.trigger( + { event: "UserPromptSubmit", prompt: "hi" }, + { sessionID, transcriptPath: "" }, + ) + expect(r.blocked).toBeDefined() + expect(r.blocked?.reason).toContain("500") + }), + ) + }), + ) +}) diff --git a/packages/opencode/test/server/httpapi-exercise/index.ts b/packages/opencode/test/server/httpapi-exercise/index.ts index 439e985a58..ad9f3a2080 100644 --- a/packages/opencode/test/server/httpapi-exercise/index.ts +++ b/packages/opencode/test/server/httpapi-exercise/index.ts @@ -1237,6 +1237,24 @@ const scenarios: Scenario[] = [ body: { event: "NotAnEvent", hooks: [{ type: "command", command: "printf '%s' 'x'" }] }, })) .status(400), + http.protected + .post("/session/{sessionID}/hook", "session.hook.add.empty_command") + .seeded((ctx) => ctx.session({ title: "Hook empty command session" })) + .at((ctx) => ({ + path: route("/session/{sessionID}/hook", { sessionID: ctx.state.id }), + headers: ctx.headers(), + body: { event: "UserPromptSubmit", hooks: [{ type: "command", command: "" }] }, + })) + .status(400), + http.protected + .post("/session/{sessionID}/hook", "session.hook.add.non_positive_timeout") + .seeded((ctx) => ctx.session({ title: "Hook bad timeout session" })) + .at((ctx) => ({ + path: route("/session/{sessionID}/hook", { sessionID: ctx.state.id }), + headers: ctx.headers(), + body: { event: "UserPromptSubmit", hooks: [{ type: "command", command: "true", timeout: 0 }] }, + })) + .status(400), http.protected .get("/session/{sessionID}/hook", "session.hook.list") .seeded((ctx) => ctx.session({ title: "Hook list session" })) diff --git a/packages/opencode/test/server/session-hooks-api.test.ts b/packages/opencode/test/server/session-hooks-api.test.ts new file mode 100644 index 0000000000..9aaf012f14 --- /dev/null +++ b/packages/opencode/test/server/session-hooks-api.test.ts @@ -0,0 +1,74 @@ +import { afterEach, describe, expect } from "bun:test" +import { Effect, Layer } from "effect" +import { Session } from "@/session/session" +import { disposeAllInstances, TestInstance } from "../fixture/fixture" +import { testEffect } from "../lib/effect" +import { httpApiLayer, requestInDirectory } from "./httpapi-layer" + +const it = testEffect(Layer.mergeAll(Session.defaultLayer, httpApiLayer)) + +afterEach(() => disposeAllInstances()) + +function addHook(directory: string, sessionID: string, hook: Record) { + return requestInDirectory(`/session/${sessionID}/hook`, directory, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ event: "UserPromptSubmit", hooks: [hook] }), + }) +} + +describe("session hook add validation", () => { + it.instance( + "rejects command-type hooks with a missing or blank command", + () => + Effect.gen(function* () { + const test = yield* TestInstance + const session = yield* Session.use.create({}) + + const missing = yield* addHook(test.directory, session.id, { type: "command" }) + expect(missing.status).toBe(400) + + const blank = yield* addHook(test.directory, session.id, { type: "command", command: " " }) + expect(blank.status).toBe(400) + }), + { git: true }, + ) + + it.instance( + "rejects non-positive timeout", + () => + Effect.gen(function* () { + const test = yield* TestInstance + const session = yield* Session.use.create({}) + + const zero = yield* addHook(test.directory, session.id, { type: "command", command: "true", timeout: 0 }) + expect(zero.status).toBe(400) + + const negative = yield* addHook(test.directory, session.id, { type: "command", command: "true", timeout: -5 }) + expect(negative.status).toBe(400) + }), + { git: true }, + ) + + it.instance( + "accepts valid command and http hooks", + () => + Effect.gen(function* () { + const test = yield* TestInstance + const session = yield* Session.use.create({}) + + const command = yield* addHook(test.directory, session.id, { type: "command", command: "true" }) + expect(command.status).toBe(200) + expect(typeof ((yield* command.json) as { id: string }).id).toBe("string") + + const http = yield* addHook(test.directory, session.id, { + type: "http", + url: "https://hooks.example.com/endpoint", + timeout: 30, + headers: { authorization: "Bearer token" }, + }) + expect(http.status).toBe(200) + }), + { git: true }, + ) +}) diff --git a/packages/tui/src/routes/session/question.tsx b/packages/tui/src/routes/session/question.tsx index 191d0a936a..cdd1a83967 100644 --- a/packages/tui/src/routes/session/question.tsx +++ b/packages/tui/src/routes/session/question.tsx @@ -1,6 +1,6 @@ import { createStore } from "solid-js/store" import { createMemo, createSignal, For, onCleanup, onMount, Show } from "solid-js" -import { useRenderer } from "@opentui/solid" +import { useRenderer, useTerminalDimensions } from "@opentui/solid" import type { TextareaRenderable } from "@opentui/core" import { selectedForeground, tint, useTheme } from "../../context/theme" import type { QuestionAnswer, QuestionRequest } from "@opencode-ai/sdk/v2" @@ -11,16 +11,33 @@ import { useBindings, useOpencodeModeStack } from "../../keymap" const QUESTION_MODE = "question" +function truncateWidth(str: string, max: number) { + if (Bun.stringWidth(str) <= max) return str + let out = "" + let width = 0 + for (const ch of str) { + const next = width + Bun.stringWidth(ch) + if (next > max - 1) break + out += ch + width = next + } + return out + "…" +} + export function QuestionPrompt(props: { request: QuestionRequest; directory?: string }) { const sdk = useSDK() const { theme } = useTheme() const renderer = useRenderer() + const dimensions = useTerminalDimensions() const tuiConfig = useTuiConfig() const modeStack = useOpencodeModeStack() const questions = createMemo(() => props.request.questions) const single = createMemo(() => questions().length === 1 && questions()[0]?.multiple !== true) const tabs = createMemo(() => (single() ? 1 : questions().length + 1)) // questions + confirm tab (no confirm for single select) + // Headers are model-generated and can be arbitrarily long (the schema only suggests "max 30 chars"), + // so clamp each tab to a share of the terminal width to keep the tab row on one line + const headerBudget = createMemo(() => Math.min(24, Math.max(6, Math.floor((dimensions().width - 8) / tabs())))) const [tabHover, setTabHover] = createSignal(null) const [store, setStore] = createStore({ tab: 0, @@ -328,7 +345,7 @@ export function QuestionPrompt(props: { request: QuestionRequest; directory?: st : theme.textMuted } > - {q.header} + {truncateWidth(q.header, headerBudget())} )