Port goal and question-card server extensions

This commit is contained in:
2026-11-07 15:24:06 +00:00
parent 3f5b6082e6
commit c42d4c03fb
18 changed files with 35404 additions and 1 deletions
+22
View File
@@ -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.
+4
View File
@@ -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
+47
View File
@@ -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,
})
+57
View File
@@ -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')
+94
View File
@@ -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),
})
}
+367
View File
@@ -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 }] }
},
}),
],
})
}
+17
View File
@@ -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.
+26
View File
@@ -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',
)
+109
View File
@@ -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' })
}
+322
View File
@@ -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()
})
+37
View File
@@ -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
+29
View File
@@ -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)
+10
View File
@@ -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()}}
+32
View File
@@ -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 -1
View File
@@ -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",
+338
View File
@@ -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?')
})
})
+126
View File
@@ -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
}