# 运行时过程事件实施计划

> **给执行代理的要求：** 实施本计划时必须使用 `superpowers:subagent-driven-development`（推荐）或 `superpowers:executing-plans`。每个步骤使用 checkbox（`- [ ]`）跟踪。

**目标：** 为长耗时工具、compute-entry skill 以及未来的子代理/批处理任务增加一条 Pi 风格的运行时过程事件流，让 Agent 对话页能实时显示“执行到了哪一步”，而不是只显示最终结果或单一进度条。

**架构：** 在现有 NetaClaw 工具生命周期 `started -> result -> completed` 中增加 `process-event`。第一期 WebSocket 事件名保留为 `tool_process_event`，便于接入当前 `streamingTools` 投影；但事件 payload 采用通用 `RuntimeProcessEvent`，包含 `version`、`operationId`、`parentOperationId`、`targetType`、`toolCallId`、`taskId` 等字段，避免绑定死某一个 skill。`execute_skill` 负责把 skill 子进程输出的结构化过程事件桥接到 runtime callback；gateway 负责把事件节流后发给前端，并在 assistant metadata 中保存摘要，同时写完整 JSONL 过程日志供后端排查。

**关键边界：**

- 过程事件只用于 UI 展示、过程回放和后端排查，不进入 LLM history，也不能被 `buildLLMMessages` 带回模型上下文。
- `processLogRef` 只能是逻辑引用，不能把本地绝对路径暴露给前端。
- 第一期不做完整事件总线，不做任务中心，不做完整日志查看 API，只做一个轻量 runtime helper，避免过度设计。
- 第一期只保证 `execute_skill` 过程可见；事件结构保留未来接入普通 tool、subagent、后台任务的能力。

**技术栈：** TypeScript、Midway.js WebSocket、NetaClaw runtime tools、Pinia/Vue 3、Element Plus、Jest、vue-tsc/Vite build。

---

## 文件结构

后端：

- 新增 `packages/backend/src/modules/netaclaw/runtime/process_events.ts`
  - 定义 `RuntimeProcessEvent`、`RuntimeProcessEventInput`。
  - 负责事件归一化、`operationId` 生成、百分比计算、payload 脱敏、metadata 采样、逻辑 `processLogRef` 生成、JSONL 记录、轻量节流/去重。
  - 注意：这是轻量 helper，不是完整 EventBus。
- 修改 `packages/backend/src/modules/netaclaw/tools/common.ts`
  - 增加 `ToolProcessUpdateCallback`。
  - 扩展 `AgentTool.execute()`，增加可选第三参数 `onUpdate`。
- 修改 `packages/backend/src/modules/netaclaw/runtime/attempt.ts`
  - 增加 `onToolProcessEvent`。
  - 调用 tool 时传入 `emitProcessEvent`。
- 修改 `packages/backend/src/modules/netaclaw/runtime/agent.ts`
  - 把 `onToolProcessEvent` 从 `runAgent` 透传到 `runAttempt`。
- 修改 `packages/backend/src/modules/netaclaw/service/skill_executor.ts`
  - 只解析显式 `type: "process_event"` 的 JSON 行。
  - 保持普通 stderr 原样进后端日志。
  - timeout、非 0 退出、spawn error、最终 JSON 解析失败时，发终态失败过程事件。
- 修改 `packages/backend/src/modules/netaclaw/tools/builtin/execute_skill.ts`
  - 把 skill 过程事件桥接成 tool process update。
- 修改 `packages/backend/src/modules/netaclaw/gateway/protocol.ts`
  - 增加 `ServerToolProcessEvent`。
- 修改 `packages/backend/src/modules/netaclaw/gateway/server.ts`
  - 接收 runtime process event。
  - 发 `tool_process_event` 给前端。
  - 在 `skillExecutions` metadata 中保存 `latestProcessEvent`、采样后的 `processEvents`、逻辑 `processLogRef`。
  - 不把完整本地文件路径放入前端 payload。

后端测试：

- 新增 `packages/backend/test/tool_process_events.test.ts`
- 新增 `packages/backend/test/skill_process_events.test.ts`
- 新增 `packages/backend/test/process_event_buffer.test.ts`

前端：

- 修改 `packages/frontend/src/modules/agent/types/index.d.ts`
  - 增加 `RuntimeProcessEventProjection`。
  - 给 `StreamingToolProjection` 增加 `latestProcessEvent`、`processEvents`、`processLogRef`。
  - 给 `WSServerEvent.type` 增加 `tool_process_event`。
- 修改 `packages/frontend/src/modules/agent/store/chat.ts`
  - 处理 live `tool_process_event`。
  - 从 assistant metadata 恢复过程事件摘要。
  - 按 `toolCallId` 合并到对应 `streamingTool`。
- 新增 `packages/frontend/src/modules/agent/components/tool-process-timeline.vue`
  - 独立渲染过程时间线。
- 修改 `packages/frontend/src/modules/agent/components/skill-card.vue`
  - 展示最新过程事件。
  - 只有存在 numeric percent 时才显示进度条。
  - 嵌入 `tool-process-timeline.vue`。
- 修改实际渲染 `streamingTools` 的父组件或 renderer
  - 明确传入 `latestProcessEvent`、`processEvents`、`processLogRef`。

---

## Task 1：后端运行时过程事件类型与工具回调契约

**文件：**

- 新增：`packages/backend/src/modules/netaclaw/runtime/process_events.ts`
- 修改：`packages/backend/src/modules/netaclaw/tools/common.ts`
- 修改：`packages/backend/src/modules/netaclaw/runtime/attempt.ts`
- 修改：`packages/backend/src/modules/netaclaw/runtime/agent.ts`
- 测试：`packages/backend/test/tool_process_events.test.ts`

- [ ] **Step 1：先写失败测试**

创建 `packages/backend/test/tool_process_events.test.ts`：

