import { ConflictException, ForbiddenException, NotFoundException } from '@nestjs/common';
import { AiChatService } from './ai-chat.service';

const authenticatedUser = {
  id: 7,
  username: 'tester',
  permissions: ['ai:chat:use'],
  isSuperAdmin: false,
};

function createService(
  conversationOverrides: Record<string, unknown> = {},
  messageOverrides: Record<string, unknown> = {},
) {
  const conversations = {
    findOne: jest.fn(),
    find: jest.fn(),
    create: jest.fn((value) => value),
    save: jest.fn(async (value) => ({ id: 1, ...value })),
    remove: jest.fn(),
    ...conversationOverrides,
  };
  const messages = {
    exists: jest.fn().mockResolvedValue(false),
    createQueryBuilder: jest.fn(),
    ...messageOverrides,
  };
  const reviewService = {
    createReview: jest.fn(),
    expirePreviousReviews: jest.fn().mockResolvedValue([]),
    findOwnedPending: jest.fn(),
    findOwned: jest.fn(),
    findPendingByAssistantMessage: jest.fn(),
    submitSection: jest.fn(),
    submitGroup: jest.fn(),
    submitAll: jest.fn(),
    parseSections: jest.fn((json: string) => JSON.parse(json)),
    serialize: jest.fn((value) => value),
  };
  const service = new AiChatService(
    conversations as never,
    messages as never,
    {} as never,
    {} as never,
    {} as never,
    {} as never,
    {} as never,
    {} as never,
    {
      createForm: jest.fn(),
      findOwnedPending: jest.fn(),
      validateValues: jest.fn(),
      markSubmitted: jest.fn(),
      serialize: jest.fn((value) => value),
    } as never,
    reviewService as never,
    {
      createChart: jest.fn(),
      serialize: jest.fn((value) => value),
    } as never,
    { createForUser: jest.fn().mockReturnValue({}) } as never,
    { assertPermission: jest.fn(), canPermission: jest.fn() } as never,
  );
  return { service, conversations, reviewService };
}

