Port goal and question-card server extensions
This commit is contained in:
@@ -0,0 +1,22 @@
|
||||
# Hands-off goals
|
||||
|
||||
The user starts or ends a goal through the exported server actions.
|
||||
Only the standing `@ultimate` conversation uses the `goal` tool.
|
||||
The root document stores one open goal and its decisions.
|
||||
Three failed tries block a criterion. Rewording preserves unchanged criteria.
|
||||
Decisions can name screenshots from the caller's conversation.
|
||||
The `latest` reference never crosses an input boundary.
|
||||
|
||||
Each input to `@ultimate` receives the current open goal note.
|
||||
A queued start card grants nothing after the user ends the goal.
|
||||
The start wakes `@ultimate`. The report uses a passive write.
|
||||
|
||||
The optional automations service creates one keyed reminder every 30 minutes.
|
||||
A durable task synchronizes its state after each lifecycle change.
|
||||
A replay adopts the existing reminder. A user-deleted reminder stays deleted.
|
||||
Pause disables the reminder. Resume enables it. End deletes it.
|
||||
A reminder input adds the reminder checklist to the goal note.
|
||||
World automations identify a fire with the `[automation <id> fired]` prefix.
|
||||
The goal hook recognizes that prefix and the structured `automations.fired` entry.
|
||||
|
||||
Side chats and browser components are outside this server port.
|
||||
@@ -0,0 +1,4 @@
|
||||
/** Server action references. Side chats are not part of the world port. */
|
||||
export const docRef = <T>(kind: string) => ({kind})
|
||||
export const actionRef = <A, R>(extension: string, action: string) => ({extension, action})
|
||||
export const implementAction = (_ref: unknown, run: any) => run
|
||||
@@ -0,0 +1,47 @@
|
||||
/** Durable delivery of goal start cards and passive report cards. */
|
||||
import { defineTask } from '@earendil-works/pi-durable'
|
||||
import type { ConversationId, Tx } from '@earendil-works/pi-durable'
|
||||
import type { Host } from '../../host/js/extensions.ts'
|
||||
|
||||
/** A goal card for @ultimate: `goal.started` as an input that wakes it, `goal.report` as a write. */
|
||||
type Card = { to: number; goal: number; kind: 'goal.started' | 'goal.report'; content: string }
|
||||
|
||||
/** Created in the commit that changes the goal, so a crash cannot lose the card; the request id makes retries harmless. */
|
||||
export const deliverTask = (host: Pick<Host, 'harness'>) =>
|
||||
defineTask<Card, { phase: 'submit' }, null>({
|
||||
name: 'goal.deliver',
|
||||
version: 1,
|
||||
initial: () => ({ phase: 'submit' }),
|
||||
phases: {
|
||||
submit: async (task, runtime, ctx) => {
|
||||
const { to, goal, kind, content } = task.input
|
||||
const requestId = `${kind}:${goal}`
|
||||
const data = { goal }
|
||||
// The host handle, unlike the task runtime's, admits passive writes.
|
||||
const target = (await host.harness().conversation(to as ConversationId, ctx))!
|
||||
await target.submit(
|
||||
kind === 'goal.started'
|
||||
? { type: 'input', requestId, content, entry: { kind, data }, whenBusy: 'followUp' }
|
||||
: {
|
||||
type: 'write',
|
||||
requestId,
|
||||
entry: { kind, data, model: [{ role: 'user', content, timestamp: Date.now() }] },
|
||||
},
|
||||
ctx,
|
||||
)
|
||||
await runtime.commit(
|
||||
() => ({ status: 'terminal', outcome: { status: 'completed', result: null } }),
|
||||
ctx,
|
||||
)
|
||||
},
|
||||
},
|
||||
abort: (_task, runtime, ctx) =>
|
||||
runtime.commit(() => ({ status: 'terminal', outcome: { status: 'aborted' } }), ctx),
|
||||
})
|
||||
|
||||
export const deliver = (host: Pick<Host, 'harness'>, tx: Tx, card: Card) =>
|
||||
tx.createTask(deliverTask(host), card, {
|
||||
conversationId: card.to as ConversationId,
|
||||
ownership: { kind: 'conversation' },
|
||||
background: true,
|
||||
})
|
||||
@@ -0,0 +1,57 @@
|
||||
/** Hands-off goals in the browser: the goal the user put @ultimate on, and every decision it took for them. Browser-safe. */
|
||||
import { actionRef, docRef } from '../goal/compat.ts'
|
||||
|
||||
/** One decision @ultimate took instead of asking the user. */
|
||||
export type Decision = {
|
||||
at: number
|
||||
/** What it would have asked the user. */
|
||||
question: string
|
||||
/** What it chose. */
|
||||
picked: string
|
||||
reason: string
|
||||
/** The branch the decision was for. */
|
||||
conversation?: number
|
||||
/** The entry of @ultimate's conversation holding the screenshots it judged, such as a UI preview it approved. */
|
||||
images?: number
|
||||
}
|
||||
|
||||
/** How a goal ended: met, blocked (every remaining criterion stuck), or ended by the user. */
|
||||
export type Ending = {
|
||||
at: number
|
||||
by: 'user' | 'ultimate'
|
||||
outcome: 'met' | 'blocked' | 'ended'
|
||||
summary?: string
|
||||
}
|
||||
|
||||
/** What "done" means, as @ultimate wrote it down: each criterion open, met, or blocked after repeated failed tries. */
|
||||
export type Criterion = { text: string; status: 'open' | 'met' | 'blocked'; tries?: number }
|
||||
|
||||
export type Goal = {
|
||||
id: number
|
||||
/** The user's words: what to reach, and when to stop if they said so. */
|
||||
text: string
|
||||
startedAt: number
|
||||
criteria: Criterion[]
|
||||
decisions: Decision[]
|
||||
/** The "Goal reminder" automation that wakes @ultimate every 30 minutes while the goal is open. */
|
||||
reminder?: string
|
||||
/** Set while @ultimate is blocked on the user: the reminder is paused until it resumes. */
|
||||
waiting?: { reason: string; at: number }
|
||||
ended?: Ending
|
||||
}
|
||||
|
||||
/** `{ next, goals }` on conversation 1, oldest first; at most one goal is open (no `ended`). */
|
||||
export const GoalDoc = docRef<{ next: number; goals: Goal[] }>('goal')
|
||||
|
||||
/** `goal.started` and `goal.report` entries in @ultimate's conversation name their goal. */
|
||||
export type GoalEntry = { goal: number }
|
||||
|
||||
/** `{ text }` → the new goal. User-only: puts @ultimate hands-off on it. */
|
||||
export const StartAction = actionRef<{ text: string }, Goal>('goal', 'start')
|
||||
/** No arguments → the ended goal. User-only: ends the open goal; @ultimate asks again as usual. */
|
||||
export const EndAction = actionRef<null, Goal>('goal', 'end')
|
||||
/** `{ goal, entry }` → the images of a decision's screenshot entry. */
|
||||
export const ImagesAction = actionRef<
|
||||
{ goal: number; entry: number },
|
||||
{ data: string; mimeType: string }[]
|
||||
>('goal', 'images')
|
||||
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* The goal reminder: an automation that wakes @ultimate every 30 minutes while a goal is open, so a stalled goal never
|
||||
* waits silently. Every goal change (start, pause, resume, end) creates a sync task in its own commit; the task brings
|
||||
* the automation in line with the goal as it is when the task runs. A crash leaves at most a task to rerun, and a rerun
|
||||
* gets the automation an earlier try made back from its create key instead of making a second one.
|
||||
*/
|
||||
import { defineTask, ROOT_CONVERSATION_ID } from '@earendil-works/pi-durable'
|
||||
import type { ConversationDocToken, Tx } from '@earendil-works/pi-durable'
|
||||
import { BACKGROUND_CONTEXT as context } from '@earendil-works/chord/context'
|
||||
import type { Host } from '../../host/js/extensions.ts'
|
||||
import { AutomationsApi } from '../automations/server.ts'
|
||||
import type { Automation } from '../automations/types.ts'
|
||||
import type { Goal } from './refs.ts'
|
||||
|
||||
export const SCHEDULE = 'every 30m'
|
||||
export const reminderName = (goal: number) => `Goal reminder · goal ${goal}`
|
||||
/** Each goal's reminder has its own create key: a rerun gets it back, and no goal touches another's. */
|
||||
const reminderKey = (goal: number) => `goal.reminder:${goal}`
|
||||
|
||||
/**
|
||||
* Steps at most: each makes one change (create, link, delete, pause or resume) and looks again, so a pause and resume
|
||||
* that race still end on the newest goal.
|
||||
*/
|
||||
const STEPS = 6
|
||||
|
||||
type Goals = ConversationDocToken<{ next: number; goals: Goal[] }>
|
||||
|
||||
export function reminderTask(host: Host, Goals: Goals) {
|
||||
const find = async (tx: Tx, id: number) =>
|
||||
(await tx.doc(Goals, ROOT_CONVERSATION_ID)).goals.find((item) => item.id === id)
|
||||
/** One look: true when it changed something and must look again, false once the automation matches the goal. */
|
||||
async function step(id: number): Promise<boolean> {
|
||||
const automations = host.want<{ operate(args: Record<string, unknown>): Promise<unknown> }>(AutomationsApi)
|
||||
// Automations is off: the goal runs without a reminder.
|
||||
if (!automations) return false
|
||||
// A plain copy: the document's overlay is unusable after the commit.
|
||||
const goal = await host
|
||||
.harness()
|
||||
.commit(
|
||||
async (tx) =>
|
||||
await find(tx, id).then((found) => found && (JSON.parse(JSON.stringify(found)) as Goal)),
|
||||
context,
|
||||
)
|
||||
const list = (await automations.operate({ action: 'list' })) as Automation[]
|
||||
// The linked one by its id, whatever the user renamed it to; before it is linked, the one its key made.
|
||||
const own = goal?.reminder
|
||||
? list.find((item) => item.id === goal.reminder)
|
||||
: list.find((item) => item.key === reminderKey(id))
|
||||
if (!goal || goal.ended) {
|
||||
if (!own) return false
|
||||
await automations.operate({ action: 'delete', id: own.id })
|
||||
return true
|
||||
}
|
||||
if (!own) {
|
||||
// One the user deleted in the Automations panel stays deleted.
|
||||
if (goal.reminder) return false
|
||||
await automations.operate({
|
||||
action: 'create',
|
||||
name: reminderName(id),
|
||||
schedule: SCHEDULE,
|
||||
subscribers: ['@ultimate'],
|
||||
key: reminderKey(id),
|
||||
})
|
||||
return true
|
||||
}
|
||||
if (goal.reminder !== own.id) {
|
||||
await host.harness().commit(async (tx) => {
|
||||
const current = await find(tx, id)
|
||||
if (current) current.reminder = own.id
|
||||
}, context)
|
||||
return true
|
||||
}
|
||||
if (own.enabled === !goal.waiting) return false
|
||||
if (goal.waiting) await automations.operate({ action: 'pause', id: own.id })
|
||||
else await automations.operate({ action: 'resume', id: own.id })
|
||||
return true
|
||||
}
|
||||
return defineTask<{ goal: number }, { phase: 'sync' }, null>({
|
||||
name: 'goal.reminder',
|
||||
version: 1,
|
||||
initial: () => ({ phase: 'sync' }),
|
||||
phases: {
|
||||
sync: async (task, runtime, ctx) => {
|
||||
for (let i = 0; i < STEPS; i++) if (!(await step(task.input.goal))) break
|
||||
await runtime.commit(
|
||||
() => ({ status: 'terminal', outcome: { status: 'completed', result: null } }),
|
||||
ctx,
|
||||
)
|
||||
},
|
||||
},
|
||||
abort: (_task, runtime, ctx) =>
|
||||
runtime.commit(() => ({ status: 'terminal', outcome: { status: 'aborted' } }), ctx),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,367 @@
|
||||
/**
|
||||
* Hands-off goals: the user puts @ultimate on a goal and goes away. While it is open, @ultimate decides what it would
|
||||
* otherwise ask them, records each decision with its reason, and ends the goal with a report. Only the user starts or
|
||||
* ends a goal early; @ultimate hears it on every run through a per-input note.
|
||||
*/
|
||||
import { arr, int, lit, obj, oneOf, opt, str, union } from '@earendil-works/pi-schema'
|
||||
import {
|
||||
defineDoc,
|
||||
defineExtension,
|
||||
defineTool,
|
||||
GenerationTask,
|
||||
hook,
|
||||
ROOT_CONVERSATION_ID,
|
||||
} from '@earendil-works/pi-durable'
|
||||
import type { ConversationId, EntryId, Tx } from '@earendil-works/pi-durable'
|
||||
import { BACKGROUND_CONTEXT as context } from '@earendil-works/chord/context'
|
||||
import { implementAction } from '../goal/compat.ts'
|
||||
import { imagesOf, latestImagesEntry } from '../messages/compat.ts'
|
||||
import type { Host } from '../../host/js/extensions.ts'
|
||||
import { AutomationsApi } from '../automations/server.ts'
|
||||
import { Standing } from '../subagents/api.ts'
|
||||
import { deliver, deliverTask } from './delivery.ts'
|
||||
import { reminderTask } from './reminder.ts'
|
||||
import { EndAction, GoalDoc, ImagesAction, StartAction } from './refs.ts'
|
||||
import type { Criterion, Decision, Ending, Goal } from './refs.ts'
|
||||
|
||||
/** Failed tries after which a criterion is blocked; work goes on with the others. */
|
||||
export const TRIES = 3
|
||||
const TEXT_CHARS = 2000
|
||||
|
||||
export const Goals = defineDoc<{ next: number; goals: Goal[] }>({
|
||||
kind: GoalDoc.kind,
|
||||
checkpointWhen: () => false,
|
||||
version: 1,
|
||||
scope: 'conversation',
|
||||
history: 'latest',
|
||||
fork: 'initial',
|
||||
initial: () => ({ next: 1, goals: [] }),
|
||||
})
|
||||
export const docs = [Goals]
|
||||
|
||||
/** A plain copy: the document's overlay is a proxy that structuredClone refuses, and unusable after the commit. */
|
||||
const copy = (goal: Goal) => JSON.parse(JSON.stringify(goal)) as Goal
|
||||
|
||||
const ultimateOf = async (tx: Tx) => (await tx.doc(Standing, ROOT_CONVERSATION_ID)).ultimate
|
||||
|
||||
/** The open goal, if any. */
|
||||
export async function openGoal(tx: Tx) {
|
||||
return (await tx.doc(Goals, ROOT_CONVERSATION_ID)).goals.find((goal) => !goal.ended)
|
||||
}
|
||||
|
||||
const text = (value: unknown, name: string) => {
|
||||
if (typeof value !== 'string' || !value.trim()) throw new Error(`${name} must be nonempty`)
|
||||
if (value.trim().length > TEXT_CHARS)
|
||||
throw new Error(`${name} must be at most ${TEXT_CHARS} characters`)
|
||||
return value.trim()
|
||||
}
|
||||
|
||||
/** Start a goal for the user's words `words`; refuses while another is open. */
|
||||
export async function start(tx: Tx, words: unknown, now: number) {
|
||||
const goals = await tx.doc(Goals, ROOT_CONVERSATION_ID)
|
||||
const open = goals.goals.find((goal) => !goal.ended)
|
||||
if (open) throw new Error(`Goal ${open.id} is still open; end it first`)
|
||||
const goalText = text(words, 'The goal')
|
||||
const goal: Goal = {
|
||||
id: goals.next++,
|
||||
text: goalText,
|
||||
startedAt: now,
|
||||
criteria: [],
|
||||
decisions: [],
|
||||
}
|
||||
goals.goals.push(goal)
|
||||
return goal
|
||||
}
|
||||
|
||||
/** End the open goal; refuses when none is open. */
|
||||
export async function end(tx: Tx, ending: Ending) {
|
||||
const goal = await openGoal(tx)
|
||||
if (!goal) throw new Error('No goal is open')
|
||||
goal.ended = ending
|
||||
return copy(goal)
|
||||
}
|
||||
|
||||
const status = (criterion: Criterion) =>
|
||||
criterion.status === 'open' && criterion.tries
|
||||
? `open, ${criterion.tries}/${TRIES} failed tries`
|
||||
: criterion.status
|
||||
|
||||
/**
|
||||
* @ultimate's per-run note while a goal is open: the goal, its criteria, and how it works; on a fire of its reminder,
|
||||
* what to check.
|
||||
*/
|
||||
export function noteText(goal: Goal, reminded = false) {
|
||||
const criteria = goal.criteria.length
|
||||
? goal.criteria.map((item, index) => `${index + 1}. [${status(item)}] ${item.text}`).join('\n')
|
||||
: "None yet: write them first with goal {action: 'criteria', criteria}, each one you can check."
|
||||
return [
|
||||
`Hands-off goal ${goal.id}, granted by the user, who is away: ${goal.text}`,
|
||||
`Done means:\n${criteria}`,
|
||||
`Work toward it without the user: decide what you would ask them yourself (a go, a decision, a UI preview approval against the criteria), record each with goal {action: 'decide'} and its reason, and keep going. ${goal.decisions.length} decided so far. Mark criteria met or failed as you check them; a criterion is blocked after ${TRIES} failed tries, so learn from each failure before the next try. Finish with goal {action: 'finish'} when every criterion is met or every open one is blocked. Only when you are truly blocked on the user, goal {action: 'pause', reason} stops the reminder that wakes you.`,
|
||||
...(goal.waiting
|
||||
? [
|
||||
`Waiting on the user since ${new Date(goal.waiting.at).toISOString()}: ${goal.waiting.reason}. The reminder is paused. Once they answer and you can go on, goal {action: 'resume'} turns it back on.`,
|
||||
]
|
||||
: []),
|
||||
...(reminded
|
||||
? [
|
||||
"This is the goal reminder: check that the goal's branches are moving, the lander queue, reactor lag and watchdog cards. Nudge or fix what is stuck and record a decide only when you changed something; otherwise end the turn quietly.",
|
||||
]
|
||||
: []),
|
||||
].join('\n\n')
|
||||
}
|
||||
|
||||
/** The newest entry with images in this turn of conversation `id`, or entry `ref` when it has some. */
|
||||
async function imagesEntry(tx: Tx, id: ConversationId, ref: 'latest' | number) {
|
||||
if (ref !== 'latest') {
|
||||
const entry = await tx.entry(ref as EntryId)
|
||||
if (entry?.conversationId !== id || !imagesOf(entry).length)
|
||||
throw new Error(`images: entry ${ref} in your conversation has no images`)
|
||||
return ref
|
||||
}
|
||||
return (await latestImagesEntry(tx, id)).id as number
|
||||
}
|
||||
|
||||
type ToolArgs = {
|
||||
action: 'criteria' | 'decide' | 'met' | 'failed' | 'finish' | 'pause' | 'resume'
|
||||
criteria?: string[]
|
||||
n?: number
|
||||
question?: string
|
||||
picked?: string
|
||||
reason?: string
|
||||
conversation?: number
|
||||
images?: 'latest' | number
|
||||
outcome?: 'met' | 'blocked'
|
||||
summary?: string
|
||||
}
|
||||
|
||||
/** One `goal` tool call by @ultimate (`ultimate`) in `tx`; returns its answer, and the goal when it finished. */
|
||||
export async function act(tx: Tx, ultimate: ConversationId, args: ToolArgs, now: number) {
|
||||
const goal = await openGoal(tx)
|
||||
if (!goal) throw new Error('No hands-off goal is open; ask the user as usual.')
|
||||
const criterion = () => {
|
||||
const found = goal.criteria[(args.n ?? 0) - 1]
|
||||
if (!found) throw new Error(`No criterion ${String(args.n)}; there are ${goal.criteria.length}`)
|
||||
return found
|
||||
}
|
||||
if (args.action === 'criteria') {
|
||||
const items = (args.criteria ?? []).map((item) => text(item, 'A criterion'))
|
||||
if (!items.length) throw new Error('criteria must list at least one')
|
||||
// Rewording keeps what was learned about a criterion with the same text.
|
||||
goal.criteria = items.map(
|
||||
(item) =>
|
||||
goal.criteria.find((old) => old.text === item) ?? { text: item, status: 'open' as const },
|
||||
)
|
||||
return { answer: `Goal ${goal.id} has ${items.length} criteria.` }
|
||||
}
|
||||
if (args.action === 'decide') {
|
||||
const decision: Decision = {
|
||||
at: now,
|
||||
question: text(args.question, 'question'),
|
||||
picked: text(args.picked, 'picked'),
|
||||
reason: text(args.reason, 'reason'),
|
||||
...(args.conversation === undefined ? {} : { conversation: args.conversation }),
|
||||
...(args.images === undefined
|
||||
? {}
|
||||
: { images: await imagesEntry(tx, ultimate, args.images) }),
|
||||
}
|
||||
goal.decisions.push(decision)
|
||||
return { answer: `Recorded decision ${goal.decisions.length}: ${decision.picked}` }
|
||||
}
|
||||
if (args.action === 'met') {
|
||||
criterion().status = 'met'
|
||||
return { answer: `Criterion ${args.n} met.` }
|
||||
}
|
||||
if (args.action === 'failed') {
|
||||
const found = criterion()
|
||||
found.tries = (found.tries ?? 0) + 1
|
||||
if (found.tries >= TRIES) found.status = 'blocked'
|
||||
const open = goal.criteria.filter((item) => item.status === 'open').length
|
||||
return {
|
||||
answer:
|
||||
found.status === 'blocked'
|
||||
? `Criterion ${args.n} is blocked after ${TRIES} failed tries.${open ? ` Go on with the ${open} still open.` : ' None is open: finish the goal as blocked.'}`
|
||||
: `Criterion ${args.n}: ${found.tries}/${TRIES} failed tries. Say what you change before the next try.`,
|
||||
}
|
||||
}
|
||||
if (args.action === 'pause') {
|
||||
goal.waiting = { reason: text(args.reason, 'reason'), at: now }
|
||||
return {
|
||||
answer: `Goal ${goal.id} waits on the user; the reminder is paused. Ask them now.`,
|
||||
sync: goal.id,
|
||||
}
|
||||
}
|
||||
if (args.action === 'resume') {
|
||||
if (!goal.waiting) throw new Error(`Goal ${goal.id} is not paused`)
|
||||
delete goal.waiting
|
||||
return {
|
||||
answer: `Goal ${goal.id} goes on; the reminder wakes you again.`,
|
||||
sync: goal.id,
|
||||
}
|
||||
}
|
||||
if (args.outcome !== 'met' && args.outcome !== 'blocked')
|
||||
throw new Error("finish needs outcome 'met' or 'blocked'")
|
||||
const ended = await end(tx, {
|
||||
at: now,
|
||||
by: 'ultimate',
|
||||
outcome: args.outcome,
|
||||
summary: text(args.summary, 'summary'),
|
||||
})
|
||||
return {
|
||||
answer: `Goal ${goal.id} finished: ${args.outcome}. The report is in your conversation.`,
|
||||
ended,
|
||||
}
|
||||
}
|
||||
|
||||
/** The report card's model text: what @ultimate (or the user, ending it) should take from it. */
|
||||
const reportText = (goal: Goal) =>
|
||||
goal.ended?.by === 'user'
|
||||
? `The user ended hands-off goal ${goal.id}. From now on ask them as usual.`
|
||||
: `Hands-off goal ${goal.id} ended (${goal.ended?.outcome}); its report card is here for the user. From now on ask them as usual.`
|
||||
|
||||
/**
|
||||
* Goal `goal`'s card of `kind` for @ultimate, delivered by a task created in `tx`, the commit that changed the goal.
|
||||
* The start card says only that the user started it: what @ultimate may do comes from the goal note, built from the
|
||||
* goal as it is when the input runs, so a start still queued after the user ended the goal grants nothing.
|
||||
*/
|
||||
async function card(host: Host, tx: Tx, kind: 'goal.started' | 'goal.report', goal: Goal) {
|
||||
const to = await ultimateOf(tx)
|
||||
if (to === undefined) throw new Error('@ultimate has no conversation yet')
|
||||
await deliver(host, tx, {
|
||||
to,
|
||||
goal: goal.id,
|
||||
kind,
|
||||
content:
|
||||
kind === 'goal.started' ? `The user started hands-off goal ${goal.id}.` : reportText(goal),
|
||||
})
|
||||
}
|
||||
|
||||
/** `name` of an action's arguments; the helpers above validate it. */
|
||||
const field = (args: unknown, name: string) =>
|
||||
args && typeof args === 'object' ? (args as Record<string, unknown>)[name] : undefined
|
||||
|
||||
/** In the commit that changes goal `goal`: a task that brings its reminder in line once the change is durable. */
|
||||
const sync = (host: Host, tx: Tx, goal: number) =>
|
||||
tx.createTask(
|
||||
reminderTask(host, Goals),
|
||||
{ goal },
|
||||
{
|
||||
conversationId: ROOT_CONVERSATION_ID,
|
||||
ownership: { kind: 'conversation' },
|
||||
background: true,
|
||||
},
|
||||
)
|
||||
|
||||
/** Starting and ending a goal is the user's grant; @ultimate only finishes one through its tool. */
|
||||
export const userActions = ['start', 'end']
|
||||
export const actions = {
|
||||
start: implementAction(StartAction, (args, { host }) =>
|
||||
host.harness().commit(async (tx) => {
|
||||
const goal = await start(tx, field(args, 'text'), Date.now())
|
||||
await card(host, tx, 'goal.started', goal)
|
||||
await sync(host, tx, goal.id)
|
||||
return copy(goal)
|
||||
}, context),
|
||||
),
|
||||
end: implementAction(EndAction, (_args, { host }) =>
|
||||
host.harness().commit(async (tx) => {
|
||||
const goal = await end(tx, { at: Date.now(), by: 'user', outcome: 'ended' })
|
||||
await card(host, tx, 'goal.report', goal)
|
||||
await sync(host, tx, goal.id)
|
||||
return goal
|
||||
}, context),
|
||||
),
|
||||
images: implementAction(ImagesAction, async (args, { host }) =>
|
||||
host.harness().commit(async (tx) => {
|
||||
const goal = (await tx.doc(Goals, ROOT_CONVERSATION_ID)).goals.find(
|
||||
(item) => item.id === field(args, 'goal'),
|
||||
)
|
||||
const entry = field(args, 'entry')
|
||||
// Only the screenshots a decision names, not any entry by id.
|
||||
if (!goal?.decisions.some((decision) => decision.images === entry))
|
||||
throw new Error('No decision of that goal has these images')
|
||||
return imagesOf(await tx.entry(entry as EntryId)).map(({ data, mimeType }) => ({
|
||||
data,
|
||||
mimeType,
|
||||
}))
|
||||
}, context),
|
||||
),
|
||||
}
|
||||
|
||||
export default function goalExtension(host: Host) {
|
||||
// Optional: without automations a goal runs as before, with no reminder.
|
||||
host.want(AutomationsApi)
|
||||
return defineExtension({
|
||||
name: 'goal',
|
||||
tasks: [deliverTask(host), reminderTask(host, Goals)],
|
||||
hooks: [
|
||||
hook(GenerationTask, {
|
||||
// Pi upstream: @earendil-works/pi-durable@1.5.0 — patched transactional beforeInput hook.
|
||||
// A per-run note while a goal is open, never a system-prompt section, keeps the prompt cached.
|
||||
beforeInput: async (input, { tx, conversationId }) => {
|
||||
if ((await ultimateOf(tx)) !== conversationId) return undefined
|
||||
const goal = await openGoal(tx)
|
||||
if (!goal) return undefined
|
||||
// World automations use a plain input with their fire prefix.
|
||||
const reminded = goal.reminder !== undefined && (
|
||||
(input.kind === 'automations.fired' &&
|
||||
(input.data as { id?: string } | undefined)?.id === goal.reminder) ||
|
||||
input.model?.some((message) => message.role === 'user' &&
|
||||
(typeof message.content === 'string' ? message.content :
|
||||
message.content.filter((block) => block.type === 'text').map((block) => block.text).join('\n'))
|
||||
.startsWith(`[automation ${goal.reminder} fired]`))
|
||||
)
|
||||
return {
|
||||
entries: [
|
||||
{
|
||||
kind: 'goal.note',
|
||||
data: { goal: goal.id },
|
||||
model: [
|
||||
{
|
||||
role: 'user' as const,
|
||||
content: noteText(goal, reminded),
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
},
|
||||
}),
|
||||
],
|
||||
tools: [
|
||||
defineTool({
|
||||
name: 'goal',
|
||||
deferred: true,
|
||||
description:
|
||||
"The hands-off goal the user put you on (only while one is open; the goal note names it): criteria {criteria: [...]} writes what done means, each checkable; decide {question, picked, reason, conversation?, images?} records a decision you took instead of asking the user (images: \"latest\" or an entry id of your conversation with screenshots you judged); met {n} / failed {n} mark criterion n; finish {outcome: 'met' | 'blocked', summary} ends the goal and leaves the report card; pause {reason} when you are truly blocked on the user stops the 30-minute goal reminder and shows them what you wait on, resume turns it back on once you can go on.",
|
||||
parameters: obj({
|
||||
action: oneOf(['criteria', 'decide', 'met', 'failed', 'finish', 'pause', 'resume']),
|
||||
criteria: opt(arr(str())),
|
||||
n: opt(int({ minimum: 1 })),
|
||||
question: opt(str()),
|
||||
picked: opt(str()),
|
||||
reason: opt(str()),
|
||||
conversation: opt(int({ minimum: 1 })),
|
||||
images: opt(union([lit('latest'), int({ minimum: 1 })])),
|
||||
outcome: opt(oneOf(['met', 'blocked'])),
|
||||
summary: opt(str()),
|
||||
}),
|
||||
// Not replay-safe (the default): an interrupted call is never rerun, so a decision is recorded at most once.
|
||||
async execute(args, api, ctx) {
|
||||
const caller = api.conversationId
|
||||
const result = await api.commit(async (tx) => {
|
||||
if ((await ultimateOf(tx)) !== caller)
|
||||
throw new Error('Only @ultimate keeps the hands-off goal.')
|
||||
const acted = await act(tx, caller, args, Date.now())
|
||||
if (acted.ended) await card(host, tx, 'goal.report', acted.ended)
|
||||
const changed = acted.sync ?? acted.ended?.id
|
||||
if (changed !== undefined) await sync(host, tx, changed)
|
||||
return acted
|
||||
}, ctx)
|
||||
return { content: [{ type: 'text', text: result.answer }] }
|
||||
},
|
||||
}),
|
||||
],
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
# Question cards
|
||||
|
||||
The exported user actions answer or dismiss an open message to the user.
|
||||
The messages service delivers the answer to the asking conversation.
|
||||
The action validates the question text and rejects duplicate answers.
|
||||
Only one concurrent answer settles an open message.
|
||||
|
||||
Answer text preserves the message's numbers and option keys.
|
||||
A note follows the answers. A note alone also settles the message.
|
||||
Free text answers a message without structured answers.
|
||||
Dismissal sends a passive note and starts no run.
|
||||
Closed messages reject further answers and dismissals.
|
||||
|
||||
The messages extension owns question schemas and open-question notes.
|
||||
Browser components are outside this server port.
|
||||
The “settling for the user” case awaits the later world UI work.
|
||||
It uses the messages UI action, not the question-card service.
|
||||
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* Questions in the browser: the actions that answer an agent's questions to the user from its message's card.
|
||||
* Browser-safe.
|
||||
*/
|
||||
import { actionRef } from '../goal/compat.ts'
|
||||
|
||||
/** One answered question: the question's text and the chosen or typed answer (several choices joined by ", "). */
|
||||
export type Answer = { question: string; answer: string }
|
||||
|
||||
/**
|
||||
* `{ messageId, answers?, note? }` or `{ messageId, text }` → `{ id }`: answer open message `messageId` with a reply
|
||||
* to its sender, from its questions (`note` goes after the answer lines) or as free text; `id` is the reply's message
|
||||
* id. Fails when the message no longer waits on the user.
|
||||
*/
|
||||
export const AnswerAction = actionRef<
|
||||
{ messageId: number; answers?: Answer[]; note?: string; text?: string },
|
||||
{ id: number }
|
||||
>('questions', 'answer')
|
||||
/**
|
||||
* `{ messageId }` → `{ id }`: close open message `messageId` without an answer; its sender gets a one-way note,
|
||||
* `id`, that starts no run. Fails when the message no longer waits on the user.
|
||||
*/
|
||||
export const DismissAction = actionRef<{ messageId: number }, { id: number }>(
|
||||
'questions',
|
||||
'dismiss',
|
||||
)
|
||||
@@ -0,0 +1,109 @@
|
||||
/** User actions that answer or dismiss question cards through the messages service. */
|
||||
import { defineExtension, ROOT_CONVERSATION_ID } from '@earendil-works/pi-durable'
|
||||
import { BACKGROUND_CONTEXT as context, withAbortSignal } from '@earendil-works/chord/context'
|
||||
import type { Host } from '../../host/js/extensions.ts'
|
||||
import { Messages, MessagesApi } from '../messages/api.ts'
|
||||
import type { Message, Question } from '../messages/api.ts'
|
||||
import { implementAction } from '../goal/compat.ts'
|
||||
import { AnswerAction, DismissAction } from './refs.ts'
|
||||
|
||||
const open = (message: Message) =>
|
||||
message.to === 'user' && message.reply && message.state === 'delivered'
|
||||
const NOT_OPEN = 'These questions are no longer open'
|
||||
|
||||
function validAnswers(value: unknown): value is { question: string; answer: string }[] {
|
||||
return (
|
||||
Array.isArray(value) &&
|
||||
value.every(
|
||||
(a: { question?: unknown; answer?: unknown } | null) =>
|
||||
a && typeof a.question === 'string' && typeof a.answer === 'string' && a.answer.trim(),
|
||||
)
|
||||
)
|
||||
}
|
||||
function checkAnswers(answers: { question: string }[], questions: Question[]) {
|
||||
if (
|
||||
new Set(answers.map((a) => a.question)).size !== answers.length ||
|
||||
answers.some((a) => !questions.some((q) => q.text === a.question))
|
||||
)
|
||||
throw new Error('Unknown question')
|
||||
}
|
||||
/** The keys of the chosen options, in option order: "a", "a,c"; none when an answer names no option. */
|
||||
function keysOf(question: Question, answer: string) {
|
||||
const labels = question.kind === 'multi' ? answer.split(', ') : [answer]
|
||||
const keys = (question.options ?? []).flatMap((o) =>
|
||||
o.key && labels.includes(o.label) ? [o.key] : [],
|
||||
)
|
||||
return keys.length === labels.length ? keys.join(',') : ''
|
||||
}
|
||||
/**
|
||||
* The reply naming each answer by the message's own numbering: `1a. Topic: label` for an option (`2a,c.` for
|
||||
* several), `3: text` for typed text or a rating, `Topic: answer` when the message numbered nothing; a note from the
|
||||
* card's field follows the answers.
|
||||
*/
|
||||
export function answerText(
|
||||
answers: { question: string; answer: string }[],
|
||||
questions: Question[],
|
||||
note?: string,
|
||||
) {
|
||||
const lines = answers.map(({ question, answer }) => {
|
||||
const q = questions.find((candidate) => candidate.text === question)!
|
||||
const keys = keysOf(q, answer)
|
||||
if (!q.ref) return `${q.text}: ${answer}`
|
||||
return keys ? `${q.ref}${keys}. ${q.text}: ${answer}` : `${q.ref}: ${answer}`
|
||||
})
|
||||
if (note?.trim()) lines.push(answers.length ? `Note: ${note.trim()}` : note.trim())
|
||||
return lines.join('\n\n')
|
||||
}
|
||||
|
||||
export const userActions = ['answer', 'dismiss']
|
||||
export const actions = {
|
||||
/** Answer an open message from its questions, with a note, or as free text: a user reply to its sender. */
|
||||
answer: implementAction(AnswerAction, async (args, { host, signal, device }) => {
|
||||
const data = args as {
|
||||
messageId: number
|
||||
answers?: { question: string; answer: string }[]
|
||||
note?: unknown
|
||||
text?: unknown
|
||||
}
|
||||
const text = typeof data?.text === 'string' ? data.text.trim() : undefined
|
||||
const note = typeof data?.note === 'string' ? data.note.trim() : undefined
|
||||
const answers = data?.answers ?? []
|
||||
if (
|
||||
!data ||
|
||||
!Number.isSafeInteger(data.messageId) ||
|
||||
(text === undefined
|
||||
? !validAnswers(answers) || (!answers.length && !note)
|
||||
: !text || data.answers !== undefined || note)
|
||||
)
|
||||
throw new Error('Invalid answers')
|
||||
const reply = await host.harness().commit(async (tx) => {
|
||||
const log = await tx.doc(Messages, ROOT_CONVERSATION_ID)
|
||||
const message = log.messages.find((m) => m.id === data.messageId)
|
||||
if (!message || !open(message)) throw new Error(NOT_OPEN)
|
||||
if (text !== undefined) return text
|
||||
checkAnswers(answers, message.questions ?? [])
|
||||
return answerText(answers, message.questions ?? [], note)
|
||||
}, context)
|
||||
return {
|
||||
id: await host
|
||||
.need(MessagesApi)
|
||||
.answer(data.messageId, reply, withAbortSignal(signal, context), device),
|
||||
}
|
||||
}),
|
||||
/** Close an open message to the user unanswered; its sender gets a one-way note. */
|
||||
dismiss: implementAction(DismissAction, async (args, { host, signal, device }) => {
|
||||
const data = args as { messageId: number }
|
||||
if (!data || !Number.isSafeInteger(data.messageId)) throw new Error('Invalid message')
|
||||
return {
|
||||
id: await host
|
||||
.need(MessagesApi)
|
||||
.dismiss(data.messageId, withAbortSignal(signal, context), device),
|
||||
}
|
||||
}),
|
||||
}
|
||||
|
||||
export default function questionsExtension(host: Host) {
|
||||
// Core, always on; resolved where it is called, declared here so the catalogue lists it.
|
||||
host.need(MessagesApi)
|
||||
return defineExtension({ name: 'questions' })
|
||||
}
|
||||
@@ -0,0 +1,322 @@
|
||||
/** The seven Ultimate goal cases use overlaid documents and the loaded server modules. */
|
||||
import type { ConversationId, GenerationHooks, Tx } from '@earendil-works/pi-durable'
|
||||
import { BACKGROUND_CONTEXT } from '@earendil-works/chord/context'
|
||||
import { expect, it } from './goal-questions-test-support'
|
||||
const { reminderTask } = await import(`${globalThis.goalBase}/goal/reminder.ts`)
|
||||
const {default:goalExtension,act,actions,end,Goals,openGoal,start,TRIES} = await import(`${globalThis.goalBase}/goal/server.ts`)
|
||||
import type { Host } from './extensions.ts'
|
||||
|
||||
const ULTIMATE = 5 as ConversationId
|
||||
const screenshot = { type: 'image', data: 'aGk=', mimeType: 'image/png' }
|
||||
|
||||
/** Like the harness's document overlay, a proxy at every level: structuredClone refuses it. */
|
||||
const overlay = <T extends object>(value: T): T =>
|
||||
new Proxy(value, {
|
||||
get: (target, key) => {
|
||||
const found = Reflect.get(target, key) as unknown
|
||||
return found && typeof found === 'object' ? overlay(found) : found
|
||||
},
|
||||
})
|
||||
|
||||
/** A transaction over overlaid documents and a few entries of @ultimate's conversation; @ultimate is conversation 5. */
|
||||
function fakeTx(
|
||||
newer: { id: number; conversationId: number; kind?: string; model: object[] }[] = [],
|
||||
) {
|
||||
const docs = new Map<string, object>()
|
||||
const entries = [
|
||||
...newer,
|
||||
{ id: 30, conversationId: ULTIMATE, model: [{ role: 'user', content: 'no images' }] },
|
||||
{
|
||||
id: 20,
|
||||
conversationId: ULTIMATE,
|
||||
model: [{ role: 'toolResult', content: [{ type: 'text', text: 'shot' }, screenshot] }],
|
||||
},
|
||||
{ id: 10, conversationId: 9, model: [{ role: 'user', content: [screenshot] }] },
|
||||
]
|
||||
const tasks: { name: string; input: { kind: string; content: string; to: number } }[] = []
|
||||
return {
|
||||
tasks,
|
||||
createTask: async ({ definition }: { definition: { name: string } }, input: never) => {
|
||||
tasks.push({ name: definition.name, input })
|
||||
},
|
||||
doc: async (
|
||||
{ definition: def }: { definition: { kind: string; initial: () => object } },
|
||||
id: number,
|
||||
) => {
|
||||
const key = `${def.kind}:${id}`
|
||||
if (!docs.has(key))
|
||||
docs.set(
|
||||
key,
|
||||
def.kind === 'ultimate.standing-agents' ? { ultimate: ULTIMATE } : def.initial(),
|
||||
)
|
||||
return overlay(docs.get(key)!)
|
||||
},
|
||||
entry: async (id: number) => entries.find((entry) => entry.id === id),
|
||||
scanEntries: async ({ conversationId }: { conversationId: number }) => ({
|
||||
items: entries.filter((entry) => entry.conversationId === conversationId),
|
||||
}),
|
||||
} as unknown as Tx & { tasks: typeof tasks }
|
||||
}
|
||||
|
||||
type Reminder = {
|
||||
id: string
|
||||
name: string
|
||||
enabled: boolean
|
||||
subscribers?: (number | string)[]
|
||||
key?: string
|
||||
}
|
||||
|
||||
/** A host over `tx` with an automations ledger, and `sync()`, which runs the queued reminder tasks. */
|
||||
function fakeHost(tx: ReturnType<typeof fakeTx>) {
|
||||
const ledger = new Map<string, Reminder>()
|
||||
let next = 1
|
||||
const service = {
|
||||
// As the real service: a repeated key returns the automation it made.
|
||||
create: async (args: Omit<Reminder, 'id' | 'enabled'>) => {
|
||||
const made = [...ledger.values()].find((item) => args.key && item.key === args.key)
|
||||
if (made) return made
|
||||
const id = `A${next++}`
|
||||
ledger.set(id, { id, ...args, enabled: true })
|
||||
return ledger.get(id)
|
||||
},
|
||||
delete: async (id: string) => void ledger.delete(id),
|
||||
pause: async (id: string) => void (ledger.get(id)!.enabled = false),
|
||||
resume: async (id: string) => void (ledger.get(id)!.enabled = true),
|
||||
operate: async (args: any) => args.action === 'list' ? structuredClone([...ledger.values()]) : (service as any)[args.action](args.action === 'create' ? Object.fromEntries(Object.entries(args).filter(([key]) => key !== 'action')) : args.id),
|
||||
}
|
||||
const host = {
|
||||
harness: () => ({ commit: (fn: (tx: Tx) => unknown) => fn(tx) }),
|
||||
need: () => service,
|
||||
want: () => service,
|
||||
doc: () => undefined,
|
||||
invoke: async () => structuredClone([...ledger.values()]),
|
||||
} as unknown as Host
|
||||
const sync = async () => {
|
||||
const runtime = { commit: (fn: () => unknown) => fn() }
|
||||
for (const task of tx.tasks.splice(0).filter((item) => item.name === 'goal.reminder'))
|
||||
await reminderTask(host, Goals).definition.phases.sync(
|
||||
{ input: task.input } as never,
|
||||
runtime as never,
|
||||
BACKGROUND_CONTEXT,
|
||||
)
|
||||
}
|
||||
return { host, ledger, service, sync }
|
||||
}
|
||||
|
||||
it('keeps one goal open at a time and ends it once', async () => {
|
||||
const tx = fakeTx()
|
||||
await expect(start(tx, ' ', 0)).rejects.toThrow('nonempty')
|
||||
expect(await start(tx, ' Ship the sidebar ', 0)).toMatchObject({
|
||||
id: 1,
|
||||
text: 'Ship the sidebar',
|
||||
})
|
||||
await expect(start(tx, 'Another', 0)).rejects.toThrow('Goal 1 is still open')
|
||||
expect(await end(tx, { at: 1, by: 'user', outcome: 'ended' })).toMatchObject({
|
||||
ended: { by: 'user' },
|
||||
})
|
||||
await expect(end(tx, { at: 2, by: 'user', outcome: 'ended' })).rejects.toThrow('No goal')
|
||||
expect((await start(tx, 'Next', 3)).id).toBe(2)
|
||||
})
|
||||
|
||||
it('blocks a criterion after repeated failed tries and finishes with the outcome', async () => {
|
||||
const tx = fakeTx()
|
||||
await expect(act(tx, ULTIMATE, { action: 'met', n: 1 }, 0)).rejects.toThrow('No hands-off goal')
|
||||
await start(tx, 'Goal', 0)
|
||||
await act(tx, ULTIMATE, { action: 'criteria', criteria: ['Loads in 1s', 'Tests pass'] }, 0)
|
||||
await expect(act(tx, ULTIMATE, { action: 'met', n: 3 }, 0)).rejects.toThrow('No criterion 3')
|
||||
for (let i = 1; i < TRIES; i++) await act(tx, ULTIMATE, { action: 'failed', n: 1 }, 0)
|
||||
expect((await openGoal(tx))!.criteria[0]).toMatchObject({ status: 'open', tries: TRIES - 1 })
|
||||
const last = await act(tx, ULTIMATE, { action: 'failed', n: 1 }, 0)
|
||||
expect(last.answer).toContain('blocked')
|
||||
expect((await openGoal(tx))!.criteria[0].status).toBe('blocked')
|
||||
// Rewording keeps what was learned about an unchanged criterion.
|
||||
await act(tx, ULTIMATE, { action: 'criteria', criteria: ['Loads in 1s', 'Lint passes'] }, 0)
|
||||
expect((await openGoal(tx))!.criteria.map((item) => item.status)).toEqual(['blocked', 'open'])
|
||||
await expect(act(tx, ULTIMATE, { action: 'finish', summary: 'x' }, 0)).rejects.toThrow('outcome')
|
||||
const done = await act(
|
||||
tx,
|
||||
ULTIMATE,
|
||||
{ action: 'finish', outcome: 'blocked', summary: 'Stuck' },
|
||||
4,
|
||||
)
|
||||
expect(done.ended).toMatchObject({
|
||||
ended: { by: 'ultimate', outcome: 'blocked', summary: 'Stuck' },
|
||||
})
|
||||
expect(await openGoal(tx)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('records decisions with the screenshots of its own conversation only', async () => {
|
||||
const tx = fakeTx()
|
||||
await start(tx, 'Goal', 0)
|
||||
await expect(
|
||||
act(tx, ULTIMATE, { action: 'decide', question: 'Go?', picked: 'Yes' }, 0),
|
||||
).rejects.toThrow('reason')
|
||||
await act(
|
||||
tx,
|
||||
ULTIMATE,
|
||||
{
|
||||
action: 'decide',
|
||||
question: 'Approve?',
|
||||
picked: 'Yes',
|
||||
reason: 'Meets 1',
|
||||
conversation: 7,
|
||||
images: 'latest',
|
||||
},
|
||||
1,
|
||||
)
|
||||
await expect(
|
||||
act(tx, ULTIMATE, { action: 'decide', question: 'q', picked: 'p', reason: 'r', images: 10 }, 2),
|
||||
).rejects.toThrow('has no images')
|
||||
expect((await openGoal(tx))!.decisions).toEqual([
|
||||
{ at: 1, question: 'Approve?', picked: 'Yes', reason: 'Meets 1', conversation: 7, images: 20 },
|
||||
])
|
||||
})
|
||||
|
||||
it('never takes an earlier turn\'s screenshot for images: "latest"', async () => {
|
||||
const tx = fakeTx([{ id: 40, kind: 'pi.user', conversationId: ULTIMATE, model: [] }])
|
||||
await start(tx, 'Goal', 0)
|
||||
const decide = { action: 'decide', question: 'q', picked: 'p', reason: 'r' } as const
|
||||
await expect(act(tx, ULTIMATE, { ...decide, images: 'latest' }, 1)).rejects.toThrow(
|
||||
'no images in this turn',
|
||||
)
|
||||
await act(tx, ULTIMATE, { ...decide, images: 20 }, 2)
|
||||
expect((await openGoal(tx))!.decisions).toMatchObject([{ images: 20 }])
|
||||
})
|
||||
|
||||
it("notes the open goal on @ultimate's runs only, and lets only @ultimate keep it", async () => {
|
||||
const tx = fakeTx()
|
||||
const extension = goalExtension({
|
||||
doc: () => undefined,
|
||||
need: () => undefined,
|
||||
want: () => undefined,
|
||||
} as unknown as Host)
|
||||
const hooks = extension.hooks![0].handlers as GenerationHooks
|
||||
const before = (conversationId: number, kind = 'pi.user') =>
|
||||
hooks.beforeInput({ kind }, { tx, conversationId } as never, BACKGROUND_CONTEXT)
|
||||
expect(await before(ULTIMATE)).toBeUndefined()
|
||||
await start(tx, 'Make it fast', 0)
|
||||
expect(await before(9)).toBeUndefined()
|
||||
const note = (await before(ULTIMATE))!.entries![0]
|
||||
expect(note.kind).toBe('goal.note')
|
||||
expect(note.model![0].content).toContain(
|
||||
'Hands-off goal 1, granted by the user, who is away: Make it fast',
|
||||
)
|
||||
|
||||
const tool = extension.tools!.find((candidate) => candidate.name === 'goal')!
|
||||
const run = (conversationId: number, args: object) =>
|
||||
tool.execute(
|
||||
args as never,
|
||||
{ conversationId, commit: (fn: (tx: Tx) => unknown) => fn(tx) } as never,
|
||||
undefined as never,
|
||||
)
|
||||
await expect(run(9, { action: 'met', n: 1 })).rejects.toThrow('Only @ultimate')
|
||||
expect(await run(ULTIMATE, { action: 'criteria', criteria: ['Fast'] })).toEqual({
|
||||
content: [{ type: 'text', text: 'Goal 1 has 1 criteria.' }],
|
||||
})
|
||||
expect((await tx.doc(Goals, 1 as ConversationId)).goals[0].criteria).toHaveLength(1)
|
||||
|
||||
// Finishing ends the goal and creates the report's delivery in the same commit; the note stops.
|
||||
await run(ULTIMATE, { action: 'finish', outcome: 'met', summary: 'Done' })
|
||||
expect(tx.tasks).toEqual([
|
||||
{ name: 'goal.deliver', input: expect.objectContaining({ to: ULTIMATE, kind: 'goal.report' }) },
|
||||
{ name: 'goal.reminder', input: { goal: 1 } },
|
||||
])
|
||||
expect(await before(ULTIMATE)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('keeps a reminder while the goal is open: paused while waiting on the user, gone once ended', async () => {
|
||||
const tx = fakeTx()
|
||||
const { host, ledger, service, sync } = fakeHost(tx)
|
||||
const extension = goalExtension(host)
|
||||
const tool = extension.tools!.find((candidate) => candidate.name === 'goal')!
|
||||
const run = (args: object) =>
|
||||
tool.execute(
|
||||
args as never,
|
||||
{ conversationId: ULTIMATE, commit: (fn: (tx: Tx) => unknown) => fn(tx) } as never,
|
||||
undefined as never,
|
||||
)
|
||||
await actions.start({ text: 'Make it fast' }, { host } as never)
|
||||
// A crash after creating, before linking, left one; and the goal was paused meanwhile. The task run adopts it by
|
||||
// its key instead of making a second one, and pauses it.
|
||||
await service.create({
|
||||
name: 'Goal reminder · goal 1',
|
||||
subscribers: ['@ultimate'],
|
||||
key: 'goal.reminder:1',
|
||||
})
|
||||
;(await openGoal(tx))!.waiting = { reason: 'Which account?', at: 0 }
|
||||
await sync()
|
||||
expect([...ledger.values()]).toEqual([
|
||||
{
|
||||
id: 'A1',
|
||||
name: 'Goal reminder · goal 1',
|
||||
subscribers: ['@ultimate'],
|
||||
key: 'goal.reminder:1',
|
||||
enabled: false,
|
||||
},
|
||||
])
|
||||
expect((await openGoal(tx))!.reminder).toBe('A1')
|
||||
await run({ action: 'resume' })
|
||||
await sync()
|
||||
expect(ledger.get('A1')!.enabled).toBe(true)
|
||||
// The user renamed it: it stays linked by its id.
|
||||
ledger.get('A1')!.name = 'Night check'
|
||||
|
||||
// Its fire tells @ultimate what to check; other inputs do not.
|
||||
const hooks = extension.hooks![0].handlers as GenerationHooks
|
||||
const note = async (input: object) =>
|
||||
(await hooks.beforeInput(input as never, { tx, conversationId: ULTIMATE }, BACKGROUND_CONTEXT))!
|
||||
.entries![0].model![0].content as string
|
||||
expect(await note({ kind: 'automations.fired', data: { id: 'A1' } })).toContain(
|
||||
'This is the goal reminder',
|
||||
)
|
||||
expect(await note({ kind: 'pi.user' })).not.toContain('This is the goal reminder')
|
||||
expect(await note({ kind: 'pi.user', model: [{ role: 'user', content: '[automation A1 fired] Night check' }] })).toContain('This is the goal reminder')
|
||||
expect(await note({ kind: 'pi.user', model: [{ role: 'user', content: '[automation A2 fired] Other check' }] })).not.toContain('This is the goal reminder')
|
||||
|
||||
await expect(run({ action: 'pause' })).rejects.toThrow('reason')
|
||||
await expect(run({ action: 'resume' })).rejects.toThrow('not paused')
|
||||
await run({ action: 'pause', reason: 'Which account to deploy to?' })
|
||||
await sync()
|
||||
expect(ledger.get('A1')!.enabled).toBe(false)
|
||||
expect((await openGoal(tx))!.waiting).toMatchObject({ reason: 'Which account to deploy to?' })
|
||||
expect(await note({ kind: 'pi.user' })).toContain('Waiting on the user')
|
||||
await run({ action: 'resume' })
|
||||
await sync()
|
||||
expect(ledger.get('A1')!.enabled).toBe(true)
|
||||
expect((await openGoal(tx))!.waiting).toBeUndefined()
|
||||
|
||||
await run({ action: 'finish', outcome: 'met', summary: 'Done' })
|
||||
await sync()
|
||||
expect(ledger.size).toBe(0)
|
||||
// A start and end whose tasks run only after both committed make nothing.
|
||||
await actions.start({ text: 'Again' }, { host } as never)
|
||||
await actions.end(null, { host } as never)
|
||||
await sync()
|
||||
expect(ledger.size).toBe(0)
|
||||
})
|
||||
|
||||
it('grants nothing through a start card still queued after the user ended its goal', async () => {
|
||||
const tx = fakeTx()
|
||||
const { host } = fakeHost(tx)
|
||||
const extension = goalExtension(host)
|
||||
await actions.start({ text: 'Make it fast' }, { host } as never)
|
||||
await actions.end(null, { host } as never)
|
||||
const [started, report] = tx.tasks
|
||||
.filter((task) => task.name === 'goal.deliver')
|
||||
.map((task) => task.input)
|
||||
expect(started).toMatchObject({
|
||||
kind: 'goal.started',
|
||||
content: 'The user started hands-off goal 1.',
|
||||
})
|
||||
expect(report).toMatchObject({ kind: 'goal.report' })
|
||||
expect(report.content).toContain('ask them as usual')
|
||||
// The queued start runs now: no goal is open, so no note says to decide for the user.
|
||||
const hooks = extension.hooks![0].handlers as GenerationHooks
|
||||
expect(
|
||||
await hooks.beforeInput(
|
||||
{ kind: 'goal.started' },
|
||||
{ tx, conversationId: ULTIMATE },
|
||||
BACKGROUND_CONTEXT,
|
||||
),
|
||||
).toBeUndefined()
|
||||
})
|
||||
@@ -0,0 +1,37 @@
|
||||
/** Real-loader fixture for the question-card cases. */
|
||||
import { BACKGROUND_CONTEXT as context } from '@earendil-works/chord/context'
|
||||
import { createModels } from '@earendil-works/pi-ai/models'
|
||||
import { fauxProvider, fauxAssistantMessage, fauxToolCall } from '@earendil-works/pi-ai/providers/faux'
|
||||
import { Harness, createRegistry } from '@earendil-works/pi-durable'
|
||||
import { openStorage } from './sqlite'
|
||||
import { loadExtensions } from './extensions'
|
||||
import { assert, until } from './goal-questions-test-support'
|
||||
let next=0
|
||||
export async function fixture(clock: any = {}) {
|
||||
const models=createModels(), faux=fauxProvider({tokensPerSecond:100000}); models.setProvider(faux.provider)
|
||||
const registry=createRegistry(), storage=await openStorage(`${globalThis.storagePath}.questions-${next++}`)
|
||||
const harness=await Harness.open(storage,{models,registry,...clock,settings:{get extensions(){return registry.snapshot().installed()}}},context)
|
||||
const root=await harness.root(context,{agent:{model:{provider:'faux',modelId:'faux-1'}}})
|
||||
const loader=await loadExtensions(harness,registry,storage,models,context)
|
||||
assert(!loader.state().error,loader.state().error)
|
||||
const base=`file:///run/sausage/extensions/${loader.state().good}/extensions`
|
||||
const question=await import(/* @vite-ignore */ `${base}/questions/server.ts`)
|
||||
const {Messages}=await import(/* @vite-ignore */ `${base}/messages/api.ts`)
|
||||
const {Standing}=await import(/* @vite-ignore */ `${base}/subagents/api.ts`)
|
||||
const observed:string[]=[];let heard:any
|
||||
const route=async(transcript:any)=>{
|
||||
faux.appendResponses([route]);const last=transcript.messages.at(-1),text=typeof last.content==='string'?last.content:last.content.filter((p:any)=>p.type==='text').map((p:any)=>p.text).join('\n')
|
||||
for(const m of transcript.messages) if(m.role==='user') observed.push(typeof m.content==='string'?m.content:m.content.filter((p:any)=>p.type==='text').map((p:any)=>p.text).join('\n'))
|
||||
await heard?.(text)
|
||||
return last.role==='user'&&text.startsWith('SEND ')?fauxAssistantMessage([fauxToolCall('message',JSON.parse(text.slice(5)))],{stopReason:'toolUse'}):fauxAssistantMessage('Sent.')
|
||||
}
|
||||
faux.setResponses([route]);harness.resume()
|
||||
const log=async()=>await harness.snapshot(Messages,root.id,context)
|
||||
const settled=async()=>{await until(async()=>(await harness.commit((tx:any)=>tx.scanTasks({},1000),context)).items.every((t:any)=>t.state.status==='terminal'));await harness.waitForIdle(context)}
|
||||
const entries=async(id:number)=>(await harness.commit((tx:any)=>tx.scanEntries({conversationId:id},1000),context)).items.toSorted((a:any,b:any)=>a.id-b.id)
|
||||
const type=async(id:number,content:string)=>{const c=await harness.conversation(id,context);await(await c.submit({type:'input',content},context)).wait(context)}
|
||||
const ultimate=async()=>{const c=await harness.createConversation({ownership:{kind:'ownerless'}},context);await c.configure({model:{provider:'faux',modelId:'faux-1'}},context);await harness.commit(async(tx:any)=>{(await tx.doc(Standing,root.id)).ultimate=c.id},context);return c.id}
|
||||
const ask=async(id:number,args:any)=>{await type(id,`SEND ${JSON.stringify({to:'user',...args})}`);await settled();const result=(await entries(id)).findLast((e:any)=>e.kind==='pi.tool-result')?.model?.[0];return {error:result?.isError?result.content.map((p:any)=>p.text).join(''):undefined,message:(await log()).messages.findLast((m:any)=>m.from===id&&m.to==='user')}}
|
||||
const action=(conversationId?:number)=>({host:globalThis.testHost,conversationId,signal:new AbortController().signal})
|
||||
return {base,faux,host:globalThis.testHost,opened:{harness,root,close:async()=>{await loader.close();await harness.close(context);await storage.close()}},ultimate,type,ask,answer:(args:any)=>question.actions.answer(args,action()),dismiss:(messageId:number)=>question.actions.dismiss({messageId},action()),log,entries,observed,settled,hear:(fn:any)=>heard=fn}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,29 @@
|
||||
/** Minimal assertions for the imported Ultimate cases, without Node or Vitest. */
|
||||
export const cases: {name:string;run:()=>any}[]=[]
|
||||
export const it=(name:string,run:()=>any)=>cases.push({name,run})
|
||||
export const describe=(_name:string,run:()=>any)=>run()
|
||||
export const cleanups: (()=>any)[]=[]
|
||||
export const afterEach=(run:()=>any)=>cleanups.push(run)
|
||||
expect.objectContaining=(value:any)=>({__object:true,value})
|
||||
expect.stringContaining=(value:string)=>({__string:true,value})
|
||||
export const assert=(value:any,message:string)=>{if(!value)throw new Error(message)}
|
||||
const same=(a:any,b:any):boolean=>b?.__object || b?.__string ? subset(a,b) : b?.__contains ? b.values.every((v:any)=>a.includes(v)) : Array.isArray(b) ? Array.isArray(a)&&a.length===b.length&&b.every((v:any,i:number)=>same(a[i],v)) : b&&typeof b==='object' ? a&&Object.keys(a).length===Object.keys(b).length&&Object.entries(b).every(([k,v])=>same(a[k],v)) : a===b
|
||||
const subset=(a:any,b:any):boolean=>b?.__object ? subset(a,b.value) : b?.__string ? a.includes(b.value) : b && typeof b==='object' ? Object.entries(b).every(([k,v])=>subset(a?.[k],v)) : a===b
|
||||
export function expect(value:any,negate=false):any {
|
||||
const check=(ok:boolean)=>assert(negate?!ok:ok,`Expected ${String(value)}${negate?' not':''} to match`)
|
||||
const result:any={
|
||||
toBe:(b:any)=>check(value===b),toEqual:(b:any)=>check(same(value,b)),toHaveLength:(n:number)=>check(value.length===n),
|
||||
toMatchObject:(b:any)=>check(subset(value,b)),toMatch:(b:RegExp)=>check(b.test(value)),toContain:(b:any)=>check(value.includes(b)),
|
||||
toBeUndefined:()=>check(value===undefined),toBeDefined:()=>check(value!==undefined),toBeGreaterThanOrEqual:(b:number)=>check(value>=b),
|
||||
toThrow:(b:any)=>{let error;try{value()}catch(e){error=e}check(error!==undefined && (b instanceof RegExp?b.test(String(error)):String(error).includes(b)))},
|
||||
}
|
||||
Object.defineProperty(result,'not',{get:()=>expect(value,!negate)})
|
||||
Object.defineProperty(result,'rejects',{get:()=>({toThrow:async(b:string)=>{let error;try{await value}catch(e){error=e}check(error!==undefined&&String(error).includes(b))}})})
|
||||
return result
|
||||
}
|
||||
expect.arrayContaining=(values:any[])=>({__contains:true,values})
|
||||
export async function until(condition:()=>any) {
|
||||
const deadline=Date.now()+5000
|
||||
while(!await condition()) {assert(Date.now()<deadline,'Condition deadline');await new Promise(resolve=>setTimeout(resolve,0))}
|
||||
}
|
||||
export const setImmediate=(callback:any)=>setTimeout(callback,0)
|
||||
@@ -0,0 +1,10 @@
|
||||
/** Ultimate goal and question-card cases execute in the world loader. */
|
||||
import {fixture} from './goal-questions-fixture'
|
||||
import {cases,cleanups} from './goal-questions-test-support'
|
||||
const initial=await fixture()
|
||||
globalThis.goalBase=initial.base
|
||||
await initial.opened.close()
|
||||
await import('./goal-cases')
|
||||
await import('./questions-cases')
|
||||
await import('./goal-reminder-cases')
|
||||
for(const test of cases){try{await test.run();Deno.core.ops.op_output(`PASS ${test.name}\n`)}finally{for(const cleanup of cleanups) await cleanup()}}
|
||||
@@ -0,0 +1,32 @@
|
||||
/** A goal reminder fires once through the real automation scheduler and injected clock. */
|
||||
import { BACKGROUND_CONTEXT as context } from '@earendil-works/chord/context'
|
||||
import {fixture} from './goal-questions-fixture'
|
||||
import {it,assert,until} from './goal-questions-test-support'
|
||||
it('fires one goal reminder through automations',async()=>{
|
||||
let now=Date.now();const timers=new Set<any>()
|
||||
const f=await fixture({now:()=>now,setTimer:(callback:any,delay:number)=>{
|
||||
if(delay<1000){const id=setTimeout(callback,delay);return()=>clearTimeout(id)}
|
||||
const timer={at:now+delay,callback};timers.add(timer);return()=>timers.delete(timer)
|
||||
}})
|
||||
try {
|
||||
const id=await f.ultimate();const {harness}=f.opened
|
||||
const goal=await import(/* @vite-ignore */ `${f.base}/goal/server.ts`)
|
||||
const {Automations}=await import(/* @vite-ignore */ `${f.base}/automations/server.ts`)
|
||||
await goal.actions.start({text:'Ship the goal'},{host:f.host})
|
||||
const get=()=>harness.snapshot(goal.Goals,f.opened.root.id,context)
|
||||
await until(async()=>Boolean((await get()).goals[0].reminder))
|
||||
const reminder=(await get()).goals[0].reminder
|
||||
const ledger=()=>harness.snapshot(Automations,context)
|
||||
await until(async()=>{const a=(await ledger()).automations[reminder];return (await harness.getTask(a.scheduler,context)).state.status==='sleeping'})
|
||||
const deadline=(await ledger()).automations[reminder].nextAt
|
||||
assert(deadline-(await ledger()).automations[reminder].createdAt===1800000,'The reminder waits 30 minutes')
|
||||
await until(()=>[...timers].some(timer=>timer.at===deadline))
|
||||
now=deadline
|
||||
for(const timer of [...timers])if(timer.at<=now){timers.delete(timer);timer.callback()}
|
||||
await until(async()=>(await ledger()).automations[reminder].fires===1)
|
||||
await until(async()=>(await f.entries(id)).some((e:any)=>e.kind==='goal.note'&&e.model[0].content.includes('This is the goal reminder')))
|
||||
assert((await ledger()).automations[reminder].fires===1,'One reminder fire')
|
||||
await goal.actions.end(null,{host:f.host})
|
||||
await until(async()=>!(await ledger()).automations[reminder])
|
||||
} finally {await f.opened.close()}
|
||||
})
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": { "bundle": "nu apply-patch.nu && esbuild main.ts --bundle --format=esm --platform=browser --outfile=bundle.js && esbuild test.ts --bundle --format=esm --platform=browser --outfile=test-bundle.js && esbuild durable-test.ts --bundle --format=esm --platform=browser --outfile=durable-test-bundle.js && esbuild foundation-test.ts --bundle --format=esm --platform=browser --outfile=foundation-test-bundle.js && esbuild extensions-test.ts --bundle --format=esm --platform=browser --outfile=extensions-test-bundle.js && esbuild events-test.ts --bundle --format=esm --platform=browser --outfile=events-test-bundle.js && esbuild subagents-test.ts --bundle --format=esm --platform=browser --outfile=subagents-test-bundle.js && esbuild core-ext-test.ts --bundle --format=esm --platform=browser --outfile=core-ext-test-bundle.js && esbuild plan-todos-test.ts --bundle --format=esm --platform=browser --outfile=plan-todos-test-bundle.js && esbuild p2-test.ts --bundle --format=esm --platform=browser --outfile=p2-test-bundle.js && esbuild chain-test.ts --bundle --format=esm --platform=browser --outfile=chain-test-bundle.js && esbuild undo-harness-test.ts --bundle --format=esm --platform=browser --outfile=undo-harness-test-bundle.js && esbuild messages-test.ts --bundle --format=esm --platform=browser --outfile=messages-test-bundle.js && esbuild p3-test.ts --bundle --format=esm --platform=browser --outfile=p3-test-bundle.js && esbuild automations-test.ts --bundle --format=esm --platform=browser --outfile=automations-test-bundle.js" },
|
||||
"scripts": { "bundle": "nu apply-patch.nu && esbuild main.ts --bundle --format=esm --platform=browser --outfile=bundle.js && esbuild test.ts --bundle --format=esm --platform=browser --outfile=test-bundle.js && esbuild durable-test.ts --bundle --format=esm --platform=browser --outfile=durable-test-bundle.js && esbuild foundation-test.ts --bundle --format=esm --platform=browser --outfile=foundation-test-bundle.js && esbuild extensions-test.ts --bundle --format=esm --platform=browser --outfile=extensions-test-bundle.js && esbuild events-test.ts --bundle --format=esm --platform=browser --outfile=events-test-bundle.js && esbuild subagents-test.ts --bundle --format=esm --platform=browser --outfile=subagents-test-bundle.js && esbuild core-ext-test.ts --bundle --format=esm --platform=browser --outfile=core-ext-test-bundle.js && esbuild plan-todos-test.ts --bundle --format=esm --platform=browser --outfile=plan-todos-test-bundle.js && esbuild p2-test.ts --bundle --format=esm --platform=browser --outfile=p2-test-bundle.js && esbuild chain-test.ts --bundle --format=esm --platform=browser --outfile=chain-test-bundle.js && esbuild undo-harness-test.ts --bundle --format=esm --platform=browser --outfile=undo-harness-test-bundle.js && esbuild messages-test.ts --bundle --format=esm --platform=browser --outfile=messages-test-bundle.js && esbuild p3-test.ts --bundle --format=esm --platform=browser --outfile=p3-test-bundle.js && esbuild automations-test.ts --bundle --format=esm --platform=browser --outfile=automations-test-bundle.js && esbuild goal-questions-test.ts --bundle --format=esm --platform=browser --outfile=goal-questions-test-bundle.js" },
|
||||
"dependencies": {
|
||||
"@earendil-works/pi-durable": "1.5.0",
|
||||
"@earendil-works/pi-ai": "1.5.0",
|
||||
|
||||
@@ -0,0 +1,338 @@
|
||||
|
||||
/** Ultimate server question cases, with a real world fixture. */
|
||||
import { BACKGROUND_CONTEXT as context } from '@earendil-works/chord/context'
|
||||
import {expect,it,describe,afterEach} from './goal-questions-test-support'
|
||||
import {fixture as openFixture} from './goal-questions-fixture'
|
||||
const {answerText,userActions} = await import(`${globalThis.goalBase}/questions/server.ts`)
|
||||
const {checkQuestions,gist} = await import(`${globalThis.goalBase}/messages/questions.ts`)
|
||||
const hosts:any[]=[]
|
||||
afterEach(async()=>{for(const h of hosts.splice(0)) await h.close()})
|
||||
async function fixture(){const f=await openFixture();hosts.push(f.opened);return f}
|
||||
const decisions = [
|
||||
{
|
||||
text: 'Scope',
|
||||
kind: 'choice',
|
||||
ref: '1',
|
||||
options: [
|
||||
{ label: 'all of it', key: 'a', recommended: true },
|
||||
{ label: 'the format only', key: 'b' },
|
||||
],
|
||||
},
|
||||
{
|
||||
text: 'Suites',
|
||||
kind: 'multi',
|
||||
ref: '2',
|
||||
options: [
|
||||
{ label: 'unit', key: 'a', recommended: true },
|
||||
{ label: 'e2e', key: 'b' },
|
||||
{ label: 'lint', key: 'c', recommended: true },
|
||||
],
|
||||
},
|
||||
{ text: 'Land it?', kind: 'confirm', ref: '3' },
|
||||
{ text: 'How finished is it?', kind: 'scale', ref: '4', scale: { min: 1, max: 5 } },
|
||||
{ text: 'Name', kind: 'text', ref: '5' },
|
||||
]
|
||||
|
||||
describe('questions on a message', () => {
|
||||
it('are stored as the tool validated them, only on reply-expected messages to the user', async () => {
|
||||
const f = await fixture()
|
||||
const id = await f.ultimate()
|
||||
const sent = await f.ask(id, { text: 'Decide these.', questions: decisions })
|
||||
expect(sent.error).toBeUndefined()
|
||||
expect(sent.message).toMatchObject({ state: 'delivered', questions: decisions })
|
||||
const oneWay = await f.ask(id, { text: 'FYI', reply: false, questions: decisions })
|
||||
expect(oneWay.error).toContain('reply-expected message to the user')
|
||||
expect(oneWay.message?.text).toBe('Decide these.')
|
||||
// To a conversation, never: questions are the user's card.
|
||||
const other = await f.opened.harness.createConversation(
|
||||
{ ownership: { kind: 'ownerless' } },
|
||||
context,
|
||||
)
|
||||
const toConversation = await f.ask(id, { to: `#${other.id}`, text: 'Hi', questions: decisions })
|
||||
expect(toConversation.error).toContain('reply-expected message to the user')
|
||||
expect((await f.log()).messages.filter((m) => m.questions)).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('refuses questions the card could not answer, naming the question', async () => {
|
||||
const f = await fixture()
|
||||
const id = await f.ultimate()
|
||||
const refused = async (questions: unknown, reason: string) => {
|
||||
const { error } = await f.ask(id, { text: 'Decide.', questions })
|
||||
expect(error).toContain(reason)
|
||||
}
|
||||
await refused([], 'at least one question')
|
||||
await refused(
|
||||
[{ text: 'One?', kind: 'choice', options: [{ label: 'only' }] }],
|
||||
'question 1 needs at least two options',
|
||||
)
|
||||
await refused(
|
||||
[{ text: 'One?', kind: 'choice', ref: '3', options: [{ label: 'a' }, { label: ' a ' }] }],
|
||||
'question 3 repeats an option label',
|
||||
)
|
||||
await refused(
|
||||
[
|
||||
{
|
||||
text: 'One?',
|
||||
kind: 'choice',
|
||||
options: [
|
||||
{ label: 'x', key: 'a' },
|
||||
{ label: 'y', key: 'a' },
|
||||
],
|
||||
},
|
||||
],
|
||||
'repeats an option key',
|
||||
)
|
||||
await refused(
|
||||
[{ text: 'One?', kind: 'choice', options: [{ label: 'x' }, { label: 'Something else' }] }],
|
||||
'offers "Something else…" itself',
|
||||
)
|
||||
await refused([{ text: ' ', kind: 'confirm' }], 'question 1 has no text')
|
||||
await refused(
|
||||
[{ text: 'One?', kind: 'choice', options: [{ label: 'x' }, { label: ' ' }] }],
|
||||
'has an option without a label',
|
||||
)
|
||||
await refused(
|
||||
[{ text: 'Which?', kind: 'multi', options: [{ label: 'unit, fast' }, { label: 'e2e' }] }],
|
||||
'its labels hold no ", "',
|
||||
)
|
||||
await refused([{ text: 'Rate', kind: 'scale' }], 'needs scale')
|
||||
await refused([{ text: 'Rate', kind: 'scale', scale: { min: 1, max: 20 } }], 'at most 10 steps')
|
||||
await refused(
|
||||
[
|
||||
{ text: 'Same', kind: 'confirm', ref: '1' },
|
||||
{ text: 'Same', kind: 'confirm', ref: '2' },
|
||||
],
|
||||
"repeats another question's text",
|
||||
)
|
||||
await refused(
|
||||
[
|
||||
{ text: 'A', kind: 'confirm', ref: '1' },
|
||||
{ text: 'B', kind: 'confirm', ref: '1' },
|
||||
],
|
||||
"repeats another question's ref",
|
||||
)
|
||||
// A schema miss is the tool's own error.
|
||||
await refused([{ text: 'One?', kind: 'ranking' }], 'kind')
|
||||
expect((await f.log()).messages.filter((m) => m.to === 'user')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('answers in the message numbering for every kind, with a note after the answers', async () => {
|
||||
const f = await fixture()
|
||||
const id = await f.ultimate()
|
||||
const { message } = await f.ask(id, { text: 'Decide these.', questions: decisions })
|
||||
expect(userActions).toEqual(['answer', 'dismiss'])
|
||||
await expect(
|
||||
f.answer({ messageId: message!.id, answers: [{ question: 'Unknown', answer: 'Yes' }] }),
|
||||
).rejects.toThrow('Unknown question')
|
||||
await expect(
|
||||
f.answer({
|
||||
messageId: message!.id,
|
||||
answers: [
|
||||
{ question: 'Scope', answer: 'all of it' },
|
||||
{ question: 'Scope', answer: 'the format only' },
|
||||
],
|
||||
}),
|
||||
).rejects.toThrow('Unknown question')
|
||||
await expect(f.answer({ messageId: message!.id, answers: [] })).rejects.toThrow(
|
||||
'Invalid answers',
|
||||
)
|
||||
const results = await Promise.allSettled([
|
||||
f.answer({
|
||||
messageId: message!.id,
|
||||
answers: [
|
||||
{ question: 'Scope', answer: 'the format only' },
|
||||
{ question: 'Suites', answer: 'unit, lint' },
|
||||
{ question: 'Land it?', answer: 'Yes' },
|
||||
{ question: 'How finished is it?', answer: '4' },
|
||||
{ question: 'Name', answer: 'Questions' },
|
||||
],
|
||||
note: 'Also rename the tab.',
|
||||
}),
|
||||
f.answer({ messageId: message!.id, answers: [{ question: 'Scope', answer: 'all of it' }] }),
|
||||
])
|
||||
expect(results.filter((result) => result.status === 'fulfilled')).toHaveLength(1)
|
||||
await f.settled()
|
||||
const reply = (await f.log()).messages.find((m) => m.re === message!.id)!
|
||||
expect(reply.text).toBe(
|
||||
'1b. Scope: the format only\n\n2a,c. Suites: unit, lint\n\n3: Yes\n\n4: 4\n\n5: Questions\n\nNote: Also rename the tab.',
|
||||
)
|
||||
expect((await f.log()).messages.find((m) => m.id === message!.id)?.state).toBe('answered')
|
||||
if (!f.observed.some((text) => text.includes('2a,c. Suites: unit, lint')))
|
||||
throw new Error(`Answer absent from model input: ${JSON.stringify({observed:f.observed,entries:await f.entries(id)})}`)
|
||||
await expect(
|
||||
f.answer({ messageId: message!.id, answers: [{ question: 'Scope', answer: 'all of it' }] }),
|
||||
).rejects.toThrow('These questions are no longer open')
|
||||
})
|
||||
|
||||
it('sends a note on its own, and free text for a message without questions', async () => {
|
||||
const f = await fixture()
|
||||
const id = await f.ultimate()
|
||||
const { message } = await f.ask(id, { text: 'Decide these.', questions: decisions })
|
||||
await expect(f.answer({ messageId: message!.id, answers: [], note: ' ' })).rejects.toThrow(
|
||||
'Invalid answers',
|
||||
)
|
||||
await f.answer({ messageId: message!.id, answers: [], note: 'Later, after the demo.' })
|
||||
await f.settled()
|
||||
expect((await f.log()).messages.find((m) => m.re === message!.id)?.text).toBe(
|
||||
'Later, after the demo.',
|
||||
)
|
||||
const plain = (await f.ask(id, { text: 'What should the tab be called?' })).message!
|
||||
expect(plain.questions).toBeUndefined()
|
||||
await expect(f.answer({ messageId: plain.id, text: ' ' })).rejects.toThrow('Invalid answers')
|
||||
await expect(f.answer({ messageId: plain.id, text: 'x', note: 'y' })).rejects.toThrow(
|
||||
'Invalid answers',
|
||||
)
|
||||
const { id: reply } = await f.answer({ messageId: plain.id, text: ' Questions ' })
|
||||
expect((await f.log()).messages.find((m) => m.id === reply)).toMatchObject({
|
||||
from: 'user',
|
||||
re: plain.id,
|
||||
text: 'Questions',
|
||||
})
|
||||
await expect(f.answer({ messageId: plain.id, text: 'Again' })).rejects.toThrow(
|
||||
'These questions are no longer open',
|
||||
)
|
||||
})
|
||||
|
||||
it("keeps @ultimate's open message open until its card answers it, naming it by its questions", async () => {
|
||||
const f = await fixture()
|
||||
const id = await f.ultimate()
|
||||
const { message } = await f.ask(id, { text: 'Decide these.', questions: decisions })
|
||||
await f.type(
|
||||
f.opened.root.id,
|
||||
`SEND ${JSON.stringify({ to: `#${id}`, text: 'Unrelated update' })}`,
|
||||
)
|
||||
await f.settled()
|
||||
expect((await f.log()).messages.find((m) => m.id === message!.id)?.state).toBe('delivered')
|
||||
await f.type(id, 'Something else entirely')
|
||||
const typed = await f.entries(id)
|
||||
const note = typed.findLast((entry) => entry.kind === 'board.open')!
|
||||
expect(note.data).toEqual({ ids: [message!.id] })
|
||||
expect(note.model?.[0]?.content).toMatch(
|
||||
new RegExp(
|
||||
`^Open questions to the user.*\\n- message ${message!.id}: questions 1\\. Scope; 2\\. Suites; 3\\. Land it\\?; 4\\. How finished is it\\?; 5\\. Name \\(asked just now\\)\\n`,
|
||||
's',
|
||||
),
|
||||
)
|
||||
expect(typed[typed.indexOf(note) + 1]).toMatchObject({ kind: 'pi.user' })
|
||||
await f.answer({ messageId: message!.id, answers: [{ question: 'Land it?', answer: 'No' }] })
|
||||
expect((await f.log()).messages.find((m) => m.id === message!.id)?.state).toBe('answered')
|
||||
await f.settled()
|
||||
const count = (await f.entries(id)).filter((entry) => entry.kind === 'board.open').length
|
||||
await f.type(id, 'Thanks')
|
||||
expect((await f.entries(id)).filter((entry) => entry.kind === 'board.open')).toHaveLength(count)
|
||||
})
|
||||
|
||||
it('dismisses one open question with a one-way note that starts no run, leaving the others open', async () => {
|
||||
const f = await fixture()
|
||||
const id = await f.ultimate()
|
||||
const first = (await f.ask(id, { text: 'Keep it running?', questions: [decisions[2]] }))
|
||||
.message!
|
||||
const second = (await f.ask(id, { text: 'Which layout?' })).message!
|
||||
const before = (await f.entries(id)).filter((entry) => entry.kind === 'pi.assistant').length
|
||||
const { id: note } = await f.dismiss(first.id)
|
||||
await f.settled()
|
||||
const log = (await f.log()).messages
|
||||
expect(log.find((m) => m.id === first.id)?.state).toBe('dismissed')
|
||||
expect(log.find((m) => m.id === second.id)?.state).toBe('delivered')
|
||||
expect(log.find((m) => m.id === note)).toMatchObject({
|
||||
from: 'user',
|
||||
to: id,
|
||||
re: first.id,
|
||||
reply: false,
|
||||
state: 'delivered',
|
||||
text: expect.stringContaining('(message ' + first.id + '): questions 3. Land it?'),
|
||||
})
|
||||
const after = await f.entries(id)
|
||||
expect(after.findLast((entry) => entry.kind === 'board.message')?.data).toMatchObject({
|
||||
id: note,
|
||||
delivery: 'note',
|
||||
})
|
||||
expect(after.filter((entry) => entry.kind === 'pi.assistant')).toHaveLength(before)
|
||||
await expect(f.dismiss(first.id)).rejects.toThrow('These questions are no longer open')
|
||||
await expect(f.answer({ messageId: first.id, text: 'Yes' })).rejects.toThrow(
|
||||
'These questions are no longer open',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
// The messages.actions.settle case awaits the later actions port. T49 exposes no settlement API.
|
||||
describe('answer text', () => {
|
||||
it('names options by letter, several by letters, typed and rated answers by number, and unnumbered ones by text', () => {
|
||||
expect(
|
||||
answerText(
|
||||
[
|
||||
{ question: 'Scope', answer: 'all of it' },
|
||||
{ question: 'Suites', answer: 'e2e' },
|
||||
{ question: 'Name', answer: 'only the keyboard one' },
|
||||
],
|
||||
decisions,
|
||||
),
|
||||
).toBe('1a. Scope: all of it\n\n2b. Suites: e2e\n\n5: only the keyboard one')
|
||||
// A typed "Something else…" names no option: the number alone.
|
||||
expect(answerText([{ question: 'Scope', answer: 'neither' }], decisions)).toBe('1: neither')
|
||||
expect(
|
||||
answerText(
|
||||
[{ question: 'Land it?', answer: 'Yes' }],
|
||||
[{ text: 'Land it?', kind: 'confirm' }],
|
||||
),
|
||||
).toBe('Land it?: Yes')
|
||||
expect(answerText([], decisions, 'Just a thought.')).toBe('Just a thought.')
|
||||
})
|
||||
|
||||
it('drops fields that do not apply to a kind and trims what it keeps', () => {
|
||||
expect(
|
||||
checkQuestions([
|
||||
{
|
||||
text: ' Land it? ',
|
||||
kind: 'confirm',
|
||||
options: [{ label: 'x' }, { label: 'y' }],
|
||||
placeholder: 'no',
|
||||
},
|
||||
{
|
||||
text: 'Rate',
|
||||
kind: 'scale',
|
||||
scale: { min: 1, max: 3, low: ' low ', high: '' },
|
||||
placeholder: 'no',
|
||||
},
|
||||
{ text: 'Name', kind: 'text', placeholder: ' e.g. Questions ' },
|
||||
{
|
||||
text: 'Pick',
|
||||
kind: 'choice',
|
||||
options: [
|
||||
{ label: ' a ', detail: ' why ', recommended: false, preview: ' ' },
|
||||
{ label: 'b', key: 'b', recommended: true },
|
||||
],
|
||||
},
|
||||
]),
|
||||
).toEqual([
|
||||
{ text: 'Land it?', kind: 'confirm' },
|
||||
{ text: 'Rate', kind: 'scale', scale: { min: 1, max: 3, low: 'low' } },
|
||||
{ text: 'Name', kind: 'text', placeholder: 'e.g. Questions' },
|
||||
{
|
||||
text: 'Pick',
|
||||
kind: 'choice',
|
||||
options: [
|
||||
{ label: 'a', detail: 'why' },
|
||||
{ label: 'b', key: 'b', recommended: true },
|
||||
],
|
||||
},
|
||||
])
|
||||
// Only a whole "Other" / "Something else" is the card's own option; a label that starts so is a real one.
|
||||
expect(
|
||||
checkQuestions([
|
||||
{
|
||||
text: 'Scope',
|
||||
kind: 'choice',
|
||||
options: [{ label: 'This repo' }, { label: 'Other repos too' }],
|
||||
},
|
||||
])[0].options!.map((o) => o.label),
|
||||
).toEqual(['This repo', 'Other repos too'])
|
||||
})
|
||||
|
||||
it('names a message by its questions in the open-questions note, else by its asking line', () => {
|
||||
expect(gist({ text: 'Decide.', questions: decisions })).toBe(
|
||||
'questions 1. Scope; 2. Suites; 3. Land it?; 4. How finished is it?; 5. Name',
|
||||
)
|
||||
expect(gist({ text: 'Done.\n\nShould I submit it?' })).toBe('Should I submit it?')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,126 @@
|
||||
//! Goal and question-card server cases execute through the world loader.
|
||||
use sausage::{harness::run_module, world::World};
|
||||
use std::{path::Path, process::Command};
|
||||
const BUNDLE: &str = include_str!("../js/goal-questions-test-bundle.js");
|
||||
static PREPARE: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
||||
fn prepare(dir: &Path) -> anyhow::Result<()> {
|
||||
let _guard = PREPARE.lock().unwrap();
|
||||
let disk = dir.join("world");
|
||||
let repo = disk.join("src/sausage");
|
||||
if repo.join(".git").exists() {
|
||||
return Ok(());
|
||||
}
|
||||
let source = Path::new(env!("CARGO_MANIFEST_DIR")).join("..");
|
||||
let target = Path::new(env!("CARGO_TARGET_TMPDIR")).parent().unwrap();
|
||||
let userland = source.join("userland");
|
||||
if !userland.join("cwd-init/init.o").exists() {
|
||||
assert!(
|
||||
Command::new("nu")
|
||||
.args(["--no-config-file", "cwd-init/build.nu"])
|
||||
.current_dir(&userland)
|
||||
.status()?
|
||||
.success()
|
||||
);
|
||||
}
|
||||
assert!(
|
||||
Command::new("cargo")
|
||||
.args(["build", "--release", "-p", "git", "--manifest-path"])
|
||||
.arg(userland.join("Cargo.toml"))
|
||||
.args(["--target", "wasm32-wasip2", "--target-dir"])
|
||||
.arg(target)
|
||||
.args(["-j", "2", "--locked"])
|
||||
.current_dir(&userland)
|
||||
.status()?
|
||||
.success()
|
||||
);
|
||||
std::fs::create_dir_all(disk.join("bin"))?;
|
||||
std::fs::copy(
|
||||
target.join("wasm32-wasip2/release/git.wasm"),
|
||||
disk.join("bin/git.wasm"),
|
||||
)?;
|
||||
for directory in [
|
||||
"extensions/subagents",
|
||||
"extensions/messages",
|
||||
"extensions/lineage",
|
||||
"extensions/models",
|
||||
"extensions/goal",
|
||||
"extensions/questions",
|
||||
"extensions/automations",
|
||||
"extensions/core",
|
||||
"agents",
|
||||
] {
|
||||
std::fs::create_dir_all(repo.join(directory))?;
|
||||
}
|
||||
for directory in [
|
||||
"extensions/subagents",
|
||||
"extensions/messages",
|
||||
"extensions/lineage",
|
||||
"extensions/models",
|
||||
"extensions/goal",
|
||||
"extensions/questions",
|
||||
"extensions/automations",
|
||||
] {
|
||||
for entry in std::fs::read_dir(source.join(directory))? {
|
||||
let entry = entry?;
|
||||
if entry.path().extension().is_some_and(|ext| ext == "ts") {
|
||||
std::fs::copy(entry.path(), repo.join(directory).join(entry.file_name()))?;
|
||||
}
|
||||
}
|
||||
}
|
||||
std::fs::create_dir_all(repo.join("extensions/probe"))?;
|
||||
std::fs::write(
|
||||
repo.join("extensions/probe/server.ts"),
|
||||
"export default host => { globalThis.testHost = host; globalThis.subagentService = host.need({name:'subagents'}); globalThis.messageService = host.need({name:'messages'}); globalThis.lineageService = host.need({name:'lineage'}); host.implement({name:'conversations'}, {summaryModel: async () => globalThis.summaryRef, concludedIn: async () => new Set(), generatedTitle: async () => {}, setStanding: async () => {}, setAutomaticSummaries: async () => {}}); return {name:'probe'}; }",
|
||||
)?;
|
||||
std::fs::copy(
|
||||
source.join("extensions/core/agents.ts"),
|
||||
repo.join("extensions/core/agents.ts"),
|
||||
)?;
|
||||
std::fs::write(
|
||||
repo.join("agents/models.json"),
|
||||
r#"{"default":{"model":"faux/faux-1"}}"#,
|
||||
)?;
|
||||
std::fs::write(
|
||||
repo.join("agents/scout.md"),
|
||||
"---\nname: scout\ndescription: Search\nresident: true\ndenyTools: [subagent]\nisolate: none\n---\nScout instructions\n",
|
||||
)?;
|
||||
std::fs::write(
|
||||
repo.join("agents/ultimate.md"),
|
||||
"---\nname: ultimate\ndescription: Primary\nresident: true\nisolate: none\n---\nPrimary agent\n",
|
||||
)?;
|
||||
std::fs::write(
|
||||
repo.join("agents/coordinator.md"),
|
||||
"---\nname: coordinator\ndescription: Coordinate\nisolate: none\n---\nCoordinator agent\n",
|
||||
)?;
|
||||
for args in [
|
||||
vec!["init", "-b", "main"],
|
||||
vec!["add", "."],
|
||||
vec!["commit", "--no-gpg-sign", "-m", "messages fixture"],
|
||||
] {
|
||||
assert!(
|
||||
Command::new("git")
|
||||
.args(args)
|
||||
.current_dir(&repo)
|
||||
.env("GIT_AUTHOR_NAME", "Test")
|
||||
.env("GIT_AUTHOR_EMAIL", "test@example.com")
|
||||
.env("GIT_COMMITTER_NAME", "Test")
|
||||
.env("GIT_COMMITTER_EMAIL", "test@example.com")
|
||||
.status()?
|
||||
.success()
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn goal_questions() -> anyhow::Result<()> {
|
||||
let dir = tempfile::tempdir()?;
|
||||
let dir = dir.path();
|
||||
let world = World::open(dir)?;
|
||||
prepare(dir)?;
|
||||
let exec = sausage::process::exec(World {
|
||||
root: world.root.clone(),
|
||||
host_dir: world.host_dir.clone(),
|
||||
processes: world.processes.clone(),
|
||||
})?;
|
||||
run_module(world, exec, BUNDLE).await
|
||||
}
|
||||
Reference in New Issue
Block a user