```ts
import { Type } from '@sinclair/typebox';
import { runAttempt } from '../src/modules/netaclaw/runtime/attempt';
import type { AnyAgentTool } from '../src/modules/netaclaw/tools/common';

jest.mock('../src/modules/netaclaw/runtime/model_selection', () => ({
  getProvider: () => ({
    chat: jest
      .fn()
      .mockResolvedValueOnce({
        content: '',
        usage: { inputTokens: 1, outputTokens: 1 },
        toolCalls: [{ id: 'call-1', name: 'slow_tool', arguments: '{"taskId":"task-1"}' }],
      })
      .mockResolvedValueOnce({
        content: 'done',
        usage: { inputTokens: 1, outputTokens: 1 },
        toolCalls: [],
      }),
  }),
}));

describe('runtime process events', () => {
  it('从执行中的 tool 发出带 toolCallId 和 operationId 的过程事件', async () => {
    const updates: any[] = [];
    const tool: AnyAgentTool = {
      name: 'slow_tool',
      label: 'Slow Tool',
      description: 'test tool',
      parameters: Type.Object({ taskId: Type.String() }),
      async execute(_id, _params, onUpdate) {
        onUpdate?.({
          source: 'tool',
          targetType: 'tool',
          stage: 'prepare',
          status: 'running',
          message: 'Preparing work',
          current: 1,
          total: 2,
        });
        return { type: 'text', text: 'ok' };
      },
    };

    await runAttempt({
      messages: [{ role: 'user', content: 'run' }],
      tools: [tool],
      config: { provider: 'openai', model: 'test', apiKey: 'test' },
      onToolProcessEvent: event => updates.push(event),
    });

    expect(updates).toEqual([
      expect.objectContaining({
        toolCallId: 'call-1',
        operationId: 'tool-call-1',
        toolName: 'slow_tool',
        args: { taskId: 'task-1' },
        event: expect.objectContaining({
          version: 1,
          operationId: 'tool-call-1',
          targetType: 'tool',
          source: 'tool',
          stage: 'prepare',
          status: 'running',
          message: 'Preparing work',
          current: 1,
          total: 2,
          percent: 50,
        }),
      }),
    ]);
  });
});
```

- [ ] **Step 2：运行测试，确认失败**

```bash
pnpm --filter @neta/backend test -- --runInBand test/tool_process_events.test.ts
```

预期：失败，因为 `RuntimeProcessEvent`、`onToolProcessEvent`、`onUpdate` 还不存在。

- [ ] **Step 3：新增运行时过程事件 helper**

创建 `packages/backend/src/modules/netaclaw/runtime/process_events.ts`：

```ts
import { randomUUID } from 'crypto';
import * as fs from 'fs';
import * as path from 'path';
import { pDataPath } from '../../../comm/path.js';

export type RuntimeProcessTargetType = 'tool' | 'skill' | 'subagent' | 'task' | 'runtime';
export type RuntimeProcessSource = 'skill' | 'tool' | 'subagent' | 'runtime';
export type RuntimeProcessStatus = 'queued' | 'started' | 'running' | 'completed' | 'failed' | 'cancelled' | 'skipped';
export type RuntimeProcessLevel = 'debug' | 'info' | 'warning' | 'error';

export interface RuntimeProcessEvent {
  version: 1;
  operationId: string;
  parentOperationId?: string;
  targetType: RuntimeProcessTargetType;
  source: RuntimeProcessSource;
  taskId?: string;
  stage?: string;
  status: RuntimeProcessStatus;
  level: RuntimeProcessLevel;
  message: string;
  detail?: string;
  current?: number;
  total?: number;
  percent?: number;
  payload?: Record<string, unknown>;
  timestamp: string;
  sequence?: number;
}

export type RuntimeProcessEventInput =
  Partial<Omit<RuntimeProcessEvent, 'version' | 'operationId' | 'targetType' | 'source' | 'status' | 'level' | 'message' | 'timestamp'>> & {
    operationId?: string;
    parentOperationId?: string;
    targetType?: RuntimeProcessTargetType;
    source?: RuntimeProcessSource;
    status?: RuntimeProcessStatus;
    level?: RuntimeProcessLevel;
    message: string;
    timestamp?: string;
  };

export interface RuntimeProcessLogRef {
  kind: 'runtime_process_log';
  sessionId: string;
  operationId: string;
  toolCallId?: string;
}

const SENSITIVE_KEY_PATTERN = /(api[_-]?key|token|password|secret|authorization|credential)/i;
const MAX_PAYLOAD_STRING_LENGTH = 500;

export function buildOperationId(toolCallId?: string): string {
  return toolCallId ? `tool-${toolCallId}` : `op-${randomUUID()}`;
}

export function normalizeRuntimeProcessEvent(input: RuntimeProcessEventInput): RuntimeProcessEvent {
  const total = typeof input.total === 'number' ? input.total : undefined;
  const current = typeof input.current === 'number' ? input.current : undefined;
  const percent = typeof input.percent === 'number'
    ? clampPercent(input.percent)
    : total && current !== undefined
      ? clampPercent(Math.round((current / total) * 100))
      : undefined;

  return {
    version: 1,
    operationId: input.operationId || buildOperationId(),
    parentOperationId: input.parentOperationId,
    targetType: input.targetType || 'runtime',
    source: input.source || 'runtime',
    taskId: input.taskId,
    stage: input.stage,
    status: input.status || 'running',
    level: input.level || 'info',
    message: input.message,
    detail: input.detail,
    current,
    total,
    percent,
    payload: sanitizeProcessPayload(input.payload),
    timestamp: input.timestamp || new Date().toISOString(),
    sequence: input.sequence,
  };
}

export function sanitizeProcessPayload(payload: unknown): Record<string, unknown> | undefined {
  if (!payload || typeof payload !== 'object' || Array.isArray(payload)) return undefined;
  const output: Record<string, unknown> = {};
  for (const [key, value] of Object.entries(payload as Record<string, unknown>)) {
    if (SENSITIVE_KEY_PATTERN.test(key)) {
      output[key] = '[redacted]';
    } else if (typeof value === 'string') {
      output[key] = value.length > MAX_PAYLOAD_STRING_LENGTH
        ? `${value.slice(0, MAX_PAYLOAD_STRING_LENGTH)}...`
        : value;
    } else if (typeof value === 'number' || typeof value === 'boolean' || value === null) {
      output[key] = value;
    } else {
      output[key] = '[object]';
    }
  }
  return Object.keys(output).length > 0 ? output : undefined;
}

export function sampleProcessEventsForMetadata<T>(events: T[], limit = 50): T[] {
  if (events.length <= limit) return events;
  const headCount = Math.min(10, Math.floor(limit / 5));
  return [
    ...events.slice(0, headCount),
    ...events.slice(-(limit - headCount)),
  ];
}

export function buildProcessLogRef(input: {
  sessionId: string;
  operationId: string;
  toolCallId?: string;
}): RuntimeProcessLogRef {
  return {
    kind: 'runtime_process_log',
    sessionId: input.sessionId,
    operationId: input.operationId,
    toolCallId: input.toolCallId,
  };
}

export function appendRuntimeProcessLog(input: {
  sessionId: string;
  operationId: string;
  toolCallId?: string;
  event: RuntimeProcessEvent;
}): RuntimeProcessLogRef {
  const dir = path.join(pDataPath(), 'netaclaw-process-events', input.sessionId);
  fs.mkdirSync(dir, { recursive: true });
  const filename = `${safeFilePart(input.operationId)}.jsonl`;
  fs.appendFileSync(path.join(dir, filename), `${JSON.stringify(input.event)}\n`, 'utf8');
  return buildProcessLogRef(input);
}

function clampPercent(value: number): number {
  return Math.max(0, Math.min(100, value));
}

function safeFilePart(value: string): string {
  return value.replace(/[^a-zA-Z0-9_.-]/g, '_').slice(0, 120);
}
```

