@evandu/super-pi 0.1.128 → 0.1.130

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.
Files changed (47) hide show
  1. package/agents/super-pi-knowledge/index.ts +4 -3
  2. package/agents/super-pi-memory/index.ts +5 -4
  3. package/agents/super-pi-task/core.ts +8 -7
  4. package/dist/.super-pi-check-inputs.json +2 -2
  5. package/dist/client/.super-pi-client-sources.json +5 -5
  6. package/dist/client/assets/{CodeMirrorEditor-BhxCTVTL.js → CodeMirrorEditor-BNVBZZSS.js} +2 -2
  7. package/dist/client/assets/{dist-DERv1ty2.js → dist-B2nPVdKh.js} +1 -1
  8. package/dist/client/assets/{dist-BAmT9jUI.js → dist-B61RSa-K.js} +1 -1
  9. package/dist/client/assets/{dist-BPBlgusB.js → dist-BBW2SE_R.js} +1 -1
  10. package/dist/client/assets/{dist-BQK4xccN.js → dist-BDygQgZD.js} +1 -1
  11. package/dist/client/assets/{dist-C-hIztMW.js → dist-BKp4AWEJ.js} +1 -1
  12. package/dist/client/assets/{dist-Cl87QYqE.js → dist-BhJGK8-b.js} +1 -1
  13. package/dist/client/assets/{dist-Bplw4N5j.js → dist-BykVFExd.js} +1 -1
  14. package/dist/client/assets/{dist-K96Y1Tut.js → dist-C90MPTnk.js} +1 -1
  15. package/dist/client/assets/{dist-DVBpWkDV.js → dist-CMnvChDX.js} +1 -1
  16. package/dist/client/assets/{dist-BfwwrK33.js → dist-CRzRB9k3.js} +1 -1
  17. package/dist/client/assets/{dist-BeYwU1xB.js → dist-CWYRjnj-.js} +1 -1
  18. package/dist/client/assets/{dist-AtHUPHQe.js → dist-CZ9I2JCd.js} +1 -1
  19. package/dist/client/assets/{dist-8-xcbQPp.js → dist-ChszWGDv.js} +1 -1
  20. package/dist/client/assets/{dist-Bof_dYLf.js → dist-CiSGSVBZ.js} +1 -1
  21. package/dist/client/assets/{dist-Dc6xSTJL.js → dist-Cxerc7Bb.js} +1 -1
  22. package/dist/client/assets/{dist-DobX-p1U.js → dist-DB5M5fZG.js} +1 -1
  23. package/dist/client/assets/{dist-YUFYdtGJ.js → dist-DZnUERoW.js} +1 -1
  24. package/dist/client/assets/{dist-BJZUzzzf.js → dist-DmAOqr0M.js} +1 -1
  25. package/dist/client/assets/{dist-Bw8QOdGP.js → dist-Dq4-QFAJ.js} +1 -1
  26. package/dist/client/assets/{dist-icIMVISS.js → dist-GkIV1LTX.js} +1 -1
  27. package/dist/client/assets/{dist-DTXHzd8a.js → dist-HdeIBl2J.js} +1 -1
  28. package/dist/client/assets/{dist-5LsaarOt.js → dist-Ur37340K.js} +1 -1
  29. package/dist/client/assets/index-CXB6U5mz.css +1 -0
  30. package/dist/client/assets/{index-BvQ3umhR.js → index-D_c-CYAr.js} +35 -37
  31. package/dist/client/index.html +2 -2
  32. package/dist/server/active-memory.d.ts +20 -3
  33. package/dist/server/active-memory.js +69 -2
  34. package/dist/server/application/conversation-service.js +5 -0
  35. package/dist/server/application/run-service.js +10 -1
  36. package/dist/server/git.d.ts +87 -0
  37. package/dist/server/git.js +301 -2
  38. package/dist/server/http-server.js +39 -7
  39. package/dist/server/pi-session.d.ts +25 -6
  40. package/dist/server/pi-session.js +33 -3
  41. package/dist/server/runtime.d.ts +12 -0
  42. package/dist/server/runtime.js +6 -0
  43. package/dist/server/store.js +16 -5
  44. package/dist/server/ws-server.js +3 -0
  45. package/package.json +4 -1
  46. package/scripts/diagnose.mjs +8 -3
  47. package/dist/client/assets/index-B8GInEf1.css +0 -1
