fix(tui): continue goal commands after creation

This commit is contained in:
Vincent Koc
2026-05-30 12:21:05 +01:00
parent 95f66a34e7
commit dc5b3ecc4c
6 changed files with 363 additions and 14 deletions

View File

@@ -4,8 +4,13 @@ import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { getSessionEntry, upsertSessionEntry } from "../../config/sessions.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { handleGoalCommand, parseGoalCommand } from "./commands-goal.js";
import {
formatGoalContinuationPrompt,
handleGoalCommand,
parseGoalCommand,
} from "./commands-goal.js";
import type { HandleCommandsParams } from "./commands-types.js";
import { parseInlineDirectives } from "./directive-handling.parse.js";
const sessionKey = "agent:main:web:main";
let tempRoots: string[] = [];
@@ -76,6 +81,18 @@ describe("goal commands", () => {
});
});
it("formats command-looking continuation prompts so inline directives leave them intact", () => {
const prompt = formatGoalContinuationPrompt("ship /fast off");
expect(prompt).toBe(
`Pursue this goal exactly as written from this JSON string: "ship \\/fast off"`,
);
const directives = parseInlineDirectives(prompt);
expect(directives.cleaned).toBe(prompt);
expect(directives.hasFastDirective).toBe(false);
});
it("starts a goal from Codex-style bare /goal objective text", async () => {
const storePath = await createStorePath();
await upsertSessionEntry({
@@ -84,13 +101,118 @@ describe("goal commands", () => {
entry: { sessionId: "sess-main", updatedAt: 1, totalTokens: 0, totalTokensFresh: true },
});
const result = await handleGoalCommand(
buildGoalParams("/goal build a 3d game", storePath),
true,
);
const params = buildGoalParams("/goal build a 3d game", storePath);
const result = await handleGoalCommand(params, true);
expect(result?.shouldContinue).toBe(false);
expect(result?.reply?.text).toBe("Goal started: build a 3d game");
expect(result?.shouldContinue).toBe(true);
expect(result?.reply).toBeUndefined();
expect(params.command.commandBodyNormalized).toBe("build a 3d game");
expect((params.ctx as { BodyForAgent?: string }).BodyForAgent).toBe("build a 3d game");
expect(getSessionEntry({ storePath, sessionKey })?.goal?.objective).toBe("build a 3d game");
});
it("wraps command-prefixed goal objectives before continuing", async () => {
const storePath = await createStorePath();
await upsertSessionEntry({
storePath,
sessionKey,
entry: { sessionId: "sess-main", updatedAt: 1, totalTokens: 0, totalTokensFresh: true },
});
const slashParams = buildGoalParams("/goal start /status", storePath);
const slashResult = await handleGoalCommand(slashParams, true);
const slashPrompt = `Pursue this goal exactly as written from this JSON string: "\\/status"`;
expect(slashResult?.shouldContinue).toBe(true);
expect(slashParams.command.commandBodyNormalized).toBe(slashPrompt);
expect((slashParams.ctx as { BodyForAgent?: string }).BodyForAgent).toBe(slashPrompt);
expect(getSessionEntry({ storePath, sessionKey })?.goal?.objective).toBe("/status");
const bangStorePath = await createStorePath();
await upsertSessionEntry({
storePath: bangStorePath,
sessionKey,
entry: { sessionId: "sess-main", updatedAt: 1, totalTokens: 0, totalTokensFresh: true },
});
const bangParams = buildGoalParams("/goal start !npm test", bangStorePath);
const bangResult = await handleGoalCommand(bangParams, true);
const bangPrompt = `Pursue this goal exactly as written from this JSON string: "!npm test"`;
expect(bangResult?.shouldContinue).toBe(true);
expect(bangParams.command.commandBodyNormalized).toBe(bangPrompt);
expect((bangParams.ctx as { BodyForAgent?: string }).BodyForAgent).toBe(bangPrompt);
expect(getSessionEntry({ storePath: bangStorePath, sessionKey })?.goal?.objective).toBe(
"!npm test",
);
});
it("resumes a goal and continues with a resume prompt", async () => {
const storePath = await createStorePath();
await upsertSessionEntry({
storePath,
sessionKey,
entry: {
sessionId: "sess-main",
updatedAt: 1,
goal: {
schemaVersion: 1,
id: "goal-1",
objective: "finish the migration",
status: "paused",
createdAt: 1,
updatedAt: 1,
tokenStart: 0,
tokenStartFresh: true,
tokensUsed: 0,
continuationTurns: 0,
},
},
});
const params = buildGoalParams("/goal resume CI passed", storePath);
const result = await handleGoalCommand(params, true);
expect(result?.shouldContinue).toBe(true);
expect(params.command.commandBodyNormalized).toBe(
"Continue pursuing the current goal. Note: CI passed",
);
expect(getSessionEntry({ storePath, sessionKey })?.goal?.status).toBe("active");
});
it("wraps command-looking resume notes before continuing", async () => {
const storePath = await createStorePath();
await upsertSessionEntry({
storePath,
sessionKey,
entry: {
sessionId: "sess-main",
updatedAt: 1,
goal: {
schemaVersion: 1,
id: "goal-1",
objective: "finish the migration",
status: "paused",
createdAt: 1,
updatedAt: 1,
tokenStart: 0,
tokenStartFresh: true,
tokensUsed: 0,
continuationTurns: 0,
},
},
});
const params = buildGoalParams("/goal resume /fast off", storePath);
const result = await handleGoalCommand(params, true);
const prompt = `Continue pursuing the current goal. Interpret this JSON string as the resume note: "\\/fast off"`;
const directives = parseInlineDirectives(prompt);
expect(result?.shouldContinue).toBe(true);
expect(params.command.commandBodyNormalized).toBe(prompt);
expect((params.ctx as { BodyForAgent?: string }).BodyForAgent).toBe(prompt);
expect(directives.cleaned).toBe(prompt);
expect(directives.hasFastDirective).toBe(false);
expect(getSessionEntry({ storePath, sessionKey })?.goal?.status).toBe("active");
});
});

