jest.mock('../src/modules/netaclaw/gateway/session.js', () => ({
  NetaClawSessionService: class NetaClawSessionService {},
}));

import { NetaClawSubagentService } from '../src/modules/netaclaw/service/subagent.js';

describe('NetaClawSubagentService', () => {
  const flushAsync = async () => {
    await Promise.resolve();
    await Promise.resolve();
  };

  const createService = () => {
    const service = new NetaClawSubagentService();
    service.subagentDefaults = {
      enabled: true,
      maxConcurrent: 3,
    };
    service.subagentRepo = {
      create: jest.fn((value: any) => ({ ...value })),
      save: jest.fn(async (value: any) => ({ id: 101, ...value })),
      update: jest.fn(async () => undefined),
    } as any;
    service.toolResolver = {
      resolve: jest.fn(),
    } as any;
    service.agentService = {
      info: jest.fn(),
      agentRepo: {
        findOneBy: jest.fn(),
        findOne: jest.fn(),
      },
    } as any;
    service.channelService = {
      resolveForAgent: jest.fn(),
    } as any;
    service.skillLoader = {
      loadSkills: jest.fn(async () => []),
      buildSkillsPrompt: jest.fn(() => ''),
    } as any;
    service.sessionService = {
      getSessionTreeSession: jest.fn(async () => ({ cwd: 'C:\\derived-workspace' })),
    } as any;
    service.agentRunner = jest.fn();
    return service;
  };

  const baseContext = {
    sessionId: 'session-1',
    parentMessageId: 12,
    parentToolCallId: 'tool-1',
    parentAgent: {
      id: 88,
      name: 'supervisor',
      systemPrompt: 'You are the parent agent. Keep answers concise.',
      skills: ['repo-skill'],
      config: {
        middleware: {
          maxToolRounds: 9,
        },
      },
      modelConfig: {
        modelId: 'gpt-4o-mini',
      },
      subagentConfig: {
        allowedPresetAgentIds: [77],
      },
    },
    parentModel: {
      model: 'openai:gpt-4o-mini',
      apiKey: 'api-key',
      baseUrl: 'https://example.invalid/v1',
      capability: 'text',
    },
    task: {
      goal: 'Inspect the repository and summarize the migration risks.',
      context: 'Focus on backend service boundaries.',
      mode: 'preset' as const,
      agentName: 'preset-researcher',
      agentId: 77,
      toolNames: ['bash', 'delegate_task', 'clarify', 'memory'],
      maxToolRounds: 6,
    },
    sortOrder: 4,
  };

  const presetAgent = {
    id: 77,
    name: 'preset-researcher',
    status: 1,
    systemPrompt: 'Preset researcher instructions.',
    skills: ['file-counting'],
    tools: { inheritCoreTools: true },
    modelConfig: {
      modelId: 'gpt-5.4',
      apiKey: 'preset-key',
      apiUrl: 'https://preset.invalid/v1',
    },
    config: {
      middleware: {
        maxToolRounds: 5,
      },
    },
  };

  it('runSingle persists a running row, executes the preset agent, and stores a completed summary', async () => {
    const service = createService();
    (service.agentService.info as jest.Mock).mockResolvedValue(presetAgent);
    (service.toolResolver.resolve as jest.Mock).mockResolvedValue({
      tools: [{ name: 'bash' }],
      toolNames: ['bash'],
      toolManifest: [
        {
          name: 'bash',
          visibility: 'tool',
          capability: 'text',
          kind: 'builtin',
          executionMode: 'parallel',
          supportedInWorker: true,
          requiresShell: true,
        },
      ],
      toolRuntimeRoutes: [
        {
          name: 'bash',
          kind: 'builtin',
          executionMode: 'parallel',
          supportedInWorker: true,
          workerRoutingHint: 'local',
          runtimeRoute: 'worker-local',
          blockedReason: null,
          policySources: ['toolManifest', 'worker-policy'],
          policyDerived: true,
        },
      ],
      toolPromptHints: {},
    });
    (service.agentRunner as jest.Mock).mockResolvedValue({
      finalContent: 'Risk summary',
      finalOutput: 'Risk summary',
      usage: { inputTokens: 20, outputTokens: 8 },
      toolCallCount: 2,
      messages: [],
    });

    const result = await service.runSingle(baseContext);

    expect(service.subagentRepo.create).toHaveBeenCalledWith(
      expect.objectContaining({
        sessionId: 'session-1',
        parentMessageId: 12,
        parentToolCallId: 'tool-1',
        parentAgentId: 88,
        sourceType: 'preset',
        presetAgentId: 77,
        name: 'preset-researcher',
        goal: 'Inspect the repository and summarize the migration risks.',
        context: 'Focus on backend service boundaries.',
        status: 'running',
        sortOrder: 4,
        startedAt: expect.any(Date),
      })
    );
    expect(service.toolResolver.resolve).toHaveBeenCalledWith(
      expect.objectContaining({
        agent: presetAgent,
        modelCapability: 'text',
        hasSkills: true,
        delegationRole: 'subagent',
      })
    );
    expect(service.agentRunner).toHaveBeenCalledWith(
      expect.objectContaining({
        tools: [{ name: 'bash' }],
        toolNames: ['bash'],
        userMessage: 'Inspect the repository and summarize the migration risks.',
        history: [],
        beforeToolCall: expect.any(Function),
        afterToolCall: expect.any(Function),
        agentConfig: expect.objectContaining({
          name: 'preset-researcher',
          model: 'openai:gpt-5.4',
          apiKey: 'preset-key',
          baseUrl: 'https://preset.invalid/v1',
          maxToolRounds: 6,
        }),
      })
    );
    const prompt = (service.agentRunner as jest.Mock).mock.calls[0][0].agentConfig.systemPrompt;
    expect(prompt).toContain('Available tools for this delegated run: bash.');
    expect(prompt).toContain('Available preset skills for this delegated run: file-counting.');
    expect(prompt).toContain('Never fabricate counts, file paths, command results, or success states.');
    expect(prompt).toContain('If a matching skill is available and the task clearly fits it, load it with `read_skill` and follow it.');
    expect(prompt).toContain('If the task asks to count files, include the count and the filenames or evidence used for the count.');

    const runnerParams = (service.agentRunner as jest.Mock).mock.calls[0][0];
    await runnerParams.beforeToolCall({
      toolCallId: 'call-1',
      name: 'bash',
      label: 'Bash',
      args: { cmd: 'dir' },
    });
    await runnerParams.afterToolCall({
      toolCallId: 'call-1',
      name: 'bash',
      label: 'Bash',
      args: { cmd: 'dir' },
      result: 'done',
      isError: false,
    });

    expect(service.subagentRepo.update).toHaveBeenCalledWith(
      101,
      expect.objectContaining({
        status: 'completed',
        model: 'openai:gpt-5.4',
        toolNames: ['bash'],
        summary: 'Risk summary',
        tokenUsage: { inputTokens: 20, outputTokens: 8, totalTokens: 28 },
        resultPayload: expect.objectContaining({
          finalContent: 'Risk summary',
          rawFinalContent: 'Risk summary',
          finalOutput: 'Risk summary',
          toolCallCount: 2,
          proxiedToolNames: [],
          pendingProxyToolNames: [],
          processEvents: expect.arrayContaining([
            expect.objectContaining({ type: 'run_start' }),
            expect.objectContaining({ type: 'run_end' }),
          ]),
          toolRuntimeRoutes: expect.any(Array),
        }),
        error: null,
        endedAt: expect.any(Date),
      })
    );
    expect(result).toEqual({
      id: 101,
      sessionId: 'session-1',
      parentMessageId: 12,
      parentToolCallId: 'tool-1',
      parentAgentId: 88,
      sourceType: 'preset',
      presetAgentId: 77,
      name: 'preset-researcher',
      goal: 'Inspect the repository and summarize the migration risks.',
      context: 'Focus on backend service boundaries.',
      status: 'completed',
      model: 'openai:gpt-5.4',
      summary: 'Risk summary',
      toolNames: ['bash'],
      tokenUsage: { inputTokens: 20, outputTokens: 8, totalTokens: 28 },
      resultPayload: expect.objectContaining({
        finalContent: 'Risk summary',
        toolCallCount: 2,
        proxiedToolNames: [],
        pendingProxyToolNames: [],
        processEvents: expect.arrayContaining([
          expect.objectContaining({ type: 'run_start' }),
          expect.objectContaining({ type: 'run_end' }),
        ]),
      }),
      sortOrder: 4,
      error: null,
    });
  });

  it('runSingle captures in-process tool events when the runner invokes tool hooks', async () => {
    const service = createService();
    (service.agentService.info as jest.Mock).mockResolvedValue(presetAgent);
    (service.toolResolver.resolve as jest.Mock).mockResolvedValue({
      tools: [{ name: 'bash', label: 'Bash' }],
      toolNames: ['bash'],
      toolManifest: [
        {
          name: 'bash',
          visibility: 'tool',
          capability: 'text',
          kind: 'builtin',
          executionMode: 'parallel',
          supportedInWorker: true,
          requiresShell: true,
        },
      ],
      toolRuntimeRoutes: [
        {
          name: 'bash',
          kind: 'builtin',
          executionMode: 'parallel',
          supportedInWorker: true,
          workerRoutingHint: 'local',
          runtimeRoute: 'worker-local',
          blockedReason: null,
          policySources: ['toolManifest', 'worker-policy'],
          policyDerived: true,
        },
      ],
      toolPromptHints: {},
    });
    (service.agentRunner as jest.Mock).mockImplementation(async (params: any) => {
      await params.beforeToolCall?.({
        toolCallId: 'call-2',
        name: 'bash',
        label: 'Bash',
        args: { cmd: 'dir' },
      });
      await params.afterToolCall?.({
        toolCallId: 'call-2',
        name: 'bash',
        label: 'Bash',
        args: { cmd: 'dir' },
        result: 'ok',
        isError: false,
      });
      return {
        finalContent: 'Hooked summary',
        usage: { inputTokens: 10, outputTokens: 4 },
        toolCallCount: 1,
        messages: [],
      };
    });

    const result = await service.runSingle(baseContext);

    expect(result.status).toBe('completed');
    expect(result.resultPayload.processEvents).toEqual(
      expect.arrayContaining([
        expect.objectContaining({ type: 'run_start' }),
        expect.objectContaining({ type: 'tool_call', toolCallId: 'call-2', name: 'bash' }),
        expect.objectContaining({ type: 'tool_result', toolCallId: 'call-2', result: 'ok' }),
        expect.objectContaining({ type: 'run_end' }),
      ])
    );
  });

  it('runSingle returns a structured failed result and marks the row failed when the runner fails', async () => {
    const service = createService();
    (service.agentService.info as jest.Mock).mockResolvedValue(presetAgent);
    (service.toolResolver.resolve as jest.Mock).mockResolvedValue({
      tools: [{ name: 'bash' }],
      toolNames: ['bash'],
      toolManifest: [
        {
          name: 'bash',
          visibility: 'tool',
          capability: 'text',
          kind: 'builtin',
          executionMode: 'parallel',
          supportedInWorker: true,
          requiresShell: true,
        },
      ],
      toolRuntimeRoutes: [
        {
          name: 'bash',
          kind: 'builtin',
          executionMode: 'parallel',
          supportedInWorker: true,
          workerRoutingHint: 'local',
          runtimeRoute: 'worker-local',
          blockedReason: null,
          policySources: ['toolManifest', 'worker-policy'],
          policyDerived: true,
        },
      ],
      toolPromptHints: {},
    });
    (service.agentRunner as jest.Mock).mockRejectedValue(new Error('runner exploded'));

    await expect(service.runSingle(baseContext)).resolves.toEqual({
      id: 101,
      sessionId: 'session-1',
      parentMessageId: 12,
      parentToolCallId: 'tool-1',
      parentAgentId: 88,
      sourceType: 'preset',
      presetAgentId: 77,
      name: 'preset-researcher',
      goal: 'Inspect the repository and summarize the migration risks.',
      context: 'Focus on backend service boundaries.',
      status: 'failed',
      model: 'openai:gpt-5.4',
      summary: '',
      toolNames: ['bash'],
      tokenUsage: null,
      resultPayload: {
        processEvents: [],
        toolRuntimeRoutes: [
          expect.objectContaining({
            name: 'bash',
            runtimeRoute: 'worker-local',
            blockedReason: null,
          }),
        ],
      },
      sortOrder: 4,
      error: 'runner exploded',
    });

    expect(service.subagentRepo.update).toHaveBeenCalledWith(
      101,
      expect.objectContaining({
        status: 'failed',
        model: 'openai:gpt-5.4',
        toolNames: ['bash'],
        error: 'runner exploded',
        endedAt: expect.any(Date),
      })
    );
  });

  it('runSingle can execute through the subprocess runner when explicitly configured', async () => {
    const service = createService();
    service.subagentDefaults = {
      ...service.subagentDefaults,
      executionMode: 'subprocess',
      process: {
        timeoutMs: 15000,
        workerPath: 'worker.js',
        workspaceRoots: ['C:\\repo'],
        allowShell: true,
        readonly: true,
      },
    };
    service.processRunner = {
      run: jest.fn(async (_envelope: any, onEvent?: (event: any) => void) => {
        onEvent?.({
          type: 'run_start',
          runId: 'subagent-101',
          timestamp: '2026-04-22T00:00:00.000Z',
          envelope: { sessionId: 'session-1', parentMessageId: 12 },
        });
        onEvent?.({
          type: 'tool_call',
          runId: 'subagent-101',
          timestamp: '2026-04-22T00:00:01.000Z',
          name: 'bash',
          toolCallId: 'call-1',
          args: { command: 'pwd' },
        });
        onEvent?.({
          type: 'tool_result',
          runId: 'subagent-101',
          timestamp: '2026-04-22T00:00:02.000Z',
          name: 'bash',
          toolCallId: 'call-1',
          result: 'C:\\repo',
        });
        onEvent?.({
          type: 'run_end',
          runId: 'subagent-101',
          timestamp: '2026-04-22T00:00:03.000Z',
          result: {
            finalContent: 'Subprocess risk summary',
            toolCallCount: 0,
          },
        });
        return {
          finalContent: 'Subprocess risk summary',
          usage: { inputTokens: 7, outputTokens: 9 },
          toolCallCount: 0,
        };
      }),
    } as any;
    (service.agentService.info as jest.Mock).mockResolvedValue(presetAgent);
    (service.toolResolver.resolve as jest.Mock).mockResolvedValue({
      tools: [],
      toolNames: [],
      toolManifest: [],
      toolPromptHints: {},
    });

    await expect(service.runSingle({
      ...baseContext,
      task: {
        goal: 'Summarize the repository architecture.',
        context: 'No filesystem inspection required.',
        mode: 'preset',
        agentId: 77,
        agentName: 'preset-researcher',
      },
    })).resolves.toEqual(
      expect.objectContaining({
        status: 'completed',
        summary: 'Subprocess risk summary',
        tokenUsage: { inputTokens: 7, outputTokens: 9, totalTokens: 16 },
      })
    );

    expect(service.agentRunner).not.toHaveBeenCalled();
    expect((service.processRunner.run as jest.Mock).mock.calls[0][0]).toEqual(
      expect.objectContaining({
        protocolVersion: 1,
        runId: 'subagent-101',
        sessionId: 'session-1',
        parentMessageId: 12,
        parentToolCallId: 'tool-1',
        parentAgentId: 88,
        toolNames: [],
        toolManifest: [],
        timeoutMs: 15000,
        policy: {
          workspaceRoots: ['C:\\derived-workspace', 'C:\\repo'],
          allowShell: false,
          readonly: true,
        },
        task: expect.objectContaining({
          goal: 'Summarize the repository architecture.',
        }),
        agentConfig: expect.objectContaining({
          name: 'preset-researcher',
          model: 'openai:gpt-5.4',
        }),
      })
    );
    expect(service.subagentRepo.update).toHaveBeenCalledWith(
      101,
      expect.objectContaining({
        status: 'completed',
        summary: 'Subprocess risk summary',
        resultPayload: expect.objectContaining({
          finalContent: 'Subprocess risk summary',
          toolCallCount: 0,
          processEvents: expect.arrayContaining([
            expect.objectContaining({ type: 'run_start', runId: 'subagent-101' }),
            expect.objectContaining({ type: 'tool_call', name: 'bash', toolCallId: 'call-1' }),
            expect.objectContaining({ type: 'tool_result', name: 'bash', result: 'C:\\repo' }),
            expect.objectContaining({ type: 'run_end', runId: 'subagent-101' }),
          ]),
        }),
      })
    );
  });

  it('derives subprocess worker policy from session cwd and tool manifest', async () => {
    const service = createService();
    service.subagentDefaults = {
      ...service.subagentDefaults,
      executionMode: 'subprocess',
      process: {
        timeoutMs: 15000,
        workerPath: 'worker.js',
      },
    };
    service.processRunner = {
      run: jest.fn(async () => ({
        finalContent: 'derived policy summary',
        usage: { inputTokens: 1, outputTokens: 2 },
        toolCallCount: 1,
      })),
    } as any;
    (service.agentService.info as jest.Mock).mockResolvedValue(presetAgent);
    (service.toolResolver.resolve as jest.Mock).mockResolvedValue({
      tools: [],
      toolNames: ['bash'],
      toolManifest: [
        {
          name: 'bash',
          visibility: 'tool',
          capability: 'text',
          kind: 'builtin',
          executionMode: 'parallel',
          supportedInWorker: true,
          requiresShell: true,
        },
      ],
      toolRuntimeRoutes: [
        {
          name: 'bash',
          kind: 'builtin',
          executionMode: 'parallel',
          supportedInWorker: true,
          workerRoutingHint: 'local',
          runtimeRoute: 'worker-local',
          blockedReason: null,
          policySources: ['toolManifest', 'worker-policy'],
          policyDerived: true,
        },
      ],
      toolPromptHints: {},
    });

    await service.runSingle({
      ...baseContext,
      sessionCwd: undefined,
      task: {
        goal: 'Inspect workspace with bash.',
        mode: 'preset',
        agentId: 77,
        agentName: 'preset-researcher',
        toolNames: ['bash'],
      },
    });

    expect(service.sessionService.getSessionTreeSession).toHaveBeenCalledWith('session-1', 88);
    expect((service.processRunner.run as jest.Mock).mock.calls[0][0]).toEqual(expect.objectContaining({
      policy: {
        workspaceRoots: ['C:\\derived-workspace'],
        allowShell: true,
        readonly: true,
      },
    }));
  });

  it('derives workspace roots for main-process-proxy tools from task roots before config fallback', async () => {
    const service = createService();
    service.subagentDefaults = {
      ...service.subagentDefaults,
      executionMode: 'subprocess',
      process: {
        timeoutMs: 15000,
        workerPath: 'worker.js',
        workspaceRoots: ['C:\\config-root'],
      },
    };
    service.processRunner = {
      run: jest.fn(async () => ({
        finalContent: 'proxy manifest summary',
        usage: { inputTokens: 3, outputTokens: 4 },
        toolCallCount: 1,
        metadata: {
          pendingProxyToolNames: ['write_file'],
          proxiedToolNames: ['write_file'],
        },
      })),
    } as any;
    (service.agentService.info as jest.Mock).mockResolvedValue(presetAgent);
    (service.toolResolver.resolve as jest.Mock).mockResolvedValue({
      tools: [{ name: 'write_file' }],
      toolNames: ['write_file'],
      toolManifest: [
        {
          name: 'write_file',
          visibility: 'tool',
          capability: 'text',
          kind: 'builtin',
          executionMode: 'sequential',
          supportedInWorker: false,
          workerRoutingHint: 'main-process-proxy',
          requiresWrite: true,
        },
      ],
      toolPromptHints: {},
    });

    await service.runSingle({
      ...baseContext,
      sessionCwd: null,
      task: {
        ...baseContext.task,
        goal: 'Write a report file into the delegated workspace.',
        toolNames: ['write_file'],
        workspaceRoots: ['C:\\task-root'],
      } as any,
    });

    expect((service.processRunner.run as jest.Mock).mock.calls[0][0]).toEqual(expect.objectContaining({
      policy: {
        workspaceRoots: ['C:\\task-root', 'C:\\config-root'],
        allowShell: false,
        readonly: true,
      },
      toolManifest: [
        expect.objectContaining({
          name: 'write_file',
          workerRoutingHint: 'main-process-proxy',
        }),
      ],
    }));
  });

  it('adds the current user desktop directory to worker policy roots for desktop evidence tasks', async () => {
    const service = createService();
    const originalUserProfile = process.env.USERPROFILE;
    process.env.USERPROFILE = 'C:\\Users\\lixin';
    service.subagentDefaults = {
      ...service.subagentDefaults,
      executionMode: 'subprocess',
      process: {
        timeoutMs: 15000,
        workerPath: 'worker.js',
      },
    };
    service.processRunner = {
      run: jest.fn(async () => ({
        finalContent: 'desktop summary',
        usage: { inputTokens: 3, outputTokens: 4 },
        toolCallCount: 1,
      })),
    } as any;
    (service.agentService.info as jest.Mock).mockResolvedValue(presetAgent);
    (service.toolResolver.resolve as jest.Mock).mockResolvedValue({
      tools: [],
      toolNames: ['bash', 'find_files'],
      toolManifest: [
        {
          name: 'bash',
          visibility: 'tool',
          capability: 'text',
          kind: 'builtin',
          executionMode: 'parallel',
          supportedInWorker: true,
          requiresShell: true,
        },
        {
          name: 'find_files',
          visibility: 'tool',
          capability: 'text',
          kind: 'builtin',
          executionMode: 'parallel',
          supportedInWorker: true,
        },
      ],
      toolRuntimeRoutes: [
        {
          name: 'bash',
          kind: 'builtin',
          executionMode: 'parallel',
          supportedInWorker: true,
          workerRoutingHint: 'local',
          runtimeRoute: 'worker-local',
          blockedReason: null,
          policySources: ['toolManifest', 'worker-policy'],
          policyDerived: true,
        },
        {
          name: 'find_files',
          kind: 'builtin',
          executionMode: 'parallel',
          supportedInWorker: true,
          workerRoutingHint: 'local',
          runtimeRoute: 'worker-local',
          blockedReason: null,
          policySources: ['toolManifest', 'worker-policy'],
          policyDerived: true,
        },
      ],
      toolPromptHints: {},
    });

    try {
      await service.runSingle({
        ...baseContext,
        sessionCwd: 'C:\\Users\\lixin\\Desktop\\RZYX_ZT\\Neta-monorepo',
        task: {
          goal: 'Count how many xlsx files are on the desktop.',
          context: 'Use tools to inspect the user desktop.',
          mode: 'preset',
          agentId: 77,
          agentName: 'preset-researcher',
          toolNames: ['bash', 'find_files'],
        },
      });
    } finally {
      process.env.USERPROFILE = originalUserProfile;
    }

    expect((service.processRunner.run as jest.Mock).mock.calls[0][0]).toEqual(expect.objectContaining({
      policy: expect.objectContaining({
        workspaceRoots: [
          'C:\\Users\\lixin\\Desktop\\RZYX_ZT\\Neta-monorepo',
          'C:\\Users\\lixin\\Desktop',
        ],
      }),
    }));
    expect(service.toolResolver.resolve).toHaveBeenCalledWith(expect.objectContaining({
      runtimePolicy: expect.objectContaining({
        sessionCwd: 'C:\\Users\\lixin\\Desktop\\RZYX_ZT\\Neta-monorepo',
        workspaceRoots: [
          'C:\\Users\\lixin\\Desktop\\RZYX_ZT\\Neta-monorepo',
          'C:\\Users\\lixin\\Desktop',
        ],
      }),
    }));
  });

  it('runSingle fails counting-style tasks that return results without any tool calls', async () => {
    const service = createService();
    (service.agentService.info as jest.Mock).mockResolvedValue(presetAgent);
    (service.toolResolver.resolve as jest.Mock).mockResolvedValue({
      tools: [{ name: 'bash' }],
      toolNames: ['bash'],
      toolManifest: [
        {
          name: 'bash',
          visibility: 'tool',
          capability: 'text',
          kind: 'builtin',
          executionMode: 'parallel',
          supportedInWorker: true,
          requiresShell: true,
        },
      ],
      toolRuntimeRoutes: [
        {
          name: 'bash',
          kind: 'builtin',
          executionMode: 'parallel',
          supportedInWorker: true,
          workerRoutingHint: 'local',
          runtimeRoute: 'worker-local',
          blockedReason: null,
          policySources: ['toolManifest', 'worker-policy'],
          policyDerived: true,
        },
      ],
      toolPromptHints: {},
    });
    (service.agentRunner as jest.Mock).mockResolvedValue({
      finalContent: 'There are 5 xlsx files on the desktop.',
      usage: { inputTokens: 10, outputTokens: 3 },
      toolCallCount: 0,
      messages: [],
    });

    await expect(service.runSingle({
      ...baseContext,
      task: {
        goal: 'Count how many xlsx files are on the desktop.',
        context: 'Return only the final count.',
        mode: 'preset',
        agentId: 77,
        agentName: 'preset-researcher',
        toolNames: ['bash'],
      },
    })).resolves.toEqual(
      expect.objectContaining({
        status: 'failed',
        error: 'Subagent did not produce a usable final answer for a task that requires external evidence',
      })
    );

    expect(service.subagentRepo.update).toHaveBeenCalledWith(
      101,
      expect.objectContaining({
        status: 'failed',
        error: 'Subagent did not produce a usable final answer for a task that requires external evidence',
      })
    );
  });

  it('fails evidence tasks that make tool calls but still produce no usable final output', async () => {
    const service = createService();
    (service.agentService.info as jest.Mock).mockResolvedValue(presetAgent);
    (service.toolResolver.resolve as jest.Mock).mockResolvedValue({
      tools: [{ name: 'bash' }, { name: 'list_dir' }],
      toolNames: ['bash', 'list_dir'],
      toolManifest: [
        {
          name: 'bash',
          visibility: 'tool',
          capability: 'text',
          kind: 'builtin',
          executionMode: 'parallel',
          supportedInWorker: true,
          requiresShell: true,
        },
        {
          name: 'list_dir',
          visibility: 'tool',
          capability: 'text',
          kind: 'builtin',
          executionMode: 'parallel',
          supportedInWorker: true,
        },
      ],
      toolRuntimeRoutes: [
        {
          name: 'bash',
          kind: 'builtin',
          executionMode: 'parallel',
          supportedInWorker: true,
          workerRoutingHint: 'local',
          runtimeRoute: 'worker-local',
          blockedReason: null,
          policySources: ['toolManifest', 'worker-policy'],
          policyDerived: true,
        },
        {
          name: 'list_dir',
          kind: 'builtin',
          executionMode: 'parallel',
          supportedInWorker: true,
          workerRoutingHint: 'local',
          runtimeRoute: 'worker-local',
          blockedReason: null,
          policySources: ['toolManifest', 'worker-policy'],
          policyDerived: true,
        },
      ],
      toolPromptHints: {},
    });
    (service.agentRunner as jest.Mock).mockResolvedValue({
      finalContent: '<think>\nTrying PowerShell\n</think>\n\n',
      finalOutput: '',
      usage: { inputTokens: 22, outputTokens: 6 },
      toolCallCount: 5,
      messages: [],
    });

    await expect(service.runSingle({
      ...baseContext,
      task: {
        goal: 'Count how many image files are on the desktop.',
        context: 'Use tools and return the final count only.',
        mode: 'preset',
        agentId: 77,
        agentName: 'preset-researcher',
        toolNames: ['bash', 'list_dir'],
      },
    })).resolves.toEqual(
      expect.objectContaining({
        status: 'failed',
        error: 'Subagent did not produce a usable final answer for a task that requires external evidence',
      })
    );
  });

  it('uses captured tool results as a bounded fallback when evidence tasks produce only thinking output', async () => {
    const service = createService();
    (service.agentService.info as jest.Mock).mockResolvedValue(presetAgent);
    (service.toolResolver.resolve as jest.Mock).mockResolvedValue({
      tools: [{ name: 'find_files' }],
      toolNames: ['find_files'],
      toolManifest: [
        {
          name: 'find_files',
          visibility: 'tool',
          capability: 'text',
          kind: 'builtin',
          executionMode: 'parallel',
          supportedInWorker: true,
        },
      ],
      toolRuntimeRoutes: [
        {
          name: 'find_files',
          kind: 'builtin',
          executionMode: 'parallel',
          supportedInWorker: true,
          workerRoutingHint: 'local',
          runtimeRoute: 'worker-local',
          blockedReason: null,
          policySources: ['toolManifest', 'worker-policy'],
          policyDerived: true,
        },
      ],
      toolPromptHints: {},
    });
    (service.agentRunner as jest.Mock).mockResolvedValue({
      finalContent: '<think>\nI found the files but forgot to answer.\n</think>',
      finalOutput: '',
      usage: { inputTokens: 22, outputTokens: 6 },
      toolCallCount: 1,
      toolResults: [
        {
          name: 'find_files',
          result: [
            'C:\\Users\\lixin\\Desktop\\111.jpg',
            'C:\\Users\\lixin\\Desktop\\11123.jpg',
            'C:\\Users\\lixin\\Desktop\\20260417-185140.jpg',
          ].join('\n'),
          toolCallId: 'tool-call-1',
        },
      ],
      messages: [],
    });

    await expect(service.runSingle({
      ...baseContext,
      task: {
        goal: '请查找我桌面上有多少张图片。',
        context: 'Use tools and return the final count only.',
        mode: 'preset',
        agentId: 77,
        agentName: 'preset-researcher',
        toolNames: ['find_files'],
      },
    })).resolves.toEqual(
      expect.objectContaining({
        status: 'completed',
        summary: [
          '根据工具结果统计，图片文件共 3 个。',
          '- 111.jpg',
          '- 11123.jpg',
          '- 20260417-185140.jpg',
        ].join('\n'),
        resultPayload: expect.objectContaining({
          finalOutput: [
            '根据工具结果统计，图片文件共 3 个。',
            '- 111.jpg',
            '- 11123.jpg',
            '- 20260417-185140.jpg',
          ].join('\n'),
          rawFinalContent: '<think>\nI found the files but forgot to answer.\n</think>',
          toolResults: [
            {
              name: 'find_files',
              result: [
                'C:\\Users\\lixin\\Desktop\\111.jpg',
                'C:\\Users\\lixin\\Desktop\\11123.jpg',
                'C:\\Users\\lixin\\Desktop\\20260417-185140.jpg',
              ].join('\n'),
              toolCallId: 'tool-call-1',
            },
          ],
        }),
      })
    );
  });

  it('fails evidence tasks before execution when filesystem tools are selected but all worker routes are blocked', async () => {
    const service = createService();
    (service.agentService.info as jest.Mock).mockResolvedValue(presetAgent);
    (service.toolResolver.resolve as jest.Mock).mockResolvedValue({
      tools: [{ name: 'bash' }, { name: 'find_files' }],
      toolNames: ['bash', 'find_files'],
      toolManifest: [
        {
          name: 'bash',
          visibility: 'tool',
          capability: 'text',
          kind: 'builtin',
          executionMode: 'parallel',
          supportedInWorker: true,
          requiresShell: true,
        },
        {
          name: 'find_files',
          visibility: 'tool',
          capability: 'text',
          kind: 'builtin',
          executionMode: 'parallel',
          supportedInWorker: true,
        },
      ],
      toolRuntimeRoutes: [
        {
          name: 'bash',
          kind: 'builtin',
          executionMode: 'parallel',
          supportedInWorker: true,
          workerRoutingHint: 'local',
          runtimeRoute: 'disabled',
          blockedReason: 'workspace_root_required',
          policySources: ['session-workspace', 'worker-policy'],
          policyDerived: true,
        },
        {
          name: 'find_files',
          kind: 'builtin',
          executionMode: 'parallel',
          supportedInWorker: true,
          workerRoutingHint: 'local',
          runtimeRoute: 'disabled',
          blockedReason: 'workspace_root_required',
          policySources: ['session-workspace', 'worker-policy'],
          policyDerived: true,
        },
      ],
      toolPromptHints: {},
    });

    await expect(service.runSingle({
      ...baseContext,
      task: {
        goal: 'Count how many xlsx files are on the desktop.',
        context: 'Return only the final count.',
        mode: 'preset',
        agentId: 77,
        agentName: 'preset-researcher',
        toolNames: ['bash', 'find_files'],
      },
    })).resolves.toEqual(
      expect.objectContaining({
        status: 'failed',
        error: expect.stringContaining(
          'Subagent evidence task has no executable filesystem/search tools under current worker policy'
        ),
      })
    );

    expect(service.agentRunner).not.toHaveBeenCalled();
  });

  it('runSingle defaults to the single allowed preset agent when no target is specified', async () => {
    const service = createService();
    (service.agentService.info as jest.Mock).mockResolvedValue(presetAgent);
    (service.toolResolver.resolve as jest.Mock).mockResolvedValue({
      tools: [{ name: 'bash' }],
      toolNames: ['bash'],
      toolManifest: [
        {
          name: 'bash',
          visibility: 'tool',
          capability: 'text',
          kind: 'builtin',
          executionMode: 'parallel',
          supportedInWorker: true,
          requiresShell: true,
        },
      ],
      toolPromptHints: {},
    });
    (service.agentRunner as jest.Mock).mockResolvedValue({
      finalContent: 'Preset result',
      usage: { inputTokens: 10, outputTokens: 4 },
      toolCallCount: 1,
      messages: [],
    });

    await expect(service.runSingle({
      ...baseContext,
      task: {
        goal: baseContext.task.goal,
        context: baseContext.task.context,
        toolNames: ['bash'],
      },
    } as any)).resolves.toEqual(
      expect.objectContaining({
        sourceType: 'preset',
        presetAgentId: 77,
        name: 'supervisor-subagent',
        status: 'completed',
      })
    );

    expect(service.agentService.info).toHaveBeenCalledWith(77);
  });

  it('runSingle rejects missing explicit target when multiple preset agents are allowed', async () => {
    const service = createService();

    await expect(service.runSingle({
      ...baseContext,
      parentAgent: {
        ...baseContext.parentAgent,
        subagentConfig: {
          allowedPresetAgentIds: [77, 99],
        },
      },
      task: {
        goal: 'Choose a worker',
      },
    } as any)).rejects.toThrow(
      'Multiple preset subagents are allowed (77, 99); specify agentId or agentName explicitly.'
    );

    expect(service.subagentRepo.save).not.toHaveBeenCalled();
    expect(service.toolResolver.resolve).not.toHaveBeenCalled();
    expect(service.agentRunner).not.toHaveBeenCalled();
  });

  it('runSingle blocks preset agents that are not in the parent allow-list', async () => {
    const service = createService();
    (service.agentService.info as jest.Mock).mockResolvedValue({
      ...presetAgent,
      id: 66,
      name: 'forbidden-agent',
    });

    await expect(service.runSingle({
      ...baseContext,
      task: {
        ...baseContext.task,
        agentId: 66,
        agentName: 'forbidden-agent',
      },
    })).resolves.toEqual(expect.objectContaining({
      status: 'failed',
      sourceType: 'preset',
      presetAgentId: 66,
      error: 'Preset subagent "forbidden-agent" is not allowed by parent agent policy',
    }));

    expect(service.agentRunner).not.toHaveBeenCalled();
  });

  it('runBatch enforces bounded concurrency and returns results in sortOrder', async () => {
    const service = createService();
    let active = 0;
    let maxObserved = 0;
    const releases: Array<() => void> = [];

    jest.spyOn(service, 'runSingle').mockImplementation(async (ctx: any) => {
      active += 1;
      maxObserved = Math.max(maxObserved, active);
      await new Promise<void>(resolve => {
        releases.push(() => {
          active -= 1;
          resolve();
        });
      });

      return {
        id: ctx.sortOrder + 1,
        sortOrder: ctx.sortOrder,
        goal: ctx.task.goal,
        status: 'completed',
      } as any;
    });

    const batchPromise = service.runBatch({
      ...baseContext,
      maxConcurrent: 2,
      tasks: [
        { goal: 'task-2', mode: 'preset', agentId: 77 },
        { goal: 'task-0', mode: 'preset', agentId: 77 },
        { goal: 'task-3', mode: 'preset', agentId: 77 },
        { goal: 'task-1', mode: 'preset', agentId: 77 },
      ],
    });

    await flushAsync();
    expect(maxObserved).toBe(2);
    expect(service.runSingle).toHaveBeenNthCalledWith(
      1,
      expect.objectContaining({ sortOrder: 0, task: expect.objectContaining({ goal: 'task-2' }) })
    );
    expect(service.runSingle).toHaveBeenNthCalledWith(
      2,
      expect.objectContaining({ sortOrder: 1, task: expect.objectContaining({ goal: 'task-0' }) })
    );

    releases.shift()?.();
    releases.shift()?.();
    await flushAsync();

    expect(service.runSingle).toHaveBeenNthCalledWith(
      3,
      expect.objectContaining({ sortOrder: 2, task: expect.objectContaining({ goal: 'task-3' }) })
    );
    expect(service.runSingle).toHaveBeenNthCalledWith(
      4,
      expect.objectContaining({ sortOrder: 3, task: expect.objectContaining({ goal: 'task-1' }) })
    );

    releases.splice(0).forEach(release => release());

    await expect(batchPromise).resolves.toEqual([
      { id: 1, sortOrder: 0, goal: 'task-2', status: 'completed' },
      { id: 2, sortOrder: 1, goal: 'task-0', status: 'completed' },
      { id: 3, sortOrder: 2, goal: 'task-3', status: 'completed' },
      { id: 4, sortOrder: 3, goal: 'task-1', status: 'completed' },
    ]);
  });

  it('clamps batch concurrency to at least one', async () => {
    const service = createService();
    jest.spyOn(service, 'runSingle').mockResolvedValue({ status: 'completed' } as any);

    await service.runBatch({
      ...baseContext,
      maxConcurrent: 0,
      tasks: [
        { goal: 'task-a', mode: 'preset', agentId: 77 },
        { goal: 'task-b', mode: 'preset', agentId: 77 },
      ],
    });

    expect(service.runSingle).toHaveBeenNthCalledWith(
      1,
      expect.objectContaining({ sortOrder: 0, task: expect.objectContaining({ goal: 'task-a' }) })
    );
    expect(service.runSingle).toHaveBeenNthCalledWith(
      2,
      expect.objectContaining({ sortOrder: 1, task: expect.objectContaining({ goal: 'task-b' }) })
    );
  });

  it('runBatch preserves partial success and stable order when one task fails', async () => {
    const service = createService();
    jest.spyOn(service, 'runSingle').mockImplementation(async (ctx: any) => {
      if (ctx.sortOrder === 1) {
        return {
          id: 102,
          sessionId: ctx.sessionId,
          parentMessageId: ctx.parentMessageId,
          parentToolCallId: ctx.parentToolCallId,
          parentAgentId: ctx.parentAgent?.id ?? null,
          sourceType: 'preset',
          presetAgentId: 77,
          name: `${ctx.task.goal}-agent`,
          goal: ctx.task.goal,
          context: null,
          status: 'failed',
          model: ctx.parentModel.model,
          summary: '',
          toolNames: [],
          tokenUsage: null,
          resultPayload: {},
          sortOrder: ctx.sortOrder,
          error: 'runner exploded',
        } as any;
      }

      return {
        id: 101,
        sessionId: ctx.sessionId,
        parentMessageId: ctx.parentMessageId,
        parentToolCallId: ctx.parentToolCallId,
        parentAgentId: ctx.parentAgent?.id ?? null,
        sourceType: 'preset',
        presetAgentId: 77,
        name: `${ctx.task.goal}-agent`,
        goal: ctx.task.goal,
        context: null,
        status: 'completed',
        model: ctx.parentModel.model,
        summary: `${ctx.task.goal}-summary`,
        toolNames: ['bash'],
        tokenUsage: null,
        resultPayload: { finalContent: `${ctx.task.goal}-summary`, toolCallCount: 0 },
        sortOrder: ctx.sortOrder,
        error: null,
      } as any;
    });

    await expect(service.runBatch({
      ...baseContext,
      maxConcurrent: 2,
      tasks: [
        { goal: 'task-success', mode: 'preset', agentId: 77 },
        { goal: 'task-failure', mode: 'preset', agentId: 77 },
      ],
    })).resolves.toEqual([
      expect.objectContaining({
        goal: 'task-success',
        sortOrder: 0,
        status: 'completed',
        error: null,
      }),
      expect.objectContaining({
        goal: 'task-failure',
        sortOrder: 1,
        status: 'failed',
        error: 'runner exploded',
      }),
    ]);
  });
});
