feat(core,cli,server,web): add goal mode — loop Tasks on one Session until an objective completes (#66)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -625,6 +625,29 @@ export interface TaskCreateRequest {
|
||||
* (in queue order, one at a time). The response then carries `queued: true`.
|
||||
*/
|
||||
queueIfBusy?: boolean;
|
||||
/**
|
||||
* Present = goal mode: the input's text becomes the objective (leading `[use_skills]`
|
||||
* blocks and the like are stripped from the recorded objective; the round-1 message keeps
|
||||
* them) and the server loops the Session until the goal reaches a terminal state.
|
||||
* `budget` is the token budget (uncached input + output); omitted or -1 = unlimited.
|
||||
*/
|
||||
goal?: { budget?: number };
|
||||
}
|
||||
|
||||
/** Goal-mode run state (from goal_state; the chat page's banner restores from the latest row). */
|
||||
export interface GoalStateView {
|
||||
objective: string;
|
||||
status: "active" | "complete" | "blocked" | "budget_limited" | "aborted";
|
||||
/** Token budget; -1 = unlimited. */
|
||||
budget: number;
|
||||
used: number;
|
||||
rounds: number;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface GoalResponse {
|
||||
/** The Session's most recent goal run; null if it never ran one. */
|
||||
goal: GoalStateView | null;
|
||||
}
|
||||
|
||||
export interface TaskCreateResponse {
|
||||
@@ -694,7 +717,23 @@ export type ServerEvent =
|
||||
sessionId: string;
|
||||
source: SessionSource;
|
||||
}
|
||||
| ScheduleServerEvent;
|
||||
| ScheduleServerEvent
|
||||
| GoalServerEvent;
|
||||
|
||||
/** Goal-mode progress on the session channel (the chat page drives its goal banner from these). */
|
||||
export type GoalServerEvent =
|
||||
/** A goal run began (published before the first round). */
|
||||
| { type: "goal_started"; sessionId: string; objective: string; budget: number }
|
||||
/** A round is starting; `used` is the runner's accounting up to this point. */
|
||||
| { type: "goal_round"; sessionId: string; round: number; used: number; budget: number }
|
||||
/** The goal reached a terminal state. */
|
||||
| {
|
||||
type: "goal_finished";
|
||||
sessionId: string;
|
||||
outcome: "complete" | "blocked" | "budget_limited" | "aborted";
|
||||
rounds: number;
|
||||
used: number;
|
||||
};
|
||||
|
||||
/** Schedule notification (user-level event stream; firing and delivery are notified via /api/events). */
|
||||
export type ScheduleServerEvent =
|
||||
|
||||
@@ -19,6 +19,7 @@ import { AuthSessionsRepo } from "./db/repos/auth-sessions.js";
|
||||
import { ErrorsRepo } from "./db/repos/errors.js";
|
||||
import { MembersRepo } from "./db/repos/members.js";
|
||||
import { ProjectsRepo } from "./db/repos/projects.js";
|
||||
import { GoalsRepo } from "./db/repos/goals.js";
|
||||
import { SchedulesRepo } from "./db/repos/schedules.js";
|
||||
import { SessionsRepo } from "./db/repos/sessions.js";
|
||||
import { UiPrefsRepo } from "./db/repos/ui-prefs.js";
|
||||
@@ -103,6 +104,8 @@ export interface AppDeps {
|
||||
benchmarks: BenchmarkService;
|
||||
snapshots: SnapshotService;
|
||||
schedulesRepo: SchedulesRepo;
|
||||
goalsRepo: GoalsRepo;
|
||||
errorsRepo: ErrorsRepo;
|
||||
scheduler: Scheduler;
|
||||
channels: ChannelHub;
|
||||
manager: SessionManager;
|
||||
@@ -140,6 +143,7 @@ export function buildAppDeps(config: ServerConfig, overrides: BuildDepsOverrides
|
||||
const errorsRepo = new ErrorsRepo(db);
|
||||
const prefsRepo = new UiPrefsRepo(db);
|
||||
const schedulesRepo = new SchedulesRepo(db);
|
||||
const goalsRepo = new GoalsRepo(db);
|
||||
|
||||
const projectConfigService = new ProjectConfigService(config.root);
|
||||
const agentConfigService = new AgentConfigService(config.root);
|
||||
@@ -186,6 +190,7 @@ export function buildAppDeps(config: ServerConfig, overrides: BuildDepsOverrides
|
||||
errors,
|
||||
titles,
|
||||
log,
|
||||
goals: goalsRepo,
|
||||
});
|
||||
managerRef = manager;
|
||||
|
||||
@@ -199,6 +204,7 @@ export function buildAppDeps(config: ServerConfig, overrides: BuildDepsOverrides
|
||||
usage: usageRepo,
|
||||
errors: errorsRepo,
|
||||
schedules: schedulesRepo,
|
||||
goals: goalsRepo,
|
||||
projectConfig: projectConfigService,
|
||||
manager,
|
||||
});
|
||||
@@ -261,6 +267,8 @@ export function buildAppDeps(config: ServerConfig, overrides: BuildDepsOverrides
|
||||
benchmarks,
|
||||
snapshots,
|
||||
schedulesRepo,
|
||||
goalsRepo,
|
||||
errorsRepo,
|
||||
scheduler,
|
||||
channels,
|
||||
manager,
|
||||
|
||||
@@ -212,6 +212,13 @@ export class ErrorsRepo {
|
||||
}));
|
||||
}
|
||||
|
||||
/** Cascading cleanup on Agent deletion (unattributed errors carry no agent_id and are unaffected). */
|
||||
deleteByAgent(projectId: string, agentId: string): void {
|
||||
this.db
|
||||
.prepare("DELETE FROM error_records WHERE project_id = ? AND agent_id = ?")
|
||||
.run(projectId, agentId);
|
||||
}
|
||||
|
||||
/** Cascading cleanup on Project deletion (unattributed errors belong to no Project and are unaffected). */
|
||||
deleteByProject(projectId: string): void {
|
||||
this.db.prepare("DELETE FROM error_records WHERE project_id = ?").run(projectId);
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* Repo for goal-mode runtime state: one row per goal run, keyed by autoincrement id (a
|
||||
* Session may run goals repeatedly; the latest row is what the UI shows). The on-disk
|
||||
* GOAL.yaml is the model-facing protocol; this table is the server-side record the banner
|
||||
* restores from and the terminal outcome lands in (including the server-only `aborted`,
|
||||
* which never appears in the file — the on-disk status stays `active` for resuming).
|
||||
*/
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
|
||||
export type GoalRowStatus = "active" | "complete" | "blocked" | "budget_limited" | "aborted";
|
||||
|
||||
export interface GoalStateRow {
|
||||
id: number;
|
||||
sessionId: string;
|
||||
projectId: string;
|
||||
agentId: string;
|
||||
objective: string;
|
||||
status: GoalRowStatus;
|
||||
budget: number;
|
||||
used: number;
|
||||
rounds: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
function mapRow(r: Record<string, unknown>): GoalStateRow {
|
||||
return {
|
||||
id: Number(r.id),
|
||||
sessionId: r.session_id as string,
|
||||
projectId: r.project_id as string,
|
||||
agentId: r.agent_id as string,
|
||||
objective: r.objective as string,
|
||||
status: r.status as GoalRowStatus,
|
||||
budget: Number(r.budget),
|
||||
used: Number(r.used),
|
||||
rounds: Number(r.rounds),
|
||||
createdAt: r.created_at as string,
|
||||
updatedAt: r.updated_at as string,
|
||||
};
|
||||
}
|
||||
|
||||
export class GoalsRepo {
|
||||
constructor(private readonly db: DatabaseSync) {}
|
||||
|
||||
/** Register a new goal run (status active, counters at zero); returns the new row id. */
|
||||
create(args: {
|
||||
sessionId: string;
|
||||
projectId: string;
|
||||
agentId: string;
|
||||
objective: string;
|
||||
budget: number;
|
||||
}): number {
|
||||
const now = new Date().toISOString();
|
||||
const result = this.db
|
||||
.prepare(
|
||||
`INSERT INTO goal_state
|
||||
(session_id, project_id, agent_id, objective, status, budget, used, rounds, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, 'active', ?, 0, 0, ?, ?)`,
|
||||
)
|
||||
.run(args.sessionId, args.projectId, args.agentId, args.objective, args.budget, now, now);
|
||||
return Number(result.lastInsertRowid);
|
||||
}
|
||||
|
||||
/** Per-round progress refresh (round just started; `used` mirrors the runner's accounting so far). */
|
||||
progress(id: number, rounds: number, used: number): void {
|
||||
this.db
|
||||
.prepare("UPDATE goal_state SET rounds = ?, used = ?, updated_at = ? WHERE id = ?")
|
||||
.run(rounds, used, new Date().toISOString(), id);
|
||||
}
|
||||
|
||||
/** Record the terminal outcome. */
|
||||
finish(id: number, status: GoalRowStatus, rounds: number, used: number): void {
|
||||
this.db
|
||||
.prepare(
|
||||
"UPDATE goal_state SET status = ?, rounds = ?, used = ?, updated_at = ? WHERE id = ?",
|
||||
)
|
||||
.run(status, rounds, used, new Date().toISOString(), id);
|
||||
}
|
||||
|
||||
/** The Session's most recent goal run (what the chat page's banner restores from); null if it never ran one. */
|
||||
latestForSession(sessionId: string): GoalStateRow | null {
|
||||
const r = this.db
|
||||
.prepare("SELECT * FROM goal_state WHERE session_id = ? ORDER BY id DESC LIMIT 1")
|
||||
.get(sessionId);
|
||||
return r ? mapRow(r as Record<string, unknown>) : null;
|
||||
}
|
||||
|
||||
deleteBySession(sessionId: string): void {
|
||||
this.db.prepare("DELETE FROM goal_state WHERE session_id = ?").run(sessionId);
|
||||
}
|
||||
|
||||
deleteByAgent(projectId: string, agentId: string): void {
|
||||
this.db
|
||||
.prepare("DELETE FROM goal_state WHERE project_id = ? AND agent_id = ?")
|
||||
.run(projectId, agentId);
|
||||
}
|
||||
|
||||
deleteByProject(projectId: string): void {
|
||||
this.db.prepare("DELETE FROM goal_state WHERE project_id = ?").run(projectId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Startup reconciliation: a goal runs only in SessionManager memory, so a hard crash
|
||||
* (SIGKILL, power loss) leaves its row stuck `active` with no runner behind it — the banner
|
||||
* would then restore a forever-"running" goal. Called once at boot, before the server accepts
|
||||
* connections (nothing can be running yet), so every remaining `active` row is an orphan:
|
||||
* mark them `aborted`. The on-disk GOAL.yaml is left `active` as the documented resume point.
|
||||
* Returns the number of rows reconciled.
|
||||
*/
|
||||
abortOrphanedActive(): number {
|
||||
const result = this.db
|
||||
.prepare("UPDATE goal_state SET status = 'aborted', updated_at = ? WHERE status = 'active'")
|
||||
.run(new Date().toISOString());
|
||||
return Number(result.changes);
|
||||
}
|
||||
}
|
||||
@@ -185,6 +185,12 @@ export class SchedulesRepo {
|
||||
return removed;
|
||||
}
|
||||
|
||||
deleteByAgent(projectId: string, agentId: string): void {
|
||||
this.db
|
||||
.prepare("DELETE FROM schedule_state WHERE project_id = ? AND agent_id = ?")
|
||||
.run(projectId, agentId);
|
||||
}
|
||||
|
||||
deleteByProject(projectId: string): void {
|
||||
this.db.prepare("DELETE FROM schedule_state WHERE project_id = ?").run(projectId);
|
||||
}
|
||||
|
||||
@@ -96,6 +96,20 @@ CREATE TABLE IF NOT EXISTS schedule_state ( -- schedule runtime state (files
|
||||
invalid_reason TEXT, -- invalidation reason (e.g. the bound Session was deleted); NULL=healthy
|
||||
PRIMARY KEY (project_id, agent_id, name)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS goal_state ( -- goal-mode runtime state (GOAL.yaml on disk is the model-facing protocol; this row is what the UI reads)
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT, -- one row per goal run; a Session may run goals repeatedly, latest row wins for display
|
||||
session_id TEXT NOT NULL,
|
||||
project_id TEXT NOT NULL,
|
||||
agent_id TEXT NOT NULL,
|
||||
objective TEXT NOT NULL,
|
||||
status TEXT NOT NULL, -- active | complete | blocked | budget_limited | aborted (aborted is server-side only; on-disk status stays active)
|
||||
budget INTEGER NOT NULL, -- token budget; -1 = unlimited
|
||||
used INTEGER NOT NULL DEFAULT 0, -- uncached input + output, refreshed per round (mirrors the runner's accounting)
|
||||
rounds INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_goal_session ON goal_state(session_id);
|
||||
CREATE TABLE IF NOT EXISTS ui_prefs (
|
||||
user_id TEXT PRIMARY KEY REFERENCES users(user_id) ON DELETE CASCADE,
|
||||
prefs_json TEXT NOT NULL -- {theme?, lastProjectId?, ...} free-form JSON
|
||||
|
||||
@@ -76,6 +76,12 @@ export function agentsRoutes(deps: AppDeps): Hono<AppEnv> {
|
||||
await settleWithin(runnings, 5000);
|
||||
await deps.agentService.deleteAgent(projectId, agentId);
|
||||
deps.sessionsRepo.deleteByAgent(projectId, agentId);
|
||||
// Per-Agent runtime state keyed on the now-removed sessions/agent: drop it so nothing is
|
||||
// orphaned (session ids are never reused, so a leftover row is dead weight). Usage records
|
||||
// are deliberately kept — historical stats survive Agent deletion (see deleteAgent).
|
||||
deps.goalsRepo.deleteByAgent(projectId, agentId);
|
||||
deps.schedulesRepo.deleteByAgent(projectId, agentId);
|
||||
deps.errorsRepo.deleteByAgent(projectId, agentId);
|
||||
} finally {
|
||||
deps.manager.endAgentDeletion(projectId, agentId);
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import type { OmniMessage, ThinkingLevelName } from "@prismshadow/penguin-core";
|
||||
import type {
|
||||
ApprovalMode,
|
||||
FilesStatResponse,
|
||||
GoalResponse,
|
||||
MessagesLiveTail,
|
||||
MessagesResponse,
|
||||
ServerEvent,
|
||||
@@ -102,6 +103,28 @@ function parseTaskInput(body: Record<string, unknown>): OmniMessage[] {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate the optional `goal` field of a task request: absent = a regular task (null);
|
||||
* present = goal mode with a token budget (a positive integer, or -1/omitted = unlimited).
|
||||
* The input text is the objective — skills ride the text itself as a `[use_skills]` block,
|
||||
* exactly like a regular task's message.
|
||||
*/
|
||||
function parseGoalField(body: Record<string, unknown>): { budget: number } | null {
|
||||
const goal = body.goal;
|
||||
if (goal === undefined) return null;
|
||||
if (goal === null || typeof goal !== "object" || Array.isArray(goal)) {
|
||||
throw badRequest("goal must be an object.");
|
||||
}
|
||||
const budget = (goal as Record<string, unknown>).budget;
|
||||
if (
|
||||
budget !== undefined &&
|
||||
(typeof budget !== "number" || !Number.isInteger(budget) || (budget <= 0 && budget !== -1))
|
||||
) {
|
||||
throw badRequest("goal.budget must be a positive integer, or -1 for unlimited.");
|
||||
}
|
||||
return { budget: (budget as number | undefined) ?? -1 };
|
||||
}
|
||||
|
||||
/** Agent-level entry: /api/projects/:p/agents/:a/sessions. */
|
||||
export function agentSessionsRoutes(deps: AppDeps): Hono<AppEnv> {
|
||||
const app = new Hono<AppEnv>();
|
||||
@@ -290,6 +313,7 @@ export function sessionsRoutes(deps: AppDeps): Hono<AppEnv> {
|
||||
{ recursive: true, force: true },
|
||||
);
|
||||
deps.sessionsRepo.deleteById(row.sessionId);
|
||||
deps.goalsRepo.deleteBySession(row.sessionId);
|
||||
// Drop the derived-origin entry along with the Session (bulk Agent/Project deletion
|
||||
// may leave stale entries; session ids are never reused, so they are never matched).
|
||||
deps.sessionSources.delete(row.sessionId);
|
||||
@@ -389,10 +413,31 @@ export function sessionsRoutes(deps: AppDeps): Hono<AppEnv> {
|
||||
app.post("/:sessionId/tasks", async (c) => {
|
||||
const row = resolveSession(c);
|
||||
const body = await readJson(c);
|
||||
const input = parseTaskInput(body);
|
||||
const goal = parseGoalField(body);
|
||||
// Per-turn thinking level (optional): validated against the five names; omitted follows
|
||||
// the session's default. A queued follow-up keeps its level for its auto-start.
|
||||
// the session's default. In goal mode it rides every round of the goal; a queued
|
||||
// follow-up keeps its level for its auto-start.
|
||||
const thinkingLevel = optionalEnum(body, "thinkingLevel", THINKING_LEVELS);
|
||||
if (goal) {
|
||||
// Goal mode: the input must be plain non-empty text (its marker-stripped text becomes
|
||||
// the objective, re-injected every round — images have no place in the protocol).
|
||||
const input = parseTaskInput(body);
|
||||
const text = input
|
||||
.filter((m) => (m.payload as { type?: string }).type === "text")
|
||||
.map((m) => (m.payload as { text: string }).text)
|
||||
.join("\n")
|
||||
.trim();
|
||||
if (!text || input.some((m) => (m.payload as { type?: string }).type !== "text")) {
|
||||
throw badRequest("goal mode requires text-only input (the objective).");
|
||||
}
|
||||
const { sessionId } = await deps.manager.startGoal(row.sessionId, {
|
||||
input,
|
||||
budget: goal.budget,
|
||||
...(thinkingLevel !== undefined ? { thinkingLevel } : {}),
|
||||
});
|
||||
return c.json({ sessionId } satisfies TaskCreateResponse, 202);
|
||||
}
|
||||
const input = parseTaskInput(body);
|
||||
// Follow-up queue: with queueIfBusy, a busy session enqueues the input instead of 409
|
||||
// (auto-starts as an ordinary next task once idle; the response says which happened).
|
||||
const queueIfBusy = body.queueIfBusy === true;
|
||||
@@ -417,6 +462,24 @@ export function sessionsRoutes(deps: AppDeps): Hono<AppEnv> {
|
||||
return c.body(null, 202);
|
||||
});
|
||||
|
||||
// The Session's most recent goal run (for restoring the chat page's goal banner on load).
|
||||
app.get("/:sessionId/goal", (c) => {
|
||||
const row = resolveSession(c);
|
||||
const g = deps.goalsRepo.latestForSession(row.sessionId);
|
||||
return c.json({
|
||||
goal: g
|
||||
? {
|
||||
objective: g.objective,
|
||||
status: g.status,
|
||||
budget: g.budget,
|
||||
used: g.used,
|
||||
rounds: g.rounds,
|
||||
updatedAt: g.updatedAt,
|
||||
}
|
||||
: null,
|
||||
} satisfies GoalResponse);
|
||||
});
|
||||
|
||||
app.post("/:sessionId/approvals/:toolCallId", async (c) => {
|
||||
const row = resolveSession(c);
|
||||
const body = await readJson(c);
|
||||
|
||||
@@ -27,6 +27,12 @@ await deps.authService.seedAdmin();
|
||||
// Schedule scheduler: startup reconciliation (missed, don't backfill) + periodic scan; only active while the server is running.
|
||||
await deps.scheduler.start();
|
||||
|
||||
// Goal mode runs only in SessionManager memory: a hard crash (SIGKILL, power loss) can leave
|
||||
// goal_state rows stuck `active` with no runner behind them. Reconcile them to `aborted` now —
|
||||
// nothing is running yet, so any `active` row is a crash orphan — so the chat banner never
|
||||
// restores a phantom "running" goal. GOAL.yaml on disk stays `active` as the resume point.
|
||||
deps.goalsRepo.abortOrphanedActive();
|
||||
|
||||
// On a loopback bind the App is canonicalized onto one name (`localhost`) and its
|
||||
// counterpart is reserved for previews, so advertise the canonical name — the other one
|
||||
// only 302s back here for App routes (see the canonical-host guard in app.ts).
|
||||
|
||||
@@ -30,7 +30,11 @@ import path from "node:path";
|
||||
import {
|
||||
createAgent,
|
||||
findLatestTraceFile,
|
||||
goalFinishedOf,
|
||||
goalTokenDelta,
|
||||
isGoalRoundInput,
|
||||
isSessionMeta,
|
||||
stripLeadingMarkerBlocks,
|
||||
tracesDir,
|
||||
} from "@prismshadow/penguin-core";
|
||||
import type {
|
||||
@@ -44,6 +48,7 @@ import type {
|
||||
} from "@prismshadow/penguin-core";
|
||||
import type { ServerEvent, SessionStatus } from "../api/types.js";
|
||||
import { HttpError, isMissingCredential, modelCredentialMissing } from "../http/errors.js";
|
||||
import type { GoalsRepo } from "../db/repos/goals.js";
|
||||
import type { SessionRow, SessionsRepo } from "../db/repos/sessions.js";
|
||||
import { ApprovalRegistry, makeApprove } from "./approvals.js";
|
||||
import type { PendingApproval } from "./approvals.js";
|
||||
@@ -72,7 +77,13 @@ export interface RuntimeSession {
|
||||
readonly sessionId: string;
|
||||
run(
|
||||
newMessages: OmniMessage[],
|
||||
opts: { approve: ApproveFn; signal: AbortSignal; thinkingLevel?: ThinkingLevelName },
|
||||
opts: {
|
||||
approve: ApproveFn;
|
||||
signal: AbortSignal;
|
||||
thinkingLevel?: ThinkingLevelName;
|
||||
/** Present = goal mode: core loops rounds inside this one run (see core SessionRunOptions). */
|
||||
goal?: { budget?: number };
|
||||
},
|
||||
): AsyncGenerator<OmniMessage>;
|
||||
compact(opts: { signal: AbortSignal }): AsyncGenerator<OmniMessage>;
|
||||
/** Whether compaction is possible and why; when not ok, compact() yields no messages (see core ContextEngine.compactability). */
|
||||
@@ -192,6 +203,8 @@ export interface SessionManagerDeps {
|
||||
/** Error persistence (optional: without it, only logs — same as before this was wired up). */
|
||||
errors?: ErrorSink;
|
||||
log?: (line: string) => void;
|
||||
/** Goal run-state persistence (optional like `titles`: without it, goals run but leave no restorable record). */
|
||||
goals?: GoalsRepo;
|
||||
}
|
||||
|
||||
/** One queued follow-up task (`queueIfBusy`): the task input plus the per-turn thinking level it was posted with. */
|
||||
@@ -467,6 +480,177 @@ export class SessionManager {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a goal run: like startTask, but the run is core goal mode — one
|
||||
* `session.run(input, { goal })` call loops rounds until a terminal state, and the
|
||||
* Session stays `running` for the whole goal (every round), so the existing abort
|
||||
* endpoint interrupts the entire loop and schedules queue behind it as usual. Round
|
||||
* inputs are yielded by core and published like any streamed message; progress
|
||||
* additionally goes out as goal_* server events and into goal_state (when a repo is
|
||||
* wired).
|
||||
*/
|
||||
async startGoal(
|
||||
sessionId: string,
|
||||
args: {
|
||||
/** Round-1 input (text-only, route-validated); its marker-stripped text is the objective. */
|
||||
input: OmniMessage[];
|
||||
budget: number;
|
||||
/** Optional per-goal thinking level: rides every round's Task (route-validated). */
|
||||
thinkingLevel?: ThinkingLevelName;
|
||||
},
|
||||
): Promise<{ sessionId: string }> {
|
||||
return this.withLock(sessionId, async () => {
|
||||
this.assertOpen();
|
||||
this.assertAgentNotDeleting(sessionId);
|
||||
this.assertSessionNotDeleting(sessionId);
|
||||
const entry = await this.ensureEntry(sessionId);
|
||||
this.assertIdle(entry);
|
||||
// The objective is the user's own text (leading skill-invocation blocks stripped) —
|
||||
// the same derivation core records in GOAL.yaml; used for the run-state row, the
|
||||
// goal_started event, and as title material.
|
||||
const text = args.input
|
||||
.filter(isPlainText("user"))
|
||||
.map((m) => m.payload.text)
|
||||
.join("\n");
|
||||
const objective = stripLeadingMarkerBlocks(text).trim() || text.trim();
|
||||
const ac = new AbortController();
|
||||
entry.status = "running";
|
||||
entry.abort = ac;
|
||||
entry.lastActivityMs = Date.now();
|
||||
this.publishState(entry, "running");
|
||||
const approve = makeApprove({
|
||||
getMode: () => this.deps.sessions.findById(entry.sessionId)?.approvalMode ?? "always-ask",
|
||||
toolPermission: (name) => entry.session.toolPermission(name),
|
||||
registry: entry.approvals,
|
||||
publishRequest: (pending) =>
|
||||
this.publishEvent(entry, {
|
||||
type: "approval_request",
|
||||
toolCall: pending.toolCall,
|
||||
...(pending.origin !== undefined ? { origin: pending.origin } : {}),
|
||||
}),
|
||||
});
|
||||
const goalId = this.deps.goals?.create({
|
||||
sessionId: entry.sessionId,
|
||||
projectId: entry.projectId,
|
||||
agentId: entry.agentId,
|
||||
objective,
|
||||
budget: args.budget,
|
||||
});
|
||||
this.publishEvent(entry, {
|
||||
type: "goal_started",
|
||||
sessionId: entry.sessionId,
|
||||
objective,
|
||||
budget: args.budget,
|
||||
});
|
||||
const gen = this.goalStream(entry, {
|
||||
input: args.input,
|
||||
budget: args.budget,
|
||||
...(args.thinkingLevel !== undefined ? { thinkingLevel: args.thinkingLevel } : {}),
|
||||
approve,
|
||||
signal: ac.signal,
|
||||
...(goalId !== undefined ? { goalId } : {}),
|
||||
});
|
||||
// The objective doubles as the title material (same role as a task's input text).
|
||||
entry.running = this.drive(entry, gen, { userExcerpt: objective });
|
||||
return { sessionId: entry.sessionId };
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Taps core's goal-mode stream for `drive`: round boundaries (the injected `[goal]`
|
||||
* inputs) become goal_round events + goal_state refreshes, and the terminal
|
||||
* `goal_finished` event message becomes the goal_finished server event + the run-state
|
||||
* row's final status. Token numbers mirror core's own accounting (same
|
||||
* `goalTokenDelta`), so the UI shows exactly what the budget check uses.
|
||||
*/
|
||||
private async *goalStream(
|
||||
entry: RuntimeEntry,
|
||||
args: {
|
||||
input: OmniMessage[];
|
||||
budget: number;
|
||||
thinkingLevel?: ThinkingLevelName;
|
||||
approve: ApproveFn;
|
||||
signal: AbortSignal;
|
||||
goalId?: number;
|
||||
},
|
||||
): AsyncGenerator<OmniMessage> {
|
||||
const gen = entry.session.run(args.input, {
|
||||
approve: args.approve,
|
||||
signal: args.signal,
|
||||
...(args.thinkingLevel !== undefined ? { thinkingLevel: args.thinkingLevel } : {}),
|
||||
goal: { budget: args.budget },
|
||||
});
|
||||
let round = 0;
|
||||
let used = 0;
|
||||
let finished = false;
|
||||
try {
|
||||
for await (const msg of gen) {
|
||||
used += goalTokenDelta(msg);
|
||||
if (isGoalRoundInput(msg)) {
|
||||
round++;
|
||||
if (args.goalId !== undefined) this.deps.goals?.progress(args.goalId, round, used);
|
||||
this.publishEvent(entry, {
|
||||
type: "goal_round",
|
||||
sessionId: entry.sessionId,
|
||||
round,
|
||||
used,
|
||||
budget: args.budget,
|
||||
});
|
||||
}
|
||||
const outcome = goalFinishedOf(msg);
|
||||
if (outcome) {
|
||||
finished = true;
|
||||
if (args.goalId !== undefined) {
|
||||
this.deps.goals?.finish(
|
||||
args.goalId,
|
||||
outcome.outcome,
|
||||
outcome.rounds,
|
||||
outcome.tokensUsed,
|
||||
);
|
||||
}
|
||||
this.publishEvent(entry, {
|
||||
type: "goal_finished",
|
||||
sessionId: entry.sessionId,
|
||||
outcome: outcome.outcome,
|
||||
rounds: outcome.rounds,
|
||||
used: outcome.tokensUsed,
|
||||
});
|
||||
}
|
||||
yield msg;
|
||||
}
|
||||
if (!finished) {
|
||||
// Defensive: core always ends a goal stream with goal_finished; a stream that
|
||||
// didn't is a cut-off run — close the row so the UI never shows a forever-active
|
||||
// goal.
|
||||
this.finishAborted(entry, args.goalId, round, used);
|
||||
}
|
||||
} catch (err) {
|
||||
// Core throws only on infrastructure failures (e.g. GOAL.yaml writes): close the
|
||||
// run state as aborted, then let drive's defensive catch record the error. Guarded on
|
||||
// `finished`: a throw after the terminal event must not overwrite the row's real
|
||||
// outcome (repo.finish is an unconditional UPDATE) or publish a contradicting event.
|
||||
if (!finished) this.finishAborted(entry, args.goalId, round, used);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/** Closes a goal's run state as aborted (stream cut off / infrastructure failure). */
|
||||
private finishAborted(
|
||||
entry: RuntimeEntry,
|
||||
goalId: number | undefined,
|
||||
round: number,
|
||||
used: number,
|
||||
): void {
|
||||
if (goalId !== undefined) this.deps.goals?.finish(goalId, "aborted", round, used);
|
||||
this.publishEvent(entry, {
|
||||
type: "goal_finished",
|
||||
sessionId: entry.sessionId,
|
||||
outcome: "aborted",
|
||||
rounds: round,
|
||||
used,
|
||||
});
|
||||
}
|
||||
|
||||
/** Shared task launch (fresh tasks and auto-started follow-ups): flips to running, publishes the input, and drives the run with the per-turn thinking level (if any). Caller holds the session lock and has verified idle. */
|
||||
private launchTask(
|
||||
entry: RuntimeEntry,
|
||||
|
||||
@@ -17,6 +17,7 @@ import { HttpError } from "../http/errors.js";
|
||||
import type { AgentsRepo } from "../db/repos/agents.js";
|
||||
import type { ErrorsRepo } from "../db/repos/errors.js";
|
||||
import type { MembersRepo } from "../db/repos/members.js";
|
||||
import type { GoalsRepo } from "../db/repos/goals.js";
|
||||
import type { ProjectRow, ProjectsRepo } from "../db/repos/projects.js";
|
||||
import type { SessionsRepo } from "../db/repos/sessions.js";
|
||||
import type { SchedulesRepo } from "../db/repos/schedules.js";
|
||||
@@ -53,6 +54,7 @@ export interface ProjectServiceDeps {
|
||||
usage: UsageRepo;
|
||||
errors: ErrorsRepo;
|
||||
schedules: SchedulesRepo;
|
||||
goals: GoalsRepo;
|
||||
projectConfig: ProjectConfigService;
|
||||
manager: SessionManager;
|
||||
}
|
||||
@@ -290,6 +292,7 @@ export class ProjectService {
|
||||
this.deps.usage.deleteByProject(projectId);
|
||||
this.deps.errors.deleteByProject(projectId);
|
||||
this.deps.schedules.deleteByProject(projectId);
|
||||
this.deps.goals.deleteByProject(projectId);
|
||||
await fs.rm(projectDir(this.deps.root, projectId), { recursive: true, force: true });
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,335 @@
|
||||
/**
|
||||
* Goal-mode server tests: GoalsRepo state rows, and SessionManager.startGoal driving core
|
||||
* goal mode through one `session.run(input, { goal })` call with a fake Session (no real LLM
|
||||
* requests) — round events, terminal state persistence, and status transitions.
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
import {
|
||||
assistantText,
|
||||
buildSkillsMessage,
|
||||
emptyTokenCounts,
|
||||
goalFinished,
|
||||
tokenUsage,
|
||||
userText,
|
||||
} from "@prismshadow/penguin-core";
|
||||
import type { OmniMessage, TokenCounts } from "@prismshadow/penguin-core";
|
||||
import { openDatabase } from "../src/db/database.js";
|
||||
import { GoalsRepo } from "../src/db/repos/goals.js";
|
||||
import { SessionsRepo } from "../src/db/repos/sessions.js";
|
||||
import type { SessionRow } from "../src/db/repos/sessions.js";
|
||||
import { ChannelHub } from "../src/runtime/channel.js";
|
||||
import type { ChannelEvent } from "../src/runtime/channel.js";
|
||||
import { SessionManager } from "../src/runtime/session-manager.js";
|
||||
import type { RuntimeSession } from "../src/runtime/session-manager.js";
|
||||
import { SessionSources } from "../src/runtime/session-sources.js";
|
||||
import { waitFor } from "./helpers.js";
|
||||
|
||||
const ROW: SessionRow = {
|
||||
sessionId: "session-1",
|
||||
projectId: "p1",
|
||||
agentId: "a1",
|
||||
modelId: "m1",
|
||||
provider: "custom",
|
||||
workspace: "/tmp/w",
|
||||
approvalMode: "allow-all",
|
||||
title: null,
|
||||
createdAt: "2026-07-06T00:00:00.000Z",
|
||||
};
|
||||
|
||||
function usage(total: number): TokenCounts {
|
||||
return { cache_read: 0, cache_write: 0, output: 0, total };
|
||||
}
|
||||
|
||||
/** A goal round's injected input, as core's loop would compose it (block + body). */
|
||||
function roundInput(round: number, body: string): OmniMessage {
|
||||
return userText(`[goal]\nround: ${round}\nprotocol lines\n[/goal]\n\n${body}`);
|
||||
}
|
||||
|
||||
describe("GoalsRepo", () => {
|
||||
let db: DatabaseSync;
|
||||
let repo: GoalsRepo;
|
||||
|
||||
beforeEach(() => {
|
||||
db = openDatabase(":memory:");
|
||||
repo = new GoalsRepo(db);
|
||||
});
|
||||
afterEach(() => db.close());
|
||||
|
||||
it("creates, progresses, finishes, and reads back the latest row per session", () => {
|
||||
const id = repo.create({
|
||||
sessionId: "s1",
|
||||
projectId: "p1",
|
||||
agentId: "a1",
|
||||
objective: "obj",
|
||||
budget: -1,
|
||||
});
|
||||
repo.progress(id, 2, 1234);
|
||||
let row = repo.latestForSession("s1");
|
||||
expect(row).toMatchObject({ id, status: "active", rounds: 2, used: 1234, budget: -1 });
|
||||
|
||||
repo.finish(id, "complete", 3, 2000);
|
||||
row = repo.latestForSession("s1");
|
||||
expect(row).toMatchObject({ status: "complete", rounds: 3, used: 2000 });
|
||||
|
||||
// A later run wins for display.
|
||||
const id2 = repo.create({
|
||||
sessionId: "s1",
|
||||
projectId: "p1",
|
||||
agentId: "a1",
|
||||
objective: "obj2",
|
||||
budget: 500,
|
||||
});
|
||||
expect(repo.latestForSession("s1")?.id).toBe(id2);
|
||||
});
|
||||
|
||||
it("deletes by session, by agent, and by project", () => {
|
||||
repo.create({ sessionId: "s1", projectId: "p1", agentId: "a1", objective: "o", budget: -1 });
|
||||
repo.create({ sessionId: "s2", projectId: "p1", agentId: "a1", objective: "o", budget: -1 });
|
||||
repo.deleteBySession("s1");
|
||||
expect(repo.latestForSession("s1")).toBeNull();
|
||||
expect(repo.latestForSession("s2")).not.toBeNull();
|
||||
|
||||
// deleteByAgent drops the agent's rows but spares another agent in the same project.
|
||||
repo.create({ sessionId: "s3", projectId: "p1", agentId: "a2", objective: "o", budget: -1 });
|
||||
repo.deleteByAgent("p1", "a1");
|
||||
expect(repo.latestForSession("s2")).toBeNull();
|
||||
expect(repo.latestForSession("s3")).not.toBeNull();
|
||||
|
||||
repo.deleteByProject("p1");
|
||||
expect(repo.latestForSession("s3")).toBeNull();
|
||||
});
|
||||
|
||||
it("reconciles orphaned active rows to aborted on startup, leaving terminal rows untouched", () => {
|
||||
const active = repo.create({
|
||||
sessionId: "s1",
|
||||
projectId: "p1",
|
||||
agentId: "a1",
|
||||
objective: "o",
|
||||
budget: -1,
|
||||
});
|
||||
const done = repo.create({
|
||||
sessionId: "s2",
|
||||
projectId: "p1",
|
||||
agentId: "a1",
|
||||
objective: "o",
|
||||
budget: -1,
|
||||
});
|
||||
repo.finish(done, "complete", 1, 10);
|
||||
|
||||
// A hard crash leaves the running goal's row `active`; boot reconciliation flips only it.
|
||||
expect(repo.abortOrphanedActive()).toBe(1);
|
||||
expect(repo.latestForSession("s1")?.status).toBe("aborted");
|
||||
expect(repo.latestForSession("s2")?.status).toBe("complete");
|
||||
// Idempotent: a second boot finds nothing left to reconcile.
|
||||
expect(repo.abortOrphanedActive()).toBe(0);
|
||||
void active;
|
||||
});
|
||||
});
|
||||
|
||||
describe("SessionManager.startGoal", () => {
|
||||
let db: DatabaseSync;
|
||||
let sessions: SessionsRepo;
|
||||
let goals: GoalsRepo;
|
||||
let channels: ChannelHub;
|
||||
|
||||
beforeEach(() => {
|
||||
db = openDatabase(":memory:");
|
||||
sessions = new SessionsRepo(db);
|
||||
sessions.insert(ROW);
|
||||
goals = new GoalsRepo(db);
|
||||
channels = new ChannelHub();
|
||||
});
|
||||
afterEach(() => {
|
||||
channels.dispose();
|
||||
db.close();
|
||||
});
|
||||
|
||||
type RunOpts = { thinkingLevel?: string; goal?: { budget?: number } };
|
||||
|
||||
/**
|
||||
* Fake session: `run` asserts it was called in goal mode and emits the whole goal stream
|
||||
* the way core's loop would — per-round `[goal]` inputs and work, then the terminal
|
||||
* goal_finished event (the loop itself is core's and is tested in core).
|
||||
*/
|
||||
function goalFakeSession(
|
||||
stream: (input: OmniMessage[]) => OmniMessage[],
|
||||
): RuntimeSession & { runOpts: RunOpts[] } {
|
||||
const runOpts: RunOpts[] = [];
|
||||
return {
|
||||
sessionId: ROW.sessionId,
|
||||
runOpts,
|
||||
toolPermission: () => "rw",
|
||||
generateTitle: async () => ({ title: null, usage: null }),
|
||||
compactability: () => "ok" as const,
|
||||
steer: () => false,
|
||||
skipReconnectWait: () => false,
|
||||
async *run(input: OmniMessage[], opts) {
|
||||
runOpts.push({
|
||||
...(opts.thinkingLevel !== undefined ? { thinkingLevel: opts.thinkingLevel } : {}),
|
||||
...(opts.goal !== undefined ? { goal: opts.goal } : {}),
|
||||
});
|
||||
yield* stream(input);
|
||||
},
|
||||
async *compact() {},
|
||||
};
|
||||
}
|
||||
|
||||
function makeManager(session: RuntimeSession, withRepo = true): SessionManager {
|
||||
return new SessionManager({
|
||||
sessions,
|
||||
channels,
|
||||
sources: new SessionSources(),
|
||||
loader: { load: async () => session },
|
||||
recorder: { record: async () => {} },
|
||||
log: () => {},
|
||||
...(withRepo ? { goals } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
it("drives one goal-mode run, publishing goal events and persisting the outcome", async () => {
|
||||
const text = buildSkillsMessage(["web-design"], "make it work");
|
||||
const session = goalFakeSession((input) => [
|
||||
roundInput(1, (input[0]!.payload as { text: string }).text),
|
||||
assistantText("round 1 work"),
|
||||
tokenUsage(usage(100), usage(100)),
|
||||
roundInput(2, "make it work"),
|
||||
assistantText("round 2 work"),
|
||||
tokenUsage(usage(200), usage(200)),
|
||||
goalFinished("complete", 2, 300),
|
||||
]);
|
||||
const manager = makeManager(session);
|
||||
const events: ChannelEvent[] = [];
|
||||
channels.get(ROW.sessionId).subscribe((e) => events.push(e));
|
||||
|
||||
await manager.startGoal(ROW.sessionId, {
|
||||
input: [userText(text)],
|
||||
budget: -1,
|
||||
thinkingLevel: "high",
|
||||
});
|
||||
await waitFor(() => manager.statusOf(ROW.sessionId) === "idle");
|
||||
|
||||
// One run call carries the whole goal: the input verbatim, the per-goal thinking
|
||||
// level, and the goal option (core loops the rounds internally).
|
||||
expect(session.runOpts).toEqual([{ thinkingLevel: "high", goal: { budget: -1 } }]);
|
||||
|
||||
const server = events
|
||||
.filter((e) => e.event === "server_event")
|
||||
.map((e) => JSON.parse(e.data) as { type: string; [k: string]: unknown });
|
||||
// The recorded objective is the user's own text: the [use_skills] prefix is stripped.
|
||||
expect(server.find((e) => e.type === "goal_started")).toMatchObject({
|
||||
objective: "make it work",
|
||||
budget: -1,
|
||||
});
|
||||
const rounds = server.filter((e) => e.type === "goal_round");
|
||||
expect(rounds).toHaveLength(2);
|
||||
expect(rounds[1]).toMatchObject({ round: 2, used: 100 });
|
||||
const finished = server.find((e) => e.type === "goal_finished");
|
||||
expect(finished).toMatchObject({ outcome: "complete", rounds: 2, used: 300 });
|
||||
|
||||
const row = goals.latestForSession(ROW.sessionId);
|
||||
expect(row).toMatchObject({
|
||||
status: "complete",
|
||||
rounds: 2,
|
||||
used: 300,
|
||||
objective: "make it work",
|
||||
});
|
||||
|
||||
// The round inputs were published on the message stream (no `event:` name) for live viewers.
|
||||
const published = events
|
||||
.filter((e) => e.event === undefined)
|
||||
.map((e) => JSON.parse(e.data) as OmniMessage)
|
||||
.filter(
|
||||
(m) =>
|
||||
m.type === "model_msg" &&
|
||||
(m.payload as { role?: string }).role === "user" &&
|
||||
((m.payload as { text?: string }).text ?? "").startsWith("[goal]"),
|
||||
);
|
||||
expect(published).toHaveLength(2);
|
||||
// Round 1 carries the caller's input verbatim — the [use_skills] block included.
|
||||
expect((published[0]!.payload as { text: string }).text).toContain("[use_skills]");
|
||||
});
|
||||
|
||||
it("409s while a goal is running (mutual exclusion); runs without a goals repo", async () => {
|
||||
let release: () => void = () => {};
|
||||
const gate = new Promise<void>((r) => {
|
||||
release = r;
|
||||
});
|
||||
const session = goalFakeSession(() => [roundInput(1, "obj"), goalFinished("complete", 1, 0)]);
|
||||
const orig = session.run.bind(session);
|
||||
session.run = async function* (input, opts) {
|
||||
yield* orig(input, opts);
|
||||
await gate;
|
||||
};
|
||||
const manager = makeManager(session, false);
|
||||
await manager.startGoal(ROW.sessionId, { input: [userText("obj")], budget: -1 });
|
||||
await expect(manager.startTask(ROW.sessionId, [userText("x")])).rejects.toMatchObject({
|
||||
status: 409,
|
||||
});
|
||||
release();
|
||||
await waitFor(() => manager.statusOf(ROW.sessionId) === "idle");
|
||||
// No goals repo wired: the goal still ran and finished without touching one.
|
||||
expect(goals.latestForSession(ROW.sessionId)).toBeNull();
|
||||
});
|
||||
|
||||
it("a throw after the terminal event does not overwrite the recorded outcome", async () => {
|
||||
// repo.finish is an unconditional UPDATE: without the `finished` guard, the defensive
|
||||
// catch would flip a completed row to aborted and publish a contradicting event.
|
||||
const session = goalFakeSession(() => []);
|
||||
session.run = async function* () {
|
||||
yield roundInput(1, "obj");
|
||||
yield goalFinished("complete", 1, 42);
|
||||
throw new Error("post-terminal hiccup");
|
||||
};
|
||||
const manager = makeManager(session);
|
||||
const events: ChannelEvent[] = [];
|
||||
channels.get(ROW.sessionId).subscribe((e) => events.push(e));
|
||||
|
||||
await manager.startGoal(ROW.sessionId, { input: [userText("obj")], budget: -1 });
|
||||
await waitFor(() => manager.statusOf(ROW.sessionId) === "idle");
|
||||
|
||||
expect(goals.latestForSession(ROW.sessionId)).toMatchObject({
|
||||
status: "complete",
|
||||
rounds: 1,
|
||||
used: 42,
|
||||
});
|
||||
const finished = events
|
||||
.filter((e) => e.event === "server_event")
|
||||
.map((e) => JSON.parse(e.data) as { type: string; outcome?: string })
|
||||
.filter((e) => e.type === "goal_finished");
|
||||
expect(finished).toEqual([expect.objectContaining({ outcome: "complete" })]);
|
||||
});
|
||||
|
||||
it("closes the run state as aborted when the stream ends without a terminal event", async () => {
|
||||
// A cut-off run (infrastructure failure upstream) must not leave the row active.
|
||||
const session = goalFakeSession(() => [
|
||||
roundInput(1, "obj"),
|
||||
assistantText("partial work"),
|
||||
tokenUsage(usage(50), usage(50)),
|
||||
]);
|
||||
const manager = makeManager(session);
|
||||
const events: ChannelEvent[] = [];
|
||||
channels.get(ROW.sessionId).subscribe((e) => events.push(e));
|
||||
|
||||
await manager.startGoal(ROW.sessionId, { input: [userText("obj")], budget: 1000 });
|
||||
await waitFor(() => manager.statusOf(ROW.sessionId) === "idle");
|
||||
|
||||
expect(goals.latestForSession(ROW.sessionId)).toMatchObject({
|
||||
status: "aborted",
|
||||
rounds: 1,
|
||||
used: 50,
|
||||
});
|
||||
const server = events
|
||||
.filter((e) => e.event === "server_event")
|
||||
.map((e) => JSON.parse(e.data) as { type: string; [k: string]: unknown });
|
||||
expect(server.find((e) => e.type === "goal_finished")).toMatchObject({
|
||||
outcome: "aborted",
|
||||
rounds: 1,
|
||||
used: 50,
|
||||
});
|
||||
});
|
||||
|
||||
it("sanity: emptyTokenCounts helper stays exported for fakes", () => {
|
||||
expect(emptyTokenCounts().total).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -582,4 +582,26 @@ describe("session-index", () => {
|
||||
});
|
||||
expect(bad.status).toBe(400);
|
||||
});
|
||||
|
||||
it("rejects a malformed goal.budget with 400", async () => {
|
||||
await configureModels();
|
||||
const res = await api.post(base(), {});
|
||||
const { session } = (await res.json()) as SessionCreateResponse;
|
||||
for (const budget of ["500k", 0, -2, 1.5]) {
|
||||
const bad = await api.post(`/api/sessions/${session.sessionId}/tasks`, {
|
||||
input: [{ type: "text", text: "objective" }],
|
||||
goal: { budget },
|
||||
});
|
||||
expect(bad.status).toBe(400);
|
||||
}
|
||||
// Image parts have no place in the re-injected objective.
|
||||
const image = await api.post(`/api/sessions/${session.sessionId}/tasks`, {
|
||||
input: [
|
||||
{ type: "text", text: "objective" },
|
||||
{ type: "image_url", imageUrl: "data:image/png;base64,aGk=" },
|
||||
],
|
||||
goal: {},
|
||||
});
|
||||
expect(image.status).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user