- [ ] **Step 4：扩展 tool execute 契约**

修改 `packages/backend/src/modules/netaclaw/tools/common.ts`。

增加 import：

```ts
import type { RuntimeProcessEventInput } from '../runtime/process_events.js';
```

增加类型：

```ts
export type ToolProcessUpdateCallback = (event: RuntimeProcessEventInput) => void;
```

把 `AgentTool.execute` 改为：

```ts
execute(id: string, params: Static<TParams>, onUpdate?: ToolProcessUpdateCallback): Promise<TResult>;
```

- [ ] **Step 5：在 attempt 中桥接 tool update**

修改 `packages/backend/src/modules/netaclaw/runtime/attempt.ts`。

增加 import：

```ts
import { AnyAgentTool, ToolResultContent, toolResultToText } from '../tools/common.js';
import { RuntimeProcessEvent, RuntimeProcessEventInput, buildOperationId, normalizeRuntimeProcessEvent } from './process_events.js';
```

给 `AttemptParams` 增加：

```ts
onToolProcessEvent?: (input: {
  toolCallId: string;
  operationId: string;
  toolName: string;
  args: Record<string, unknown>;
  event: RuntimeProcessEvent;
}) => void;
```

在 `runAttempt()` 中解构 `onToolProcessEvent`。

调用 `tool.execute` 前增加：

```ts
const operationId = buildOperationId(tc.id);
const emitProcessEvent = (event: RuntimeProcessEventInput) => {
  const normalized = normalizeRuntimeProcessEvent({
    operationId,
    targetType: event.targetType || 'tool',
    source: event.source || 'tool',
    ...event,
  });
  onToolProcessEvent?.({
    toolCallId: tc.id,
    operationId,
    toolName: tc.name,
    args: effectiveArgs,
    event: normalized,
  });
};
```

把：

```ts
const result = await tool.execute(tc.id, effectiveArgs);
```

改为：

```ts
const result = await tool.execute(tc.id, effectiveArgs, emitProcessEvent);
```

- [ ] **Step 6：在 agent 中透传 callback**

修改 `packages/backend/src/modules/netaclaw/runtime/agent.ts`。

增加 import：

```ts
import type { RuntimeProcessEvent } from './process_events.js';
```

给 `AgentRunParams` 增加：

```ts
onToolProcessEvent?: (input: {
  toolCallId: string;
  operationId: string;
  toolName: string;
  args: Record<string, unknown>;
  event: RuntimeProcessEvent;
}) => void;
```

在 `runAttempt()` 参数中增加：

```ts
onToolProcessEvent: params.onToolProcessEvent,
```

- [ ] **Step 7：运行测试**

```bash
pnpm --filter @neta/backend test -- --runInBand test/tool_process_events.test.ts
```

预期：PASS。

---

## Task 2：Skill 子进程过程事件解析

**文件：**

- 修改：`packages/backend/src/modules/netaclaw/service/skill_executor.ts`
- 测试：`packages/backend/test/skill_process_events.test.ts`

- [ ] **Step 1：先写失败测试**

创建 `packages/backend/test/skill_process_events.test.ts`：

```ts
import { normalizeSkillProcessLine } from '../src/modules/netaclaw/service/skill_executor';

describe('skill process event parsing', () => {
  it('只解析显式 type=process_event 的 JSON 行', () => {
    const event = normalizeSkillProcessLine(
      'vehicle-damage-inspection',
      JSON.stringify({
        type: 'process_event',
        time: '2026-05-06T04:07:17.187Z',
        stage: 'frames',
        message: 'frames extracted',
        taskId: 'inspection-1',
        frameCount: 361,
        duration: 120.38,
        resolution: '960x544',
      }),
      7,
    );

    expect(event).toEqual({
      source: 'skill',
      targetType: 'skill',
      taskId: 'inspection-1',
      stage: 'frames',
      status: 'running',
      level: 'info',
      message: 'frames extracted',
      timestamp: '2026-05-06T04:07:17.187Z',
      sequence: 7,
      payload: {
        frameCount: 361,
        duration: 120.38,
        resolution: '960x544',
      },
    });
  });

  it('从 batch/totalBatches 推导 current total percent', () => {
    const event = normalizeSkillProcessLine(
      'vehicle-damage-inspection',
      JSON.stringify({
        type: 'process_event',
        time: '2026-05-06T04:08:11.443Z',
        stage: 'detect',
        message: 'batch completed',
        batch: 3,
        totalBatches: 8,
        damageCount: 3,
      }),
      8,
    );

    expect(event).toEqual(expect.objectContaining({
      source: 'skill',
      targetType: 'skill',
      stage: 'detect',
      message: 'batch completed',
      current: 3,
      total: 8,
      percent: 38,
      payload: expect.objectContaining({ damageCount: 3 }),
    }));
  });

  it('忽略普通 stderr', () => {
    expect(normalizeSkillProcessLine('demo', 'plain stderr', 1)).toBeNull();
  });

  it('忽略没有 process_event 标记的普通 JSON', () => {
    expect(normalizeSkillProcessLine('demo', '{"stage":"frames","message":"too broad"}', 1)).toBeNull();
  });
});
```