describe('AiChatService', () => {
  it('按 userId 查询会话，无法借 id 访问其他用户会话', async () => {
    const { service, conversations } = createService({ findOne: jest.fn().mockResolvedValue(null) });
    await expect(service.getMessages(7, 99)).rejects.toBeInstanceOf(NotFoundException);
    expect(conversations.findOne).toHaveBeenCalledWith({ where: { id: 99, userId: 7 } });
  });

  it('生成中的会话禁止删除', async () => {
    const entity = { id: 2, userId: 7 };
    const { service, conversations } = createService({ findOne: jest.fn().mockResolvedValue(entity) });
    (service as unknown as { activeConversations: Set<number> }).activeConversations.add(2);
    await expect(service.deleteConversation(7, 2)).rejects.toBeInstanceOf(ConflictException);
    expect(conversations.remove).not.toHaveBeenCalled();
  });

  it('批量删除全部会话并清理附件', async () => {
    const entities = [
      { id: 2, userId: 7 },
      { id: 3, userId: 7 },
    ];
    const getRawMany = jest.fn().mockResolvedValue([{ id: 10 }, { id: 11 }]);
    const queryBuilder = {
      innerJoin: jest.fn().mockReturnThis(),
      where: jest.fn().mockReturnThis(),
      select: jest.fn().mockReturnThis(),
      getRawMany,
    };
    const { service, conversations } = createService(
      { find: jest.fn().mockResolvedValue(entities) },
      { createQueryBuilder: jest.fn().mockReturnValue(queryBuilder) },
    );
    const removeOrphans = jest.fn().mockResolvedValue(undefined);
    (
      service as unknown as {
        attachmentService: { removeOrphans: jest.Mock };
      }
    ).attachmentService.removeOrphans = removeOrphans;

    await expect(service.deleteAllConversations(7)).resolves.toBe(2);
    expect(conversations.find).toHaveBeenCalledWith({ where: { userId: 7 } });
    expect(queryBuilder.where).toHaveBeenCalledWith('message.conversation_id IN (:...ids)', {
      ids: [2, 3],
    });
    expect(conversations.remove).toHaveBeenCalledWith(entities);
    expect(removeOrphans).toHaveBeenCalledWith(7, [10, 11]);
  });

  it('存在生成中的会话时禁止批量删除', async () => {
    const entities = [
      { id: 2, userId: 7 },
      { id: 3, userId: 7 },
    ];
    const { service, conversations } = createService({
      find: jest.fn().mockResolvedValue(entities),
    });
    (service as unknown as { activeConversations: Set<number> }).activeConversations.add(2);

    await expect(service.deleteAllConversations(7)).rejects.toBeInstanceOf(ConflictException);
    expect(conversations.remove).not.toHaveBeenCalled();
  });

  it('没有会话时批量删除返回 0', async () => {
    const { service, conversations } = createService({
      find: jest.fn().mockResolvedValue([]),
    });
    await expect(service.deleteAllConversations(7)).resolves.toBe(0);
    expect(conversations.remove).not.toHaveBeenCalled();
  });

  it('并发获取同一会话时只允许一个请求进入生成流程', async () => {
    let resolveExists!: (value: boolean) => void;
    const exists = jest.fn(
      () => new Promise<boolean>((resolve) => {
        resolveExists = resolve;
      }),
    );
    const { service } = createService();
    (service as unknown as { messages: { exists: typeof exists } }).messages.exists = exists;
    const acquire = (service as unknown as { acquireConversation(id: number): Promise<void> })
      .acquireConversation.bind(service);

    const first = acquire(5);
    await expect(acquire(5)).rejects.toBeInstanceOf(ConflictException);
    resolveExists(false);
    await expect(first).resolves.toBeUndefined();
  });

  it('工具摘要脱敏并限制长度', () => {
    const { service } = createService();
    const summarize = (service as unknown as { summarize(value: unknown): string }).summarize.bind(service);
    const summary = summarize({
      phone: '13800138000',
      idCard: '11010519491231002X',
      note: `联系电话 13900139000 ${'x'.repeat(3000)}`,
      apiKey: 'sk-sensitive-value',
    });
    expect(summary).not.toContain('13800138000');
    expect(summary).not.toContain('13900139000');
    expect(summary).not.toContain('11010519491231002X');
    expect(summary).not.toContain('sk-sensitive-value');
    expect(summary.length).toBeLessThanOrEqual(2000);
  });

  it('超大附件文本在进入模型前被截断并提示', async () => {
    const { service } = createService();
    (service as unknown as { attachmentService: { toModelParts: jest.Mock } }).attachmentService = {
      toModelParts: jest.fn().mockResolvedValue([
        { attachment: { id: 1, originalName: 'big.xlsx' }, text: 'x'.repeat(120000) },
      ]),
    };
    const build = (service as unknown as {
      buildUserContent(
        text: string,
        attachments: unknown[],
        supportsVision: boolean,
      ): Promise<string | unknown[]>;
    }).buildUserContent.bind(service);
    const result = await build('请看这个文件', [{ id: 1 }], false);
    expect(typeof result).toBe('string');
    expect(result as string).toContain('内容过长');
    expect((result as string).length).toBeLessThan(50000);
  });

  it('大 Excel 附件在进入模型前生成概览并提示可动态读取', async () => {
    const { service } = createService();
    (service as unknown as { excelReader: { overview: jest.Mock } }).excelReader = {
      overview: jest.fn().mockResolvedValue({ text: '# 名单（共 100 行）\n表头\t列2' }),
    };
    (service as unknown as { attachmentService: unknown }).attachmentService = {
      readStoredBuffer: jest.fn().mockResolvedValue(Buffer.from('x')),
      toModelParts: jest.fn().mockResolvedValue([
        {
          attachment: {
            id: 1,
            originalName: 'big.xlsx',
            mimeType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
          },
          text: 'x'.repeat(30000),
        },
      ]),
    };
    const build = (service as unknown as {
      buildUserContent(
        text: string,
        attachments: unknown[],
        supportsVision: boolean,
      ): Promise<string | unknown[]>;
    }).buildUserContent.bind(service);
    const result = await build('请看这个文件', [{ id: 1 }], false);
    expect(result as string).toContain('# 名单（共 100 行）');
    expect(result as string).toContain('office_analyze');
  });

  it.each([
    { abort: false, expectedStatus: 'failed', expectedCode: 'UPSTREAM_ERROR' },
    { abort: true, expectedStatus: 'cancelled', expectedCode: 'CLIENT_ABORTED' },
  ])('流中断后保存已生成内容和 $expectedStatus 状态', async ({ abort, expectedStatus, expectedCode }) => {
    const conversation = {
      id: 3,
      userId: 7,
      title: '测试',
      lockedSkillKey: null,
      lastMessageAt: null,
    };
    const assistant = {
      id: 12,
      conversationId: 3,
      role: 'assistant',
      content: '',
      reasoningContent: null,
      status: 'pending',
      errorCode: null,
    };
    const messageSave = jest.fn(async (value) => value);
    const messages = {
      exists: jest.fn().mockResolvedValue(false),
      find: jest.fn().mockResolvedValue([]),
      save: messageSave,
    };
    const manager = {
      create: jest.fn((_entity, value) => value),
      save: jest
        .fn()
        .mockResolvedValueOnce({ id: 11, conversationId: 3, role: 'user', content: '查询' })
        .mockResolvedValueOnce(assistant),
      update: jest.fn(),
    };
    const abortController = new AbortController();
    const modelStream = {
      stream: async function* () {
        yield { type: 'content' as const, delta: '部分回答' };
        if (abort) {
          abortController.abort(new Error('client disconnected'));
          yield { type: 'complete' as const, toolCalls: [] };
          return;
        }
        throw new Error('upstream failed');
      },
    };
    const service = new AiChatService(
      { findOne: jest.fn().mockResolvedValue(conversation) } as never,
      messages as never,
      { save: jest.fn() } as never,
      { transaction: jest.fn(async (callback) => callback(manager)) } as never,
      { getRuntimeConfig: jest.fn().mockResolvedValue({ supportsVision: false }) } as never,
      { listAvailable: jest.fn().mockReturnValue([]) } as never,
      modelStream as never,
      {
        requireReadyOwned: jest.fn().mockResolvedValue([]),
        toModelParts: jest.fn().mockResolvedValue([]),
        serialize: jest.fn((value) => value),
      } as never,
      {
        createForm: jest.fn(),
        findOwnedPending: jest.fn(),
        validateValues: jest.fn(),
        markSubmitted: jest.fn(),
        serialize: jest.fn((value) => value),
      } as never,
      {
        createReview: jest.fn(),
        expirePreviousReviews: jest.fn().mockResolvedValue([]),
        findOwnedPending: jest.fn(),
        findPendingByAssistantMessage: jest.fn(),
        serialize: jest.fn((value) => value),
        submit: jest.fn(),
      } as never,
      {
        createChart: jest.fn(),
        serialize: jest.fn((value) => value),
      } as never,
      { createForUser: jest.fn().mockReturnValue({}) } as never,
      { assertPermission: jest.fn(), canPermission: jest.fn() } as never,
    );
    const emitted: Array<{ event: string; data: Record<string, unknown> }> = [];
    const run = service.streamMessage(
      authenticatedUser as never,
      3,
      {
        message: '查询',
        attachmentIds: [],
        skillKey: null,
        clientRequestId: '6a8bc680-3cb5-4f2d-85ee-974974e0f194',
      },
      abortController.signal,
      (event, data) => emitted.push({ event, data }),
      jest.fn(),
    );

    if (abort) await expect(run).resolves.toBeUndefined();
    else await expect(run).rejects.toThrow('upstream failed');

    expect(messageSave).toHaveBeenCalledWith(
      expect.objectContaining({
        id: 12,
        content: '部分回答',
        status: expectedStatus,
        errorCode: expectedCode,
      }),
    );
    expect(emitted.some(({ event }) => event === 'content.delta')).toBe(true);
    expect(emitted.some(({ event }) => event === 'message.cancelled')).toBe(abort);
  });

  it('普通对话中模型直接调用 create_student 被拒绝', async () => {
    const { service } = createService();
    const toolRuns = {
      create: jest.fn((value) => value),
      save: jest.fn(async (value) => value),
      find: jest.fn().mockResolvedValue([]),
    };
    (service as unknown as { toolRuns: typeof toolRuns }).toolRuns = toolRuns;
    const emitted: Array<{ event: string }> = [];
    const deny = (service as unknown as {
      denyWriteTool(
        messageId: number,
        call: { id: string },
        emit: (event: string, data: Record<string, unknown>) => void,
      ): Promise<string>;
    }).denyWriteTool.bind(service);
    const payload = await deny(12, { id: 'call-1' }, (event) => emitted.push({ event }));
    expect(JSON.parse(payload)).toEqual({ status: 'failed', error: '该操作需要表单确认' });
    expect(emitted).toEqual([{ event: 'tool.failed' }]);
  });

  it('render_form 生成的表单在生成结束保存时保留在消息 metadata 中', async () => {
    const conversation = {
      id: 3,
      userId: 7,
      title: '测试',
      lockedSkillKey: null,
      lastMessageAt: null,
    };
    const assistant = {
      id: 12,
      conversationId: 3,
      role: 'assistant',
      content: '',
      reasoningContent: null,
      status: 'pending',
      errorCode: null,
      replyToMessageId: 11,
      feedback: null,
      feedbackReason: null,
      metadata: {},
    };
    const formShape = {
      id: 'form-1',
      conversationId: 3,
      title: '新增学生',
      status: 'pending',
      fields: [],
    };
    const messageSave = jest.fn(async (value) => value);
    const messages = {
      exists: jest.fn().mockResolvedValue(false),
      find: jest.fn().mockResolvedValue([]),
      findOne: jest.fn().mockImplementation((options?: unknown) => {
        const opts = options as { select?: { metadata?: boolean } } | undefined;
        if (opts?.select?.metadata) return Promise.resolve({ metadata: { a2uiForm: formShape } });
        return Promise.resolve(assistant);
      }),
      save: messageSave,
    };
    const manager = {
      create: jest.fn((_entity, value) => value),
      save: jest
        .fn()
        .mockResolvedValueOnce({ id: 11, conversationId: 3, role: 'user', content: '帮我新增一个学生' })
        .mockResolvedValueOnce(assistant),
      update: jest.fn(),
    };
    const toolRuns = {
      create: jest.fn((value) => value),
      save: jest.fn(async (value) => value),
      find: jest.fn().mockResolvedValue([]),
    };
    const modelStream = {
      stream: async function* () {
        yield {
          type: 'complete' as const,
          toolCalls: [
            {
              id: 'call-1',
              name: 'render_form',
              arguments: JSON.stringify({
                title: '新增学生',
                fields: [{ name: 'name', label: '姓名', type: 'input', required: true }],
              }),
            },
          ],
        };
      },
    };
    const service = new AiChatService(
      { findOne: jest.fn().mockResolvedValue(conversation) } as never,
      messages as never,
      toolRuns as never,
      { transaction: jest.fn(async (callback) => callback(manager)) } as never,
      { getRuntimeConfig: jest.fn().mockResolvedValue({ supportsVision: false }) } as never,
      { listAvailable: jest.fn().mockReturnValue([]) } as never,
      modelStream as never,
      {
        requireReadyOwned: jest.fn().mockResolvedValue([]),
        toModelParts: jest.fn().mockResolvedValue([]),
        serialize: jest.fn((value) => value),
      } as never,
      {
        createForm: jest.fn().mockResolvedValue(formShape),
        findOwnedPending: jest.fn(),
        validateValues: jest.fn(),
        markSubmitted: jest.fn(),
        serialize: jest.fn((value) => value),
      } as never,
      {
        createReview: jest.fn(),
        expirePreviousReviews: jest.fn().mockResolvedValue([]),
        findOwnedPending: jest.fn(),
        findPendingByAssistantMessage: jest.fn(),
        serialize: jest.fn((value) => value),
        submit: jest.fn(),
      } as never,
      {
        createChart: jest.fn(),
        serialize: jest.fn((value) => value),
      } as never,
      { createForUser: jest.fn().mockReturnValue({}) } as never,
      { assertPermission: jest.fn(), canPermission: jest.fn() } as never,
    );
    const emitted: Array<{ event: string; data: Record<string, unknown> }> = [];

    await service.streamMessage(
      authenticatedUser as never,
      3,
      {
        message: '帮我新增一个学生',
        attachmentIds: [],
        skillKey: null,
        clientRequestId: '6a8bc680-3cb5-4f2d-85ee-974974e0f194',
      },
      new AbortController().signal,
      (event, data) => emitted.push({ event, data }),
      jest.fn(),
    );

    expect(emitted.some(({ event }) => event === 'ui.form')).toBe(true);
    expect(messageSave).toHaveBeenCalledWith(
      expect.objectContaining({
        id: 12,
        metadata: expect.objectContaining({
          a2uiForm: expect.objectContaining({ id: 'form-1' }),
        }),
      }),
    );
  });

  it('render_review 生成的预览通过 ui.review 推送并保留在消息 metadata 中', async () => {
    const conversation = {
      id: 3,
      userId: 7,
      title: '测试',
      lockedSkillKey: null,
      lastMessageAt: null,
    };
    const assistant = {
      id: 12,
      conversationId: 3,
      role: 'assistant',
      content: '',
      reasoningContent: null,
      status: 'pending',
      errorCode: null,
      replyToMessageId: 11,
      feedback: null,
      feedbackReason: null,
      metadata: {},
    };
    const reviewShape = {
      id: 'review-1',
      conversationId: 3,
      title: '批量导入',
      status: 'pending',
      sections: [
        {
          key: 'students',
          title: '学生',
          kind: 'table',
          columns: [{ key: 'name', title: '姓名' }],
          rows: [{ name: '张三' }],
          issues: [],
        },
      ],
    };
    const messageSave = jest.fn(async (value) => value);
    const messages = {
      exists: jest.fn().mockResolvedValue(false),
      find: jest.fn().mockResolvedValue([]),
      findOne: jest.fn().mockImplementation((options?: unknown) => {
        const opts = options as { select?: { metadata?: boolean } } | undefined;
        if (opts?.select?.metadata) return Promise.resolve({ metadata: { a2uiReview: reviewShape } });
        return Promise.resolve(assistant);
      }),
      save: messageSave,
    };
    const manager = {
      create: jest.fn((_entity, value) => value),
      save: jest
        .fn()
        .mockResolvedValueOnce({ id: 11, conversationId: 3, role: 'user', content: '导入这个Excel' })
        .mockResolvedValueOnce(assistant),
      update: jest.fn(),
    };
    const toolRuns = {
      create: jest.fn((value) => value),
      save: jest.fn(async (value) => value),
      find: jest.fn().mockResolvedValue([]),
    };
    const modelStream = {
      stream: async function* () {
        yield {
          type: 'complete' as const,
          toolCalls: [
            {
              id: 'call-1',
              name: 'render_review',
              arguments: JSON.stringify({
                title: '批量导入',
                sections: [
                  {
                    key: 'students',
                    title: '学生',
                    kind: 'table',
                    columns: [{ key: 'name', title: '姓名' }],
                    rows: [{ name: '张三' }],
                  },
                ],
              }),
            },
          ],
        };
      },
    };
    const service = new AiChatService(
      { findOne: jest.fn().mockResolvedValue(conversation) } as never,
      messages as never,
      toolRuns as never,
      { transaction: jest.fn(async (callback) => callback(manager)) } as never,
      { getRuntimeConfig: jest.fn().mockResolvedValue({ supportsVision: false }) } as never,
      { listAvailable: jest.fn().mockReturnValue([]) } as never,
      modelStream as never,
      {
        requireReadyOwned: jest.fn().mockResolvedValue([]),
        toModelParts: jest.fn().mockResolvedValue([]),
        serialize: jest.fn((value) => value),
      } as never,
      {
        createForm: jest.fn(),
        findOwnedPending: jest.fn(),
        validateValues: jest.fn(),
        markSubmitted: jest.fn(),
        serialize: jest.fn((value) => value),
      } as never,
      {
        createReview: jest.fn().mockResolvedValue(reviewShape),
        expirePreviousReviews: jest.fn().mockResolvedValue([]),
        findOwnedPending: jest.fn(),
        findPendingByAssistantMessage: jest.fn(),
        serialize: jest.fn((value) => value),
        submit: jest.fn(),
      } as never,
      {
        createChart: jest.fn(),
        serialize: jest.fn((value) => value),
      } as never,
      { createForUser: jest.fn().mockReturnValue({}) } as never,
      { assertPermission: jest.fn(), canPermission: jest.fn() } as never,
    );
    const emitted: Array<{ event: string; data: Record<string, unknown> }> = [];

    await service.streamMessage(
      authenticatedUser as never,
      3,
      {
        message: '导入这个Excel',
        attachmentIds: [],
        skillKey: null,
        clientRequestId: '6a8bc680-3cb5-4f2d-85ee-974974e0f194',
      },
      new AbortController().signal,
      (event, data) => emitted.push({ event, data }),
      jest.fn(),
    );

    expect(emitted.some(({ event }) => event === 'ui.review')).toBe(true);
    expect(messageSave).toHaveBeenCalledWith(
      expect.objectContaining({
        id: 12,
        metadata: expect.objectContaining({
          a2uiReview: expect.objectContaining({ id: 'review-1' }),
        }),
      }),
    );
  });

  it('render_chart 生成的图表通过 ui.chart 推送并追加到消息 metadata', async () => {
    const conversation = {
      id: 3,
      userId: 7,
      title: '测试',
      lockedSkillKey: null,
      lastMessageAt: null,
    };
    const assistant = {
      id: 12,
      conversationId: 3,
      role: 'assistant',
      content: '',
      reasoningContent: null,
      status: 'pending',
      errorCode: null,
      replyToMessageId: 11,
      feedback: null,
      feedbackReason: null,
      metadata: {},
    };
    const chartShape = {
      id: 'chart-1',
      title: '各班级人数',
      chartType: 'bar',
      columns: [
        { key: 'className', title: '班级' },
        { key: 'count', title: '人数' },
      ],
      rows: [
        { className: '一班', count: 20 },
        { className: '二班', count: 15 },
      ],
    };
    const messageSave = jest.fn(async (value) => value);
    const messages = {
      exists: jest.fn().mockResolvedValue(false),
      find: jest.fn().mockResolvedValue([]),
      findOne: jest.fn().mockImplementation((options?: unknown) => {
        const opts = options as { select?: { metadata?: boolean } } | undefined;
        if (opts?.select?.metadata) return Promise.resolve({ metadata: { a2uiChart: [chartShape] } });
        return Promise.resolve(assistant);
      }),
      save: messageSave,
    };
    const manager = {
      create: jest.fn((_entity, value) => value),
      save: jest
        .fn()
        .mockResolvedValueOnce({ id: 11, conversationId: 3, role: 'user', content: '画个柱状图' })
        .mockResolvedValueOnce(assistant),
      update: jest.fn(),
    };
    const toolRuns = {
      create: jest.fn((value) => value),
      save: jest.fn(async (value) => value),
      find: jest.fn().mockResolvedValue([]),
    };
    const modelStream = {
      stream: async function* () {
        yield {
          type: 'complete' as const,
          toolCalls: [
            {
              id: 'call-1',
              name: 'render_chart',
              arguments: JSON.stringify({
                title: '各班级人数',
                chartType: 'bar',
                columns: [
                  { key: 'className', title: '班级' },
                  { key: 'count', title: '人数' },
                ],
                rows: [
                  { className: '一班', count: 20 },
                  { className: '二班', count: 15 },
                ],
              }),
            },
          ],
        };
      },
    };
    const service = new AiChatService(
      { findOne: jest.fn().mockResolvedValue(conversation) } as never,
      messages as never,
      toolRuns as never,
      { transaction: jest.fn(async (callback) => callback(manager)) } as never,
      { getRuntimeConfig: jest.fn().mockResolvedValue({ supportsVision: false }) } as never,
      { listAvailable: jest.fn().mockReturnValue([]) } as never,
      modelStream as never,
      {
        requireReadyOwned: jest.fn().mockResolvedValue([]),
        toModelParts: jest.fn().mockResolvedValue([]),
        serialize: jest.fn((value) => value),
      } as never,
      {
        createForm: jest.fn(),
        findOwnedPending: jest.fn(),
        validateValues: jest.fn(),
        markSubmitted: jest.fn(),
        serialize: jest.fn((value) => value),
      } as never,
      {
        createReview: jest.fn(),
        expirePreviousReviews: jest.fn().mockResolvedValue([]),
        findOwnedPending: jest.fn(),
        findPendingByAssistantMessage: jest.fn(),
        serialize: jest.fn((value) => value),
        submit: jest.fn(),
      } as never,
      {
        createChart: jest.fn().mockResolvedValue(chartShape),
        serialize: jest.fn((value) => value),
      } as never,
      { createForUser: jest.fn().mockReturnValue({}) } as never,
      { assertPermission: jest.fn(), canPermission: jest.fn() } as never,
    );
    const emitted: Array<{ event: string; data: Record<string, unknown> }> = [];

    await service.streamMessage(
      authenticatedUser as never,
      3,
      {
        message: '画个柱状图',
        attachmentIds: [],
        skillKey: null,
        clientRequestId: '6a8bc680-3cb5-4f2d-85ee-974974e0f194',
      },
      new AbortController().signal,
      (event, data) => emitted.push({ event, data }),
      jest.fn(),
    );

    expect(emitted.some(({ event }) => event === 'ui.chart')).toBe(true);
    expect(messageSave).toHaveBeenCalledWith(
      expect.objectContaining({
        id: 12,
        metadata: expect.objectContaining({
          a2uiChart: expect.arrayContaining([expect.objectContaining({ id: 'chart-1' })]),
        }),
      }),
    );
  });

  it('批量导入确认后的生成轮次中，模型再调 render_review 被拒绝', async () => {
    const conversation = {
      id: 3,
      userId: 7,
      title: '测试',
      lockedSkillKey: null,
      lastMessageAt: null,
    };
    const assistant = {
      id: 12,
      conversationId: 3,
      role: 'assistant',
      content: '',
      reasoningContent: null,
      status: 'pending',
      errorCode: null,
      replyToMessageId: 11,
      feedback: null,
      feedbackReason: null,
      metadata: {},
    };
    const messageSave = jest.fn(async (value) => value);
    const messages = {
      exists: jest.fn().mockResolvedValue(false),
      find: jest.fn().mockResolvedValue([]),
      findOne: jest.fn().mockImplementation((options?: unknown) => {
        const opts = options as { select?: { metadata?: boolean } } | undefined;
        if (opts?.select?.metadata) return Promise.resolve({ metadata: {} });
        return Promise.resolve(assistant);
      }),
      save: messageSave,
    };
    const manager = {
      create: jest.fn((_entity, value) => value),
      save: jest
        .fn()
        .mockResolvedValueOnce({
          id: 11,
          conversationId: 3,
          role: 'user',
          content: '已确认导入',
          metadata: {
            a2uiReviewSubmit: {
              reviewId: 'review-1',
              reviewTitle: '批量导入',
              resultMessage: '成功导入学生 1 人',
            },
          },
        })
        .mockResolvedValueOnce(assistant),
      update: jest.fn(),
    };
    const toolRuns = {
      create: jest.fn((value) => value),
      save: jest.fn(async (value) => value),
      find: jest.fn().mockResolvedValue([]),
    };
    let toolRound = 0;
    const modelStream = {
      stream: async function* () {
        toolRound += 1;
        if (toolRound === 1) {
          yield {
            type: 'complete' as const,
            toolCalls: [
              { id: 'call-1', name: 'render_review', arguments: '{}' },
              { id: 'call-2', name: 'create_student', arguments: '{}' },
              { id: 'call-3', name: 'update_students', arguments: '{}' },
            ],
          };
        } else {
          yield { type: 'complete' as const, toolCalls: [] };
        }
      },
    };
    const createReview = jest.fn();
    const service = new AiChatService(
      { findOne: jest.fn().mockResolvedValue(conversation) } as never,
      messages as never,
      toolRuns as never,
      { transaction: jest.fn(async (callback) => callback(manager)) } as never,
      { getRuntimeConfig: jest.fn().mockResolvedValue({ supportsVision: false }) } as never,
      { listAvailable: jest.fn().mockReturnValue([]) } as never,
      modelStream as never,
      {
        requireReadyOwned: jest.fn().mockResolvedValue([]),
        toModelParts: jest.fn().mockResolvedValue([]),
        serialize: jest.fn((value) => value),
      } as never,
      {
        createForm: jest.fn(),
        findOwnedPending: jest.fn(),
        validateValues: jest.fn(),
        markSubmitted: jest.fn(),
        serialize: jest.fn((value) => value),
      } as never,
      {
        createReview,
        expirePreviousReviews: jest.fn().mockResolvedValue([]),
        findOwnedPending: jest.fn(),
        findPendingByAssistantMessage: jest.fn(),
        serialize: jest.fn((value) => value),
        submit: jest.fn(),
      } as never,
      {
        createChart: jest.fn(),
        serialize: jest.fn((value) => value),
      } as never,
      { createForUser: jest.fn().mockReturnValue({}) } as never,
      { assertPermission: jest.fn(), canPermission: jest.fn() } as never,
    );
    const emitted: Array<{ event: string; data: Record<string, unknown> }> = [];

    await service.streamMessage(
      authenticatedUser as never,
      3,
      {
        message: '已确认导入',
        attachmentIds: [],
        skillKey: null,
        clientRequestId: '6a8bc680-3cb5-4f2d-85ee-974974e0f194',
      },
      new AbortController().signal,
      (event, data) => emitted.push({ event, data }),
      jest.fn(),
    );

    expect(createReview).not.toHaveBeenCalled();
    const failedTools = emitted.filter(({ event }) => event === 'tool.failed');
    expect(failedTools.map(({ data }) => (data as { toolName?: string }).toolName)).toEqual([
      'render_review',
      'create_student',
      'update_students',
    ]);
  });

  it('同一消息回合内 render_review 只生成一张预览卡，重复调用被拒绝', async () => {
    const conversation = {
      id: 3,
      userId: 7,
      title: '测试',
      lockedSkillKey: null,
      lastMessageAt: null,
    };
    const assistant = {
      id: 12,
      conversationId: 3,
      role: 'assistant',
      content: '',
      reasoningContent: null,
      status: 'pending',
      errorCode: null,
      replyToMessageId: 11,
      feedback: null,
      feedbackReason: null,
      metadata: {},
    };
    const reviewShape = {
      id: 'review-1',
      conversationId: 3,
      title: '批量导入',
      status: 'pending',
      sections: [
        {
          key: 'students',
          title: '学生',
          kind: 'table',
          columns: [{ key: 'name', title: '姓名' }],
          rows: [{ name: '张三' }],
          issues: [],
        },
      ],
    };
    const messageSave = jest.fn(async (value) => value);
    const messages = {
      exists: jest.fn().mockResolvedValue(false),
      find: jest.fn().mockResolvedValue([]),
      findOne: jest.fn().mockImplementation((options?: unknown) => {
        const opts = options as { select?: { metadata?: boolean } } | undefined;
        if (opts?.select?.metadata) return Promise.resolve({ metadata: { a2uiReview: reviewShape } });
        return Promise.resolve(assistant);
      }),
      save: messageSave,
    };
    const manager = {
      create: jest.fn((_entity, value) => value),
      save: jest
        .fn()
        .mockResolvedValueOnce({ id: 11, conversationId: 3, role: 'user', content: '导入这个Excel' })
        .mockResolvedValueOnce(assistant),
      update: jest.fn(),
    };
    const toolRuns = {
      create: jest.fn((value) => value),
      save: jest.fn(async (value) => value),
      find: jest.fn().mockResolvedValue([]),
    };
    let toolRound = 0;
    const modelStream = {
      stream: async function* () {
        toolRound += 1;
        if (toolRound === 1) {
          yield {
            type: 'complete' as const,
            toolCalls: [
              {
                id: 'call-1',
                name: 'render_review',
                arguments: JSON.stringify({
                  title: '批量导入',
                  sections: [{ key: 'students', title: '学生', kind: 'table' }],
                }),
              },
              {
                id: 'call-2',
                name: 'render_review',
                arguments: JSON.stringify({
                  title: '批量导入',
                  sections: [{ key: 'rooms', title: '宿舍', kind: 'table' }],
                }),
              },
            ],
          };
        } else {
          yield { type: 'complete' as const, toolCalls: [] };
        }
      },
    };
    const createReview = jest.fn().mockResolvedValue(reviewShape);
    const service = new AiChatService(
      { findOne: jest.fn().mockResolvedValue(conversation) } as never,
      messages as never,
      toolRuns as never,
      { transaction: jest.fn(async (callback) => callback(manager)) } as never,
      { getRuntimeConfig: jest.fn().mockResolvedValue({ supportsVision: false }) } as never,
      { listAvailable: jest.fn().mockReturnValue([]) } as never,
      modelStream as never,
      {
        requireReadyOwned: jest.fn().mockResolvedValue([]),
        toModelParts: jest.fn().mockResolvedValue([]),
        serialize: jest.fn((value) => value),
      } as never,
      {
        createForm: jest.fn(),
        findOwnedPending: jest.fn(),
        validateValues: jest.fn(),
        markSubmitted: jest.fn(),
        serialize: jest.fn((value) => value),
      } as never,
      {
        createReview,
        expirePreviousReviews: jest.fn().mockResolvedValue([]),
        findOwnedPending: jest.fn(),
        findPendingByAssistantMessage: jest
          .fn()
          .mockResolvedValueOnce(null)
          .mockResolvedValueOnce(reviewShape),
        serialize: jest.fn((value) => value),
        submit: jest.fn(),
      } as never,
      {
        createChart: jest.fn(),
        serialize: jest.fn((value) => value),
      } as never,
      { createForUser: jest.fn().mockReturnValue({}) } as never,
      { assertPermission: jest.fn(), canPermission: jest.fn() } as never,
    );
    const emitted: Array<{ event: string; data: Record<string, unknown> }> = [];

    await service.streamMessage(
      authenticatedUser as never,
      3,
      {
        message: '导入这个Excel',
        attachmentIds: [],
        skillKey: null,
        clientRequestId: '6a8bc680-3cb5-4f2d-85ee-974974e0f194',
      },
      new AbortController().signal,
      (event, data) => emitted.push({ event, data }),
      jest.fn(),
    );

    expect(createReview).toHaveBeenCalledTimes(1);
    expect(emitted.filter(({ event }) => event === 'ui.review')).toHaveLength(1);
    const denied = emitted.find(
      ({ event, data }) =>
        event === 'tool.failed' && (data as { toolName?: string }).toolName === 'render_review',
    );
    expect(denied).toBeDefined();
    expect((denied?.data as { error?: string }).error).toContain('不要再次调用 render_review');
  });

  it('submitReview 无写入权限时拒绝批量导入', async () => {
    const conversation = {
      id: 3,
      userId: 7,
      title: '测试',
      lockedSkillKey: null,
      lastMessageAt: null,
    };
    const review = {
      id: 'review-1',
      conversationId: 3,
      userId: 7,
      assistantMessageId: 12,
      title: '批量导入',
      summary: null,
      sectionsJson: JSON.stringify([
        { key: 'students', title: '学生' },
        { key: 'rooms', title: '宿舍' },
      ]),
      status: 'pending',
      resultSummary: null,
      submittedAt: null,
    };
    const messages = {
      exists: jest.fn().mockResolvedValue(false),
      find: jest.fn().mockResolvedValue([]),
      findOne: jest.fn().mockResolvedValue({ id: 12, metadata: {} }),
      save: jest.fn(async (value) => value),
    };
    const manager = {
      create: jest.fn((_entity, value) => value),
      save: jest.fn().mockResolvedValue({ id: 11 }),
      update: jest.fn(),
    };
    const toolRuns = {
      create: jest.fn((value) => value),
      save: jest.fn(async (value) => value),
      find: jest.fn().mockResolvedValue([]),
    };
    const submitAll = jest.fn();
    const assertPermission = jest.fn().mockImplementation(() => {
      throw new ForbiddenException('权限不足：缺少权限码 student:create');
    });
    const service = new AiChatService(
      { findOne: jest.fn().mockResolvedValue(conversation) } as never,
      messages as never,
      toolRuns as never,
      { transaction: jest.fn(async (callback) => callback(manager)) } as never,
      { getRuntimeConfig: jest.fn().mockResolvedValue({ supportsVision: false }) } as never,
      { listAvailable: jest.fn().mockReturnValue([]) } as never,
      { stream: jest.fn() } as never,
      {
        requireReadyOwned: jest.fn().mockResolvedValue([]),
        toModelParts: jest.fn().mockResolvedValue([]),
        serialize: jest.fn((value) => value),
      } as never,
      {
        createForm: jest.fn(),
        findOwnedPending: jest.fn(),
        validateValues: jest.fn(),
        markSubmitted: jest.fn(),
        serialize: jest.fn((value) => value),
      } as never,
      {
        createReview: jest.fn(),
        expirePreviousReviews: jest.fn().mockResolvedValue([]),
        findOwnedPending: jest.fn().mockResolvedValue(review),
        findPendingByAssistantMessage: jest.fn(),
        findOwned: jest.fn(),
        submitSection: jest.fn(),
        submitAll,
        parseSections: jest.fn((json: string) => JSON.parse(json)),
        serialize: jest.fn((value) => value),
      } as never,
      {
        createChart: jest.fn(),
        serialize: jest.fn((value) => value),
      } as never,
      { createForUser: jest.fn().mockReturnValue({}) } as never,
      { assertPermission, canPermission: jest.fn() } as never,
    );
    const emitted: Array<{ event: string }> = [];

    await expect(
      service.submitReview(
        authenticatedUser as never,
        'review-1',
        {
          clientRequestId: '6a8bc680-3cb5-4f2d-85ee-974974e0f194',
          reasoningEffort: null,
        } as never,
        new AbortController().signal,
        (event) => emitted.push({ event }),
        jest.fn(),
      ),
    ).rejects.toBeInstanceOf(ForbiddenException);

    expect(submitAll).not.toHaveBeenCalled();
    expect(emitted).toHaveLength(0);
  });

  it('submitReview 校验写入权限、先 onReady 再推 ui.review，并标记原卡片已提交', async () => {
    const conversation = {
      id: 3,
      userId: 7,
      title: '测试',
      lockedSkillKey: null,
      lastMessageAt: null,
    };
    const assistant = {
      id: 12,
      conversationId: 3,
      role: 'assistant',
      content: '',
      reasoningContent: null,
      status: 'pending',
      errorCode: null,
      replyToMessageId: 11,
      feedback: null,
      feedbackReason: null,
      metadata: { a2uiReview: { id: 'review-1', status: 'pending' } },
    };
    const review = {
      id: 'review-1',
      conversationId: 3,
      userId: 7,
      assistantMessageId: 12,
      title: '批量导入',
      summary: null,
      sectionsJson: JSON.stringify([{ key: 'students', title: '学生' }]),
      status: 'pending',
      resultSummary: null,
      submittedAt: null,
    };
    const messageSave = jest.fn(async (value) => value);
    const messages = {
      exists: jest.fn().mockResolvedValue(false),
      find: jest.fn().mockResolvedValue([]),
      findOne: jest.fn().mockImplementation((options?: unknown) => {
        const opts = options as { select?: { metadata?: boolean } } | undefined;
        if (opts?.select?.metadata) {
          return Promise.resolve({ metadata: { a2uiReview: { id: 'review-1', status: 'submitted' } } });
        }
        return Promise.resolve(assistant);
      }),
      save: messageSave,
    };
    const manager = {
      create: jest.fn((_entity, value) => value),
      save: jest
        .fn()
        .mockResolvedValueOnce({ id: 11, conversationId: 3, role: 'user', content: '已确认导入' })
        .mockResolvedValueOnce({ id: 13, conversationId: 3, role: 'assistant' }),
      update: jest.fn(),
    };
    const toolRuns = {
      create: jest.fn((value) => value),
      save: jest.fn(async (value) => value),
      find: jest.fn().mockResolvedValue([]),
    };
    const modelStream = {
      stream: async function* () {
        yield { type: 'complete' as const, toolCalls: [] };
      },
    };
    const submitAll = jest.fn(async () => {
      review.status = 'submitted';
      review.submittedAt = new Date();
      return {
        review,
        result: {
          message: '成功导入学生 1 人、宿舍 0 间、换宿 0 条、入住 0 条；跳过 0 条',
          students: { created: 1, skipped: 0, issues: [] },
          rooms: { created: 0, skipped: 0, issues: [] },
          transfers: { completed: 0, skipped: 0, issues: [] },
          checkins: { completed: 0, skipped: 0, issues: [] },
        },
      };
    });
    const assertPermission = jest.fn();
    const service = new AiChatService(
      { findOne: jest.fn().mockResolvedValue(conversation) } as never,
      messages as never,
      toolRuns as never,
      { transaction: jest.fn(async (callback) => callback(manager)) } as never,
      { getRuntimeConfig: jest.fn().mockResolvedValue({ supportsVision: false }) } as never,
      { listAvailable: jest.fn().mockReturnValue([]) } as never,
      modelStream as never,
      {
        requireReadyOwned: jest.fn().mockResolvedValue([]),
        toModelParts: jest.fn().mockResolvedValue([]),
        serialize: jest.fn((value) => value),
      } as never,
      {
        createForm: jest.fn(),
        findOwnedPending: jest.fn(),
        validateValues: jest.fn(),
        markSubmitted: jest.fn(),
        serialize: jest.fn((value) => value),
      } as never,
      {
        createReview: jest.fn(),
        expirePreviousReviews: jest.fn().mockResolvedValue([]),
        findOwnedPending: jest.fn().mockResolvedValue(review),
        findPendingByAssistantMessage: jest.fn(),
        findOwned: jest.fn(),
        submitSection: jest.fn(),
        submitAll,
        parseSections: jest.fn((json: string) => JSON.parse(json)),
        serialize: jest.fn((value) => value),
      } as never,
      {
        createChart: jest.fn(),
        serialize: jest.fn((value) => value),
      } as never,
      { createForUser: jest.fn().mockReturnValue({}) } as never,
      { assertPermission, canPermission: jest.fn() } as never,
    );
    const order: string[] = [];
    const emitted: Array<{ event: string; data: Record<string, unknown> }> = [];

    await service.submitReview(
      authenticatedUser as never,
      'review-1',
      {
        clientRequestId: '6a8bc680-3cb5-4f2d-85ee-974974e0f194',
        reasoningEffort: null,
      } as never,
      new AbortController().signal,
      (event, data) => {
        order.push(`emit:${event}`);
        emitted.push({ event, data });
      },
      () => order.push('onReady'),
    );

    expect(assertPermission).toHaveBeenCalledWith(
      expect.anything(),
      'student:create',
    );
    expect(submitAll).toHaveBeenCalledTimes(1);
    expect(emitted[0]).toMatchObject({
      event: 'ui.review',
      data: { messageId: 12, review: expect.objectContaining({ id: 'review-1' }) },
    });
    expect(order.indexOf('onReady')).toBeLessThan(order.indexOf('emit:ui.review'));
    expect(messageSave).toHaveBeenCalledWith(
      expect.objectContaining({
        id: 12,
        metadata: expect.objectContaining({
          a2uiReview: expect.objectContaining({ status: 'submitted' }),
        }),
      }),
    );
  });

  it('confirmReviewStep 只校验当前分表权限并返回更新后的预览', async () => {
    const { service, reviewService } = createService();
    const review = {
      id: 'review-1',
      conversationId: 3,
      userId: 7,
      assistantMessageId: 12,
      title: '批量导入',
      summary: null,
      sectionsJson: JSON.stringify([
        { key: 'students', title: '学生' },
        { key: 'rooms', title: '宿舍' },
      ]),
      status: 'pending',
      resultSummary: null,
      submittedAt: null,
    };
    const updated = {
      ...review,
      sectionsJson: JSON.stringify([
        { key: 'students', title: '学生', status: 'submitted' },
        { key: 'rooms', title: '宿舍', status: 'pending' },
      ]),
    };
    (service as unknown as { messages: unknown }).messages = {
      findOne: jest.fn().mockResolvedValue({
        id: 12,
        conversationId: 3,
        metadata: { a2uiReview: { id: 'review-1', status: 'pending' } },
      }),
      save: jest.fn(async (value) => value),
    };
    reviewService.findOwned.mockResolvedValue(review);
    reviewService.submitSection.mockResolvedValue({ review: updated, result: { created: 1, skipped: 0, issues: [] }, message: '成功导入学生 1 人' });

    const data = await service.confirmReviewStep(
      authenticatedUser as never,
      'review-1',
      'students',
    );

    expect(reviewService.findOwned).toHaveBeenCalledWith('review-1', 7);
    expect(reviewService.submitSection).toHaveBeenCalledWith(
      'review-1',
      7,
      'students',
    );
    expect(data).toMatchObject({ id: 'review-1' });
  });

  it('confirmReviewStep / confirmReviewGroup 对已失效预览返回 409', async () => {
    const { service, reviewService } = createService();
    const review = {
      id: 'review-1',
      conversationId: 3,
      userId: 7,
      assistantMessageId: 12,
      title: '批量导入',
      summary: null,
      sectionsJson: '[]',
      status: 'expired',
      resultSummary: null,
      submittedAt: null,
    };
    reviewService.findOwned.mockResolvedValue(review);

    await expect(
      service.confirmReviewStep(authenticatedUser as never, 'review-1', 'students'),
    ).rejects.toMatchObject({ message: expect.stringContaining('已失效') });
    await expect(
      service.confirmReviewGroup(authenticatedUser as never, 'review-1', 'students'),
    ).rejects.toMatchObject({ message: expect.stringContaining('已失效') });
    expect(reviewService.submitSection).not.toHaveBeenCalled();
    expect(reviewService.submitGroup).not.toHaveBeenCalled();
  });

  it('confirmReviewGroup 只校验当前类型权限并原位更新预览卡', async () => {
    const { service, reviewService } = createService();
    const review = {
      id: 'review-1',
      conversationId: 3,
      userId: 7,
      assistantMessageId: 12,
      title: '批量导入',
      summary: null,
      sectionsJson: JSON.stringify([
        { key: 'checkins_a', type: 'checkins', title: '入住A' },
        { key: 'checkins_b', type: 'checkins', title: '入住B' },
      ]),
      status: 'pending',
      resultSummary: null,
      submittedAt: null,
    };
    const updated = {
      ...review,
      sectionsJson: JSON.stringify([
        { key: 'checkins_a', type: 'checkins', title: '入住A', status: 'submitted' },
        { key: 'checkins_b', type: 'checkins', title: '入住B', status: 'submitted' },
      ]),
      status: 'submitted',
    };
    (service as unknown as { messages: unknown }).messages = {
      findOne: jest.fn().mockResolvedValue({
        id: 12,
        conversationId: 3,
        metadata: { a2uiReview: { id: 'review-1', status: 'pending' } },
      }),
      save: jest.fn(async (value) => value),
    };
    reviewService.findOwned.mockResolvedValue(review);
    reviewService.submitGroup.mockResolvedValue({ review: updated });

    const data = await service.confirmReviewGroup(
      authenticatedUser as never,
      'review-1',
      'checkins',
    );

    expect(reviewService.findOwned).toHaveBeenCalledWith('review-1', 7);
    expect(reviewService.submitGroup).toHaveBeenCalledWith('review-1', 7, 'checkins');
    expect(data).toMatchObject({ id: 'review-1', status: 'submitted' });
  });

  it('office_analyze 通过 OfficeCli 分析用户自己的附件并返回结果', async () => {
    const conversation = {
      id: 3,
      userId: 7,
      title: '测试',
      lockedSkillKey: null,
      lastMessageAt: null,
    };
    const assistant = {
      id: 12,
      conversationId: 3,
      role: 'assistant',
      content: '',
      reasoningContent: null,
      status: 'pending',
      errorCode: null,
      replyToMessageId: 11,
      feedback: null,
      feedbackReason: null,
      metadata: {},
    };
    const messageSave = jest.fn(async (value) => value);
    const messages = {
      exists: jest.fn().mockResolvedValue(false),
      find: jest.fn().mockResolvedValue([]),
      findOne: jest.fn().mockImplementation((options?: unknown) => {
        const opts = options as { select?: { metadata?: boolean } } | undefined;
        if (opts?.select?.metadata) return Promise.resolve({ metadata: {} });
        return Promise.resolve(assistant);
      }),
      save: messageSave,
    };
    const manager = {
      create: jest.fn((_entity, value) => value),
      save: jest
        .fn()
        .mockResolvedValueOnce({ id: 11, conversationId: 3, role: 'user', content: '分析一下' })
        .mockResolvedValueOnce(assistant),
      update: jest.fn(),
    };
    const toolRuns = {
      create: jest.fn((value) => value),
      save: jest.fn(async (value) => value),
      find: jest.fn().mockResolvedValue([]),
    };
    const modelStream = {
      stream: async function* () {
        yield {
          type: 'complete' as const,
          toolCalls: [
            {
              id: 'call-1',
              name: 'office_analyze',
              arguments: JSON.stringify({ attachmentId: 5, action: 'outline' }),
            },
          ],
        };
      },
    };
    const officeCli = {
      run: jest.fn().mockResolvedValue({
        success: true,
        data: { sheets: [{ name: '入住名单', rows: 360, cols: 18 }] },
      }),
    };
    const attachmentService = {
      requireReadyOwned: jest.fn().mockResolvedValue([
        {
          id: 5,
          mimeType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
          storageKey: '1/test.xlsx',
        },
      ]),
      storagePathFor: jest.fn().mockReturnValue('/tmp/attachments/1/test.xlsx'),
      readStoredBuffer: jest.fn().mockResolvedValue(Buffer.from('x')),
      toModelParts: jest.fn().mockResolvedValue([]),
      serialize: jest.fn((value) => value),
    };
    const service = new AiChatService(
      { findOne: jest.fn().mockResolvedValue(conversation) } as never,
      messages as never,
      toolRuns as never,
      { transaction: jest.fn(async (callback) => callback(manager)) } as never,
      { getRuntimeConfig: jest.fn().mockResolvedValue({ supportsVision: false }) } as never,
      { listAvailable: jest.fn().mockReturnValue([]) } as never,
      modelStream as never,
      attachmentService as never,
      {
        createForm: jest.fn(),
        findOwnedPending: jest.fn(),
        validateValues: jest.fn(),
        markSubmitted: jest.fn(),
        serialize: jest.fn((value) => value),
      } as never,
      {
        createReview: jest.fn(),
        expirePreviousReviews: jest.fn().mockResolvedValue([]),
        findOwnedPending: jest.fn(),
        findPendingByAssistantMessage: jest.fn(),
        serialize: jest.fn((value) => value),
        submit: jest.fn(),
      } as never,
      {
        createChart: jest.fn(),
        serialize: jest.fn((value) => value),
      } as never,
      { createForUser: jest.fn().mockReturnValue({}) } as never,
      { assertPermission: jest.fn(), canPermission: jest.fn() } as never,
      undefined,
      officeCli as never,
    );
    const emitted: Array<{ event: string; data: Record<string, unknown> }> = [];

    await service.streamMessage(
      authenticatedUser as never,
      3,
      {
        message: '分析一下附件',
        attachmentIds: [5],
        skillKey: null,
        clientRequestId: '6a8bc680-3cb5-4f2d-85ee-974974e0f194',
      },
      new AbortController().signal,
      (event, data) => emitted.push({ event, data }),
      jest.fn(),
    );

    expect(officeCli.run).toHaveBeenCalledWith([
      'view',
      '/tmp/attachments/1/test.xlsx',
      'outline',
      '--json',
    ]);
    expect(emitted.some(({ event }) => event === 'tool.completed')).toBe(true);
  });
});
