diff --git a/.papercuts/troubleshooting.md b/.papercuts/troubleshooting.md index 57ff5f79..96bd1729 100644 --- a/.papercuts/troubleshooting.md +++ b/.papercuts/troubleshooting.md @@ -379,3 +379,10 @@ owns; reopen the terminal before judging the final live state. - Zsh does not split scalar loop values by default; use explicit delimiters in pairwise merge probes so branch names are not accidentally concatenated. - Standalone green PRs still conflicted in shared settings, test registries, and UI fixtures. Assemble the exact combined stack and retain every feature's test registration before merging to main. + +## 2026-09-09 — Draft chat planning +- The checkout has no `.memory/` directory despite AGENTS.md referencing it; used current source and the plan index for project context. +- Native verification: no physical iOS device is online and local Java/Android SDK tools are unavailable. Run generic iOS build-for-testing and shared Remote contract suites; device XCTest and Android runtime acceptance remain unavailable locally. +- Draft lifecycle regression tests intercepted `chats:appendMessage` for first-send failures; updated that fault injection to the new atomic `chats:createWithFirstMessage` boundary. +- Empty-chat migration must distinguish header-only Pi journals (created by the old Todo snapshot read even before Send) from real private records; preserving every journal would leave ordinary abandoned chats behind. +- Completed Pi v3-to-v4 promotion adds lane/navigation records even for a header-only source. Empty cleanup must validate the real receipt, backup digest, and exact migration scaffolding rather than treating all promoted records as user history. diff --git a/docs/plans/README.md b/docs/plans/README.md index 54218fa9..a27456cd 100644 --- a/docs/plans/README.md +++ b/docs/plans/README.md @@ -6,6 +6,7 @@ This directory is the source of truth for Aiden's implementation plans. The engi | Plan | Status | Current state | | -------------------------------------------------------------------------------------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| [Draft Agent Chats](draft-agent-chats-plan.md) | Implemented | Transient desktop drafts, atomic first-message creation, and the one-time legacy empty-chat migration are implemented with Electron regression coverage; PR CI and merge pending. | | [Aiden Assistant](aiden-assistant-plan.md) | Partial | The dock, Markdown rendering, and confirmed provider-connection/model-pinned project-or-MCP automation creation/editing ship; settings tools and proactivity remain planned. | | [Aiden On The Go](aiden-on-the-go-plan.md) | Active | Version 0.1.0 build 22 is `VALID` and `IN_BETA_TESTING` for Internal Testers. Android matches iOS's app-icon switcher, Workspace hierarchy, warm scoped Bots/Usage/SSE lifecycle, Usage dashboard, image showcase/gallery, keyboard-safe elevated composer, and split Photo/File pickers. Both clients support native in-process dictation or bounded no-retention transcription by the paired Mac's local Parakeet model. iOS also ships progressive onboarding, bidirectional media, reliable mobile approvals, typed activity timelines, semantic haptics, and one-chat-per-Bot conversations with companion vision for text-only models. Physical iPad/manual permission-system-UI acceptance, privacy publication, final store assets, and external/public-release decisions remain open. | | [Unified Workspace Sidebar](unified-workspace-sidebar-plan.md) | Active | Phases 1 and 2 ship the unified workspace/chat outline plus a feature-negotiated, transcript-free paginated summary read on Electron, iOS/iPadOS, and Android; physical-device performance acceptance remains open. | diff --git a/docs/plans/draft-agent-chats-plan.md b/docs/plans/draft-agent-chats-plan.md new file mode 100644 index 00000000..f854570b --- /dev/null +++ b/docs/plans/draft-agent-chats-plan.md @@ -0,0 +1,33 @@ +# Draft agent chats + +Status: Implemented; pull-request CI and merge pending. + +Ordinary desktop workspace chats stay transient until the first user message is durably saved. Opening New Agent, entering an empty workspace, or opening a fresh worktree must not install an empty chat or sidebar history entry. Leaving an unsent draft discards it. A committed message remains saved even if generation fails. + +## Implementation + +- Use an explicit renderer draft record with a stable future chat ID. Keep it outside persisted chat lists and query caches. Model, workspace, title, Computer Use, attachments and composer options remain local until Send. +- Freeze first-send input and settings while saving. Commit a complete nonempty chat through an additive desktop IPC, preserving existing workspace authority, attachment quotas, skill leases, turn admission and durability recovery. +- Store a private first-message receipt. Matching retries confirm the same message; mismatched identifier reuse fails. Reconciliation prevents a lost or uncertain receipt from duplicating a chat. +- Promote the draft in place using the same chat/component identity. Publish one sidebar entry and start generation once. Navigation wins over late completion; release a pending turn if its conversation is no longer open. +- Preserve existing Bot, Assistant, scheduled, Telegram and runtime child-agent lifecycles. Remote HTTP contracts remain unchanged; inspect native consumers and validate applicable shared contracts. + +## Authorized legacy migration + +The owner authorized deleting existing empty chats in this migration. At startup, after recovery and before clients can write, snapshot readable zero-message chat identities and fingerprints before consulting workspace, schedule, artifact, or private-history eligibility stores. Only eligible ordinary chats in registered workspaces are deleted. Exclude Bot/Assistant/Telegram conversations, scheduled task and run references, unreadable records, and chats with staged artifacts or private execution history. A message object counts as history even if its text is empty. Validated header-only Pi journals created by the old Todo snapshot read, including completed empty v3-to-v4 promotions with matching backup/receipt and migration-only scaffolding, do not count as history; body records and uncertain journal state remain protected. + +Persist the exact candidate set and per-chat fingerprints before deletion, recheck each candidate and fingerprint, and remove via the existing cross-store deletion service. Checkpoint progress and mark completion once. Interrupted migration resumes only original candidates; subsequent launches never sweep newer empty chats created through unchanged remote APIs. Unknown/corrupt migration state fails closed. If the initial index enumeration or snapshot save fails, startup stops before admitting writers. Unreadable payloads and uncertain eligibility preserve the affected candidates and allow checkpointed completion; they never cause a later resweep. The final cross-store deletion assertion checks only the frozen chat fingerprint and zero-message state, without reopening already-deleted private stores. + +## Verification gates + +- Draft abandonment through all creation paths creates no chat payload or sidebar row. +- First send failure preserves composer payload; successful promotion persists one message and runs once. +- Duplicate submission, changed-payload retry, navigation during saving, workspace removal, document invalidation and post-install storage failure are covered. +- Migration deletes empty payload/index entries, preserves real history and special conversations, resumes safely, and runs once across restarts. +- Focused unit/contract tests, Electron draft/migration acceptance, TypeScript, lint and PR exact-head CI must pass before delivery. + +No new setup capability or onboarding asset is introduced; this corrects the existing new-chat lifecycle. + +## Local verification + +The Electron acceptance tests cover: draft abandonment and in-place promotion, one-time migration across restart, definite first-save failure and retry, a delayed receipt after navigation, and corruption-safe migration completion. Shared Remote tests and a generic iOS build-for-testing also pass. Native iOS devices were offline and the local Android toolchain was unavailable; no physical-device acceptance is claimed. The PR checks are the source of truth for final exact-head CI. diff --git a/main/handlers/chat-first-message-params.ts b/main/handlers/chat-first-message-params.ts new file mode 100644 index 00000000..4d7de532 --- /dev/null +++ b/main/handlers/chat-first-message-params.ts @@ -0,0 +1,61 @@ +import { parseChatAppend, type ParsedChatAppend } from "./chat-append-params.js"; +import { parseChatCreate } from "./chat-create-params.js"; +import { ASSISTANT_WORKSPACE_ID } from "../../renderer/shared/assistant.js"; + +const KEYS = new Set([ + "draftId", "workspaceId", "providerId", "model", "computerUseEnabled", "title", + "turnId", "message", "skillInvocation", +]); + +export interface ParsedChatFirstMessage extends ParsedChatAppend { + title?: string; + workspaceId: string; + computerUseEnabled: boolean; +} + +/** Project the complete untrusted envelope before retaining it across awaits. */ +export function parseChatFirstMessage(input: unknown): ParsedChatFirstMessage { + if (!input || typeof input !== "object" || Array.isArray(input)) { + throw new Error("Invalid first-message request."); + } + const record = input as Record; + for (const key in record) { + if (!Object.prototype.hasOwnProperty.call(record, key)) continue; + if (!KEYS.has(key)) throw new Error("Invalid first-message fields."); + } + // A dedicated UUID namespace prevents a draft from impersonating bot, + // Assistant, scheduled, or imported chat identities. + if (typeof record.draftId !== "string" || + !/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/iu.test(record.draftId)) { + throw new Error("Invalid draft identifier."); + } + const create = parseChatCreate({ + title: record.title, + workspaceId: record.workspaceId, + providerId: record.providerId, + model: record.model, + }); + if (create.workspaceId === ASSISTANT_WORKSPACE_ID) { + throw new Error("Assistant chats require the Assistant chat creation path."); + } + if (record.computerUseEnabled !== undefined && typeof record.computerUseEnabled !== "boolean") { + throw new Error("Invalid Computer Use setting."); + } + const append = parseChatAppend(record.draftId, record.message, { + turnId: record.turnId, + providerId: create.providerId, + model: create.model, + autoTitle: true, + skillInvocation: record.skillInvocation, + }); + if (!append.content.trim() && !append.attachments?.length) { + throw new Error("Add a message or attachment before sending."); + } + return { + ...append, + title: create.title, + workspaceId: create.workspaceId, + computerUseEnabled: record.computerUseEnabled === true, + retainedBytes: append.retainedBytes + Buffer.byteLength(create.workspaceId, "utf8") + Buffer.byteLength(create.title ?? "", "utf8") + 64, + }; +} diff --git a/main/handlers/chats.ts b/main/handlers/chats.ts index 197e6dcf..c7a9aac3 100644 --- a/main/handlers/chats.ts +++ b/main/handlers/chats.ts @@ -31,6 +31,8 @@ import { workspaceOperationRegistry, } from "../services/workspace-operation-registry.js"; import { parseChatAppend } from "./chat-append-params.js"; +import { parseChatFirstMessage } from "./chat-first-message-params.js"; +import { createFirstMessageCommitter } from "../services/chat-first-message-commit.js"; import { appendChatMessageWithReconciliation, isAppendReconciliationRequiredError, @@ -82,6 +84,53 @@ function artifactRecoveryMessage(unresolved: string, recoveredMessage: string): } export function registerChatHistoryHandlers(): void { + const commitFirstMessage = createFirstMessageCommitter({ + store: chatStore, + beginTurn: (chatId, turnId, ownerId) => llmClient.beginChatTurn(chatId, turnId, ownerId), + requiresReconciliation: (ownerId) => llmClient.requiresAppendReconciliation(ownerId), + markReconciliation: (ownerId) => llmClient.markAppendReconciliationRequired(ownerId), + clearReconciliation: (ownerId) => llmClient.clearAppendReconciliationRequired(ownerId), + admitWorkspace: (workspaceId, owner) => { + const mutation = workspaceMutationGate.admit(workspaceId); + try { + const operation = admitRendererOwnedWorkspaceOperation(workspaceOperationRegistry, owner, workspaceId); + const abort = () => operation.cancel(); + mutation.signal.addEventListener("abort", abort, { once: true }); + if (mutation.signal.aborted) abort(); + return { + signal: operation.signal, + cancel: operation.cancel, + release: () => { + mutation.signal.removeEventListener("abort", abort); + operation.release(); + mutation.release(); + }, + }; + } catch (error) { + mutation.release(); + throw error; + } + }, + workspaceExists: async (workspaceId) => Boolean(await configStore.getWorkspace(workspaceId)), + requireComputerUseReady: async (signal) => { + const status = await computerUseStatus.status({ signal }); + if (!status.ready) throw new Error(status.detail); + }, + resolveSkill: (workspaceId, invocationId) => skillRegistry.resolveFresh(workspaceId, invocationId), + }); + ipcMain.handle("chats:createWithFirstMessage", (event, input: unknown) => { + const parsed = parseChatFirstMessage(input); + const owner = rendererDocumentOwner(event, () => new Error("Chats require the active application document.")); + return commitFirstMessage(parsed, owner).then((chat) => { + ipcMain.broadcast("chats:metadata-updated", { + chatId: chat.id, + title: chat.title, + workspaceId: persistedChatWorkspaceId(chat.workspaceId), + updatedAt: chat.updatedAt, + }); + return chatForRenderer(chat); + }); + }); let chatCopyActive = false; let chatExportActive = false; ipcMain.handle("chats:activitySnapshot", () => chatActivityRegistry.snapshot()); diff --git a/main/index.ts b/main/index.ts index 239ac136..69f148d6 100644 --- a/main/index.ts +++ b/main/index.ts @@ -105,6 +105,8 @@ import { } from "./services/git.js"; import { reconcilePendingManagedWorktreeDeletions } from "./services/managed-worktree-deletion-recovery.js"; import { reconcilePendingChatDeletions } from "./services/chat-deletion-reconciliation.js"; +import { EmptyChatMigrationSnapshotError } from "./services/empty-chat-migration.js"; +import { migrateLegacyEmptyWorkspaceChats } from "./services/empty-chat-migration-main.js"; import { ensureUserDataDir } from "./services/data-store.js"; import { piCompactionSessionStore } from "./services/pi-compaction-session-store.js"; import { @@ -1874,6 +1876,16 @@ if (!ownsSingleInstanceLock) { error, ); } + // One-time legacy cleanup runs after recoverable artifacts and Bot identity + // restoration, but before renderers, schedules, or remote clients can write. + try { + await migrateLegacyEmptyWorkspaceChats(); + } catch (error) { + // Do not admit new writers after an uncertain initial snapshot write: + // otherwise a restart could mistake their new chats for legacy data. + if (error instanceof EmptyChatMigrationSnapshotError) throw error; + logger.warn("chat", "Empty-chat migration is incomplete; it will resume on the next launch.", error); + } const visibleChatIds = new Set( (await chatStore.list()).map((chat) => chat.id), ); diff --git a/main/services/chat-first-message-commit.test.ts b/main/services/chat-first-message-commit.test.ts new file mode 100644 index 00000000..026c3af8 --- /dev/null +++ b/main/services/chat-first-message-commit.test.ts @@ -0,0 +1,248 @@ +import assert from "node:assert/strict"; +import { randomUUID } from "node:crypto"; +import * as fs from "node:fs/promises"; +import * as os from "node:os"; +import * as path from "node:path"; +import test from "node:test"; +import { parseChatFirstMessage } from "../handlers/chat-first-message-params.js"; +import { createChatStore, type ChatStoreDurability } from "./chat-store-core.js"; +import { createFirstMessageCommitter } from "./chat-first-message-commit.js"; +import { ChatTurnAdmission, type ChatTurnAdmissionOptions } from "./chat-turn-admission.js"; +import { WorkspaceOperationRegistry, admitOwnedWorkspaceOperation } from "./workspace-operation-registry.js"; +import { isAppendReconciliationRequiredError } from "./chat-append-commit.js"; +import { chatForRenderer } from "./visible-chat-projection.js"; +import type { RegisteredSkill } from "./skill-registry.js"; + +let nextTurn = 0; +function request(overrides: Record = {}) { + return { + draftId: randomUUID(), workspaceId: "workspace", providerId: "openai", model: "test-model", + turnId: `turn-${++nextTurn}`, message: { role: "user", content: "Investigate the failing build" }, + ...overrides, + }; +} + +function documentOwner() { + let destroyed = false; + const listeners = new Set<() => void>(); + return { + documentId: randomUUID(), + isDestroyed: () => destroyed, + onInvalidated: (listener: () => void) => { + listeners.add(listener); + return () => { listeners.delete(listener); }; + }, + invalidate: () => { + destroyed = true; + for (const listener of [...listeners]) listener(); + }, + }; +} + +async function fixture(t: test.TestContext, durability: ChatStoreDurability = {}, admission: ChatTurnAdmissionOptions = {}) { + const directory = await fs.mkdtemp(path.join(os.tmpdir(), "aiden-first-message-")); + t.after(() => fs.rm(directory, { recursive: true, force: true })); + const store = createChatStore(async () => directory, undefined, durability); + const owner = documentOwner(); + t.after(() => owner.invalidate()); + const turns = new ChatTurnAdmission(admission); + const workspaces = new WorkspaceOperationRegistry(); + const deps: Parameters[0] = { + store, + beginTurn: (chatId, turnId, ownerId) => turns.tryBegin(chatId, turnId, ownerId, false), + requiresReconciliation: (ownerId) => turns.requiresAppendReconciliation(ownerId), + markReconciliation: (ownerId) => turns.markAppendReconciliationRequired(ownerId), + clearReconciliation: (ownerId) => turns.clearAppendReconciliationRequired(ownerId), + admitWorkspace: (workspaceId, document) => admitOwnedWorkspaceOperation(workspaces, document, workspaceId), + workspaceExists: async () => true, + requireComputerUseReady: async () => {}, + resolveSkill: async () => { throw new Error("Skill is unavailable"); }, + }; + return { directory, store, owner, turns, workspaces, deps }; +} + +test("first-message parser rejects empty, forged, and oversized requests", () => { + for (const value of [ + request({ draftId: "../chat" }), + request({ message: { role: "assistant", content: "forged" } }), + request({ message: { role: "user", content: " " } }), + request({ message: { role: "user", content: "x".repeat(1_048_577) } }), + request({ computerUseEnabled: "true" }), + request({ botId: "bot" }), + request({ workspaceId: "" }), + ]) assert.throws(() => parseChatFirstMessage(value)); + const original = request(); + const parsed = parseChatFirstMessage(original); + original.message.content = "changed after admission"; + assert.equal(parsed.content, "Investigate the failing build"); +}); + +test("first message publishes a nonempty chat, title, settings, and private durable receipt", async (t) => { + const f = await fixture(t); + const parsed = parseChatFirstMessage(request({ computerUseEnabled: true })); + assert.deepEqual(await f.store.list(), []); + const chat = await createFirstMessageCommitter(f.deps)(parsed, f.owner); + assert.equal(chat.id, parsed.chatId); + assert.equal(chat.title, parsed.content); + assert.equal(chat.messages.length, 1); + assert.equal(chat.messages[0]?.role, "user"); + assert.equal(chat.computerUseEnabled, true); + assert.equal((await f.store.list())[0]?.id, chat.id); + assert.equal(f.turns.owns(chat.id, parsed.turnId, f.owner.documentId), true); + assert.equal(chatForRenderer(chat)?.firstMessageCommit, undefined); + const restart = createChatStore(async () => f.directory); + assert.deepEqual((await restart.get(chat.id))?.firstMessageCommit, chat.firstMessageCommit); +}); + +test("attachment-only first send retains attachments and an explicit draft title", async (t) => { + const f = await fixture(t); + const attachment = { id: "text-1", name: "notes.txt", kind: "text", mimeType: "text/plain", size: 5, text: "notes" }; + const parsed = parseChatFirstMessage(request({ + title: "My investigation", + message: { role: "user", content: "", attachments: [attachment] }, + })); + const chat = await createFirstMessageCommitter(f.deps)(parsed, f.owner); + assert.equal(chat.title, "My investigation"); + assert.deepEqual(chat.messages[0]?.attachments, [attachment]); +}); + +test("first send prepares skill instructions for the exact durable user message", async (t) => { + const f = await fixture(t); + const skill: RegisteredSkill = { + stableId: "configured:review", invocationId: `sk1_${"a".repeat(43)}`, + toolKey: "skill_review", name: "Review", description: "Review changes", + instructions: "Inspect the diff carefully.", source: "configured", enabled: true, available: true, + }; + const parsed = parseChatFirstMessage(request({ + skillInvocation: { version: 1, invocationId: skill.invocationId, displayName: "Review", source: "configured" }, + })); + const chat = await createFirstMessageCommitter({ ...f.deps, resolveSkill: async () => skill })(parsed, f.owner); + assert.deepEqual(chat.messages[0]?.skill, { version: 1, name: "Review", source: "configured" }); + let preparedId: string | undefined; + assert.equal(f.turns.handoff(chat.id, parsed.turnId, f.owner.documentId, (prepared) => { preparedId = prepared?.userMessageId; }), true); + assert.equal(preparedId, chat.messages[0]?.id); +}); + +test("duplicate first sends share one operation and mismatched identities cannot overwrite", async (t) => { + const f = await fixture(t); + const commit = createFirstMessageCommitter(f.deps); + const input = request(); + const parsed = parseChatFirstMessage(input); + const first = commit(parsed, f.owner); + assert.equal(commit(parseChatFirstMessage(input), f.owner), first); + assert.throws(() => commit(parseChatFirstMessage({ ...input, message: { role: "user", content: "different" } }), f.owner)); + const chat = await first; + f.turns.releaseMatching(chat.id, parsed.turnId, f.owner.documentId); + const replay = await commit(parsed, f.owner); + assert.equal(replay.messages.length, 1); + assert.equal(replay.messages[0]?.id, chat.messages[0]?.id); + assert.equal(f.turns.owns(chat.id, parsed.turnId, f.owner.documentId), false, "replay must not authorize another generation"); + await assert.rejects(commit(parseChatFirstMessage({ ...input, turnId: "turn-reused" }), f.owner), /already been used/u); + assert.equal((await f.store.get(chat.id))?.messages.length, 1); +}); + +test("a regular or Bot chat identity cannot be claimed by a first-message retry", async (t) => { + const f = await fixture(t); + const input = request(); + await f.store.create({ id: input.draftId, botId: "bot", workspaceId: "managed" }); + await assert.rejects(createFirstMessageCommitter(f.deps)(parseChatFirstMessage(input), f.owner), /already been used/u); + assert.equal((await f.store.get(input.draftId))?.botId, "bot"); + assert.equal((await f.store.get(input.draftId))?.messages.length, 0); +}); + +test("completed first-send receipts retain quota until handoff or abandonment", async (t) => { + const f = await fixture(t, {}, { maxAppendTurns: 1 }); + const commit = createFirstMessageCommitter(f.deps); + const first = parseChatFirstMessage(request()); + const next = parseChatFirstMessage(request()); + const receipt = await commit(first, f.owner); + assert.equal(await commit(first, f.owner), receipt, "same-lease retry reuses the bounded receipt"); + assert.throws(() => commit(next, f.owner), /Too many messages/u); + assert.equal(f.turns.owns(next.chatId, next.turnId, f.owner.documentId), false); + assert.equal(f.turns.handoff(first.chatId, first.turnId, f.owner.documentId, () => {}), true); + const secondReceipt = await commit(next, f.owner); + assert.equal(secondReceipt.messages.length, 1); + const third = parseChatFirstMessage(request()); + assert.throws(() => commit(third, f.owner), /Too many messages/u); + f.turns.releaseMatching(next.chatId, next.turnId, f.owner.documentId); + assert.equal((await commit(third, f.owner)).messages.length, 1); +}); + +test("corrupt and mismatched UUID payload collisions are preserved byte-for-byte", async (t) => { + const f = await fixture(t); + const commit = createFirstMessageCommitter(f.deps); + for (const contents of ["{interrupted payload", JSON.stringify({ id: "another-id", title: "Keep me", messages: [], createdAt: 1, updatedAt: 1 })]) { + const input = parseChatFirstMessage(request()); + const file = path.join(f.directory, `${input.chatId}.json`); + await fs.writeFile(file, contents, "utf8"); + await assert.rejects(commit(input, f.owner), /unreadable existing chat/u); + assert.equal(await fs.readFile(file, "utf8"), contents); + assert.equal(f.turns.owns(input.chatId, input.turnId, f.owner.documentId), false); + } + assert.deepEqual(await f.store.list(), []); +}); + +test("missing workspace, unavailable Computer Use, and skill rejection leave no chat or lease", async (t) => { + const f = await fixture(t); + for (const kind of ["workspace", "computer", "skill"] as const) { + const parsed = parseChatFirstMessage(request({ computerUseEnabled: kind === "computer" })); + if (kind === "skill") parsed.skillReference = { version: 1, invocationId: "configured:test", displayName: "test", source: "configured" }; + const commit = createFirstMessageCommitter({ + ...f.deps, + workspaceExists: async () => kind !== "workspace", + requireComputerUseReady: async () => { throw new Error("Computer Use is unavailable"); }, + }); + await assert.rejects(commit(parsed, f.owner)); + assert.equal(f.turns.owns(parsed.chatId, parsed.turnId, f.owner.documentId), false); + } + assert.deepEqual(await f.store.list(), []); +}); + +test("owner invalidation before installation aborts without an empty chat", async (t) => { + const f = await fixture(t); + const commit = createFirstMessageCommitter({ ...f.deps, workspaceExists: async () => { f.owner.invalidate(); return true; } }); + await assert.rejects(commit(parseChatFirstMessage(request()), f.owner), /changed/u); + assert.deepEqual(await f.store.list(), []); +}); + +test("workspace cancellation drains first-message preparation before releasing its operation", async (t) => { + const f = await fixture(t); + let completeValidation = () => {}; + let validationStarted = () => {}; + const started = new Promise((resolve) => { validationStarted = resolve; }); + const waiting = new Promise((resolve) => { completeValidation = resolve; }); + const commit = createFirstMessageCommitter({ ...f.deps, workspaceExists: async () => { validationStarted(); await waiting; return true; } }); + const writing = commit(parseChatFirstMessage(request()), f.owner); + await started; + let drained = false; + const drain = f.workspaces.cancelAndSettle("workspace").then(() => { drained = true; }); + await Promise.resolve(); + assert.equal(drained, false); + completeValidation(); + await assert.rejects(writing, /changed/u); + await drain; + assert.equal(drained, true); + assert.deepEqual(await f.store.list(), []); +}); + +test("payload failure keeps draft retryable; index uncertainty blocks retry and recovers a nonempty chat", async (t) => { + for (const phase of ["chat-write", "index-write"] as const) { + const f = await fixture(t, { + syncFile: async (target) => { + if (target.endsWith(`.${phase}.tmp`)) throw new Error(`Injected ${phase} failure`); + const handle = await fs.open(target, "r"); + try { await handle.sync(); } finally { await handle.close(); } + }, + }); + const input = parseChatFirstMessage(request()); + let failure: unknown; + try { await createFirstMessageCommitter(f.deps)(input, f.owner); } catch (error) { failure = error; } + assert.ok(failure); + assert.equal(isAppendReconciliationRequiredError(failure), phase === "index-write"); + assert.equal(f.turns.requiresAppendReconciliation(f.owner.documentId), phase === "index-write"); + const restart = createChatStore(async () => f.directory); + const chat = await restart.get(input.chatId); + assert.equal(chat?.messages.length ?? 0, phase === "index-write" ? 1 : 0); + assert.equal((await restart.list()).length, phase === "index-write" ? 1 : 0); + } +}); diff --git a/main/services/chat-first-message-commit.ts b/main/services/chat-first-message-commit.ts new file mode 100644 index 00000000..0d0ad595 --- /dev/null +++ b/main/services/chat-first-message-commit.ts @@ -0,0 +1,161 @@ +import { createHash, randomUUID } from "node:crypto"; +import type { ParsedChatFirstMessage } from "../handlers/chat-first-message-params.js"; +import type { Chat } from "./types.js"; +import type { ChatTurnLease } from "./chat-turn-admission.js"; +import type { RendererDocumentOwner } from "./renderer-document-owner.js"; +import type { WorkspaceOperationAdmission } from "./workspace-operation-registry.js"; +import type { createChatStore } from "./chat-store-core.js"; +import { isChatCreateReconciliationRequiredError } from "./chat-store-core.js"; +import { AppendReconciliationRequiredError } from "./chat-append-commit.js"; +import { appendReconciliationFailureMessage } from "../../renderer/shared/chat-message-contract.js"; +import { commitSkillInvocationForAppend } from "./skill-invocation-turn.js"; +import type { RegisteredSkill } from "./skill-registry.js"; +import type { SkillProvenanceV1 } from "../../renderer/shared/slash-commands.js"; + +type Owner = Pick; + +interface Dependencies { + store: Pick, "get" | "createWithFirstMessage">; + beginTurn(chatId: string, turnId: string, ownerId: string): ChatTurnLease | null; + requiresReconciliation(ownerId: string): boolean; + markReconciliation(ownerId: string): void; + clearReconciliation(ownerId: string): void; + admitWorkspace(workspaceId: string, owner: Owner): WorkspaceOperationAdmission; + workspaceExists(workspaceId: string): Promise; + requireComputerUseReady(signal: AbortSignal): Promise; + resolveSkill(workspaceId: string, invocationId: string): Promise; +} + +/** Own the draft-to-durable handoff independently of renderer navigation. */ +export function createFirstMessageCommitter(deps: Dependencies) { + // Entries live only as long as the bounded turn lease. Their append payload + // reservation stays charged while the completed receipt is cached, so sends + // that never hand off to generation cannot bypass the memory budget. + const pending = new Map }>(); + + return (input: ParsedChatFirstMessage, owner: Owner): Promise => { + if (owner.isDestroyed()) throw new Error("The renderer document is no longer active."); + if (deps.requiresReconciliation(owner.documentId)) { + throw new Error(appendReconciliationFailureMessage("blocked")); + } + const fingerprint = createHash("sha256").update(JSON.stringify(input)).digest("hex"); + const inFlight = pending.get(input.chatId); + if (inFlight) { + if (inFlight.ownerId !== owner.documentId || inFlight.fingerprint !== fingerprint) { + throw new Error("This draft is already sending a different message."); + } + return inFlight.promise; + } + const turn = deps.beginTurn(input.chatId, input.turnId, owner.documentId); + if (!turn) throw new Error("Wait for the previous response to finish saving before sending again."); + turn.onReleased(owner.onInvalidated(turn.release)); + try { + turn.reserveAppendPayload(input.retainedBytes); + if (input.skillReference) turn.reserveSkillPreparation(); + } catch (error) { + turn.release(); + turn.settleAsyncWork(); + throw error; + } + const entry = { ownerId: owner.documentId, fingerprint, promise: undefined as unknown as Promise }; + pending.set(input.chatId, entry); + turn.onReleased(() => { + if (pending.get(input.chatId) === entry) pending.delete(input.chatId); + }); + entry.promise = (async () => { + let committed = false; + let workSettled = false; + let workspace: WorkspaceOperationAdmission | undefined; + try { + workspace = deps.admitWorkspace(input.workspaceId, owner); + const admittedWorkspace = workspace; + const abortTurn = () => turn.release(); + workspace.signal.addEventListener("abort", abortTurn, { once: true }); + turn.onReleased(() => { + admittedWorkspace.signal.removeEventListener("abort", abortTurn); + // Revocation must drain an in-flight store write before the owning + // workspace may be deleted or repurposed. + if (workSettled) { + admittedWorkspace.release(); + turn.settleAsyncWork(); + } + }); + if (workspace.signal.aborted) abortTurn(); + const isCurrent = () => turn.isActive() && !owner.isDestroyed() && !workspace!.signal.aborted; + const assertCurrent = () => { + if (!isCurrent()) throw new Error("The workspace or message turn changed before the chat could be saved."); + if (deps.requiresReconciliation(owner.documentId)) { + throw new Error(appendReconciliationFailureMessage("blocked")); + } + }; + const existing = await deps.store.get(input.chatId); + assertCurrent(); + if (existing) { + if (existing.firstMessageCommit?.turnId !== input.turnId || + existing.firstMessageCommit.fingerprint !== fingerprint) { + throw new Error("This draft identifier has already been used for a different message."); + } + // A replay after the original lease settled confirms delivery but + // must not authorize a second generation for the same user message. + return existing; + } + if (!(await deps.workspaceExists(input.workspaceId))) { + throw new Error("The selected workspace is no longer available."); + } + if (input.computerUseEnabled) await deps.requireComputerUseReady(workspace.signal); + assertCurrent(); + const userMessageId = randomUUID(); + const append = (prepared?: { provenance: SkillProvenanceV1 }) => deps.store.createWithFirstMessage({ + id: input.chatId, + title: input.title, + workspaceId: input.workspaceId, + providerId: input.providerId, + model: input.metaModel, + computerUseEnabled: input.computerUseEnabled, + turnId: input.turnId, + fingerprint, + message: { + id: userMessageId, + content: input.content, + model: input.messageModel, + attachments: input.attachments, + skill: prepared?.provenance, + }, + assertCurrent, + }); + const chat = input.skillReference + ? await commitSkillInvocationForAppend({ + invocationId: input.skillReference.invocationId, + role: "user", + content: input.content, + attachments: input.attachments, + workspaceId: input.workspaceId, + userMessageId, + }, { + resolveFresh: deps.resolveSkill, + isCurrent, + prepareLease: (prepared) => turn.prepareSkillInvocation(prepared), + append, + }) + : await append(); + committed = true; + return chat; + } catch (error) { + if (isChatCreateReconciliationRequiredError(error)) { + deps.markReconciliation(owner.documentId); + owner.onInvalidated(() => deps.clearReconciliation(owner.documentId)); + throw new AppendReconciliationRequiredError(); + } + throw error; + } finally { + workSettled = true; + if (!committed) turn.release(); + if (!turn.isActive()) workspace?.release(); + // A successful receipt remains in pending until handoff/abandon/expiry. + // Keep its payload capacity reserved for exactly that same lifetime. + turn.settleAsyncWork({ retainAppendPayloadUntilRelease: committed && turn.isActive() }); + } + })(); + return entry.promise; + }; +} diff --git a/main/services/chat-store-core.ts b/main/services/chat-store-core.ts index 282ee64e..7221dd35 100644 --- a/main/services/chat-store-core.ts +++ b/main/services/chat-store-core.ts @@ -732,6 +732,67 @@ export function createChatStore( return serialized(() => readChat(id)); }, + /** Install the first user message and sidebar metadata as one recoverable transaction. */ + async createWithFirstMessage(input: { + id: string; + title?: string; + workspaceId: string; + providerId?: string; + model?: string; + computerUseEnabled: boolean; + turnId: string; + fingerprint: string; + message: Pick; + assertCurrent: () => void; + }): Promise { + return serialized(async () => { + input.assertCurrent(); + const existing = await readChat(input.id); + if (existing) { + if (existing.firstMessageCommit?.turnId !== input.turnId || + existing.firstMessageCommit.fingerprint !== input.fingerprint) { + throw new Error("This draft identifier has already been used for a different message."); + } + return existing; + } + // readChat returns null for malformed payloads as well as missing + // files. Never replace an unreadable existing conversation on an ID + // collision; only a genuinely absent path may receive a new draft. + try { + await fs.lstat(await chatPath(input.id)); + throw new Error("This draft identifier belongs to an unreadable existing chat."); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + } + if (!input.message.content.trim() && !input.message.attachments?.length) { + throw new Error("Add a message or attachment before sending."); + } + const now = Date.now(); + const message: ChatMessage = { + id: input.message.id, + role: "user", + content: input.message.content, + model: input.message.model, + attachments: safeStoredAttachments(input.message.attachments), + skill: parseSkillProvenanceV1(input.message.skill), + createdAt: now, + }; + const chat: Chat = { + id: input.id, + workspaceId: input.workspaceId, + providerId: await resolveProviderId(input.providerId), + model: input.model, + computerUseEnabled: input.computerUseEnabled, + title: input.title?.trim() || deriveChatTitleSeed(message), + createdAt: now, + updatedAt: now, + messages: [message], + firstMessageCommit: { turnId: input.turnId, fingerprint: input.fingerprint }, + }; + return installNewChat(chat, input.assertCurrent); + }); + }, + async create(input: { id?: string; title?: string; diff --git a/main/services/chat-turn-admission.test.ts b/main/services/chat-turn-admission.test.ts index 3fc63e83..1b1306fa 100644 --- a/main/services/chat-turn-admission.test.ts +++ b/main/services/chat-turn-admission.test.ts @@ -90,6 +90,26 @@ test("generation handoff remains closed until append async work has settled", () assert.equal(admission.isAdmitted("chat-1"), false); }); +test("settled cached receipts remain byte-budgeted while allowing generation handoff", () => { + const admission = new ChatTurnAdmission({ maxAppendTurns: 2, maxAppendBytes: 100 }); + const first = admission.tryBegin("chat-1", "turn-1", "owner", false); + const second = admission.tryBegin("chat-2", "turn-2", "owner", false); + assert.ok(first && second); + first.reserveAppendPayload(100); + first.settleAsyncWork({ retainAppendPayloadUntilRelease: true }); + assert.equal(admission.owns("chat-1", "turn-1", "owner"), true); + assert.throws(() => second.reserveAppendPayload(1), /Too many messages/u); + assert.equal(admission.handoff("chat-1", "turn-1", "owner", () => {}), true); + second.reserveAppendPayload(100); + second.settleAsyncWork({ retainAppendPayloadUntilRelease: true }); + second.release(); + const third = admission.tryBegin("chat-3", "turn-3", "owner", false); + assert.ok(third); + third.reserveAppendPayload(100); + third.release(); + third.settleAsyncWork(); +}); + test("handed-off skill prompts remain globally charged until generation cleanup", () => { const admission = new ChatTurnAdmission({ maxPreparedTurns: 1, diff --git a/main/services/chat-turn-admission.ts b/main/services/chat-turn-admission.ts index f50e3ccf..fbdfe47b 100644 --- a/main/services/chat-turn-admission.ts +++ b/main/services/chat-turn-admission.ts @@ -26,8 +26,8 @@ export interface ChatTurnLease { reserveAppendPayload(bytes: number): void; reserveSkillPreparation(): void; prepareSkillInvocation(invocation: PreparedSkillInvocation): void; - /** Release payload accounting only after the append async frame settles. */ - settleAsyncWork(): void; + /** Mark work ready for handoff; cached first-send receipts stay charged until release. */ + settleAsyncWork(options?: { retainAppendPayloadUntilRelease?: boolean }): void; onReleased(cleanup: () => void): void; release(): void; } @@ -187,11 +187,11 @@ export class ChatTurnAdmission { this.skillBytes -= record.skillBytes - invocationBytes; record.skillBytes = invocationBytes; }, - settleAsyncWork: () => { + settleAsyncWork: (options) => { const record = this.turns.get(chatId); if (!record || record.lease !== lease || record.asyncSettled) return; record.asyncSettled = true; - if (record.appendSlotReserved) { + if (record.appendSlotReserved && !options?.retainAppendPayloadUntilRelease) { this.appendTurns -= 1; this.appendBytes -= record.appendBytes; record.appendSlotReserved = false; diff --git a/main/services/empty-chat-migration-main.ts b/main/services/empty-chat-migration-main.ts new file mode 100644 index 00000000..7cc786a1 --- /dev/null +++ b/main/services/empty-chat-migration-main.ts @@ -0,0 +1,92 @@ +import * as path from "node:path"; +import { app, logger } from "../platform.js"; +import { DataStore } from "./data-store.js"; +import { readRegularFile, decodeUtf8 } from "./regular-file-read.js"; +import { chatStore } from "./chat-store.js"; +import { chatApplicationService } from "./chat-application-service-main.js"; +import { configStore } from "./config-store.js"; +import { subagentRunStore } from "./subagents/subagent-run-store.js"; +import { piCompactionSessionStore } from "./pi-compaction-session-store.js"; +import { piRuntimeEffectStore } from "./pi-runtime-effect-store.js"; +import { displayImageArtifactStore } from "./display-image-artifact-store.js"; +import { generativeUiArtifactStore } from "./generative-ui-artifact-store.js"; +import { + isEmptyChatMigrationState, + isLegacyEmptyWorkspaceChat, + migrateEmptyWorkspaceChats, + type EmptyChatMigrationState, +} from "./empty-chat-migration.js"; + +const migration = new DataStore( + "empty-workspace-chats-migration-v1.json", + { version: 1, pending: null, complete: false }, + undefined, + { fileMode: 0o600, maxBytes: 4 * 1024 * 1024, isSafe: isEmptyChatMigrationState, + rejectCorruptWrite: true, rejectUnsafeWrite: true }, +); + +/** Protect even disabled tasks and retained historical runs, without normalizing away invalid records. */ +async function scheduledChatIds(): Promise> { + const ids = new Set(); + for (const file of ["schedules.json", "schedule-runs.json"]) { + let bytes: Buffer; + try { + bytes = await readRegularFile(path.join(app.getPath("userData"), file), 16 * 1024 * 1024); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") continue; + throw error; + } + const records: unknown = JSON.parse(decodeUtf8(bytes)); + if (!Array.isArray(records)) throw new Error("Scheduled chat references could not be read safely."); + for (const record of records) { + if (!record || typeof record !== "object" || Array.isArray(record)) { + throw new Error("Scheduled chat references could not be read safely."); + } + if (typeof record.chatId === "string") ids.add(record.chatId); + } + } + return ids; +} + +export async function migrateLegacyEmptyWorkspaceChats(): Promise { + const state = await migration.load(); + if (await migration.loadedFromCorruptFile() || await migration.loadedFromUnsafeFile()) { + throw new Error("Empty-chat migration receipt could not be read safely."); + } + if (state.complete) return 0; + // Defer unrelated stores until after the immutable candidate boundary is + // saved. If they are unreadable, preserve affected candidates and finish. + let context: Promise<{ workspaceIds: Set; reservedChatIds: Set }> | undefined; + const eligibilityContext = () => context ??= (async () => { + if (!displayImageArtifactStore.availability().available || !generativeUiArtifactStore.availability().available) { + throw new Error("Empty-chat cleanup requires readable artifact recovery stores."); + } + return { + workspaceIds: new Set((await configStore.listWorkspaces()).map((workspace) => workspace.id)), + reservedChatIds: await scheduledChatIds(), + }; + })(); + let reportedPreservation = false; + return migrateEmptyWorkspaceChats({ + load: async () => state, + save: async (next) => { await migration.update((current) => Object.assign(current, next)); }, + list: () => chatStore.list(), + get: (id) => chatStore.get(id), + onPreserved: (error) => { + if (reportedPreservation) return; + reportedPreservation = true; + logger.warn("chat", "Empty-chat cleanup preserved records whose private history or ownership could not be read safely.", error); + }, + eligible: async (chat) => { + const { workspaceIds, reservedChatIds } = await eligibilityContext(); + return isLegacyEmptyWorkspaceChat(chat, workspaceIds, reservedChatIds) && + !(await displayImageArtifactStore.hasPending(chat.id)) && + !(await generativeUiArtifactStore.hasPending(chat.id)) && + (await subagentRunStore.listByChat(chat.id)).length === 0 && + (await piRuntimeEffectStore.listOperationsByChat(chat.id)).length === 0 && + (await piRuntimeEffectStore.listEffectsByChat(chat.id)).length === 0 && + !(await piCompactionSessionStore.hasChatHistory(chat.id)); + }, + remove: (id, assertCurrent) => chatApplicationService.remove(id, { assertCurrent }), + }); +} diff --git a/main/services/empty-chat-migration.test.ts b/main/services/empty-chat-migration.test.ts new file mode 100644 index 00000000..e547c6e5 --- /dev/null +++ b/main/services/empty-chat-migration.test.ts @@ -0,0 +1,175 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import * as fs from "node:fs/promises"; +import * as os from "node:os"; +import * as path from "node:path"; +import { createChatStore } from "./chat-store-core.js"; +import { isEmptyChatMigrationState, isLegacyEmptyWorkspaceChat, migrateEmptyWorkspaceChats, type EmptyChatMigrationState } from "./empty-chat-migration.js"; +import type { Chat } from "./types.js"; + +const empty = (id: string, overrides: Partial = {}): Chat => ({ + id, title: "New chat", workspaceId: "default", createdAt: 1, updatedAt: 1, messages: [], ...overrides, +}); +const eligible = async (chat: Chat) => isLegacyEmptyWorkspaceChat(chat, new Set(["default", "worktree"]), new Set(["scheduled"])); +function harness(chats: Chat[]) { + const records = new Map(chats.map((chat) => [chat.id, chat])); + let state: EmptyChatMigrationState = { version: 1, pending: null, complete: false }; + const removed: string[] = []; + const deps = { + load: async () => structuredClone(state), + save: async (next: EmptyChatMigrationState) => { state = structuredClone(next); }, + list: async () => [...records.values()], + get: async (id: string) => records.get(id) ?? null, + eligible, + remove: async (id: string, assertCurrent: (chat: Chat) => Promise) => { + await assertCurrent(records.get(id)!); + records.delete(id); + removed.push(id); + }, + }; + return { deps, records, removed, state: () => state }; +} + +test("only zero-message ordinary workspace chats qualify, regardless of title", async () => { + const h = harness([ + empty("blank"), empty("renamed", { title: "User title" }), empty("legacy", { workspaceId: undefined }), + empty("tree", { workspaceId: "worktree" }), + empty("bot", { botId: "bot-1" }), empty("assistant", { workspaceId: "assistant" }), + empty("telegram-123-default"), empty("assistant-live:123", { workspaceId: "assistant" }), + empty("unknown", { workspaceId: "missing" }), empty("scheduled"), + empty("message", { messages: [{ id: "m", role: "user", content: "", createdAt: 1 }] }), + empty("assistant-message", { messages: [{ id: "m", role: "assistant", content: "hello", createdAt: 1 }] }), + ]); + assert.equal(await migrateEmptyWorkspaceChats(h.deps), 4); + assert.deepEqual(h.removed, ["blank", "renamed", "legacy", "tree"]); + assert.deepEqual(h.state(), { version: 1, pending: [], complete: true }); + h.records.set("newer-empty", empty("newer-empty")); + assert.equal(await migrateEmptyWorkspaceChats(h.deps), 0); + assert.ok(h.records.has("newer-empty")); +}); + +test("partial deletion resumes only original snapshot, rechecking nonempty chats", async () => { + const h = harness([empty("one"), empty("two"), empty("three")]); + const remove = h.deps.remove; + let failed = false; + h.deps.remove = async (id, check) => { + if (id === "two" && !failed) { failed = true; throw new Error("disk unavailable"); } + await remove(id, check); + }; + await assert.rejects(migrateEmptyWorkspaceChats(h.deps), /disk unavailable/u); + assert.deepEqual(h.state().pending?.map((candidate) => candidate.id), ["two", "three"]); + h.records.set("newer", empty("newer")); + h.records.get("three")!.messages.push({ id: "m", role: "user", content: "sent", createdAt: 2 }); + assert.equal(await migrateEmptyWorkspaceChats(h.deps), 1); + assert.deepEqual(h.removed, ["one", "two"]); + assert.ok(h.records.has("newer")); + assert.ok(h.records.has("three")); +}); + +test("failure saving initial snapshot deletes nothing; failure recording deletion is retryable", async () => { + const h = harness([empty("one")]); + const save = h.deps.save; + h.deps.save = async () => { throw new Error("receipt unavailable"); }; + await assert.rejects(migrateEmptyWorkspaceChats(h.deps), /snapshot could not be saved/u); + assert.deepEqual(h.removed, []); + let writes = 0; + h.deps.save = async (next) => { if (++writes === 2) throw new Error("receipt unavailable"); await save(next); }; + await assert.rejects(migrateEmptyWorkspaceChats(h.deps), /receipt unavailable/u); + assert.deepEqual(h.removed, ["one"]); + h.deps.save = save; + assert.equal(await migrateEmptyWorkspaceChats(h.deps), 0); + assert.equal(h.state().complete, true); +}); + +test("corrupt and incomplete migration receipts fail closed", () => { + for (const state of [null, {}, { version: 2, pending: [], complete: true }, + { version: 1, pending: ["../chat"], complete: false }, + { version: 1, pending: ["a", "a"], complete: false }, + { version: 1, pending: ["a"], complete: true }, + { version: 1, pending: null, complete: true }]) { + assert.equal(isEmptyChatMigrationState(state), false); + } +}); + +test("real chat files and summary rows are deleted while corrupt and populated payloads survive restart", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "aiden-empty-migration-")); + try { + const store = createChatStore(async () => root); + await store.create({ id: "empty", workspaceId: "default" }); + await store.create({ id: "corrupt", workspaceId: "default" }); + await store.create({ id: "populated", workspaceId: "default" }); + await store.appendMessage("populated", { role: "user", content: "hello" }); + await fs.writeFile(path.join(root, "corrupt.json"), "{broken"); + const h = harness([]); + assert.equal(await migrateEmptyWorkspaceChats({ ...h.deps, + list: () => store.list(), get: (id) => store.get(id), + remove: (id, check) => store.remove(id, async (chat) => { if (chat) await check(chat); }), + }), 1); + await assert.rejects(fs.stat(path.join(root, "empty.json")), { code: "ENOENT" }); + assert.equal(await fs.readFile(path.join(root, "corrupt.json"), "utf8"), "{broken"); + const reopened = createChatStore(async () => root); + assert.equal((await reopened.get("populated"))?.messages[0]?.content, "hello"); + assert.ok(!(await reopened.listSummaryMetadata()).some((chat) => chat.id === "empty")); + } finally { await fs.rm(root, { recursive: true, force: true }); } +}); + + +test("resume preserves a replacement chat reusing a candidate ID", async () => { + const h = harness([empty("reused")]); + const remove = h.deps.remove; + h.deps.remove = async () => { throw new Error("interrupted"); }; + await assert.rejects(migrateEmptyWorkspaceChats(h.deps), /interrupted/u); + h.records.set("reused", empty("reused", { createdAt: 2, updatedAt: 2 })); + h.deps.remove = remove; + assert.equal(await migrateEmptyWorkspaceChats(h.deps), 0); + assert.ok(h.records.has("reused")); + assert.equal(h.state().complete, true); +}); + +test("eligibility errors occur after snapshot and preserve candidates without a later resweep", async () => { + const h = harness([empty("uncertain"), empty("healthy")]); + h.deps.eligible = async (chat) => { + assert.notEqual(h.state().pending, null, "freeze identities before reading eligibility stores"); + if (chat.id === "uncertain") throw new Error("unreadable private journal"); + return true; + }; + const warnings: unknown[] = []; + assert.equal(await migrateEmptyWorkspaceChats({ ...h.deps, onPreserved: (error) => warnings.push(error) }), 1); + assert.equal(warnings.length, 1); + assert.ok(h.records.has("uncertain")); + assert.equal(h.state().complete, true); + h.records.set("later", empty("later")); + h.deps.eligible = async () => true; + assert.equal(await migrateEmptyWorkspaceChats(h.deps), 0); + assert.ok(h.records.has("later")); +}); + +test("initial enumeration errors use the startup-blocking snapshot error", async () => { + const h = harness([empty("one")]); + h.deps.list = async () => { throw new Error("unreadable index"); }; + await assert.rejects(migrateEmptyWorkspaceChats(h.deps), { name: "EmptyChatMigrationSnapshotError" }); + assert.equal(h.state().pending, null); + assert.deepEqual(h.removed, []); +}); + +test("unreadable payloads are preserved without preventing healthy empty cleanup", async () => { + const h = harness([empty("unreadable"), empty("healthy")]); + const get = h.deps.get; + h.deps.get = async (id) => { if (id === "unreadable") throw new Error("unreadable payload"); return get(id); }; + assert.equal(await migrateEmptyWorkspaceChats(h.deps), 1); + assert.ok(h.records.has("unreadable")); + assert.equal(h.state().complete, true); +}); + +test("cross-store final assertion does not reopen already-deleted private stores", async () => { + const h = harness([empty("one")]); + let eligibilityReads = 0; + h.deps.eligible = async () => { assert.equal(++eligibilityReads, 1); return true; }; + const remove = h.deps.remove; + h.deps.remove = async (id, check) => { + await check(h.records.get(id)!); // pre-delete fence + await remove(id, check); // application service rechecks after side-store cleanup + }; + assert.equal(await migrateEmptyWorkspaceChats(h.deps), 1); + assert.equal(eligibilityReads, 1); +}); diff --git a/main/services/empty-chat-migration.ts b/main/services/empty-chat-migration.ts new file mode 100644 index 00000000..9b75f004 --- /dev/null +++ b/main/services/empty-chat-migration.ts @@ -0,0 +1,112 @@ +import { createHash } from "node:crypto"; +import { ASSISTANT_WORKSPACE_ID } from "../../renderer/shared/assistant.js"; +import { persistedChatWorkspaceId } from "../../renderer/shared/chat-workspace.js"; +import type { Chat, ChatMeta } from "./types.js"; + +export class EmptyChatMigrationSnapshotError extends Error { + readonly cause: unknown; + constructor(cause: unknown) { + super("The empty-chat migration snapshot could not be saved safely."); + this.cause = cause; + this.name = "EmptyChatMigrationSnapshotError"; + } +} + +export interface EmptyChatCandidate { id: string; fingerprint: string } +const fingerprint = (chat: Chat) => createHash("sha256").update(JSON.stringify(chat)).digest("hex"); + +export interface EmptyChatMigrationState { + version: 1; + /** null means the legacy snapshot has not been taken yet. */ + pending: EmptyChatCandidate[] | null; + complete: boolean; +} + +export function isEmptyChatMigrationState(value: unknown): value is EmptyChatMigrationState { + if (!value || typeof value !== "object" || Array.isArray(value)) return false; + const state = value as Partial; + return state.version === 1 && typeof state.complete === "boolean" && + (state.pending === null || (Array.isArray(state.pending) && + state.pending.every((candidate) => candidate && typeof candidate === "object" && + typeof candidate.id === "string" && /^[A-Za-z0-9._:-]{1,160}$/u.test(candidate.id) && + typeof candidate.fingerprint === "string" && /^[a-f0-9]{64}$/u.test(candidate.fingerprint)) && + new Set(state.pending.map((candidate) => candidate.id)).size === state.pending.length)) && + (!state.complete || (Array.isArray(state.pending) && state.pending.length === 0)); +} + +export function isLegacyEmptyWorkspaceChat( + chat: Chat, + workspaceIds: ReadonlySet, + reservedChatIds: ReadonlySet, +): boolean { + const workspaceId = persistedChatWorkspaceId(chat.workspaceId); + return chat.messages.length === 0 && chat.botId === undefined && + workspaceId !== ASSISTANT_WORKSPACE_ID && workspaceIds.has(workspaceId) && + !chat.id.startsWith("telegram-") && !chat.id.startsWith("assistant-") && + !reservedChatIds.has(chat.id); +} + +/** + * Startup-only, before any renderer or remote writer starts. Persist the exact + * legacy candidates before deleting anything: retries must never sweep chats + * created by newer clients after the first migration attempt. + */ +export async function migrateEmptyWorkspaceChats(deps: { + load(): Promise; + save(state: EmptyChatMigrationState): Promise; + list(): Promise; + get(id: string): Promise; + eligible(chat: Chat): Promise; + remove(id: string, assertCurrent: (chat: Chat) => Promise): Promise; + onPreserved?(error: unknown): void; +}): Promise { + let state = await deps.load(); + if (!isEmptyChatMigrationState(state)) throw new Error("Invalid empty-chat migration state."); + if (state.complete) return 0; + const readCandidate = async (id: string) => { + try { return await deps.get(id); } + catch (error) { deps.onPreserved?.(error); return null; } + }; + if (state.pending === null) { + try { + const candidates: EmptyChatCandidate[] = []; + const seen = new Set(); + for (const meta of await deps.list()) { + if (seen.has(meta.id)) continue; + seen.add(meta.id); + const chat = await readCandidate(meta.id); + // Snapshot before consulting any external eligibility store. Unknown + // payloads survive; any stored message is already outside cleanup scope. + if (chat?.messages.length === 0) candidates.push({ id: chat.id, fingerprint: fingerprint(chat) }); + } + state = { version: 1, pending: candidates, complete: false }; + await deps.save(state); + } catch (error) { + throw new EmptyChatMigrationSnapshotError(error); + } + } + let removed = 0; + for (const candidate of [...state.pending!]) { + const { id } = candidate; + const chat = await readCandidate(id); + let eligible = false; + if (chat && fingerprint(chat) === candidate.fingerprint) { + try { eligible = await deps.eligible(chat); } + catch (error) { deps.onPreserved?.(error); } + } + if (eligible) { + await deps.remove(id, async (current) => { + // Cross-store removal calls this again after deleting private stores; + // do not reopen those stores or re-evaluate eligibility at that point. + if (current.messages.length !== 0 || fingerprint(current) !== candidate.fingerprint) throw new Error("The empty chat changed during migration."); + }); + removed++; + } + // Eligibility uncertainty preserves this candidate permanently. It cannot + // keep a pre-snapshot retry window open or sweep newer chats on restart. + state = { version: 1, pending: state.pending!.filter((entry) => entry.id !== id), complete: false }; + await deps.save(state); + } + await deps.save({ version: 1, pending: [], complete: true }); + return removed; +} diff --git a/main/services/pi-compaction-core.test.ts b/main/services/pi-compaction-core.test.ts index 19916785..033599b9 100644 --- a/main/services/pi-compaction-core.test.ts +++ b/main/services/pi-compaction-core.test.ts @@ -1,5 +1,5 @@ import assert from "node:assert/strict"; -import { appendFile, mkdtemp, mkdir, readFile, rm, stat, unlink, writeFile } from "node:fs/promises"; +import { appendFile, mkdtemp, mkdir, readFile, readdir, rm, stat, symlink, unlink, writeFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import test from "node:test"; @@ -26,6 +26,7 @@ import { } from "./pi-compaction-session-store.js"; import type { ChatMessage } from "./types.js"; import { createPiSessionPort, type PiSessionPort } from "./pi-session-port.js"; +import { migratePiSessionJournal } from "./pi-session-migration.js"; const ZERO_COST = { input: 0, @@ -1206,6 +1207,121 @@ test("durable journals are private and delete with their chat", async (t) => { await assert.rejects(stat(metadata.path), { code: "ENOENT" }); }); +test("private history inspection preserves indexed, current, legacy, and quarantined journals without opening sessions", async (t) => { + const root = await mkdtemp(path.join(os.tmpdir(), "aiden-pi-history-")); + t.after(() => rm(root, { recursive: true, force: true })); + const store = new PiCompactionSessionStore({ + root: async () => { throw new Error("inspection must not initialize the repository"); }, + readOnlyRoot: async () => root, + }); + assert.equal(await store.hasChatHistory("empty"), false); + assert.deepEqual(await readdir(root), []); + await writeFile(path.join(root, "aiden-journal-index.json"), JSON.stringify({ version: 1, chats: { indexed: [path.join(root, "old.jsonl.corrupt-1")] } })); + assert.equal(await store.hasChatHistory("indexed"), true); + await unlink(path.join(root, "aiden-journal-index.json")); + for (const [chatId, file, version] of [ + ["current", "current.jsonl", 4], + ["legacy", "legacy.jsonl", 3], + ["backup", "legacy.jsonl.v3-backup", 3], + ["quarantine", "current.jsonl.corrupt-1", 4], + ] as const) { + const header = JSON.stringify({ + ...(version === 4 ? { kind: "header" } : { type: "session" }), version, id: chatId, + metadata: { kind: "aiden-chat-compaction-v1", chatId }, + }); + await writeFile(path.join(root, file), `${header}\n{"private":"untouched"}\n`); + assert.equal(await store.hasChatHistory(chatId), true); + assert.equal(await readFile(path.join(root, file), "utf8"), `${header}\n{"private":"untouched"}\n`); + } + assert.equal(await store.hasChatHistory("unrelated-empty"), false); + assert.equal((await readdir(root)).length, 4); + const header = JSON.stringify({ kind: "header", version: 4, id: "multibyte-body", metadata: { kind: "aiden-chat-compaction-v1", chatId: "multibyte-body" } }); + const contents = `${header}\n${"x".repeat(65_536 - Buffer.byteLength(header) - 2)}€`; + await writeFile(path.join(root, "multibyte.jsonl"), contents); + assert.equal(await store.hasChatHistory("multibyte-body"), true, "decode only the complete header when the scan ends inside a multibyte body character"); + assert.equal(await store.hasChatHistory("still-unrelated"), false); + store.quarantineChatUntilRecovered("recovering", new Promise(() => {})); + assert.equal(await store.hasChatHistory("recovering"), true); +}); + +test("private history inspection fails closed on corrupt index, malformed journals, and unreadable paths", async (t) => { + const root = await mkdtemp(path.join(os.tmpdir(), "aiden-pi-history-corrupt-")); + t.after(() => rm(root, { recursive: true, force: true })); + const store = new PiCompactionSessionStore({ root: async () => root }); + const indexPath = path.join(root, "aiden-journal-index.json"); + for (const contents of ["{broken", JSON.stringify({ version: 1, chats: { other: "not-an-array" } })]) { + await writeFile(indexPath, contents); + await assert.rejects(store.hasChatHistory("empty")); + assert.equal(await readFile(indexPath, "utf8"), contents); + } + await unlink(indexPath); + const journal = path.join(root, "unknown.jsonl.corrupt-1"); + await writeFile(journal, "{broken private history"); + await assert.rejects(store.hasChatHistory("empty")); + assert.equal(await readFile(journal, "utf8"), "{broken private history"); + await unlink(journal); + await symlink(path.join(root, "missing-private-file"), journal); + await assert.rejects(store.hasChatHistory("empty"), /symbolic link/u); + const absentRoot = path.join(root, "absent"); + assert.equal(await new PiCompactionSessionStore({ root: async () => absentRoot }).hasChatHistory("empty"), false); + await assert.rejects(stat(absentRoot), { code: "ENOENT" }); +}); + +test("header-only sessions created by old empty-chat reads are empty, but private body entries preserve them", async (t) => { + const root = await mkdtemp(path.join(os.tmpdir(), "aiden-pi-header-only-")); + t.after(() => rm(root, { recursive: true, force: true })); + const original = new PiCompactionSessionStore({ root: async () => root }); + const session = await original.openChat("empty-chat-read"); + assert.equal(await original.hasChatHistory("empty-chat-read"), true, "an active session stays protected"); + const inspect = () => new PiCompactionSessionStore({ root: async () => root }).hasChatHistory("empty-chat-read"); + assert.equal(await inspect(), false, "a persisted index plus valid header alone is not private history"); + const metadata = await session.getMetadata(); + await appendFile(metadata.path, " \t\n\r\n"); + assert.equal(await inspect(), false, "trailing JSON whitespace is still empty"); + await session.appendMessage(user("private retained work")); + assert.equal(await inspect(), true); + assert.match(await readFile(metadata.path, "utf8"), /private retained work/u); + await unlink(metadata.path); + assert.equal(await inspect(), true, "an unresolved indexed path stays protected"); +}); + +test("completed migration of an empty legacy journal is disposable with indexed backup and receipt", async (t) => { + const root = await mkdtemp(path.join(os.tmpdir(), "aiden-pi-empty-migration-")); + t.after(() => rm(root, { recursive: true, force: true })); + const chatId = "empty-promoted-chat"; + const promoted = path.join(root, "empty.jsonl"); + const header = { + type: "session", version: 3, id: chatId, timestamp: "2026-08-31T12:00:00.000Z", cwd: root, + metadata: { kind: "aiden-chat-compaction-v1", chatId }, + }; + await writeFile(promoted, `${JSON.stringify(header)}\n`); + const migration = await migratePiSessionJournal(promoted, chatId); + assert.equal(migration.receipt.counts.entries, 0); + const inspect = () => new PiCompactionSessionStore({ root: async () => root }).hasChatHistory(chatId); + assert.equal(await inspect(), false, "discovery recognizes actual empty migration scaffolding"); + const index = JSON.stringify({ version: 1, chats: { [chatId]: [promoted, migration.receipt.backupPath, migration.receiptPath] } }); + await writeFile(path.join(root, "aiden-journal-index.json"), index); + assert.equal(await inspect(), false, "indexed migration artifacts alone are not conversation history"); + const originalPromoted = await readFile(promoted, "utf8"); + const originalBackup = await readFile(migration.receipt.backupPath, "utf8"); + const originalReceipt = await readFile(migration.receiptPath, "utf8"); + await appendFile(promoted, '{"private":"preserve additional content"}\n'); + assert.equal(await inspect(), true); + await writeFile(promoted, originalPromoted); + await appendFile(migration.receipt.backupPath, '{"private":"preserve backup history"}\n'); + assert.equal(await inspect(), true); + await writeFile(migration.receipt.backupPath, originalBackup); + await writeFile(migration.receiptPath, JSON.stringify({ ...migration.receipt, validation: "failed" })); + assert.equal(await inspect(), true); + await writeFile(migration.receiptPath, originalReceipt); + await unlink(migration.receipt.backupPath); + assert.equal(await inspect(), true, "incomplete artifact sets remain protected"); + await writeFile(migration.receipt.backupPath, originalBackup); + await writeFile(migration.receiptPath, "{invalid receipt"); + await assert.rejects(inspect()); + assert.equal(await readFile(promoted, "utf8"), originalPromoted); +}); + test("opening a chat promotes its legacy v3 journal before current repository discovery", async (t) => { const temporary = await mkdtemp(path.join(os.tmpdir(), "aiden-pi-v3-open-")); t.after(() => rm(temporary, { recursive: true, force: true })); diff --git a/main/services/pi-compaction-session-store.ts b/main/services/pi-compaction-session-store.ts index 587c167a..85c5d678 100644 --- a/main/services/pi-compaction-session-store.ts +++ b/main/services/pi-compaction-session-store.ts @@ -1,5 +1,7 @@ -import { randomUUID } from "node:crypto"; -import { chmod, open, readFile, readdir, rename, stat, unlink, writeFile } from "node:fs/promises"; +import { createHash, randomUUID } from "node:crypto"; +import { isDeepStrictEqual } from "node:util"; +import { constants as fsConstants } from "node:fs"; +import { chmod, lstat, open, readFile, readdir, rename, stat, unlink, writeFile } from "node:fs/promises"; import path from "node:path"; import { type AgentMessage, @@ -14,7 +16,8 @@ import { createCurrentPiSessionRepository, type PiSessionRepositoryPort, } from "./pi-session-repository-port.js"; -import { migratePiSessionJournal } from "./pi-session-migration.js"; +import { migratePiSessionJournal, parsePiSessionMigrationReceipt } from "./pi-session-migration.js"; +import { decodeUtf8, readRegularFile } from "./regular-file-read.js"; import { decodeLegacyPiSession } from "./pi-legacy-session.js"; import { piUpgradeBehaviorEnabledAtStartup, @@ -165,6 +168,86 @@ function currentJournalHeaderOwnsChat(headerLine: string, chatId: string): boole } } +/** Read a stable descriptor without decoding or retaining the private journal body. */ +async function inspectJournalHistory(filePath: string): Promise<{ chatId: string; hasBody: boolean }> { + const handle = await open(filePath, fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW | fsConstants.O_NONBLOCK); + try { + if (!(await handle.stat()).isFile()) throw new Error("Pi journal history is not a regular file."); + const buffer = Buffer.alloc(JOURNAL_HEADER_SCAN_BYTES); + const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0); + const bytes = buffer.subarray(0, bytesRead); + const newline = bytes.indexOf(10); + const prefix = decodeUtf8(newline >= 0 ? bytes.subarray(0, newline) : bytes); + const header: unknown = JSON.parse(prefix); + const id = header && typeof header === "object" && !Array.isArray(header) + ? (header as { id?: unknown }).id : undefined; + if (typeof id !== "string" || !(journalHeaderOwnsChat(prefix, id) || currentJournalHeaderOwnsChat(prefix, id))) { + throw new Error("Pi journal history contains an unreadable session header."); + } + // Even a malformed body is private state worth preserving. Only an exact + // validated header followed by ASCII JSON whitespace counts as empty. + const hasContent = (chunk: Buffer) => chunk.some((byte) => byte !== 9 && byte !== 10 && byte !== 13 && byte !== 32); + if (newline >= 0 && hasContent(bytes.subarray(newline + 1))) return { chatId: id, hasBody: true }; + let position = bytesRead; + while (true) { + const next = await handle.read(buffer, 0, buffer.length, position); + if (next.bytesRead === 0) return { chatId: id, hasBody: false }; + if (hasContent(buffer.subarray(0, next.bytesRead))) return { chatId: id, hasBody: true }; + position += next.bytesRead; + } + } finally { + await handle.close(); + } +} + +/** Only the exact scaffolding produced by a completed migration of an empty v3 journal is disposable. */ +async function isCompletedEmptyMigration(promotedPath: string, chatId: string, root: string): Promise { + const backupPath = `${promotedPath}.v3-backup`; + const receiptPath = `${promotedPath}.migration-v1.json`; + if (!path.resolve(promotedPath).startsWith(`${path.resolve(root)}${path.sep}`)) return false; + try { + const receipt = parsePiSessionMigrationReceipt(JSON.parse(decodeUtf8(await readRegularFile(receiptPath, JOURNAL_HEADER_SCAN_BYTES)))); + if (receipt.chatId !== chatId || receipt.validation !== "passed" || + path.resolve(receipt.promotedPath) !== path.resolve(promotedPath) || + path.resolve(receipt.backupPath) !== path.resolve(backupPath) || + Object.values(receipt.counts).some((count) => count !== 0)) return false; + const backup = await readRegularFile(backupPath, JOURNAL_HEADER_SCAN_BYTES); + if (createHash("sha256").update(backup).digest("hex") !== receipt.sourceSha256) return false; + const backupText = decodeUtf8(backup); + if (backupText.split("\n").filter((line) => line.trim()).length !== 1) return false; + const legacy = decodeLegacyPiSession(backupText); + if (legacy.header.id !== chatId || legacy.entries.length !== 0 || legacy.tornFinalLine || + !journalHeaderOwnsChat(backupText.split("\n", 1)[0]!, chatId)) return false; + const current = decodeUtf8(await readRegularFile(promotedPath, JOURNAL_HEADER_SCAN_BYTES)); + const records: unknown[] = current.split("\n").filter((line) => line.trim()).map((line) => JSON.parse(line)); + if (records.length !== 4 || !current.endsWith("\n")) return false; + const [header, lane, startedValue, finishedValue] = records; + if (!startedValue || typeof startedValue !== "object" || !finishedValue || typeof finishedValue !== "object") return false; + const started = startedValue as Record; + const finished = finishedValue as Record; + const uuid = "[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}"; + if (typeof started.id !== "string" || !new RegExp(`^migration-${uuid}$`, "u").test(started.id) || + typeof finished.id !== "string" || !new RegExp(`^migration-finished-${uuid}$`, "u").test(finished.id) || + !Number.isSafeInteger(started.timestamp) || Number(started.timestamp) < 0 || + !Number.isSafeInteger(finished.timestamp) || Number(finished.timestamp) < Number(started.timestamp)) return false; + return isDeepStrictEqual(header, { + kind: "header", version: 4, id: chatId, createdAt: Date.parse(legacy.header.timestamp), cwd: legacy.header.cwd, + ...(legacy.header.parentSession === undefined ? {} : { legacyParentSessionPath: legacy.header.parentSession }), + ...(legacy.header.metadata === undefined ? {} : { metadata: legacy.header.metadata }), + }) && isDeepStrictEqual(lane, { kind: "lane", seq: 1, lane: "main", leafId: null }) && + isDeepStrictEqual(started, { + kind: "record", seq: 2, id: started.id, lane: "main", type: "operation_started", timestamp: started.timestamp, + sourceLeafId: null, intent: { kind: "navigation", targetId: null, summarize: false }, + }) && isDeepStrictEqual(finished, { + kind: "record", seq: 3, id: finished.id, lane: "main", type: "operation_finished", timestamp: finished.timestamp, + runId: started.id, outcome: "completed", + }); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return false; + throw error; + } +} + async function fileExists(filePath: string): Promise { try { return (await stat(filePath)).isFile(); @@ -529,6 +612,8 @@ export async function recordPiEffectRecoveryBoundary( export interface PiCompactionSessionStoreOptions { root: () => Promise; + /** Resolve the location without creating it when inspecting cleanup eligibility. */ + readOnlyRoot?: () => Promise; rollout?: { load(): Promise; development: boolean; @@ -749,6 +834,84 @@ export class PiCompactionSessionStore { } } + /** Conservatively inspect private history without creating, repairing, or migrating a session. */ + async hasChatHistory(chatId: string): Promise { + if (!SAFE_SESSION_ID.test(chatId)) { + throw new Error("Invalid chat identity for the Pi compaction journal."); + } + if (this.sessions.has(chatId) || this.opening.has(chatId) || this.quarantined.has(chatId)) return true; + await this.indexMutation; + const root = await (this.options.readOnlyRoot ?? this.options.root)(); + try { + if (!(await lstat(root)).isDirectory()) throw new Error("Pi journal history root is not a directory."); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return false; + throw error; + } + let indexBytes: Buffer | undefined; + try { + indexBytes = await readRegularFile(path.join(root, JOURNAL_INDEX_FILE), 16 * 1024 * 1024); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + } + if (indexBytes) { + const index: unknown = JSON.parse(decodeUtf8(indexBytes)); + if (!index || typeof index !== "object" || Array.isArray(index) || + (index as Partial).version !== 1) { + throw new Error("Pi journal history index could not be read safely."); + } + const chats = (index as Partial).chats; + if (!chats || typeof chats !== "object" || Array.isArray(chats) || + Object.values(chats).some((paths) => !Array.isArray(paths) || paths.some((file) => typeof file !== "string" || !file))) { + throw new Error("Pi journal history index could not be read safely."); + } + if (Object.prototype.hasOwnProperty.call(chats, chatId)) { + for (const indexedPath of chats[chatId]!) { + const candidate = path.resolve(indexedPath); + if (!candidate.startsWith(`${path.resolve(root)}${path.sep}`)) { + throw new Error("Pi journal history index escaped its private storage root."); + } + if (candidate.endsWith(".jsonl.v3-backup") || candidate.endsWith(".jsonl.migration-v1.json")) { + const promoted = candidate.replace(/\.(?:v3-backup|migration-v1\.json)$/u, ""); + if (!(await isCompletedEmptyMigration(promoted, chatId, root))) return true; + continue; + } + if (!candidate.endsWith(".jsonl")) return true; + try { + const history = await inspectJournalHistory(candidate); + if (history.chatId !== chatId || (history.hasBody && !(await isCompletedEmptyMigration(candidate, chatId, root)))) return true; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return true; + throw error; + } + } + } + } + const directories = [root]; + while (directories.length > 0) { + const directory = directories.pop()!; + for (const entry of await readdir(directory, { withFileTypes: true })) { + const candidate = path.join(directory, entry.name); + if (entry.isSymbolicLink()) throw new Error("Pi journal history contains an uninspectable symbolic link."); + if (entry.isDirectory()) { directories.push(candidate); continue; } + if (!entry.name.includes(".jsonl")) continue; + if (entry.name.endsWith(".migration-v1.json")) { + const receipt = parsePiSessionMigrationReceipt(JSON.parse(decodeUtf8(await readRegularFile(candidate, 65_536)))); + if (receipt.chatId === chatId && !(await isCompletedEmptyMigration(candidate.slice(0, -".migration-v1.json".length), chatId, root))) return true; + continue; + } + const history = await inspectJournalHistory(candidate); + if (history.chatId === chatId) { + if (entry.name.endsWith(".jsonl.v3-backup")) { + if (!(await isCompletedEmptyMigration(candidate.slice(0, -".v3-backup".length), chatId, root))) return true; + } else if (!entry.name.endsWith(".jsonl") || + (history.hasBody && !(await isCompletedEmptyMigration(candidate, chatId, root)))) return true; + } + } + } + return false; + } + async deleteChat(chatId: string): Promise { if (!SAFE_SESSION_ID.test(chatId)) { throw new Error("Invalid chat identity for the Pi compaction journal."); @@ -814,6 +977,7 @@ export class PiCompactionSessionStore { export const piCompactionSessionStore = new PiCompactionSessionStore({ root: () => ensureUserDataDir("pi-compaction-sessions"), + readOnlyRoot: async () => path.join((await import("../platform.js")).app.getPath("userData"), "pi-compaction-sessions"), rollout: { load: () => piUpgradeRolloutStore.load(), development: isDevelopmentRuntime(process.env, Boolean(process.versions.electron)), diff --git a/main/services/types.ts b/main/services/types.ts index ce2ec9ad..b7fa89fc 100644 --- a/main/services/types.ts +++ b/main/services/types.ts @@ -321,6 +321,8 @@ export interface ChatMeta { } export interface Chat extends ChatMeta { + /** Main-owned receipt for an idempotent first-message commit; never renderer-authored. */ + firstMessageCommit?: { turnId: string; fingerprint: string }; /** Per-chat opt-in. The global Computer Use beta setting remains authoritative. */ computerUseEnabled?: boolean; messages: ChatMessage[]; diff --git a/main/services/visible-chat-projection.ts b/main/services/visible-chat-projection.ts index a81f5373..68a11d88 100644 --- a/main/services/visible-chat-projection.ts +++ b/main/services/visible-chat-projection.ts @@ -37,8 +37,9 @@ export interface VisibleChatMessage { /** Strip private provider protocol before a Chat crosses into the renderer. */ export function chatForRenderer(chat: Chat | null): Chat | null { if (!chat) return null; + const { firstMessageCommit: _privateFirstMessageCommit, ...visibleChat } = chat; return { - ...chat, + ...visibleChat, messages: chat.messages.map((message) => { const { pi: _privatePiProtocol, ...visible } = message; return { diff --git a/package.json b/package.json index c461348d..ffabacb8 100644 --- a/package.json +++ b/package.json @@ -53,7 +53,7 @@ "test:aiden-remote": "tsx --test main/handlers/aiden-remote.test.ts main/services/aiden-remote-chat-summaries.test.ts main/services/aiden-remote-approved-roots.test.ts main/services/aiden-remote-revocation.test.ts main/services/aiden-remote-bot-files.test.ts main/services/aiden-remote-bots.test.ts main/services/aiden-remote-chat-http.test.ts main/services/aiden-remote-chats.test.ts main/services/aiden-remote-files.test.ts main/services/aiden-remote-git.test.ts main/services/aiden-remote-models.test.ts main/services/aiden-remote-protocol.test.ts main/services/aiden-remote-opaque-handles.test.ts main/services/aiden-remote-operation-contract.test.ts main/services/aiden-remote-pairing.test.ts main/services/aiden-remote-ports.test.ts main/services/aiden-remote-router.test.ts main/services/aiden-remote-schedules.test.ts main/services/aiden-remote-service.test.ts main/services/aiden-remote-state.test.ts main/services/aiden-remote-streams.test.ts main/services/aiden-remote-tailscale-route.test.ts main/services/aiden-remote-tailscale.test.ts main/services/aiden-remote-tls-identity.test.ts main/services/aiden-remote-workspace-browser.test.ts main/services/aiden-remote-workspace-http.test.ts main/services/aiden-remote-workspaces.test.ts renderer/components/remote-connection-popover.test.tsx renderer/components/settings/remote-access-settings.test.tsx renderer/lib/remote-approval.test.ts renderer/lib/remote-connection-status.test.ts renderer/lib/remote-pairing-lifecycle.test.ts renderer/lib/settings-section.test.ts && node --test scripts/aiden-remote-lan-transport-spike.test.mjs", "test:aiden-remote-chat-summaries": "tsx --test main/services/aiden-remote-chat-summaries.test.ts", "test:memory-policy": "tsx --test main/services/memory-policy.test.ts main/services/aiden-remote-memory-settings.test.ts renderer/components/settings/memory-settings.test.tsx", - "test:aiden-service-boundary": "tsx --test main/services/chat-application-service.test.ts main/services/chat-generation-owner.test.ts main/services/workspace-application-service.test.ts main/services/workspace-environment-application-service.test.ts main/services/workspace-worktree-application-service.test.ts main/services/scheduled-task-application-service.test.ts main/services/bot-application-service.test.ts", + "test:aiden-service-boundary": "tsx --test main/services/empty-chat-migration.test.ts main/services/chat-first-message-commit.test.ts main/services/chat-application-service.test.ts main/services/chat-generation-owner.test.ts main/services/workspace-application-service.test.ts main/services/workspace-environment-application-service.test.ts main/services/workspace-worktree-application-service.test.ts main/services/scheduled-task-application-service.test.ts main/services/bot-application-service.test.ts", "ios:asc-monitor": "node scripts/ios-asc-monitor.mjs", "ios:activitykit-process-proof": "node scripts/ios-live-activity-process-proof.mjs", "test:ios-release": "ruby ios/ci/select_testflight_build_number_test.rb && node --test scripts/check-ios-testflight-policy.test.mjs scripts/check-ios-app-store-metadata.test.mjs scripts/check-ios-shipping-target.test.mjs scripts/ios-asc-monitor.test.mjs scripts/ios-live-activity-process-proof.test.mjs", @@ -63,14 +63,14 @@ "test:model-insights": "tsx --test main/services/openrouter-benchmark.test.ts main/services/models.test.ts main/services/provider-model-info-core.test.ts main/handlers/ipc-contract.test.ts", "test:model-pad": "tsx --test renderer/components/settings/model-pad-settings.test.tsx renderer/lib/google-provider-migration.test.ts renderer/lib/model-pad-layout.test.ts renderer/lib/model-picker-data.test.ts renderer/lib/pi-provider-display.test.ts", "test:command-system": "tsx --test main/services/native-menu-command-contract.test.ts main/services/renderer-readiness-core.test.ts main/services/shortcut-registration-core.test.ts main/services/shortcut-transaction-core.test.ts main/services/superseding-task-core.test.ts renderer/lib/appearance-intent.test.ts renderer/lib/command-palette-contract.test.ts renderer/lib/command-palette-recent.test.ts renderer/lib/command-system-core.test.ts renderer/lib/shortcut-settings-contract.test.ts renderer/lib/use-model-selection.test.ts renderer/shared/keybindings.test.ts", - "test:slash-commands": "tsx --test main/services/generation-initialization-terminal.test.ts main/handlers/attachments.contract.test.ts main/handlers/chat.parse.test.ts main/handlers/chat-create-params.test.ts main/handlers/chat-session-params.test.ts main/handlers/worktree-create-params.test.ts main/services/chat-workspace-authority.test.ts main/handlers/chats.append.contract.test.ts main/services/attachment-contract.test.ts main/services/attachments.test.ts main/services/chat-append-commit.test.ts main/services/chat-export.test.ts main/services/chat-message-contract.test.ts main/services/chat-session-copy.test.ts main/services/chat-store-core.test.ts main/services/chat-turn-admission.test.ts main/services/generation-messages.test.ts main/services/provider-auth-flow-core.test.ts main/services/provider-list-core.test.ts main/services/provider-artwork-core.test.ts main/services/scheduled-chat-creation.test.ts main/services/skill-invocation-flow.integration.test.ts main/services/skill-invocation-turn.test.ts main/services/skill-registry-core.test.ts main/services/skill-registry.test.ts main/services/skill-tools.test.ts main/services/skills-discovery.test.ts renderer/components/assistant/assistant-ui.test.tsx renderer/components/assistant/use-assistant-chat.test.ts renderer/components/composer.test.tsx renderer/lib/chat-message-queue.test.ts renderer/components/message-bubble.test.tsx renderer/lib/chat-copy-view.test.ts renderer/lib/codex-provider-cache.test.ts renderer/lib/computer-use-control.test.ts renderer/lib/ipc-stream.test.ts renderer/lib/skill-catalog-workspace.test.ts renderer/lib/slash-command-actions.test.ts renderer/lib/slash-command-core.test.ts renderer/lib/slash-command-performance.test.ts renderer/main/chat-transition.test.tsx renderer/shared/attachment-contract.test.ts renderer/shared/chat-message-contract.test.ts renderer/shared/slash-commands.test.ts renderer/components/settings/skills-settings.test.tsx", + "test:slash-commands": "tsx --test main/services/generation-initialization-terminal.test.ts main/handlers/attachments.contract.test.ts main/handlers/chat.parse.test.ts main/handlers/chat-create-params.test.ts main/handlers/chat-session-params.test.ts main/handlers/worktree-create-params.test.ts main/services/chat-workspace-authority.test.ts main/handlers/chats.append.contract.test.ts main/services/attachment-contract.test.ts main/services/attachments.test.ts main/services/chat-append-commit.test.ts main/services/chat-export.test.ts main/services/chat-message-contract.test.ts main/services/chat-session-copy.test.ts main/services/chat-store-core.test.ts main/services/empty-chat-migration.test.ts main/services/chat-first-message-commit.test.ts main/services/chat-turn-admission.test.ts main/services/generation-messages.test.ts main/services/provider-auth-flow-core.test.ts main/services/provider-list-core.test.ts main/services/provider-artwork-core.test.ts main/services/scheduled-chat-creation.test.ts main/services/skill-invocation-flow.integration.test.ts main/services/skill-invocation-turn.test.ts main/services/skill-registry-core.test.ts main/services/skill-registry.test.ts main/services/skill-tools.test.ts main/services/skills-discovery.test.ts renderer/components/assistant/assistant-ui.test.tsx renderer/components/assistant/use-assistant-chat.test.ts renderer/components/composer.test.tsx renderer/lib/chat-message-queue.test.ts renderer/lib/chat-draft.test.ts renderer/components/message-bubble.test.tsx renderer/lib/chat-copy-view.test.ts renderer/lib/codex-provider-cache.test.ts renderer/lib/computer-use-control.test.ts renderer/lib/ipc-stream.test.ts renderer/lib/skill-catalog-workspace.test.ts renderer/lib/slash-command-actions.test.ts renderer/lib/slash-command-core.test.ts renderer/lib/slash-command-performance.test.ts renderer/main/chat-transition.test.tsx renderer/shared/attachment-contract.test.ts renderer/shared/chat-message-contract.test.ts renderer/shared/slash-commands.test.ts renderer/components/settings/skills-settings.test.tsx", "test:display-image": "tsx --test main/services/display-image-artifact-store.test.ts main/services/display-image-extension.test.ts main/services/generation-timeline.test.ts renderer/components/message-bubble.test.tsx renderer/lib/ipc-stream.test.ts", "test:ask-user-question": "tsx --test renderer/shared/ask-user-question.test.ts main/services/ask-user-question-coordinator.test.ts main/services/ask-user-question-extension.test.ts renderer/components/ask-user-question-composer.test.ts", "test:todo": "tsx --test main/services/rpiv-todo/*.test.ts renderer/shared/todo.test.ts renderer/components/todo-panel.test.tsx main/services/generation-timeline.test.ts main/handlers/ipc-contract.test.ts renderer/lib/ipc-stream.test.ts", "test:btw": "tsx --test main/services/rpiv-btw/*.test.ts renderer/shared/btw.test.ts renderer/components/btw-card.test.tsx", "test:advisor": "tsx --test renderer/shared/advisor.test.ts main/services/advisor-context.test.ts main/services/advisor-attempt-store.test.ts main/services/advisor-runtime.test.ts main/services/advisor-integration.test.ts", "test:generative-ui": "tsx --test main/services/generative-ui-html.test.ts main/services/generative-ui-extension.test.ts main/services/generative-ui-artifact-store.test.ts main/services/generative-ui-host-libraries.test.ts main/services/generative-ui-protocol.test.ts renderer/shared/chat-artifacts.test.ts renderer/shared/generative-ui.test.ts && node --test scripts/vendor-generative-ui-libs.test.mjs && playwright test --config=playwright.generative-ui.config.ts --fail-on-flaky-tests", - "test:google-provider": "tsx --test main/services/anthropic-provider.test.ts main/services/google-provider.test.ts main/services/model-runtime-core.test.ts main/services/models.test.ts main/services/provider-config-migration-core.test.ts main/services/chat-store-core.test.ts main/services/schedule-store.test.ts renderer/lib/google-provider-migration.test.ts", + "test:google-provider": "tsx --test main/services/anthropic-provider.test.ts main/services/google-provider.test.ts main/services/model-runtime-core.test.ts main/services/models.test.ts main/services/provider-config-migration-core.test.ts main/services/chat-store-core.test.ts main/services/empty-chat-migration.test.ts main/services/chat-first-message-commit.test.ts main/services/schedule-store.test.ts renderer/lib/google-provider-migration.test.ts", "test:config-recovery": "tsx --test main/services/mcp-oauth-client-metadata.test.ts main/services/secret-map-core.test.ts main/services/provider-credential-rotation-core.test.ts main/services/legacy-pi-credential-migration-core.test.ts main/services/mcp-credential-cleanup-core.test.ts main/services/mcp-oauth-operation.test.ts main/services/mcp-oauth-session.test.ts main/services/mcp-oauth-store-core.test.ts", "pretest:subagents": "npm run build:worktree-remover && npm run build:subagent-run-store && node scripts/build-subagent-run-store.mjs --test && npm run build:subagent-file-mutator && node scripts/build-subagent-file-mutator.mjs --test && npm run build:subagent-shell-runner && node scripts/build-subagent-shell-runner.mjs --test && npm run test:subagents:inventory && npm run test:subagents:workspace-write && npm run test:subagents:phase5a && npm run test:subagents:phase5b && npm run test:subagents:phase5c && npm run test:subagents:phase5d && npm run test:subagents:phase5e && npm run test:subagents:phase6a && npm run test:subagents:phase6b && npm run test:subagents:phase7a && npm run test:subagents:soak:contracts", "test:subagents:inventory": "tsx --test main/services/subagents/subagent-mcp-inventory-core.test.ts main/services/subagents/subagent-inference-process-core.test.ts", @@ -109,8 +109,8 @@ "test:voice": "tsx --test main/services/transcription-core.test.ts main/services/gemini-live-transcription-core.test.ts main/services/dictation-coordinator.test.ts main/services/dictation-paste.test.ts main/services/parakeet-protocol.test.ts main/services/parakeet-process-core.test.ts main/services/parakeet-transcription-lane.test.ts renderer/shared/voice-models.test.ts renderer/shared/gemini-usage-scope.test.ts renderer/components/settings/gemini-voice-setup.test.tsx renderer/lib/accessibility-permission-core.test.ts renderer/lib/accessibility-refresh.test.ts renderer/lib/dictation-operation-gate.test.ts renderer/lib/gemini-recorded-retry.test.ts renderer/lib/live-pcm-capture.test.ts renderer/lib/voice-recorder-core.test.ts renderer/lib/wav-audio.test.ts", "test:diagnostics": "tsx --test main/services/diagnostics-contract.test.ts main/services/diagnostic-health.test.ts main/services/diagnostic-journal.test.ts main/services/diagnostic-support.test.ts main/services/dev-log.test.ts main/services/process-diagnostics.test.ts main/services/renderer-crash-recovery.test.ts main/services/renderer-diagnostic-rate.test.ts main/services/subagents/subagent-runtime-diagnostics.test.ts renderer/components/settings/diagnostics-settings.test.tsx && node --test scripts/diagnostic-policy.test.mjs", "diagnostics:failure-receipt": "node scripts/write-diagnostic-failure-receipt.mjs", - "test": "tsx --test main/services/mcp-oauth-client-metadata.test.ts main/handlers/assistant-parse.test.ts main/services/assistant/system-prompt.test.ts main/services/chat-activity-core.test.ts main/services/chat-generation-start.test.ts main/services/chat-title-policy.test.ts main/services/chat-title-routing.test.ts main/services/chat-store-core.test.ts main/services/codex-provider.test.ts main/services/coding-tools.test.ts main/services/config-store-core.test.ts main/services/computer-use/computer-use-foundation.test.ts main/services/computer-use/computer-use-tool.test.ts main/services/computer-use/generation-gate.test.ts main/services/computer-use/safety.test.ts main/services/computer-use/settings-core.test.ts main/services/computer-use/status-core.test.ts main/services/data-store.test.ts main/services/data-store.resilience.test.ts main/services/terminal.test.ts main/services/terminal-history.test.ts main/services/aiden-config-dir.test.ts main/services/portable-config-core.test.ts main/services/portable-config-core.roundtrip.test.ts main/services/portable-config-watch-core.test.ts main/services/secret-map-core.test.ts main/services/dev-log.test.ts main/services/process-diagnostics.test.ts main/services/dictation-coordinator.test.ts main/services/dictation-paste.test.ts main/services/dictation-cleanup-core.test.ts main/services/dictation-hotkey.test.ts main/services/dictation-key-state.test.ts main/services/dictation-keycode.test.ts main/services/parakeet-protocol.test.ts main/services/parakeet-process-core.test.ts main/services/foundation-models-connection.test.ts main/services/foundation-models-connection-core.test.ts main/services/gemini-context-cache.test.ts main/services/generation-bound-connection-cache.test.ts main/services/generation-context.test.ts main/services/generation-messages.test.ts main/services/generation-runtime.test.ts main/services/external-editors.test.ts main/services/git.test.ts main/services/model-runtime-core.test.ts main/services/models.test.ts main/services/mcp-oauth-operation.test.ts main/services/mcp-oauth-session.test.ts main/services/mcp-presets.test.ts renderer/shared/plugin-catalog.test.ts main/services/pi-credential-store-core.test.ts main/services/pi-provider-contract.test.ts main/services/profile-share-core.test.ts main/services/profile-share-files.test.ts main/services/profile.test.ts main/services/provider-auth-flow-core.test.ts main/services/provider-auth-owner.test.ts main/services/provider-key-policy.test.ts main/services/provider-list-core.test.ts main/services/provider-artwork-core.test.ts main/services/quit-barrier.test.ts main/services/scratch-workspace.test.ts main/services/skills-discovery.test.ts main/services/tool-approval.test.ts main/services/local-runtime-status.test.ts main/services/usage-store-core.test.ts main/services/workspace-files.test.ts main/windows/pill-window-security.test.ts renderer/components/assistant/use-assistant-chat.test.ts renderer/components/assistant/assistant-ui.test.tsx renderer/components/activity-feed.test.tsx renderer/components/environment-subagents-contract.test.ts renderer/components/subagents-panel.test.tsx renderer/components/chat-sidebar.test.tsx renderer/components/composer.test.tsx renderer/lib/chat-message-queue.test.ts renderer/main/chat-transition.test.tsx renderer/components/usage/profile-share-card.test.tsx renderer/lib/accessibility-refresh.test.ts renderer/lib/agent-activity.test.ts renderer/lib/chat-activity.test.ts renderer/lib/assistant-dock.test.ts renderer/lib/assistant-motion-contract.test.ts renderer/lib/dialog-motion-contract.test.ts renderer/lib/scrollbar-gutter-contract.test.ts renderer/lib/text-entry-focus-contract.test.ts renderer/lib/chat-deletion-cache.test.ts renderer/lib/chat-terminal-sync.test.ts renderer/lib/ipc-stream.test.ts renderer/lib/chat-title-reveal.test.ts renderer/lib/codex-auth-session.test.ts renderer/lib/codex-provider-cache.test.ts renderer/lib/composer-placeholder.test.ts renderer/lib/computer-use-notice.test.ts renderer/lib/dictation-operation-gate.test.ts renderer/lib/media-recorder-stop.test.ts renderer/lib/dictation-vad.test.ts renderer/lib/dictation-sounds.test.ts renderer/lib/editor-preference.test.ts renderer/lib/environment-panel-layout.test.ts renderer/lib/subagent-view-state.test.ts renderer/lib/truncate-path.test.ts renderer/lib/mcp-preset-state.test.ts renderer/components/settings/mcp-settings.test.tsx renderer/components/settings/mcp-preset-icons.test.ts renderer/lib/model-display.test.ts renderer/lib/model-picker-data.test.ts renderer/lib/profile-share-data.test.ts renderer/lib/sidebar-chat-shortcuts.test.ts renderer/lib/usage-profile-data.test.ts renderer/shared/appearance.test.ts renderer/components/interface-polish.test.tsx renderer/shared/provider-deployment.test.ts main/handlers/ipc-contract.test.ts main/handlers/chat.parse.test.ts main/handlers/voice-codec.test.ts main/handlers/phase2-parse.test.ts scripts/apple-developer-tools.test.mjs scripts/check-macos-release.test.mjs scripts/computer-use-packaged-acceptance.test.mjs scripts/configure-electron-fuses.test.mjs scripts/model-snapshot-core.test.mjs scripts/prepare-macos-dev-runtime.test.mjs scripts/prepare-macos-package-output.test.mjs scripts/run-macos-distribution.test.mjs scripts/sign-macos.test.mjs scripts/hide-dmg-support-files.test.mjs scripts/update-model-capabilities.test.mjs scripts/vendor-cua-driver.test.mjs scripts/verify-macos-package.test.mjs && npm run test:telegram && npm run test:worktree-remover:native && npm run test:computer-use:native && npm run test:settings-design", - "test:coverage": "tsx --test --experimental-test-coverage main/services/mcp-oauth-client-metadata.test.ts main/handlers/assistant-parse.test.ts main/services/assistant/system-prompt.test.ts main/services/chat-activity-core.test.ts main/services/chat-generation-start.test.ts main/services/chat-title-policy.test.ts main/services/chat-title-routing.test.ts main/services/chat-store-core.test.ts main/services/codex-provider.test.ts main/services/coding-tools.test.ts main/services/config-store-core.test.ts main/services/computer-use/computer-use-foundation.test.ts main/services/computer-use/computer-use-tool.test.ts main/services/computer-use/generation-gate.test.ts main/services/computer-use/safety.test.ts main/services/computer-use/settings-core.test.ts main/services/computer-use/status-core.test.ts main/services/data-store.test.ts main/services/data-store.resilience.test.ts main/services/terminal.test.ts main/services/terminal-history.test.ts main/services/aiden-config-dir.test.ts main/services/portable-config-core.test.ts main/services/portable-config-core.roundtrip.test.ts main/services/portable-config-watch-core.test.ts main/services/dev-log.test.ts main/services/process-diagnostics.test.ts main/services/dictation-coordinator.test.ts main/services/dictation-paste.test.ts main/services/dictation-cleanup-core.test.ts main/services/dictation-hotkey.test.ts main/services/dictation-key-state.test.ts main/services/dictation-keycode.test.ts main/services/parakeet-protocol.test.ts main/services/parakeet-process-core.test.ts main/services/foundation-models-connection.test.ts main/services/foundation-models-connection-core.test.ts main/services/generation-bound-connection-cache.test.ts main/services/generation-context.test.ts main/services/generation-messages.test.ts main/services/generation-runtime.test.ts main/services/external-editors.test.ts main/services/git.test.ts main/services/model-runtime-core.test.ts main/services/pi-compaction-core.test.ts main/services/models.test.ts main/services/mcp-oauth-operation.test.ts main/services/mcp-oauth-session.test.ts main/services/mcp-presets.test.ts renderer/shared/plugin-catalog.test.ts main/services/pi-credential-store-core.test.ts main/services/pi-provider-contract.test.ts main/services/profile-share-core.test.ts main/services/profile-share-files.test.ts main/services/profile.test.ts main/services/provider-auth-flow-core.test.ts main/services/provider-auth-owner.test.ts main/services/provider-key-policy.test.ts main/services/provider-list-core.test.ts main/services/provider-artwork-core.test.ts main/services/quit-barrier.test.ts main/services/scratch-workspace.test.ts main/services/skills-discovery.test.ts main/services/tool-approval.test.ts main/services/local-runtime-status.test.ts main/services/usage-store-core.test.ts main/services/workspace-files.test.ts main/windows/pill-window-security.test.ts renderer/components/assistant/use-assistant-chat.test.ts renderer/components/assistant/assistant-ui.test.tsx renderer/components/environment-subagents-contract.test.ts renderer/components/subagents-panel.test.tsx renderer/components/chat-sidebar.test.tsx renderer/components/composer.test.tsx renderer/main/chat-transition.test.tsx renderer/components/usage/profile-share-card.test.tsx renderer/lib/accessibility-refresh.test.ts renderer/lib/agent-activity.test.ts renderer/lib/chat-activity.test.ts renderer/lib/assistant-dock.test.ts renderer/lib/assistant-motion-contract.test.ts renderer/lib/dialog-motion-contract.test.ts renderer/lib/chat-deletion-cache.test.ts renderer/lib/chat-terminal-sync.test.ts renderer/lib/ipc-stream.test.ts renderer/lib/chat-title-reveal.test.ts renderer/lib/codex-auth-session.test.ts renderer/lib/codex-auth-view-state.test.ts renderer/lib/codex-provider-cache.test.ts renderer/lib/composer-placeholder.test.ts renderer/lib/computer-use-notice.test.ts renderer/lib/dictation-operation-gate.test.ts renderer/lib/media-recorder-stop.test.ts renderer/lib/dictation-vad.test.ts renderer/lib/dictation-sounds.test.ts renderer/lib/editor-preference.test.ts renderer/lib/environment-panel-layout.test.ts renderer/lib/subagent-view-state.test.ts renderer/lib/truncate-path.test.ts renderer/lib/mcp-preset-state.test.ts renderer/components/settings/mcp-settings.test.tsx renderer/components/settings/mcp-preset-icons.test.ts renderer/lib/model-display.test.ts renderer/lib/profile-share-data.test.ts renderer/lib/sidebar-chat-shortcuts.test.ts renderer/lib/usage-profile-data.test.ts renderer/shared/appearance.test.ts renderer/shared/provider-deployment.test.ts main/handlers/ipc-contract.test.ts main/handlers/chat.parse.test.ts main/handlers/voice-codec.test.ts main/handlers/phase2-parse.test.ts scripts/apple-developer-tools.test.mjs scripts/check-macos-release.test.mjs scripts/computer-use-packaged-acceptance.test.mjs scripts/configure-electron-fuses.test.mjs scripts/model-snapshot-core.test.mjs scripts/prepare-macos-dev-runtime.test.mjs scripts/prepare-macos-package-output.test.mjs scripts/run-macos-distribution.test.mjs scripts/sign-macos.test.mjs scripts/update-model-capabilities.test.mjs scripts/vendor-cua-driver.test.mjs scripts/verify-macos-package.test.mjs", + "test": "tsx --test main/services/mcp-oauth-client-metadata.test.ts main/handlers/assistant-parse.test.ts main/services/assistant/system-prompt.test.ts main/services/chat-activity-core.test.ts main/services/chat-generation-start.test.ts main/services/chat-title-policy.test.ts main/services/chat-title-routing.test.ts main/services/chat-store-core.test.ts main/services/empty-chat-migration.test.ts main/services/chat-first-message-commit.test.ts main/services/codex-provider.test.ts main/services/coding-tools.test.ts main/services/config-store-core.test.ts main/services/computer-use/computer-use-foundation.test.ts main/services/computer-use/computer-use-tool.test.ts main/services/computer-use/generation-gate.test.ts main/services/computer-use/safety.test.ts main/services/computer-use/settings-core.test.ts main/services/computer-use/status-core.test.ts main/services/data-store.test.ts main/services/data-store.resilience.test.ts main/services/terminal.test.ts main/services/terminal-history.test.ts main/services/aiden-config-dir.test.ts main/services/portable-config-core.test.ts main/services/portable-config-core.roundtrip.test.ts main/services/portable-config-watch-core.test.ts main/services/secret-map-core.test.ts main/services/dev-log.test.ts main/services/process-diagnostics.test.ts main/services/dictation-coordinator.test.ts main/services/dictation-paste.test.ts main/services/dictation-cleanup-core.test.ts main/services/dictation-hotkey.test.ts main/services/dictation-key-state.test.ts main/services/dictation-keycode.test.ts main/services/parakeet-protocol.test.ts main/services/parakeet-process-core.test.ts main/services/foundation-models-connection.test.ts main/services/foundation-models-connection-core.test.ts main/services/gemini-context-cache.test.ts main/services/generation-bound-connection-cache.test.ts main/services/generation-context.test.ts main/services/generation-messages.test.ts main/services/generation-runtime.test.ts main/services/external-editors.test.ts main/services/git.test.ts main/services/model-runtime-core.test.ts main/services/models.test.ts main/services/mcp-oauth-operation.test.ts main/services/mcp-oauth-session.test.ts main/services/mcp-presets.test.ts renderer/shared/plugin-catalog.test.ts main/services/pi-credential-store-core.test.ts main/services/pi-provider-contract.test.ts main/services/profile-share-core.test.ts main/services/profile-share-files.test.ts main/services/profile.test.ts main/services/provider-auth-flow-core.test.ts main/services/provider-auth-owner.test.ts main/services/provider-key-policy.test.ts main/services/provider-list-core.test.ts main/services/provider-artwork-core.test.ts main/services/quit-barrier.test.ts main/services/scratch-workspace.test.ts main/services/skills-discovery.test.ts main/services/tool-approval.test.ts main/services/local-runtime-status.test.ts main/services/usage-store-core.test.ts main/services/workspace-files.test.ts main/windows/pill-window-security.test.ts renderer/components/assistant/use-assistant-chat.test.ts renderer/components/assistant/assistant-ui.test.tsx renderer/components/activity-feed.test.tsx renderer/components/environment-subagents-contract.test.ts renderer/components/subagents-panel.test.tsx renderer/components/chat-sidebar.test.tsx renderer/components/composer.test.tsx renderer/lib/chat-message-queue.test.ts renderer/lib/chat-draft.test.ts renderer/main/chat-transition.test.tsx renderer/components/usage/profile-share-card.test.tsx renderer/lib/accessibility-refresh.test.ts renderer/lib/agent-activity.test.ts renderer/lib/chat-activity.test.ts renderer/lib/assistant-dock.test.ts renderer/lib/assistant-motion-contract.test.ts renderer/lib/dialog-motion-contract.test.ts renderer/lib/scrollbar-gutter-contract.test.ts renderer/lib/text-entry-focus-contract.test.ts renderer/lib/chat-deletion-cache.test.ts renderer/lib/chat-terminal-sync.test.ts renderer/lib/ipc-stream.test.ts renderer/lib/chat-title-reveal.test.ts renderer/lib/codex-auth-session.test.ts renderer/lib/codex-provider-cache.test.ts renderer/lib/composer-placeholder.test.ts renderer/lib/computer-use-notice.test.ts renderer/lib/dictation-operation-gate.test.ts renderer/lib/media-recorder-stop.test.ts renderer/lib/dictation-vad.test.ts renderer/lib/dictation-sounds.test.ts renderer/lib/editor-preference.test.ts renderer/lib/environment-panel-layout.test.ts renderer/lib/subagent-view-state.test.ts renderer/lib/truncate-path.test.ts renderer/lib/mcp-preset-state.test.ts renderer/components/settings/mcp-settings.test.tsx renderer/components/settings/mcp-preset-icons.test.ts renderer/lib/model-display.test.ts renderer/lib/model-picker-data.test.ts renderer/lib/profile-share-data.test.ts renderer/lib/sidebar-chat-shortcuts.test.ts renderer/lib/usage-profile-data.test.ts renderer/shared/appearance.test.ts renderer/components/interface-polish.test.tsx renderer/shared/provider-deployment.test.ts main/handlers/ipc-contract.test.ts main/handlers/chat.parse.test.ts main/handlers/voice-codec.test.ts main/handlers/phase2-parse.test.ts scripts/apple-developer-tools.test.mjs scripts/check-macos-release.test.mjs scripts/computer-use-packaged-acceptance.test.mjs scripts/configure-electron-fuses.test.mjs scripts/model-snapshot-core.test.mjs scripts/prepare-macos-dev-runtime.test.mjs scripts/prepare-macos-package-output.test.mjs scripts/run-macos-distribution.test.mjs scripts/sign-macos.test.mjs scripts/hide-dmg-support-files.test.mjs scripts/update-model-capabilities.test.mjs scripts/vendor-cua-driver.test.mjs scripts/verify-macos-package.test.mjs && npm run test:telegram && npm run test:worktree-remover:native && npm run test:computer-use:native && npm run test:settings-design", + "test:coverage": "tsx --test --experimental-test-coverage main/services/mcp-oauth-client-metadata.test.ts main/handlers/assistant-parse.test.ts main/services/assistant/system-prompt.test.ts main/services/chat-activity-core.test.ts main/services/chat-generation-start.test.ts main/services/chat-title-policy.test.ts main/services/chat-title-routing.test.ts main/services/chat-store-core.test.ts main/services/empty-chat-migration.test.ts main/services/chat-first-message-commit.test.ts main/services/codex-provider.test.ts main/services/coding-tools.test.ts main/services/config-store-core.test.ts main/services/computer-use/computer-use-foundation.test.ts main/services/computer-use/computer-use-tool.test.ts main/services/computer-use/generation-gate.test.ts main/services/computer-use/safety.test.ts main/services/computer-use/settings-core.test.ts main/services/computer-use/status-core.test.ts main/services/data-store.test.ts main/services/data-store.resilience.test.ts main/services/terminal.test.ts main/services/terminal-history.test.ts main/services/aiden-config-dir.test.ts main/services/portable-config-core.test.ts main/services/portable-config-core.roundtrip.test.ts main/services/portable-config-watch-core.test.ts main/services/dev-log.test.ts main/services/process-diagnostics.test.ts main/services/dictation-coordinator.test.ts main/services/dictation-paste.test.ts main/services/dictation-cleanup-core.test.ts main/services/dictation-hotkey.test.ts main/services/dictation-key-state.test.ts main/services/dictation-keycode.test.ts main/services/parakeet-protocol.test.ts main/services/parakeet-process-core.test.ts main/services/foundation-models-connection.test.ts main/services/foundation-models-connection-core.test.ts main/services/generation-bound-connection-cache.test.ts main/services/generation-context.test.ts main/services/generation-messages.test.ts main/services/generation-runtime.test.ts main/services/external-editors.test.ts main/services/git.test.ts main/services/model-runtime-core.test.ts main/services/pi-compaction-core.test.ts main/services/models.test.ts main/services/mcp-oauth-operation.test.ts main/services/mcp-oauth-session.test.ts main/services/mcp-presets.test.ts renderer/shared/plugin-catalog.test.ts main/services/pi-credential-store-core.test.ts main/services/pi-provider-contract.test.ts main/services/profile-share-core.test.ts main/services/profile-share-files.test.ts main/services/profile.test.ts main/services/provider-auth-flow-core.test.ts main/services/provider-auth-owner.test.ts main/services/provider-key-policy.test.ts main/services/provider-list-core.test.ts main/services/provider-artwork-core.test.ts main/services/quit-barrier.test.ts main/services/scratch-workspace.test.ts main/services/skills-discovery.test.ts main/services/tool-approval.test.ts main/services/local-runtime-status.test.ts main/services/usage-store-core.test.ts main/services/workspace-files.test.ts main/windows/pill-window-security.test.ts renderer/components/assistant/use-assistant-chat.test.ts renderer/components/assistant/assistant-ui.test.tsx renderer/components/environment-subagents-contract.test.ts renderer/components/subagents-panel.test.tsx renderer/components/chat-sidebar.test.tsx renderer/components/composer.test.tsx renderer/main/chat-transition.test.tsx renderer/components/usage/profile-share-card.test.tsx renderer/lib/accessibility-refresh.test.ts renderer/lib/agent-activity.test.ts renderer/lib/chat-activity.test.ts renderer/lib/assistant-dock.test.ts renderer/lib/assistant-motion-contract.test.ts renderer/lib/dialog-motion-contract.test.ts renderer/lib/chat-deletion-cache.test.ts renderer/lib/chat-terminal-sync.test.ts renderer/lib/ipc-stream.test.ts renderer/lib/chat-title-reveal.test.ts renderer/lib/codex-auth-session.test.ts renderer/lib/codex-auth-view-state.test.ts renderer/lib/codex-provider-cache.test.ts renderer/lib/composer-placeholder.test.ts renderer/lib/computer-use-notice.test.ts renderer/lib/dictation-operation-gate.test.ts renderer/lib/media-recorder-stop.test.ts renderer/lib/dictation-vad.test.ts renderer/lib/dictation-sounds.test.ts renderer/lib/editor-preference.test.ts renderer/lib/environment-panel-layout.test.ts renderer/lib/subagent-view-state.test.ts renderer/lib/truncate-path.test.ts renderer/lib/mcp-preset-state.test.ts renderer/components/settings/mcp-settings.test.tsx renderer/components/settings/mcp-preset-icons.test.ts renderer/lib/model-display.test.ts renderer/lib/profile-share-data.test.ts renderer/lib/sidebar-chat-shortcuts.test.ts renderer/lib/usage-profile-data.test.ts renderer/shared/appearance.test.ts renderer/shared/provider-deployment.test.ts main/handlers/ipc-contract.test.ts main/handlers/chat.parse.test.ts main/handlers/voice-codec.test.ts main/handlers/phase2-parse.test.ts scripts/apple-developer-tools.test.mjs scripts/check-macos-release.test.mjs scripts/computer-use-packaged-acceptance.test.mjs scripts/configure-electron-fuses.test.mjs scripts/model-snapshot-core.test.mjs scripts/prepare-macos-dev-runtime.test.mjs scripts/prepare-macos-package-output.test.mjs scripts/run-macos-distribution.test.mjs scripts/sign-macos.test.mjs scripts/update-model-capabilities.test.mjs scripts/vendor-cua-driver.test.mjs scripts/verify-macos-package.test.mjs", "test:computer-use": "tsx --test main/services/computer-use/computer-use-foundation.test.ts main/services/computer-use/computer-use-tool.test.ts main/services/computer-use/generation-gate.test.ts main/services/computer-use/safety.test.ts main/services/computer-use/settings-core.test.ts main/services/computer-use/status-core.test.ts main/services/data-store.test.ts main/services/generation-messages.test.ts main/services/generation-runtime.test.ts main/services/quit-barrier.test.ts main/services/tool-approval.test.ts scripts/check-macos-release.test.mjs scripts/computer-use-packaged-acceptance.test.mjs scripts/configure-electron-fuses.test.mjs scripts/prepare-macos-package-output.test.mjs scripts/run-macos-distribution.test.mjs scripts/sign-macos.test.mjs scripts/vendor-cua-driver.test.mjs scripts/verify-macos-package.test.mjs && npm run test:computer-use:native", "test:computer-use:packaged": "node scripts/computer-use-packaged-acceptance.mjs", "test:computer-use:native": "cd native/computer-use-broker && CARGO_TARGET_DIR=../../build/computer-use-broker-test cargo fmt -- --check && CARGO_TARGET_DIR=../../build/computer-use-broker-test cargo test --locked && CARGO_TARGET_DIR=../../build/computer-use-broker-test cargo clippy --locked --all-targets -- -D warnings", diff --git a/renderer/components/btw-card.test.tsx b/renderer/components/btw-card.test.tsx index cd5b10e3..d942953d 100644 --- a/renderer/components/btw-card.test.tsx +++ b/renderer/components/btw-card.test.tsx @@ -68,7 +68,8 @@ test("BTW composer dispatch returns before durable chat append", () => { test("BTW slash eligibility is scoped to ordinary chat surfaces", () => { const pane = readFileSync(new URL("../main/chat-pane.tsx", import.meta.url), "utf8"); - assert.match(pane, /const sideQuestionBlockedReason = chat\.data\?\.botId \|\| bot\.data/u); + assert.match(pane, /const sideQuestionBlockedReason = draft[\s\S]*chat\.data\?\.botId \|\| bot\.data/u); + assert.match(pane, /Send the first message before asking a side question/u); assert.match(pane, /effectiveWorkspaceId === ASSISTANT_WORKSPACE_ID/u); assert.match(pane, /sideQuestionBlockedReason=\{sideQuestionBlockedReason\}/u); }); diff --git a/renderer/components/chat-sidebar.test.tsx b/renderer/components/chat-sidebar.test.tsx index c4330e02..31afde60 100644 --- a/renderer/components/chat-sidebar.test.tsx +++ b/renderer/components/chat-sidebar.test.tsx @@ -38,10 +38,11 @@ test("new agent uses the same sidebar row style as scheduled", () => { assert.doesNotMatch(section, /variant="accent"/u); }); -test("newAgent delegates explicit creation to the active workspace", () => { +test("newAgent opens a transient draft in the active workspace", () => { const sidebar = source("./chat-sidebar.tsx"); assert.match(sidebar, /const newAgentInWorkspace = React\.useCallback/u); - assert.match(sidebar, /chatsApi\.create\(\{ workspaceId \}\)/u); + assert.match(sidebar, /createChatDraft\(workspaceId\)/u); + assert.doesNotMatch(sidebar, /chatsApi\.create\(/u); assert.match(sidebar, /const newAgent = React\.useCallback\(async \(\) => \{/u); assert.match(sidebar, /if \(!activeId\) return;/u); assert.match(sidebar, /await newAgentInWorkspace\(activeId\)/u); diff --git a/renderer/components/chat-sidebar.tsx b/renderer/components/chat-sidebar.tsx index e589c1c6..c194ff83 100644 --- a/renderer/components/chat-sidebar.tsx +++ b/renderer/components/chat-sidebar.tsx @@ -1,3 +1,4 @@ +import { createChatDraft, discardChatDraft } from "../lib/chat-draft"; // Unified workspace/chat sidebar with alternate workspace-grouped and recent // projections, route-driven selection, and workspace/chat management actions. @@ -740,7 +741,7 @@ export function ChatSidebar({ activeChatId, titleReveal }: ChatSidebarProps) { return () => unregister.forEach((dispose) => dispose()); }, [chatNavigationTargets, openChat, registerCommand, shortcutAssignments]); - // Move to a workspace and land on one of its chats (creating one if empty). + // Move to a workspace and open its latest chat, or an unsaved draft if empty. const enterWorkspace = React.useCallback( async (id: string, allowDirtyDiscard = false) => { if (environmentPanel.gitOperationBusy) { @@ -761,13 +762,13 @@ export function ChatSidebar({ activeChatId, titleReveal }: ChatSidebarProps) { toast.error("Reload Aiden before creating a chat in this workspace."); return false; } - const target = list[0] ?? (await chatsApi.create({ workspaceId: id })); - await qc.invalidateQueries({ queryKey: queryKeys.chats }); + const target = list[0] ?? createChatDraft(id).chat; const previousWorkspaceId = activeId; select(id); try { await navigate({ to: "/chat/$chatId", params: { chatId: target.id } }); } catch (error) { + if (!list.length) discardChatDraft(target.id); if (previousWorkspaceId) select(previousWorkspaceId); throw error; } @@ -934,11 +935,15 @@ export function ChatSidebar({ activeChatId, titleReveal }: ChatSidebarProps) { if (workspaceId !== activeId && environmentPanel.agentBusy) { environmentPanel.cancelAgent?.(); } - const created = await chatsApi.create({ workspaceId }); - await qc.invalidateQueries({ queryKey: queryKeys.chats }); + const created = createChatDraft(workspaceId).chat; select(workspaceId); setExpandedWorkspaceIds((current) => new Set(current).add(workspaceId)); - await navigate({ to: "/chat/$chatId", params: { chatId: created.id } }); + try { + await navigate({ to: "/chat/$chatId", params: { chatId: created.id } }); + } catch (error) { + discardChatDraft(created.id); + throw error; + } } catch (error) { toast.error(error instanceof Error ? error.message : "Aiden could not create a chat."); } diff --git a/renderer/components/composer.test.tsx b/renderer/components/composer.test.tsx index fa3ed992..e634ca34 100644 --- a/renderer/components/composer.test.tsx +++ b/renderer/components/composer.test.tsx @@ -185,7 +185,7 @@ test("composer slash palette is an overlaid textarea-owned accessible listbox", const optimisticClear = composer.indexOf('setText("");'); const sendAwait = composer.indexOf("await submit("); assert.ok(optimisticClear >= 0 && optimisticClear < sendAwait); - assert.match(composer, /if \(sendPendingRef\.current\) return false;/u); + assert.match(composer, /if \(sendPendingRef\.current \|\| firstSendPendingRef\.current\) return false;/u); assert.match(composer, /type: "send-started"/u); assert.match(composer, /failedSendDraft\(payload\.draftText, currentDraft\)/u); assert.match(composer, /failedSendAttachments\([\s\S]{0,160}payload\.attachments/u); @@ -216,7 +216,7 @@ test("selected session slash commands dispatch through explicit Aiden-owned work assert.match(composer, /authenticatedProviders\.map\(\(provider\)/u); assert.match(composer, /openWorktreeOnMount=\{worktreeRequest > 0\}/u); assert.match(composer, /programmaticReturnFocusRef=\{inputRef\}/u); - assert.match(composer, /readOnly=\{sessionCommandBusy\}/u); + assert.match(composer, /readOnly=\{sessionCommandBusy \|\| firstSendPending\}/u); assert.match(composer, /role="status" aria-live="polite"/u); assert.match(branchPicker, /openManagedWorktree \? "worktree" : null/u); assert.match(chatPane, /chatsApi\.copyVisibleHistory\([\s\S]{0,100}throughAssistantMessageId/u); @@ -295,3 +295,22 @@ test("model picker details sit beside the menu without overlapping the pad", () /\.model-pad:focus-visible\s*\{\s*outline: none !important;\s*box-shadow:\s*inset 0 0 0 2px var\(--focus-ring\)/u, ); }); + +test("first-send draft freeze blocks edits and browser annotation delivery until commit", () => { + const composer = source("./composer.tsx"); + assert.match(composer, /firstSendPendingRef\.current = freezeWhileSending/u); + assert.match(composer, /inert=\{firstSendPending \|\| undefined\}/u); + assert.match(composer, /if \(firstSendPendingRef\.current \|\| !available\(\)\) return false/u); + assert.match(composer, /readOnly=\{sessionCommandBusy \|\| firstSendPending\}/u); + assert.match(composer, /role="status"[^\n]*Sending…/u); +}); + + +test("reopening a draft uses its shared pending state instead of fresh composer state", () => { + const composer = source("./composer.tsx"); + const pane = source("../main/chat-pane.tsx"); + assert.match(pane, /firstMessageSaving=\{draft\?\.sending === true\}/u); + assert.match(composer, /const firstSendPending = firstMessageSaving \|\| \(freezeWhileSending && sending\)/u); + assert.match(composer, /!firstMessageSaving &&/u); + assert.match(composer, /if \(sendPendingRef\.current \|\| firstSendPendingRef\.current\) return false/u); +}); diff --git a/renderer/components/composer.tsx b/renderer/components/composer.tsx index 36454183..c5eebb00 100644 --- a/renderer/components/composer.tsx +++ b/renderer/components/composer.tsx @@ -135,6 +135,10 @@ interface ComposerProps { canStopGeneration?: boolean; /** Blocks both click and Enter submission while a model-scoped option is being saved. */ configurationBusy?: boolean; + /** New-agent drafts cannot accept edits while their first message commits. */ + freezeWhileSending?: boolean; + /** Survives navigating away and reopening a draft whose commit is pending. */ + firstMessageSaving?: boolean; inputRef?: React.RefObject; workspace?: Workspace; /** Current git branch of the workspace folder, or undefined if not a repo. */ @@ -279,6 +283,8 @@ export function Composer({ isGenerating, canStopGeneration = isGenerating, configurationBusy = false, + freezeWhileSending = false, + firstMessageSaving = false, inputRef, workspace, gitBranch, @@ -329,6 +335,7 @@ export function Composer({ React.useLayoutEffect(() => { draftRef.current = draft; }, [draft]); + const firstSendPendingRef = React.useRef(false); const slashActionPendingRef = React.useRef(false); const sessionCommandBusyRef = React.useRef(false); const slashPaletteBlockedRef = React.useRef(slashPaletteBlocked); @@ -374,7 +381,7 @@ export function Composer({ return Boolean(input?.isConnected && !input.disabled && !input.readOnly && input.getClientRects().length && !input.closest('[aria-hidden="true"], [inert]')); }; const receive = (annotation: BrowserAnnotation) => { - if (!available()) return false; + if (firstSendPendingRef.current || !available()) return false; const result = browserAnnotationAttachments(annotation, attachmentsRef.current, visionSupported !== false, crypto.randomUUID()); const comment = annotation.comment.trim(); const fallback = result.attachments.some((item) => item.kind === "text") ? "" : browserAnnotationContext(annotation); @@ -398,6 +405,8 @@ export function Composer({ const attachmentDescriptionId = React.useId(); const [sending, setSending] = React.useState(false); const sendPendingRef = React.useRef(false); + const firstSendPending = firstMessageSaving || (freezeWhileSending && sending); + firstSendPendingRef.current = firstSendPending; const [permissionSaving, setPermissionSaving] = React.useState(false); const [confirmFullAccess, setConfirmFullAccess] = React.useState(false); const [permissionMenuOpen, setPermissionMenuOpen] = React.useState(false); @@ -454,6 +463,7 @@ export function Composer({ attaching, }) && !configurationBusy && + !firstMessageSaving && !sessionCommandBusy; const settings = useSettings(); const skillCatalog = useDiscoveredSkills(workspace?.id); @@ -481,7 +491,9 @@ export function Composer({ !composing && (!selectedSkillState || selectedSkillState.state === "valid"); const voice = useVoiceRecorder( - (transcript) => setText((prev) => (prev.trim() ? `${prev.trim()} ${transcript}` : transcript)), + (transcript) => { + if (!firstSendPendingRef.current) setText((prev) => (prev.trim() ? `${prev.trim()} ${transcript}` : transcript)); + }, { provider: settings.data?.voiceProvider ?? "openai", localModel: settings.data?.localVoiceModel, @@ -814,8 +826,9 @@ export function Composer({ }): Promise => { // React state does not close the same-tick Enter + click window. Claim // the send synchronously before making any optimistic UI changes. - if (sendPendingRef.current) return false; + if (sendPendingRef.current || firstSendPendingRef.current) return false; sendPendingRef.current = true; + firstSendPendingRef.current = freezeWhileSending; setSending(true); setText(""); @@ -875,15 +888,17 @@ export function Composer({ throw error; } finally { sendPendingRef.current = false; + firstSendPendingRef.current = false; setSending(false); } }, - [onSend, onQueue, isGenerating, hasQueuedMessages, setText, updateAttachments], + [onSend, onQueue, isGenerating, hasQueuedMessages, freezeWhileSending, setText, updateAttachments], ); const selectSlashResult = React.useCallback( async (result: SlashResult) => { if ( + firstSendPendingRef.current || slashActionPendingRef.current || !slashSession || result.kind !== slashSession.kind || @@ -1091,6 +1106,7 @@ export function Composer({ }, [onRenameChat, renameTitle, renaming]); const beginAttachmentRead = (status: string): boolean => { + if (firstSendPendingRef.current) return false; if (gitOperationBusy) { toast.info("Wait for the current Git operation to finish before attaching files."); return false; @@ -1256,6 +1272,7 @@ export function Composer({ }; const handlePaste = (event: React.ClipboardEvent) => { + if (firstSendPendingRef.current) { event.preventDefault(); return; } const images = Array.from(event.clipboardData.items).flatMap((item) => { if (item.kind !== "file" || !CLIPBOARD_IMAGE_MIME_TYPES.has(item.type.toLowerCase())) { return []; @@ -1282,6 +1299,7 @@ export function Composer({ }; const removeAttachment = (id: string) => { + if (firstSendPendingRef.current) return; attachmentRevisionRef.current += 1; updateAttachments((prev) => prev.filter((a) => a.id !== id)); }; @@ -1455,7 +1473,8 @@ export function Composer({ return ( <>
-
+ {firstSendPending ? Sending… : null} +
{ + if (firstSendPendingRef.current) return; setText(event.target.value); updateSelection({ start: event.target.selectionStart, diff --git a/renderer/lib/chat-draft.test.ts b/renderer/lib/chat-draft.test.ts new file mode 100644 index 00000000..9781745c --- /dev/null +++ b/renderer/lib/chat-draft.test.ts @@ -0,0 +1,123 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + beginChatDraftSend, createChatDraft, discardChatDraft, finishChatDraftSend, + getChatDraft, retainChatDraft, subscribeChatDrafts, updateChatDraft, +} from "./chat-draft"; + +const flush = async () => { await Promise.resolve(); }; + +test("leaving an unsent draft drops its renderer-only state", async () => { + const draft = createChatDraft("workspace-a"); + const release = retainChatDraft(draft.chat.id); + updateChatDraft(draft.chat.id, { workspaceId: "workspace-b", computerUseEnabled: true }); + assert.equal(getChatDraft(draft.chat.id)?.chat.workspaceId, "workspace-b"); + assert.deepEqual(draft.chat.messages, []); + release(); + await flush(); + assert.equal(getChatDraft(draft.chat.id), undefined); +}); + +test("StrictMode release and reacquire does not discard the mounted draft", async () => { + const { chat } = createChatDraft("workspace-a"); + const firstRelease = retainChatDraft(chat.id); + firstRelease(); + const secondRelease = retainChatDraft(chat.id); + await flush(); + assert.ok(getChatDraft(chat.id)); + firstRelease(); // cleanup is idempotent + assert.ok(getChatDraft(chat.id)); + secondRelease(); + await flush(); + assert.equal(getChatDraft(chat.id), undefined); +}); + +test("same-tick duplicate sends and settings edits are rejected while committing", () => { + const { chat } = createChatDraft("workspace-a"); + const release = retainChatDraft(chat.id); + updateChatDraft(chat.id, { computerUseEnabled: true }); + const submitted = beginChatDraftSend(chat.id); + assert.equal(submitted.chat.computerUseEnabled, true); + assert.throws(() => beginChatDraftSend(chat.id), /already being sent/u); + assert.throws(() => updateChatDraft(chat.id, { workspaceId: "workspace-b" }), /finish saving/u); + finishChatDraftSend(chat.id, false); + assert.equal(getChatDraft(chat.id)?.sending, false); + assert.equal(getChatDraft(chat.id)?.chat.workspaceId, "workspace-a"); + release(); +}); + +test("an in-flight draft survives navigation until its operation settles", async () => { + const { chat } = createChatDraft("workspace-a"); + const release = retainChatDraft(chat.id); + beginChatDraftSend(chat.id); + release(); + await flush(); + assert.equal(getChatDraft(chat.id)?.sending, true); + finishChatDraftSend(chat.id, false); + assert.equal(getChatDraft(chat.id), undefined); +}); + +test("successful promotion removes only the submitted draft and keeps its identity", () => { + const first = createChatDraft("workspace-a"); + const releaseFirst = retainChatDraft(first.chat.id); + const second = createChatDraft("workspace-b"); + const releaseSecond = retainChatDraft(second.chat.id); + const submitted = beginChatDraftSend(first.chat.id); + assert.equal(submitted.chat.id, first.chat.id); + finishChatDraftSend(first.chat.id, true); + assert.equal(getChatDraft(first.chat.id), undefined); + assert.equal(getChatDraft(second.chat.id), second); + finishChatDraftSend(first.chat.id, false); // finally after success is harmless + assert.equal(getChatDraft(second.chat.id), second); + releaseFirst(); + releaseSecond(); +}); + +test("draft mutation subscriptions are removable and failed navigation can discard", () => { + let changes = 0; + const unsubscribe = subscribeChatDrafts(() => { changes += 1; }); + const { chat } = createChatDraft("workspace-a"); + assert.equal(changes, 1); + discardChatDraft(chat.id); + assert.equal(changes, 2); + unsubscribe(); + discardChatDraft(chat.id); + assert.equal(changes, 2); +}); + +test("reopening an in-flight draft retains its busy state and unlocks on definite failure", async () => { + const { chat } = createChatDraft("workspace-a"); + const firstRelease = retainChatDraft(chat.id); + beginChatDraftSend(chat.id); + firstRelease(); + await flush(); + const reopenedRelease = retainChatDraft(chat.id); + assert.equal(getChatDraft(chat.id)?.sending, true); + assert.throws(() => beginChatDraftSend(chat.id), /already being sent/u); + finishChatDraftSend(chat.id, false); + assert.equal(getChatDraft(chat.id)?.sending, false); + beginChatDraftSend(chat.id); + finishChatDraftSend(chat.id, true); + reopenedRelease(); +}); + + +test("superseded new activations discard unowned drafts while preserving mounted and sending drafts", async () => { + const mounted = createChatDraft("workspace-a"); + const releaseMounted = retainChatDraft(mounted.chat.id); + const sending = createChatDraft("workspace-b"); + const releaseSending = retainChatDraft(sending.chat.id); + beginChatDraftSend(sending.chat.id); + releaseSending(); + await flush(); + const superseded = createChatDraft("workspace-c"); + const latest = createChatDraft("workspace-d"); + assert.equal(getChatDraft(superseded.chat.id), undefined); + assert.equal(getChatDraft(mounted.chat.id), mounted); + assert.equal(getChatDraft(sending.chat.id)?.sending, true); + assert.equal(getChatDraft(latest.chat.id), latest); + releaseMounted(); + finishChatDraftSend(sending.chat.id, false); + discardChatDraft(latest.chat.id); + await flush(); +}); diff --git a/renderer/lib/chat-draft.ts b/renderer/lib/chat-draft.ts new file mode 100644 index 00000000..632b3829 --- /dev/null +++ b/renderer/lib/chat-draft.ts @@ -0,0 +1,83 @@ +import type { Chat } from "./types"; + +/** A new-agent surface is renderer-only until its first user message commits. */ +export interface ChatDraft { + readonly chat: Chat; + readonly sending: boolean; +} + +const drafts = new Map(); +const owners = new Map(); +const listeners = new Set<() => void>(); +const notify = () => { for (const listener of listeners) listener(); }; + +export function subscribeChatDrafts(listener: () => void): () => void { + listeners.add(listener); + return () => { listeners.delete(listener); }; +} + +export function getChatDraft(id: string): ChatDraft | undefined { + return drafts.get(id); +} + +export function createChatDraft(workspaceId: string, id = crypto.randomUUID()): ChatDraft { + if (drafts.has(id)) throw new Error("This draft already exists."); + // Multiple New activations can supersede navigation before a pane mounts. + // Only keep drafts that acquired a view owner or have a send to settle. + for (const [previousId, previous] of drafts) { + if (!owners.has(previousId) && !previous.sending) drafts.delete(previousId); + } + const now = Date.now(); + const draft: ChatDraft = { + chat: { id, workspaceId, title: "New agent", messages: [], createdAt: now, updatedAt: now }, + sending: false, + }; + drafts.set(id, draft); + notify(); + return draft; +} + +export function updateChatDraft(id: string, update: Pick, "workspaceId" | "computerUseEnabled" | "title">): void { + const draft = drafts.get(id); + if (!draft || draft.sending) throw new Error("Wait for the first message to finish saving."); + drafts.set(id, { ...draft, chat: { ...draft.chat, ...update } }); + notify(); +} + +export function beginChatDraftSend(id: string): ChatDraft { + const draft = drafts.get(id); + if (!draft || draft.sending) throw new Error("This draft is already being sent."); + drafts.set(id, { ...draft, sending: true }); + notify(); + return draft; +} + +export function finishChatDraftSend(id: string, committed: boolean): void { + const draft = drafts.get(id); + if (!draft) return; + if (committed || !owners.get(id)) drafts.delete(id); + else drafts.set(id, { ...draft, sending: false }); + notify(); +} + +export function discardChatDraft(id: string): void { + if (drafts.get(id)?.sending) return; + if (drafts.delete(id)) notify(); +} + +/** Delay disposal one microtask so StrictMode's effect replay can reacquire it. */ +export function retainChatDraft(id: string): () => void { + if (!drafts.has(id)) return () => {}; + owners.set(id, (owners.get(id) ?? 0) + 1); + let released = false; + return () => { + if (released) return; + released = true; + const count = (owners.get(id) ?? 1) - 1; + if (count > 0) owners.set(id, count); + else owners.delete(id); + queueMicrotask(() => { + if (!owners.has(id)) discardChatDraft(id); + }); + }; +} diff --git a/renderer/lib/ipc.ts b/renderer/lib/ipc.ts index 15177b8e..499da565 100644 --- a/renderer/lib/ipc.ts +++ b/renderer/lib/ipc.ts @@ -778,6 +778,17 @@ export const chatsApi = { }), create: (input: { title?: string; workspaceId: string; providerId?: string; model?: string }) => invokeChatMutation("chats:create", input), + createWithFirstMessage: (input: { + draftId: string; + title?: string; + workspaceId: string; + providerId?: string; + model?: string; + computerUseEnabled?: boolean; + turnId: string; + message: { role: "user"; content: string; attachments?: Attachment[] }; + skillInvocation?: SkillInvocationV1; + }) => invokeChatMutation("chats:createWithFirstMessage", input), createAssistant: (input: { providerId?: string; model?: string }) => invokeChatMutation("chats:createAssistant", input), rename: (id: string, title: string) => invoke("chats:rename", id, title), diff --git a/renderer/main/chat-layout.tsx b/renderer/main/chat-layout.tsx index e25e894f..97eebb1e 100644 --- a/renderer/main/chat-layout.tsx +++ b/renderer/main/chat-layout.tsx @@ -1,3 +1,4 @@ +import { createChatDraft, discardChatDraft } from "../lib/chat-draft"; // Persistent chat shell: workspace switcher + history sidebar + active chat. // Selection is route-driven (chatId param); the unified sidebar can open chats // across registered workspaces while WorkspaceProvider tracks execution context. @@ -7,7 +8,7 @@ import { useQueryClient } from "@tanstack/react-query"; import * as React from "react"; import { SplitView, Text, toast } from "../components/ui"; import { ChatSidebar } from "../components/chat-sidebar"; -import { chatsApi, onNotification } from "../lib/ipc"; +import { onNotification } from "../lib/ipc"; import { CHAT_TITLE_FADE_OUT_MS, CHAT_TITLE_REVEAL_DURATION_MS, @@ -90,7 +91,7 @@ export function ChatLayout() { /** * Index route: send the user to the most recent chat in the active workspace, - * or create a fresh one so the composer always operates on a concrete chatId. + * or open a renderer-only draft until its first message is saved. */ export function ChatIndex() { const navigate = useNavigate(); @@ -114,15 +115,12 @@ export function ChatIndex() { if (list.length > 0) { void navigate({ to: "/chat/$chatId", params: { chatId: list[0].id }, replace: true }); } else { - void chatsApi - .create({ workspaceId: activeId }) - .then((chat) => { - void chats.refetch(); - void navigate({ to: "/chat/$chatId", params: { chatId: chat.id }, replace: true }); - }) + const draft = createChatDraft(activeId); + void navigate({ to: "/chat/$chatId", params: { chatId: draft.chat.id }, replace: true }) .catch((error: unknown) => { + discardChatDraft(draft.chat.id); startedRef.current = false; - toast.error(error instanceof Error ? error.message : "Aiden could not create a chat."); + toast.error(error instanceof Error ? error.message : "Aiden could not open a new chat."); }); } }, [ diff --git a/renderer/main/chat-pane.tsx b/renderer/main/chat-pane.tsx index 290921c3..5c9ae1df 100644 --- a/renderer/main/chat-pane.tsx +++ b/renderer/main/chat-pane.tsx @@ -1,3 +1,4 @@ +import { beginChatDraftSend, createChatDraft, discardChatDraft, finishChatDraftSend, getChatDraft, retainChatDraft, subscribeChatDrafts, updateChatDraft } from "../lib/chat-draft"; import { QueuedMessages } from "../components/queued-messages"; import { chatMessageQueue } from "../lib/chat-message-queue"; import { useChatMessageQueue } from "../lib/use-chat-message-queue"; @@ -166,7 +167,13 @@ export function ChatPane({ chatId }: { chatId: string }) { const navigate = useNavigate(); const providers = useProviders(); const documentAppendReconciliationRequired = useAppendReconciliationRequired(); - const chat = useChat(chatId); + const draft = React.useSyncExternalStore(subscribeChatDrafts, () => getChatDraft(chatId)); + const persistedChat = useChat(draft ? undefined : chatId); + // Draft projection stays out of the query cache and every persisted chat list. + const chat = draft + ? { ...persistedChat, data: draft.chat, isLoading: false, isError: false } + : persistedChat; + React.useEffect(() => retainChatDraft(chatId), [chatId]); const bot = useBot(chat.data?.botId); const settings = useSettings(); const computerUseGloballyEnabled = settings.data?.computerUseEnabled === true; @@ -178,7 +185,9 @@ export function ChatPane({ chatId }: { chatId: string }) { const chatWorkspaceId = chat.data?.workspaceId; const effectiveWorkspaceId = chat.data ? persistedChatWorkspaceId(chatWorkspaceId) : undefined; const effectiveWorkspace = workspaces.find((workspace) => workspace.id === effectiveWorkspaceId); - const sideQuestionBlockedReason = chat.data?.botId || bot.data + const sideQuestionBlockedReason = draft + ? "Send the first message before asking a side question." + : chat.data?.botId || bot.data ? "Side questions are not available in Bot chats." : effectiveWorkspaceId === ASSISTANT_WORKSPACE_ID ? "Side questions are not available in Assistant chats." @@ -265,6 +274,8 @@ export function ChatPane({ chatId }: { chatId: string }) { ? "Loading chat…" : chat.isError ? "This chat could not be loaded. Try again." + : !chat.data + ? "This chat is no longer available. Start a new agent." : documentAppendReconciliationRequired || appendReconciliationRequiredChats.has(chatId) ? "Message save status is unknown. Reload Aiden before sending another message." : detachedGenerationDraining @@ -473,6 +484,7 @@ export function ChatPane({ chatId }: { chatId: string }) { ); React.useEffect(() => { + if (draft) return; let active = true; const refresh = async () => { const requestId = ++remoteApprovalRefreshRef.current; @@ -498,7 +510,7 @@ export function ChatPane({ chatId }: { chatId: string }) { remoteApprovalRefreshRef.current += 1; unsubscribe(); }; - }, [chatId]); + }, [chatId, Boolean(draft)]); // Detach only the generation owned by the departing chat. The main process // keeps that operation alive and reconciles its durable terminal state. @@ -563,6 +575,7 @@ export function ChatPane({ chatId }: { chatId: string }) { }, [chatId]); React.useEffect(() => { + if (draft) return; let current = true; const ticket = todoSnapshotReadFence.beginInitialRead(chatId); void chatsApi.todoSnapshot(chatId).then( @@ -583,7 +596,7 @@ export function ChatPane({ chatId }: { chatId: string }) { return () => { current = false; }; - }, [chatId]); + }, [chatId, Boolean(draft)]); const messages = React.useMemo(() => chat.data?.messages ?? [], [chat.data?.messages]); const visibleDetachedProjection = @@ -669,6 +682,7 @@ export function ChatPane({ chatId }: { chatId: string }) { const renameChat = React.useCallback( async (title: string) => { + if (getChatDraft(chatId)) { updateChatDraft(chatId, { title }); return; } await chatsApi.rename(chatId, title); qc.setQueryData(queryKeys.chat(chatId), (current) => current ? { ...current, title } : current, @@ -680,6 +694,7 @@ export function ChatPane({ chatId }: { chatId: string }) { const copyChat = React.useCallback( async (throughAssistantMessageId?: string) => { + if (getChatDraft(chatId)) throw new Error("Send the first message before copying this chat."); if (documentAppendReconciliationRequired) { throw new Error("Reload Aiden before copying this chat."); } @@ -739,6 +754,7 @@ export function ChatPane({ chatId }: { chatId: string }) { ); const exportChat = React.useCallback(async () => { + if (getChatDraft(chatId)) throw new Error("Send the first message before exporting this chat."); const result = await chatsApi.export(chatId); return result.status; }, [chatId]); @@ -1162,6 +1178,7 @@ export function ChatPane({ chatId }: { chatId: string }) { options?: { visualize?: boolean; btw?: boolean }, ) => { if (options?.btw) { + if (getChatDraft(chatId)) throw new Error("Send the first message before asking a side question."); if (attachments.length > 0 || skillInvocation) { throw new Error("Side questions do not accept attachments or skills."); } @@ -1206,6 +1223,7 @@ export function ChatPane({ chatId }: { chatId: string }) { if (detachedGenerationDraining) { throw new Error("Wait for the previous response to finish saving before sending again."); } + const firstDraft = getChatDraft(chatId) ? beginChatDraftSend(chatId) : undefined; const generationIntent = ++generationIntentRef.current; const messageTurnId = createChatTurnId(); setIsStoppingGeneration(false); @@ -1213,7 +1231,19 @@ export function ChatPane({ chatId }: { chatId: string }) { try { let updated: Chat; try { - updated = await chatsApi.appendMessage( + updated = firstDraft + ? await chatsApi.createWithFirstMessage({ + draftId: chatId, + title: firstDraft.chat.title === "New agent" ? undefined : firstDraft.chat.title, + workspaceId: firstDraft.chat.workspaceId!, + providerId, + model, + computerUseEnabled: firstDraft.chat.computerUseEnabled, + turnId: messageTurnId, + message: { role: "user", content: text, attachments: attachments.length ? attachments : undefined }, + skillInvocation, + }) + : await chatsApi.appendMessage( chatId, { role: "user", @@ -1231,13 +1261,17 @@ export function ChatPane({ chatId }: { chatId: string }) { } catch (appendError) { if (isAppendReconciliationRequired(appendError)) { setAppendReconciliationRequiredChats((current) => new Set(current).add(chatId)); - void qc.invalidateQueries({ queryKey: queryKeys.chat(chatId) }); + if (!firstDraft) void qc.invalidateQueries({ queryKey: queryKeys.chat(chatId) }); } throw appendError; } qc.setQueryData(queryKeys.chat(chatId), updated); + if (firstDraft) finishChatDraftSend(chatId, true); void qc.invalidateQueries({ queryKey: queryKeys.chats }); - if (generationIntentRef.current !== generationIntent) { + if ( + generationIntentRef.current !== generationIntent || + (firstDraft && (!mountedRef.current || chatIdRef.current !== chatId)) + ) { try { await chatsApi.abandonTurn(chatId, messageTurnId); } catch (error) { @@ -1253,6 +1287,7 @@ export function ChatPane({ chatId }: { chatId: string }) { // that committed message back into an unsent composer payload. if (!started.ok && mountedRef.current) setError(started.error.message); } finally { + if (firstDraft) finishChatDraftSend(chatId, false); if ( mountedRef.current && chatIdRef.current === chatId && @@ -1285,7 +1320,7 @@ export function ChatPane({ chatId }: { chatId: string }) { const { queue: messageQueue, snapshot: queuedState } = useChatMessageQueue({ chatId, contextKey: JSON.stringify([providerId, model, effectiveWorkspaceId, effectiveWorkspace?.permission]), - enabled: ready && !isGenerating && !isStartingGeneration && !isStoppingGeneration && + enabled: !draft && ready && !isGenerating && !isStartingGeneration && !isStoppingGeneration && !detachedGenerationDraining && !thinkingSaving && !computerUseSaving && !environmentPanel.gitOperationBusy && !imageArtifactRecoveryPending && !imageArtifactRecoveryUnavailable && !questionnaire && approvals.length === 0, @@ -1406,6 +1441,7 @@ export function ChatPane({ chatId }: { chatId: string }) { const changePermission = React.useCallback( async (permission: WorkspacePermission) => { if (!effectiveWorkspace) return; + if (getChatDraft(chatId)?.sending) throw new Error("Wait for the first message to finish saving."); if (environmentPanel.gitOperationBusy) throw new Error( "Wait for the current Git operation to finish before changing workspace access.", @@ -1426,6 +1462,7 @@ export function ChatPane({ chatId }: { chatId: string }) { ]); }, [ + chatId, effectiveWorkspace, environmentPanel.agentBusy, environmentPanel.cancelAgent, @@ -1441,6 +1478,7 @@ export function ChatPane({ chatId }: { chatId: string }) { if (computerUseSaving || isStartingGeneration || isGenerating) return; setComputerUseSaving(true); try { + if (getChatDraft(chatId)) { updateChatDraft(chatId, { computerUseEnabled: enabled }); return; } const updated = await chatsApi.setComputerUse(chatId, enabled); qc.setQueryData(queryKeys.chat(chatId), updated); } catch (changeError) { @@ -1622,6 +1660,11 @@ export function ChatPane({ chatId }: { chatId: string }) { throw new Error("Save or discard the open file's edits before switching workspaces."); } if (workspaceId === effectiveWorkspaceId) return; + if (getChatDraft(chatId)) { + updateChatDraft(chatId, { workspaceId }); + selectWorkspace(workspaceId); + return; + } const updated = await chatsApi.moveEmptyToWorkspace(chatId, workspaceId); qc.setQueryData(queryKeys.chat(chatId), updated); selectWorkspace(workspaceId); @@ -1704,41 +1747,15 @@ export function ChatPane({ chatId }: { chatId: string }) { } return; } - let created: Chat; - try { - created = await chatsApi.create({ workspaceId: workspace.id }); - } catch (error) { - toast.info( - error instanceof Error - ? `The worktree was created, but its chat could not be created: ${error.message}` - : "The worktree was created, but its chat could not be created.", - ); - return; - } - qc.setQueryData(queryKeys.chat(created.id), created); - qc.setQueryData(queryKeys.chatsIn(workspace.id), (current) => [ - { - id: created.id, - title: created.title, - workspaceId: workspace.id, - providerId: created.providerId, - model: created.model, - createdAt: created.createdAt, - updatedAt: created.updatedAt, - }, - ...(current ?? []).filter((entry) => entry.id !== created.id), - ]); - if (!mountedRef.current || chatIdRef.current !== sourceChatId) return; + const created = createChatDraft(workspace.id).chat; selectWorkspace(workspace.id); - void qc.invalidateQueries({ queryKey: queryKeys.chats }); try { await navigate({ to: "/chat/$chatId", params: { chatId: created.id } }); requestAnimationFrame(() => composerRef.current?.focus({ preventScroll: true })); } catch { + discardChatDraft(created.id); selectWorkspace(effectiveWorkspace.id); - toast.info( - "The worktree and chat were created, but Aiden could not open them automatically.", - ); + toast.info("The worktree was created, but Aiden could not open its new chat."); } }, [ @@ -2087,7 +2104,9 @@ export function ChatPane({ chatId }: { chatId: string }) { hasMessages={hasMessages} chatId={chatId} onSend={handleSend} - onQueue={queueMessage} + freezeWhileSending={Boolean(draft)} + firstMessageSaving={draft?.sending === true} + onQueue={draft ? undefined : queueMessage} hasQueuedMessages={queuedState.messages.length > 0} queuedMessages={ environmentPanel.openReview("changes")} - sessionChat={chat.data ?? undefined} + sessionChat={draft ? undefined : chat.data ?? undefined} authenticatedProviders={authenticatedProviders} onCloneChat={() => copyChat()} onForkChat={(throughAssistantMessageId) => copyChat(throughAssistantMessageId)} onExportChat={exportChat} - onCompactChat={(engine) => chatsApi.compact(chatId, engine)} - onCancelCompact={() => chatsApi.cancelCompact(chatId)} + onCompactChat={draft ? undefined : (engine) => chatsApi.compact(chatId, engine)} + onCancelCompact={draft ? undefined : () => chatsApi.cancelCompact(chatId)} onLogoutProvider={logoutProvider} thinkingControl={ googleThinkingSupported ? ( @@ -2219,8 +2238,10 @@ export function ChatPane({ chatId }: { chatId: string }) { providers={settings.data ? (providers.data ?? []) : []} providerId={providerId} model={model} - onChange={select} - disabled={isGenerating || thinkingSaving} + onChange={(nextProviderId, nextModel) => { + if (!getChatDraft(chatId)?.sending) select(nextProviderId, nextModel); + }} + disabled={isGenerating || isStartingGeneration || thinkingSaving} settingsBlockedReason={settingsBlockedReason} hiddenModelsByProvider={settings.data?.hiddenModelsByProvider} /> diff --git a/renderer/main/chat-transition.test.tsx b/renderer/main/chat-transition.test.tsx index 0cfe1013..7a590735 100644 --- a/renderer/main/chat-transition.test.tsx +++ b/renderer/main/chat-transition.test.tsx @@ -122,7 +122,9 @@ test("session navigation seeds caches, respects route intent, and restores desti ); assert.match(worktree, /setQueryData/u); assert.match(worktree, /chatIdRef\.current !== sourceChatId/u); - assert.match(worktree, /The worktree was created, but its chat could not be created/u); + assert.match(worktree, /The worktree was created, but Aiden could not open its new chat/u); + assert.match(worktree, /createChatDraft\(workspace.id\)/u); + assert.doesNotMatch(worktree, /chatsApi\.create\(/u); assert.match(worktree, /await navigate/u); assert.match(worktree, /requestAnimationFrame\(\(\) => composerRef\.current\?\.focus/u); }); @@ -314,3 +316,34 @@ test("a revisited detached stream restores the responding window from its last t /streamingText:[\s\S]{0,180}detachedGenerationDraining[\s\S]{0,120}displayedStreamingText/u, ); }); + +test("every ordinary new-agent entry opens a draft without eager creation", () => { + for (const file of ["./chat-layout.tsx", "./root-view.tsx", "./chat-pane.tsx", "../components/chat-sidebar.tsx"]) { + const implementation = source(file); + assert.match(implementation, /createChatDraft\(/u, file); + assert.doesNotMatch(implementation, /chatsApi\s*\.create\(/u, file); + } +}); + +test("first-message promotion seeds the real cache before releasing draft state without navigation", () => { + const pane = source("./chat-pane.tsx"); + const send = between(pane, "const handleSend = React.useCallback(", "const handleStop = React.useCallback"); + assert.match(pane, /useChat\(draft \? undefined : chatId\)/u); + assert.match(send, /chatsApi\.createWithFirstMessage\(/u); + const seed = send.indexOf("qc.setQueryData(queryKeys.chat(chatId), updated)"); + const promote = send.indexOf("finishChatDraftSend(chatId, true)"); + const ownerGuard = send.indexOf("(firstDraft && (!mountedRef.current || chatIdRef.current !== chatId))"); + const start = send.indexOf("await runGeneration(messageTurnId)"); + assert.ok(seed >= 0 && promote > seed && ownerGuard > promote && start > ownerGuard); + assert.match(send, /await chatsApi\.abandonTurn\(chatId, messageTurnId\)/u); + assert.doesNotMatch(send, /navigate\(/u); + assert.match(pane, /enabled: !draft && ready/u); +}); + + +test("ordinary sends retain their generation-intent guard while draft promotion also checks its view owner", () => { + const pane = source("./chat-pane.tsx"); + const send = between(pane, "const handleSend = React.useCallback(", "const handleStop = React.useCallback"); + assert.match(send, /if \(\s*generationIntentRef\.current !== generationIntent \|\|\s*\(firstDraft && \(!mountedRef\.current \|\| chatIdRef\.current !== chatId\)\)\s*\) \{/u); + assert.doesNotMatch(send, /if \(!mountedRef\.current \|\| chatIdRef\.current !== chatId \|\| generationIntentRef\.current/u); +}); diff --git a/renderer/main/root-view.tsx b/renderer/main/root-view.tsx index d4ba60cc..553c113a 100644 --- a/renderer/main/root-view.tsx +++ b/renderer/main/root-view.tsx @@ -1,3 +1,4 @@ +import { createChatDraft, discardChatDraft } from "../lib/chat-draft"; import { Outlet, useNavigate, useRouterState } from "@tanstack/react-router"; import { useQueryClient } from "@tanstack/react-query"; import * as React from "react"; @@ -138,9 +139,13 @@ function RootContent() { toast.info(navigationBlockedReason); return; } - const chat = await chatsApi.create({ workspaceId: activeId }); - await queryClient.invalidateQueries({ queryKey: queryKeys.chats }); - await navigate({ to: "/chat/$chatId", params: { chatId: chat.id } }); + const chat = createChatDraft(activeId).chat; + try { + await navigate({ to: "/chat/$chatId", params: { chatId: chat.id } }); + } catch (error) { + discardChatDraft(chat.id); + throw error; + } }, Boolean(activeId) && !appendReconciliationRequired, ); diff --git a/tests/e2e/chat-message-queue.spec.ts b/tests/e2e/chat-message-queue.spec.ts index c25e8a82..56ddcc75 100644 --- a/tests/e2e/chat-message-queue.spec.ts +++ b/tests/e2e/chat-message-queue.spec.ts @@ -139,8 +139,8 @@ test("an unsuccessful first message save keeps the workspace bar and draft visib const { page } = aiden; await finishLmStudioOnboarding(page); await aiden.app.evaluate(({ ipcMain }) => { - ipcMain.removeHandler("chats:appendMessage"); - ipcMain.handle("chats:appendMessage", () => { + ipcMain.removeHandler("chats:createWithFirstMessage"); + ipcMain.handle("chats:createWithFirstMessage", () => { throw new Error("Queue test: message save rejected"); }); }); diff --git a/tests/e2e/draft-agent-chats.spec.ts b/tests/e2e/draft-agent-chats.spec.ts new file mode 100644 index 00000000..1d0a8b03 --- /dev/null +++ b/tests/e2e/draft-agent-chats.spec.ts @@ -0,0 +1,176 @@ +import { readFile, writeFile, rm, mkdir } from "node:fs/promises"; +import path from "node:path"; +import { expect, finishLmStudioOnboarding, test } from "./fixtures"; +import { migratePiSessionJournal } from "../../main/services/pi-session-migration"; + +type StoredChat = { id: string; title: string; workspaceId?: string; botId?: string; + createdAt: number; updatedAt: number; messages: Array<{ role: string; content: string }> }; +async function index(root: string): Promise[]> { + try { return JSON.parse(await readFile(path.join(root, "chats", "index.json"), "utf8")); } + catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return []; throw error; } +} +async function regularChats(root: string): Promise { + const metas = (await index(root)).filter((chat) => !chat.botId && chat.workspaceId !== "assistant"); + return Promise.all(metas.map(async (chat) => JSON.parse(await readFile(path.join(root, "chats", `${chat.id}.json`), "utf8")))); +} + +test("abandoned drafts never persist; first send creates exactly one conversation and survives promotion", async ({ aiden }) => { + const { page, userDataDir } = aiden; + await finishLmStudioOnboarding(page); + expect(await regularChats(userDataDir)).toEqual([]); + await page.locator("textarea").fill("Unsent draft must disappear"); + await page.getByRole("button", { name: "Settings", exact: true }).click(); + expect(await regularChats(userDataDir)).toEqual([]); + await page.getByRole("button", { name: "Back to app", exact: true }).click(); + await expect(page.locator("textarea")).toHaveValue(""); + for (let count = 0; count < 3; count++) { + await page.getByRole("button", { name: "New Agent", exact: true }).click(); + await expect(page.locator("textarea")).toBeVisible(); + } + expect(await regularChats(userDataDir)).toEqual([]); + await page.locator("textarea").fill("First persisted draft message"); + await page.locator("textarea").press("Enter"); + await expect.poll(async () => (await regularChats(userDataDir)).length).toBe(1); + await expect.poll(async () => (await regularChats(userDataDir))[0]?.messages.some((message) => message.role === "assistant")).toBe(true); + const [sent] = await regularChats(userDataDir); + expect(sent.messages.filter((message) => message.role === "user").map((message) => message.content)).toEqual(["First persisted draft message"]); + await page.getByRole("button", { name: "New Agent", exact: true }).click(); + await page.locator("textarea").fill("Another abandoned draft"); + const reopened = await aiden.relaunch(); + await expect(reopened.locator("textarea")).toBeVisible(); + expect((await regularChats(userDataDir)).map((chat) => chat.id)).toEqual([sent.id]); +}); + +test("startup migration removes legacy empty chats once and keeps sent conversations", async ({ aiden }) => { + await finishLmStudioOnboarding(aiden.page); + await aiden.page.locator("textarea").fill("Keep this real conversation"); + await aiden.page.locator("textarea").press("Enter"); + await expect.poll(async () => (await regularChats(aiden.userDataDir))[0]?.messages.some((message) => message.role === "assistant")).toBe(true); + const before = await index(aiden.userDataDir); + const workspaceId = before.find((chat) => !chat.botId && chat.workspaceId !== "assistant")!.workspaceId; + const seed = async (id: string) => { + const chat: StoredChat = { id, workspaceId, title: "New chat", messages: [], createdAt: Date.now(), updatedAt: Date.now() }; + await writeFile(path.join(aiden.userDataDir, "chats", `${id}.json`), JSON.stringify(chat)); + const { messages: _messages, ...meta } = chat; + await writeFile(path.join(aiden.userDataDir, "chats", "index.json"), JSON.stringify([...(await index(aiden.userDataDir)), meta])); + }; + await seed("legacy-empty"); + await seed("private-empty"); + await seed("header-only-empty"); + await seed("promoted-empty"); + const journalRoot = path.join(aiden.userDataDir, "pi-compaction-sessions"); + await mkdir(journalRoot, { recursive: true }); + const journalPath = path.join(journalRoot, "private-empty.jsonl"); + const journal = `${JSON.stringify({ type: "session", version: 3, id: "private-empty", + timestamp: "2026-08-31T12:00:00.000Z", cwd: journalRoot, + metadata: { kind: "aiden-chat-compaction-v1", chatId: "private-empty" } })}\n`; + const privateJournal = journal + `${JSON.stringify({ type: "message", id: "private-message", parentId: null, + timestamp: "2026-08-31T12:00:01.000Z", message: { role: "user", content: "Private journal history", timestamp: 1 } })}\n`; + await writeFile(journalPath, privateJournal); + await writeFile(path.join(journalRoot, "header-only-empty.jsonl"), journal.replace(/private-empty/gu, "header-only-empty")); + const promotedPath = path.join(journalRoot, "promoted-empty.jsonl"); + await writeFile(promotedPath, journal.replace(/private-empty/gu, "promoted-empty")); + const promoted = await migratePiSessionJournal(promotedPath, "promoted-empty"); + const journalIndexPath = path.join(journalRoot, "aiden-journal-index.json"); + let journalIndex: { version: number; chats: Record } = { version: 1, chats: {} }; + try { journalIndex = JSON.parse(await readFile(journalIndexPath, "utf8")); } + catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; } + journalIndex.chats["promoted-empty"] = [promotedPath, promoted.receipt.backupPath, promoted.receiptPath]; + await writeFile(journalIndexPath, JSON.stringify(journalIndex)); + await rm(path.join(aiden.userDataDir, "empty-workspace-chats-migration-v1.json"), { force: true }); + const reopened = await aiden.relaunch(); + await expect(reopened.locator("textarea")).toBeVisible(); + expect((await index(aiden.userDataDir)).some((chat) => chat.id === "legacy-empty")).toBe(false); + expect((await index(aiden.userDataDir)).some((chat) => chat.id === "private-empty")).toBe(true); + expect(await readFile(journalPath, "utf8")).toBe(privateJournal); + expect((await index(aiden.userDataDir)).some((chat) => chat.id === "header-only-empty")).toBe(false); + expect((await index(aiden.userDataDir)).some((chat) => chat.id === "promoted-empty")).toBe(false); + await expect.poll(async () => { try { await readFile(promoted.receiptPath); return true; } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return false; throw error; } }).toBe(false); + expect((await regularChats(aiden.userDataDir)).some((chat) => chat.messages.some((message) => message.content === "Keep this real conversation"))).toBe(true); + await expect.poll(async () => JSON.parse(await readFile(path.join(aiden.userDataDir, "empty-workspace-chats-migration-v1.json"), "utf8")).complete).toBe(true); + await seed("later-remote-empty"); + await aiden.relaunch(); + expect((await index(aiden.userDataDir)).some((chat) => chat.id === "later-remote-empty")).toBe(true); +}); + +test("failed first send retains its draft and retry commits once", async ({ aiden }) => { + await finishLmStudioOnboarding(aiden.page); + await aiden.app.evaluate(({ ipcMain }) => { + // Test-only fault injection at the real IPC boundary, without a production seam. + const handlers = (ipcMain as unknown as { _invokeHandlers: Map unknown> })._invokeHandlers; + const original = handlers.get("chats:createWithFirstMessage")!; + let fail = true; + handlers.set("chats:createWithFirstMessage", (event, ...args) => { + if (fail) { fail = false; throw new Error("Test first-send storage failure"); } + return original(event, ...args); + }); + }); + const composer = aiden.page.locator("textarea"); + await composer.fill("Recover this first message"); + await composer.press("Enter"); + await expect(aiden.page.getByText(/Test first-send storage failure/u)).toBeVisible(); + await expect(composer).toHaveValue("Recover this first message"); + expect(await regularChats(aiden.userDataDir)).toEqual([]); + await composer.press("Enter"); + await expect.poll(async () => (await regularChats(aiden.userDataDir))[0]?.messages.some((message) => message.role === "assistant")).toBe(true); + expect((await regularChats(aiden.userDataDir))).toHaveLength(1); + expect((await regularChats(aiden.userDataDir))[0].messages.filter((message) => message.role === "user")).toHaveLength(1); +}); + +test("late first-send receipt cannot navigate back or start a response in a different draft", async ({ aiden }) => { + await finishLmStudioOnboarding(aiden.page); + await aiden.app.evaluate(({ ipcMain }) => { + const handlers = (ipcMain as unknown as { _invokeHandlers: Map unknown> })._invokeHandlers; + const original = handlers.get("chats:createWithFirstMessage")!; + let release: () => void = () => {}; + const gate = new Promise((resolve) => { release = resolve; }); + (globalThis as unknown as { releaseDraftReceipt: () => void }).releaseDraftReceipt = release; + handlers.set("chats:createWithFirstMessage", async (event, ...args) => { + const receipt = await original(event, ...args); + await gate; + return receipt; + }); + }); + await aiden.page.locator("textarea").fill("Committed before navigating away"); + await aiden.page.locator("textarea").press("Enter"); + await expect.poll(async () => (await regularChats(aiden.userDataDir)).length).toBe(1); + await aiden.page.getByRole("button", { name: "New Agent", exact: true }).click(); + await aiden.page.locator("textarea").fill("Keep the new draft here"); + const destination = aiden.page.url(); + await aiden.app.evaluate(() => (globalThis as unknown as { releaseDraftReceipt: () => void }).releaseDraftReceipt()); + // The published sidebar receipt proves the late save completed without + // selecting or generating the previously committed conversation. + await expect(aiden.page.locator("[data-sidebar]").getByRole("button", { name: /Committed before navigating away/u })).toBeVisible(); + expect(aiden.page.url()).toBe(destination); + await expect(aiden.page.locator("textarea")).toHaveValue("Keep the new draft here"); + const [chat] = await regularChats(aiden.userDataDir); + expect(chat.messages.map((message) => message.role)).toEqual(["user"]); + expect(aiden.lmStudio.requests.filter((request) => request.url.endsWith("/chat/completions"))).toHaveLength(0); +}); + +test("unreadable private history preserves candidates without leaving a future migration sweep", async ({ aiden }) => { + await finishLmStudioOnboarding(aiden.page); + const seed = async (id: string) => { + const chat: StoredChat = { id, workspaceId: "default", title: "New chat", messages: [], createdAt: Date.now(), updatedAt: Date.now() }; + const directory = path.join(aiden.userDataDir, "chats"); + await mkdir(directory, { recursive: true }); + await writeFile(path.join(directory, `${id}.json`), JSON.stringify(chat)); + const { messages: _messages, ...meta } = chat; + await writeFile(path.join(directory, "index.json"), JSON.stringify([...(await index(aiden.userDataDir)), meta])); + }; + await seed("uncertain-empty"); + const journalRoot = path.join(aiden.userDataDir, "pi-compaction-sessions"); + await mkdir(journalRoot, { recursive: true }); + const corruptJournal = path.join(journalRoot, "unreadable.jsonl.corrupt-test"); + await writeFile(corruptJournal, "{broken header"); + const marker = path.join(aiden.userDataDir, "empty-workspace-chats-migration-v1.json"); + await rm(marker, { force: true }); + const reopened = await aiden.relaunch(); + await expect(reopened.locator("textarea")).toBeVisible(); + expect(JSON.parse(await readFile(marker, "utf8")).complete).toBe(true); + expect((await index(aiden.userDataDir)).some((chat) => chat.id === "uncertain-empty")).toBe(true); + await rm(corruptJournal); + await seed("created-after-migration"); + await aiden.relaunch(); + expect((await index(aiden.userDataDir)).some((chat) => chat.id === "created-after-migration")).toBe(true); +});