toeverything/AFFiNE · error · CopilotSessionDeleted

copilot_session_deleted

copilot_session_deleted

Error message

Copilot session has been deleted.

What it means

CopilotSessionDeleted (action_forbidden / copilot_session_deleted) thrown by CopilotSessionModel.find (packages/backend/server/src/models/copilot-session.ts:472). find() looks up a root (parentSessionId null), no-action chat session matching userId/workspaceId/docId. If a matching row exists but its deletedAt is non-null (soft-deleted), the session cannot be reused and the guard throws.

Source

Thrown at packages/backend/server/src/models/copilot-session.ts:472

    const extraCondition: Record<string, any> = {};
    if (state.parentSessionId) {
      // also check session id if provided session is forked session
      extraCondition.id = state.sessionId;
      extraCondition.parentSessionId = state.parentSessionId;
    }

    const session = await this.db.aiSession.findFirst({
      where: {
        userId: state.userId,
        workspaceId: state.workspaceId,
        docId: state.docId,
        parentSessionId: null,
        ...this.noActionPromptCondition(),
        ...extraCondition,
      },
      select: { id: true, deletedAt: true },
    });
    if (session?.deletedAt) throw new CopilotSessionDeleted();
    return session?.id;
  }

  @Transactional()
  async getExists<Select extends Prisma.AiSessionSelect>(
    sessionId: string,
    select?: Select,
    where?: Omit<Prisma.AiSessionWhereInput, 'id' | 'deletedAt'>
  ) {
    return (await this.db.aiSession.findUnique({
      where: { ...where, id: sessionId, deletedAt: null },
      select,
    })) as Prisma.AiSessionGetPayload<{ select: Select }> | null;
  }

  @Transactional()
  async get(sessionId: string) {
    return await this.getExists(sessionId, {

View on GitHub (pinned to 26c515e050)

Solutions

  1. Stop reusing the deleted session: create a fresh one (reuseChat=false or clear the cached sessionId client-side).
  2. Before reusing, check session.deletedAt via getExists and skip reuse if set.
  3. If restoration is intended, clear deletedAt in the DB (operational step) before reusing.
  4. Client-side: on receiving copilot_session_deleted, evict the cached sessionId and retry as a new session.

Example fix

// before
const id = await sessionModel.create(state, /* reuseChat */ true);
// after
const existing = await sessionModel.getExists(state.sessionId, { id: true, deletedAt: true });
if (existing?.deletedAt) {
  state.sessionId = undefined; // force new session
}
const id = await sessionModel.create(state, /* reuseChat */ true);
Defensive patterns

Strategy: validation

Validate before calling

const existing = await sessionModel.getExists(state.sessionId, { id: true, deletedAt: true });
if (existing?.deletedAt) {
  state.sessionId = undefined; // force new session
}
const id = await sessionModel.create(state, true);

Type guard

const isReusable = (s: { id: string; deletedAt: Date | null } | null): s is { id: string; deletedAt: null } =>
  s !== null && s.deletedAt === null;

Try / catch

try {
  await sessionModel.create(state, true);
} catch (e) {
  if (e instanceof UserFriendlyError && e.code === 'copilot_session_deleted') {
    state.sessionId = undefined;
    await sessionModel.create(state, true);
    return;
  }
  throw e;
}

Prevention

When it happens

Trigger: Calling find(state) — typically from create(state, reuseChat=true) at copilot-session.ts:380 — when the only matching session has been soft-deleted (deletedAt set). The query selects id and deletedAt; if deletedAt is truthy, reuse is refused.

Common situations: User deleted a chat session in the UI, then the client (still holding the prior state) tries to reuse it instead of creating a new one; race between delete and the next message send; reuseChat=true passed after a prior cleanup job soft-deleted old sessions.

Related errors


AI-assisted analysis of toeverything/AFFiNE@26c515e050 (2026-08-12). Data as JSON: /api/errors/f1baf6b6ada5ef3d. Report an issue: GitHub.