@@ -7,7 +7,7 @@ import { basename, dirname, extname, join, relative, resolve } from "node:path";
7
7
  import { describe as describeFilesError, fileMeta, listDirectory, MAX_FILE_BYTES, readWorkspaceFile, resolveInsideRoot, toRelative, writeWorkspaceFile, } from "./files.js";
8
8
  import { findUploadFile, MAX_UPLOAD_BODY_BYTES, saveUpload } from "./upload.js";
9
9
  import { checkDiskVersion } from "./version.js";
10
- import { gitDiff, gitLog, gitStatus } from "./git.js";
10
+ import { gitBlame, gitDiff, gitLog, gitMutate, gitStatus } from "./git.js";
11
11
  import { computeFileDiff } from "./file-diff.js";
12
12
  import { MAX_KNOWLEDGE_REQUEST_BYTES } from "./knowledge.js";
13
13
  import { MAX_TOOL_BODY_BYTES } from "./tool-registry.js";
@@ -797,10 +797,6 @@ export function createHttpServer(options) {
797
797
  if (url.pathname === "/api/git") {
798
798
  const json = (status, body) => response.writeHead(status, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify(body));
799
799
  try {
800
- if (request.method !== "GET") {
801
- json(405, { error: "Git 视图需要 GET", code: "method" });
802
- return;
803
- }
804
800
  const workspaceId = url.searchParams.get("workspaceId");
805
801
  const workspace = context.store.snapshot().workspaces.find((item) => item.id === workspaceId);
806
802
  if (!workspace) {
@@ -808,6 +804,24 @@ export function createHttpServer(options) {
808
804
  return;
809
805
  }
810
806
  const op = url.searchParams.get("op") ?? "status";
807
+ // Blame is a read with a GET shape: every other verb is refused HERE, before the POST branch
808
+ // could ever route the op into gitMutate and before any body is read.
809
+ if (op === "blame" && request.method !== "GET") {
810
+ json(405, { error: "Git 请求方法不支持", code: "method" });
811
+ return;
812
+ }
813
+ if (request.method === "POST") {
814
+ let raw = "";
815
+ for await (const chunk of request)
816
+ raw += chunk;
817
+ const body = raw ? JSON.parse(raw) : {};
818
+ json(200, await gitMutate(workspace.path, op, body.path, body.message));
819
+ return;
820
+ }
821
+ if (request.method !== "GET") {
822
+ json(405, { error: "Git 请求方法不支持", code: "method" });
823
+ return;
824
+ }
811
825
  if (op === "status")
812
826
  json(200, await gitStatus(workspace.path));
813
827
  else if (op === "log")
@@ -820,6 +834,20 @@ export function createHttpServer(options) {
820
834
  }
821
835
  json(200, await gitDiff(workspace.path, path, url.searchParams.get("staged") === "1"));
822
836
  }
837
+ else if (op === "blame") {
838
+ const path = url.searchParams.get("path");
839
+ if (!path) {
840
+ json(400, { error: "blame 需要 path", code: "bad_path" });
841
+ return;
842
+ }
843
+ const startLine = url.searchParams.get("startLine");
844
+ const endLine = url.searchParams.get("endLine");
845
+ json(200, await gitBlame(workspace.path, path, {
846
+ revision: url.searchParams.get("revision") ?? undefined,
847
+ startLine: startLine === null ? undefined : Number(startLine),
848
+ endLine: endLine === null ? undefined : Number(endLine),
849
+ }));
850
+ }
823
851
  else
824
852
  json(400, { error: "未知 Git 操作", code: "bad_op" });
825
853
  }
@@ -830,6 +858,10 @@ export function createHttpServer(options) {
830
858
  json(404, { error: "此工作区不是 git 仓库", code: "not_a_repo" });
831
859
  else if (code === "path")
832
860
  json(400, { error: message, code: "bad_path" });
861
+ else if (code === "not_found")
862
+ json(404, { error: message, code: "not_found" });
863
+ else if (code === "bad_range" || code === "bad_revision")
864
+ json(400, { error: message, code });
833
865
  else
834
866
  json(400, { error: message, code: "git_error" });
835
867
  }
@@ -900,7 +932,7 @@ export function createHttpServer(options) {
900
932
  json(400, { error: "请求体不是合法的 JSON。" });
901
933
  return;
902
934
  }
903
- const { cmd, cwd, conversationId, capability, toolCallId } = body;
935
+ const { cmd, description, cwd, conversationId, capability, toolCallId } = body;
904
936
  if (!cmd || !cwd || !conversationId) {
905
937
  json(400, { error: "需要 cmd、cwd 和 conversationId。" });
906
938
  return;
@@ -929,7 +961,7 @@ export function createHttpServer(options) {
929
961
  await store.setBackgroundTask({
930
962
  id: sessionId,
931
963
  conversationId,
932
- title: cmd.length > 60 ? cmd.slice(0, 60) + "…" : cmd,
964
+ title: description?.trim() || (cmd.length > 60 ? cmd.slice(0, 60) + "…" : cmd),
933
965
  agentId: "exec",
934
966
  status: "running",
935
967
  startedAt: new Date().toISOString(),
@@ -184,6 +184,13 @@ export interface ExtensionEnvironment {
184
184
  * concurrent session's environment would otherwise land in the middle of this one.
185
185
  */
186
186
  export declare function withEnvironment<T>(values: Record<string, string>, work: () => Promise<T>): Promise<T>;
187
+ /**
188
+ * `withEnvironment` for one session's extension load, serialized across all session creations.
189
+ *
190
+ * The extension factories run inside `work` and capture the values into their closures; after the
191
+ * window closes they never read `process.env` again, so only the WINDOW needs to be exclusive.
192
+ */
193
+ export declare function withIsolatedSessionEnv<T>(values: Record<string, string>, work: () => Promise<T>): Promise<T>;
187
194
  /**
188
195
  * The live session for one conversation.
189
196
  *
@@ -210,6 +217,15 @@ export interface PiSessionLike {
210
217
  * message for later", and the caller falls back to answering it as a turn of its own.
211
218
  */
212
219
  followUp?(message: string, images?: unknown[]): Promise<"queued" | "handled">;
220
+ sendCustomMessage?(message: {
221
+ customType: string;
222
+ content: unknown;
223
+ display?: string;
224
+ details?: unknown;
225
+ }, options?: {
226
+ triggerTurn?: boolean;
227
+ deliverAs?: "steer" | "followUp" | "nextTurn";
228
+ }): Promise<void>;
213
229
  /**
214
230
  * Removes every message Pi is holding in its steering and follow-up queues, and returns what was
215
231
  * removed.
@@ -354,14 +370,17 @@ export interface SessionCreation {
354
370
  *
355
371
  * The order of operations here IS the isolation mechanism, and each step is load-bearing:
356
372
  *
357
- * 1. The environment is applied, so the extension factories that run during the reload read this
358
- * session's values rather than another session's.
359
- * 2. The loader is created and reloaded, which is when Pi calls the extension factories. The factories
360
- * CAPTURE the values; they do not read them later.
373
+ * 1. The loader is created with Super Pi's extension paths, for PI to load - through its jiti
374
+ * transpiler and virtual modules, the same machinery the spawned CLI used. Nothing here imports a
375
+ * Super Pi extension file directly; see the comment at the loader below for why that must never
376
+ * come back.
377
+ * 2. The reload runs inside this session's env window (`withIsolatedSessionEnv`), which is when Pi
378
+ * calls the extension factories. The factories CAPTURE the values; they do not read them later.
361
379
  * 3. The session is created with that loader.
362
380
  *
363
- * Step 2 happening outside step 1 would give every session the same configuration, and step 1 happening
364
- * after step 2 would give every session no configuration at all. The window is the whole of the reload.
381
+ * Step 2 happening outside its env window would give every session the same configuration, and the
382
+ * window would give every session no configuration at all if two creations could interleave - which
383
+ * the serialization in `withIsolatedSessionEnv` prevents.
365
384
  *
366
385
  * THE FACTORY IS INJECTABLE. A test needs to drive a session without a real Pi behind it - to check what
367
386
  * a session was BUILT with (its working directory, its allowlist) rather than what it eventually
@@ -225,13 +225,40 @@ export async function withEnvironment(values, work) {
225
225
  }
226
226
  }
227
227
  }
228
+ /**
229
+ * The env window of the session currently being built, so the next one queues behind it.
230
+ *
231
+ * `withEnvironment` alone is not safe here: its window is process-global, and two sessions created
232
+ * concurrently would interleave - A applies its capability, B applies its own, and A's extension
233
+ * factory captures B's credential. Chaining the windows means a concurrent creation waits for the
234
+ * other's `reload()` - milliseconds - instead of silently wearing its capability.
235
+ */
236
+ let extensionEnvWindow = Promise.resolve();
237
+ /**
238
+ * `withEnvironment` for one session's extension load, serialized across all session creations.
239
+ *
240
+ * The extension factories run inside `work` and capture the values into their closures; after the
241
+ * window closes they never read `process.env` again, so only the WINDOW needs to be exclusive.
242
+ */
243
+ export function withIsolatedSessionEnv(values, work) {
244
+ const run = extensionEnvWindow.then(() => withEnvironment(values, work));
245
+ extensionEnvWindow = run.then(() => undefined, () => undefined);
246
+ return run;
247
+ }
228
248
  export async function createPiSession(creation) {
229
249
  const startedAt = performance.now();
230
250
  traceSession(creation.sessionId, "sdk.load.begin", startedAt);
231
251
  const sdk = await loadSdk();
232
252
  traceSession(creation.sessionId, "sdk.load.end", startedAt);
233
253
  const agentDir = creation.agentDir ?? defaultAgentDir();
234
- return withEnvironment(creation.extensionEnv, async () => {
254
+ // THE EXTENSIONS ARE PI'S TO LOAD. Every path goes to Pi's own resource loader, which imports them
255
+ // through its jiti transpiler with its virtual modules - and both of those are load-bearing:
256
+ // `@sinclair/typebox` is a Pi virtual module that NO node_modules anywhere can resolve, and Node's
257
+ // type stripping refuses .ts under any `node_modules` segment (which is where a published package
258
+ // lives). A native `import()` here survived neither - it broke every installed layout the moment it
259
+ // shipped, invisibly, because nothing in `npm run check` runs from an installed layout.
260
+ // `scripts/installed-layout.test.mjs` exists to keep that class of breakage visible.
261
+ return (async () => {
235
262
  // THE AGENT'S PROMPT. Pi's loader takes the same two shapes the command line took: a `systemPrompt`
236
263
  // that REPLACES Pi's own, and an `appendSystemPrompt` list of files that are ADDED to it. An agent
237
264
  // that says `promptMode: replace` gets the first, and one that says nothing gets the second - the
@@ -248,7 +275,10 @@ export async function createPiSession(creation) {
248
275
  ? { appendSystemPrompt: [creation.systemPromptFile] }
249
276
  : {}),
250
277
  });
251
- await loader.reload();
278
+ // The extension factories run inside `reload()` and capture this session's env values into their
279
+ // closures; the window is serialized (`withIsolatedSessionEnv`) so a concurrent creation cannot
280
+ // land its own capability in the middle.
281
+ await withIsolatedSessionEnv(creation.extensionEnv, () => loader.reload());
252
282
  traceSession(creation.sessionId, "sdk.loader.reload", startedAt, `extensions=${creation.extensionPaths.length}`);
253
283
  const sessionManager = creation.persist === false
254
284
  ? sdk.SessionManager.inMemory(creation.cwd, { id: creation.sessionId })
@@ -298,7 +328,7 @@ export async function createPiSession(creation) {
298
328
  return found;
299
329
  };
300
330
  return live;
301
- });
331
+ })();
302
332
  }
303
333
  function transcriptBytes(sessionManager) {
304
334
  const file = sessionManager.getSessionFile?.();
@@ -272,6 +272,12 @@ export interface AgentRuntime {
272
272
  * own waiting list - Pi is holding a copy, not the only copy.
273
273
  */
274
274
  followUp?(conversationId: string, message: string, images?: MessageImage[]): Promise<SteerDisposition>;
275
+ sendCustomMessage?(conversationId: string, message: {
276
+ customType: string;
277
+ content: string;
278
+ display?: string;
279
+ details: Record<string, unknown>;
280
+ }): Promise<void>;
275
281
  /**
276
282
  * Empties Pi's steering and follow-up queues, and returns what was in them.
277
283
  *
@@ -1067,6 +1073,12 @@ export declare class PiRpcRuntime implements AgentRuntime {
1067
1073
  * was busy means).
1068
1074
  */
1069
1075
  followUp(conversationId: string, message: string, images?: MessageImage[]): Promise<SteerDisposition>;
1076
+ sendCustomMessage(conversationId: string, message: {
1077
+ customType: string;
1078
+ content: string;
1079
+ display?: string;
1080
+ details: Record<string, unknown>;
1081
+ }): Promise<void>;
1070
1082
  /**
1071
1083
  * Empties Pi's steering and follow-up queues and returns what was in them.
1072
1084
  *
@@ -2184,6 +2184,12 @@ export class PiRpcRuntime {
2184
2184
  // do anyway.
2185
2185
  throw new SteerUnavailableError(`会话 ${conversationId} 当前没有可以接手的 Pi 会话,无法排队`);
2186
2186
  }
2187
+ async sendCustomMessage(conversationId, message) {
2188
+ const session = await this.sessionFor(conversationId);
2189
+ if (!session.sendCustomMessage)
2190
+ throw new SteerUnavailableError(`会话 ${conversationId} 的 Pi 不支持 custom message`);
2191
+ await session.sendCustomMessage(message, { triggerTurn: true, deliverAs: "followUp" });
2192
+ }
2187
2193
  /**
2188
2194
  * Empties Pi's steering and follow-up queues and returns what was in them.
2189
2195
  *
@@ -636,13 +636,17 @@ export class Store {
636
636
  console.warn(`Super Pi: state file ${repository.filePath} could not be loaded (${reason}) and could not be moved aside either (${moveError instanceof Error ? moveError.message : String(moveError)}) - the server starts with a fresh state; the unreadable file is still at ${repository.filePath}.`);
637
637
  }
638
638
  }
639
- const createdAt = now();
640
639
  // NO WRITE HERE, AND THAT IS AN INVARIANT RATHER THAN AN OMISSION: a store with no file behind it
641
640
  // stays lazy, so opening one never creates a file and never has to un-create one. `persistence-
642
641
  // repository.test.mjs` asserts it by reading the path and expecting ENOENT. Repairing an EXISTING
643
642
  // file is the other case and is handled above; this branch invented its contents and says nothing
644
643
  // until something actually happens.
645
- return new Store(repository, { workspaces: [{ id: "workspace_default", name: "Super Pi", path: process.cwd(), updatedAt: createdAt }], conversations: [], subagentRuns: [], backgroundTasks: [] });
644
+ //
645
+ // A fresh state starts with NO workspace, and that is a constraint rather than a gap: this branch
646
+ // used to seed one named after process.cwd(), so whatever directory the server happened to be
647
+ // launched from silently became a workspace. The first workspace is always an explicit decision -
648
+ // the client's zero-workspace empty state (App.tsx) asks for one instead.
649
+ return new Store(repository, { workspaces: [], conversations: [], subagentRuns: [], backgroundTasks: [] });
646
650
  }
647
651
  }
648
652
  async setModelFilterRules(rules) {
@@ -697,7 +701,13 @@ export class Store {
697
701
  throw error;
698
702
  }
699
703
  }
700
- snapshot() { return structuredClone(this.state); }
704
+ snapshot() {
705
+ const startedAt = process.env.SUPER_PI_TRACE_LATENCY === "1" ? performance.now() : 0;
706
+ const value = structuredClone(this.state);
707
+ if (startedAt)
708
+ console.warn(`[super-pi latency] phase=state.snapshot bytes=${JSON.stringify(value).length} cloneMs=${Math.round(performance.now() - startedAt)}`);
709
+ return value;
710
+ }
701
711
  /**
702
712
  * Whether a conversation already holds a message with this id.
703
713
  *
@@ -796,8 +806,9 @@ export class Store {
796
806
  async deleteWorkspace(workspaceId) {
797
807
  if (!this.state.workspaces.some((item) => item.id === workspaceId))
798
808
  throw new Error("找不到这个工作区。");
799
- if (this.state.workspaces.length <= 1)
800
- throw new Error("至少要保留一个工作区,不能把最后一个删掉。");
809
+ // Deleting the LAST workspace is allowed: a fresh store starts with zero (see `openRepository`), so
810
+ // "at least one" was an artifact of the old first-run seed, and the client's zero-workspace empty
811
+ // state is the onboarding path back in.
801
812
  // Cascade: workspaces and conversations live in one state.json, so this single atomic persist removes
802
813
  // both together. A waiting message needs no sweep of its own any more - it is a message inside one of
803
814
  // the conversations being removed, so it goes with its container, which is the whole reason it is a
@@ -73,7 +73,10 @@ export class WsBroadcaster {
73
73
  * Stream deltas stay per-event: they are coalesced per socket, so there is no single payload to
74
74
  * share, and their cost is one small string.
75
75
  */
76
+ const startedAt = performance.now();
76
77
  const payload = streaming ? undefined : JSON.stringify(event);
78
+ if (!streaming && event.type === "state" && process.env.SUPER_PI_TRACE_LATENCY === "1")
79
+ console.warn(`[super-pi latency] phase=state.broadcast bytes=${payload?.length ?? 0} sockets=${this.sockets.size} serializeMs=${Math.round(performance.now() - startedAt)}`);
77
80
  for (const socket of this.sockets) {
78
81
  if (socket.readyState !== WebSocket.OPEN)
79
82
  continue;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@evandu/super-pi",
3
- "version": "0.1.128",
3
+ "version": "0.1.130",
4
4
  "description": "Web control plane and companion for Pi",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -43,6 +43,8 @@
43
43
  "scripts/stream-checkpoint.test.mjs",
44
44
  "scripts/stream-transport.test.mjs",
45
45
  "scripts/pi-session.test.mjs",
46
+ "scripts/pi-session-isolation.test.mjs",
47
+ "scripts/installed-layout.test.mjs",
46
48
  "scripts/pi-model-switch.test.mjs",
47
49
  "scripts/pi-thinking-levels.test.mjs",
48
50
  "scripts/diagnose-cli.test.mjs",
@@ -101,6 +103,7 @@
101
103
  "scripts/send-retry.test.mjs",
102
104
  "scripts/error-messages.test.mjs",
103
105
  "scripts/git-diff-view.test.mjs",
106
+ "scripts/git-blame.test.mjs",
104
107
  "scripts/fork-server.test.mjs",
105
108
  "scripts/styles-no-dangling.test.mjs",
106
109
  "scripts/theme-no-hardcoded-colours.test.mjs",
@@ -229,8 +229,13 @@ export async function diagnose({ repositoryRoot, turns = 2, onProgress = () => {
229
229
  throw new Error(`timed out waiting for ${label}`);
230
230
  };
231
231
 
232
+ // A fresh store starts with no workspace, so the diagnosis creates the one it measures against -
233
+ // before any measured span, so workspace setup never lands inside a number.
234
+ send({ type: "workspace.create", name: "diagnose", path: process.cwd() });
235
+ const workspaceId = (await waitFor((e) => e.type === "state" && e.state.workspaces.some((w) => w.name === "diagnose"), "the diagnose workspace")).state.workspaces.find((w) => w.name === "diagnose").id;
236
+
232
237
  onProgress("creating a conversation...");
233
- send({ type: "conversation.create", workspaceId: "workspace_default" });
238
+ send({ type: "conversation.create", workspaceId });
234
239
  await waitFor((e) => e.type === "state" && e.state.conversations.length > 0, "a conversation");
235
240
  const conversation = events.filter((e) => e.type === "state").at(-1).state.conversations[0];
236
241
 
@@ -258,13 +263,13 @@ export async function diagnose({ repositoryRoot, turns = 2, onProgress = () => {
258
263
  onProgress("setting a workspace default model, then creating a conversation...");
259
264
  send({
260
265
  type: "workspace.settings",
261
- workspaceId: "workspace_default",
266
+ workspaceId,
262
267
  model: { provider: defaultModel.provider, modelId: defaultModel.modelId },
263
268
  thinkingLevel: null,
264
269
  });
265
270
  await sleep(400);
266
271
  const withDefaultStart = Date.now();
267
- send({ type: "conversation.create", workspaceId: "workspace_default" });
272
+ send({ type: "conversation.create", workspaceId });
268
273
  const withDefault = await waitFor((e) => e.type === "state"
269
274
  && e.state.conversations.length >= 2, "a conversation with the workspace default");
270
275
  result.conversationWithDefaultMs = Date.now() - withDefaultStart;