--- a/packages/circe-client-runtime/src/circe/mesh.test.ts +++ b/packages/circe-client-runtime/src/circe/mesh.test.ts @@ -1801,3 +1801,17 @@ }), ); }); + + +describe("Circe app audit regressions", () => { + it("does not select a Controller for execution-gated semantic interpretation", () => { + const catalog: CirceMeshCatalog = { + nodes: [ + { nodeId: NODE_DESKTOP, label: "Controller", reachability: "online", capabilities: circeNodeCapabilitiesForPreset("controller") }, + { nodeId: NODE_LAPTOP, label: "Execution", reachability: "online", capabilities: circeNodeCapabilitiesForPreset("headless") }, + ], + projects: [], providers: [], + }; + expect(selectCirceSemanticNode(catalog)?.nodeId).toBe(NODE_LAPTOP); + }); +}); --- a/apps/web/src/components/circe/CirceLiveVoice.logic.test.ts +++ b/apps/web/src/components/circe/CirceLiveVoice.logic.test.ts @@ -794,3 +794,39 @@ ); }); }); + + +describe("Circe app audit speech cancellation", () => { + it("retracts a report cleared before the live connection is ready", async () => { + const { createCirceSpeechPlaybackQueue, cancelCirceSpeechDelivery } = await import("./CirceVoiceReporter.logic"); + const { setCirceLiveVoiceSink } = await import("./CirceLiveVoice.bridge"); + const { speakPresentation } = await import("./CirceVoiceReporter"); + const { EnvironmentId, ProjectId, ThreadId } = await import("@t3tools/contracts"); + const f = fixture({ listen: false }); + await f.controller.start(); + setCirceLiveVoiceSink({ speak: f.controller.speak, note: f.controller.note }); + const node = EnvironmentId.make("audit-node"); + const queue = createCirceSpeechPlaybackQueue({ + speak: (event) => speakPresentation(node, event), + cancel: (event) => cancelCirceSpeechDelivery(event.presentationId), + shouldDeliver: () => true, + onDeliveryFailure: () => undefined, + }); + try { + queue.enqueue({ + presentationId: "audit-report", projectId: ProjectId.make("audit-project"), + threadId: ThreadId.make("audit-thread"), + taskRef: { executionNodeId: node, threadId: ThreadId.make("audit-thread") }, + origin: { originNodeId: node, originInteractionId: "audit-origin" }, + kind: "completed", threadTitle: "Audit", providerName: "Codex", + text: "Obsolete report from disconnected node", createdAt: "2026-09-13T00:00:00.000Z", + }); + queue.clear(); + f.peer.channel.emit(started); + expect(f.peer.channel.events().filter((event) => event.type === "session.commentary.append")).toEqual([]); + } finally { + setCirceLiveVoiceSink(null); + await f.controller.close(); + } + }); +}); --- /dev/null +++ b/apps/server/src/persistence/Migrations/circeUpgrade.audit.test.ts @@ -0,0 +1,21 @@ +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; +import { runMigrations } from "../Migrations.ts"; + +it.layer(NodeSqliteClient.layerMemory())("Circe app audit upgrade", (it) => { + it.effect("upgrades shipped table names without losing an existing alias", () => Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* runMigrations(); + // Before the rename, the same shipped migration IDs produced jarvis_* tables. + yield* sql`ALTER TABLE circe_project_aliases RENAME TO jarvis_project_aliases`; + yield* sql`ALTER TABLE circe_task_desks RENAME TO jarvis_task_desks`; + yield* sql`ALTER TABLE circe_follow_up_queue RENAME TO jarvis_follow_up_queue`; + yield* sql`ALTER TABLE circe_push_registrations RENAME TO jarvis_push_registrations`; + yield* sql`INSERT INTO jarvis_project_aliases(project_id, normalized_alias, alias, kind, updated_at) VALUES ('existing-project', 'beacon', 'Beacon', 'user-defined', '2026-09-13T00:00:00.000Z')`; + yield* runMigrations(); + const rows = yield* sql`SELECT alias FROM circe_project_aliases`; + assert.deepStrictEqual(rows, [{ alias: "Beacon" }]); + })); +}); --- a/apps/server/src/circe/Layers/CirceController.test.ts +++ b/apps/server/src/circe/Layers/CirceController.test.ts @@ -3236,6 +3236,105 @@ }).pipe(Effect.provide(layer)); }); + it.effect("Circe app audit: routes conversation by managed workspace identity, not a duplicate title", () => { + const commands: Array = []; + const decoy = { ...project, id: ProjectId.make("decoy-conversations"), title: "Conversations", workspaceRoot: "/workspace/user-repo" }; + const managed = { ...project, id: ProjectId.make("managed-conversations"), title: "Conversations", workspaceRoot: "/state/conversations" }; + const projectCatalog = [project, decoy, managed]; + const executionNodeId = EnvironmentId.make("node-conversation"); + const interpreterLayer = Layer.succeed(CirceControllerInterpreter, { + interpret: (context) => { + const prepared = prepareCirceSemanticTurn(context); + if (prepared.status === "needs-input") return Effect.succeed(prepared); + return Effect.succeed( + interpretCirceCommand(context, prepared, { + action: "converse", + refs: [], + model: null, + effort: null, + answer: "Nothing new.", + }), + ); + }, + }); + const layer = makeCirceControllerLive(interpreterLayer).pipe( + Layer.provideMerge(testFollowUpQueueLayer), + Layer.provideMerge(testTaskDeskLayer), + Layer.provideMerge(testLexiconLayer), + Layer.provideMerge( + ServerSettingsModule.ServerSettingsService.layerTest({ + circeDefaultModelSelection: { + instanceId: codexProvider.instanceId, + model: "gpt-5.6-sol", + options: [{ id: "reasoningEffort", value: "high" }], + }, + }), + ), + Layer.provideMerge( + Layer.mock(ProviderRegistry)({ getProviders: Effect.succeed([codexProvider]) }), + ), + Layer.provideMerge( + Layer.mock(ProjectionSnapshotQuery)({ + getProjectShellById: (id) => Effect.succeed(Option.fromUndefinedOr(projectCatalog.find((p) => p.id === id))), + getThreadDetailById: () => Effect.succeed(Option.none()), + getShellSnapshot: () => + Effect.succeed({ + snapshotSequence: 1, + projects: projectCatalog, + threads: [], + updatedAt: "2026-08-12T00:02:00.000Z", + }), + }), + ), + Layer.provideMerge( + Layer.mock(OrchestrationEngineService)({ + dispatch: (command) => + Effect.sync(() => { + commands.push(command); + return { sequence: commands.length }; + }), + readEvents: () => Stream.empty, + streamDomainEvents: Stream.empty, + latestSequence: Effect.succeed(0), + }), + ), + Layer.provideMerge(testCryptoLayer), + ); + + return Effect.gen(function* () { + const controller = yield* CirceController; + const result = yield* controller.execute({ + sessionId, + executionNodeId, + utterance: "What is new today?", + projectId: project.id, + requestMetadata: { + requestId: "request-conversation", + inputMode: "voice", + sourceUtterance: "What is new today?", + }, + }); + expect(result).toMatchObject({ status: "started", projectId: managed.id }); + const create = commands.find((command) => command.type === "thread.create"); + expect(create).toMatchObject({ projectId: managed.id }); + expect(create?.type === "thread.create" ? create.title : "").toBe("What is new today"); + const marker = commands.find( + (command) => + command.type === "thread.activity.append" && + command.activity.kind === "circe.task.created", + ); + expect(marker).toMatchObject({ + activity: { payload: { flow: "conversation", objective: "What is new today?" } }, + }); + const turn = commands.find((command) => command.type === "thread.turn.start"); + const turnText = turn?.type === "thread.turn.start" ? turn.message.text : ""; + // The transcript stays exactly what the user said. Provider guidance + // (fetch live data, etc.) belongs in the conversation project's + // instructions, never in the visible message. + expect(turnText).toBe("What is new today?"); + }).pipe(Effect.provide(layer)); + }); + it.effect("refuses the lead fragment of a compound voice request", () => { const commands: Array = []; const layer = CirceControllerLive.pipe(