- [ ] **Step 2：运行测试，确认失败**

```bash
pnpm --filter @neta/backend test -- --runInBand test/skill_process_events.test.ts
```

预期：失败，因为 `normalizeSkillProcessLine` 还不存在。

- [ ] **Step 3：实现解析器**

在 `packages/backend/src/modules/netaclaw/service/skill_executor.ts` 中增加 import：

```ts
import type { RuntimeProcessEventInput } from '../runtime/process_events.js';
```

扩展 `SkillExecuteParams`：

```ts
toolCallId?: string;
onProcessEvent?: (event: RuntimeProcessEventInput) => void;
```

在 `SkillExecutorService` 之前增加：

```ts
const RESERVED_PROCESS_KEYS = new Set([
  'type',
  'time',
  'timestamp',
  'stage',
  'message',
  'taskId',
  'level',
  'status',
  'current',
  'total',
  'percent',
  'batch',
  'totalBatches',
]);

export function normalizeSkillProcessLine(
  _skillName: string,
  line: string,
  sequence: number,
): RuntimeProcessEventInput | null {
  let parsed: Record<string, unknown>;
  try {
    parsed = JSON.parse(line);
  } catch {
    return null;
  }
  if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null;
  if (parsed.type !== 'process_event') return null;
  if (typeof parsed.message !== 'string' && typeof parsed.stage !== 'string') return null;

  const current = typeof parsed.current === 'number'
    ? parsed.current
    : typeof parsed.batch === 'number'
      ? parsed.batch
      : undefined;
  const total = typeof parsed.total === 'number'
    ? parsed.total
    : typeof parsed.totalBatches === 'number'
      ? parsed.totalBatches
      : undefined;
  const percent = typeof parsed.percent === 'number'
    ? parsed.percent
    : total && current !== undefined
      ? Math.max(0, Math.min(100, Math.round((current / total) * 100)))
      : undefined;

  const payload: Record<string, unknown> = {};
  for (const [key, value] of Object.entries(parsed)) {
    if (!RESERVED_PROCESS_KEYS.has(key)) payload[key] = value;
  }

  return {
    source: 'skill',
    targetType: 'skill',
    taskId: typeof parsed.taskId === 'string' ? parsed.taskId : undefined,
    stage: typeof parsed.stage === 'string' ? parsed.stage : undefined,
    status: isProcessStatus(parsed.status) ? parsed.status : 'running',
    level: isProcessLevel(parsed.level) ? parsed.level : 'info',
    message: typeof parsed.message === 'string' ? parsed.message : String(parsed.stage || 'running'),
    current,
    total,
    percent,
    timestamp: typeof parsed.time === 'string'
      ? parsed.time
      : typeof parsed.timestamp === 'string'
        ? parsed.timestamp
        : new Date().toISOString(),
    sequence,
    payload: Object.keys(payload).length > 0 ? payload : undefined,
  };
}

function isProcessStatus(value: unknown): value is RuntimeProcessEventInput['status'] {
  return value === 'queued'
    || value === 'started'
    || value === 'running'
    || value === 'completed'
    || value === 'failed'
    || value === 'cancelled'
    || value === 'skipped';
}

function isProcessLevel(value: unknown): value is RuntimeProcessEventInput['level'] {
  return value === 'debug' || value === 'info' || value === 'warning' || value === 'error';
}
```

- [ ] **Step 4：从 stderr 中提取过程事件**

在 `execute()` 中增加：

```ts
let processSequence = 0;
```

在 stderr line loop 中 logger 后增加：

```ts
const processEvent = normalizeSkillProcessLine(skillName, line, ++processSequence);
if (processEvent) {
  params.onProcessEvent?.(processEvent);
}
```

- [ ] **Step 5：补失败终态事件**

timeout 时发：

```ts
params.onProcessEvent?.({
  source: 'runtime',
  targetType: 'skill',
  taskId: typeof input.taskId === 'string' ? input.taskId : undefined,
  stage: 'skill-timeout',
  status: 'failed',
  level: 'error',
  message: `Execution timed out after ${timeout}ms`,
});
```

非 0 退出、spawn error、stdout JSON 解析失败时同样发 `status: 'failed'`，`source: 'runtime'`，`targetType: 'skill'`。

- [ ] **Step 6：运行测试**

```bash
pnpm --filter @neta/backend test -- --runInBand test/skill_process_events.test.ts
```

预期：PASS。

---

## Task 3：execute_skill 桥接 skill 过程事件

**文件：**

- 修改：`packages/backend/src/modules/netaclaw/tools/builtin/execute_skill.ts`
- 测试：扩展 `packages/backend/test/tool_process_events.test.ts`

- [ ] **Step 1：增加失败测试**

在 `packages/backend/test/tool_process_events.test.ts` 追加：

```ts
import { createExecuteSkillTool } from '../src/modules/netaclaw/tools/builtin/execute_skill';

describe('execute_skill process bridge', () => {
  it('把 skill 过程事件转发给 tool update callback', async () => {
    const executor = {
      execute: jest.fn(async (params: any) => {
        params.onProcessEvent({
          source: 'skill',
          targetType: 'skill',
          taskId: 'task-1',
          stage: 'frames',
          status: 'running',
          message: 'extracting frames',
          current: 1,
          total: 4,
        });
        return { success: true, output: { ok: true }, duration: 10 };
      }),
    } as any;
    const updates: any[] = [];
    const tool = createExecuteSkillTool(executor);

    await tool.execute(
      'call-skill-1',
      { name: 'vehicle-damage-inspection', input: { taskId: 'task-1' } },
      event => updates.push(event),
    );

    expect(executor.execute).toHaveBeenCalledWith(expect.objectContaining({
      skillName: 'vehicle-damage-inspection',
      input: { taskId: 'task-1' },
      toolCallId: 'call-skill-1',
    }));
    expect(updates).toEqual([
      expect.objectContaining({
        source: 'skill',
        targetType: 'skill',
        taskId: 'task-1',
        stage: 'frames',
        message: 'extracting frames',
        current: 1,
        total: 4,
        percent: 25,
      }),
    ]);
  });
});
```