View File

@@ -73,6 +73,61 @@ function goalReply(text: string): CommandHandlerResult {
};
}
function hasCommandLikeGoalText(trimmed: string): boolean {
return /(?:^|\s)\//.test(trimmed) || trimmed.startsWith("!");
}
function encodeGoalJsonString(trimmed: string): string {
return JSON.stringify(trimmed).replaceAll("/", "\\/");
}
export function formatGoalContinuationPrompt(objective: string): string {
const trimmed = objective.trim();
return hasCommandLikeGoalText(trimmed)
? `Pursue this goal exactly as written from this JSON string: ${encodeGoalJsonString(trimmed)}`
: trimmed;
}
export function formatGoalResumeContinuationPrompt(note: string): string {
const trimmed = note.trim();
if (!trimmed) {
return "Continue pursuing the current goal.";
}
return hasCommandLikeGoalText(trimmed)
? `Continue pursuing the current goal. Interpret this JSON string as the resume note: ${encodeGoalJsonString(trimmed)}`
: `Continue pursuing the current goal. Note: ${trimmed}`;
}
function applyGoalPromptToContext(ctx: HandleCommandsParams["ctx"], message: string): void {
const mutableCtx = ctx as HandleCommandsParams["ctx"] & {
Body?: string;
RawBody?: string;
CommandBody?: string;
BodyForCommands?: string;
BodyForAgent?: string;
BodyStripped?: string;
};
mutableCtx.Body = message;
mutableCtx.RawBody = message;
mutableCtx.CommandBody = message;
mutableCtx.BodyForCommands = message;
mutableCtx.BodyForAgent = message;
mutableCtx.BodyStripped = message;
}
function applyGoalContinuationPrompt(params: HandleCommandsParams, message: string): void {
applyGoalPromptToContext(params.ctx, message);
if (params.rootCtx && params.rootCtx !== params.ctx) {
applyGoalPromptToContext(params.rootCtx, message);
}
params.command.rawBodyNormalized = message;
params.command.commandBodyNormalized = message;
}
function goalContinuation(): CommandHandlerResult {
return { shouldContinue: true };
}
function goalErrorReply(error: unknown): CommandHandlerResult {
const message = error instanceof Error ? error.message : String(error);
return goalReply(`Goal error: ${message}`);
@@ -115,7 +170,8 @@ export const handleGoalCommand: CommandHandler = async (params, allowTextCommand
fallbackEntry: params.sessionEntry,
});
syncGoalSessionEntry(params);
return goalReply(`Goal started: ${goal.objective}`);
applyGoalContinuationPrompt(params, formatGoalContinuationPrompt(goal.objective));
return goalContinuation();
}
case "pause": {
const goal = await updateSessionGoalStatus({
@@ -128,14 +184,16 @@ export const handleGoalCommand: CommandHandler = async (params, allowTextCommand
return goalReply(`Goal paused: ${goal.objective}`);
}
case "resume": {
const goal = await updateSessionGoalStatus({
await updateSessionGoalStatus({
sessionKey: params.sessionKey,
storePath: params.storePath,
status: "active",
...(parsed.text ? { note: parsed.text } : {}),
});
syncGoalSessionEntry(params);
return goalReply(`Goal resumed: ${goal.objective}`);
const message = formatGoalResumeContinuationPrompt(parsed.text);
applyGoalContinuationPrompt(params, message);
return goalContinuation();
}
case "complete":
case "done": {

View File

@@ -200,6 +200,7 @@ export async function maybeResolveNativeSlashCommandFastReply(params: {
if (!commandResult.shouldContinue) {
return { handled: true, reply: commandResult.reply };
}
const continuationTriggerBodyNormalized = command.rawBodyNormalized;
const directiveResult = await resolveReplyDirectives({
ctx: params.ctx,
@@ -216,7 +217,7 @@ export async function maybeResolveNativeSlashCommandFastReply(params: {
sessionScope: sessionState.sessionScope,
groupResolution: sessionState.groupResolution,
isGroup: sessionState.isGroup,
triggerBodyNormalized: sessionState.triggerBodyNormalized,
triggerBodyNormalized: continuationTriggerBodyNormalized,
resetTriggered: false,
commandAuthorized: params.commandAuthorized,
defaultProvider: params.defaultProvider,

View File

@@ -12,6 +12,7 @@ import {
} from "./get-reply-fast-path.js";
import {
buildGetReplyCtx,
createGetReplyContinueDirectivesResult,
createGetReplySessionState,
expectResolvedTelegramTimezone,
registerGetReplyRuntimeOverrides,
@@ -28,6 +29,7 @@ function emptyAliasIndex(): ModelAliasIndex {
const mocks = vi.hoisted(() => ({
ensureAgentWorkspace: vi.fn(),
handleInlineActions: vi.fn(),
initSessionState: vi.fn(),
loadModelCatalog: vi.fn<LoadModelCatalogFn>(async () => [
{
@@ -113,6 +115,8 @@ describe("getReplyFromConfig fast test bootstrap", () => {
resolveRuntimeCliBackends: () => [],
});
mocks.ensureAgentWorkspace.mockReset();
mocks.handleInlineActions.mockReset();
mocks.handleInlineActions.mockResolvedValue({ kind: "reply", reply: { text: "ok" } });
mocks.initSessionState.mockReset();
mocks.loadModelCatalog.mockReset();
mocks.loadModelCatalog.mockResolvedValue([
@@ -525,6 +529,82 @@ describe("getReplyFromConfig fast test bootstrap", () => {
expect(directiveParams.workspaceDir).toBe("/tmp/workspace");
});
it("continues native slash goal starts with the rewritten command-safe prompt", async () => {
const home = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-native-goal-fast-"));
const targetSessionKey = "agent:main:telegram:123";
const storePath = path.join(home, "sessions.json");
const cfg = markCompleteReplyConfig({
agents: {
defaults: {
model: "anthropic/claude-opus-4-6",
workspace: path.join(home, "workspace"),
},
},
session: { store: storePath },
} as OpenClawConfig);
const continuationPrompt = `Pursue this goal exactly as written from this JSON string: "\\/status"`;
const continueDirectives = async (params: unknown) =>
createGetReplyContinueDirectivesResult({
body: (params as { triggerBodyNormalized: string }).triggerBodyNormalized,
abortKey: targetSessionKey,
from: "telegram:user:42",
to: "telegram:123",
senderId: "telegram:user:42",
commandSource: (params as { triggerBodyNormalized: string }).triggerBodyNormalized,
senderIsOwner: true,
resetHookTriggered: false,
});
mocks.resolveReplyDirectives
.mockImplementationOnce(continueDirectives)
.mockImplementationOnce(async (params: unknown) => {
expect((params as { triggerBodyNormalized: string }).triggerBodyNormalized).toBe(
continuationPrompt,
);
return continueDirectives(params);
});
mocks.handleInlineActions.mockImplementation(async (params: unknown) => {
expect(params).toMatchObject({
command: {
rawBodyNormalized: continuationPrompt,
commandBodyNormalized: continuationPrompt,
},
cleanedBody: continuationPrompt,
});
return {
kind: "continue",
directives: {},
abortedLastRun: false,
cleanedBody: continuationPrompt,
};
});
await expect(
getReplyFromConfig(
buildGetReplyCtx({
Body: "/goal start /status",
BodyForAgent: "/goal start /status",
RawBody: "/goal start /status",
CommandBody: "/goal start /status",
CommandSource: "native",
CommandAuthorized: true,
SessionKey: "telegram:slash:123",
CommandTargetSessionKey: targetSessionKey,
}),
undefined,
cfg,
),
).resolves.toEqual({ text: "ok" });
const stored = JSON.parse(await fs.readFile(storePath, "utf8")) as {
sessions?: Record<string, { goal?: { objective?: string } }>;
};
expect(stored.sessions?.[targetSessionKey]?.goal?.objective).toBe("/status");
const preparedReplyParams = requirePreparedReplyParams();
expect(preparedReplyParams.command.commandBodyNormalized).toBe(continuationPrompt);
expect(preparedReplyParams.sessionCtx.BodyForAgent).toBe(continuationPrompt);
expect(mocks.handleInlineActions).toHaveBeenCalledTimes(2);
});
it("uses native command target session keys during fast bootstrap", () => {
const result = initFastReplySessionState({
ctx: buildGetReplyCtx({

View File

@@ -268,9 +268,9 @@ describe("tui command handlers", () => {
});
});
it("runs goal commands locally instead of sending them to the model", async () => {
it("starts local goals and sends the objective to the model", async () => {
const runGoalCommand = vi.fn().mockResolvedValue({ text: "Goal started: ship" });
const { handleCommand, sendChat, addSystem, refreshSessionInfo } = createHarness({
const { handleCommand, sendChat, addSystem, refreshSessionInfo, addUser } = createHarness({
opts: { local: true },
runGoalCommand,
});
@@ -282,11 +282,75 @@ describe("tui command handlers", () => {
agentId: "main",
command: "/goal start ship",
});
expect(sendChat).not.toHaveBeenCalled();
expectSendChatFields(sendChat, {
sessionKey: "agent:main:main",
message: "ship",
});
expect(addUser).toHaveBeenCalledWith("ship");
expect(addSystem).toHaveBeenCalledWith("Goal started: ship");
expect(refreshSessionInfo).toHaveBeenCalled();
});
it("wraps command-prefixed local goal objectives before sending", async () => {
const slashRunGoalCommand = vi.fn().mockResolvedValue({ text: "Goal started" });
const slashHarness = createHarness({
opts: { local: true },
runGoalCommand: slashRunGoalCommand,
});
await slashHarness.handleCommand("/goal start /status");
const slashPrompt = `Pursue this goal exactly as written from this JSON string: "\\/status"`;
expectSendChatFields(slashHarness.sendChat, {
sessionKey: "agent:main:main",
message: slashPrompt,
});
expect(slashHarness.addUser).toHaveBeenCalledWith(slashPrompt);
const bangRunGoalCommand = vi.fn().mockResolvedValue({ text: "Goal started" });
const bangHarness = createHarness({
opts: { local: true },
runGoalCommand: bangRunGoalCommand,
});
await bangHarness.handleCommand("/goal start !npm test");
const bangPrompt = `Pursue this goal exactly as written from this JSON string: "!npm test"`;
expectSendChatFields(bangHarness.sendChat, {
sessionKey: "agent:main:main",
message: bangPrompt,
});
expect(bangHarness.addUser).toHaveBeenCalledWith(bangPrompt);
});
it("keeps local goal status as a control command", async () => {
const runGoalCommand = vi.fn().mockResolvedValue({ text: "Goal: ship" });
const { handleCommand, sendChat, addSystem } = createHarness({
opts: { local: true },
runGoalCommand,
});
await handleCommand("/goal status");
expect(sendChat).not.toHaveBeenCalled();
expect(addSystem).toHaveBeenCalledWith("Goal: ship");
});
it("wraps command-prefixed local goal resume notes before sending", async () => {
const runGoalCommand = vi.fn().mockResolvedValue({ text: "Goal resumed: ship" });
const { handleCommand, sendChat, addUser } = createHarness({
opts: { local: true },
runGoalCommand,
});
await handleCommand("/goal resume /fast off");
const prompt = `Continue pursuing the current goal. Interpret this JSON string as the resume note: "\\/fast off"`;
expectSendChatFields(sendChat, {
sessionKey: "agent:main:main",
message: prompt,
});
expect(addUser).toHaveBeenCalledWith(prompt);
});
it("passes the selected agent for local global goal commands", async () => {
const runGoalCommand = vi.fn().mockResolvedValue({ text: "Goal started: ship" });
const { handleCommand } = createHarness({

View File

@@ -3,6 +3,11 @@ import type { Component, SelectItem, TUI } from "@earendil-works/pi-tui";
import type { SessionsPatchResult } from "../../packages/gateway-protocol/src/index.js";
import { modelKey } from "../agents/model-ref-shared.js";
import { normalizeGroupActivation } from "../auto-reply/group-activation.js";
import {
formatGoalContinuationPrompt,
formatGoalResumeContinuationPrompt,
parseGoalCommand,
} from "../auto-reply/reply/commands-goal.js";
import {
formatThinkingLevels,
normalizeUsageDisplay,
@@ -69,6 +74,21 @@ function isSlashStopCommand(text: string): boolean {
return trimmed.startsWith("/") && isChatStopCommandText(trimmed);
}
function goalContinuationPrompt(text: string): string | null {
const parsed = parseGoalCommand(text);
if (!parsed) {
return null;
}
const action = parsed.action;
if (action === "start" || action === "set" || action === "create") {
return formatGoalContinuationPrompt(parsed.text) || null;
}
if (action === "resume") {
return formatGoalResumeContinuationPrompt(parsed.text);
}
return null;
}
export function createCommandHandlers(context: CommandHandlerContext) {
const {
client,
@@ -396,6 +416,10 @@ export function createCommandHandlers(context: CommandHandlerContext) {
});
chatLog.addSystem(result.text);
await refreshSessionInfo();
const continuation = goalContinuationPrompt(raw);
if (continuation) {
await sendMessage(continuation);
}
} catch (err) {
chatLog.addSystem(`goal failed: ${sanitizeRenderableText(String(err))}`);
}