@tickernelz/paperclip-pro-server 2026.928.3 → 2026.928.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/build-info.json +1 -1
- package/dist/routes/issues.d.ts.map +1 -1
- package/dist/routes/issues.js +5 -1
- package/dist/routes/issues.js.map +1 -1
- package/dist/routes/openapi.js +1 -1
- package/dist/routes/openapi.js.map +1 -1
- package/node_modules/@agentclientprotocol/sdk/dist/acp.d.ts +4 -1
- package/node_modules/@agentclientprotocol/sdk/dist/acp.js +3 -2
- package/node_modules/@agentclientprotocol/sdk/dist/acp.js.map +1 -1
- package/node_modules/@agentclientprotocol/sdk/dist/connection.d.ts +199 -11
- package/node_modules/@agentclientprotocol/sdk/dist/connection.js +454 -46
- package/node_modules/@agentclientprotocol/sdk/dist/connection.js.map +1 -1
- package/node_modules/@agentclientprotocol/sdk/dist/connection.test.js +170 -11
- package/node_modules/@agentclientprotocol/sdk/dist/connection.test.js.map +1 -1
- package/node_modules/@agentclientprotocol/sdk/dist/examples/http-server.js +6 -1
- package/node_modules/@agentclientprotocol/sdk/dist/examples/http-server.js.map +1 -1
- package/node_modules/@agentclientprotocol/sdk/dist/http-stream.d.ts +2 -0
- package/node_modules/@agentclientprotocol/sdk/dist/http-stream.js +46 -8
- package/node_modules/@agentclientprotocol/sdk/dist/http-stream.js.map +1 -1
- package/node_modules/@agentclientprotocol/sdk/dist/line-buffer.d.ts +7 -5
- package/node_modules/@agentclientprotocol/sdk/dist/line-buffer.js +36 -29
- package/node_modules/@agentclientprotocol/sdk/dist/line-buffer.js.map +1 -1
- package/node_modules/@agentclientprotocol/sdk/dist/node-adapter.js +33 -2
- package/node_modules/@agentclientprotocol/sdk/dist/node-adapter.js.map +1 -1
- package/node_modules/@agentclientprotocol/sdk/dist/node-adapter.test.js +87 -0
- package/node_modules/@agentclientprotocol/sdk/dist/node-adapter.test.js.map +1 -1
- package/node_modules/@agentclientprotocol/sdk/dist/server-sse.d.ts +6 -1
- package/node_modules/@agentclientprotocol/sdk/dist/server-sse.js +78 -30
- package/node_modules/@agentclientprotocol/sdk/dist/server-sse.js.map +1 -1
- package/node_modules/@agentclientprotocol/sdk/dist/server-sse.test.js +59 -6
- package/node_modules/@agentclientprotocol/sdk/dist/server-sse.test.js.map +1 -1
- package/node_modules/@agentclientprotocol/sdk/dist/server-websocket-upgrade.test.js +320 -2
- package/node_modules/@agentclientprotocol/sdk/dist/server-websocket-upgrade.test.js.map +1 -1
- package/node_modules/@agentclientprotocol/sdk/dist/server.d.ts +4 -3
- package/node_modules/@agentclientprotocol/sdk/dist/server.js +49 -21
- package/node_modules/@agentclientprotocol/sdk/dist/server.js.map +1 -1
- package/node_modules/@agentclientprotocol/sdk/dist/server.test.js +530 -0
- package/node_modules/@agentclientprotocol/sdk/dist/server.test.js.map +1 -1
- package/node_modules/@agentclientprotocol/sdk/dist/sse.d.ts +3 -1
- package/node_modules/@agentclientprotocol/sdk/dist/sse.js +59 -36
- package/node_modules/@agentclientprotocol/sdk/dist/sse.js.map +1 -1
- package/node_modules/@agentclientprotocol/sdk/dist/stream-limits.d.ts +15 -0
- package/node_modules/@agentclientprotocol/sdk/dist/stream-limits.js +58 -0
- package/node_modules/@agentclientprotocol/sdk/dist/stream-limits.js.map +1 -0
- package/node_modules/@agentclientprotocol/sdk/dist/stream.d.ts +4 -1
- package/node_modules/@agentclientprotocol/sdk/dist/stream.js +6 -2
- package/node_modules/@agentclientprotocol/sdk/dist/stream.js.map +1 -1
- package/node_modules/@agentclientprotocol/sdk/dist/test-support/until.d.ts +5 -0
- package/node_modules/@agentclientprotocol/sdk/dist/test-support/until.js +14 -0
- package/node_modules/@agentclientprotocol/sdk/dist/test-support/until.js.map +1 -0
- package/node_modules/@agentclientprotocol/sdk/dist/v2/acp.d.ts +4 -1
- package/node_modules/@agentclientprotocol/sdk/dist/v2/acp.js +3 -2
- package/node_modules/@agentclientprotocol/sdk/dist/v2/acp.js.map +1 -1
- package/node_modules/@agentclientprotocol/sdk/dist/ws-server.js +143 -15
- package/node_modules/@agentclientprotocol/sdk/dist/ws-server.js.map +1 -1
- package/node_modules/@agentclientprotocol/sdk/dist/ws-utils.d.ts +6 -0
- package/node_modules/@agentclientprotocol/sdk/dist/ws-utils.js.map +1 -1
- package/node_modules/@agentclientprotocol/sdk/package.json +1 -1
- package/package.json +21 -21
- package/ui-dist/assets/{Agents.production-DjLgD8Fp.js → Agents.production-Ou10IBgG.js} +2 -2
- package/ui-dist/assets/{CompanyActivity.production-CaNV-c_e.js → CompanyActivity.production-dbz2o26S.js} +1 -1
- package/ui-dist/assets/{CompanyExport-Bx-trKPv.js → CompanyExport-VHEmaNC_.js} +1 -1
- package/ui-dist/assets/{CompanySkills.production-CbfnmhC4.js → CompanySkills.production-DuSUb8DQ.js} +1 -1
- package/ui-dist/assets/{Costs.production-DVn0oet3.js → Costs.production-BRz8Rn3s.js} +1 -1
- package/ui-dist/assets/{OrgChart.production-BnI1PljD.js → OrgChart.production-Dgq5pMMa.js} +1 -1
- package/ui-dist/assets/{RoutineDetail.production-qJIGUNKj.js → RoutineDetail.production-B2lkeY8y.js} +1 -1
- package/ui-dist/assets/{Routines.production-DxtvLQGU.js → Routines.production-CGNSsZDr.js} +1 -1
- package/ui-dist/assets/{definition-ftJgFFS4.js → definition-D6jTm4Mv.js} +1 -1
- package/ui-dist/assets/{index-CgTz21on.js → index-DGStZYMS.js} +4 -4
- package/ui-dist/index.html +1 -1
- package/ui-dist/sw.js +1 -1
|
@@ -5,6 +5,7 @@ import { AcpServer } from "./server.js";
|
|
|
5
5
|
import { parseSseStream } from "./sse.js";
|
|
6
6
|
import { createTestAgentApp, TestAgent } from "./test-support/test-agent.js";
|
|
7
7
|
import { startTestServer } from "./test-support/test-http-server.js";
|
|
8
|
+
import { until } from "./test-support/until.js";
|
|
8
9
|
const initializeRequest = {
|
|
9
10
|
jsonrpc: "2.0",
|
|
10
11
|
id: 1,
|
|
@@ -937,6 +938,503 @@ describe("AcpServer", () => {
|
|
|
937
938
|
}
|
|
938
939
|
});
|
|
939
940
|
});
|
|
941
|
+
describe("AcpServer connection limits", () => {
|
|
942
|
+
it("does not keep session streams after their receivers leave", async () => {
|
|
943
|
+
const server = new AcpServer({
|
|
944
|
+
createAgent: () => createTestAgentApp(),
|
|
945
|
+
});
|
|
946
|
+
try {
|
|
947
|
+
const connectionId = await initializeDirect(server);
|
|
948
|
+
for (let index = 0; index < 100; index++) {
|
|
949
|
+
const response = await server.handleRequest(sseRequest(connectionId, `unknown-session-${index}`));
|
|
950
|
+
expect(response.status).toBe(200);
|
|
951
|
+
await response.body?.cancel();
|
|
952
|
+
}
|
|
953
|
+
expect(connectionState(server, connectionId).sessionStreams.size).toBe(0);
|
|
954
|
+
}
|
|
955
|
+
finally {
|
|
956
|
+
await server.close();
|
|
957
|
+
}
|
|
958
|
+
});
|
|
959
|
+
it("does not track routes for messages the agent never answers", async () => {
|
|
960
|
+
const error = vi
|
|
961
|
+
.spyOn(console, "error")
|
|
962
|
+
.mockImplementation(() => undefined);
|
|
963
|
+
const server = new AcpServer({
|
|
964
|
+
createAgent: () => createTestAgentApp(),
|
|
965
|
+
});
|
|
966
|
+
try {
|
|
967
|
+
const connectionId = await initializeDirect(server);
|
|
968
|
+
const unanswered = [
|
|
969
|
+
{ id: "bare-id" },
|
|
970
|
+
{ jsonrpc: "2.0", id: "no-result" },
|
|
971
|
+
{
|
|
972
|
+
jsonrpc: "2.0",
|
|
973
|
+
id: "result-and-error",
|
|
974
|
+
result: {},
|
|
975
|
+
error: { code: -32603, message: "Internal error" },
|
|
976
|
+
},
|
|
977
|
+
{
|
|
978
|
+
id: "no-version",
|
|
979
|
+
method: "session/cancel",
|
|
980
|
+
params: { sessionId: "session-1" },
|
|
981
|
+
},
|
|
982
|
+
];
|
|
983
|
+
for (const message of unanswered) {
|
|
984
|
+
const response = await server.handleRequest(jsonRequest(message, sessionHeaders(connectionId, "session-1")));
|
|
985
|
+
expect(response.status).toBe(202);
|
|
986
|
+
}
|
|
987
|
+
expect(connectionState(server, connectionId).pendingRoutes.size).toBe(0);
|
|
988
|
+
}
|
|
989
|
+
finally {
|
|
990
|
+
error.mockRestore();
|
|
991
|
+
await server.close();
|
|
992
|
+
}
|
|
993
|
+
});
|
|
994
|
+
it("rejects session IDs and request IDs longer than maxIdLength", async () => {
|
|
995
|
+
const server = new AcpServer({
|
|
996
|
+
createAgent: () => createTestAgentApp(),
|
|
997
|
+
maxIdLength: 8,
|
|
998
|
+
});
|
|
999
|
+
try {
|
|
1000
|
+
const connectionId = await initializeDirect(server);
|
|
1001
|
+
const tooLong = "s".repeat(9);
|
|
1002
|
+
const stream = await server.handleRequest(sseRequest(connectionId, tooLong));
|
|
1003
|
+
const prompt = await server.handleRequest(jsonRequest(promptRequestFor(4, tooLong), sessionHeaders(connectionId, tooLong)));
|
|
1004
|
+
const longRequestId = await server.handleRequest(jsonRequest({ ...sessionNewRequest, id: tooLong }, { [HEADER_CONNECTION_ID]: connectionId }));
|
|
1005
|
+
expect(stream.status).toBe(400);
|
|
1006
|
+
expect(prompt.status).toBe(400);
|
|
1007
|
+
expect(longRequestId.status).toBe(400);
|
|
1008
|
+
expect(await longRequestId.text()).toBe("Request ID exceeds maxIdLength (8)");
|
|
1009
|
+
const allowed = await server.handleRequest(sseRequest(connectionId, "s".repeat(8)));
|
|
1010
|
+
expect(allowed.status).toBe(200);
|
|
1011
|
+
await allowed.body?.cancel();
|
|
1012
|
+
}
|
|
1013
|
+
finally {
|
|
1014
|
+
await server.close();
|
|
1015
|
+
}
|
|
1016
|
+
});
|
|
1017
|
+
it("closes the connection when the agent issues an over-long session ID", async () => {
|
|
1018
|
+
const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined);
|
|
1019
|
+
const server = new AcpServer({
|
|
1020
|
+
createAgent: () => createTestAgentApp({
|
|
1021
|
+
newSession: () => ({ sessionId: "s".repeat(9) }),
|
|
1022
|
+
}),
|
|
1023
|
+
maxIdLength: 8,
|
|
1024
|
+
});
|
|
1025
|
+
try {
|
|
1026
|
+
const connectionId = await initializeDirect(server);
|
|
1027
|
+
const connection = connectionState(server, connectionId);
|
|
1028
|
+
const stream = await server.handleRequest(sseRequest(connectionId));
|
|
1029
|
+
if (!stream.body) {
|
|
1030
|
+
throw new Error("Expected SSE response body");
|
|
1031
|
+
}
|
|
1032
|
+
const reader = stream.body.getReader();
|
|
1033
|
+
await server.handleRequest(jsonRequest(sessionNewRequest, {
|
|
1034
|
+
[HEADER_CONNECTION_ID]: connectionId,
|
|
1035
|
+
}));
|
|
1036
|
+
await expect(withTimeout(reader.read())).rejects.toThrow("Session ID exceeds maxIdLength (8)");
|
|
1037
|
+
await withTimeout(connection.closed);
|
|
1038
|
+
expect(warn).toHaveBeenCalledWith(`Closing ACP connection ${connectionId}:`, "Session ID exceeds maxIdLength (8)");
|
|
1039
|
+
}
|
|
1040
|
+
finally {
|
|
1041
|
+
warn.mockRestore();
|
|
1042
|
+
await server.close();
|
|
1043
|
+
}
|
|
1044
|
+
});
|
|
1045
|
+
it("closes only the connection that buffers too many session streams", async () => {
|
|
1046
|
+
const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined);
|
|
1047
|
+
const server = new AcpServer({
|
|
1048
|
+
createAgent: () => createTestAgentApp(),
|
|
1049
|
+
maxBufferedSessionStreams: 2,
|
|
1050
|
+
});
|
|
1051
|
+
try {
|
|
1052
|
+
const connectionId = await initializeDirect(server);
|
|
1053
|
+
const otherConnectionId = await initializeDirect(server);
|
|
1054
|
+
const connection = connectionState(server, connectionId);
|
|
1055
|
+
// Nothing receives these sessions' streams, so their output buffers.
|
|
1056
|
+
for (const [index, sessionId] of ["a", "b", "c"].entries()) {
|
|
1057
|
+
await server.handleRequest(jsonRequest(promptRequestFor(10 + index, sessionId), sessionHeaders(connectionId, sessionId)));
|
|
1058
|
+
}
|
|
1059
|
+
await withTimeout(connection.closed);
|
|
1060
|
+
expect(warn).toHaveBeenCalledWith(`Closing ACP connection ${connectionId}:`, "Connection exceeds maxBufferedSessionStreams (2)");
|
|
1061
|
+
const other = await server.handleRequest(sseRequest(otherConnectionId));
|
|
1062
|
+
expect(other.status).toBe(200);
|
|
1063
|
+
await other.body?.cancel();
|
|
1064
|
+
}
|
|
1065
|
+
finally {
|
|
1066
|
+
warn.mockRestore();
|
|
1067
|
+
await server.close();
|
|
1068
|
+
}
|
|
1069
|
+
});
|
|
1070
|
+
it("closes a connection whose clients leave too many session streams with output queued", async () => {
|
|
1071
|
+
const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined);
|
|
1072
|
+
const server = new AcpServer({
|
|
1073
|
+
createAgent: () => createTestAgentApp(),
|
|
1074
|
+
maxBufferedSessionStreams: 2,
|
|
1075
|
+
});
|
|
1076
|
+
try {
|
|
1077
|
+
const connectionId = await initializeDirect(server);
|
|
1078
|
+
const connection = connectionState(server, connectionId);
|
|
1079
|
+
const sessionIds = ["a", "b", "c"];
|
|
1080
|
+
// Open streams are not limited, and these read none of their output.
|
|
1081
|
+
const streams = [];
|
|
1082
|
+
for (const [index, sessionId] of sessionIds.entries()) {
|
|
1083
|
+
streams.push(await server.handleRequest(sseRequest(connectionId, sessionId)));
|
|
1084
|
+
await server.handleRequest(jsonRequest(promptRequestFor(10 + index, sessionId), sessionHeaders(connectionId, sessionId)));
|
|
1085
|
+
}
|
|
1086
|
+
await until(() => sessionIds.every((sessionId) => connection.sessionStreams.get(sessionId)?.hasQueuedMessages));
|
|
1087
|
+
expect(streams.map((stream) => stream.status)).toEqual([200, 200, 200]);
|
|
1088
|
+
expect(connection.isClosed).toBe(false);
|
|
1089
|
+
// Leaving them buffers their output, one session too many.
|
|
1090
|
+
for (const stream of streams) {
|
|
1091
|
+
await stream.body?.cancel();
|
|
1092
|
+
}
|
|
1093
|
+
await withTimeout(connection.closed);
|
|
1094
|
+
expect(warn).toHaveBeenCalledWith(`Closing ACP connection ${connectionId}:`, "Connection exceeds maxBufferedSessionStreams (2)");
|
|
1095
|
+
}
|
|
1096
|
+
finally {
|
|
1097
|
+
warn.mockRestore();
|
|
1098
|
+
await server.close();
|
|
1099
|
+
}
|
|
1100
|
+
});
|
|
1101
|
+
it("closes a full connection whose output no client takes for maxOutputStallMs", async () => {
|
|
1102
|
+
const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined);
|
|
1103
|
+
const server = new AcpServer({
|
|
1104
|
+
createAgent: () => createTestAgentApp(),
|
|
1105
|
+
maxBufferedBytes: 1024,
|
|
1106
|
+
maxOutputStallMs: 50,
|
|
1107
|
+
});
|
|
1108
|
+
try {
|
|
1109
|
+
const connectionId = await initializeDirect(server);
|
|
1110
|
+
const connection = connectionState(server, connectionId);
|
|
1111
|
+
// Nothing receives the connection stream, so its output never drains.
|
|
1112
|
+
for (let index = 0; index < 50 && !connection.isClosed; index++) {
|
|
1113
|
+
await server.handleRequest(jsonRequest({ ...sessionNewRequest, id: 100 + index }, { [HEADER_CONNECTION_ID]: connectionId }));
|
|
1114
|
+
}
|
|
1115
|
+
await withTimeout(connection.closed);
|
|
1116
|
+
expect(warn).toHaveBeenCalledWith(`Closing ACP connection ${connectionId}:`, "Connection output stalled for maxOutputStallMs (50)");
|
|
1117
|
+
}
|
|
1118
|
+
finally {
|
|
1119
|
+
warn.mockRestore();
|
|
1120
|
+
await server.close();
|
|
1121
|
+
}
|
|
1122
|
+
});
|
|
1123
|
+
it("delivers a burst queued before the client opens its session stream", async () => {
|
|
1124
|
+
const server = new AcpServer({
|
|
1125
|
+
createAgent: () => createBurstAgent(50, 1000),
|
|
1126
|
+
maxBufferedBytes: 4096,
|
|
1127
|
+
});
|
|
1128
|
+
try {
|
|
1129
|
+
const connectionId = await initializeDirect(server);
|
|
1130
|
+
const connection = connectionState(server, connectionId);
|
|
1131
|
+
await server.handleRequest(jsonRequest(promptRequestFor(10, "session-1"), sessionHeaders(connectionId, "session-1")));
|
|
1132
|
+
await until(() => connection.hasOutboundBacklog);
|
|
1133
|
+
const stream = await server.handleRequest(sseRequest(connectionId, "session-1"));
|
|
1134
|
+
expect(await readSseMessages(stream, 51)).toHaveLength(51);
|
|
1135
|
+
expect(connection.isClosed).toBe(false);
|
|
1136
|
+
}
|
|
1137
|
+
finally {
|
|
1138
|
+
await server.close();
|
|
1139
|
+
}
|
|
1140
|
+
});
|
|
1141
|
+
it("keeps a full connection open while its receiver reconnects", async () => {
|
|
1142
|
+
const server = new AcpServer({
|
|
1143
|
+
createAgent: () => createBurstAgent(50, 1000),
|
|
1144
|
+
maxBufferedBytes: 4096,
|
|
1145
|
+
});
|
|
1146
|
+
try {
|
|
1147
|
+
const connectionId = await initializeDirect(server);
|
|
1148
|
+
const first = await server.handleRequest(sseRequest(connectionId, "session-1"));
|
|
1149
|
+
await server.handleRequest(jsonRequest(promptRequestFor(10, "session-1"), sessionHeaders(connectionId, "session-1")));
|
|
1150
|
+
await until(() => connectionState(server, connectionId).hasOutboundBacklog);
|
|
1151
|
+
await first.body?.cancel();
|
|
1152
|
+
const second = await server.handleRequest(sseRequest(connectionId, "session-1"));
|
|
1153
|
+
if (!second.body) {
|
|
1154
|
+
throw new Error("Expected SSE response body");
|
|
1155
|
+
}
|
|
1156
|
+
const messages = parseSseStream(second.body)[Symbol.asyncIterator]();
|
|
1157
|
+
for (;;) {
|
|
1158
|
+
const next = await withTimeout(messages.next());
|
|
1159
|
+
if (next.done) {
|
|
1160
|
+
throw new Error("Expected the prompt response");
|
|
1161
|
+
}
|
|
1162
|
+
if (next.value.id === 10) {
|
|
1163
|
+
break;
|
|
1164
|
+
}
|
|
1165
|
+
}
|
|
1166
|
+
await messages.return?.();
|
|
1167
|
+
expect(connectionState(server, connectionId).isClosed).toBe(false);
|
|
1168
|
+
}
|
|
1169
|
+
finally {
|
|
1170
|
+
await server.close();
|
|
1171
|
+
}
|
|
1172
|
+
});
|
|
1173
|
+
it("leaves nothing waiting for requests aborted while held back", async () => {
|
|
1174
|
+
const server = new AcpServer({
|
|
1175
|
+
createAgent: () => createBurstAgent(50, 1000),
|
|
1176
|
+
maxBufferedBytes: 4096,
|
|
1177
|
+
});
|
|
1178
|
+
try {
|
|
1179
|
+
const connectionId = await initializeDirect(server);
|
|
1180
|
+
const stream = await server.handleRequest(sseRequest(connectionId, "session-1"));
|
|
1181
|
+
await server.handleRequest(jsonRequest(promptRequestFor(10, "session-1"), sessionHeaders(connectionId, "session-1")));
|
|
1182
|
+
const connection = connectionState(server, connectionId);
|
|
1183
|
+
await until(() => connection.hasOutboundBacklog);
|
|
1184
|
+
// Held-back requests wait alongside the router, which is paused.
|
|
1185
|
+
const waiters = () => connection
|
|
1186
|
+
.progressWaiters.size;
|
|
1187
|
+
const waitersBefore = waiters();
|
|
1188
|
+
for (let index = 0; index < 20; index++) {
|
|
1189
|
+
const abort = new AbortController();
|
|
1190
|
+
const response = server.handleRequest(jsonRequest(promptRequestFor(100 + index, "session-1"), sessionHeaders(connectionId, "session-1"), abort.signal));
|
|
1191
|
+
await delay(1);
|
|
1192
|
+
abort.abort();
|
|
1193
|
+
expect((await withTimeout(response)).status).toBe(499);
|
|
1194
|
+
}
|
|
1195
|
+
expect(waiters()).toBe(waitersBefore);
|
|
1196
|
+
await stream.body?.cancel();
|
|
1197
|
+
}
|
|
1198
|
+
finally {
|
|
1199
|
+
await server.close();
|
|
1200
|
+
}
|
|
1201
|
+
});
|
|
1202
|
+
it("delivers an agent burst larger than maxBufferedBytes to a client that reads it", async () => {
|
|
1203
|
+
const server = new AcpServer({
|
|
1204
|
+
createAgent: () => createBurstAgent(50, 1000),
|
|
1205
|
+
maxBufferedBytes: 4096,
|
|
1206
|
+
});
|
|
1207
|
+
try {
|
|
1208
|
+
const connectionId = await initializeDirect(server);
|
|
1209
|
+
const stream = await server.handleRequest(sseRequest(connectionId, "session-1"));
|
|
1210
|
+
if (!stream.body) {
|
|
1211
|
+
throw new Error("Expected SSE response body");
|
|
1212
|
+
}
|
|
1213
|
+
const messages = parseSseStream(stream.body)[Symbol.asyncIterator]();
|
|
1214
|
+
await server.handleRequest(jsonRequest(promptRequestFor(10, "session-1"), sessionHeaders(connectionId, "session-1")));
|
|
1215
|
+
for (let index = 0; index < 50; index++) {
|
|
1216
|
+
const next = await withTimeout(messages.next());
|
|
1217
|
+
expect(next.value).toMatchObject({ method: "session/update" });
|
|
1218
|
+
}
|
|
1219
|
+
expect((await withTimeout(messages.next())).value).toMatchObject({
|
|
1220
|
+
id: 10,
|
|
1221
|
+
result: { stopReason: "end_turn" },
|
|
1222
|
+
});
|
|
1223
|
+
await messages.return?.();
|
|
1224
|
+
expect(connectionState(server, connectionId).isClosed).toBe(false);
|
|
1225
|
+
}
|
|
1226
|
+
finally {
|
|
1227
|
+
await server.close();
|
|
1228
|
+
}
|
|
1229
|
+
});
|
|
1230
|
+
it("holds back agent output and client requests while a receiver is behind", async () => {
|
|
1231
|
+
const sent = [];
|
|
1232
|
+
const server = new AcpServer({
|
|
1233
|
+
createAgent: () => createBurstAgent(50, 1000, (index) => {
|
|
1234
|
+
sent.push(index);
|
|
1235
|
+
}),
|
|
1236
|
+
maxBufferedBytes: 4096,
|
|
1237
|
+
});
|
|
1238
|
+
try {
|
|
1239
|
+
const connectionId = await initializeDirect(server);
|
|
1240
|
+
const stream = await server.handleRequest(sseRequest(connectionId, "session-1"));
|
|
1241
|
+
if (!stream.body) {
|
|
1242
|
+
throw new Error("Expected SSE response body");
|
|
1243
|
+
}
|
|
1244
|
+
await server.handleRequest(jsonRequest(promptRequestFor(10, "session-1"), sessionHeaders(connectionId, "session-1")));
|
|
1245
|
+
await until(() => connectionState(server, connectionId).hasOutboundBacklog);
|
|
1246
|
+
await delay(20);
|
|
1247
|
+
// Nothing has read the stream, so the agent is waiting on its sends
|
|
1248
|
+
// and another request waits too.
|
|
1249
|
+
expect(sent.length).toBeGreaterThan(0);
|
|
1250
|
+
expect(sent.length).toBeLessThan(10);
|
|
1251
|
+
let isNextPromptAccepted = false;
|
|
1252
|
+
const nextPrompt = server
|
|
1253
|
+
.handleRequest(jsonRequest(promptRequestFor(11, "session-1"), sessionHeaders(connectionId, "session-1")))
|
|
1254
|
+
.then((response) => {
|
|
1255
|
+
isNextPromptAccepted = true;
|
|
1256
|
+
return response;
|
|
1257
|
+
});
|
|
1258
|
+
await delay(50);
|
|
1259
|
+
expect(isNextPromptAccepted).toBe(false);
|
|
1260
|
+
// Reading lets both carry on.
|
|
1261
|
+
const messages = parseSseStream(stream.body)[Symbol.asyncIterator]();
|
|
1262
|
+
for (;;) {
|
|
1263
|
+
const next = await withTimeout(messages.next());
|
|
1264
|
+
if (next.done) {
|
|
1265
|
+
throw new Error("Expected the prompt response");
|
|
1266
|
+
}
|
|
1267
|
+
if (next.value.id === 10) {
|
|
1268
|
+
break;
|
|
1269
|
+
}
|
|
1270
|
+
}
|
|
1271
|
+
expect(sent.length).toBeGreaterThanOrEqual(50);
|
|
1272
|
+
expect((await withTimeout(nextPrompt)).status).toBe(202);
|
|
1273
|
+
await messages.return?.();
|
|
1274
|
+
}
|
|
1275
|
+
finally {
|
|
1276
|
+
await server.close();
|
|
1277
|
+
}
|
|
1278
|
+
});
|
|
1279
|
+
it("answers a held-back request with 404 once its agent exits", async () => {
|
|
1280
|
+
const agentClosed = createDeferred();
|
|
1281
|
+
let agentWriter;
|
|
1282
|
+
const server = new AcpServer({
|
|
1283
|
+
agent: {
|
|
1284
|
+
connect(stream) {
|
|
1285
|
+
void (async () => {
|
|
1286
|
+
const reader = stream.readable.getReader();
|
|
1287
|
+
const { value } = await reader.read();
|
|
1288
|
+
agentWriter = stream.writable.getWriter();
|
|
1289
|
+
await agentWriter.write({
|
|
1290
|
+
jsonrpc: "2.0",
|
|
1291
|
+
id: value.id,
|
|
1292
|
+
result: { protocolVersion: 1, agentCapabilities: {} },
|
|
1293
|
+
});
|
|
1294
|
+
})();
|
|
1295
|
+
return { closed: agentClosed.promise };
|
|
1296
|
+
},
|
|
1297
|
+
},
|
|
1298
|
+
maxBufferedBytes: 64,
|
|
1299
|
+
});
|
|
1300
|
+
try {
|
|
1301
|
+
const connectionId = await initializeDirect(server);
|
|
1302
|
+
const connection = connectionState(server, connectionId);
|
|
1303
|
+
const stream = await server.handleRequest(sseRequest(connectionId));
|
|
1304
|
+
if (!agentWriter) {
|
|
1305
|
+
throw new Error("Expected the agent to have answered initialize");
|
|
1306
|
+
}
|
|
1307
|
+
// The connection stream is open but unread, so this fills the limit.
|
|
1308
|
+
void agentWriter.write({
|
|
1309
|
+
jsonrpc: "2.0",
|
|
1310
|
+
method: "_vendor/acme/notification",
|
|
1311
|
+
params: { padding: "p".repeat(100) },
|
|
1312
|
+
});
|
|
1313
|
+
await until(() => connection.hasOutboundBacklog);
|
|
1314
|
+
const request = server.handleRequest(jsonRequest({ ...sessionNewRequest, id: 20 }, { [HEADER_CONNECTION_ID]: connectionId }));
|
|
1315
|
+
await delay(20);
|
|
1316
|
+
agentClosed.resolve();
|
|
1317
|
+
expect((await withTimeout(request)).status).toBe(404);
|
|
1318
|
+
await stream.body?.cancel();
|
|
1319
|
+
}
|
|
1320
|
+
finally {
|
|
1321
|
+
await server.close();
|
|
1322
|
+
}
|
|
1323
|
+
});
|
|
1324
|
+
it("closes a connection whose invented session stalls its output", async () => {
|
|
1325
|
+
const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined);
|
|
1326
|
+
const server = new AcpServer({
|
|
1327
|
+
createAgent: () => createTestAgentApp(),
|
|
1328
|
+
maxBufferedBytes: 1024,
|
|
1329
|
+
maxOutputStallMs: 50,
|
|
1330
|
+
});
|
|
1331
|
+
try {
|
|
1332
|
+
const connectionId = await initializeDirect(server);
|
|
1333
|
+
const connection = connectionState(server, connectionId);
|
|
1334
|
+
// One session, never received, so its output fills the limit and stays.
|
|
1335
|
+
for (let index = 0; index < 50 && !connection.isClosed; index++) {
|
|
1336
|
+
await server.handleRequest(jsonRequest(promptRequestFor(100 + index, "unknown-session"), sessionHeaders(connectionId, "unknown-session")));
|
|
1337
|
+
}
|
|
1338
|
+
await withTimeout(connection.closed);
|
|
1339
|
+
expect(warn).toHaveBeenCalledWith(`Closing ACP connection ${connectionId}:`, "Connection output stalled for maxOutputStallMs (50)");
|
|
1340
|
+
}
|
|
1341
|
+
finally {
|
|
1342
|
+
warn.mockRestore();
|
|
1343
|
+
await server.close();
|
|
1344
|
+
}
|
|
1345
|
+
});
|
|
1346
|
+
it("ends the stream of a client that stopped reading when its connection closes", async () => {
|
|
1347
|
+
const server = new AcpServer({
|
|
1348
|
+
createAgent: () => createTestAgentApp(),
|
|
1349
|
+
});
|
|
1350
|
+
try {
|
|
1351
|
+
const connectionId = await initializeDirect(server);
|
|
1352
|
+
const stream = await server.handleRequest(sseRequest(connectionId));
|
|
1353
|
+
if (!stream.body) {
|
|
1354
|
+
throw new Error("Expected SSE response body");
|
|
1355
|
+
}
|
|
1356
|
+
await server.handleRequest(jsonRequest(sessionNewRequest, {
|
|
1357
|
+
[HEADER_CONNECTION_ID]: connectionId,
|
|
1358
|
+
}));
|
|
1359
|
+
const deleted = await server.handleRequest(new Request("http://127.0.0.1/acp", {
|
|
1360
|
+
method: "DELETE",
|
|
1361
|
+
headers: { [HEADER_CONNECTION_ID]: connectionId },
|
|
1362
|
+
}));
|
|
1363
|
+
expect(deleted.status).toBe(202);
|
|
1364
|
+
// A body left waiting for its reader would keep the response open.
|
|
1365
|
+
await expect(withTimeout(stream.body.getReader().read())).rejects.toThrow("ACP connection is closed");
|
|
1366
|
+
}
|
|
1367
|
+
finally {
|
|
1368
|
+
await server.close();
|
|
1369
|
+
}
|
|
1370
|
+
});
|
|
1371
|
+
it("rejects connection limits that are not positive safe integers", () => {
|
|
1372
|
+
const createAgent = () => createTestAgentApp();
|
|
1373
|
+
for (const value of [0, -1, 1.5, Number.NaN, Number.POSITIVE_INFINITY]) {
|
|
1374
|
+
expect(() => new AcpServer({ createAgent, maxBufferedBytes: value })).toThrow(RangeError);
|
|
1375
|
+
expect(() => new AcpServer({ createAgent, maxOutputStallMs: value })).toThrow(RangeError);
|
|
1376
|
+
expect(() => new AcpServer({ createAgent, maxBufferedSessionStreams: value })).toThrow(RangeError);
|
|
1377
|
+
expect(() => new AcpServer({ createAgent, maxIdLength: value })).toThrow(RangeError);
|
|
1378
|
+
}
|
|
1379
|
+
// Timers cannot wait longer than this.
|
|
1380
|
+
expect(() => new AcpServer({ createAgent, maxOutputStallMs: 2 ** 31 })).toThrow("maxOutputStallMs must be at most 2147483647");
|
|
1381
|
+
});
|
|
1382
|
+
it("does not count delivered output against maxBufferedBytes", async () => {
|
|
1383
|
+
const server = new AcpServer({
|
|
1384
|
+
createAgent: () => createTestAgentApp(),
|
|
1385
|
+
maxBufferedBytes: 1024,
|
|
1386
|
+
});
|
|
1387
|
+
try {
|
|
1388
|
+
const connectionId = await initializeDirect(server);
|
|
1389
|
+
const stream = await server.handleRequest(sseRequest(connectionId));
|
|
1390
|
+
if (!stream.body) {
|
|
1391
|
+
throw new Error("Expected SSE response body");
|
|
1392
|
+
}
|
|
1393
|
+
const messages = parseSseStream(stream.body)[Symbol.asyncIterator]();
|
|
1394
|
+
// Together these responses exceed the limit, but each is delivered first.
|
|
1395
|
+
for (let index = 0; index < 50; index++) {
|
|
1396
|
+
await server.handleRequest(jsonRequest({ ...sessionNewRequest, id: 100 + index }, { [HEADER_CONNECTION_ID]: connectionId }));
|
|
1397
|
+
const next = await withTimeout(messages.next());
|
|
1398
|
+
expect(next.value).toMatchObject({ id: 100 + index });
|
|
1399
|
+
}
|
|
1400
|
+
await messages.return?.();
|
|
1401
|
+
expect(connectionState(server, connectionId).isClosed).toBe(false);
|
|
1402
|
+
}
|
|
1403
|
+
finally {
|
|
1404
|
+
await server.close();
|
|
1405
|
+
}
|
|
1406
|
+
});
|
|
1407
|
+
});
|
|
1408
|
+
function connectionState(server, connectionId) {
|
|
1409
|
+
const connection = server.registry.get(connectionId);
|
|
1410
|
+
if (!connection) {
|
|
1411
|
+
throw new Error("Expected an open connection");
|
|
1412
|
+
}
|
|
1413
|
+
return connection;
|
|
1414
|
+
}
|
|
1415
|
+
function sseRequest(connectionId, sessionId) {
|
|
1416
|
+
return new Request("http://127.0.0.1/acp", {
|
|
1417
|
+
method: "GET",
|
|
1418
|
+
headers: {
|
|
1419
|
+
Accept: EVENT_STREAM_MIME_TYPE,
|
|
1420
|
+
[HEADER_CONNECTION_ID]: connectionId,
|
|
1421
|
+
...(sessionId === undefined ? {} : { [HEADER_SESSION_ID]: sessionId }),
|
|
1422
|
+
},
|
|
1423
|
+
});
|
|
1424
|
+
}
|
|
1425
|
+
function sessionHeaders(connectionId, sessionId) {
|
|
1426
|
+
return {
|
|
1427
|
+
[HEADER_CONNECTION_ID]: connectionId,
|
|
1428
|
+
[HEADER_SESSION_ID]: sessionId,
|
|
1429
|
+
};
|
|
1430
|
+
}
|
|
1431
|
+
function promptRequestFor(id, sessionId) {
|
|
1432
|
+
return {
|
|
1433
|
+
...promptRequest,
|
|
1434
|
+
id,
|
|
1435
|
+
params: { ...promptRequest.params, sessionId },
|
|
1436
|
+
};
|
|
1437
|
+
}
|
|
940
1438
|
async function initializeDirect(server) {
|
|
941
1439
|
const response = await server.handleRequest(jsonRequest(initializeRequest));
|
|
942
1440
|
const connectionId = response.headers.get(HEADER_CONNECTION_ID);
|
|
@@ -1054,6 +1552,38 @@ function createBackpressureAgent(onPromptDone) {
|
|
|
1054
1552
|
})
|
|
1055
1553
|
.onNotification(methods.agent.session.cancel, () => { });
|
|
1056
1554
|
}
|
|
1555
|
+
/**
|
|
1556
|
+
* An agent that answers each prompt with `count` updates of about `size`
|
|
1557
|
+
* characters, calling `onSent` as each send resolves.
|
|
1558
|
+
*/
|
|
1559
|
+
function createBurstAgent(count, size, onSent = () => { }) {
|
|
1560
|
+
return createAgentApp({ name: "burst-agent" })
|
|
1561
|
+
.onRequest(methods.agent.initialize, () => ({
|
|
1562
|
+
protocolVersion: 1,
|
|
1563
|
+
agentCapabilities: {
|
|
1564
|
+
loadSession: false,
|
|
1565
|
+
},
|
|
1566
|
+
}))
|
|
1567
|
+
.onRequest(methods.agent.session.new, () => ({ sessionId: "session-1" }))
|
|
1568
|
+
.onRequest(methods.agent.authenticate, () => ({}))
|
|
1569
|
+
.onRequest(methods.agent.session.prompt, async (c) => {
|
|
1570
|
+
for (let index = 0; index < count; index++) {
|
|
1571
|
+
await c.client.notify(methods.client.session.update, {
|
|
1572
|
+
sessionId: c.params.sessionId,
|
|
1573
|
+
update: {
|
|
1574
|
+
sessionUpdate: "agent_message_chunk",
|
|
1575
|
+
content: { type: "text", text: "x".repeat(size) },
|
|
1576
|
+
},
|
|
1577
|
+
});
|
|
1578
|
+
onSent(index);
|
|
1579
|
+
}
|
|
1580
|
+
return { stopReason: "end_turn" };
|
|
1581
|
+
})
|
|
1582
|
+
.onNotification(methods.agent.session.cancel, () => { });
|
|
1583
|
+
}
|
|
1584
|
+
function delay(ms) {
|
|
1585
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
1586
|
+
}
|
|
1057
1587
|
async function waitForConnectionNotFound(server, connectionId) {
|
|
1058
1588
|
const deadline = Date.now() + 1_000;
|
|
1059
1589
|
for (;;) {
|