- [ ] **Step 2：运行测试，确认失败**

```bash
pnpm --filter @neta/backend test -- --runInBand test/tool_process_events.test.ts
```

- [ ] **Step 3：修改 execute_skill**

在 `packages/backend/src/modules/netaclaw/tools/builtin/execute_skill.ts` 中把 execute 改成接收第三参数：

```ts
async execute(id, params, onUpdate) {
```

调用 executor 时改为：

```ts
const result = await executor.execute({
  skillName: params.name,
  input: params.input as Record<string, unknown>,
  toolCallId: id,
  onProcessEvent: event => {
    const total = typeof event.total === 'number' ? event.total : undefined;
    const current = typeof event.current === 'number' ? event.current : undefined;
    onUpdate?.({
      ...event,
      source: 'skill',
      targetType: event.targetType || 'skill',
      percent: typeof event.percent === 'number'
        ? event.percent
        : total && current !== undefined
          ? Math.max(0, Math.min(100, Math.round((current / total) * 100)))
          : undefined,
    });
  },
});
```

- [ ] **Step 4：运行测试**

```bash
pnpm --filter @neta/backend test -- --runInBand test/tool_process_events.test.ts test/skill_process_events.test.ts
```

预期：PASS。

---

## Task 4：Gateway 协议、节流、metadata 摘要和 JSONL 记录

**文件：**

- 修改：`packages/backend/src/modules/netaclaw/runtime/process_events.ts`
- 修改：`packages/backend/src/modules/netaclaw/gateway/protocol.ts`
- 修改：`packages/backend/src/modules/netaclaw/gateway/server.ts`
- 测试：`packages/backend/test/process_event_buffer.test.ts`

- [ ] **Step 1：写轻量 buffer 测试**

创建 `packages/backend/test/process_event_buffer.test.ts`：

```ts
import { ProcessEventBuffer } from '../src/modules/netaclaw/runtime/process_events';
import type { RuntimeProcessEvent } from '../src/modules/netaclaw/runtime/process_events';

function event(overrides: Partial<RuntimeProcessEvent>): RuntimeProcessEvent {
  return {
    version: 1,
    operationId: 'tool-call-1',
    targetType: 'skill',
    source: 'skill',
    status: 'running',
    level: 'info',
    message: 'batch completed',
    timestamp: '2026-05-06T04:00:00.000Z',
    ...overrides,
  };
}

describe('ProcessEventBuffer', () => {
  it('合并重复过程事件，并 flush 最新事件', () => {
    const emitted: RuntimeProcessEvent[] = [];
    const buffer = new ProcessEventBuffer(e => emitted.push(e), { throttleMs: 500 });

    buffer.push(event({ operationId: 'op-1', stage: 'detect', sequence: 1 }));
    buffer.push(event({ operationId: 'op-1', stage: 'detect', sequence: 2 }));
    buffer.flushAll();

    expect(emitted).toHaveLength(1);
    expect(emitted[0]).toEqual(expect.objectContaining({
      operationId: 'op-1',
      stage: 'detect',
      sequence: 2,
      payload: expect.objectContaining({ repeatCount: 2 }),
    }));
  });

  it('终态和错误事件立即发送', () => {
    const emitted: RuntimeProcessEvent[] = [];
    const buffer = new ProcessEventBuffer(e => emitted.push(e), { throttleMs: 500 });

    buffer.push(event({ operationId: 'op-1', status: 'failed', level: 'error', message: 'failed' }));

    expect(emitted).toHaveLength(1);
    expect(emitted[0]).toEqual(expect.objectContaining({ status: 'failed', message: 'failed' }));
  });
});
```

- [ ] **Step 2：运行测试，确认失败**

```bash
pnpm --filter @neta/backend test -- --runInBand test/process_event_buffer.test.ts
```

- [ ] **Step 3：在 process_events.ts 中增加轻量 buffer**

在 `packages/backend/src/modules/netaclaw/runtime/process_events.ts` 追加：

```ts
export interface ProcessEventBufferOptions {
  throttleMs?: number;
}

interface BufferedEvent {
  event: RuntimeProcessEvent;
  repeatCount: number;
  timer?: NodeJS.Timeout;
}

export class ProcessEventBuffer {
  private readonly throttleMs: number;
  private readonly pending = new Map<string, BufferedEvent>();

  constructor(
    private readonly emit: (event: RuntimeProcessEvent) => void,
    options: ProcessEventBufferOptions = {},
  ) {
    this.throttleMs = options.throttleMs ?? 400;
  }

  push(event: RuntimeProcessEvent) {
    if (isTerminalProcessEvent(event)) {
      this.flushOperation(event.operationId);
      this.emit(event);
      return;
    }

    const key = `${event.operationId}:${event.stage || ''}:${event.message}`;
    const existing = this.pending.get(key);
    if (existing) {
      existing.event = event;
      existing.repeatCount += 1;
      return;
    }

    const buffered: BufferedEvent = {
      event,
      repeatCount: 1,
      timer: setTimeout(() => this.flushKey(key), this.throttleMs),
    };
    this.pending.set(key, buffered);
  }

  flushAll() {
    for (const key of [...this.pending.keys()]) {
      this.flushKey(key);
    }
  }

  private flushOperation(operationId: string) {
    for (const [key, buffered] of [...this.pending.entries()]) {
      if (buffered.event.operationId === operationId) {
        this.flushKey(key);
      }
    }
  }

  private flushKey(key: string) {
    const buffered = this.pending.get(key);
    if (!buffered) return;
    if (buffered.timer) clearTimeout(buffered.timer);
    this.pending.delete(key);
    this.emit({
      ...buffered.event,
      payload: buffered.repeatCount > 1
        ? { ...(buffered.event.payload || {}), repeatCount: buffered.repeatCount }
        : buffered.event.payload,
    });
  }
}

function isTerminalProcessEvent(event: RuntimeProcessEvent): boolean {
  return event.status === 'completed'
    || event.status === 'failed'
    || event.status === 'cancelled'
    || event.status === 'skipped'
    || event.level === 'error';
}
```

