@spunto/build 0.22.0 → 0.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,315 @@
1
+ /**
2
+ * The conformance suite of the Spunto agent contract: what says an agent app is one, rather than
3
+ * the platform finding out in a customer's task.
4
+ *
5
+ * It drives a runner the way the platform does — the three stdin verbs, a resume, closing stdin —
6
+ * and checks what comes back: every line valid against the contract, and each scenario's events in
7
+ * the order the platform relies on (a turn always ends, an interrupt leaves the session alive, a
8
+ * resume keeps the session id…).
9
+ *
10
+ * **What a scenario asks of the harness.** A suite cannot make a real model call a tool on cue, so
11
+ * each scenario's prompt is a fixed phrase (`CONFORMANCE_PROMPTS`) and an app runs the suite against
12
+ * a **test double of its harness** that does what the phrase says — a fake SDK replaying recorded
13
+ * messages, for an app built on one (`test/fixtures/fake-*-sdk.mjs` here), or nothing at all for
14
+ * an agent with no SDK (`examples/fake-agent.mjs`). What is tested is the runner: its reading of
15
+ * the harness, and its side of the contract.
16
+ *
17
+ * Pure: the process is the caller's (`StartRunner`) — with Node, `child_process.spawn` and a
18
+ * line splitter, see `test/agent-contract.exec.test.ts`. The suite only needs a timer.
19
+ */
20
+
21
+ import type { RunnerInput } from "./vocabulary"
22
+ import { contractLineProblem } from "./vocabulary"
23
+
24
+ /** A runner the suite drives. `lines` grows as the runner prints; `exited` resolves with its code. */
25
+ export type RunnerProcess = {
26
+ send(input: RunnerInput): void
27
+ /** Close stdin: the session ends once its turn is over. */
28
+ close(): void
29
+ /** Stop it outright — only when a scenario has already failed. */
30
+ kill(): void
31
+ lines: string[]
32
+ exited: Promise<number | null>
33
+ }
34
+
35
+ /** Start the runner: `run`, plus `--resume <id>` when asked. */
36
+ export type StartRunner = (opts: { resume?: string }) => RunnerProcess
37
+
38
+ export const CONFORMANCE_SCENARIOS = ["simple", "tool", "interrupt", "resume", "limit", "title", "files"] as const
39
+ export type ConformanceScenario = (typeof CONFORMANCE_SCENARIOS)[number]
40
+
41
+ /**
42
+ * What the test double of the harness must do on each phrase.
43
+ *
44
+ * - `reply`: answer in prose, end the turn;
45
+ * - `tool`: call one tool, get its result, answer, end the turn;
46
+ * - `long`: keep the turn going (a long tool call, say) until interrupted;
47
+ * - `limit`: end the turn on the account's usage limit, with the time it lifts;
48
+ * - `screenshot`: call a tool whose result is a PNG, inline (base64), and end the turn.
49
+ */
50
+ export const CONFORMANCE_PROMPTS = {
51
+ reply: "spunto-conformance: reply",
52
+ tool: "spunto-conformance: tool",
53
+ long: "spunto-conformance: long",
54
+ limit: "spunto-conformance: limit",
55
+ screenshot: "spunto-conformance: screenshot",
56
+ } as const
57
+
58
+ export type ConformanceResult = {
59
+ scenario: ConformanceScenario
60
+ /** `skipped`: the app said its harness cannot (`skip`) — Codex names no conversation, say. */
61
+ status: "passed" | "failed" | "skipped"
62
+ problems: string[]
63
+ }
64
+
65
+ export type ConformanceOptions = {
66
+ /** Scenarios the app's harness cannot do, and says so (a `title` the harness cannot produce). */
67
+ skip?: ConformanceScenario[]
68
+ /** How long one step may wait for the runner, in ms. */
69
+ timeoutMs?: number
70
+ }
71
+
72
+ type Line = Record<string, unknown>
73
+
74
+ class ScenarioFailure extends Error {}
75
+
76
+ /** Run every scenario, one runner process (or two, for `resume`) each. */
77
+ export async function runConformance(start: StartRunner, opts: ConformanceOptions = {}): Promise<ConformanceResult[]> {
78
+ const results: ConformanceResult[] = []
79
+ for (const scenario of CONFORMANCE_SCENARIOS) {
80
+ if (opts.skip?.includes(scenario)) {
81
+ results.push({ scenario, status: "skipped", problems: [] })
82
+ continue
83
+ }
84
+ results.push(await runScenario(scenario, start, opts.timeoutMs ?? 10_000))
85
+ }
86
+ return results
87
+ }
88
+
89
+ async function runScenario(scenario: ConformanceScenario, start: StartRunner, timeoutMs: number): Promise<ConformanceResult> {
90
+ const problems: string[] = []
91
+ const procs: RunnerProcess[] = []
92
+ const launch = (o: { resume?: string } = {}) => {
93
+ const p = start(o)
94
+ procs.push(p)
95
+ return new Session(p, timeoutMs, problems)
96
+ }
97
+ try {
98
+ await SCENARIOS[scenario](launch, problems)
99
+ } catch (err) {
100
+ problems.push(err instanceof ScenarioFailure ? err.message : `the scenario threw: ${err}`)
101
+ }
102
+ for (const p of procs) {
103
+ for (const [i, line] of p.lines.entries()) {
104
+ const problem = lineProblem(line)
105
+ if (problem) problems.push(`line ${i + 1} is not valid: ${problem} — ${line.slice(0, 200)}`)
106
+ }
107
+ p.kill()
108
+ }
109
+ return { scenario, status: problems.length ? "failed" : "passed", problems: [...new Set(problems)] }
110
+ }
111
+
112
+ function lineProblem(line: string): string | null {
113
+ let parsed: unknown
114
+ try {
115
+ parsed = JSON.parse(line)
116
+ } catch {
117
+ return "not JSON"
118
+ }
119
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return "not a JSON object"
120
+ return contractLineProblem(parsed as Line)
121
+ }
122
+
123
+ /** One runner process, read as a sequence of contract lines. */
124
+ class Session {
125
+ /** How many lines the scenario has already looked past. */
126
+ private cursor = 0
127
+
128
+ constructor(
129
+ readonly proc: RunnerProcess,
130
+ private readonly timeoutMs: number,
131
+ private readonly problems: string[],
132
+ ) {}
133
+
134
+ send(input: RunnerInput): void {
135
+ this.proc.send(input)
136
+ }
137
+
138
+ /** Every parsed line so far. */
139
+ all(): Line[] {
140
+ return this.proc.lines.flatMap((l) => {
141
+ try {
142
+ const v = JSON.parse(l)
143
+ return v && typeof v === "object" && !Array.isArray(v) ? [v as Line] : []
144
+ } catch {
145
+ return []
146
+ }
147
+ })
148
+ }
149
+
150
+ /** Wait for the next line (past the cursor) that matches, and move the cursor past it. */
151
+ async next(what: string, match: (l: Line) => boolean): Promise<Line> {
152
+ const deadline = Date.now() + this.timeoutMs
153
+ for (;;) {
154
+ const lines = this.all()
155
+ for (let i = this.cursor; i < lines.length; i++) {
156
+ if (match(lines[i]!)) {
157
+ this.cursor = i + 1
158
+ return lines[i]!
159
+ }
160
+ }
161
+ if (Date.now() > deadline) throw new ScenarioFailure(`timed out waiting for ${what}`)
162
+ await sleep(25)
163
+ }
164
+ }
165
+
166
+ /** The turn's end, and every line of the turn up to it. */
167
+ async turn(): Promise<{ lines: Line[]; ended: Line }> {
168
+ const from = this.cursor
169
+ const ended = await this.next("the turn's `session.ended`", (l) => l.type === "session.ended")
170
+ return { lines: this.all().slice(from, this.cursor), ended }
171
+ }
172
+
173
+ /** Close stdin and expect a clean exit. */
174
+ async close(): Promise<void> {
175
+ this.proc.close()
176
+ const code = await Promise.race([this.proc.exited, sleep(this.timeoutMs).then(() => "timeout" as const)])
177
+ if (code === "timeout") this.problems.push("did not exit after stdin was closed")
178
+ else if (code !== 0) this.problems.push(`exited ${code} after stdin was closed (expected 0)`)
179
+ }
180
+ }
181
+
182
+ function sleep(ms: number): Promise<void> {
183
+ return new Promise((resolve) => setTimeout(resolve, ms))
184
+ }
185
+
186
+ function expect(problems: string[], ok: boolean, message: string): void {
187
+ if (!ok) problems.push(message)
188
+ }
189
+
190
+ type Launch = (opts?: { resume?: string }) => Session
191
+
192
+ const SCENARIOS: Record<ConformanceScenario, (launch: Launch, problems: string[]) => Promise<void>> = {
193
+ // A turn: the session opens, the agent answers, the turn ends, and closing stdin exits 0.
194
+ async simple(launch, problems) {
195
+ const s = launch()
196
+ s.send({ type: "user", text: CONFORMANCE_PROMPTS.reply })
197
+ const { lines, ended } = await s.turn()
198
+ const started = lines.find((l) => l.type === "session.started")
199
+ expect(problems, !!started, "no `session.started` before the turn ended")
200
+ expect(problems, lines.findIndex((l) => l.type === "session.started") <= lines.findIndex((l) => l.type === "message"), "`session.started` came after the first message")
201
+ expect(problems, typeof started?.sessionId === "string" && !!started.sessionId, "`session.started` has no `sessionId` — the platform resumes with it")
202
+ expect(problems, lines.some((l) => l.type === "message" && l.role === "assistant"), "no assistant `message` in the turn")
203
+ expect(problems, ended.status === "succeeded", `the turn ended ${String(ended.status)} (expected succeeded)`)
204
+ await s.close()
205
+ },
206
+
207
+ // A tool call and its result, paired by `callId`.
208
+ async tool(launch, problems) {
209
+ const s = launch()
210
+ s.send({ type: "user", text: CONFORMANCE_PROMPTS.tool })
211
+ const { lines, ended } = await s.turn()
212
+ const call = lines.find((l) => l.type === "tool.call")
213
+ const result = lines.find((l) => l.type === "tool.result")
214
+ expect(problems, !!call, "no `tool.call`")
215
+ expect(problems, !!result, "no `tool.result`")
216
+ if (call && result) {
217
+ expect(problems, typeof call.callId === "string" && call.callId === result.callId, "the `tool.result` does not carry its call's `callId`")
218
+ expect(problems, lines.indexOf(call) < lines.indexOf(result), "the result came before its call")
219
+ }
220
+ expect(problems, ended.status === "succeeded", `the turn ended ${String(ended.status)} (expected succeeded)`)
221
+ await s.close()
222
+ },
223
+
224
+ // Interrupting ends the turn and leaves the session listening: the next message is a normal turn.
225
+ async interrupt(launch, problems) {
226
+ const s = launch()
227
+ s.send({ type: "user", text: CONFORMANCE_PROMPTS.long })
228
+ await s.next("the session to start", (l) => l.type === "session.started")
229
+ s.send({ type: "interrupt", id: "spunto-conformance-1" })
230
+ await s.turn()
231
+ s.send({ type: "user", text: CONFORMANCE_PROMPTS.reply })
232
+ const after = await s.turn()
233
+ expect(problems, after.lines.some((l) => l.type === "message" && l.role === "assistant"), "no answer to the message sent after the interrupt — did the session survive it?")
234
+ expect(problems, after.ended.status === "succeeded", "the turn after the interrupt did not succeed")
235
+ await s.close()
236
+ },
237
+
238
+ // `--resume <id>` reopens the same session, and says so.
239
+ async resume(launch, problems) {
240
+ const first = launch()
241
+ first.send({ type: "user", text: CONFORMANCE_PROMPTS.reply })
242
+ const opened = await first.next("the session to start", (l) => l.type === "session.started")
243
+ await first.turn()
244
+ await first.close()
245
+ const id = typeof opened.sessionId === "string" ? opened.sessionId : ""
246
+ if (!id) throw new ScenarioFailure("no session id to resume")
247
+ const second = launch({ resume: id })
248
+ second.send({ type: "user", text: CONFORMANCE_PROMPTS.reply })
249
+ const { lines, ended } = await second.turn()
250
+ const started = lines.find((l) => l.type === "session.started")
251
+ expect(problems, !!started, "a resumed session does not announce itself (`session.started`)")
252
+ expect(problems, started?.sessionId === id, `the resumed session says ${String(started?.sessionId)}, not ${id}`)
253
+ expect(problems, ended.status === "succeeded", "the resumed turn did not succeed")
254
+ await second.close()
255
+ },
256
+
257
+ // A turn the account ended: `session.ended` says so, with the time it lifts.
258
+ async limit(launch, problems) {
259
+ const s = launch()
260
+ s.send({ type: "user", text: CONFORMANCE_PROMPTS.limit })
261
+ const { ended } = await s.turn()
262
+ const limit = ended.limit as Line | undefined
263
+ expect(problems, ended.status === "failed", "a turn the account ended is not a success")
264
+ expect(problems, !!limit && limit.kind === "usage", "`session.ended` carries no `limit` of kind `usage`")
265
+ expect(problems, typeof limit?.resetsAt === "string" && !Number.isNaN(Date.parse(limit.resetsAt as string)), "the limit has no `resetsAt` the platform can wait for")
266
+ await s.close()
267
+ },
268
+
269
+ // The `title` verb is answered with a `session.title`.
270
+ async title(launch, problems) {
271
+ const s = launch()
272
+ s.send({ type: "user", text: CONFORMANCE_PROMPTS.reply })
273
+ await s.turn()
274
+ s.send({ type: "title", id: "spunto-title-conformance", description: "Fix the login page redirect" })
275
+ const named = await s.next("a `session.title`", (l) => l.type === "session.title")
276
+ expect(problems, typeof named.title === "string" && !!named.title.trim(), "an empty title")
277
+ await s.close()
278
+ },
279
+
280
+ // A file a tool hands back reaches the platform whole — in the line, as a base64 content block —
281
+ // and not as kilobytes of base64 in the event's text.
282
+ async files(launch, problems) {
283
+ const s = launch()
284
+ s.send({ type: "user", text: CONFORMANCE_PROMPTS.screenshot })
285
+ const { lines } = await s.turn()
286
+ const result = lines.find((l) => l.type === "tool.result" && hasFileBlock(l))
287
+ expect(problems, !!result, "no `tool.result` line carrying the file as a base64 `image` block (in its `source`)")
288
+ if (result) expect(problems, !String(result.output).includes(fileData(result) ?? "\u0000"), "the file's bytes are in the result's `output` text")
289
+ await s.close()
290
+ },
291
+ }
292
+
293
+ /** The first base64 file block's data in a line, if any. */
294
+ function fileData(value: unknown, depth = 0): string | null {
295
+ if (depth > 8 || !value || typeof value !== "object") return null
296
+ if (Array.isArray(value)) {
297
+ for (const v of value) {
298
+ const found = fileData(v, depth + 1)
299
+ if (found) return found
300
+ }
301
+ return null
302
+ }
303
+ const obj = value as Line
304
+ const src = obj.source as Line | undefined
305
+ if ((obj.type === "image" || obj.type === "document") && src?.type === "base64" && typeof src.data === "string") return src.data
306
+ for (const v of Object.values(obj)) {
307
+ const found = fileData(v, depth + 1)
308
+ if (found) return found
309
+ }
310
+ return null
311
+ }
312
+
313
+ function hasFileBlock(line: Line): boolean {
314
+ return fileData(line) !== null
315
+ }
@@ -0,0 +1,46 @@
1
+ #!/usr/bin/env node
2
+ // A whole agent app runner, with no SDK and no import of ours: what it takes to speak the Spunto
3
+ // agent contract (docs/agent-contract.md). It passes the conformance suite — that is its job; the
4
+ // "harness" is the switch below, which does what each conformance phrase asks.
5
+ //
6
+ // node fake-agent.mjs run [--resume <session id>]
7
+
8
+ import { createInterface } from "node:readline"
9
+
10
+ const out = (line) => process.stdout.write(`${JSON.stringify(line)}\n`)
11
+ const argv = process.argv.slice(2)
12
+ const resume = argv[argv.indexOf("--resume") + 1]
13
+ const sessionId = argv.includes("--resume") && resume ? resume : `fake-${process.pid}`
14
+ let started = false
15
+ let pending = null // the turn waiting to be interrupted
16
+
17
+ function turn(text) {
18
+ if (!started) out({ type: "session.started", harness: "fake", model: null, sessionId }), (started = true)
19
+ const done = (summary) => out({ type: "session.ended", status: "succeeded", summary })
20
+ if (text.endsWith("tool")) {
21
+ out({ type: "tool.call", callId: "c1", name: "Bash", input: '{"command":"ls"}' })
22
+ out({ type: "tool.result", callId: "c1", isError: false, output: "README.md" })
23
+ }
24
+ if (text.endsWith("screenshot")) {
25
+ const block = { type: "image", source: { type: "base64", media_type: "image/png", data: "iVBORw0KGgo=" } }
26
+ out({ type: "tool.call", callId: "c2", name: "Screenshot", input: "{}" })
27
+ out({ type: "tool.result", callId: "c2", isError: false, output: "[image]", source: { content: [block] } })
28
+ }
29
+ if (text.endsWith("limit")) {
30
+ const resetsAt = new Date(Date.now() + 3_600_000).toISOString()
31
+ const limit = { kind: "usage", status: 429, message: "Usage limit reached", resetsAt }
32
+ return out({ type: "session.ended", status: "failed", summary: limit.message, limit })
33
+ }
34
+ if (text.endsWith("long")) return (pending = () => out({ type: "session.ended", status: "failed", summary: "Interrupted." }))
35
+ out({ type: "message", role: "assistant", text: `You said: ${text}` })
36
+ done("ok")
37
+ }
38
+
39
+ createInterface({ input: process.stdin })
40
+ .on("line", (line) => {
41
+ const msg = JSON.parse(line)
42
+ if (msg.type === "user") turn(msg.text)
43
+ if (msg.type === "interrupt") pending?.(), (pending = null)
44
+ if (msg.type === "title") out({ type: "session.title", title: msg.description.slice(0, 60) })
45
+ })
46
+ .on("close", () => process.exit(0))
@@ -0,0 +1,20 @@
1
+ // The Spunto agent contract (RFC 0048 § 9, docs/agent-contract.md): what an agent app's runner reads
2
+ // on stdin and prints on stdout, the one strict reader the platform has for it, its JSON Schema, and
3
+ // the conformance suite that says whether an app speaks it.
4
+
5
+ export {
6
+ AGENT_CONTRACT_VERSION,
7
+ agentContractSchema,
8
+ clip,
9
+ contractLineProblem,
10
+ isTransient,
11
+ MAX_TEXT,
12
+ readContractLine,
13
+ readContractObject,
14
+ TASK_EVENT_TYPES,
15
+ TRANSIENT_TYPES,
16
+ } from "./vocabulary"
17
+ export type { NormalizedEvent, RunnerInput, SourcedEvent, TaskEventType, TransientType } from "./vocabulary"
18
+
19
+ export { CONFORMANCE_PROMPTS, CONFORMANCE_SCENARIOS, runConformance } from "./conformance"
20
+ export type { ConformanceOptions, ConformanceResult, ConformanceScenario, RunnerProcess, StartRunner } from "./conformance"