cortad 0.2.3 → 0.3.0-rc.10
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.
- package/README.md +11 -2
- package/lib/cli.mjs +90 -38
- package/lib/home.mjs +9 -2
- package/lib/mcp.mjs +32 -12
- package/lib/pyhook/sitecustomize.py +396 -11
- package/lib/read-text.mjs +91 -0
- package/lib/register.mjs +64 -30
- package/lib/replay.mjs +43 -5
- package/lib/spec.mjs +20 -0
- package/lib/start.mjs +10 -0
- package/lib/stick.mjs +99 -0
- package/lib/text.mjs +268 -0
- package/lib/trace.cjs +303 -9
- package/lib/verbs.mjs +148 -107
- package/lib/words.mjs +37 -0
- package/local.mjs +47 -10
- package/package.json +6 -1
- package/skill/SKILL.md +75 -31
- package/skill/references/results.md +175 -34
package/README.md
CHANGED
|
@@ -6,7 +6,7 @@ Runs Cortad's test conversations against the AI app on your machine, and gives y
|
|
|
6
6
|
npx cortad <code>
|
|
7
7
|
```
|
|
8
8
|
|
|
9
|
-
Run it in your app's folder with the code from cortad.com. It starts your app, connects it to Cortad, and adds Cortad to Claude Code, Codex and
|
|
9
|
+
Run it in your app's folder with the code from cortad.com. It starts your app, connects it to Cortad, and adds Cortad to Claude Code, Codex, Cursor and Copilot on this machine. The first run starts on its own. Ctrl-C disconnects.
|
|
10
10
|
|
|
11
11
|
## Your coding agent
|
|
12
12
|
|
|
@@ -21,6 +21,15 @@ npx cortad verify <findingId>
|
|
|
21
21
|
|
|
22
22
|
A run needs your app up. If nothing on this machine is holding it, the command starts it with the key in `~/.cortad` and stops it ten minutes after the last run.
|
|
23
23
|
|
|
24
|
+
To make checking with Cortad part of the repository:
|
|
25
|
+
|
|
26
|
+
```
|
|
27
|
+
npx cortad stick one line in AGENTS.md, CLAUDE.md, .cursor/rules and .github/copilot-instructions.md, and an after-edit hook for Claude Code and Codex
|
|
28
|
+
npx cortad unstick takes them out
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Both print every file they changed. Neither touches git.
|
|
32
|
+
|
|
24
33
|
## Flags
|
|
25
34
|
|
|
26
35
|
```
|
|
@@ -32,7 +41,7 @@ A run needs your app up. If nothing on this machine is holding it, the command s
|
|
|
32
41
|
|
|
33
42
|
## Files it creates
|
|
34
43
|
|
|
35
|
-
- `~/.cortad/<project>/` the key, the last tree digest, which process holds your app up
|
|
44
|
+
- `~/.cortad/<project>/` the key, the last tree digest, which process holds your app up, the run it asked for last
|
|
36
45
|
- `~/.cortad/identity.key` the seed for the session's test accounts
|
|
37
46
|
- `$TMPDIR/cortad-<pid>/` removed on exit
|
|
38
47
|
|
package/lib/cli.mjs
CHANGED
|
@@ -1,49 +1,82 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { closeSync, openSync } from "node:fs";
|
|
2
2
|
import { spawn } from "node:child_process";
|
|
3
3
|
import { dirname, join } from "node:path";
|
|
4
4
|
import { fileURLToPath } from "node:url";
|
|
5
|
-
import { homeOf, projectOf, readRunner, readToken, writeRunner } from "./home.mjs";
|
|
5
|
+
import { homeOf, projectOf, readPending, readRunner, readToken, writePending, writeRunner } from "./home.mjs";
|
|
6
6
|
import { serveMcp } from "./mcp.mjs";
|
|
7
|
-
import {
|
|
7
|
+
import { SHOW } from "./read-text.mjs";
|
|
8
|
+
import { cliSpec, VERSION } from "./spec.mjs";
|
|
9
|
+
import { hookOutput, stick, unstick } from "./stick.mjs";
|
|
10
|
+
import { makeVerbs, READY_MS } from "./verbs.mjs";
|
|
8
11
|
|
|
9
|
-
// `npx cortad mcp
|
|
10
|
-
// connect.
|
|
11
|
-
// nothing on this machine is holding it, the runner (local.mjs with the stored key) is started
|
|
12
|
+
// `npx cortad mcp`, `npx cortad <verb>` and `npx cortad stick`: what a coding agent uses after the
|
|
13
|
+
// first connect. None of them uploads or edits anything by itself. When a run needs the app up and
|
|
14
|
+
// nothing on this machine is holding it, the runner (local.mjs with the stored key) is started, and
|
|
15
|
+
// a second detached process posts the run once the app answers, so `run` returns at once from
|
|
16
|
+
// either face and the shell face exits after printing.
|
|
12
17
|
const LOCAL = join(dirname(fileURLToPath(import.meta.url)), "..", "local.mjs");
|
|
13
|
-
const VERSION = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), "..", "package.json"), "utf8")).version;
|
|
14
18
|
const OURS = ["cortad.com", "brainsless.com", "brainsless-frontend.pages.dev"];
|
|
19
|
+
const WAITER = "run-when-up";
|
|
15
20
|
export const VERBS = ["status", "run", "run_status", "findings", "verify", "dispute", "field_connect", "field"];
|
|
16
|
-
export const COMMANDS = new Set(["mcp", ...VERBS, "run-status", "field-connect"]);
|
|
21
|
+
export const COMMANDS = new Set(["mcp", ...VERBS, "run-status", "field-connect", "stick", "unstick", WAITER]);
|
|
17
22
|
|
|
18
23
|
export async function main(argv, { root = process.cwd(), env = process.env, stdout = process.stdout, stderr = process.stderr } = {}) {
|
|
24
|
+
const [command, ...rest] = argv;
|
|
25
|
+
if (command === "stick" || command === "unstick") {
|
|
26
|
+
stdout.write(`${(command === "stick" ? stick : unstick)(root, { spec: cliSpec({ env }) }).join("\n")}\n`);
|
|
27
|
+
return 0;
|
|
28
|
+
}
|
|
19
29
|
const origin = new URL(env.CORTAD_ORIGIN || "https://cortad.com");
|
|
20
30
|
const trusted = (origin.protocol === "https:" && OURS.some((host) => origin.hostname === host || origin.hostname.endsWith(`.${host}`)))
|
|
21
31
|
|| origin.hostname === "localhost" || origin.hostname === "127.0.0.1";
|
|
22
32
|
if (!trusted) { stderr.write(`cortad refusing: ${origin.host} is not Cortad.\n`); return 1; }
|
|
23
|
-
const api = `${origin.origin}/api`;
|
|
24
33
|
const project = projectOf(root);
|
|
25
|
-
const [command, ...rest] = argv;
|
|
26
34
|
const mcp = command === "mcp";
|
|
27
35
|
const children = [];
|
|
36
|
+
const pending = { read: () => readPending(project), write: (p) => writePending(project, p) };
|
|
28
37
|
// The key is read at every call: the MCP process outlives a connect that writes it after the process started.
|
|
29
|
-
const verbs = makeVerbs({
|
|
38
|
+
const verbs = makeVerbs({
|
|
39
|
+
api: `${origin.origin}/api`,
|
|
40
|
+
root,
|
|
41
|
+
token: () => readToken(project),
|
|
42
|
+
pending,
|
|
43
|
+
startApp: (args, kind) => startApp({ root, project, env, keep: mcp, children, args, kind }),
|
|
44
|
+
});
|
|
30
45
|
|
|
46
|
+
if (command === WAITER) {
|
|
47
|
+
await runWhenUp({ verbs, project, env, pending, request: requestOf(rest[0]) });
|
|
48
|
+
return 0;
|
|
49
|
+
}
|
|
31
50
|
if (mcp) {
|
|
32
51
|
const code = await serveMcp({ verbs, version: VERSION, log: (line) => stderr.write(`${line}\n`) });
|
|
33
52
|
for (const child of children) try { child.kill("SIGTERM"); } catch { /* gone */ }
|
|
34
53
|
return code;
|
|
35
54
|
}
|
|
36
|
-
|
|
37
|
-
|
|
55
|
+
// The hook face: one line or nothing, never an error on every edit in a folder that is not connected.
|
|
56
|
+
if (command === "status" && rest.includes("--changed")) {
|
|
57
|
+
const { text } = await verbs.changed();
|
|
58
|
+
if (text) stdout.write(`${rest.includes("--hook") ? hookOutput(text) : text}\n`);
|
|
59
|
+
return 0;
|
|
60
|
+
}
|
|
61
|
+
const out = await verbs[command.replace(/-/g, "_")](argsOf(command, rest));
|
|
38
62
|
stdout.write(`${out.text}\n`);
|
|
39
63
|
return out.isError ? 1 : 0;
|
|
40
64
|
}
|
|
41
65
|
|
|
42
|
-
// Positional arguments for the shell face: `verify f1`, `run_status <jobId>`,
|
|
66
|
+
// Positional arguments for the shell face: `status rules 2`, `verify f1`, `run_status <jobId>`,
|
|
67
|
+
// `findings 2`, `dispute f1 "why"`. A bare number is a page; in findings anything else is a run id.
|
|
43
68
|
function argsOf(command, rest) {
|
|
69
|
+
const page = rest.find((w) => /^\d+$/.test(w));
|
|
44
70
|
switch (command.replace(/-/g, "_")) {
|
|
45
|
-
case "
|
|
46
|
-
|
|
71
|
+
case "status": {
|
|
72
|
+
const show = rest.find((w) => SHOW.includes(w));
|
|
73
|
+
return { ...(show ? { show } : {}), ...(page ? { page: Number(page) } : {}) };
|
|
74
|
+
}
|
|
75
|
+
case "run_status": return rest[0] ? { jobId: rest[0] } : {};
|
|
76
|
+
case "findings": {
|
|
77
|
+
const jobId = rest.find((w) => w !== "--page" && !/^\d+$/.test(w));
|
|
78
|
+
return { ...(jobId ? { jobId } : {}), ...(page ? { page: Number(page) } : {}) };
|
|
79
|
+
}
|
|
47
80
|
case "verify": return { findingId: rest[0], ...(rest[1] ? { jobId: rest[1] } : {}) };
|
|
48
81
|
case "dispute": return { findingId: rest[0], why: rest.slice(1).join(" ") };
|
|
49
82
|
case "field": return rest[0] ? { days: rest[0] } : {};
|
|
@@ -51,37 +84,56 @@ function argsOf(command, rest) {
|
|
|
51
84
|
}
|
|
52
85
|
}
|
|
53
86
|
|
|
54
|
-
//
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
const
|
|
60
|
-
|
|
87
|
+
// What the waiter was asked for, from its own command line: a run, or a verify of one finding.
|
|
88
|
+
function requestOf(raw) {
|
|
89
|
+
let value = {};
|
|
90
|
+
try { value = JSON.parse(raw ?? "{}"); } catch { /* a run */ }
|
|
91
|
+
const pick = (v) => (typeof v === "string" && v.length <= 200 ? v : undefined);
|
|
92
|
+
const findingId = pick(value.args?.findingId);
|
|
93
|
+
const jobId = pick(value.args?.jobId);
|
|
94
|
+
return findingId ? { kind: "verify", args: { findingId, ...(jobId ? { jobId } : {}) } } : { kind: "run", args: {} };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Returns at once. The runner holds the app up: from the MCP it lives as long as the MCP does;
|
|
98
|
+
// from a shell command it stays until the runs stop for a while (--until-idle). The waiter is
|
|
99
|
+
// detached either way and ends once the run is posted or the app did not come up.
|
|
100
|
+
export function startApp({ root, project, env, keep, children, args, kind, spawnImpl = spawn }) {
|
|
101
|
+
const log = openSync(join(homeOf(project), "runner.log"), "a");
|
|
61
102
|
if (!readRunner(project)) {
|
|
62
|
-
const
|
|
63
|
-
|
|
64
|
-
cwd: root, env, stdio: ["ignore", log, log], detached: !keep,
|
|
65
|
-
});
|
|
66
|
-
if (!keep) child.unref(); else children.push(child);
|
|
103
|
+
const child = spawnImpl(process.execPath, [LOCAL, "--token", ...(keep ? [] : ["--until-idle"])], { cwd: root, env, stdio: ["ignore", log, log], detached: !keep });
|
|
104
|
+
if (keep) children.push(child); else child.unref();
|
|
67
105
|
writeRunner(project, { pid: child.pid, startedAt: new Date().toISOString(), by: keep ? "mcp" : "cli" });
|
|
68
|
-
say("starting your app for the run");
|
|
69
106
|
}
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
107
|
+
spawnImpl(process.execPath, [LOCAL, WAITER, JSON.stringify({ kind, args })], { cwd: root, env, stdio: ["ignore", log, log], detached: true }).unref();
|
|
108
|
+
closeSync(log);
|
|
109
|
+
return { ok: true };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// The waiter: posts the run once the app answers and leaves the outcome in pending.json.
|
|
113
|
+
export async function runWhenUp({ verbs, project, env, pending, request, poll = appOf, now = Date.now, sleep = (ms) => new Promise((r) => setTimeout(r, ms)), readyMs = READY_MS }) {
|
|
114
|
+
const log = join(homeOf(project), "runner.log");
|
|
115
|
+
const fail = (text) => pending.write({ ...pending.read(), error: text });
|
|
116
|
+
const until = now() + readyMs;
|
|
117
|
+
let said = "";
|
|
118
|
+
while (now() < until) {
|
|
119
|
+
await sleep(3000);
|
|
120
|
+
const app = await poll(env, project);
|
|
121
|
+
if (app?.state === "ready-to-test") {
|
|
122
|
+
const out = await verbs.post(request.args, request.kind);
|
|
123
|
+
if (!out.data?.jobId) fail(out.text);
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
if (app?.said) said = ` ${app.said}`;
|
|
127
|
+
if (!readRunner(project)) return fail(`The process holding your app up ended before the app answered.${said} Its output is in ${log}.\nNothing ran.`);
|
|
76
128
|
}
|
|
77
|
-
|
|
129
|
+
fail(`Your app did not answer within ${Math.round(readyMs / 60_000)} minutes.${said} Its output is in ${log}.\nNothing ran.`);
|
|
78
130
|
}
|
|
79
131
|
|
|
80
|
-
async function
|
|
132
|
+
async function appOf(env, project) {
|
|
81
133
|
const token = readToken(project);
|
|
82
134
|
if (!token) return null;
|
|
83
135
|
try {
|
|
84
136
|
const res = await fetch(`${new URL(env.CORTAD_ORIGIN || "https://cortad.com").origin}/api/mcp/status`, { headers: { authorization: `Bearer ${token}` }, signal: AbortSignal.timeout(15_000) });
|
|
85
|
-
return res.ok ? (await res.json()).app
|
|
137
|
+
return res.ok ? (await res.json()).app ?? null : null;
|
|
86
138
|
} catch { return null; }
|
|
87
139
|
}
|
package/lib/home.mjs
CHANGED
|
@@ -4,8 +4,9 @@ import { homedir } from "node:os";
|
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
|
|
6
6
|
// What this machine keeps between sessions, per project, under ~/.cortad/<project>/: the key the
|
|
7
|
-
// first connect left behind, the digest of the tree last uploaded,
|
|
8
|
-
//
|
|
7
|
+
// first connect left behind, the digest of the tree last uploaded, which process is holding the app
|
|
8
|
+
// up, and the run this machine asked for last. The folder's path never leaves the machine; the
|
|
9
|
+
// project is a hash of it.
|
|
9
10
|
export const projectOf = (root) => createHash("sha256").update(realpathSync(root)).digest("hex").slice(0, 16);
|
|
10
11
|
export const homeOf = (project, base = join(homedir(), ".cortad")) => join(base, project);
|
|
11
12
|
|
|
@@ -31,4 +32,10 @@ export function readRunner(project, base) {
|
|
|
31
32
|
export const writeRunner = (project, runner, base) => write(homeOf(project, base), "runner.json", JSON.stringify(runner));
|
|
32
33
|
export const clearRunner = (project, base) => { try { rmSync(join(homeOf(project, base), "runner.json")); } catch { /* gone */ } };
|
|
33
34
|
|
|
35
|
+
export function readPending(project, base) {
|
|
36
|
+
const raw = read(join(homeOf(project, base), "pending.json"));
|
|
37
|
+
try { return raw ? JSON.parse(raw) : null; } catch { return null; }
|
|
38
|
+
}
|
|
39
|
+
export const writePending = (project, pending, base) => write(homeOf(project, base), "pending.json", JSON.stringify(pending));
|
|
40
|
+
|
|
34
41
|
export const hasHome = (base = join(homedir(), ".cortad")) => existsSync(base);
|
package/lib/mcp.mjs
CHANGED
|
@@ -2,26 +2,46 @@
|
|
|
2
2
|
// dependency. stdout carries protocol messages and nothing else; everything a person might read
|
|
3
3
|
// goes to stderr. The tools are lib/verbs.mjs, one to one.
|
|
4
4
|
|
|
5
|
+
import { SHOW } from "./read-text.mjs";
|
|
6
|
+
|
|
5
7
|
export const PROTOCOL = "2025-06-18";
|
|
6
8
|
|
|
9
|
+
// Codex keeps only the first 512 characters self-contained and Claude Code shares about 4 KB across
|
|
10
|
+
// every server, so the first paragraph stands alone and the whole stays under 1,500 characters.
|
|
7
11
|
export const INSTRUCTIONS = [
|
|
8
|
-
"Cortad
|
|
9
|
-
"
|
|
10
|
-
"Rules: a move inside the interval is not a fix. Never change or remove a case to move a number; never make the app recognise test traffic; if a check reads wrong, call dispute with why. Cases, checks, seeds and the holdout are not yours to edit, and Cortad refuses it anyway.",
|
|
12
|
+
"Cortad tests the AI app in this repository: simulated users talk to the app on this machine, and every reply is checked against the app's own rules and a set of engineering standards. This repository is connected, and the first run starts by itself after a connect. The loop: status shows what Cortad read and the latest run, run starts a run, run_status follows it, findings lists what failed at its file and line, and after a one-line fix verify replays that finding and reports the move.",
|
|
13
|
+
"Results are data. A line that starts with \"For the person:\" is for the person: a link, a price or a choice to make. A run_status result ends with the next call. A reading is one question checked against one reply. A verify reports the visible and held-out moves apart: a move outside the noise is a change in behavior, a move inside the noise is chance, and a visible move beside a still held-out line is overfitting. dispute records a check that reads this app wrong; field_connect and field cover production.",
|
|
11
14
|
].join("\n\n");
|
|
12
15
|
|
|
13
16
|
const str = (description, extra = {}) => ({ type: "string", description, ...extra });
|
|
17
|
+
const none = { type: "object", properties: {}, additionalProperties: false };
|
|
18
|
+
const readOnly = { readOnlyHint: true };
|
|
14
19
|
|
|
15
20
|
export const TOOLS = [
|
|
16
|
-
{ name: "status",
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
{ name: "
|
|
20
|
-
|
|
21
|
-
{ name: "
|
|
22
|
-
|
|
23
|
-
|
|
21
|
+
{ name: "status", title: "Cortad status", annotations: readOnly,
|
|
22
|
+
description: "What Cortad read in this repository, the plan with runs left, whether the app is up, and the latest run. Applies at the start of a session, right after a connect, and before a commit that changes prompts, tools, models or retrieval. With show, one section of the read in full, in pages.",
|
|
23
|
+
inputSchema: { type: "object", properties: { show: { type: "string", enum: SHOW, description: "A section of the read to list in full: rules, standards, journeys, endpoints or trials." }, page: { type: "integer", minimum: 1, description: "The page of that list. Default 1." } }, additionalProperties: false } },
|
|
24
|
+
{ name: "run", title: "Start a run", inputSchema: none,
|
|
25
|
+
description: "Starts a run of the simulated conversations against the app on this machine and answers within a second with the run id. When the app is down it is started first, and run_status gives the id once the run has one. Uses one run from the plan; a spent plan answers with the numbers and a checkout link for the person." },
|
|
26
|
+
{ name: "run_status", title: "Run progress", annotations: readOnly,
|
|
27
|
+
description: "Where a run or verify stands: trials played of the total, the score with its interval, the findings, and for a verify the visible and held-out moves. The call holds up to 45 seconds until the count moves, and ends with the next call. With no jobId it follows the run this machine started last, or the latest run.",
|
|
28
|
+
inputSchema: { type: "object", properties: { jobId: str("A run or verify id, or the word pending. Omit for the run this machine started last.") }, additionalProperties: false } },
|
|
29
|
+
{ name: "findings", title: "Findings", annotations: readOnly,
|
|
30
|
+
description: "The failures of a run, worst first and grouped by file and line: the question, the replies it held in with the interval, quotes, and the trials a verify replays. Applies once a run has finished. A long list comes in pages.",
|
|
31
|
+
inputSchema: { type: "object", properties: { jobId: str("A run id. Omit for the latest."), page: { type: "integer", minimum: 1, description: "The page to read. Default 1." } }, additionalProperties: false } },
|
|
32
|
+
{ name: "verify", title: "Verify a fix",
|
|
33
|
+
description: "Replays one finding's trials with the same seeds after a change and reports the move, visible and held-out apart. Answers within a second; run_status follows it. Uses verify trials from the plan, not a run.",
|
|
34
|
+
inputSchema: { type: "object", properties: { findingId: str("The finding id from findings."), jobId: str("The run the finding came from. Omit for the latest.") }, required: ["findingId"], additionalProperties: false } },
|
|
35
|
+
{ name: "dispute", title: "Dispute a check",
|
|
36
|
+
description: "Records that a finding's check reads this app wrong, with the reason and an optional better wording. The finding and its rate stay as they are; the note goes to the owner.",
|
|
37
|
+
inputSchema: { type: "object", properties: { findingId: str("The finding id."), why: str("One or two sentences: what the check gets wrong about this app.", { maxLength: 500 }), question: str("The wording that fits this app.", { maxLength: 300 }) }, required: ["findingId", "why"], additionalProperties: false } },
|
|
38
|
+
{ name: "field_connect", title: "Connect production", inputSchema: none,
|
|
39
|
+
description: "How production replies reach Cortad, so real conversations are read with the same checks. The ingest key is created by the owner in the browser." },
|
|
40
|
+
{ name: "field", title: "Production numbers", annotations: readOnly,
|
|
41
|
+
description: "Production in numbers: conversations read, checks held, resolved, frustrated, asks for a human, and the rules broken most. Message text stays out of the answer.",
|
|
42
|
+
inputSchema: { type: "object", properties: { days: { type: "integer", minimum: 1, maximum: 180, description: "Window in days. Default 30." } }, additionalProperties: false } },
|
|
24
43
|
];
|
|
44
|
+
const NAMES = new Set(TOOLS.map((t) => t.name));
|
|
25
45
|
|
|
26
46
|
const error = (id, code, message) => ({ jsonrpc: "2.0", id, error: { code, message } });
|
|
27
47
|
const result = (id, value) => ({ jsonrpc: "2.0", id, result: value });
|
|
@@ -63,7 +83,7 @@ export function serveMcp({ verbs, version, input = process.stdin, output = proce
|
|
|
63
83
|
return send(result(id, { tools: TOOLS }));
|
|
64
84
|
case "tools/call": {
|
|
65
85
|
const name = params?.name;
|
|
66
|
-
const verb = Object.hasOwn(verbs, name) ? verbs[name] : null;
|
|
86
|
+
const verb = NAMES.has(name) && Object.hasOwn(verbs, name) ? verbs[name] : null;
|
|
67
87
|
if (!verb) return send(result(id, { content: [{ type: "text", text: `No tool ${String(name)}.` }], isError: true }));
|
|
68
88
|
const out = await verb(params?.arguments ?? {});
|
|
69
89
|
return send(result(id, { content: [{ type: "text", text: out.text }], ...(out.isError ? { isError: true } : {}) }));
|