From 1164478ecb71fe3422c69d380206dc637789d59d Mon Sep 17 00:00:00 2001 From: Daniel Ehrhardt Date: Thu, 29 Oct 2026 23:09:26 +0100 Subject: [PATCH] Let agents end a chat's /goal themselves (goal_clear) When the human drops or replaces a /goal, the agent ends it with the new goal_clear tool instead of handing them a task to type /goal clear. The goal's check stops holding the turn up, and after the turn Godmode runs /goal clear in the Claude session (a local command, no model call) and notes it in the chat with the agent's reason. The goal is read from the session's latest goal_status, also for GLM runs. --- apps/desktop/src/components/chat/tool-meta.ts | 4 + docs/ARCHITECTURE.md | 1 + packages/core/src/mcp/tools.ts | 21 ++- packages/core/src/runner/prompt.ts | 2 +- packages/core/src/runner/runner.ts | 83 ++++++++++++ packages/core/src/runner/stream.ts | 3 + packages/core/src/services/conversations.ts | 55 +++++++- packages/core/test/fixtures/fake-claude.ts | 60 +++++++++ packages/core/test/goal-clear.test.ts | 123 ++++++++++++++++++ 9 files changed, 345 insertions(+), 7 deletions(-) create mode 100644 packages/core/test/goal-clear.test.ts diff --git a/apps/desktop/src/components/chat/tool-meta.ts b/apps/desktop/src/components/chat/tool-meta.ts index 68d53590..d94a1f97 100644 --- a/apps/desktop/src/components/chat/tool-meta.ts +++ b/apps/desktop/src/components/chat/tool-meta.ts @@ -20,6 +20,7 @@ import { FileSearch, FileText, FileUp, + FlagOff, FolderKanban, FolderSearch, Globe, @@ -416,6 +417,7 @@ const GODMODE_TOOLS = new Set([ "notify_user", "followup_schedule", "followup_cancel", + "goal_clear", "ask_human", "request_approval", "tasks_list", @@ -571,6 +573,8 @@ function godmodeMeta(tool: string, input: Input, ctx: ToolContext): Omit (cancelFollowup(ctx.conversationId) ? "Follow-up removed." : "This chat had no follow-up."), }), + defineTool({ + name: "goal_clear", + description: + "End this chat's `/goal` yourself, as the human would with `/goal clear`. Only when the goal is over without being met: the human told you to end, drop or replace it, or a decision of theirs leaves nothing to work towards. Never to get out of work that is merely hard, long or unfinished. The goal's check no longer holds this turn up: Godmode clears the goal when the turn is over and notes it in the chat with your reason. Never hand the human a task to type `/goal clear`.", + schema: z.object({ + reason: z.string().min(1).max(300).describe('Why it ends, shown in the chat, e.g. "Daniel decided on 10-28 to end it"'), + }), + when: canFollowUp, + run: ({ reason }, { ctx }) => { + const goal = chatGoal(ctx.conversationId, runClaudeConfigDir(ctx.runId)); + if (!goal) return "This chat has no active /goal — nothing to end."; + const refused = endGoalAfterTurn(ctx.runId, reason); + if (refused) return fail(refused); + return `The goal “${goal.length > 200 ? `${goal.slice(0, 199).trimEnd()}…` : goal}” ends when this turn is over. Finish your answer now and don't work on it any further.`; + }, + }), + defineTool({ name: "artifact_create", description: `Build an artifact: a small web app the human opens right in Godmode — a sales dashboard, a KPI tracker, a deal board, a calculator, a planner. Use it whenever they ask for a dashboard, a tracker, a tool or a page to look at or work with (not for a one-off answer or a file they asked for). It shows next to this chat and on the Artifacts page; they change it by chatting with you, and every code change is a version they can go back to. diff --git a/packages/core/src/runner/prompt.ts b/packages/core/src/runner/prompt.ts index 32860de8..c7a0a94b 100644 --- a/packages/core/src/runner/prompt.ts +++ b/packages/core/src/runner/prompt.ts @@ -406,7 +406,7 @@ When you can't go on because ${human} has to do something themselves — solve a if (ctx.followups) { out.push(`### Following up later -When a task can't be finished now because you have to wait — for a reply to an email or message, a delivery, a build or deployment, a status or price change, office hours, another person — don't leave it to ${human} to remind you. Schedule a follow-up with \`followup_schedule({ at | inMinutes, note })\`, like a coworker who says "I'll check back tomorrow at 10": at that time Godmode continues this chat on its own and you pick up where you left off, with the whole conversation. Pick a realistic time (when the answer is likely there; business hours when people are involved) and write the note so you know exactly what to check and do. Then end your turn with a short summary of what you're waiting for and when you'll continue. The same holds for a \`/goal\` whose remaining parts wait on such outside events: schedule the follow-up and end the turn with a short status; Godmode pauses the goal loop until then. Don't wait inside the turn instead: no \`sleep\`, \`until\` or polling loop that runs longer than about ten minutes, in the foreground or as a background task — a run that shows no sign of life that long is stopped. A chat has one follow-up: scheduling again moves it, \`followup_cancel\` removes it. Don't schedule follow-ups for work you can do now or for things that repeat on a schedule${perms.canManageAgents ? " (those are automations)" : ""}.`); +When a task can't be finished now because you have to wait — for a reply to an email or message, a delivery, a build or deployment, a status or price change, office hours, another person — don't leave it to ${human} to remind you. Schedule a follow-up with \`followup_schedule({ at | inMinutes, note })\`, like a coworker who says "I'll check back tomorrow at 10": at that time Godmode continues this chat on its own and you pick up where you left off, with the whole conversation. Pick a realistic time (when the answer is likely there; business hours when people are involved) and write the note so you know exactly what to check and do. Then end your turn with a short summary of what you're waiting for and when you'll continue. The same holds for a \`/goal\` whose remaining parts wait on such outside events: schedule the follow-up and end the turn with a short status; Godmode pauses the goal loop until then. When ${human} ends, drops or replaces a \`/goal\`, end it yourself with \`goal_clear\`. Don't wait inside the turn instead: no \`sleep\`, \`until\` or polling loop that runs longer than about ten minutes, in the foreground or as a background task — a run that shows no sign of life that long is stopped. A chat has one follow-up: scheduling again moves it, \`followup_cancel\` removes it. Don't schedule follow-ups for work you can do now or for things that repeat on a schedule${perms.canManageAgents ? " (those are automations)" : ""}.`); } if (ctx.calls) { diff --git a/packages/core/src/runner/runner.ts b/packages/core/src/runner/runner.ts index ea192f44..129e485e 100644 --- a/packages/core/src/runner/runner.ts +++ b/packages/core/src/runner/runner.ts @@ -405,6 +405,10 @@ interface Job { private?: boolean; /** Godmode ended it because a `/goal` kept it going with nothing to do: it succeeded. */ goalPaused?: boolean; + /** The agent ended the chat's `/goal` (`goal_clear`) when the hook had blocked this many times: Godmode clears it once the process is done. */ + goalClear?: { reason: string; atCheck: number }; + /** Where its Claude Code keeps sessions (GLM runs have a config folder of their own); null = the default. */ + claudeConfigDir?: string | null; /** Resolves ids in tool input for the activity label (memoized per run). */ names?: ActivityNames; /** The human started or let it run: a used-up monthly budget doesn't hold it. */ @@ -1803,6 +1807,12 @@ async function spawnClaude( if (job.acc.push(event)) scheduleDelta(job); showContext(job); if (job.acc.goalIdle >= GOAL_IDLE_LIMIT && !job.goalPaused && !job.pause && !halted(job) && !job.timedOut && !job.stalled) pauseGoal(job, proc); + // The agent ended the goal: the hook's next block is only the goal it no longer pursues. + if (job.goalClear && job.acc.goalChecks > job.goalClear.atCheck && !job.goalPaused && !job.pause && !halted(job) && !job.timedOut && !job.stalled) { + job.goalPaused = true; + log.info(`run ${job.runId}: the agent ended the goal, ending the run`); + killTree(proc); + } // Claude has read the human's answer the moment it starts replying: settle it now, so a crash later in the run // can't hand the same answer (an approval: "Do it now") to the next turn again. if (job.acc.answered && !job.answersSettled) { @@ -1841,6 +1851,72 @@ function pauseGoal(job: Job, proc: Subprocess) { killTree(proc); } +/** + * `goal_clear`: the agent ends the chat's `/goal` (the human dropped it, replaced it, or it can no longer be reached). + * The goal's check doesn't hold this turn up anymore, and Godmode clears the goal in the session once the process is done. + */ +export function endGoalAfterTurn(runId: string, reason: string): string | null { + const job = jobs.get(runId); + if (!job || job.status !== "running" || job.cancelReason || job.timedOut || job.stalled) return "This run is being stopped — the goal stays. Try again in the next turn."; + job.goalClear = { reason, atCheck: job.acc.goalChecks }; + return null; +} + +/** The Claude Code config folder the run's sessions live in, or null for the default (or no such run). */ +export function runClaudeConfigDir(runId: string): string | null { + return jobs.get(runId)?.claudeConfigDir ?? null; +} + +const GOAL_CLEAR_TIMEOUT_MS = 60_000; + +/** Clear the session's `/goal` as the human would with `/goal clear`: Claude Code runs it without the model. */ +async function clearGoal(job: Job, cmd: string[], sessionId: string, cwd: string, env: Record): Promise { + const reason = job.goalClear?.reason ?? ""; + let said = ""; + let timer: ReturnType | undefined; + try { + const proc = Bun.spawn({ + cmd: [...cmd, "-p", "--resume", sessionId, "--output-format", "json", "--setting-sources", "", "--strict-mcp-config"], + cwd, + env, + stdin: "pipe", + stdout: "pipe", + stderr: "ignore", + detached: process.platform !== "win32", + }); + job.proc = proc; + timer = setTimeout(() => killTree(proc), GOAL_CLEAR_TIMEOUT_MS); + proc.stdin.write("/goal clear"); + await proc.stdin.end(); + const [out] = await Promise.all([new Response(proc.stdout).text(), proc.exited]); + for (const line of out.trim().split("\n").reverse()) { + try { + const event = JSON.parse(line) as { type?: unknown; result?: unknown }; + if (event.type === "result" && typeof event.result === "string") { + said = event.result; + break; + } + } catch { + /* not a line of its own */ + } + } + } catch (err) { + log.warn(`run ${job.runId}: could not clear the goal of session ${sessionId}`, err); + } finally { + clearTimeout(timer); + job.proc = null; + } + const cleared = /^Goal cleared: ([\s\S]+)/.exec(said.trim()); + if (cleared) { + const goal = cleared[1]!.replace(/\s+/g, " ").trim(); + job.acc.addNotice("info", `Goal ended: “${goal.length > 160 ? `${goal.slice(0, 159).trimEnd()}…` : goal}”${reason ? ` — ${reason.replace(/[.!]$/, "")}` : ""}.`); + log.info(`run ${job.runId}: goal of session ${sessionId} cleared`); + } else if (!/^No goal set/.test(said.trim())) { + job.acc.addNotice("warning", "Godmode couldn't end this chat's goal. Type /goal clear to end it."); + } + scheduleDelta(job); +} + /** The model a run is recorded with: what Claude reported, but for GLM the requested id (Z.ai may name it otherwise). */ function recordedModel(job: Job, acc: { model: string | null }): string | null { return job.model && modelProvider(job.model) === "zai" ? job.model : (acc.model ?? job.model ?? null); @@ -2345,6 +2421,7 @@ async function runClaude(job: Job, agent: Agent, res: Resources): Promise t.status === "running"); + this.goalChecks++; if (this.goalFeedback !== null) this.goalIdle = this.toolCalls > this.toolCallsAtGoalCheck || busy ? 0 : this.goalIdle + 1; this.toolCallsAtGoalCheck = this.toolCalls; // "[]: Not satisfied. …": the verdict is what tells. diff --git a/packages/core/src/services/conversations.ts b/packages/core/src/services/conversations.ts index 2c88239d..7ca4f5dc 100644 --- a/packages/core/src/services/conversations.ts +++ b/packages/core/src/services/conversations.ts @@ -3,7 +3,7 @@ * A conversation belongs to one agent; every user turn starts a run (runner/runner.ts) that streams * into one assistant message. A human-readable transcript is kept in the agent repo. */ -import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { appendFileSync, closeSync, existsSync, fstatSync, mkdirSync, openSync, readdirSync, readFileSync, readSync, rmSync, statSync, writeFileSync } from "node:fs"; import { basename, extname, join } from "node:path"; import type { Agent, @@ -678,9 +678,9 @@ export function isPrivateChat(id: string): boolean { } /** Claude Code's own record of a session: its JSONL and the folder of its subagents and tool output, in any project. */ -function claudeSessionPaths(sessionId: string): string[] { +function claudeSessionPaths(sessionId: string, configDir = claudeConfigDir()): string[] { if (!/^[A-Za-z0-9][A-Za-z0-9_-]{0,99}$/.test(sessionId)) return []; - const root = join(claudeConfigDir(), "projects"); + const root = join(configDir, "projects"); let dirs: string[]; try { dirs = readdirSync(root); @@ -690,6 +690,53 @@ function claudeSessionPaths(sessionId: string): string[] { return dirs.flatMap((dir) => [join(root, dir, `${sessionId}.jsonl`), join(root, dir, sessionId)].filter((p) => existsSync(p))); } +/** How far back a goal status is looked for at most: while a goal is set, its latest status is near the end. */ +const GOAL_LOOKBACK_BYTES = 64 * 1024 * 1024; + +/** + * The condition of the chat's `/goal` while one is set, or null. Claude Code keeps it in the session: the latest goal + * status there tells, as it does when the session resumes (met, failed or cleared = none). `configDir`: where the + * run's Claude Code keeps its sessions (GLM runs have their own). + */ +export function chatGoal(conversationId: string, configDir?: string | null): string | null { + const sessionId = get<{ claude_session_id: string | null }>("SELECT claude_session_id FROM conversations WHERE id = ?", conversationId)?.claude_session_id; + const files = sessionId ? claudeSessionPaths(sessionId, configDir || undefined).filter((p) => p.endsWith(".jsonl")) : []; + const newest = files.map((p) => ({ p, at: statSync(p, { throwIfNoEntry: false })?.mtimeMs ?? 0 })).sort((x, y) => y.at - x.at)[0]?.p; + if (!newest) return null; + let fd: number; + try { + fd = openSync(newest, "r"); + } catch { + return null; + } + try { + const size = fstatSync(fd).size; + for (let span = Math.min(size, 1 << 20); ; span = Math.min(size, GOAL_LOOKBACK_BYTES, span * 4)) { + const buf = Buffer.alloc(span); + readSync(fd, buf, 0, span, size - span); + let text = buf.toString("utf8"); + // A line cut off at the start of the window is read with the next, larger one. + if (span < size) text = text.slice(text.indexOf("\n") + 1); + for (let at = text.lastIndexOf('"goal_status"'); at >= 0; at = text.lastIndexOf('"goal_status"', at - 1)) { + const end = text.indexOf("\n", at); + let status: { type?: unknown; met?: unknown; failed?: unknown; condition?: unknown } | undefined; + try { + status = (JSON.parse(text.slice(text.lastIndexOf("\n", at) + 1, end < 0 ? undefined : end)) as { attachment?: typeof status }).attachment; + } catch { + continue; + } + if (status?.type !== "goal_status") continue; + return !status.met && !status.failed && typeof status.condition === "string" && status.condition ? status.condition : null; + } + if (span >= size || span >= GOAL_LOOKBACK_BYTES) return null; + } + } catch { + return null; + } finally { + closeSync(fd); + } +} + /** * Wipe a private chat: its messages, uploads, run logs and Claude Code session go, and its runs keep only what they * cost (budgets count it). Other chats are left alone. @@ -714,7 +761,7 @@ export async function discardPrivateChat(id: string): Promise { const files = [ ...logs, ...(agent ? uploads.filter((p) => p.startsWith("workspace/uploads/")).map((p) => join(agent.repoPath, p)) : []), - ...[...sessions].flatMap(claudeSessionPaths), + ...[...sessions].flatMap((s) => claudeSessionPaths(s)), ]; for (const file of new Set(files)) { try { diff --git a/packages/core/test/fixtures/fake-claude.ts b/packages/core/test/fixtures/fake-claude.ts index e6fe0f2e..b4db8033 100755 --- a/packages/core/test/fixtures/fake-claude.ts +++ b/packages/core/test/fixtures/fake-claude.ts @@ -84,6 +84,11 @@ * SHOTS: run tool steps that each answer with a screenshot and a line of text, then answer "shots done". * With THEN_WAIT: it first waits for the state dir's `-next` file, writes "almost there", and * waits for `-done` + * GOAL_CLEAR a turn of a chat whose `/goal` (written to the session file under CLAUDE_CONFIG_DIR) is over: the agent + * calls the gateway's goal_clear (reason from the state dir's `goal-clear-args.json`, result to + * `goal-clear-result.json`), answers, and the goal's Stop hook blocks the end of the turn; the process + * then idles for 5 s, writes `goal-clear-idled` and answers "No response requested." GOAL_CLEAR_LATE + * lets the hook block once before the call. `/goal clear` clears that goal. * / a slash command Claude Code runs locally (`/clear` resets the session, `/model bogus` is rejected, * `/effort ultracode [on|off]` switches Ultracode, and a new effort level ends an Ultracode that the * --settings file turned on) @@ -360,6 +365,22 @@ function textTurn(text: string) { const slash = /^\/(\S+)\s*([\s\S]*)$/.exec(prompt.trim()); +/** Claude Code's own record of the session, where `/goal` keeps its state. */ +const sessionFile = () => join(process.env.CLAUDE_CONFIG_DIR ?? join(stateDir, "claude"), "projects", "fake", `${sessionId}.jsonl`); +const goalStatus = (status: Record) => { + mkdirSync(join(sessionFile(), ".."), { recursive: true }); + appendFileSync(sessionFile(), JSON.stringify({ type: "attachment", uuid: crypto.randomUUID(), sessionId, attachment: { type: "goal_status", ...status } }) + "\n"); +}; +const activeGoal = (): string | null => { + if (!existsSync(sessionFile())) return null; + const statuses = readFileSync(sessionFile(), "utf8") + .split("\n") + .filter((l) => l.includes('"goal_status"')) + .map((l) => (JSON.parse(l) as { attachment: { met: boolean; condition: string } }).attachment); + const last = statuses.at(-1); + return last && !last.met ? last.condition : null; +}; + /** Ask the human through the gateway like an agent does, as one step, then stand still at the hook. */ async function ask(mode: "question" | "approval" | "twice" | "nohook") { out(init); @@ -419,6 +440,12 @@ if (slash?.[1] === "clear") { out({ type: "conversation_reset", new_conversation_id: fresh, trigger: "clear" }); out({ ...init, session_id: fresh }); result("", { session_id: fresh, num_turns: 0, local_command: "clear" }); +} else if (slash?.[1] === "goal" && slash[2].trim() === "clear") { + const goal = activeGoal(); + if (goal) goalStatus({ met: true, sentinel: true, condition: goal }); + appendFileSync(join(stateDir, "goal-clears"), `${sessionId}\n`); + const text = goal ? `Goal cleared: ${goal}` : "No goal set"; + out({ type: "result", subtype: "success", is_error: false, num_turns: 0, duration_ms: 1, total_cost_usd: 0, result: text, session_id: sessionId }); } else if (slash) { const [, name, args] = slash; out(init); @@ -1098,6 +1125,39 @@ if (slash?.[1] === "clear") { const text = `GUEST ${JSON.stringify(summary)}`; textTurn(text); result(text); +} else if (prompt.includes("GOAL_CLEAR")) { + out(init); + const goal = "sell the course and make the first sale"; + goalStatus({ met: false, sentinel: true, condition: goal }); + const cfg = JSON.parse(readFileSync(argValue("--mcp-config")!, "utf8")) as { mcpServers: Record }> }; + const gw = cfg.mcpServers.godmode!; + const rpc = async (body: unknown) => { + const res = await fetch(gw.url, { method: "POST", headers: { ...gw.headers, "Content-Type": "application/json", Accept: "application/json" }, body: JSON.stringify(body) }); + const raw = await res.text(); + return raw ? JSON.parse(raw) : null; + }; + await rpc({ jsonrpc: "2.0", id: 1, method: "initialize", params: { protocolVersion: "2025-06-18", capabilities: {}, clientInfo: { name: "fake", version: "1" } } }); + const verdict = () => { + goalStatus({ met: false, condition: goal, reason: "No paying customer yet." }); + out({ type: "user", message: { role: "user", content: [{ type: "text", text: `Stop hook feedback:\n[${goal}]: Not satisfied. No paying customer yet.` }] }, parent_tool_use_id: null, session_id: sessionId }); + }; + if (prompt.includes("GOAL_CLEAR_LATE")) { + textTurn("Still no sale."); + verdict(); + await pause(150); + } + const argsFile = join(stateDir, "goal-clear-args.json"); + const input = existsSync(argsFile) ? (JSON.parse(readFileSync(argsFile, "utf8")) as Record) : { reason: "Daniel decided to end it." }; + out({ type: "assistant", message: { id: "msg_goal_clear", role: "assistant", content: [{ type: "tool_use", id: "toolu_goal_clear", name: "mcp__godmode__goal_clear", input }] }, parent_tool_use_id: null, session_id: sessionId }); + const called = await rpc({ jsonrpc: "2.0", id: 2, method: "tools/call", params: { name: "goal_clear", arguments: input } }); + writeFileSync(join(stateDir, "goal-clear-result.json"), JSON.stringify(called.result)); + out({ type: "user", message: { role: "user", content: [{ type: "tool_result", tool_use_id: "toolu_goal_clear", content: called.result.content[0].text, is_error: called.result.isError === true }] }, parent_tool_use_id: null, session_id: sessionId }); + textTurn("The line is closed, the site stays live."); + verdict(); + await pause(5000); + writeFileSync(join(stateDir, "goal-clear-idled"), ""); + textTurn("No response requested."); + result("No response requested."); } else if (prompt.includes("GOAL_LOOP")) { await replay(prompt.includes("GOAL_LOOP_WORKING") ? "stream-goal-working.jsonl" : "stream-goal-loop.jsonl", async (event) => { if (event.type !== "user" || !JSON.stringify(event).includes("Stop hook feedback:")) return; diff --git a/packages/core/test/goal-clear.test.ts b/packages/core/test/goal-clear.test.ts new file mode 100644 index 00000000..c9e59b38 --- /dev/null +++ b/packages/core/test/goal-clear.test.ts @@ -0,0 +1,123 @@ +/** + * `goal_clear`: an agent ends its chat's `/goal` itself when the human dropped it, instead of handing them a task to + * type `/goal clear`. The goal's check no longer holds the turn up, and Godmode clears the goal in the session after it. + */ +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { appendFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { Agent, MessageBlock } from "@godmode/shared"; +import { argValue, invocations, makeAgent, setupEnv, type TestEnv } from "./fixtures/runner-harness"; +import { callTool } from "../src/mcp/tools"; +import { chatGoal, getConversation, sendMessage, startChat } from "../src/services/conversations"; +import { waitForRun } from "../src/runner/runner"; + +let env: TestEnv; +let agent: Agent; +let configDir: string; +const configBefore = process.env.CLAUDE_CONFIG_DIR; + +beforeAll(async () => { + configDir = mkdtempSync(join(tmpdir(), "godmode-goal-claude-")); + process.env.CLAUDE_CONFIG_DIR = configDir; + env = await setupEnv("godmode-goal-clear-"); + agent = await makeAgent({ name: "Goal Bot", description: "test agent" }); +}); + +afterAll(async () => { + await env.close(); + if (configBefore === undefined) delete process.env.CLAUDE_CONFIG_DIR; + else process.env.CLAUDE_CONFIG_DIR = configBefore; + rmSync(configDir, { recursive: true, force: true }); +}); + +const notices = (blocks: MessageBlock[]) => blocks.flatMap((b) => (b.type === "notice" ? [b.text] : [])); +const clears = () => (existsSync(join(env.stateDir, "goal-clears")) ? readFileSync(join(env.stateDir, "goal-clears"), "utf8").split("\n").filter(Boolean).length : 0); + +async function chat(): Promise<{ conversationId: string; sessionId: string }> { + const started = await startChat({ agentId: agent.id, content: "hello" }); + await waitForRun(started.run.id, 20_000); + const conversationId = started.conversation.id; + return { conversationId, sessionId: getConversation(conversationId).claudeSessionId! }; +} + +function goalStatus(sessionId: string, status: Record) { + const dir = join(configDir, "projects", "-tmp-somewhere"); + mkdirSync(dir, { recursive: true }); + appendFileSync(join(dir, `${sessionId}.jsonl`), `${JSON.stringify({ type: "attachment", attachment: { type: "goal_status", ...status } })}\n`); +} + +describe("the chat's goal", () => { + test("is the latest goal status of its Claude session: none once it was met, failed or cleared", async () => { + const { conversationId, sessionId } = await chat(); + expect(chatGoal(conversationId)).toBeNull(); + goalStatus(sessionId, { met: false, sentinel: true, condition: "ship the report" }); + expect(chatGoal(conversationId)).toBe("ship the report"); + goalStatus(sessionId, { met: false, condition: "ship the report", reason: "Not yet." }); + expect(chatGoal(conversationId)).toBe("ship the report"); + goalStatus(sessionId, { met: true, sentinel: true, condition: "ship the report" }); + expect(chatGoal(conversationId)).toBeNull(); + goalStatus(sessionId, { met: false, sentinel: true, condition: "and the invoice" }); + expect(chatGoal(conversationId)).toBe("and the invoice"); + goalStatus(sessionId, { met: false, failed: true, condition: "and the invoice" }); + expect(chatGoal(conversationId)).toBeNull(); + }); +}); + +describe("goal_clear", () => { + test("the agent ends the goal: the run ends at the goal's check, the goal is cleared and the chat says why", async () => { + const { conversationId, sessionId } = await chat(); + const before = clears(); + rmSync(join(env.stateDir, "goal-clear-idled"), { force: true }); + const run = await waitForRun((await sendMessage(conversationId, { content: "GOAL_CLEAR" })).run.id, 20_000); + expect(run.status).toBe("succeeded"); + expect(run.error).toBeNull(); + // It doesn't sit out the continuation the goal's check forced. + expect(existsSync(join(env.stateDir, "goal-clear-idled"))).toBe(false); + + const called = JSON.parse(readFileSync(join(env.stateDir, "goal-clear-result.json"), "utf8")) as { isError?: boolean; content: { text: string }[] }; + expect(called.isError).toBeFalsy(); + expect(called.content[0]!.text).toContain("sell the course and make the first sale"); + + const message = getConversation(conversationId).messages.at(-1)!; + expect(message.content).toBe("The line is closed, the site stays live."); + expect(notices(message.blocks)).toContain("Goal ended: “sell the course and make the first sale” — Daniel decided to end it."); + expect(notices(message.blocks).some((t) => t.startsWith("Goal still open"))).toBe(false); + + expect(clears()).toBe(before + 1); + const clear = invocations(env).at(-1)!; + expect(clear.prompt).toBe("/goal clear"); + expect(argValue(clear, "--resume")).toBe(sessionId); + expect(chatGoal(conversationId)).toBeNull(); + // The chat goes on in the same session. + expect(getConversation(conversationId).claudeSessionId).toBe(sessionId); + }); + + test("called in a continuation the goal's check forced, it waits for the next check: the answer stays", async () => { + const { conversationId } = await chat(); + rmSync(join(env.stateDir, "goal-clear-idled"), { force: true }); + const run = await waitForRun((await sendMessage(conversationId, { content: "GOAL_CLEAR_LATE" })).run.id, 20_000); + expect(run.status).toBe("succeeded"); + expect(existsSync(join(env.stateDir, "goal-clear-idled"))).toBe(false); + const message = getConversation(conversationId).messages.at(-1)!; + expect(message.content).toContain("The line is closed, the site stays live."); + expect(notices(message.blocks).filter((t) => t.startsWith("Goal ended"))).toHaveLength(1); + expect(chatGoal(conversationId)).toBeNull(); + }); + + test("a chat without a goal has nothing to end", async () => { + const { conversationId } = await chat(); + const out = await callTool({ runId: "run_goal_test", agentId: agent.id, conversationId, workspaceId: null, depth: 0 }, "goal_clear", { reason: "Daniel ended it." }); + expect(out.isError).toBeFalsy(); + expect(out.content[0]!.text).toBe("This chat has no active /goal — nothing to end."); + }); + + test("a run that isn't working anymore keeps the goal", async () => { + const { conversationId, sessionId } = await chat(); + goalStatus(sessionId, { met: false, sentinel: true, condition: "ship the report" }); + const out = await callTool({ runId: "run_gone", agentId: agent.id, conversationId, workspaceId: null, depth: 0 }, "goal_clear", { reason: "Daniel ended it." }); + expect(out.isError).toBe(true); + expect(out.content[0]!.text).toContain("the goal stays"); + expect(chatGoal(conversationId)).toBe("ship the report"); + }); +});