- [ ] **Step 4：增加 WebSocket 协议**

在 `packages/backend/src/modules/netaclaw/gateway/protocol.ts` 增加：

```ts
export interface ServerToolProcessEvent {
  type: 'tool_process_event';
  sessionId: string;
  data: {
    version: 1;
    operationId: string;
    parentOperationId?: string;
    targetType: 'tool' | 'skill' | 'subagent' | 'task' | 'runtime';
    toolCallId: string;
    assistantEntryId?: string;
    toolName: string;
    toolLabel?: string;
    source: 'skill' | 'tool' | 'subagent' | 'runtime';
    taskId?: string;
    stage?: string;
    status: 'queued' | 'started' | 'running' | 'completed' | 'failed' | 'cancelled' | 'skipped';
    level?: 'debug' | 'info' | 'warning' | 'error';
    message: string;
    detail?: string;
    current?: number;
    total?: number;
    percent?: number;
    payload?: Record<string, unknown>;
    timestamp: string;
    sequence: number;
  };
}
```

把 `ServerToolProcessEvent` 加入 `ServerEvent` union。

- [ ] **Step 5：Gateway 发送 live event 并保存 metadata 摘要**

在 `packages/backend/src/modules/netaclaw/gateway/server.ts` import：

```ts
import {
  ProcessEventBuffer,
  RuntimeProcessEvent,
  RuntimeProcessLogRef,
  appendRuntimeProcessLog,
  buildProcessLogRef,
  sampleProcessEventsForMetadata,
} from '../runtime/process_events.js';
```

扩展 `toolExecutions` item：

```ts
processEvents?: RuntimeProcessEvent[];
processLogRef?: RuntimeProcessLogRef;
```

在 `buildToolExecutionMetadata()` 的每个 item 中增加：

```ts
latestProcessEvent: te.processEvents?.at(-1),
processEvents: sampleProcessEventsForMetadata(te.processEvents || [], 50),
processLogRef: te.processLogRef,
```

在 runner 内创建 buffer：

```ts
const processEventBuffer = new ProcessEventBuffer(event => {
  this.send({
    type: 'tool_process_event',
    sessionId: sid,
    data: event as any,
  });
});
```

给 `runAgent` 增加：

```ts
onToolProcessEvent: ({ toolCallId, operationId, toolName, event }) => {
  const projectionId = toolCallId || randomUUID();
  const te = [...toolExecutions].reverse().find(t => t.toolCallId === projectionId)
    || [...toolExecutions].reverse().find(t => t.name === toolName && t.status === 'running');
  const sequence = (te?.processEvents?.length || 0) + 1;
  const processEvent: RuntimeProcessEvent = {
    ...event,
    sequence,
  };

  if (te) {
    te.processEvents = [...(te.processEvents || []), processEvent].slice(-50);
    te.processLogRef = appendRuntimeProcessLog({
      sessionId: sid,
      operationId: processEvent.operationId || operationId,
      toolCallId: projectionId,
      event: processEvent,
    });
  }

  processEventBuffer.push({
    ...processEvent,
    payload: processEvent.payload,
  } as RuntimeProcessEvent & {
    toolCallId: string;
    assistantEntryId?: string;
    toolName: string;
    toolLabel?: string;
  });
},
```

注意：如果 TypeScript 不接受扩展字段，给发送 payload 单独构造对象，不要污染 `RuntimeProcessEvent` 基础类型。

- [ ] **Step 6：失败时发终态事件**

在 `failRunningTools()` 中为每个 running tool 追加 `status: 'failed'` 的过程事件，并调用 `processEventBuffer.push(...)`。

逻辑引用使用：

```ts
te.processLogRef = buildProcessLogRef({
  sessionId: sid,
  operationId: `tool-${te.toolCallId}`,
  toolCallId: te.toolCallId,
});
```

不要把本地 JSONL 绝对路径写进 metadata。

- [ ] **Step 7：flush pending events**

在 `runAgent` 正常完成后、构建 final metadata 前调用：

```ts
processEventBuffer.flushAll();
```

在 catch 中 `failRunningTools(...)` 后也调用。

- [ ] **Step 8：运行测试和构建**

```bash
pnpm --filter @neta/backend test -- --runInBand test/tool_process_events.test.ts test/skill_process_events.test.ts test/process_event_buffer.test.ts
pnpm --filter @neta/backend build
```

预期：PASS。

---

## Task 5：前端类型和 chat store 投影

**文件：**

- 修改：`packages/frontend/src/modules/agent/types/index.d.ts`
- 修改：`packages/frontend/src/modules/agent/store/chat.ts`

- [ ] **Step 1：增加前端类型**

在 `packages/frontend/src/modules/agent/types/index.d.ts` 增加：

```ts
export interface RuntimeProcessLogRef {
	kind: 'runtime_process_log';
	sessionId: string;
	operationId: string;
	toolCallId?: string;
}

export interface RuntimeProcessEventProjection {
	version: 1;
	operationId: string;
	parentOperationId?: string;
	targetType: 'tool' | 'skill' | 'subagent' | 'task' | 'runtime';
	source: 'skill' | 'tool' | 'subagent' | 'runtime';
	taskId?: string;
	stage?: string;
	status: 'queued' | 'started' | 'running' | 'completed' | 'failed' | 'cancelled' | 'skipped';
	level?: 'debug' | 'info' | 'warning' | 'error';
	message: string;
	detail?: string;
	current?: number;
	total?: number;
	percent?: number;
	payload?: Record<string, unknown>;
	timestamp: string;
	sequence: number;
}
```

给 `StreamingToolProjection` 增加：

```ts
latestProcessEvent?: RuntimeProcessEventProjection;
processEvents?: RuntimeProcessEventProjection[];
processLogRef?: RuntimeProcessLogRef;
```

