Wires the ported Tasks screen + core task subsystem through App.tsx: sticky Clear Completed, cancel/refresh error toasts, core-side per-task progress correlation, Run-as-task on the Tools screen (gated on server support), per-server Task TTL, and live task-status toasts.
This commit is contained in:
parent
8923a9bbf9
commit
ed6eda7492
8 changed files with 1192 additions and 40 deletions
|
|
@ -341,6 +341,7 @@ describe("RemoteClientTransport", () => {
|
|||
metadata: [],
|
||||
connectionTimeout: 0,
|
||||
requestTimeout: 0,
|
||||
taskTtl: 0,
|
||||
};
|
||||
const transport = new RemoteClientTransport(
|
||||
{
|
||||
|
|
|
|||
|
|
@ -201,6 +201,8 @@ describe("serverEntriesToMcpConfig", () => {
|
|||
metadata: [],
|
||||
connectionTimeout: 0,
|
||||
requestTimeout: 0,
|
||||
// Absent taskTtl on disk → product default in memory (for the form)
|
||||
taskTtl: 60000,
|
||||
// Nested oauth on disk → flat oauthClientId in memory
|
||||
oauthClientId: "the-client",
|
||||
});
|
||||
|
|
@ -272,6 +274,7 @@ describe("serverEntriesToMcpConfig", () => {
|
|||
metadata: [],
|
||||
connectionTimeout: 0,
|
||||
requestTimeout: 0,
|
||||
taskTtl: 0,
|
||||
},
|
||||
connection: { status: "disconnected" },
|
||||
},
|
||||
|
|
@ -295,6 +298,7 @@ describe("serverEntriesToMcpConfig", () => {
|
|||
metadata: [],
|
||||
connectionTimeout: 0,
|
||||
requestTimeout: 0,
|
||||
taskTtl: 0,
|
||||
},
|
||||
connection: { status: "disconnected" },
|
||||
},
|
||||
|
|
@ -302,6 +306,7 @@ describe("serverEntriesToMcpConfig", () => {
|
|||
const stored = serverEntriesToMcpConfig(entries).mcpServers.alpha;
|
||||
expect(stored).not.toHaveProperty("connectionTimeout");
|
||||
expect(stored).not.toHaveProperty("requestTimeout");
|
||||
expect(stored).not.toHaveProperty("taskTtl");
|
||||
expect(stored).not.toHaveProperty("oauth");
|
||||
expect(stored).not.toHaveProperty("headers");
|
||||
expect(stored).not.toHaveProperty("metadata");
|
||||
|
|
|
|||
|
|
@ -243,4 +243,101 @@ describe("ManagedRequestorTasksState", () => {
|
|||
state.destroy();
|
||||
expect(() => state.destroy()).not.toThrow();
|
||||
});
|
||||
|
||||
describe("clearCompleted", () => {
|
||||
it("drops terminal-state tasks and keeps active ones", async () => {
|
||||
client.setStatus("connected");
|
||||
client.queueTaskPages({
|
||||
tasks: [
|
||||
task("active", "working"),
|
||||
task("done", "completed"),
|
||||
task("bad", "failed"),
|
||||
task("gone", "cancelled"),
|
||||
task("waiting", "input_required"),
|
||||
],
|
||||
});
|
||||
await state.refresh();
|
||||
|
||||
const changePromise = waitForChange(state);
|
||||
state.clearCompleted();
|
||||
const next = await changePromise;
|
||||
expect(next.map((t) => t.taskId)).toEqual(["active", "waiting"]);
|
||||
expect(state.getTasks().map((t) => t.taskId)).toEqual([
|
||||
"active",
|
||||
"waiting",
|
||||
]);
|
||||
});
|
||||
|
||||
it("is a no-op (no event) when there are no terminal tasks", async () => {
|
||||
client.setStatus("connected");
|
||||
client.queueTaskPages({ tasks: [task("active", "working")] });
|
||||
await state.refresh();
|
||||
|
||||
let dispatched = false;
|
||||
state.addEventListener("tasksChange", () => {
|
||||
dispatched = true;
|
||||
});
|
||||
state.clearCompleted();
|
||||
expect(dispatched).toBe(false);
|
||||
expect(state.getTasks().map((t) => t.taskId)).toEqual(["active"]);
|
||||
});
|
||||
|
||||
it("keeps cleared tasks gone across a subsequent refresh (sticky)", async () => {
|
||||
client.setStatus("connected");
|
||||
client.queueTaskPages({
|
||||
tasks: [task("active", "working"), task("done", "completed")],
|
||||
});
|
||||
await state.refresh();
|
||||
state.clearCompleted();
|
||||
expect(state.getTasks().map((t) => t.taskId)).toEqual(["active"]);
|
||||
|
||||
// Server still lists the dismissed task on the next refresh — it must be
|
||||
// filtered back out rather than reappearing.
|
||||
client.queueTaskPages({
|
||||
tasks: [task("active", "working"), task("done", "completed")],
|
||||
});
|
||||
const refreshed = await state.refresh();
|
||||
expect(refreshed.map((t) => t.taskId)).toEqual(["active"]);
|
||||
});
|
||||
|
||||
it("ignores a late status update for a cleared task (sticky)", async () => {
|
||||
client.setStatus("connected");
|
||||
client.queueTaskPages({ tasks: [task("done", "completed")] });
|
||||
await state.refresh();
|
||||
state.clearCompleted();
|
||||
expect(state.getTasks()).toEqual([]);
|
||||
|
||||
let dispatched = false;
|
||||
state.addEventListener("tasksChange", () => {
|
||||
dispatched = true;
|
||||
});
|
||||
client.dispatchTypedEvent("taskStatusChange", {
|
||||
taskId: "done",
|
||||
task: task("done", "completed"),
|
||||
});
|
||||
client.dispatchTypedEvent("requestorTaskUpdated", {
|
||||
taskId: "done",
|
||||
task: task("done", "working"),
|
||||
});
|
||||
client.dispatchTypedEvent("taskCancelled", { taskId: "done" });
|
||||
expect(dispatched).toBe(false);
|
||||
expect(state.getTasks()).toEqual([]);
|
||||
});
|
||||
|
||||
it("resets dismissals on disconnect so a reconnect starts fresh", async () => {
|
||||
client.setStatus("connected");
|
||||
client.queueTaskPages({ tasks: [task("done", "completed")] });
|
||||
await state.refresh();
|
||||
state.clearCompleted();
|
||||
|
||||
// Disconnect clears the dismissed set...
|
||||
client.setStatus("disconnected");
|
||||
client.setStatus("connected");
|
||||
|
||||
// ...so the same task id can reappear on a fresh refresh.
|
||||
client.queueTaskPages({ tasks: [task("done", "completed")] });
|
||||
const refreshed = await state.refresh();
|
||||
expect(refreshed.map((t) => t.taskId)).toEqual(["done"]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -116,4 +116,33 @@ describe("useManagedRequestorTasks", () => {
|
|||
|
||||
expect(result.current.tasks).toEqual([]);
|
||||
});
|
||||
|
||||
it("clearCompleted() drops terminal tasks through to the state", async () => {
|
||||
client.queueTaskPages({
|
||||
tasks: [task("active", "working"), task("done", "completed")],
|
||||
});
|
||||
await state.refresh();
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useManagedRequestorTasks(client, state),
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(result.current.tasks.map((t) => t.taskId)).toEqual([
|
||||
"active",
|
||||
"done",
|
||||
]);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.clearCompleted();
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(result.current.tasks.map((t) => t.taskId)).toEqual(["active"]);
|
||||
});
|
||||
});
|
||||
|
||||
it("clearCompleted() is a safe no-op when state is null", () => {
|
||||
const { result } = renderHook(() => useManagedRequestorTasks(client, null));
|
||||
expect(() => result.current.clearCompleted()).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -391,6 +391,7 @@ describe("useServers", () => {
|
|||
metadata: [{ key: "trace", value: "abc" }],
|
||||
connectionTimeout: 5000,
|
||||
requestTimeout: 30000,
|
||||
taskTtl: 30000,
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -400,6 +401,7 @@ describe("useServers", () => {
|
|||
metadata: [{ key: "trace", value: "abc" }],
|
||||
connectionTimeout: 5000,
|
||||
requestTimeout: 30000,
|
||||
taskTtl: 30000,
|
||||
});
|
||||
});
|
||||
const stored = readConfig(h.configPath).mcpServers
|
||||
|
|
@ -414,6 +416,7 @@ describe("useServers", () => {
|
|||
expect(stored.metadata).toEqual([{ key: "trace", value: "abc" }]);
|
||||
expect(stored.connectionTimeout).toBe(5000);
|
||||
expect(stored.requestTimeout).toBe(30000);
|
||||
expect(stored.taskTtl).toBe(30000);
|
||||
});
|
||||
|
||||
it("updateServerSettings throws when the target id does not exist (server-side 404)", async () => {
|
||||
|
|
@ -429,6 +432,7 @@ describe("useServers", () => {
|
|||
metadata: [],
|
||||
connectionTimeout: 0,
|
||||
requestTimeout: 0,
|
||||
taskTtl: 0,
|
||||
});
|
||||
}),
|
||||
).rejects.toThrow(/not found/);
|
||||
|
|
@ -475,6 +479,8 @@ describe("useServers", () => {
|
|||
metadata: [],
|
||||
connectionTimeout: 0,
|
||||
requestTimeout: 0,
|
||||
// Absent taskTtl on disk reads back as the product default for the form.
|
||||
taskTtl: 60000,
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -319,6 +319,7 @@ describe("InspectorClient", () => {
|
|||
metadata: [],
|
||||
connectionTimeout: 50,
|
||||
requestTimeout: 0,
|
||||
taskTtl: 0,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
|
@ -3607,6 +3608,75 @@ describe("InspectorClient", () => {
|
|||
expect(task).toHaveProperty("ttl");
|
||||
});
|
||||
|
||||
it("emits requestorTaskProgress tagged with the owning taskId during a task-augmented call", async () => {
|
||||
// Core-side progress→task correlation (#1422): the wrapped onprogress in
|
||||
// callToolStream tags each tick with the taskId it owns, so App can map
|
||||
// progress to the right TaskCard. The progress_task tool emits 5 ticks.
|
||||
const progressEvents: Array<{ taskId: string; progress: Progress }> = [];
|
||||
client!.addEventListener(
|
||||
"requestorTaskProgress",
|
||||
(event: TypedEvent<"requestorTaskProgress">) => {
|
||||
progressEvents.push(event.detail);
|
||||
},
|
||||
);
|
||||
|
||||
// Capture the created taskId from the toolCall event stream for comparison.
|
||||
let createdTaskId: string | undefined;
|
||||
client!.addEventListener(
|
||||
"toolCallTaskUpdated",
|
||||
(event: TypedEvent<"toolCallTaskUpdated">) => {
|
||||
createdTaskId ??= event.detail.taskId;
|
||||
},
|
||||
);
|
||||
|
||||
const progressTool = await getTool(client!, "progress_task");
|
||||
const result = await client!.callToolStream(progressTool, {
|
||||
message: "with progress",
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
|
||||
expect(progressEvents.length).toBeGreaterThan(0);
|
||||
// Every tick is tagged with the same (and the correct) task id.
|
||||
const taskIds = new Set(progressEvents.map((e) => e.taskId));
|
||||
expect(taskIds.size).toBe(1);
|
||||
const [taggedTaskId] = [...taskIds];
|
||||
expect(typeof taggedTaskId).toBe("string");
|
||||
expect(taggedTaskId!.length).toBeGreaterThan(0);
|
||||
expect(taggedTaskId).toBe(createdTaskId);
|
||||
// The payload carries the server's progress units.
|
||||
expect(progressEvents.some((e) => e.progress.total === 5)).toBe(true);
|
||||
});
|
||||
|
||||
it("does not emit requestorTaskProgress when progress is globally disabled", async () => {
|
||||
// The callToolStream onprogress wrapper is gated on `this.progress`, so a
|
||||
// client with progress disabled neither requests a progress token nor
|
||||
// emits requestorTaskProgress — task calls respect the same toggle as
|
||||
// every other call path. The task still completes.
|
||||
const noProgressClient = new InspectorClient(
|
||||
{ type: "sse", url: server!.url },
|
||||
{ environment: { transport: createTransportNode }, progress: false },
|
||||
);
|
||||
await noProgressClient.connect();
|
||||
try {
|
||||
const progressEvents: Array<{ taskId: string; progress: Progress }> =
|
||||
[];
|
||||
noProgressClient.addEventListener(
|
||||
"requestorTaskProgress",
|
||||
(event: TypedEvent<"requestorTaskProgress">) => {
|
||||
progressEvents.push(event.detail);
|
||||
},
|
||||
);
|
||||
const progressTool = await getTool(noProgressClient, "progress_task");
|
||||
const result = await noProgressClient.callToolStream(progressTool, {
|
||||
message: "no progress",
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
expect(progressEvents).toHaveLength(0);
|
||||
} finally {
|
||||
await noProgressClient.disconnect();
|
||||
}
|
||||
});
|
||||
|
||||
it("should get task by taskId", async () => {
|
||||
// First create a task
|
||||
const simpleTaskByIdTool = await getTool(client!, "simple_task");
|
||||
|
|
|
|||
|
|
@ -216,6 +216,7 @@ describe("Transport", () => {
|
|||
metadata: [],
|
||||
connectionTimeout: 0,
|
||||
requestTimeout: 0,
|
||||
taskTtl: 0,
|
||||
};
|
||||
|
||||
const fetchRequests: FetchRequestEntryBase[] = [];
|
||||
|
|
@ -267,6 +268,7 @@ describe("Transport", () => {
|
|||
metadata: [],
|
||||
connectionTimeout: 0,
|
||||
requestTimeout: 0,
|
||||
taskTtl: 0,
|
||||
};
|
||||
|
||||
const fetchRequests: FetchRequestEntryBase[] = [];
|
||||
|
|
@ -308,6 +310,7 @@ describe("Transport", () => {
|
|||
metadata: [],
|
||||
connectionTimeout: 0,
|
||||
requestTimeout: 0,
|
||||
taskTtl: 0,
|
||||
};
|
||||
const result = createTransportNode(config, { settings });
|
||||
// Just exercise the empty-headers path — no transport construction
|
||||
|
|
|
|||
|
|
@ -410,6 +410,7 @@ describe("Remote transport e2e", () => {
|
|||
metadata: [],
|
||||
connectionTimeout: 0,
|
||||
requestTimeout: 0,
|
||||
taskTtl: 0,
|
||||
};
|
||||
|
||||
const createTransport = createRemoteTransport({ baseUrl, authToken });
|
||||
|
|
|
|||
Loading…
Reference in a new issue