File size: 4,769 Bytes
e249c6d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
  markMcpLoopbackToolCallStarted,
  recordMcpLoopbackToolCallResult,
  updateMcpLoopbackToolCallCapture,
} from "../../gateway/mcp-http.loopback-runtime.js";
import { buildPreparedCliRunContext } from "../cli-runner.test-helpers.js";
import { createCliToolTracking } from "./execute-tool-tracking.js";

type Tracking = ReturnType<typeof createCliToolTracking>;

function createTracking() {
  const context = buildPreparedCliRunContext();
  context.preparedBackend.mcpClientGrantCapture = {
    transportToken: "test-token",
    adoptProcessToken: vi.fn(),
    revokeProcessToken: vi.fn(),
    activate: vi.fn(),
    deactivate: vi.fn(),
  };
  const tracking = createCliToolTracking(context);
  tracking.beginGatewayCapture("deadline-test", () => {});
  return tracking;
}

function startParsed(tracking: Tracking, toolCallId: string, args: Record<string, unknown>) {
  tracking.handleCliToolUseStart({
    toolCallId,
    name: "mcp__openclaw__ask_user",
    kind: "mcp_tool_use",
    args,
  });
}

function startQuestion(tracking: Tracking, toolCallId: string, timeoutSeconds?: unknown) {
  const args = { questions: [], timeoutSeconds };
  startParsed(tracking, toolCallId, args);
  const capture = markMcpLoopbackToolCallStarted({
    captureKey: "deadline-test",
    toolName: "ask_user",
    args,
  });
  if (!capture) {
    throw new Error("expected captured ask_user call");
  }
  updateMcpLoopbackToolCallCapture(capture, { toolName: "ask_user", args });
  return capture;
}

describe("CLI loopback ask_user deadline tracking", () => {
  beforeEach(() => {
    vi.useFakeTimers();
    vi.setSystemTime(new Date("2026-09-07T00:00:00Z"));
  });
  afterEach(() => vi.useRealTimers());

  it("keeps the deadline until Claude emits the correlated tool result", () => {
    const tracking = createTracking();
    const listener = vi.fn();
    tracking.onActiveLoopbackAskUserDeadlineChange(listener);
    const capture = startQuestion(tracking, "tool-1");
    expect(tracking.getActiveLoopbackAskUserDeadline()).toBe(Date.now() + 910_000);
    expect(listener).toHaveBeenCalledOnce();

    vi.advanceTimersByTime(900_000);
    recordMcpLoopbackToolCallResult({
      captureHandle: capture,
      toolName: "ask_user",
      args: capture.call.args,
      outcome: "completed",
    });
    startQuestion(tracking, "tool-2", 60);
    expect(tracking.getActiveLoopbackAskUserDeadline()).toBe(Date.now() + 10_000);
    expect(listener).toHaveBeenCalledOnce();

    tracking.handleCliToolResult({
      toolCallId: "tool-1",
      name: "mcp__openclaw__ask_user",
      isError: false,
    });
    expect(tracking.getActiveLoopbackAskUserDeadline()).toBe(Date.now() + 70_000);
    expect(listener).toHaveBeenCalledTimes(2);

    tracking.handleCliToolResult({
      toolCallId: "tool-2",
      name: "mcp__openclaw__ask_user",
      isError: false,
    });
    expect(tracking.getActiveLoopbackAskUserDeadline()).toBeUndefined();
    expect(listener).toHaveBeenCalledTimes(3);
    tracking.finalizeCapture(() => {});
  });

  it("reports the earliest concurrent uniquely correlated question", () => {
    const tracking = createTracking();
    startQuestion(tracking, "later", 600);
    vi.advanceTimersByTime(100);
    startQuestion(tracking, "earlier", 30);
    expect(tracking.getActiveLoopbackAskUserDeadline()).toBe(Date.now() + 40_000);
    tracking.finalizeCapture(() => {});
  });

  it("fails closed for invalid and ambiguous correlation", () => {
    const tracking = createTracking();
    startQuestion(tracking, "invalid", "bad");
    expect(tracking.getActiveLoopbackAskUserDeadline()).toBeUndefined();
    tracking.handleCliToolResult({
      toolCallId: "invalid",
      name: "mcp__openclaw__ask_user",
      isError: true,
    });

    const args = { questions: [], timeoutSeconds: 3_600 };
    startParsed(tracking, "duplicate-1", args);
    startParsed(tracking, "duplicate-2", args);
    const capture = markMcpLoopbackToolCallStarted({
      captureKey: "deadline-test",
      toolName: "ask_user",
      args,
    });
    updateMcpLoopbackToolCallCapture(capture, { toolName: "ask_user", args });
    expect(tracking.getActiveLoopbackAskUserDeadline()).toBeUndefined();
    tracking.finalizeCapture(() => {});
  });

  it("fails closed after correlation overflow", () => {
    const tracking = createTracking();
    for (let index = 0; index < 65; index += 1) {
      startQuestion(tracking, `overflow-${index}`, 30 + index);
      if (index === 0) {
        expect(tracking.getActiveLoopbackAskUserDeadline()).toBeDefined();
      }
    }
    expect(tracking.getActiveLoopbackAskUserDeadline()).toBeUndefined();
    tracking.finalizeCapture(() => {});
  });
});