给 `WSServerEvent.type` 增加 `'tool_process_event'`。

- [ ] **Step 2：从 metadata 恢复过程事件**

在 `packages/frontend/src/modules/agent/store/chat.ts` 的 `toStreamingToolsFromEntry()` 中，每个 projected tool 增加：

```ts
latestProcessEvent: normalizeToolProcessEvent(item.latestProcessEvent),
processEvents: normalizeToolProcessEvents(item.processEvents),
processLogRef: normalizeProcessLogRef(item.processLogRef),
```

增加 helper：

```ts
function normalizeToolProcessEvents(value: unknown): import('../types/index.d').RuntimeProcessEventProjection[] | undefined {
	if (!Array.isArray(value)) return undefined;
	const events = value
		.map(normalizeToolProcessEvent)
		.filter((item): item is import('../types/index.d').RuntimeProcessEventProjection => Boolean(item));
	return events.length > 0 ? events.slice(-50) : undefined;
}

function normalizeToolProcessEvent(value: unknown): import('../types/index.d').RuntimeProcessEventProjection | undefined {
	if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined;
	const record = value as Record<string, any>;
	if (typeof record.message !== 'string') return undefined;
	return {
		version: 1,
		operationId: typeof record.operationId === 'string' ? record.operationId : `legacy-${record.toolCallId || record.sequence || Date.now()}`,
		parentOperationId: typeof record.parentOperationId === 'string' ? record.parentOperationId : undefined,
		targetType: ['tool', 'skill', 'subagent', 'task', 'runtime'].includes(record.targetType) ? record.targetType : 'tool',
		source: ['skill', 'tool', 'subagent', 'runtime'].includes(record.source) ? record.source : 'tool',
		taskId: typeof record.taskId === 'string' ? record.taskId : undefined,
		stage: typeof record.stage === 'string' ? record.stage : undefined,
		status: ['queued', 'started', 'running', 'completed', 'failed', 'cancelled', 'skipped'].includes(record.status) ? record.status : 'running',
		level: ['debug', 'info', 'warning', 'error'].includes(record.level) ? record.level : undefined,
		message: record.message,
		detail: typeof record.detail === 'string' ? record.detail : undefined,
		current: typeof record.current === 'number' ? record.current : undefined,
		total: typeof record.total === 'number' ? record.total : undefined,
		percent: typeof record.percent === 'number' ? record.percent : undefined,
		payload: record.payload && typeof record.payload === 'object' && !Array.isArray(record.payload) ? record.payload : undefined,
		timestamp: typeof record.timestamp === 'string' ? record.timestamp : new Date().toISOString(),
		sequence: typeof record.sequence === 'number' ? record.sequence : 0,
	};
}

function normalizeProcessLogRef(value: unknown): import('../types/index.d').RuntimeProcessLogRef | undefined {
	if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined;
	const record = value as Record<string, any>;
	if (record.kind !== 'runtime_process_log') return undefined;
	if (typeof record.sessionId !== 'string' || typeof record.operationId !== 'string') return undefined;
	return {
		kind: 'runtime_process_log',
		sessionId: record.sessionId,
		operationId: record.operationId,
		toolCallId: typeof record.toolCallId === 'string' ? record.toolCallId : undefined,
	};
}
```

- [ ] **Step 3：处理 live tool_process_event**

在 `handleWSEvent()` 中增加：

```ts
case 'tool_process_event':
	if (event.data?.toolCallId) {
		const processEvent = normalizeToolProcessEvent(event.data);
		if (processEvent) {
			const existing = streamingTools.value.find(item => item.toolCallId === event.data.toolCallId);
			const processEvents = [...(existing?.processEvents || []), processEvent].slice(-50);
			upsertStreamingTool({
				toolCallId: event.data.toolCallId,
				assistantEntryId: event.data.assistantEntryId,
				toolName: event.data.toolName,
				label: event.data.toolLabel,
				status: existing?.status || 'running',
				args: existing?.args,
				result: existing?.result,
				rawResult: existing?.rawResult,
				runtimeRoute: existing?.runtimeRoute,
				workerRoutingHint: existing?.workerRoutingHint,
				blockedReason: existing?.blockedReason,
				policySources: existing?.policySources,
				processEvents,
				latestProcessEvent: processEvent,
				processLogRef: existing?.processLogRef,
			});
		}
	}
	break;
```

- [ ] **Step 4：运行前端类型检查**

```bash
pnpm --filter @neta/frontend type-check
```

预期：PASS。如果存在无关旧错误，记录下来，并在最终执行 `pnpm --filter @neta/frontend build`。

---

## Task 6：前端过程时间线 UI

**文件：**

- 新增：`packages/frontend/src/modules/agent/components/tool-process-timeline.vue`
- 修改：`packages/frontend/src/modules/agent/components/skill-card.vue`
- 修改：实际渲染 `streamingTools` 的父组件或 renderer

- [ ] **Step 1：新增独立时间线组件**

创建 `packages/frontend/src/modules/agent/components/tool-process-timeline.vue`：

```vue
<template>
  <div v-if="events.length" class="tool-process">
    <el-button text size="small" @click="showProcess = !showProcess">
      {{ showProcess ? '收起过程' : `查看过程 ${events.length}` }}
    </el-button>
    <el-collapse-transition>
      <ol v-if="showProcess" class="tool-process__timeline">
        <li
          v-for="event in events"
          :key="`${event.operationId}-${event.sequence}-${event.timestamp}`"
          class="tool-process__item"
          :class="[`is-${event.level || 'info'}`]"
        >
          <span class="tool-process__dot" />
          <span class="tool-process__main">
            <span class="tool-process__message">{{ event.message }}</span>
            <span v-if="event.current !== undefined && event.total !== undefined" class="tool-process__count">
              {{ event.current }}/{{ event.total }}
            </span>
          </span>
        </li>
      </ol>
    </el-collapse-transition>
  </div>
</template>

<script lang="ts" setup>
import { computed, ref } from 'vue';
import type { RuntimeProcessEventProjection } from '../types/index.d';

const props = defineProps<{
  events?: RuntimeProcessEventProjection[];
}>();

const showProcess = ref(false);
const events = computed(() => props.events || []);
</script>

<style lang="scss" scoped>
.tool-process {
  margin-top: 6px;
}

.tool-process__timeline {
  list-style: none;
  margin: 8px 0 0;
  padding: 0;
  display: grid;
  gap: 6px;
}

.tool-process__item {
  display: grid;
  grid-template-columns: 10px 1fr;
  gap: 8px;
  align-items: start;
  color: var(--el-text-color-secondary);
  font-size: 12px;
  line-height: 1.45;
}

.tool-process__dot {
  width: 6px;
  height: 6px;
  margin-top: 6px;
  border-radius: 50%;
  background: var(--el-color-primary);
}

.tool-process__item.is-warning .tool-process__dot {
  background: var(--el-color-warning);
}

.tool-process__item.is-error .tool-process__dot {
  background: var(--el-color-danger);
}

.tool-process__main {
  min-width: 0;
  display: flex;
  gap: 6px;
  align-items: baseline;
}

.tool-process__message {
  min-width: 0;
  overflow-wrap: anywhere;
}

.tool-process__count {
  flex: none;
  color: var(--el-text-color-placeholder);
}
</style>
```

- [ ] **Step 2：修改 skill-card**

在 `skill-card.vue` 中增加：

```ts
import type { RuntimeProcessEventProjection } from '../types/index.d';
import ToolProcessTimeline from './tool-process-timeline.vue';
```

props 增加：

```ts
latestProcessEvent?: RuntimeProcessEventProjection;
processEvents?: RuntimeProcessEventProjection[];
```

把 running 区域改为：

```vue
<transition name="fade">
  <div v-if="status === 'running' || latestProcessEvent" class="skill-card__progress">
    <el-progress
      v-if="typeof latestProcessEvent?.percent === 'number' || typeof percent === 'number'"
      :percentage="latestProcessEvent?.percent ?? percent ?? 0"
      :stroke-width="4"
      :show-text="false"
    />
    <span v-if="latestProcessEvent?.message || step || detail" class="skill-card__step">
      {{ latestProcessEvent?.message || step || detail }}
    </span>
    <span v-if="latestProcessEvent?.detail" class="skill-card__detail">
      {{ latestProcessEvent.detail }}
    </span>
  </div>
</transition>

<ToolProcessTimeline :events="processEvents" />
```

增加样式：

```scss
.skill-card__detail {
  display: block;
  margin-top: 2px;
  font-size: 12px;
  color: var(--el-text-color-placeholder);
  line-height: 1.45;
}
```

- [ ] **Step 3：确认父组件传参**

搜索：

```bash
rg -n "<skill-card|SkillCard|streamingTools" packages/frontend/src/modules/agent
```

确保最终渲染工具卡的位置传入：

```vue
:latest-process-event="tool.latestProcessEvent"
:process-events="tool.processEvents"
```

如果走 renderer registry，确保 renderer 保留：

```ts
latestProcessEvent: tool.latestProcessEvent,
processEvents: tool.processEvents,
processLogRef: tool.processLogRef,
```

- [ ] **Step 4：运行构建**

```bash
pnpm --filter @neta/frontend build
```

预期：PASS。

---

## Task 7：端到端验证

**文件：**

- 不要求新增源码文件。
- 使用现有 `vehicle-damage-inspection` skill。

- [ ] **Step 1：运行后端测试**

```bash
pnpm --filter @neta/backend test -- --runInBand test/tool_process_events.test.ts test/skill_process_events.test.ts test/process_event_buffer.test.ts
```

预期：PASS。

- [ ] **Step 2：运行后端构建**

```bash
pnpm --filter @neta/backend build
```

预期：PASS。

- [ ] **Step 3：运行前端构建**

```bash
pnpm --filter @neta/frontend build
```

预期：PASS。

- [ ] **Step 4：手工验证旧伤检测过程**

启动项目：

```bash
pnpm dev
```

在 Agent 对话页输入：

```text
请检查是否有旧伤
```

预期：

- `execute_skill` 工具卡出现。
- 旧伤检测过程中，卡片持续显示当前阶段，例如：
  - `creating workspace`
  - `extracting frames`
  - `frames extracted`
  - `batch started`
  - `batch completed`
  - `grounding damage candidates`
  - `selecting best frames`
- 展开过程后可以看到采样后的过程时间线。
- assistant metadata 中有 `latestProcessEvent`、`processEvents`、逻辑 `processLogRef`。
- `processLogRef` 不包含本地绝对路径。
- 后端数据目录下存在完整 JSONL 过程日志。
- 刷新页面后，已完成会话仍能显示 metadata 中保存的过程摘要。
- 模型最终回复和 tool result 不受影响。

- [ ] **Step 5：检查模型上下文边界**

检查 `buildLLMMessages` 和 session-tree runtime context 构建逻辑，确认：

```text
latestProcessEvent
processEvents
processLogRef
```

不会被注入 LLM history。

- [ ] **Step 6：并发关联验证**

如果能触发同一轮里多个工具或多次 skill 调用，验证：

- 过程事件按 `toolCallId` 合并到正确工具卡。
- 同名 skill 不串线。
- 每个事件都有稳定 `version=1`、`operationId`、`targetType`、`timestamp`。
- 失败时卡片收到 `failed` 过程事件，并结束 running 状态。

---

## 自检清单

- [ ] 第一版只做 `execute_skill` 过程可见，没有扩展成完整任务中心。
- [ ] 事件结构支持未来 subagent/tool/background task，但不在本期强行接入。
- [ ] `processLogRef` 是逻辑引用，不暴露本地绝对路径。
- [ ] 过程事件只存在 metadata/UI replay/log，不进入 LLM 上下文。
- [ ] payload 做敏感字段脱敏和长字符串截断。
- [ ] stderr 解析只接受 `type: "process_event"`，不会误解析普通 JSON 日志。
- [ ] gateway 中只保留必要桥接逻辑，通用 normalize/sample/log/buffer 放在轻量 helper 中。
- [ ] 前端不是单纯进度条，而是“最新步骤 + 可展开过程时间线”。
- [ ] 后端测试、后端 build、前端 build 全部通过。
